From 564ff1b02d00741a4173e203e1181e9dfaf9cf9d Mon Sep 17 00:00:00 2001 From: Tairui Qu Date: Fri, 10 Oct 2025 20:43:38 +0800 Subject: [PATCH] core&ui: assignment auto complete (#1061) --- .../frontend/autocomplete/AutoComplete.tsx | 26 ++-- packages/hydrooj/src/handler/domain.ts | 19 +++ packages/ui-default/api.ts | 3 +- .../autocomplete/AssignSelectAutoComplete.tsx | 24 ++++ .../components/AssignSelectAutoComplete.tsx | 118 ++++++++++++++++++ .../ui-default/pages/contest_edit.page.ts | 2 + 6 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 packages/ui-default/components/autocomplete/AssignSelectAutoComplete.tsx create mode 100644 packages/ui-default/components/autocomplete/components/AssignSelectAutoComplete.tsx diff --git a/packages/components/frontend/autocomplete/AutoComplete.tsx b/packages/components/frontend/autocomplete/AutoComplete.tsx index 1148a5099..5fef9ca89 100644 --- a/packages/components/frontend/autocomplete/AutoComplete.tsx +++ b/packages/components/frontend/autocomplete/AutoComplete.tsx @@ -112,10 +112,16 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re setCurrentItem(null); return; } - queryCache[query] ||= await queryItems(query); - for (const item of queryCache[query]) valueCache[itemKey(item)] = item; - setItemList(queryCache[query]); - setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null); + try { + queryCache[query] ||= await queryItems(query); + for (const item of queryCache[query]) valueCache[itemKey(item)] = item; + setItemList(queryCache[query]); + setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null); + } catch (e) { + console.error('Failed to query items', e); + setItemList([]); + setCurrentItem(null); + } }; useEffect(() => { @@ -135,6 +141,8 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re Promise.resolve(props.fetchItems(ids)).then((items) => { for (const item of items) valueCache[itemKey(item)] = item; setRerender(!rerender); + }).catch((e) => { + console.error('Failed to fetch items', e); }); }, [selectedKeys, multi]); @@ -287,9 +295,13 @@ const AutoComplete = forwardRef(function Impl(props: AutoCompleteProps, re e.preventDefault(); const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v)); if (!ids.length) return; - const fetched = await props.fetchItems(ids); - for (const item of fetched) valueCache[itemKey(item)] = item; - setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]); + try { + const fetched = await props.fetchItems(ids); + for (const item of fetched) valueCache[itemKey(item)] = item; + setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]); + } catch (err) { + console.error('Failed to fetch items on paste', err); + } }} placeholder={props.placeholder} onBlur={() => setFocused(false)} diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index f2c25189c..70266b4fc 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -433,6 +433,25 @@ export const DomainApi = { return ddoc; }, ), + groups: Query( + Schema.object({ + search: Schema.string(), + names: Schema.array(Schema.string()), + domainId: Schema.string().required(), + }), + async (ctx, args) => { + if (!ctx.user.hasPerm(PERM.PERM_VIEW) && !ctx.user.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) throw new PermissionError(PERM.PERM_VIEW); + const groups = await user.listGroup(args.domainId); + if (args.names?.length) { + return groups.filter((g) => args.names.includes(g.name)); + } + if (args.search) { + const searchLower = args.search.toLowerCase(); + return groups.filter((g) => g.name.toLowerCase().includes(searchLower)); + } + return groups; + }, + ), 'domain.group': Mutation( Schema.object({ name: Schema.string().required(), diff --git a/packages/ui-default/api.ts b/packages/ui-default/api.ts index fd86dcd4c..f4bc3e2ed 100644 --- a/packages/ui-default/api.ts +++ b/packages/ui-default/api.ts @@ -24,13 +24,14 @@ export default load; export interface EventMap { } import AutoComplete from './components/autocomplete'; +import AssignSelectAutoComplete from './components/autocomplete/AssignSelectAutoComplete'; import CustomSelectAutoComplete from './components/autocomplete/CustomSelectAutoComplete'; import DomainSelectAutoComplete from './components/autocomplete/DomainSelectAutoComplete'; import ProblemSelectAutoComplete from './components/autocomplete/ProblemSelectAutoComplete'; import UserSelectAutoComplete from './components/autocomplete/UserSelectAutoComplete'; export { - AutoComplete, CustomSelectAutoComplete, DomainSelectAutoComplete, ProblemSelectAutoComplete, UserSelectAutoComplete, + AssignSelectAutoComplete, AutoComplete, CustomSelectAutoComplete, DomainSelectAutoComplete, ProblemSelectAutoComplete, UserSelectAutoComplete, }; export function addPage(page: import('./misc/Page').Page | (() => Promise | void)) { window.Hydro.extraPages.push(page); diff --git a/packages/ui-default/components/autocomplete/AssignSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/AssignSelectAutoComplete.tsx new file mode 100644 index 000000000..92dea64fc --- /dev/null +++ b/packages/ui-default/components/autocomplete/AssignSelectAutoComplete.tsx @@ -0,0 +1,24 @@ +import AutoComplete, { AutoCompleteOptions } from '.'; +import AssignSelectAutoCompleteFC from './components/AssignSelectAutoComplete'; + +export default class AssignSelectAutoComplete extends AutoComplete { + static DOMAttachKey = 'ucwAssignSelectAutoCompleteInstance'; + + constructor($dom, options: AutoCompleteOptions = {}) { + super($dom, { + classes: 'assign-select', + component: AssignSelectAutoCompleteFC, + props: { + multi: true, + height: 'auto', + }, + ...options, + }); + } + + value(): string { + return this.ref?.getSelectedItemKeys().join(',') ?? this.$dom.val(); + } +} + +window.Hydro.components.AssignSelectAutoComplete = AssignSelectAutoComplete; diff --git a/packages/ui-default/components/autocomplete/components/AssignSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/AssignSelectAutoComplete.tsx new file mode 100644 index 000000000..6988caefe --- /dev/null +++ b/packages/ui-default/components/autocomplete/components/AssignSelectAutoComplete.tsx @@ -0,0 +1,118 @@ +import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/components'; +import type { GDoc, Udoc } from 'hydrooj/src/interface'; +import PropTypes from 'prop-types'; +import React, { forwardRef } from 'react'; +import { api } from 'vj/utils'; + +interface AssignItem { + type: 'user' | 'group'; + key: string; + name: string; + displayName?: string; + avatarUrl?: string; + uids?: number[]; + invalid?: boolean; +} + +const toUserItem = (user: Udoc): AssignItem => ({ + type: 'user', + key: user._id.toString(), + name: user.uname, + displayName: user.displayName, + avatarUrl: user.avatarUrl, +}); + +const toGroupItem = (group: GDoc): AssignItem => ({ + type: 'group', + key: group.name, + name: group.name, + uids: group.uids, +}); + +const AssignSelectAutoComplete = forwardRef, AutoCompleteProps>((props, ref) => ( + + ref={ref as any} + cacheKey="assign" + queryItems={async (query) => { + const [users, groups] = await Promise.all([ + api('users', { search: query }, ['_id', 'uname', 'displayName', 'avatarUrl']), + api('groups', { search: query }, ['name', 'uids']), + ]); + const userItems: AssignItem[] = users.map((user: Udoc) => toUserItem(user)); + const groupItems: AssignItem[] = groups.map((group: GDoc) => toGroupItem(group)); + return [...groupItems, ...userItems]; + }} + fetchItems={async (keys) => { + const isUserId = (k: string) => /^-?[0-9]+$/.test(k); + const userIds = keys.filter((k) => isUserId(k)); + const groupNames = keys.filter((k) => !isUserId(k)); + + const [users, groups]: [Udoc[], GDoc[]] = await Promise.all([ + userIds.length > 0 ? api('users', { auto: userIds }, ['_id', 'uname', 'displayName']) : [], + groupNames.length > 0 ? api('groups', { names: groupNames }, ['name', 'uids']) : [], + ]); + + const userItems: AssignItem[] = users.map((user: Udoc) => toUserItem(user)); + const groupItems: AssignItem[] = keys + .filter((key) => !isUserId(key)) + .map((key) => { + const group = groups.find((g) => g.name === key); + return group ? toGroupItem(group) : { type: 'group', key, name: key, invalid: true }; + }); + + return [...groupItems, ...userItems]; + }} + itemText={(item) => { + if (item.type === 'group') { + if (item.invalid) return `${item.name} (invalid)`; + return `${item.name} (${item.uids?.length || 0} users)`; + } + return item.name + (item.displayName ? ` (${item.displayName})` : ''); + }} + itemKey={(item) => item.key} + renderItem={(item) => ( +
+ {item.type === 'user' && ( +
+ +
+ )} +
+
+ {item.name}{item.type === 'user' && item.displayName && ` (${item.displayName})`} +
+
+ {item.type === 'group' ? `Group • ${item.uids?.length || 0} users` : `User • UID = ${item.key}`} +
+
+
+ )} + {...{ + width: '100%', + height: 'auto', + listStyle: {}, + multi: true, + selectedKeys: [], + allowEmptyQuery: false, + freeSolo: false, + freeSoloConverter: (input) => input, + ...props, + }} + /> +)); + +AssignSelectAutoComplete.propTypes = { + width: PropTypes.string, + height: PropTypes.string, + listStyle: PropTypes.object, + onChange: PropTypes.func.isRequired, + multi: PropTypes.bool, + selectedKeys: PropTypes.arrayOf(PropTypes.string), + allowEmptyQuery: PropTypes.bool, + freeSolo: PropTypes.bool, + freeSoloConverter: PropTypes.func, +}; + +AssignSelectAutoComplete.displayName = 'AssignSelectAutoComplete'; + +export default AssignSelectAutoComplete; diff --git a/packages/ui-default/pages/contest_edit.page.ts b/packages/ui-default/pages/contest_edit.page.ts index a4709a357..047287a86 100644 --- a/packages/ui-default/pages/contest_edit.page.ts +++ b/packages/ui-default/pages/contest_edit.page.ts @@ -1,5 +1,6 @@ import $ from 'jquery'; import moment from 'moment'; +import AssignSelectAutoComplete from 'vj/components/autocomplete/AssignSelectAutoComplete'; import LanguageSelectAutoComplete from 'vj/components/autocomplete/LanguageSelectAutoComplete'; import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete'; import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; @@ -11,6 +12,7 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false }); UserSelectAutoComplete.getOrConstruct($('[name="maintainer"]'), { multi: true, clearDefaultValue: false }); LanguageSelectAutoComplete.getOrConstruct($('[name=langs]'), { multi: true }); + AssignSelectAutoComplete.getOrConstruct($('[name="assign"]'), { multi: true }); $('[name="rule"]').on('change', () => { const rule = $('[name="rule"]').val(); $('.contest-rule-settings input').attr('disabled', 'disabled');