@scalio-cloud/ui 0.18.1 → 0.20.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/CHANGELOG.md +19 -1
- package/README.md +7 -0
- package/nav.css +18 -0
- package/package.json +1 -1
- package/react/beacon.js +112 -0
- package/react/index.js +1 -1
- package/react/labels.js +78 -0
- package/react/support.js +209 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
Scalio Ideen (ADR 0050): feature requests from the apps, votes and comments, kept centrally in Compass.
|
|
5
|
+
- `supportApi(api, { ideas: true })` adds the routes of scalio-connect 0.8.0 (`ideas_router`). The support drawer then
|
|
6
|
+
gets a third tab «Ideen»: ideas of this app (most votes, newest, done), vote and take back, «Meine», idea page with
|
|
7
|
+
the answer from Scalio and comments, and «Idee vorschlagen» with similar ideas shown while typing.
|
|
8
|
+
- With ideas on, the ticket category «Wunsch» leaves the ticket form. A self-hosted installation without a connection
|
|
9
|
+
shows a link to the public page (`<portal>/ideen`), also in the link-only support view.
|
|
10
|
+
- New export `IdeasPanel`; CSS in `nav.css` (`.scalio-idea-*`, `.scalio-ideas*`).
|
|
11
|
+
- **Visible change:** only in apps that pass `{ ideas: true }`. No token changes.
|
|
12
|
+
|
|
13
|
+
## 0.19.0
|
|
14
|
+
Settings card "Scalio Beacon" for the apps (Beacon phase 3, scalio-docs `concepts/beacon-suite.md`).
|
|
15
|
+
- `BeaconConnection` with `peersApi(api)`: pair the installation with a Beacon instance (address + one-time code from
|
|
16
|
+
Beacon), show the pairing (address, version, last contact, queue), test it and disconnect. Routes from
|
|
17
|
+
scalio-connect 0.8.0 (`scalio_connect.peers.web.admin_router`).
|
|
18
|
+
- Labels `beacon*` in German and English.
|
|
19
|
+
- **Visible change:** none until an app shows the card. No token changes.
|
|
20
|
+
|
|
3
21
|
## 0.18.1
|
|
4
22
|
Page actions after Markus' feedback (2026-10-31, UISPEC section 2 "Page actions"). 0.18.0 was released before these
|
|
5
23
|
changes were merged; apps use 0.18.1.
|
|
@@ -36,7 +54,7 @@ Same profile page in every app, reference Scalio Nexus (Markus 2026-10-31, scali
|
|
|
36
54
|
- **Visible change:** none until an app uses the new parts. No token changes.
|
|
37
55
|
|
|
38
56
|
## 0.15.0
|
|
39
|
-
New app Scalio Beacon (service desk, scalio-docs ADR
|
|
57
|
+
New app Scalio Beacon (service desk, scalio-docs ADR 0044).
|
|
40
58
|
- Product switcher: entry "Beacon" (icon `RadioTower`) between Citadel and Pulse, available like the others.
|
|
41
59
|
- `manualUrl("beacon")`: docs.scaliobeacon.com / devdocs.scaliobeacon.com.
|
|
42
60
|
- **Visible change:** every app shows "Beacon" in the switcher after its upgrade (greyed out until the instance
|
package/README.md
CHANGED
|
@@ -116,6 +116,13 @@ With `client` the drawer has the tabs "Neue Anfrage" (category, subject, descrip
|
|
|
116
116
|
0022), the backend routes come from scalio-connect `scalio_connect.support.web`. `supportApi(api)` takes the app's
|
|
117
117
|
axios instance. The settings card `<SupportConnection client={supportApi(api)} lang={lang} />` connects the
|
|
118
118
|
installation with a one-time code (admins). Without `client` the drawer keeps the old single form with `onSubmit`.
|
|
119
|
+
`supportApi(api, { ideas: true })` adds the tab "Ideen" (Scalio Ideen, ADR 0050; backend router
|
|
120
|
+
`scalio_connect.support.ideas_web.ideas_router`, scalio-connect 0.8.0): ideas of the app with votes and comments and
|
|
121
|
+
"Idee vorschlagen" with similar ideas; the ticket category "Wunsch" then moves there.
|
|
122
|
+
|
|
123
|
+
The settings card `<BeaconConnection client={peersApi(api)} lang={lang} />` pairs the app with Scalio Beacon
|
|
124
|
+
(address and one-time code from Beacon, Einstellungen -> Verbundene Apps); findings then go to Beacon. Backend:
|
|
125
|
+
scalio-connect 0.8.0 `peers.web.admin_router`.
|
|
119
126
|
|
|
120
127
|
`link` is the router's `NavLink` (it gets `to`, `end`, a `className` function and `onClick`); without it the
|
|
121
128
|
sidebar renders plain links and marks the item for `activePath`. Item test ids default to `nav-<path>`
|
package/nav.css
CHANGED
|
@@ -192,6 +192,24 @@
|
|
|
192
192
|
.scalio-support-msg { margin: 0; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; font-size: 0.875rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
193
193
|
.scalio-support-msg.is-support { border-color: hsl(var(--primary) / 0.3); background: hsl(var(--primary) / 0.05); }
|
|
194
194
|
.scalio-support-stars { display: flex; gap: 0.25rem; }
|
|
195
|
+
/* Scalio Ideen tab in the support drawer (ADR 0050) */
|
|
196
|
+
.scalio-ideas { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
197
|
+
.scalio-ideas-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
|
|
198
|
+
.scalio-ideas-bar .scalio-support-tab { flex: 0 0 auto; }
|
|
199
|
+
.scalio-ideas-sort { width: auto; margin-left: auto; padding-block: 0.25rem; font-size: 0.75rem; }
|
|
200
|
+
.scalio-idea-row { display: grid; grid-template-columns: 3rem 1fr; gap: 0.75rem; align-items: start; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; font-size: 0.875rem; cursor: pointer; }
|
|
201
|
+
.scalio-idea-row:hover { background: hsl(var(--muted)); }
|
|
202
|
+
.scalio-idea-row:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
|
|
203
|
+
.scalio-idea-row.is-static { padding: 0; border: 0; cursor: default; }
|
|
204
|
+
.scalio-idea-row.is-static:hover { background: none; }
|
|
205
|
+
.scalio-idea-body { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; overflow-wrap: anywhere; }
|
|
206
|
+
.scalio-idea-vote { display: flex; flex-direction: column; align-items: center; padding: 0.25rem 0; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: inherit; font: inherit; cursor: pointer; }
|
|
207
|
+
.scalio-idea-vote:hover:not(:disabled) { border-color: hsl(var(--primary)); }
|
|
208
|
+
.scalio-idea-vote:disabled { opacity: 0.6; cursor: default; }
|
|
209
|
+
.scalio-idea-vote.is-voted { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.08); color: hsl(var(--primary)); }
|
|
210
|
+
.scalio-idea-vote-n { font-family: 'JetBrains Mono', monospace; font-size: 1rem; font-weight: 600; line-height: 1.2; font-variant-numeric: tabular-nums; }
|
|
211
|
+
.scalio-idea-vote-l { font-size: 0.625rem; color: hsl(var(--muted-foreground)); }
|
|
212
|
+
.scalio-ideas-page { align-self: flex-start; text-decoration: underline; }
|
|
195
213
|
.scalio-support-stars button { border: 0; background: none; font-size: 1.25rem; line-height: 1; color: #f59e0b; cursor: pointer; }
|
|
196
214
|
.scalio-support-conn { font-size: 0.875rem; }
|
|
197
215
|
.scalio-support-conn-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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/beacon.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Settings card "Scalio Beacon" (Beacon phase 3, scalio-docs concepts/beacon-suite.md): an app (Citadel, Rosetta,
|
|
3
|
+
// Foundry, Nexus) pairs with a Beacon instance using the address and one-time code from Beacon (Einstellungen ->
|
|
4
|
+
// Verbundene Apps); findings then go to Beacon and become tickets. Backend routes from scalio-connect
|
|
5
|
+
// (`scalio_connect.peers.web.admin_router`); peersApi(api) maps them for an axios-like client.
|
|
6
|
+
const React = require("react");
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
const { labelsFor } = require("./labels");
|
|
9
|
+
|
|
10
|
+
function errorText(err) {
|
|
11
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
12
|
+
if (typeof d === "string") return d;
|
|
13
|
+
if (d && typeof d.message === "string") return d.message;
|
|
14
|
+
return (err && err.message) || String(err);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fmtDate(value, lang) {
|
|
18
|
+
if (!value) return "";
|
|
19
|
+
const d = new Date(value);
|
|
20
|
+
if (isNaN(d.getTime())) return String(value);
|
|
21
|
+
return d.toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "medium", timeStyle: "short" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// api: axios instance with the app's base URL (/api). Every method resolves to the response body.
|
|
25
|
+
function peersApi(api) {
|
|
26
|
+
const data = (p) => p.then((r) => r.data);
|
|
27
|
+
const enc = encodeURIComponent;
|
|
28
|
+
return {
|
|
29
|
+
list: () => data(api.get("/peers")),
|
|
30
|
+
connect: (url, code) => data(api.post("/peers", { url, code })),
|
|
31
|
+
check: (id) => data(api.post(`/peers/${enc(id)}/check`)),
|
|
32
|
+
remove: (id) => data(api.delete(`/peers/${enc(id)}`)),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function BeaconConnection({ client, lang, labels, onChange }) {
|
|
37
|
+
const L = labelsFor(lang, labels);
|
|
38
|
+
const [st, setSt] = React.useState(null);
|
|
39
|
+
const [url, setUrl] = React.useState("");
|
|
40
|
+
const [code, setCode] = React.useState("");
|
|
41
|
+
const [busy, setBusy] = React.useState(false);
|
|
42
|
+
const [error, setError] = React.useState("");
|
|
43
|
+
const [note, setNote] = React.useState("");
|
|
44
|
+
const load = React.useCallback(() => client.list().then((d) => setSt(d)).catch((err) => (setError(errorText(err)), setSt(false))), [client]);
|
|
45
|
+
React.useEffect(() => { load(); }, [load]);
|
|
46
|
+
const run = async (fn, ok) => {
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError("");
|
|
49
|
+
setNote("");
|
|
50
|
+
try {
|
|
51
|
+
const d = await fn();
|
|
52
|
+
if (d && d.ok === false) throw new Error(L.beaconCheckFailed.replace("{detail}", d.detail || d.status));
|
|
53
|
+
setCode("");
|
|
54
|
+
setNote(typeof ok === "function" ? ok(d) : ok);
|
|
55
|
+
await load();
|
|
56
|
+
if (onChange) onChange(d);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(errorText(err));
|
|
59
|
+
} finally {
|
|
60
|
+
setBusy(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
if (st === null) return h("div", { className: "scalio-support-meta" }, L.loading);
|
|
64
|
+
if (st === false) return h("div", { className: "scalio-notice is-error", role: "alert" }, error);
|
|
65
|
+
const beacons = (st.peers || []).filter((p) => p.app === "beacon");
|
|
66
|
+
return h(
|
|
67
|
+
"div",
|
|
68
|
+
{ className: "scalio-support-conn", "data-testid": "beacon-connection" },
|
|
69
|
+
h("p", { className: "scalio-support-meta" }, beacons.length ? L.beaconHelpConnected : L.beaconHelp),
|
|
70
|
+
beacons.map((p) => {
|
|
71
|
+
const q = p.queue || {};
|
|
72
|
+
return h(
|
|
73
|
+
"div",
|
|
74
|
+
{ key: p.id, className: "scalio-support-conn", "data-testid": "beacon-peer" },
|
|
75
|
+
h("div", { className: "scalio-support-conn-head" },
|
|
76
|
+
h("span", { className: "scalio-badge " + (p.usable ? "is-ok" : "is-error") }, p.usable ? L.beaconConnected : L.beaconKeyLost)),
|
|
77
|
+
h("dl", { className: "scalio-support-conn-facts" },
|
|
78
|
+
h("dt", null, L.beaconAddress), h("dd", null, p.url),
|
|
79
|
+
p.version ? h(React.Fragment, null, h("dt", null, L.version), h("dd", null, p.version)) : null,
|
|
80
|
+
h("dt", null, L.supportSince), h("dd", null, fmtDate(p.paired_at, lang)),
|
|
81
|
+
h("dt", null, L.beaconLastContact), h("dd", null, fmtDate(p.last_seen, lang))),
|
|
82
|
+
q.pending || q.failed
|
|
83
|
+
? h("div", { className: "scalio-support-meta", "data-testid": "beacon-queue" },
|
|
84
|
+
L.supportOutbox.replace("{pending}", q.pending || 0).replace("{failed}", q.failed || 0) + (q.last_error ? ` (${q.last_error})` : ""))
|
|
85
|
+
: null,
|
|
86
|
+
h("div", { className: "scalio-support-conn-actions" },
|
|
87
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "beacon-check",
|
|
88
|
+
onClick: () => run(() => client.check(p.id), (d) => L.beaconChecked.replace("{version}", (d && d.version) || "")) }, L.supportCheck),
|
|
89
|
+
h("button", { type: "button", className: "scalio-btn scalio-btn-danger", disabled: busy, "data-testid": "beacon-remove",
|
|
90
|
+
onClick: () => (typeof window === "undefined" || window.confirm(L.beaconRemoveConfirm)) && run(() => client.remove(p.id), L.supportRemoved) },
|
|
91
|
+
L.supportRemove))
|
|
92
|
+
);
|
|
93
|
+
}),
|
|
94
|
+
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "beacon-note" }, note) : null,
|
|
95
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "beacon-error" }, error) : null,
|
|
96
|
+
beacons.length
|
|
97
|
+
? null
|
|
98
|
+
: h(
|
|
99
|
+
"form",
|
|
100
|
+
{ className: "scalio-support-form", onSubmit: (e) => { e.preventDefault(); run(() => client.connect(url.trim(), code.trim()), L.beaconPaired); } },
|
|
101
|
+
h("label", { className: "scalio-field-label", htmlFor: "beacon-url" }, L.beaconAddress),
|
|
102
|
+
h("input", { id: "beacon-url", className: "scalio-input", placeholder: "https://beacon.example.com", value: url, required: true,
|
|
103
|
+
onChange: (e) => setUrl(e.target.value), "data-testid": "beacon-url-input" }),
|
|
104
|
+
h("label", { className: "scalio-field-label", htmlFor: "beacon-code" }, L.beaconCode),
|
|
105
|
+
h("input", { id: "beacon-code", className: "scalio-input", placeholder: "bpc_…", value: code, required: true,
|
|
106
|
+
onChange: (e) => setCode(e.target.value), "data-testid": "beacon-code-input" }),
|
|
107
|
+
h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "beacon-connect-btn" }, L.supportConnect)
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { peersApi, BeaconConnection };
|
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("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"));
|
|
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"));
|
|
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
|
@@ -140,6 +140,45 @@ const LABELS = {
|
|
|
140
140
|
supportRemove: "Trennen",
|
|
141
141
|
supportRemoveConfirm: "Verbindung zum Support trennen? Neue Tickets warten dann, bis wieder verbunden ist.",
|
|
142
142
|
supportRemoved: "Verbindung getrennt.",
|
|
143
|
+
ideasTab: "Ideen",
|
|
144
|
+
ideasAll: "Alle",
|
|
145
|
+
ideasMine: "Meine",
|
|
146
|
+
ideasSuggest: "Idee vorschlagen",
|
|
147
|
+
ideasSort: "Sortierung",
|
|
148
|
+
ideasSortTop: "Meiste Stimmen",
|
|
149
|
+
ideasSortNew: "Neueste",
|
|
150
|
+
ideasSortDone: "Erledigt",
|
|
151
|
+
ideasVote: "Abstimmen",
|
|
152
|
+
ideasVoted: "Abgestimmt",
|
|
153
|
+
ideasVotes: "Stimmen",
|
|
154
|
+
ideasComments: "Kommentare",
|
|
155
|
+
ideasComment: "Kommentar schreiben",
|
|
156
|
+
ideasAnswer: "Antwort von Scalio",
|
|
157
|
+
ideasNone: "Noch keine Ideen für dieses Produkt.",
|
|
158
|
+
ideasTitle: "Titel",
|
|
159
|
+
ideasTitleHelp: "Ein Satz, z.B. «An offene Zugriffsprüfungen erinnern»",
|
|
160
|
+
ideasDescriptionHelp: "Was möchten Sie erreichen? Wer würde es nutzen?",
|
|
161
|
+
ideasSimilar: "Ähnliche Ideen gibt es schon. Vielleicht lieber dort abstimmen:",
|
|
162
|
+
ideasSubmit: "Idee einreichen",
|
|
163
|
+
ideasSent: "Danke. Ihre Idee {n} erscheint nach unserer Prüfung für alle.",
|
|
164
|
+
ideasWaiting: "Wartet auf Prüfung: vorerst sehen nur Sie diese Idee.",
|
|
165
|
+
ideasWaitingShort: "wartet auf Prüfung",
|
|
166
|
+
ideasLinkOnly: "Diese Installation ist nicht mit Scalio verbunden. Ideen können Sie auf der öffentlichen Seite einreichen und abstimmen.",
|
|
167
|
+
ideasOpenPage: "Zur Seite Scalio Ideen",
|
|
168
|
+
ideasStatus: { new: "neu", open: "offen", reviewed: "geprüft", planned: "geplant", in_progress: "in Arbeit", done: "erledigt", declined: "abgelehnt", merged: "zusammengeführt" },
|
|
169
|
+
// Scalio Beacon (connected apps, Beacon phase 3)
|
|
170
|
+
beaconTitle: "Scalio Beacon",
|
|
171
|
+
beaconHelp: "Befunde dieser Installation gehen an Scalio Beacon und werden dort Tickets. Adresse und Kopplungscode erhalten Sie in Beacon unter Einstellungen -> Verbundene Apps.",
|
|
172
|
+
beaconHelpConnected: "Befunde dieser Installation gehen an Scalio Beacon und werden dort Tickets.",
|
|
173
|
+
beaconAddress: "Beacon-Adresse",
|
|
174
|
+
beaconCode: "Kopplungscode",
|
|
175
|
+
beaconConnected: "Verbunden",
|
|
176
|
+
beaconKeyLost: "Schlüssel nicht lesbar, bitte neu verbinden",
|
|
177
|
+
beaconLastContact: "Letzter Kontakt",
|
|
178
|
+
beaconPaired: "Mit Beacon verbunden.",
|
|
179
|
+
beaconChecked: "Verbindung funktioniert (Beacon {version}).",
|
|
180
|
+
beaconCheckFailed: "Keine Verbindung: {detail}",
|
|
181
|
+
beaconRemoveConfirm: "Verbindung zu Beacon trennen? Befunde gehen dann nicht mehr an Beacon; die Tickets dort bleiben.",
|
|
143
182
|
// Two-factor card (SecurityFactors) and license panel (LicensePanel)
|
|
144
183
|
mfa: {
|
|
145
184
|
backupTitle: "Ihre Backup-Codes",
|
|
@@ -384,6 +423,45 @@ const LABELS = {
|
|
|
384
423
|
supportRemove: "Disconnect",
|
|
385
424
|
supportRemoveConfirm: "Disconnect from support? New tickets then wait until the instance is connected again.",
|
|
386
425
|
supportRemoved: "Disconnected.",
|
|
426
|
+
ideasTab: "Ideas",
|
|
427
|
+
ideasAll: "All",
|
|
428
|
+
ideasMine: "Mine",
|
|
429
|
+
ideasSuggest: "Suggest an idea",
|
|
430
|
+
ideasSort: "Sort",
|
|
431
|
+
ideasSortTop: "Most votes",
|
|
432
|
+
ideasSortNew: "Newest",
|
|
433
|
+
ideasSortDone: "Done",
|
|
434
|
+
ideasVote: "Vote",
|
|
435
|
+
ideasVoted: "Voted",
|
|
436
|
+
ideasVotes: "votes",
|
|
437
|
+
ideasComments: "comments",
|
|
438
|
+
ideasComment: "Write a comment",
|
|
439
|
+
ideasAnswer: "Answer from Scalio",
|
|
440
|
+
ideasNone: "No ideas for this product yet.",
|
|
441
|
+
ideasTitle: "Title",
|
|
442
|
+
ideasTitleHelp: "One sentence, e.g. “Remind owners about open access reviews”",
|
|
443
|
+
ideasDescriptionHelp: "What do you want to achieve? Who would use it?",
|
|
444
|
+
ideasSimilar: "Similar ideas exist already. Maybe vote there instead:",
|
|
445
|
+
ideasSubmit: "Submit idea",
|
|
446
|
+
ideasSent: "Thank you. Your idea {n} becomes visible to everyone after our review.",
|
|
447
|
+
ideasWaiting: "Waiting for review: only you see this idea for now.",
|
|
448
|
+
ideasWaitingShort: "waiting for review",
|
|
449
|
+
ideasLinkOnly: "This installation is not connected to Scalio. You can suggest and vote for ideas on the public page.",
|
|
450
|
+
ideasOpenPage: "Open Scalio Ideas",
|
|
451
|
+
ideasStatus: { new: "new", open: "open", reviewed: "reviewed", planned: "planned", in_progress: "in progress", done: "done", declined: "declined", merged: "merged" },
|
|
452
|
+
// Scalio Beacon (connected apps, Beacon phase 3)
|
|
453
|
+
beaconTitle: "Scalio Beacon",
|
|
454
|
+
beaconHelp: "Findings of this installation go to Scalio Beacon and become tickets there. You get the address and the pairing code in Beacon under Settings -> Connected apps.",
|
|
455
|
+
beaconHelpConnected: "Findings of this installation go to Scalio Beacon and become tickets there.",
|
|
456
|
+
beaconAddress: "Beacon address",
|
|
457
|
+
beaconCode: "Pairing code",
|
|
458
|
+
beaconConnected: "Connected",
|
|
459
|
+
beaconKeyLost: "Key cannot be read, please connect again",
|
|
460
|
+
beaconLastContact: "Last contact",
|
|
461
|
+
beaconPaired: "Connected with Beacon.",
|
|
462
|
+
beaconChecked: "Connection works (Beacon {version}).",
|
|
463
|
+
beaconCheckFailed: "No connection: {detail}",
|
|
464
|
+
beaconRemoveConfirm: "Disconnect from Beacon? Findings no longer go to Beacon; the tickets there stay.",
|
|
387
465
|
mfa: {
|
|
388
466
|
backupTitle: "Your backup codes",
|
|
389
467
|
backupHint: "Each code works once, if you do not have your phone. Keep them in a safe place, for example your password manager. They are shown only now.",
|
package/react/support.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// them for an axios-like client. SupportDesk is the body of SupportDrawer when it gets a `client`:
|
|
5
5
|
// "Neue Anfrage" (category, subject, description, diagnostics) and "Meine Tickets" (list, detail, reply, rating).
|
|
6
6
|
// SupportConnection is the settings card "Support-Verbindung" (portal address + one-time code).
|
|
7
|
+
// Scalio Ideen (ADR 0050): supportApi(api, { ideas: true }) adds the tab "Ideen" (routes of
|
|
8
|
+
// scalio_connect.support.ideas_web): ideas of this app with votes and comments, and "Idee vorschlagen" with similar
|
|
9
|
+
// ideas shown while typing. The category "Wunsch" then leaves the ticket form.
|
|
7
10
|
const React = require("react");
|
|
8
11
|
const h = React.createElement;
|
|
9
12
|
const { labelsFor } = require("./labels");
|
|
@@ -16,10 +19,23 @@ function cx() {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
// api: axios instance with the app's base URL (/api). Every method resolves to the response body.
|
|
19
|
-
function supportApi(api) {
|
|
22
|
+
function supportApi(api, options) {
|
|
20
23
|
const data = (p) => p.then((r) => r.data);
|
|
21
24
|
const enc = encodeURIComponent;
|
|
25
|
+
const ideas = options && options.ideas
|
|
26
|
+
? {
|
|
27
|
+
status: () => data(api.get("/ideas/status")),
|
|
28
|
+
list: (params) => data(api.get("/ideas", { params: params || {} })),
|
|
29
|
+
similar: (text) => data(api.get("/ideas/similar", { params: { text } })),
|
|
30
|
+
mine: () => data(api.get("/ideas/mine")),
|
|
31
|
+
get: (ref) => data(api.get(`/ideas/${enc(ref)}`)),
|
|
32
|
+
submit: (idea) => data(api.post("/ideas", idea)),
|
|
33
|
+
vote: (ref, on) => data(api.post(`/ideas/${enc(ref)}/vote`, { on })),
|
|
34
|
+
comment: (ref, body) => data(api.post(`/ideas/${enc(ref)}/comments`, { body })),
|
|
35
|
+
}
|
|
36
|
+
: null;
|
|
22
37
|
return {
|
|
38
|
+
ideas,
|
|
23
39
|
create: (ticket) => data(api.post("/support/tickets", ticket)),
|
|
24
40
|
list: () => data(api.get("/support/tickets")),
|
|
25
41
|
get: (id) => data(api.get(`/support/tickets/${enc(id)}`)),
|
|
@@ -53,6 +69,7 @@ function statusText(L, s) {
|
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
function NewTicket({ client, page, L, onSent }) {
|
|
72
|
+
const categories = client.ideas ? CATEGORIES.filter((c) => c !== "wish") : CATEGORIES;
|
|
56
73
|
const [category, setCategory] = React.useState("question");
|
|
57
74
|
const [subject, setSubject] = React.useState("");
|
|
58
75
|
const [description, setDescription] = React.useState("");
|
|
@@ -83,7 +100,7 @@ function NewTicket({ client, page, L, onSent }) {
|
|
|
83
100
|
h(
|
|
84
101
|
"select",
|
|
85
102
|
{ id: "support-category", className: "scalio-input", value: category, onChange: (e) => setCategory(e.target.value), "data-testid": "ticket-category-select" },
|
|
86
|
-
|
|
103
|
+
categories.map((c) => h("option", { key: c, value: c }, L.supportCategories[c]))
|
|
87
104
|
),
|
|
88
105
|
h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
|
|
89
106
|
h("input", {
|
|
@@ -226,14 +243,194 @@ function TicketDetail({ client, id, L, lang, onBack }) {
|
|
|
226
243
|
}
|
|
227
244
|
|
|
228
245
|
// Self-hosted without a connection (ADR 0022, T11): no form, only the way to the portal's support page.
|
|
229
|
-
function PortalLink({ status, L }) {
|
|
246
|
+
function PortalLink({ status, L, ideasLink }) {
|
|
230
247
|
return h(
|
|
231
248
|
"div",
|
|
232
249
|
{ className: "scalio-support-form", "data-testid": "support-link-only" },
|
|
233
250
|
h("p", { className: "scalio-support-meta" }, L.supportLinkOnly),
|
|
234
251
|
h("a", { className: "scalio-btn-primary", href: status.portal_page, target: "_blank", rel: "noopener noreferrer",
|
|
235
252
|
"data-testid": "support-portal-link" }, L.supportOpenPortal),
|
|
236
|
-
h("p", { className: "scalio-support-meta" }, L.supportLinkOnlyAdmin)
|
|
253
|
+
h("p", { className: "scalio-support-meta" }, L.supportLinkOnlyAdmin),
|
|
254
|
+
ideasLink ? h("a", { className: "scalio-support-meta scalio-ideas-page", href: ideasLink, target: "_blank", rel: "noopener noreferrer",
|
|
255
|
+
"data-testid": "support-ideas-link" }, L.ideasOpenPage) : null
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
// ---------------- Scalio Ideen ----------------
|
|
261
|
+
function ideaStatus(L, s) {
|
|
262
|
+
return (L.ideasStatus && L.ideasStatus[s]) || s;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function VoteBtn({ idea, L, onVote }) {
|
|
266
|
+
const closed = ["done", "declined", "merged"].includes(idea.status);
|
|
267
|
+
return h(
|
|
268
|
+
"button",
|
|
269
|
+
{
|
|
270
|
+
type: "button", className: cx("scalio-idea-vote", idea.voted && "is-voted"), disabled: closed && !idea.voted,
|
|
271
|
+
"aria-pressed": !!idea.voted, title: idea.voted ? L.ideasVoted : L.ideasVote, "data-testid": `idea-vote-${idea.number}`,
|
|
272
|
+
onClick: (e) => { e.stopPropagation(); onVote(idea); },
|
|
273
|
+
},
|
|
274
|
+
h("span", { className: "scalio-idea-vote-n" }, String(idea.votes || 0)),
|
|
275
|
+
h("span", { className: "scalio-idea-vote-l" }, L.ideasVotes)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function IdeaForm({ ideas, L, onDone }) {
|
|
280
|
+
const [title, setTitle] = React.useState("");
|
|
281
|
+
const [description, setDescription] = React.useState("");
|
|
282
|
+
const [similar, setSimilar] = React.useState([]);
|
|
283
|
+
const [busy, setBusy] = React.useState(false);
|
|
284
|
+
const [error, setError] = React.useState("");
|
|
285
|
+
React.useEffect(() => {
|
|
286
|
+
if (title.trim().length < 4) { setSimilar([]); return undefined; }
|
|
287
|
+
let alive = true;
|
|
288
|
+
const t = setTimeout(() => {
|
|
289
|
+
ideas.similar(`${title} ${description}`).then((d) => alive && setSimilar((d && d.ideas) || [])).catch(() => alive && setSimilar([]));
|
|
290
|
+
}, 300);
|
|
291
|
+
return () => { alive = false; clearTimeout(t); };
|
|
292
|
+
}, [ideas, title, description]);
|
|
293
|
+
const submit = async (e) => {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
setBusy(true);
|
|
296
|
+
setError("");
|
|
297
|
+
try {
|
|
298
|
+
const res = await ideas.submit({ title: title.trim(), description: description.trim() });
|
|
299
|
+
setTitle("");
|
|
300
|
+
setDescription("");
|
|
301
|
+
onDone(L.ideasSent.replace("{n}", (res && res.number) || ""));
|
|
302
|
+
} catch (err) {
|
|
303
|
+
setError(errorText(err));
|
|
304
|
+
} finally {
|
|
305
|
+
setBusy(false);
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
return h(
|
|
309
|
+
"form",
|
|
310
|
+
{ className: "scalio-support-form", onSubmit: submit, "data-testid": "idea-form" },
|
|
311
|
+
h("label", { className: "scalio-field-label", htmlFor: "idea-title" }, L.ideasTitle),
|
|
312
|
+
h("input", { id: "idea-title", className: "scalio-input", value: title, required: true, minLength: 5, maxLength: 140,
|
|
313
|
+
placeholder: L.ideasTitleHelp, onChange: (e) => setTitle(e.target.value), "data-testid": "idea-title-input" }),
|
|
314
|
+
similar.length
|
|
315
|
+
? h(
|
|
316
|
+
"div",
|
|
317
|
+
{ className: "scalio-notice is-warn", "data-testid": "idea-similar" },
|
|
318
|
+
h("div", null, L.ideasSimilar),
|
|
319
|
+
similar.map((s) => h("div", { key: s.id, className: "scalio-support-meta" }, `${s.number} · ${s.title} (${s.votes} ${L.ideasVotes})`))
|
|
320
|
+
)
|
|
321
|
+
: null,
|
|
322
|
+
h("label", { className: "scalio-field-label", htmlFor: "idea-description" }, L.description),
|
|
323
|
+
h("textarea", { id: "idea-description", className: "scalio-input scalio-textarea", rows: 4, value: description, maxLength: 10000,
|
|
324
|
+
placeholder: L.ideasDescriptionHelp, onChange: (e) => setDescription(e.target.value), "data-testid": "idea-description-input" }),
|
|
325
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "idea-error" }, error) : null,
|
|
326
|
+
h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "idea-submit-btn" }, busy ? L.sending : L.ideasSubmit)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function IdeaDetail({ ideas, ref_, L, lang, onBack }) {
|
|
331
|
+
const [idea, setIdea] = React.useState(null);
|
|
332
|
+
const [text, setText] = React.useState("");
|
|
333
|
+
const [error, setError] = React.useState("");
|
|
334
|
+
const load = React.useCallback(() => ideas.get(ref_).then(setIdea).catch((err) => { setError(errorText(err)); setIdea(false); }), [ideas, ref_]);
|
|
335
|
+
React.useEffect(() => { load(); }, [load]);
|
|
336
|
+
const back = h("button", { type: "button", className: "scalio-support-back", onClick: onBack, "data-testid": "idea-back" }, `‹ ${L.ideasTab}`);
|
|
337
|
+
if (idea === null) return h("div", null, back, h("div", { className: "scalio-support-meta" }, L.loading));
|
|
338
|
+
if (idea === false) return h("div", null, back, h("div", { className: "scalio-notice is-error", role: "alert" }, error));
|
|
339
|
+
const vote = async () => {
|
|
340
|
+
try { await ideas.vote(idea.number, !idea.voted); await load(); } catch (err) { setError(errorText(err)); }
|
|
341
|
+
};
|
|
342
|
+
const send = async () => {
|
|
343
|
+
try { await ideas.comment(idea.number, text.trim()); setText(""); await load(); } catch (err) { setError(errorText(err)); }
|
|
344
|
+
};
|
|
345
|
+
return h(
|
|
346
|
+
"div",
|
|
347
|
+
{ className: "scalio-support-detail", "data-testid": "idea-detail" },
|
|
348
|
+
back,
|
|
349
|
+
h("div", { className: "scalio-idea-row is-static" },
|
|
350
|
+
h(VoteBtn, { idea, L, onVote: vote }),
|
|
351
|
+
h("div", { className: "scalio-idea-body" },
|
|
352
|
+
h("h4", { className: "scalio-support-item-title" }, idea.title),
|
|
353
|
+
h("div", { className: "scalio-support-meta" }, `${idea.number} · ${ideaStatus(L, idea.status)}${idea.author ? ` · ${idea.author}` : ""}`),
|
|
354
|
+
idea.public === false ? h("div", { className: "scalio-support-meta" }, L.ideasWaiting) : null)),
|
|
355
|
+
idea.description ? h("p", { className: "scalio-support-msg" }, idea.description) : null,
|
|
356
|
+
idea.answer && idea.answer.body
|
|
357
|
+
? h("div", { className: "scalio-support-msg is-support" }, h("div", { className: "scalio-support-meta" }, L.ideasAnswer), h("div", null, idea.answer.body))
|
|
358
|
+
: null,
|
|
359
|
+
(idea.comments || []).map((c) =>
|
|
360
|
+
h("div", { key: c.id, className: cx("scalio-support-msg", c.official && "is-support") },
|
|
361
|
+
h("div", { className: "scalio-support-meta" }, `${c.author} · ${fmtDate(c.created_at, lang)}`),
|
|
362
|
+
h("div", null, c.body))
|
|
363
|
+
),
|
|
364
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
|
|
365
|
+
idea.status !== "merged"
|
|
366
|
+
? h("div", { className: "scalio-support-form" },
|
|
367
|
+
h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: text, placeholder: L.ideasComment,
|
|
368
|
+
"aria-label": L.ideasComment, onChange: (e) => setText(e.target.value), "data-testid": "idea-comment-input" }),
|
|
369
|
+
h("button", { type: "button", className: "scalio-btn-primary", disabled: !text.trim(), onClick: send, "data-testid": "idea-comment-btn" }, L.send))
|
|
370
|
+
: null
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function IdeasPanel({ ideas, L, lang }) {
|
|
375
|
+
const [status, setStatus] = React.useState(null);
|
|
376
|
+
const [view, setView] = React.useState("list");
|
|
377
|
+
const [sort, setSort] = React.useState("top");
|
|
378
|
+
const [rows, setRows] = React.useState(null);
|
|
379
|
+
const [open, setOpen] = React.useState(null);
|
|
380
|
+
const [note, setNote] = React.useState("");
|
|
381
|
+
const [error, setError] = React.useState("");
|
|
382
|
+
React.useEffect(() => {
|
|
383
|
+
let alive = true;
|
|
384
|
+
ideas.status().then((d) => alive && setStatus(d || {})).catch(() => alive && setStatus({}));
|
|
385
|
+
return () => { alive = false; };
|
|
386
|
+
}, [ideas]);
|
|
387
|
+
const load = React.useCallback(() => {
|
|
388
|
+
setRows(null);
|
|
389
|
+
const p = view === "mine" ? ideas.mine() : ideas.list({ sort });
|
|
390
|
+
return p.then((d) => setRows((d && d.ideas) || [])).catch((err) => { setError(errorText(err)); setRows([]); });
|
|
391
|
+
}, [ideas, sort, view]);
|
|
392
|
+
React.useEffect(() => { if (status && status.connected && view !== "new") load(); }, [status, view, load]);
|
|
393
|
+
if (status === null) return h("div", { className: "scalio-support-meta" }, L.loading);
|
|
394
|
+
if (!status.connected) {
|
|
395
|
+
return h("div", { className: "scalio-support-form", "data-testid": "ideas-link-only" },
|
|
396
|
+
h("p", { className: "scalio-support-meta" }, L.ideasLinkOnly),
|
|
397
|
+
status.page ? h("a", { className: "scalio-btn-primary", href: status.page, target: "_blank", rel: "noopener noreferrer", "data-testid": "ideas-page-link" }, L.ideasOpenPage) : null);
|
|
398
|
+
}
|
|
399
|
+
if (open) return h(IdeaDetail, { ideas, ref_: open, L, lang, onBack: () => { setOpen(null); load(); } });
|
|
400
|
+
const vote = async (idea) => {
|
|
401
|
+
try {
|
|
402
|
+
const res = await ideas.vote(idea.number, !idea.voted);
|
|
403
|
+
setRows((list) => (list || []).map((r) => (r.id === idea.id ? { ...r, votes: res.votes, voted: res.voted } : r)));
|
|
404
|
+
} catch (err) { setError(errorText(err)); }
|
|
405
|
+
};
|
|
406
|
+
const pill = (key, label) => h("button", { type: "button", className: cx("scalio-support-tab", view === key && "is-active"),
|
|
407
|
+
onClick: () => { setView(key); setNote(""); setError(""); }, "data-testid": `ideas-view-${key}` }, label);
|
|
408
|
+
return h(
|
|
409
|
+
"div",
|
|
410
|
+
{ className: "scalio-ideas", "data-testid": "ideas-panel" },
|
|
411
|
+
h("div", { className: "scalio-ideas-bar" },
|
|
412
|
+
pill("list", L.ideasAll), pill("mine", L.ideasMine), pill("new", L.ideasSuggest),
|
|
413
|
+
view === "list"
|
|
414
|
+
? h("select", { className: "scalio-input scalio-ideas-sort", value: sort, "aria-label": L.ideasSort,
|
|
415
|
+
onChange: (e) => setSort(e.target.value), "data-testid": "ideas-sort" },
|
|
416
|
+
h("option", { value: "top" }, L.ideasSortTop), h("option", { value: "new" }, L.ideasSortNew), h("option", { value: "done" }, L.ideasSortDone))
|
|
417
|
+
: null),
|
|
418
|
+
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "ideas-note" }, note) : null,
|
|
419
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
|
|
420
|
+
view === "new"
|
|
421
|
+
? h(IdeaForm, { ideas, L, onDone: (text) => { setNote(text); setView("mine"); } })
|
|
422
|
+
: rows === null
|
|
423
|
+
? h("div", { className: "scalio-support-meta" }, L.loading)
|
|
424
|
+
: h("div", { className: "scalio-support-list" },
|
|
425
|
+
rows.length ? null : h("div", { className: "scalio-support-empty" }, L.ideasNone),
|
|
426
|
+
rows.map((i) => h("div", { key: i.id, role: "button", tabIndex: 0, className: "scalio-idea-row", "data-testid": `idea-row-${i.number}`,
|
|
427
|
+
onClick: () => setOpen(i.number), onKeyDown: (e) => { if (e.key === "Enter") setOpen(i.number); } },
|
|
428
|
+
h(VoteBtn, { idea: i, L, onVote: vote }),
|
|
429
|
+
h("div", { className: "scalio-idea-body" },
|
|
430
|
+
h("div", { className: "scalio-support-item-title" }, i.title),
|
|
431
|
+
h("div", { className: "scalio-support-meta" },
|
|
432
|
+
`${ideaStatus(L, i.status)} · ${i.number}${i.comment_count ? ` · ${i.comment_count} ${L.ideasComments}` : ""}${i.public === false ? ` · ${L.ideasWaitingShort}` : ""}`))))),
|
|
433
|
+
status.page ? h("a", { className: "scalio-support-meta scalio-ideas-page", href: status.page, target: "_blank", rel: "noopener noreferrer" }, L.ideasOpenPage) : null
|
|
237
434
|
);
|
|
238
435
|
}
|
|
239
436
|
|
|
@@ -255,15 +452,19 @@ function SupportDesk({ client, page, lang, labels }) {
|
|
|
255
452
|
onClick: () => { setTab(key); setTicket(null); setNote(""); }, "data-testid": `support-tab-${key}`,
|
|
256
453
|
}, label);
|
|
257
454
|
if (status === null) return h("div", { className: "scalio-support-meta" }, L.loading);
|
|
258
|
-
if (status.link_only && status.portal_page)
|
|
455
|
+
if (status.link_only && status.portal_page) {
|
|
456
|
+
return h(PortalLink, { status, L, ideasLink: client.ideas && status.portal ? `${status.portal}/ideen` : "" });
|
|
457
|
+
}
|
|
259
458
|
return h(
|
|
260
459
|
React.Fragment,
|
|
261
460
|
null,
|
|
262
|
-
h("div", { className: "scalio-support-tabs", role: "tablist" }, tabBtn("new", L.supportNew), tabBtn("mine", L.supportMine)
|
|
461
|
+
h("div", { className: "scalio-support-tabs", role: "tablist" }, tabBtn("new", L.supportNew), tabBtn("mine", L.supportMine),
|
|
462
|
+
client.ideas ? tabBtn("ideas", L.ideasTab) : null),
|
|
263
463
|
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "support-note" }, note) : null,
|
|
264
464
|
tab === "new" ? h(NewTicket, { client, page, L, onSent: (text) => { setNote(text); setTab("mine"); } }) : null,
|
|
265
465
|
tab === "mine" && !ticket ? h(MyTickets, { client, L, lang, onOpen: (id) => { setNote(""); setTicket(id); } }) : null,
|
|
266
|
-
tab === "mine" && ticket ? h(TicketDetail, { client, id: ticket, L, lang, onBack: () => setTicket(null) }) : null
|
|
466
|
+
tab === "mine" && ticket ? h(TicketDetail, { client, id: ticket, L, lang, onBack: () => setTicket(null) }) : null,
|
|
467
|
+
tab === "ideas" && client.ideas ? h(IdeasPanel, { ideas: client.ideas, L, lang }) : null
|
|
267
468
|
);
|
|
268
469
|
}
|
|
269
470
|
|
|
@@ -369,4 +570,4 @@ function SupportConnection({ client, lang, labels, onChange }) {
|
|
|
369
570
|
);
|
|
370
571
|
}
|
|
371
572
|
|
|
372
|
-
module.exports = { supportApi, SupportDesk, SupportConnection, SUPPORT_CATEGORIES: CATEGORIES };
|
|
573
|
+
module.exports = { supportApi, SupportDesk, SupportConnection, IdeasPanel, SUPPORT_CATEGORIES: CATEGORIES };
|