viur-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/index.d.ts +351 -0
- package/index.js +46 -0
- package/package.json +84 -0
- package/src/components/Browser/Browser.vue +496 -0
- package/src/components/Browser/TreeNav.vue +408 -0
- package/src/components/Browser/ViurBrowser.d.vue.ts +64 -0
- package/src/components/Browser/ViurBrowser.vue +271 -0
- package/src/components/Browser/dragSession.ts +134 -0
- package/src/components/Browser/index.ts +14 -0
- package/src/components/Browser/types.ts +30 -0
- package/src/components/DataTable/ColumnFilterPanel.vue +225 -0
- package/src/components/DataTable/DataTable.vue +3155 -0
- package/src/components/DataTable/ViurDataTable.d.vue.ts +72 -0
- package/src/components/DataTable/ViurDataTable.vue +331 -0
- package/src/components/DataTable/columns.ts +400 -0
- package/src/components/DataTable/index.ts +60 -0
- package/src/components/DataTable/rows.ts +446 -0
- package/src/components/DataTable/types.ts +267 -0
- package/src/components/DataTable/useColumnLayout.ts +294 -0
- package/src/components/DataTable/useGridNavigation.ts +193 -0
- package/src/components/DataTable/useVirtualRows.ts +126 -0
- package/src/components/DataTable/viurColumns.ts +330 -0
- package/src/forms/ViForm.d.vue.ts +106 -0
- package/src/forms/ViForm.vue +592 -0
- package/src/forms/ViFormCategory.vue +57 -0
- package/src/forms/bones/Bone.vue +1023 -0
- package/src/forms/bones/BoneActions.vue +127 -0
- package/src/forms/bones/BoneLabel.vue +122 -0
- package/src/forms/bones/BoneMultipleList.vue +91 -0
- package/src/forms/bones/BoneMultipleWrapper.vue +105 -0
- package/src/forms/bones/BoneNestedWrapper.vue +155 -0
- package/src/forms/bones/BoneSingleEntry.vue +51 -0
- package/src/forms/bones/FilterBone.vue +694 -0
- package/src/forms/bones/actionbar/defaultBar.vue +98 -0
- package/src/forms/bones/actionbar/fileBar.vue +165 -0
- package/src/forms/bones/actionbar/relationalBar.vue +125 -0
- package/src/forms/bones/actions/ActionDialog.vue +70 -0
- package/src/forms/bones/actions/Debug.vue +106 -0
- package/src/forms/bones/actions/Delete.vue +13 -0
- package/src/forms/bones/actions/DescribeImage.vue +201 -0
- package/src/forms/bones/actions/Download.vue +27 -0
- package/src/forms/bones/actions/Placeholder.vue +14 -0
- package/src/forms/bones/actions/Translation.vue +238 -0
- package/src/forms/bones/actions/index.js +280 -0
- package/src/forms/bones/bone.d.ts +45 -0
- package/src/forms/bones/country_information.json +779 -0
- package/src/forms/bones/index.js +225 -0
- package/src/forms/bones/uploadAdapter.js +46 -0
- package/src/forms/bones/utils.js +284 -0
- package/src/forms/bones/view/base_item.vue +35 -0
- package/src/forms/bones/view/boneLogic.js +202 -0
- package/src/forms/bones/view/index.js +5 -0
- package/src/forms/bones/widgets/booleanBone.vue +83 -0
- package/src/forms/bones/widgets/booleanBoneChoose.vue +57 -0
- package/src/forms/bones/widgets/booleanBoneSelect.vue +58 -0
- package/src/forms/bones/widgets/codeBone.vue +214 -0
- package/src/forms/bones/widgets/colorBone.vue +75 -0
- package/src/forms/bones/widgets/dateBone.vue +114 -0
- package/src/forms/bones/widgets/emailBone.vue +100 -0
- package/src/forms/bones/widgets/fileBone.vue +273 -0
- package/src/forms/bones/widgets/jsonBone.vue +63 -0
- package/src/forms/bones/widgets/keyBone.vue +71 -0
- package/src/forms/bones/widgets/numericBone.vue +125 -0
- package/src/forms/bones/widgets/passwordBone.vue +215 -0
- package/src/forms/bones/widgets/phoneBone.vue +173 -0
- package/src/forms/bones/widgets/rawBone.vue +94 -0
- package/src/forms/bones/widgets/recordBone.vue +64 -0
- package/src/forms/bones/widgets/relationalBone.vue +164 -0
- package/src/forms/bones/widgets/relationalBoneSelect.vue +120 -0
- package/src/forms/bones/widgets/selectBone.vue +122 -0
- package/src/forms/bones/widgets/selectBoneChoose.vue +104 -0
- package/src/forms/bones/widgets/spatialBone.vue +111 -0
- package/src/forms/bones/widgets/stringBone.vue +139 -0
- package/src/forms/bones/widgets/textBone.vue +641 -0
- package/src/forms/install.d.ts +4 -0
- package/src/forms/install.js +43 -0
- package/src/forms/layouts/LayloutList2Columns.vue +57 -0
- package/src/forms/layouts/LayoutCategory.vue +38 -0
- package/src/forms/layouts/LayoutList.vue +31 -0
- package/src/forms/utils.js +601 -0
- package/src/public/assets/icons/fontawesome-manifest.json +8 -0
- package/src/public/assets/icons/regular/file.svg +1 -0
- package/src/public/assets/icons/solid/arrows-left-right.svg +1 -0
- package/src/public/assets/icons/solid/bug.svg +1 -0
- package/src/public/assets/icons/solid/check.svg +1 -0
- package/src/public/assets/icons/solid/circle-info.svg +1 -0
- package/src/public/assets/icons/solid/compress.svg +1 -0
- package/src/public/assets/icons/solid/download.svg +1 -0
- package/src/public/assets/icons/solid/expand.svg +1 -0
- package/src/public/assets/icons/solid/grip-vertical.svg +1 -0
- package/src/public/assets/icons/solid/plus.svg +1 -0
- package/src/public/assets/icons/solid/question.svg +1 -0
- package/src/public/assets/icons/solid/triangle-exclamation.svg +1 -0
- package/src/public/assets/icons/solid/upload.svg +1 -0
- package/src/public/assets/icons/solid/xmark.svg +1 -0
- package/src/request/cache.js +183 -0
- package/src/request/configStore.js +516 -0
- package/src/request/envelope.js +211 -0
- package/src/request/index.js +35 -0
- package/src/request/messageStore.js +201 -0
- package/src/request/request.js +752 -0
- package/src/request/skel.js +90 -0
- package/src/theme/colorStore.js +168 -0
- package/src/translations/de.js +178 -0
- package/src/translations/en.js +177 -0
- package/src/user/LoginScreen.d.vue.ts +54 -0
- package/src/user/LoginScreen.vue +627 -0
- package/src/user/google.js +75 -0
- package/src/user/index.js +3 -0
- package/src/user/store.d.ts +59 -0
- package/src/user/store.js +183 -0
- package/src/webawesomeCompat.css +72 -0
- package/src/webawesomeConfig.js +61 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// --------------------------------------------------------------------------- //
|
|
2
|
+
// Envelope normalization — v1 → v2 //
|
|
3
|
+
// --------------------------------------------------------------------------- //
|
|
4
|
+
//
|
|
5
|
+
// viur-ui works PRIMARILY on the viur-actions "envelope v2" response shape
|
|
6
|
+
// (see sources/viur-actions/src/viur/actions/envelope.py). To also keep
|
|
7
|
+
// supporting the classic v1 ViUR JSON protocol (`values` / `skellist` /
|
|
8
|
+
// `structure` / `errors` / `action`), we normalize every response UP to the
|
|
9
|
+
// canonical v2 envelope at the `.json()`/parse boundary.
|
|
10
|
+
//
|
|
11
|
+
// This is the exact inverse of vi-vue-utils, which normalizes v2 DOWN to v1.
|
|
12
|
+
// There, v1 is canonical and v2 responses are adapted; here, v2 is canonical
|
|
13
|
+
// and v1 responses are adapted.
|
|
14
|
+
//
|
|
15
|
+
// The normalization is transparent for bodies that are already v2 (they carry
|
|
16
|
+
// the JSON number `version: 2`) and for non-ViUR payloads (skey arrays, the
|
|
17
|
+
// version-agnostic `getStructure` map, custom endpoints) — those pass through
|
|
18
|
+
// unchanged.
|
|
19
|
+
//
|
|
20
|
+
// By default the request layer hits the unversioned `/json/…` endpoint, which
|
|
21
|
+
// the server aliases to the newest wire format (envelope v2). A specific
|
|
22
|
+
// version can be pinned via the separate `version` parameter (`2`/`"v2"` for
|
|
23
|
+
// v2, `1`/`"v1"` for classic v1) — see `create_url` in request.js. Either way,
|
|
24
|
+
// this module guarantees viur-ui consumes a v2-shaped body.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical v2 envelope field set (see viur-actions envelope.py):
|
|
28
|
+
* version, action, status, step, step_status, steps, follow,
|
|
29
|
+
* datatype, module, structure, data, errors
|
|
30
|
+
* plus a list-only `meta: { cursor, orders }`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** v1 actions whose selected value IS the url of the next step. */
|
|
34
|
+
const SELECTION_ACTIONS = ["select_authentication_provider", "select_secondfactor_provider"]
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Target url of the next step in the CLASSIC v1 login.
|
|
38
|
+
*
|
|
39
|
+
* v2 (viur-actions) drives a multi-step action itself: every response carries
|
|
40
|
+
* the `follow` url of the next step. v1 has no such field — there the target IS
|
|
41
|
+
* the value the user picked: `/user/login` renders a single select whose option
|
|
42
|
+
* KEYS are the provider urls ("/vi/user/auth_userpassword/login", …), and a POST
|
|
43
|
+
* echoes the chosen key back in the body. So the selection doubles as the
|
|
44
|
+
* follow.
|
|
45
|
+
*
|
|
46
|
+
* The same shape appears twice in the flow: choosing the PRIMARY provider
|
|
47
|
+
* (`select_authentication_provider`) and choosing the SECOND FACTOR
|
|
48
|
+
* (`select_secondfactor_provider`).
|
|
49
|
+
*
|
|
50
|
+
* Only applied to a body that really is such a selection: one of those actions,
|
|
51
|
+
* a select bone, and a chosen value that is one of that bone's own option keys.
|
|
52
|
+
* Returns null otherwise.
|
|
53
|
+
*
|
|
54
|
+
* @param {*} body normalized (v2-shaped) body
|
|
55
|
+
* @returns {string|null}
|
|
56
|
+
*/
|
|
57
|
+
export function v1SelectionFollow(body) {
|
|
58
|
+
if (!body || typeof body !== "object" || !SELECTION_ACTIONS.includes(body["action"])) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
const values = body["data"] ?? body["values"] ?? {}
|
|
62
|
+
const structure = body["structure"] ?? {}
|
|
63
|
+
for (const [name, bone] of Object.entries(structure)) {
|
|
64
|
+
if (!String(bone?.["type"] ?? "").startsWith("select")) continue
|
|
65
|
+
const chosen = values?.[name]
|
|
66
|
+
if (typeof chosen !== "string" || !/^(\/|https?:\/\/)/.test(chosen)) continue
|
|
67
|
+
// Nur wenn der Wert tatsächlich eine der angebotenen Ziel-URLs ist.
|
|
68
|
+
if (bone?.["values"] && Object.prototype.hasOwnProperty.call(bone["values"], chosen)) return chosen
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalize the `errors` array so consumers can rely on `fieldPath` being a
|
|
75
|
+
* LIST of path segments (that is how viur-core declares it and how the bones
|
|
76
|
+
* match errors to fields: `fieldPath[0] === boneName`).
|
|
77
|
+
*
|
|
78
|
+
* Some server paths hand a plain string instead — viur-core's userpassword
|
|
79
|
+
* login, for example, builds its "Invalid username or password provided" error
|
|
80
|
+
* with the bare bone name. A string would then be indexed character-wise and
|
|
81
|
+
* the error would silently never reach any field.
|
|
82
|
+
*
|
|
83
|
+
* @param {*} errors
|
|
84
|
+
* @returns {Array}
|
|
85
|
+
*/
|
|
86
|
+
function normalizeErrors(errors) {
|
|
87
|
+
if (!Array.isArray(errors)) return []
|
|
88
|
+
return errors.map((error) => {
|
|
89
|
+
if (!error || typeof error !== "object") return error
|
|
90
|
+
const fieldPath = error["fieldPath"]
|
|
91
|
+
if (fieldPath == null || Array.isArray(fieldPath)) return error
|
|
92
|
+
return { ...error, fieldPath: String(fieldPath).split(".").filter(Boolean) }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True if `data` is an already-parsed viur-actions envelope-v2 body.
|
|
98
|
+
* The wire carries `version` as the JSON number `2`; we also tolerate the
|
|
99
|
+
* string `"2"` defensively.
|
|
100
|
+
* @param {*} data
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function isEnvelopeV2(data) {
|
|
104
|
+
return (
|
|
105
|
+
!!data && typeof data === "object" && !Array.isArray(data) && (data["version"] === 2 || data["version"] === "2")
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Map a classic v1 `action` string (+ errors) to a v2 `(action, status)` pair.
|
|
111
|
+
* Inverse of vi-vue-utils' `v1ActionString`.
|
|
112
|
+
*
|
|
113
|
+
* - `addSuccess` / `editSuccess` / `cloneSuccess` / `deleteSuccess` / `moveSuccess` → the plain
|
|
114
|
+
* verb with `status: "success"`.
|
|
115
|
+
* - `view` / `list` → the verb with `status: "success"` (a completed read).
|
|
116
|
+
* - any other verb (`add` / `edit` / `clone` / custom): `status: "rejected"`
|
|
117
|
+
* when the response carries errors, else `status: "init"` (a freshly rendered
|
|
118
|
+
* or in-flight form).
|
|
119
|
+
*
|
|
120
|
+
* @param {string|undefined} action v1 action string
|
|
121
|
+
* @param {Array|undefined} errors v1 errors array
|
|
122
|
+
* @returns {{ action: string, status: string }}
|
|
123
|
+
*/
|
|
124
|
+
function v2ActionStatus(action, errors) {
|
|
125
|
+
switch (action) {
|
|
126
|
+
case "addSuccess":
|
|
127
|
+
return { action: "add", status: "success" }
|
|
128
|
+
case "editSuccess":
|
|
129
|
+
return { action: "edit", status: "success" }
|
|
130
|
+
case "cloneSuccess":
|
|
131
|
+
return { action: "clone", status: "success" }
|
|
132
|
+
case "deleteSuccess":
|
|
133
|
+
return { action: "delete", status: "success" }
|
|
134
|
+
case "moveSuccess":
|
|
135
|
+
return { action: "move", status: "success" }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (action === "view" || action === "list") {
|
|
139
|
+
return { action, status: "success" }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const status = Array.isArray(errors) && errors.length > 0 ? "rejected" : "init"
|
|
143
|
+
return { action: action ?? null, status }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Normalize a parsed response body to the canonical v2 envelope shape.
|
|
148
|
+
*
|
|
149
|
+
* - Bodies already in v2 pass through (only `errors`/`meta` are defensively
|
|
150
|
+
* ensured).
|
|
151
|
+
* - Classic v1 bodies (carrying `skellist`, `values`, or a v1 `action`) are
|
|
152
|
+
* lifted to v2: `data` (from `skellist`/`values`), `datatype`, `structure`,
|
|
153
|
+
* `errors`, `follow`, `(action, status)` and — for lists — `meta`. The
|
|
154
|
+
* original v1 fields are kept alongside, so the result is a superset and the
|
|
155
|
+
* transform is idempotent.
|
|
156
|
+
* - Everything else (arrays, primitives, non-ViUR objects such as the skey list
|
|
157
|
+
* or the `getStructure` map) passes through unchanged.
|
|
158
|
+
*
|
|
159
|
+
* @param {*} data parsed response body
|
|
160
|
+
* @returns {*} v2-shaped body (or the input unchanged)
|
|
161
|
+
*/
|
|
162
|
+
export function normalizeEnvelope(data) {
|
|
163
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
164
|
+
return data
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isEnvelopeV2(data)) {
|
|
168
|
+
const out = { ...data }
|
|
169
|
+
out["version"] = 2
|
|
170
|
+
out["errors"] = normalizeErrors(data["errors"])
|
|
171
|
+
if (data["datatype"] === "list" && !data["meta"]) {
|
|
172
|
+
out["meta"] = { cursor: data["cursor"] ?? null, orders: data["orders"] || [] }
|
|
173
|
+
}
|
|
174
|
+
return out
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const isList = Array.isArray(data["skellist"])
|
|
178
|
+
const isEntity = "values" in data
|
|
179
|
+
const hasV1Action = typeof data["action"] === "string"
|
|
180
|
+
|
|
181
|
+
// Not a ViUR structured response (skey, getStructure map, custom payload):
|
|
182
|
+
// leave untouched.
|
|
183
|
+
if (!isList && !isEntity && !hasV1Action) {
|
|
184
|
+
return data
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const out = { ...data }
|
|
188
|
+
out["version"] = 2
|
|
189
|
+
const { action, status } = v2ActionStatus(data["action"], data["errors"])
|
|
190
|
+
out["action"] = action
|
|
191
|
+
out["status"] = status
|
|
192
|
+
out["errors"] = normalizeErrors(data["errors"])
|
|
193
|
+
// Ziel des Schritts. v1 legt es je nach Stufe anderswo ab:
|
|
194
|
+
// next_url – vom Server gesetzt (z. B. Auswahl des zweiten Faktors)
|
|
195
|
+
// params.action_url – bei einem gerenderten Schritt, der selbst ein Ziel
|
|
196
|
+
// mitbringt (viur-core: der OTP-Schritt des zweiten
|
|
197
|
+
// Faktors kommt als `edit` mit action_url)
|
|
198
|
+
out["follow"] = data["follow"] ?? data["next_url"] ?? data["params"]?.["action_url"] ?? null
|
|
199
|
+
out["structure"] = data["structure"] ?? null
|
|
200
|
+
|
|
201
|
+
if (isList) {
|
|
202
|
+
out["datatype"] = "list"
|
|
203
|
+
out["data"] = data["skellist"]
|
|
204
|
+
out["meta"] = { cursor: data["cursor"] ?? null, orders: data["orders"] || [] }
|
|
205
|
+
} else {
|
|
206
|
+
out["datatype"] = "entity"
|
|
207
|
+
out["data"] = isEntity ? data["values"] : (data["data"] ?? null)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
useAddFetch,
|
|
3
|
+
useCloneFetch,
|
|
4
|
+
usePreviewFetch,
|
|
5
|
+
useMoveFetch,
|
|
6
|
+
useListRootNodesFetch,
|
|
7
|
+
useDeleteFetch,
|
|
8
|
+
useEditFetch,
|
|
9
|
+
useListFetch,
|
|
10
|
+
useListFetchAll,
|
|
11
|
+
useTreeFetch,
|
|
12
|
+
useViewFetch,
|
|
13
|
+
useStructureFetch,
|
|
14
|
+
uploadFile,
|
|
15
|
+
downloadUrlFor,
|
|
16
|
+
serveUrlFor,
|
|
17
|
+
resetState,
|
|
18
|
+
HTTPError,
|
|
19
|
+
createControllerFetch,
|
|
20
|
+
} from "./request.js"
|
|
21
|
+
export { getCached, setCached, invalidateByKey, clearCache } from "./cache.js"
|
|
22
|
+
export { skelDataToFormData, normalizeStructure } from "./skel.js"
|
|
23
|
+
export { normalizeEnvelope, isEnvelopeV2, v1SelectionFollow } from "./envelope.js"
|
|
24
|
+
export { useConfigStore, canAccessAdminConfig } from "./configStore.js"
|
|
25
|
+
export {
|
|
26
|
+
buildModuleTree,
|
|
27
|
+
flattenModuleTree,
|
|
28
|
+
findModule,
|
|
29
|
+
visibleModuleTree,
|
|
30
|
+
hasModuleAccess,
|
|
31
|
+
handlerType,
|
|
32
|
+
normalizeModulePath,
|
|
33
|
+
baseModuleName,
|
|
34
|
+
} from "./configStore.js"
|
|
35
|
+
export { useMessageStore, getMessageStore, notify, requestMessage } from "./messageStore.js"
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { defineStore, getActivePinia } from "pinia"
|
|
2
|
+
import { reactive, computed } from "vue"
|
|
3
|
+
|
|
4
|
+
// --------------------------------------------------------------------------- //
|
|
5
|
+
// Message store — user feedback (toasts / notifications) //
|
|
6
|
+
// --------------------------------------------------------------------------- //
|
|
7
|
+
//
|
|
8
|
+
// A UI-agnostic queue of feedback messages: "entry saved", "login failed",
|
|
9
|
+
// "request failed (500)". The REQUEST LAYER feeds it automatically (see
|
|
10
|
+
// `notify()` and request.js): a successful POST (add/edit/delete/clone/move/
|
|
11
|
+
// login/…) pushes a success, a rejected envelope or a HTTP error pushes an
|
|
12
|
+
// error. Components may push their own messages as well.
|
|
13
|
+
//
|
|
14
|
+
// Opt-in by existence: `notify()` only does something when a Pinia instance is
|
|
15
|
+
// active (`getActivePinia()`), i.e. the app installed Pinia. Without Pinia the
|
|
16
|
+
// request layer works exactly as before. A single form can switch it off via
|
|
17
|
+
// `<ViForm :use-message-store="false">`, any request via `ctx.messages = false`.
|
|
18
|
+
//
|
|
19
|
+
// Messages carry a translation `code` (e.g. "messages.request.edit.success")
|
|
20
|
+
// plus a generic `fallbackCode`, so the rendering component decides the text:
|
|
21
|
+
// const text = te(m.code) ? t(m.code, m.params) : t(m.fallbackCode, m.params)
|
|
22
|
+
// Optional `text` holds a ready-made string (e.g. a server error message).
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUTS = { success: 5000, info: 5000, warning: 8000, error: 0 } // 0 = sticky
|
|
25
|
+
|
|
26
|
+
let _nextId = 1
|
|
27
|
+
|
|
28
|
+
export const useMessageStore = defineStore("viurMessages", () => {
|
|
29
|
+
const state = reactive({
|
|
30
|
+
messages: [],
|
|
31
|
+
timeouts: { ...DEFAULT_TIMEOUTS },
|
|
32
|
+
// keep at most this many messages; the oldest are dropped
|
|
33
|
+
max: 50,
|
|
34
|
+
})
|
|
35
|
+
const _timers = new Map()
|
|
36
|
+
|
|
37
|
+
const latest = computed(() => state.messages.at(-1) ?? null)
|
|
38
|
+
const errors = computed(() => state.messages.filter((m) => m.type === "error"))
|
|
39
|
+
const count = computed(() => state.messages.length)
|
|
40
|
+
|
|
41
|
+
function configure({ timeouts, max } = {}) {
|
|
42
|
+
if (timeouts) state.timeouts = { ...state.timeouts, ...timeouts }
|
|
43
|
+
if (typeof max === "number") state.max = max
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Add a message. Returns the stored message (with `id`).
|
|
48
|
+
* @param {object} message
|
|
49
|
+
* @param {"success"|"error"|"warning"|"info"} [message.type]
|
|
50
|
+
* @param {string} [message.code] translation key
|
|
51
|
+
* @param {string} [message.fallbackCode] generic translation key
|
|
52
|
+
* @param {string|null} [message.text] ready-made text (wins over code in UIs)
|
|
53
|
+
* @param {object} [message.params] translation params
|
|
54
|
+
* @param {string|null} [message.module] controller path ("user", "shop.order")
|
|
55
|
+
* @param {string|null} [message.action] envelope action / endpoint
|
|
56
|
+
* @param {string|null} [message.key] entity key
|
|
57
|
+
* @param {Array} [message.errors] envelope errors
|
|
58
|
+
* @param {number|null} [message.status] HTTP status
|
|
59
|
+
* @param {number} [message.timeout] ms until auto-dismiss (0 = sticky)
|
|
60
|
+
*/
|
|
61
|
+
function push(message) {
|
|
62
|
+
const type = message.type ?? "info"
|
|
63
|
+
const entry = {
|
|
64
|
+
id: _nextId++,
|
|
65
|
+
type,
|
|
66
|
+
code: message.code ?? null,
|
|
67
|
+
fallbackCode: message.fallbackCode ?? `messages.request.default.${type}`,
|
|
68
|
+
text: message.text ?? null,
|
|
69
|
+
params: message.params ?? {},
|
|
70
|
+
module: message.module ?? null,
|
|
71
|
+
action: message.action ?? null,
|
|
72
|
+
key: message.key ?? null,
|
|
73
|
+
errors: message.errors ?? [],
|
|
74
|
+
status: message.status ?? null,
|
|
75
|
+
timeout: message.timeout ?? state.timeouts[type] ?? 0,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
}
|
|
78
|
+
state.messages.push(entry)
|
|
79
|
+
while (state.messages.length > state.max) {
|
|
80
|
+
const dropped = state.messages.shift()
|
|
81
|
+
_clearTimer(dropped.id)
|
|
82
|
+
}
|
|
83
|
+
if (entry.timeout > 0 && typeof setTimeout === "function") {
|
|
84
|
+
_timers.set(
|
|
85
|
+
entry.id,
|
|
86
|
+
setTimeout(() => remove(entry.id), entry.timeout)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
return entry
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const _shortcut =
|
|
93
|
+
(type) =>
|
|
94
|
+
(codeOrMessage, extra = {}) =>
|
|
95
|
+
push(typeof codeOrMessage === "string" ? { type, code: codeOrMessage, ...extra } : { ...codeOrMessage, type })
|
|
96
|
+
const success = _shortcut("success")
|
|
97
|
+
const error = _shortcut("error")
|
|
98
|
+
const warning = _shortcut("warning")
|
|
99
|
+
const info = _shortcut("info")
|
|
100
|
+
|
|
101
|
+
function _clearTimer(id) {
|
|
102
|
+
const timer = _timers.get(id)
|
|
103
|
+
if (timer) {
|
|
104
|
+
clearTimeout(timer)
|
|
105
|
+
_timers.delete(id)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function remove(id) {
|
|
110
|
+
_clearTimer(id)
|
|
111
|
+
const index = state.messages.findIndex((m) => m.id === id)
|
|
112
|
+
if (index !== -1) state.messages.splice(index, 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clear(type = null) {
|
|
116
|
+
const keep = type ? state.messages.filter((m) => m.type !== type) : []
|
|
117
|
+
for (const m of state.messages) if (!keep.includes(m)) _clearTimer(m.id)
|
|
118
|
+
state.messages = keep
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { state, latest, errors, count, configure, push, success, error, warning, info, remove, clear }
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The message store IF the app installed Pinia — else null. This is what makes
|
|
126
|
+
* the feedback opt-in by existence.
|
|
127
|
+
* @returns {ReturnType<typeof useMessageStore>|null}
|
|
128
|
+
*/
|
|
129
|
+
export function getMessageStore() {
|
|
130
|
+
try {
|
|
131
|
+
if (!getActivePinia()) return null
|
|
132
|
+
return useMessageStore()
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Push a message when a store exists; no-op otherwise. Used by the request
|
|
140
|
+
* layer so it never depends on Pinia being installed.
|
|
141
|
+
* @returns {object|null} the stored message or null
|
|
142
|
+
*/
|
|
143
|
+
export function notify(message) {
|
|
144
|
+
const store = getMessageStore()
|
|
145
|
+
return store ? store.push(message) : null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the feedback message for a finished request from its normalized
|
|
150
|
+
* envelope (or a HTTP error). Returns null when nothing should be shown
|
|
151
|
+
* (GET reads, form renders, intermediate multistep steps).
|
|
152
|
+
*
|
|
153
|
+
* @param {{ method: string, endpoint: string, module: string|null, body?: any, error?: Error|null }} info
|
|
154
|
+
*/
|
|
155
|
+
export function requestMessage({ method, endpoint, module, body = undefined, error = null }) {
|
|
156
|
+
if (error) {
|
|
157
|
+
const status = error.statusCode ?? error.status ?? null
|
|
158
|
+
return {
|
|
159
|
+
type: "error",
|
|
160
|
+
code: `messages.request.${endpoint}.error`,
|
|
161
|
+
fallbackCode: "messages.request.default.error",
|
|
162
|
+
text: error.message && !/^\d{3}$/.test(error.message) ? error.message : null,
|
|
163
|
+
params: { status, module, action: endpoint },
|
|
164
|
+
module,
|
|
165
|
+
action: endpoint,
|
|
166
|
+
status,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (method.toUpperCase() === "POST") {
|
|
170
|
+
const isObject = body && typeof body === "object"
|
|
171
|
+
const action = (isObject && body.action) || endpoint
|
|
172
|
+
// a non-envelope body on a POST (e.g. v1 delete answers the string "OKAY")
|
|
173
|
+
// counts as success
|
|
174
|
+
const status = isObject ? body.status : "success"
|
|
175
|
+
const key = isObject ? (body.data?.key ?? null) : null
|
|
176
|
+
if (status === "success") {
|
|
177
|
+
return {
|
|
178
|
+
type: "success",
|
|
179
|
+
code: `messages.request.${action}.success`,
|
|
180
|
+
fallbackCode: "messages.request.default.success",
|
|
181
|
+
params: { module, action },
|
|
182
|
+
module,
|
|
183
|
+
action,
|
|
184
|
+
key,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (status === "rejected") {
|
|
188
|
+
return {
|
|
189
|
+
type: "error",
|
|
190
|
+
code: `messages.request.${action}.rejected`,
|
|
191
|
+
fallbackCode: "messages.request.default.rejected",
|
|
192
|
+
params: { module, action, count: body.errors?.length ?? 0 },
|
|
193
|
+
module,
|
|
194
|
+
action,
|
|
195
|
+
key,
|
|
196
|
+
errors: body.errors ?? [],
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return null
|
|
201
|
+
}
|