mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-19 01:38:19 +08:00
ui: new judge config (#533)
Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
1 parent
f4d8cd7a5d
commit
85cb1d6dff
26 files changed
+910
-222
No files matched your search
@@ -2,6 +2,7 @@ import { diffLines } from 'diff';
|
||||
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
|
||||
import $ from 'jquery';
|
||||
import yaml from 'js-yaml';
|
||||
import { isEqual } from 'lodash';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
@@ -21,7 +22,7 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
});
|
||||
|
||||
interface Props {
|
||||
config: object;
|
||||
config: any;
|
||||
handleUpdateCode: Function;
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ const configKey = [
|
||||
'type', 'subType', 'target', 'score', 'time',
|
||||
'memory', 'filename', 'checker_type', 'checker', 'interactor',
|
||||
'validator', 'user_extra_files', 'judge_extra_files', 'detail', 'outputs',
|
||||
'redirect', 'cases', 'subtasks', 'langs',
|
||||
'redirect', 'cases', 'subtasks', 'langs', 'key',
|
||||
];
|
||||
|
||||
const subtasksKey = [
|
||||
@@ -99,6 +100,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
|
||||
this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
|
||||
language: 'yaml',
|
||||
model: this.model,
|
||||
lineNumbers: 'off',
|
||||
onChange: (value: string) => {
|
||||
this.__preventUpdate = true;
|
||||
if (!this.__preventFormat) this.props.handleUpdateCode(value);
|
||||
@@ -109,30 +111,34 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.__preventUpdate || !this.model) return;
|
||||
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
|
||||
this.__preventFormat = true;
|
||||
const curValue = this.model.getValue();
|
||||
const diff = diffLines(curValue, yaml.dump(configYamlFormat(this.props.config)));
|
||||
const ops = [];
|
||||
let cursor = 1;
|
||||
for (const line of diff) {
|
||||
if (line.added) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: line.value });
|
||||
} else if (line.removed) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
cursor += line.count;
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: '' });
|
||||
} else cursor += line.count;
|
||||
}
|
||||
this.model.pushEditOperations([], ops, undefined);
|
||||
this.__preventFormat = false;
|
||||
if (this.__preventUpdate || !this.model || !this.props.config.__valid) return;
|
||||
if (yaml.dump(prevProps.config) === yaml.dump(this.props.config)) return;
|
||||
const curValue = this.model.getValue();
|
||||
const pending = configYamlFormat(this.props.config);
|
||||
try {
|
||||
const curConfig = yaml.load(curValue);
|
||||
if (isEqual(curConfig, pending)) return;
|
||||
} catch { }
|
||||
this.__preventFormat = true;
|
||||
const diff = diffLines(curValue, yaml.dump(pending));
|
||||
const ops = [];
|
||||
let cursor = 1;
|
||||
for (const line of diff) {
|
||||
if (line.added) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: line.value });
|
||||
} else if (line.removed) {
|
||||
let range = this.model.getFullModelRange();
|
||||
range = range.setStartPosition(cursor, 0);
|
||||
cursor += line.count;
|
||||
range = range.setEndPosition(cursor, 0);
|
||||
ops.push({ range, text: '' });
|
||||
} else cursor += line.count;
|
||||
}
|
||||
this.model.pushEditOperations([], ops, undefined);
|
||||
this.__preventFormat = false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Card, InputGroup, Tab, Tabs, Tag,
|
||||
Card, InputGroup, Tag,
|
||||
} from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -9,7 +9,6 @@ import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoCom
|
||||
import { FormItem } from './BasicForm';
|
||||
import ProblemType from './ProblemType';
|
||||
import type { RootState } from './reducer/index';
|
||||
import { TaskConfig } from './SubtaskTable';
|
||||
|
||||
function FileIOConfig() {
|
||||
const filename = useSelector((state: RootState) => state.config.filename);
|
||||
@@ -42,12 +41,10 @@ function ExtraFilesConfig() {
|
||||
const userRef = React.useRef<any>();
|
||||
const judgeRef = React.useRef<any>();
|
||||
return (
|
||||
<FormItem columns={12} label="ExtraFilesTabs" disableLabel>
|
||||
<Tabs id="ExtraFilesTabs">
|
||||
<Tab
|
||||
id="user_extra_files"
|
||||
title={i18n('user_extra_files')}
|
||||
panel={(
|
||||
<FormItem columns={12} label="ExtraFilesConfig" disableLabel>
|
||||
<Card style={{ padding: 10 }}>
|
||||
<div className="row">
|
||||
<FormItem columns={12} label={i18n('user_extra_files')}>
|
||||
<FileSelectAutoComplete
|
||||
ref={userRef}
|
||||
data={Files}
|
||||
@@ -55,12 +52,8 @@ function ExtraFilesConfig() {
|
||||
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
|
||||
multi
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Tab
|
||||
id="judge_extra_files"
|
||||
title={i18n('judge_extra_files')}
|
||||
panel={(
|
||||
</FormItem>
|
||||
<FormItem columns={12} label={i18n('judge_extra_files')}>
|
||||
<FileSelectAutoComplete
|
||||
ref={judgeRef}
|
||||
data={Files}
|
||||
@@ -68,9 +61,9 @@ function ExtraFilesConfig() {
|
||||
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
|
||||
multi
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Tabs>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -79,24 +72,30 @@ function LangConfig() {
|
||||
const langs = useSelector((state: RootState) => state.config.langs) || [];
|
||||
const prefixes = new Set(Object.keys(window.LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
|
||||
const data = Object.keys(window.LANGS).filter((i) => !prefixes.has(i))
|
||||
.map((i) => ({ name: window.LANGS[i].display, _id: i }));
|
||||
.map((i) => ({ name: `${i.includes('.') ? `${window.LANGS[i.split('.')[0]].display || ''}/` : ''}${window.LANGS[i].display}`, _id: i }));
|
||||
const dispatch = useDispatch();
|
||||
const ref = React.useRef<any>();
|
||||
const selectedKeys = langs.filter((i) => !prefixes.has(i));
|
||||
return (
|
||||
<FormItem columns={12} label="langs">
|
||||
<CustomSelectAutoComplete
|
||||
ref={ref}
|
||||
data={data}
|
||||
placeholder={i18n('Unlimited')}
|
||||
selectedKeys={selectedKeys}
|
||||
onChange={(val) => {
|
||||
const value = val.split(',');
|
||||
value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0]))));
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value });
|
||||
}}
|
||||
multi
|
||||
/>
|
||||
<FormItem columns={12} label="langs" disableLabel>
|
||||
<Card style={{ padding: 10 }}>
|
||||
<div className="row">
|
||||
<FormItem columns={12} label="langs">
|
||||
<CustomSelectAutoComplete
|
||||
ref={ref}
|
||||
data={data}
|
||||
placeholder={i18n('Unlimited')}
|
||||
selectedKeys={selectedKeys}
|
||||
onChange={(val) => {
|
||||
const value = val.split(',');
|
||||
value.push(...Array.from(new Set(value.filter((i) => i.includes('.')).map((i) => i.split('.')[0]))));
|
||||
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'langs', value });
|
||||
}}
|
||||
multi
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +108,6 @@ export default function ProblemConfigForm() {
|
||||
{Type === 'default' && <FileIOConfig />}
|
||||
{!['submit_answer', 'objective'].includes(Type) && (
|
||||
<>
|
||||
<TaskConfig />
|
||||
<ExtraFilesConfig />
|
||||
<LangConfig />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Icon, TreeNode } from '@blueprintjs/core';
|
||||
import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/utils/lib/common';
|
||||
import { TestCaseConfig } from 'hydrooj';
|
||||
import { isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
import { DndProvider, useDrop } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
import { RootState } from './reducer';
|
||||
import { AddTestcase } from './tree/AddTestcase';
|
||||
import { SelectionManager } from './tree/SelectionManager';
|
||||
import { GlobalSettings, SubtaskSettings } from './tree/SubtaskSettings';
|
||||
|
||||
interface TestcasesDndItem {
|
||||
cases: TestCaseConfig[];
|
||||
subtaskId: number;
|
||||
}
|
||||
|
||||
export function SubtaskNode(props: { subtaskId: number }) {
|
||||
const { subtaskId } = props;
|
||||
const subtaskIds = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual);
|
||||
const clen = useSelector((state: RootState) => (subtaskId === -1
|
||||
? state.config.__cases.length
|
||||
: state.config.subtasks.find((i) => i.id === subtaskId).cases?.length || 0));
|
||||
const time = useSelector((s: RootState) => s.config?.time);
|
||||
const memory = useSelector((s: RootState) => s.config?.memory);
|
||||
const dispatch = useDispatch();
|
||||
const [expand, setExpand] = React.useState(true);
|
||||
const [, drop] = useDrop<TestcasesDndItem>(() => ({
|
||||
accept: 'cases',
|
||||
canDrop(item) {
|
||||
return item.subtaskId !== subtaskId;
|
||||
},
|
||||
drop(item) {
|
||||
dispatch({
|
||||
type: 'problemconfig/moveTestcases',
|
||||
payload: {
|
||||
target: subtaskId,
|
||||
source: item.subtaskId,
|
||||
cases: item.cases,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<li className="bp4-tree-node bp4-tree-node-expanded">
|
||||
{subtaskId !== -1 && <div className="bp4-tree-node-content" onClick={() => setExpand((e) => !e)}>
|
||||
<Icon icon={expand ? 'folder-open' : 'folder-close'} />
|
||||
<span className="bp4-tree-node-label">Subtask {subtaskId}</span>
|
||||
<span className="bp4-tree-node-secondary-label">
|
||||
<Icon icon="trash"></Icon>
|
||||
</span>
|
||||
</div>}
|
||||
<ul className="bp4-tree-node-list" ref={drop}>
|
||||
{subtaskId !== -1 && <SubtaskSettings subtaskId={subtaskId} time={time} memory={memory} />}
|
||||
{expand
|
||||
? <SelectionManager subtaskId={subtaskId} subtaskIds={subtaskIds} />
|
||||
: <TreeNode
|
||||
depth={0}
|
||||
id={`s${subtaskId}`}
|
||||
onClick={() => setExpand(false)}
|
||||
icon="layers"
|
||||
label={<> {clen} testcases.</>}
|
||||
path={[0]}
|
||||
/>}
|
||||
{!clen && (
|
||||
<li className="bp4-tree-node">
|
||||
<div className="bp4-tree-node-content">
|
||||
<span className="bp4-tree-node-caret-none bp4-icon-standard"></span>
|
||||
<span className="bp4-tree-node-label">{subtaskId === -1
|
||||
? 'No testcase here'
|
||||
: 'Drag and drop testcases here:'}</span>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function SubtaskConfigTree() {
|
||||
const ids = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual);
|
||||
const dispatch = useDispatch();
|
||||
const store = useStore<RootState>();
|
||||
function autoConfigure() {
|
||||
const state = store.getState();
|
||||
const subtasks = readSubtasksFromFiles(state.testdata, state.config);
|
||||
dispatch({
|
||||
type: 'CONFIG_AUTOCASES_UPDATE',
|
||||
subtasks: normalizeSubtasks(subtasks, (i) => i, state.config.time, state.config.memory, true),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="bp4-tree">
|
||||
<ul className="bp4-tree-node-list bp4-tree-root">
|
||||
<li
|
||||
className="bp4-tree-node"
|
||||
onClick={autoConfigure}
|
||||
>
|
||||
<div className="bp4-tree-node-content bp4-tree-node-content-0">
|
||||
<Icon icon="clean" />
|
||||
<span className="bp4-tree-node-label">Auto Configure</span>
|
||||
</div>
|
||||
</li>
|
||||
<GlobalSettings />
|
||||
{ids.map((id) => <SubtaskNode key={id} subtaskId={id} />)}
|
||||
<li
|
||||
className="bp4-tree-node"
|
||||
onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })}
|
||||
>
|
||||
<div className="bp4-tree-node-content bp4-tree-node-content-0">
|
||||
<Icon icon="folder-new" />
|
||||
<span className="bp4-tree-node-label">Add New Subtask</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProblemConfigTree() {
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="row">
|
||||
<div className="medium-6 columns">
|
||||
<SubtaskConfigTree />
|
||||
</div>
|
||||
<div className="medium-6 columns">
|
||||
<div className="bp4-tree">
|
||||
<ul className="bp4-tree-node-list bp4-tree-root">
|
||||
<AddTestcase />
|
||||
<SubtaskNode subtaskId={-1} />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Tab, Tabs } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { i18n } from 'vj/utils';
|
||||
import ProblemConfigEditor from './ProblemConfigEditor';
|
||||
import ProblemConfigForm from './ProblemConfigForm';
|
||||
import { ProblemConfigTree } from './ProblemConfigTree';
|
||||
import { RootState } from './reducer';
|
||||
|
||||
interface Props {
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export default function ProblemConfig(props: Props) {
|
||||
const [selected, setSelected] = React.useState('basic');
|
||||
const valid = useSelector((state: RootState) => state.config.__valid);
|
||||
const errors = useSelector((state: RootState) => state.config.__errors, (a, b) => JSON.stringify(a) === JSON.stringify(b));
|
||||
|
||||
function save() {
|
||||
// eslint-disable-next-line
|
||||
if (!valid && !confirm('Errors detected in the config. Still save?')) return;
|
||||
props.onSave();
|
||||
}
|
||||
|
||||
return (<>
|
||||
<div className="row">
|
||||
<div className="medium-4 columns">
|
||||
<ProblemConfigEditor />
|
||||
</div>
|
||||
<div className="medium-8 columns">
|
||||
<Tabs onChange={(t) => (t !== 'errors' && setSelected(t.toString()))} selectedTabId={valid ? selected : 'errors'}>
|
||||
<Tab id="basic" disabled={!valid} title="Basic" panel={<ProblemConfigForm />} />
|
||||
<Tab id="subtasks" disabled={!valid} title="Subtasks" panel={<ProblemConfigTree />} />
|
||||
<Tab id="errors" disabled={valid} title={errors.length ? `Errors(${errors.length})` : 'No Errors'} panel={
|
||||
<div>{errors.map((i) => (<pre key={i}>{i}</pre>))}</div>} />
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded primary button" onClick={save}>{i18n('Submit')}</button>
|
||||
</>);
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
|
||||
import type { ProblemConfigFile } from 'hydrooj/src/interface';
|
||||
import { parseMemoryMB, parseTimeMS, sortFiles } from '@hydrooj/utils/lib/common';
|
||||
import Ajv from 'ajv';
|
||||
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
|
||||
import yaml from 'js-yaml';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import schema from '../../monaco/schema/problemconfig';
|
||||
|
||||
type State = ProblemConfigFile & { __loaded: boolean };
|
||||
type State = ProblemConfigFile & {
|
||||
__loaded: boolean;
|
||||
__valid: boolean;
|
||||
__errors: string[];
|
||||
__cases: TestCaseConfig[];
|
||||
};
|
||||
const ajv = new Ajv();
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
export default function reducer(state = { type: 'default', __loaded: false } as State, action: any = {}): State {
|
||||
export default function reducer(state = {
|
||||
type: 'default', __loaded: false, __valid: true, __errors: [], __cases: [],
|
||||
} as State, action: any = {}): State {
|
||||
switch (action.type) {
|
||||
case 'CONFIG_LOAD_FULFILLED': {
|
||||
return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true };
|
||||
@@ -19,9 +30,15 @@ export default function reducer(state = { type: 'default', __loaded: false } as
|
||||
}
|
||||
case 'CONFIG_CODE_UPDATE': {
|
||||
try {
|
||||
return { ...state, ...yaml.load(action.payload) as object };
|
||||
} catch {
|
||||
return state;
|
||||
const data = yaml.load(action.payload);
|
||||
if (!validate(data)) {
|
||||
return { ...state, __valid: false, __errors: validate.errors.map((i) => `${i.instancePath}: ${i.message}`) };
|
||||
}
|
||||
return {
|
||||
...state, ...data as object, __valid: true, __errors: [],
|
||||
};
|
||||
} catch (e) {
|
||||
return { ...state, __valid: false, __errors: [e.message] };
|
||||
}
|
||||
}
|
||||
case 'CONFIG_AUTOCASES_UPDATE': {
|
||||
@@ -42,27 +59,72 @@ export default function reducer(state = { type: 'default', __loaded: false } as
|
||||
}
|
||||
case 'CONFIG_SUBTASK_UPDATE': {
|
||||
const subtasks = cloneDeep(state.subtasks);
|
||||
const subsubtasks = cloneDeep(state.subtasks[action.id]);
|
||||
const subtask = state.subtasks.find((i) => i.id === action.id);
|
||||
if (action.value !== '' && ['score', 'id'].includes(action.key)) action.value = +action.value;
|
||||
if (action.key === 'if' && action.value.join('') !== '') action.value = action.value.map((i) => +i);
|
||||
if (action.key.split('-')[0] === 'cases') {
|
||||
if (action.key === 'cases-add') subsubtasks.cases.push(action.value);
|
||||
if (action.key === 'cases-add') subtask.cases.push(action.value);
|
||||
else if (action.key === 'cases-edit') {
|
||||
if (action.value === '' && !['input', 'output'].includes(action.casesKey)) delete subsubtasks.cases[action.casesId][action.casesKey];
|
||||
else subsubtasks.cases[action.casesId][action.casesKey] = action.value;
|
||||
if (action.value === '' && !['input', 'output'].includes(action.casesKey)) delete subtask.cases[action.casesId][action.casesKey];
|
||||
else subtask.cases[action.casesId][action.casesKey] = action.value;
|
||||
} else if (action.key === 'cases-delete') {
|
||||
subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
|
||||
subtask.cases = subtask.cases.filter((k, v) => v !== action.value);
|
||||
}
|
||||
} else if (action.key === 'add') {
|
||||
return { ...state, subtasks: [...subtasks, { time: state.time || '1s', memory: state.memory || '256m', cases: [] }] };
|
||||
subtasks.push({
|
||||
time: state.time || '1s',
|
||||
memory: state.memory || '256m',
|
||||
cases: [],
|
||||
score: 0,
|
||||
id: Object.keys(subtasks).map((i) => subtasks[i].id).reduce((a, b) => Math.max(+a, +b), 0) + 1,
|
||||
});
|
||||
return { ...state, subtasks };
|
||||
} else if (action.key === 'delete') return { ...state, subtasks: subtasks.filter((k, v) => v !== action.id) };
|
||||
else {
|
||||
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key];
|
||||
else subsubtasks[action.key] = action.value;
|
||||
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subtask[action.key];
|
||||
else subtask[action.key] = action.value;
|
||||
}
|
||||
subtasks[action.id] = subsubtasks;
|
||||
return { ...state, subtasks };
|
||||
}
|
||||
case 'problemconfig/updateGlobalConfig': {
|
||||
const n = { ...state, time: action.time, memory: action.memory };
|
||||
if (!n.time) delete n.time;
|
||||
if (!n.memory) delete n.memory;
|
||||
return n;
|
||||
}
|
||||
case 'problemconfig/updateSubtaskConfig': {
|
||||
if (!state.subtasks.find((i) => i.id === action.id)) return state;
|
||||
const subtask = { ...state.subtasks.find((i) => i.id === action.id) };
|
||||
const subtasks = [...state.subtasks];
|
||||
subtasks.splice(state.subtasks.findIndex((i) => i.id === action.id), 1, subtask);
|
||||
subtask.time = action.time;
|
||||
subtask.memory = action.memory;
|
||||
subtask.score = +action.score || 0;
|
||||
if (!subtask.time) delete subtask.time;
|
||||
if (!subtask.memory) delete subtask.memory;
|
||||
return { ...state, subtasks };
|
||||
}
|
||||
case 'problemconfig/addTestcases': {
|
||||
return { ...state, __cases: sortFiles([...state.__cases, ...action.cases], 'input') };
|
||||
}
|
||||
case 'problemconfig/moveTestcases': {
|
||||
const testcases = action.payload.cases;
|
||||
const subtasks = cloneDeep(state.subtasks);
|
||||
const __cases = action.payload.source === -1
|
||||
? state.__cases.filter((i) => !testcases.find((j) => i.input === j.input && i.output === j.output))
|
||||
: action.payload.target === -1
|
||||
? sortFiles([...state.__cases, ...testcases], 'input')
|
||||
: state.__cases;
|
||||
for (const key in subtasks) {
|
||||
const subtask = subtasks[key];
|
||||
if (subtask.id === action.payload.source) {
|
||||
subtask.cases = subtask.cases.filter((i) => !testcases.find((j) => i.input === j.input && i.output === j.output));
|
||||
} else if (subtask.id === action.payload.target) {
|
||||
subtask.cases = sortFiles([...subtask.cases, ...testcases], 'input');
|
||||
}
|
||||
}
|
||||
return { ...state, subtasks, __cases };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Button, ControlGroup, Dialog, DialogBody, DialogFooter, Icon, InputGroup,
|
||||
} from '@blueprintjs/core';
|
||||
import { readSubtasksFromFiles } from '@hydrooj/utils/lib/common';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector, useStore } from 'react-redux';
|
||||
import { RootState } from '../reducer';
|
||||
|
||||
export function AddTestcase() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [input, setInput] = React.useState('');
|
||||
const [output, setOutput] = React.useState('');
|
||||
const [valid, setValid] = React.useState(false);
|
||||
const testdata = useSelector((state: RootState) => state.testdata);
|
||||
const store = useStore<RootState>();
|
||||
|
||||
useEffect(() => {
|
||||
setValid(testdata.find((i) => i.name === input) && testdata.find((i) => i.name === output));
|
||||
}, [input, output]);
|
||||
|
||||
function onConfirm() {
|
||||
store.dispatch({
|
||||
type: 'problemconfig/addTestcases',
|
||||
cases: [{ input, output }],
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function auto() {
|
||||
const state = store.getState();
|
||||
const subtasks = readSubtasksFromFiles(state.testdata.map((i) => i.name), {});
|
||||
const current = state.config.subtasks.flatMap((i) => i.cases).concat(state.config.__cases);
|
||||
const pending = [];
|
||||
for (const c of subtasks.flatMap((s) => s.cases)) {
|
||||
if (!current.find((i) => i.input === c.input && i.output === c.output)) {
|
||||
pending.push({
|
||||
input: c.input,
|
||||
output: c.output,
|
||||
});
|
||||
}
|
||||
}
|
||||
store.dispatch({
|
||||
type: 'problemconfig/addTestcases',
|
||||
cases: pending,
|
||||
});
|
||||
}
|
||||
|
||||
return (<>
|
||||
<li
|
||||
className="bp4-tree-node"
|
||||
onClick={auto}
|
||||
>
|
||||
<div className="bp4-tree-node-content bp4-tree-node-content-0">
|
||||
<Icon icon="clean" />
|
||||
<span className="bp4-tree-node-label">Auto detect</span>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="bp4-tree-node"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="bp4-tree-node-content bp4-tree-node-content-0">
|
||||
<Icon icon="clean" />
|
||||
<span className="bp4-tree-node-label">Add testcase</span>
|
||||
</div>
|
||||
</li>
|
||||
<Dialog title="Add testcasse" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
|
||||
<DialogBody>
|
||||
<ControlGroup fill={true} vertical={false}>
|
||||
{/* TODO: autocomplete */}
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="import" />}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Input"
|
||||
value={input || ''}
|
||||
/>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="export" />}
|
||||
onChange={(e) => setOutput(e.target.value)}
|
||||
placeholder="Output"
|
||||
value={output || ''}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</DialogBody>
|
||||
<DialogFooter actions={<Button onClick={onConfirm} disabled={!valid} intent="primary" text="Save" />} />
|
||||
</Dialog>
|
||||
</>);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../reducer';
|
||||
import { TestcaseGroup } from './Testcase';
|
||||
|
||||
interface SelectionManagerProps {
|
||||
subtaskId: number;
|
||||
subtaskIds: number[];
|
||||
}
|
||||
|
||||
function collide(rect1: any, rect2: any): boolean {
|
||||
if ('getBoundingClientRect' in rect1) rect1 = rect1.getBoundingClientRect();
|
||||
if ('getBoundingClientRect' in rect2) rect2 = rect2.getBoundingClientRect();
|
||||
const maxX = Math.max(rect1.x + rect1.width, rect2.x + rect2.width);
|
||||
const maxY = Math.max(rect1.y + rect1.height, rect2.y + rect2.height);
|
||||
const minX = Math.min(rect1.x, rect2.x);
|
||||
const minY = Math.min(rect1.y, rect2.y);
|
||||
return maxX - minX <= rect1.width + rect2.width && maxY - minY <= rect1.height + rect2.height;
|
||||
}
|
||||
|
||||
export function SelectionManager(props: SelectionManagerProps) {
|
||||
const { subtaskIds, subtaskId } = props;
|
||||
const cases = useSelector((state: RootState) => (subtaskId === -1
|
||||
? state.config.__cases || []
|
||||
: state.config.subtasks.find((i) => i.id === subtaskId).cases || []));
|
||||
// Don't need to trigger a re-render for this property change
|
||||
const pos = React.useMemo(() => ({
|
||||
x: 0, y: 0, endX: 0, endY: 0,
|
||||
}), []);
|
||||
const [start, setStart] = React.useState(0);
|
||||
const [end, setEnd] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
setStart(0);
|
||||
setEnd(0);
|
||||
}, [JSON.stringify(cases)]);
|
||||
|
||||
const handleMouseDown = React.useCallback((event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
pos.x = event.pageX;
|
||||
pos.y = event.pageY;
|
||||
// Check if clicking on a selected testcase
|
||||
const selected = Array.from($('[data-selected="true"]'));
|
||||
for (const el of selected) {
|
||||
if (collide(el, { ...pos, width: 1, height: 1 })) return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
document.body.addEventListener('mousemove', handleMouseMove);
|
||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||
document.body.addEventListener('mouseup', handleMouseUp);
|
||||
$('body').css('cursor', 'crosshair')
|
||||
.append('<div id="divSelectArea" style="position:absolute;background-color:#e073d4;"></div>');
|
||||
$('#divSelectArea').css({
|
||||
top: event.pageY,
|
||||
left: event.pageX,
|
||||
zIndex: 9999999,
|
||||
}).fadeTo(12, 0.2);
|
||||
}, [JSON.stringify(cases)]);
|
||||
const handleMouseMove = React.useCallback((event) => {
|
||||
pos.endX = event.pageX;
|
||||
pos.endY = event.pageY;
|
||||
$('#divSelectArea').css({
|
||||
top: Math.min(event.pageY, pos.y),
|
||||
left: Math.min(event.pageX, pos.x),
|
||||
height: Math.abs(event.pageY - pos.y),
|
||||
width: Math.abs(event.pageX - pos.x),
|
||||
});
|
||||
}, [JSON.stringify(cases)]);
|
||||
const handleMouseUp = React.useCallback(() => {
|
||||
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||
document.body.removeEventListener('mouseup', handleMouseUp);
|
||||
const caseEntries = Array.from($(`[data-subtaskid="${subtaskId}"]`));
|
||||
const selected = [];
|
||||
for (let i = 0; i < caseEntries.length; i += 1) {
|
||||
if (collide(caseEntries[i], $('#divSelectArea')[0])) {
|
||||
selected.push(cases.indexOf(cases.find((c) => c.input === caseEntries[i].dataset.index)));
|
||||
}
|
||||
}
|
||||
const sorted = selected.sort((a, b) => a - b);
|
||||
$('#divSelectArea').remove();
|
||||
$('body').css('cursor', 'default');
|
||||
pos.x = pos.y = pos.endX = pos.endY = 0;
|
||||
if (!sorted.length) return;
|
||||
setStart(sorted[0]);
|
||||
setEnd(sorted[selected.length - 1] + 1);
|
||||
}, [JSON.stringify(cases)]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{end > start && cases.slice(0, start).map((c, id) => (
|
||||
<TestcaseGroup
|
||||
subtaskId={subtaskId}
|
||||
cases={[c]}
|
||||
key={`${c.input}@${id}`}
|
||||
selected={false}
|
||||
subtaskIds={subtaskIds}
|
||||
index={id}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={() => {
|
||||
setStart(id);
|
||||
setEnd(id + 1);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{start <= end && (
|
||||
<TestcaseGroup
|
||||
cases={cases.slice(start, end)}
|
||||
subtaskId={subtaskId}
|
||||
subtaskIds={subtaskIds}
|
||||
onMouseDown={handleMouseDown}
|
||||
selected={true}
|
||||
index={start}
|
||||
/>
|
||||
)}
|
||||
{end < cases.length && cases.slice(end).map((c, id) => (
|
||||
<TestcaseGroup
|
||||
subtaskId={subtaskId}
|
||||
cases={[c]}
|
||||
key={`${c.input}@${id}`}
|
||||
subtaskIds={subtaskIds}
|
||||
selected={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
index={id + end}
|
||||
onClick={() => {
|
||||
setStart(id + end);
|
||||
setEnd(id + end + 1);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
Button, ControlGroup,
|
||||
Dialog, DialogBody, DialogFooter,
|
||||
Icon, InputGroup,
|
||||
} from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '../reducer';
|
||||
|
||||
interface SubtaskSettingsProps {
|
||||
subtaskId: number;
|
||||
time: string;
|
||||
memory: string;
|
||||
}
|
||||
|
||||
export function SubtaskSettings(props: SubtaskSettingsProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const score = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).score);
|
||||
const time = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).time);
|
||||
const memory = useSelector((state: RootState) => state.config.subtasks.find((i) => i.id === props.subtaskId).memory);
|
||||
|
||||
const [ctime, setTime] = React.useState(time);
|
||||
const [cmemory, setMemory] = React.useState(memory);
|
||||
const [cscore, setScore] = React.useState(score);
|
||||
const dispatcher = (func, key) => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
|
||||
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
|
||||
if (key === 'score') value = +value;
|
||||
func(value);
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
function onConfirm() {
|
||||
dispatch({
|
||||
type: 'problemconfig/updateSubtaskConfig',
|
||||
id: props.subtaskId,
|
||||
time: ctime,
|
||||
memory: cmemory,
|
||||
score: cscore,
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Dialog title="Set limits" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
|
||||
<DialogBody>
|
||||
<ControlGroup fill={true} vertical={false}>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="time" />}
|
||||
onChange={dispatcher(setTime, 'time')}
|
||||
placeholder={`Inherit (${props.time || '1s'})`}
|
||||
value={ctime || ''}
|
||||
/>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="comparison" />}
|
||||
onChange={dispatcher(setMemory, 'memory')}
|
||||
placeholder={`Inherit (${props.memory || '256m'})`}
|
||||
value={cmemory || ''}
|
||||
/>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="star" />}
|
||||
onChange={dispatcher(setScore, 'score')}
|
||||
placeholder="Score"
|
||||
type="number"
|
||||
value={cscore.toString()}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</DialogBody>
|
||||
<DialogFooter actions={<Button onClick={onConfirm} intent="primary" text="Save" />} />
|
||||
</Dialog>
|
||||
<li className="bp4-tree-node" onClick={() => setOpen(true)}>
|
||||
<div className="bp4-tree-node-content">
|
||||
<span className="bp4-tree-node-caret-none bp4-icon-standard"></span>
|
||||
<Icon icon="time" />
|
||||
|
||||
<span className={`bp4-tree-node-label${!(time || props.time) ? ' text-gray' : ''}`}>{time || props.time || '1s'}</span>
|
||||
<Icon icon="comparison" />
|
||||
|
||||
<span className={`bp4-tree-node-label${!(memory || props.memory) ? ' text-gray' : ''}`}>{memory || props.memory || '256m'}</span>
|
||||
<Icon icon="star" />
|
||||
{' '}
|
||||
<span className="bp4-tree-node-secondary-label">{score || 0}</span>
|
||||
</div>
|
||||
</li>
|
||||
</>);
|
||||
}
|
||||
|
||||
export function GlobalSettings() {
|
||||
const time = useSelector((s: RootState) => s.config?.time);
|
||||
const memory = useSelector((s: RootState) => s.config?.memory);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [ctime, setTime] = React.useState(time);
|
||||
const [cmemory, setMemory] = React.useState(memory);
|
||||
React.useEffect(() => {
|
||||
setTime(time);
|
||||
}, [time]);
|
||||
React.useEffect(() => {
|
||||
setMemory(memory);
|
||||
}, [memory]);
|
||||
const dispatch = useDispatch();
|
||||
function onConfirm() {
|
||||
dispatch({
|
||||
type: 'problemconfig/updateGlobalConfig',
|
||||
time: ctime,
|
||||
memory: cmemory,
|
||||
});
|
||||
setOpen(false);
|
||||
}
|
||||
return (<>
|
||||
<Dialog title="Set limits" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
|
||||
<DialogBody>
|
||||
<ControlGroup fill={true} vertical={false}>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="time" />}
|
||||
onChange={(ev) => setTime(ev.currentTarget.value)}
|
||||
placeholder="1s"
|
||||
value={ctime || ''}
|
||||
/>
|
||||
<InputGroup
|
||||
leftElement={<Icon icon="comparison" />}
|
||||
onChange={(ev) => setMemory(ev.currentTarget.value)}
|
||||
placeholder="256m"
|
||||
value={cmemory || ''}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</DialogBody>
|
||||
<DialogFooter actions={<Button onClick={onConfirm} intent="primary" text="Save" />} />
|
||||
</Dialog>
|
||||
<li className="bp4-tree-node" onClick={() => setOpen(true)}>
|
||||
<div className="bp4-tree-node-content">
|
||||
<Icon icon="time" />
|
||||
|
||||
<span className={`bp4-tree-node-label${!time ? ' text-gray' : ''}`}>{time || '1s'}</span>
|
||||
<Icon icon="comparison" />
|
||||
{' '}
|
||||
<span className={`bp4-tree-node-secondary-label${!memory ? ' text-gray' : ''}`}>{memory || '256m'}</span>
|
||||
</div>
|
||||
</li>
|
||||
</>);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Menu, MenuItem, TreeNode } from '@blueprintjs/core';
|
||||
import { ContextMenu2 } from '@blueprintjs/popover2';
|
||||
import { TestCaseConfig } from 'hydrooj';
|
||||
import { omit } from 'lodash';
|
||||
import React from 'react';
|
||||
import { useDrag } from 'react-dnd';
|
||||
|
||||
interface TestcaseNodeProps {
|
||||
c: TestCaseConfig;
|
||||
index: number;
|
||||
time?: string;
|
||||
memory?: string;
|
||||
onClick?: () => void;
|
||||
selected: boolean;
|
||||
subtaskId: number;
|
||||
subtaskIds: number[];
|
||||
}
|
||||
|
||||
export function TestcaseNode(props: TestcaseNodeProps) {
|
||||
const {
|
||||
c, selected, onClick, subtaskIds, subtaskId,
|
||||
} = props;
|
||||
return (
|
||||
<ContextMenu2
|
||||
onContextMenu={onClick}
|
||||
data-subtaskid={subtaskId}
|
||||
data-index={c.input}
|
||||
data-selected={selected}
|
||||
content={
|
||||
<Menu>
|
||||
<MenuItem icon="drawer-left" text="Move to subtask" >
|
||||
{subtaskIds.filter((i) => i !== subtaskId).map((i) => (
|
||||
<MenuItem key={i} text={`Subtask ${i}`} />
|
||||
))}
|
||||
{subtaskIds.length <= 1 && (
|
||||
<MenuItem icon="disable" disabled text="No target available" />
|
||||
)}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<TreeNode
|
||||
depth={0}
|
||||
id={c.input}
|
||||
isSelected={selected}
|
||||
onClick={onClick}
|
||||
icon="document"
|
||||
label={<> {c.input} / {c.output}</>}
|
||||
path={[0]}
|
||||
/>
|
||||
</ContextMenu2>
|
||||
);
|
||||
}
|
||||
|
||||
interface TestcaseGroupProps extends Omit<TestcaseNodeProps, 'c'> {
|
||||
cases: TestCaseConfig[];
|
||||
onMouseDown?: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export function TestcaseGroup(props: TestcaseGroupProps) {
|
||||
const {
|
||||
cases, subtaskId, onClick, index,
|
||||
} = props;
|
||||
const [collected, drag] = useDrag(() => ({
|
||||
type: 'cases',
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: props.selected,
|
||||
item: { cases, subtaskId },
|
||||
}), [JSON.stringify(cases), subtaskId]);
|
||||
return <div ref={drag} onClick={onClick} onMouseDown={props.onMouseDown} style={{ opacity: collected.isDragging ? 0.5 : 1 }}>
|
||||
{cases.map((c, id) => (
|
||||
<TestcaseNode c={c} key={`${c.input}@${index + id}`} {...omit(props, 'onClick')} index={index + id} />
|
||||
))}
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user