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.
- package/dist/index.cjs +1220 -763
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +113 -1
- package/dist/index.d.ts +113 -1
- package/dist/index.js +1211 -754
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter/core.ts +27 -2
- package/src/adapter/types.ts +9 -0
- package/src/components/TranslationsProvider.tsx +92 -6
- package/src/components/page/TranslationsTable.tsx +5 -3
- package/src/components/shared/SingleDocumentView.tsx +5 -3
- package/src/components/tab/TranslationView.tsx +24 -14
- package/src/configuration/baseDocumentLevelConfig/documentLevelPatch.ts +1 -1
- package/src/configuration/baseFieldLevelConfig.ts +1 -1
- package/src/configuration/internationalizedArrayConfig/internationalizedArrayPatch.ts +59 -0
- package/src/index.ts +114 -58
- package/src/schema/InternationalizedArrayInput.tsx +278 -0
- package/src/schema/__tests__/createInternationalizedArrayTypes.test.ts +169 -0
- package/src/schema/createInternationalizedArrayTypes.ts +209 -0
- package/src/schema/types.ts +84 -0
- package/src/serialization/__tests__/BaseDocumentDeserializer/baseDeserialization.test.ts +3 -3
- package/src/serialization/__tests__/BaseDocumentMerger/baseMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentMerger/documentLevelMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentMerger/fieldLevelMerge.test.ts +1 -1
- package/src/serialization/__tests__/BaseDocumentSerializer/baseSerialization.test.ts +2 -2
- package/src/serialization/__tests__/BaseDocumentSerializer/documentInlineMarks.test.ts +4 -2
- package/src/serialization/__tests__/global.setup.ts +6 -0
- package/src/serialization/__tests__/helpers.ts +2 -1
- package/src/serialization/internationalizedArray/__tests__/internationalizedArray.test.ts +265 -0
- package/src/serialization/internationalizedArray/__tests__/serializeRoundTrip.test.ts +83 -0
- package/src/serialization/internationalizedArray/collapse.ts +52 -0
- package/src/serialization/internationalizedArray/detect.ts +81 -0
- package/src/serialization/internationalizedArray/merge.ts +149 -0
- package/src/serialization/types.ts +5 -1
- package/src/translation/__tests__/strategy.test.ts +47 -0
- package/src/translation/importDocument.ts +9 -5
- package/src/translation/strategy.ts +92 -0
- package/src/translation/uploadFiles.ts +1 -1
- package/src/types.ts +1 -1
- package/src/utils/__tests__/batchProcessor.test.ts +61 -2
- package/src/utils/batchProcessor.ts +11 -6
- package/src/utils/serialize.ts +24 -7
- 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
32
|
-
} from './serialization';
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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'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
|
+
});
|