gt-sanity 2.0.21 → 2.1.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.
Files changed (44) hide show
  1. package/dist/index.cjs +1220 -763
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +113 -1
  4. package/dist/index.d.ts +113 -1
  5. package/dist/index.js +1211 -754
  6. package/dist/index.js.map +1 -1
  7. package/package.json +2 -2
  8. package/src/adapter/core.ts +27 -2
  9. package/src/adapter/types.ts +9 -0
  10. package/src/components/TranslationsProvider.tsx +92 -6
  11. package/src/components/page/TranslationsTable.tsx +5 -3
  12. package/src/components/shared/SingleDocumentView.tsx +5 -3
  13. package/src/components/tab/TranslationView.tsx +24 -14
  14. package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +1 -1
  15. package/src/configuration/baseFieldLevelConfig.ts +1 -1
  16. package/src/configuration/internationalizedArrayConfig/internationalizedArrayPatch.ts +59 -0
  17. package/src/index.ts +114 -58
  18. package/src/schema/InternationalizedArrayInput.tsx +278 -0
  19. package/src/schema/__tests__/createInternationalizedArrayTypes.test.ts +169 -0
  20. package/src/schema/createInternationalizedArrayTypes.ts +209 -0
  21. package/src/schema/types.ts +84 -0
  22. package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +3 -3
  23. package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +1 -1
  24. package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +1 -1
  25. package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +1 -1
  26. package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +2 -2
  27. package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +4 -2
  28. package/src/serialization/__tests__/global.setup.ts +6 -0
  29. package/src/serialization/__tests__/helpers.ts +2 -1
  30. package/src/serialization/internationalizedArray/__tests__/internationalizedArray.test.ts +265 -0
  31. package/src/serialization/internationalizedArray/__tests__/serializeRoundTrip.test.ts +83 -0
  32. package/src/serialization/internationalizedArray/collapse.ts +52 -0
  33. package/src/serialization/internationalizedArray/detect.ts +81 -0
  34. package/src/serialization/internationalizedArray/merge.ts +149 -0
  35. package/src/serialization/types.ts +5 -1
  36. package/src/translation/__tests__/strategy.test.ts +47 -0
  37. package/src/translation/importDocument.ts +9 -5
  38. package/src/translation/strategy.ts +92 -0
  39. package/src/translation/uploadFiles.ts +1 -1
  40. package/src/types.ts +1 -1
  41. package/src/utils/__tests__/batchProcessor.test.ts +61 -2
  42. package/src/utils/batchProcessor.ts +11 -6
  43. package/src/utils/serialize.ts +24 -7
  44. package/src/serialization/index.ts +0 -16
package/src/index.ts CHANGED
@@ -1,10 +1,35 @@
1
+ import type { PortableTextHtmlComponents } from '@portabletext/to-html';
2
+ import { getLocaleProperties } from 'generaltranslation';
3
+ import { libraryDefaultLocale } from 'generaltranslation/internal';
4
+ import { definePlugin } from 'sanity';
5
+ import { route } from 'sanity/router';
6
+ import { translateAction } from './actions/translateAction';
7
+ import { gt, pluginConfig } from './adapter/core';
8
+ import type {
9
+ DedupeFields,
10
+ IgnoreFields,
11
+ SkipFields,
12
+ TranslateDocumentFilter,
13
+ FieldLevelTranslationMode,
14
+ } from './adapter/types';
15
+ import {
16
+ createInternationalizedArrayTypes,
17
+ resolveFieldLevelConfig,
18
+ } from './schema/createInternationalizedArrayTypes';
19
+ import type { GTFieldLevelLocalizationConfig } from './schema/types';
20
+ import TranslationsTool from './components/page/TranslationsTool';
21
+ import { documentInternationalization } from './documentInternationalization';
22
+ import type { CustomDeserializers } from './serialization/types';
23
+ import { SECRETS_NAMESPACE } from './utils/shared';
24
+
25
+ // ===== Document Internationalization ===== //
1
26
  export {
2
- documentInternationalization,
3
27
  useDocumentInternationalizationContext,
4
28
  DocumentInternationalizationMenu,
5
29
  useDeleteTranslationAction,
6
30
  useDuplicateWithTranslationsAction,
7
31
  } from './documentInternationalization';
