@hotstaq/admin-panel 0.3.18 → 0.4.1
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.
- package/assets/css/freelight-panel.css +174 -0
- package/build/WebExport.d.ts.map +1 -1
- package/build/WebExport.js +7 -1
- package/build/WebExport.js.map +1 -1
- package/build/components/admin-add-panel.d.ts +56 -0
- package/build/components/admin-add-panel.d.ts.map +1 -0
- package/build/components/admin-add-panel.js +177 -0
- package/build/components/admin-add-panel.js.map +1 -0
- package/build/components/admin-card-table.d.ts +55 -0
- package/build/components/admin-card-table.d.ts.map +1 -0
- package/build/components/admin-card-table.js +149 -0
- package/build/components/admin-card-table.js.map +1 -0
- package/build/components/admin-detail-page.d.ts +68 -0
- package/build/components/admin-detail-page.d.ts.map +1 -0
- package/build/components/admin-detail-page.js +247 -0
- package/build/components/admin-detail-page.js.map +1 -0
- package/build/components/admin-disclaimer.d.ts +21 -0
- package/build/components/admin-disclaimer.d.ts.map +1 -0
- package/build/components/admin-disclaimer.js +37 -0
- package/build/components/admin-disclaimer.js.map +1 -0
- package/build/components/admin-eyebrow-heading.d.ts +23 -0
- package/build/components/admin-eyebrow-heading.d.ts.map +1 -0
- package/build/components/admin-eyebrow-heading.js +40 -0
- package/build/components/admin-eyebrow-heading.js.map +1 -0
- package/build/components/admin-form-field.d.ts +69 -0
- package/build/components/admin-form-field.d.ts.map +1 -0
- package/build/components/admin-form-field.js +117 -0
- package/build/components/admin-form-field.js.map +1 -0
- package/build/index.d.ts +7 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +16 -1
- package/build/index.js.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build-web/AdminPanelComponents.js +2 -2
- package/package.json +1 -1
- package/src/WebExport.ts +7 -1
- package/src/components/admin-add-panel.ts +210 -0
- package/src/components/admin-card-table.ts +183 -0
- package/src/components/admin-detail-page.ts +270 -0
- package/src/components/admin-disclaimer.ts +43 -0
- package/src/components/admin-eyebrow-heading.ts +50 -0
- package/src/components/admin-form-field.ts +166 -0
- package/src/index.ts +18 -2
package/package.json
CHANGED
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,210 @@
|
|
|
1
|
+
import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Self-contained inline add form. Replaces the modal opened by
|
|
5
|
+
* <admin-edit hot-type="add"> with a Bootstrap-collapse card that
|
|
6
|
+
* lives directly on the page — header carries the "+ Add" toggle,
|
|
7
|
+
* body holds the form fields. No cross-component DOM injection.
|
|
8
|
+
*
|
|
9
|
+
* Place this above an <admin-card-table>; when the user clicks the
|
|
10
|
+
* header toggle, the form panel slides down. On Save, the paired
|
|
11
|
+
* table is asked to refresh via `attached_list`.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <admin-add-panel name="bankAccountsAdd"
|
|
15
|
+
* hot-title="Add a bank account"
|
|
16
|
+
* hot-attached_list="bankAccountsList"
|
|
17
|
+
* hot-add_text="+ Add bank account"
|
|
18
|
+
* hot-button_title="Create"
|
|
19
|
+
* hot-onsave="<(values) => {...}Ra>">
|
|
20
|
+
* <admin-form-field hot-field="name" hot-label="Name" hot-required="1"
|
|
21
|
+
* hot-col="col-md-6"></admin-form-field>
|
|
22
|
+
* <admin-form-field hot-field="bankSyncAPIType" hot-label="Sync type"
|
|
23
|
+
* hot-control="select"
|
|
24
|
+
* hot-options="paypal_webhooks:PayPal Webhooks"
|
|
25
|
+
* hot-col="col-md-6"></admin-form-field>
|
|
26
|
+
* </admin-add-panel>
|
|
27
|
+
*/
|
|
28
|
+
export class AdminAddPanel extends HotComponent
|
|
29
|
+
{
|
|
30
|
+
/** Title shown in the card header (next to the toggle button). */
|
|
31
|
+
title: string;
|
|
32
|
+
/** Submit button label. */
|
|
33
|
+
button_title: string;
|
|
34
|
+
/** Cancel button label. Empty hides the cancel button. */
|
|
35
|
+
cancel_text: string;
|
|
36
|
+
/** Optional id of the partner <admin-card-table>; refreshList() is called after a successful save. */
|
|
37
|
+
attached_list: string;
|
|
38
|
+
/** Text shown on the header toggle button. */
|
|
39
|
+
add_text: string;
|
|
40
|
+
/** "1" / "true" → panel starts expanded. */
|
|
41
|
+
start_open: string;
|
|
42
|
+
/** What to run when the user clicks Save. Receives a values object built from hot-field inputs. Return false to keep the panel open. */
|
|
43
|
+
onsave: (values: any) => Promise<boolean | void>;
|
|
44
|
+
|
|
45
|
+
protected panelId: string;
|
|
46
|
+
protected formId: string;
|
|
47
|
+
|
|
48
|
+
constructor (copy: HotComponent | HotStaq, api: HotAPI)
|
|
49
|
+
{
|
|
50
|
+
super (copy, api);
|
|
51
|
+
|
|
52
|
+
this.tag = "admin-add-panel";
|
|
53
|
+
this.title = "";
|
|
54
|
+
this.button_title = "Save";
|
|
55
|
+
this.cancel_text = "Cancel";
|
|
56
|
+
this.attached_list = "";
|
|
57
|
+
this.add_text = "+ Add";
|
|
58
|
+
this.start_open = "0";
|
|
59
|
+
this.onsave = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wire submit + cancel handlers after the DOM is in place.
|
|
64
|
+
* Bootstrap's data-bs-toggle handles open/close automatically.
|
|
65
|
+
*/
|
|
66
|
+
onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
|
|
67
|
+
{
|
|
68
|
+
const self = this;
|
|
69
|
+
const root = document.getElementById (this.name);
|
|
70
|
+
if (root == null)
|
|
71
|
+
return (null);
|
|
72
|
+
|
|
73
|
+
const submitBtn = root.querySelector (".fl-add-panel-submit") as HTMLButtonElement | null;
|
|
74
|
+
const cancelBtn = root.querySelector (".fl-add-panel-cancel") as HTMLButtonElement | null;
|
|
75
|
+
|
|
76
|
+
if (submitBtn != null)
|
|
77
|
+
{
|
|
78
|
+
submitBtn.addEventListener ("click", async (e) =>
|
|
79
|
+
{
|
|
80
|
+
e.preventDefault ();
|
|
81
|
+
const values = self.collectFieldValues (root);
|
|
82
|
+
submitBtn.disabled = true;
|
|
83
|
+
try
|
|
84
|
+
{
|
|
85
|
+
let keepOpen: any = false;
|
|
86
|
+
if (typeof self.onsave === "function")
|
|
87
|
+
keepOpen = await self.onsave (values);
|
|
88
|
+
|
|
89
|
+
if (keepOpen === false || keepOpen == null)
|
|
90
|
+
{
|
|
91
|
+
self.resetFields (root);
|
|
92
|
+
self.collapsePanel ();
|
|
93
|
+
self.refreshAttachedList ();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (ex)
|
|
97
|
+
{
|
|
98
|
+
console.error ("admin-add-panel onsave threw:", ex);
|
|
99
|
+
}
|
|
100
|
+
finally
|
|
101
|
+
{
|
|
102
|
+
submitBtn.disabled = false;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (cancelBtn != null)
|
|
108
|
+
{
|
|
109
|
+
cancelBtn.addEventListener ("click", (e) =>
|
|
110
|
+
{
|
|
111
|
+
e.preventDefault ();
|
|
112
|
+
self.resetFields (root);
|
|
113
|
+
self.collapsePanel ();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected collectFieldValues (root: HTMLElement): any
|
|
121
|
+
{
|
|
122
|
+
const out: any = {};
|
|
123
|
+
const nodes = root.querySelectorAll ("[hot-field]");
|
|
124
|
+
for (let i = 0; i < nodes.length; i++)
|
|
125
|
+
{
|
|
126
|
+
const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
127
|
+
const field = el.getAttribute ("hot-field");
|
|
128
|
+
if (field == null || field === "") continue;
|
|
129
|
+
|
|
130
|
+
if (el instanceof HTMLInputElement && el.type === "checkbox")
|
|
131
|
+
out[field] = el.checked;
|
|
132
|
+
else if (el instanceof HTMLInputElement && el.type === "number")
|
|
133
|
+
out[field] = el.value === "" ? null : Number (el.value);
|
|
134
|
+
else
|
|
135
|
+
out[field] = el.value;
|
|
136
|
+
}
|
|
137
|
+
return (out);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
protected resetFields (root: HTMLElement): void
|
|
141
|
+
{
|
|
142
|
+
const nodes = root.querySelectorAll ("[hot-field]");
|
|
143
|
+
for (let i = 0; i < nodes.length; i++)
|
|
144
|
+
{
|
|
145
|
+
const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
146
|
+
if (el instanceof HTMLInputElement && el.type === "checkbox") el.checked = false;
|
|
147
|
+
else if (el instanceof HTMLSelectElement) el.selectedIndex = 0;
|
|
148
|
+
else el.value = "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
protected collapsePanel (): void
|
|
153
|
+
{
|
|
154
|
+
const panel = document.getElementById (this.panelId);
|
|
155
|
+
if (panel == null) return;
|
|
156
|
+
panel.classList.remove ("show");
|
|
157
|
+
const triggers = document.querySelectorAll (`[data-bs-target="#${this.panelId}"]`);
|
|
158
|
+
triggers.forEach (t => t.setAttribute ("aria-expanded", "false"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
protected refreshAttachedList (): void
|
|
162
|
+
{
|
|
163
|
+
if (this.attached_list === "") return;
|
|
164
|
+
const list: any = document.getElementById (this.attached_list);
|
|
165
|
+
if (list != null && typeof list.refreshList === "function")
|
|
166
|
+
list.refreshList ();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
output (): string | HotComponentOutput[]
|
|
170
|
+
{
|
|
171
|
+
if (this.name === "")
|
|
172
|
+
throw new Error ("admin-add-panel: name is required");
|
|
173
|
+
|
|
174
|
+
this.panelId = `${this.name}Panel`;
|
|
175
|
+
this.formId = `${this.name}Form`;
|
|
176
|
+
|
|
177
|
+
const showClass = (this.start_open === "1" || this.start_open === "true") ? " show" : "";
|
|
178
|
+
const ariaExp = showClass ? "true" : "false";
|
|
179
|
+
const titleHtml = this.title ? `<strong class="fl-add-panel-title">${this.title}</strong>` : `<span></span>`;
|
|
180
|
+
const cancelHtml = this.cancel_text
|
|
181
|
+
? `<button type="button" class="btn btn-sm btn-link text-muted fl-add-panel-cancel">${this.cancel_text}</button>`
|
|
182
|
+
: "";
|
|
183
|
+
|
|
184
|
+
// Single self-contained card. Header has the toggle, body is the
|
|
185
|
+
// collapse panel containing the form. Bootstrap's data-bs-toggle
|
|
186
|
+
// drives the open/close — no JS wiring needed for that.
|
|
187
|
+
return (`
|
|
188
|
+
<div id="${this.name}" class="card fl-add-panel mb-3">
|
|
189
|
+
<div class="card-header d-flex justify-content-between align-items-center">
|
|
190
|
+
${titleHtml}
|
|
191
|
+
<button type="button" class="btn btn-sm btn-primary fl-add-panel-toggle"
|
|
192
|
+
data-bs-toggle="collapse" data-bs-target="#${this.panelId}"
|
|
193
|
+
aria-expanded="${ariaExp}" aria-controls="${this.panelId}">${this.add_text}</button>
|
|
194
|
+
</div>
|
|
195
|
+
<div id="${this.panelId}" class="collapse fl-add-panel-body${showClass}">
|
|
196
|
+
<div class="card-body border-top">
|
|
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
|
+
</div>`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -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, "&").replace (/</g, "<").replace (/>/g, ">")
|
|
131
|
+
.replace (/"/g, """).replace (/'/g, "'");
|
|
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
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Full-page detail chrome for an entity. Replaces the modal opened by
|
|
5
|
+
* <admin-edit hot-type="edit"> with a dedicated page (e.g. /budget/:id)
|
|
6
|
+
* that has its own back link, sections, and a sticky save bar at the
|
|
7
|
+
* bottom.
|
|
8
|
+
*
|
|
9
|
+
* The page is responsible for placing <admin-form-field>s inside the
|
|
10
|
+
* `<hot-place-here name="detailBody">` slot. This component handles:
|
|
11
|
+
* - reading ?id from the URL
|
|
12
|
+
* - GET'ing the entity via hot-get-url and populating form fields by
|
|
13
|
+
* matching hot-field names against the response keys
|
|
14
|
+
* - POST'ing values to hot-save-url on Save click
|
|
15
|
+
* - DELETE'ing via hot-delete-url on Delete click (with confirm)
|
|
16
|
+
* - showing a back link to hot-back-url
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <admin-detail-page name="budgetDetail"
|
|
20
|
+
* hot-title="Budget"
|
|
21
|
+
* hot-back-url="/budgets"
|
|
22
|
+
* hot-back-text="← All budgets"
|
|
23
|
+
* hot-get-url="/v1/budgets/get"
|
|
24
|
+
* hot-save-url="/v1/budgets/edit"
|
|
25
|
+
* hot-delete-url="/v1/budgets/delete"
|
|
26
|
+
* hot-payload-key="budget"
|
|
27
|
+
* hot-jwt="${jwtToken}">
|
|
28
|
+
* <admin-form-field hot-field="name" hot-label="Name" hot-required="1"></admin-form-field>
|
|
29
|
+
* ...
|
|
30
|
+
* </admin-detail-page>
|
|
31
|
+
*/
|
|
32
|
+
export class AdminDetailPage extends HotComponent
|
|
33
|
+
{
|
|
34
|
+
/** Heading shown at the top of the page. */
|
|
35
|
+
title: string;
|
|
36
|
+
/** URL the back link points at. */
|
|
37
|
+
back_url: string;
|
|
38
|
+
/** Text on the back link. */
|
|
39
|
+
back_text: string;
|
|
40
|
+
/** GET-by-id endpoint. POSTed with { id: <id> }. */
|
|
41
|
+
get_url: string;
|
|
42
|
+
/** Edit/save endpoint. POSTed with { [payload_key]: { id, ...fields } }. */
|
|
43
|
+
save_url: string;
|
|
44
|
+
/** Optional delete endpoint. Hides the Delete button when blank. */
|
|
45
|
+
delete_url: string;
|
|
46
|
+
/** Wrapper key in the save payload (most DAO routes expect { budget: {...} } / { issue: {...} } etc). */
|
|
47
|
+
payload_key: string;
|
|
48
|
+
/** JWT bearer for the API calls. */
|
|
49
|
+
jwt: string;
|
|
50
|
+
/** URL query param that holds the entity id (default: "id"). */
|
|
51
|
+
id_param: string;
|
|
52
|
+
/** Save button text. */
|
|
53
|
+
save_text: string;
|
|
54
|
+
/** Delete button text. */
|
|
55
|
+
delete_text: string;
|
|
56
|
+
/** Confirmation prompt for delete. */
|
|
57
|
+
delete_confirm: string;
|
|
58
|
+
|
|
59
|
+
constructor (copy: HotComponent | HotStaq, api: HotAPI)
|
|
60
|
+
{
|
|
61
|
+
super (copy, api);
|
|
62
|
+
|
|
63
|
+
this.tag = "admin-detail-page";
|
|
64
|
+
this.title = "";
|
|
65
|
+
this.back_url = "/";
|
|
66
|
+
this.back_text = "← Back";
|
|
67
|
+
this.get_url = "";
|
|
68
|
+
this.save_url = "";
|
|
69
|
+
this.delete_url = "";
|
|
70
|
+
this.payload_key = "";
|
|
71
|
+
this.jwt = "";
|
|
72
|
+
this.id_param = "id";
|
|
73
|
+
this.save_text = "Save";
|
|
74
|
+
this.delete_text = "Delete";
|
|
75
|
+
this.delete_confirm = "Are you sure you want to delete this?";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
|
|
79
|
+
{
|
|
80
|
+
const self = this;
|
|
81
|
+
const page = document.getElementById (this.name);
|
|
82
|
+
if (page == null) return (null);
|
|
83
|
+
|
|
84
|
+
const id = self.readIdFromUrl ();
|
|
85
|
+
if (!id)
|
|
86
|
+
{
|
|
87
|
+
self.showError (page, "No id provided in the URL.");
|
|
88
|
+
return (null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Wire buttons.
|
|
92
|
+
const saveBtn = page.querySelector (".fl-detail-save") as HTMLButtonElement | null;
|
|
93
|
+
const deleteBtn = page.querySelector (".fl-detail-delete") as HTMLButtonElement | null;
|
|
94
|
+
|
|
95
|
+
if (saveBtn != null)
|
|
96
|
+
saveBtn.addEventListener ("click", (e) => { e.preventDefault (); self.handleSave (page, id); });
|
|
97
|
+
|
|
98
|
+
if (deleteBtn != null)
|
|
99
|
+
deleteBtn.addEventListener ("click", (e) => { e.preventDefault (); self.handleDelete (id); });
|
|
100
|
+
|
|
101
|
+
// Load existing record.
|
|
102
|
+
self.fetchAndFill (page, id);
|
|
103
|
+
|
|
104
|
+
return (null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected readIdFromUrl (): string
|
|
108
|
+
{
|
|
109
|
+
const params = new URLSearchParams (window.location.search);
|
|
110
|
+
return params.get (this.id_param) || "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected showError (page: HTMLElement, msg: string): void
|
|
114
|
+
{
|
|
115
|
+
const fb = page.querySelector (".fl-detail-feedback") as HTMLElement | null;
|
|
116
|
+
if (fb) { fb.className = "fl-detail-feedback alert alert-danger"; fb.textContent = msg; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected showSuccess (page: HTMLElement, msg: string): void
|
|
120
|
+
{
|
|
121
|
+
const fb = page.querySelector (".fl-detail-feedback") as HTMLElement | null;
|
|
122
|
+
if (fb)
|
|
123
|
+
{
|
|
124
|
+
fb.className = "fl-detail-feedback alert alert-success";
|
|
125
|
+
fb.textContent = msg;
|
|
126
|
+
setTimeout (() => { fb.className = "fl-detail-feedback d-none"; fb.textContent = ""; }, 2500);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
protected async fetchAndFill (page: HTMLElement, id: string): Promise<void>
|
|
131
|
+
{
|
|
132
|
+
try
|
|
133
|
+
{
|
|
134
|
+
const headers: any = { "Content-Type": "application/json" };
|
|
135
|
+
if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
|
|
136
|
+
const res = await fetch (this.get_url, {
|
|
137
|
+
method: "POST", headers: headers, body: JSON.stringify ({ id: id })
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) { this.showError (page, "Could not load: HTTP " + res.status); return; }
|
|
140
|
+
const obj = await res.json ();
|
|
141
|
+
if (obj == null) { this.showError (page, "Record not found."); return; }
|
|
142
|
+
this.populateFields (page, obj);
|
|
143
|
+
}
|
|
144
|
+
catch (ex)
|
|
145
|
+
{
|
|
146
|
+
this.showError (page, "Could not load: " + (ex as Error).message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
protected populateFields (page: HTMLElement, obj: any): void
|
|
151
|
+
{
|
|
152
|
+
const nodes = page.querySelectorAll ("[hot-field]");
|
|
153
|
+
for (let i = 0; i < nodes.length; i++)
|
|
154
|
+
{
|
|
155
|
+
const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
156
|
+
const field = el.getAttribute ("hot-field");
|
|
157
|
+
if (field == null || field === "") continue;
|
|
158
|
+
const val = obj[field];
|
|
159
|
+
if (val == null) continue;
|
|
160
|
+
if (el instanceof HTMLInputElement && el.type === "checkbox")
|
|
161
|
+
el.checked = val === true || val === "true" || val === 1;
|
|
162
|
+
else
|
|
163
|
+
el.value = String (val);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
protected collectValues (page: HTMLElement): any
|
|
168
|
+
{
|
|
169
|
+
const out: any = {};
|
|
170
|
+
const nodes = page.querySelectorAll ("[hot-field]");
|
|
171
|
+
for (let i = 0; i < nodes.length; i++)
|
|
172
|
+
{
|
|
173
|
+
const el = nodes[i] as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
|
174
|
+
const field = el.getAttribute ("hot-field");
|
|
175
|
+
if (field == null || field === "") continue;
|
|
176
|
+
if (el instanceof HTMLInputElement && el.type === "checkbox")
|
|
177
|
+
out[field] = el.checked;
|
|
178
|
+
else if (el instanceof HTMLInputElement && el.type === "number")
|
|
179
|
+
out[field] = el.value === "" ? null : Number (el.value);
|
|
180
|
+
else
|
|
181
|
+
out[field] = el.value;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
protected async handleSave (page: HTMLElement, id: string): Promise<void>
|
|
187
|
+
{
|
|
188
|
+
const values = this.collectValues (page);
|
|
189
|
+
values.id = id;
|
|
190
|
+
const body: any = this.payload_key ? { [this.payload_key]: values } : values;
|
|
191
|
+
|
|
192
|
+
const btn = page.querySelector (".fl-detail-save") as HTMLButtonElement | null;
|
|
193
|
+
if (btn) btn.disabled = true;
|
|
194
|
+
try
|
|
195
|
+
{
|
|
196
|
+
const headers: any = { "Content-Type": "application/json" };
|
|
197
|
+
if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
|
|
198
|
+
const res = await fetch (this.save_url, {
|
|
199
|
+
method: "POST", headers: headers, body: JSON.stringify (body)
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok)
|
|
202
|
+
{
|
|
203
|
+
let msg = "HTTP " + res.status;
|
|
204
|
+
try { const j = await res.json (); if (j && j.error) msg = j.error; } catch (e) {}
|
|
205
|
+
this.showError (page, "Save failed: " + msg);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
this.showSuccess (page, "Saved.");
|
|
209
|
+
}
|
|
210
|
+
catch (ex)
|
|
211
|
+
{
|
|
212
|
+
this.showError (page, "Save failed: " + (ex as Error).message);
|
|
213
|
+
}
|
|
214
|
+
finally
|
|
215
|
+
{
|
|
216
|
+
if (btn) btn.disabled = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
protected async handleDelete (id: string): Promise<void>
|
|
221
|
+
{
|
|
222
|
+
if (!window.confirm (this.delete_confirm)) return;
|
|
223
|
+
try
|
|
224
|
+
{
|
|
225
|
+
const headers: any = { "Content-Type": "application/json" };
|
|
226
|
+
if (this.jwt) headers["Authorization"] = "Bearer " + this.jwt;
|
|
227
|
+
const res = await fetch (this.delete_url, {
|
|
228
|
+
method: "POST", headers: headers, body: JSON.stringify ({ id: id })
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) { alert ("Delete failed: HTTP " + res.status); return; }
|
|
231
|
+
window.location.href = this.back_url;
|
|
232
|
+
}
|
|
233
|
+
catch (ex)
|
|
234
|
+
{
|
|
235
|
+
alert ("Delete failed: " + (ex as Error).message);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
output (): string | HotComponentOutput[]
|
|
240
|
+
{
|
|
241
|
+
if (this.name === "")
|
|
242
|
+
throw new Error ("admin-detail-page: id (name) is required");
|
|
243
|
+
if (this.get_url === "" || this.save_url === "")
|
|
244
|
+
throw new Error ("admin-detail-page: hot-get-url and hot-save-url are required");
|
|
245
|
+
|
|
246
|
+
const deleteBtn = this.delete_url
|
|
247
|
+
? `<button type="button" class="btn btn-outline-danger fl-detail-delete">${this.delete_text}</button>`
|
|
248
|
+
: "";
|
|
249
|
+
|
|
250
|
+
return (`
|
|
251
|
+
<div id="${this.name}" class="fl-detail-page">
|
|
252
|
+
<div class="container" style="max-width:880px;padding:1.5rem 1rem 7rem;">
|
|
253
|
+
<div class="mb-3"><a href="${this.back_url}" class="text-muted small text-decoration-none">${this.back_text}</a></div>
|
|
254
|
+
<h1 class="h3 mb-3">${this.title}</h1>
|
|
255
|
+
<div class="fl-detail-feedback d-none"></div>
|
|
256
|
+
<div class="card mb-3"><div class="card-body">
|
|
257
|
+
<div class="row g-3">
|
|
258
|
+
<hot-place-here name="detailBody"></hot-place-here>
|
|
259
|
+
</div>
|
|
260
|
+
</div></div>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="fl-detail-save-bar">
|
|
263
|
+
<div class="container d-flex justify-content-between align-items-center" style="max-width:880px;">
|
|
264
|
+
${deleteBtn}
|
|
265
|
+
<button type="button" class="btn btn-primary fl-detail-save ms-auto">${this.save_text}</button>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>`);
|
|
269
|
+
}
|
|
270
|
+
}
|