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,446 @@
|
|
|
1
|
+
// Reine Zeilen-Pipeline: Filter → Suche → Sortierung → Baum/Gruppen →
|
|
2
|
+
// Paginierung → flache Anzeigezeilen. Ebenfalls Vue-frei und damit direkt
|
|
3
|
+
// testbar; die Komponente ruft das Ganze nur in einem computed auf.
|
|
4
|
+
|
|
5
|
+
import { aggregateColumn, defaultText, findColumn, rowMatchesFilters, rowMatchesSearch, sortRows } from "./columns"
|
|
6
|
+
import type { ColumnFacets, DisplayRow, FilterEntry, ResolvedColumn, Row, RowKey, RowNode, SortEntry } from "./types"
|
|
7
|
+
import { computeFacets, isEmptyFilter } from "./columns"
|
|
8
|
+
|
|
9
|
+
export type ChildRowsAccessor<R extends Row = Row> = string | ((row: R) => R[] | undefined | null)
|
|
10
|
+
|
|
11
|
+
export interface BuildRowsOptions<R extends Row = Row> {
|
|
12
|
+
data: R[]
|
|
13
|
+
columns: ResolvedColumn<R>[]
|
|
14
|
+
sort?: SortEntry[]
|
|
15
|
+
filters?: FilterEntry[]
|
|
16
|
+
search?: string
|
|
17
|
+
searchFn?: ((value: unknown, term: string, row: R) => boolean) | null
|
|
18
|
+
/** Baumdaten: Feldname oder Funktion, die die Kinder liefert. */
|
|
19
|
+
childRows?: ChildRowsAccessor<R> | null
|
|
20
|
+
/** Elternzeile bleibt sichtbar, wenn ein Nachfahre passt. */
|
|
21
|
+
filterFromLeafRows?: boolean
|
|
22
|
+
/** Gruppierung nach einer oder mehreren Spalten-ids (ohne Wirkung bei Baumdaten). */
|
|
23
|
+
groupBy?: string[]
|
|
24
|
+
/** Feld mit stabiler Zeilen-id. Ohne das werden Indexpfade benutzt ("0", "0.1"). */
|
|
25
|
+
rowKey?: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BuiltRows<R extends Row = Row> {
|
|
29
|
+
/** Hierarchie nach Filter/Sortierung/Gruppierung. */
|
|
30
|
+
nodes: RowNode<R>[]
|
|
31
|
+
/** Alle Datenzeilen in Anzeigereihenfolge (auch eingeklappte). */
|
|
32
|
+
dataRows: R[]
|
|
33
|
+
/** Nur oberste Ebene — für Fußzeilen-Funktionen bei Baumdaten. */
|
|
34
|
+
topLevelRows: R[]
|
|
35
|
+
/** Zeilen im Ergebnis (oberste Ebene bzw. Gruppen) über alle Seiten. */
|
|
36
|
+
filteredCount: number
|
|
37
|
+
facets: Record<string, ColumnFacets>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function childrenOf<R extends Row>(row: R, accessor: ChildRowsAccessor<R> | null | undefined): R[] {
|
|
41
|
+
if (!accessor) return []
|
|
42
|
+
const value = typeof accessor === "function" ? accessor(row) : (row[accessor] as R[] | undefined)
|
|
43
|
+
return Array.isArray(value) ? value : []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function keyOf<R extends Row>(row: R, rowKey: string | null | undefined, path: string): RowKey {
|
|
47
|
+
if (rowKey) {
|
|
48
|
+
const value = row[rowKey]
|
|
49
|
+
if (value !== undefined && value !== null) return value as RowKey
|
|
50
|
+
}
|
|
51
|
+
return path
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Datenzeilen (mit Kindern) in Knoten überführen — Filter und Sortierung inklusive. */
|
|
55
|
+
function buildDataNodes<R extends Row>(
|
|
56
|
+
rows: R[],
|
|
57
|
+
options: BuildRowsOptions<R>,
|
|
58
|
+
depth: number,
|
|
59
|
+
pathPrefix: string
|
|
60
|
+
): RowNode<R>[] {
|
|
61
|
+
const { columns, filters = [], search = "", searchFn, childRows, filterFromLeafRows } = options
|
|
62
|
+
const sorted = sortRows(rows, options.sort ?? [], columns)
|
|
63
|
+
|
|
64
|
+
const nodes: RowNode<R>[] = []
|
|
65
|
+
sorted.forEach((row, index) => {
|
|
66
|
+
const path = pathPrefix ? `${pathPrefix}.${index}` : String(index)
|
|
67
|
+
const selfMatches = rowMatchesFilters(row, filters, columns) && rowMatchesSearch(row, search, columns, searchFn)
|
|
68
|
+
|
|
69
|
+
const rawChildren = childrenOf(row, childRows)
|
|
70
|
+
// Bei filterFromLeafRows zählt der Treffer eines Nachfahren; sonst nimmt
|
|
71
|
+
// eine nicht passende Zeile ihren ganzen Teilbaum mit.
|
|
72
|
+
const children = rawChildren.length ? buildDataNodes(rawChildren, options, depth + 1, path) : []
|
|
73
|
+
|
|
74
|
+
if (!selfMatches && !(filterFromLeafRows && children.length)) return
|
|
75
|
+
|
|
76
|
+
nodes.push({
|
|
77
|
+
kind: "data",
|
|
78
|
+
key: keyOf(row, options.rowKey, path),
|
|
79
|
+
depth,
|
|
80
|
+
row,
|
|
81
|
+
children,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
return nodes
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Datenknoten nach einer oder mehreren Spalten gruppieren. */
|
|
88
|
+
function groupNodes<R extends Row>(
|
|
89
|
+
nodes: RowNode<R>[],
|
|
90
|
+
groupBy: string[],
|
|
91
|
+
columns: ResolvedColumn<R>[],
|
|
92
|
+
depth: number,
|
|
93
|
+
pathPrefix: string
|
|
94
|
+
): RowNode<R>[] {
|
|
95
|
+
const [current, ...rest] = groupBy
|
|
96
|
+
const column = findColumn(columns, current)
|
|
97
|
+
if (!column) return nodes
|
|
98
|
+
|
|
99
|
+
const groups = new Map<string, { value: unknown; nodes: RowNode<R>[] }>()
|
|
100
|
+
for (const node of nodes) {
|
|
101
|
+
if (!node.row) continue
|
|
102
|
+
const value = column.get(node.row)
|
|
103
|
+
const key = defaultText(value)
|
|
104
|
+
const bucket = groups.get(key)
|
|
105
|
+
if (bucket) bucket.nodes.push(node)
|
|
106
|
+
else groups.set(key, { value, nodes: [node] })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return [...groups.entries()].map(([key, bucket], index) => {
|
|
110
|
+
const path = pathPrefix ? `${pathPrefix}.${key}` : `group:${current}:${key}`
|
|
111
|
+
const children = rest.length
|
|
112
|
+
? groupNodes(bucket.nodes, rest, columns, depth + 1, path)
|
|
113
|
+
: bucket.nodes.map((node) => ({ ...node, depth: depth + 1 }))
|
|
114
|
+
const groupRows = bucket.nodes.map((node) => node.row!).filter(Boolean)
|
|
115
|
+
const aggregates: Record<string, unknown> = {}
|
|
116
|
+
for (const col of columns) {
|
|
117
|
+
const value = aggregateColumn(col, groupRows)
|
|
118
|
+
if (value !== undefined) aggregates[col.id] = value
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
kind: "group" as const,
|
|
122
|
+
key: `${path}#${index}`,
|
|
123
|
+
depth,
|
|
124
|
+
children,
|
|
125
|
+
groupColumnId: current,
|
|
126
|
+
groupValue: bucket.value,
|
|
127
|
+
groupCount: groupRows.length,
|
|
128
|
+
groupRows,
|
|
129
|
+
aggregates,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function collectDataRows<R extends Row>(nodes: RowNode<R>[], into: R[] = []): R[] {
|
|
135
|
+
for (const node of nodes) {
|
|
136
|
+
if (node.row) into.push(node.row)
|
|
137
|
+
if (node.children.length) collectDataRows(node.children, into)
|
|
138
|
+
}
|
|
139
|
+
return into
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Facetten je filterbarer Spalte — jeweils ohne den eigenen Filter. */
|
|
143
|
+
function buildFacets<R extends Row>(options: BuildRowsOptions<R>): Record<string, ColumnFacets> {
|
|
144
|
+
const { columns, filters = [], search = "", searchFn, childRows } = options
|
|
145
|
+
const flat = flattenData(options.data, childRows)
|
|
146
|
+
const facets: Record<string, ColumnFacets> = {}
|
|
147
|
+
for (const column of columns) {
|
|
148
|
+
if (!column.filterable) continue
|
|
149
|
+
const others = filters.filter((filter) => filter.id !== column.id && !isEmptyFilter(filter.value))
|
|
150
|
+
const base = flat.filter(
|
|
151
|
+
(row) => rowMatchesFilters(row, others, columns) && rowMatchesSearch(row, search, columns, searchFn)
|
|
152
|
+
)
|
|
153
|
+
facets[column.id] = computeFacets(base, column)
|
|
154
|
+
}
|
|
155
|
+
return facets
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function flattenData<R extends Row>(
|
|
159
|
+
rows: R[],
|
|
160
|
+
childRows: ChildRowsAccessor<R> | null | undefined,
|
|
161
|
+
into: R[] = []
|
|
162
|
+
): R[] {
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
into.push(row)
|
|
165
|
+
const children = childrenOf(row, childRows)
|
|
166
|
+
if (children.length) flattenData(children, childRows, into)
|
|
167
|
+
}
|
|
168
|
+
return into
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildRows<R extends Row>(options: BuildRowsOptions<R>): BuiltRows<R> {
|
|
172
|
+
const nodes = buildDataNodes(options.data, options, 0, "")
|
|
173
|
+
const grouped =
|
|
174
|
+
options.groupBy?.length && !options.childRows ? groupNodes(nodes, options.groupBy, options.columns, 0, "") : nodes
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
nodes: grouped,
|
|
178
|
+
dataRows: collectDataRows(grouped),
|
|
179
|
+
topLevelRows: nodes.map((node) => node.row!).filter(Boolean),
|
|
180
|
+
filteredCount: grouped.length,
|
|
181
|
+
facets: buildFacets(options),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Eine Seite aus der obersten Ebene schneiden (Teilbäume bleiben bei ihrem Elternteil). */
|
|
186
|
+
export function paginateNodes<R extends Row>(nodes: RowNode<R>[], page: number, pageSize: number): RowNode<R>[] {
|
|
187
|
+
if (pageSize <= 0) return nodes
|
|
188
|
+
const start = page * pageSize
|
|
189
|
+
return nodes.slice(start, start + pageSize)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface FlattenOptions<R extends Row = Row> {
|
|
193
|
+
isExpanded: (key: RowKey) => boolean
|
|
194
|
+
/** Zeilen bekommen ein Detail-Panel (eigene Zeile unter der Datenzeile). */
|
|
195
|
+
withDetail?: boolean
|
|
196
|
+
/**
|
|
197
|
+
* Für nachgeladene Ebenen: Zeile gilt als aufklappbar, obwohl (noch) keine
|
|
198
|
+
* Kinder in den Daten stehen. Beim Nachladen weiß man vorher nicht, ob es
|
|
199
|
+
* welche gibt — der Pfeil muss trotzdem von Anfang an da sein.
|
|
200
|
+
*/
|
|
201
|
+
mayHaveChildren?: (node: RowNode<R>) => boolean
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Hierarchie zu einer flachen Renderliste machen: eingeklappte Teilbäume
|
|
206
|
+
* fallen weg, aufgeklappte Detail-Panels kommen als eigene Zeile dazu.
|
|
207
|
+
*/
|
|
208
|
+
export function flattenNodes<R extends Row>(nodes: RowNode<R>[], options: FlattenOptions<R>): DisplayRow<R>[] {
|
|
209
|
+
const out: DisplayRow<R>[] = []
|
|
210
|
+
let dataIndex = 0
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `lines` sind die Durchlauf-Spalten links des eigenen Winkels: je Eintrag
|
|
214
|
+
* eine Spalte, in der eine senkrechte Linie durchläuft, weil der Vorfahre der
|
|
215
|
+
* NÄCHSTEN Ebene noch ein Geschwister hat (siehe `DisplayRow.ancestorLines`).
|
|
216
|
+
* Nach dem letzten Kind bleibt die Spalte leer.
|
|
217
|
+
*/
|
|
218
|
+
const walk = (list: RowNode<R>[], lines: boolean[]): void => {
|
|
219
|
+
list.forEach((node, position) => {
|
|
220
|
+
const isLastChild = position === list.length - 1
|
|
221
|
+
const hasChildren = node.children.length > 0
|
|
222
|
+
const expandable =
|
|
223
|
+
node.kind === "group"
|
|
224
|
+
? hasChildren
|
|
225
|
+
: hasChildren || Boolean(options.withDetail) || Boolean(options.mayHaveChildren?.(node))
|
|
226
|
+
const expanded = expandable && options.isExpanded(node.key)
|
|
227
|
+
|
|
228
|
+
const base = {
|
|
229
|
+
nodeKey: node.key,
|
|
230
|
+
depth: node.depth,
|
|
231
|
+
ancestorLines: lines,
|
|
232
|
+
isLastChild,
|
|
233
|
+
row: node.row,
|
|
234
|
+
node,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
out.push({
|
|
238
|
+
...base,
|
|
239
|
+
kind: node.kind,
|
|
240
|
+
key: String(node.key),
|
|
241
|
+
expandable,
|
|
242
|
+
expanded,
|
|
243
|
+
index: node.kind === "data" ? dataIndex++ : -1,
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (!expanded) return
|
|
247
|
+
const detailRow = (): DisplayRow<R> => ({
|
|
248
|
+
...base,
|
|
249
|
+
kind: "detail",
|
|
250
|
+
key: `${String(node.key)}::detail`,
|
|
251
|
+
expandable: false,
|
|
252
|
+
expanded: true,
|
|
253
|
+
index: -1,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
if (node.kind === "data" && options.withDetail && !hasChildren) out.push(detailRow())
|
|
257
|
+
// Wurzeln erzeugen keine Durchlauf-Spalte (zwischen Wurzeln gibt es keine
|
|
258
|
+
// Linie); ab Ebene 1 kommt je Ebene eine dazu.
|
|
259
|
+
else if (hasChildren) {
|
|
260
|
+
walk(node.children, node.depth === 0 ? [] : [...lines, !isLastChild])
|
|
261
|
+
} else if (options.withDetail) out.push(detailRow())
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
walk(nodes, [])
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Auswahl (Baum/Gruppen) ────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Alle Datenschlüssel eines Knotens inklusive Nachfahren. */
|
|
272
|
+
export function collectKeys<R extends Row>(node: RowNode<R>, into: RowKey[] = []): RowKey[] {
|
|
273
|
+
if (node.kind === "data") into.push(node.key)
|
|
274
|
+
for (const child of node.children) collectKeys(child, into)
|
|
275
|
+
return into
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export type SelectionState = "checked" | "indeterminate" | "unchecked"
|
|
279
|
+
|
|
280
|
+
/** Zustand der Checkbox eines Knotens (Elternteil spiegelt seine Kinder). */
|
|
281
|
+
export function selectionStateFor<R extends Row>(node: RowNode<R>, selected: Set<RowKey>): SelectionState {
|
|
282
|
+
const keys = collectKeys(node)
|
|
283
|
+
if (!keys.length) return "unchecked"
|
|
284
|
+
const hits = keys.filter((key) => selected.has(key)).length
|
|
285
|
+
if (hits === 0) return "unchecked"
|
|
286
|
+
return hits === keys.length ? "checked" : "indeterminate"
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Flache Liste zu Baum ──────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export interface NestedTree<R extends Row = Row> {
|
|
292
|
+
/** Zeilen der obersten Ebene, in Eingabereihenfolge. */
|
|
293
|
+
roots: R[]
|
|
294
|
+
/** Kinder je Zeilenschlüssel. */
|
|
295
|
+
children: Map<RowKey, R[]>
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Aus einer FLACHEN Liste mit Eltern-Verweis (ViUR: `parententry`) die
|
|
300
|
+
* Hierarchie bauen.
|
|
301
|
+
*
|
|
302
|
+
* Grundsatz: es geht nichts verloren. Zeilen ohne Elternwert sind Wurzeln;
|
|
303
|
+
* Zeilen, deren Elternteil nicht in den Daten steckt (Waisen — etwa weil nur
|
|
304
|
+
* eine Seite geladen ist), ebenfalls. Auch Zyklen (A → B → A, oder eine Zeile
|
|
305
|
+
* als ihr eigener Elternteil) landen als Wurzel, statt die Liste zu sprengen
|
|
306
|
+
* oder Zeilen verschwinden zu lassen.
|
|
307
|
+
*/
|
|
308
|
+
export function nestByParent<R extends Row>(rows: R[], rowKey: string, parentField: string): NestedTree<R> {
|
|
309
|
+
const byKey = new Map<RowKey, R>()
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
const key = row[rowKey] as RowKey | undefined
|
|
312
|
+
if (key !== undefined && key !== null) byKey.set(key, row)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const children = new Map<RowKey, R[]>()
|
|
316
|
+
const roots: R[] = []
|
|
317
|
+
const attached = new Set<R>()
|
|
318
|
+
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
const key = row[rowKey] as RowKey | undefined
|
|
321
|
+
const parent = row[parentField] as RowKey | undefined
|
|
322
|
+
const hasParent = parent !== undefined && parent !== null && parent !== "" && parent !== key && byKey.has(parent)
|
|
323
|
+
|
|
324
|
+
if (!hasParent) {
|
|
325
|
+
roots.push(row)
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
const list = children.get(parent!) ?? []
|
|
329
|
+
list.push(row)
|
|
330
|
+
children.set(parent!, list)
|
|
331
|
+
attached.add(row)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Zyklen: alles, was zwar einen Elternteil hat, aber von keiner Wurzel aus
|
|
335
|
+
// erreichbar ist, wird selbst zur Wurzel — sonst wäre es unsichtbar.
|
|
336
|
+
const reachable = new Set<R>()
|
|
337
|
+
const walk = (list: R[]): void => {
|
|
338
|
+
for (const row of list) {
|
|
339
|
+
if (reachable.has(row)) continue
|
|
340
|
+
reachable.add(row)
|
|
341
|
+
const key = row[rowKey] as RowKey | undefined
|
|
342
|
+
if (key !== undefined) walk(children.get(key) ?? [])
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
walk(roots)
|
|
346
|
+
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
if (!attached.has(row) || reachable.has(row)) continue
|
|
349
|
+
// Kante zum (kreisenden) Elternteil kappen und als Wurzel führen.
|
|
350
|
+
const parent = row[parentField] as RowKey
|
|
351
|
+
const siblings = children.get(parent)
|
|
352
|
+
if (siblings) {
|
|
353
|
+
const index = siblings.indexOf(row)
|
|
354
|
+
if (index !== -1) siblings.splice(index, 1)
|
|
355
|
+
}
|
|
356
|
+
roots.push(row)
|
|
357
|
+
walk([row])
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { roots, children }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Position im Baum ──────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
export interface NodeLocation<R extends Row = Row> {
|
|
366
|
+
node: RowNode<R>
|
|
367
|
+
/** Schlüssel der Elternzeile; null auf der obersten Ebene. */
|
|
368
|
+
parentKey: RowKey | null
|
|
369
|
+
/** Geschwister einschließlich des Knotens selbst, in Anzeigereihenfolge. */
|
|
370
|
+
siblings: RowNode<R>[]
|
|
371
|
+
index: number
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Knoten samt Elternteil und Geschwistern suchen. */
|
|
375
|
+
export function locateNode<R extends Row>(
|
|
376
|
+
nodes: RowNode<R>[],
|
|
377
|
+
key: RowKey,
|
|
378
|
+
parentKey: RowKey | null = null
|
|
379
|
+
): NodeLocation<R> | null {
|
|
380
|
+
const index = nodes.findIndex((node) => node.key === key)
|
|
381
|
+
if (index !== -1) return { node: nodes[index], parentKey, siblings: nodes, index }
|
|
382
|
+
for (const node of nodes) {
|
|
383
|
+
if (!node.children.length) continue
|
|
384
|
+
const found = locateNode(node.children, key, node.key)
|
|
385
|
+
if (found) return found
|
|
386
|
+
}
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Liegt `candidateKey` im Teilbaum von `ancestorKey`? Verhindert, dass eine
|
|
392
|
+
* Zeile in ihren eigenen Nachfahren gezogen wird — der Teilbaum hinge sonst
|
|
393
|
+
* an sich selbst und wäre aus der Ansicht verschwunden.
|
|
394
|
+
*/
|
|
395
|
+
export function isDescendant<R extends Row>(nodes: RowNode<R>[], ancestorKey: RowKey, candidateKey: RowKey): boolean {
|
|
396
|
+
const ancestor = locateNode(nodes, ancestorKey)
|
|
397
|
+
if (!ancestor) return false
|
|
398
|
+
const walk = (list: RowNode<R>[]): boolean => list.some((node) => node.key === candidateKey || walk(node.children))
|
|
399
|
+
return walk(ancestor.node.children)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Feste Reihenfolge (numerische Ordnungsspalte) ─────────────────────
|
|
403
|
+
|
|
404
|
+
export interface OrderValue {
|
|
405
|
+
/** Neuer Wert für die Ordnungsspalte der verschobenen Zeile. */
|
|
406
|
+
order: number
|
|
407
|
+
/**
|
|
408
|
+
* true, wenn die beiden Nachbarn NICHT unterscheidbar sind — praktisch immer
|
|
409
|
+
* dann, wenn mehrere Datensätze denselben Ordnungswert tragen (frisch
|
|
410
|
+
* angelegte Einträge mit Default 0 etwa). Die Mitte wäre dort wieder derselbe
|
|
411
|
+
* Wert, die Zeile würde also nicht sichtbar wandern; stattdessen wird hinter
|
|
412
|
+
* dem Block eingeordnet und die Anwendung kann neu durchnummerieren.
|
|
413
|
+
*
|
|
414
|
+
* Bei verteilten Fließkommawerten passiert das nicht: zwischen zwei Werten
|
|
415
|
+
* liegen rund 50 Halbierungen, bevor die Genauigkeit ausgeht.
|
|
416
|
+
*/
|
|
417
|
+
needsReindex: boolean
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Neuen Ordnungswert für eine Einfügeposition bestimmen — dieselbe Rechnung wie
|
|
422
|
+
* im admin4: `(prev + next) / 2`, an den Rändern ±Schritt.
|
|
423
|
+
*
|
|
424
|
+
* `values` sind die Ordnungswerte der Zielliste OHNE die verschobene Zeile,
|
|
425
|
+
* aufsteigend; `index` ist die Einfügeposition (0 = ganz vorn, values.length =
|
|
426
|
+
* ganz hinten). Die Mitte zwischen den Nachbarn heißt: beim Verschieben muss
|
|
427
|
+
* genau EIN Datensatz gespeichert werden, nicht die ganze Liste.
|
|
428
|
+
*/
|
|
429
|
+
export function orderValueFor(values: number[], index: number, step = 1): OrderValue {
|
|
430
|
+
const clean = values.filter((value) => Number.isFinite(value))
|
|
431
|
+
const position = Math.max(0, Math.min(clean.length, index))
|
|
432
|
+
|
|
433
|
+
if (!clean.length) return { order: step, needsReindex: false }
|
|
434
|
+
if (position === 0) return { order: clean[0] - step, needsReindex: false }
|
|
435
|
+
if (position === clean.length) {
|
|
436
|
+
return { order: clean[clean.length - 1] + step, needsReindex: false }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const previous = clean[position - 1]
|
|
440
|
+
const next = clean[position]
|
|
441
|
+
const middle = previous + (next - previous) / 2
|
|
442
|
+
// Gleiche (oder nicht unterscheidbare) Nachbarn: die Mitte träfe einen von
|
|
443
|
+
// beiden — dann hinter dem Block einordnen, statt still nichts zu tun.
|
|
444
|
+
const needsReindex = middle <= previous || middle >= next
|
|
445
|
+
return { order: needsReindex ? previous + step : middle, needsReindex }
|
|
446
|
+
}
|