32
+ export { documentInternationalization };
8
33
  export type {
9
34
  DocumentInternationalizationConfig,
10
35
  Language,
@@ -12,26 +37,18 @@ export type {
12
37
  TranslationReference,
13
38
  } from './documentInternationalization';
14
39
 
15
- import TranslationsTab from './components/tab/TranslationsTab';
16
- import {
17
- Secrets,
18
- Adapter,
19
- ExportForTranslation,
20
- ImportTranslation,
21
- TranslationFunctionContext,
22
- TranslationsTabConfigOptions,
23
- } from './types';
24
- import { findLatestDraft } from './configuration/utils/findLatestDraft';
25
- import {
26
- BaseDocumentSerializer,
27
- BaseDocumentDeserializer,
28
- BaseDocumentMerger,
40
+ // ===== Serialization Helpers ===== //
41
+ export { default as TranslationsTab } from './components/tab/TranslationsTab';
42
+ export { findLatestDraft } from './configuration/utils/findLatestDraft';
43
+ export { BaseDocumentSerializer } from './serialization/serialize/index';
44
+ export { BaseDocumentDeserializer } from './serialization/deserialize/BaseDocumentDeserializer';
45
+ export { BaseDocumentMerger } from './serialization/BaseDocumentMerger';
46
+ export {
29
47
  defaultStopTypes,
30
48
  customSerializers,
31
- SerializedDocument,
32
- } from './serialization';
33
- import { translateAction } from './actions/translateAction';
34
-
49
+ } from './serialization/BaseSerializationConfig';
50
+ export { attachGTData, detachGTData } from './serialization/data';
51
+ export { translateAction };
35
52
  export type {
36
53
  Secrets,
37
54
  Adapter,
@@ -39,39 +56,17 @@ export type {
39
56
  ImportTranslation,
40
57
  TranslationFunctionContext,
41
58
  TranslationsTabConfigOptions,
42
- SerializedDocument,
43
- };
44
- export {
45
- TranslationsTab,
46
- translateAction,
47
- //helpers for end developers who may need to customize serialization
48
- findLatestDraft,
49
- BaseDocumentSerializer,
50
- BaseDocumentDeserializer,
51
- BaseDocumentMerger,
52
- defaultStopTypes,
53
- customSerializers,
54
- attachGTData,
55
- detachGTData,
56
- };
59
+ } from './types';
60
+ export type { SerializedDocument } from './serialization/types';
57
61
 
58
- import { definePlugin } from 'sanity';
59
- import { route } from 'sanity/router';
60
- import { gt, pluginConfig } from './adapter/core';
61
- import { libraryDefaultLocale } from 'generaltranslation/internal';
62
- import { getLocaleProperties } from 'generaltranslation';
63
- import type {
64
- DedupeFields,
65
- IgnoreFields,
66
- SkipFields,
67
- TranslateDocumentFilter,
68
- } from './adapter/types';
69
- import TranslationsTool from './components/page/TranslationsTool';
70
- import { SECRETS_NAMESPACE } from './utils/shared';
71
- import type { PortableTextHtmlComponents } from '@portabletext/to-html';
72
- import { attachGTData, detachGTData } from './serialization/data';
73
- import { documentInternationalization } from './documentInternationalization';
74
- import type { CustomDeserializers } from './serialization';
62
+ // ===== Field-Level (Internationalized Array) Localization ===== //
63
+ export { createInternationalizedArrayTypes };
64
+ export type {
65
+ GTFieldLevelLocalizationConfig,
66
+ FieldLevelFieldType,
67
+ FieldLevelUIComponents,
68
+ } from './schema/types';
69
+ export type { FieldLevelTranslationMode };
75
70
 
76
71
  export type GTPluginConfig = Omit<
77
72
  Parameters<typeof gt.setConfig>[0],
@@ -98,6 +93,18 @@ export type GTPluginConfig = Omit<
98
93
  // with language badges, translation menu, and per-language templates.
99
94
  // Requires translateDocuments to specify which document types to enable translations for.
100
95
  showDocumentInternationalization?: boolean;
96
+ // Field-level (internationalized-array) localization. Generates
97
+ // `internationalizedArray*` schema types + input components when enabled.
98
+ // `fieldLevelLocalization` is a descriptive alias for the same config.
99
+ internationalizedArray?: GTFieldLevelLocalizationConfig;
100
+ fieldLevelLocalization?: GTFieldLevelLocalizationConfig;
101
+ // How matched documents are translated. Independent from schema generation:
102
+ // enabling `internationalizedArray` only adds editable fields; routing
103
+ // translation through the array path requires opting in here. Default 'document'.
104
+ translationLevel?: FieldLevelTranslationMode;
105
+ // In 'mixed' mode, the document types that use the internationalized-array
106
+ // strategy; everything else stays document-level.
107
+ fieldLevelDocuments?: TranslateDocumentFilter[] | string[];
101
108
  };
