@hotstaq/admin-panel 0.3.18 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/assets/css/freelight-panel.css +174 -0
  2. package/build/WebExport.d.ts.map +1 -1
  3. package/build/WebExport.js +7 -1
  4. package/build/WebExport.js.map +1 -1
  5. package/build/components/admin-add-panel.d.ts +62 -0
  6. package/build/components/admin-add-panel.d.ts.map +1 -0
  7. package/build/components/admin-add-panel.js +191 -0
  8. package/build/components/admin-add-panel.js.map +1 -0
  9. package/build/components/admin-card-table.d.ts +55 -0
  10. package/build/components/admin-card-table.d.ts.map +1 -0
  11. package/build/components/admin-card-table.js +149 -0
  12. package/build/components/admin-card-table.js.map +1 -0
  13. package/build/components/admin-detail-page.d.ts +68 -0
  14. package/build/components/admin-detail-page.d.ts.map +1 -0
  15. package/build/components/admin-detail-page.js +247 -0
  16. package/build/components/admin-detail-page.js.map +1 -0
  17. package/build/components/admin-disclaimer.d.ts +21 -0
  18. package/build/components/admin-disclaimer.d.ts.map +1 -0
  19. package/build/components/admin-disclaimer.js +37 -0
  20. package/build/components/admin-disclaimer.js.map +1 -0
  21. package/build/components/admin-eyebrow-heading.d.ts +23 -0
  22. package/build/components/admin-eyebrow-heading.d.ts.map +1 -0
  23. package/build/components/admin-eyebrow-heading.js +40 -0
  24. package/build/components/admin-eyebrow-heading.js.map +1 -0
  25. package/build/components/admin-form-field.d.ts +69 -0
  26. package/build/components/admin-form-field.d.ts.map +1 -0
  27. package/build/components/admin-form-field.js +117 -0
  28. package/build/components/admin-form-field.js.map +1 -0
  29. package/build/index.d.ts +7 -1
  30. package/build/index.d.ts.map +1 -1
  31. package/build/index.js +16 -1
  32. package/build/index.js.map +1 -1
  33. package/build/tsconfig.tsbuildinfo +1 -1
  34. package/build-web/AdminPanelComponents.js +2 -2
  35. package/package.json +1 -1
  36. package/src/WebExport.ts +7 -1
  37. package/src/components/admin-add-panel.ts +232 -0
  38. package/src/components/admin-card-table.ts +183 -0
  39. package/src/components/admin-detail-page.ts +270 -0
  40. package/src/components/admin-disclaimer.ts +43 -0
  41. package/src/components/admin-eyebrow-heading.ts +50 -0
  42. package/src/components/admin-form-field.ts +166 -0
  43. package/src/index.ts +18 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hotstaq/admin-panel",
3
3
  "description": "",
4
- "version": "0.3.18",
4
+ "version": "0.4.0",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
7
7
  "start": "hotstaq --hotsite ./HotSite.json --env-file ./.env run --server-type web-api",
package/src/WebExport.ts CHANGED
@@ -23,7 +23,13 @@ async function buildAssets (): Promise<any>
23
23
  "AdminTable",
24
24
  "AdminTableField",
25
25
  "AdminTableRow",
26
- "AdminText"]
26
+ "AdminText",
27
+ "AdminFormField",
28
+ "AdminAddPanel",
29
+ "AdminCardTable",
30
+ "AdminDetailPage",
31
+ "AdminDisclaimer",
32
+ "AdminEyebrowHeading"]
27
33
  });
28
34
  }
29
35
 
