@raclettejs/core 0.1.32 → 0.1.33-canary.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +2 -2
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/services/backend/.yarn/install-state.gz +0 -0
- package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/routes/route.user.patchOwn.ts +1 -1
- package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/user.service.ts +22 -1
- package/services/backend/yarn.lock +1 -1
- package/services/frontend/.yarn/install-state.gz +0 -0
- package/services/frontend/package.json +2 -2
- package/services/frontend/src/core/lib/data/dataApi.ts +1 -2
- package/services/frontend/src/orchestrator/ProductOrchestrator.vue +8 -1
- package/services/frontend/src/orchestrator/assets/styles/layers.css +1 -4
- package/services/frontend/src/orchestrator/assets/styles/vuetifyStyles.scss +4 -1
- package/services/frontend/src/orchestrator/components/dataExport/DataExporter.vue +303 -0
- package/services/frontend/src/orchestrator/components/dataImport/ImportSelectionDialog.vue +169 -0
- package/services/frontend/src/orchestrator/components/dataImport/ImportSummaryDialog.vue +211 -0
- package/services/frontend/src/orchestrator/components/dataTable/BaseDataTable.vue +473 -0
- package/services/frontend/src/orchestrator/components/index.ts +10 -0
- package/services/frontend/src/orchestrator/components/input/FileUpload.vue +83 -0
- package/services/frontend/src/orchestrator/composables/index.ts +1 -0
- package/services/frontend/src/orchestrator/composables/useExport/formats/builtins.ts +124 -0
- package/services/frontend/src/orchestrator/composables/useExport/index.ts +176 -0
- package/services/frontend/src/orchestrator/composables/useExport/types.ts +141 -0
- package/services/frontend/src/orchestrator/i18n/de-DE.json +26 -1
- package/services/frontend/src/orchestrator/i18n/en-EU.json +26 -1
- package/services/frontend/src/orchestrator/i18n/sk.json +26 -1
- package/services/frontend/src/orchestrator/i18n/tl-TL.json +14 -0
- package/services/frontend/src/orchestrator/setup/vuetify.ts +0 -1
- package/services/frontend/yarn.lock +4854 -0
- package/types/index.ts +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="dialog" max-width="800" scrollable>
|
|
3
|
+
<v-card class="tw:overflow-hidden">
|
|
4
|
+
<v-card-title class="tw:flex tw:items-center tw:justify-between tw:gap-2">
|
|
5
|
+
<span class="tw:text-lg tw:font-semibold">{{ title }}</span>
|
|
6
|
+
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
|
|
7
|
+
</v-card-title>
|
|
8
|
+
|
|
9
|
+
<v-divider />
|
|
10
|
+
|
|
11
|
+
<v-card-text class="tw:py-4">
|
|
12
|
+
<v-alert :type="type" variant="tonal" class="tw:mb-4">
|
|
13
|
+
<div class="tw:text-base tw:font-medium tw:mb-2">
|
|
14
|
+
{{ message }}
|
|
15
|
+
</div>
|
|
16
|
+
<div class="tw:text-sm">
|
|
17
|
+
{{ $t("core.itemsCount") }}:
|
|
18
|
+
<strong>{{ itemCount }}</strong>
|
|
19
|
+
</div>
|
|
20
|
+
</v-alert>
|
|
21
|
+
|
|
22
|
+
<div v-if="showItems && renderedItems.length > 0">
|
|
23
|
+
<div class="tw:text-xs tw:uppercase tw:font-semibold tw:opacity-60 tw:mb-2">
|
|
24
|
+
{{ $t("core.selectedItems") }}
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<v-list density="compact" class="tw:border tw:rounded-md tw:py-0">
|
|
28
|
+
<v-list-item
|
|
29
|
+
v-for="item in renderedItems"
|
|
30
|
+
:key="item.key"
|
|
31
|
+
class="tw:py-2"
|
|
32
|
+
>
|
|
33
|
+
<template #title>
|
|
34
|
+
<span class="tw:text-sm tw:font-medium">{{ item.title }}</span>
|
|
35
|
+
</template>
|
|
36
|
+
|
|
37
|
+
<template #subtitle>
|
|
38
|
+
<pre
|
|
39
|
+
v-if="item.kind === 'pre'"
|
|
40
|
+
class="tw:mt-1 tw:text-xs tw:font-mono tw:p-2 tw:max-h-44 tw:overflow-auto tw:rounded tw:bg-black/5 dark:tw:bg-white/5 tw:whitespace-pre-wrap tw:break-all"
|
|
41
|
+
>{{ item.text }}</pre>
|
|
42
|
+
<div v-else class="tw:mt-1 tw:text-sm tw:opacity-80 tw:break-all">
|
|
43
|
+
{{ item.text }}
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
</v-list-item>
|
|
47
|
+
</v-list>
|
|
48
|
+
</div>
|
|
49
|
+
</v-card-text>
|
|
50
|
+
|
|
51
|
+
<v-divider />
|
|
52
|
+
|
|
53
|
+
<v-card-actions class="tw:px-4 tw:py-3">
|
|
54
|
+
<v-spacer />
|
|
55
|
+
<v-btn variant="text" @click="close">
|
|
56
|
+
{{ cancelText || $t("core.close") }}
|
|
57
|
+
</v-btn>
|
|
58
|
+
<v-btn
|
|
59
|
+
v-if="showConfirm"
|
|
60
|
+
:color="type === 'warning' ? 'warning' : type === 'error' ? 'error' : 'primary'"
|
|
61
|
+
variant="flat"
|
|
62
|
+
@click="confirm"
|
|
63
|
+
>
|
|
64
|
+
{{ confirmText }}
|
|
65
|
+
</v-btn>
|
|
66
|
+
</v-card-actions>
|
|
67
|
+
</v-card>
|
|
68
|
+
</v-dialog>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
import { computed } from "vue"
|
|
73
|
+
import { useI18n } from "vue-i18n"
|
|
74
|
+
|
|
75
|
+
interface Props {
|
|
76
|
+
modelValue: boolean
|
|
77
|
+
items: any[]
|
|
78
|
+
title: string
|
|
79
|
+
message: string
|
|
80
|
+
type?: "info" | "success" | "warning" | "error"
|
|
81
|
+
showItems?: boolean
|
|
82
|
+
showConfirm?: boolean
|
|
83
|
+
displayAttribute: string
|
|
84
|
+
confirmText?: string
|
|
85
|
+
cancelText?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
89
|
+
type: "info",
|
|
90
|
+
showItems: true,
|
|
91
|
+
showConfirm: false,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const emit = defineEmits<{
|
|
95
|
+
"update:modelValue": [value: boolean]
|
|
96
|
+
confirm: []
|
|
97
|
+
}>()
|
|
98
|
+
const { t } = useI18n()
|
|
99
|
+
|
|
100
|
+
const dialog = computed({
|
|
101
|
+
get: () => props.modelValue,
|
|
102
|
+
set: (value) => emit("update:modelValue", value),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const itemCount = computed(() => props.items.length)
|
|
106
|
+
|
|
107
|
+
const close = () => {
|
|
108
|
+
dialog.value = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
type SummaryRenderKind = "text" | "pre"
|
|
112
|
+
|
|
113
|
+
interface SummaryRenderItem {
|
|
114
|
+
key: string
|
|
115
|
+
title: string
|
|
116
|
+
kind: SummaryRenderKind
|
|
117
|
+
text: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface SummaryFormatter {
|
|
121
|
+
kind: SummaryRenderKind
|
|
122
|
+
canHandle: (value: unknown) => boolean
|
|
123
|
+
format: (value: unknown) => string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const formatAsText = (value: unknown): string => {
|
|
127
|
+
if (value === null || value === undefined) return "-"
|
|
128
|
+
return String(value)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const objectJsonFormatter: SummaryFormatter = {
|
|
132
|
+
kind: "pre",
|
|
133
|
+
canHandle: (value) =>
|
|
134
|
+
value !== null && typeof value === "object" && !Array.isArray(value),
|
|
135
|
+
format: (value) => JSON.stringify(value, null, 2),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const arrayJsonFormatter: SummaryFormatter = {
|
|
139
|
+
kind: "pre",
|
|
140
|
+
canHandle: (value) => Array.isArray(value),
|
|
141
|
+
format: (value) => JSON.stringify(value, null, 2),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const primitiveFormatter: SummaryFormatter = {
|
|
145
|
+
kind: "text",
|
|
146
|
+
canHandle: () => true,
|
|
147
|
+
format: (value) => formatAsText(value),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// TODO(summary-formatters): Add XML formatter when import/export surfaces XML payloads.
|
|
151
|
+
// TODO(summary-formatters): Add CSV formatter with row/column-aware preview rendering.
|
|
152
|
+
// TODO(summary-formatters): Add image formatter for URL/blob metadata and thumbnail previews.
|
|
153
|
+
const summaryFormatters: SummaryFormatter[] = [
|
|
154
|
+
objectJsonFormatter,
|
|
155
|
+
arrayJsonFormatter,
|
|
156
|
+
primitiveFormatter,
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
const resolveFormatter = (value: unknown): SummaryFormatter => {
|
|
160
|
+
return (
|
|
161
|
+
summaryFormatters.find((formatter) => formatter.canHandle(value)) ??
|
|
162
|
+
primitiveFormatter
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const formatDisplayTitle = (item: unknown, index: number): string => {
|
|
167
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
168
|
+
return `${t("core.selectedItems")} #${index + 1}`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const record = item as Record<string, unknown>
|
|
172
|
+
const attributeValue = record[props.displayAttribute]
|
|
173
|
+
if (attributeValue !== undefined && attributeValue !== null) {
|
|
174
|
+
return formatAsText(attributeValue)
|
|
175
|
+
}
|
|
176
|
+
if (record._id !== undefined && record._id !== null) {
|
|
177
|
+
return formatAsText(record._id)
|
|
178
|
+
}
|
|
179
|
+
return `${t("core.selectedItems")} #${index + 1}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const getDisplayPayload = (item: unknown): unknown => {
|
|
183
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
184
|
+
return item
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const record = item as Record<string, unknown>
|
|
188
|
+
const payload = { ...record }
|
|
189
|
+
delete payload[props.displayAttribute]
|
|
190
|
+
return payload
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const renderedItems = computed<SummaryRenderItem[]>(() =>
|
|
194
|
+
props.items.map((item, index) => {
|
|
195
|
+
const payload = getDisplayPayload(item)
|
|
196
|
+
const formatter = resolveFormatter(payload)
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
key: `${index}-${formatDisplayTitle(item, index)}`,
|
|
200
|
+
title: formatDisplayTitle(item, index),
|
|
201
|
+
kind: formatter.kind,
|
|
202
|
+
text: formatter.format(payload),
|
|
203
|
+
}
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const confirm = () => {
|
|
208
|
+
emit("confirm")
|
|
209
|
+
close()
|
|
210
|
+
}
|
|
211
|
+
</script>
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-sheet border rounded>
|
|
4
|
+
<v-data-table
|
|
5
|
+
v-model="selected"
|
|
6
|
+
v-model:search="search"
|
|
7
|
+
density="compact"
|
|
8
|
+
:items-per-page="itemsPerPage"
|
|
9
|
+
sticky
|
|
10
|
+
fixed-header
|
|
11
|
+
:headers="computedHeaders"
|
|
12
|
+
:items="items"
|
|
13
|
+
:loading="loading"
|
|
14
|
+
@click:row="handleRowClick"
|
|
15
|
+
:class="{ itemsDeleted }"
|
|
16
|
+
:show-select="showExport"
|
|
17
|
+
:item-value
|
|
18
|
+
>
|
|
19
|
+
<template #top>
|
|
20
|
+
<v-toolbar flat>
|
|
21
|
+
<v-toolbar-title v-if="dataName?.length">
|
|
22
|
+
<v-icon
|
|
23
|
+
color="medium-emphasis"
|
|
24
|
+
icon="mdi-book-multiple"
|
|
25
|
+
size="x-small"
|
|
26
|
+
start
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
{{ dataName }}
|
|
30
|
+
</v-toolbar-title>
|
|
31
|
+
|
|
32
|
+
<template #append>
|
|
33
|
+
<div
|
|
34
|
+
class="tw:flex tw:flex-wrap tw:gap-2 tw:items-center tw:justify-end"
|
|
35
|
+
>
|
|
36
|
+
<slot name="actions" />
|
|
37
|
+
<slot name="toolbar-leading" />
|
|
38
|
+
<FileUpload
|
|
39
|
+
v-if="onFileLoaded"
|
|
40
|
+
ref="importFileUploadRef"
|
|
41
|
+
:on-file-loaded="onFileLoaded"
|
|
42
|
+
:accept="importAccept"
|
|
43
|
+
:read-as="importReadAs"
|
|
44
|
+
/>
|
|
45
|
+
<v-btn-group density="compact" divided>
|
|
46
|
+
<DataExporter
|
|
47
|
+
v-if="showExport"
|
|
48
|
+
:data="exportPayloadData"
|
|
49
|
+
:disabled="!selected.length"
|
|
50
|
+
:formats="exportFormats"
|
|
51
|
+
:default-format="exportDefaultFormat"
|
|
52
|
+
:button-label="$t('core.export')"
|
|
53
|
+
color="info"
|
|
54
|
+
variant="elevated"
|
|
55
|
+
/>
|
|
56
|
+
<v-btn
|
|
57
|
+
v-if="onFileLoaded"
|
|
58
|
+
color="teal"
|
|
59
|
+
variant="elevated"
|
|
60
|
+
density="compact"
|
|
61
|
+
prepend-icon="mdi-database-import"
|
|
62
|
+
:text="$t('core.import')"
|
|
63
|
+
@click="importFileUploadRef?.openPicker()"
|
|
64
|
+
/>
|
|
65
|
+
<v-btn
|
|
66
|
+
v-if="showCreateButton"
|
|
67
|
+
@click="handleCreate"
|
|
68
|
+
prepend-icon="mdi-plus"
|
|
69
|
+
density="compact"
|
|
70
|
+
:text="
|
|
71
|
+
$t('core.baseDataTable.createDataButton', {
|
|
72
|
+
dataName,
|
|
73
|
+
})
|
|
74
|
+
"
|
|
75
|
+
variant="elevated"
|
|
76
|
+
color="success"
|
|
77
|
+
/>
|
|
78
|
+
</v-btn-group>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
</v-toolbar>
|
|
82
|
+
|
|
83
|
+
<v-text-field
|
|
84
|
+
v-model="search"
|
|
85
|
+
:label="
|
|
86
|
+
$t('core.baseDataTable.searchPlaceholder', {
|
|
87
|
+
dataName,
|
|
88
|
+
})
|
|
89
|
+
"
|
|
90
|
+
prepend-inner-icon="mdi-magnify"
|
|
91
|
+
variant="outlined"
|
|
92
|
+
hide-details
|
|
93
|
+
single-line
|
|
94
|
+
density="compact"
|
|
95
|
+
/>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<template
|
|
99
|
+
#item.color="{ item }"
|
|
100
|
+
v-if="!customSlots.includes('item.color')"
|
|
101
|
+
>
|
|
102
|
+
<div class="tw:flex tw:gap-2" v-if="item.color?.length">
|
|
103
|
+
<v-chip :color="item.color" label variant="outlined" size="small">
|
|
104
|
+
{{ item.color }}
|
|
105
|
+
</v-chip>
|
|
106
|
+
</div>
|
|
107
|
+
</template>
|
|
108
|
+
<!-- Built-in tags column -->
|
|
109
|
+
<template
|
|
110
|
+
#item.tags="{ item }"
|
|
111
|
+
v-if="!customSlots.includes('item.tags')"
|
|
112
|
+
>
|
|
113
|
+
<div class="tw:flex tw:gap-2" v-if="item.tags?.length">
|
|
114
|
+
<v-chip
|
|
115
|
+
v-for="tag in item.tags"
|
|
116
|
+
:key="tag._id || tag.id || tag"
|
|
117
|
+
:color="tag.color"
|
|
118
|
+
label
|
|
119
|
+
variant="outlined"
|
|
120
|
+
size="small"
|
|
121
|
+
>
|
|
122
|
+
{{ tag.title || tag.name || tag }}
|
|
123
|
+
</v-chip>
|
|
124
|
+
</div>
|
|
125
|
+
</template>
|
|
126
|
+
|
|
127
|
+
<!-- Dynamic slots for custom column rendering -->
|
|
128
|
+
<template v-for="slot in customSlots" :key="slot" #[slot]="slotProps">
|
|
129
|
+
<slot :name="slot" v-bind="slotProps" />
|
|
130
|
+
</template>
|
|
131
|
+
|
|
132
|
+
<!-- Actions column template -->
|
|
133
|
+
<template #item.actions="{ item }" v-if="showActionsColumn">
|
|
134
|
+
<slot name="prepend-row-actions" :item="item"> </slot>
|
|
135
|
+
<slot name="row-actions" :item="item">
|
|
136
|
+
<!-- Default delete action -->
|
|
137
|
+
<v-icon
|
|
138
|
+
v-if="showDeleteAction && !isDeleteDisabled(item)"
|
|
139
|
+
color="red"
|
|
140
|
+
@click.stop.prevent="handleDelete(item)"
|
|
141
|
+
class="mr-2"
|
|
142
|
+
:title="
|
|
143
|
+
$t('core.baseDataTable.deleteDataTitle', {
|
|
144
|
+
dataName,
|
|
145
|
+
})
|
|
146
|
+
"
|
|
147
|
+
icon="mdi-delete"
|
|
148
|
+
/>
|
|
149
|
+
</slot>
|
|
150
|
+
<slot name="append-row-actions" :item="item"> </slot>
|
|
151
|
+
</template>
|
|
152
|
+
|
|
153
|
+
<!-- Loading template -->
|
|
154
|
+
<template #loader>
|
|
155
|
+
<v-skeleton-loader
|
|
156
|
+
class="mx-auto border"
|
|
157
|
+
:height="500"
|
|
158
|
+
width="100%"
|
|
159
|
+
type="list-item"
|
|
160
|
+
:loading="true"
|
|
161
|
+
/>
|
|
162
|
+
</template>
|
|
163
|
+
|
|
164
|
+
<!-- Additional table slots (this caused the table to be empty) -->
|
|
165
|
+
<!-- <slot name="table-slots" /> -->
|
|
166
|
+
</v-data-table>
|
|
167
|
+
</v-sheet>
|
|
168
|
+
<!-- Delete Confirmation Dialog -->
|
|
169
|
+
<v-dialog
|
|
170
|
+
v-model="deleteDialog"
|
|
171
|
+
max-width="400"
|
|
172
|
+
v-if="showDeleteAction && itemToDelete"
|
|
173
|
+
>
|
|
174
|
+
<v-card>
|
|
175
|
+
<v-card-title class="text-h6">
|
|
176
|
+
{{
|
|
177
|
+
$t("core.baseDataTable.deleteDataTitle", {
|
|
178
|
+
dataName,
|
|
179
|
+
})
|
|
180
|
+
}}
|
|
181
|
+
</v-card-title>
|
|
182
|
+
|
|
183
|
+
<v-card-text
|
|
184
|
+
v-html="
|
|
185
|
+
$t('core.baseDataTable.deleteDataConfirmMessage', {
|
|
186
|
+
article: dataArticle,
|
|
187
|
+
dataName,
|
|
188
|
+
value:
|
|
189
|
+
itemToDelete.email ||
|
|
190
|
+
itemToDelete.title ||
|
|
191
|
+
itemToDelete.name ||
|
|
192
|
+
itemToDelete._id,
|
|
193
|
+
})
|
|
194
|
+
"
|
|
195
|
+
/>
|
|
196
|
+
<v-card-actions>
|
|
197
|
+
<v-spacer />
|
|
198
|
+
<v-btn variant="text" @click="deleteDialog = false">
|
|
199
|
+
{{ $t("core.baseDataTable.deleteDataConfirmCancel") }}
|
|
200
|
+
</v-btn>
|
|
201
|
+
<v-btn color="red" variant="flat" @click="confirmDelete">
|
|
202
|
+
{{ $t("core.baseDataTable.deleteDataConfirmButton") }}
|
|
203
|
+
</v-btn>
|
|
204
|
+
</v-card-actions>
|
|
205
|
+
</v-card>
|
|
206
|
+
</v-dialog>
|
|
207
|
+
|
|
208
|
+
<!-- Additional dialogs slot -->
|
|
209
|
+
<slot name="dialogs" />
|
|
210
|
+
</div>
|
|
211
|
+
</template>
|
|
212
|
+
|
|
213
|
+
<script setup lang="ts" generic="T extends Record<string, any>">
|
|
214
|
+
import { computed, ref, useSlots, useTemplateRef } from "vue"
|
|
215
|
+
import { useRouteState } from "@raclettejs/core/orchestrator/composables"
|
|
216
|
+
import DataExporter from "@raclettejs/core/orchestrator/components/dataExport/DataExporter.vue"
|
|
217
|
+
import FileUpload from "@raclettejs/core/orchestrator/components/input/FileUpload.vue"
|
|
218
|
+
import type {
|
|
219
|
+
ExportPayload,
|
|
220
|
+
FormatEntry,
|
|
221
|
+
} from "@racletteOrchestrator/composables/useExport/types"
|
|
222
|
+
|
|
223
|
+
export interface BaseDataTableProps<T> {
|
|
224
|
+
uuid?: string
|
|
225
|
+
// Data props
|
|
226
|
+
items?: T[]
|
|
227
|
+
loading?: boolean
|
|
228
|
+
itemsDeleted?: boolean
|
|
229
|
+
// Table configuration
|
|
230
|
+
headers: Array<{
|
|
231
|
+
title: string
|
|
232
|
+
key: keyof T | string
|
|
233
|
+
align?: "start" | "center" | "end"
|
|
234
|
+
sortable?: boolean
|
|
235
|
+
width?: string | number
|
|
236
|
+
}>
|
|
237
|
+
itemsPerPage?: number
|
|
238
|
+
|
|
239
|
+
// Create button
|
|
240
|
+
showCreateButton?: boolean
|
|
241
|
+
createInteractionLinkId?: string
|
|
242
|
+
|
|
243
|
+
// Row actions
|
|
244
|
+
showActionsColumn?: boolean
|
|
245
|
+
showDeleteAction?: boolean
|
|
246
|
+
|
|
247
|
+
// Navigation
|
|
248
|
+
editInteractionLinkId?: string
|
|
249
|
+
|
|
250
|
+
// Item processing
|
|
251
|
+
deleteValidator?: (item: T) => boolean
|
|
252
|
+
rowClickHandler?: (item: T) => void
|
|
253
|
+
|
|
254
|
+
dataName: string
|
|
255
|
+
dataArticle: string
|
|
256
|
+
|
|
257
|
+
showExport?: boolean
|
|
258
|
+
|
|
259
|
+
itemValue?: string
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* When set, exported rows come from here (matched to table selection via `itemValue`, default `_id`).
|
|
263
|
+
* Use this to omit display-only fields (e.g. relative dates) from JSON export while keeping table `items`.
|
|
264
|
+
*/
|
|
265
|
+
exportableItems?: Record<string, unknown>[]
|
|
266
|
+
exportMeta?: Record<string, unknown>
|
|
267
|
+
|
|
268
|
+
/** Formats offered in the export menu (workbench list tables default to JSON only). */
|
|
269
|
+
exportFormats?: FormatEntry[]
|
|
270
|
+
exportDefaultFormat?: string
|
|
271
|
+
|
|
272
|
+
/** When set, an import control is shown and the handler receives file contents (same contract as FileUpload). */
|
|
273
|
+
onFileLoaded?: (content: string | ArrayBuffer, file: File) => void
|
|
274
|
+
importAccept?: string
|
|
275
|
+
importReadAs?: "text" | "arrayBuffer"
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const props = withDefaults(defineProps<BaseDataTableProps<T>>(), {
|
|
279
|
+
uuid: undefined,
|
|
280
|
+
items: () => [],
|
|
281
|
+
itemsDeleted: false,
|
|
282
|
+
itemsPerPage: 50,
|
|
283
|
+
showCreateButton: true,
|
|
284
|
+
showActionsColumn: true,
|
|
285
|
+
showDeleteAction: true,
|
|
286
|
+
showExport: true,
|
|
287
|
+
itemValue: "_id",
|
|
288
|
+
exportableItems: undefined,
|
|
289
|
+
exportMeta: undefined,
|
|
290
|
+
exportFormats: () => ["json"] as FormatEntry[],
|
|
291
|
+
exportDefaultFormat: "json",
|
|
292
|
+
onFileLoaded: undefined,
|
|
293
|
+
importAccept: ".json",
|
|
294
|
+
importReadAs: "text",
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const emit = defineEmits<{
|
|
298
|
+
create: []
|
|
299
|
+
edit: [item: T]
|
|
300
|
+
delete: [item: T]
|
|
301
|
+
rowClick: [item: T]
|
|
302
|
+
}>()
|
|
303
|
+
|
|
304
|
+
const slots = useSlots()
|
|
305
|
+
const { triggerInteractionLinkById } = useRouteState()
|
|
306
|
+
const importFileUploadRef = useTemplateRef<{
|
|
307
|
+
openPicker: () => void
|
|
308
|
+
}>("importFileUploadRef")
|
|
309
|
+
|
|
310
|
+
// Reactive data
|
|
311
|
+
const search = ref("")
|
|
312
|
+
const selected = ref<Array<string | number>>([])
|
|
313
|
+
const deleteDialog = ref(false)
|
|
314
|
+
const itemToDelete = ref<T | null>(null)
|
|
315
|
+
|
|
316
|
+
const computedHeaders = computed(() => {
|
|
317
|
+
if (!props.headers || !props.headers?.length) {
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const headers = [...props.headers]
|
|
322
|
+
|
|
323
|
+
// Add actions column if needed
|
|
324
|
+
if (props.showActionsColumn) {
|
|
325
|
+
headers.push({
|
|
326
|
+
title: "Actions",
|
|
327
|
+
key: "actions",
|
|
328
|
+
align: "end" as const,
|
|
329
|
+
sortable: false,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return headers
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const customSlots = computed(() => {
|
|
337
|
+
return Object.keys(slots).filter(
|
|
338
|
+
(slot) => slot.startsWith("item.") && slot !== "item.actions",
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const exportColumnKeys = computed(() => {
|
|
343
|
+
return (props.headers ?? [])
|
|
344
|
+
.map((header) => String(header.key).trim())
|
|
345
|
+
.filter((key) => key.length > 0 && key !== "actions")
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const projectRowsToVisibleColumns = <TRow extends Record<string, unknown>>(
|
|
349
|
+
rows: TRow[],
|
|
350
|
+
) => {
|
|
351
|
+
if (!exportColumnKeys.value.length) {
|
|
352
|
+
return rows
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return rows.map((row) => {
|
|
356
|
+
const projectedRow: Record<string, unknown> = {}
|
|
357
|
+
for (const key of exportColumnKeys.value) {
|
|
358
|
+
if (key in row) {
|
|
359
|
+
projectedRow[key] = row[key]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return projectedRow
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Rows passed to DataExporter when something is selected. */
|
|
367
|
+
const exportPayloadData = computed<ExportPayload>(() => {
|
|
368
|
+
const idKey = props.itemValue as keyof T
|
|
369
|
+
const selectedIds = selected.value
|
|
370
|
+
const toKey = (value: unknown) => String(value)
|
|
371
|
+
|
|
372
|
+
if (props.exportableItems != null) {
|
|
373
|
+
const idToRow = new Map<string, Record<string, unknown>>()
|
|
374
|
+
|
|
375
|
+
for (const row of props.exportableItems) {
|
|
376
|
+
const rawKey = row[idKey as string]
|
|
377
|
+
if (rawKey === undefined || rawKey === null) {
|
|
378
|
+
console.warn(
|
|
379
|
+
`[BaseDataTable] Export row is missing "${String(idKey)}" key and will be ignored.`,
|
|
380
|
+
row,
|
|
381
|
+
)
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const rowKey = toKey(rawKey)
|
|
386
|
+
if (idToRow.has(rowKey)) {
|
|
387
|
+
console.warn(
|
|
388
|
+
`[BaseDataTable] Duplicate export key "${rowKey}" for itemValue "${String(idKey)}".`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
idToRow.set(rowKey, row)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const selectedExportRows = selectedIds
|
|
395
|
+
.map((id) => idToRow.get(toKey(id)))
|
|
396
|
+
.filter((row): row is Record<string, unknown> => row !== undefined)
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
items: projectRowsToVisibleColumns(selectedExportRows),
|
|
400
|
+
...(props.exportMeta ? { meta: props.exportMeta } : {}),
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const selectedKeySet = new Set(selectedIds.map((id) => toKey(id)))
|
|
405
|
+
const selectedRows = (props.items ?? []).filter((item) => {
|
|
406
|
+
const rowId = item[idKey]
|
|
407
|
+
if (rowId === undefined || rowId === null) {
|
|
408
|
+
console.warn(
|
|
409
|
+
`[BaseDataTable] Table item is missing "${String(idKey)}" key and will not be exportable.`,
|
|
410
|
+
item,
|
|
411
|
+
)
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
return selectedKeySet.has(toKey(rowId))
|
|
415
|
+
}) as Record<string, unknown>[]
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
items: projectRowsToVisibleColumns(selectedRows),
|
|
419
|
+
...(props.exportMeta ? { meta: props.exportMeta } : {}),
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Methods
|
|
424
|
+
const handleCreate = () => {
|
|
425
|
+
if (props.createInteractionLinkId) {
|
|
426
|
+
triggerInteractionLinkById(props.createInteractionLinkId)
|
|
427
|
+
}
|
|
428
|
+
emit("create")
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const handleRowClick = (_: PointerEvent, row: { item: T }) => {
|
|
432
|
+
if (props.rowClickHandler) {
|
|
433
|
+
props.rowClickHandler(row.item)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (props.editInteractionLinkId) {
|
|
438
|
+
const item = row.item as any
|
|
439
|
+
|
|
440
|
+
triggerInteractionLinkById(props.editInteractionLinkId, {
|
|
441
|
+
id: item._id,
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
emit("edit", row.item)
|
|
446
|
+
emit("rowClick", row.item)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const handleDelete = (item: T) => {
|
|
450
|
+
itemToDelete.value = item
|
|
451
|
+
deleteDialog.value = true
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const confirmDelete = () => {
|
|
455
|
+
if (itemToDelete.value) {
|
|
456
|
+
emit("delete", itemToDelete.value)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
itemToDelete.value = null
|
|
460
|
+
deleteDialog.value = false
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const isDeleteDisabled = (item: T): boolean => {
|
|
464
|
+
return props.deleteValidator ? !props.deleteValidator(item) : false
|
|
465
|
+
}
|
|
466
|
+
</script>
|
|
467
|
+
|
|
468
|
+
<style lang="css">
|
|
469
|
+
.itemsDeleted .v-data-table__tr {
|
|
470
|
+
background-color: rgb(233, 190, 190);
|
|
471
|
+
color: #000;
|
|
472
|
+
}
|
|
473
|
+
</style>
|
|
@@ -14,9 +14,19 @@ import UserChip from "./chip/UserChip.vue"
|
|
|
14
14
|
import UserMenu from "./menu/UserMenu.vue"
|
|
15
15
|
import UserNotifications from "./menu/UserNotifications.vue"
|
|
16
16
|
import WidgetsLayoutLoader from "./composition/WidgetsLayoutLoader.vue"
|
|
17
|
+
import DataExporter from "./dataExport/DataExporter.vue"
|
|
18
|
+
import ImportSelectionDialog from "./dataImport/ImportSelectionDialog.vue"
|
|
19
|
+
import ImportSummaryDialog from "./dataImport/ImportSummaryDialog.vue"
|
|
20
|
+
import BaseDataTable from "./dataTable/BaseDataTable.vue"
|
|
21
|
+
import FileUpload from "./input/FileUpload.vue"
|
|
17
22
|
|
|
18
23
|
export {
|
|
24
|
+
ImportSelectionDialog,
|
|
25
|
+
ImportSummaryDialog,
|
|
26
|
+
BaseDataTable,
|
|
27
|
+
FileUpload,
|
|
19
28
|
CompositionOverlay,
|
|
29
|
+
DataExporter,
|
|
20
30
|
DefaultModal,
|
|
21
31
|
DraggableElement,
|
|
22
32
|
DropzoneElement,
|