core&ui: assignment auto complete (#1061)

This commit is contained in:
Tairui Qu authored and GitHub committed 2025-10-10 13:43:38 +01:00
1 parent f32cee2200
commit 564ff1b02d
6 files changed
+184 -8

No files matched your search

@@ -112,10 +112,16 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
setCurrentItem(null); setCurrentItem(null);
return; return;
} }
queryCache[query] ||= await queryItems(query); try {
for (const item of queryCache[query]) valueCache[itemKey(item)] = item; queryCache[query] ||= await queryItems(query);
setItemList(queryCache[query]); for (const item of queryCache[query]) valueCache[itemKey(item)] = item;
setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null); setItemList(queryCache[query]);
setCurrentItem((!freeSolo && queryCache[query].length) ? 0 : null);
} catch (e) {
console.error('Failed to query items', e);
setItemList([]);
setCurrentItem(null);
}
}; };
useEffect(() => { useEffect(() => {
@@ -135,6 +141,8 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
Promise.resolve(props.fetchItems(ids)).then((items) => { Promise.resolve(props.fetchItems(ids)).then((items) => {
for (const item of items) valueCache[itemKey(item)] = item; for (const item of items) valueCache[itemKey(item)] = item;
setRerender(!rerender); setRerender(!rerender);
}).catch((e) => {
console.error('Failed to fetch items', e);
}); });
}, [selectedKeys, multi]); }, [selectedKeys, multi]);
@@ -287,9 +295,13 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, re
e.preventDefault(); e.preventDefault();
const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v)); const ids = text.replace(/,/g, ',').split(',').filter((v) => v?.trim().length && !selectedKeys.includes(v));
if (!ids.length) return; if (!ids.length) return;
const fetched = await props.fetchItems(ids); try {
for (const item of fetched) valueCache[itemKey(item)] = item; const fetched = await props.fetchItems(ids);
setSelectedKeys([...selectedKeys, ...fetched.map((val) => itemKey(val))]); 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} placeholder={props.placeholder}
onBlur={() => setFocused(false)} onBlur={() => setFocused(false)}
+19
View File
@@ -433,6 +433,25 @@ export const DomainApi = {
return ddoc; 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( 'domain.group': Mutation(
Schema.object({ Schema.object({
name: Schema.string().required(), name: Schema.string().required(),
+2 -1
View File
@@ -24,13 +24,14 @@ export default load;
export interface EventMap { } export interface EventMap { }
import AutoComplete from './components/autocomplete'; import AutoComplete from './components/autocomplete';
import AssignSelectAutoComplete from './components/autocomplete/AssignSelectAutoComplete';
import CustomSelectAutoComplete from './components/autocomplete/CustomSelectAutoComplete'; import CustomSelectAutoComplete from './components/autocomplete/CustomSelectAutoComplete';
import DomainSelectAutoComplete from './components/autocomplete/DomainSelectAutoComplete'; import DomainSelectAutoComplete from './components/autocomplete/DomainSelectAutoComplete';
import ProblemSelectAutoComplete from './components/autocomplete/ProblemSelectAutoComplete'; import ProblemSelectAutoComplete from './components/autocomplete/ProblemSelectAutoComplete';
import UserSelectAutoComplete from './components/autocomplete/UserSelectAutoComplete'; import UserSelectAutoComplete from './components/autocomplete/UserSelectAutoComplete';
export { export {
AutoComplete, CustomSelectAutoComplete, DomainSelectAutoComplete, ProblemSelectAutoComplete, UserSelectAutoComplete, AssignSelectAutoComplete, AutoComplete, CustomSelectAutoComplete, DomainSelectAutoComplete, ProblemSelectAutoComplete, UserSelectAutoComplete,
}; };
export function addPage(page: import('./misc/Page').Page | (() => Promise<void> | void)) { export function addPage(page: import('./misc/Page').Page | (() => Promise<void> | void)) {
window.Hydro.extraPages.push(page); window.Hydro.extraPages.push(page);
@@ -0,0 +1,24 @@
import AutoComplete, { AutoCompleteOptions } from '.';
import AssignSelectAutoCompleteFC from './components/AssignSelectAutoComplete';
export default class AssignSelectAutoComplete<Multi extends boolean> extends AutoComplete {
static DOMAttachKey = 'ucwAssignSelectAutoCompleteInstance';
constructor($dom, options: AutoCompleteOptions<Multi> = {}) {
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;
@@ -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<AutoCompleteHandle<AssignItem>, AutoCompleteProps<AssignItem>>((props, ref) => (
<AutoComplete<AssignItem>
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) => (
<div className="media">
{item.type === 'user' && (
<div className="media__left medium">
<img className="small user-profile-avatar" alt="" src={item.avatarUrl} width="30" height="30" />
</div>
)}
<div className="media__body medium">
<div className="assign-select__name">
{item.name}{item.type === 'user' && item.displayName && ` (${item.displayName})`}
</div>
<div className="assign-select__desc">
{item.type === 'group' ? `Group • ${item.uids?.length || 0} users` : `User • UID = ${item.key}`}
</div>
</div>
</div>
)}
{...{
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;
@@ -1,5 +1,6 @@
import $ from 'jquery'; import $ from 'jquery';
import moment from 'moment'; import moment from 'moment';
import AssignSelectAutoComplete from 'vj/components/autocomplete/AssignSelectAutoComplete';
import LanguageSelectAutoComplete from 'vj/components/autocomplete/LanguageSelectAutoComplete'; import LanguageSelectAutoComplete from 'vj/components/autocomplete/LanguageSelectAutoComplete';
import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete'; import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete';
import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; 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 }); ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false });
UserSelectAutoComplete.getOrConstruct<true>($('[name="maintainer"]'), { multi: true, clearDefaultValue: false }); UserSelectAutoComplete.getOrConstruct<true>($('[name="maintainer"]'), { multi: true, clearDefaultValue: false });
LanguageSelectAutoComplete.getOrConstruct($('[name=langs]'), { multi: true }); LanguageSelectAutoComplete.getOrConstruct($('[name=langs]'), { multi: true });
AssignSelectAutoComplete.getOrConstruct($('[name="assign"]'), { multi: true });
$('[name="rule"]').on('change', () => { $('[name="rule"]').on('change', () => {
const rule = $('[name="rule"]').val(); const rule = $('[name="rule"]').val();
$('.contest-rule-settings input').attr('disabled', 'disabled'); $('.contest-rule-settings input').attr('disabled', 'disabled');