@@ -0,0 +1,232 @@
1
+ import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
2
+
3
+ /**
4
+ * Inline collapsible "Add" panel. Replaces the modal opened by
5
+ * <admin-edit hot-type="add">. Built on Bootstrap's collapse so it
6
+ * slides down below a trigger button without overlaying the page.
7
+ *
8
+ * Pairs with <admin-card-table>: the table's "+ Add" button toggles
9
+ * this panel via Bootstrap's data-bs-toggle="collapse" data-bs-target.
10
+ * Slot for form fields lives at `hot-place-here name="panelBody"`.
11
+ *
12
+ * Usage:
13
+ * <admin-add-panel name="bankAccountsAdd" hot-title="Add bank account"
14
+ * hot-onsave="<(values) => {
15
+ * const r = await Hot.jsonRequest(`${config.baseUrl}/v1/bank_accounts/create`,
16
+ * { bankAccount: values }, '${jwtToken}');
17
+ * if (r && r.error) { alertError(r.error); return false; }
18
+ * }Ra>"
19
+ * hot-attached_list="bankAccountsList">
20
+ * <admin-form-field hot-field="name" hot-label="Name" hot-required="1"
21
+ * hot-col="col-md-6"></admin-form-field>
22
+ * ...
23
+ * </admin-add-panel>
24
+ */
25
+ export class AdminAddPanel extends HotComponent
26
+ {
27
+ /** Title shown at the top of the panel. */
28
+ title: string;
29
+ /** Submit button label. */
30
+ button_title: string;
31
+ /** Cancel button label. Empty disables the cancel button. */
32
+ cancel_text: string;
33
+ /** Optional id of the related <admin-card-table>; the toggle button gets injected into its header. */
34
+ attached_list: string;
35
+ /** Where to render the toggle button (a hot-place-here name on the page). Leave blank when attached_list is set. */
36
+ add_place_here: string;
37
+ /** Text shown on the toggle button. */
38
+ add_text: string;
39
+ /** When set to "1" / "true", panel starts expanded. */
40
+ start_open: string;
41
+ /** What to run when the user clicks Save. Receives a values object built from hot-field inputs. Return false to keep the panel open. */
42
+ onsave: (values: any) => Promise<boolean | void>;
43
+
44
+ protected panelId: string;
45
+ protected formId: string;
46
+
47
+ constructor (copy: HotComponent | HotStaq, api: HotAPI)
48
+ {
49
+ super (copy, api);
50
+
51
+ this.tag = "admin-add-panel";
52
+ this.title = "";
53
+ this.button_title = "Save";
54
+ this.cancel_text = "Cancel";
55
+ this.attached_list = "";
56
+ this.add_place_here = "";
57
+ this.add_text = "+ Add";
58
+ this.start_open = "0";
59
+ this.onsave = null;
60
+ }
61
+
62
+ /**
63
+ * Wires the submit + cancel handlers after the DOM is in place.
64
+ * Browsers handle the collapse open/close via Bootstrap data-attrs
65
+ * on the toggle button — we don't need to manage that ourselves.
66
+ */
67
+ onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
68
+ {
69
+ const self = this;
70
+ const panel = document.getElementById (this.panelId);
71
+ if (panel == null)
72
+ return (null);
73
+
74
+ const submitBtn = panel.querySelector (`.fl-add-panel-submit`) as HTMLButtonElement | null;
75
+ const cancelBtn = panel.querySelector (`.fl-add-panel-cancel`) as HTMLButtonElement | null;
76
+
77
+ if (submitBtn != null)
78
+ {
79
+ submitBtn.addEventListener ("click", async (e) =>
80
+ {
81
+ e.preventDefault ();
82
+ const values = self.collectFieldValues (panel);
83
+ submitBtn.disabled = true;
84
+ try
85
+ {
86
+ let keepOpen: any = false;
87
+ if (typeof self.onsave === "function")
88
+ keepOpen = await self.onsave (values);
89
+
90
+ if (keepOpen === false || keepOpen == null)
91
+ {
92
+ self.resetFields (panel);
93
+ self.collapsePanel ();
94
+ self.refreshAttachedList ();
95
+ }
96
+ }
97
+ catch (ex)
98
+ {
99
+ console.error ("admin-add-panel onsave threw:", ex);
100
+ }
101
+ finally
102
+ {
103
+ submitBtn.disabled = false;
104
+ }
105
+ });
106
+ }
107
+
108
+ if (cancelBtn != null)
109
+ {
110
+ cancelBtn.addEventListener ("click", (e) =>
111
+ {
112
+ e.preventDefault ();
113
+ self.resetFields (panel);
114
+ self.collapsePanel ();
115
+ });
116
+ }
117
+
118
+ return (null);
119
+ }
120
+
121
+ /** Read every hot-field-marked input inside the panel into a plain object. */
122
+ protected collectFieldValues (panel: HTMLElement): any
123
+ {
124
+ const out: any = {};
125
+ const nodes = panel.querySelectorAll ("[hot-field]");
126
+ for (let i = 0; i < nodes.length; i++)
127
+ {
128
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
129
+ const field = el.getAttribute ("hot-field");
130
+ if (field == null || field === "") continue;
131
+
132
+ if (el instanceof HTMLInputElement && el.type === "checkbox")
133
+ out[field] = el.checked;
134
+ else if (el instanceof HTMLInputElement && el.type === "number")
135
+ out[field] = el.value === "" ? null : Number (el.value);
136
+ else
137
+ out[field] = el.value;
138
+ }
139
+ return (out);
140
+ }
141
+
142
+ protected resetFields (panel: HTMLElement): void
143
+ {
144
+ const nodes = panel.querySelectorAll ("[hot-field]");
145
+ for (let i = 0; i < nodes.length; i++)
146
+ {
147
+ const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
148
+ if (el instanceof HTMLInputElement && el.type === "checkbox") el.checked = false;
149
+ else if (el instanceof HTMLSelectElement) el.selectedIndex = 0;
150
+ else el.value = "";
151
+ }
152
+ }
153
+
154
+ protected collapsePanel (): void
155
+ {
156
+ const panel = document.getElementById (this.panelId);
157
+ if (panel == null) return;
158
+ // Bootstrap collapse hide — works without importing Bootstrap JS
159
+ // directly by toggling the .show class and aria-expanded on any
160
+ // trigger pointed at us.
161
+ panel.classList.remove ("show");
162
+ const triggers = document.querySelectorAll (`[data-bs-target="#${this.panelId}"]`);
163
+ triggers.forEach (t => t.setAttribute ("aria-expanded", "false"));
164
+ }
165
+
166
+ /**
167
+ * Best-effort refresh of the paired <admin-card-table>. The table
168
+ * exposes a `refreshList` method on the rendered element when its
169
+ * data is loaded; calling it re-fetches without a page reload.
170
+ */
171
+ protected refreshAttachedList (): void
172
+ {
173
+ if (this.attached_list === "") return;
174
+ const list: any = document.getElementById (this.attached_list);
175
+ if (list != null && typeof list.refreshList === "function")
176
+ list.refreshList ();
177
+ }
178
+
179
+ output (): string | HotComponentOutput[]
180
+ {
181
+ if (this.name === "")
182
+ throw new Error ("admin-add-panel: name is required");
183
+
184
+ this.panelId = `${this.name}Panel`;
185
+ this.formId = `${this.name}Form`;
186
+
187
+ const showClass = (this.start_open === "1" || this.start_open === "true") ? " show" : "";
188
+ const titleHtml = this.title ? `<h2 class="h6 fl-add-panel-title mb-3">${this.title}</h2>` : "";
189
+ const cancelHtml = this.cancel_text
190
+ ? `<button type="button" class="btn btn-sm btn-link text-muted fl-add-panel-cancel">${this.cancel_text}</button>`
191
+ : "";
192
+
193
+ const panelHtml = `
194
+ <div id="${this.panelId}" class="collapse fl-add-panel${showClass}">
195
+ <div class="card-body border-top bg-body-tertiary fl-add-panel-body">
196
+ ${titleHtml}
197
+ <form id="${this.formId}" class="fl-add-panel-form">
198
+ <div class="row g-2 align-items-end">
199
+ <hot-place-here name="panelBody"></hot-place-here>
200
+ </div>
201
+ <div class="d-flex justify-content-end gap-2 mt-3">
202
+ ${cancelHtml}
203
+ <button type="submit" class="btn btn-sm btn-success fl-add-panel-submit">${this.button_title}</button>
204
+ </div>
205
+ </form>
206
+ </div>
207
+ </div>`;
208
+
209
+ const outputs: HotComponentOutput[] = [{ html: panelHtml, documentSelector: "body" }];
210
+
211
+ // If a partner card-table is named, inject the toggle button into
212
+ // its header slot. Otherwise honour an explicit add_place_here.
213
+ const toggleBtn = `<button type="button" class="btn btn-sm btn-primary fl-add-panel-toggle" data-bs-toggle="collapse" data-bs-target="#${this.panelId}" aria-expanded="${showClass ? "true" : "false"}" aria-controls="${this.panelId}">${this.add_text}</button>`;
214
+
215
+ if (this.attached_list !== "")
216
+ {
217
+ outputs.push ({
218
+ html: toggleBtn,
219
+ documentSelector: `[data-card-table-add-slot="${this.attached_list}"]`
220
+ });
221
+ }
222
+ else if (this.add_place_here !== "")
223
+ {
224
+ outputs.push ({
225
+ html: toggleBtn,
226
+ documentSelector: `hot-place-here[name="${this.add_place_here}"]`
227
+ });
228
+ }
229
+
230
+ return (outputs);
231
+ }
232
+ }
@@ -0,0 +1,183 @@
1
+ import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
2
+
3
+ /**
4
+ * Freelight-style card list. Renders campaign / project / member rows
5
+ * as cards on every viewport, with optional inline action buttons and
6
+ * a sub-line below the primary label.
7
+ *
8
+ * Replaces the click-row-opens-modal flow of <admin-table> + <admin-edit>:
9
+ * when `hot-detail-route` is set, the whole row becomes a link to the
10
+ * configured detail URL (e.g. "/budget/:id"). When `hot-detail-route` is
11
+ * empty, the row renders without navigation — the caller is expected to
12
+ * provide their own inline action buttons via `hot-row-actions`.
13
+ *
14
+ * Usage:
15
+ * <admin-card-table id="bankAccountsList" hot-list-url="/v1/bank_accounts/list"
16
+ * hot-detail-route="/bankAccount/:id"
17
+ * hot-primary-field="name" hot-subline-field="bankSyncAPIType"
18
+ * hot-empty-text="No bank accounts yet.">
19
+ * </admin-card-table>
20
+ *
21
+ * The component exposes a `.refreshList()` method on the rendered DOM
22
+ * element so callers (the paired admin-add-panel, an edit-save callback,
23
+ * etc.) can re-fetch without a page reload.
24
+ */
25
+ export class AdminCardTable extends HotComponent
26
+ {
27
+ /** Optional title shown in the card header. */
28
+ title: string;
29
+ /** Endpoint that returns { length, data: [...] } — usually the entity's list route. */
30
+ list_url: string;
31
+ /** JWT token to send as Authorization bearer. Empty for public endpoints. */
32
+ jwt: string;
33
+ /** Body params to POST with the list call (JSON). */
34
+ list_params: string;
35
+ /** Field name on each row used as the primary card label (default "name"). */
36
+ primary_field: string;
37
+ /** Optional second-line field rendered in muted text under the primary label. */
38
+ subline_field: string;
39
+ /** Pattern for the row's click target. ":id" interpolates row.id. Empty disables row navigation. */
40
+ detail_route: string;
41
+ /** Text shown when the list is empty. */
42
+ empty_text: string;
43
+ /** Text shown while the list is loading. */
44
+ loading_text: string;
45
+ /** Inner HTML template for the right-side action area, with ":id" placeholder. */
46
+ row_actions: string;
47
+ /** Slot name where the partner admin-add-panel injects its toggle button. */
48
+ add_slot: string;
49
+
50
+ constructor (copy: HotComponent | HotStaq, api: HotAPI)
51
+ {
52
+ super (copy, api);
53
+
54
+ this.tag = "admin-card-table";
55
+ this.title = "";
56
+ this.list_url = "";
57
+ this.jwt = "";
58
+ this.list_params = "{}";
59
+ this.primary_field = "name";
60
+ this.subline_field = "";
61
+ this.detail_route = "";
62
+ this.empty_text = "No items yet.";
63
+ this.loading_text = "Loading…";
64
+ this.row_actions = "";
65
+ this.add_slot = "";
66
+ }
67
+
68
+ onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
69
+ {
70
+ const self = this;
71
+ const container = document.getElementById (this.name);
72
+ if (container == null)
73
+ return (null);
74
+
75
+ // Expose a refreshList() method on the rendered element so the
76
+ // partner add-panel (and any other caller) can re-fetch when a
77
+ // row changes.
78
+ (container as any).refreshList = function () { return self.fetchAndRender (); };
79
+
80
+ // Initial load.
81
+ self.fetchAndRender ();
82
+
83
+ return (null);
84
+ }
85
+
86
+ protected async fetchAndRender (): Promise<void>
87
+ {
88
+ const container = document.getElementById (this.name);
89
+ const list = container ? container.querySelector (".fl-card-list") as HTMLElement : null;
90
+ if (list == null) return;
91
+
92
+ try
93
+ {
94
+ let payload: any = {};
95
+ try { payload = JSON.parse (this.list_params || "{}"); } catch (e) { payload = {}; }
96
+
97
+ const headers: any = { "Content-Type": "application/json" };
98
+ if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
99
+
100
+ const res = await fetch (this.list_url, {
101
+ method: "POST", headers: headers, body: JSON.stringify (payload)
102
+ });
103
+
104
+ if (!res.ok)
105
+ {
106
+ list.innerHTML = `<div class="text-danger small p-3">Could not load: HTTP ${res.status}</div>`;
107
+ return;
108
+ }
109
+
110
+ const result = await res.json ();
111
+ const rows: any[] = (result && Array.isArray (result.data)) ? result.data : (Array.isArray (result) ? result : []);
112
+
113
+ if (rows.length === 0)
114
+ {
115
+ list.innerHTML = `<div class="text-muted small text-center py-4">${this.empty_text}</div>`;
116
+ return;
117
+ }
118
+
119
+ list.innerHTML = rows.map ((row) => this.renderRow (row)).join ("");
120
+ }
121
+ catch (ex)
122
+ {
123
+ list.innerHTML = `<div class="text-danger small p-3">Could not load: ${(ex as Error).message}</div>`;
124
+ }
125
+ }
126
+
127
+ protected escapeHtml (s: any): string
128
+ {
129
+ return String (s == null ? "" : s)
130
+ .replace (/&/g, "&amp;").replace (/</g, "&lt;").replace (/>/g, "&gt;")
131
+ .replace (/"/g, "&quot;").replace (/'/g, "&#39;");
132
+ }
133
+
134
+ protected interpolate (template: string, row: any): string
135
+ {
136
+ if (!template) return "";
137
+ return template.replace (/:(\w+)/g, (_m, key) => this.escapeHtml (row[key] || ""));
138
+ }
139
+
140
+ protected renderRow (row: any): string
141
+ {
142
+ const primaryRaw = row[this.primary_field];
143
+ const primary = this.escapeHtml (primaryRaw != null && primaryRaw !== "" ? primaryRaw : (row.id || ""));
144
+ const sublineRaw = this.subline_field ? row[this.subline_field] : "";
145
+ const subline = sublineRaw ? `<div class="fl-card-row-sub text-muted small">${this.escapeHtml (sublineRaw)}</div>` : "";
146
+
147
+ const inner = `
148
+ <div class="fl-card-row-main">
149
+ <div class="fl-card-row-label">${primary}</div>
150
+ ${subline}
151
+ </div>
152
+ <div class="fl-card-row-actions">${this.interpolate (this.row_actions, row)}</div>`;
153
+
154
+ if (this.detail_route)
155
+ {
156
+ const href = this.interpolate (this.detail_route, row);
157
+ return `<a href="${href}" class="fl-card-row fl-card-row-link list-group-item list-group-item-action">${inner}</a>`;
158
+ }
159
+ return `<div class="fl-card-row list-group-item">${inner}</div>`;
160
+ }
161
+
162
+ output (): string | HotComponentOutput[]
163
+ {
164
+ if (this.name === "")
165
+ throw new Error ("admin-card-table: id (name) is required");
166
+ if (this.list_url === "")
167
+ throw new Error ("admin-card-table: hot-list-url is required");
168
+
169
+ const titleHtml = this.title ? `<strong>${this.title}</strong>` : "";
170
+ const addSlotAttr = this.add_slot ? ` data-card-table-add-slot="${this.add_slot}"` : ` data-card-table-add-slot="${this.name}"`;
171
+
172
+ return (`
173
+ <div id="${this.name}" class="card fl-card-table mb-4">
174
+ <div class="card-header d-flex justify-content-between align-items-center">
175
+ ${titleHtml}
176
+ <div class="fl-card-table-actions"${addSlotAttr}></div>
177
+ </div>
178
+ <div class="fl-card-list list-group list-group-flush">
179
+ <div class="text-muted small text-center py-4">${this.loading_text}</div>
180
+ </div>
181
+ </div>`);
182
+ }
183
+ }