102
109
 
103
110
  /**
@@ -134,18 +141,31 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
134
141
  additionalDeserializers = {},
135
142
  additionalBlockDeserializers = [],
136
143
  showDocumentInternationalization = true,
144
+ internationalizedArray,
145
+ fieldLevelLocalization,
146
+ translationLevel = 'document',
147
+ fieldLevelDocuments,
137
148
  }) => {
138
149
  // Resolve sourceLocale: explicit sourceLocale > defaultLocale (from gt.config.json) > library default
139
150
  const resolvedSourceLocale =
140
151
  sourceLocale ?? defaultLocale ?? libraryDefaultLocale;
141
152
 
142
153
  // Normalize translateDocuments: string[] → TranslateDocumentFilter[]
143
- let normalizedTranslateDocuments: TranslateDocumentFilter[] | undefined;
144
- if (translateDocuments) {
145
- normalizedTranslateDocuments = translateDocuments
146
- .map((entry) => (typeof entry === 'string' ? { type: entry } : entry))
154
+ const normalizeFilters = (
155
+ entries: (TranslateDocumentFilter | string)[] | undefined
156
+ ): TranslateDocumentFilter[] | undefined =>
157
+ entries
158
+ ?.map((entry) => (typeof entry === 'string' ? { type: entry } : entry))
147
159
  .filter((filter) => filter.documentId || filter.type);
148
- }
160
+
161
+ const normalizedTranslateDocuments = normalizeFilters(translateDocuments);
162
+ const normalizedFieldLevelDocuments =
163
+ normalizeFilters(fieldLevelDocuments) ?? [];
164
+
165
+ // `internationalizedArray` and `fieldLevelLocalization` are aliases.
166
+ const fieldLevelConfig = resolveFieldLevelConfig(
167
+ internationalizedArray ?? fieldLevelLocalization
168
+ );
149
169
 
150
170
  pluginConfig.init(
151
171
  secretsNamespace,
@@ -163,7 +183,10 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
163
183
  additionalStopTypes,
164
184
  additionalSerializers,
165
185
  additionalDeserializers,
166
- additionalBlockDeserializers
186
+ additionalBlockDeserializers,
187
+ translationLevel,
188
+ normalizedFieldLevelDocuments,
189
+ fieldLevelConfig.typePrefix
167
190
  );
168
191
  gt.setConfig({
169
192
  sourceLocale: resolvedSourceLocale,
@@ -172,13 +195,30 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
172
195
  projectId: projectId,
173
196
  });
174
197
 
198
+ // Document types localized in place via internationalized arrays must NOT
199
+ // get @sanity/document-internationalization (language badges + per-locale
200
+ // documents). Build the set of array-localized types from translationLevel.
201
+ const arrayLocalizedTypes = new Set<string>();
202
+ if (translationLevel === 'internationalizedArray') {
203
+ normalizedTranslateDocuments
204
+ ?.map((filter) => filter.type)
205
+ .filter((type): type is string => !!type)
206
+ .forEach((type) => arrayLocalizedTypes.add(type));
207
+ } else if (translationLevel === 'mixed') {
208
+ normalizedFieldLevelDocuments
209
+ .map((filter) => filter.type)
210
+ .filter((type): type is string => !!type)
211
+ .forEach((type) => arrayLocalizedTypes.add(type));
212
+ }
213
+
175
214
  // Auto-add document internationalization plugin
176
215
  const plugins = [];
177
216
  if (showDocumentInternationalization) {
178
217
  const schemaTypes =
179
218
  normalizedTranslateDocuments
180
219
  ?.map((filter) => filter.type)
181
- .filter((type): type is string => !!type) ?? [];
220
+ .filter((type): type is string => !!type)
221
+ .filter((type) => !arrayLocalizedTypes.has(type)) ?? [];
182
222
  if (schemaTypes.length > 0) {
183
223
  const allLocales = [resolvedSourceLocale, ...locales];
184
224
  const supportedLanguages = allLocales.map((locale) => {
@@ -195,9 +235,25 @@ export const gtPlugin = definePlugin<GTPluginConfig>(
195
235
  }
196
236
  }
197
237
 
238
+ const schemaTypes = fieldLevelConfig.enabled
239
+ ? createInternationalizedArrayTypes({
240
+ sourceLocale: resolvedSourceLocale,
241
+ locales,
242
+ fieldTypes: fieldLevelConfig.fieldTypes,
243
+ languageTitles: fieldLevelConfig.languageTitles,
244
+ getLanguageTitle: fieldLevelConfig.getLanguageTitle,
245
+ typePrefix: fieldLevelConfig.typePrefix,
246
+ includeCompatibilityTypes: fieldLevelConfig.includeCompatibilityTypes,
247
+ components: fieldLevelConfig.components,
248
+ })
249
+ : [];
250
+
198
251
  return {
199
252
  name: 'gt-sanity',
200
253
  plugins,
254
+ schema: {
255
+ types: schemaTypes,
256
+ },
201
257
  tools: [
202
258
  {
203
259
  name: 'translations',
@@ -0,0 +1,278 @@
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import { AddIcon, RemoveCircleIcon } from '@sanity/icons';
3
+ import {
4
+ Box,
5
+ Button,
6
+ Card,
7
+ Flex,
8
+ Grid,
9
+ Label,
10
+ Stack,
11
+ Text,
12
+ Tooltip,
13
+ } from '@sanity/ui';
14
+ import {
15
+ ArrayOfObjectsItem,
16
+ MemberItemError,
17
+ setIfMissing,
18
+ unset,
19
+ type ArrayOfObjectsInputProps,
20
+ type ArraySchemaType,
21
+ type FieldMember,
22
+ type FormPatch,
23
+ type ObjectItemProps,
24
+ type ObjectSchemaType,
25
+ type PatchEvent,
26
+ } from 'sanity';
27
+ import { randomKey } from '../utils/randomKey';
28
+
29
+ type LocaleItem = { _key: string; _type?: string; language?: string };
30
+
31
+ type GTArrayOptions = {
32
+ sourceLocale: string;
33
+ locales: string[];
34
+ titles?: Record<string, string>;
35
+ };
36
+
37
+ function readGTOptions(
38
+ schemaType: ArraySchemaType | ObjectSchemaType | undefined
39
+ ): GTArrayOptions | undefined {
40
+ const options = schemaType?.options as
41
+ | { gtInternationalizedArray?: GTArrayOptions }
42
+ | undefined;
43
+ return options?.gtInternationalizedArray;
44
+ }
45
+
46
+ function toneFromValidation(
47
+ validation: ObjectItemProps['validation']
48
+ ): 'critical' | 'caution' | undefined {
49
+ if (!validation?.length) return undefined;
50
+ if (validation.some((marker) => marker.level === 'error')) return 'critical';
51
+ if (validation.some((marker) => marker.level === 'warning')) return 'caution';
52
+ return undefined;
53
+ }
54
+
55
+ function RemoveLocaleButton(props: {
56
+ isSource: boolean;
57
+ readOnly: boolean;
58
+ onRemove: () => void;
59
+ }) {
60
+ const { isSource, readOnly, onRemove } = props;
61
+ const button = (
62
+ // The span keeps the tooltip working when the button is disabled
63
+ // (disabled buttons don't emit pointer events).
64
+ <span style={{ paddingBottom: '2px' }}>
65
+ <Button
66
+ mode='bleed'
67
+ icon={RemoveCircleIcon}
68
+ tone='critical'
69
+ disabled={readOnly || isSource}
70
+ onClick={onRemove}
71
+ />
72
+ </span>
73
+ );
74
+
75
+ if (!isSource) return button;
76
+
77
+ return (
78
+ <Tooltip
79
+ animate
80
+ portal
81
+ placement='top'
82
+ fallbackPlacements={['right', 'left']}
83
+ content={
84
+ <Text muted size={1}>
85
+ Can&apos;t remove the source language
86
+ </Text>
87
+ }
88
+ >
89
+ {button}
90
+ </Tooltip>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Item component for generated `internationalizedArray*Value` objects.
96
+ *
97
+ * Replaces the default array item (collapsed preview row + edit dialog) with
98
+ * an inline editor: the `value` field rendered in place, retitled with the
99
+ * locale label, plus a remove button. This is what makes localized fields
100
+ * read as "one labeled input per language" instead of a generic object list.
101
+ */
102
+ export function InternationalizedValueItem(props: ObjectItemProps) {
103
+ const { inputProps, parentSchemaType, schemaType, validation } = props;
104
+ const value = props.value as LocaleItem & { value?: unknown };
105
+ const { onChange } = inputProps;
106
+
107
+ const gtOptions =
108
+ readGTOptions(schemaType) ??
109
+ readGTOptions(parentSchemaType as ArraySchemaType);
110
+ const language = value?.language;
111
+ const languageLabel = language
112
+ ? (gtOptions?.titles?.[language] ?? language)
113
+ : 'Unknown language';
114
+ const isSource = !!language && language === gtOptions?.sourceLocale;
115
+
116
+ // For array-based values (e.g. Portable Text), insert patches from the
117
+ // nested input can arrive rooted at the item while `value` doesn't exist
118
+ // yet; initialize it and re-root those patches under `value`.
119
+ const wrappedOnChange = useCallback(
120
+ (patch: FormPatch | FormPatch[] | PatchEvent) => {
121
+ if (!Array.isArray(patch)) {
122
+ onChange(patch);
123
+ return;
124
+ }
125
+ const currentValue = value?.value;
126
+ const valueIsEmpty =
127
+ currentValue == null ||
128
+ (Array.isArray(currentValue) && currentValue.length === 0);
129
+ const needsRerooting =
130
+ valueIsEmpty &&
131
+ patch.some(
132
+ (p) =>
133
+ p.type === 'insert' &&
134
+ Array.isArray(p.path) &&
135
+ p.path.length > 0 &&
136
+ (p.path[0] === 'value' || typeof p.path[0] === 'number')
137
+ );
138
+ if (!needsRerooting) {
139
+ onChange(patch);
140
+ return;
141
+ }
142
+ const rerooted = patch.map((p) =>
143
+ p.type === 'insert' && Array.isArray(p.path) && p.path[0] !== 'value'
144
+ ? { ...p, path: ['value', ...p.path] }
145
+ : p
146
+ );
147
+ onChange(
148
+ currentValue === undefined
149
+ ? [setIfMissing([], ['value']), ...rerooted]
150
+ : rerooted
151
+ );
152
+ },
153
+ [onChange, value?.value]
154
+ );
155
+
156
+ // Render only the `value` field, with the locale label as its title.
157
+ const members = useMemo(
158
+ () =>
159
+ inputProps.members
160
+ .filter(
161
+ (member): member is FieldMember =>
162
+ member.kind === 'field' && member.name === 'value'
163
+ )
164
+ .map((member) => ({
165
+ ...member,
166
+ field: {
167
+ ...member.field,
168
+ schemaType: {
169
+ ...member.field.schemaType,
170
+ title: (
171
+ <Label muted size={1}>
172
+ {languageLabel}
173
+ </Label>
174
+ ) as unknown as string,
175
+ },
176
+ },
177
+ })),
178
+ [inputProps.members, languageLabel]
179
+ );
180
+
181
+ const handleRemove = useCallback(() => {
182
+ onChange(unset());
183
+ }, [onChange]);
184
+
185
+ return (
186
+ <Card paddingTop={2} tone={toneFromValidation(validation)}>
187
+ <Flex align='flex-end' gap={2}>
188
+ <Box flex={1}>
189
+ {inputProps.renderInput({
190
+ ...inputProps,
191
+ members,
192
+ onChange: wrappedOnChange,
193
+ // renderInput's parameter is typed as the base InputProps, which
194
+ // doesn't know about object members.
195
+ } as unknown as Parameters<typeof inputProps.renderInput>[0])}
196
+ </Box>
197
+ <RemoveLocaleButton
198
+ isSource={isSource}
199
+ readOnly={!!inputProps.readOnly}
200
+ onRemove={handleRemove}
201
+ />
202
+ </Flex>
203
+ </Card>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Input for generated `internationalizedArray*` fields.
209
+ *
210
+ * Renders items inline (each one an `InternationalizedValueItem`) instead of
211
+ * the default array input's draggable preview rows, followed by one add
212
+ * button per locale that doesn't have an entry yet. Each add button appends
213
+ * `{ _key, _type, language }` so the new item lands with its locale preset
214
+ * (the default "Add item" would create an item with no `language` — a dead
215
+ * end, since `language` is read-only).
216
+ */
217
+ export function InternationalizedArrayInput(props: ArrayOfObjectsInputProps) {
218
+ const { members, schemaType, value, readOnly, onItemAppend } = props;
219
+ const gtOptions = readGTOptions(schemaType);
220
+ const itemType = schemaType.of[0]?.name;
221
+
222
+ const handleAdd = useCallback(
223
+ (language: string) => {
224
+ const item: LocaleItem = { _key: randomKey(), _type: itemType, language };
225
+ onItemAppend(item);
226
+ },
227
+ [onItemAppend, itemType]
228
+ );
229
+
230
+ if (!gtOptions) {
231
+ // No locale config on the type (not generated by gtPlugin) — fall back
232
+ // to the default array input rather than rendering a dead-end UI.
233
+ return props.renderDefault(props);
234
+ }
235
+
236
+ const allLocales = [gtOptions.sourceLocale, ...gtOptions.locales];
237
+ const existing = new Set(
238
+ ((value as LocaleItem[] | undefined) ?? [])
239
+ .map((item) => item.language)
240
+ .filter(Boolean)
241
+ );
242
+ const missing = allLocales.filter((locale) => !existing.has(locale));
243
+
244
+ return (
245
+ <Stack space={3}>
246
+ {members.length > 0 ? (
247
+ <Stack space={2}>
248
+ {members.map((member) =>
249
+ member.kind === 'item' ? (
250
+ <ArrayOfObjectsItem key={member.key} {...props} member={member} />
251
+ ) : (
252
+ <MemberItemError key={member.key} member={member} />
253
+ )
254
+ )}
255
+ </Stack>
256
+ ) : (
257
+ <Card border tone='transparent' padding={3} radius={2}>
258
+ <Text size={1} muted>
259
+ This internationalized field currently has no translations.
260
+ </Text>
261
+ </Card>
262
+ )}
263
+ {!readOnly && missing.length > 0 ? (
264
+ <Grid columns={Math.min(missing.length, 4)} gap={1}>
265
+ {missing.map((locale) => (
266
+ <Button
267
+ key={locale}
268
+ icon={AddIcon}
269
+ mode='ghost'
270
+ text={gtOptions.titles?.[locale] ?? locale}
271
+ onClick={() => handleAdd(locale)}
272
+ />
273
+ ))}
274
+ </Grid>
275
+ ) : null}
276
+ </Stack>
277
+ );
278
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { createInternationalizedArrayTypes } from '../createInternationalizedArrayTypes';
3
+
4
+ type GeneratedArrayType = {
5
+ name: string;
6
+ type: string;
7
+ components?: { input?: unknown; field?: unknown };
8
+ of: Array<{
9
+ name: string;
10
+ type: string;
11
+ components?: { item?: unknown };
12
+ fields: Array<{
13
+ name: string;
14
+ type: string;
15
+ readOnly?: boolean;
16
+ hidden?: boolean;
17
+ }>;
18
+ }>;
19
+ };
20
+
21
+ const generate = (
22
+ overrides: Partial<Parameters<typeof createInternationalizedArrayTypes>[0]>
23
+ ) =>
24
+ createInternationalizedArrayTypes({
25
+ sourceLocale: 'en',
26
+ locales: ['es', 'fr'],
27
+ fieldTypes: ['string', 'text'],
28
+ ...overrides,
29
+ }) as unknown as GeneratedArrayType[];
30
+
31
+ describe('createInternationalizedArrayTypes', () => {
32
+ test('generates internationalizedArrayString', () => {
33
+ const types = generate({ fieldTypes: ['string'] });
34
+ const stringType = types.find(
35
+ (t) => t.name === 'internationalizedArrayString'
36
+ );
37
+ expect(stringType).toBeDefined();
38
+ expect(stringType?.type).toBe('array');
39
+ const valueObject = stringType?.of[0];
40
+ expect(valueObject?.name).toBe('internationalizedArrayStringValue');
41
+ const valueField = valueObject?.fields.find((f) => f.name === 'value');
42
+ expect(valueField?.type).toBe('string');
43
+ });
44
+
45
+ test('generates internationalizedArrayText with a text value field', () => {
46
+ const types = generate({ fieldTypes: ['text'] });
47
+ const textType = types.find((t) => t.name === 'internationalizedArrayText');
48
+ const valueField = textType?.of[0].fields.find((f) => f.name === 'value');
49
+ expect(valueField?.type).toBe('text');
50
+ });
51
+
52
+ test('the language field is read-only', () => {
53
+ const types = generate({ fieldTypes: ['string'] });
54
+ const languageField = types[0].of[0].fields.find(
55
+ (f) => f.name === 'language'
56
+ );
57
+ expect(languageField?.readOnly).toBe(true);
58
+ });
59
+
60
+ test('generates a Portable Text (block) value field', () => {
61
+ const types = generate({ fieldTypes: ['block'] });
62
+ const blockType = types.find(
63
+ (t) => t.name === 'internationalizedArrayBlock'
64
+ );
65
+ const valueField = blockType?.of[0].fields.find(
66
+ (f) => f.name === 'value'
67
+ ) as { type: string; of: Array<{ type: string }> } | undefined;
68
+ expect(valueField?.type).toBe('array');
69
+ expect(valueField?.of[0].type).toBe('block');
70
+ });
71
+
72
+ test('supports custom field types', () => {
73
+ const types = generate({
74
+ fieldTypes: [{ name: 'seo', type: 'seoObject' }],
75
+ });
76
+ const seoType = types.find((t) => t.name === 'internationalizedArraySeo');
77
+ expect(seoType).toBeDefined();
78
+ expect(seoType?.of[0].name).toBe('internationalizedArraySeoValue');
79
+ const valueField = seoType?.of[0].fields.find((f) => f.name === 'value');
80
+ expect(valueField?.type).toBe('seoObject');
81
+ });
82
+
83
+ test('generates compatibility aliases when a custom prefix is used', () => {
84
+ const types = generate({
85
+ fieldTypes: ['string'],
86
+ typePrefix: 'gtArray',
87
+ includeCompatibilityTypes: true,
88
+ });
89
+ expect(types.find((t) => t.name === 'gtArrayString')).toBeDefined();
90
+ expect(
91
+ types.find((t) => t.name === 'internationalizedArrayString')
92
+ ).toBeDefined();
93
+ });
94
+
95
+ test('does not duplicate types when prefix is the default', () => {
96
+ const names = generate({ fieldTypes: ['string'] }).map((t) => t.name);
97
+ expect(names).toEqual(['internationalizedArrayString']);
98
+ });
99
+
100
+ test('omits compatibility aliases when disabled', () => {
101
+ const names = generate({
102
+ fieldTypes: ['string'],
103
+ typePrefix: 'gtArray',
104
+ includeCompatibilityTypes: false,
105
+ }).map((t) => t.name);
106
+ expect(names).toEqual(['gtArrayString']);
107
+ });
108
+
109
+ describe('components option', () => {
110
+ test('attaches GT components by default and hides the language field', () => {
111
+ const [type] = generate({ fieldTypes: ['string'] });
112
+ expect(typeof type.components?.input).toBe('function');
113
+ expect(typeof type.components?.field).toBe('function');
114
+ expect(typeof type.of[0].components?.item).toBe('function');
115
+ const languageField = type.of[0].fields.find(
116
+ (f) => f.name === 'language'
117
+ );
118
+ expect(languageField?.hidden).toBe(true);
119
+ });
120
+
121
+ test('replaces slots with custom components', () => {
122
+ const CustomInput = () => null;
123
+ const CustomItem = () => null;
124
+ const [type] = generate({
125
+ fieldTypes: ['string'],
126
+ components: { input: CustomInput, item: CustomItem },
127
+ });
128
+ expect(type.components?.input).toBe(CustomInput);
129
+ expect(type.of[0].components?.item).toBe(CustomItem);
130
+ });
131
+
132
+ test('detaches components when set to false', () => {
133
+ const [type] = generate({
134
+ fieldTypes: ['string'],
135
+ components: { input: false, item: false, field: false },
136
+ });
137
+ expect(type.components).toBeUndefined();
138
+ expect(type.of[0].components).toBeUndefined();
139
+ });
140
+
141
+ test('field level-reset default follows the input slot', () => {
142
+ const [detached] = generate({
143
+ fieldTypes: ['string'],
144
+ components: { input: false },
145
+ });
146
+ expect(detached.components?.field).toBeUndefined();
147
+
148
+ const CustomInput = () => null;
149
+ const [custom] = generate({
150
+ fieldTypes: ['string'],
151
+ components: { input: CustomInput },
152
+ });
153
+ expect(custom.components?.field).toBeUndefined();
154
+ expect(custom.components?.input).toBe(CustomInput);
155
+ });
156
+
157
+ test('keeps the language field visible with a custom or detached item', () => {
158
+ const [type] = generate({
159
+ fieldTypes: ['string'],
160
+ components: { item: false },
161
+ });
162
+ const languageField = type.of[0].fields.find(
163
+ (f) => f.name === 'language'
164
+ );
165
+ expect(languageField?.hidden).toBe(false);
166
+ expect(languageField?.readOnly).toBe(true);
167
+ });
168
+ });
169
+ });