Hint the return type based on the provided parameters
[GitHub/WoltLab/WCF.git] / ts / WoltLabSuite / Core / Ui / Message / Manager.ts
1 /**
2 * Provides access and editing of message properties.
3 *
4 * @author Alexander Ebert
5 * @copyright 2001-2019 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Ui/Message/Manager
8 */
9
10 import * as Ajax from "../../Ajax";
11 import { AjaxCallbackObject, AjaxCallbackSetup, ResponseData } from "../../Ajax/Data";
12 import * as Core from "../../Core";
13 import DomChangeListener from "../../Dom/Change/Listener";
14 import * as Language from "../../Language";
15 import * as StringUtil from "../../StringUtil";
16
17 interface MessageManagerOptions {
18 className: string;
19 selector: string;
20 }
21
22 type StringableValue = boolean | number | string;
23
24 class UiMessageManager implements AjaxCallbackObject {
25 protected readonly _elements = new Map<string, HTMLElement>();
26 protected readonly _options: MessageManagerOptions;
27
28 /**
29 * Initializes a new manager instance.
30 */
31 constructor(options: MessageManagerOptions) {
32 this._options = Core.extend(
33 {
34 className: "",
35 selector: "",
36 },
37 options,
38 ) as MessageManagerOptions;
39
40 this.rebuild();
41
42 DomChangeListener.add(`Ui/Message/Manager${this._options.className}`, this.rebuild.bind(this));
43 }
44
45 /**
46 * Rebuilds the list of observed messages. You should call this method whenever a
47 * message has been either added or removed from the document.
48 */
49 rebuild(): void {
50 this._elements.clear();
51
52 document.querySelectorAll(this._options.selector).forEach((element: HTMLElement) => {
53 this._elements.set(element.dataset.objectId!, element);
54 });
55 }
56
57 /**
58 * Returns a boolean value for the given permission. The permission should not start
59 * with "can" or "can-" as this is automatically assumed by this method.
60 */
61 getPermission(objectId: string, permission: string): boolean {
62 permission = "can" + StringUtil.ucfirst(permission);
63 const element = this._elements.get(objectId);
64 if (element === undefined) {
65 throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
66 }
67
68 return Core.stringToBool(element.dataset[permission] || "");
69 }
70
71 /**
72 * Returns the given property value from a message, optionally supporting a boolean return value.
73 */
74 getPropertyValue(objectId: string, propertyName: string, asBool: true): boolean;
75 getPropertyValue(objectId: string, propertyName: string, asBool: false): string;
76 getPropertyValue(objectId: string, propertyName: string, asBool: boolean): boolean | string {
77 const element = this._elements.get(objectId);
78 if (element === undefined) {
79 throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
80 }
81
82 const value = element.dataset[StringUtil.toCamelCase(propertyName)] || "";
83
84 if (asBool) {
85 return Core.stringToBool(value);
86 }
87
88 return value;
89 }
90
91 /**
92 * Invokes a method for given message object id in order to alter its state or properties.
93 */
94 update(objectId: string, actionName: string, parameters?: ArbitraryObject): void {
95 Ajax.api(this, {
96 actionName: actionName,
97 parameters: parameters || {},
98 objectIDs: [objectId],
99 });
100 }
101
102 /**
103 * Updates properties and states for given object ids. Keep in mind that this method does
104 * not support setting individual properties per message, instead all property changes
105 * are applied to all matching message objects.
106 */
107 updateItems(objectIds: string | string[], data: ArbitraryObject): void {
108 if (!Array.isArray(objectIds)) {
109 objectIds = [objectIds];
110 }
111
112 objectIds.forEach((objectId) => {
113 const element = this._elements.get(objectId);
114 if (element === undefined) {
115 return;
116 }
117
118 Object.entries(data).forEach(([key, value]) => {
119 this._update(element, key, value as StringableValue);
120 });
121 });
122 }
123
124 /**
125 * Bulk updates the properties and states for all observed messages at once.
126 */
127 updateAllItems(data: ArbitraryObject): void {
128 const objectIds = Array.from(this._elements.keys());
129
130 this.updateItems(objectIds, data);
131 }
132
133 /**
134 * Sets or removes a message note identified by its unique CSS class.
135 */
136 setNote(objectId: string, className: string, htmlContent: string): void {
137 const element = this._elements.get(objectId);
138 if (element === undefined) {
139 throw new Error(`Unknown object id '${objectId}' for selector '${this._options.selector}'`);
140 }
141
142 const messageFooterNotes = element.querySelector(".messageFooterNotes") as HTMLElement;
143 let note = messageFooterNotes.querySelector(`.${className}`);
144 if (htmlContent) {
145 if (note === null) {
146 note = document.createElement("p");
147 note.className = "messageFooterNote " + className;
148
149 messageFooterNotes.appendChild(note);
150 }
151
152 note.innerHTML = htmlContent;
153 } else if (note !== null) {
154 note.remove();
155 }
156 }
157
158 /**
159 * Updates a single property of a message element.
160 */
161 protected _update(element: HTMLElement, propertyName: string, propertyValue: StringableValue): void {
162 element.dataset[propertyName] = propertyValue.toString();
163
164 // handle special properties
165 const propertyValueBoolean = propertyValue == 1 || propertyValue === true || propertyValue === "true";
166 this._updateState(element, propertyName, propertyValue, propertyValueBoolean);
167 }
168
169 /**
170 * Updates the message element's state based upon a property change.
171 */
172 protected _updateState(
173 element: HTMLElement,
174 propertyName: string,
175 propertyValue: StringableValue,
176 propertyValueBoolean: boolean,
177 ): void {
178 switch (propertyName) {
179 case "isDeleted":
180 if (propertyValueBoolean) {
181 element.classList.add("messageDeleted");
182 } else {
183 element.classList.remove("messageDeleted");
184 }
185
186 this._toggleMessageStatus(element, "jsIconDeleted", "wcf.message.status.deleted", "red", propertyValueBoolean);
187
188 break;
189
190 case "isDisabled":
191 if (propertyValueBoolean) {
192 element.classList.add("messageDisabled");
193 } else {
194 element.classList.remove("messageDisabled");
195 }
196
197 this._toggleMessageStatus(
198 element,
199 "jsIconDisabled",
200 "wcf.message.status.disabled",
201 "green",
202 propertyValueBoolean,
203 );
204
205 break;
206 }
207 }
208
209 /**
210 * Toggles the message status bade for provided element.
211 */
212 protected _toggleMessageStatus(
213 element: HTMLElement,
214 className: string,
215 phrase: string,
216 badgeColor: string,
217 addBadge: boolean,
218 ): void {
219 let messageStatus = element.querySelector(".messageStatus");
220 if (messageStatus === null) {
221 const messageHeaderMetaData = element.querySelector(".messageHeaderMetaData");
222 if (messageHeaderMetaData === null) {
223 // can't find appropriate location to insert badge
224 return;
225 }
226
227 messageStatus = document.createElement("ul");
228 messageStatus.className = "messageStatus";
229 messageHeaderMetaData.insertAdjacentElement("afterend", messageStatus);
230 }
231
232 let badge = messageStatus.querySelector(`.${className}`);
233 if (addBadge) {
234 if (badge !== null) {
235 // badge already exists
236 return;
237 }
238
239 badge = document.createElement("span");
240 badge.className = `badge label ${badgeColor} ${className}`;
241 badge.textContent = Language.get(phrase);
242
243 const listItem = document.createElement("li");
244 listItem.appendChild(badge);
245 messageStatus.appendChild(listItem);
246 } else {
247 if (badge === null) {
248 // badge does not exist
249 return;
250 }
251
252 badge.parentElement!.remove();
253 }
254 }
255
256 /**
257 * Transforms camel-cased property names into their attribute equivalent.
258 *
259 * @deprecated 5.4 Access the value via `element.dataset` which uses camel-case.
260 */
261 protected _getAttributeName(propertyName: string): string {
262 if (propertyName.indexOf("-") !== -1) {
263 return propertyName;
264 }
265
266 return propertyName
267 .split(/([A-Z][a-z]+)/)
268 .map((s) => s.trim().toLowerCase())
269 .filter((s) => s.length > 0)
270 .join("-");
271 }
272
273 _ajaxSuccess(_data: ResponseData): void {
274 // This should be an abstract method, but cannot be marked as such for backwards compatibility.
275 throw new Error("Method _ajaxSuccess() must be implemented by deriving functions.");
276 }
277
278 _ajaxSetup(): ReturnType<AjaxCallbackSetup> {
279 return {
280 data: {
281 className: this._options.className,
282 },
283 };
284 }
285 }
286
287 Core.enableLegacyInheritance(UiMessageManager);
288
289 export = UiMessageManager;