Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { useState } from 'react'
import { StrictMode, useState } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { JsonForms } from '@jsonforms/react'
import { Theme } from '@radix-ui/themes'
Expand Down Expand Up @@ -363,6 +363,49 @@ describe('SearchSelectControl displayTemplate', () => {
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('USTMR-ALTHEIMER')
})

it('shows displayTemplate for a stored id after resolve is cancelled and retried', async () => {
const formSchema = {
type: 'object',
properties: {
port: {
type: 'string',
title: 'Port',
'x-search': {
service: 'countries',
mode: 'small-list',
displayTemplate: '{name} ({id})',
},
},
},
} as unknown as JsonSchema

const resolves: string[] = []
render(
<StrictMode>
<Theme>
<SearchServiceProvider
services={{
countries: {
search: () => Promise.resolve({ options: [] }),
async resolve(value: string) {
resolves.push(value)
return { id: value, name: 'ALTHEIMER' }
},
},
}}
>
<JsonForms schema={formSchema} uischema={stringUi} data={{ port: 'USTMR' }} renderers={radixRenderers} />
</SearchServiceProvider>
</Theme>
</StrictMode>,
)

await waitFor(() => {
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('ALTHEIMER (USTMR)')
})
expect(resolves).toContain('USTMR')
})

it('writes the service id and templated label for an object-shaped field', async () => {
const formSchema = {
type: 'object',
Expand Down
22 changes: 16 additions & 6 deletions packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,26 +200,36 @@ const SearchSelectControl = ({
return
}
if (lastResolvedRef.current?.value === currentValue && lastResolvedRef.current?.label === currentLabel) return
// mark as resolving immediately — prevents re-runs if resolve is absent, rejects, or returns undefined
lastResolvedRef.current = { value: currentValue, label: currentLabel }

// object-shaped fields already carry the label from submission time — no need to re-resolve it
if (isObjectMode && currentLabel) {
setSelectedOption({ id: currentValue, name: currentLabel })
lastResolvedRef.current = { value: currentValue, label: currentLabel }
return
}

// optimistic raw-value label first, so the field isn't blank while resolving
setSelectedOption({ id: currentValue, name: currentLabel ?? currentValue })
if (!service?.resolve) return
if (!service?.resolve) {
lastResolvedRef.current = { value: currentValue, label: currentLabel }
return
}

// Mark resolved only after this call settles. StrictMode runs the effect,
// cancels it, and runs it again; recording the id up front makes that
// second run skip resolve and leave the raw id on screen.
let cancelled = false
const value = currentValue
const label = currentLabel
void service
.resolve(currentValue, searchParams)
.resolve(value, searchParams)
.then((opt) => {
if (!cancelled && opt) setSelectedOption(presentOption(opt, displayTemplate))
if (cancelled) return
if (opt) setSelectedOption(presentOption(opt, displayTemplate))
lastResolvedRef.current = { value, label }
})
.catch(() => {
/* keep raw-value fallback */
if (!cancelled) lastResolvedRef.current = { value, label }
})
return () => {
cancelled = true
Expand Down
Loading