Skip to content

Commit d37a6cb

Browse files
authored
fix(internationalized-array): respect document-level read-only state (#814)
* fix(internationalized-array): respect document-level read-only state * chore(internationalized-array): add changeset for read-only fix
1 parent be1496a commit d37a6cb

7 files changed

Lines changed: 104 additions & 40 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sanity-plugin-internationalized-array": patch
3+
---
4+
5+
Fix document-level read-only state not being respected by add-language buttons and field actions

plugins/sanity-plugin-internationalized-array/src/components/DocumentAddButtons.test.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import DocumentAddButtons from './DocumentAddButtons'
99
const mockOnChange = vi.fn()
1010
const mockToastPush = vi.fn()
1111
const mockGetFormValue = vi.fn()
12+
let mockFormState: Record<string, unknown> | undefined
1213

1314
vi.mock('sanity', () => ({
1415
isSanityDocument: vi.fn(
@@ -34,6 +35,7 @@ vi.mock('sanity', () => ({
3435
vi.mock('sanity/structure', () => ({
3536
useDocumentPane: vi.fn(() => ({
3637
onChange: mockOnChange,
38+
formState: mockFormState,
3739
})),
3840
}))
3941

@@ -68,6 +70,7 @@ describe('DocumentAddButtons', () => {
6870
mockOnChange.mockClear()
6971
mockToastPush.mockClear()
7072
mockGetFormValue.mockReset()
73+
mockFormState = undefined
7174
})
7275

7376
test('renders heading and add buttons', () => {
@@ -81,8 +84,7 @@ describe('DocumentAddButtons', () => {
8184
expect(screen.getByTestId('add-de')).toBeInTheDocument()
8285
})
8386

84-
test('shows error toast when document has no internationalized fields', async () => {
85-
// Document with no internationalized array fields
87+
test('shows warning toast when document has no internationalized fields', async () => {
8688
const docValue = {
8789
_id: 'doc1',
8890
_type: 'article',
@@ -124,7 +126,6 @@ describe('DocumentAddButtons', () => {
124126

125127
fireEvent.click(screen.getByTestId('add-fr'))
126128

127-
// onChange should have been called with patches
128129
expect(mockOnChange).toHaveBeenCalledWith([
129130
{
130131
type: 'setIfMissing',
@@ -174,7 +175,7 @@ describe('DocumentAddButtons', () => {
174175
mockGetFormValue.mockReturnValue(docValue)
175176
render(<DocumentAddButtons />, {wrapper: ThemeWrapper})
176177
fireEvent.click(screen.getByTestId('add-fr'))
177-
// onChange should have been called with patches
178+
178179
expect(mockOnChange).toHaveBeenCalledWith([
179180
{
180181
type: 'setIfMissing',
@@ -213,6 +214,17 @@ describe('DocumentAddButtons', () => {
213214
])
214215
})
215216

217+
test('disables add buttons when document formState is readOnly', () => {
218+
mockFormState = {readOnly: true}
219+
mockGetFormValue.mockReturnValue(undefined)
220+
render(<DocumentAddButtons />, {wrapper: ThemeWrapper})
221+
222+
expect(screen.getByTestId('add-en')).toHaveAttribute('data-disabled', 'true')
223+
expect(screen.getByTestId('add-fr')).toHaveAttribute('data-disabled', 'true')
224+
expect(screen.getByTestId('add-es')).toHaveAttribute('data-disabled', 'true')
225+
expect(screen.getByTestId('add-de')).toHaveAttribute('data-disabled', 'true')
226+
})
227+
216228
test('skips fields that already have the selected language translation', async () => {
217229
const docValue = {
218230
_id: 'doc1',
@@ -235,8 +247,7 @@ describe('DocumentAddButtons', () => {
235247
// Add 'en' again - should be filtered out as already existing
236248
fireEvent.click(screen.getByTestId('add-en'))
237249

238-
// Since all fields already have 'en', the toast should show an error
239-
// (the filter reduces to 0 items)
250+
// All fields already have 'en', so no eligible fields remain
240251
expect(mockToastPush).toHaveBeenCalledWith({
241252
status: 'warning',
242253
title: 'No missing translations for English found.',

plugins/sanity-plugin-internationalized-array/src/components/DocumentAddButtons.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ export default function DocumentAddButtons(): ReactElement {
4242
const {filteredLanguages} = useInternationalizedArrayContext()
4343

4444
const toast = useToast()
45-
const {onChange} = useDocumentPane()
45+
const {onChange, formState} = useDocumentPane()
4646
const schema = useSchema()
4747

48-
// Helper function to determine if a field should be initialized as an array
48+
// Array-based types (e.g. Portable Text) need [] as initial value, not undefined
4949
const getInitialValueForType = useCallback(
5050
(typeName: string): unknown => {
5151
if (!typeName) return undefined
@@ -138,13 +138,12 @@ export default function DocumentAddButtons(): ReactElement {
138138
return
139139
}
140140

141-
// Write a new patch for each empty field
141+
// Build patches for each field missing the selected language
142142
const patches: (FormSetIfMissingPatch | FormInsertPatch)[] = []
143143

144144
for (const toTranslate of removeDuplicates) {
145145
const path = toTranslate.path
146146

147-
// Get the appropriate initial value for this field type
148147
const initialValue = getInitialValueForType(toTranslate._type)
149148

150149
const ifMissing = setIfMissing([], path)
@@ -154,7 +153,7 @@ export default function DocumentAddButtons(): ReactElement {
154153
_key: randomKey(),
155154
[LANGUAGE_FIELD_NAME]: languageId,
156155
_type: toTranslate._type,
157-
value: initialValue, // Use the determined initial value instead of undefined
156+
value: initialValue,
158157
},
159158
],
160159
'after',
@@ -175,7 +174,11 @@ export default function DocumentAddButtons(): ReactElement {
175174
Add translation to internationalized fields
176175
</Text>
177176
</Box>
178-
<AddButtons readOnly={false} handleClick={handleDocumentButtonClick} languagesInUse={[]} />
177+
<AddButtons
178+
readOnly={Boolean(formState?.readOnly)}
179+
handleClick={handleDocumentButtonClick}
180+
languagesInUse={[]}
181+
/>
179182
</Stack>
180183
)
181184
}

plugins/sanity-plugin-internationalized-array/src/components/InternationalizedArray.test.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {createValues, MOCK_INTERNATIONALIZED_ARRAY_CONTEXT, MOCK_LANGUAGES} from
77
const mockToastPush = vi.fn()
88
const mockGetFormValue = vi.fn()
99

10-
// Mock sanity hooks and components
1110
vi.mock('sanity', () => ({
1211
useFormValue: vi.fn(() => 'article'),
1312
useGetFormValue: vi.fn(() => mockGetFormValue),
@@ -54,7 +53,6 @@ vi.mock('./InternationalizedArrayContext', () => ({
5453
})),
5554
}))
5655

57-
// Mock useToast from @sanity/ui to avoid missing ToastProvider context
5856
vi.mock('@sanity/ui', async (importOriginal) => {
5957
const original = await importOriginal<typeof import('@sanity/ui')>()
6058
return {
@@ -373,7 +371,6 @@ describe('InternationalizedArray', () => {
373371

374372
renderInternationalizedArray(props)
375373

376-
// The useEffect should detect out-of-order and call onChange(set(reordered))
377374
expect(onChange).toHaveBeenCalled()
378375
expect(onChange).toHaveBeenCalledWith({
379376
type: 'set',
@@ -406,7 +403,6 @@ describe('InternationalizedArray', () => {
406403

407404
renderInternationalizedArray(props)
408405

409-
// Should NOT reorder because documentReadOnly is true
410406
expect(onChange).not.toHaveBeenCalled()
411407
})
412408

@@ -472,7 +468,6 @@ describe('InternationalizedArray', () => {
472468

473469
renderInternationalizedArray(props)
474470

475-
// Should not add defaults while deleting
476471
expect(onChange).not.toHaveBeenCalled()
477472
})
478473

@@ -487,10 +482,9 @@ describe('InternationalizedArray', () => {
487482

488483
renderInternationalizedArray(props)
489484

490-
// Give the setTimeout a chance to fire
491-
await new Promise((r) => setTimeout(r, 10))
485+
// Allow the scheduled setTimeout in the useEffect to fire
486+
await new Promise((resolve) => setTimeout(resolve, 10))
492487

493-
// Should not add defaults when documentReadOnly is true
494488
expect(onChange).not.toHaveBeenCalled()
495489
})
496490

@@ -507,7 +501,6 @@ describe('InternationalizedArray', () => {
507501
expect(screen.getByTestId('add-fr')).toBeInTheDocument()
508502
expect(screen.getByTestId('add-es')).toBeInTheDocument()
509503
expect(screen.getByTestId('add-de')).toBeInTheDocument()
510-
// Add buttons should still be visible (individual language buttons)
511504
expect(screen.queryByTestId('add-all-languages')).not.toBeInTheDocument()
512505
})
513506

@@ -522,7 +515,25 @@ describe('InternationalizedArray', () => {
522515

523516
renderInternationalizedArray(props)
524517

525-
// Add buttons should be disabled
518+
expect(screen.getByTestId('add-en')).toHaveAttribute('data-disabled', 'true')
519+
expect(screen.getByTestId('add-fr')).toHaveAttribute('data-disabled', 'true')
520+
expect(screen.getByTestId('add-es')).toHaveAttribute('data-disabled', 'true')
521+
expect(screen.getByTestId('add-de')).toHaveAttribute('data-disabled', 'true')
522+
expect(screen.getByTestId('add-all-languages')).toHaveAttribute('data-disabled', 'true')
523+
})
524+
525+
test('disables add buttons when document is readOnly (props.readOnly)', () => {
526+
vi.mocked(useInternationalizedArrayContext).mockReturnValue(
527+
MOCK_INTERNATIONALIZED_ARRAY_CONTEXT,
528+
)
529+
530+
const props = createMockArrayProps({
531+
readOnly: true,
532+
schemaType: {name: 'internationalizedArrayString', readOnly: false},
533+
})
534+
535+
renderInternationalizedArray(props)
536+
526537
expect(screen.getByTestId('add-en')).toHaveAttribute('data-disabled', 'true')
527538
expect(screen.getByTestId('add-fr')).toHaveAttribute('data-disabled', 'true')
528539
expect(screen.getByTestId('add-es')).toHaveAttribute('data-disabled', 'true')

plugins/sanity-plugin-internationalized-array/src/components/InternationalizedArray.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export default function InternationalizedArray(
5555
const value = _value as InternationalizedArrayItem[]
5656
const itemsNeedingMigration = value?.filter((v) => !v[LANGUAGE_FIELD_NAME]) ?? []
5757
const shouldMigrateArray = itemsNeedingMigration.length > 0
58-
const readOnly = typeof schemaType.readOnly === 'boolean' ? schemaType.readOnly : false
58+
const readOnly = Boolean(documentReadOnly) || schemaType.readOnly === true
5959
const toast = useToast()
6060

6161
const getFormValue = useGetFormValue()
@@ -76,7 +76,7 @@ export default function InternationalizedArray(
7676
const usingBuiltInLanguageFilter =
7777
typeof documentType === 'string' && builtInLanguageFilter.documentTypes.includes(documentType)
7878

79-
// TODO:Is this redundant? The filter plugin is already filtering the members, why do we also need to call it at this level.
79+
// TODO: Is this redundant? The filter plugin already filters members at its own level.
8080
const filteredMembers = useMemo(
8181
() =>
8282
usingLanguageFilterPlugin || usingBuiltInLanguageFilter
@@ -94,11 +94,9 @@ export default function InternationalizedArray(
9494
if (!valueMember || valueMember.kind !== 'field') {
9595
return false
9696
}
97-
// Yes, this is a mess but it's necessary to support both the built-in language filter and the language filter plugin.
98-
// If they are using the built-in method it's better to pass the languages to the filter so we can return the fields that are
99-
// not using a valid language id instead of hiding them from the users.
100-
// We can't do that with the language filter plugin.
101-
// Also, the built in method is better because the filter function will only run once.
97+
// The built-in filter receives the full languages list so it can
98+
// surface fields with invalid language IDs rather than hiding them.
99+
// The language filter plugin does not support this.
102100
return usingBuiltInLanguageFilter
103101
? internationalizedArrayLanguageFilter(
104102
member.item.schemaType,
@@ -173,7 +171,7 @@ export default function InternationalizedArray(
173171
.filter((language) => languages.find((l) => l.id === language))
174172
// Account for strict mode by scheduling the update
175173
const timeout = setTimeout(() => {
176-
if (!documentReadOnly) handleAddLanguages(languagesToAdd)
174+
if (!readOnly) handleAddLanguages(languagesToAdd)
177175
})
178176
return () => clearTimeout(timeout)
179177
}
@@ -184,7 +182,7 @@ export default function InternationalizedArray(
184182
defaultLanguages,
185183
addedLanguages,
186184
languages,
187-
documentReadOnly,
185+
readOnly,
188186
shouldMigrateArray,
189187
])
190188

@@ -248,10 +246,10 @@ export default function InternationalizedArray(
248246

249247
// Automatically restore order of fields
250248
useEffect(() => {
251-
if (languagesOutOfOrder.length > 0 && allKeysAreLanguages && !documentReadOnly) {
249+
if (languagesOutOfOrder.length > 0 && allKeysAreLanguages && !readOnly) {
252250
handleRestoreOrder()
253251
}
254-
}, [languagesOutOfOrder, allKeysAreLanguages, handleRestoreOrder, documentReadOnly])
252+
}, [languagesOutOfOrder, allKeysAreLanguages, handleRestoreOrder, readOnly])
255253

256254
// compare value keys with possible languages
257255
const allLanguagesArePresent = useMemo(

plugins/sanity-plugin-internationalized-array/src/fieldActions/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {internationalizedArrayFieldAction} from './index'
1313

1414
const mockUseFormValue = vi.fn()
1515
const mockOnChange = vi.fn()
16+
let mockFormState: Record<string, unknown> | undefined
1617

1718
vi.mock('sanity', async (importOriginal) => {
1819
const original = await importOriginal<typeof import('sanity')>()
@@ -25,6 +26,7 @@ vi.mock('sanity', async (importOriginal) => {
2526
vi.mock('sanity/structure', () => ({
2627
useDocumentPane: vi.fn(() => ({
2728
onChange: mockOnChange,
29+
formState: mockFormState,
2830
})),
2931
}))
3032

@@ -60,6 +62,7 @@ describe('internationalizedArrayFieldAction', () => {
6062
beforeEach(() => {
6163
mockUseFormValue.mockReturnValue(undefined)
6264
mockOnChange.mockClear()
65+
mockFormState = undefined
6366
vi.mocked(useInternationalizedArrayContext).mockReturnValue(
6467
MOCK_INTERNATIONALIZED_ARRAY_CONTEXT,
6568
)
@@ -380,4 +383,37 @@ describe('internationalizedArrayFieldAction', () => {
380383
],
381384
})
382385
})
386+
387+
test('all translate actions disabled when document is readOnly', () => {
388+
mockFormState = {readOnly: true}
389+
mockUseFormValue.mockReturnValue(undefined)
390+
391+
const {result} = renderHook(() => fieldAction.useAction(createMockFieldActionProps()))
392+
393+
const fieldGroup = result.current.type === 'group' ? result.current : undefined
394+
if (!fieldGroup) {
395+
throw new Error('Field group not found')
396+
}
397+
398+
fieldGroup.children.filter(isActionItem).forEach((action) => {
399+
expect(action.disabled).toBe(true)
400+
})
401+
})
402+
403+
test('add-missing action disabled when document is readOnly', () => {
404+
mockFormState = {readOnly: true}
405+
mockUseFormValue.mockReturnValue(undefined)
406+
407+
const {result} = renderHook(() => fieldAction.useAction(createMockFieldActionProps()))
408+
409+
const fieldGroup = result.current.type === 'group' ? result.current : undefined
410+
if (!fieldGroup) {
411+
throw new Error('Field group not found')
412+
}
413+
const addMissing = fieldGroup.children[fieldGroup.children.length - 1]!
414+
if (!isActionItem(addMissing)) {
415+
throw new Error('Add missing action is not an action item')
416+
}
417+
expect(addMissing.disabled).toBe(true)
418+
})
383419
})

plugins/sanity-plugin-internationalized-array/src/fieldActions/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ const createTranslateFieldActions: (
2727
languages.map((language) => {
2828
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
2929
const value = useFormValue(fieldActionProps.path) as InternationalizedArrayItem[]
30+
const {onChange, formState} = useDocumentPane()
3031
const disabled =
31-
value && Array.isArray(value)
32+
Boolean(formState?.readOnly) ||
33+
(value && Array.isArray(value)
3234
? Boolean(value?.find((item) => item[LANGUAGE_FIELD_NAME] === language.id))
33-
: false
35+
: false)
3436
const hidden = !filteredLanguages.some((f) => f.id === language.id)
3537

36-
const {onChange} = useDocumentPane()
37-
3838
const onAction = useCallback(() => {
3939
const {schemaType, path} = fieldActionProps
4040

@@ -70,11 +70,11 @@ const AddMissingTranslationsFieldAction: (
7070
) => DocumentFieldActionItem = (fieldActionProps, {languages, filteredLanguages}) => {
7171
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
7272
const value = useFormValue(fieldActionProps.path) as InternationalizedArrayItem[]
73-
const disabled = value && value.length === filteredLanguages.length
73+
const {onChange, formState} = useDocumentPane()
74+
const disabled =
75+
Boolean(formState?.readOnly) || (value && value.length === filteredLanguages.length)
7476
const hidden = checkAllLanguagesArePresent(filteredLanguages, value)
7577

76-
const {onChange} = useDocumentPane()
77-
7878
const onAction = useCallback(() => {
7979
const {schemaType, path} = fieldActionProps
8080

0 commit comments

Comments
 (0)