From 7e8853feaa42f818ab3dc759e3524d82ba06f0bd Mon Sep 17 00:00:00 2001 From: Janitha Karunarathna Date: Wed, 7 Oct 2026 13:33:10 +0530 Subject: [PATCH] feat(jsonforms-renderers): enhance SearchSelectControl with displayTemplate handling and new test case This update introduces a new test case to verify the displayTemplate functionality for stored IDs, ensuring that the correct label is shown after a resolve is cancelled and retried. Additionally, the SearchSelectControl implementation has been refined to improve label persistence and handling of optimistic UI updates during asynchronous resolution. The changes enhance the robustness and clarity of the dropdown behavior in the component. --- .../renderers/SearchSelectControl.test.tsx | 45 ++++++++++++++++++- .../src/renderers/SearchSelectControl.tsx | 22 ++++++--- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx index 2bf80ad..f39561f 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.test.tsx @@ -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' @@ -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( + + + Promise.resolve({ options: [] }), + async resolve(value: string) { + resolves.push(value) + return { id: value, name: 'ALTHEIMER' } + }, + }, + }} + > + + + + , + ) + + 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', diff --git a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx index 6e5da60..389cbc6 100644 --- a/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx +++ b/packages/jsonforms-renderers/src/renderers/SearchSelectControl.tsx @@ -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