@xleddyl/nuxt-cms 0.4.1 → 0.4.3
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 +86 -22
- 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/graphql-sdl.js +4 -7
- 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
|
@@ -21,14 +21,24 @@
|
|
|
21
21
|
/>
|
|
22
22
|
|
|
23
23
|
<template #footer>
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
<div v-if="!isNew" class="cms-actions">
|
|
25
|
+
<CmsButton
|
|
26
|
+
label="Delete"
|
|
27
|
+
icon="trash"
|
|
28
|
+
variant="ghost"
|
|
29
|
+
color="error"
|
|
30
|
+
:disabled="saving || loading"
|
|
31
|
+
@click="remove"
|
|
32
|
+
/>
|
|
33
|
+
<CmsButton
|
|
34
|
+
label="Duplicate"
|
|
35
|
+
icon="document-duplicate"
|
|
36
|
+
variant="ghost"
|
|
37
|
+
color="neutral"
|
|
38
|
+
:disabled="saving || loading"
|
|
39
|
+
@click="duplicate"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
32
42
|
<span v-else />
|
|
33
43
|
<div class="cms-actions">
|
|
34
44
|
<CmsButton
|
|
@@ -54,8 +64,9 @@
|
|
|
54
64
|
</template>
|
|
55
65
|
|
|
56
66
|
<script setup>
|
|
57
|
-
import { isTranslatableField } from "#nuxt-cms";
|
|
67
|
+
import { isTranslatableField, selectDisplayValue } from "#nuxt-cms";
|
|
58
68
|
import { computed, ref, watch } from "#imports";
|
|
69
|
+
import { duplicateEntryValues } from "../../../shared/duplicate";
|
|
59
70
|
import { useCmsConfirm } from "../../composables/cms-confirm";
|
|
60
71
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
61
72
|
import { useCmsToast } from "../../composables/cms-toast";
|
|
@@ -65,10 +76,11 @@ const FORM_ID = "cms-entry-drawer-form";
|
|
|
65
76
|
const props = defineProps({
|
|
66
77
|
collection: { type: String, required: true },
|
|
67
78
|
config: { type: Object, required: true },
|
|
68
|
-
entryId: { type: [String, null], required: true }
|
|
79
|
+
entryId: { type: [String, null], required: true },
|
|
80
|
+
initial: { type: [Object, null], required: false }
|
|
69
81
|
});
|
|
70
82
|
const open = defineModel("open", { type: Boolean, ...{ default: false } });
|
|
71
|
-
const emit = defineEmits(["saved", "deleted"]);
|
|
83
|
+
const emit = defineEmits(["saved", "deleted", "duplicate"]);
|
|
72
84
|
const toast = useCmsToast();
|
|
73
85
|
const confirmAction = useCmsConfirm();
|
|
74
86
|
const endpoint = computed(() => `/api/cms/admin/${props.collection}`);
|
|
@@ -91,10 +103,11 @@ const entryName = computed(() => {
|
|
|
91
103
|
const record = value;
|
|
92
104
|
return record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
|
|
93
105
|
}
|
|
106
|
+
if (field.type === "select") return selectDisplayValue(field, value);
|
|
94
107
|
return String(value);
|
|
95
108
|
});
|
|
96
109
|
const title = computed(() => {
|
|
97
|
-
if (isNew.value) return "Add entry";
|
|
110
|
+
if (isNew.value) return props.initial ? "Duplicate entry" : "Add entry";
|
|
98
111
|
return entryName.value || "Untitled";
|
|
99
112
|
});
|
|
100
113
|
function emptyState() {
|
|
@@ -128,6 +141,7 @@ async function load() {
|
|
|
128
141
|
if (id === null) {
|
|
129
142
|
loading.value = false;
|
|
130
143
|
setState(emptyState());
|
|
144
|
+
if (props.initial) formState.value = { ...emptyState(), ...pickFields(props.initial) };
|
|
131
145
|
return;
|
|
132
146
|
}
|
|
133
147
|
const isStale = () => request !== loadRequest || props.entryId !== id || !open.value;
|
|
@@ -149,7 +163,7 @@ async function load() {
|
|
|
149
163
|
}
|
|
150
164
|
}
|
|
151
165
|
watch(
|
|
152
|
-
() => [open.value, props.entryId],
|
|
166
|
+
() => [open.value, props.entryId, props.initial],
|
|
153
167
|
([isOpen]) => {
|
|
154
168
|
if (isOpen) load();
|
|
155
169
|
},
|
|
@@ -182,6 +196,11 @@ async function save() {
|
|
|
182
196
|
saving.value = false;
|
|
183
197
|
}
|
|
184
198
|
}
|
|
199
|
+
async function duplicate() {
|
|
200
|
+
if (dirty.value && !await confirmAction("You have unsaved changes. Duplicate the saved entry anyway?"))
|
|
201
|
+
return;
|
|
202
|
+
emit("duplicate", duplicateEntryValues(props.config, JSON.parse(snapshot.value)));
|
|
203
|
+
}
|
|
185
204
|
async function remove() {
|
|
186
205
|
if (!await confirmAction("Delete this row?")) return;
|
|
187
206
|
saving.value = true;
|
|
@@ -3,6 +3,7 @@ type __VLS_Props = {
|
|
|
3
3
|
collection: string;
|
|
4
4
|
config: CmsEntry;
|
|
5
5
|
entryId: string | null;
|
|
6
|
+
initial?: Record<string, unknown> | null;
|
|
6
7
|
};
|
|
7
8
|
type __VLS_ModelProps = {
|
|
8
9
|
'open'?: boolean;
|
|
@@ -11,10 +12,12 @@ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
|
11
12
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
12
13
|
saved: () => any;
|
|
13
14
|
deleted: () => any;
|
|
15
|
+
duplicate: (values: Record<string, unknown>) => any;
|
|
14
16
|
"update:open": (value: boolean) => any;
|
|
15
17
|
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
16
18
|
onSaved?: (() => any) | undefined;
|
|
17
19
|
onDeleted?: (() => any) | undefined;
|
|
20
|
+
onDuplicate?: ((values: Record<string, unknown>) => any) | undefined;
|
|
18
21
|
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
19
22
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
23
|
declare const _default: typeof __VLS_export;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
ref="form"
|
|
5
5
|
:state="state"
|
|
6
6
|
:schema="schema"
|
|
7
|
+
:warnings="warnings"
|
|
7
8
|
@submit="emit('submit')"
|
|
8
9
|
@error="onError"
|
|
9
10
|
>
|
|
@@ -112,7 +113,8 @@ import {
|
|
|
112
113
|
slugSourceValue
|
|
113
114
|
} from "#nuxt-cms";
|
|
114
115
|
import { computed, nextTick, onMounted, ref, watch } from "#imports";
|
|
115
|
-
import
|
|
116
|
+
import cmsConfig from "#cms-config";
|
|
117
|
+
import { buildEntrySchema, entryValidationWarnings } from "../../../shared/validation";
|
|
116
118
|
import { useCmsFieldFocus } from "../../composables/cms-field-focus";
|
|
117
119
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
118
120
|
import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
|
|
@@ -125,7 +127,8 @@ const props = defineProps({
|
|
|
125
127
|
loading: { type: Boolean, required: false },
|
|
126
128
|
formId: { type: String, required: false },
|
|
127
129
|
footer: { type: Boolean, required: false, default: true },
|
|
128
|
-
focus: { type: [Object, null], required: false }
|
|
130
|
+
focus: { type: [Object, null], required: false },
|
|
131
|
+
path: { type: String, required: false }
|
|
129
132
|
});
|
|
130
133
|
const state = defineModel({ type: Object, ...{ required: true } });
|
|
131
134
|
const emit = defineEmits(["submit", "error"]);
|
|
@@ -193,9 +196,23 @@ function localeFor(key) {
|
|
|
193
196
|
function setLocale(key, locale) {
|
|
194
197
|
activeLocale.value = { ...activeLocale.value, [key]: locale };
|
|
195
198
|
}
|
|
199
|
+
const validate = computed(
|
|
200
|
+
() => props.entryName ? cmsConfig[props.entryName]?.validate : void 0
|
|
201
|
+
);
|
|
196
202
|
const schema = computed(
|
|
197
|
-
() => buildEntrySchema(
|
|
203
|
+
() => buildEntrySchema(
|
|
204
|
+
{ fields: props.fields, drafts: props.drafts, validate: validate.value, path: props.path },
|
|
205
|
+
i18n
|
|
206
|
+
)
|
|
198
207
|
);
|
|
208
|
+
function warnings(values) {
|
|
209
|
+
return entryValidationWarnings(
|
|
210
|
+
{ fields: props.fields, validate: validate.value },
|
|
211
|
+
values,
|
|
212
|
+
i18n,
|
|
213
|
+
props.path
|
|
214
|
+
);
|
|
215
|
+
}
|
|
199
216
|
const published = computed({
|
|
200
217
|
get: () => state.value.status === "published",
|
|
201
218
|
set: (value) => {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
</template>
|
|
65
65
|
|
|
66
66
|
<script setup>
|
|
67
|
-
import { isTranslatableField } from "#nuxt-cms";
|
|
67
|
+
import { isTranslatableField, selectOptions } from "#nuxt-cms";
|
|
68
68
|
import { computed } from "#imports";
|
|
69
69
|
const props = defineProps({
|
|
70
70
|
field: { type: Object, required: true },
|
|
@@ -76,7 +76,7 @@ const props = defineProps({
|
|
|
76
76
|
const model = defineModel({ type: null, ...{ required: true } });
|
|
77
77
|
const selectItems = computed(() => [
|
|
78
78
|
...props.field.required || props.field.multiple ? [] : [{ label: "\u2014", value: null }],
|
|
79
|
-
...(props.field
|
|
79
|
+
...selectOptions(props.field).map(({ value, label }) => ({ label, value }))
|
|
80
80
|
]);
|
|
81
81
|
function proxy(fromModel, toModel = (v) => v) {
|
|
82
82
|
return computed({
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
interface FormIssue {
|
|
2
|
+
path: readonly PropertyKey[];
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
1
5
|
interface ParseResult {
|
|
2
6
|
success: boolean;
|
|
3
7
|
error?: {
|
|
4
|
-
issues: readonly
|
|
5
|
-
path: readonly PropertyKey[];
|
|
6
|
-
message: string;
|
|
7
|
-
}[];
|
|
8
|
+
issues: readonly FormIssue[];
|
|
8
9
|
};
|
|
9
10
|
}
|
|
10
11
|
type __VLS_Props = {
|
|
@@ -13,10 +14,11 @@ type __VLS_Props = {
|
|
|
13
14
|
schema?: {
|
|
14
15
|
safeParse: (value: unknown) => ParseResult;
|
|
15
16
|
};
|
|
17
|
+
warnings?: (value: Record<string, unknown>) => readonly FormIssue[];
|
|
16
18
|
};
|
|
17
|
-
declare var
|
|
19
|
+
declare var __VLS_13: {};
|
|
18
20
|
type __VLS_Slots = {} & {
|
|
19
|
-
default?: (props: typeof
|
|
21
|
+
default?: (props: typeof __VLS_13) => any;
|
|
20
22
|
};
|
|
21
23
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
22
24
|
error: () => any;
|
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<form :id="id" class="cms-form" @submit.prevent="onSubmit">
|
|
3
|
+
<CmsAlert v-if="errors[ROOT_KEY]" color="error">{{ errors[ROOT_KEY] }}</CmsAlert>
|
|
4
|
+
<CmsAlert v-for="message in rootWarnings" :key="message" color="warning">
|
|
5
|
+
{{ message }}
|
|
6
|
+
</CmsAlert>
|
|
3
7
|
<slot />
|
|
4
8
|
</form>
|
|
5
9
|
</template>
|
|
6
10
|
|
|
7
11
|
<script setup>
|
|
8
|
-
import { provide, reactive, watch } from "#imports";
|
|
9
|
-
import { CMS_FORM_ERRORS } from "../../utils/ui";
|
|
12
|
+
import { computed, provide, reactive, watch } from "#imports";
|
|
13
|
+
import { CMS_FORM_ERRORS, CMS_FORM_WARNINGS } from "../../utils/ui";
|
|
14
|
+
const ROOT_KEY = "";
|
|
10
15
|
const props = defineProps({
|
|
11
16
|
id: { type: String, required: false },
|
|
12
17
|
state: { type: Object, required: true },
|
|
13
|
-
schema: { type: Object, required: false }
|
|
18
|
+
schema: { type: Object, required: false },
|
|
19
|
+
warnings: { type: Function, required: false }
|
|
14
20
|
});
|
|
15
21
|
const emit = defineEmits(["submit", "error"]);
|
|
16
22
|
const errors = reactive({});
|
|
17
23
|
provide(CMS_FORM_ERRORS, errors);
|
|
24
|
+
const warningList = computed(() => props.warnings?.(props.state) ?? []);
|
|
25
|
+
const warningsByKey = computed(() => {
|
|
26
|
+
const grouped = {};
|
|
27
|
+
for (const issue of warningList.value) {
|
|
28
|
+
const key = String(issue.path[0] ?? ROOT_KEY);
|
|
29
|
+
if (key !== ROOT_KEY) grouped[key] ??= issue.message;
|
|
30
|
+
}
|
|
31
|
+
return grouped;
|
|
32
|
+
});
|
|
33
|
+
provide(CMS_FORM_WARNINGS, warningsByKey);
|
|
34
|
+
const rootWarnings = computed(() => [
|
|
35
|
+
...new Set(
|
|
36
|
+
warningList.value.filter((issue) => !issue.path.length).map((issue) => issue.message)
|
|
37
|
+
)
|
|
38
|
+
]);
|
|
18
39
|
let submitted = false;
|
|
19
40
|
function validate() {
|
|
20
41
|
for (const key of Object.keys(errors)) delete errors[key];
|
|
@@ -22,8 +43,8 @@ function validate() {
|
|
|
22
43
|
const result = props.schema.safeParse(props.state);
|
|
23
44
|
if (result.success) return true;
|
|
24
45
|
for (const issue of result.error?.issues ?? []) {
|
|
25
|
-
const key = String(issue.path[0] ??
|
|
26
|
-
if (
|
|
46
|
+
const key = String(issue.path[0] ?? ROOT_KEY);
|
|
47
|
+
if (!errors[key]) errors[key] = issue.message;
|
|
27
48
|
}
|
|
28
49
|
return false;
|
|
29
50
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
interface FormIssue {
|
|
2
|
+
path: readonly PropertyKey[];
|
|
3
|
+
message: string;
|
|
4
|
+
}
|
|
1
5
|
interface ParseResult {
|
|
2
6
|
success: boolean;
|
|
3
7
|
error?: {
|
|
4
|
-
issues: readonly
|
|
5
|
-
path: readonly PropertyKey[];
|
|
6
|
-
message: string;
|
|
7
|
-
}[];
|
|
8
|
+
issues: readonly FormIssue[];
|
|
8
9
|
};
|
|
9
10
|
}
|
|
10
11
|
type __VLS_Props = {
|
|
@@ -13,10 +14,11 @@ type __VLS_Props = {
|
|
|
13
14
|
schema?: {
|
|
14
15
|
safeParse: (value: unknown) => ParseResult;
|
|
15
16
|
};
|
|
17
|
+
warnings?: (value: Record<string, unknown>) => readonly FormIssue[];
|
|
16
18
|
};
|
|
17
|
-
declare var
|
|
19
|
+
declare var __VLS_13: {};
|
|
18
20
|
type __VLS_Slots = {} & {
|
|
19
|
-
default?: (props: typeof
|
|
21
|
+
default?: (props: typeof __VLS_13) => any;
|
|
20
22
|
};
|
|
21
23
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
22
24
|
error: () => any;
|
|
@@ -12,12 +12,15 @@
|
|
|
12
12
|
<p v-if="error" class="cms-form-error">
|
|
13
13
|
<CmsIcon name="exclamation-circle" class="size-3.5 shrink-0" />{{ error }}
|
|
14
14
|
</p>
|
|
15
|
+
<p v-else-if="warning" class="cms-form-warning">
|
|
16
|
+
<CmsIcon name="exclamation-triangle" class="size-3.5 shrink-0" />{{ warning }}
|
|
17
|
+
</p>
|
|
15
18
|
</div>
|
|
16
19
|
</template>
|
|
17
20
|
|
|
18
21
|
<script setup>
|
|
19
22
|
import { computed, inject } from "#imports";
|
|
20
|
-
import { CMS_FORM_ERRORS } from "../../utils/ui";
|
|
23
|
+
import { CMS_FORM_ERRORS, CMS_FORM_WARNINGS } from "../../utils/ui";
|
|
21
24
|
const props = defineProps({
|
|
22
25
|
label: { type: String, required: false },
|
|
23
26
|
icon: { type: String, required: false },
|
|
@@ -26,5 +29,7 @@ const props = defineProps({
|
|
|
26
29
|
required: { type: Boolean, required: false }
|
|
27
30
|
});
|
|
28
31
|
const errors = inject(CMS_FORM_ERRORS, null);
|
|
32
|
+
const warnings = inject(CMS_FORM_WARNINGS, null);
|
|
29
33
|
const error = computed(() => props.name && errors ? errors[props.name] : void 0);
|
|
34
|
+
const warning = computed(() => props.name && warnings ? warnings.value[props.name] : void 0);
|
|
30
35
|
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CmsEntry } from '#nuxt-cms';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
collection: string;
|
|
4
|
+
config: CmsEntry;
|
|
5
|
+
};
|
|
6
|
+
type __VLS_ModelProps = {
|
|
7
|
+
'open'?: boolean;
|
|
8
|
+
};
|
|
9
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
10
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
11
|
+
imported: () => any;
|
|
12
|
+
"update:open": (value: boolean) => any;
|
|
13
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
14
|
+
onImported?: (() => any) | undefined;
|
|
15
|
+
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
16
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: typeof __VLS_export;
|
|
18
|
+
export default _default;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<CmsModal v-model:open="open" :title="`Import ${config.label}`" size="lg">
|
|
3
|
+
<div class="cms-form">
|
|
4
|
+
<p class="cms-form-description">
|
|
5
|
+
{{ csv ? "A JSON or CSV file" : "A JSON file" }} in the format of the export, up to
|
|
6
|
+
{{ maxSize }} and {{ IMPORT_MAX_ROWS }} rows. Media fields take keys of files that are
|
|
7
|
+
already in the media library. Nothing is written until you confirm.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<div class="cms-import-file">
|
|
11
|
+
<input
|
|
12
|
+
ref="input"
|
|
13
|
+
type="file"
|
|
14
|
+
class="hidden"
|
|
15
|
+
:accept="csv ? '.json,.csv,application/json,text/csv' : '.json,application/json'"
|
|
16
|
+
@change="onFile"
|
|
17
|
+
/>
|
|
18
|
+
<CmsButton
|
|
19
|
+
:label="file ? 'Choose another file' : 'Choose file'"
|
|
20
|
+
icon="document-arrow-up"
|
|
21
|
+
variant="soft"
|
|
22
|
+
color="neutral"
|
|
23
|
+
:disabled="busy"
|
|
24
|
+
@click="input?.click()"
|
|
25
|
+
/>
|
|
26
|
+
<span v-if="file" class="cms-import-file-name">
|
|
27
|
+
{{ file.name }} · {{ formatFileSize(file.size) }}
|
|
28
|
+
</span>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<CmsFormField
|
|
32
|
+
label="Mode"
|
|
33
|
+
:description="
|
|
34
|
+
mode === 'create' ? 'Every row becomes a new entry. A row with the id of an existing entry is an error.' : 'A row with the id of an existing entry replaces it. Other rows become new entries.'
|
|
35
|
+
"
|
|
36
|
+
>
|
|
37
|
+
<div class="cms-media-filters">
|
|
38
|
+
<button
|
|
39
|
+
v-for="option in MODE_OPTIONS"
|
|
40
|
+
:key="option.value"
|
|
41
|
+
type="button"
|
|
42
|
+
class="cms-pill"
|
|
43
|
+
:class="{ 'is-active': mode === option.value }"
|
|
44
|
+
:disabled="busy"
|
|
45
|
+
@click="setMode(option.value)"
|
|
46
|
+
>
|
|
47
|
+
{{ option.label }}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
</CmsFormField>
|
|
51
|
+
|
|
52
|
+
<CmsSpinner v-if="checking" />
|
|
53
|
+
|
|
54
|
+
<CmsAlert v-else-if="fileErrors.length" color="error" title="The file cannot be imported">
|
|
55
|
+
<ul class="cms-import-list">
|
|
56
|
+
<li v-for="message in fileErrors" :key="message">{{ message }}</li>
|
|
57
|
+
</ul>
|
|
58
|
+
</CmsAlert>
|
|
59
|
+
|
|
60
|
+
<template v-else-if="preview">
|
|
61
|
+
<CmsAlert :color="invalidCount ? 'warning' : 'success'" :title="summary" />
|
|
62
|
+
<div class="cms-card cms-table-shell cms-import-preview">
|
|
63
|
+
<div class="cms-table-scroll">
|
|
64
|
+
<table class="cms-table">
|
|
65
|
+
<thead>
|
|
66
|
+
<tr>
|
|
67
|
+
<th>Row</th>
|
|
68
|
+
<th>Entry</th>
|
|
69
|
+
<th>Action</th>
|
|
70
|
+
<th>Problems</th>
|
|
71
|
+
</tr>
|
|
72
|
+
</thead>
|
|
73
|
+
<tbody>
|
|
74
|
+
<tr v-for="row in shownRows" :key="row.index">
|
|
75
|
+
<td>{{ row.index }}</td>
|
|
76
|
+
<td class="cms-import-title">{{ row.title || row.id || "Untitled" }}</td>
|
|
77
|
+
<td>
|
|
78
|
+
<span class="cms-tag">
|
|
79
|
+
{{ row.action === "update" ? "Update" : "New" }}
|
|
80
|
+
</span>
|
|
81
|
+
</td>
|
|
82
|
+
<td>
|
|
83
|
+
<ul
|
|
84
|
+
v-if="row.errors.length"
|
|
85
|
+
class="cms-import-list cms-import-issues"
|
|
86
|
+
>
|
|
87
|
+
<li v-for="(issue, index) in row.errors" :key="index">
|
|
88
|
+
{{ issue.path ? `${issue.path}: ` : "" }}{{ issue.message }}
|
|
89
|
+
</li>
|
|
90
|
+
</ul>
|
|
91
|
+
<span v-else class="cms-import-ok">
|
|
92
|
+
<CmsIcon name="check" class="size-3.5" />Ready
|
|
93
|
+
</span>
|
|
94
|
+
</td>
|
|
95
|
+
</tr>
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<div class="cms-actions is-end">
|
|
103
|
+
<CmsButton label="Cancel" variant="soft" color="neutral" @click="open = false" />
|
|
104
|
+
<CmsButton
|
|
105
|
+
:label="importLabel"
|
|
106
|
+
icon="arrow-down-tray"
|
|
107
|
+
:disabled="!canImport"
|
|
108
|
+
:loading="importing"
|
|
109
|
+
@click="commit"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</CmsModal>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<script setup>
|
|
117
|
+
import { formatFileSize } from "#nuxt-cms";
|
|
118
|
+
import { computed, ref, watch } from "#imports";
|
|
119
|
+
import { canTransferCsv, IMPORT_MAX_BYTES, IMPORT_MAX_ROWS } from "../../../shared/transfer";
|
|
120
|
+
import { useCmsToast } from "../../composables/cms-toast";
|
|
121
|
+
import { errorMessage } from "../../utils/ui";
|
|
122
|
+
const MODE_OPTIONS = [
|
|
123
|
+
{ value: "create", label: "Create only" },
|
|
124
|
+
{ value: "upsert", label: "Create or update by id" }
|
|
125
|
+
];
|
|
126
|
+
const props = defineProps({
|
|
127
|
+
collection: { type: String, required: true },
|
|
128
|
+
config: { type: Object, required: true }
|
|
129
|
+
});
|
|
130
|
+
const open = defineModel("open", { type: Boolean, ...{ default: false } });
|
|
131
|
+
const emit = defineEmits(["imported"]);
|
|
132
|
+
const toast = useCmsToast();
|
|
133
|
+
const csv = canTransferCsv(props.config);
|
|
134
|
+
const maxSize = formatFileSize(IMPORT_MAX_BYTES);
|
|
135
|
+
const input = ref(null);
|
|
136
|
+
const file = ref(null);
|
|
137
|
+
const text = ref("");
|
|
138
|
+
const mode = ref("create");
|
|
139
|
+
const preview = ref(null);
|
|
140
|
+
const fileErrors = ref([]);
|
|
141
|
+
const checking = ref(false);
|
|
142
|
+
const importing = ref(false);
|
|
143
|
+
const busy = computed(() => checking.value || importing.value);
|
|
144
|
+
const invalidCount = computed(() => preview.value ? preview.value.total - preview.value.valid : 0);
|
|
145
|
+
const summary = computed(() => {
|
|
146
|
+
const current = preview.value;
|
|
147
|
+
if (!current) return "";
|
|
148
|
+
if (!current.total) return "The file has no rows";
|
|
149
|
+
const parts = [
|
|
150
|
+
current.create ? `${current.create} new` : "",
|
|
151
|
+
current.update ? `${current.update} to update` : ""
|
|
152
|
+
].filter(Boolean);
|
|
153
|
+
const ready = parts.length ? parts.join(", ") : "nothing ready";
|
|
154
|
+
return invalidCount.value ? `${invalidCount.value} of ${current.total} rows have problems; fix the file and choose it again` : `${current.total} rows checked: ${ready}`;
|
|
155
|
+
});
|
|
156
|
+
const shownRows = computed(() => {
|
|
157
|
+
const rows = preview.value?.rows ?? [];
|
|
158
|
+
return invalidCount.value ? rows.filter((row) => row.errors.length) : rows;
|
|
159
|
+
});
|
|
160
|
+
const canImport = computed(
|
|
161
|
+
() => !!preview.value && !busy.value && !fileErrors.value.length && preview.value.total > 0 && !invalidCount.value
|
|
162
|
+
);
|
|
163
|
+
const importLabel = computed(() => {
|
|
164
|
+
const total = preview.value?.total ?? 0;
|
|
165
|
+
return total ? `Import ${total} ${total === 1 ? "entry" : "entries"}` : "Import";
|
|
166
|
+
});
|
|
167
|
+
function formatOf(name) {
|
|
168
|
+
return name.toLowerCase().endsWith(".csv") ? "csv" : "json";
|
|
169
|
+
}
|
|
170
|
+
function reset() {
|
|
171
|
+
file.value = null;
|
|
172
|
+
text.value = "";
|
|
173
|
+
preview.value = null;
|
|
174
|
+
fileErrors.value = [];
|
|
175
|
+
mode.value = "create";
|
|
176
|
+
if (input.value) input.value.value = "";
|
|
177
|
+
}
|
|
178
|
+
watch(open, (value) => {
|
|
179
|
+
if (value) reset();
|
|
180
|
+
});
|
|
181
|
+
function send(dryRun) {
|
|
182
|
+
return $fetch(`/api/cms/import/${props.collection}`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
query: { format: formatOf(file.value.name), mode: mode.value, dryRun: String(dryRun) },
|
|
185
|
+
body: text.value,
|
|
186
|
+
headers: { "content-type": "text/plain" }
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function readPreview(error) {
|
|
190
|
+
const data = error.data?.data;
|
|
191
|
+
return data && typeof data === "object" && Array.isArray(data.rows) ? data : null;
|
|
192
|
+
}
|
|
193
|
+
async function check() {
|
|
194
|
+
if (!file.value) return;
|
|
195
|
+
checking.value = true;
|
|
196
|
+
preview.value = null;
|
|
197
|
+
fileErrors.value = [];
|
|
198
|
+
try {
|
|
199
|
+
const result = await send(true);
|
|
200
|
+
preview.value = result;
|
|
201
|
+
fileErrors.value = result.errors;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
fileErrors.value = [errorMessage(error) ?? "The file could not be checked"];
|
|
204
|
+
} finally {
|
|
205
|
+
checking.value = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function onFile(event) {
|
|
209
|
+
const picked = event.target.files?.[0];
|
|
210
|
+
if (!picked) return;
|
|
211
|
+
file.value = picked;
|
|
212
|
+
preview.value = null;
|
|
213
|
+
if (picked.size > IMPORT_MAX_BYTES) {
|
|
214
|
+
fileErrors.value = [`The file is larger than ${maxSize}`];
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (formatOf(picked.name) === "csv" && !csv) {
|
|
218
|
+
fileErrors.value = ["This entry has blocks or many-to-many fields: use a JSON file"];
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
text.value = await picked.text();
|
|
222
|
+
await check();
|
|
223
|
+
}
|
|
224
|
+
function setMode(value) {
|
|
225
|
+
if (mode.value === value) return;
|
|
226
|
+
mode.value = value;
|
|
227
|
+
if (text.value) void check();
|
|
228
|
+
}
|
|
229
|
+
async function commit() {
|
|
230
|
+
if (!canImport.value) return;
|
|
231
|
+
importing.value = true;
|
|
232
|
+
try {
|
|
233
|
+
const result = await send(false);
|
|
234
|
+
const count = result.create + result.update;
|
|
235
|
+
toast.add({
|
|
236
|
+
title: `Imported ${count} ${count === 1 ? "entry" : "entries"}`,
|
|
237
|
+
color: "success"
|
|
238
|
+
});
|
|
239
|
+
emit("imported");
|
|
240
|
+
open.value = false;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const returned = readPreview(error);
|
|
243
|
+
if (returned) {
|
|
244
|
+
preview.value = returned;
|
|
245
|
+
fileErrors.value = returned.errors;
|
|
246
|
+
}
|
|
247
|
+
toast.add({ title: "Import failed", description: errorMessage(error), color: "error" });
|
|
248
|
+
} finally {
|
|
249
|
+
importing.value = false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CmsEntry } from '#nuxt-cms';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
collection: string;
|
|
4
|
+
config: CmsEntry;
|
|
5
|
+
};
|
|
6
|
+
type __VLS_ModelProps = {
|
|
7
|
+
'open'?: boolean;
|
|
8
|
+
};
|
|
9
|
+
type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
|
|
10
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
11
|
+
imported: () => any;
|
|
12
|
+
"update:open": (value: boolean) => any;
|
|
13
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
14
|
+
onImported?: (() => any) | undefined;
|
|
15
|
+
"onUpdate:open"?: ((value: boolean) => any) | undefined;
|
|
16
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: typeof __VLS_export;
|
|
18
|
+
export default _default;
|
|
@@ -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;
|