ui: domain_group management

This commit is contained in:
undefined committed 2022-01-12 15:44:58 +08:00
1 parent 8144e85c14
commit b1c63ddd6c
17 files changed
+270 -58

No files matched your search

+2 -1
View File
@@ -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
+1
View File
@@ -259,6 +259,7 @@ domain_dashboard: 管理域
domain_discussion: 讨论节点
domain_edit: 编辑域资料
domain_file: 我的文件
domain_group: 管理小组
domain_join_applications: 加域申请
domain_join: 加入域
domain_main: 首页
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "3.3.8",
"version": "3.3.9",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
+9 -5
View File
@@ -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)
+1 -1
View File
@@ -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 });
}
}
+1 -1
View File
@@ -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();
@@ -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<HTMLElement>, 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<T = DOMAttachedObject>($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<HTMLElement>, monitorDetach = false) {
if ($dom == null) return null;
this.$dom = $dom;
this.id = ++DOMAttachedObject.uniqueIdCounter;
@@ -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<Multi extends boolean> 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<Multi>) {
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;
}
@@ -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<AutoComplete>,
props: AutoCompleteOptions, ref: React.ForwardedRef<AutoComplete>,
) {
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<AutoComplete>();
const itemsBox = useRef([]);
@@ -4,8 +4,29 @@ import { assign } from 'lodash';
import DOMAttachedObject from 'vj/components/DOMAttachedObject';
import AutoCompleteFC from './components/AutoComplete';
export interface AutoCompleteOptions<Multi extends boolean = boolean> {
multi?: Multi;
defaultItems?: string;
width?: string;
height?: string;
listStyle?: any;
allowEmptyQuery?: boolean;
freeSolo?: boolean;
freeSoloConverter?: any;
onChange?: (value) => any;
items?: () => Promise<any[]>;
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;
}
@@ -4,7 +4,7 @@ import zIndexManager from 'vj/utils/zIndexManager';
export interface DialogOptions {
classes: string;
$body: HTMLElement;
$body: HTMLElement | JQuery<HTMLElement> | string;
$action: any;
width?: string;
height?: string;
@@ -83,6 +83,11 @@ export class ActionDialog extends Dialog {
...options,
});
}
clear() {
this.$dom.find('input').val('');
return this;
}
}
export class ConfirmDialog extends Dialog {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.29.5",
"version": "4.29.6",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -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`
<div>
<div class="row"><div class="columns">
<h1>${i18n('Create Group')}</h1>
</div></div>
<div class="row"><div class="columns">
<label>
${i18n('Group Name')}
<input name="create_group_name" type="text" class="textbox" data-autofocus>
</label>
</div></div>
<div class="row"><div class="columns">
<label>
${i18n('Users')}
<input name="create_group_users" type="text" class="textbox" autocomplete="off">
</label>
</div></div>
</div>
`);
createGroupDialogContent.appendTo(document.body);
const userSelect = UserSelectAutoComplete.getOrConstruct<UserSelectAutoComplete<true>>(
createGroupDialogContent.find('[name="create_group_users"]'),
{ multi: true },
);
$('input[data-gid]').get().forEach((ele) => {
const input = UserSelectAutoComplete.getOrConstruct<UserSelectAutoComplete<true>>($(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`
<div class="typo">
<p>${i18n('Confirm deleting the selected groups?')}</p>
<p>${i18n('Users with those roles will be removed from the domain.')}</p>
</div>`,
}).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;
@@ -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') }}
</ol>
</li>
</ol>
@@ -0,0 +1,48 @@
{% extends "domain_base.html" %}
{% block domain_content %}
<div class="section">
<div class="section__header">
<h1 class="section__title">{{ _('{0}: Groups').format(domain.name) }}</h1>
<div class="section__tools">
<button class="primary rounded button" name="create_group">{{ _('Create Group') }}</button>
</div>
</div>
{{ noscript_note.render() }}
<div class="section__body no-padding domain-group">
<table class="data-table">
<colgroup>
<col class="col--checkbox">
<col class="col--group">
<col class="col--user">
</colgroup>
<thead>
<tr>
<th class="col--checkbox">
<label class="compact checkbox">
<input type="checkbox" name="select_all" data-checkbox-toggle="group">
</label>
</th>
<th class="col--group">{{ _('Group Name') }}</th>
<th class="col--user">{{ _('Users') }}</th>
</tr>
</thead>
<tbody>
{%- for gdoc in groups -%}
<tr data-gid="{{ gdoc.name }}">
<td class="col--checkbox">
<label class="compact checkbox">
<input type="checkbox" data-checkbox-group="group" data-checkbox-range>
</label>
</td>
<td class="col--group">{{ gdoc.name }}</td>
<td class="col--user"><input type="text" value="{{ gdoc.uids.join(',') }}" data-gid="{{ gdoc.name }}"></input></td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
<div class="section__body">
<button class="rounded button" name="remove_selected">{{ _('Remove Selected Group') }}</button>
</div>
</div>
{% endblock %}
@@ -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`
<div class="typo">
<p>${raw ? { html: msg, templateRaw: true } : msg}</p>
@@ -21,7 +23,7 @@ tpl.typoMsg = function (msg, raw = false) {
`;
};
export function rawHtml(html) {
export function rawHtml(html: string) {
return {
templateRaw: true,
html,