diff --git a/web/packages/common/src/components/form/ControlledSwitch/index.tsx b/web/packages/common/src/components/form/ControlledSwitch/index.tsx index b31618ec66..997518a686 100644 --- a/web/packages/common/src/components/form/ControlledSwitch/index.tsx +++ b/web/packages/common/src/components/form/ControlledSwitch/index.tsx @@ -14,6 +14,10 @@ interface Props attributes?: { Flex?: ComponentProps; }; + /** Converts the stored form value to the switch's boolean checked state. */ + toChecked?: (value: unknown) => boolean; + /** Converts the switch's boolean value before storing it in the form. */ + toFormValue?: (checked: boolean) => unknown; } export const ControlledSwitch: FC = ({ @@ -21,13 +25,15 @@ export const ControlledSwitch: FC = ({ formFieldProps, onChange, attributes, + toChecked, + toFormValue, ...props }) => { const { field } = useController(useControllerProps); const wrappedOnChange = (checked: boolean) => { onChange?.(checked); - field.onChange(checked); + field.onChange(toFormValue ? toFormValue(checked) : checked); }; return ( @@ -39,7 +45,7 @@ export const ControlledSwitch: FC = ({ name={field.name} ref={field.ref} onBlur={field.onBlur} - checked={field.value} + checked={toChecked ? toChecked(field.value) : Boolean(field.value)} onCheckedChange={wrappedOnChange} {...props} /> diff --git a/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx b/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx index cd529bf66f..8fe513fca0 100644 --- a/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx +++ b/web/packages/studio/src/components/ColumnConfigPanel/SeedDatasetConfig.tsx @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; import { getPartsFromReference } from '@nemo/common/src/namedEntity'; import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; -import { Flex, FormField, Select, Tag, Text } from '@nvidia/foundations-react-core'; +import { Flex, FormField, Tag, Text } from '@nvidia/foundations-react-core'; import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { @@ -13,20 +14,14 @@ import { SEED_FILESET_REF_KEY, SEED_SAMPLING_STRATEGY_KEY, } from '@studio/routes/DataDesignerJobBuildRoute/columns'; +import type { JobBuilderFormValues } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder'; import { FilesetSearchableSelect } from '@studio/routes/DeploymentsListRoute/CreateDeploymentSidePanel/FilesetSearchableSelect'; import { getContentColumns, getFileExtension } from '@studio/util/files'; -import { type FC, useEffect, useMemo } from 'react'; -import { useForm } from 'react-hook-form'; +import { type FC, useEffect, useMemo, useRef } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; export interface SeedDatasetConfigProps { - /** The seed-dataset column's current field values. */ - values: Record; - /** Merges the given keys into the column's values (parent spreads onto the rest). */ - onPatch: (patch: Record) => void; -} - -interface SeedFilesetForm { - [SEED_FILESET_REF_KEY]: string; + columnIndex: number; } /** @@ -37,31 +32,32 @@ interface SeedFilesetForm { * fileset and the in-fileset file as separate picks (stored under {@link SEED_FILESET_REF_KEY} / * {@link SEED_FILE_PATH_KEY}). `buildSeedConfig` assembles them into the composite path at submit. * - * The fileset uses {@link FilesetSearchableSelect} for server-side `$like` search + paging. It is - * react-hook-form based, so a local form holds its value and pushes changes up via `onPatch`. - * The panel keys this component by column id, so each column mounts a form seeded from its values. + * Every control is registered directly on the build form. This keeps the fileset controls in the + * same state tree as the rest of the column and isolates updates to their own subscribers. */ -export const SeedDatasetConfig: FC = ({ values, onPatch }) => { +export const SeedDatasetConfig: FC = ({ columnIndex }) => { const workspace = useWorkspaceFromPath(); - const filesetRef = values[SEED_FILESET_REF_KEY] ?? ''; - const filePath = values[SEED_FILE_PATH_KEY] ?? ''; - const samplingStrategy = values[SEED_SAMPLING_STRATEGY_KEY] ?? ''; - - const { control, watch } = useForm({ - defaultValues: { [SEED_FILESET_REF_KEY]: filesetRef }, + const { control, setValue } = useFormContext(); + const filesetRefPath = `columns.${columnIndex}.values.${SEED_FILESET_REF_KEY}` as const; + const filePathPath = `columns.${columnIndex}.values.${SEED_FILE_PATH_KEY}` as const; + const samplingStrategyPath = + `columns.${columnIndex}.values.${SEED_SAMPLING_STRATEGY_KEY}` as const; + const availableColumnsPath = + `columns.${columnIndex}.values.${SEED_AVAILABLE_COLUMNS_KEY}` as const; + const filesetRef = useWatch({ control, name: filesetRefPath }) ?? ''; + const filePath = useWatch({ control, name: filePathPath }) ?? ''; + const availableColumnsValue = useWatch({ + control, + name: availableColumnsPath, }); + const previousFilesetRef = useRef(filesetRef); useEffect(() => { - const subscription = watch((formValues, { name }) => { - if (name !== SEED_FILESET_REF_KEY) return; - onPatch({ - [SEED_FILESET_REF_KEY]: formValues[SEED_FILESET_REF_KEY] ?? '', - [SEED_FILE_PATH_KEY]: '', - [SEED_AVAILABLE_COLUMNS_KEY]: '', - }); - }); - return () => subscription.unsubscribe(); - }, [watch, onPatch]); + if (previousFilesetRef.current === filesetRef) return; + previousFilesetRef.current = filesetRef; + setValue(filePathPath, ''); + setValue(availableColumnsPath, ''); + }, [availableColumnsPath, filePathPath, filesetRef, setValue]); const { workspace: filesetWorkspace, name: filesetName } = getPartsFromReference(filesetRef); const { data: filesResponse, isLoading: isLoadingFiles } = useFilesListFilesetFiles( @@ -95,21 +91,16 @@ export const SeedDatasetConfig: FC = ({ values, onPatch useEffect(() => { const joined = availableColumns.join(','); - if ((values[SEED_AVAILABLE_COLUMNS_KEY] ?? '') !== joined) { - onPatch({ [SEED_AVAILABLE_COLUMNS_KEY]: joined }); + if (availableColumnsValue !== joined) { + setValue(availableColumnsPath, joined); } - }, [availableColumns, values, onPatch]); - - const samplingItems = SAMPLING_STRATEGY_OPTIONS.map((option) => ({ - children: option.label, - value: option.value, - })); + }, [availableColumns, availableColumnsPath, availableColumnsValue, setValue]); return ( <> = ({ values, onPatch triggerPlaceholder="Select a fileset" /> - - onPatch({ [SEED_SAMPLING_STRATEGY_KEY]: value ?? '' })} - placeholder="Ordered" - /> - + ({ + children: option.label, + value: option.value, + }))} + useControllerProps={{ name: samplingStrategyPath }} + formFieldProps={{ + slotLabel: 'Sampling strategy', + slotInfo: 'How rows are read from the seed dataset. Defaults to ordered.', + }} + placeholder="Ordered" + /> ); }; diff --git a/web/packages/studio/src/components/ColumnConfigPanel/index.tsx b/web/packages/studio/src/components/ColumnConfigPanel/index.tsx index e6f3013e3f..e3c9e774fa 100644 --- a/web/packages/studio/src/components/ColumnConfigPanel/index.tsx +++ b/web/packages/studio/src/components/ColumnConfigPanel/index.tsx @@ -1,118 +1,121 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; +import { ControlledSwitch } from '@nemo/common/src/components/form/ControlledSwitch'; +import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; +import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; import { SamplerType } from '@nemo/sdk/generated/data-designer/schema'; -import { - Banner, - Button, - Flex, - FormField, - SelectContent, - SelectItem, - SelectListbox, - SelectRoot, - SelectTrigger, - Stack, - Switch, - Text, - TextArea, - TextInput, -} from '@nvidia/foundations-react-core'; +import { Banner, Button, Flex, Stack, Text } from '@nvidia/foundations-react-core'; import { ICON_COLOR_CLASS } from '@studio/components/AddColumnPalette/constants'; import { SeedDatasetConfig } from '@studio/components/ColumnConfigPanel/SeedDatasetConfig'; import { CardIconBadge } from '@studio/components/common/SelectableCard'; import { - type BuilderColumn, type ColumnField, getColumnFields, validateColumnName, } from '@studio/routes/DataDesignerJobBuildRoute/columns'; +import type { JobBuilderFormValues } from '@studio/routes/DataDesignerJobBuildRoute/useJobBuilder'; import { Trash2, X } from 'lucide-react'; import type { FC } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; -/** Renders one config field as the appropriate control, wrapped in a `FormField`. */ -const FieldControl: FC<{ +interface FieldControlProps { + columnIndex: number; field: ColumnField; - value: string; - onChange: (value: string) => void; -}> = ({ field, value, onChange }) => { - const control = () => { - switch (field.kind) { - case 'textarea': - return ( -