diff --git a/packages/hydrooj/locales/en.yaml b/packages/hydrooj/locales/en.yaml index 4ffd9c585..c78311e8d 100644 --- a/packages/hydrooj/locales/en.yaml +++ b/packages/hydrooj/locales/en.yaml @@ -2,8 +2,8 @@ __id: en __langname: English Can be Extended For: Extension contest_create: Contest Create -contest_detail_problem: Problem Detail contest_detail_problem_submit: Submit Contest Problem +contest_detail_problem: Problem Detail contest_detail: Contest Detail contest_edit: Contest Edit contest_main: Contest @@ -17,6 +17,7 @@ display_name: Display Name domain_dashboard: Dashboard domain_discussion: Discussion Nodes domain_edit: Edit Info +domain_group: Manage Groups domain_join_applications: Join Applications domain_main: Main domain_permission: System Permission diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index 8a9e979b7..a9cd1c81b 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -259,6 +259,7 @@ domain_dashboard: 管理域 domain_discussion: 讨论节点 domain_edit: 编辑域资料 domain_file: 我的文件 +domain_group: 管理小组 domain_join_applications: 加域申请 domain_join: 加入域 domain_main: 首页 diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 8b6820c50..ec50a4c38 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.3.8", + "version": "3.3.9", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/handler/domain.ts b/packages/hydrooj/src/handler/domain.ts index 5cd7df092..f78689d75 100644 --- a/packages/hydrooj/src/handler/domain.ts +++ b/packages/hydrooj/src/handler/domain.ts @@ -27,9 +27,9 @@ registerValue('GroupInfo', [ ]); registerResolver('Query', 'domain(id: String)', 'Domain', async (args, ctx) => { - const ddoc = await domain.get(args.id); + const ddoc = args.id ? await domain.get(args.id) : ctx.domain; if (!ddoc) return null; - const udoc = await user.getById(args.id, ctx.user._id); + const udoc = await user.getById(ddoc._id, ctx.user._id); if (!udoc.hasPerm(PERM.PERM_VIEW) && !udoc.hasPriv(PRIV.PRIV_VIEW_ALL_DOMAIN)) return null; ctx.udoc = udoc; return ddoc; @@ -37,7 +37,7 @@ registerResolver('Query', 'domain(id: String)', 'Domain', async (args, ctx) => { registerResolver('Domain', 'manage', 'DomainManage', async (args, ctx) => { if (!ctx.udoc.hasPerm(PERM.PERM_EDIT_DOMAIN)) throw new PermissionError(PERM.PERM_EDIT_DOMAIN); - return {}; + return ctx.parent; }); registerResolver('DomainManage', 'group', 'DomainGroup', (args, ctx) => ctx.parent); @@ -267,8 +267,12 @@ class DomainJoinApplicationsHandler extends ManageHandler { } class DomainUserGroupHandler extends ManageHandler { - async get() { - this.response.body = 'domain_group.html'; + async get({ domainId }) { + this.response.template = 'domain_group.html'; + this.response.body = { + domain: this.domain, + groups: await user.listGroup(domainId), + }; } @param('name', Types.Name) diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 81179a4c3..94b3f7078 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -392,7 +392,7 @@ class UserModel { } static updateGroup(domainId: string, name: string, uids: number[]) { - return collGroup.updateOne({ domainId, name }, { $set: { uids } }); + return collGroup.updateOne({ domainId, name }, { $set: { uids } }, { upsert: true }); } } diff --git a/packages/hydrooj/src/service/monitor.ts b/packages/hydrooj/src/service/monitor.ts index eae184c3f..77b0deacc 100644 --- a/packages/hydrooj/src/service/monitor.ts +++ b/packages/hydrooj/src/service/monitor.ts @@ -96,7 +96,7 @@ if (process.env.NODE_APP_INSTANCE === '0') { { upsert: true }, ); feedback(); - setInterval(update, 60 * 1000); + setInterval(update, 1800 * 1000); }); const taskColl = db.collection('task'); let taskCount = await taskColl.find().count(); diff --git a/packages/ui-default/components/DOMAttachedObject.js b/packages/ui-default/components/DOMAttachedObject.ts similarity index 82% rename from packages/ui-default/components/DOMAttachedObject.js rename to packages/ui-default/components/DOMAttachedObject.ts index fbe251e82..b0d095dfe 100644 --- a/packages/ui-default/components/DOMAttachedObject.js +++ b/packages/ui-default/components/DOMAttachedObject.ts @@ -23,16 +23,24 @@ function monitorResource(resource) { export default class DOMAttachedObject { static uniqueIdCounter = 0; + static DOMAttachKey: string; + static DOMAttachSelector: string; + static DOMDetachSelector: string; + // FIXME should be JQuery, but typings for $.transition() wasn't found + $dom: any; + id: number; + detached: boolean; + eventNS: string; static get($obj) { const key = this.DOMAttachKey; return $obj.data(key); } - static getOrConstruct($obj, ...args) { + static getOrConstruct($obj, ...args): T { const $singleObj = $obj.eq(0); const key = this.DOMAttachKey; - const Protoclass = this; + const Protoclass = this as any; const instance = this.get($singleObj); if (instance !== undefined) return instance; const newInstance = new Protoclass($singleObj, ...args); @@ -43,7 +51,7 @@ export default class DOMAttachedObject { return newInstance; } - static attachAll(container = document.body, ...args) { + static attachAll(container: Document | HTMLElement = document.body, ...args) { if (process.env.NODE_ENV !== 'production') { if (!this.DOMAttachSelector) { // eslint-disable-next-line quotes @@ -63,7 +71,7 @@ export default class DOMAttachedObject { } } - static detachAll(container = document.body) { + static detachAll(container: Document | HTMLElement = document.body) { const selector = this.DOMDetachSelector || this.DOMAttachSelector; if (process.env.NODE_ENV !== 'production') { if (!selector) { @@ -104,13 +112,13 @@ export default class DOMAttachedObject { } detach() { - if (this.constructor.DOMAttachKey) { - this.$dom.removeData(this.constructor.DOMAttachKey); + if ((this.constructor as any).DOMAttachKey) { + this.$dom.removeData((this.constructor as any).DOMAttachKey); } this.detached = true; } - constructor($dom, monitorDetach = false) { + constructor($dom: JQuery, monitorDetach = false) { if ($dom == null) return null; this.$dom = $dom; this.id = ++DOMAttachedObject.uniqueIdCounter; diff --git a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx similarity index 70% rename from packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx rename to packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx index 4b56e034f..877eacc26 100644 --- a/packages/ui-default/components/autocomplete/UserSelectAutoComplete.jsx +++ b/packages/ui-default/components/autocomplete/UserSelectAutoComplete.tsx @@ -3,31 +3,31 @@ import ReactDOM from 'react-dom'; import { QueryClient, QueryClientProvider } from 'react-query'; import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; -import AutoComplete from '.'; +import AutoComplete, { AutoCompleteOptions } from '.'; import UserSelectAutoCompleteFC from './components/UserSelectAutoComplete'; -export default class UserSelectAutoComplete extends AutoComplete { +export default class UserSelectAutoComplete extends AutoComplete { static DOMAttachKey = 'ucwUserSelectAutoCompleteInstance'; + client = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + retry: false, + staleTime: Infinity, + }, + }, + }); - constructor($dom, options) { + constructor($dom, options: AutoCompleteOptions) { super($dom, { classes: 'user-select', ...options, }); - this.client = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - retry: false, - staleTime: Infinity, - }, - }, - }); } - value() { + value(): Multi extends true ? number[] : string { if (this.options.multi) return this.ref?.getSelectedItems().map((item) => item._id) ?? this.$dom.val(); return this.ref?.getSelectedItems()[0] ?? null; } diff --git a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx index 86ae6379a..0f759b94e 100644 --- a/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx +++ b/packages/ui-default/components/autocomplete/components/UserSelectAutoComplete.tsx @@ -5,30 +5,18 @@ import { useQuery } from 'react-query'; import PropTypes from 'prop-types'; import api, { gql } from 'vj/utils/api'; import AutoComplete from './AutoComplete'; - -interface UserSelectAutoCompleteProps { - multi?: boolean; - defaultItems?: string; - width: string, - height: string, - listStyle: any, - onChange: (value) => any, - allowEmptyQuery: boolean, - freeSolo: boolean, - freeSoloConverter: any, -} +import type { AutoCompleteOptions } from '..'; // eslint-disable-next-line prefer-arrow-callback const UserSelectAutoComplete = forwardRef(function UserSelectAutoComplete( - props: UserSelectAutoCompleteProps, ref: React.ForwardedRef, + props: AutoCompleteOptions, ref: React.ForwardedRef, ) { const multi = props.multi ?? false; const rawDefaultItems = props.defaultItems ?? ''; const defaultItems = multi ? rawDefaultItems .split(',') - .map((i) => i.trim()) - .filter((i) => i.length) - .map((i) => +i) : rawDefaultItems; + .map((i) => +i.trim()) + .filter((i) => i) : rawDefaultItems; const comRef = useRef(); const itemsBox = useRef([]); diff --git a/packages/ui-default/components/autocomplete/index.jsx b/packages/ui-default/components/autocomplete/index.tsx similarity index 76% rename from packages/ui-default/components/autocomplete/index.jsx rename to packages/ui-default/components/autocomplete/index.tsx index 14f827ed3..977aeae37 100644 --- a/packages/ui-default/components/autocomplete/index.jsx +++ b/packages/ui-default/components/autocomplete/index.tsx @@ -4,8 +4,29 @@ import { assign } from 'lodash'; import DOMAttachedObject from 'vj/components/DOMAttachedObject'; import AutoCompleteFC from './components/AutoComplete'; +export interface AutoCompleteOptions { + multi?: Multi; + defaultItems?: string; + width?: string; + height?: string; + listStyle?: any; + allowEmptyQuery?: boolean; + freeSolo?: boolean; + freeSoloConverter?: any; + onChange?: (value) => any; + items?: () => Promise; + render?: () => string; + text?: () => string; +} + export default class AutoComplete extends DOMAttachedObject { static DOMAttachKey = 'ucwAutoCompleteInstance'; + ref = null; + container = document.createElement('div'); + options: AutoCompleteOptions; + changeListener = [ + (val) => this.$dom.val(val), + ]; constructor($dom, options = {}) { super($dom); @@ -16,7 +37,6 @@ export default class AutoComplete extends DOMAttachedObject { multi: false, ...options, }; - this.ref = null; this.clear = this.clear.bind(this); this.onChange = this.onChange.bind(this); this.attach = this.attach.bind(this); @@ -25,7 +45,6 @@ export default class AutoComplete extends DOMAttachedObject { this.value = this.value.bind(this); this.detach = this.detach.bind(this); this.focus = this.focus.bind(this); - this.container = document.createElement('div'); this.$dom.addClass('autocomplete-dummy').after(this.container); // Note: use `setTimeout(fn, 0)` to ensure that code is executed after browser autofill // also see https://stackoverflow.com/a/779785/13553984 @@ -38,8 +57,9 @@ export default class AutoComplete extends DOMAttachedObject { else this.ref.closeList(); } - onChange(val) { - this.$dom.val(val); + onChange(val: string | ((v: string) => any)) { + if (typeof val === 'string') this.changeListener.forEach((f) => f(val)); + else this.changeListener.push(val); } attach() { @@ -70,7 +90,7 @@ export default class AutoComplete extends DOMAttachedObject { this.ref.closeList(); } - value() { + value(): any { if (this.options.multi) return this.$dom.val(); return this.ref?.getSelectedItems()[0] ?? null; } diff --git a/packages/ui-default/components/dialog/DomDialog.ts b/packages/ui-default/components/dialog/DomDialog.ts index ab939d92d..04dbe3d23 100644 --- a/packages/ui-default/components/dialog/DomDialog.ts +++ b/packages/ui-default/components/dialog/DomDialog.ts @@ -4,7 +4,7 @@ import zIndexManager from 'vj/utils/zIndexManager'; export interface DialogOptions { classes: string; - $body: HTMLElement; + $body: HTMLElement | JQuery | string; $action: any; width?: string; height?: string; diff --git a/packages/ui-default/components/dialog/index.ts b/packages/ui-default/components/dialog/index.ts index 1aef54a92..0be5aa5c8 100644 --- a/packages/ui-default/components/dialog/index.ts +++ b/packages/ui-default/components/dialog/index.ts @@ -83,6 +83,11 @@ export class ActionDialog extends Dialog { ...options, }); } + + clear() { + this.$dom.find('input').val(''); + return this; + } } export class ConfirmDialog extends Dialog { diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index 04be1aae4..82cb5e9a0 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.29.5", + "version": "4.29.6", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/domain_group.page.ts b/packages/ui-default/pages/domain_group.page.ts new file mode 100644 index 000000000..77d1e3b47 --- /dev/null +++ b/packages/ui-default/pages/domain_group.page.ts @@ -0,0 +1,134 @@ +import _ from 'lodash'; + +import { NamedPage } from 'vj/misc/Page'; +import Notification from 'vj/components/notification'; +import { ConfirmDialog, ActionDialog } from 'vj/components/dialog'; + +import tpl from 'vj/utils/tpl'; +import delay from 'vj/utils/delay'; +import i18n from 'vj/utils/i18n'; +import UserSelectAutoComplete from 'vj/components/autocomplete/UserSelectAutoComplete'; +import api, { gql } from 'vj/utils/api'; + +function update(name: string, uids: number[]) { + return api(gql` + domain { manage { group { + update(name: ${name}, uids: ${uids}) + } } } + `); +} +function del(name: string) { + return api(gql` + domain { manage { group { + del(name: ${name}) + } } } + `); +} + +const page = new NamedPage('domain_group', () => { + const createGroupDialogContent = $(tpl` +
+
+

${i18n('Create Group')}

+
+
+ +
+
+ +
+
+ `); + createGroupDialogContent.appendTo(document.body); + const userSelect = UserSelectAutoComplete.getOrConstruct>( + createGroupDialogContent.find('[name="create_group_users"]'), + { multi: true }, + ); + $('input[data-gid]').get().forEach((ele) => { + const input = UserSelectAutoComplete.getOrConstruct>($(ele), { multi: true }); + const gid = ele.getAttribute('data-gid'); + let loaded = false; + input.onChange(() => { + if (input.value.length) { + loaded = true; + return; + } + if (!loaded) return; + update(gid, input.value()); + }); + }); + + const createGroupDialog = new ActionDialog({ + $body: createGroupDialogContent, + onDispatch(action) { + const $name = createGroupDialog.$dom.find('[name="create_group_name"]'); + if (action === 'ok' && ($name.val() === '' || !userSelect.value())) { + $name.focus(); + return false; + } + return true; + }, + }); + createGroupDialog.clear = function () { + userSelect.clear(); + createGroupDialog.$dom.find('[name="create_group_name"]').val(''); + return this; + }; + + function ensureAndGetSelectedGroups() { + const groups = _.map( + $('.domain-group tbody [type="checkbox"]:checked'), + (ch) => $(ch).closest('tr').attr('data-gid'), + ); + if (groups.length === 0) { + Notification.error(i18n('Please select at least one group to perform this operation.')); + return null; + } + return groups; + } + + async function handleClickCreateGroup() { + const action = await createGroupDialog.clear().open(); + if (action !== 'ok') return; + const name = createGroupDialog.$dom.find('[name="create_group_name"]').val() as string; + const uids = userSelect.value(); + try { + await update(name, uids); + window.location.reload(); + } catch (error) { + Notification.error(error.message); + } + } + + async function handleClickDeleteSelected() { + const selectedGroups = ensureAndGetSelectedGroups(); + if (selectedGroups === null) return; + const action = await new ConfirmDialog({ + $body: tpl` +
+

${i18n('Confirm deleting the selected groups?')}

+

${i18n('Users with those roles will be removed from the domain.')}

+
`, + }).open(); + if (action !== 'yes') return; + try { + await Promise.all(selectedGroups.map((name) => del(name))); + Notification.success(i18n('Selected groups have been deleted.')); + await delay(2000); + window.location.reload(); + } catch (error) { + Notification.error(error.message); + } + } + + $('[name="create_group"]').click(() => handleClickCreateGroup()); + $('[name="delete_selected"]').click(() => handleClickDeleteSelected()); +}); + +export default page; diff --git a/packages/ui-default/templates/domain_base.html b/packages/ui-default/templates/domain_base.html index 537d4721c..f85cf31c1 100644 --- a/packages/ui-default/templates/domain_base.html +++ b/packages/ui-default/templates/domain_base.html @@ -25,6 +25,7 @@ {{ sidemenu.render_item(null, 'domain_role') }} {{ sidemenu.render_item(null, 'domain_user') }} {{ sidemenu.render_item(null, 'domain_permission') }} + {{ sidemenu.render_item(null, 'domain_group') }} diff --git a/packages/ui-default/templates/domain_group.html b/packages/ui-default/templates/domain_group.html new file mode 100644 index 000000000..298c7c426 --- /dev/null +++ b/packages/ui-default/templates/domain_group.html @@ -0,0 +1,48 @@ +{% extends "domain_base.html" %} +{% block domain_content %} +
+
+

