From d4271b3fbd8f41464deb4666e1c17cd468273f91 Mon Sep 17 00:00:00 2001 From: undefined Date: Sat, 19 Jul 2025 11:27:37 +0100 Subject: [PATCH] ui: add alert/prompt/confirm helper function (#1018) --- .../ui-default/components/dialog/index.tsx | 92 +++++++++++++++++++ .../components/problem/create.page.js | 8 +- packages/ui-default/components/selectUser.tsx | 55 +++-------- .../ui-default/pages/contest_edit.page.ts | 15 ++- packages/ui-default/pages/domain_user.page.js | 12 +-- .../ui-default/pages/problem_config.page.tsx | 14 ++- .../ui-default/pages/problem_detail.page.tsx | 7 +- .../ui-default/pages/problem_files.page.tsx | 12 +-- .../ui-default/pages/problem_main.page.tsx | 68 ++++---------- .../ui-default/pages/problem_sidebar.page.ts | 68 +++++--------- .../ui-default/pages/training_edit.page.ts | 10 +- packages/ui-default/utils/db.ts | 11 +-- 12 files changed, 176 insertions(+), 196 deletions(-) diff --git a/packages/ui-default/components/dialog/index.tsx b/packages/ui-default/components/dialog/index.tsx index 101e9c5f2..c9761963f 100644 --- a/packages/ui-default/components/dialog/index.tsx +++ b/packages/ui-default/components/dialog/index.tsx @@ -1,6 +1,9 @@ +/* eslint-disable react-refresh/only-export-components */ import $ from 'jquery'; import React from 'react'; import { i18n, tpl } from 'vj/utils'; +import DomainSelectAutoComplete from '../autocomplete/components/DomainSelectAutoComplete'; +import UserSelectAutoComplete from '../autocomplete/components/UserSelectAutoComplete'; import DomDialog, { DialogOptions } from './DomDialog'; export class Dialog { @@ -108,7 +111,96 @@ export class ConfirmDialog extends Dialog { } } +export interface Field { + type: 'text' | 'checkbox' | 'user' | 'domain'; + placeholder?: string; + label?: string; + autofocus?: boolean; + required?: boolean; + default?: string; +} + +export async function prompt(title: string, fields: Record) { + let valueCache: Record = {} as any; + const defaultValues = Object.fromEntries(Object.entries(fields) + .map(([name, field]: [T, Field]) => [name, field.default || ''])) as Record; + + const Component = () => { + const [values, setValues] = React.useState(defaultValues); + + React.useEffect(() => { + valueCache = values; + }, [values]); + + return
+
+

{title}

+
+ {Object.entries(fields).map(([name, field]: [string, Field]) =>
+
+ {['text', 'user', 'domain'].includes(field.type) && } + {field.type === 'checkbox' && } +
+
)} +
; + }; + const res = await new Dialog({ + $body: $(tpl(, true)), + onDispatch(action) { + if (action === 'ok') { + for (const [name, field] of Object.entries(fields)) { + if ((field as any).required && !valueCache[name]) return false; + } + } + return true; + }, + }).open(); + if (res !== 'ok') return null; + return valueCache; +} + +export async function confirm(text: string) { + const res = await new ConfirmDialog({ + $body: tpl.typoMsg(text), + }).open(); + return res === 'yes'; +} + +export async function alert(text: string) { + return await new InfoDialog({ + $body: tpl.typoMsg(text), + }).open(); +} + window.Hydro.components.Dialog = Dialog; window.Hydro.components.InfoDialog = InfoDialog; window.Hydro.components.ActionDialog = ActionDialog; window.Hydro.components.ConfirmDialog = ConfirmDialog; +window.Hydro.components.prompt = prompt; +window.Hydro.components.confirm = confirm; +window.Hydro.components.alert = alert; diff --git a/packages/ui-default/components/problem/create.page.js b/packages/ui-default/components/problem/create.page.js index 92e7aaa03..07e4c75b3 100644 --- a/packages/ui-default/components/problem/create.page.js +++ b/packages/ui-default/components/problem/create.page.js @@ -1,14 +1,12 @@ import $ from 'jquery'; -import { InfoDialog } from 'vj/components/dialog'; +import { alert } from 'vj/components/dialog'; import { NamedPage } from 'vj/misc/Page'; -import { i18n, tpl } from 'vj/utils'; +import { i18n } from 'vj/utils'; export default new NamedPage(['problem_create', 'problem_edit'], () => { $('input[name="pid"]').on('blur', () => { if (/^[0-9]+$/.test($('input[name="pid"]').val())) { - new InfoDialog({ - $body: tpl.typoMsg(i18n('Problem ID cannot be a pure number. Leave blank if you want to use numberic id.')), - }).open(); + alert(i18n('Problem ID cannot be a pure number. Leave blank if you want to use numberic id.')); } }); }); diff --git a/packages/ui-default/components/selectUser.tsx b/packages/ui-default/components/selectUser.tsx index c9a3da306..ba3cbcaa2 100644 --- a/packages/ui-default/components/selectUser.tsx +++ b/packages/ui-default/components/selectUser.tsx @@ -1,47 +1,16 @@ -import React from 'react'; -import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; -import { ActionDialog } from 'vj/components/dialog'; -import { i18n, tpl } from 'vj/utils'; -import createHint from './hint'; +import { prompt } from 'vj/components/dialog'; +import { i18n } from 'vj/utils'; -let hintInserted = false; -$(tpl( -
-
-
-

{i18n('Select User')}

-
-
-
- -
-
-
-
, -)).appendTo(document.body); -const userSelector = UserSelectAutoComplete.getOrConstruct($('.dialog__body--user-select [name="user"]')) as any; -const userSelectDialog = new ActionDialog({ - $body: $('.dialog__body--user-select'), - onDispatch(action) { - if (action === 'ok' && userSelector.value() === null) { - userSelector.focus(); - return false; - } - return true; - }, -}); - -export default async function selectUser(hint?: string) { - if (hint && !hintInserted) { - createHint(hint, $('#select_user_hint')); - hintInserted = true; - } - userSelector.clear(); - const action = await userSelectDialog.open(); - if (action !== 'ok') return null; - return userSelector.value(); +export default async function selectUser() { + const res = await prompt(i18n('Select User'), { + user: { + type: 'user', + label: i18n('Username / UID'), + required: true, + autofocus: true, + }, + }); + return res?.user; } window.Hydro.components.selectUser = selectUser; diff --git a/packages/ui-default/pages/contest_edit.page.ts b/packages/ui-default/pages/contest_edit.page.ts index 6a6d59d06..a0cbad85d 100644 --- a/packages/ui-default/pages/contest_edit.page.ts +++ b/packages/ui-default/pages/contest_edit.page.ts @@ -3,9 +3,9 @@ import moment from 'moment'; import LanguageSelectAutoComplete from 'vj/components/autocomplete/LanguageSelectAutoComplete'; import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete'; import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; -import { ConfirmDialog } from 'vj/components/dialog'; +import { confirm } from 'vj/components/dialog'; import { NamedPage } from 'vj/misc/Page'; -import { i18n, request, tpl } from 'vj/utils'; +import { i18n, request } from 'vj/utils'; const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', 'homework_edit'], (pagename) => { ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false }); @@ -42,12 +42,11 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', }); } const message = `Confirm deleting this ${pagename.split('_')[0]}? Its files and status will be deleted as well.`; - return new ConfirmDialog({ - $body: tpl.typoMsg(i18n(message)), - }).open().then((action) => { - if (action !== 'yes') return; - confirmed = true; - ev.target.click(); + confirm(i18n(message)).then((yes) => { + if (yes) { + confirmed = true; + ev.target.click(); + } }); }); setInterval(() => { diff --git a/packages/ui-default/pages/domain_user.page.js b/packages/ui-default/pages/domain_user.page.js index 345fa1ea5..4ddd94654 100644 --- a/packages/ui-default/pages/domain_user.page.js +++ b/packages/ui-default/pages/domain_user.page.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import _ from 'lodash'; import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; -import { ActionDialog, ConfirmDialog, InfoDialog } from 'vj/components/dialog'; +import { ActionDialog, confirm, InfoDialog } from 'vj/components/dialog'; import Notification from 'vj/components/notification'; import { NamedPage } from 'vj/misc/Page'; import { @@ -101,14 +101,8 @@ const page = new NamedPage('domain_user', () => { if (selectedUsers === null) { return; } - const action = await new ConfirmDialog({ - $body: tpl` -
-

