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,183 @@
|
|
|
1
|
+
const MAX_CACHE_ENTRIES = 1000
|
|
2
|
+
const DEFAULT_TTL = 60 * 60 * 24 // 24h in seconds
|
|
3
|
+
const DB_NAME = "viur-request-cache"
|
|
4
|
+
const DB_VERSION = 1
|
|
5
|
+
const STORE_NAME = "requests"
|
|
6
|
+
|
|
7
|
+
let _db = null
|
|
8
|
+
|
|
9
|
+
async function _openDb() {
|
|
10
|
+
if (_db) return _db
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
|
13
|
+
req.onupgradeneeded = (e) => {
|
|
14
|
+
const db = e.target.result
|
|
15
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
16
|
+
db.createObjectStore(STORE_NAME, { keyPath: "cacheKey" })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
req.onsuccess = (e) => {
|
|
20
|
+
_db = e.target.result
|
|
21
|
+
resolve(_db)
|
|
22
|
+
}
|
|
23
|
+
req.onerror = () => reject(req.error)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function _dbGet(cacheKey) {
|
|
28
|
+
const db = await _openDb()
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const tx = db.transaction(STORE_NAME, "readonly")
|
|
31
|
+
const req = tx.objectStore(STORE_NAME).get(cacheKey)
|
|
32
|
+
req.onsuccess = () => resolve(req.result ?? null)
|
|
33
|
+
req.onerror = () => reject(req.error)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function _dbPut(entry) {
|
|
38
|
+
const db = await _openDb()
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const tx = db.transaction(STORE_NAME, "readwrite")
|
|
41
|
+
tx.objectStore(STORE_NAME).put(entry)
|
|
42
|
+
tx.oncomplete = resolve
|
|
43
|
+
tx.onerror = () => reject(tx.error)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function _dbDelete(cacheKey) {
|
|
48
|
+
const db = await _openDb()
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const tx = db.transaction(STORE_NAME, "readwrite")
|
|
51
|
+
tx.objectStore(STORE_NAME).delete(cacheKey)
|
|
52
|
+
tx.oncomplete = resolve
|
|
53
|
+
tx.onerror = () => reject(tx.error)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function _dbGetAll() {
|
|
58
|
+
const db = await _openDb()
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const tx = db.transaction(STORE_NAME, "readonly")
|
|
61
|
+
const req = tx.objectStore(STORE_NAME).getAll()
|
|
62
|
+
req.onsuccess = () => resolve(req.result)
|
|
63
|
+
req.onerror = () => reject(req.error)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function _dbClear() {
|
|
68
|
+
const db = await _openDb()
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const tx = db.transaction(STORE_NAME, "readwrite")
|
|
71
|
+
tx.objectStore(STORE_NAME).clear()
|
|
72
|
+
tx.oncomplete = resolve
|
|
73
|
+
tx.onerror = () => reject(tx.error)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function _trimCache() {
|
|
78
|
+
const all = await _dbGetAll()
|
|
79
|
+
if (all.length <= MAX_CACHE_ENTRIES) return
|
|
80
|
+
all.sort((a, b) => a.date - b.date)
|
|
81
|
+
const toDelete = all.slice(0, all.length - MAX_CACHE_ENTRIES)
|
|
82
|
+
for (const entry of toDelete) {
|
|
83
|
+
await _dbDelete(entry.cacheKey)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// keyToCache maps entity keys (e.g. viur db keys) to cache keys for invalidation
|
|
88
|
+
// In-memory only — rebuilt lazily from IndexedDB after page reload
|
|
89
|
+
const _keyToCache = new Map()
|
|
90
|
+
let _keyMappingsLoaded = false
|
|
91
|
+
|
|
92
|
+
function _registerKeyMapping(entityKey, cacheKey) {
|
|
93
|
+
if (!_keyToCache.has(entityKey)) _keyToCache.set(entityKey, new Set())
|
|
94
|
+
_keyToCache.get(entityKey).add(cacheKey)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function _ensureKeyMappings() {
|
|
98
|
+
if (_keyMappingsLoaded) return
|
|
99
|
+
const all = await _dbGetAll()
|
|
100
|
+
for (const entry of all) {
|
|
101
|
+
for (const key of entry.entityKeys ?? []) {
|
|
102
|
+
_registerKeyMapping(key, entry.cacheKey)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
_keyMappingsLoaded = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get cached response for a URL.
|
|
110
|
+
* Returns null if not cached or expired.
|
|
111
|
+
* @param {string} url
|
|
112
|
+
* @param {number} ttl - TTL in seconds (0 = no expiry)
|
|
113
|
+
*/
|
|
114
|
+
if (typeof window !== "undefined" && window.location.hostname === "localhost") {
|
|
115
|
+
clearCache().catch(() => {})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function getCached(url, ttl = DEFAULT_TTL) {
|
|
119
|
+
const entry = await _dbGet(url)
|
|
120
|
+
if (!entry) return null
|
|
121
|
+
if (ttl > 0 && Date.now() - entry.date > ttl * 1000) {
|
|
122
|
+
await _dbDelete(url)
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
return entry.data
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Store a response in cache.
|
|
130
|
+
* @param {string} url
|
|
131
|
+
* @param {any} data
|
|
132
|
+
* @param {string[]} [entityKeys] - viur entity keys contained in the response (for invalidation)
|
|
133
|
+
*/
|
|
134
|
+
export async function setCached(url, data, entityKeys = []) {
|
|
135
|
+
await _dbPut({ cacheKey: url, data, date: Date.now(), entityKeys })
|
|
136
|
+
for (const key of entityKeys) {
|
|
137
|
+
_registerKeyMapping(key, url)
|
|
138
|
+
}
|
|
139
|
+
await _trimCache()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Invalidate cache entries associated with an entity key.
|
|
144
|
+
* Called after edit/delete/add.
|
|
145
|
+
* @param {string|string[]} entityKeys
|
|
146
|
+
*/
|
|
147
|
+
export async function invalidateByKey(entityKeys) {
|
|
148
|
+
await _ensureKeyMappings()
|
|
149
|
+
const keys = Array.isArray(entityKeys) ? entityKeys : [entityKeys]
|
|
150
|
+
for (const entityKey of keys) {
|
|
151
|
+
const cacheKeys = _keyToCache.get(entityKey)
|
|
152
|
+
if (!cacheKeys) continue
|
|
153
|
+
for (const cacheKey of cacheKeys) {
|
|
154
|
+
await _dbDelete(cacheKey)
|
|
155
|
+
}
|
|
156
|
+
_keyToCache.delete(entityKey)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Clear cached entries.
|
|
162
|
+
* @param {string} [prefix] - If given, only entries whose cache key starts with this prefix are removed.
|
|
163
|
+
* If omitted, all entries are cleared.
|
|
164
|
+
*/
|
|
165
|
+
export async function clearCache(prefix) {
|
|
166
|
+
if (prefix === undefined) {
|
|
167
|
+
_keyToCache.clear()
|
|
168
|
+
_keyMappingsLoaded = false
|
|
169
|
+
await _dbClear()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
_keyMappingsLoaded = false
|
|
173
|
+
const all = await _dbGetAll()
|
|
174
|
+
for (const entry of all) {
|
|
175
|
+
if (entry.cacheKey.startsWith(prefix)) {
|
|
176
|
+
await _dbDelete(entry.cacheKey)
|
|
177
|
+
for (const [entityKey, cacheKeys] of _keyToCache) {
|
|
178
|
+
cacheKeys.delete(entry.cacheKey)
|
|
179
|
+
if (cacheKeys.size === 0) _keyToCache.delete(entityKey)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { defineStore } from "pinia"
|
|
2
|
+
import { reactive, computed, markRaw } from "vue"
|
|
3
|
+
|
|
4
|
+
// --------------------------------------------------------------------------- //
|
|
5
|
+
// Config store — `/vi/config` //
|
|
6
|
+
// --------------------------------------------------------------------------- //
|
|
7
|
+
//
|
|
8
|
+
// ViUR core (>= 3.9) exposes `/vi/config`, which serves two audiences with one
|
|
9
|
+
// endpoint:
|
|
10
|
+
//
|
|
11
|
+
// • ANONYMOUS: the public `configuration` map — the `conf.admin.*` settings
|
|
12
|
+
// (dotted keys, e.g. "admin.name"), the Google OAuth client id under
|
|
13
|
+
// "admin.user.google.clientID" (only when configured), the current
|
|
14
|
+
// "admin.language" and the available "admin.languages". The login screen
|
|
15
|
+
// needs this BEFORE it renders, e.g. to decide whether Google sign-in is
|
|
16
|
+
// offered at all.
|
|
17
|
+
//
|
|
18
|
+
// • ADMIN / ROOT: the same `configuration` PLUS `version` (core version as a
|
|
19
|
+
// 4-tuple) and `modules` — the admin info of every routable module, keyed
|
|
20
|
+
// by its vi path ("user", "file", "shop.order", …). This is everything the
|
|
21
|
+
// admin needs to build its navigation and module views. The server only
|
|
22
|
+
// adds these when the session user carries the "admin" or "root" right, so
|
|
23
|
+
// after a login the call is only worth repeating for such a user — see
|
|
24
|
+
// `fetchConfig({ user })`.
|
|
25
|
+
|
|
26
|
+
// --------------------------------------------------------------------------- //
|
|
27
|
+
// Module tree — navigation tree + lookup (part of the config store) //
|
|
28
|
+
// --------------------------------------------------------------------------- //
|
|
29
|
+
//
|
|
30
|
+
// Input (both held by the store):
|
|
31
|
+
// • `modules`: `{ [modulePath]: adminInfo }` — every routable module keyed by
|
|
32
|
+
// its dotted vi path ("user", "shop.order"). adminInfo carries `name`,
|
|
33
|
+
// `handler`, `icon`, `display`, `sortIndex`, `moduleGroup`, `views`, … (see
|
|
34
|
+
// viur-core `Module.adminInfo`).
|
|
35
|
+
// • `groups`: `configuration["admin.module.groups"]` (so rendert viur-core
|
|
36
|
+
// `conf.admin.module_groups`) —
|
|
37
|
+
// `{ [groupKey]: { name, icon, sortIndex, moduleGroup? } }`. A group may
|
|
38
|
+
// itself name a parent group via `moduleGroup` (nested groups).
|
|
39
|
+
//
|
|
40
|
+
// Requirements (derived from the old admin's db.js, not copied):
|
|
41
|
+
// 1. Modules are placed below the group named in their `moduleGroup`; when
|
|
42
|
+
// that group does not exist they stay at top level.
|
|
43
|
+
// 2. Groups nest via their own `moduleGroup`; a group without any visible
|
|
44
|
+
// child is hidden.
|
|
45
|
+
// 3. `views` of a module become child nodes that INHERIT the module's info
|
|
46
|
+
// (`{ ...module, ...view }`, `context` merged) and are addressed by their
|
|
47
|
+
// index. Views may nest again.
|
|
48
|
+
// 4. Access: "root" sees everything; otherwise a module is accessible with
|
|
49
|
+
// `<module>-add|edit|delete` (plus `-view` when `showWithViewAccess`), a
|
|
50
|
+
// grouped view with `<module>-<group>-…`. The flags are checked for the
|
|
51
|
+
// full path ("shop.order") and the base module name ("order").
|
|
52
|
+
// 5. Ordering: explicit numeric `sortIndex` first (ascending), then the rest
|
|
53
|
+
// alphabetically by name. Groups and modules are sorted together.
|
|
54
|
+
// 6. `display`: "visible" (default) | "hidden" (registered but not shown; a
|
|
55
|
+
// relational bone may still use it) | "group" (shown, not clickable, only
|
|
56
|
+
// expands its views).
|
|
57
|
+
// 7. Two consumers: the NAVIGATION needs the tree (`buildModuleTree`), the
|
|
58
|
+
// rest of the app needs to LOOK UP a module/view by name
|
|
59
|
+
// (`flattenModuleTree` + `findModule`), e.g. from a route.
|
|
60
|
+
//
|
|
61
|
+
// The input objects are never mutated; every node is a fresh object.
|
|
62
|
+
|
|
63
|
+
const MUTATING_RIGHTS = ["add", "edit", "delete"]
|
|
64
|
+
const VIEW_RIGHTS = [...MUTATING_RIGHTS, "view"]
|
|
65
|
+
|
|
66
|
+
// one collator for all name comparisons (much cheaper than localeCompare with
|
|
67
|
+
// options per call)
|
|
68
|
+
const collator = new Intl.Collator(undefined, { sensitivity: "base" })
|
|
69
|
+
|
|
70
|
+
/** Access flags as a Set for O(1) checks; accepts an array or a Set. */
|
|
71
|
+
function accessSet(access) {
|
|
72
|
+
if (access instanceof Set) return access
|
|
73
|
+
return new Set(Array.isArray(access) ? access : [])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Dotted module path — accepts the "/" form the old admin used in routes. */
|
|
77
|
+
export function normalizeModulePath(path) {
|
|
78
|
+
return typeof path === "string" ? path.replaceAll("/", ".") : path
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Last segment of a dotted module path: "shop.order" → "order". */
|
|
82
|
+
export function baseModuleName(path) {
|
|
83
|
+
return normalizeModulePath(path).split(".").at(-1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Coarse handler family for picking a UI component:
|
|
88
|
+
* "list" | "tree" | "hierarchy" (tree.node) | "singleton" | null
|
|
89
|
+
*/
|
|
90
|
+
export function handlerType(handler) {
|
|
91
|
+
if (typeof handler !== "string" || !handler) return null
|
|
92
|
+
if (handler === "tree.node" || handler.startsWith("tree.node.")) return "hierarchy"
|
|
93
|
+
for (const type of ["list", "tree", "singleton"]) {
|
|
94
|
+
if (handler === type || handler.startsWith(`${type}.`)) return type
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether an access list grants a module (or one of its grouped views).
|
|
101
|
+
* @param {string[]|Set<string>} access user access flags
|
|
102
|
+
* @param {string} module dotted module path
|
|
103
|
+
* @param {{ group?: string|null, showWithViewAccess?: boolean }} [options]
|
|
104
|
+
*/
|
|
105
|
+
export function hasModuleAccess(access, module, { group = null, showWithViewAccess = false } = {}) {
|
|
106
|
+
const flags = accessSet(access)
|
|
107
|
+
if (flags.has("root")) return true
|
|
108
|
+
const rights = showWithViewAccess ? VIEW_RIGHTS : MUTATING_RIGHTS
|
|
109
|
+
const path = normalizeModulePath(module)
|
|
110
|
+
const base = baseModuleName(path)
|
|
111
|
+
const names = base === path ? [path] : [path, base]
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const prefix = group ? `${name}-${group}` : name
|
|
114
|
+
for (const right of rights) {
|
|
115
|
+
if (flags.has(`${prefix}-${right}`)) return true
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sortNodes(nodes) {
|
|
122
|
+
const indexed = (n) => typeof n.sortIndex === "number" && Number.isFinite(n.sortIndex) && n.sortIndex !== 0
|
|
123
|
+
return nodes.sort((a, b) => {
|
|
124
|
+
const ai = indexed(a)
|
|
125
|
+
const bi = indexed(b)
|
|
126
|
+
if (ai && bi && a.sortIndex !== b.sortIndex) return a.sortIndex - b.sortIndex
|
|
127
|
+
if (ai !== bi) return ai ? -1 : 1
|
|
128
|
+
return collator.compare(String(a.name ?? ""), String(b.name ?? ""))
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mergeViewInfo(parentInfo, viewInfo) {
|
|
133
|
+
// a view inherits the module's info, but not its own children, position or
|
|
134
|
+
// display mode
|
|
135
|
+
const { views: _views, display: _display, sortIndex: _sortIndex, ...inherited } = parentInfo
|
|
136
|
+
const merged = { ...inherited, ...viewInfo }
|
|
137
|
+
if (parentInfo.context || viewInfo.context) {
|
|
138
|
+
merged.context = { ...(parentInfo.context ?? {}), ...(viewInfo.context ?? {}) }
|
|
139
|
+
}
|
|
140
|
+
return merged
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function finalizeVisibility(node) {
|
|
144
|
+
for (const child of node.children) finalizeVisibility(child)
|
|
145
|
+
if (node.display === "hidden") {
|
|
146
|
+
node.visible = false
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const childVisible = node.children.some((c) => c.visible)
|
|
150
|
+
if (node.type === "group") {
|
|
151
|
+
node.visible = childVisible
|
|
152
|
+
} else {
|
|
153
|
+
node.visible = node.hasAccess || childVisible
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the navigation tree.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ modules?: object, groups?: object, access?: string[] }} input
|
|
161
|
+
* @returns {ModuleNode[]} sorted top-level nodes
|
|
162
|
+
*/
|
|
163
|
+
export function buildModuleTree({ modules = {}, groups = {}, access = [] } = {}) {
|
|
164
|
+
const flags = accessSet(access)
|
|
165
|
+
const isRoot = flags.has("root")
|
|
166
|
+
const groupNodes = {}
|
|
167
|
+
for (const [key, info] of Object.entries(groups ?? {})) {
|
|
168
|
+
groupNodes[key] = {
|
|
169
|
+
type: "group",
|
|
170
|
+
key: `group:${key}`,
|
|
171
|
+
groupKey: key,
|
|
172
|
+
module: null,
|
|
173
|
+
baseModule: null,
|
|
174
|
+
name: info?.name ?? key,
|
|
175
|
+
icon: info?.icon ?? null,
|
|
176
|
+
handler: null,
|
|
177
|
+
handlerType: null,
|
|
178
|
+
display: info?.display ?? "visible",
|
|
179
|
+
sortIndex: info?.sortIndex,
|
|
180
|
+
hasAccess: false,
|
|
181
|
+
visible: true,
|
|
182
|
+
parent: null,
|
|
183
|
+
info: info ?? {},
|
|
184
|
+
children: [],
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const roots = []
|
|
189
|
+
|
|
190
|
+
// groups: nest below their parent group, else top level
|
|
191
|
+
for (const node of Object.values(groupNodes)) {
|
|
192
|
+
const parentKey = node.info.moduleGroup
|
|
193
|
+
const parent = parentKey && parentKey !== node.groupKey ? groupNodes[parentKey] : null
|
|
194
|
+
if (parent) {
|
|
195
|
+
node.parent = parent.key
|
|
196
|
+
parent.children.push(node)
|
|
197
|
+
} else {
|
|
198
|
+
roots.push(node)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function makeViews(parentNode, views, depth) {
|
|
203
|
+
const out = []
|
|
204
|
+
views.forEach((viewInfo, index) => {
|
|
205
|
+
const info = mergeViewInfo(parentNode.info, viewInfo ?? {})
|
|
206
|
+
const node = makeModuleNode(parentNode.module, info, {
|
|
207
|
+
type: "view",
|
|
208
|
+
key: `${parentNode.key}#${index}`,
|
|
209
|
+
view: index,
|
|
210
|
+
parent: parentNode.key,
|
|
211
|
+
})
|
|
212
|
+
if (Array.isArray(info.views) && info.views.length && depth < 10) {
|
|
213
|
+
node.children = makeViews(node, info.views, depth + 1)
|
|
214
|
+
}
|
|
215
|
+
out.push(node)
|
|
216
|
+
})
|
|
217
|
+
return out // sorted once for the whole tree by sortAll()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function makeModuleNode(module, info, extra) {
|
|
221
|
+
const showWithViewAccess = !!info.showWithViewAccess
|
|
222
|
+
const group = info.group ?? null
|
|
223
|
+
const baseModule = baseModuleName(module)
|
|
224
|
+
const node = {
|
|
225
|
+
type: "module",
|
|
226
|
+
key: module,
|
|
227
|
+
groupKey: null,
|
|
228
|
+
module,
|
|
229
|
+
baseModule,
|
|
230
|
+
name: info.name ?? baseModule,
|
|
231
|
+
icon: info.icon ?? null,
|
|
232
|
+
handler: info.handler ?? null,
|
|
233
|
+
handlerType: handlerType(info.handler),
|
|
234
|
+
group,
|
|
235
|
+
display: info.display ?? "visible",
|
|
236
|
+
sortIndex: info.sortIndex,
|
|
237
|
+
hasAccess: false,
|
|
238
|
+
visible: true,
|
|
239
|
+
view: null,
|
|
240
|
+
parent: null,
|
|
241
|
+
info,
|
|
242
|
+
children: [],
|
|
243
|
+
...extra,
|
|
244
|
+
}
|
|
245
|
+
// a "group" display never gets its own access — it only expands its views
|
|
246
|
+
node.hasAccess =
|
|
247
|
+
node.display !== "group" &&
|
|
248
|
+
!!node.handler &&
|
|
249
|
+
(isRoot || hasModuleAccess(flags, module, { group, showWithViewAccess }))
|
|
250
|
+
return node
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const [path, rawInfo] of Object.entries(modules ?? {})) {
|
|
254
|
+
const module = normalizeModulePath(path)
|
|
255
|
+
const info = rawInfo ?? {}
|
|
256
|
+
const node = makeModuleNode(module, info, {})
|
|
257
|
+
if (Array.isArray(info.views) && info.views.length) {
|
|
258
|
+
node.children = makeViews(node, info.views, 0)
|
|
259
|
+
}
|
|
260
|
+
const parent = info.moduleGroup ? groupNodes[info.moduleGroup] : null
|
|
261
|
+
if (parent) {
|
|
262
|
+
node.parent = parent.key
|
|
263
|
+
parent.children.push(node)
|
|
264
|
+
} else {
|
|
265
|
+
roots.push(node)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const sortAll = (nodes) => {
|
|
270
|
+
sortNodes(nodes)
|
|
271
|
+
for (const n of nodes) sortAll(n.children)
|
|
272
|
+
return nodes
|
|
273
|
+
}
|
|
274
|
+
sortAll(roots)
|
|
275
|
+
for (const node of roots) finalizeVisibility(node)
|
|
276
|
+
return roots
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Flatten a tree into a lookup `{ [key]: node }`. Keys are the dotted module
|
|
281
|
+
* path for modules ("shop.order"), `<module>#<index>` for views
|
|
282
|
+
* ("shop.order#1", nested "shop.order#1#0") and `group:<key>` for groups.
|
|
283
|
+
*/
|
|
284
|
+
export function flattenModuleTree(tree) {
|
|
285
|
+
const out = {}
|
|
286
|
+
const walk = (nodes) => {
|
|
287
|
+
for (const node of nodes) {
|
|
288
|
+
out[node.key] = node
|
|
289
|
+
if (node.children.length) walk(node.children)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
walk(tree ?? [])
|
|
293
|
+
return out
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Look up a module or one of its views.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} lookup result of `flattenModuleTree`
|
|
300
|
+
* @param {string} module dotted (or slashed) module path
|
|
301
|
+
* @param {number|string|null} [view] view index, or a string matching the
|
|
302
|
+
* view's `group` or `name`
|
|
303
|
+
* @returns {ModuleNode|null}
|
|
304
|
+
*/
|
|
305
|
+
export function findModule(lookup, module, view = null) {
|
|
306
|
+
if (!lookup || !module) return null
|
|
307
|
+
const node = lookup[normalizeModulePath(module)] ?? null
|
|
308
|
+
if (view === null || view === undefined || view === "") return node
|
|
309
|
+
if (!node) return null
|
|
310
|
+
const index = typeof view === "number" ? view : /^\d+$/.test(String(view)) ? Number(view) : null
|
|
311
|
+
if (index !== null) return lookup[`${node.key}#${index}`] ?? null
|
|
312
|
+
return node.children.find((c) => c.type === "view" && (c.group === view || c.name === view)) ?? null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Only the visible nodes (recursively) — what a navigation renders. */
|
|
316
|
+
export function visibleModuleTree(tree) {
|
|
317
|
+
return (tree ?? []).filter((n) => n.visible).map((n) => ({ ...n, children: visibleModuleTree(n.children) }))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --------------------------------------------------------------------------- //
|
|
321
|
+
// Store //
|
|
322
|
+
// --------------------------------------------------------------------------- //
|
|
323
|
+
|
|
324
|
+
const ADMIN_RIGHTS = ["admin", "root"]
|
|
325
|
+
|
|
326
|
+
/** True when a user skel carries a right that unlocks the module part. */
|
|
327
|
+
export function canAccessAdminConfig(user) {
|
|
328
|
+
const access = Array.isArray(user?.access) ? user.access : []
|
|
329
|
+
return ADMIN_RIGHTS.some((flag) => access.includes(flag))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function configUrl(renderer) {
|
|
333
|
+
const base = import.meta.env.VITE_API_URL ?? window.location.origin
|
|
334
|
+
return `${base}/${renderer}/config`
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const useConfigStore = defineStore("viurConfig", () => {
|
|
338
|
+
const state = reactive({
|
|
339
|
+
// public settings: `conf.admin.*` with dotted keys, language(s), google client id
|
|
340
|
+
configuration: {},
|
|
341
|
+
// admin-only: `{ [modulePath]: adminInfo }`, null until loaded with admin rights
|
|
342
|
+
modules: null,
|
|
343
|
+
// admin-only: core version `[major, minor, patch, extra]`, null until loaded
|
|
344
|
+
version: null,
|
|
345
|
+
// "unknown" | "loading" | "public" | "admin" | "error"
|
|
346
|
+
status: "unknown",
|
|
347
|
+
error: null,
|
|
348
|
+
// renderer segment of the endpoint; ViUR serves the config via the vi renderer
|
|
349
|
+
renderer: "vi",
|
|
350
|
+
// access flags of the session user, used to gate the module tree
|
|
351
|
+
access: [],
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// Set view of `state.access`, rebuilt only when the flags change
|
|
355
|
+
const accessFlags = computed(() => new Set(state.access))
|
|
356
|
+
|
|
357
|
+
let _pending = null
|
|
358
|
+
|
|
359
|
+
// ── getters ────────────────────────────────────────────────────────────────
|
|
360
|
+
const isLoaded = computed(() => state.status === "public" || state.status === "admin")
|
|
361
|
+
/** The module part (admin/root) has been loaded. */
|
|
362
|
+
const isAdminLoaded = computed(() => state.status === "admin")
|
|
363
|
+
const googleClientId = computed(() => state.configuration["admin.user.google.clientID"] ?? null)
|
|
364
|
+
const hasGoogleLogin = computed(() => !!googleClientId.value)
|
|
365
|
+
const language = computed(() => state.configuration["admin.language"] ?? null)
|
|
366
|
+
const languages = computed(() => state.configuration["admin.languages"] ?? [])
|
|
367
|
+
const moduleNames = computed(() => Object.keys(state.modules ?? {}))
|
|
368
|
+
/**
|
|
369
|
+
* Gruppen der Modul-Navigation. viur-core rendert `conf.admin.module_groups`
|
|
370
|
+
* als PUNKT-Schlüssel `admin.module.groups` (Singular). Der Plural bleibt als
|
|
371
|
+
* Rückfallebene stehen, falls ein Projekt/eine Version ihn so ausliefert.
|
|
372
|
+
*/
|
|
373
|
+
const groups = computed(
|
|
374
|
+
() => state.configuration["admin.module.groups"] ?? state.configuration["admin.modules.groups"] ?? {}
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
// ── module tree (navigation) & lookup ─────────────────────────────────────
|
|
378
|
+
/** Full tree incl. hidden/inaccessible nodes (see buildModuleTree above). */
|
|
379
|
+
const moduleTree = computed(() =>
|
|
380
|
+
buildModuleTree({ modules: state.modules ?? {}, groups: groups.value, access: accessFlags.value })
|
|
381
|
+
)
|
|
382
|
+
/** Tree pruned to what the navigation should render. */
|
|
383
|
+
const navigationTree = computed(() => visibleModuleTree(moduleTree.value))
|
|
384
|
+
/** `{ [key]: node }` for lookups; keys: "shop.order", "shop.order#1", "group:shop". */
|
|
385
|
+
const moduleLookup = computed(() => flattenModuleTree(moduleTree.value))
|
|
386
|
+
|
|
387
|
+
/** Resolve a module (or one of its views) node; view = index | group | name. */
|
|
388
|
+
function moduleNode(name, view = null) {
|
|
389
|
+
return findModule(moduleLookup.value, name, view)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Set the access flags used for the tree (usually `user.access`). */
|
|
393
|
+
function setAccess(access) {
|
|
394
|
+
state.access = Array.isArray(access) ? [...access] : []
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Read a setting; accepts the dotted key ("admin.name") or the bare part
|
|
398
|
+
* ("name", "user.google.clientID") — the `admin.` prefix is optional. */
|
|
399
|
+
function get(key, fallback = undefined) {
|
|
400
|
+
if (key in state.configuration) return state.configuration[key]
|
|
401
|
+
const prefixed = `admin.${key}`
|
|
402
|
+
return prefixed in state.configuration ? state.configuration[prefixed] : fallback
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Admin info of one module ("user", "shop.order"); null when unknown or
|
|
406
|
+
* when the module part has not been loaded. */
|
|
407
|
+
function module(name) {
|
|
408
|
+
return state.modules?.[name] ?? null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function configure({ renderer } = {}) {
|
|
412
|
+
if (renderer) state.renderer = renderer
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── fetching ───────────────────────────────────────────────────────────────
|
|
416
|
+
/**
|
|
417
|
+
* Load `/vi/config`.
|
|
418
|
+
*
|
|
419
|
+
* Without arguments the request is always made — call it once BEFORE
|
|
420
|
+
* rendering the login screen (anonymous → public settings).
|
|
421
|
+
*
|
|
422
|
+
* Pass `{ user }` (the UserSkel values) after a login: the module part is
|
|
423
|
+
* only served for admin/root, so for any other user the request is skipped
|
|
424
|
+
* and the public settings already loaded are kept. If the user has the
|
|
425
|
+
* rights, the config is re-fetched and `modules`/`version` filled.
|
|
426
|
+
*
|
|
427
|
+
* Concurrent calls share one request. Resolves with the store state; a
|
|
428
|
+
* failed request sets `status: "error"` and rejects.
|
|
429
|
+
*
|
|
430
|
+
* @param {{ user?: object|null, force?: boolean }} [options]
|
|
431
|
+
* @returns {Promise<object>} the reactive state
|
|
432
|
+
*/
|
|
433
|
+
async function fetchConfig({ user = undefined, force = false } = {}) {
|
|
434
|
+
if (user !== undefined) {
|
|
435
|
+
setAccess(user?.access)
|
|
436
|
+
if (!canAccessAdminConfig(user)) return state
|
|
437
|
+
if (isAdminLoaded.value && !force) return state
|
|
438
|
+
}
|
|
439
|
+
if (_pending) return _pending
|
|
440
|
+
_pending = _fetch().finally(() => {
|
|
441
|
+
_pending = null
|
|
442
|
+
})
|
|
443
|
+
return _pending
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function _fetch() {
|
|
447
|
+
state.status = "loading"
|
|
448
|
+
state.error = null
|
|
449
|
+
try {
|
|
450
|
+
const response = await fetch(configUrl(state.renderer), {
|
|
451
|
+
credentials: "include",
|
|
452
|
+
headers: { Accept: "application/json" },
|
|
453
|
+
})
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
throw new Error(`config request failed: ${response.status} ${response.statusText}`)
|
|
456
|
+
}
|
|
457
|
+
const body = await response.json()
|
|
458
|
+
// markRaw: the config is read-only data. Wrapping thousands of module
|
|
459
|
+
// infos in deep reactive proxies would make every read in the tree
|
|
460
|
+
// builder go through a proxy trap and track thousands of dependencies.
|
|
461
|
+
// Replacing the whole object still triggers the computeds.
|
|
462
|
+
state.configuration = markRaw(body?.configuration ?? {})
|
|
463
|
+
if (body?.modules) {
|
|
464
|
+
state.modules = markRaw(body.modules)
|
|
465
|
+
state.version = Array.isArray(body.version) ? body.version : null
|
|
466
|
+
state.status = "admin"
|
|
467
|
+
} else {
|
|
468
|
+
// an anonymous answer never carries modules — drop stale admin data
|
|
469
|
+
// (e.g. after a logout)
|
|
470
|
+
state.modules = null
|
|
471
|
+
state.version = null
|
|
472
|
+
state.status = "public"
|
|
473
|
+
}
|
|
474
|
+
return state
|
|
475
|
+
} catch (error) {
|
|
476
|
+
state.status = "error"
|
|
477
|
+
state.error = error
|
|
478
|
+
throw error
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function reset() {
|
|
483
|
+
state.configuration = {}
|
|
484
|
+
state.modules = null
|
|
485
|
+
state.version = null
|
|
486
|
+
state.status = "unknown"
|
|
487
|
+
state.error = null
|
|
488
|
+
state.access = []
|
|
489
|
+
_pending = null
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
state,
|
|
494
|
+
// getters
|
|
495
|
+
isLoaded,
|
|
496
|
+
isAdminLoaded,
|
|
497
|
+
googleClientId,
|
|
498
|
+
hasGoogleLogin,
|
|
499
|
+
language,
|
|
500
|
+
languages,
|
|
501
|
+
moduleNames,
|
|
502
|
+
groups,
|
|
503
|
+
moduleTree,
|
|
504
|
+
navigationTree,
|
|
505
|
+
moduleLookup,
|
|
506
|
+
get,
|
|
507
|
+
module,
|
|
508
|
+
moduleNode,
|
|
509
|
+
setAccess,
|
|
510
|
+
// config
|
|
511
|
+
configure,
|
|
512
|
+
// actions
|
|
513
|
+
fetchConfig,
|
|
514
|
+
reset,
|
|
515
|
+
}
|
|
516
|
+
})
|