{{ _('{0}: Groups').format(domain.name) }}

+
+ +
+
+ {{ noscript_note.render() }} +
+ + + + + + + + + + + + + + + {%- for gdoc in groups -%} + + + + + + {%- endfor -%} + +
+ + {{ _('Group Name') }}{{ _('Users') }}
+ + {{ gdoc.name }}
+
+
+ +
+
+{% endblock %} diff --git a/packages/ui-default/utils/tpl.js b/packages/ui-default/utils/tpl.ts similarity index 65% rename from packages/ui-default/utils/tpl.js rename to packages/ui-default/utils/tpl.ts index d602f1ee2..80771c8af 100644 --- a/packages/ui-default/utils/tpl.js +++ b/packages/ui-default/utils/tpl.ts @@ -1,10 +1,12 @@ import _ from 'lodash'; -export default function tpl(pieces, ...substitutions) { +type Substitution = string | { templateRaw: true, html: string }; + +export default function tpl(pieces: TemplateStringsArray, ...substitutions: Substitution[]) { let result = pieces[0]; for (let i = 0; i < substitutions.length; ++i) { const subst = substitutions[i]; - let substHtml; + let substHtml: string; if (typeof subst === 'object' && subst.templateRaw) { substHtml = subst.html; } else substHtml = _.escape(String(subst)); @@ -13,7 +15,7 @@ export default function tpl(pieces, ...substitutions) { return result; } -tpl.typoMsg = function (msg, raw = false) { +tpl.typoMsg = function (msg: string, raw = false) { return tpl`

${raw ? { html: msg, templateRaw: true } : msg}

@@ -21,7 +23,7 @@ tpl.typoMsg = function (msg, raw = false) { `; }; -export function rawHtml(html) { +export function rawHtml(html: string) { return { templateRaw: true, html,