Skip to content
Merged
26 changes: 19 additions & 7 deletions packages/components/frontend/autocomplete/AutoComplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,16 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, 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(() => {
Expand All @@ -135,6 +141,8 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, 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]);

Expand Down Expand Up @@ -287,9 +295,13 @@ const AutoComplete = forwardRef(function Impl<T>(props: AutoCompleteProps<T>, 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);
}
Comment on lines +298 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silent failure on paste may confuse users.

When fetchItems fails during a paste operation, no items are added and users receive no feedback about the failure. This can be confusing, especially when users paste multiple comma-separated values expecting them to appear.

Consider providing user-visible feedback when paste operations fail, such as:

  • Displaying a toast notification
  • Adding invalid items with a visual indicator
  • Showing an error state in the input

Example improvement:

 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);
+  // Consider: Show toast notification or set error state
+  // e.g., props.onError?.('Failed to load pasted items');
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
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);
// Consider: Show toast notification or set error state
// e.g., props.onError?.('Failed to load pasted items');
}
🤖 Prompt for AI Agents
In packages/components/frontend/autocomplete/AutoComplete.tsx around lines
298-304 the paste handler silently logs fetchItems errors to console which
leaves users unaware that their pasted items failed to load; modify the catch
block to surface a user-visible error (e.g., trigger the app's
toast/notification system with a clear message, or add the pasted keys to the
selection with an "invalid" flag that renders a visual error state in the
list/input), and ensure valueCache and selectedKeys are not left in a
partial/incorrect state on failure (use a local temporary array and only update
state on success, or roll back on error) so the UI reflects the failure and
guides the user to retry or correct input.

}}
placeholder={props.placeholder}
onBlur={() => setFocused(false)}
Expand Down
19 changes: 19 additions & 0 deletions packages/hydrooj/src/handler/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,25 @@ export const DomainApi = {
return ddoc;
},
),
groups: Query(
Schema.object({
search: Schema.string(),
names: Schema.array(Schema.string()),
Comment thread
TaiRuiQu marked this conversation as resolved.
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);
Comment thread
TaiRuiQu marked this conversation as resolved.
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(),
Expand Down
3 changes: 2 additions & 1 deletion packages/ui-default/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void)) {
window.Hydro.extraPages.push(page);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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];
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
};
Comment thread
TaiRuiQu marked this conversation as resolved.

AssignSelectAutoComplete.displayName = 'AssignSelectAutoComplete';

export default AssignSelectAutoComplete;
2 changes: 2 additions & 0 deletions packages/ui-default/pages/contest_edit.page.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +12,7 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create',
ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false });
UserSelectAutoComplete.getOrConstruct<true>($('[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');
Expand Down