ui: drop blueprintjs 6 (#1073)

This commit is contained in:
undefined authored and GitHub committed 2025-11-04 09:08:20 +00:00
1 parent 40114323e4
commit 3996f6f2ef
27 files changed
+764 -652

No files matched your search

@@ -1,6 +1,4 @@
import {
Card, InputGroup, Tag,
} from '@blueprintjs/core';
import { Card, Text, TextInput } from '@mantine/core';
import { isEqual } from 'lodash';
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -16,16 +14,18 @@ function FileIOConfig() {
const dispatch = useDispatch();
return (
<FormItem columns={12} label="FileIOConfig" disableLabel>
<Card style={{ padding: 10 }}>
<Card withBorder style={{ padding: 10, overflow: 'visible' }}>
<div className="row">
<FormItem columns={6} label="FileIO">
<InputGroup
rightElement={<Tag minimal>.in/.out</Tag>}
<TextInput
rightSection={<Text size="sm">.in/.out</Text>}
rightSectionWidth={100}
rightSectionPointerEvents="none"
value={filename || ''}
onChange={(ev) => {
dispatch({ type: 'problemconfig/updateFileIO', filename: ev.currentTarget.value });
}}
fill
style={{ width: '100%' }}
/>
</FormItem>
</div>
@@ -41,7 +41,7 @@ function ExtraFilesConfig() {
const dispatch = useDispatch();
return (
<FormItem columns={12} label="ExtraFilesConfig" disableLabel>
<Card style={{ padding: 10 }}>
<Card withBorder style={{ padding: 10, overflow: 'visible' }}>
<div className="row">
<FormItem columns={12} label={i18n('user_extra_files')}>
<FileSelectAutoComplete
@@ -70,7 +70,7 @@ function LangConfig() {
const dispatch = useDispatch();
return (
<FormItem columns={12} label="langs" disableLabel>
<Card style={{ padding: 10 }}>
<Card withBorder style={{ padding: 10, overflow: 'visible' }}>
<div className="row">
<FormItem columns={12} label="langs">
<LanguageSelectAutoComplete
@@ -1,13 +1,12 @@
import { normalizeSubtasks, readSubtasksFromFiles } from '@hydrooj/common';
import { TestCaseConfig } from 'hydrooj';
import {
Button, Classes, Icon, Popover, TreeNode,
} from '@blueprintjs/core';
import { Button, Text, Tree } from '@mantine/core';
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 { confirm } from 'vj/components/dialog/index';
import { i18n } from 'vj/utils';
import { RootState } from './reducer';
import { AddTestcase } from './tree/AddTestcase';
@@ -47,14 +46,6 @@ export function SubtaskNode(props: { subtaskId: number }) {
}));
function deleteSubtask() {
dispatch({
type: 'problemconfig/moveTestcases',
payload: {
target: -1,
source: subtaskId,
cases,
},
});
dispatch({
type: 'problemconfig/deleteSubtask',
id: subtaskId,
@@ -62,47 +53,47 @@ export function SubtaskNode(props: { subtaskId: number }) {
}
return (
<li className={`${Classes.TREE_NODE} ${Classes.TREE_NODE_EXPANDED}`}>
{subtaskId !== -1 && <div className={Classes.TREE_NODE_CONTENT} onClick={() => setExpand((e) => !e)}>
<Icon icon={expand ? 'folder-open' : 'folder-close'} />&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Subtask {0}', subtaskId)}</span>
<span className={Classes.TREE_NODE_SECONDARY_LABEL} onClick={(ev) => ev.stopPropagation()}>
<Popover
content={<div style={{ padding: 20 }}>
<b>{i18n('Are you sure you want to delete this subtask?')}</b>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 15 }}>
<Button intent="danger" onClick={deleteSubtask}>{i18n('Delete')}</Button>
</div>
</div>}
<div style={{ marginBottom: 8 }}>
{subtaskId !== -1 && (
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} onClick={() => setExpand((e) => !e)}>
<Text fw={600} style={{ marginRight: 8 }}>
{expand ? <i className="icon icon-expand_more" /> : <i className="icon icon-expand_less" />}
</Text>
<Text>{i18n('Subtask {0}', subtaskId)}</Text>
<Button
variant="subtle"
size="sm"
color="black"
style={{ marginLeft: 'auto' }}
onClick={(ev) => {
ev.stopPropagation();
confirm(i18n('Are you sure you want to delete this subtask?')).then((yes) => {
if (yes) deleteSubtask();
});
}}
>
<Icon icon="trash" />
</Popover>
</span>
</div>}
<ul className={Classes.TREE_NODE_LIST} ref={drop}>
{subtaskId !== -1 && <SubtaskSettings subtaskId={subtaskId} subtaskIds={subtaskIds} time={time} memory={memory} />}
<i className="icon icon-delete" />
</Button>
</div>
)}
<div ref={drop}>
{subtaskId !== -1 && expand && (
<SubtaskSettings subtaskId={subtaskId} subtaskIds={subtaskIds} time={time} memory={memory} />
)}
{expand
? <SelectionManager subtaskId={subtaskId} subtaskIds={subtaskIds} />
: <TreeNode
depth={0}
id={`s${subtaskId}`}
onClick={() => setExpand(false)}
icon="layers"
label={<>&nbsp;{cases.length} testcases.</>}
path={[0]}
/>}
: <div style={{ paddingLeft: 22 }}>
<Text>{cases.length} testcases.</Text>
</div>}
{!cases.length && (
<li className={Classes.TREE_NODE}>
<div className={Classes.TREE_NODE_CONTENT}>
<span className={`${Classes.TREE_NODE_CARET_NONE} ${Classes.ICON_STANDARD}`}></span>
<span className={`${Classes.TREE_NODE_LABEL} text-gray`}>{subtaskId === -1
? i18n('No testcase here')
: i18n('Drag and drop testcases here:')}</span>
</div>
</li>
<div style={{ paddingLeft: 22 }}>
<Text c="dimmed">{subtaskId === -1
? i18n('No testcase here')
: i18n('Drag and drop testcases here:')}</Text>
</div>
)}
</ul>
</li>
</div>
</div>
);
}
@@ -110,7 +101,7 @@ 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 autoConfigure = React.useCallback(() => {
const state = store.getState();
const subtasks = readSubtasksFromFiles(state.testdata.map((t) => t.name), state.config);
const cases = subtasks.reduce((a, b) => a.concat(b.cases), []);
@@ -122,29 +113,34 @@ export function SubtaskConfigTree() {
type: 'problemconfig/delTestcases',
cases,
});
}
}, [dispatch, store]);
const rootNodes = React.useMemo<any[]>(() => [
{ id: 'auto', label: i18n('Auto configure'), type: 'action' as const },
{ id: 'global', label: i18n('Global settings'), type: 'global' as const },
...ids.map((id) => ({ id: `sub-${id}`, label: i18n('Subtask {0}', id), type: 'subtask' as const, subtaskId: id })),
{ id: 'add', label: i18n('Add new subtask'), type: 'add' as const },
], [ids]);
return (
<div className={Classes.TREE}>
<ul className={`${Classes.TREE_NODE_LIST} ${Classes.TREE_ROOT}`}>
<li className={Classes.TREE_NODE} onClick={autoConfigure}>
<div className={`${Classes.TREE_NODE_CONTENT} ${Classes.TREE_NODE_CONTENT}-0`}>
<Icon icon="clean" />&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Auto configure')}</span>
</div>
</li>
<GlobalSettings />
{ids.map((id) => <SubtaskNode key={id} subtaskId={id} />)}
<li
className={Classes.TREE_NODE}
onClick={() => dispatch({ type: 'problemconfig/addSubtask' })}
>
<div className={`${Classes.TREE_NODE_CONTENT} ${Classes.TREE_NODE_CONTENT}-0`}>
<Icon icon="folder-new" />&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Add new subtask')}</span>
</div>
</li>
</ul>
</div>
<Tree
data={rootNodes}
levelOffset={12}
renderNode={({ node }) => {
const n: any = node;
if (n.type === 'action') {
return <div style={{ padding: '6px 0' }} onClick={autoConfigure}>
<Text><i className="icon icon-settings" /> {i18n('Auto configure')}</Text>
</div>;
}
if (n.type === 'global') return <GlobalSettings />;
if (n.type === 'add') {
return <div style={{ padding: '6px 0' }} onClick={() => dispatch({ type: 'problemconfig/addSubtask' })}>
<Text><i className="icon icon-add" /> {i18n('Add new subtask')}</Text>
</div>;
}
return <SubtaskNode subtaskId={n.subtaskId} />;
}}
/>
);
}
@@ -156,11 +152,9 @@ export function ProblemConfigTree() {
<SubtaskConfigTree />
</div>
<div className="medium-6 columns">
<div className={Classes.TREE}>
<ul className={`${Classes.TREE_ROOT} ${Classes.TREE_NODE_LIST}`}>
<AddTestcase />
<SubtaskNode subtaskId={-1} />
</ul>
<div>
<AddTestcase />
<SubtaskNode subtaskId={-1} />
</div>
</div>
</div>
@@ -1,6 +1,4 @@
import {
Card, Classes, Switch, Tab, Tabs, TabsExpander,
} from '@blueprintjs/core';
import { Card, SegmentedControl, Switch } from '@mantine/core';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { i18n } from 'vj/utils';
@@ -26,158 +24,141 @@ export default function ProblemType() {
}, [checker]);
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={Classes.TAB}>{i18n('Problem Type')}</span>
<TabsExpander />
<Tab
id="default"
title={i18n('problem_type.default')}
panel={(
<Tabs
id="CheckerTypeTabs"
selectedTabId={
['strict', 'default'].includes(checkerType) || !checkerType
? 'default' : (checkerType !== 'testlib' ? 'other' : 'testlib')
}
<Card withBorder style={{ padding: 10, overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 3 }}>
<div>{i18n('Problem Type')}</div>
<SegmentedControl
value={Type}
onChange={dispatcher({ type: 'CONFIG_FORM_UPDATE', key: 'type' })}
data={[
{ label: i18n('problem_type.default'), value: 'default' },
{ label: i18n('problem_type.interactive'), value: 'interactive' },
{ label: i18n('problem_type.communication'), value: 'communication' },
{ label: i18n('problem_type.submit_answer'), value: 'submit_answer' },
{ label: i18n('problem_type.objective'), value: 'objective' },
]}
/>
</div>
{Type === 'default' && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 3 }}>
<div>{i18n('CheckerType')}</div>
<SegmentedControl
value={(['strict', 'default'].includes(checkerType) || !checkerType)
? 'default' : (checkerType !== 'testlib' ? 'other' : 'testlib')}
onChange={(value) => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value });
if (value === 'testlib' && !category) setCategory('custom');
}}
renderActiveTabPanelOnly
>
<span className={Classes.TAB}>{i18n('CheckerType')}</span>
<TabsExpander />
<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>
)}
/>
<Tab
id="testlib"
title="testlib"
panel={(
<div className="row">
<FormItem columns={4} label="Type">
<select
value={category}
onChange={(ev) => {
setCategory(ev.currentTarget.value);
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker', value: ev.currentTarget.value === 'preset' ? 'acmp' : null });
}}
className="select"
>
<option value="preset">{i18n('Preset')}</option>
<option value="custom">{i18n('Custom')}</option>
</select>
</FormItem>
{category === 'preset'
? <FormItem columns={8} label="Checker">
<select
value={typeof checker === 'string' ? checker : checker.file}
onChange={(ev) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker', value: ev.currentTarget.value })}
className="select"
>
{testlibCheckers.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</FormItem>
: <SingleFileSelect formKey="checker" label="Checker" withLang />}
</div>
)}
/>
<Tab
id="other"
title="other"
panel={(
<div className="row">
<FormItem columns={4} label="Interface">
<ManagedSelect options={['syzoj', 'hustoj', 'qduoj', 'lemon', 'kattis']} formKey="checker_type" />
</FormItem>
<SingleFileSelect formKey="checker" label="Checker" withLang />
</div>
)}
/>
</Tabs>
data={[
{ label: i18n('default'), value: 'default' },
{ label: 'testlib', value: 'testlib' },
{ label: 'other', value: 'other' },
]}
/>
</div>
{((['strict', 'default'].includes(checkerType) || !checkerType)) && (
<Switch
styles={{ body: { display: 'flex' } }}
checked={checkerType !== 'strict'}
label={i18n('Ignore trailing space and enter.')}
onChange={() => {
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker_type', value: checkerType === 'strict' ? 'default' : 'strict' });
}}
/>
)}
/>
<Tab
id="interactive"
title={i18n('problem_type.interactive')}
panel={(
{(!['strict', 'default'].includes(checkerType) && checkerType && checkerType === 'testlib') && (
<div className="row">
<SingleFileSelect formKey="interactor" label="Interactor" withLang />
</div>
)}
/>
<Tab
id="communication"
title={i18n('problem_type.communication')}
panel={(
<div className="row">
<SingleFileSelect formKey="manager" label="Manager" withLang />
<FormItem columns={4} label="Number of Processes">
<input
defaultValue={numProcesses || 2}
placeholder="2"
onChange={(ev) => dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'num_processes', value: +ev.currentTarget.value }))}
className="textbox"
/>
</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 columns={4} label="Type">
<select
value={category}
onChange={(ev) => {
setCategory(ev.currentTarget.value);
dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker', value: ev.currentTarget.value === 'preset' ? 'acmp' : null });
}}
/>
</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"
/>
className="select"
>
<option value="preset">{i18n('Preset')}</option>
<option value="custom">{i18n('Custom')}</option>
</select>
</FormItem>
{category === 'preset'
? <FormItem columns={8} label="Checker">
<select
value={typeof checker === 'string' ? checker : checker.file}
onChange={(ev) => dispatch({ type: 'CONFIG_FORM_UPDATE', key: 'checker', value: ev.currentTarget.value })}
className="select"
>
{testlibCheckers.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</FormItem>
: <SingleFileSelect formKey="checker" label="Checker" withLang />}
</div>
)}
/>
<Tab
id="objective"
title={i18n('problem_type.objective')}
panel={(<p>{i18n('Unsupported configure this type of problem. Please refer to the documentation.')}</p>)}
/>
</Tabs>
{(!['strict', 'default'].includes(checkerType) && checkerType && checkerType !== 'testlib') && (
<div className="row">
<FormItem columns={4} label="Interface">
<ManagedSelect options={['syzoj', 'hustoj', 'qduoj', 'lemon', 'kattis']} formKey="checker_type" />
</FormItem>
<SingleFileSelect formKey="checker" label="Checker" withLang />
</div>
)}
</>
)}
{Type === 'interactive' && (
<div className="row">
<SingleFileSelect formKey="interactor" label="Interactor" withLang />
</div>
)}
{Type === 'communication' && (
<div className="row">
<SingleFileSelect formKey="manager" label="Manager" withLang />
<FormItem columns={4} label="Number of Processes">
<input
defaultValue={numProcesses || 2}
placeholder="2"
onChange={(ev) => dispatch(({ type: 'CONFIG_FORM_UPDATE', key: 'num_processes', value: +ev.currentTarget.value }))}
className="textbox"
/>
</FormItem>
</div>
)}
{Type === 'submit_answer' && (
<div className="row">
<FormItem columns={6} label="Config" disableLabel>
<Switch
checked={subType === 'multi'}
styles={{ body: { display: 'flex' } }}
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>
)}
{Type === 'objective' && (
<p>{i18n('Unsupported configure this type of problem. Please refer to the documentation.')}</p>
)}
</Card>
</FormItem>
);
@@ -1,8 +1,9 @@
import {
Button, Dialog, DialogBody, DialogFooter, Tab, Tabs,
} from '@blueprintjs/core';
import { Button, Tabs } from '@mantine/core';
import { Allotment } from 'allotment';
import { ContextMenuProvider } from 'mantine-contextmenu';
import React from 'react';
import { useSelector } from 'react-redux';
import { useSelector, useStore } from 'react-redux';
import { confirm } from 'vj/components/dialog/index';
import { i18n } from 'vj/utils';
import ProblemConfigEditor from './ProblemConfigEditor';
import ProblemConfigForm from './ProblemConfigForm';
@@ -15,40 +16,74 @@ interface Props {
export default function ProblemConfig(props: Props) {
const [selected, setSelected] = React.useState('basic');
const [open, setOpen] = React.useState(false);
const [container, setContainer] = React.useState<HTMLDivElement | null>(null);
const [tabs, setTabs] = React.useState<HTMLDivElement | null>(null);
const [editor, setEditor] = React.useState<any>(null);
const store = useStore<RootState>();
const valid = useSelector((state: RootState) => state.config.__valid);
const errors = useSelector((state: RootState) => state.config.__errors, (a, b) => JSON.stringify(a) === JSON.stringify(b));
React.useEffect(() => {
if (valid) setOpen(false);
}, [valid]);
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={i18n('Basic')} panel={<ProblemConfigForm />} />
<Tab id="subtasks" disabled={!valid} title={i18n('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>
const handleSave = React.useCallback(async () => {
if (valid) props.onSave();
else if (await confirm(i18n('Errors detected in the config. Confirm save?'))) props.onSave();
}, [valid, props.onSave]);
React.useEffect(() => {
if (!container) return () => { };
container.style.height ||= '600px';
const callback = () => {
setTimeout(() => {
const target = Math.max(container.clientHeight, tabs?.clientHeight || 0);
container.style.height = `${target}px`;
if (editor) editor.layout();
}, 50);
};
const dispose = store.subscribe(callback);
window.addEventListener('resize', callback);
return () => {
dispose();
window.removeEventListener('resize', callback);
};
}, [container, tabs, store]);
return <ContextMenuProvider>
<div ref={setContainer}>
<Allotment defaultSizes={[2, 3]}>
<Allotment.Pane>
<div style={{ height: '100%', overflow: 'auto' }}>
<ProblemConfigEditor ref={setEditor} />
</div>
</Allotment.Pane>
<Allotment.Pane>
<Tabs
ref={setTabs}
value={valid ? selected : 'errors'}
keepMounted={false}
style={{ paddingLeft: 20 }}
onChange={(t) => (t !== 'errors' && t && setSelected(t.toString()))}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tabs.List>
<Tabs.Tab value="basic" disabled={!valid}>{i18n('Basic')}</Tabs.Tab>
<Tabs.Tab value="subtasks" disabled={!valid}>{i18n('Subtasks')}</Tabs.Tab>
<Tabs.Tab value="errors" disabled={valid}>{errors.length ? `Errors(${errors.length})` : 'No Errors'}</Tabs.Tab>
</Tabs.List>
<Button onClick={handleSave}>
{i18n('Save')}
</Button>
</div>
<Tabs.Panel value="basic">
<ProblemConfigForm />
</Tabs.Panel>
<Tabs.Panel value="subtasks">
<ProblemConfigTree />
</Tabs.Panel>
<Tabs.Panel value="errors">
<div>{errors.map((i) => (<pre key={i}>{i}</pre>))}</div>
</Tabs.Panel>
</Tabs>
</Allotment.Pane>
</Allotment>
</div>
<Dialog isOpen={open && !valid} onClose={() => setOpen(false)}>
<DialogBody>
<p>{i18n('Errors detected in the config. Confirm save?')}</p>
</DialogBody>
<DialogFooter actions={<>
<Button onClick={() => setOpen(false)}>{i18n('Cancel')}</Button>
<Button intent="warning" onClick={props.onSave}>{i18n('Save')}</Button>
</>} />
</Dialog>
<button className="rounded primary button" onClick={valid ? props.onSave : () => setOpen(true)}>{i18n('Save')}</button>
</>);
</ContextMenuProvider>;
}
@@ -138,17 +138,21 @@ export default function reducer(state = {
case 'problemconfig/moveTestcases': {
const testcases = action.payload.cases;
const subtasks = cloneDeep(state.subtasks);
const targetCases = action.payload.target === -1
? state.__cases
: (subtasks.find((s) => s.id === action.payload.target)?.cases || []);
const toAdd = testcases.filter((tc) => !targetCases.find((j) => j.input === tc.input && j.output === tc.output));
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')
? sortFiles([...state.__cases, ...toAdd], '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');
subtask.cases = sortFiles([...subtask.cases, ...toAdd], 'input');
}
}
return { ...state, subtasks, __cases };
@@ -158,8 +162,10 @@ export default function reducer(state = {
return { ...state, __cases: state.__cases.filter((i) => !testcases.find((j) => i.input === j.input && i.output === j.output)) };
}
case 'problemconfig/deleteSubtask': {
const currentCases = state.subtasks.find((i) => i.id === action.id)?.cases || [];
const subtasks = state.subtasks.filter((i) => i.id !== action.id);
return { ...state, subtasks };
const add = currentCases.filter((i) => !subtasks.find((j) => j.cases.find((k) => k.input === i.input && k.output === i.output)));
return { ...state, subtasks, __cases: sortFiles([...state.__cases, ...add], 'input') };
}
default:
return state;
@@ -1,7 +1,5 @@
import { readSubtasksFromFiles } from '@hydrooj/common';
import {
Button, Classes, Dialog, DialogBody, DialogFooter, Icon,
} from '@blueprintjs/core';
import { Button, Modal, Text } from '@mantine/core';
import React, { useEffect, useRef } from 'react';
import { useSelector, useStore } from 'react-redux';
import { i18n } from 'vj/utils';
@@ -83,14 +81,14 @@ export function AddTestcase() {
}
return (<>
<li className={Classes.TREE_NODE} onClick={auto}>
<div className={`${Classes.TREE_NODE_CONTENT} ${Classes.TREE_NODE_CONTENT}-0`}>
<Icon icon="clean" />&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Auto detect')}</span>
</div>
</li>
<li
className={Classes.TREE_NODE}
<div
style={{ cursor: 'pointer', padding: '6px 0' }}
onClick={auto}
>
<Text><i className="icon icon-wrench" /> {i18n('Auto detect')}</Text>
</div>
<div
style={{ cursor: 'pointer', padding: '6px 0' }}
onClick={() => {
setInput('');
setOutput('');
@@ -99,47 +97,41 @@ export function AddTestcase() {
setOpen(true);
}}
>
<div className={`${Classes.TREE_NODE_CONTENT} ${Classes.TREE_NODE_CONTENT}-0`}>
<Icon icon="clean" />&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Add testcase')}</span>
</div>
</li>
<Dialog title="Add testcase" icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<div className="row">
<div className="columns medium-6">
<FileSelectAutoComplete
ref={refInput}
data={testdata}
label="Input"
width="100%"
onChange={(e) => setInput(e)}
placeholder={i18n('Input')}
value={input || ''}
/>
</div>
<div className="columns medium-6">
<FileSelectAutoComplete
ref={refOutput}
data={testdata}
label="Output"
width="100%"
onChange={(e) => setOutput(e)}
placeholder={i18n('Output')}
value={input || ''}
/>
</div>
<Text><i className="icon icon-add" /> {i18n('Add testcase')}</Text>
</div>
<Modal
opened={open}
onClose={() => setOpen(false)}
title={i18n('Add testcase')}
styles={{ body: { overflow: 'visible' }, content: { overflow: 'visible' } }}
>
<div className="row" style={{ overflow: 'visible' }}>
<div className="columns medium-6" style={{ overflow: 'visible' }}>
<FileSelectAutoComplete
ref={refInput}
data={testdata}
label="Input"
width="100%"
onChange={(e) => setInput(e)}
placeholder={i18n('Input')}
value={input || ''}
/>
</div>
</DialogBody>
<DialogFooter
actions={<Button
className={`primary rounded button${valid ? '' : ' disabled'}`}
onClick={onConfirm}
disabled={!valid}
intent="primary"
text="Save"
/>}
/>
</Dialog>
<div className="columns medium-6" style={{ overflow: 'visible' }}>
<FileSelectAutoComplete
ref={refOutput}
data={testdata}
label="Output"
width="100%"
onChange={(e) => setOutput(e)}
placeholder={i18n('Output')}
value={output || ''}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Button onClick={onConfirm} disabled={!valid}> {i18n('Save')}</Button>
</div>
</Modal>
</>);
}
@@ -1,10 +1,6 @@
import { CustomSelectAutoComplete } from '@hydrooj/components';
import { parseMemoryMB, parseTimeMS } from '@hydrooj/utils/lib/common';
import {
Button, Classes, ControlGroup,
Dialog, DialogBody, DialogFooter,
Icon, InputGroup, Tag,
} from '@blueprintjs/core';
import { Button, Group, Modal, Text, TextInput } from '@mantine/core';
import { isEqual } from 'lodash';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -61,83 +57,71 @@ export function SubtaskSettings(props: SubtaskSettingsProps) {
}
return (<>
<Dialog title={i18n('Set time and memory limits')} icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<ControlGroup fill={true} vertical={false}>
<InputGroup
leftElement={<Icon icon="time" />}
rightElement={<Tag minimal>ms</Tag>}
onChange={(ev) => setTime(ev.currentTarget.value ? `${ev.currentTarget.value}ms` : '')}
placeholder={parseTimeMS(props.time, false).toString() || '1000'}
value={ctime ? parseTimeMS(ctime, false).toString() || '' : ''}
/>
<InputGroup
leftElement={<Icon icon="comparison" />}
rightElement={<Tag minimal>MB</Tag>}
onChange={(ev) => setMemory(ev.currentTarget.value ? `${ev.currentTarget.value}MB` : '')}
placeholder={parseMemoryMB(props.memory, false).toString() || '256'}
value={cmemory ? parseMemoryMB(cmemory, false).toString() || '' : ''}
/>
<InputGroup
leftElement={<Icon icon="star" />}
onChange={(ev) => setScore(+ev.target.value || 0)}
placeholder="Score"
type="number"
value={cscore.toString()}
/>
</ControlGroup>
</DialogBody>
<DialogFooter actions={<Button className="primary rounded button" onClick={onConfirm} intent="primary" text="Save" />} />
</Dialog>
<Dialog title={i18n('Set dependencies')} icon="cog" isOpen={depsOpen} onClose={() => setDepsOpen(false)}>
<DialogBody>
<CustomSelectAutoComplete
data={subtaskIds.map((i) => ({ _id: i, name: `${i18n('Subtask {0}', i)}` }))}
setSelectItems={cdeps.split(',').map((i) => i.trim()).filter((i) => +i).map((i) => +i)}
onChange={(items) => setDeps(items)}
placeholder="dependencies"
multi
<Modal opened={open} onClose={() => setOpen(false)} title={i18n('Set time and memory limits')}>
<Group grow>
<TextInput
leftSection={<i className="icon icon-stopwatch" />}
rightSection={<Text size="sm">ms</Text>}
rightSectionWidth={60}
onChange={(ev) => setTime(ev.currentTarget.value ? `${ev.currentTarget.value}ms` : '')}
placeholder={parseTimeMS(props.time, false).toString() || '1000'}
value={ctime ? parseTimeMS(ctime, false).toString() || '' : ''}
/>
</DialogBody>
<DialogFooter actions={<Button className="primary rounded button" onClick={onConfirm} intent="primary" text="Save" />} />
</Dialog>
<li className={Classes.TREE_NODE} onClick={() => setOpen(true)}>
<div className={Classes.TREE_NODE_CONTENT}>
<span className={`${Classes.TREE_NODE_CARET_NONE} ${Classes.ICON_STANDARD}`}></span>
<Icon icon="time" />
&nbsp;&nbsp;
<span className={`${Classes.TREE_NODE_LABEL}${time ? '' : ' text-gray'}`}>{time || props.time || '1s'}</span>
<Icon icon="comparison" />
&nbsp;&nbsp;
<span className={`${Classes.TREE_NODE_LABEL}${memory ? '' : ' text-gray'}`}>{memory || props.memory || '256m'}</span>
<Icon icon="star" />
{' '}
<span className={Classes.TREE_NODE_SECONDARY_LABEL}>{score || 0}</span>
<TextInput
leftSection={<i className="icon icon-comparison" />}
rightSection={<Text size="sm">MB</Text>}
rightSectionWidth={60}
onChange={(ev) => setMemory(ev.currentTarget.value ? `${ev.currentTarget.value}MB` : '')}
placeholder={parseMemoryMB(props.memory, false).toString() || '256'}
value={cmemory ? parseMemoryMB(cmemory, false).toString() || '' : ''}
/>
<TextInput
leftSection={<i className="icon icon-star" />}
onChange={(ev) => setScore(+ev.target.value || 0)}
placeholder="Score"
type="number"
value={cscore.toString()}
/>
</Group>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Button color="blue" onClick={onConfirm}>{i18n('Save')}</Button>
</div>
</li>
<li className={Classes.TREE_NODE} onClick={() => setDepsOpen(true)}>
<div className={Classes.TREE_NODE_CONTENT}>
<span className={`${Classes.TREE_NODE_CARET_NONE} ${Classes.ICON_STANDARD}`}></span>
<Icon icon="diagram-tree" />
&nbsp;&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Dependencies')}: {deps.length ? deps.join(', ') : i18n('(None)')}</span>
</Modal>
<Modal opened={depsOpen} onClose={() => setDepsOpen(false)} title={i18n('Set dependencies')}>
<CustomSelectAutoComplete
data={subtaskIds.map((i) => ({ _id: i, name: `${i18n('Subtask {0}', i)}` }))}
setSelectItems={cdeps.split(',').map((i) => i.trim()).filter((i) => +i).map((i) => +i)}
onChange={(items) => setDeps(items)}
placeholder="dependencies"
multi
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Button color="blue" onClick={onConfirm}>{i18n('Save')}</Button>
</div>
</li>
<li className={Classes.TREE_NODE}>
<div className={Classes.TREE_NODE_CONTENT}>
<span className={`${Classes.TREE_NODE_CARET_NONE} ${Classes.ICON_STANDARD}`}></span>
<Icon icon="asterisk" />
&nbsp;&nbsp;
<span className={Classes.TREE_NODE_LABEL}>{i18n('Scoring method')}</span>
<span className={Classes.TREE_NODE_SECONDARY_LABEL}>
<select className="compact select" value={ctype} onChange={(e) => setType(e.target.value)}>
<option value="min">Min</option>
<option value="max">Max</option>
<option value="sum">Sum</option>
</select>
</span>
</div>
</li>
</Modal>
<div style={{ paddingLeft: 22, display: 'flex', cursor: 'pointer', gap: 8 }} onClick={() => setOpen(true)}>
<Text><i className="icon icon-stopwatch" /></Text>
<Text className={time ? '' : 'text-gray'}>{time || props.time || '1s'}&nbsp;&nbsp;</Text>
<Text><i className="icon icon-comparison" /></Text>
<Text className={memory ? '' : 'text-gray'}>{memory || props.memory || '256m'}&nbsp;&nbsp;</Text>
<Text><i className="icon icon-star" /></Text>
<Text>{score || 0}</Text>
</div>
<div style={{ paddingLeft: 22, display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 8 }} onClick={() => setDepsOpen(true)}>
<Text><i className="icon icon-diagram-tree" /></Text>
<Text>{i18n('Dependencies')}: {deps.length ? deps.join(', ') : i18n('(None)')}</Text>
</div>
<div style={{ paddingLeft: 22, display: 'flex', alignItems: 'center', gap: 8 }}>
<i className="icon icon-asterisk" />
<Text>{i18n('Scoring method')}</Text>
<span style={{ marginLeft: 'auto' }}>
<select className="compact select" value={ctype} onChange={(e) => setType(e.target.value)}>
<option value="min">Min</option>
<option value="max">Max</option>
<option value="sum">Sum</option>
</select>
</span>
</div>
</>);
}
@@ -163,36 +147,34 @@ export function GlobalSettings() {
setOpen(false);
}
return (<>
<Dialog title={i18n('Set time and memory limits')} icon="cog" isOpen={open} onClose={() => setOpen(false)}>
<DialogBody>
<ControlGroup fill={true} vertical={false}>
<InputGroup
leftElement={<Icon icon="time" />}
rightElement={<Tag minimal>ms</Tag>}
onChange={(ev) => setTime(ev.currentTarget.value ? `${ev.currentTarget.value}ms` : '')}
placeholder="1000"
value={ctime ? parseTimeMS(ctime, false).toString() || '' : ''}
/>
<InputGroup
leftElement={<Icon icon="comparison" />}
rightElement={<Tag minimal>MB</Tag>}
onChange={(ev) => setMemory(ev.currentTarget.value ? `${ev.currentTarget.value}MB` : '')}
placeholder="256"
value={cmemory ? parseMemoryMB(cmemory, false).toString() || '' : ''}
/>
</ControlGroup>
</DialogBody>
<DialogFooter actions={<Button className="primary rounded button" onClick={onConfirm} intent="primary" text="Save" />} />
</Dialog>
<li className="bp6-tree-node" onClick={() => setOpen(true)}>
<div className="bp6-tree-node-content">
<Icon icon="time" />
&nbsp;&nbsp;
<span className={`bp6-tree-node-label${time ? '' : ' text-gray'}`}>{time || '1s'}</span>
<Icon icon="comparison" />
{' '}
<span className={`bp6-tree-node-secondary-label${memory ? '' : ' text-gray'}`}>{memory || '256MB'}</span>
<Modal opened={open} onClose={() => setOpen(false)} title={i18n('Set time and memory limits')}>
<Group grow>
<TextInput
leftSection={<i className="icon icon-stopwatch" />}
rightSection={<Text size="sm">ms</Text>}
rightSectionWidth={60}
onChange={(ev) => setTime(ev.currentTarget.value ? `${ev.currentTarget.value}ms` : '')}
placeholder="1000"
value={ctime ? parseTimeMS(ctime, false).toString() || '' : ''}
/>
<TextInput
leftSection={<i className="icon icon-comparison" />}
rightSection={<Text size="sm">MB</Text>}
rightSectionWidth={60}
onChange={(ev) => setMemory(ev.currentTarget.value ? `${ev.currentTarget.value}MB` : '')}
placeholder="256"
value={cmemory ? parseMemoryMB(cmemory, false).toString() || '' : ''}
/>
</Group>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Button color="blue" onClick={onConfirm}>{i18n('Save')}</Button>
</div>
</li>
</Modal>
<div style={{ paddingLeft: 22, display: 'flex', alignItems: 'center', cursor: 'pointer', gap: 8 }} onClick={() => setOpen(true)}>
<i className="icon icon-stopwatch" />
<Text className={time ? '' : 'text-gray'}>{time || '1s'}</Text>
<i className="icon icon-comparison" />
<Text className={memory ? '' : 'text-gray'}>{memory || '256MB'}</Text>
</div>
</>);
}
@@ -1,10 +1,10 @@
import { TestCaseConfig } from 'hydrooj';
import {
ContextMenu, Menu, MenuItem, TreeNode,
} from '@blueprintjs/core';
import { Text } from '@mantine/core';
import { omit } from 'lodash';
import { useContextMenu } from 'mantine-contextmenu';
import React from 'react';
import { useDrag } from 'react-dnd';
import { useDispatch } from 'react-redux';
import { i18n } from 'vj/utils';
interface TestcaseNodeProps {
@@ -20,37 +20,35 @@ interface TestcaseNodeProps {
export function TestcaseNode(props: TestcaseNodeProps) {
const {
c, selected, onClick, subtaskIds, subtaskId,
c, selected, onClick, subtaskId,
} = props;
let display = `${c.input} / ${c.output}`;
const minlength = Math.min(c.input.length, c.output.length);
for (let i = minlength; i >= 0; i--) {
const prefix = c.input.slice(0, i);
if (c.input.startsWith(prefix) && c.output.startsWith(prefix)) {
display = `${prefix}(${c.input.substring(i)}/${c.output.substring(i)})`;
break;
}
}
return (
<ContextMenu
onContextMenu={onClick}
<div
onClick={onClick}
data-subtaskid={subtaskId}
data-index={c.input}
data-selected={selected}
content={
<Menu>
<MenuItem icon="drawer-left" text={i18n('Move to subtask')}>
{subtaskIds.filter((i) => i !== subtaskId).map((i) => (
<MenuItem key={i} text={i18n('Subtask {0}', i)} />
))}
{subtaskIds.length <= 1 && (
<MenuItem icon="disable" disabled text={i18n('No target available')} />
)}
</MenuItem>
</Menu>
}
style={{
paddingLeft: 22,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: selected ? 'var(--mantine-color-blue-light, #ebf3ff)' : 'transparent',
borderRadius: 4,
fontFamily: 'var(--mantine-font-family-monospace)',
}}
>
<TreeNode
depth={0}
id={c.input}
isSelected={selected}
onClick={onClick}
icon="document"
label={<>&nbsp;{c.input} / {c.output}</>}
path={[0]}
/>
</ContextMenu>
<Text ml="xs"><i className="icon icon-file" /> {display}</Text>
</div>
);
}
@@ -61,8 +59,11 @@ interface TestcaseGroupProps extends Omit<TestcaseNodeProps, 'c'> {
export function TestcaseGroup(props: TestcaseGroupProps) {
const {
cases, subtaskId, onClick, index,
cases, subtaskId, subtaskIds, onClick, index,
} = props;
const dispatch = useDispatch();
const { showContextMenu } = useContextMenu();
const moveTargets = subtaskIds.filter((i) => i !== subtaskId);
const [collected, drag] = useDrag(() => ({
type: 'cases',
collect: (monitor) => ({
@@ -71,7 +72,25 @@ export function TestcaseGroup(props: TestcaseGroupProps) {
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 }}>
return <div
ref={drag}
onClick={onClick}
onMouseDown={props.onMouseDown}
onContextMenu={showContextMenu(
moveTargets.length
? moveTargets.map((id) => ({
key: `move-${id}`,
icon: <i className="icon icon-send" />,
title: `${i18n('Move to')} ${i18n('Subtask {0}', id)}`,
onClick: () => dispatch({
type: 'problemconfig/moveTestcases',
payload: { target: id, source: subtaskId, cases },
}),
}))
: [{ key: 'no-target', title: i18n('No target available'), onClick: () => { }, disabled: true }],
)}
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} />
))}