@scalio-cloud/ui 0.32.0 → 0.34.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 +34 -0
- package/nav.css +37 -0
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/labels.js +2 -0
- package/react/mail.js +86 -7
- 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
|
|
@@ -376,6 +403,13 @@ lists `tenant` («Microsoft 365 über die Tenant-Verbindung», for apps that alr
|
|
|
376
403
|
tenant, like Nexus), the block asks only for the sending mailbox and saves `graph: {sender}`; `tenantHint` replaces
|
|
377
404
|
the default text about Mail.Send. Without `providers` the three providers stay as before.
|
|
378
405
|
|
|
406
|
+
Since 0.34.0 (scalio-platform 0.12.0): when the backend lists `tenants` (an app connected to several tenants, like
|
|
407
|
+
Citadel, Foundry and Pulse), the provider `tenant` also asks «Tenant, der sendet» and saves `graph: {sender, tenant_id}`
|
|
408
|
+
(an empty list shows a hint to connect a tenant first; with one tenant it is preselected). When the settings report
|
|
409
|
+
`send_rights`, the block «Senderecht in Exchange Online» shows the PowerShell from `GET /mail/send-rights` (Exchange
|
|
410
|
+
«RBAC for Applications», only the sending mailbox) with a copy button; `mailApi` has `sendRights()`. The default
|
|
411
|
+
`graphHint` and `tenantHint` now point to that script instead of the Entra permission Mail.Send.
|
|
412
|
+
|
|
379
413
|
## Protokoll: Aktionen und Jobs (React)
|
|
380
414
|
|
|
381
415
|
One menu item «Protokoll» with two tabs like Nexus (scalio-docs ADR 0070). The server side is scalio-platform
|
package/nav.css
CHANGED
|
@@ -754,6 +754,7 @@
|
|
|
754
754
|
.scalio-mail-test { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
755
755
|
.scalio-mail-test .scalio-input { flex: 1 1 15rem; width: auto; }
|
|
756
756
|
.scalio-mail-outbox { overflow-x: auto; }
|
|
757
|
+
.scalio-mail-script { margin: 0 0 0.75rem; padding: 0.75rem; max-height: 22rem; overflow: auto; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted)); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; line-height: 1.5; white-space: pre; }
|
|
757
758
|
.scalio-mail-outbox table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
|
758
759
|
.scalio-mail-outbox td { padding: 0.5rem 0.75rem 0.5rem 0; border-bottom: 1px solid hsl(var(--border)); vertical-align: top; }
|
|
759
760
|
.scalio-mail-outbox tr:last-child td { border-bottom: 0; }
|
|
@@ -777,3 +778,39 @@
|
|
|
777
778
|
.scalio-mail-preview-subject { padding: 0.5rem 0.75rem; border-bottom: 1px solid hsl(var(--border)); font-size: 0.875rem; }
|
|
778
779
|
.scalio-mail-preview-subject span { font-weight: 600; }
|
|
779
780
|
.scalio-mail-preview iframe { flex: 1; width: 100%; min-height: 16rem; border: 0; background: #fff; }
|
|
781
|
+
/* Page templates (pages.js, 0.33.0): Overview, List, Detail. Full width (ADR 0024), grids add columns. */
|
|
782
|
+
.scalio-template { display: flex; flex-direction: column; gap: 1.5rem; }
|
|
783
|
+
.scalio-template > .scalio-page-header { margin-bottom: 0; }
|
|
784
|
+
.scalio-kpi-row { display: grid; gap: 0.75rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
785
|
+
@media (min-width: 768px) { .scalio-kpi-row { gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); } }
|
|
786
|
+
.scalio-panels { display: grid; gap: 1rem; grid-template-columns: 1fr; }
|
|
787
|
+
@media (min-width: 1024px) { .scalio-panels { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
788
|
+
@media (min-width: 1536px) { .scalio-panels { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
|
789
|
+
.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)); }
|
|
790
|
+
.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)); }
|
|
791
|
+
.scalio-panel-title { margin: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
|
|
792
|
+
.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; }
|
|
793
|
+
.scalio-panel-more:hover, .scalio-panel-more a:hover, .scalio-panel-more button:hover { text-decoration: underline; }
|
|
794
|
+
.scalio-panel-body { padding: 1rem 1.25rem; min-width: 0; }
|
|
795
|
+
.scalio-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
|
|
796
|
+
.scalio-toolbar-search { width: 100%; max-width: 20rem; margin: 0; }
|
|
797
|
+
.scalio-chips { display: flex; flex-wrap: wrap; gap: 0.375rem; }
|
|
798
|
+
.scalio-chip {
|
|
799
|
+
display: inline-flex; align-items: center; gap: 0.375rem; height: 1.875rem; padding: 0 0.75rem; border: 1px solid hsl(var(--border));
|
|
800
|
+
border-radius: 999px; background: hsl(var(--background)); color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.8125rem; cursor: pointer;
|
|
801
|
+
}
|
|
802
|
+
.scalio-chip:hover { color: hsl(var(--foreground)); border-color: hsl(var(--foreground) / 0.4); }
|
|
803
|
+
.scalio-chip.is-active { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.12); color: hsl(var(--foreground)); font-weight: 600; }
|
|
804
|
+
.scalio-chip:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
805
|
+
.scalio-chip-count { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
|
|
806
|
+
.scalio-toolbar-extra { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
807
|
+
.scalio-toolbar-menu { margin-left: auto; }
|
|
808
|
+
.scalio-list-body { min-width: 0; overflow-x: auto; }
|
|
809
|
+
.scalio-detail-body { display: grid; gap: 1.5rem; grid-template-columns: minmax(0, 1fr); }
|
|
810
|
+
@media (min-width: 1024px) { .scalio-detail-body.has-facts { grid-template-columns: minmax(0, 1fr) 18rem; } }
|
|
811
|
+
.scalio-detail-main { min-width: 0; }
|
|
812
|
+
.scalio-facts { align-self: start; padding: 1rem 1.25rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); }
|
|
813
|
+
.scalio-facts dl { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0.75rem 0 0; font-size: 0.875rem; }
|
|
814
|
+
.scalio-facts dt { color: hsl(var(--muted-foreground)); }
|
|
815
|
+
.scalio-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
816
|
+
.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.34.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
|
@@ -385,6 +385,7 @@ const LABELS = {
|
|
|
385
385
|
// States
|
|
386
386
|
loading: "Wird geladen …",
|
|
387
387
|
empty: "Noch keine Einträge.",
|
|
388
|
+
pages: { showAll: "Alle anzeigen", filter: "Filter", sections: "Bereiche" },
|
|
388
389
|
sections: {
|
|
389
390
|
account: "Konto",
|
|
390
391
|
appearance: "Darstellung",
|
|
@@ -795,6 +796,7 @@ const LABELS = {
|
|
|
795
796
|
},
|
|
796
797
|
loading: "Loading …",
|
|
797
798
|
empty: "No entries yet.",
|
|
799
|
+
pages: { showAll: "Show all", filter: "Filter", sections: "Sections" },
|
|
798
800
|
sections: {
|
|
799
801
|
account: "Account",
|
|
800
802
|
appearance: "Appearance",
|
package/react/mail.js
CHANGED
|
@@ -27,8 +27,17 @@ const LABELS = {
|
|
|
27
27
|
clientId: "Client-ID der App",
|
|
28
28
|
clientSecret: "Client-Secret",
|
|
29
29
|
sender: "Postfach, das sendet",
|
|
30
|
-
graphHint: "
|
|
31
|
-
tenantHint: "Die App sendet mit ihrer bestehenden Verbindung zum Tenant, ohne zweites Secret.
|
|
30
|
+
graphHint: "Das Senderecht geben Sie in Exchange Online mit «RBAC for Applications» nur für das Postfach, das sendet (Skript unten, nach dem Speichern); die App braucht dann keine Berechtigung Mail.Send in Entra ID.",
|
|
31
|
+
tenantHint: "Die App sendet mit ihrer bestehenden Verbindung zum Tenant, ohne zweites Secret. Das Senderecht geben Sie in Exchange Online nur für dieses Postfach (Skript unten, nach dem Speichern).",
|
|
32
|
+
tenantPick: "Tenant, der sendet",
|
|
33
|
+
tenantChoose: "Bitte wählen",
|
|
34
|
+
noTenants: "Noch kein Tenant verbunden. Verbinden Sie zuerst einen Tenant, dann wählen Sie ihn hier.",
|
|
35
|
+
rightsTitle: "Senderecht in Exchange Online",
|
|
36
|
+
rightsHint: "Damit die App nur als dieses Postfach senden darf: Führen Sie das Skript einmal als Exchange-Administrator in der Azure Cloud Shell (PowerShell) aus. Es nutzt «RBAC for Applications» und kann erneut laufen, auch nach einem Wechsel des Postfachs. Exchange übernimmt das Recht nach bis zu zwei Stunden; dann die Testmail senden.",
|
|
37
|
+
showScript: "Skript anzeigen",
|
|
38
|
+
copy: "Kopieren",
|
|
39
|
+
copied: "Skript kopiert",
|
|
40
|
+
copyFailed: "Kopieren nicht möglich. Bitte markieren und kopieren.",
|
|
32
41
|
testMailbox: "Testinstanz: Mails gehen über das Testpostfach, die Einstellungen hier gelten nicht.",
|
|
33
42
|
save: "Speichern",
|
|
34
43
|
saved: "Gespeichert",
|
|
@@ -84,8 +93,17 @@ const LABELS = {
|
|
|
84
93
|
clientId: "Client ID of the app",
|
|
85
94
|
clientSecret: "Client secret",
|
|
86
95
|
sender: "Mailbox that sends",
|
|
87
|
-
graphHint: "
|
|
88
|
-
tenantHint: "The app sends with its existing connection to the tenant, no second secret.
|
|
96
|
+
graphHint: "Give the send right in Exchange Online with RBAC for Applications, only for the sending mailbox (script below, after saving); the app then needs no Mail.Send permission in Entra ID.",
|
|
97
|
+
tenantHint: "The app sends with its existing connection to the tenant, no second secret. Give the send right in Exchange Online for this mailbox only (script below, after saving).",
|
|
98
|
+
tenantPick: "Tenant that sends",
|
|
99
|
+
tenantChoose: "Please choose",
|
|
100
|
+
noTenants: "No tenant connected yet. Connect a tenant first, then choose it here.",
|
|
101
|
+
rightsTitle: "Send right in Exchange Online",
|
|
102
|
+
rightsHint: "So the app may send only as this mailbox: run the script once as an Exchange administrator in Azure Cloud Shell (PowerShell). It uses RBAC for Applications and can run again, also after changing the mailbox. Exchange applies the right within up to two hours; then send the test mail.",
|
|
103
|
+
showScript: "Show script",
|
|
104
|
+
copy: "Copy",
|
|
105
|
+
copied: "Script copied",
|
|
106
|
+
copyFailed: "Copying is not possible. Please select and copy.",
|
|
89
107
|
testMailbox: "Test instance: mails go through the test mailbox, the settings here do not apply.",
|
|
90
108
|
save: "Save",
|
|
91
109
|
saved: "Saved",
|
|
@@ -166,6 +184,7 @@ function mailApi(api, base) {
|
|
|
166
184
|
resetTemplate: (kind, lang) => api.delete(`${b}/templates/${enc(kind)}/${enc(lang)}`).then(data),
|
|
167
185
|
preview: (body) => api.post(`${b}/templates/preview`, body).then(data),
|
|
168
186
|
testTemplate: (body) => api.post(`${b}/templates/test`, body).then(data),
|
|
187
|
+
sendRights: () => api.get(`${b}/send-rights`).then(data),
|
|
169
188
|
};
|
|
170
189
|
}
|
|
171
190
|
|
|
@@ -202,7 +221,9 @@ function select(id, value, options, onChange, extra) {
|
|
|
202
221
|
// Props: client (mailApi), lang, labels, notify(message, tone), formatError(err), graphHint (the app's own text for
|
|
203
222
|
// the Graph permissions, e.g. Beacon also needs Mail.ReadWrite), tenantHint (the same for the provider «tenant»,
|
|
204
223
|
// offered when the backend lists it in settings.providers, scalio-platform 0.10.0), children (more blocks after «Testmail», e.g. Beacon's
|
|
205
|
-
// mail intake), outbox (false hides «Letzte Mails»).
|
|
224
|
+
// mail intake), outbox (false hides «Letzte Mails»). With scalio-platform 0.12.0 the backend may list `tenants` (the app is
|
|
225
|
+
// connected to several tenants: the administrator picks the one that sends) and `send_rights` (the block «Senderecht in
|
|
226
|
+
// Exchange Online» shows the PowerShell from GET /mail/send-rights).
|
|
206
227
|
function MailTransport(props) {
|
|
207
228
|
const { client, lang } = props;
|
|
208
229
|
const L = mailLabels(lang, props.labels);
|
|
@@ -213,6 +234,9 @@ function MailTransport(props) {
|
|
|
213
234
|
const [testing, setTesting] = React.useState(false);
|
|
214
235
|
const [testMsg, setTestMsg] = React.useState(null);
|
|
215
236
|
const [rows, setRows] = React.useState([]);
|
|
237
|
+
const [script, setScript] = React.useState(null);
|
|
238
|
+
const [scriptBusy, setScriptBusy] = React.useState(false);
|
|
239
|
+
const [scriptMsg, setScriptMsg] = React.useState(null);
|
|
216
240
|
const showOutbox = props.outbox !== false;
|
|
217
241
|
|
|
218
242
|
const loadOutbox = React.useCallback(() => {
|
|
@@ -240,8 +264,12 @@ function MailTransport(props) {
|
|
|
240
264
|
const body = { provider: form.provider, from_name: form.from_name, from_address: form.from_address, language: form.language };
|
|
241
265
|
if (form.provider === "smtp") body.smtp = Object.assign({}, form.smtp, { port: Number(form.smtp.port) || 587 });
|
|
242
266
|
if (form.provider === "graph") body.graph = form.graph;
|
|
243
|
-
if (form.provider === "tenant")
|
|
267
|
+
if (form.provider === "tenant") {
|
|
268
|
+
body.graph = { sender: form.graph.sender };
|
|
269
|
+
if (Array.isArray(form.tenants)) body.graph.tenant_id = form.graph.tenant_id || "";
|
|
270
|
+
}
|
|
244
271
|
setForm(await client.save(body));
|
|
272
|
+
setScript(null);
|
|
245
273
|
m.ok(L.saved);
|
|
246
274
|
} catch (err) {
|
|
247
275
|
m.fail(err);
|
|
@@ -265,6 +293,30 @@ function MailTransport(props) {
|
|
|
265
293
|
setTesting(false);
|
|
266
294
|
};
|
|
267
295
|
|
|
296
|
+
const showScript = async () => {
|
|
297
|
+
setScriptBusy(true);
|
|
298
|
+
setScriptMsg(null);
|
|
299
|
+
try {
|
|
300
|
+
const r = client.sendRights ? await client.sendRights() : null;
|
|
301
|
+
setScript((r && r.script) || "");
|
|
302
|
+
} catch (err) {
|
|
303
|
+
setScriptMsg({ tone: "error", text: (props.formatError || errorText)(err) });
|
|
304
|
+
}
|
|
305
|
+
setScriptBusy(false);
|
|
306
|
+
};
|
|
307
|
+
const copyScript = async () => {
|
|
308
|
+
let text = L.copied, tone = "success";
|
|
309
|
+
try {
|
|
310
|
+
await navigator.clipboard.writeText(script);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
text = L.copyFailed;
|
|
313
|
+
tone = "error";
|
|
314
|
+
}
|
|
315
|
+
if (props.notify) props.notify(text, tone);
|
|
316
|
+
else setScriptMsg({ tone: tone === "error" ? "error" : "ok", text });
|
|
317
|
+
};
|
|
318
|
+
const tenants = Array.isArray(form.tenants) ? form.tenants : null;
|
|
319
|
+
|
|
268
320
|
const providers = (form.providers || ["off", "smtp", "graph"]).filter((id) => L.providers[id]);
|
|
269
321
|
const transport = h(
|
|
270
322
|
SettingsSection,
|
|
@@ -313,7 +365,17 @@ function MailTransport(props) {
|
|
|
313
365
|
: null,
|
|
314
366
|
form.provider === "tenant"
|
|
315
367
|
? h("div", { "data-testid": "mail-tenant" },
|
|
368
|
+
tenants && tenants.length === 0
|
|
369
|
+
? h("div", { className: "scalio-notice is-info", "data-testid": "mail-no-tenants" }, L.noTenants)
|
|
370
|
+
: null,
|
|
316
371
|
h("div", { className: "scalio-mail-grid" },
|
|
372
|
+
tenants && tenants.length > 0
|
|
373
|
+
? h(Field, { id: "mail-tenant-pick", label: L.tenantPick },
|
|
374
|
+
select("mail-tenant-pick",
|
|
375
|
+
tenants.some((t) => t.id === form.graph.tenant_id) ? form.graph.tenant_id : (tenants.length === 1 ? tenants[0].id : ""),
|
|
376
|
+
(tenants.length === 1 ? [] : [["", L.tenantChoose]]).concat(tenants.map((t) => [t.id, t.name || t.id])),
|
|
377
|
+
part("graph", "tenant_id"), { disabled: busy }))
|
|
378
|
+
: null,
|
|
317
379
|
h(Field, { id: "mail-tenant-sender", label: L.sender }, input("mail-tenant-sender", form.graph.sender, part("graph", "sender"),
|
|
318
380
|
{ type: "email", maxLength: 254, placeholder: "it@example.com", disabled: busy }))),
|
|
319
381
|
h("p", { className: "scalio-settings-help" }, props.tenantHint || L.tenantHint))
|
|
@@ -335,6 +397,23 @@ function MailTransport(props) {
|
|
|
335
397
|
h(Notice, { msg: testMsg, testId: "mail-test-message" })
|
|
336
398
|
);
|
|
337
399
|
|
|
400
|
+
const rights = form.send_rights && (form.provider === "graph" || form.provider === "tenant")
|
|
401
|
+
? h(
|
|
402
|
+
SettingsSection,
|
|
403
|
+
{ id: "mail-rights", title: L.rightsTitle, description: L.rightsHint, testId: "mail-rights" },
|
|
404
|
+
script
|
|
405
|
+
? h(React.Fragment, null,
|
|
406
|
+
h("pre", { className: "scalio-mail-script", "data-testid": "mail-rights-script" }, script),
|
|
407
|
+
h("div", { className: "scalio-settings-section-footer" },
|
|
408
|
+
h("button", { type: "button", className: "scalio-btn", onClick: copyScript, "data-testid": "mail-rights-copy" },
|
|
409
|
+
h(Icons.Copy, { className: "scalio-icon" }), L.copy)))
|
|
410
|
+
: h("div", { className: "scalio-settings-section-footer" },
|
|
411
|
+
h("button", { type: "button", className: "scalio-btn", onClick: showScript, disabled: scriptBusy, "data-testid": "mail-rights-show" },
|
|
412
|
+
L.showScript)),
|
|
413
|
+
h(Notice, { msg: scriptMsg, testId: "mail-rights-message" })
|
|
414
|
+
)
|
|
415
|
+
: null;
|
|
416
|
+
|
|
338
417
|
const outbox = showOutbox
|
|
339
418
|
? h(
|
|
340
419
|
SettingsSection,
|
|
@@ -355,7 +434,7 @@ function MailTransport(props) {
|
|
|
355
434
|
)
|
|
356
435
|
: null;
|
|
357
436
|
|
|
358
|
-
return h(React.Fragment, null, transport, testBlock, props.children || null, outbox);
|
|
437
|
+
return h(React.Fragment, null, transport, rights, testBlock, props.children || null, outbox);
|
|
359
438
|
}
|
|
360
439
|
|
|
361
440
|
// Props: client (mailApi), lang, labels, notify, formatError, editor (the app's rich text editor: a component with
|
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 };
|