ui: UI&UX enhance, bug fixes

Co-authored-by: panda <panda_dtdyy@outlook.com>
This commit is contained in:
undefinedandpanda authored and panda committed 2022-05-10 01:55:20 +08:00
1 parent 3f37b02b79
commit 310189bc4d
37 files changed
+1104 -948

No files matched your search

@@ -0,0 +1,69 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import type { RootState } from './reducer/index';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
export function FormItem({
columns, label, children, helpText = '', disableLabel = false, ...props
}) {
return (
<div {...props} className={`${columns && `medium-${columns}`} columns form__item`}>
<label htmlFor={`${label}-form`}>
{!disableLabel && i18n(label)}
{children}
{helpText && (<p className="help-text">{i18n(helpText)}</p>)}
</label>
</div>
);
}
type KeyType<K, T = string | number> = {
[Q in keyof K]: K[Q] extends T ? Q : never;
}[keyof K];
export function ManagedInput({ placeholder, formKey }: { placeholder?: string, formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<input
placeholder={i18n(placeholder)}
value={value || ''}
type={typeof value === 'number' ? 'number' : 'text'}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="textbox"
/>
);
}
export function ManagedSelect({ options, formKey }: { options: string[], formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<select
value={value || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="select"
>
{options.map((o) => (<option id={o} key={o}>{o}</option>))}
</select>
);
}
export function SingleFileSelect({ formKey }: { formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
return (
<FileSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[value]}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })}
/>
);
}
@@ -3,6 +3,7 @@ import type { editor } from 'monaco-editor';
import { connect } from 'react-redux';
import { load } from 'vj/components/monaco/loader';
import Editor from 'vj/components/editor';
import { diffLines } from 'diff';
import yaml from 'js-yaml';
import type { ProblemConfigFile, TestCaseConfig } from 'hydrooj/src/interface';
@@ -41,9 +42,10 @@ function configYamlFormat(config: ProblemConfigFile) {
const formatConfig: ProblemConfigFile = {};
configKey.forEach((key) => {
if (config[key] !== undefined) {
if (key === 'subType' && ['single', 'multi'].includes(config[key]) && config.type !== 'submit_answer') return;
if (key === 'checker_type' && config.type !== 'default') return;
if (key === 'checker'
&& (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return;
&& (['default', 'strict'].includes(formatConfig.checker_type) || formatConfig.checker_type === undefined)) return;
if (key === 'interactor' && config.type !== 'interactive') return;
if (key === 'subtasks') {
formatConfig[key] = [];
@@ -71,6 +73,11 @@ function configYamlFormat(config: ProblemConfigFile) {
} else formatConfig[key] = config[key];
}
});
if (formatConfig.type === 'objective') {
Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]);
formatConfig.outputs = formatConfig.outputs || [];
}
Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]);
return formatConfig;
}
@@ -87,7 +94,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
async componentDidMount() {
const { monaco } = await load(['yaml']);
const uri = monaco.Uri.parse('hydro://problem/file/config.yaml');
this.model = monaco.editor.createModel(yaml.dump(this.props.config), 'yaml', uri);
this.model = monaco.editor.createModel(yaml.dump(configYamlFormat(this.props.config)), 'yaml', uri);
this.vjEditor = Editor.getOrConstruct($(this.containerElement), {
language: 'yaml',
model: this.model,
@@ -101,17 +108,28 @@ export default connect(mapStateToProps, mapDispatchToProps)(class MonacoEditor e
}
componentDidUpdate(prevProps) {
if (this.__preventUpdate) return;
if (this.__preventUpdate || !this.model) return;
if (yaml.dump(prevProps.config) !== yaml.dump(this.props.config)) {
this.__preventFormat = true;
this.model?.pushEditOperations(
[],
[{
range: this.model.getFullModelRange(),
text: yaml.dump(configYamlFormat(this.props.config)),
}],
undefined,
);
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;
}
}
@@ -1,153 +1,36 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import { isEqual } from 'lodash';
import { Switch, Tab, Tabs } from '@blueprintjs/core';
import type { SubtaskConfig, TestCaseConfig } from 'hydrooj/src/interface';
import {
Card, Switch, Tab, Tabs, InputGroup, Tag,
} from '@blueprintjs/core';
import type { RootState } from './reducer/index';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
import { FormItem } from './BasicForm';
import ProblemType from './ProblemType';
import { TaskConfig } from './SubtaskTable';
const SelectValue = {
type: ['default', 'interactive', 'submit_answer', 'objective'],
checker_type: ['default', 'lemon', 'syzoj', 'hustoj', 'testlib', 'qduoj'],
task_type: ['min', 'max', 'sum'],
};
const eq = (a: TestCaseConfig, b: TestCaseConfig) => isEqual(a, b);
const eqId = (a: SubtaskConfig[], b: SubtaskConfig[]) => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (a[i].id !== b[i].id) return true;
}
return false;
};
function FormItem({
columns, label, children, helpText = '', disableLabel = false, ...props
}) {
return (
<div {...props} className={`${columns && `medium-${columns}`} columns form__item`}>
<label htmlFor={`${label}-form`}>
{!disableLabel && i18n(label)}
{children}
{helpText && (<p className="help-text">{i18n(helpText)}</p>)}
</label>
</div>
);
}
type KeyType<K, T = string | number> = {
[Q in keyof K]: K[Q] extends T ? Q : never;
}[keyof K];
function ManagedInput({ placeholder, formKey }: { placeholder: string, formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
function FileIOConfig() {
const filename = useSelector((state: RootState) => state.config.filename);
const dispatch = useDispatch();
return (
<input
placeholder={i18n(placeholder)}
value={value || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="textbox"
/>
);
}
function ManagedSelect({ placeholder, formKey }: { placeholder: string, formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const dispatch = useDispatch();
return (
<select
placeholder={i18n(placeholder)}
value={value || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: ev.currentTarget.value });
}}
className="select"
>
{SelectValue[formKey].map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
);
}
function SingleFileSelect({ formKey }: { formKey: KeyType<RootState['config']> }) {
const value = useSelector((state: RootState) => state.config[formKey]);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
return (
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[value]}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: formKey, value: val })}
/>
);
}
function BasicInfo() {
const Type = useSelector((state: RootState) => state.config.type);
const checkerType = useSelector((state: RootState) => state.config.checker_type);
const dispatch = useDispatch();
return (
<>
<FormItem columns={6} label="Type">
<ManagedSelect placeholder="type" formKey="type" />
</FormItem>
<FormItem columns={6} label="Filename" style={Type === 'interactive' ? { display: 'none' } : {}}>
<ManagedInput placeholder="filename" formKey="filename" />
</FormItem>
<FormItem
columns={12}
label="CheckerType"
disableLabel
style={Type !== 'default' ? { display: 'none' } : {}}
>
<Tabs
id="CheckerTypeTabs"
selectedTabId={checkerType !== 'strict' ? checkerType : 'default'}
onChange={(value) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value });
}}
renderActiveTabPanelOnly
>
<span>{i18n('CheckerType')}</span>
<Tabs.Expander />
{
SelectValue.checker_type.map((i) => (
<Tab
id={i}
title={i}
key={i}
panel={(
<FormItem columns={12} label="Checker">
{['default', 'strict'].includes(i)
? (
<Switch
checked={checkerType === 'strict'}
label={i18n('Don\'t ignore space and enter.')}
onChange={
() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
}
}
/>
)
: (
<SingleFileSelect formKey="checker" />
)}
</FormItem>
)}
/>
))
}
</Tabs>
</FormItem>
<FormItem columns={6} label="Interactor" style={Type !== 'interactive' ? { display: 'none' } : {}}>
<SingleFileSelect formKey="interactor" />
</FormItem>
</>
<FormItem columns={12} label="FileIOConfig" disableLabel>
<Card style={{ padding: 10 }}>
<div className="row">
<FormItem columns={6} label="FileIO">
<InputGroup
rightElement={<Tag minimal>.in/.out</Tag>}
value={filename || ''}
onChange={(ev) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value });
}}
fill
/>
</FormItem>
</div>
</Card>
</FormItem>
);
}
@@ -166,7 +49,7 @@ function ExtraFilesConfig() {
id="user_extra_files"
title={i18n('user_extra_files')}
panel={(
<CustomSelectAutoComplete
<FileSelectAutoComplete
data={Files}
selectedKeys={userExtraFiles || []}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'user_extra_files', value: val.split(',') })}
@@ -178,7 +61,7 @@ function ExtraFilesConfig() {
id="judge_extra_files"
title={i18n('judge_extra_files')}
panel={(
<CustomSelectAutoComplete
<FileSelectAutoComplete
data={Files}
selectedKeys={judgeExtraFiles || []}
onChange={(val) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'judge_extra_files', value: val.split(',') })}
@@ -192,261 +75,6 @@ function ExtraFilesConfig() {
);
}
function CasesSubCasesTable({ index, subindex }) {
const subcases = useSelector((state: RootState) => (index === -1
? state.config.cases[subindex] : state.config.subtasks[index].cases[subindex]), eq);
const Files = useSelector((state: RootState) => state.testdata);
const dispatch = useDispatch();
const dispatcher = (casesKey: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (ev) => {
dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value: ev.currentTarget.value,
});
};
const dispatcher1 = (casesKey: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (val) => {
dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value: val,
});
};
return (
<tr>
{
index === -1 && (
<>
<td>
<input
value={subcases.time || ''}
onChange={dispatcher('time')}
className="textbox"
/>
</td>
<td>
<input
value={subcases.memory || ''}
onChange={dispatcher('memory')}
className="textbox"
/>
</td>
</>
)
}
<td>
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[subcases.input]}
onChange={dispatcher1('input')}
/>
</td>
<td>
<CustomSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[subcases.output]}
onChange={dispatcher1('output')}
/>
</td>
<td>
<a
onClick={() => dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex,
})}
><span className="icon icon-close"></span>
</a>
</td>
</tr>
);
}
function CasesTable({ index }) {
const casesLength = useSelector((state: RootState) => (index === -1 ? state.config.cases?.length : state.config.subtasks[index].cases?.length));
const dispatch = useDispatch();
return (
<table className="data-table">
<thead>
<tr>
{
index === -1 && (
<>
<th>
{i18n('Time')}
</th>
<th>
{i18n('Memory')}
</th>
</>
)
}
<th>
{i18n('Input')}
</th>
<th>
{i18n('Output')}
</th>
<th className="col--operation">
<a
onClick={() => dispatch({
type: index === -1 ? 'CONFIG_CASES_UPDATE' : 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: index === -1 ? {
time: 1000, memory: 256, input: '', output: '',
} : { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</th>
</tr>
</thead>
<tbody>
{casesLength && [...Array(casesLength).keys()].map((i) => <CasesSubCasesTable index={index} subindex={i} key={i} />)}
</tbody>
</table>
);
}
function SubtasksIds({ index }) {
const subtasks = useSelector((state: RootState) => state.config.subtasks, eqId);
const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if);
const dispatch = useDispatch();
const subtasksIds = subtasks.map((i) => i.id).filter((i) => i !== undefined);
return (
<tr>
<td>if</td>
<td colSpan={4}>
<CustomSelectAutoComplete
data={subtasksIds}
selectedKeys={subtaskIf?.join(',').split(',') || []}
itemKey={(item) => `${item}`}
onChange={(val) => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
})}
multi
/>
</td>
</tr>
);
}
function SubtasksTable({ index }) {
const subtask = (key: string) => useSelector((state: RootState) => state.config.subtasks[index][key]);
const dispatch = useDispatch();
const dispatcher = (key: string): React.ChangeEventHandler<HTMLInputElement | HTMLSelectElement> => (ev) => {
dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value: ev.currentTarget.value,
});
};
return (
<>
<span>Subtasks #{index + 1} </span>
<a onClick={() => { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' }); }}><span className="icon icon-add"></span></a>
<a
style={index === 0 ? { display: 'none' } : {}}
onClick={() => { dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' }); }}
><span className="icon icon-delete"></span>
</a>
<table className="data-table">
<thead>
<tr>
<th>#</th>
<th>Score</th>
<th>Type</th>
<th>Time</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input
value={subtask('id')?.toString() || ''}
onChange={dispatcher('id')}
className="textbox"
/>
</td>
<td>
<input
value={subtask('score') || ''}
onChange={dispatcher('score')}
className="textbox"
/>
</td>
<td>
<select
value={subtask('type') || ''}
onChange={dispatcher('type')}
className="select"
>
<option aria-label="null" value="" style={{ display: 'none' }}></option>
{SelectValue.task_type.map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
</td>
<td>
<input
value={subtask('time') || ''}
onChange={dispatcher('time')}
className="textbox"
/>
</td>
<td>
<input
value={subtask('memory') || ''}
onChange={dispatcher('memory')}
className="textbox"
/>
</td>
</tr>
<SubtasksIds index={index} />
</tbody>
</table>
<CasesTable index={index} />
</>
);
}
function TaskConfig({ onAutoLoad }) {
const subtasksLength = useSelector((state: RootState) => state.config.subtasks?.length);
const casesLength = useSelector((state: RootState) => state.config.cases?.length);
const dispatch = useDispatch();
return (
<FormItem columns={12} label="Task Settings">
<div className="row">
<FormItem columns={4} label="Time">
<ManagedInput placeholder="Time" formKey="time" />
</FormItem>
<FormItem columns={4} label="Memory">
<ManagedInput placeholder="Memory" formKey="memory" />
</FormItem>
<FormItem columns={4} label="Score">
<ManagedInput placeholder="Score" formKey="score" />
</FormItem>
<FormItem columns={12} label="Cases Settings" disableLabel>
{casesLength && <Switch label={i18n('Use Subtasks Mode')} onChange={() => dispatch({ type: 'CONFIG_SUBTASKS_SWITCH', value: true })} />}
{
subtasksLength || casesLength
? subtasksLength && [...Array(subtasksLength).keys()].map((i) => <SubtasksTable index={i} key={i} />)
|| casesLength && (<CasesTable index={-1} />)
: (
<a onClick={() => onAutoLoad()}>
<span className="icon icon-settings"> {i18n('Auto Read Tasks')}</span>
</a>
)
}
</FormItem>
</div>
</FormItem>
);
}
function LangConfig() {
const [showTab, setshowTab] = useState(false);
const langs = useSelector((state: RootState) => state.config.langs) || [];
@@ -479,13 +107,19 @@ function LangConfig() {
);
}
export default function ProblemConfigForm({ onAutoLoad }) {
export default function ProblemConfigForm() {
const Type = useSelector((state: RootState) => state.config.type);
return (
<div className="row">
<BasicInfo />
<TaskConfig onAutoLoad={onAutoLoad} />
<ExtraFilesConfig />
<LangConfig />
<div className="row problem-config-form">
<ProblemType />
{Type === 'default' && <FileIOConfig />}
{!['submit_answer', 'objective'].includes(Type) && (
<>
<TaskConfig />
<ExtraFilesConfig />
<LangConfig />
</>
)}
</div>
);
}
@@ -0,0 +1,129 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import {
Card, Switch, Tab, Tabs,
} from '@blueprintjs/core';
import type { RootState } from './reducer/index';
import { FormItem, ManagedSelect, SingleFileSelect } from './BasicForm';
export default function ProblemType() {
const Type = useSelector((state: RootState) => state.config.type);
const checkerType = useSelector((state: RootState) => state.config.checker_type);
const filename = useSelector((state: RootState) => state.config.filename);
const subType = useSelector((state: RootState) => state.config.subType);
const dispatch = useDispatch();
const dispatcher = (base) => (value) => dispatch({ ...base, value });
return (
<FormItem columns={12} label="" disableLabel>
<Card style={{ padding: 10 }}>
<Tabs
id="ProblemTypeTabs"
selectedTabId={Type}
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'type' })}
defaultSelectedTabId="default"
renderActiveTabPanelOnly
>
<span className="bp4-tab">{i18n('Problem Type')}</span>
<Tabs.Expander />
<Tab
id="default"
title={i18n('problem_type.default')}
panel={(
<Tabs
id="CheckerTypeTabs"
selectedTabId={
['strict', 'default'].includes(checkerType) || !checkerType
? 'default' : (checkerType !== 'testlib' ? 'other' : 'testlib')
}
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type' })}
renderActiveTabPanelOnly
>
<span className="bp4-tab">{i18n('CheckerType')}</span>
<Tabs.Expander />
<Tab
id="default"
title={i18n('default')}
panel={(
<div className="row">
<FormItem columns={12} label="Config">
<Switch
checked={checkerType !== 'strict'}
label={i18n('Ignore trailing space and enter.')}
onChange={() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
}}
/>
</FormItem>
</div>
)}
/>
{['testlib', 'other'].map((i) => (
<Tab
id={i}
title={i}
key={i}
panel={(
<div className="row">
{i === 'other' && (
<FormItem columns={6} label="Interface">
<ManagedSelect options={['syzoj', 'hustoj', 'qduoj', 'lemon']} formKey="checker_type" />
</FormItem>
)}
<FormItem columns={6} label="Checker">
<SingleFileSelect formKey="checker" />
</FormItem>
</div>
)}
/>
))}
</Tabs>
)}
/>
<Tab
id="interactive"
title={i18n('problem_type.interactive')}
panel={(
<div className="row">
<FormItem columns={6} label="Interactor">
<SingleFileSelect formKey="interactor" />
</FormItem>
</div>
)}
/>
<Tab
id="submit_answer"
title={i18n('problem_type.submit_answer')}
panel={(
<div className="row">
<FormItem columns={6} label="Config" disableLabel>
<Switch
checked={subType === 'multi'}
label={i18n('Multi-file')}
onChange={() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'subType', value: subType === 'multi' ? 'single' : 'multi' });
}}
/>
</FormItem>
<FormItem columns={6} label="Filename">
<input
defaultValue={filename || '#.txt'}
placeholder="#.txt"
disabled={subType !== 'multi'}
onChange={(ev) => dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'filename', value: ev.currentTarget.value }))}
className="textbox"
/>
</FormItem>
</div>
)}
/>
<Tab
id="objective"
title={i18n('problem_type.objective')}
panel={(<p>暂不支持快捷配置此类题目。请参照文档操作。</p>)}
/>
</Tabs>
</Card>
</FormItem>
);
}
@@ -0,0 +1,139 @@
import { Card, NumericInput, Tag } from '@blueprintjs/core';
import { pick, isEqual } from 'lodash';
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { SubtaskConfig } from 'hydrooj/src/interface';
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
import CustomSelectAutoComplete from '../autocomplete/components/CustomSelectAutoComplete';
import { FormItem, ManagedInput } from './BasicForm';
import { RootState } from './reducer';
import { CasesTable } from './TestCasesTable';
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
export function SubtasksIds({ index }) {
const ids = useSelector((state: RootState) => state.config.subtasks.map((i) => i.id).filter((i) => i !== undefined).join(','));
const subtaskIf = useSelector((state: RootState) => state.config.subtasks[index].if);
const dispatch = useDispatch();
return (
<tr>
<td style={{ textAlign: 'center' }}>if</td>
<td colSpan={4}>
<CustomSelectAutoComplete
data={ids.split(',').filter((i) => i.length)}
selectedKeys={subtaskIf?.map(String) || []}
onChange={(val) => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'if', value: val.split(','),
})}
multi
/>
</td>
</tr>
);
}
export function SubtasksTable({ index }) {
const keys = ['id', 'type', 'score', 'time', 'memory'];
const subtask = useSelector((state: RootState) => pick(state.config.subtasks[index], keys), eq);
const defaultTime = useSelector((state: RootState) => state.config.time);
const defaultMemory = useSelector((state: RootState) => state.config.memory);
const dispatch = useDispatch();
const dispatcher = (key: string, suffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
let value = typeof ev !== 'object' ? ev : ev.currentTarget?.value;
if (value === 0) value = '';
if (value && suffix) value += suffix;
dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key, value,
});
};
return (
<Card style={{ padding: 0 }}>
<span>Subtasks #{index + 1} </span>
<a onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'add' })}><span className="icon icon-add"></span></a>
<a
style={index === 0 ? { display: 'none' } : {}}
onClick={() => dispatch({ type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'delete' })}
>
<span className="icon icon-delete"></span>
</a>
<table className="data-table">
<thead>
<tr>
<th>#</th>
<th>Score</th>
<th>Type</th>
<th>Time</th>
<th>Memory</th>
</tr>
</thead>
<tbody>
<tr>
{['id', 'score'].map((key) => (
<td key={key}>
<input
value={subtask[key] || ''}
onChange={dispatcher(key)}
type="number"
className="textbox"
/>
</td>
))}
<td>
<select
value={subtask.type || ''}
onChange={dispatcher('type')}
className="select"
>
{['min', 'max', 'sum'].map((i) => (<option value={i} key={i}>{i}</option>))}
</select>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={subtask.time ? parseTimeMS(subtask.time).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms').toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={subtask.memory ? parseMemoryMB(subtask.memory).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m').toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</td>
</tr>
<SubtasksIds index={index} />
</tbody>
</table>
<CasesTable index={index} />
</Card>
);
}
export function TaskConfig() {
const len = useSelector((state: RootState) => state.config.subtasks?.length);
return (
<FormItem columns={12} label="Task Settings">
<div className="row">
<FormItem columns={4} label="Time">
<ManagedInput placeholder="Time" formKey="time" />
</FormItem>
<FormItem columns={4} label="Memory">
<ManagedInput placeholder="Memory" formKey="memory" />
</FormItem>
<FormItem columns={4} label="Score">
<ManagedInput placeholder="Score" formKey="score" />
</FormItem>
<FormItem columns={12} label="TestCases" disableLabel>
{len && [...Array(len).keys()].map((i) => <SubtasksTable index={i} key={i} />)}
</FormItem>
</div>
</FormItem>
);
}
@@ -0,0 +1,123 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import i18n from 'vj/utils/i18n';
import { isEqual } from 'lodash';
import { Tag, NumericInput } from '@blueprintjs/core';
import { parseTimeMS, parseMemoryMB } from '@hydrooj/utils/lib/common';
import type { SubtaskConfig } from 'hydrooj/src/interface';
import type { RootState } from './reducer/index';
import FileSelectAutoComplete from '../autocomplete/components/FileSelectAutoComplete';
const eq = (a: SubtaskConfig, b: SubtaskConfig) => isEqual(a, b);
const eqArr = (a: any[], b: any[]) => isEqual(a, b);
export function TestCaseEntry({ index, subindex }) {
const testcase = useSelector((state: RootState) => state.config.subtasks[index].cases[subindex], eq);
const Files = useSelector((state: RootState) => state.testdata, eqArr);
const defaultTime = useSelector((state: RootState) => state.config.subtasks[index].time || state.config.time);
const defaultMemory = useSelector((state: RootState) => state.config.subtasks[index].memory || state.config.memory);
const dispatch = useDispatch();
const dispatcher = (casesKey: string, valueSuffix = '') => (ev: React.ChangeEvent<HTMLInputElement | HTMLSelectElement> | number) => {
let value = typeof ev !== 'object' ? ev : ev.currentTarget.value;
if (value === 0) value = '';
if (valueSuffix && value) value += valueSuffix;
dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-edit',
casesId: subindex,
casesKey,
value,
});
};
return (
<tr>
<td>
<NumericInput
rightElement={<Tag minimal>ms</Tag>}
value={testcase.time ? parseTimeMS(testcase.time).toString() : ''}
placeholder={parseTimeMS(defaultTime || '1000ms').toString()}
onValueChange={dispatcher('time', 'ms')}
buttonPosition="none"
fill
/>
</td>
<td>
<NumericInput
rightElement={<Tag minimal>MB</Tag>}
value={testcase.memory ? parseMemoryMB(testcase.memory).toString() : ''}
placeholder={parseMemoryMB(defaultMemory || '256m').toString()}
onValueChange={dispatcher('memory', 'MB')}
buttonPosition="none"
fill
/>
</td>
{['input', 'output'].map((t) => (
<td key={t}>
<FileSelectAutoComplete
width="100%"
data={Files}
selectedKeys={[testcase[t]]}
onChange={dispatcher(t)}
/>
</td>
))}
<td className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE', id: index, key: 'cases-delete', value: subindex,
})}
><span className="icon icon-close"></span>
</a>
</td>
</tr>
);
}
export function CasesTable({ index }) {
const len = useSelector((state: RootState) => state.config.subtasks[index].cases?.length);
const dispatch = useDispatch();
return (
<table className="data-table">
<thead style={{ display: 'none' }}>
<tr>
<th>{i18n('Time')}</th>
<th>{i18n('Memory')}</th>
<th>{i18n('Input')}</th>
<th>{i18n('Output')}</th>
<th className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</th>
</tr>
</thead>
<tbody>
<tr className="thead">
<td>{i18n('Time')}</td>
<td>{i18n('Memory')}</td>
<td>{i18n('Input')}</td>
<td>{i18n('Output')}</td>
<td className="col--operation">
<a
onClick={() => dispatch({
type: 'CONFIG_SUBTASK_UPDATE',
id: index,
key: 'cases-add',
value: { input: '', output: '' },
})}
><span className="icon icon-add" />
</a>
</td>
</tr>
{[...Array(len).keys()].map((i) => <TestCaseEntry index={index} subindex={i} key={i} />)}
</tbody>
</table>
);
}
@@ -2,84 +2,69 @@ import yaml from 'js-yaml';
import type { ProblemConfigFile } from 'hydrooj/src/interface';
import { cloneDeep } from 'lodash';
export default function reducer(state = { type: 'default' } as ProblemConfigFile, action): ProblemConfigFile {
type State = ProblemConfigFile & { __loaded: boolean };
export default function reducer(state = { type: 'default', __loaded: false } as State, action): State {
switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': {
// TODO set yaml schema
return { ...state, ...yaml.load(action.payload.config) as object };
}
case 'CONFIG_FORM_UPDATE': {
const next = { ...state, [action.key]: action.value };
if (action.key === 'score' && action.value) next.score = +next.score;
if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
return next;
}
case 'CONFIG_CODE_UPDATE': {
try {
return { ...state, ...yaml.load(action.payload) as object };
} catch {
return state;
case 'CONFIG_LOAD_FULFILLED': {
return { ...state, ...yaml.load(action.payload.config) as object, __loaded: true };
}
}
case 'CONFIG_AUTOCASES_UPDATE': {
const next = { ...state };
const autocases = action.value;
if (autocases.subtasks.length === 0) next.cases = [];
else if (autocases.subtasks.length === 1) {
next.score = autocases.subtasks[0].score;
next.cases = [];
autocases.subtasks[0].cases.map((i) => next.cases.push({
time: autocases.subtasks[0].time, memory: autocases.subtasks[0].memory, input: i.input, output: i.output,
}));
} else {
next.subtasks = autocases.subtasks.map((subtask) => (
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
case 'CONFIG_FORM_UPDATE': {
const next = { ...state, [action.key]: action.value };
if (action.key === 'score' && action.value) next.score = +next.score;
if (action.key === 'checker_type' && action.value === 'other') next.checker_type = 'syzoj';
if (!action.value || (typeof action.value === 'object' && !action.value.join(''))) delete next[action.key];
return next;
}
return next;
}
case 'CONFIG_SUBTASKS_SWITCH': {
const next = { ...state };
next.subtasks = next.cases.map((k, v) => ({
id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }],
}));
if (!next.subtasks.length) next.subtasks.push({ id: 0 });
delete next.cases;
return next;
}
case 'CONFIG_CASES_UPDATE': {
if (['time', 'memory'].includes(action.casesKey) && action.value !== '') action.value = +action.value;
if (action.key === 'cases-add') return { ...state, cases: [...state.cases, action.value] };
if (action.key === 'cases-delete') {
return { ...state, cases: state.cases.filter((k, v) => v !== action.value) };
}
const cases = cloneDeep(state.cases);
cases[action.casesId][action.casesKey] = action.value;
if (action.value === '') delete cases[action.casesId][action.casesKey];
return { ...state, cases };
}
case 'CONFIG_SUBTASK_UPDATE': {
const subtasks = cloneDeep(state.subtasks);
const subsubtasks = cloneDeep(state.subtasks[action.id]);
if (action.value !== ''
&& (['time', 'memory'].includes(action.casesKey) || ['time', 'memory', '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);
else if (action.key === 'cases-edit') subsubtasks.cases[action.casesId][action.casesKey] = action.value;
else if (action.key === 'cases-delete') {
subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
case 'CONFIG_CODE_UPDATE': {
try {
return { ...state, ...yaml.load(action.payload) as object };
} catch {
return state;
}
} else if (action.key === 'add') subtasks.push({ time: 1000, memory: 256, cases: [] });
else if (action.key === 'delete') delete subtasks[action.key];
else {
if (action.value === '' || (action.key === 'if' && action.value.join('') === '')) delete subsubtasks[action.key];
else subsubtasks[action.key] = action.value;
}
if (action.key !== 'delete') subtasks[action.id] = subsubtasks;
return { ...state, subtasks };
}
default:
return state;
case 'CONFIG_AUTOCASES_UPDATE': {
const next = { ...state };
const autocases = action.value;
if (autocases.subtasks.length === 0) next.subtasks = [];
else {
next.subtasks = autocases.subtasks.map((subtask) => (
{ ...subtask, ...{ cases: subtask.cases.map((i) => ({ input: i.input, output: i.output })) } }));
}
return next;
}
case 'CONFIG_SUBTASKS_SWITCH': {
const next = { ...state };
next.subtasks = next.cases.map((k, v) => ({
id: v, time: k.time, memory: k.memory, cases: [{ input: k.input, output: k.output }],
}));
if (!next.subtasks.length) next.subtasks.push({ id: 0 });
delete next.cases;
return next;
}
case 'CONFIG_SUBTASK_UPDATE': {
const subtasks = cloneDeep(state.subtasks);
const subsubtasks = cloneDeep(state.subtasks[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);
else if (action.key === 'cases-edit') {
if (action.value === '') delete subsubtasks.cases[action.casesId][action.casesKey];
else subsubtasks.cases[action.casesId][action.casesKey] = action.value;
} else if (action.key === 'cases-delete') {
subsubtasks.cases = subsubtasks.cases.filter((k, v) => v !== action.value);
}
} else if (action.key === 'add') subtasks.push({ cases: [] });
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;
}
subtasks[action.id] = subsubtasks;
return { ...state, subtasks };
}
default:
return state;
}
}
@@ -1,16 +1,16 @@
export default function reducer(state = [], action) {
switch (action.type) {
case 'CONFIG_LOAD_FULFILLED': {
return state.concat(action.payload.testdata);
}
case 'CONFIG_DELETE_TESTDATA': {
const next = state.filter((i) => i.name !== action.value[0]);
return next;
}
case 'CONFIG_ADD_TESTDATA': {
return state.concat([action.value]);
}
default:
return state;
case 'CONFIG_LOAD_FULFILLED': {
return state.concat(action.payload.testdata);
}
case 'CONFIG_DELETE_TESTDATA': {
const next = state.filter((i) => i.name !== action.value[0]);
return next;
}
case 'CONFIG_ADD_TESTDATA': {
return state.concat([action.value]);
}
default:
return state;
}
}