@xleddyl/nuxt-cms 0.1.33 → 0.1.35
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/dist/module.json +1 -1
- package/dist/module.mjs +52 -7
- package/dist/runtime/app/components/cms/EntryForm.vue +7 -2
- package/dist/runtime/shared/graphql-sdl.js +4 -4
- package/dist/runtime/shared/index.d.ts +13 -1
- package/dist/runtime/shared/index.js +16 -0
- package/dist/runtime/shared/validation.js +25 -3
- package/package.json +1 -1
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { introspectionFromSchema, buildSchema } from 'graphql';
|
|
|
10
10
|
import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
|
|
11
11
|
import { createJiti } from 'jiti';
|
|
12
12
|
import { typeName, blockTypeName, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
|
|
13
|
-
import { isMultiSelect, isTranslatableField, isTranslatableMediaField, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
13
|
+
import { isMultiSelect, fieldConditions, isTranslatableField, isTranslatableMediaField, isRequiredField, isPrivateField, DEFAULT_MEDIA_MAX_FILE_SIZE } from '../dist/runtime/shared/index.js';
|
|
14
14
|
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
15
|
|
|
16
16
|
async function collectMediaManifest(root) {
|
|
@@ -45,6 +45,7 @@ const RESERVED_ENTRY_KEYS = ["admin", "auth", "login", "media", "graphql", "cms_
|
|
|
45
45
|
const RESERVED_COLUMNS = ["id", "status", "created_at", "updated_at"];
|
|
46
46
|
const TITLE_FIELD_TYPES = ["text", "slug", "email", "number", "date", "select"];
|
|
47
47
|
const TRANSLATABLE_FIELD_TYPES = ["text", "richtext", "media"];
|
|
48
|
+
const CONDITION_FIELD_TYPES = ["select", "boolean", "text", "number", "date", "email", "slug"];
|
|
48
49
|
const RESERVED_TYPE_NAMES = [
|
|
49
50
|
"Query",
|
|
50
51
|
"Mutation",
|
|
@@ -138,6 +139,48 @@ function validateConfig(config, i18n) {
|
|
|
138
139
|
else if (new Set(field.options).size !== field.options.length)
|
|
139
140
|
errors.push(`${fat}: select options must be unique`);
|
|
140
141
|
}
|
|
142
|
+
if (field.showIf) {
|
|
143
|
+
if (entry.titleField === key)
|
|
144
|
+
errors.push(`${fat}: the titleField cannot be conditional`);
|
|
145
|
+
for (const condition of fieldConditions(field)) {
|
|
146
|
+
const cat = `${fat}, showIf on '${condition.field}'`;
|
|
147
|
+
const target = entry.fields?.[condition.field];
|
|
148
|
+
if (!condition.field || !target) {
|
|
149
|
+
errors.push(`${cat}: '${condition.field}' is not a declared field`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (condition.field === key) {
|
|
153
|
+
errors.push(`${cat}: a field cannot depend on itself`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!CONDITION_FIELD_TYPES.includes(target.type) || isMultiSelect(target)) {
|
|
157
|
+
errors.push(
|
|
158
|
+
`${cat}: showIf can only depend on ${CONDITION_FIELD_TYPES.join(
|
|
159
|
+
", "
|
|
160
|
+
)} fields (got '${target.type}')`
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (isTranslatableField(target)) {
|
|
165
|
+
errors.push(`${cat}: showIf cannot depend on a translatable field`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const values = condition.in ?? (condition.eq === void 0 ? null : [condition.eq]);
|
|
169
|
+
if (!values || !values.length) {
|
|
170
|
+
errors.push(`${cat}: showIf requires 'eq' or a non-empty 'in'`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (condition.in && condition.eq !== void 0)
|
|
174
|
+
errors.push(`${cat}: showIf accepts either 'eq' or 'in', not both`);
|
|
175
|
+
if (target.type === "select") {
|
|
176
|
+
const unknown = values.filter((value) => !target.options?.includes(String(value)));
|
|
177
|
+
if (unknown.length)
|
|
178
|
+
errors.push(
|
|
179
|
+
`${cat}: ${unknown.map((value) => `'${String(value)}'`).join(", ")} not in the options of '${condition.field}'`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
141
184
|
if (field.type === "slug" && field.from) {
|
|
142
185
|
const source = entry.fields?.[field.from];
|
|
143
186
|
if (!source) errors.push(`${fat}: slug source '${field.from}' is not a declared field`);
|
|
@@ -180,6 +223,8 @@ function validateConfig(config, i18n) {
|
|
|
180
223
|
}
|
|
181
224
|
if (blockField.private)
|
|
182
225
|
errors.push(`${bfat}: private fields are not supported inside blocks`);
|
|
226
|
+
if (blockField.showIf)
|
|
227
|
+
errors.push(`${bfat}: showIf is not supported inside blocks`);
|
|
183
228
|
if (blockField.type === "select" && !blockField.options?.length) {
|
|
184
229
|
errors.push(`${bfat}: select requires a non-empty options array`);
|
|
185
230
|
}
|
|
@@ -249,7 +294,7 @@ function columnExpr(key, field, dialect) {
|
|
|
249
294
|
let expr;
|
|
250
295
|
if (isTranslatableField(field)) {
|
|
251
296
|
expr = isTranslatableMediaField(field) ? `text('${col}')` : jsonExpr(col, dialect);
|
|
252
|
-
if (field
|
|
297
|
+
if (isRequiredField(field)) expr += ".notNull()";
|
|
253
298
|
return ` ${key}: ${expr},`;
|
|
254
299
|
}
|
|
255
300
|
switch (field.type) {
|
|
@@ -281,7 +326,7 @@ function columnExpr(key, field, dialect) {
|
|
|
281
326
|
default:
|
|
282
327
|
expr = `text('${col}')`;
|
|
283
328
|
}
|
|
284
|
-
if (field
|
|
329
|
+
if (isRequiredField(field)) expr += ".notNull()";
|
|
285
330
|
return ` ${key}: ${expr},`;
|
|
286
331
|
}
|
|
287
332
|
function isManyToMany(field) {
|
|
@@ -386,7 +431,7 @@ function fieldTsType(config, entryName, key, field) {
|
|
|
386
431
|
if (field.type === "relation") {
|
|
387
432
|
const target = typeName(field.to);
|
|
388
433
|
if (field.cardinality === "many-to-many") return `${target}[]`;
|
|
389
|
-
return field
|
|
434
|
+
return isRequiredField(field) && !config[field.to]?.drafts ? target : `${target} | null`;
|
|
390
435
|
}
|
|
391
436
|
if (field.type === "media") return "CmsMedia | null";
|
|
392
437
|
if (field.type === "select" && field.multiple) {
|
|
@@ -395,11 +440,11 @@ function fieldTsType(config, entryName, key, field) {
|
|
|
395
440
|
}
|
|
396
441
|
if (field.type === "blocks") {
|
|
397
442
|
const union = blockUnionName(entryName, key);
|
|
398
|
-
return field
|
|
443
|
+
return isRequiredField(field) ? `${union}[]` : `${union}[] | null`;
|
|
399
444
|
}
|
|
400
|
-
if (isTranslatableField(field)) return field
|
|
445
|
+
if (isTranslatableField(field)) return isRequiredField(field) ? "string" : "string | null";
|
|
401
446
|
const base = scalarTsType(field);
|
|
402
|
-
return field
|
|
447
|
+
return isRequiredField(field) ? base : `${base} | null`;
|
|
403
448
|
}
|
|
404
449
|
function blockTypesTs(entryName, key, field) {
|
|
405
450
|
const defs = [];
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
@error="emit('error')"
|
|
8
8
|
>
|
|
9
9
|
<CmsFormField
|
|
10
|
-
v-for="(field, key) in
|
|
10
|
+
v-for="(field, key) in visibleFields"
|
|
11
11
|
:key="key"
|
|
12
12
|
:label="field.label"
|
|
13
13
|
:name="key"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
</template>
|
|
44
44
|
|
|
45
45
|
<script setup>
|
|
46
|
-
import { hasTranslatableBlockFields, isTranslatableField } from "#nuxt-cms";
|
|
46
|
+
import { hasTranslatableBlockFields, isFieldVisible, isTranslatableField } from "#nuxt-cms";
|
|
47
47
|
import { computed, ref } from "#imports";
|
|
48
48
|
import { buildEntrySchema } from "../../../shared/validation";
|
|
49
49
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
@@ -58,6 +58,11 @@ const state = defineModel({ type: Object, ...{ required: true } });
|
|
|
58
58
|
const emit = defineEmits(["submit", "error"]);
|
|
59
59
|
const { i18n } = useCmsRuntime();
|
|
60
60
|
const activeLocale = ref({});
|
|
61
|
+
const visibleFields = computed(
|
|
62
|
+
() => Object.fromEntries(
|
|
63
|
+
Object.entries(props.fields).filter(([, field]) => isFieldVisible(field, state.value))
|
|
64
|
+
)
|
|
65
|
+
);
|
|
61
66
|
function hasLocaleSwitch(field) {
|
|
62
67
|
return isTranslatableField(field) || hasTranslatableBlockFields(field);
|
|
63
68
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isPrivateField, isTranslatableField } from "./index.js";
|
|
1
|
+
import { isPrivateField, isRequiredField, isTranslatableField } from "./index.js";
|
|
2
2
|
export function typeName(name) {
|
|
3
3
|
return name.replace(/(?:^|_)([a-z0-9])/gi, (_, c) => c.toUpperCase());
|
|
4
4
|
}
|
|
@@ -52,14 +52,14 @@ function fieldSdl(config, entryName, key, field) {
|
|
|
52
52
|
if (field.type === "relation") {
|
|
53
53
|
const target = typeName(field.to);
|
|
54
54
|
if (field.cardinality === "many-to-many") return ` ${key}: [${target}!]!`;
|
|
55
|
-
const nonNull = field
|
|
55
|
+
const nonNull = isRequiredField(field) && !config[field.to]?.drafts;
|
|
56
56
|
return ` ${key}: ${target}${nonNull ? "!" : ""}`;
|
|
57
57
|
}
|
|
58
58
|
if (field.type === "media") return ` ${key}: CmsMedia`;
|
|
59
59
|
if (field.type === "select" && field.multiple) return ` ${key}: [String!]!`;
|
|
60
60
|
if (field.type === "blocks")
|
|
61
|
-
return ` ${key}: [${blockUnionName(entryName, key)}!]${field
|
|
62
|
-
return ` ${key}: ${scalarFor(field)}${field
|
|
61
|
+
return ` ${key}: [${blockUnionName(entryName, key)}!]${isRequiredField(field) ? "!" : ""}`;
|
|
62
|
+
return ` ${key}: ${scalarFor(field)}${isRequiredField(field) ? "!" : ""}`;
|
|
63
63
|
}
|
|
64
64
|
function entrySdl(config, name, entry) {
|
|
65
65
|
const lines = [" id: ID!"];
|
|
@@ -42,6 +42,12 @@ export interface BlockConfig {
|
|
|
42
42
|
label: string;
|
|
43
43
|
fields: Record<string, FieldConfig>;
|
|
44
44
|
}
|
|
45
|
+
export type ConditionValue = string | number | boolean;
|
|
46
|
+
export interface FieldCondition {
|
|
47
|
+
field: string;
|
|
48
|
+
eq?: ConditionValue;
|
|
49
|
+
in?: ConditionValue[];
|
|
50
|
+
}
|
|
45
51
|
export interface FieldConfig {
|
|
46
52
|
label: string;
|
|
47
53
|
type: FieldType;
|
|
@@ -59,8 +65,12 @@ export interface FieldConfig {
|
|
|
59
65
|
to?: string;
|
|
60
66
|
cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
|
|
61
67
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
68
|
+
showIf?: FieldCondition | FieldCondition[];
|
|
62
69
|
}
|
|
63
70
|
export declare function isPrivateField(field: FieldConfig): boolean;
|
|
71
|
+
export declare function isRequiredField(field: FieldConfig): boolean;
|
|
72
|
+
export declare function fieldConditions(field: FieldConfig): FieldCondition[];
|
|
73
|
+
export declare function isFieldVisible(field: FieldConfig, values: Record<string, unknown> | null | undefined): boolean;
|
|
64
74
|
export declare function isTranslatableField(field: FieldConfig): boolean;
|
|
65
75
|
export declare function isTranslatableMediaField(field: FieldConfig): boolean;
|
|
66
76
|
export declare function decodeTranslatableValue(value: unknown, defaultLocale: string): Record<string, string> | null;
|
|
@@ -90,6 +100,7 @@ interface FieldInputBase {
|
|
|
90
100
|
label: string;
|
|
91
101
|
required?: boolean;
|
|
92
102
|
private?: boolean;
|
|
103
|
+
showIf?: FieldCondition | FieldCondition[];
|
|
93
104
|
}
|
|
94
105
|
export interface TextFieldInput extends FieldInputBase {
|
|
95
106
|
type: 'text';
|
|
@@ -137,7 +148,8 @@ export interface RelationFieldInput extends FieldInputBase {
|
|
|
137
148
|
cardinality?: 'many-to-one' | 'one-to-one' | 'many-to-many';
|
|
138
149
|
onDelete?: 'set null' | 'cascade' | 'restrict';
|
|
139
150
|
}
|
|
140
|
-
|
|
151
|
+
type BlockField<T> = Omit<T, 'private' | 'showIf'>;
|
|
152
|
+
export type BlockFieldInput = BlockField<TextFieldInput> | BlockField<RichtextFieldInput> | BlockField<NumberFieldInput> | BlockField<BooleanFieldInput> | BlockField<DateFieldInput> | BlockField<EmailFieldInput> | BlockField<SelectFieldInput> | BlockField<JsonFieldInput> | BlockField<MediaFieldInput>;
|
|
141
153
|
export interface BlockInput {
|
|
142
154
|
label: string;
|
|
143
155
|
fields: Record<string, BlockFieldInput>;
|
|
@@ -64,6 +64,22 @@ export function normalizeMediaFolder(value) {
|
|
|
64
64
|
export function isPrivateField(field) {
|
|
65
65
|
return !!field.private;
|
|
66
66
|
}
|
|
67
|
+
export function isRequiredField(field) {
|
|
68
|
+
return !!field.required && !field.showIf;
|
|
69
|
+
}
|
|
70
|
+
export function fieldConditions(field) {
|
|
71
|
+
if (!field.showIf) return [];
|
|
72
|
+
return Array.isArray(field.showIf) ? field.showIf : [field.showIf];
|
|
73
|
+
}
|
|
74
|
+
function matchesCondition(condition, value) {
|
|
75
|
+
if (condition.in) return condition.in.some((option) => option === value);
|
|
76
|
+
return condition.eq === value;
|
|
77
|
+
}
|
|
78
|
+
export function isFieldVisible(field, values) {
|
|
79
|
+
const conditions = fieldConditions(field);
|
|
80
|
+
if (!conditions.length) return true;
|
|
81
|
+
return conditions.every((condition) => matchesCondition(condition, values?.[condition.field]));
|
|
82
|
+
}
|
|
67
83
|
export function isTranslatableField(field) {
|
|
68
84
|
return !!field.translatable && (field.type === "text" || field.type === "richtext" || field.type === "media");
|
|
69
85
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { isTranslatableField } from "./index.js";
|
|
2
|
+
import { isFieldVisible, isTranslatableField } from "./index.js";
|
|
3
3
|
export const objectKeySchema = z.string().min(1).max(1024).refine((k) => !k.includes("..") && !k.startsWith("/"), "Invalid object key");
|
|
4
4
|
const DEFAULT_MESSAGES = {
|
|
5
5
|
required: "Required field",
|
|
@@ -58,10 +58,23 @@ function blocksSchema(field, i18n, m) {
|
|
|
58
58
|
);
|
|
59
59
|
return z.array(z.discriminatedUnion("type", variants));
|
|
60
60
|
}
|
|
61
|
+
function isEmptyValue(field, value, defaultLocale) {
|
|
62
|
+
if (value == null) return true;
|
|
63
|
+
if (Array.isArray(value)) return !value.length;
|
|
64
|
+
if (isTranslatableField(field)) {
|
|
65
|
+
const values = value;
|
|
66
|
+
return !values[defaultLocale]?.trim();
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === "string") return !value.trim();
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
61
71
|
export function buildEntrySchema(entry, i18n, messages) {
|
|
62
72
|
const m = { ...DEFAULT_MESSAGES, ...messages };
|
|
63
73
|
const shape = {};
|
|
64
|
-
|
|
74
|
+
const conditional = [];
|
|
75
|
+
for (const [key, declared] of Object.entries(entry.fields)) {
|
|
76
|
+
const field = declared.showIf ? { ...declared, required: false } : declared;
|
|
77
|
+
if (declared.showIf && declared.required) conditional.push([key, declared]);
|
|
65
78
|
if (field.type === "relation" && field.cardinality === "many-to-many") {
|
|
66
79
|
const list = z.array(z.string().min(1));
|
|
67
80
|
shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? []);
|
|
@@ -91,5 +104,14 @@ export function buildEntrySchema(entry, i18n, messages) {
|
|
|
91
104
|
if (entry.drafts) {
|
|
92
105
|
shape.status = z.enum(["draft", "published"]).nullish().transform((v) => v ?? void 0);
|
|
93
106
|
}
|
|
94
|
-
|
|
107
|
+
const schema = z.object(shape);
|
|
108
|
+
if (!conditional.length) return schema;
|
|
109
|
+
return schema.superRefine((values, ctx) => {
|
|
110
|
+
for (const [key, field] of conditional) {
|
|
111
|
+
if (!isFieldVisible(field, values)) continue;
|
|
112
|
+
if (!isEmptyValue(field, values[key], i18n.defaultLocale))
|
|
113
|
+
continue;
|
|
114
|
+
ctx.addIssue({ code: "custom", path: [key], message: m.required });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
95
117
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.35",
|
|
4
4
|
"description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Edoardo Alberti (https://github.com/xleddyl)",
|