Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions packages/hydrooj/src/handler/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { stringify as toCSV } from 'csv-stringify/sync';
import { escapeRegExp, pick } from 'lodash';
import moment from 'moment-timezone';
import { ObjectId } from 'mongodb';
import {
Counter, diffArray, randomstring, sortFiles, Time, yaml,
} from '@hydrooj/utils/lib/utils';
import { Counter, diffArray, randomstring, sortFiles, Time, yaml } from '@hydrooj/utils/lib/utils';
import { Context, Service } from '../context';
import {
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
Expand All @@ -26,6 +24,7 @@ import ScheduleModel from '../model/schedule';
import storage from '../model/storage';
import * as system from '../model/system';
import user from '../model/user';
import * as setting from '../model/setting';
import {
Handler, param, post, Type, Types,
} from '../service/server';
Expand Down Expand Up @@ -279,13 +278,47 @@ export class ContestEditHandler extends Handler {
let ts = Date.now();
ts = ts - (ts % (15 * Time.minute)) + 15 * Time.minute;
const beginAt = moment(this.tdoc?.beginAt || new Date(ts)).tz(this.user.timeZone);

// key, label, selected
let langList = [] as [string, string, boolean][];
if (!Array.isArray(setting.SETTINGS_BY_KEY.codeLang.range)) {
Object.keys(setting.SETTINGS_BY_KEY.codeLang.range).forEach((key) => {
langList.push([key, setting.SETTINGS_BY_KEY.codeLang.range[key], true]);
});
} else {
langList = setting.SETTINGS_BY_KEY.codeLang.range.map((el) => [...el, true]);
}
const langPrefixes = new Set(langList.map((i) => i[0]).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
langList = langList.filter((i) => !langPrefixes.has(i[0]));

let limitLangListString = '';
let isLimitLang = false;
if (Array.isArray(this.tdoc?.limitLangList)) {
isLimitLang = true;
const allowedLangSet = new Set<string>();
this.tdoc?.limitLangList.forEach((k) => {
allowedLangSet.add(k);
});
for (let i = 0; i < langList.length; i++) {
if (!allowedLangSet.has(langList[i][0])) {
langList[i][2] = false;
}
}
limitLangListString = this.tdoc?.limitLangList.join(',');
} else {
limitLangListString = langList.map((el) => el[0]).join(',');
}

this.response.body = {
rules,
tdoc: this.tdoc,
duration: tid ? -beginAt.diff(this.tdoc.endAt, 'hour', true) : 2,
pids: tid ? this.tdoc.pids.join(',') : '',
beginAt,
page_name: tid ? 'contest_edit' : 'contest_create',
langList,
isLimitLang,
limitLangListString,
};
}

Expand All @@ -305,11 +338,14 @@ export class ContestEditHandler extends Handler {
@param('contestDuration', Types.Float, true)
@param('maintainer', Types.NumericArray, true)
@param('allowViewCode', Types.Boolean)
@param('limitLang', Types.Boolean)
@param('limitLangList', Types.CommaSeperatedArray, true)
async postUpdate(
domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number,
title: string, content: string, rule: string, _pids: string, rated = false,
_code = '', autoHide = false, assign: string[] = [], lock: number = null,
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false,
limitLang = false, limitLangList: string[] = [],
) {
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i);
Expand Down Expand Up @@ -350,7 +386,7 @@ export class ContestEditHandler extends Handler {
});
}
await contest.edit(domainId, tid, {
assign, _code, autoHide, lockAt, maintainer, allowViewCode,
assign, _code, autoHide, lockAt, maintainer, allowViewCode, limitLangList: limitLang ? limitLangList : null,
});
this.response.body = { tid };
this.response.redirect = this.url('contest_detail', { tid });
Expand Down
5 changes: 5 additions & 0 deletions packages/hydrooj/src/handler/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
ProblemNotFoundError, RecordNotFoundError, SolutionNotFoundError, ValidationError,
} from '../error';
import {
ProblemConfig,
ProblemDoc, ProblemSearchOptions, ProblemStatusDoc, RecordDoc, User,
} from '../interface';
import { PERM, PRIV, STATUS } from '../model/builtin';
Expand Down Expand Up @@ -315,6 +316,10 @@ export class ProblemDetailHandler extends ContestDetailBaseHandler {
(needHiddenLangs ? !setting.langs[i].remote : !setting.langs[i].remote && !setting.langs[i].hidden));
}
this.pdoc.config.langs = ['objective', 'submit_answer'].includes(this.pdoc.config.type) ? ['_'] : intersection(baseLangs, ...t);
// apply contest language limits
if (this?.tdoc && Array.isArray(this.tdoc?.limitLangList)) {
this.pdoc.config.langs = Array.from(Set.intersection(this.pdoc.config.langs, this.tdoc.limitLangList));
}
}
await this.ctx.parallel('problem/get', this.pdoc, this);
[this.psdoc, this.udoc] = await Promise.all([
Expand Down
3 changes: 3 additions & 0 deletions packages/hydrooj/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ export interface Tdoc extends Document {
balloon?: Record<number, string | { color: string, name: string }>;
score?: Record<number, number>;

// 允许使用的语言, undefined|null 则允许全部
limitLangList?: string[] | null;

/**
* In hours
* 在比赛有效时间内选择特定的 X 小时参加比赛(从首次打开比赛算起)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import AutoComplete from '.';
import LanguageSelectAutoCompleteFC from './components/LanguageSelectAutoComplete';

const Component = React.forwardRef<any, any>((props, ref) => {
const [value, setValue] = React.useState(props.value ?? '');
return (
<LanguageSelectAutoCompleteFC
ref={ref as any}
height="auto"
selectedKeys={value.split(',').map((i) => i.trim()).filter((i) => i)}
onChange={(v) => {
setValue(v);
props.onChange(v);
}}
multi={props.multi}
allowEmptyQuery={true}
/>
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve value parsing robustness and error handling.

The current value splitting logic may fail with malformed input and lacks error handling for the onChange callback.

 const Component = React.forwardRef<any, any>((props, ref) => {
-  const [value, setValue] = React.useState(props.value ?? '');
+  const [value, setValue] = React.useState(() => {
+    const initialValue = props.value ?? '';
+    return typeof initialValue === 'string' ? initialValue : String(initialValue);
+  });
+  
+  const parseValue = (val: string) => {
+    if (!val || typeof val !== 'string') return [];
+    return val.split(',').map((i) => i.trim()).filter((i) => i);
+  };
+  
   return (
     <LanguageSelectAutoCompleteFC
       ref={ref as any}
       height="auto"
-      selectedKeys={value.split(',').map((i) => i.trim()).filter((i) => i)}
+      selectedKeys={parseValue(value)}
       onChange={(v) => {
-        setValue(v);
-        props.onChange(v);
+        try {
+          setValue(v);
+          props.onChange?.(v);
+        } catch (error) {
+          console.error('Error in LanguageSelectAutoComplete onChange:', error);
+        }
       }}
       multi={props.multi}
       allowEmptyQuery={true}
     />
   );
 });
📝 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
const Component = React.forwardRef<any, any>((props, ref) => {
const [value, setValue] = React.useState(props.value ?? '');
return (
<LanguageSelectAutoCompleteFC
ref={ref as any}
height="auto"
selectedKeys={value.split(',').map((i) => i.trim()).filter((i) => i)}
onChange={(v) => {
setValue(v);
props.onChange(v);
}}
multi={props.multi}
allowEmptyQuery={true}
/>
);
});
const Component = React.forwardRef<any, any>((props, ref) => {
const [value, setValue] = React.useState(() => {
const initialValue = props.value ?? '';
return typeof initialValue === 'string' ? initialValue : String(initialValue);
});
const parseValue = (val: string) => {
if (!val || typeof val !== 'string') return [];
return val.split(',').map((i) => i.trim()).filter((i) => i);
};
return (
<LanguageSelectAutoCompleteFC
ref={ref as any}
height="auto"
selectedKeys={parseValue(value)}
onChange={(v) => {
try {
setValue(v);
props.onChange?.(v);
} catch (error) {
console.error('Error in LanguageSelectAutoComplete onChange:', error);
}
}}
multi={props.multi}
allowEmptyQuery={true}
/>
);
});
🤖 Prompt for AI Agents
In packages/ui-default/components/autocomplete/LanguageSelectAutoComplete.tsx
around lines 5 to 20, improve the robustness of the value parsing by safely
handling cases where props.value might be undefined or not a string, and ensure
the splitting and trimming logic gracefully handles malformed input.
Additionally, wrap the props.onChange call in a try-catch block to handle any
potential errors during the callback execution without breaking the component.


export default class LanguageSelectAutoComplete extends AutoComplete {
static DOMAttachKey = 'ucwLanguageSelectAutoCompleteInstance';

constructor($dom, options) {
super($dom, {
classes: 'language-select',
...options,
});
}

attach() {
const value = this.$dom.val();
this.component.render(
<Component
ref={(ref) => { this.ref = ref; }}
value={value}
multi={this.options.multi}
onChange={this.onChange}
/>,
);
}
}

window.Hydro.components.LanguageSelectAutoComplete = LanguageSelectAutoComplete;
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { AutoComplete, AutoCompleteHandle, AutoCompleteProps } from '@hydrooj/components';
import PropTypes from 'prop-types';
import React, { forwardRef } from 'react';

interface LanguageFakeDoc {
_id: string
name: string
}
const LanguageSelectAutoComplete = forwardRef<AutoCompleteHandle<LanguageFakeDoc>, AutoCompleteProps<LanguageFakeDoc>>((props, ref) => (
<AutoComplete<LanguageFakeDoc>
ref={ref as any}
cacheKey={`language-${UiContext.domainId}`}
queryItems={async (query) => {
console.log('query', query);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const prefixes = new Set(Object.keys(window.LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
const listAll = Object.keys(window.LANGS)
.filter((i) => !prefixes.has(i))
.map((i) => ({
name: `${i.includes('.') ? `${window.LANGS[i.split('.')[0]].display || ''}/` : ''}${window.LANGS[i].display}`,
_id: i,
}));
const q = query.toLocaleLowerCase();
return listAll.filter((el) =>
el._id.toLocaleLowerCase().includes(q) || el.name.toLocaleLowerCase().includes(q),
);
}}
fetchItems={async (ids) => {
// api('problems', { ids: ids.map((i) => +i) }, ['docId', 'pid', 'title'])
console.log('ids', ids);
const prefixes = new Set(Object.keys(window.LANGS).filter((i) => i.includes('.')).map((i) => i.split('.')[0]));
const listAll = Object.keys(window.LANGS)
.filter((i) => !prefixes.has(i))
.map((i) => ({
name: `${i.includes('.') ? `${window.LANGS[i.split('.')[0]].display || ''}/` : ''}${window.LANGS[i].display}`,
_id: i,
}));
return listAll;
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
itemText={(pdoc) => pdoc.name}
itemKey={(pdoc) => pdoc._id}
renderItem={(pdoc) => (
<div className="media">
<div className="media__body medium">
<div className="language-select__name">{pdoc.name}</div>
<div className="language-select__id">{pdoc._id}</div>
</div>
</div>
)}
{...{
width: '100%',
height: 'auto',
listStyle: {},
multi: false,
selectedKeys: [],
allowEmptyQuery: false,
freeSolo: false,
freeSoloConverter: (input) => input,
...props,
}}
/>
));

LanguageSelectAutoComplete.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,
};

LanguageSelectAutoComplete.displayName = 'LanguageSelectAutoComplete';

export default LanguageSelectAutoComplete;
3 changes: 3 additions & 0 deletions packages/ui-default/locales/zh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ Ledo: 乐多
Legacy mode: 兼容模式
Light: 亮色
Limit user to finish contest within N hours: 限制用户在N小时内完成比赛
Limit contest submission language: 限制比赛可提交的语言
Limitations: 限制
Links: 链接
List View: 列表视图
Expand Down Expand Up @@ -821,6 +822,8 @@ Section: 章节
Security: 安全
Select a node to create discussion.: 选择一个节点来发表讨论。
Select a role: 选择一个角色
Select All: 全选
Select None: 全不选
Select Category: 选择标签
Select User: 选择用户
Selected categories: 已选标签
Expand Down
10 changes: 10 additions & 0 deletions packages/ui-default/pages/contest_edit.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import $ from 'jquery';
import moment from 'moment';
import ProblemSelectAutoComplete from 'vj/components/autocomplete/ProblemSelectAutoComplete';
import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete';
import LanguageSelectAutoComplete from 'vj/components/autocomplete/LanguageSelectAutoComplete';
import { ConfirmDialog } from 'vj/components/dialog';
import { NamedPage } from 'vj/misc/Page';
import { i18n, request, tpl } from 'vj/utils';

const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create', 'homework_edit'], (pagename) => {
ProblemSelectAutoComplete.getOrConstruct($('[name="pids"]'), { multi: true, clearDefaultValue: false });
UserSelectAutoComplete.getOrConstruct<true>($('[name="maintainer"]'), { multi: true, clearDefaultValue: false });
LanguageSelectAutoComplete.getOrConstruct($('[name="limitLangList"]'), { multi: true, clearDefaultValue: false });
$('[name="rule"]').on('change', () => {
const rule = $('[name="rule"]').val();
$('.contest-rule-settings input').attr('disabled', 'disabled');
Expand All @@ -30,6 +32,14 @@ const page = new NamedPage(['contest_edit', 'contest_create', 'homework_create',
$(`[data-perm="${type}"] input`).removeAttr('disabled');
$(`[data-perm="${type}"]`).show();
}).trigger('change');
$('[name=limitLang]').removeAttr('disabled').on('change', () => {
const checked = $('[name=limitLang]').is(':checked');
if (checked) {
$('#language-select-part').show();
} else {
$('#language-select-part').hide();
}
}).trigger('change');
if (pagename.endsWith('edit')) {
let confirmed = false;
$(document).on('click', '[value="delete"]', (ev) => {
Expand Down
16 changes: 16 additions & 0 deletions packages/ui-default/templates/contest_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ <h2 class="section__title">{{ _('Basic Info') }}</h2>
markdown:true
}) }}
</div>
<div class="section__body">
<div class="row">
{{ form.form_checkbox({
columns:9,
label:'Limit language',
name:'limitLang',
placeholder:_('Limit contest submission language'),
value:isLimitLang,
row:false,
disabled:true
}) }}
</div>
<div id="language-select-part" {% if not isLimitLang %}style="display:none;"{% endif %}>
<input name="limitLangList" value="{{limitLangListString}}">
</div>
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve form accessibility and structure.

The language limitation section has several accessibility and usability issues:

  1. The hidden input field lacks proper labeling
  2. No clear indication this is a read-only display
  3. Missing form validation attributes
 <div class="section__body">
   <div class="row">
     {{ form.form_checkbox({
       columns:9,
       label:'Limit language',
       name:'limitLang',
       placeholder:_('Limit contest submission language'),
       value:isLimitLang,
       row:false,
-      disabled:true
+      disabled:true,
+      help_text:_('(Read-only)')
     }) }}
   </div>
   <div id="language-select-part"{% if not isLimitLang %} style="display:none;"{% endif %}>
-    <input name="limitLangList" value="{{limitLangListString}}">
+    <input type="hidden" name="limitLangList" value="{{limitLangListString}}" aria-label="Selected languages list">
+    {% if isLimitLang and limitLangListString %}
+      <div class="form-item">
+        <label class="form-item__label">{{ _('Selected Languages') }}</label>
+        <div class="form-item__body">
+          <span class="text-muted">{{ limitLangListString }}</span>
+        </div>
+      </div>
+    {% endif %}
   </div>
 </div>
📝 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
<div class="section__body">
<div class="row">
{{ form.form_checkbox({
columns:9,
label:'Limit language',
name:'limitLang',
placeholder:_('Limit contest submission language'),
value:isLimitLang,
row:false,
disabled:true
}) }}
</div>
<div id="language-select-part" {% if not isLimitLang %}style="display:none;"{% endif %}>
<input name="limitLangList" value="{{limitLangListString}}">
</div>
</div>
<div class="section__body">
<div class="row">
{{ form.form_checkbox({
columns:9,
label:'Limit language',
name:'limitLang',
placeholder:_('Limit contest submission language'),
value:isLimitLang,
row:false,
disabled:true,
help_text:_('(Read-only)')
}) }}
</div>
<div id="language-select-part"{% if not isLimitLang %} style="display:none;"{% endif %}>
<input type="hidden" name="limitLangList" value="{{limitLangListString}}" aria-label="Selected languages list">
{% if isLimitLang and limitLangListString %}
<div class="form-item">
<label class="form-item__label">{{ _('Selected Languages') }}</label>
<div class="form-item__body">
<span class="text-muted">{{ limitLangListString }}</span>
</div>
</div>
{% endif %}
</div>
</div>
🧰 Tools
🪛 HTMLHint (1.5.0)

[error] 94-94: Special characters must be escaped : [ < ].

(spec-char-escape)


[error] 94-94: Special characters must be escaped : [ > ].

(spec-char-escape)

🤖 Prompt for AI Agents
In packages/ui-default/templates/contest_edit.html around lines 82 to 97,
improve accessibility and usability of the language limitation section by adding
a proper label linked to the hidden input field, indicating that the field is
read-only or disabled, and including appropriate form validation attributes such
as required or aria attributes to clarify its state. Ensure the input is clearly
associated with its label and that users understand it is not editable.

<div style="padding-top: 0" class="section__header">
<h2 class="section__title">{{ _('Permission Control') }}</h2>
</div>
Expand Down