${i18n('Confirm removing the selected users?')}

-

${i18n('Their account will not be deleted and they will be with the guest role until they re-join the domain.')}

-
`, - }).open(); - if (action !== 'yes') return; + if (!(await confirm(`${i18n('Confirm removing the selected users?')} +${i18n('Their account will not be deleted and they will be with the guest role until they re-join the domain.')}`))) return; try { await request.post('', { operation: 'kick', diff --git a/packages/ui-default/pages/problem_config.page.tsx b/packages/ui-default/pages/problem_config.page.tsx index da379e97e..331cbe803 100644 --- a/packages/ui-default/pages/problem_config.page.tsx +++ b/packages/ui-default/pages/problem_config.page.tsx @@ -3,14 +3,14 @@ import $ from 'jquery'; import yaml from 'js-yaml'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { ConfirmDialog } from 'vj/components/dialog/index'; +import { confirm, prompt } from 'vj/components/dialog/index'; import Notification from 'vj/components/notification'; import { configYamlFormat } from 'vj/components/problemconfig/ProblemConfigEditor'; import uploadFiles from 'vj/components/upload'; import download from 'vj/components/zipDownloader'; import { NamedPage } from 'vj/misc/Page'; import { - i18n, loadReactRedux, pjax, request, tpl, + i18n, loadReactRedux, pjax, request, } from 'vj/utils'; const page = new NamedPage('problem_config', () => { @@ -41,8 +41,9 @@ const page = new NamedPage('problem_config', () => { async function handleClickRename(ev: JQuery.ClickEvent) { const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - // eslint-disable-next-line no-alert - const newName = prompt(i18n('Enter a new name for the file: ')); + const newName = (await prompt(i18n('Enter a new name for the file: '), { + name: { required: true, type: 'text', autofocus: true }, + }))?.name as string; if (!newName) return; try { await request.post('./files', { @@ -60,10 +61,7 @@ const page = new NamedPage('problem_config', () => { async function handleClickRemove(ev: JQuery.ClickEvent) { const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - const action = await new ConfirmDialog({ - $body: tpl.typoMsg(i18n('Confirm to delete the file?')), - }).open(); - if (action !== 'yes') return; + if (!(await confirm(i18n('Confirm to delete the file?')))) return; try { await request.post('./files', { operation: 'delete_files', diff --git a/packages/ui-default/pages/problem_detail.page.tsx b/packages/ui-default/pages/problem_detail.page.tsx index 19e04eeb3..b77d444d5 100644 --- a/packages/ui-default/pages/problem_detail.page.tsx +++ b/packages/ui-default/pages/problem_detail.page.tsx @@ -2,7 +2,7 @@ import $ from 'jquery'; import yaml from 'js-yaml'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import { ConfirmDialog } from 'vj/components/dialog'; +import { confirm } from 'vj/components/dialog'; import Notification from 'vj/components/notification'; import { downloadProblemSet } from 'vj/components/zipDownloader'; import { NamedPage } from 'vj/misc/Page'; @@ -316,10 +316,7 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor )); $(document).on('click', '#clearAnswers', async () => { - const result = await new ConfirmDialog({ - $body: tpl.typoMsg(i18n('All changes will be lost. Are you sure to clear all answers?')), - }).open(); - if (result === 'yes') await clearAns(); + if (await confirm(i18n('All changes will be lost. Are you sure to clear all answers?'))) await clearAns(); }); } const ele = document.createElement('div'); diff --git a/packages/ui-default/pages/problem_files.page.tsx b/packages/ui-default/pages/problem_files.page.tsx index 00fc90d54..c2123a469 100644 --- a/packages/ui-default/pages/problem_files.page.tsx +++ b/packages/ui-default/pages/problem_files.page.tsx @@ -4,7 +4,7 @@ import { map } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; import FileSelectAutoComplete from 'vj/components/autocomplete/FileSelectAutoComplete'; -import { ActionDialog, ConfirmDialog, InfoDialog } from 'vj/components/dialog/index'; +import { ActionDialog, confirm, InfoDialog } from 'vj/components/dialog/index'; import createHint from 'vj/components/hint'; import Notification from 'vj/components/notification'; import { previewFile } from 'vj/components/preview/preview.page'; @@ -255,10 +255,7 @@ const page = new NamedPage('problem_files', () => { async function handleClickRemove(ev, type) { const file = [$(ev.currentTarget).parent().parent().attr('data-filename')]; - const action = await new ConfirmDialog({ - $body: tpl.typoMsg(i18n('Confirm to delete the file?')), - }).open(); - if (action !== 'yes') return; + if (!(await confirm(i18n('Confirm to delete the file?')))) return; try { await request.post('./files', { operation: 'delete_files', @@ -275,10 +272,7 @@ const page = new NamedPage('problem_files', () => { async function handleClickRemoveSelected(type) { const selectedFiles = ensureAndGetSelectedFiles(type); if (selectedFiles === null) return; - const action = await new ConfirmDialog({ - $body: tpl.typoMsg(i18n('Confirm to delete the selected files?')), - }).open(); - if (action !== 'yes') return; + if (!(await confirm(i18n('Confirm to delete the selected files?')))) return; try { await request.post('', { operation: 'delete_files', diff --git a/packages/ui-default/pages/problem_main.page.tsx b/packages/ui-default/pages/problem_main.page.tsx index b03da78bb..6f6a8540d 100644 --- a/packages/ui-default/pages/problem_main.page.tsx +++ b/packages/ui-default/pages/problem_main.page.tsx @@ -5,15 +5,14 @@ import _ from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom/client'; import ProblemSelectAutoComplete from 'vj/components/autocomplete/components/ProblemSelectAutoComplete'; -import DomainSelectAutoComplete from 'vj/components/autocomplete/DomainSelectAutoComplete'; -import { ActionDialog, ConfirmDialog, Dialog } from 'vj/components/dialog'; +import { confirm, Dialog, prompt } from 'vj/components/dialog'; import Dropdown from 'vj/components/dropdown/Dropdown'; import createHint from 'vj/components/hint'; import Notification from 'vj/components/notification'; import { downloadProblemSet } from 'vj/components/zipDownloader'; import { NamedPage } from 'vj/misc/Page'; import { - addSpeculationRules, delay, i18n, pjax, request, tpl, + addSpeculationRules, delay, i18n, pjax, request, } from 'vj/utils'; const list = []; @@ -177,33 +176,28 @@ function ensureAndGetSelectedPids() { return selectedPids; } -let refDiv: any; - async function handleOperation(operation: string) { const pids = ensureAndGetSelectedPids(); if (pids === null) return; const payload: any = {}; if (operation === 'delete') { - const action = await new ConfirmDialog({ - $body: tpl.typoMsg(i18n('Confirm to delete the selected problems?')), - }).open(); - if (action !== 'yes') return; + if (!(await confirm(i18n('Confirm to delete the selected problems?')))) return; } else if (operation === 'copy') { - const $target = $(refDiv).find('[name="target"]'); - const domainSelector: any = DomainSelectAutoComplete.getOrConstruct($target); - const copyDialog = await new ActionDialog({ - $body: refDiv, - onDispatch(action) { - if (action === 'ok' && domainSelector.value() === null) { - domainSelector.focus(); - return false; - } - return true; + const res = await prompt(i18n('Copy Problems'), { + target: { + type: 'domain', + label: i18n('Target'), + required: true, + autofocus: true, }, - }).open(); - if (copyDialog !== 'ok') return; - payload.target = domainSelector.value()._id; - payload.hidden = $(refDiv).find('[name="hidden"]').prop('checked'); + hidden: { + type: 'checkbox', + label: i18n('Hidden'), + }, + }); + if (!res) return; + payload.target = res.target; + payload.hidden = res.hidden; } try { await request.post('', { operation, pids, ...payload }); @@ -277,8 +271,8 @@ function buildSearchContainer() { async function handleDownload(ev) { let name = 'Export'; - // eslint-disable-next-line no-alert - if (ev.shiftKey) name = prompt(i18n('Filename'), name); + if (ev.shiftKey) name = (await prompt(i18n('Filename'), { name: { type: 'text', default: name } }))?.name as string; + if (!name) return; const pids = ensureAndGetSelectedPids(); if (pids) await downloadProblemSet(pids, name); } @@ -414,30 +408,6 @@ const page = new NamedPage(['problem_main'], () => { const $body = $('body'); $body.addClass('display-mode'); $('.section.display-mode').removeClass('display-mode'); - $(tpl( -
-
-

{i18n('Copy Problems')}

-
-
-
- -
-
- -
-
-
, - )).appendTo(document.body); - refDiv = $('.dialog__body--problem-copy > div'); buildSearchContainer(); buildLegacyCategoryFilter(); diff --git a/packages/ui-default/pages/problem_sidebar.page.ts b/packages/ui-default/pages/problem_sidebar.page.ts index 797a1af9c..9b26f2e8a 100644 --- a/packages/ui-default/pages/problem_sidebar.page.ts +++ b/packages/ui-default/pages/problem_sidebar.page.ts @@ -1,9 +1,8 @@ import $ from 'jquery'; -import DomainSelectAutoComplete from 'vj/components/autocomplete/DomainSelectAutoComplete'; -import { ActionDialog, ConfirmDialog } from 'vj/components/dialog'; +import { confirm, prompt } from 'vj/components/dialog'; import Notification from 'vj/components/notification'; import { NamedPage } from 'vj/misc/Page'; -import { i18n, request, tpl } from 'vj/utils'; +import { i18n, request } from 'vj/utils'; const page = new NamedPage([ 'problem_create', 'problem_edit', 'problem_solution', 'problem_submit', @@ -16,53 +15,30 @@ const page = new NamedPage([ }); $(document).on('click', '[name="problem-sidebar__rejudge"]', (ev) => { ev.preventDefault(); - new ConfirmDialog({ - $body: tpl.typoMsg(i18n('Confirm rejudge this problem?')), - }).open().then((action) => { - if (action !== 'yes') return; - $(ev.currentTarget).closest('form').trigger('submit'); + confirm(i18n('Confirm rejudge this problem?')).then((yes) => { + if (yes) $(ev.currentTarget).closest('form').trigger('submit'); }); }); - $(document).on('click', '[name="problem-sidebar__copy"]', () => { - $(tpl``).appendTo(document.body); - const domainSelector = DomainSelectAutoComplete.getOrConstruct($('.dialog__body--problem-copy [name="target"]')) as any; - new ActionDialog({ - $body: $('.dialog__body--problem-copy > div'), - onDispatch(action) { - if (action === 'ok' && domainSelector.value() === null) { - domainSelector.focus(); - return false; - } - return true; + $(document).on('click', '[name="problem-sidebar__copy"]', async () => { + const res = await prompt(i18n('Copy Problem'), { + target: { + type: 'domain', + label: i18n('Target'), + required: true, + autofocus: true, }, - }).open().then(async (action) => { - if (action !== 'ok') return; - const target = $('[name="target"]').val(); - if (!target) return; - try { - const res = await request.post('', { - operation: 'copy', - target, - }); - window.location.href = res.url; - } catch (error) { - Notification.error(error.message); - } }); + if (!res) return; + try { + const { url } = await request.post('..', { + operation: 'copy', + pids: [UiContext.pdoc.docId], + target: res.target, + }); + window.location.href = url; + } catch (error) { + Notification.error(error.message); + } }); }); diff --git a/packages/ui-default/pages/training_edit.page.ts b/packages/ui-default/pages/training_edit.page.ts index e5fc7760b..6d190d583 100644 --- a/packages/ui-default/pages/training_edit.page.ts +++ b/packages/ui-default/pages/training_edit.page.ts @@ -1,7 +1,7 @@ import $ from 'jquery'; -import { ConfirmDialog } from 'vj/components/dialog'; +import { confirm } from 'vj/components/dialog'; import { NamedPage } from 'vj/misc/Page'; -import { i18n, request, tpl } from 'vj/utils'; +import { i18n, request } from 'vj/utils'; export default new NamedPage('training_edit', () => { let confirmed = false; @@ -13,10 +13,8 @@ export default new NamedPage('training_edit', () => { }); } const message = 'Confirm deleting this training? Its files and status will be deleted as well.'; - return new ConfirmDialog({ - $body: tpl.typoMsg(i18n(message)), - }).open().then((action) => { - if (action !== 'yes') return; + return confirm(i18n(message)).then((yes) => { + if (!yes) return; confirmed = true; ev.target.click(); }); diff --git a/packages/ui-default/utils/db.ts b/packages/ui-default/utils/db.ts index 02e945296..a6f4922ed 100644 --- a/packages/ui-default/utils/db.ts +++ b/packages/ui-default/utils/db.ts @@ -1,6 +1,5 @@ import { openDB as _open } from 'idb'; -import { InfoDialog } from 'vj/components/dialog'; -import { tpl } from 'vj/utils'; +import { alert } from 'vj/components/dialog'; export const openDB = _open('hydro', 1, { upgrade(db, oldVersion) { @@ -21,15 +20,11 @@ export const openDB = _open('hydro', 1, { }, blocked(currentVersion, blockedVersion) { console.error('IDB Blocked by version', blockedVersion, 'want', currentVersion); - new InfoDialog({ - $body: tpl.typoMsg('Some other opened tabs locked the database. Please close them.'), - }).open(); + alert('Some other opened tabs locked the database. Please close them.'); }, blocking(currentVersion, blockedVersion) { console.error('IDB Blocking version', blockedVersion); - new InfoDialog({ - $body: tpl.typoMsg('Please close or refresh this tab to perform the upgrade.'), - }).open(); + alert('Please close or refresh this tab to perform the upgrade.'); }, terminated() { console.error('IDB Terminated');