@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.
- 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 +62 -0
- package/build/components/admin-add-panel.d.ts.map +1 -0
- package/build/components/admin-add-panel.js +191 -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 +232 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pinned info card — short headline + body text, indigo left border.
|
|
5
|
+
* Pattern lifted from the DonorTiers "No governance influence"
|
|
6
|
+
* disclaimer on /donorTiers. Useful for "no influence", "draft only",
|
|
7
|
+
* "internal use", etc. notes that belong above the main content.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <admin-disclaimer hot-heading="Disclaimer">
|
|
11
|
+
* The body text of the disclaimer goes here.
|
|
12
|
+
* </admin-disclaimer>
|
|
13
|
+
*/
|
|
14
|
+
export class AdminDisclaimer extends HotComponent
|
|
15
|
+
{
|
|
16
|
+
/** Heading (h2.h6). */
|
|
17
|
+
heading: string;
|
|
18
|
+
/** Accent color for the left border. */
|
|
19
|
+
accent: string;
|
|
20
|
+
|
|
21
|
+
constructor (copy: HotComponent | HotStaq, api: HotAPI)
|
|
22
|
+
{
|
|
23
|
+
super (copy, api);
|
|
24
|
+
|
|
25
|
+
this.tag = "admin-disclaimer";
|
|
26
|
+
this.heading = "";
|
|
27
|
+
this.accent = "#4f46e5";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output (): string | HotComponentOutput[]
|
|
31
|
+
{
|
|
32
|
+
const heading = this.heading ? `<h2 class="h6 mb-2" style="letter-spacing:0.02em;">${this.heading}</h2>` : "";
|
|
33
|
+
return `
|
|
34
|
+
<div class="card fl-disclaimer mb-3" style="border-left:4px solid ${this.accent};">
|
|
35
|
+
<div class="card-body">
|
|
36
|
+
${heading}
|
|
37
|
+
<div class="mb-0 text-muted" style="font-size:0.9rem;line-height:1.45;">
|
|
38
|
+
${this.inner || ""}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A page heading with a small uppercase "eyebrow" label above it.
|
|
5
|
+
* Pattern lifted from the /live page on Freelight — the "Live Campaign
|
|
6
|
+
* Page" label above the candidate name.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <admin-eyebrow-heading hot-eyebrow="Campaign Project"
|
|
10
|
+
* hot-heading="Q4 Outreach Plan"></admin-eyebrow-heading>
|
|
11
|
+
*/
|
|
12
|
+
export class AdminEyebrowHeading extends HotComponent
|
|
13
|
+
{
|
|
14
|
+
/** The small uppercase label rendered above the heading. */
|
|
15
|
+
eyebrow: string;
|
|
16
|
+
/** The h1 text. */
|
|
17
|
+
heading: string;
|
|
18
|
+
/** Optional muted subtitle rendered below the heading. */
|
|
19
|
+
subtitle: string;
|
|
20
|
+
/** "center" | "start" (default). */
|
|
21
|
+
align: string;
|
|
22
|
+
|
|
23
|
+
constructor (copy: HotComponent | HotStaq, api: HotAPI)
|
|
24
|
+
{
|
|
25
|
+
super (copy, api);
|
|
26
|
+
|
|
27
|
+
this.tag = "admin-eyebrow-heading";
|
|
28
|
+
this.eyebrow = "";
|
|
29
|
+
this.heading = "";
|
|
30
|
+
this.subtitle = "";
|
|
31
|
+
this.align = "start";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
output (): string | HotComponentOutput[]
|
|
35
|
+
{
|
|
36
|
+
const alignClass = this.align === "center" ? "text-center" : "";
|
|
37
|
+
const eyebrow = this.eyebrow
|
|
38
|
+
? `<div class="text-uppercase text-muted small mb-2" style="letter-spacing:0.08em;">${this.eyebrow}</div>`
|
|
39
|
+
: "";
|
|
40
|
+
const subtitle = this.subtitle
|
|
41
|
+
? `<p class="text-muted mb-0">${this.subtitle}</p>`
|
|
42
|
+
: "";
|
|
43
|
+
return `
|
|
44
|
+
<div class="fl-eyebrow-heading mb-3 ${alignClass}">
|
|
45
|
+
${eyebrow}
|
|
46
|
+
<h1 class="h3 mb-1">${this.heading || this.inner || ""}</h1>
|
|
47
|
+
${subtitle}
|
|
48
|
+
</div>`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { HotStaq, Hot, HotAPI, HotComponent, HotComponentOutput } from "hotstaq";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A labeled form field. Wraps a single <input>, <select>, or <textarea>
|
|
5
|
+
* with a proper <label>, an optional required-marker, and helper text.
|
|
6
|
+
*
|
|
7
|
+
* Replaces the boilerplate of hand-rolling label + input + small help
|
|
8
|
+
* div across every Add/Edit form. Inspired by the Freelight pages
|
|
9
|
+
* (volunteers.hott, yard-signs.hott, members.hott) where every field
|
|
10
|
+
* already follows this pattern.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* <admin-form-field hot-field="name" hot-label="First name" hot-required="1"
|
|
14
|
+
* hot-placeholder="e.g. Jane" hot-autocomplete="given-name">
|
|
15
|
+
* </admin-form-field>
|
|
16
|
+
*
|
|
17
|
+
* <admin-form-field hot-field="status" hot-label="Status" hot-control="select"
|
|
18
|
+
* hot-options="active,suspended,invited"></admin-form-field>
|
|
19
|
+
*
|
|
20
|
+
* <admin-form-field hot-field="description" hot-label="Description"
|
|
21
|
+
* hot-control="textarea" hot-rows="3"
|
|
22
|
+
* hot-help="Shown on the public page."></admin-form-field>
|
|
23
|
+
*/
|
|
24
|
+
export class AdminFormField extends HotComponent
|
|
25
|
+
{
|
|
26
|
+
/** The field name (used for hot-field and the label's `for` attribute). */
|
|
27
|
+
field: string;
|
|
28
|
+
/** The visible label text. */
|
|
29
|
+
label: string;
|
|
30
|
+
/** "1" / "true" to render the red required marker after the label. */
|
|
31
|
+
required: string;
|
|
32
|
+
/** input type when control is "input" (text, email, tel, number, date, etc). */
|
|
33
|
+
type: string;
|
|
34
|
+
/** Control kind: "input" (default) | "select" | "textarea" | "checkbox". */
|
|
35
|
+
control: string;
|
|
36
|
+
/** Placeholder text (ignored for type=date / control=select / checkbox). */
|
|
37
|
+
placeholder: string;
|
|
38
|
+
/** Autocomplete attribute value (given-name, email, tel, street-address, etc). */
|
|
39
|
+
autocomplete: string;
|
|
40
|
+
/** Small helper text shown below the input. */
|
|
41
|
+
help: string;
|
|
42
|
+
/** Comma-separated options when control="select". Each item can be "value:label" or just "value". */
|
|
43
|
+
options: string;
|
|
44
|
+
/** Rows for control="textarea". */
|
|
45
|
+
rows: string;
|
|
46
|
+
/** Minimum value for type=number. */
|
|
47
|
+
min: string;
|
|
48
|
+
/** Step for type=number. */
|
|
49
|
+
step: string;
|
|
50
|
+
/** Max length for input/textarea. */
|
|
51
|
+
maxlength: string;
|
|
52
|
+
/** Bootstrap column class for grid use, e.g. "col-md-6". Empty => no wrapper col. */
|
|
53
|
+
col: string;
|
|
54
|
+
/** Size: "sm" (default in the new theme) | "md". */
|
|
55
|
+
size: string;
|
|
56
|
+
/** Extra CSS classes for the input element itself. */
|
|
57
|
+
css_class: string;
|
|
58
|
+
|
|
59
|
+
constructor (copy: HotComponent | HotStaq, api: HotAPI)
|
|
60
|
+
{
|
|
61
|
+
super (copy, api);
|
|
62
|
+
|
|
63
|
+
this.tag = "admin-form-field";
|
|
64
|
+
this.field = "";
|
|
65
|
+
this.label = "";
|
|
66
|
+
this.required = "0";
|
|
67
|
+
this.type = "text";
|
|
68
|
+
this.control = "input";
|
|
69
|
+
this.placeholder = "";
|
|
70
|
+
this.autocomplete = "";
|
|
71
|
+
this.help = "";
|
|
72
|
+
this.options = "";
|
|
73
|
+
this.rows = "3";
|
|
74
|
+
this.min = "";
|
|
75
|
+
this.step = "";
|
|
76
|
+
this.maxlength = "";
|
|
77
|
+
this.col = "";
|
|
78
|
+
this.size = "sm";
|
|
79
|
+
this.css_class = "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Field renders inside the surrounding flow — no modal-body-relocation
|
|
84
|
+
* trick like admin-text does. The new components are designed for
|
|
85
|
+
* detail pages and inline add panels, not modals.
|
|
86
|
+
*/
|
|
87
|
+
onPostPlace (parentHtmlElement: HTMLElement, htmlElement: HTMLElement): HTMLElement
|
|
88
|
+
{
|
|
89
|
+
return (null);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private renderInput (id: string, sizeClass: string, classes: string): string
|
|
93
|
+
{
|
|
94
|
+
const minAttr = this.min ? ` min="${this.min}"` : "";
|
|
95
|
+
const stepAttr = this.step ? ` step="${this.step}"` : "";
|
|
96
|
+
const maxAttr = this.maxlength ? ` maxlength="${this.maxlength}"` : "";
|
|
97
|
+
const acAttr = this.autocomplete ? ` autocomplete="${this.autocomplete}"` : "";
|
|
98
|
+
const phAttr = (this.type === "date" || this.placeholder === "")
|
|
99
|
+
? "" : ` placeholder="${this.placeholder.replace (/"/g, """)}"`;
|
|
100
|
+
return `<input id="${id}" hot-field="${this.field}" type="${this.type}" class="form-control${sizeClass}${classes}"${minAttr}${stepAttr}${maxAttr}${acAttr}${phAttr} />`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private renderSelect (id: string, sizeClass: string, classes: string): string
|
|
104
|
+
{
|
|
105
|
+
const opts = (this.options || "").split (",").map ((raw) =>
|
|
106
|
+
{
|
|
107
|
+
const part = raw.trim ();
|
|
108
|
+
if (part === "") return "";
|
|
109
|
+
const colon = part.indexOf (":");
|
|
110
|
+
const val = colon >= 0 ? part.slice (0, colon) : part;
|
|
111
|
+
const lbl = colon >= 0 ? part.slice (colon + 1) : part;
|
|
112
|
+
return `<option value="${val.replace (/"/g, """)}">${lbl}</option>`;
|
|
113
|
+
}).join ("");
|
|
114
|
+
return `<select id="${id}" hot-field="${this.field}" class="form-select${sizeClass}${classes}">${opts}</select>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private renderTextarea (id: string, sizeClass: string, classes: string): string
|
|
118
|
+
{
|
|
119
|
+
const phAttr = this.placeholder ? ` placeholder="${this.placeholder.replace (/"/g, """)}"` : "";
|
|
120
|
+
const maxAttr = this.maxlength ? ` maxlength="${this.maxlength}"` : "";
|
|
121
|
+
return `<textarea id="${id}" hot-field="${this.field}" rows="${this.rows}" class="form-control${sizeClass}${classes}"${maxAttr}${phAttr}></textarea>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private renderCheckbox (id: string): string
|
|
125
|
+
{
|
|
126
|
+
return `<div class="form-check"><input id="${id}" hot-field="${this.field}" type="checkbox" class="form-check-input" /><label for="${id}" class="form-check-label">${this.label}</label></div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
output (): string | HotComponentOutput[]
|
|
130
|
+
{
|
|
131
|
+
if (this.field === "")
|
|
132
|
+
throw new Error ("admin-form-field: hot-field is required");
|
|
133
|
+
|
|
134
|
+
const id = `ff-${this.field}-${Math.random ().toString (36).slice (2, 7)}`;
|
|
135
|
+
const sizeClass = this.size === "sm" ? " form-control-sm" : "";
|
|
136
|
+
const classes = this.css_class ? " " + this.css_class : "";
|
|
137
|
+
|
|
138
|
+
// Checkbox is a special case — the label sits next to the box,
|
|
139
|
+
// not above. Skip the rest of the wrapping.
|
|
140
|
+
if (this.control === "checkbox")
|
|
141
|
+
{
|
|
142
|
+
const inner = this.renderCheckbox (id);
|
|
143
|
+
return (this.col ? `<div class="${this.col}">${inner}</div>` : inner);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let control = "";
|
|
147
|
+
if (this.control === "select") control = this.renderSelect (id, sizeClass, classes);
|
|
148
|
+
else if (this.control === "textarea") control = this.renderTextarea (id, sizeClass, classes);
|
|
149
|
+
else control = this.renderInput (id, sizeClass, classes);
|
|
150
|
+
|
|
151
|
+
const requiredMarker = (this.required === "1" || this.required === "true")
|
|
152
|
+
? ` <span class="text-danger" aria-label="required">*</span>`
|
|
153
|
+
: "";
|
|
154
|
+
|
|
155
|
+
const helpHtml = this.help
|
|
156
|
+
? `<div class="form-text small">${this.help}</div>`
|
|
157
|
+
: "";
|
|
158
|
+
|
|
159
|
+
const fieldHtml = `
|
|
160
|
+
<label for="${id}" class="form-label small mb-1">${this.label}${requiredMarker}</label>
|
|
161
|
+
${control}
|
|
162
|
+
${helpHtml}`;
|
|
163
|
+
|
|
164
|
+
return (this.col ? `<div class="${this.col}">${fieldHtml}</div>` : `<div>${fieldHtml}</div>`);
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,16 @@ import { AdminTableField } from "./components/admin-table-field";
|
|
|
7
7
|
import { AdminTableRow } from "./components/admin-table-row";
|
|
8
8
|
import { AdminText } from "./components/admin-text";
|
|
9
9
|
|
|
10
|
+
// admin-panel 0.4.0 — Freelight-style components. Backwards compatible
|
|
11
|
+
// with the existing modal/table set above; pages opt in by switching
|
|
12
|
+
// their .hott templates to use the new tags.
|
|
13
|
+
import { AdminFormField } from "./components/admin-form-field";
|
|
14
|
+
import { AdminAddPanel } from "./components/admin-add-panel";
|
|
15
|
+
import { AdminCardTable } from "./components/admin-card-table";
|
|
16
|
+
import { AdminDetailPage } from "./components/admin-detail-page";
|
|
17
|
+
import { AdminDisclaimer } from "./components/admin-disclaimer";
|
|
18
|
+
import { AdminEyebrowHeading } from "./components/admin-eyebrow-heading";
|
|
19
|
+
|
|
10
20
|
export {
|
|
11
21
|
AdminButton,
|
|
12
22
|
AdminDropdown,
|
|
@@ -15,5 +25,11 @@ export {
|
|
|
15
25
|
AdminTable,
|
|
16
26
|
AdminTableField,
|
|
17
27
|
AdminTableRow,
|
|
18
|
-
AdminText
|
|
19
|
-
|
|
28
|
+
AdminText,
|
|
29
|
+
AdminFormField,
|
|
30
|
+
AdminAddPanel,
|
|
31
|
+
AdminCardTable,
|
|
32
|
+
AdminDetailPage,
|
|
33
|
+
AdminDisclaimer,
|
|
34
|
+
AdminEyebrowHeading
|
|
35
|
+
};
|