@scalio-cloud/ui 0.31.1 → 0.33.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/README.md +32 -1
- package/nav.css +36 -0
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/labels.js +8 -0
- package/react/maintenance.js +19 -2
- package/react/pages.js +191 -0
package/README.md
CHANGED
|
@@ -151,6 +151,33 @@ Only actions that start something or lead away go there (Neu …, Import, Scanne
|
|
|
151
151
|
Labels without "Neu"/"New": the + icon says it ("+ Kontakt", not "+ Neuer Kontakt").
|
|
152
152
|
Filters, search, tabs and Speichern stay on the page. Apps with their own sidebar place `<ActionSlot />` themselves.
|
|
153
153
|
|
|
154
|
+
## Page templates (React, from 0.33.0)
|
|
155
|
+
|
|
156
|
+
UISPEC section 2 «Page templates» (`nav/N10` B): every page is an overview, a list, a detail or the settings
|
|
157
|
+
(`SettingsLayout`, below). The templates draw the header, put `actions` into `<PageActions>` (sidebar slot from
|
|
158
|
+
768px), and show `Loading` or `EmptyState` where the content would be.
|
|
159
|
+
|
|
160
|
+
```jsx
|
|
161
|
+
<OverviewPage title="Übersicht" kpis={[{ label: "Tenants", value: 3 }, { label: "Abweichungen", value: 2, tone: "error" }]}>
|
|
162
|
+
<Panel title="Letzte Scans" showAll={<Link to="/scans">Alle anzeigen</Link>}>…</Panel>
|
|
163
|
+
</OverviewPage>
|
|
164
|
+
<ListPage title="Kontakte" actions={<Button><Plus /> Kontakt</Button>} loading={loading} empty={!rows.length}
|
|
165
|
+
emptyText="Noch keine Kontakte." emptyAction={<Button>Ersten Kontakt erfassen</Button>}
|
|
166
|
+
toolbar={<Toolbar search={q} onSearch={setQ} filters={[{ id: "open", label: "Offen", active, count: 4 }]} onFilter={toggle} menu={<ExportMenu />} />}>
|
|
167
|
+
<Table>…</Table>
|
|
168
|
+
</ListPage>
|
|
169
|
+
<DetailPage title={t.name} status={{ tone: "ok", label: "Verbunden" }} back={<Link to="/tenants">Zurück</Link>}
|
|
170
|
+
pageActions={<Button><Upload /> Hochladen</Button>} actions={<Button>Freigeben</Button>}
|
|
171
|
+
tabs={[{ id: "info", label: "Info" }, { id: "log", label: "Protokoll" }]} tab={tab} onTab={setTab}
|
|
172
|
+
facts={[{ label: "Tenant-ID", value: t.id }]}>…</DetailPage>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
On a detail page `back` and `pageActions` (start something, lead away) go to the sidebar slot; `actions` are the
|
|
176
|
+
record's own actions (Freigeben, Stornieren) and stay in the header. `badge` takes an app's own status badge.
|
|
177
|
+
|
|
178
|
+
Panels sit in a grid with one column, two from 1024px and three from 1536px; key facts of a detail page move to
|
|
179
|
+
a right column from 1024px and leave no gap when there are none.
|
|
180
|
+
|
|
154
181
|
## Settings page (React)
|
|
155
182
|
|
|
156
183
|
Every area of the settings has its own address (`/settings/<area>`); the app's router picks the area, the
|
|
@@ -319,9 +346,13 @@ sorts the group `system` that way). The reset block talks to scalio-platform `fa
|
|
|
319
346
|
import { FactoryReset, factoryResetApi, SettingsField, SettingsRow } from "@scalio-cloud/ui/react";
|
|
320
347
|
|
|
321
348
|
const reset = factoryResetApi(api); // once, outside the component
|
|
322
|
-
<FactoryReset client={reset} lang={lang} tokenKey="citadel_token" backupTo="/settings/backup" link={Link} />
|
|
349
|
+
<FactoryReset client={reset} lang={lang} tokenKey="citadel_token" backupTo="/settings/backup" accountTo="/profile" link={Link} />
|
|
323
350
|
```
|
|
324
351
|
|
|
352
|
+
Since 0.32.0 (N21, security review 2026-10-27) the reset needs the code of the signed-in admin's authenticator app
|
|
353
|
+
or a backup code (`code` in the request, scalio-platform 0.11.0). Without a second factor the block says so and links
|
|
354
|
+
to `accountTo` (the profile page) instead of allowing the reset.
|
|
355
|
+
|
|
325
356
|
`SettingsField` (label, field, help) and `SettingsRow` (title and help left, a switch or button right; `tone="danger"`)
|
|
326
357
|
keep the fields inside a `SettingsSection` the same in every app.
|
|
327
358
|
|
package/nav.css
CHANGED
|
@@ -777,3 +777,39 @@
|
|
|
777
777
|
.scalio-mail-preview-subject { padding: 0.5rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); font-size: 0.875rem; }
|
|
778
778
|
.scalio-mail-preview-subject span { font-weight: 600; }
|
|
779
779
|
.scalio-mail-preview iframe { flex: 1; width: 100%; min-height: 16rem; border: 0; background: #fff; }
|
|
780
|
+
/* Page templates (pages.js, 0.33.0): Overview, List, Detail. Full width (ADR 0024), grids add columns. */
|
|
781
|
+
.scalio-template { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
782
|
+
.scalio-template > .scalio-page-header { margin-bottom: 0; }
|
|
783
|
+
.scalio-kpi-row { display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
784
|
+
@media (min-width: 768px) { .scalio-kpi-row { gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } }
|
|
785
|
+
.scalio-panels { display: grid; gap: 1rem; grid-template-columns: 1fr; }
|
|
786
|
+
@media (min-width: 1024px) { .scalio-panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
787
|
+
@media (min-width: 1536px) { .scalio-panels { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
788
|
+
.scalio-panel { display: flex; flex-direction: column; min-width: 0; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); }
|
|
789
|
+
.scalio-panel-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.875rem 1.25rem; border-bottom: 1px solid hsl(var(--border)); }
|
|
790
|
+
.scalio-panel-title { margin: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
|
|
791
|
+
.scalio-panel-more, .scalio-panel-more a, .scalio-panel-more button { border: 0; background: none; padding: 0; font: inherit; font-size: 0.8125rem; font-weight: 600; color: hsl(var(--primary)); text-decoration: none; cursor: pointer; }
|
|
792
|
+
.scalio-panel-more:hover, .scalio-panel-more a:hover, .scalio-panel-more button:hover { text-decoration: underline; }
|
|
793
|
+
.scalio-panel-body { padding: 1rem 1.25rem; min-width: 0; }
|
|
794
|
+
.scalio-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
|
795
|
+
.scalio-toolbar-search { width: 100%; max-width: 20rem; margin: 0; }
|
|
796
|
+
.scalio-chips { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
|
797
|
+
.scalio-chip {
|
|
798
|
+
display: inline-flex; align-items: center; gap: 0.375rem; height: 1.875rem; padding: 0 0.75rem; border: 1px solid hsl(var(--border));
|
|
799
|
+
border-radius: 999px; background: hsl(var(--background)); color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.8125rem; cursor: pointer;
|
|
800
|
+
}
|
|
801
|
+
.scalio-chip:hover { color: hsl(var(--foreground)); border-color: hsl(var(--foreground) / 0.4); }
|
|
802
|
+
.scalio-chip.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.12); color: hsl(var(--foreground)); font-weight: 600; }
|
|
803
|
+
.scalio-chip:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
804
|
+
.scalio-chip-count { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
|
805
|
+
.scalio-toolbar-extra { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
806
|
+
.scalio-toolbar-menu { margin-left: auto; }
|
|
807
|
+
.scalio-list-body { min-width: 0; overflow-x: auto; }
|
|
808
|
+
.scalio-detail-body { display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1fr); }
|
|
809
|
+
@media (min-width: 1024px) { .scalio-detail-body.has-facts { grid-template-columns: minmax(0, 1fr) 18rem; } }
|
|
810
|
+
.scalio-detail-main { min-width: 0; }
|
|
811
|
+
.scalio-facts { align-self: start; padding: 1rem 1.25rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); }
|
|
812
|
+
.scalio-facts dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0.75rem 0 0; font-size: 0.875rem; }
|
|
813
|
+
.scalio-facts dt { color: hsl(var(--muted-foreground)); }
|
|
814
|
+
.scalio-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
815
|
+
.scalio-page-title .scalio-badge { vertical-align: middle; margin-left: 0.25rem; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"private": false,
|
package/react/index.js
CHANGED
|
@@ -378,6 +378,6 @@ module.exports = {
|
|
|
378
378
|
TEST_IDS,
|
|
379
379
|
initials,
|
|
380
380
|
};
|
|
381
|
-
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"), require("./mail"), require("./saas_updates"));
|
|
381
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"), require("./users"), require("./roles"), require("./wizard"), require("./maintenance"), require("./audit"), require("./mail"), require("./saas_updates"), require("./pages"));
|
|
382
382
|
// After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
|
|
383
383
|
Object.assign(module.exports, require("./auth"));
|
package/react/labels.js
CHANGED
|
@@ -336,6 +336,9 @@ const LABELS = {
|
|
|
336
336
|
removeLicense: "Lizenz ebenfalls entfernen",
|
|
337
337
|
removeLicenseHint: "Danach läuft die Instanz als freie Edition, bis wieder ein Lizenzschlüssel eingetragen ist. Die Installations-ID bleibt.",
|
|
338
338
|
password: "Ihr Passwort",
|
|
339
|
+
code: "Code aus der Authenticator-App oder Backup-Code",
|
|
340
|
+
noFactor: "Zum Zurücksetzen braucht Ihr Konto eine Authenticator-App; richten Sie sie zuerst im Profil ein. Ein Konto, das sich nur mit Microsoft anmeldet, kann nicht zurücksetzen: Das macht ein lokaler Admin mit Authenticator-App.",
|
|
341
|
+
setUpFactor: "Zum Profil",
|
|
339
342
|
run: "Auf Werkseinstellungen zurücksetzen",
|
|
340
343
|
},
|
|
341
344
|
license: {
|
|
@@ -382,6 +385,7 @@ const LABELS = {
|
|
|
382
385
|
// States
|
|
383
386
|
loading: "Wird geladen …",
|
|
384
387
|
empty: "Noch keine Einträge.",
|
|
388
|
+
pages: { showAll: "Alle anzeigen", filter: "Filter", sections: "Bereiche" },
|
|
385
389
|
sections: {
|
|
386
390
|
account: "Konto",
|
|
387
391
|
appearance: "Darstellung",
|
|
@@ -744,6 +748,9 @@ const LABELS = {
|
|
|
744
748
|
removeLicense: "Remove the license as well",
|
|
745
749
|
removeLicenseHint: "Afterwards the instance runs as the free edition until a license key is entered again. The installation ID stays.",
|
|
746
750
|
password: "Your password",
|
|
751
|
+
code: "Code from the authenticator app or a backup code",
|
|
752
|
+
noFactor: "To reset, your account needs an authenticator app; set it up in your profile first. An account that signs in only with Microsoft cannot reset: a local admin with an authenticator app does it.",
|
|
753
|
+
setUpFactor: "Go to profile",
|
|
747
754
|
run: "Reset to factory settings",
|
|
748
755
|
},
|
|
749
756
|
license: {
|
|
@@ -789,6 +796,7 @@ const LABELS = {
|
|
|
789
796
|
},
|
|
790
797
|
loading: "Loading …",
|
|
791
798
|
empty: "No entries yet.",
|
|
799
|
+
pages: { showAll: "Show all", filter: "Filter", sections: "Sections" },
|
|
792
800
|
sections: {
|
|
793
801
|
account: "Account",
|
|
794
802
|
appearance: "Appearance",
|
package/react/maintenance.js
CHANGED
|
@@ -32,6 +32,7 @@ function FactoryReset(props) {
|
|
|
32
32
|
const [info, setInfo] = React.useState(null);
|
|
33
33
|
const [confirm, setConfirm] = React.useState("");
|
|
34
34
|
const [password, setPassword] = React.useState("");
|
|
35
|
+
const [code, setCode] = React.useState("");
|
|
35
36
|
const [removeLicense, setRemoveLicense] = React.useState(false);
|
|
36
37
|
const [busy, setBusy] = React.useState(false);
|
|
37
38
|
const [error, setError] = React.useState("");
|
|
@@ -42,14 +43,17 @@ function FactoryReset(props) {
|
|
|
42
43
|
return () => { alive = false; };
|
|
43
44
|
}, [client]);
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// N21: the backend says second_factor true (code needed) or false (set one up first); an older backend says nothing.
|
|
47
|
+
const factor = info ? info.second_factor : undefined;
|
|
48
|
+
const ready = confirm.trim() === "RESET" && (!info || !info.password || !!password)
|
|
49
|
+
&& factor !== false && (factor !== true || !!code.trim());
|
|
46
50
|
const run = async (e) => {
|
|
47
51
|
e.preventDefault();
|
|
48
52
|
if (!ready) return;
|
|
49
53
|
setBusy(true);
|
|
50
54
|
setError("");
|
|
51
55
|
try {
|
|
52
|
-
await client.run({ confirm, password, remove_license: removeLicense });
|
|
56
|
+
await client.run({ confirm, password, code, remove_license: removeLicense });
|
|
53
57
|
try { if (props.tokenKey) window.localStorage.removeItem(props.tokenKey); } catch (x) { /* private mode */ }
|
|
54
58
|
if (props.onDone) props.onDone();
|
|
55
59
|
else window.location.href = "/login";
|
|
@@ -94,6 +98,19 @@ function FactoryReset(props) {
|
|
|
94
98
|
h("input", { id: "factory-reset-password", type: "password", className: "scalio-input", value: password, disabled: busy,
|
|
95
99
|
autoComplete: "current-password", "data-testid": "factory-reset-password", onChange: (e) => setPassword(e.target.value) }))
|
|
96
100
|
: null,
|
|
101
|
+
factor === true
|
|
102
|
+
? h(Field, { id: "factory-reset-code", label: L.code },
|
|
103
|
+
h("input", { id: "factory-reset-code", className: "scalio-input", value: code, disabled: busy, inputMode: "numeric",
|
|
104
|
+
autoComplete: "one-time-code", "data-testid": "factory-reset-code", onChange: (e) => setCode(e.target.value) }))
|
|
105
|
+
: null,
|
|
106
|
+
factor === false
|
|
107
|
+
? h("div", { className: "scalio-notice is-warn", "data-testid": "factory-reset-no-factor" },
|
|
108
|
+
h("span", null, L.noFactor),
|
|
109
|
+
props.accountTo && info.password
|
|
110
|
+
? h(props.link || "a", Object.assign({ className: "scalio-btn" },
|
|
111
|
+
props.link ? { to: props.accountTo } : { href: props.accountTo }), L.setUpFactor)
|
|
112
|
+
: null)
|
|
113
|
+
: null,
|
|
97
114
|
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "factory-reset-error" }, error) : null,
|
|
98
115
|
h("div", { className: "scalio-settings-section-footer" },
|
|
99
116
|
h("button", { type: "submit", className: "scalio-btn is-danger is-solid", disabled: busy || !ready, "data-testid": "factory-reset-run" },
|
package/react/pages.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Page templates (UISPEC section 2 «Page templates», nav/N10 B, from 0.33.0): Overview, List and Detail. The
|
|
3
|
+
// Settings template is SettingsLayout (settings.js). A page passes its data; header, page actions in the sidebar
|
|
4
|
+
// slot, grids, toolbar, loading and empty state look the same in every app.
|
|
5
|
+
const React = require("react");
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const { labelsFor } = require("./labels");
|
|
8
|
+
const { KpiTile, EmptyState, Loading, StatusBadge } = require("./blocks");
|
|
9
|
+
const { PageActions } = require("./actions");
|
|
10
|
+
|
|
11
|
+
function cx() {
|
|
12
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Same layout as PageHeader in index.js (not required from there: index.js requires this module).
|
|
16
|
+
function Header({ title, subtitle, eyebrow, back, actions, extra }) {
|
|
17
|
+
const right = actions || extra ? h(React.Fragment, null, extra || null, actions ? h(PageActions, null, actions) : null) : null;
|
|
18
|
+
return h(
|
|
19
|
+
"div",
|
|
20
|
+
{ className: "scalio-page-header", "data-testid": "page-header" },
|
|
21
|
+
h(
|
|
22
|
+
"div",
|
|
23
|
+
{ className: "scalio-page-header-text" },
|
|
24
|
+
back ? h("div", { className: "scalio-page-back" }, back) : null,
|
|
25
|
+
eyebrow ? h("div", { className: "scalio-eyebrow" }, eyebrow) : null,
|
|
26
|
+
h("h1", { className: "scalio-page-title" }, title),
|
|
27
|
+
subtitle ? h("p", { className: "scalio-page-subtitle" }, subtitle) : null
|
|
28
|
+
),
|
|
29
|
+
right ? h("div", { className: "scalio-page-actions" }, right) : null
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Box on an overview page: title, optional «Alle anzeigen» (link element or onShowAll), content or empty text.
|
|
34
|
+
function Panel({ title, showAll, onShowAll, empty, emptyText, children, lang, labels, testId, className }) {
|
|
35
|
+
const L = labelsFor(lang, labels);
|
|
36
|
+
let more = null;
|
|
37
|
+
if (showAll) more = h("span", { className: "scalio-panel-more" }, showAll);
|
|
38
|
+
else if (onShowAll) more = h("button", { type: "button", className: "scalio-panel-more", onClick: onShowAll }, L.pages.showAll);
|
|
39
|
+
return h(
|
|
40
|
+
"section",
|
|
41
|
+
{ className: cx("scalio-panel", className), "data-testid": testId },
|
|
42
|
+
title || more
|
|
43
|
+
? h("div", { className: "scalio-panel-head" }, title ? h("h2", { className: "scalio-panel-title" }, title) : h("span"), more)
|
|
44
|
+
: null,
|
|
45
|
+
h("div", { className: "scalio-panel-body" }, empty ? h(EmptyState, { text: emptyText, lang, labels }) : children)
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Overview: header, row of KPI tiles (tone "error" = needs attention), panels in a grid that adds columns.
|
|
50
|
+
function OverviewPage({ title, subtitle, actions, kpis, loading, children, lang, labels, testId }) {
|
|
51
|
+
const tiles = (kpis || []).filter(Boolean);
|
|
52
|
+
return h(
|
|
53
|
+
"div",
|
|
54
|
+
{ className: "scalio-template is-overview", "data-testid": testId || "overview-page" },
|
|
55
|
+
h(Header, { title, subtitle, actions }),
|
|
56
|
+
loading ? h(Loading, { lang, labels }) : null,
|
|
57
|
+
tiles.length
|
|
58
|
+
? h("div", { className: "scalio-kpi-row", "data-testid": "kpi-row" }, tiles.map((k, i) => h(KpiTile, Object.assign({ key: k.key || k.testId || i }, k))))
|
|
59
|
+
: null,
|
|
60
|
+
children ? h("div", { className: "scalio-panels" }, children) : null
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Toolbar of a list: search, filter chips, extra controls and a menu (import/export) on the right.
|
|
65
|
+
function Toolbar({ search, onSearch, searchLabel, filters, onFilter, menu, children, lang, labels, testId }) {
|
|
66
|
+
const L = labelsFor(lang, labels);
|
|
67
|
+
const chips = (filters || []).filter(Boolean);
|
|
68
|
+
return h(
|
|
69
|
+
"div",
|
|
70
|
+
{ className: "scalio-toolbar", "data-testid": testId || "list-toolbar" },
|
|
71
|
+
onSearch
|
|
72
|
+
? h("input", {
|
|
73
|
+
type: "search",
|
|
74
|
+
className: "scalio-input scalio-toolbar-search",
|
|
75
|
+
value: search || "",
|
|
76
|
+
placeholder: searchLabel || L.search,
|
|
77
|
+
"aria-label": searchLabel || L.search,
|
|
78
|
+
onChange: (e) => onSearch(e.target.value),
|
|
79
|
+
"data-testid": "list-search",
|
|
80
|
+
})
|
|
81
|
+
: null,
|
|
82
|
+
chips.length
|
|
83
|
+
? h(
|
|
84
|
+
"div",
|
|
85
|
+
{ className: "scalio-chips", role: "group", "aria-label": L.pages.filter },
|
|
86
|
+
chips.map((f) =>
|
|
87
|
+
h(
|
|
88
|
+
"button",
|
|
89
|
+
{
|
|
90
|
+
key: f.id,
|
|
91
|
+
type: "button",
|
|
92
|
+
className: cx("scalio-chip", f.active && "is-active"),
|
|
93
|
+
"aria-pressed": f.active ? "true" : "false",
|
|
94
|
+
onClick: () => onFilter && onFilter(f.id),
|
|
95
|
+
"data-testid": `filter-${f.id}`,
|
|
96
|
+
},
|
|
97
|
+
f.label,
|
|
98
|
+
f.count != null ? h("span", { className: "scalio-chip-count" }, f.count) : null
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
: null,
|
|
103
|
+
children ? h("div", { className: "scalio-toolbar-extra" }, children) : null,
|
|
104
|
+
menu ? h("div", { className: "scalio-toolbar-menu" }, menu) : null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// List: header with the main action (sidebar slot from 768px), toolbar, then the table or the empty state with
|
|
109
|
+
// the next step. `empty` true shows the empty state instead of children.
|
|
110
|
+
function ListPage({ title, subtitle, actions, toolbar, loading, empty, emptyText, emptyAction, children, lang, labels, testId }) {
|
|
111
|
+
let body = children;
|
|
112
|
+
if (loading) body = h(Loading, { lang, labels });
|
|
113
|
+
else if (empty) body = h(EmptyState, { text: emptyText, lang, labels }, emptyAction || null);
|
|
114
|
+
return h(
|
|
115
|
+
"div",
|
|
116
|
+
{ className: "scalio-template is-list", "data-testid": testId || "list-page" },
|
|
117
|
+
h(Header, { title, subtitle, actions }),
|
|
118
|
+
toolbar || null,
|
|
119
|
+
h("div", { className: "scalio-list-body" }, body)
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Key facts on the right of a detail page: [{label, value}] as a definition list.
|
|
124
|
+
function KeyFacts({ items, title, testId }) {
|
|
125
|
+
const rows = (items || []).filter((i) => i && i.value != null && i.value !== "");
|
|
126
|
+
if (!rows.length) return null;
|
|
127
|
+
return h(
|
|
128
|
+
"aside",
|
|
129
|
+
{ className: "scalio-facts", "data-testid": testId || "key-facts" },
|
|
130
|
+
title ? h("h2", { className: "scalio-panel-title" }, title) : null,
|
|
131
|
+
h(
|
|
132
|
+
"dl",
|
|
133
|
+
null,
|
|
134
|
+
rows.map((r, i) =>
|
|
135
|
+
h(React.Fragment, { key: r.key || i }, h("dt", null, r.label), h("dd", null, r.value))
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Detail: back link and actions that lead away (`pageActions`) in the sidebar slot from 768px, header with status
|
|
142
|
+
// badge and the record's own actions (`actions`: Freigeben, Stornieren; they stay on the page), tabs, key facts on
|
|
143
|
+
// the right. tabs: [{id, label}] with tab/onTab, or leave them out for one section. status: {tone, label}, or
|
|
144
|
+
// `badge` for an app's own badge element.
|
|
145
|
+
function DetailPage({ title, subtitle, back, pageActions, status, badge: ownBadge, actions, tabs, tab, onTab, facts, factsTitle, loading, children, lang, labels, testId }) {
|
|
146
|
+
const L = labelsFor(lang, labels);
|
|
147
|
+
const list = (tabs || []).filter(Boolean);
|
|
148
|
+
const badge = ownBadge || (status && status.label ? h(StatusBadge, { tone: status.tone, testId: "detail-status" }, status.label) : null);
|
|
149
|
+
const slot = back || pageActions
|
|
150
|
+
? h(React.Fragment, null, back ? h("span", { className: "scalio-detail-back", "data-testid": "detail-back" }, back) : null, pageActions || null)
|
|
151
|
+
: null;
|
|
152
|
+
return h(
|
|
153
|
+
"div",
|
|
154
|
+
{ className: "scalio-template is-detail", "data-testid": testId || "detail-page" },
|
|
155
|
+
h(Header, {
|
|
156
|
+
title: badge ? h(React.Fragment, null, title, " ", badge) : title,
|
|
157
|
+
subtitle,
|
|
158
|
+
actions: slot,
|
|
159
|
+
extra: actions || null,
|
|
160
|
+
}),
|
|
161
|
+
list.length
|
|
162
|
+
? h(
|
|
163
|
+
"div",
|
|
164
|
+
{ className: "scalio-tabs", role: "tablist", "aria-label": L.pages.sections },
|
|
165
|
+
list.map((t) =>
|
|
166
|
+
h(
|
|
167
|
+
"button",
|
|
168
|
+
{
|
|
169
|
+
key: t.id,
|
|
170
|
+
type: "button",
|
|
171
|
+
role: "tab",
|
|
172
|
+
className: cx("scalio-tab", t.id === tab && "is-active"),
|
|
173
|
+
"aria-selected": t.id === tab ? "true" : "false",
|
|
174
|
+
onClick: () => onTab && onTab(t.id),
|
|
175
|
+
"data-testid": t.testId || `tab-${t.id}`,
|
|
176
|
+
},
|
|
177
|
+
t.label
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
: null,
|
|
182
|
+
h(
|
|
183
|
+
"div",
|
|
184
|
+
{ className: cx("scalio-detail-body", facts && facts.length && "has-facts") },
|
|
185
|
+
h("div", { className: "scalio-detail-main" }, loading ? h(Loading, { lang, labels }) : children),
|
|
186
|
+
h(KeyFacts, { items: facts, title: factsTitle })
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { OverviewPage, Panel, ListPage, Toolbar, DetailPage, KeyFacts };
|