@btst/stack 2.12.0 → 2.12.1
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/packages/stack/src/plugins/cms/api/plugin.cjs +14 -11
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +14 -11
- package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.cjs +87 -15
- package/dist/packages/stack/src/plugins/cms/client/components/forms/relation-field.mjs +87 -15
- package/dist/packages/stack/src/plugins/cms/schemas.cjs +11 -6
- package/dist/packages/stack/src/plugins/cms/schemas.mjs +10 -6
- package/dist/packages/ui/src/components/auto-form/fields/array.cjs +181 -4
- package/dist/packages/ui/src/components/auto-form/fields/array.mjs +184 -7
- package/dist/packages/ui/src/components/auto-form/index.cjs +16 -1
- package/dist/packages/ui/src/components/auto-form/index.mjs +17 -2
- package/dist/plugins/cms/api/index.cjs +2 -0
- package/dist/plugins/cms/api/index.d.cts +3 -2
- package/dist/plugins/cms/api/index.d.mts +3 -2
- package/dist/plugins/cms/api/index.d.ts +3 -2
- package/dist/plugins/cms/api/index.mjs +1 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.d.cts +398 -7
- package/dist/plugins/cms/query-keys.d.mts +398 -7
- package/dist/plugins/cms/query-keys.d.ts +398 -7
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/plugins/ui-builder/index.d.cts +1 -1
- package/dist/plugins/ui-builder/index.d.mts +1 -1
- package/dist/plugins/ui-builder/index.d.ts +1 -1
- package/dist/shared/{stack.B8QD11QU.d.cts → stack.DFtpFS00.d.cts} +10 -0
- package/dist/shared/{stack.B8QD11QU.d.mts → stack.DFtpFS00.d.mts} +10 -0
- package/dist/shared/{stack.B8QD11QU.d.ts → stack.DFtpFS00.d.ts} +10 -0
- package/package.json +1 -1
- package/src/plugins/cms/__tests__/schema-roundtrip.test.ts +203 -0
- package/src/plugins/cms/api/index.ts +1 -0
- package/src/plugins/cms/api/plugin.ts +33 -16
- package/src/plugins/cms/client/components/forms/relation-field.tsx +121 -36
- package/src/plugins/cms/schemas.ts +26 -6
- package/src/plugins/cms/types.ts +10 -0
- package/dist/shared/stack.DbnCLdNF.d.mts +0 -399
- package/dist/shared/stack.fXlShnhv.d.ts +0 -399
- package/dist/shared/stack.r6HohbyX.d.cts +0 -399
- package/dist/shared/{stack.B6S3cgwN.d.cts → stack.CuI_-z-_.d.cts} +16 -16
- package/dist/shared/{stack.Bzfx-_lq.d.mts → stack.D2RVkQD8.d.mts} +16 -16
- package/dist/shared/{stack.j5SFLC1d.d.ts → stack.DXrOtuek.d.ts} +16 -16
|
@@ -804,3 +804,206 @@ describe("Zod to JSON Schema roundtrip", () => {
|
|
|
804
804
|
});
|
|
805
805
|
});
|
|
806
806
|
});
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* These tests confirm and protect against a bug where `useFieldArray` in
|
|
810
|
+
* AutoFormArray corrupts primitive string values in arrays like
|
|
811
|
+
* `structuredContraindications: z.array(z.string()).default([])`.
|
|
812
|
+
*
|
|
813
|
+
* When react-hook-form's `useFieldArray` processes a primitive array
|
|
814
|
+
* (e.g. `["pregnancy", "active malignancy"]`), it wraps each element as a
|
|
815
|
+
* tracking object `{ id: "rhf_generated_id" }`, discarding the original
|
|
816
|
+
* string value. When `form.watch()` fires, it returns these objects. The
|
|
817
|
+
* `handleValuesChange` callback then calls `setFormData(values)` which stores
|
|
818
|
+
* the corrupted objects. On form submit, `zodResolver` validates the corrupted
|
|
819
|
+
* values against `z.array(z.string())` and FAILS — causing the Save button
|
|
820
|
+
* to do nothing (no error shown, no API request made).
|
|
821
|
+
*
|
|
822
|
+
* The fix: AutoFormArray must NOT use `useFieldArray` for primitive (non-object)
|
|
823
|
+
* arrays. Instead it uses `form.watch` + `form.setValue` directly so primitive
|
|
824
|
+
* values are always preserved in the form state.
|
|
825
|
+
*/
|
|
826
|
+
describe("Primitive string array — useFieldArray corruption bug", () => {
|
|
827
|
+
/**
|
|
828
|
+
* Simulates what zodToFormSchema + formSchemaToZod does:
|
|
829
|
+
* Zod schema → JSON Schema (stored in DB) → reconstructed Zod schema.
|
|
830
|
+
*/
|
|
831
|
+
function roundtripSchema(schema: z.ZodType): z.ZodType {
|
|
832
|
+
const jsonSchema = z.toJSONSchema(schema, { unrepresentable: "any" });
|
|
833
|
+
return z.fromJSONSchema(jsonSchema as z.core.JSONSchema.JSONSchema);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
it("z.array(z.string()) survives JSON Schema roundtrip and accepts string values", () => {
|
|
837
|
+
const schema = z.object({
|
|
838
|
+
structuredContraindications: z.array(z.string()).default([]),
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const reconstructed = roundtripSchema(schema);
|
|
842
|
+
|
|
843
|
+
// Actual compound data — should PASS
|
|
844
|
+
expect(
|
|
845
|
+
reconstructed.safeParse({
|
|
846
|
+
structuredContraindications: [
|
|
847
|
+
"pregnancy",
|
|
848
|
+
"active malignancy",
|
|
849
|
+
"active cancer",
|
|
850
|
+
"trying to conceive",
|
|
851
|
+
],
|
|
852
|
+
}).success,
|
|
853
|
+
).toBe(true);
|
|
854
|
+
|
|
855
|
+
// Empty array — should PASS (default)
|
|
856
|
+
expect(
|
|
857
|
+
reconstructed.safeParse({ structuredContraindications: [] }).success,
|
|
858
|
+
).toBe(true);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("useFieldArray-corrupted values (objects) fail z.array(z.string()) validation — this is the root cause of the silent save failure", () => {
|
|
862
|
+
const schema = z.object({
|
|
863
|
+
structuredContraindications: z.array(z.string()).default([]),
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const reconstructed = roundtripSchema(schema);
|
|
867
|
+
|
|
868
|
+
// Simulates what react-hook-form's useFieldArray returns when used with
|
|
869
|
+
// primitive string arrays: each string is replaced by a tracking object
|
|
870
|
+
// { id: "rhf_generated_id" } and the original value is lost.
|
|
871
|
+
const corruptedByUseFieldArray = {
|
|
872
|
+
structuredContraindications: [
|
|
873
|
+
{ id: "rhf_internal_id_1" },
|
|
874
|
+
{ id: "rhf_internal_id_2" },
|
|
875
|
+
{ id: "rhf_internal_id_3" },
|
|
876
|
+
{ id: "rhf_internal_id_4" },
|
|
877
|
+
],
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// This is the actual validation error that occurs when Save is clicked:
|
|
881
|
+
// zodResolver validates the corrupted objects against z.array(z.string())
|
|
882
|
+
// and FAILS, so onSubmit is never called → no API request → "nothing happens"
|
|
883
|
+
const result = reconstructed.safeParse(corruptedByUseFieldArray);
|
|
884
|
+
expect(result.success).toBe(false);
|
|
885
|
+
if (!result.success) {
|
|
886
|
+
// Confirm the error is specifically about the string array elements
|
|
887
|
+
const paths = result.error.issues.map((i) => i.path.join("."));
|
|
888
|
+
expect(
|
|
889
|
+
paths.some((p) => p.startsWith("structuredContraindications")),
|
|
890
|
+
).toBe(true);
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
it("primitive string array values are preserved correctly (NOT corrupted) when form state is managed without useFieldArray", () => {
|
|
895
|
+
// After the fix, AutoFormArray uses form.watch + form.setValue for primitive
|
|
896
|
+
// arrays. The values remain as strings throughout the lifecycle:
|
|
897
|
+
// initialData → formData → form state → zodResolver → submit
|
|
898
|
+
|
|
899
|
+
const schema = z.object({
|
|
900
|
+
structuredContraindications: z.array(z.string()).default([]),
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
const reconstructed = roundtripSchema(schema);
|
|
904
|
+
|
|
905
|
+
// The correctly-preserved values (no useFieldArray wrapping)
|
|
906
|
+
const preservedValues = {
|
|
907
|
+
structuredContraindications: [
|
|
908
|
+
"pregnancy",
|
|
909
|
+
"active malignancy",
|
|
910
|
+
"active cancer",
|
|
911
|
+
"trying to conceive",
|
|
912
|
+
],
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
// With the fix applied, these values pass validation → Save works
|
|
916
|
+
expect(reconstructed.safeParse(preservedValues).success).toBe(true);
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it("compound schema with structuredContraindications passes full validation with string array values", () => {
|
|
920
|
+
// Representative subset of CompoundSchema fields that appear on the
|
|
921
|
+
// Epitalon compound page — verifies the full schema round-trip for the
|
|
922
|
+
// fields that are relevant to the reported bug.
|
|
923
|
+
const compoundSchema = z.object({
|
|
924
|
+
name: z.string().min(1),
|
|
925
|
+
compoundType: z.enum([
|
|
926
|
+
"healing-peptide",
|
|
927
|
+
"gh-axis",
|
|
928
|
+
"metabolic-peptide",
|
|
929
|
+
"sarm",
|
|
930
|
+
"steroid",
|
|
931
|
+
"nootropic",
|
|
932
|
+
"supplement",
|
|
933
|
+
"ancillary-pct",
|
|
934
|
+
"longevity",
|
|
935
|
+
"hair",
|
|
936
|
+
"skin",
|
|
937
|
+
"sexual",
|
|
938
|
+
"other",
|
|
939
|
+
]),
|
|
940
|
+
researchStatus: z.enum([
|
|
941
|
+
"research-only",
|
|
942
|
+
"approved",
|
|
943
|
+
"banned",
|
|
944
|
+
"grey-market",
|
|
945
|
+
"supplement",
|
|
946
|
+
]),
|
|
947
|
+
legalStatus: z.enum([
|
|
948
|
+
"OTC",
|
|
949
|
+
"Research",
|
|
950
|
+
"Grey-Market",
|
|
951
|
+
"Rx-Only",
|
|
952
|
+
"Schedule-III",
|
|
953
|
+
"Banned",
|
|
954
|
+
]),
|
|
955
|
+
doseUnit: z.enum(["mcg", "mg", "IU", "ml", "g"]),
|
|
956
|
+
doseFrequency: z.enum([
|
|
957
|
+
"once-daily",
|
|
958
|
+
"twice-daily",
|
|
959
|
+
"three-times-daily",
|
|
960
|
+
"every-other-day",
|
|
961
|
+
"weekly",
|
|
962
|
+
"twice-weekly",
|
|
963
|
+
"three-times-weekly",
|
|
964
|
+
"as-needed",
|
|
965
|
+
"custom",
|
|
966
|
+
]),
|
|
967
|
+
structuredContraindications: z.array(z.string()).default([]),
|
|
968
|
+
affiliates: z
|
|
969
|
+
.array(
|
|
970
|
+
z.object({
|
|
971
|
+
partnerId: z.object({ id: z.string() }).optional(),
|
|
972
|
+
title: z.string().optional(),
|
|
973
|
+
url: z.string().min(1),
|
|
974
|
+
}),
|
|
975
|
+
)
|
|
976
|
+
.default([]),
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const reconstructed = roundtripSchema(compoundSchema);
|
|
980
|
+
|
|
981
|
+
// Epitalon-like data — all values as they come from parsedData
|
|
982
|
+
const epitalon = {
|
|
983
|
+
name: "Epitalon",
|
|
984
|
+
compoundType: "longevity",
|
|
985
|
+
researchStatus: "research-only",
|
|
986
|
+
legalStatus: "Research",
|
|
987
|
+
doseUnit: "mg",
|
|
988
|
+
doseFrequency: "once-daily",
|
|
989
|
+
// The 4 string items that were causing the silent save failure
|
|
990
|
+
structuredContraindications: [
|
|
991
|
+
"pregnancy",
|
|
992
|
+
"active malignancy",
|
|
993
|
+
"active cancer",
|
|
994
|
+
"trying to conceive",
|
|
995
|
+
],
|
|
996
|
+
affiliates: [
|
|
997
|
+
{
|
|
998
|
+
partnerId: { id: "v6yAqOSO_example_id" },
|
|
999
|
+
title: "Buy Epitalon 10mg",
|
|
1000
|
+
url: "https://swisschems.is/product/epitalon-10mg-price-is-per-vial/",
|
|
1001
|
+
},
|
|
1002
|
+
],
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const result = reconstructed.safeParse(epitalon);
|
|
1006
|
+
// After the fix, this should PASS (the save button works)
|
|
1007
|
+
expect(result.success).toBe(true);
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
@@ -18,7 +18,10 @@ import type {
|
|
|
18
18
|
RelationValue,
|
|
19
19
|
InverseRelation,
|
|
20
20
|
} from "../types";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
createListContentQuerySchema,
|
|
23
|
+
DEFAULT_MAX_PAGE_SIZE,
|
|
24
|
+
} from "../schemas";
|
|
22
25
|
import { slugify } from "../utils";
|
|
23
26
|
import {
|
|
24
27
|
getAllContentTypes,
|
|
@@ -492,6 +495,20 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
|
|
|
492
495
|
}),
|
|
493
496
|
|
|
494
497
|
routes: (adapter: Adapter) => {
|
|
498
|
+
// Build pagination schemas once — honours config.maxPageSize
|
|
499
|
+
const listContentQuerySchema = createListContentQuerySchema(
|
|
500
|
+
config.maxPageSize,
|
|
501
|
+
);
|
|
502
|
+
const paginationQuerySchema = z.object({
|
|
503
|
+
limit: z.coerce
|
|
504
|
+
.number()
|
|
505
|
+
.min(1)
|
|
506
|
+
.max(config.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE)
|
|
507
|
+
.optional()
|
|
508
|
+
.default(20),
|
|
509
|
+
offset: z.coerce.number().min(0).optional().default(0),
|
|
510
|
+
});
|
|
511
|
+
|
|
495
512
|
// Helper to get content type by slug
|
|
496
513
|
const getContentType = async (
|
|
497
514
|
slug: string,
|
|
@@ -525,10 +542,10 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
|
|
|
525
542
|
sortBy: { field: "name", direction: "asc" },
|
|
526
543
|
});
|
|
527
544
|
|
|
528
|
-
// Get item counts for each content type
|
|
545
|
+
// Get item counts for each content type via adapter.count() (avoids N+1 scan)
|
|
529
546
|
const typesWithCounts = await Promise.all(
|
|
530
547
|
contentTypes.map(async (ct) => {
|
|
531
|
-
const
|
|
548
|
+
const itemCount: number = await adapter.count({
|
|
532
549
|
model: "contentItem",
|
|
533
550
|
where: [
|
|
534
551
|
{
|
|
@@ -540,7 +557,7 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
|
|
|
540
557
|
});
|
|
541
558
|
return {
|
|
542
559
|
...serializeContentType(ct),
|
|
543
|
-
itemCount
|
|
560
|
+
itemCount,
|
|
544
561
|
};
|
|
545
562
|
}),
|
|
546
563
|
);
|
|
@@ -964,12 +981,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
|
|
|
964
981
|
{
|
|
965
982
|
method: "GET",
|
|
966
983
|
params: z.object({ typeSlug: z.string() }),
|
|
967
|
-
query: z
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
984
|
+
query: z
|
|
985
|
+
.object({
|
|
986
|
+
field: z.string(),
|
|
987
|
+
targetId: z.string(),
|
|
988
|
+
})
|
|
989
|
+
.merge(paginationQuerySchema),
|
|
973
990
|
},
|
|
974
991
|
async (ctx) => {
|
|
975
992
|
const { typeSlug } = ctx.params;
|
|
@@ -1144,12 +1161,12 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
|
|
|
1144
1161
|
slug: z.string(),
|
|
1145
1162
|
sourceType: z.string(),
|
|
1146
1163
|
}),
|
|
1147
|
-
query: z
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1164
|
+
query: z
|
|
1165
|
+
.object({
|
|
1166
|
+
itemId: z.string(),
|
|
1167
|
+
fieldName: z.string(),
|
|
1168
|
+
})
|
|
1169
|
+
.merge(paginationQuerySchema),
|
|
1153
1170
|
},
|
|
1154
1171
|
async (ctx) => {
|
|
1155
1172
|
const { slug, sourceType } = ctx.params;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useMemo } from "react";
|
|
4
|
+
import { useQueries } from "@tanstack/react-query";
|
|
5
|
+
import { createApiClient } from "@btst/stack/plugins/client";
|
|
6
|
+
import { usePluginOverrides } from "@btst/stack/context";
|
|
4
7
|
import { useContent, useCreateContent } from "../../hooks";
|
|
8
|
+
import type { CMSApiRouter } from "../../../api";
|
|
9
|
+
import type { SerializedContentItemWithType } from "../../../types";
|
|
10
|
+
import type { CMSPluginOverrides } from "../../overrides";
|
|
11
|
+
import { createCMSQueryKeys } from "../../../query-keys";
|
|
5
12
|
import MultipleSelector from "@workspace/ui/components/multi-select";
|
|
6
13
|
import type { Option } from "@workspace/ui/components/multi-select";
|
|
7
14
|
import { Button } from "@workspace/ui/components/button";
|
|
@@ -19,6 +26,16 @@ import { Textarea } from "@workspace/ui/components/textarea";
|
|
|
19
26
|
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
|
|
20
27
|
import type { RelationConfig } from "../../../types";
|
|
21
28
|
|
|
29
|
+
/** Match cms-hooks SHARED_QUERY_CONFIG for detail fetches (deduped labels). */
|
|
30
|
+
const RELATION_DETAIL_QUERY_OPTS = {
|
|
31
|
+
retry: false,
|
|
32
|
+
refetchOnWindowFocus: false,
|
|
33
|
+
refetchOnMount: false,
|
|
34
|
+
refetchOnReconnect: false,
|
|
35
|
+
staleTime: 1000 * 60 * 5,
|
|
36
|
+
gcTime: 1000 * 60 * 10,
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
22
39
|
interface RelationFieldProps extends AutoFormInputComponentProps {
|
|
23
40
|
relation: RelationConfig;
|
|
24
41
|
}
|
|
@@ -43,16 +60,25 @@ export function RelationField({
|
|
|
43
60
|
const [newItemDescription, setNewItemDescription] = useState("");
|
|
44
61
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
45
62
|
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
const { apiBaseURL, apiBasePath, headers } =
|
|
64
|
+
usePluginOverrides<CMSPluginOverrides>("cms");
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
const listClient = useMemo(
|
|
67
|
+
() =>
|
|
68
|
+
createApiClient<CMSApiRouter>({
|
|
69
|
+
baseURL: apiBaseURL,
|
|
70
|
+
basePath: apiBasePath,
|
|
71
|
+
}),
|
|
72
|
+
[apiBaseURL, apiBasePath],
|
|
73
|
+
);
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
const cmsQueries = useMemo(
|
|
76
|
+
() => createCMSQueryKeys(listClient, headers),
|
|
77
|
+
[listClient, headers],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// For belongsTo (single relation), we only allow one selection
|
|
81
|
+
const isSingleSelect = relation.type === "belongsTo";
|
|
56
82
|
|
|
57
83
|
// Normalize the field value to an array for internal use
|
|
58
84
|
// belongsTo stores as single object { id }, hasMany/manyToMany store as array
|
|
@@ -72,36 +98,95 @@ export function RelationField({
|
|
|
72
98
|
return (field.value as Array<{ id: string }>) || [];
|
|
73
99
|
}, [field.value, isSingleSelect]);
|
|
74
100
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
101
|
+
// Fetch available items from the target content type (first page only)
|
|
102
|
+
const { items: availableItems, isLoading } = useContent(relation.targetType, {
|
|
103
|
+
limit: 500,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const missingDetailIds = useMemo(() => {
|
|
107
|
+
const loadedIds = new Set(availableItems.map((i) => i.id));
|
|
108
|
+
return normalizedValue
|
|
109
|
+
.map((v) => v.id)
|
|
110
|
+
.filter((id) => id.length > 0 && !loadedIds.has(id));
|
|
111
|
+
}, [availableItems, normalizedValue]);
|
|
112
|
+
|
|
113
|
+
const hydrationResult = useQueries({
|
|
114
|
+
queries: missingDetailIds.map((id) => ({
|
|
115
|
+
...cmsQueries.cmsContent.detail(relation.targetType, id),
|
|
116
|
+
...RELATION_DETAIL_QUERY_OPTS,
|
|
117
|
+
enabled: Boolean(relation.targetType && id),
|
|
118
|
+
})),
|
|
119
|
+
combine: (results) => ({
|
|
120
|
+
data: results.map(
|
|
121
|
+
(r) => r.data as SerializedContentItemWithType | null | undefined,
|
|
122
|
+
),
|
|
123
|
+
isHydrating: results.some((r) => r.isFetching),
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const isHydratingLabels = hydrationResult.isHydrating;
|
|
128
|
+
|
|
129
|
+
const itemById = useMemo(() => {
|
|
130
|
+
const m = new Map<string, SerializedContentItemWithType>();
|
|
131
|
+
for (const it of availableItems) {
|
|
132
|
+
m.set(it.id, it as SerializedContentItemWithType);
|
|
133
|
+
}
|
|
134
|
+
for (let i = 0; i < missingDetailIds.length; i++) {
|
|
135
|
+
const row = hydrationResult.data[i];
|
|
136
|
+
if (row?.id) {
|
|
137
|
+
m.set(row.id, row);
|
|
88
138
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
.filter(Boolean);
|
|
139
|
+
}
|
|
140
|
+
return m;
|
|
141
|
+
}, [availableItems, missingDetailIds, hydrationResult.data]);
|
|
93
142
|
|
|
94
|
-
// Convert
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
143
|
+
// Convert normalized value to Option[] for MultipleSelector
|
|
144
|
+
const selectedOptions: Option[] = normalizedValue.map((v) => {
|
|
145
|
+
const item = itemById.get(v.id);
|
|
146
|
+
if (item) {
|
|
147
|
+
const displayValue =
|
|
148
|
+
(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
|
|
149
|
+
item.slug;
|
|
150
|
+
return {
|
|
151
|
+
value: item.id,
|
|
152
|
+
label: String(displayValue),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
|
|
103
156
|
});
|
|
104
157
|
|
|
158
|
+
// Listed options + any selected partners loaded by id (not on first list page)
|
|
159
|
+
const options: Option[] = useMemo(() => {
|
|
160
|
+
const merged: SerializedContentItemWithType[] = [
|
|
161
|
+
...(availableItems as SerializedContentItemWithType[]),
|
|
162
|
+
];
|
|
163
|
+
const seen = new Set(merged.map((x) => x.id));
|
|
164
|
+
for (let i = 0; i < missingDetailIds.length; i++) {
|
|
165
|
+
const row = hydrationResult.data[i];
|
|
166
|
+
if (row?.id && !seen.has(row.id)) {
|
|
167
|
+
merged.push(row);
|
|
168
|
+
seen.add(row.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return merged.map((item) => {
|
|
172
|
+
const displayValue =
|
|
173
|
+
(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
|
|
174
|
+
item.slug;
|
|
175
|
+
return {
|
|
176
|
+
value: item.id,
|
|
177
|
+
label: String(displayValue),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}, [
|
|
181
|
+
availableItems,
|
|
182
|
+
hydrationResult.data,
|
|
183
|
+
missingDetailIds,
|
|
184
|
+
relation.displayField,
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
// Mutation for creating new items
|
|
188
|
+
const createMutation = useCreateContent(relation.targetType);
|
|
189
|
+
|
|
105
190
|
// Handle selection change - convert back to appropriate format
|
|
106
191
|
const handleChange = useCallback(
|
|
107
192
|
(newOptions: Option[]) => {
|
|
@@ -187,11 +272,11 @@ export function RelationField({
|
|
|
187
272
|
onChange={handleChange}
|
|
188
273
|
options={options}
|
|
189
274
|
placeholder={
|
|
190
|
-
isLoading
|
|
275
|
+
isLoading || isHydratingLabels
|
|
191
276
|
? "Loading..."
|
|
192
277
|
: `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
|
|
193
278
|
}
|
|
194
|
-
disabled={isLoading}
|
|
279
|
+
disabled={isLoading || isHydratingLabels}
|
|
195
280
|
hidePlaceholderWhenSelected
|
|
196
281
|
emptyIndicator={
|
|
197
282
|
<p className="text-center text-sm text-muted-foreground py-4">
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
/** Default upper bound for a single page when no maxPageSize is configured. */
|
|
4
|
+
export const DEFAULT_MAX_PAGE_SIZE = 1000;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
|
-
*
|
|
7
|
+
* Factory that creates the list-content query schema with a configurable
|
|
8
|
+
* upper bound on the `limit` parameter.
|
|
9
|
+
*
|
|
10
|
+
* Use this inside the backend plugin factory (where `config.maxPageSize` is
|
|
11
|
+
* available) so the cap is set at registration time rather than hardcoded.
|
|
5
12
|
*/
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
export function createListContentQuerySchema(
|
|
14
|
+
maxPageSize = DEFAULT_MAX_PAGE_SIZE,
|
|
15
|
+
) {
|
|
16
|
+
return z.object({
|
|
17
|
+
slug: z.string().optional(),
|
|
18
|
+
limit: z.coerce.number().min(1).max(maxPageSize).optional().default(20),
|
|
19
|
+
offset: z.coerce.number().min(0).optional().default(0),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Schema for listing content items with pagination.
|
|
25
|
+
* Uses the default maxPageSize (1000).
|
|
26
|
+
*
|
|
27
|
+
* @deprecated Prefer {@link createListContentQuerySchema} inside plugin
|
|
28
|
+
* factories so consumers can configure the upper bound via `maxPageSize`.
|
|
29
|
+
*/
|
|
30
|
+
export const listContentQuerySchema = createListContentQuerySchema();
|
|
11
31
|
|
|
12
32
|
/**
|
|
13
33
|
* Schema for creating a content item
|
package/src/plugins/cms/types.ts
CHANGED
|
@@ -303,4 +303,14 @@ export interface CMSBackendConfig {
|
|
|
303
303
|
contentTypes: ContentTypeConfig[];
|
|
304
304
|
/** Optional hooks for customizing behavior */
|
|
305
305
|
hooks?: CMSBackendHooks;
|
|
306
|
+
/**
|
|
307
|
+
* Maximum number of items that can be requested in a single page.
|
|
308
|
+
* Applied to all list endpoints (`/content/:typeSlug`, by-relation, and inverse-relation).
|
|
309
|
+
*
|
|
310
|
+
* Raise this when your content types have many items and you need to fetch
|
|
311
|
+
* large pages (e.g. for SSG, CSV export, or programmatic access).
|
|
312
|
+
*
|
|
313
|
+
* @default 1000
|
|
314
|
+
*/
|
|
315
|
+
maxPageSize?: number;
|
|
306
316
|
}
|