+
diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx
index 3be7646ab..15df4898f 100644
--- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx
+++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx
@@ -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() {
diff --git a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx
index 316bd699f..d2d584ef9 100644
--- a/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx
+++ b/packages/ui-default/components/problemconfig/ProblemConfigForm.tsx
@@ -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
();
const judgeRef = React.useRef();
return (
-
-
);
}
@@ -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();
const selectedKeys = langs.filter((i) => !prefixes.has(i));
return (
-
- {
- 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
- />
+
+
+
+
+ {
+ 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
+ />
+
+
+
);
}
@@ -109,7 +108,6 @@ export default function ProblemConfigForm() {
{Type === 'default' && }
{!['submit_answer', 'objective'].includes(Type) && (
<>
-
>
diff --git a/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx b/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx
new file mode 100644
index 000000000..873e29b3d
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/ProblemConfigTree.tsx
@@ -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(() => ({
+ accept: 'cases',
+ canDrop(item) {
+ return item.subtaskId !== subtaskId;
+ },
+ drop(item) {
+ dispatch({
+ type: 'problemconfig/moveTestcases',
+ payload: {
+ target: subtaskId,
+ source: item.subtaskId,
+ cases: item.cases,
+ },
+ });
+ },
+ }));
+
+ return (
+
+ {subtaskId !== -1 && setExpand((e) => !e)}>
+
+ Subtask {subtaskId}
+
+
+
+
}
+
+ {subtaskId !== -1 && }
+ {expand
+ ?
+ : setExpand(false)}
+ icon="layers"
+ label={<> {clen} testcases.>}
+ path={[0]}
+ />}
+ {!clen && (
+ -
+
+
+ {subtaskId === -1
+ ? 'No testcase here'
+ : 'Drag and drop testcases here:'}
+
+
+ )}
+
+
+ );
+}
+
+export function SubtaskConfigTree() {
+ const ids = useSelector((s: RootState) => Object.values(s.config?.subtasks || []).map((i) => i.id), isEqual);
+ const dispatch = useDispatch();
+ const store = useStore();
+ 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 (
+
+
+ -
+
+
+ Auto Configure
+
+
+
+ {ids.map((id) => )}
+ - dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: 0, key: 'add' })}
+ >
+
+
+ Add New Subtask
+
+
+
+
+ );
+}
+
+export function ProblemConfigTree() {
+ return (
+
+
+
+ );
+}
diff --git a/packages/ui-default/components/problemconfig/index.tsx b/packages/ui-default/components/problemconfig/index.tsx
new file mode 100644
index 000000000..1bff20346
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/index.tsx
@@ -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 (<>
+
+
+
+
(t !== 'errors' && setSelected(t.toString()))} selectedTabId={valid ? selected : 'errors'}>
+ } />
+ } />
+ {errors.map((i) => ({i}))}} />
+
+
+
+
+ >);
+}
diff --git a/packages/ui-default/components/problemconfig/reducer/config.ts b/packages/ui-default/components/problemconfig/reducer/config.ts
index 190939225..f90d1d7c5 100644
--- a/packages/ui-default/components/problemconfig/reducer/config.ts
+++ b/packages/ui-default/components/problemconfig/reducer/config.ts
@@ -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;
}
diff --git a/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx
new file mode 100644
index 000000000..7f2f40e17
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/tree/AddTestcase.tsx
@@ -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
();
+
+ 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 (<>
+
+
+
+ Auto detect
+
+
+ setOpen(true)}
+ >
+
+
+ Add testcase
+
+
+
+ >);
+}
diff --git a/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx b/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx
new file mode 100644
index 000000000..53602da3b
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/tree/SelectionManager.tsx
@@ -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) => {
+ 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('');
+ $('#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) => (
+ {
+ setStart(id);
+ setEnd(id + 1);
+ }}
+ />
+ ))}
+ {start <= end && (
+
+ )}
+ {end < cases.length && cases.slice(end).map((c, id) => (
+ {
+ setStart(id + end);
+ setEnd(id + end + 1);
+ }}
+ />
+ ))}
+ >
+ );
+}
diff --git a/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx
new file mode 100644
index 000000000..0fb13cb9c
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/tree/SubtaskSettings.tsx
@@ -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 | 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 (<>
+
+ setOpen(true)}>
+
+
+
+
+ {time || props.time || '1s'}
+
+
+ {memory || props.memory || '256m'}
+
+ {' '}
+ {score || 0}
+
+
+ >);
+}
+
+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 (<>
+
+ setOpen(true)}>
+
+
+
+ {time || '1s'}
+
+ {' '}
+ {memory || '256m'}
+
+
+ >);
+}
diff --git a/packages/ui-default/components/problemconfig/tree/Testcase.tsx b/packages/ui-default/components/problemconfig/tree/Testcase.tsx
new file mode 100644
index 000000000..f80c9eff3
--- /dev/null
+++ b/packages/ui-default/components/problemconfig/tree/Testcase.tsx
@@ -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 (
+
+
+
+ }
+ >
+ {c.input} / {c.output}>}
+ path={[0]}
+ />
+
+ );
+}
+
+interface TestcaseGroupProps extends Omit {
+ cases: TestCaseConfig[];
+ onMouseDown?: (event: React.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
+ {cases.map((c, id) => (
+
+ ))}
+
;
+}
diff --git a/packages/ui-default/components/selectUser.tsx b/packages/ui-default/components/selectUser.tsx
index 9cf75c95d..c9a3da306 100644
--- a/packages/ui-default/components/selectUser.tsx
+++ b/packages/ui-default/components/selectUser.tsx
@@ -6,15 +6,17 @@ import createHint from './hint';
let hintInserted = false;
$(tpl(
-
-
-
{i18n('Select User')}
-
-
-
-
+
+
+
+
{i18n('Select User')}
+
+
,
diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js
index ad19e1b4e..edd988d30 100644
--- a/packages/ui-default/entry.js
+++ b/packages/ui-default/entry.js
@@ -47,7 +47,7 @@ const prefetch = Promise.all([
]);
document.addEventListener('DOMContentLoaded', async () => {
- Object.assign(window.UiContext, JSON.parse(window.UiContextNew))
+ Object.assign(window.UiContext, JSON.parse(window.UiContextNew));
const [data, HydroExports] = await prefetch;
Object.assign(window, { HydroExports });
eval(data); // eslint-disable-line no-eval
diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json
index ab0859412..27f4aaa3e 100644
--- a/packages/ui-default/package.json
+++ b/packages/ui-default/package.json
@@ -15,6 +15,7 @@
},
"devDependencies": {
"@blueprintjs/core": "4.17.4",
+ "@blueprintjs/popover2": "^1.13.8",
"@fontsource/dm-mono": "^4.5.10",
"@fontsource/fira-code": "^4.5.13",
"@fontsource/inconsolata": "^4.5.10",
@@ -40,6 +41,7 @@
"@types/sharedworker": "^0.0.93",
"@types/webpack-env": "^1.18.0",
"@vscode/codicons": "^0.0.32",
+ "ajv": "^8.12.0",
"allotment": "^1.18.1",
"autoprefixer": "^10.4.14",
"browser-update": "^3.3.44",
diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx
index 284d2ac98..8254f206d 100644
--- a/packages/ui-default/pages/problem_config.page.tsx
+++ b/packages/ui-default/pages/problem_config.page.tsx
@@ -85,9 +85,8 @@ const page = new NamedPage('problem_config', () => {
}
async function mountComponent() {
- const [{ default: ProblemConfigEditor }, { default: ProblemConfigForm }, { default: ProblemConfigReducer }] = await Promise.all([
- import('vj/components/problemconfig/ProblemConfigEditor'),
- import('vj/components/problemconfig/ProblemConfigForm'),
+ const [{ default: ProblemConfig }, { default: ProblemConfigReducer }] = await Promise.all([
+ import('vj/components/problemconfig/index'),
import('vj/components/problemconfig/reducer'),
]);
@@ -120,15 +119,7 @@ const page = new NamedPage('problem_config', () => {
});
createRoot(document.getElementById('ProblemConfig')!).render(
-
-
+ uploadConfig(store.getState().config)} />
,
);
}
diff --git a/packages/ui-default/templates/record_detail_status.html b/packages/ui-default/templates/record_detail_status.html
index e6a9fcd29..430642ccf 100644
--- a/packages/ui-default/templates/record_detail_status.html
+++ b/packages/ui-default/templates/record_detail_status.html
@@ -71,8 +71,8 @@
{{ model.builtin.STATUS_TEXTS[rcdoc['status']] }}
- {% if (rdoc.subtask && _key != 'id' and rcdocs.length == 1 ) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %}
-
+ {% if (rdoc.subtask and _key != 'id' and rcdocs.length == 1) or (_key == 'id' and rdoc.subtasks[rcdoc.subtaskId].type == 'sum') %}
+
{{ (rdoc.subtasks[subtaskId]['score'] if _key != 'id' else rcdoc.score)|default(0) }}
{% endif %}
diff --git a/packages/ui-default/theme/default.js b/packages/ui-default/theme/default.js
index 0c7a2a817..d83210055 100644
--- a/packages/ui-default/theme/default.js
+++ b/packages/ui-default/theme/default.js
@@ -6,6 +6,7 @@ import 'pickadate/lib/themes/classic.date.css';
import 'pickadate/lib/themes/classic.time.css';
import 'katex/dist/katex.min.css';
import '@blueprintjs/core/lib/css/blueprint.css';
+import '@blueprintjs/popover2/lib/css/blueprint-popover2.css';
import '@fontsource/fira-code';
import '@fontsource/source-code-pro';
import '@fontsource/roboto-mono';
diff --git a/packages/utils/lib/common.ts b/packages/utils/lib/common.ts
index e28178245..6f833687a 100644
--- a/packages/utils/lib/common.ts
+++ b/packages/utils/lib/common.ts
@@ -199,6 +199,37 @@ export function size(s: number, base = 1) {
return `${Math.round(s * unit)} ${unitNames[unitNames.length - 1]}`;
}
+export type StringKeys = {
+ [K in keyof O]: string extends O[K] ? K : never
+}[keyof O];
+const fSortR = /[^\d]+|\d+/g;
+export function sortFiles(files: string[]): string[];
+export function sortFiles(files: { _id: string }[], key?: '_id'): { _id: string }[];
+export function sortFiles>(files: T[], key: StringKeys): T[];
+export function sortFiles(files: Record[] | string[], key = '_id') {
+ if (!files?.length) return [];
+ const isString = typeof files[0] === 'string';
+ const result = files
+ .map((i) => (isString ? { name: i, _weights: i.match(fSortR) } : { ...i, _weights: (i[key] || i.name).match(fSortR) }))
+ .sort((a, b) => {
+ let pos = 0;
+ const weightsA = a._weights;
+ const weightsB = b._weights;
+ let weightA = weightsA[pos];
+ let weightB = weightsB[pos];
+ while (weightA && weightB) {
+ const v = weightA - weightB;
+ if (!Number.isNaN(v) && v !== 0) return v;
+ if (weightA !== weightB) return weightA > weightB ? 1 : -1;
+ pos += 1;
+ weightA = weightsA[pos];
+ weightB = weightsB[pos];
+ }
+ return weightA ? 1 : -1;
+ });
+ return result.map((x) => (isString ? x.name : (delete x._weights && x)));
+}
+
interface MatchRule {
regex: RegExp;
output: ((a: RegExpExecArray) => string)[];
diff --git a/packages/utils/lib/utils.ts b/packages/utils/lib/utils.ts
index 4b588d786..083c218e4 100644
--- a/packages/utils/lib/utils.ts
+++ b/packages/utils/lib/utils.ts
@@ -255,31 +255,6 @@ export function CallableInstance(property = '__call__') {
CallableInstance.prototype = Object.create(Function.prototype);
-const fSortR = /[^\d]+|\d+/g;
-export function sortFiles(files: { _id: string }[] | string[]) {
- if (!files?.length) return [];
- const isString = typeof files[0] === 'string';
- const result = files
- .map((i) => (isString ? { name: i, weights: i.match(fSortR) } : { ...i, weights: (i._id || i.name).match(fSortR) }))
- .sort((a, b) => {
- let pos = 0;
- const weightsA = a.weights;
- const weightsB = b.weights;
- let weightA = weightsA[pos];
- let weightB = weightsB[pos];
- while (weightA && weightB) {
- const v = weightA - weightB;
- if (!Number.isNaN(v) && v !== 0) return v;
- if (weightA !== weightB) return weightA > weightB ? 1 : -1;
- pos += 1;
- weightA = weightsA[pos];
- weightB = weightsB[pos];
- }
- return weightA ? 1 : -1;
- });
- return isString ? result.map((x) => x.name) : result;
-}
-
export const htmlEncode = (str: string) => str.replace(/[&<>'"]/g,
(tag: string) => ({
'&': '&',