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,271 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Browser
|
|
3
|
+
ref="browserEl"
|
|
4
|
+
v-model:location="location"
|
|
5
|
+
v-model:selected-keys="selectedKeys"
|
|
6
|
+
:root-nodes="rootNodeList"
|
|
7
|
+
:rows="rows"
|
|
8
|
+
:columns="effectiveColumns"
|
|
9
|
+
row-key="key"
|
|
10
|
+
:row-kind="rowKind"
|
|
11
|
+
:load-node-children="loadNodeChildren"
|
|
12
|
+
:order-field="orderField"
|
|
13
|
+
:loading="tree.isFetching.value"
|
|
14
|
+
:uploads="uploads"
|
|
15
|
+
:label="module"
|
|
16
|
+
:has-more="tree.hasMore.value"
|
|
17
|
+
:table-props="mergedTableProps"
|
|
18
|
+
@open="$emit('open', $event)"
|
|
19
|
+
@navigate="onNavigate"
|
|
20
|
+
@move="onMove"
|
|
21
|
+
@reorder="onReorder"
|
|
22
|
+
@upload="onUpload"
|
|
23
|
+
@load-more="onLoadMore"
|
|
24
|
+
@page-change="onPageSizeChange"
|
|
25
|
+
>
|
|
26
|
+
<template
|
|
27
|
+
v-for="(_, slotName) in $slots"
|
|
28
|
+
#[slotName]="scope"
|
|
29
|
+
>
|
|
30
|
+
<slot
|
|
31
|
+
:name="slotName"
|
|
32
|
+
v-bind="scope"
|
|
33
|
+
></slot>
|
|
34
|
+
</template>
|
|
35
|
+
</Browser>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup>
|
|
39
|
+
import { ref, computed, watch } from "vue"
|
|
40
|
+
import Browser from "./Browser.vue"
|
|
41
|
+
import { useTreeFetch, useListFetch, useListRootNodesFetch, useMoveFetch, uploadFile } from "../../request/index.js"
|
|
42
|
+
import { columnsFromStructure } from "../DataTable/viurColumns"
|
|
43
|
+
|
|
44
|
+
// Dünner ViUR-Wrapper um den generischen Browser: `module` rein, fertig.
|
|
45
|
+
// Rootnodes über /listRootNodes, Inhalt des Ortes über useTreeFetch (Nodes +
|
|
46
|
+
// Leaves), Baum-Ebenen über list(skelType=node), Verschieben über /move,
|
|
47
|
+
// Uploads über uploadFile. Gespeichert wird pro Zeile; die Ansicht verschiebt
|
|
48
|
+
// der Browser lokal — nachgeladen wird nur der Ort selbst nach Uploads.
|
|
49
|
+
|
|
50
|
+
const props = defineProps({
|
|
51
|
+
module: { type: String, required: true },
|
|
52
|
+
renderer: { type: String, default: null },
|
|
53
|
+
/** Fester Rootnode; ohne ihn wird /listRootNodes befragt. */
|
|
54
|
+
rootNode: { type: [String, Object], default: null },
|
|
55
|
+
/** Spalten; ohne Angabe aus der viewLeafSkel-Struktur abgeleitet. */
|
|
56
|
+
columns: { type: Array, default: null },
|
|
57
|
+
/** Nur diese Bones als Spalten (bei abgeleiteten Spalten). */
|
|
58
|
+
bones: { type: Array, default: null },
|
|
59
|
+
/**
|
|
60
|
+
* Anzeigesprache mehrsprachiger Bones ("de", "en" oder "*" für alle mit der
|
|
61
|
+
* primären vorn) — bestimmt Anzeige UND lokale Sortierung der Spalten.
|
|
62
|
+
*/
|
|
63
|
+
language: { type: String, default: null },
|
|
64
|
+
primaryLanguage: { type: String, default: null },
|
|
65
|
+
params: { type: Object, default: () => ({}) },
|
|
66
|
+
orderField: { type: String, default: "sortindex" },
|
|
67
|
+
uploads: { type: Boolean, default: false },
|
|
68
|
+
tableProps: { type: Object, default: () => ({}) },
|
|
69
|
+
})
|
|
70
|
+
const emit = defineEmits(["open", "moved", "move-failed", "uploaded", "upload-failed"])
|
|
71
|
+
|
|
72
|
+
const browserEl = ref(null)
|
|
73
|
+
const location = defineModel("location", { type: [String, Number, null], default: null })
|
|
74
|
+
const selectedKeys = defineModel("selectedKeys", { type: Array, default: () => [] })
|
|
75
|
+
|
|
76
|
+
const ctx = computed(() => (props.renderer ? { renderer: props.renderer } : {}))
|
|
77
|
+
|
|
78
|
+
// Seitengröße der Cursor-Leiste = ViUR-Parameter `limit` (core ≥ 3.9; das
|
|
79
|
+
// frühere `amount` gibt es dort nicht mehr). Sie gilt vom ERSTEN Fetch an:
|
|
80
|
+
// eine per tableProps vorgegebene pageSize (oder ein `limit` in den params)
|
|
81
|
+
// bestimmt schon den Initial-Request; sonst 30 (ViUR-Standard). Angezeigt
|
|
82
|
+
// wird immer die tatsächliche Fetch-Größe.
|
|
83
|
+
const limit = ref(Math.min(99, Number(props.params?.limit ?? props.tableProps?.pageSize ?? 30) || 30))
|
|
84
|
+
const baseParams = () => ({ ...props.params, orderby: props.orderField, limit: limit.value })
|
|
85
|
+
// Für jeden Fetch nach dem Setup gilt: IMMER mit dem aktuellen Ort (Rootnode
|
|
86
|
+
// oder navigierter Ordner) als parententry — sonst listet der Server quer
|
|
87
|
+
// über den ganzen Baum.
|
|
88
|
+
const currentParams = () => ({
|
|
89
|
+
...baseParams(),
|
|
90
|
+
...(location.value != null ? { parententry: location.value } : {}),
|
|
91
|
+
})
|
|
92
|
+
const mergedTableProps = computed(() => ({
|
|
93
|
+
// Keine Suche im Browser: navigiert wird über Baum und Breadcrumb, gesucht
|
|
94
|
+
// wird in der Listen-Ansicht des Moduls. Wer sie doch will, setzt
|
|
95
|
+
// `tableProps.withSearch` (lokal sinnvoll nur, wenn alles geladen ist:
|
|
96
|
+
// `allLoaded`).
|
|
97
|
+
...props.tableProps,
|
|
98
|
+
pageSize: limit.value,
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
// ── Rootnodes ────────────────────────────────────────────────────────────────
|
|
102
|
+
const rootNodesFetch = props.rootNode ? null : useListRootNodesFetch(props.module, "/", {}, ctx.value)
|
|
103
|
+
const rootNodeList = computed(() => {
|
|
104
|
+
if (props.rootNode) {
|
|
105
|
+
return typeof props.rootNode === "string"
|
|
106
|
+
? [{ key: props.rootNode, name: props.module }]
|
|
107
|
+
: [{ name: props.module, ...props.rootNode }]
|
|
108
|
+
}
|
|
109
|
+
const list = rootNodesFetch?.data?.value
|
|
110
|
+
return Array.isArray(list) ? list.map((entry) => ({ key: entry.key, name: entry.name ?? entry.key })) : []
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ── Inhalt des aktuellen Ortes (Nodes + Leaves) ───────────────────────────────
|
|
114
|
+
const tree = useTreeFetch(props.module, {
|
|
115
|
+
params: baseParams(),
|
|
116
|
+
parententry: location.value ?? undefined,
|
|
117
|
+
...(props.renderer ? { renderer: props.renderer } : {}),
|
|
118
|
+
immediate: false,
|
|
119
|
+
})
|
|
120
|
+
const rows = computed(() => tree.data.value)
|
|
121
|
+
const rowKind = (row) => (row?.skelType === "node" ? "node" : "leaf")
|
|
122
|
+
|
|
123
|
+
// Ohne expliziten Ort ist die ROOTNODE der Ort: sobald sie bekannt ist, wird
|
|
124
|
+
// sie zum Standard-`parententry` für alles — Listen-Fetch, Drops auf die
|
|
125
|
+
// freie Fläche, Reorder und Uploads. Gefetcht wird nie ohne parententry.
|
|
126
|
+
watch(
|
|
127
|
+
rootNodeList,
|
|
128
|
+
(roots) => {
|
|
129
|
+
if (location.value == null && roots.length) location.value = roots[0].key
|
|
130
|
+
},
|
|
131
|
+
{ immediate: true }
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
watch(
|
|
135
|
+
location,
|
|
136
|
+
(parent) => {
|
|
137
|
+
if (parent != null) void tree.open(parent).catch(() => null)
|
|
138
|
+
},
|
|
139
|
+
{ immediate: true }
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Alles geladen (tree.complete)? Dann laufen Sortierung, Suche und
|
|
143
|
+
// Spaltenfilter nur noch LOKAL auf dem vollständigen Datensatz.
|
|
144
|
+
const allLoaded = computed(() => tree.complete.value)
|
|
145
|
+
|
|
146
|
+
const effectiveColumns = computed(() => {
|
|
147
|
+
if (props.columns) return props.columns
|
|
148
|
+
const structure = tree.structure.value?.leaf ?? tree.structure.value?.node ?? {}
|
|
149
|
+
const derived = columnsFromStructure(structure, {
|
|
150
|
+
...(props.bones ? { bones: props.bones } : {}),
|
|
151
|
+
...(props.language ? { language: props.language } : {}),
|
|
152
|
+
...(props.primaryLanguage ? { primaryLanguage: props.primaryLanguage } : {}),
|
|
153
|
+
local: allLoaded.value,
|
|
154
|
+
})
|
|
155
|
+
return derived.length ? derived : [{ id: "name", label: "Name" }]
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
function onNavigate() {
|
|
159
|
+
selectedKeys.value = []
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Cursor-Nachladen: 1 / 5 / 10 Seiten oder alles ───────────────────────────
|
|
163
|
+
async function onLoadMore({ pages }) {
|
|
164
|
+
if (pages === "all") {
|
|
165
|
+
await tree.nextAll().catch(() => null)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
let remaining = pages
|
|
169
|
+
while (remaining-- > 0 && tree.hasMore.value) {
|
|
170
|
+
await tree.next().catch(() => null)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Neue Seitengröße: aktuellen Ort mit geändertem `limit` neu holen. */
|
|
175
|
+
function onPageSizeChange({ pageSize }) {
|
|
176
|
+
if (!pageSize || pageSize === limit.value) return
|
|
177
|
+
limit.value = Math.min(99, pageSize)
|
|
178
|
+
void tree.filter(currentParams()).catch(() => null)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Verschieben (Drop auf Baum/Ordner) & Umsortieren ─────────────────────────
|
|
182
|
+
async function saveMove(row, parentNode, sortindex, kind = rowKind(row)) {
|
|
183
|
+
const data = {
|
|
184
|
+
skelType: kind,
|
|
185
|
+
key: row.key,
|
|
186
|
+
parentNode,
|
|
187
|
+
...(sortindex !== undefined ? { sortindex } : {}),
|
|
188
|
+
}
|
|
189
|
+
const result = useMoveFetch(props.module, "/", data, { ...ctx.value, immediate: false })
|
|
190
|
+
try {
|
|
191
|
+
await result.execute()
|
|
192
|
+
return result.data.value
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Fehler trägt der Aufrufer aus (onMove/onReorder fangen und melden).
|
|
195
|
+
throw error
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function onMove({ rows: movedRows, target, keys, source }) {
|
|
200
|
+
try {
|
|
201
|
+
for (const row of movedRows) {
|
|
202
|
+
// Aus dem BAUM kommen BrowserNodes ({ key, name, data }) — das Skel
|
|
203
|
+
// steckt in `data`, und ein Baum-Eintrag ist immer ein Node. Aus der
|
|
204
|
+
// LISTE kommen Skels mit skelType.
|
|
205
|
+
const raw = row?.data ?? row
|
|
206
|
+
await saveMove(raw, target, undefined, source === "tree" ? "node" : rowKind(raw))
|
|
207
|
+
}
|
|
208
|
+
emit("moved", { keys, target })
|
|
209
|
+
// Ansicht (Liste UND Baum) ist bereits lokal verschoben — kein Reload.
|
|
210
|
+
} catch (error) {
|
|
211
|
+
emit("move-failed", { keys, target, error })
|
|
212
|
+
// Der lokale Stand (Liste UND Baum) ist dann falsch — einmal frisch holen.
|
|
213
|
+
void refresh().catch(() => null)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function onReorder({ row, order, parent, parentChanged, key }) {
|
|
218
|
+
try {
|
|
219
|
+
await saveMove(row, parent ?? location.value, order)
|
|
220
|
+
emit("moved", { keys: [key], target: parent ?? location.value, parentChanged })
|
|
221
|
+
} catch (error) {
|
|
222
|
+
emit("move-failed", { keys: [key], target: parent, error })
|
|
223
|
+
void refresh().catch(() => null)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Uploads ───────────────────────────────────────────────────────────────────
|
|
228
|
+
async function onUpload({ files, target }) {
|
|
229
|
+
const results = []
|
|
230
|
+
for (const file of files) {
|
|
231
|
+
try {
|
|
232
|
+
results.push(await uploadFile(file, { target: target ?? undefined, renderer: props.renderer ?? undefined }))
|
|
233
|
+
} catch (error) {
|
|
234
|
+
emit("upload-failed", { file, error })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (results.length) {
|
|
238
|
+
emit("uploaded", { files: results, target })
|
|
239
|
+
// Neue Einträge gibt es nur vom Server — den aktuellen Ort einmal holen.
|
|
240
|
+
void tree.execute().catch(() => null)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Baum-Ebenen (nur Nodes) ───────────────────────────────────────────────────
|
|
245
|
+
async function loadNodeChildren(node) {
|
|
246
|
+
const list = useListFetch(props.module, {
|
|
247
|
+
// Baum-Ebenen sind Navigation: möglichst vollständig (ViUR-Maximum 99).
|
|
248
|
+
params: { skelType: "node", parententry: node.key, orderby: props.orderField, limit: 99 },
|
|
249
|
+
...(props.renderer ? { renderer: props.renderer } : {}),
|
|
250
|
+
immediate: false,
|
|
251
|
+
})
|
|
252
|
+
await list.execute().catch(() => null)
|
|
253
|
+
return list.data.value.map((skel) => ({
|
|
254
|
+
key: skel.key,
|
|
255
|
+
name: typeof skel.name === "string" ? skel.name : (skel.name?.name ?? skel.key),
|
|
256
|
+
data: skel,
|
|
257
|
+
}))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Alles neu laden: die Liste des aktuellen Ortes UND den Navigations-Baum.
|
|
262
|
+
* Der Baum verwirft seine geladenen Ebenen und klappt die Rootnodes wieder
|
|
263
|
+
* auf (frisch vom Server); offene Unterebenen laden beim Aufklappen neu.
|
|
264
|
+
*/
|
|
265
|
+
function refresh() {
|
|
266
|
+
browserEl.value?.tree?.reset?.()
|
|
267
|
+
return tree.execute()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
defineExpose({ browser: browserEl, tree, refresh })
|
|
271
|
+
</script>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { reactive } from "vue"
|
|
2
|
+
import type { InjectionKey } from "vue"
|
|
3
|
+
import type { RowKey } from "../DataTable/types"
|
|
4
|
+
|
|
5
|
+
// --------------------------------------------------------------------------- //
|
|
6
|
+
// Drag-Session — gemeinsames Ziehen zwischen Panels (Liste ↔ Baum) //
|
|
7
|
+
// --------------------------------------------------------------------------- //
|
|
8
|
+
//
|
|
9
|
+
// Die DataTable zieht Zeilen mit Zeiger-Ereignissen (kein HTML5-D&D). Damit
|
|
10
|
+
// eine Zeile auch AUSSERHALB der Tabelle fallen gelassen werden kann (auf einen
|
|
11
|
+
// Ordner im Navigations-Baum oder eine Ordnerzeile der Liste), stellt der
|
|
12
|
+
// Browser diese Session per provide() bereit:
|
|
13
|
+
//
|
|
14
|
+
// • Die QUELLE (DataTable) meldet Beginn/Bewegung/Ende des Zugs.
|
|
15
|
+
// • Jedes PANEL registriert sich als Container mit einer `targetFor`-Funktion,
|
|
16
|
+
// die ein DOM-Element unter dem Zeiger in ein Drop-Ziel übersetzt (oder
|
|
17
|
+
// null, wenn dort nichts fallen gelassen werden kann).
|
|
18
|
+
// • Beim Loslassen über einem Ziel liefert `drop()` Nutzlast + Ziel an den
|
|
19
|
+
// Halter der Session (Browser) — die Quelle überspringt dann ihre interne
|
|
20
|
+
// Verschiebung.
|
|
21
|
+
//
|
|
22
|
+
// Die Session ist bewusst DOM-basiert (elementFromPoint statt Ziel-Rechtecke
|
|
23
|
+
// vorab zu vermessen): Panels scrollen und virtualisieren unabhängig.
|
|
24
|
+
|
|
25
|
+
export interface DragPayload<R = Record<string, unknown>> {
|
|
26
|
+
keys: RowKey[]
|
|
27
|
+
rows: R[]
|
|
28
|
+
/** Container-Id der Quelle (z. B. "list"). */
|
|
29
|
+
source: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DropTarget {
|
|
33
|
+
/** Container-Id des Panels, in dem das Ziel liegt. */
|
|
34
|
+
container: string
|
|
35
|
+
/** Schlüssel des Zielknotens; null = Wurzelebene des Containers. */
|
|
36
|
+
key: RowKey | null
|
|
37
|
+
mode: "inside" | "before" | "after"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DragContainer {
|
|
41
|
+
id: string
|
|
42
|
+
/** Wurzelelement des Panels — nur Treffer darin zählen. */
|
|
43
|
+
el: () => HTMLElement | null
|
|
44
|
+
/**
|
|
45
|
+
* Element unter dem Zeiger → Drop-Ziel (oder null). `y`/`x` erlauben
|
|
46
|
+
* Zonen-Entscheidungen (before/inside/after) anhand der Trefferposition.
|
|
47
|
+
*/
|
|
48
|
+
targetFor: (hit: Element, x: number, y: number) => DropTarget | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DragSession<R = Record<string, unknown>> {
|
|
52
|
+
state: {
|
|
53
|
+
active: boolean
|
|
54
|
+
payload: DragPayload<R> | null
|
|
55
|
+
over: DropTarget | null
|
|
56
|
+
}
|
|
57
|
+
register(container: DragContainer): () => void
|
|
58
|
+
start(payload: DragPayload<R>): void
|
|
59
|
+
/** Hit-Test unter (x, y); `hint` übergeht elementFromPoint (Tests). */
|
|
60
|
+
update(x: number, y: number, hint?: Element | null): DropTarget | null
|
|
61
|
+
/** Ziel eingelöst: liefert Nutzlast+Ziel und beendet die Session. */
|
|
62
|
+
drop(): { payload: DragPayload<R>; target: DropTarget } | null
|
|
63
|
+
cancel(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const DRAG_SESSION: InjectionKey<DragSession> = Symbol("viur-browser-drag-session")
|
|
67
|
+
|
|
68
|
+
export function createDragSession<R = Record<string, unknown>>(
|
|
69
|
+
onDrop?: (payload: DragPayload<R>, target: DropTarget) => void
|
|
70
|
+
): DragSession<R> {
|
|
71
|
+
const containers = new Map<string, DragContainer>()
|
|
72
|
+
// `reactive` entpackt generische Typen (R wird zu UnwrapRefSimple<R>), womit
|
|
73
|
+
// der Zustand nicht mehr auf DragSession<R> passt. Die Nutzlast ist reine
|
|
74
|
+
// Datenweitergabe — deshalb hier die Form festschreiben.
|
|
75
|
+
const state = reactive({
|
|
76
|
+
active: false,
|
|
77
|
+
payload: null as DragPayload<R> | null,
|
|
78
|
+
over: null as DropTarget | null,
|
|
79
|
+
}) as DragSession<R>["state"]
|
|
80
|
+
|
|
81
|
+
function register(container: DragContainer): () => void {
|
|
82
|
+
containers.set(container.id, container)
|
|
83
|
+
return () => containers.delete(container.id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function start(payload: DragPayload<R>): void {
|
|
87
|
+
state.active = true
|
|
88
|
+
state.payload = payload as typeof state.payload
|
|
89
|
+
state.over = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function update(x: number, y: number, hint?: Element | null): DropTarget | null {
|
|
93
|
+
if (!state.active) return null
|
|
94
|
+
const hit = hint !== undefined ? hint : (document.elementFromPoint(x, y) ?? null)
|
|
95
|
+
if (!hit) {
|
|
96
|
+
state.over = null
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
for (const container of containers.values()) {
|
|
100
|
+
const root = container.el()
|
|
101
|
+
if (!root || !root.contains(hit)) continue
|
|
102
|
+
state.over = container.targetFor(hit, x, y)
|
|
103
|
+
return state.over
|
|
104
|
+
}
|
|
105
|
+
state.over = null
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function drop(): { payload: DragPayload<R>; target: DropTarget } | null {
|
|
110
|
+
const payload = state.payload
|
|
111
|
+
const target = state.over
|
|
112
|
+
cancel()
|
|
113
|
+
if (!payload || !target) return null
|
|
114
|
+
onDrop?.(payload, target)
|
|
115
|
+
return { payload, target }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cancel(): void {
|
|
119
|
+
state.active = false
|
|
120
|
+
state.payload = null
|
|
121
|
+
state.over = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { state, register, start, update, drop, cancel }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Zonen-Helfer: Position im Element → before / inside / after. */
|
|
128
|
+
export function dropModeFor(hitRect: DOMRect, y: number, insideOnly = false): "inside" | "before" | "after" {
|
|
129
|
+
if (insideOnly) return "inside"
|
|
130
|
+
const offset = (y - hitRect.top) / Math.max(1, hitRect.height)
|
|
131
|
+
if (offset < 0.25) return "before"
|
|
132
|
+
if (offset > 0.75) return "after"
|
|
133
|
+
return "inside"
|
|
134
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Öffentliche Oberfläche des Browser-Widgets.
|
|
2
|
+
export { default as Browser } from "./Browser.vue"
|
|
3
|
+
export { default as ViurBrowser } from "./ViurBrowser.vue"
|
|
4
|
+
export { default as TreeNav } from "./TreeNav.vue"
|
|
5
|
+
export {
|
|
6
|
+
createDragSession,
|
|
7
|
+
dropModeFor,
|
|
8
|
+
DRAG_SESSION,
|
|
9
|
+
type DragSession,
|
|
10
|
+
type DragPayload,
|
|
11
|
+
type DragContainer,
|
|
12
|
+
type DropTarget,
|
|
13
|
+
} from "./dragSession"
|
|
14
|
+
export type { BrowserNode, BrowserMovePayload, BrowserUploadPayload } from "./types"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RowKey } from "../DataTable/types"
|
|
2
|
+
|
|
3
|
+
/** Knoten des Navigations-Baums (und Ordner in der Liste). */
|
|
4
|
+
export interface BrowserNode {
|
|
5
|
+
key: RowKey
|
|
6
|
+
name: string
|
|
7
|
+
icon?: string | null
|
|
8
|
+
/** false = sicher keine Kinder → Pfeil entfällt. */
|
|
9
|
+
hasChildren?: boolean
|
|
10
|
+
/** Beliebige Nutzdaten (z. B. das Skel) — der Browser liest sie nicht. */
|
|
11
|
+
data?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BrowserMovePayload<R = Record<string, unknown>> {
|
|
15
|
+
/** Zeilenschlüssel in Anzeige-Reihenfolge. */
|
|
16
|
+
keys: RowKey[]
|
|
17
|
+
rows: R[]
|
|
18
|
+
/** Zielknoten (null = Wurzel des aktuellen Rootnodes). */
|
|
19
|
+
target: RowKey | null
|
|
20
|
+
/** "inside" (in einen Ordner) oder before/after (Reihenfolge in der Liste). */
|
|
21
|
+
mode: "inside" | "before" | "after"
|
|
22
|
+
/** Quelle des Zugs: Liste oder Baum. */
|
|
23
|
+
source: "list" | "tree"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BrowserUploadPayload {
|
|
27
|
+
files: File[]
|
|
28
|
+
/** Zielknoten (null = aktuelle Ebene ist die Wurzel). */
|
|
29
|
+
target: RowKey | null
|
|
30
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Inhalt des Filter-Popovers einer Spalte. Welche Steuerelemente erscheinen,
|
|
3
|
+
entscheidet der filterType; die Werte für set-Filter kommen aus den
|
|
4
|
+
Facetten (oder aus column.def.filterOptions, z. B. im Server-Modus). -->
|
|
5
|
+
<div class="vi-dtf wa-stack wa-gap-s">
|
|
6
|
+
<span class="vi-dtf-title">{{ column.label }}</span>
|
|
7
|
+
|
|
8
|
+
<template v-if="state.kind === 'range'">
|
|
9
|
+
<div class="wa-cluster wa-gap-2xs wa-flex-nowrap">
|
|
10
|
+
<wa-input
|
|
11
|
+
class="vi-dtf-input"
|
|
12
|
+
size="small"
|
|
13
|
+
appearance="outlined"
|
|
14
|
+
:type="state.inputType"
|
|
15
|
+
:placeholder="t('dataTable.filterFrom')"
|
|
16
|
+
:value="state.from"
|
|
17
|
+
@input="setRange(0, ($event.target as HTMLInputElement).value)"
|
|
18
|
+
></wa-input>
|
|
19
|
+
<wa-input
|
|
20
|
+
class="vi-dtf-input"
|
|
21
|
+
size="small"
|
|
22
|
+
appearance="outlined"
|
|
23
|
+
:type="state.inputType"
|
|
24
|
+
:placeholder="t('dataTable.filterTo')"
|
|
25
|
+
:value="state.to"
|
|
26
|
+
@input="setRange(1, ($event.target as HTMLInputElement).value)"
|
|
27
|
+
></wa-input>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<template v-else-if="state.kind === 'set'">
|
|
32
|
+
<wa-input
|
|
33
|
+
v-if="state.options.length > 10"
|
|
34
|
+
class="vi-dtf-search"
|
|
35
|
+
size="small"
|
|
36
|
+
appearance="outlined"
|
|
37
|
+
:placeholder="t('dataTable.searchValues')"
|
|
38
|
+
:value="state.optionQuery"
|
|
39
|
+
@input="state.optionQuery = ($event.target as HTMLInputElement).value"
|
|
40
|
+
>
|
|
41
|
+
<wa-icon
|
|
42
|
+
slot="start"
|
|
43
|
+
name="magnifying-glass"
|
|
44
|
+
></wa-icon>
|
|
45
|
+
</wa-input>
|
|
46
|
+
<div class="vi-dtf-options wa-stack wa-gap-2xs">
|
|
47
|
+
<label
|
|
48
|
+
v-for="option in state.filteredOptions"
|
|
49
|
+
:key="option.value"
|
|
50
|
+
class="vi-dtf-option wa-flank wa-flex-nowrap wa-gap-xs"
|
|
51
|
+
>
|
|
52
|
+
<input
|
|
53
|
+
class="vi-dtf-check"
|
|
54
|
+
type="checkbox"
|
|
55
|
+
:value="option.value"
|
|
56
|
+
:checked="state.selected.includes(option.value)"
|
|
57
|
+
@change="toggleOption(option.value)"
|
|
58
|
+
/>
|
|
59
|
+
<span class="vi-dtf-option-label wa-split wa-flex-nowrap wa-gap-xs">
|
|
60
|
+
<span class="wa-ellipsis">{{ option.label ?? option.value }}</span>
|
|
61
|
+
<span
|
|
62
|
+
v-if="option.count !== undefined"
|
|
63
|
+
class="vi-dtf-count"
|
|
64
|
+
>
|
|
65
|
+
{{ option.count }}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
</label>
|
|
69
|
+
<span
|
|
70
|
+
v-if="!state.filteredOptions.length"
|
|
71
|
+
class="vi-dtf-empty"
|
|
72
|
+
>
|
|
73
|
+
Keine Werte
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<template v-else>
|
|
79
|
+
<wa-input
|
|
80
|
+
class="vi-dtf-input"
|
|
81
|
+
size="small"
|
|
82
|
+
appearance="outlined"
|
|
83
|
+
:placeholder="state.kind === 'equals' ? 'genau …' : 'enthält …'"
|
|
84
|
+
:value="state.text"
|
|
85
|
+
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || undefined)"
|
|
86
|
+
></wa-input>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<div class="wa-cluster wa-gap-2xs">
|
|
90
|
+
<wa-button
|
|
91
|
+
class="vi-dtf-clear"
|
|
92
|
+
size="small"
|
|
93
|
+
appearance="outlined"
|
|
94
|
+
@click="clear"
|
|
95
|
+
>
|
|
96
|
+
Leeren
|
|
97
|
+
</wa-button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<script setup lang="ts">
|
|
103
|
+
import { useI18n } from "vue-i18n"
|
|
104
|
+
import { computed, reactive } from "vue"
|
|
105
|
+
import type { ColumnFacets, FilterOption, ResolvedColumn } from "./types"
|
|
106
|
+
|
|
107
|
+
const { t } = useI18n()
|
|
108
|
+
|
|
109
|
+
const props = defineProps<{
|
|
110
|
+
column: ResolvedColumn
|
|
111
|
+
facets?: ColumnFacets
|
|
112
|
+
modelValue?: unknown
|
|
113
|
+
}>()
|
|
114
|
+
|
|
115
|
+
const emit = defineEmits<{ "update:modelValue": [value: unknown] }>()
|
|
116
|
+
|
|
117
|
+
const state = reactive({
|
|
118
|
+
optionQuery: "",
|
|
119
|
+
|
|
120
|
+
kind: computed((): "text" | "equals" | "range" | "set" => {
|
|
121
|
+
switch (props.column.filterType) {
|
|
122
|
+
case "number-range":
|
|
123
|
+
case "date-range":
|
|
124
|
+
return "range"
|
|
125
|
+
case "set":
|
|
126
|
+
case "includes-any":
|
|
127
|
+
case "includes-all":
|
|
128
|
+
return "set"
|
|
129
|
+
case "equals":
|
|
130
|
+
return "equals"
|
|
131
|
+
default:
|
|
132
|
+
return "text"
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
inputType: computed((): string => (props.column.filterType === "date-range" ? "date" : "number")),
|
|
136
|
+
text: computed((): string => (props.modelValue === undefined ? "" : String(props.modelValue))),
|
|
137
|
+
range: computed((): [unknown, unknown] =>
|
|
138
|
+
Array.isArray(props.modelValue) ? (props.modelValue as [unknown, unknown]) : [undefined, undefined]
|
|
139
|
+
),
|
|
140
|
+
from: computed((): string => valueToText(state.range[0])),
|
|
141
|
+
to: computed((): string => valueToText(state.range[1])),
|
|
142
|
+
selected: computed((): string[] =>
|
|
143
|
+
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map((item) => String(item)) : []
|
|
144
|
+
),
|
|
145
|
+
options: computed((): FilterOption[] => {
|
|
146
|
+
if (props.column.def.filterOptions) return props.column.def.filterOptions
|
|
147
|
+
return (props.facets?.values ?? []).map((facet) => ({
|
|
148
|
+
value: facet.value,
|
|
149
|
+
count: facet.count,
|
|
150
|
+
}))
|
|
151
|
+
}),
|
|
152
|
+
filteredOptions: computed((): FilterOption[] => {
|
|
153
|
+
const query = state.optionQuery.trim().toLowerCase()
|
|
154
|
+
if (!query) return state.options
|
|
155
|
+
return state.options.filter((option) =>
|
|
156
|
+
String(option.label ?? option.value)
|
|
157
|
+
.toLowerCase()
|
|
158
|
+
.includes(query)
|
|
159
|
+
)
|
|
160
|
+
}),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
function valueToText(value: unknown): string {
|
|
164
|
+
return value === undefined || value === null ? "" : String(value)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setRange(index: 0 | 1, raw: string): void {
|
|
168
|
+
const next: [unknown, unknown] = [...state.range] as [unknown, unknown]
|
|
169
|
+
next[index] = raw === "" ? undefined : raw
|
|
170
|
+
emit("update:modelValue", next[0] === undefined && next[1] === undefined ? undefined : next)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toggleOption(value: string): void {
|
|
174
|
+
const selected = new Set(state.selected)
|
|
175
|
+
if (selected.has(value)) selected.delete(value)
|
|
176
|
+
else selected.add(value)
|
|
177
|
+
const next = [...selected]
|
|
178
|
+
emit("update:modelValue", next.length ? next : undefined)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function clear(): void {
|
|
182
|
+
state.optionQuery = ""
|
|
183
|
+
emit("update:modelValue", undefined)
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<style scoped>
|
|
188
|
+
.vi-dtf {
|
|
189
|
+
min-inline-size: 15rem;
|
|
190
|
+
max-inline-size: 20rem;
|
|
191
|
+
}
|
|
192
|
+
.vi-dtf-title {
|
|
193
|
+
font-size: var(--wa-font-size-s);
|
|
194
|
+
font-weight: 700;
|
|
195
|
+
}
|
|
196
|
+
.vi-dtf-input,
|
|
197
|
+
.vi-dtf-search {
|
|
198
|
+
flex: 1 1 auto;
|
|
199
|
+
min-inline-size: 0;
|
|
200
|
+
width: 100%;
|
|
201
|
+
}
|
|
202
|
+
.vi-dtf-options {
|
|
203
|
+
max-block-size: 14rem;
|
|
204
|
+
overflow: auto;
|
|
205
|
+
}
|
|
206
|
+
.vi-dtf-option {
|
|
207
|
+
font-size: var(--wa-font-size-s);
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
.vi-dtf-option-label {
|
|
211
|
+
min-inline-size: 0;
|
|
212
|
+
flex: 1 1 auto;
|
|
213
|
+
}
|
|
214
|
+
.vi-dtf-count {
|
|
215
|
+
color: var(--wa-color-text-quiet);
|
|
216
|
+
font-variant-numeric: tabular-nums;
|
|
217
|
+
}
|
|
218
|
+
.vi-dtf-empty,
|
|
219
|
+
.vi-dtf-count {
|
|
220
|
+
font-size: var(--wa-font-size-xs);
|
|
221
|
+
}
|
|
222
|
+
.vi-dtf-empty {
|
|
223
|
+
color: var(--wa-color-text-quiet);
|
|
224
|
+
}
|
|
225
|
+
</style>
|