Add hover and border
[GitHub/WoltLab/WCF.git] / ts / WoltLabSuite / Core / Ui / Acl / List.ts
1 /**
2 * @woltlabExcludeBundle all
3 */
4
5 import UiUserSearchInput from "WoltLabSuite/Core/Ui/User/Search/Input";
6 import { checkDependencies } from "WoltLabSuite/Core/Form/Builder/Field/Dependency/Manager";
7 import { getPhrase } from "WoltLabSuite/Core/Language";
8 import DomUtil from "WoltLabSuite/Core/Dom/Util";
9 import * as StringUtil from "WoltLabSuite/Core/StringUtil";
10 import { DatabaseObjectActionResponse } from "WoltLabSuite/Core/Ajax/Data";
11 import * as Ajax from "WoltLabSuite/Core/Ajax";
12 import { extend } from "WoltLabSuite/Core/Core";
13
14 interface AclOption {
15 categoryName: string;
16 label: string;
17 optionName: string;
18 }
19
20 interface AclValues {
21 label: {
22 [key: string]: string;
23 };
24 option: {
25 [key: string]: {
26 [key: string]: number;
27 };
28 };
29 }
30
31 interface AjaxResponse extends DatabaseObjectActionResponse {
32 returnValues: {
33 options: {
34 [key: string]: AclOption;
35 };
36 group: AclValues;
37 user: AclValues;
38 categories: {
39 [key: string]: string;
40 };
41 };
42 }
43
44 export = class AclList {
45 readonly #categoryName: string | undefined;
46 readonly #container: HTMLElement;
47 readonly #aclList: HTMLUListElement;
48 readonly #permissionList: HTMLDivElement;
49 readonly #searchInput: HTMLInputElement;
50 readonly #objectID: number;
51 readonly #objectTypeID: number;
52 readonly #aclValuesFieldName: string;
53 readonly #search: UiUserSearchInput;
54 #values: {
55 [key: string]: {
56 [key: string]: {
57 [key: string]: number;
58 };
59 };
60 };
61
62 constructor(
63 containerSelector: string,
64 objectTypeID: number,
65 categoryName: string | undefined,
66 objectID: number,
67 includeUserGroups: boolean,
68 initialPermissions: AjaxResponse | undefined,
69 aclValuesFieldName: string | undefined,
70 ) {
71 this.#objectID = objectID || 0;
72 this.#objectTypeID = objectTypeID;
73 this.#categoryName = categoryName;
74 if (includeUserGroups === undefined) {
75 includeUserGroups = true;
76 }
77 this.#values = {
78 group: {},
79 user: {},
80 };
81 this.#aclValuesFieldName = aclValuesFieldName || "aclValues";
82
83 // bind hidden container
84 this.#container = document.querySelector(containerSelector)!;
85 DomUtil.hide(this.#container);
86 this.#container.classList.add("aclContainer");
87
88 // insert container elements
89 const elementContainer = this.#container.querySelector("dd")!;
90 this.#aclList = document.createElement("ul");
91 this.#aclList.classList.add("aclList");
92 elementContainer.appendChild(this.#aclList);
93
94 this.#searchInput = document.createElement("input");
95 this.#searchInput.type = "text";
96 this.#searchInput.classList.add("long");
97 this.#searchInput.placeholder = getPhrase("wcf.acl.search." + (!includeUserGroups ? "user." : "") + "description");
98 elementContainer.appendChild(this.#searchInput);
99
100 this.#permissionList = document.createElement("div");
101 this.#permissionList.classList.add("aclPermissionList", "containerList");
102 DomUtil.hide(this.#permissionList);
103 elementContainer.appendChild(this.#permissionList);
104
105 // prepare search input
106 this.#search = new UiUserSearchInput(this.#searchInput, {
107 callbackSelect: this.addObject.bind(this),
108 includeUserGroups: includeUserGroups,
109 preventSubmit: true,
110 });
111
112 // bind event listener for submit
113 const form = this.#container.closest("form")!;
114 form.addEventListener("submit", () => {
115 this.submit();
116 });
117
118 // reset ACL on click
119 const resetButton = form.querySelector("input[type=reset]");
120 resetButton?.addEventListener("click", () => {
121 this.#reset();
122 });
123
124 if (initialPermissions) {
125 this.#success(initialPermissions);
126 } else {
127 this.#loadACL();
128 }
129 }
130
131 public getData() {
132 this.#savePermissions();
133
134 return this.#values;
135 }
136
137 public addObject(selectedItem: HTMLLIElement): boolean {
138 const type = selectedItem.dataset.type!;
139 const label = selectedItem.dataset.label!;
140 const objectId = selectedItem.dataset.objectId!;
141
142 const listItem = this.#createListItem(objectId, label, type);
143
144 // toggle element
145 this.#savePermissions();
146 this.#aclList.querySelectorAll("li").forEach((element: HTMLLIElement) => {
147 element.classList.remove("active");
148 });
149 listItem.classList.add("active");
150
151 this.#search.addExcludedSearchValues(label);
152
153 this.#select(listItem, false);
154
155 // clear search input
156 this.#searchInput.value = "";
157
158 // show permissions
159 DomUtil.show(this.#permissionList);
160
161 return false;
162 }
163
164 public submit() {
165 this.#savePermissions();
166
167 this.#save("group");
168 this.#save("user");
169 }
170
171 #reset() {
172 // reset stored values
173 this.#values = {
174 group: {},
175 user: {},
176 };
177
178 // remove entries
179 this.#aclList.innerHTML = "";
180 this.#searchInput.value = "";
181
182 // deselect all input elements
183 DomUtil.hide(this.#permissionList);
184 this.#permissionList.querySelectorAll("input[type=radio]").forEach((inputElement: HTMLInputElement) => {
185 inputElement.checked = false;
186 });
187 }
188
189 #loadACL() {
190 Ajax.apiOnce({
191 data: {
192 actionName: "loadAll",
193 className: "wcf\\data\\acl\\option\\ACLOptionAction",
194 parameters: {
195 categoryName: this.#categoryName,
196 objectID: this.#objectID,
197 objectTypeID: this.#objectTypeID,
198 },
199 },
200 success: (data: AjaxResponse) => {
201 this.#success(data);
202 },
203 });
204 }
205
206 #createListItem(objectID: string, label: string, type: string): HTMLLIElement {
207 const html = `<fa-icon size="16" name="${type === "group" ? "users" : "user"}" solid></fa-icon>
208 <span class="aclLabel">${StringUtil.escapeHTML(label)}</span>
209 <button type="button" class="aclItemDeleteButton jsTooltip" title="${getPhrase("wcf.global.button.delete")}">
210 <fa-icon size="16" name="xmark" solid></fa-icon>
211 </button>`;
212 const listItem = document.createElement("li");
213 listItem.innerHTML = html;
214 listItem.dataset.objectId = objectID;
215 listItem.dataset.type = type;
216 listItem.dataset.label = label;
217 listItem.addEventListener("click", () => {
218 if (listItem.classList.contains("active")) {
219 return;
220 }
221
222 this.#select(listItem, true);
223 });
224
225 const deleteButton = listItem.querySelector(".aclItemDeleteButton") as HTMLButtonElement;
226 deleteButton.addEventListener("click", () => this.#removeItem(listItem));
227
228 this.#aclList.appendChild(listItem);
229
230 return listItem;
231 }
232
233 #removeItem(listItem: HTMLLIElement) {
234 this.#savePermissions();
235
236 const type = listItem.dataset.type!;
237 const objectID = listItem.dataset.objectId!;
238
239 this.#search.removeExcludedSearchValues(listItem.dataset.label!);
240 listItem.remove();
241
242 // remove stored data
243 if (this.#values[type][objectID]) {
244 delete this.#values[type][objectID];
245 }
246
247 // try to select something else
248 this.#selectFirstEntry();
249 }
250
251 #selectFirstEntry() {
252 const listItem = this.#aclList.querySelector("li");
253 if (listItem) {
254 this.#select(listItem, false);
255 } else {
256 this.#reset();
257 }
258 }
259
260 #success(data: AjaxResponse) {
261 if (Object.keys(data.returnValues.options).length === 0) {
262 return;
263 }
264
265 const header = document.createElement("div");
266 header.classList.add("aclHeader");
267 header.innerHTML = `<span class="inherited">${getPhrase("wcf.acl.option.inherited")}</span>
268 <span class="grant">${getPhrase("wcf.acl.option.grant")}</span>
269 <span class="deny">${getPhrase("wcf.acl.option.deny")}</span>`;
270
271 this.#permissionList.appendChild(header);
272
273 // prepare options
274 const structure: { [key: string]: HTMLDivElement[] } = {};
275 for (const [optionID, option] of Object.entries(data.returnValues.options)) {
276 const listItem = document.createElement("div");
277
278 listItem.innerHTML = `<span>${StringUtil.escapeHTML(option.label)}</span>
279 <label for="inherited${optionID}" class="inherited jsTooltip" title="${getPhrase("wcf.acl.option.inherited")}">
280 <input type="radio" id="inherited${optionID}" />
281 </label>
282 <label for="grant${optionID}" class="grant jsTooltip" title="${getPhrase("wcf.acl.option.grant")}">
283 <input type="radio" id="grant${optionID}" />
284 </label>
285 <label for="deny${optionID}" class="deny jsTooltip" title="${getPhrase("wcf.acl.option.deny")}">
286 <input type="radio" id="deny${optionID}" />
287 </label>`;
288 listItem.dataset.optionId = optionID;
289 listItem.dataset.optionName = option.optionName;
290
291 const grantPermission = listItem.querySelector(`#grant${optionID}`) as HTMLInputElement;
292 const denyPermission = listItem.querySelector(`#deny${optionID}`) as HTMLInputElement;
293 const inheritedPermission = listItem.querySelector(`#inherited${optionID}`) as HTMLInputElement;
294
295 grantPermission.dataset.type = "grant";
296 grantPermission.dataset.optionId = optionID;
297 grantPermission.addEventListener("change", this.#change.bind(this));
298
299 denyPermission.dataset.type = "deny";
300 denyPermission.dataset.optionId = optionID;
301 denyPermission.addEventListener("change", this.#change.bind(this));
302
303 inheritedPermission.dataset.type = "inherited";
304 inheritedPermission.dataset.optionId = optionID;
305 inheritedPermission.addEventListener("change", this.#change.bind(this));
306
307 if (!structure[option.categoryName]) {
308 structure[option.categoryName] = [];
309 }
310
311 if (option.categoryName === "") {
312 this.#permissionList.appendChild(listItem);
313 } else {
314 structure[option.categoryName].push(listItem);
315 }
316 }
317
318 if (Object.keys(structure).length > 0) {
319 for (const [categoryName, listItems] of Object.entries(structure)) {
320 if (data.returnValues.categories[categoryName]) {
321 const category = document.createElement("div");
322 category.classList.add("aclCategory");
323 category.innerText = StringUtil.escapeHTML(data.returnValues.categories[categoryName]);
324 this.#permissionList.appendChild(category);
325 }
326
327 listItems.forEach((listItem) => {
328 this.#permissionList.appendChild(listItem);
329 });
330 }
331 }
332
333 // set data
334 this.#parseData(data, "group");
335 this.#parseData(data, "user");
336
337 // show container
338 DomUtil.show(this.#container);
339
340 // Because the container might have been hidden before, we must ensure that
341 // form builder field dependencies are checked again to avoid having ACL
342 // form fields not being shown in form builder forms.
343 checkDependencies();
344
345 // pre-select an entry
346 this.#selectFirstEntry();
347 }
348
349 #parseData(data: AjaxResponse, type: string) {
350 if (Object.keys(data.returnValues[type].option).length === 0) {
351 return;
352 }
353
354 // add list items
355 for (const typeID in data.returnValues[type].label) {
356 this.#createListItem(typeID, data.returnValues[type].label[typeID], type);
357
358 this.#search.addExcludedSearchValues(data.returnValues[type].label[typeID]);
359 }
360
361 // add options
362 this.#values[type] = data.returnValues[type].option;
363 }
364
365 #select(listItem: HTMLLIElement, savePermissions: boolean) {
366 // save previous permissions
367 if (savePermissions) {
368 this.#savePermissions();
369 }
370
371 // switch active item
372 this.#aclList.querySelectorAll("li").forEach((li: HTMLLIElement) => {
373 li.classList.remove("active");
374 });
375 listItem.classList.add("active");
376
377 // apply permissions for current item
378 this.#setupPermissions(listItem.dataset.type!, listItem.dataset.objectId!);
379 }
380
381 #change(event: MouseEvent) {
382 const checkbox = event.currentTarget as HTMLInputElement;
383 const optionID = checkbox.dataset.optionId!;
384 const type = checkbox.dataset.type!;
385
386 if (checkbox.checked) {
387 switch (type) {
388 case "grant":
389 (document.getElementById("deny" + optionID)! as HTMLInputElement).checked = false;
390 (document.getElementById("inherited" + optionID)! as HTMLInputElement).checked = false;
391 break;
392 case "deny":
393 (document.getElementById("grant" + optionID)! as HTMLInputElement).checked = false;
394 (document.getElementById("inherited" + optionID)! as HTMLInputElement).checked = false;
395 break;
396 case "inherited":
397 (document.getElementById("deny" + optionID)! as HTMLInputElement).checked = false;
398 (document.getElementById("grant" + optionID)! as HTMLInputElement).checked = false;
399 break;
400 }
401 }
402 }
403
404 #setupPermissions(type: string, objectID: string) {
405 // reset all checkboxes to default value
406 this.#permissionList.querySelectorAll("input[type='radio']").forEach((inputElement: HTMLInputElement) => {
407 inputElement.checked = inputElement.dataset.type === "inherited";
408 });
409
410 // use stored permissions if applicable
411 if (this.#values[type] && this.#values[type][objectID]) {
412 for (const optionID in this.#values[type][objectID]) {
413 if (this.#values[type][objectID][optionID] == 1) {
414 const option = document.getElementById("grant" + optionID) as HTMLInputElement;
415 option.checked = true;
416 option.dispatchEvent(new Event("change"));
417 } else {
418 const option = document.getElementById("deny" + optionID) as HTMLInputElement;
419 option.checked = true;
420 option.dispatchEvent(new Event("change"));
421 }
422 }
423 }
424
425 // show permissions
426 DomUtil.show(this.#permissionList);
427 }
428
429 #savePermissions() {
430 // get active object
431 const activeObject = this.#aclList.querySelector("li.active") as HTMLLIElement;
432 if (!activeObject) {
433 return;
434 }
435
436 const objectID = activeObject.dataset.objectId!;
437 const type = activeObject.dataset.type!;
438
439 // clear old values
440 this.#values[type][objectID] = {};
441 this.#permissionList.querySelectorAll("input[type='radio']").forEach((checkbox: HTMLInputElement) => {
442 if (checkbox.dataset.type === "inherited") {
443 return;
444 }
445
446 const optionValue = checkbox.dataset.type === "deny" ? 0 : 1;
447 const optionID = checkbox.dataset.optionId!;
448
449 if (checkbox.checked) {
450 // store value
451 this.#values[type][objectID][optionID] = optionValue;
452
453 // reset value afterwards
454 checkbox.checked = false;
455 } else if (
456 this.#values[type] &&
457 this.#values[type][objectID] &&
458 this.#values[type][objectID][optionID] &&
459 this.#values[type][objectID][optionID] == optionValue
460 ) {
461 delete this.#values[type][objectID][optionID];
462 }
463 });
464 }
465
466 #save(type: string) {
467 const form = this.#container.closest("form")!;
468 const name = this.#aclValuesFieldName + "[" + type + "]";
469 let input = form.querySelector<HTMLInputElement>("input[name='" + name + "']");
470 if (input) {
471 // combine json values
472 input.value = JSON.stringify(extend(JSON.parse(input.value), this.#values[type]));
473 } else {
474 input = document.createElement("input");
475 input.type = "hidden";
476 input.name = name;
477 input.value = JSON.stringify(this.#values[type]);
478 form.appendChild(input);
479 }
480 }
481 };