@xleddyl/nuxt-cms 0.7.0 → 0.8.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/dist/module.json +1 -1
- package/dist/module.mjs +30 -6
- package/dist/runtime/app/components/cms/FieldInput.vue +9 -0
- package/dist/runtime/app/components/cms/TagsField.d.vue.ts +18 -0
- package/dist/runtime/app/components/cms/TagsField.vue +72 -0
- package/dist/runtime/app/components/cms/TagsField.vue.d.ts +18 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue +2 -0
- package/dist/runtime/app/pages/admin-collection.vue +2 -0
- package/dist/runtime/app/utils/ui.js +1 -0
- package/dist/runtime/server/api/tags.get.d.ts +2 -0
- package/dist/runtime/server/api/tags.get.js +26 -0
- package/dist/runtime/server/utils/graphql.js +47 -3
- package/dist/runtime/server/utils/list-filter.d.ts +14 -0
- package/dist/runtime/server/utils/list-filter.js +47 -0
- package/dist/runtime/server/utils/search.js +12 -1
- package/dist/runtime/shared/graphql-sdl.d.ts +1 -0
- package/dist/runtime/shared/graphql-sdl.js +11 -6
- package/dist/runtime/shared/index.d.ts +14 -2
- package/dist/runtime/shared/index.js +40 -0
- package/dist/runtime/shared/transfer.js +11 -5
- package/dist/runtime/shared/validation.d.ts +2 -0
- package/dist/runtime/shared/validation.js +24 -3
- package/package.json +2 -2
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal'
|
|
|
9
9
|
import { createJiti } from 'jiti';
|
|
10
10
|
import { typeName, blockTypeName as blockTypeName$1, blocksFieldTypeName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
|
|
11
11
|
import { migrationsDirFor } from '../dist/runtime/shared/migrations-dir.js';
|
|
12
|
-
import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField,
|
|
12
|
+
import { entryFieldsFor, isPrivateField, blockTypeName, pageColumnFields, isTranslatableField, isTranslatableMediaField, isStringListField, pageAllFields, normalizeContentEntries, PAGE_PATH_FIELD, pageRoutes, isCollectionKind, isMultiSelect, layoutErrors, listErrors, mobileMediaKey, fieldConditions, selectOptionValues, CONTENT_SLUG_FIELD, pageFieldsTableName, pageMediaTableName, CONTENT_SYSTEM_FIELDS, isRequiredField, pageFields, mediaTypeFilter, DEFAULT_MEDIA_MAX_FILE_SIZE, normalizeCmsConfig } from '../dist/runtime/shared/index.js';
|
|
13
13
|
import { previewPathErrors, DEFAULT_CMS_PREVIEW_PATH } from '../dist/runtime/shared/preview.js';
|
|
14
14
|
import { scanMediaDirectory, readMediaFileMeta } from '../dist/runtime/server/utils/media-sync.js';
|
|
15
15
|
import { createHash } from 'node:crypto';
|
|
@@ -264,7 +264,8 @@ const RESERVED_TYPE_NAMES = [
|
|
|
264
264
|
"IntFilter",
|
|
265
265
|
"FloatFilter",
|
|
266
266
|
"StringFilter",
|
|
267
|
-
"BooleanFilter"
|
|
267
|
+
"BooleanFilter",
|
|
268
|
+
"StringListFilter"
|
|
268
269
|
];
|
|
269
270
|
function validateConfig(config, i18n) {
|
|
270
271
|
const errors = [];
|
|
@@ -484,8 +485,13 @@ function validateConfig(config, i18n) {
|
|
|
484
485
|
else if (isTranslatableField(source) && !(entry.kind === "content" && key === CONTENT_SLUG_FIELD))
|
|
485
486
|
errors.push(`${fat}: slug source '${field.from}' cannot be translatable`);
|
|
486
487
|
}
|
|
487
|
-
if (field.type
|
|
488
|
-
errors.push(
|
|
488
|
+
if (field.type === "tags") {
|
|
489
|
+
errors.push(...tagsOptionErrors(fat, field));
|
|
490
|
+
} else if (field.suggestions !== void 0) {
|
|
491
|
+
errors.push(`${fat}: suggestions is only supported on tags fields`);
|
|
492
|
+
}
|
|
493
|
+
if (field.type !== "blocks" && field.type !== "tags" && (field.min !== void 0 || field.max !== void 0))
|
|
494
|
+
errors.push(`${fat}: min and max are only supported on blocks and tags fields`);
|
|
489
495
|
if (field.type === "blocks") {
|
|
490
496
|
if (field.translatable) errors.push(`${fat}: blocks fields cannot be translatable`);
|
|
491
497
|
errors.push(...blocksCountErrors(fat, field));
|
|
@@ -505,7 +511,7 @@ function validateConfig(config, i18n) {
|
|
|
505
511
|
errors.push(`${bfat}: key must be a valid identifier`);
|
|
506
512
|
if (blockFieldKey === "type")
|
|
507
513
|
errors.push(`${bfat}: 'type' is reserved inside blocks`);
|
|
508
|
-
if (["relation", "blocks", "slug"].includes(blockField.type)) {
|
|
514
|
+
if (["relation", "blocks", "slug", "tags"].includes(blockField.type)) {
|
|
509
515
|
errors.push(
|
|
510
516
|
`${bfat}: ${blockField.type} fields are not supported inside blocks`
|
|
511
517
|
);
|
|
@@ -659,6 +665,15 @@ function blocksCountErrors(at, field) {
|
|
|
659
665
|
errors.push(`${at}: min (${field.min}) cannot be greater than max (${field.max})`);
|
|
660
666
|
return errors;
|
|
661
667
|
}
|
|
668
|
+
function tagsOptionErrors(at, field) {
|
|
669
|
+
const errors = [];
|
|
670
|
+
if (field.min !== void 0) errors.push(`${at}: min is not supported on tags fields`);
|
|
671
|
+
if (field.max !== void 0 && !(typeof field.max === "number" && Number.isInteger(field.max) && field.max >= 1))
|
|
672
|
+
errors.push(`${at}: max must be an integer of 1 or more`);
|
|
673
|
+
if (field.suggestions !== void 0 && typeof field.suggestions !== "boolean")
|
|
674
|
+
errors.push(`${at}: suggestions must be a boolean`);
|
|
675
|
+
return errors;
|
|
676
|
+
}
|
|
662
677
|
function blockMetaErrors(at, block) {
|
|
663
678
|
const errors = [];
|
|
664
679
|
if (block.validate !== void 0 && typeof block.validate !== "function")
|
|
@@ -739,6 +754,9 @@ function columnExpr(key, field, dialect, table) {
|
|
|
739
754
|
case "select":
|
|
740
755
|
expr = field.multiple ? jsonExpr(col, dialect) : `text('${col}')`;
|
|
741
756
|
break;
|
|
757
|
+
case "tags":
|
|
758
|
+
expr = jsonExpr(col, dialect);
|
|
759
|
+
break;
|
|
742
760
|
case "slug":
|
|
743
761
|
expr = `text('${col}').unique()`;
|
|
744
762
|
break;
|
|
@@ -865,7 +883,7 @@ function renderSchemaFile(config, dialect, resolveImport = (s) => s) {
|
|
|
865
883
|
if (pg && fields.some((f) => f.type === "date")) core.add("date");
|
|
866
884
|
if (pg) core.add("boolean");
|
|
867
885
|
if (pg && fields.some(
|
|
868
|
-
(f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) && !isTranslatableMediaField(f) ||
|
|
886
|
+
(f) => f.type === "json" || f.type === "blocks" || isTranslatableField(f) && !isTranslatableMediaField(f) || isStringListField(f)
|
|
869
887
|
))
|
|
870
888
|
core.add("jsonb");
|
|
871
889
|
if (pg) core.add("timestamp");
|
|
@@ -1049,6 +1067,7 @@ function fieldTsType(config, entryName, key, field) {
|
|
|
1049
1067
|
return isRequiredField(field) && !config[field.to]?.drafts ? target : `${target} | null`;
|
|
1050
1068
|
}
|
|
1051
1069
|
if (field.type === "media") return `${mediaTsType(field)} | null`;
|
|
1070
|
+
if (field.type === "tags") return "string[]";
|
|
1052
1071
|
if (field.type === "select" && field.multiple) {
|
|
1053
1072
|
const union = selectOptionValues(field).map((o) => JSON.stringify(o)).join(" | ");
|
|
1054
1073
|
return `(${union})[]`;
|
|
@@ -2104,6 +2123,11 @@ const module$1 = defineNuxtModule({
|
|
|
2104
2123
|
method: "delete",
|
|
2105
2124
|
handler: resolver.resolve("./runtime/server/api/settings.delete")
|
|
2106
2125
|
});
|
|
2126
|
+
addServerHandler({
|
|
2127
|
+
route: "/api/cms/tags/:collection/:field",
|
|
2128
|
+
method: "get",
|
|
2129
|
+
handler: resolver.resolve("./runtime/server/api/tags.get")
|
|
2130
|
+
});
|
|
2107
2131
|
addServerHandler({
|
|
2108
2132
|
route: "/api/cms/export/:collection",
|
|
2109
2133
|
method: "get",
|
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
:options="selectItems"
|
|
37
37
|
:placeholder="field.placeholder ?? 'Select\u2026'"
|
|
38
38
|
/>
|
|
39
|
+
<CmsTagsField
|
|
40
|
+
v-else-if="field.type === 'tags'"
|
|
41
|
+
v-model="model"
|
|
42
|
+
:max="field.max"
|
|
43
|
+
:placeholder="field.placeholder"
|
|
44
|
+
:suggestions="field.suggestions"
|
|
45
|
+
:entry-name="entryName"
|
|
46
|
+
:field-key="fieldKey"
|
|
47
|
+
/>
|
|
39
48
|
<KSelect
|
|
40
49
|
v-else-if="field.type === 'select'"
|
|
41
50
|
v-model="singleValue"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
max?: number;
|
|
3
|
+
placeholder?: string;
|
|
4
|
+
suggestions?: boolean;
|
|
5
|
+
entryName?: string;
|
|
6
|
+
fieldKey?: string;
|
|
7
|
+
};
|
|
8
|
+
type __VLS_ModelProps = {
|
|
9
|
+
modelValue: unknown;
|
|
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: unknown) => any;
|
|
14
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
15
|
+
"onUpdate:modelValue"?: ((value: unknown) => any) | undefined;
|
|
16
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: typeof __VLS_export;
|
|
18
|
+
export default _default;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<KCombobox
|
|
3
|
+
v-model="tags"
|
|
4
|
+
multiple
|
|
5
|
+
open-on-focus
|
|
6
|
+
:search="search"
|
|
7
|
+
:debounce="0"
|
|
8
|
+
:max-selected="max"
|
|
9
|
+
:placeholder="placeholder ?? 'Add a tag\u2026'"
|
|
10
|
+
:empty-text="emptyText"
|
|
11
|
+
@input="onInput"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup>
|
|
16
|
+
import { canAddTag, normalizeTags, tagKey } from "#nuxt-cms";
|
|
17
|
+
import { Combobox as KCombobox } from "@xleddyl/443-components-vue";
|
|
18
|
+
import { computed, onMounted, ref } from "#imports";
|
|
19
|
+
const SUGGESTION_LIMIT = 50;
|
|
20
|
+
const props = defineProps({
|
|
21
|
+
max: { type: Number, required: false },
|
|
22
|
+
placeholder: { type: String, required: false },
|
|
23
|
+
suggestions: { type: Boolean, required: false },
|
|
24
|
+
entryName: { type: String, required: false },
|
|
25
|
+
fieldKey: { type: String, required: false }
|
|
26
|
+
});
|
|
27
|
+
const model = defineModel({ type: null, ...{ required: true } });
|
|
28
|
+
const known = ref([]);
|
|
29
|
+
const tags = computed({
|
|
30
|
+
get: () => Array.isArray(model.value) ? normalizeTags(model.value) : [],
|
|
31
|
+
set: (value) => {
|
|
32
|
+
const next = normalizeTags(Array.isArray(value) ? value : []);
|
|
33
|
+
model.value = props.max === void 0 ? next : next.slice(0, props.max);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const full = computed(() => !canAddTag({ max: props.max }, tags.value.length));
|
|
37
|
+
const emptyText = computed(
|
|
38
|
+
() => full.value ? `At most ${props.max} tags` : "Type a tag and press Enter"
|
|
39
|
+
);
|
|
40
|
+
function search(query) {
|
|
41
|
+
if (full.value) return [];
|
|
42
|
+
const taken = new Set(tags.value.map(tagKey));
|
|
43
|
+
const typed = query.trim();
|
|
44
|
+
const needle = tagKey(typed);
|
|
45
|
+
const matches = known.value.filter((tag) => !taken.has(tagKey(tag)) && tagKey(tag).includes(needle)).filter((tag) => tagKey(tag) !== needle).slice(0, SUGGESTION_LIMIT);
|
|
46
|
+
const options = matches.map((tag) => ({ label: tag, value: tag }));
|
|
47
|
+
if (!needle || taken.has(needle)) return options;
|
|
48
|
+
const existing = known.value.find((tag) => tagKey(tag) === needle);
|
|
49
|
+
return [{ label: existing ?? typed, value: existing ?? typed }, ...options];
|
|
50
|
+
}
|
|
51
|
+
function onInput(event) {
|
|
52
|
+
const input = event.target;
|
|
53
|
+
if (!input.value.includes(",")) return;
|
|
54
|
+
const parts = input.value.split(",");
|
|
55
|
+
const rest = parts.pop() ?? "";
|
|
56
|
+
tags.value = [...tags.value, ...parts];
|
|
57
|
+
input.value = rest.trimStart();
|
|
58
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
59
|
+
}
|
|
60
|
+
onMounted(async () => {
|
|
61
|
+
if (props.suggestions === false || !props.entryName || !props.fieldKey) return;
|
|
62
|
+
try {
|
|
63
|
+
known.value = await $fetch(
|
|
64
|
+
`/api/cms/tags/${encodeURIComponent(props.entryName)}/${encodeURIComponent(
|
|
65
|
+
props.fieldKey
|
|
66
|
+
)}`
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
known.value = [];
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
max?: number;
|
|
3
|
+
placeholder?: string;
|
|
4
|
+
suggestions?: boolean;
|
|
5
|
+
entryName?: string;
|
|
6
|
+
fieldKey?: string;
|
|
7
|
+
};
|
|
8
|
+
type __VLS_ModelProps = {
|
|
9
|
+
modelValue: unknown;
|
|
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: unknown) => any;
|
|
14
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
|
|
15
|
+
"onUpdate:modelValue"?: ((value: unknown) => any) | undefined;
|
|
16
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: typeof __VLS_export;
|
|
18
|
+
export default _default;
|
|
@@ -504,6 +504,8 @@ function displayValue(field, key, value) {
|
|
|
504
504
|
return formatDateTime(value);
|
|
505
505
|
case "select":
|
|
506
506
|
return truncate(selectDisplayValue(field, value));
|
|
507
|
+
case "tags":
|
|
508
|
+
return Array.isArray(value) ? truncate(value.join(", ")) : "";
|
|
507
509
|
case "blocks":
|
|
508
510
|
return `${value.length} \u25A4`;
|
|
509
511
|
case "relation": {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createError, defineEventHandler, getRouterParam } from "h3";
|
|
2
|
+
import { useDb } from "#cms-db";
|
|
3
|
+
import { distinctTags, entryFieldsFor } from "../../shared/index.js";
|
|
4
|
+
import { readPages } from "../utils/page-storage.js";
|
|
5
|
+
import { getRegistryEntry, tableColumns } from "../utils/registry.js";
|
|
6
|
+
import { requireAdmin } from "../utils/require-admin.js";
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
await requireAdmin(event);
|
|
9
|
+
const { name, entry, table } = getRegistryEntry(event);
|
|
10
|
+
const key = getRouterParam(event, "field") ?? "";
|
|
11
|
+
const fields = entryFieldsFor(entry);
|
|
12
|
+
const field = Object.hasOwn(fields, key) ? fields[key] : void 0;
|
|
13
|
+
if (!field || field.type !== "tags") {
|
|
14
|
+
throw createError({ statusCode: 404, statusMessage: `Unknown tags field: ${key}` });
|
|
15
|
+
}
|
|
16
|
+
if (field.suggestions === false) return [];
|
|
17
|
+
if (entry.kind === "page") {
|
|
18
|
+
const rows2 = await readPages(name, entry, table);
|
|
19
|
+
return distinctTags(rows2.map((row) => row[key]));
|
|
20
|
+
}
|
|
21
|
+
const columns = tableColumns(table);
|
|
22
|
+
if (!Object.hasOwn(columns, key)) return [];
|
|
23
|
+
const column = columns[key];
|
|
24
|
+
const rows = await useDb().select({ value: column }).from(table);
|
|
25
|
+
return distinctTags(rows.map((row) => row.value));
|
|
26
|
+
});
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
decodeTranslatableMedia,
|
|
26
26
|
entryFieldsFor,
|
|
27
27
|
hasTranslatableBlockFields,
|
|
28
|
+
isMultiValueField,
|
|
28
29
|
isPrivateField,
|
|
29
30
|
isTranslatableMediaField,
|
|
30
31
|
localizeBlocks,
|
|
@@ -43,6 +44,12 @@ import {
|
|
|
43
44
|
import { normalizeTimestampFields } from "../../shared/timestamps.js";
|
|
44
45
|
import { useMediaIndex } from "./media-index.js";
|
|
45
46
|
import { readPages } from "./page-storage.js";
|
|
47
|
+
import {
|
|
48
|
+
isListFilterOperator,
|
|
49
|
+
joinListCondition,
|
|
50
|
+
jsonListCondition,
|
|
51
|
+
listFilterValues
|
|
52
|
+
} from "./list-filter.js";
|
|
46
53
|
import { getContentI18n, resolveTable, tableColumns } from "./registry.js";
|
|
47
54
|
import { visibilityConditions } from "./visibility.js";
|
|
48
55
|
const MAX_LIMIT = 100;
|
|
@@ -141,11 +148,42 @@ function columnFor(table, key) {
|
|
|
141
148
|
const columns = tableColumns(table);
|
|
142
149
|
return Object.hasOwn(columns, key) ? columns[key] : void 0;
|
|
143
150
|
}
|
|
144
|
-
function
|
|
151
|
+
function joinTableFor(name, key) {
|
|
152
|
+
const join = cmsTables[`${name}_${key}`];
|
|
153
|
+
if (!join) throw new GraphQLError(`No join table for ${name}.${key}`);
|
|
154
|
+
const columns = tableColumns(join);
|
|
155
|
+
return { table: join, sourceId: columns.sourceId, targetId: columns.targetId };
|
|
156
|
+
}
|
|
157
|
+
function listConditions(name, table, key, field, ops) {
|
|
158
|
+
const conditions = [];
|
|
159
|
+
for (const [op, value] of Object.entries(ops)) {
|
|
160
|
+
if (value === void 0 || value === null) continue;
|
|
161
|
+
if (!isListFilterOperator(op)) throw new GraphQLError(`Unknown operator for ${key}: ${op}`);
|
|
162
|
+
if (op !== "isEmpty" && !listFilterValues(value).length)
|
|
163
|
+
throw new GraphQLError(`Empty ${op} list for ${key}`);
|
|
164
|
+
if (field.type === "relation") {
|
|
165
|
+
const id = columnFor(table, "id");
|
|
166
|
+
if (!id) throw new GraphQLError(`Unknown filter field: ${key}`);
|
|
167
|
+
conditions.push(joinListCondition(joinTableFor(name, key), id, op, value));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const column = columnFor(table, key);
|
|
171
|
+
if (!column) throw new GraphQLError(`Unknown filter field: ${key}`);
|
|
172
|
+
conditions.push(jsonListCondition(cmsDialect, column, op, value));
|
|
173
|
+
}
|
|
174
|
+
return conditions;
|
|
175
|
+
}
|
|
176
|
+
function filterConditions(name, entry, table, filters) {
|
|
145
177
|
if (!filters) return [];
|
|
146
178
|
const conditions = [];
|
|
179
|
+
const fields = entryFieldsFor(entry);
|
|
147
180
|
for (const [key, ops] of Object.entries(filters)) {
|
|
148
181
|
if (!ops) continue;
|
|
182
|
+
const field = Object.hasOwn(fields, key) ? fields[key] : void 0;
|
|
183
|
+
if (field && isMultiValueField(field)) {
|
|
184
|
+
conditions.push(...listConditions(name, table, key, field, ops));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
149
187
|
const column = columnFor(table, key);
|
|
150
188
|
if (!column) throw new GraphQLError(`Unknown filter field: ${key}`);
|
|
151
189
|
for (const [op, value] of Object.entries(ops)) {
|
|
@@ -353,7 +391,10 @@ export function buildCmsSchema() {
|
|
|
353
391
|
queryResolvers[name] = async (_, args) => {
|
|
354
392
|
const locale = resolveLocaleArg(args.locale);
|
|
355
393
|
const table = tableFor(name);
|
|
356
|
-
const where = [
|
|
394
|
+
const where = [
|
|
395
|
+
...publishedFilter(entry, table),
|
|
396
|
+
...filterConditions(name, entry, table, args.filters)
|
|
397
|
+
];
|
|
357
398
|
const limit = Math.min(MAX_LIMIT, Math.max(0, args.limit ?? DEFAULT_LIMIT));
|
|
358
399
|
const offset = Math.max(0, args.offset ?? 0);
|
|
359
400
|
const rows = await useDb().select().from(table).where(where.length ? and(...where) : void 0).orderBy(...sortOrder(entry, table, args.sort)).limit(limit).offset(offset);
|
|
@@ -376,7 +417,10 @@ export function buildCmsSchema() {
|
|
|
376
417
|
}
|
|
377
418
|
queryResolvers[`${name}Count`] = async (_, args) => {
|
|
378
419
|
const table = tableFor(name);
|
|
379
|
-
const where = [
|
|
420
|
+
const where = [
|
|
421
|
+
...publishedFilter(entry, table),
|
|
422
|
+
...filterConditions(name, entry, table, args.filters)
|
|
423
|
+
];
|
|
380
424
|
const [counted] = await useDb().select({ total: sql`count(*)` }).from(table).where(where.length ? and(...where) : void 0);
|
|
381
425
|
return counted?.total ?? 0;
|
|
382
426
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SQL } from 'drizzle-orm';
|
|
2
|
+
import type { AnySQLiteColumn, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
|
+
export type ListFilterDialect = 'sqlite' | 'postgres';
|
|
4
|
+
export declare const LIST_FILTER_OPERATORS: readonly ["hasAny", "hasAll", "isEmpty"];
|
|
5
|
+
export type ListFilterOperator = (typeof LIST_FILTER_OPERATORS)[number];
|
|
6
|
+
export interface JoinTable {
|
|
7
|
+
table: SQLiteTable;
|
|
8
|
+
sourceId: AnySQLiteColumn;
|
|
9
|
+
targetId: AnySQLiteColumn;
|
|
10
|
+
}
|
|
11
|
+
export declare function isListFilterOperator(op: string): op is ListFilterOperator;
|
|
12
|
+
export declare function listFilterValues(value: unknown): string[];
|
|
13
|
+
export declare function jsonListCondition(dialect: ListFilterDialect, column: AnySQLiteColumn, op: ListFilterOperator, value: unknown): SQL;
|
|
14
|
+
export declare function joinListCondition(join: JoinTable, id: AnySQLiteColumn, op: ListFilterOperator, value: unknown): SQL;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
export const LIST_FILTER_OPERATORS = ["hasAny", "hasAll", "isEmpty"];
|
|
3
|
+
export function isListFilterOperator(op) {
|
|
4
|
+
return LIST_FILTER_OPERATORS.includes(op);
|
|
5
|
+
}
|
|
6
|
+
export function listFilterValues(value) {
|
|
7
|
+
if (!Array.isArray(value)) return [];
|
|
8
|
+
return [...new Set(value.map((item) => String(item)))];
|
|
9
|
+
}
|
|
10
|
+
function valueList(values) {
|
|
11
|
+
return sql`(${sql.join(
|
|
12
|
+
values.map((value) => sql`${value}`),
|
|
13
|
+
sql`, `
|
|
14
|
+
)})`;
|
|
15
|
+
}
|
|
16
|
+
export function jsonListCondition(dialect, column, op, value) {
|
|
17
|
+
if (op === "isEmpty") {
|
|
18
|
+
const length = dialect === "postgres" ? sql`coalesce(jsonb_array_length(${column}), 0)` : sql`coalesce(json_array_length(${column}), 0)`;
|
|
19
|
+
return value ? sql`${length} = 0` : sql`${length} > 0`;
|
|
20
|
+
}
|
|
21
|
+
const values = listFilterValues(value);
|
|
22
|
+
const list = valueList(values);
|
|
23
|
+
if (dialect === "postgres") {
|
|
24
|
+
if (op === "hasAll") return sql`${column} @> ${JSON.stringify(values)}::jsonb`;
|
|
25
|
+
const items2 = sql`jsonb_array_elements_text(${column}) as cms_item(value)`;
|
|
26
|
+
return sql`exists (select 1 from ${items2} where cms_item.value in ${list})`;
|
|
27
|
+
}
|
|
28
|
+
const items = sql`json_each(${column})`;
|
|
29
|
+
if (op === "hasAll") {
|
|
30
|
+
const count = sql`select count(distinct json_each.value) from ${items}`;
|
|
31
|
+
return sql`(${count} where json_each.value in ${list}) = ${sql.raw(String(values.length))}`;
|
|
32
|
+
}
|
|
33
|
+
return sql`exists (select 1 from ${items} where json_each.value in ${list})`;
|
|
34
|
+
}
|
|
35
|
+
export function joinListCondition(join, id, op, value) {
|
|
36
|
+
if (op === "isEmpty") {
|
|
37
|
+
const linked = sql`exists (select 1 from ${join.table} where ${join.sourceId} = ${id})`;
|
|
38
|
+
return value ? sql`not ${linked}` : linked;
|
|
39
|
+
}
|
|
40
|
+
const values = listFilterValues(value);
|
|
41
|
+
const matching = sql`${join.sourceId} = ${id} and ${join.targetId} in ${valueList(values)}`;
|
|
42
|
+
if (op === "hasAll") {
|
|
43
|
+
const count = sql`select count(distinct ${join.targetId}) from ${join.table}`;
|
|
44
|
+
return sql`(${count} where ${matching}) = ${sql.raw(String(values.length))}`;
|
|
45
|
+
}
|
|
46
|
+
return sql`exists (select 1 from ${join.table} where ${matching})`;
|
|
47
|
+
}
|
|
@@ -26,7 +26,16 @@ import {
|
|
|
26
26
|
stripMarkup
|
|
27
27
|
} from "../../shared/search.js";
|
|
28
28
|
import { decodePageRows, selectPages } from "./page-rows.js";
|
|
29
|
-
const VALUE_TYPES = /* @__PURE__ */ new Set([
|
|
29
|
+
const VALUE_TYPES = /* @__PURE__ */ new Set([
|
|
30
|
+
"text",
|
|
31
|
+
"richtext",
|
|
32
|
+
"email",
|
|
33
|
+
"slug",
|
|
34
|
+
"select",
|
|
35
|
+
"tags",
|
|
36
|
+
"json",
|
|
37
|
+
"blocks"
|
|
38
|
+
]);
|
|
30
39
|
function isSearchableField(field) {
|
|
31
40
|
return VALUE_TYPES.has(field.type);
|
|
32
41
|
}
|
|
@@ -57,6 +66,8 @@ function fieldTexts(field, value, defaultLocale) {
|
|
|
57
66
|
}).join(", ");
|
|
58
67
|
return [{ locale: null, text }];
|
|
59
68
|
}
|
|
69
|
+
if (field.type === "tags" && Array.isArray(value))
|
|
70
|
+
return value.length ? [{ locale: null, text: value.map(String).join(", ") }] : [];
|
|
60
71
|
if (typeof value === "string") return [{ locale: null, text: plainText(field, value) }];
|
|
61
72
|
return [{ locale: null, text: JSON.stringify(value) }];
|
|
62
73
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CmsConfig } from './index.js';
|
|
2
2
|
import { blockTypeName, blocksFieldTypeName, typeName } from './index.js';
|
|
3
3
|
export { blockTypeName, blocksFieldTypeName, typeName };
|
|
4
|
+
export declare const LIST_FILTER = "StringListFilter";
|
|
4
5
|
export declare function renderGraphqlSdl(config: CmsConfig): string;
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
blockTypeName,
|
|
4
4
|
blocksFieldTypeName,
|
|
5
5
|
isCollectionKind,
|
|
6
|
+
isMultiValueField,
|
|
6
7
|
isPrivateField,
|
|
7
8
|
isRequiredField,
|
|
8
9
|
isTranslatableField,
|
|
@@ -13,6 +14,7 @@ export { blockTypeName, blocksFieldTypeName, typeName };
|
|
|
13
14
|
function entryFields(entry) {
|
|
14
15
|
return entry.kind === "page" ? pageAllFields(entry) : entry.fields;
|
|
15
16
|
}
|
|
17
|
+
export const LIST_FILTER = "StringListFilter";
|
|
16
18
|
function scalarFor(field) {
|
|
17
19
|
switch (field.type) {
|
|
18
20
|
case "number":
|
|
@@ -27,15 +29,12 @@ function scalarFor(field) {
|
|
|
27
29
|
}
|
|
28
30
|
function filterScalarFor(field) {
|
|
29
31
|
if (isPrivateField(field) || isTranslatableField(field)) return null;
|
|
32
|
+
if (isMultiValueField(field)) return LIST_FILTER;
|
|
30
33
|
switch (field.type) {
|
|
31
34
|
case "json":
|
|
32
35
|
return null;
|
|
33
36
|
case "blocks":
|
|
34
37
|
return null;
|
|
35
|
-
case "select":
|
|
36
|
-
return field.multiple ? null : "StringFilter";
|
|
37
|
-
case "relation":
|
|
38
|
-
return field.cardinality === "many-to-many" ? null : "StringFilter";
|
|
39
38
|
case "number":
|
|
40
39
|
return field.integer ? "IntFilter" : "FloatFilter";
|
|
41
40
|
case "boolean":
|
|
@@ -61,7 +60,8 @@ function fieldSdl(config, entryName, key, field) {
|
|
|
61
60
|
return ` ${key}: ${target}${nonNull ? "!" : ""}`;
|
|
62
61
|
}
|
|
63
62
|
if (field.type === "media") return ` ${key}: CmsMedia`;
|
|
64
|
-
if (field.type === "select" && field.multiple)
|
|
63
|
+
if (field.type === "tags" || field.type === "select" && field.multiple)
|
|
64
|
+
return ` ${key}: [String!]!`;
|
|
65
65
|
if (field.type === "blocks")
|
|
66
66
|
return ` ${key}: [${blocksFieldTypeName(entryName, key)}!]${isRequiredField(field) ? "!" : ""}`;
|
|
67
67
|
return ` ${key}: ${scalarFor(field)}${isRequiredField(field) ? "!" : ""}`;
|
|
@@ -120,7 +120,7 @@ function filterSdl(name, entry) {
|
|
|
120
120
|
const gqlType = typeName(name);
|
|
121
121
|
const keys = filterableKeys(entry);
|
|
122
122
|
const filters = keys.map(([key, scalar]) => ` ${key}: ${scalar}`).join("\n");
|
|
123
|
-
const sortFields = keys.map(([key]) => ` ${key}`).join("\n");
|
|
123
|
+
const sortFields = keys.filter(([, scalar]) => scalar !== LIST_FILTER).map(([key]) => ` ${key}`).join("\n");
|
|
124
124
|
return [
|
|
125
125
|
`input ${gqlType}Filters {
|
|
126
126
|
${filters}
|
|
@@ -141,6 +141,11 @@ const COMMON_SDL = [
|
|
|
141
141
|
"input FloatFilter {\n eq: Float\n neq: Float\n gt: Float\n gte: Float\n lt: Float\n lte: Float\n in: [Float!]\n isNull: Boolean\n}",
|
|
142
142
|
"input StringFilter {\n eq: String\n neq: String\n gt: String\n gte: String\n lt: String\n lte: String\n like: String\n in: [String!]\n isNull: Boolean\n}",
|
|
143
143
|
"input BooleanFilter {\n eq: Boolean\n neq: Boolean\n isNull: Boolean\n}",
|
|
144
|
+
`input ${LIST_FILTER} {
|
|
145
|
+
hasAny: [String!]
|
|
146
|
+
hasAll: [String!]
|
|
147
|
+
isEmpty: Boolean
|
|
148
|
+
}`,
|
|
144
149
|
"enum CmsMediaType {\n image\n video\n file\n}",
|
|
145
150
|
"type CmsMedia {\n key: String!\n url: String\n type: CmsMediaType!\n alt: String\n folder: String\n mime: String\n size: Int\n width: Int\n height: Int\n}"
|
|
146
151
|
];
|
|
@@ -2,7 +2,7 @@ import type { PgTable } from 'drizzle-orm/pg-core';
|
|
|
2
2
|
import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
3
|
import type { CmsLayoutBlock, CmsListInput } from './layout.js';
|
|
4
4
|
export type CmsTable = SQLiteTable | PgTable;
|
|
5
|
-
export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'slug' | 'select' | 'json' | 'media' | 'relation' | 'blocks';
|
|
5
|
+
export type FieldType = 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'datetime' | 'email' | 'slug' | 'select' | 'tags' | 'json' | 'media' | 'relation' | 'blocks';
|
|
6
6
|
export interface CmsI18n {
|
|
7
7
|
locales: string[];
|
|
8
8
|
defaultLocale: string;
|
|
@@ -141,6 +141,7 @@ export interface FieldConfig {
|
|
|
141
141
|
translatable?: boolean;
|
|
142
142
|
options?: readonly SelectOptionInput[];
|
|
143
143
|
multiple?: boolean;
|
|
144
|
+
suggestions?: boolean;
|
|
144
145
|
from?: string;
|
|
145
146
|
blocks?: Record<string, BlockConfig>;
|
|
146
147
|
min?: number;
|
|
@@ -169,6 +170,12 @@ export declare function translatableMediaKeys(entry: EntryLike): string[];
|
|
|
169
170
|
export declare function encodeEntryTranslatableMedia(entry: EntryLike, values: Record<string, unknown>): Record<string, unknown>;
|
|
170
171
|
export declare function decodeEntryTranslatableMedia<T extends Record<string, unknown>>(entry: EntryLike, rows: T[], defaultLocale: string): T[];
|
|
171
172
|
export declare function isMultiSelect(field: FieldConfig): boolean;
|
|
173
|
+
export declare function isStringListField(field: FieldConfig): boolean;
|
|
174
|
+
export declare function isMultiValueField(field: FieldConfig): boolean;
|
|
175
|
+
export declare function tagKey(value: string): string;
|
|
176
|
+
export declare function normalizeTags(values: readonly unknown[]): string[];
|
|
177
|
+
export declare function distinctTags(values: readonly unknown[]): string[];
|
|
178
|
+
export declare function canAddTag(field: Pick<FieldConfig, 'max'>, count: number): boolean;
|
|
172
179
|
export declare function translatableFieldKeys(entry: EntryLike): string[];
|
|
173
180
|
export declare function translatableBlockFieldKeys(block: BlockConfig): string[];
|
|
174
181
|
export declare function hasTranslatableBlockFields(field: FieldConfig): boolean;
|
|
@@ -303,6 +310,11 @@ export interface SelectFieldInput extends FieldInputBase {
|
|
|
303
310
|
options: readonly SelectOptionInput[];
|
|
304
311
|
multiple?: boolean;
|
|
305
312
|
}
|
|
313
|
+
export interface TagsFieldInput extends FieldInputBase {
|
|
314
|
+
type: 'tags';
|
|
315
|
+
max?: number;
|
|
316
|
+
suggestions?: boolean;
|
|
317
|
+
}
|
|
306
318
|
export interface JsonFieldInput extends FieldInputBase {
|
|
307
319
|
type: 'json';
|
|
308
320
|
}
|
|
@@ -335,7 +347,7 @@ export interface BlocksFieldInput extends FieldInputBase {
|
|
|
335
347
|
min?: number;
|
|
336
348
|
max?: number;
|
|
337
349
|
}
|
|
338
|
-
export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | DateTimeFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
|
|
350
|
+
export type CmsFieldInput = TextFieldInput | RichtextFieldInput | NumberFieldInput | BooleanFieldInput | DateFieldInput | DateTimeFieldInput | EmailFieldInput | SlugFieldInput | SelectFieldInput | TagsFieldInput | JsonFieldInput | MediaFieldInput | RelationFieldInput | BlocksFieldInput;
|
|
339
351
|
interface CmsEntryInputBase {
|
|
340
352
|
id: string;
|
|
341
353
|
label: string;
|
|
@@ -237,6 +237,46 @@ export function decodeEntryTranslatableMedia(entry, rows, defaultLocale) {
|
|
|
237
237
|
export function isMultiSelect(field) {
|
|
238
238
|
return field.type === "select" && !!field.multiple;
|
|
239
239
|
}
|
|
240
|
+
export function isStringListField(field) {
|
|
241
|
+
return field.type === "tags" || isMultiSelect(field);
|
|
242
|
+
}
|
|
243
|
+
export function isMultiValueField(field) {
|
|
244
|
+
return isStringListField(field) || field.type === "relation" && field.cardinality === "many-to-many";
|
|
245
|
+
}
|
|
246
|
+
export function tagKey(value) {
|
|
247
|
+
return value.trim().toLowerCase();
|
|
248
|
+
}
|
|
249
|
+
export function normalizeTags(values) {
|
|
250
|
+
const seen = /* @__PURE__ */ new Set();
|
|
251
|
+
const tags = [];
|
|
252
|
+
for (const value of values) {
|
|
253
|
+
if (typeof value !== "string") continue;
|
|
254
|
+
const tag = value.trim();
|
|
255
|
+
const key = tagKey(tag);
|
|
256
|
+
if (!key || seen.has(key)) continue;
|
|
257
|
+
seen.add(key);
|
|
258
|
+
tags.push(tag);
|
|
259
|
+
}
|
|
260
|
+
return tags;
|
|
261
|
+
}
|
|
262
|
+
export function distinctTags(values) {
|
|
263
|
+
const lists = values.map((value) => {
|
|
264
|
+
if (Array.isArray(value)) return value;
|
|
265
|
+
if (typeof value !== "string" || !value.startsWith("[")) return [];
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(value);
|
|
268
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
269
|
+
} catch {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return normalizeTags(lists.flat()).sort(
|
|
274
|
+
(a, b) => a.localeCompare(b, void 0, { sensitivity: "base" })
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
export function canAddTag(field, count) {
|
|
278
|
+
return field.max === void 0 || count < field.max;
|
|
279
|
+
}
|
|
240
280
|
export function translatableFieldKeys(entry) {
|
|
241
281
|
return Object.entries(entryFieldsFor(entry)).filter(([, field]) => isTranslatableField(field)).map(([key]) => key);
|
|
242
282
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isCollectionKind,
|
|
3
|
+
isStringListField,
|
|
4
|
+
isTranslatableField,
|
|
5
|
+
selectOptionLabel
|
|
6
|
+
} from "./index.js";
|
|
2
7
|
export const TRANSFER_FORMATS = ["json", "csv"];
|
|
3
8
|
export const IMPORT_MODES = ["create", "upsert"];
|
|
4
9
|
export const IMPORT_MAX_BYTES = 5 * 1024 * 1024;
|
|
@@ -93,13 +98,13 @@ export function parseCsv(text) {
|
|
|
93
98
|
}
|
|
94
99
|
function csvCellText(field, value) {
|
|
95
100
|
if (value === null || value === void 0) return "";
|
|
96
|
-
if (field?.type === "json" ||
|
|
101
|
+
if (field?.type === "json" || isStringList(field)) return JSON.stringify(value);
|
|
97
102
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
98
103
|
if (typeof value === "object") return JSON.stringify(value);
|
|
99
104
|
return String(value);
|
|
100
105
|
}
|
|
101
|
-
function
|
|
102
|
-
return !!field &&
|
|
106
|
+
function isStringList(field) {
|
|
107
|
+
return !!field && isStringListField(field);
|
|
103
108
|
}
|
|
104
109
|
export function entryRowsToCsv(entry, rows, i18n) {
|
|
105
110
|
const keys = transferKeys(entry);
|
|
@@ -130,7 +135,8 @@ export function csvCellValue(field, text) {
|
|
|
130
135
|
if (FALSE_VALUES.includes(lower)) return { value: false };
|
|
131
136
|
return { error: "Not a boolean (true or false)" };
|
|
132
137
|
}
|
|
133
|
-
if (field.type === "
|
|
138
|
+
if (field.type === "tags" && !text.trim().startsWith("[")) return { value: text.split(",") };
|
|
139
|
+
if (field.type === "json" || isStringListField(field)) {
|
|
134
140
|
try {
|
|
135
141
|
return { value: JSON.parse(text) };
|
|
136
142
|
} catch {
|
|
@@ -11,9 +11,11 @@ export interface ValidationMessages {
|
|
|
11
11
|
requiredLocale: (locale: string) => string;
|
|
12
12
|
minBlocks: (min: number) => string;
|
|
13
13
|
maxBlocks: (max: number) => string;
|
|
14
|
+
maxTags: (max: number) => string;
|
|
14
15
|
}
|
|
15
16
|
export declare function datetimeSchema(message: string): z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
|
|
16
17
|
export declare function blocksCountMessage(field: Pick<FieldConfig, 'min' | 'max'>, count: number, messages?: Partial<ValidationMessages>): string | null;
|
|
18
|
+
export declare function tagsCountMessage(field: Pick<FieldConfig, 'max'>, count: number, messages?: Partial<ValidationMessages>): string | null;
|
|
17
19
|
export interface EntryValidationIssue {
|
|
18
20
|
path: (string | number)[];
|
|
19
21
|
message: string;
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
declaresHiddenField,
|
|
5
5
|
isFieldVisible,
|
|
6
6
|
isTranslatableField,
|
|
7
|
+
normalizeTags,
|
|
7
8
|
selectOptionValues
|
|
8
9
|
} from "./index.js";
|
|
9
10
|
export const objectKeySchema = z.string().min(1).max(1024).refine((k) => !k.includes("..") && !k.startsWith("/"), "Invalid object key");
|
|
@@ -16,7 +17,8 @@ const DEFAULT_MESSAGES = {
|
|
|
16
17
|
unknownLocale: "Unknown locale key",
|
|
17
18
|
requiredLocale: (locale) => `Required field (locale '${locale}')`,
|
|
18
19
|
minBlocks: (min) => `Add at least ${min} ${min === 1 ? "block" : "blocks"}`,
|
|
19
|
-
maxBlocks: (max) => `Add at most ${max} ${max === 1 ? "block" : "blocks"}
|
|
20
|
+
maxBlocks: (max) => `Add at most ${max} ${max === 1 ? "block" : "blocks"}`,
|
|
21
|
+
maxTags: (max) => `Add at most ${max} ${max === 1 ? "tag" : "tags"}`
|
|
20
22
|
};
|
|
21
23
|
const DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
22
24
|
export function datetimeSchema(message) {
|
|
@@ -88,6 +90,21 @@ export function blocksCountMessage(field, count, messages) {
|
|
|
88
90
|
if (field.min !== void 0 && count > 0 && count < field.min) return m.minBlocks(field.min);
|
|
89
91
|
return null;
|
|
90
92
|
}
|
|
93
|
+
export function tagsCountMessage(field, count, messages) {
|
|
94
|
+
const m = { ...DEFAULT_MESSAGES, ...messages };
|
|
95
|
+
if (field.max !== void 0 && count > field.max) return m.maxTags(field.max);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function countMessage(field, count, m) {
|
|
99
|
+
return field.type === "tags" ? tagsCountMessage(field, count, m) : blocksCountMessage(field, count, m);
|
|
100
|
+
}
|
|
101
|
+
function tagsSchema(field, bounded, m) {
|
|
102
|
+
return z.array(z.string()).nullish().transform((value) => normalizeTags(value ?? [])).superRefine((tags, ctx) => {
|
|
103
|
+
if (field.required && !tags.length) ctx.addIssue({ code: "custom", message: m.required });
|
|
104
|
+
const message = bounded ? tagsCountMessage(field, tags.length, m) : null;
|
|
105
|
+
if (message) ctx.addIssue({ code: "custom", message });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
91
108
|
function boundedBlocks(list, field, m) {
|
|
92
109
|
if (field.min === void 0 && field.max === void 0) return list;
|
|
93
110
|
return list.superRefine((items, ctx) => {
|
|
@@ -166,7 +183,7 @@ export function buildEntrySchema(entry, i18n, messages) {
|
|
|
166
183
|
for (const [key, declared] of Object.entries(entry.fields)) {
|
|
167
184
|
const field = declared.showIf ? { ...declared, required: false } : declared;
|
|
168
185
|
if (declared.showIf && declared.required) conditional.push([key, declared]);
|
|
169
|
-
if (declared.showIf && declared.type === "blocks" && (declared.min !== void 0 || declared.max !== void 0))
|
|
186
|
+
if (declared.showIf && (declared.type === "blocks" || declared.type === "tags") && (declared.min !== void 0 || declared.max !== void 0))
|
|
170
187
|
conditionalBounds.push([key, declared]);
|
|
171
188
|
if (field.type === "relation" && field.cardinality === "many-to-many") {
|
|
172
189
|
const list = z.array(z.string().min(1));
|
|
@@ -178,6 +195,10 @@ export function buildEntrySchema(entry, i18n, messages) {
|
|
|
178
195
|
shape[key] = field.required ? list.min(1, m.required) : list.nullish().transform((v) => v ?? []);
|
|
179
196
|
continue;
|
|
180
197
|
}
|
|
198
|
+
if (field.type === "tags") {
|
|
199
|
+
shape[key] = tagsSchema(field, !declared.showIf, m);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
181
202
|
if (field.type === "blocks") {
|
|
182
203
|
const list = blocksSchema(field, i18n, m);
|
|
183
204
|
const base = field.required ? list.min(1, m.required) : list;
|
|
@@ -212,7 +233,7 @@ export function buildEntrySchema(entry, i18n, messages) {
|
|
|
212
233
|
for (const [key, field] of conditionalBounds) {
|
|
213
234
|
const list = values[key];
|
|
214
235
|
if (!isFieldVisible(field, values) || !Array.isArray(list)) continue;
|
|
215
|
-
const message =
|
|
236
|
+
const message = countMessage(field, list.length, m);
|
|
216
237
|
if (message) ctx.addIssue({ code: "custom", path: [key], message });
|
|
217
238
|
}
|
|
218
239
|
if (!hooks) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xleddyl/nuxt-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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)",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"@tiptap/pm": "^3.32.0",
|
|
70
70
|
"@tiptap/starter-kit": "^3.32.0",
|
|
71
71
|
"@tiptap/vue-3": "^3.32.0",
|
|
72
|
-
"@xleddyl/443-components-vue": "^0.6.
|
|
72
|
+
"@xleddyl/443-components-vue": "^0.6.2",
|
|
73
73
|
"aws4fetch": "^1.0.20",
|
|
74
74
|
"drizzle-kit": "^0.31.0",
|
|
75
75
|
"drizzle-orm": "^0.44.0",
|