From 582b93852bc3287f52260133c7468b9bda883b80 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sun, 21 Mar 2021 16:51:30 +0100 Subject: [PATCH] Reworked the server group assignment modal --- ChangeLog.md | 5 +- shared/js/Mutex.ts | 62 +++ shared/js/connection/ClientInfo.ts | 259 +++++++++++ shared/js/tree/Client.ts | 27 +- .../ui/modal/group-assignment/Controller.ts | 409 ++++++++++++++++++ .../ui/modal/group-assignment/Definitions.ts | 57 +++ .../ui/modal/group-assignment/Renderer.scss | 172 ++++++++ .../js/ui/modal/group-assignment/Renderer.tsx | 222 ++++++++++ shared/js/ui/react-elements/Checkbox.tsx | 3 +- shared/js/ui/react-elements/Icon.tsx | 16 +- .../js/ui/react-elements/modal/Definitions.ts | 10 +- shared/js/ui/react-elements/modal/Registry.ts | 7 + shared/js/ui/tree/EntryTags.tsx | 84 ++-- shared/js/ui/utils/Variable.ts | 4 + 14 files changed, 1271 insertions(+), 66 deletions(-) create mode 100644 shared/js/Mutex.ts create mode 100644 shared/js/connection/ClientInfo.ts create mode 100644 shared/js/ui/modal/group-assignment/Controller.ts create mode 100644 shared/js/ui/modal/group-assignment/Definitions.ts create mode 100644 shared/js/ui/modal/group-assignment/Renderer.scss create mode 100644 shared/js/ui/modal/group-assignment/Renderer.tsx diff --git a/ChangeLog.md b/ChangeLog.md index c29085d3..9c47dc07 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,6 +1,9 @@ # Changelog: +* **21.03.21** + - Reworked the server group assignment modal. It now better reacts to the user input as well is now popoutable + * **18.03.21** - - Finally got fully rid of the initial backend glue and changes all systems to provider + - Finally, got fully rid of the initial backend glue and changes all systems to provider * **17.03.21** - Updated from webpack 4 to webpack 5 diff --git a/shared/js/Mutex.ts b/shared/js/Mutex.ts new file mode 100644 index 00000000..2c675e38 --- /dev/null +++ b/shared/js/Mutex.ts @@ -0,0 +1,62 @@ +export class Mutex { + private value: T; + private taskExecuting = false; + private taskQueue = []; + + constructor(value: T) { + this.value = value; + } + + execute(callback: (value: T, setValue: (newValue: T) => void) => R | Promise) : Promise { + return new Promise((resolve, reject) => { + this.taskQueue.push(() => new Promise(taskResolve => { + try { + const result = callback(this.value, newValue => this.value = newValue); + if(result instanceof Promise) { + result.then(result => { + taskResolve(); + resolve(result); + }).catch(error => { + taskResolve(); + reject(error); + }); + } else { + taskResolve(); + resolve(result); + } + } catch (error) { + taskResolve(); + reject(error); + } + })); + + if(!this.taskExecuting) { + this.executeNextTask(); + } + }); + } + + async tryExecute(callback: (value: T, setValue: (newValue: T) => void) => R | Promise) : Promise<{ status: "success", result: R } | { status: "would-block" }> { + if(!this.taskExecuting) { + return { + status: "success", + result: await this.execute(callback) + }; + } else { + return { + status: "would-block" + }; + } + } + + private executeNextTask() { + const task = this.taskQueue.pop_front(); + if(typeof task === "undefined") { + this.taskExecuting = false; + return; + } + + this.taskExecuting = true; + task().then(() => this.executeNextTask()); + } +} \ No newline at end of file diff --git a/shared/js/connection/ClientInfo.ts b/shared/js/connection/ClientInfo.ts new file mode 100644 index 00000000..8fd5c4af --- /dev/null +++ b/shared/js/connection/ClientInfo.ts @@ -0,0 +1,259 @@ +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; + +export type ClientInfoResult = { + status: "success", + + clientName: string, + clientUniqueId: string, + clientDatabaseId: number, +} | { + status: "not-found" +} | { + status: "error", + error: string +}; + +type PendingInfoRequest = { + promise: Promise, + resolve: (result: ClientInfoResult) => void, + fullFilled: boolean, +}; + +type ClientInfo = { + clientName: string, + clientUniqueId: string, + clientDatabaseId: number, +}; + +export class ClientInfoResolver { + private readonly handler: ConnectionHandler; + private readonly requestDatabaseIds: { [key: number]: PendingInfoRequest }; + private readonly requestUniqueIds: { [key: string]: PendingInfoRequest }; + + private executed: boolean; + + constructor(handler: ConnectionHandler) { + this.handler = handler; + this.executed = false; + + this.requestDatabaseIds = {}; + this.requestUniqueIds = {}; + } + + private registerRequest(type: "database-id", key: number) : Promise; + private registerRequest(type: "unique-id", key: string) : Promise; + private registerRequest(type, key) : Promise { + let targetObject; + switch (type) { + case "database-id": + targetObject = this.requestDatabaseIds; + break; + + case "unique-id": + targetObject = this.requestUniqueIds; + break; + + default: + return; + } + + if(this.executed) { + throw tr("request already executed"); + } + + if(typeof targetObject[key] === "undefined") { + const handle: PendingInfoRequest = { + fullFilled: false, + + resolve: undefined, + promise: undefined, + }; + + handle.promise = new Promise(resolve => handle.resolve = resolve); + targetObject[key] = handle; + } + + return targetObject[key].promise; + } + + private fullFullAllRequests(type: "database-id" | "unique-id", result: ClientInfoResult) { + let targetObject; + switch (type) { + case "database-id": + targetObject = this.requestDatabaseIds; + break; + + case "unique-id": + targetObject = this.requestUniqueIds; + break; + + default: + return; + } + + Object.keys(targetObject).forEach(key => { + if(targetObject[key].fullFilled) { + return; + } + + targetObject[key].fullFilled = true; + targetObject[key].resolve(result); + }); + } + + private static parseClientInfo(json: any[]) : ClientInfo[] { + const result: ClientInfo[] = []; + + let index = 0; + for(const entry of json) { + index++; + + if(typeof entry["cluid"] === "undefined") { + logWarn(LogCategory.NETWORKING, tr("Missing client unique id in client info result bulk %d."), index); + continue; + } + + if(typeof entry["clname"] === "undefined") { + logWarn(LogCategory.NETWORKING, tr("Missing client name in client info result bulk %d."), index); + continue; + } + + if(typeof entry["cldbid"] === "undefined") { + logWarn(LogCategory.NETWORKING, tr("Missing client database id in client info result bulk %d."), index); + continue; + } + + const databaseId = parseInt(entry["cldbid"]); + if(isNaN(databaseId)) { + logWarn(LogCategory.NETWORKING, tr("Client database id (%s) in client info isn't parse able as integer in bulk %d."), entry["cldbid"], index); + continue; + } + + result.push({ clientName: entry["clname"], clientUniqueId: entry["cluid"], clientDatabaseId: databaseId }); + } + + return result; + } + + getInfoByDatabaseId(databaseId: number) : Promise { + return this.registerRequest("database-id", databaseId); + } + + getInfoByUniqueId(uniqueId: string) : Promise { + return this.registerRequest("unique-id", uniqueId); + } + + async executeQueries() { + this.executed = true; + let promises = []; + let handlers = []; + + try { + const requestDatabaseIds = Object.keys(this.requestDatabaseIds); + if(requestDatabaseIds.length > 0) { + handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientgetnamefromdbid", command => { + ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => { + if(this.requestDatabaseIds[info.clientDatabaseId].fullFilled) { + return; + } + + this.requestDatabaseIds[info.clientDatabaseId].fullFilled = true; + this.requestDatabaseIds[info.clientDatabaseId].resolve({ + status: "success", + + clientName: info.clientName, + clientDatabaseId: info.clientDatabaseId, + clientUniqueId: info.clientUniqueId + }); + }); + })); + + promises.push(this.handler.serverConnection.send_command("clientgetnamefromdbid", + requestDatabaseIds.map(entry => ({ cldbid: entry })), + { + process_result: false, + } + ).catch(error => { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) { + return; + } + + error = error.formattedMessage(); + } else if(typeof error !== "string") { + logError(LogCategory.NETWORKING, tr("Failed to resolve client info from database id: %o"), error); + error = tr("lookup the console"); + } + + this.fullFullAllRequests("database-id", { + status: "error", + error: error + }); + })); + } + + const requestUniqueIds = Object.keys(this.requestUniqueIds); + if(requestUniqueIds.length > 0) { + handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientnamefromuid", command => { + ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => { + if(this.requestUniqueIds[info.clientUniqueId].fullFilled) { + return; + } + + this.requestUniqueIds[info.clientUniqueId].fullFilled = true; + this.requestUniqueIds[info.clientUniqueId].resolve({ + status: "success", + + clientName: info.clientName, + clientDatabaseId: info.clientDatabaseId, + clientUniqueId: info.clientUniqueId + }); + }); + })); + + promises.push(this.handler.serverConnection.send_command("clientgetnamefromuid", + requestUniqueIds.map(entry => ({ cluid: entry })), + { + process_result: false, + } + ).catch(error => { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) { + return; + } + + error = error.formattedMessage(); + } else if(typeof error !== "string") { + logError(LogCategory.NETWORKING, tr("Failed to resolve client info from unique id: %o"), error); + error = tr("lookup the console"); + } + + this.fullFullAllRequests("unique-id", { + status: "error", + error: error + }); + })); + } + + await Promise.all(promises); + + this.fullFullAllRequests("unique-id", { status: "not-found" }); + this.fullFullAllRequests("database-id", { status: "not-found" }); + } finally { + handlers.forEach(callback => callback()); + + this.fullFullAllRequests("unique-id", { + status: "error", + error: tr("request failed") + }); + + this.fullFullAllRequests("database-id", { + status: "error", + error: tr("request failed") + }); + } + } +} \ No newline at end of file diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index f95a08d1..cfe22dd1 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -12,7 +12,6 @@ import * as htmltags from "../ui/htmltags"; import {CommandResult} from "../connection/ServerConnectionDeclaration"; import {ChannelEntry} from "./Channel"; import {ConnectionHandler, ViewReasonId} from "../ConnectionHandler"; -import {createServerGroupAssignmentModal} from "../ui/modal/ModalGroupAssignment"; import {openClientInfo} from "../ui/modal/ModalClientInfo"; import {spawnBanClient} from "../ui/modal/ModalBanClient"; import {spawnChangeLatency} from "../ui/modal/ModalChangeLatency"; @@ -31,6 +30,7 @@ import {VideoClient} from "tc-shared/connection/VideoConnection"; import { tr } from "tc-shared/i18n/localize"; import {EventClient} from "tc-shared/connectionlog/Definitions"; import {W2GPluginCmdHandler} from "tc-shared/ui/modal/video-viewer/W2GPlugin"; +import {spawnServerGroupAssignments} from "tc-shared/ui/modal/group-assignment/Controller"; export enum ClientType { CLIENT_VOICE, @@ -472,30 +472,7 @@ export class ClientEntry extends Cha } open_assignment_modal() { - createServerGroupAssignmentModal(this as any, (groups, flag) => { - if(groups.length == 0) return Promise.resolve(true); - - if(groups.length == 1) { - if(flag) { - return this.channelTree.client.serverConnection.send_command("servergroupaddclient", { - sgid: groups[0], - cldbid: this.properties.client_database_id - }).then(() => true); - } else - return this.channelTree.client.serverConnection.send_command("servergroupdelclient", { - sgid: groups[0], - cldbid: this.properties.client_database_id - }).then(() => true); - } else { - const data = groups.map(e => { return {sgid: e}; }); - data[0]["cldbid"] = this.properties.client_database_id; - - if(flag) { - return this.channelTree.client.serverConnection.send_command("clientaddservergroup", data, {flagset: ["continueonerror"]}).then(() => true); - } else - return this.channelTree.client.serverConnection.send_command("clientdelservergroup", data, {flagset: ["continueonerror"]}).then(() => true); - } - }); + spawnServerGroupAssignments(this.channelTree.client, this.properties.client_database_id); } open_text_chat() { diff --git a/shared/js/ui/modal/group-assignment/Controller.ts b/shared/js/ui/modal/group-assignment/Controller.ts new file mode 100644 index 00000000..af2d0134 --- /dev/null +++ b/shared/js/ui/modal/group-assignment/Controller.ts @@ -0,0 +1,409 @@ +import {Registry} from "tc-events"; +import { + ModalClientGroupAssignmentEvents, + ModalClientGroupAssignmentVariables +} from "tc-shared/ui/modal/group-assignment/Definitions"; +import {createIpcUiVariableProvider, IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {ClientInfoResolver, ClientInfoResult} from "tc-shared/connection/ClientInfo"; +import {GroupManager, GroupType} from "tc-shared/permission/GroupManager"; +import PermissionType from "tc-shared/permission/PermissionType"; +import {spawnModal} from "tc-shared/ui/react-elements/modal"; +import {Mutex} from "tc-shared/Mutex"; +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration"; +import {ErrorCode} from "tc-shared/connection/ErrorCode"; +import {ModalController} from "tc-shared/ui/react-elements/modal/Definitions"; + +type AssignedGroups = { + status: "unloaded" +} | { + status: "error", + error: string +} | { + status: "success", + groups: number[] +}; + +const generateUniqueId = (handler: ConnectionHandler, clientDatabaseId: number) => "server-group-assignments-" + handler.handlerId + " - " + clientDatabaseId; + +class Controller { + readonly events: Registry; + readonly variables: IpcUiVariableProvider; + + readonly handler: ConnectionHandler; + readonly clientDatabaseId: number; + + private clientInfoTimeout: number; + private clientInfoPromise: Promise; + + private registeredListener: (() => void)[]; + private resendAvailableGroups = false; + + private assignedGroups: Mutex; + private assignmentLoadEnqueued = false; + + constructor(handler: ConnectionHandler, clientDatabaseId: number) { + this.handler = handler; + this.clientDatabaseId = clientDatabaseId; + + this.events = new Registry(); + this.variables = createIpcUiVariableProvider(); + + this.assignedGroups = new Mutex({ status: "unloaded" }); + this.registeredListener = []; + + this.variables.setVariableProvider("handlerId", () => this.handler.handlerId); + this.variables.setVariableProvider("availableGroups", () => { + this.resendAvailableGroups = false; + + const addPermissions = this.handler.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_ADD_POWER); + const removePermissions = this.handler.permissions.neededPermission(PermissionType.I_SERVER_GROUP_MEMBER_REMOVE_POWER); + + const serverGroups = this.handler.groups.serverGroups.slice(0).sort(GroupManager.sorter()); + return { + defaultGroup: this.handler.channelTree.server.properties.virtualserver_default_server_group, + groups: serverGroups.filter(entry => entry.type === GroupType.NORMAL).map(entry => ({ + name: entry.name, + groupId: entry.id, + saveDB: entry.properties.savedb, + + icon: { + iconId: entry.properties.iconid, + serverUniqueId: this.handler.getCurrentServerUniqueId(), + handlerId: this.handler.handlerId + }, + + addAble: addPermissions.granted(entry.requiredMemberAddPower), + removeAble: removePermissions.granted(entry.requiredMemberRemovePower) + })) + } + }); + this.variables.setVariableProviderAsync("targetClient", async () => { + const result = await this.getClientInfo(); + switch (result.status) { + case "error": + return { status: "error", message: result.error }; + + case "not-found": + return { status: "error", message: tr("not found") }; + + case "success": + return { + status: "success", + + clientName: result.clientName, + clientUniqueId: result.clientUniqueId, + clientDatabaseId: result.clientDatabaseId + }; + + default: + return { status: "error", message: tr("unknown status") }; + } + }); + + this.variables.setVariableProviderAsync("assignedGroupStatus", async () => { + const result = await this.assignedGroups.tryExecute(value => { + switch (value.status) { + case "success": + return { status: "loaded", assignedGroups: value.groups.length }; + + case "unloaded": + return { status: "loading" }; + + case "error": + default: + return { status: "error", message: value.error || tr("invalid status") }; + } + }); + + if(result.status === "would-block") { + return { status: "loading" }; + } else { + return result.result; + } + }); + + this.variables.setVariableProviderAsync("groupAssigned", groupId => this.assignedGroups.execute(assignedGroups => { + switch (assignedGroups.status) { + case "success": + return assignedGroups.groups.indexOf(groupId) !== -1; + + case "error": + case "unloaded": + default: + return false; + } + })); + + this.variables.setVariableEditorAsync("groupAssigned", async (newValue, groupId) => this.assignedGroups.execute(async assignedGroups => { + if(assignedGroups.status !== "success") { + return false; + } + + if((assignedGroups.groups.indexOf(groupId) === -1) !== newValue) { + /* No change to propagate but update the local value */ + return true; + } + + let command, action: "add" | "remove"; + if(newValue) { + action = "add"; + command = "servergroupaddclient"; + } else { + action = "remove"; + command = "servergroupdelclient"; + } + + try { + await this.handler.serverConnection.send_command(command, { sgid: groupId, cldbid: this.clientDatabaseId }); + assignedGroups.groups.toggle(groupId, newValue); + this.variables.sendVariable("assignedGroupStatus"); + } catch (error) { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { + this.events.fire("notify_toggle_result", { + action: action, + groupId: groupId, + groupName: "", + result: { + status: "no-permissions", + permission: this.handler.permissions.getFailedPermission(error) + } + }); + return false; + } else { + this.events.fire("notify_toggle_result", { + action: action, + groupId: groupId, + groupName: "", + result: { + status: "error", + reason: error.formattedMessage() + } + }); + return false; + } + } + + logError(LogCategory.NETWORKING, tr("Failed to toggle client server group %d: %o"), groupId, error); + return false; + } + + return true; + })); + + this.events.on("action_remove_all", () => { + this.assignedGroups.execute(async value => { + let args = []; + + switch (value.status) { + case "success": + if(value.groups.length === 0) { + return; + } + args = value.groups.map(entry => ({ sgid: entry })); + break; + + default: + return; + } + + args[0].cldbid = this.clientDatabaseId; + let result: CommandResult; + try { + result = await this.handler.serverConnection.send_command("servergroupdelclient", args); + } catch (error) { + if(error instanceof CommandResult) { + result = error; + } else { + logError(LogCategory.NETWORKING, tr("Failed to remote all server groups from target client: %o"), error); + return; + } + } + + const bulks = result.getBulks(); + if(bulks.length !== args.length) { + if(!result.success) { + /* result.bulks.length must be one then */ + logError(LogCategory.NETWORKING, tr("Failed to remote all server groups from target client: %o"), result.formattedMessage()); + return; + } else { + /* Server does not send a bulked response. We've to do a full refresh */ + this.events.fire("action_refresh", { slowMode: false }); + } + } else { + let statusUpdated = false; + for(let index = 0; index < args.length; index++) { + if(!bulks[index].success) { + continue; + } + + statusUpdated = true; + value.groups.remove(args[index].sgid); + } + + if(statusUpdated) { + this.variables.sendVariable("assignedGroupStatus"); + this.sendAllGroupStatus(); + } + } + }).then(undefined); + }); + + this.events.on("action_refresh", event => { + this.loadAssignedGroups(event.slowMode); + this.variables.sendVariable("assignedGroupStatus"); + }); + + this.registeredListener.push( + handler.groups.events.on(["notify_groups_created", "notify_groups_deleted", "notify_groups_received", "notify_groups_updated", "notify_reset"], () => this.enqueueGroupResend()) + ); + this.registeredListener.push(handler.permissions.register_needed_permission(PermissionType.I_SERVER_GROUP_MEMBER_ADD_POWER, () => this.enqueueGroupResend())); + this.registeredListener.push(handler.permissions.register_needed_permission(PermissionType.I_SERVER_GROUP_MEMBER_REMOVE_POWER, () => this.enqueueGroupResend())); + this.loadAssignedGroups(false); + } + + destroy() { + this.registeredListener.forEach(callback => callback()); + this.registeredListener = []; + + this.events.destroy(); + this.variables.destroy(); + } + + private sendAllGroupStatus() { + this.variables.getVariable("availableGroups", undefined).then(groups => { + groups.groups.forEach(group => this.variables.sendVariable("groupAssigned", group.groupId)); + }); + } + + private loadAssignedGroups(slowMode: boolean) { + if(this.assignmentLoadEnqueued) { + return; + } + + this.assignmentLoadEnqueued = true; + this.assignedGroups.execute(async (assignedGroups, setAssignedGroups) => { + if(slowMode) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + this.assignmentLoadEnqueued = false; + + let resultSet = false; + const unregisterCallback = this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyservergroupsbyclientid", command => { + const payload = command.arguments; + const clientId = parseInt(payload[0].cldbid); + if(isNaN(clientId) || clientId !== this.clientDatabaseId) { + return; + } + + let groups = []; + for(const entry of payload) { + const groupId = parseInt(entry.sgid); + if(isNaN(groupId)) { + logWarn(LogCategory.NETWORKING, tr("Failed to parse sgid as integer of server groups by client id response (%o)."), entry); + continue; + } + + groups.push(groupId); + } + + resultSet = true; + setAssignedGroups({ status: "success", groups: groups }); + }); + + try { + await this.handler.serverConnection.send_command("servergroupsbyclientid", { cldbid: this.clientDatabaseId }); + if(!resultSet) { + setAssignedGroups({ status: "error", error: tr("missing result") }); + } + } catch (error) { + if(!resultSet) { + if(error instanceof CommandResult) { + if(error.id === ErrorCode.DATABASE_EMPTY_RESULT) { + setAssignedGroups({ status: "success", groups: [] }); + } else if(error.id === ErrorCode.CLIENT_INVALID_ID) { + setAssignedGroups({ status: "error", error: tr("invalid client id") }); + } else { + setAssignedGroups({ status: "error", error: error.formattedMessage() }); + } + } else { + if(typeof error !== "string") { + logError(LogCategory.NETWORKING, tr("Failed to query client server groups: %o"), error); + error = tr("lookup the console"); + } + + setAssignedGroups({ status: "error", error: error }); + } + } + } finally { + unregisterCallback(); + } + }).then(() => { + this.variables.sendVariable("assignedGroupStatus"); + this.sendAllGroupStatus(); + }); + } + + private enqueueGroupResend() { + if(this.resendAvailableGroups) { + return; + } + + this.resendAvailableGroups = true; + this.variables.sendVariable("availableGroups"); + } + + private getClientInfo() : Promise { + if(typeof this.clientInfoTimeout === "number" && Date.now() < this.clientInfoTimeout) { + return this.clientInfoPromise; + } + + this.clientInfoTimeout = Date.now() + 5000; + return (this.clientInfoPromise = new Promise(resolve => { + const resolver = new ClientInfoResolver(this.handler); + resolver.getInfoByDatabaseId(this.clientDatabaseId).then(result => { + resolve(result); + switch (result.status) { + case "success": + case "not-found": + this.clientInfoTimeout = Date.now() + 60 * 1000; + break; + + case "error": + default: + this.clientInfoTimeout = Date.now() + 5 * 1000; + break; + } + }); + resolver.executeQueries().then(undefined); + })); + } +} + +let controllerInstances: {[key: string]: ModalController} = {}; +export function spawnServerGroupAssignments(handler: ConnectionHandler, targetClientDatabaseId: number) { + const uniqueId = generateUniqueId(handler, targetClientDatabaseId); + if(typeof controllerInstances[uniqueId] !== "undefined") { + controllerInstances[uniqueId].show().then(undefined); + return; + } + + const controller = new Controller(handler, targetClientDatabaseId); + const modal = spawnModal("modal-assign-server-groups", [ + controller.events.generateIpcDescription(), + controller.variables.generateConsumerDescription() + ], { + popoutable: true, + uniqueId: uniqueId + }); + controller.events.on("action_close", () => modal.destroy()); + modal.getEvents().on("destroy", () => { + delete controllerInstances[uniqueId]; + controller.destroy(); + }); + + modal.show().then(undefined); + controllerInstances[uniqueId] = modal; +} \ No newline at end of file diff --git a/shared/js/ui/modal/group-assignment/Definitions.ts b/shared/js/ui/modal/group-assignment/Definitions.ts new file mode 100644 index 00000000..7da5bb2f --- /dev/null +++ b/shared/js/ui/modal/group-assignment/Definitions.ts @@ -0,0 +1,57 @@ +import {RemoteIconInfo} from "tc-shared/file/Icons"; + +export type AvailableGroup = { + groupId: number, + saveDB: boolean, + + name: string, + icon: RemoteIconInfo | undefined, + + addAble: boolean, + removeAble: boolean, +} + +export type ClientInfo = { + status: "success", + + clientDatabaseId: number, + clientUniqueId: string, + clientName: string +} | { + status: "error", + message: string +}; + +export interface ModalClientGroupAssignmentVariables { + readonly handlerId: string, + readonly targetClient: ClientInfo, + readonly availableGroups: { + groups: AvailableGroup[], + defaultGroup: number + }, + readonly assignedGroupStatus: { status: "loaded", assignedGroups: number } | { status: "loading" } | { status: "error", message: string }; + groupAssigned: boolean, +} + +export interface ModalClientGroupAssignmentEvents { + action_close: {}, + action_remove_all: {}, + action_refresh: { slowMode: boolean }, + + notify_toggle_result: { + action: "add" | "remove", + + groupId: number, + groupName: string, + + result: { + status: "success" + } | { + status: "error", + reason: string + } | { + status: "no-permissions", + permission: string + }, + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/group-assignment/Renderer.scss b/shared/js/ui/modal/group-assignment/Renderer.scss new file mode 100644 index 00000000..7ec7307b --- /dev/null +++ b/shared/js/ui/modal/group-assignment/Renderer.scss @@ -0,0 +1,172 @@ +@import "../../../../css/static/mixin"; +@import "../../../../css/static/properties"; + +.container { + user-select: none; + + min-width: 20em; + width: 30em; + + min-height: 10em; + max-height: calc(100vh - 10rem); + + display: flex; + flex-direction: column; + justify-content: stretch; + + background-color: #2f2f35; + padding: .5em; + + + .title { + display: flex; + flex-direction: row; + justify-content: stretch; + + .clientInfo { + flex-grow: 1; + flex-shrink: 1; + + min-width: 4em; + + margin-bottom: .25em; + + .textError { + color: #cc0000; + } + } + + .refreshButton { + flex-grow: 0; + flex-shrink: 0; + } + } + + .containerButtons { + flex-grow: 0; + flex-shrink: 0; + + padding-top: 1em; + + display: flex; + flex-direction: row; + justify-content: space-between; + } + + &.windowed { + width: 100%; + height: 100%; + + max-height: 100%; + } +} + + +.assignmentList { + flex-shrink: 1; + flex-grow: 1; + + min-height: 4em; + height: 20em; + + padding: 3px; + overflow-y: auto; + + border: 1px #161616 solid; + border-radius: $border_radius_middle; + background-color: #28292b; + + position: relative; + + @include chat-scrollbar-vertical(); + + .entry { + flex-shrink: 0; + flex-grow: 0; + + display: flex; + flex-direction: row; + justify-content: stretch; + + height: 1.75em; + + > * { + flex-shrink: 0; + flex-grow: 0; + align-self: center; + } + + .checkbox { + margin-right: .5em; + } + + .icon { + margin-right: .25em; + } + + .name { + flex-shrink: 1; + flex-grow: 1; + + min-width: 6em; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .overlay { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + justify-content: center; + + .text { + align-self: center; + text-align: center; + + color: #666; + font-size: 1.2em; + + &.error { + color: #a32929; + } + } + } + + .iconContainer { + align-self: center; + margin-right: 4px; + margin-left: 2px; + margin-top: -2px; + } + + a { + align-self: center; + } +} + +.button { + display: flex; + flex-direction: column; + justify-content: center; + + align-self: center; + cursor: pointer; + + padding: .2em; + border-radius: .2em; + + transition: all ease-in-out $button_hover_animation_time; + + &:hover { + background-color: #0000004f; + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/group-assignment/Renderer.tsx b/shared/js/ui/modal/group-assignment/Renderer.tsx new file mode 100644 index 00000000..4c370366 --- /dev/null +++ b/shared/js/ui/modal/group-assignment/Renderer.tsx @@ -0,0 +1,222 @@ +import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; +import React, {useContext} from "react"; +import {IpcRegistryDescription, Registry} from "tc-events"; +import { + AvailableGroup, + ModalClientGroupAssignmentEvents, + ModalClientGroupAssignmentVariables +} from "tc-shared/ui/modal/group-assignment/Definitions"; +import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable"; +import {UiVariableConsumer} from "tc-shared/ui/utils/Variable"; +import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; +import {ClientTag} from "tc-shared/ui/tree/EntryTags"; +import {Checkbox} from "tc-shared/ui/react-elements/Checkbox"; +import {RemoteIconInfoRenderer} from "tc-shared/ui/react-elements/Icon"; +import {Button} from "tc-shared/ui/react-elements/Button"; +import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; +import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; +import {ClientIcon} from "svg-sprites/client-icons"; + +const cssStyle = require("./Renderer.scss"); +const VariablesContext = React.createContext>(undefined); +const EventsContext = React.createContext>(undefined); + +const GroupEntryRenderer = React.memo((props: { entry : AvailableGroup, defaultGroup: boolean, defaultGroupActive: boolean }) => { + const variables = useContext(VariablesContext); + const assigned = variables.useVariable("groupAssigned", props.entry.groupId, false); + + let disabled = false; + if(assigned.status === "applying") { + disabled = true; + } else if(assigned.localValue ? !props.entry.removeAble : !props.entry.addAble) { + disabled = true; + } else if(props.defaultGroup && !assigned.localValue) { + /* Only disable it if we haven't the group assigned in order to remove the assignment even though the groups is the default group */ + disabled = true; + } + + let nameSuffix; + if(props.defaultGroup) { + nameSuffix = " [default]"; + } else if(!props.entry.saveDB) { + nameSuffix = " [temp]"; + } + return ( +
+ assigned.setValue(value)} + disabled={disabled} + className={cssStyle.checkbox} + /> + +
{props.entry.name}{nameSuffix}
+
+ ); +}); + +const GroupListRenderer = React.memo(() => { + const variables = useContext(VariablesContext); + const serverGroups = variables.useReadOnly("availableGroups", undefined, { defaultGroup: -1, groups: [] }); + const assignmentStatus = variables.useReadOnly("assignedGroupStatus", undefined, { status: "loading" }); + + let body; + if(assignmentStatus.status === "loaded") { + body = serverGroups.groups.map(entry => ( + + )); + } else if(assignmentStatus.status === "loading") { + body = ( +
+
+ loading +
+
+ ); + } else if(assignmentStatus.status === "error") { + body = ( +
+
+ An error occurred:
+ {assignmentStatus.message} +
+
+ ); + } + return ( +
+ {body} +
+ ); +}); + +const Buttons = React.memo(() => { + const events = useContext(EventsContext); + const variables = useContext(VariablesContext); + const assignmentStatus = variables.useReadOnly("assignedGroupStatus", undefined, { status: "loading" }); + + return ( +
+ + +
+ ) +}); + +const ClientInfoRenderer = React.memo(() => { + const variables = useContext(VariablesContext); + const handlerId = variables.useReadOnly("handlerId", undefined, undefined); + const user = variables.useReadOnly("targetClient"); + + let inner; + if(handlerId && user.status === "loaded") { + let clientTag; + if(user.value.status === "success") { + clientTag = ; + } else { + clientTag =
{user.value.message}
; + } + + inner = ( + + {clientTag} + + ); + } else { + inner = Change server groups; + } + + return ( +
+ {inner} +
+ ); +}); + +const RefreshButton = React.memo(() => { + const events = useContext(EventsContext); + + return ( +
events.fire("action_refresh", { slowMode: true })}> + +
+ ) +}) + +const TitleRenderer = React.memo(() => { + const variables = useContext(VariablesContext); + const client = variables.useReadOnly("targetClient"); + const handlerId = variables.useReadOnly("handlerId", undefined, undefined); + + if(client.status === "loaded" && client.value.status === "success" && handlerId) { + return ( + + + + ); + } else { + return Server group assignments; + } +}); + +export default class ModalServerGroups extends AbstractModal { + private readonly events: Registry; + private readonly variables: UiVariableConsumer; + + constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor) { + super(); + + this.events = Registry.fromIpcDescription(events); + this.variables = createIpcUiVariableConsumer(variables); + } + + protected onDestroy() { + super.onDestroy(); + + this.events.destroy(); + this.variables.destroy(); + } + + renderBody(): React.ReactElement { + return ( + + +
+
+ + +
+ + +
+
+
+ ); + } + + renderTitle(): string | React.ReactElement { + return ( + + + + ) + } +} \ No newline at end of file diff --git a/shared/js/ui/react-elements/Checkbox.tsx b/shared/js/ui/react-elements/Checkbox.tsx index 033b83e0..63cafa20 100644 --- a/shared/js/ui/react-elements/Checkbox.tsx +++ b/shared/js/ui/react-elements/Checkbox.tsx @@ -11,6 +11,7 @@ export interface CheckboxProperties { value?: boolean; initialValue?: boolean; + className?: string; children?: never; } @@ -32,7 +33,7 @@ export class Checkbox extends React.Component const disabledClass = disabled ? cssStyle.disabled : ""; return ( -