@xleddyl/nuxt-cms 0.4.2 → 0.4.4
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 +74 -96
- package/dist/module.json +1 -1
- package/dist/module.mjs +84 -16
- package/dist/runtime/app/components/cms/BlocksField.vue +30 -5
- package/dist/runtime/app/components/cms/DropdownMenu.vue +1 -5
- package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/EntryDrawer.vue +32 -13
- package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +20 -3
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +2 -2
- package/dist/runtime/app/components/cms/Form.d.vue.ts +8 -6
- package/dist/runtime/app/components/cms/Form.vue +26 -5
- package/dist/runtime/app/components/cms/Form.vue.d.ts +8 -6
- package/dist/runtime/app/components/cms/FormField.vue +6 -1
- package/dist/runtime/app/components/cms/ImportModal.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ImportModal.vue +252 -0
- package/dist/runtime/app/components/cms/ImportModal.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/ListFilters.vue +151 -0
- package/dist/runtime/app/components/cms/ListFilters.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/RelationField.vue +3 -1
- package/dist/runtime/app/components/cms/VisualEditor.vue +81 -12
- package/dist/runtime/app/pages/admin-collection.vue +158 -7
- package/dist/runtime/app/pages/admin-content.vue +18 -7
- package/dist/runtime/app/pages/admin-entry.vue +1 -0
- package/dist/runtime/app/pages/admin-preview.vue +6 -3
- package/dist/runtime/app/preview/PreviewOverlay.vue +4 -0
- package/dist/runtime/app/utils/ui.d.ts +2 -1
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection-export.get.d.ts +2 -0
- package/dist/runtime/server/api/collection-export.get.js +67 -0
- package/dist/runtime/server/api/collection-import.post.d.ts +2 -0
- package/dist/runtime/server/api/collection-import.post.js +85 -0
- package/dist/runtime/server/api/collection.get.js +7 -15
- package/dist/runtime/server/utils/import.d.ts +31 -0
- package/dist/runtime/server/utils/import.js +202 -0
- package/dist/runtime/server/utils/list-query.d.ts +15 -0
- package/dist/runtime/server/utils/list-query.js +54 -0
- package/dist/runtime/server/utils/media-check.d.ts +2 -0
- package/dist/runtime/server/utils/media-check.js +1 -1
- package/dist/runtime/server/utils/media-references.js +4 -1
- package/dist/runtime/server/utils/registry.js +1 -1
- package/dist/runtime/server/utils/search.js +23 -4
- package/dist/runtime/shared/duplicate.d.ts +10 -0
- package/dist/runtime/shared/duplicate.js +54 -0
- package/dist/runtime/shared/index.d.ts +49 -2
- package/dist/runtime/shared/index.js +33 -0
- package/dist/runtime/shared/layout.d.ts +3 -0
- package/dist/runtime/shared/layout.js +35 -2
- package/dist/runtime/shared/list-filters.d.ts +20 -0
- package/dist/runtime/shared/list-filters.js +83 -0
- package/dist/runtime/shared/transfer.d.ts +70 -0
- package/dist/runtime/shared/transfer.js +256 -0
- package/dist/runtime/shared/validation.d.ts +15 -2
- package/dist/runtime/shared/validation.js +102 -9
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="cms-media-filters cms-list-filters">
|
|
3
|
+
<template v-for="key in keys" :key="key">
|
|
4
|
+
<CmsDropdownMenu v-if="optionsOf(key)" :items="menuItems(key)">
|
|
5
|
+
<button
|
|
6
|
+
type="button"
|
|
7
|
+
class="cms-pill"
|
|
8
|
+
:class="{ 'is-active': isActive(key) }"
|
|
9
|
+
aria-haspopup="menu"
|
|
10
|
+
>
|
|
11
|
+
{{ pillLabel(key) }}
|
|
12
|
+
<CmsIcon name="chevron-down" class="size-3" />
|
|
13
|
+
</button>
|
|
14
|
+
</CmsDropdownMenu>
|
|
15
|
+
<CmsPopover
|
|
16
|
+
v-else
|
|
17
|
+
:open="openKey === key"
|
|
18
|
+
class="cms-list-filter"
|
|
19
|
+
@update:open="(value) => openKey = value ? key : null"
|
|
20
|
+
>
|
|
21
|
+
<button
|
|
22
|
+
type="button"
|
|
23
|
+
class="cms-pill"
|
|
24
|
+
:class="{ 'is-active': isActive(key) }"
|
|
25
|
+
aria-haspopup="dialog"
|
|
26
|
+
>
|
|
27
|
+
{{ pillLabel(key) }}
|
|
28
|
+
<CmsIcon name="chevron-down" class="size-3" />
|
|
29
|
+
</button>
|
|
30
|
+
<template #content>
|
|
31
|
+
<div class="cms-list-filter-panel" role="dialog" :aria-label="labelOf(key)">
|
|
32
|
+
<CmsRelationField
|
|
33
|
+
:model-value="model[key] ?? null"
|
|
34
|
+
:to="config.fields[key].to"
|
|
35
|
+
@update:model-value="(value) => pickRelation(key, value)"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
</CmsPopover>
|
|
40
|
+
</template>
|
|
41
|
+
<CmsButton
|
|
42
|
+
v-if="activeCount"
|
|
43
|
+
label="Clear filters"
|
|
44
|
+
icon="x-mark"
|
|
45
|
+
size="xs"
|
|
46
|
+
variant="ghost"
|
|
47
|
+
color="neutral"
|
|
48
|
+
@click="model = {}"
|
|
49
|
+
/>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup>
|
|
54
|
+
import { isTranslatableField, selectDisplayValue } from "#nuxt-cms";
|
|
55
|
+
import { computed, reactive, ref, watch } from "#imports";
|
|
56
|
+
import cmsConfig from "#cms-config";
|
|
57
|
+
import {
|
|
58
|
+
listFilterLabel,
|
|
59
|
+
listFilterOptions,
|
|
60
|
+
listFilterValueLabel
|
|
61
|
+
} from "../../../shared/list-filters";
|
|
62
|
+
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
63
|
+
import { cmsApi } from "../../utils/api";
|
|
64
|
+
const props = defineProps({
|
|
65
|
+
config: { type: Object, required: true },
|
|
66
|
+
keys: { type: Array, required: true },
|
|
67
|
+
relations: { type: Object, required: false }
|
|
68
|
+
});
|
|
69
|
+
const model = defineModel({ type: Object, ...{ required: true } });
|
|
70
|
+
const { i18n } = useCmsRuntime();
|
|
71
|
+
const openKey = ref(null);
|
|
72
|
+
const fetchedTitles = reactive({});
|
|
73
|
+
const activeCount = computed(() => Object.values(model.value).filter(Boolean).length);
|
|
74
|
+
function optionsOf(key) {
|
|
75
|
+
return listFilterOptions(props.config, key);
|
|
76
|
+
}
|
|
77
|
+
function labelOf(key) {
|
|
78
|
+
return listFilterLabel(props.config, key);
|
|
79
|
+
}
|
|
80
|
+
function isActive(key) {
|
|
81
|
+
return !!model.value[key];
|
|
82
|
+
}
|
|
83
|
+
function set(key, value) {
|
|
84
|
+
const next = { ...model.value };
|
|
85
|
+
if (value) next[key] = value;
|
|
86
|
+
else delete next[key];
|
|
87
|
+
model.value = next;
|
|
88
|
+
}
|
|
89
|
+
function menuItems(key) {
|
|
90
|
+
const current = model.value[key];
|
|
91
|
+
return [
|
|
92
|
+
{ label: "Any", checked: !current, onSelect: () => set(key, null) },
|
|
93
|
+
...(optionsOf(key) ?? []).map((option) => ({
|
|
94
|
+
label: option.label,
|
|
95
|
+
checked: current === option.value,
|
|
96
|
+
onSelect: () => set(key, option.value)
|
|
97
|
+
}))
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
function pickRelation(key, value) {
|
|
101
|
+
set(key, typeof value === "string" ? value : null);
|
|
102
|
+
openKey.value = null;
|
|
103
|
+
}
|
|
104
|
+
function titleText(entry, value) {
|
|
105
|
+
const field = entry?.titleField ? entry.fields[entry.titleField] : void 0;
|
|
106
|
+
if (value == null || value === "") return "";
|
|
107
|
+
if (field && isTranslatableField(field) && typeof value === "object") {
|
|
108
|
+
const record = value;
|
|
109
|
+
return record[i18n.defaultLocale] ?? Object.values(record).find(Boolean) ?? "";
|
|
110
|
+
}
|
|
111
|
+
if (field?.type === "select") return selectDisplayValue(field, value);
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
function relationTitle(key, id) {
|
|
115
|
+
const target = cmsConfig[props.config.fields[key]?.to ?? ""];
|
|
116
|
+
const known = props.relations?.[key]?.[id];
|
|
117
|
+
const title = known !== void 0 ? titleText(target, known) : fetchedTitles[`${key}:${id}`];
|
|
118
|
+
return title || `#${id}`;
|
|
119
|
+
}
|
|
120
|
+
async function resolveTitle(key, id) {
|
|
121
|
+
if (import.meta.server) return;
|
|
122
|
+
const cacheKey = `${key}:${id}`;
|
|
123
|
+
if (props.relations?.[key]?.[id] !== void 0 || fetchedTitles[cacheKey] !== void 0) return;
|
|
124
|
+
const to = props.config.fields[key]?.to;
|
|
125
|
+
if (!to) return;
|
|
126
|
+
fetchedTitles[cacheKey] = "";
|
|
127
|
+
try {
|
|
128
|
+
const row = await cmsApi(`/api/cms/admin/${to}/${id}`);
|
|
129
|
+
const target = cmsConfig[to];
|
|
130
|
+
fetchedTitles[cacheKey] = titleText(target, target?.titleField ? row[target.titleField] : "");
|
|
131
|
+
} catch {
|
|
132
|
+
fetchedTitles[cacheKey] = "";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
watch(
|
|
136
|
+
model,
|
|
137
|
+
(filters) => {
|
|
138
|
+
for (const key of props.keys) {
|
|
139
|
+
const value = filters[key];
|
|
140
|
+
if (value && !optionsOf(key)) void resolveTitle(key, value);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{ immediate: true }
|
|
144
|
+
);
|
|
145
|
+
function pillLabel(key) {
|
|
146
|
+
const value = model.value[key];
|
|
147
|
+
if (!value) return labelOf(key);
|
|
148
|
+
const shown = optionsOf(key) ? listFilterValueLabel(props.config, key, value) : relationTitle(key, value);
|
|
149
|
+
return `${labelOf(key)}: ${shown}`;
|
|
150
|
+
}
|
|
151
|
+
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CmsEntry } from '#nuxt-cms';
|
|
2
|
+
import type { CmsListFilters } from '../../../shared/list-filters.js';
|
|
3
|
+
type __VLS_Props = {
|
|
4
|
+
config: CmsEntry;
|
|
5
|
+
keys: string[];
|
|
6
|
+
relations?: Record<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
type __VLS_ModelProps = {
|
|
9
|
+
modelValue: CmsListFilters;
|
|
10
|
+
};
|
|
11
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
12
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
13
|
+
"update:modelValue": (value: CmsListFilters) => any;
|
|
14
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
15
|
+
"onUpdate:modelValue"?: ((value: CmsListFilters) => any) | undefined;
|
|
16
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: typeof __VLS_export;
|
|
18
|
+
export default _default;
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
</template>
|
|
22
22
|
|
|
23
23
|
<script setup>
|
|
24
|
-
import { isTranslatableField } from "#nuxt-cms";
|
|
24
|
+
import { isTranslatableField, selectDisplayValue } from "#nuxt-cms";
|
|
25
25
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "#imports";
|
|
26
26
|
import cmsConfig from "#cms-config";
|
|
27
27
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
@@ -98,6 +98,8 @@ function optionLabel(row) {
|
|
|
98
98
|
const raw = record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
|
|
99
99
|
return raw || `#${row.id}`;
|
|
100
100
|
}
|
|
101
|
+
if (value != null && titleFieldConfig?.type === "select")
|
|
102
|
+
return selectDisplayValue(titleFieldConfig, value) || `#${row.id}`;
|
|
101
103
|
return String(value ?? `#${row.id}`);
|
|
102
104
|
}
|
|
103
105
|
const options = computed(() => {
|
|
@@ -80,6 +80,10 @@
|
|
|
80
80
|
role="tabpanel"
|
|
81
81
|
:aria-labelledby="tabList.length > 1 ? `cms-editor-tab-${activeTab}` : void 0"
|
|
82
82
|
>
|
|
83
|
+
<CmsAlert v-if="errors[ROOT_KEY]" color="error">{{ errors[ROOT_KEY] }}</CmsAlert>
|
|
84
|
+
<CmsAlert v-for="message in rootWarnings" :key="message" color="warning">
|
|
85
|
+
{{ message }}
|
|
86
|
+
</CmsAlert>
|
|
83
87
|
<template v-if="activeTab === 'block'">
|
|
84
88
|
<template v-if="selectedItem && selected !== null">
|
|
85
89
|
<div class="cms-editor-block-head">
|
|
@@ -129,6 +133,7 @@
|
|
|
129
133
|
variant="soft"
|
|
130
134
|
color="neutral"
|
|
131
135
|
label="Duplicate"
|
|
136
|
+
:disabled="!canAdd"
|
|
132
137
|
@click="duplicate(selected)"
|
|
133
138
|
/>
|
|
134
139
|
<CmsButton
|
|
@@ -150,6 +155,14 @@
|
|
|
150
155
|
@click="remove(selected)"
|
|
151
156
|
/>
|
|
152
157
|
</div>
|
|
158
|
+
<p v-if="errors[`${fieldKey}.${selected}`]" class="cms-form-error">
|
|
159
|
+
<CmsIcon name="exclamation-circle" class="size-3.5 shrink-0" />
|
|
160
|
+
{{ errors[`${fieldKey}.${selected}`] }}
|
|
161
|
+
</p>
|
|
162
|
+
<p v-else-if="warnings[`${fieldKey}.${selected}`]" class="cms-form-warning">
|
|
163
|
+
<CmsIcon name="exclamation-triangle" class="size-3.5 shrink-0" />
|
|
164
|
+
{{ warnings[`${fieldKey}.${selected}`] }}
|
|
165
|
+
</p>
|
|
153
166
|
<div class="cms-editor-fields">
|
|
154
167
|
<CmsFormField
|
|
155
168
|
v-for="(blockField, blockKey) in selectedBlock?.fields ?? {}"
|
|
@@ -177,6 +190,10 @@
|
|
|
177
190
|
<CmsIcon name="exclamation-circle" class="size-3.5 shrink-0" />
|
|
178
191
|
{{ errors[fieldKey] }}
|
|
179
192
|
</p>
|
|
193
|
+
<p v-else-if="warnings[fieldKey]" class="cms-form-warning">
|
|
194
|
+
<CmsIcon name="exclamation-triangle" class="size-3.5 shrink-0" />
|
|
195
|
+
{{ warnings[fieldKey] }}
|
|
196
|
+
</p>
|
|
180
197
|
<div v-if="blocks.length" class="cms-editor-section">
|
|
181
198
|
<span class="cms-label">{{ blocksField.label }}</span>
|
|
182
199
|
<CmsBlockOutline
|
|
@@ -188,13 +205,14 @@
|
|
|
188
205
|
@move="move"
|
|
189
206
|
/>
|
|
190
207
|
</div>
|
|
191
|
-
<div class="cms-editor-section">
|
|
208
|
+
<div v-if="canAdd" class="cms-editor-section">
|
|
192
209
|
<span class="cms-label">Add a block</span>
|
|
193
210
|
<CmsBlockPalette
|
|
194
211
|
:blocks="blocksField.blocks ?? {}"
|
|
195
212
|
@pick="(type) => insert(blocks.length, type)"
|
|
196
213
|
/>
|
|
197
214
|
</div>
|
|
215
|
+
<p v-else class="cms-form-description">{{ maxReached }}</p>
|
|
198
216
|
</template>
|
|
199
217
|
</template>
|
|
200
218
|
|
|
@@ -240,7 +258,13 @@
|
|
|
240
258
|
</template>
|
|
241
259
|
|
|
242
260
|
<script setup>
|
|
243
|
-
import {
|
|
261
|
+
import {
|
|
262
|
+
canAddBlock,
|
|
263
|
+
isFieldVisible,
|
|
264
|
+
isHiddenBlock,
|
|
265
|
+
isTranslatableField,
|
|
266
|
+
slugSourceValue
|
|
267
|
+
} from "#nuxt-cms";
|
|
244
268
|
import {
|
|
245
269
|
computed,
|
|
246
270
|
onBeforeUnmount,
|
|
@@ -251,7 +275,8 @@ import {
|
|
|
251
275
|
shallowRef,
|
|
252
276
|
watch
|
|
253
277
|
} from "#imports";
|
|
254
|
-
import
|
|
278
|
+
import cmsConfig from "#cms-config";
|
|
279
|
+
import { buildEntrySchema, entryValidationWarnings } from "../../../shared/validation";
|
|
255
280
|
import {
|
|
256
281
|
DEFAULT_CMS_PREVIEW_PATH,
|
|
257
282
|
canHideBlock,
|
|
@@ -273,7 +298,8 @@ import { useCmsConfirm } from "../../composables/cms-confirm";
|
|
|
273
298
|
import { useCmsFieldFocus } from "../../composables/cms-field-focus";
|
|
274
299
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
275
300
|
import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
|
|
276
|
-
import { CMS_FORM_ERRORS, fieldIcon } from "../../utils/ui";
|
|
301
|
+
import { CMS_FORM_ERRORS, CMS_FORM_WARNINGS, fieldIcon } from "../../utils/ui";
|
|
302
|
+
const ROOT_KEY = "";
|
|
277
303
|
const SEO_KEYS = ["seoTitle", "seoDescription", "seoImage"];
|
|
278
304
|
const DEVICES = [
|
|
279
305
|
{ id: "desktop", label: "Desktop width", icon: "computer-desktop" },
|
|
@@ -305,6 +331,11 @@ const blocks = computed(() => {
|
|
|
305
331
|
const value = state.value[props.fieldKey];
|
|
306
332
|
return Array.isArray(value) ? value : [];
|
|
307
333
|
});
|
|
334
|
+
const canAdd = computed(() => canAddBlock(blocksField.value, blocks.value.length));
|
|
335
|
+
const maxReached = computed(() => {
|
|
336
|
+
const max = blocksField.value.max ?? 0;
|
|
337
|
+
return `This field holds at most ${max} ${max === 1 ? "block" : "blocks"}.`;
|
|
338
|
+
});
|
|
308
339
|
const selectedItem = computed(
|
|
309
340
|
() => selected.value === null ? void 0 : blocks.value[selected.value]
|
|
310
341
|
);
|
|
@@ -366,7 +397,7 @@ function blockConfigAt(index) {
|
|
|
366
397
|
}
|
|
367
398
|
function insert(index, type) {
|
|
368
399
|
const block = blocksField.value.blocks?.[type];
|
|
369
|
-
if (!block) return;
|
|
400
|
+
if (!block || !canAdd.value) return;
|
|
370
401
|
const at = Math.min(Math.max(index, 0), blocks.value.length);
|
|
371
402
|
setBlocks(insertBlock(blocks.value, at, emptyBlock(type, block)));
|
|
372
403
|
select(at);
|
|
@@ -376,6 +407,7 @@ function insertFromPalette(type) {
|
|
|
376
407
|
insert(paletteIndex.value, type);
|
|
377
408
|
}
|
|
378
409
|
function openPalette(index) {
|
|
410
|
+
if (!canAdd.value) return;
|
|
379
411
|
paletteIndex.value = index;
|
|
380
412
|
paletteOpen.value = true;
|
|
381
413
|
}
|
|
@@ -389,6 +421,7 @@ function moveSelected(delta) {
|
|
|
389
421
|
move(selected.value, selected.value + delta);
|
|
390
422
|
}
|
|
391
423
|
function duplicate(index) {
|
|
424
|
+
if (!canAdd.value) return;
|
|
392
425
|
setBlocks(duplicateBlock(blocks.value, index));
|
|
393
426
|
select(index + 1);
|
|
394
427
|
}
|
|
@@ -494,30 +527,66 @@ onBeforeUnmount(() => {
|
|
|
494
527
|
});
|
|
495
528
|
const errors = reactive({});
|
|
496
529
|
provide(CMS_FORM_ERRORS, errors);
|
|
530
|
+
const validateHook = computed(
|
|
531
|
+
() => props.content ? cmsConfig[props.entryName]?.validate : void 0
|
|
532
|
+
);
|
|
497
533
|
const schema = computed(
|
|
498
|
-
() => buildEntrySchema(
|
|
534
|
+
() => buildEntrySchema(
|
|
535
|
+
{ fields: props.fields, drafts: props.content, validate: validateHook.value },
|
|
536
|
+
i18n
|
|
537
|
+
)
|
|
499
538
|
);
|
|
500
539
|
let submitted = false;
|
|
540
|
+
function issueKeys(path) {
|
|
541
|
+
const [key, index, blockKey] = path;
|
|
542
|
+
if (key === void 0) return [ROOT_KEY];
|
|
543
|
+
const keys = [String(key)];
|
|
544
|
+
if (String(key) === props.fieldKey && typeof index === "number")
|
|
545
|
+
keys.push(
|
|
546
|
+
blockKey === void 0 ? `${String(key)}.${index}` : `${String(key)}.${index}.${String(blockKey)}`
|
|
547
|
+
);
|
|
548
|
+
return keys;
|
|
549
|
+
}
|
|
501
550
|
function collectErrors() {
|
|
502
551
|
for (const key of Object.keys(errors)) delete errors[key];
|
|
503
552
|
const result = schema.value.safeParse(state.value);
|
|
504
553
|
if (result.success) return [];
|
|
505
554
|
const issues = result.error.issues;
|
|
506
555
|
for (const issue of issues) {
|
|
507
|
-
const
|
|
508
|
-
if (key === void 0) continue;
|
|
509
|
-
errors[String(key)] ??= issue.message;
|
|
510
|
-
if (String(key) === props.fieldKey && typeof index === "number" && blockKey !== void 0)
|
|
511
|
-
errors[`${String(key)}.${index}.${String(blockKey)}`] ??= issue.message;
|
|
556
|
+
for (const key of issueKeys(issue.path)) errors[key] ??= issue.message;
|
|
512
557
|
}
|
|
513
558
|
return issues;
|
|
514
559
|
}
|
|
560
|
+
const warningList = computed(
|
|
561
|
+
() => entryValidationWarnings(
|
|
562
|
+
{ fields: props.fields, validate: validateHook.value },
|
|
563
|
+
state.value,
|
|
564
|
+
i18n
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
const warnings = computed(() => {
|
|
568
|
+
const grouped = {};
|
|
569
|
+
for (const issue of warningList.value) {
|
|
570
|
+
for (const key of issueKeys(issue.path)) {
|
|
571
|
+
if (key !== ROOT_KEY) grouped[key] ??= issue.message;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return grouped;
|
|
575
|
+
});
|
|
576
|
+
provide(CMS_FORM_WARNINGS, warnings);
|
|
577
|
+
const rootWarnings = computed(() => [
|
|
578
|
+
...new Set(
|
|
579
|
+
warningList.value.filter((issue) => !issue.path.length).map((issue) => issue.message)
|
|
580
|
+
)
|
|
581
|
+
]);
|
|
515
582
|
function tabOfKey(key) {
|
|
516
583
|
if (key === props.fieldKey) return "block";
|
|
517
584
|
return SEO_KEYS.includes(key) ? "seo" : "content";
|
|
518
585
|
}
|
|
519
586
|
function tabHasError(tab) {
|
|
520
|
-
return Object.keys(errors).some(
|
|
587
|
+
return Object.keys(errors).some(
|
|
588
|
+
(key) => key !== ROOT_KEY && tabOfKey(key.split(".")[0]) === tab
|
|
589
|
+
);
|
|
521
590
|
}
|
|
522
591
|
function validate() {
|
|
523
592
|
submitted = true;
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
<template v-else>
|
|
99
99
|
<div class="cms-toolbar">
|
|
100
100
|
<CmsInput
|
|
101
|
-
v-if="total ||
|
|
101
|
+
v-if="total || narrowed"
|
|
102
102
|
v-model="search"
|
|
103
103
|
icon="magnifying-glass"
|
|
104
104
|
placeholder="Search…"
|
|
@@ -131,6 +131,14 @@
|
|
|
131
131
|
@click="bulkStatus('published')"
|
|
132
132
|
/>
|
|
133
133
|
</template>
|
|
134
|
+
<CmsButton
|
|
135
|
+
v-if="selected.length === 1"
|
|
136
|
+
label="Duplicate"
|
|
137
|
+
icon="document-duplicate"
|
|
138
|
+
variant="soft"
|
|
139
|
+
:disabled="saving"
|
|
140
|
+
@click="duplicateSelected"
|
|
141
|
+
/>
|
|
134
142
|
<CmsButton
|
|
135
143
|
label="Delete"
|
|
136
144
|
icon="trash"
|
|
@@ -140,10 +148,28 @@
|
|
|
140
148
|
/>
|
|
141
149
|
</div>
|
|
142
150
|
<div v-else :key="'default'" class="cms-toolbar-actions">
|
|
151
|
+
<CmsDropdownMenu :items="transferItems" :content="{ align: 'end' }">
|
|
152
|
+
<CmsButton
|
|
153
|
+
icon="arrows-up-down"
|
|
154
|
+
variant="soft"
|
|
155
|
+
color="neutral"
|
|
156
|
+
:loading="exporting"
|
|
157
|
+
aria-label="Import and export"
|
|
158
|
+
title="Import and export"
|
|
159
|
+
/>
|
|
160
|
+
</CmsDropdownMenu>
|
|
143
161
|
<CmsButton label="New entry" icon="plus" @click="openCreate" />
|
|
144
162
|
</div>
|
|
145
163
|
</div>
|
|
146
164
|
|
|
165
|
+
<CmsListFilters
|
|
166
|
+
v-if="filterKeys.length && (total || narrowed)"
|
|
167
|
+
v-model="filters"
|
|
168
|
+
:config="config"
|
|
169
|
+
:keys="filterKeys"
|
|
170
|
+
:relations="relations"
|
|
171
|
+
/>
|
|
172
|
+
|
|
147
173
|
<CmsTable
|
|
148
174
|
v-if="rows.length"
|
|
149
175
|
v-model:selected="selected"
|
|
@@ -168,11 +194,19 @@
|
|
|
168
194
|
<CmsSpinner v-else-if="status === 'pending'" />
|
|
169
195
|
|
|
170
196
|
<CmsEmptyState
|
|
171
|
-
v-else-if="
|
|
197
|
+
v-else-if="narrowed"
|
|
172
198
|
icon="magnifying-glass"
|
|
173
199
|
title="No matching entries"
|
|
174
200
|
fill
|
|
175
|
-
|
|
201
|
+
>
|
|
202
|
+
<CmsButton
|
|
203
|
+
v-if="filterCount"
|
|
204
|
+
label="Clear filters"
|
|
205
|
+
icon="x-mark"
|
|
206
|
+
variant="soft"
|
|
207
|
+
@click="filters = {}"
|
|
208
|
+
/>
|
|
209
|
+
</CmsEmptyState>
|
|
176
210
|
|
|
177
211
|
<CmsEmptyState
|
|
178
212
|
v-else
|
|
@@ -192,8 +226,17 @@
|
|
|
192
226
|
:collection="name"
|
|
193
227
|
:config="config"
|
|
194
228
|
:entry-id="drawerEntryId"
|
|
229
|
+
:initial="drawerInitial"
|
|
195
230
|
@saved="refresh"
|
|
196
231
|
@deleted="onDrawerDeleted"
|
|
232
|
+
@duplicate="openDuplicate"
|
|
233
|
+
/>
|
|
234
|
+
|
|
235
|
+
<CmsImportModal
|
|
236
|
+
v-model:open="importOpen"
|
|
237
|
+
:collection="name"
|
|
238
|
+
:config="config"
|
|
239
|
+
@imported="refresh"
|
|
197
240
|
/>
|
|
198
241
|
</template>
|
|
199
242
|
</div>
|
|
@@ -206,7 +249,8 @@ import {
|
|
|
206
249
|
isTranslatableField,
|
|
207
250
|
isTranslatableMediaField,
|
|
208
251
|
pageParentPath,
|
|
209
|
-
pickTranslatedMedia
|
|
252
|
+
pickTranslatedMedia,
|
|
253
|
+
selectDisplayValue
|
|
210
254
|
} from "#nuxt-cms";
|
|
211
255
|
import {
|
|
212
256
|
computed,
|
|
@@ -220,7 +264,15 @@ import {
|
|
|
220
264
|
watch
|
|
221
265
|
} from "#imports";
|
|
222
266
|
import cmsConfig from "#cms-config";
|
|
267
|
+
import { duplicateEntryValues } from "../../shared/duplicate";
|
|
268
|
+
import {
|
|
269
|
+
entryListFilterKeys,
|
|
270
|
+
listFilterQuery,
|
|
271
|
+
parseListFilters,
|
|
272
|
+
withoutListFilterQuery
|
|
273
|
+
} from "../../shared/list-filters";
|
|
223
274
|
import { readSearchFocus } from "../../shared/search";
|
|
275
|
+
import { canTransferCsv, exportFileName } from "../../shared/transfer";
|
|
224
276
|
import { useCmsConfirm } from "../composables/cms-confirm";
|
|
225
277
|
import { useCmsRuntime } from "../composables/cms-runtime";
|
|
226
278
|
import { entryListColumns, useCmsSettingsState } from "../composables/cms-settings";
|
|
@@ -231,7 +283,7 @@ definePageMeta({
|
|
|
231
283
|
layout: "cms-admin",
|
|
232
284
|
middleware: "cms-auth",
|
|
233
285
|
validate: (route2) => Object.hasOwn(cmsConfig, route2.params.collection),
|
|
234
|
-
key: (route2) => route2.
|
|
286
|
+
key: (route2) => `${route2.path}${JSON.stringify(withoutListFilterQuery(route2.query))}`
|
|
235
287
|
});
|
|
236
288
|
const FORM_ID = "cms-single-form";
|
|
237
289
|
const route = useRoute();
|
|
@@ -271,11 +323,33 @@ const sort = ref(null);
|
|
|
271
323
|
watch(sort, () => {
|
|
272
324
|
page.value = 1;
|
|
273
325
|
});
|
|
326
|
+
const filters = computed({
|
|
327
|
+
get: () => isCollectionKind(config) ? parseListFilters(config, route.query).filters : {},
|
|
328
|
+
set: (value) => {
|
|
329
|
+
void navigateTo(
|
|
330
|
+
{ query: { ...withoutListFilterQuery(route.query), ...listFilterQuery(value) } },
|
|
331
|
+
{ replace: true }
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
const filterQuery = computed(() => listFilterQuery(filters.value));
|
|
336
|
+
const filterCount = computed(() => Object.keys(filterQuery.value).length);
|
|
337
|
+
const narrowed = computed(() => !!searchTerm.value || filterCount.value > 0);
|
|
338
|
+
watch(
|
|
339
|
+
() => JSON.stringify(filterQuery.value),
|
|
340
|
+
() => {
|
|
341
|
+
page.value = 1;
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
const sortQuery = computed(
|
|
345
|
+
() => sort.value ? { sort: sort.value.key, order: sort.value.order } : {}
|
|
346
|
+
);
|
|
274
347
|
const listQuery = computed(() => ({
|
|
275
348
|
limit: PAGE_SIZE,
|
|
276
349
|
offset: (page.value - 1) * PAGE_SIZE,
|
|
277
350
|
...searchTerm.value ? { search: searchTerm.value } : {},
|
|
278
|
-
...
|
|
351
|
+
...sortQuery.value,
|
|
352
|
+
...filterQuery.value
|
|
279
353
|
}));
|
|
280
354
|
const { data, refresh, error, status } = await useFetch(endpoint, {
|
|
281
355
|
query: config.kind === "single" ? void 0 : listQuery
|
|
@@ -384,7 +458,7 @@ function relationLabel(field, key, id) {
|
|
|
384
458
|
if (title == null || title === "") return `#${id}`;
|
|
385
459
|
const target = cmsConfig[field.to];
|
|
386
460
|
const titleField = target?.titleField ? target.fields[target.titleField] : void 0;
|
|
387
|
-
const label = titleField && isTranslatableField(titleField) ? localized(title) : String(title);
|
|
461
|
+
const label = titleField && isTranslatableField(titleField) ? localized(title) : titleField?.type === "select" ? selectDisplayValue(titleField, title) : String(title);
|
|
388
462
|
return label || `#${id}`;
|
|
389
463
|
}
|
|
390
464
|
function displayValue(field, key, value) {
|
|
@@ -402,6 +476,8 @@ function displayValue(field, key, value) {
|
|
|
402
476
|
return truncate(JSON.stringify(value));
|
|
403
477
|
case "datetime":
|
|
404
478
|
return formatDateTime(value);
|
|
479
|
+
case "select":
|
|
480
|
+
return truncate(selectDisplayValue(field, value));
|
|
405
481
|
case "blocks":
|
|
406
482
|
return `${value.length} \u25A4`;
|
|
407
483
|
case "relation": {
|
|
@@ -428,13 +504,21 @@ const columns = computed(
|
|
|
428
504
|
}
|
|
429
505
|
)
|
|
430
506
|
);
|
|
507
|
+
const filterKeys = computed(() => {
|
|
508
|
+
if (!isCollectionKind(config)) return [];
|
|
509
|
+
const shown = entryListFilterKeys(config, entryListColumns(name, settings.value));
|
|
510
|
+
const active = Object.keys(filters.value).filter((key) => !shown.includes(key));
|
|
511
|
+
return [...shown, ...active];
|
|
512
|
+
});
|
|
431
513
|
const drawerOpen = ref(false);
|
|
432
514
|
const drawerEntryId = ref(null);
|
|
515
|
+
const drawerInitial = ref(null);
|
|
433
516
|
function onSelect(_event, row) {
|
|
434
517
|
if (isContent) {
|
|
435
518
|
void navigateTo(`/cms/${name}/${row.original.id}`);
|
|
436
519
|
return;
|
|
437
520
|
}
|
|
521
|
+
drawerInitial.value = null;
|
|
438
522
|
drawerEntryId.value = String(row.original.id);
|
|
439
523
|
drawerOpen.value = true;
|
|
440
524
|
}
|
|
@@ -443,9 +527,76 @@ function openCreate() {
|
|
|
443
527
|
void navigateTo(`/cms/${name}/new`);
|
|
444
528
|
return;
|
|
445
529
|
}
|
|
530
|
+
drawerInitial.value = null;
|
|
531
|
+
drawerEntryId.value = null;
|
|
532
|
+
drawerOpen.value = true;
|
|
533
|
+
}
|
|
534
|
+
function openDuplicate(values) {
|
|
535
|
+
drawerInitial.value = values;
|
|
446
536
|
drawerEntryId.value = null;
|
|
447
537
|
drawerOpen.value = true;
|
|
448
538
|
}
|
|
539
|
+
async function duplicateSelected() {
|
|
540
|
+
const id = selected.value[0];
|
|
541
|
+
if (!id || saving.value) return;
|
|
542
|
+
if (isContent) {
|
|
543
|
+
void navigateTo({ path: `/cms/${name}/new`, query: { duplicate: id } });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
saving.value = true;
|
|
547
|
+
try {
|
|
548
|
+
const entry = await cmsApi(`${endpoint}/${id}`);
|
|
549
|
+
selected.value = [];
|
|
550
|
+
openDuplicate(duplicateEntryValues(config, entry));
|
|
551
|
+
} catch (error2) {
|
|
552
|
+
toast.add({ title: "Could not load entry", description: errorMessage(error2), color: "error" });
|
|
553
|
+
} finally {
|
|
554
|
+
saving.value = false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const importOpen = ref(false);
|
|
558
|
+
const exporting = ref(false);
|
|
559
|
+
const csvAvailable = canTransferCsv(config);
|
|
560
|
+
async function exportEntries(format, narrow) {
|
|
561
|
+
if (exporting.value) return;
|
|
562
|
+
exporting.value = true;
|
|
563
|
+
try {
|
|
564
|
+
const query = {
|
|
565
|
+
format,
|
|
566
|
+
...sortQuery.value,
|
|
567
|
+
...narrow && searchTerm.value ? { search: searchTerm.value } : {},
|
|
568
|
+
...narrow ? filterQuery.value : {}
|
|
569
|
+
};
|
|
570
|
+
const blob = await $fetch(`/api/cms/export/${name}`, { query, responseType: "blob" });
|
|
571
|
+
const url = URL.createObjectURL(blob);
|
|
572
|
+
const link = document.createElement("a");
|
|
573
|
+
link.href = url;
|
|
574
|
+
link.download = exportFileName(name, format);
|
|
575
|
+
link.click();
|
|
576
|
+
URL.revokeObjectURL(url);
|
|
577
|
+
} catch (error2) {
|
|
578
|
+
toast.add({ title: "Export failed", description: errorMessage(error2), color: "error" });
|
|
579
|
+
} finally {
|
|
580
|
+
exporting.value = false;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const transferItems = computed(() => {
|
|
584
|
+
const formats = csvAvailable ? ["json", "csv"] : ["json"];
|
|
585
|
+
const exports = (narrow) => formats.map((format) => ({
|
|
586
|
+
label: `Export ${narrow ? "filtered" : "all"} as ${format.toUpperCase()}`,
|
|
587
|
+
onSelect: () => void exportEntries(format, narrow)
|
|
588
|
+
}));
|
|
589
|
+
return [
|
|
590
|
+
...narrowed.value ? exports(true) : [],
|
|
591
|
+
...exports(false),
|
|
592
|
+
{
|
|
593
|
+
label: "Import from file\u2026",
|
|
594
|
+
onSelect: () => {
|
|
595
|
+
importOpen.value = true;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
];
|
|
599
|
+
});
|
|
449
600
|
async function onDrawerDeleted() {
|
|
450
601
|
if (rows.value.length === 1 && page.value > 1) page.value -= 1;
|
|
451
602
|
else await refresh();
|