-
Notifications
You must be signed in to change notification settings - Fork 20
feat(studio): Anonymizer builder Source form [ASTD-327] #884
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
411edbe
feat(studio): Anonymizer builder Source form [ASTD-327]
marcusds edd7c38
feat(studio): Anonymizer builder model settings + create wiring [ASTD…
marcusds d718bcc
feat(studio): per-role model settings for Anonymizer builder [ASTD-327]
marcusds 41130ee
feat(studio): use searchable model dropdown in Anonymizer model setti…
marcusds 1567260
feat(studio): per-role model params for Anonymizer builder [ASTD-327]
marcusds 5cf230b
fix(studio): seed Anonymizer model defaults regardless of active tab …
marcusds 8bab561
feat(studio): disable Anonymizer submit while models load [ASTD-327]
marcusds 6b93efa
fix(studio): surface Anonymizer validation errors on submit [ASTD-327]
marcusds 5178d4e
fix(studio): add noValidate to Anonymizer builder form [ASTD-327]
marcusds d335e60
feat(studio): lock Anonymizer strategy to Substitute for now [ASTD-327]
marcusds ef2da21
feat(studio): lean entity-label picker for Anonymizer builder [ASTD-327]
marcusds 96986f7
feat(studio): use SegmentedControl for Anonymizer builder tabs [ASTD-…
marcusds 2a6c6b1
feat(studio): introspect dataset columns for Text Column select [ASTD…
marcusds d853384
feat(studio): seed a suggested default model, not the first one [ASTD…
marcusds 1a97b69
feat(studio): auto-select the only column in Text Column dropdown [AS…
marcusds e7f956d
fix(studio): default a generous model timeout for anonymizer jobs [AS…
marcusds 2b92580
chore(studio): bump default anonymizer model timeout to 500s [ASTD-327]
marcusds 0969bd4
feat(studio): redirect anonymizer create to platform job detail [ASTD…
marcusds 4e12ab8
fix(studio): default high max_tokens for anonymizer models [ASTD-327]
marcusds fb7d1eb
perf(studio): memoize column options and hoist static tab items [ASTD…
marcusds 778b7db
style(studio): prettier-format anonymizer schema test [ASTD-327]
marcusds 8a19de6
fix(studio): reset source on type change, trim source validation [AST…
marcusds 5e4afb5
chore(studio): remove explanatory comments from anonymizer builder [A…
marcusds 223c68c
refactor(studio): lift trimToUndefined to shared strings util [ASTD-327]
marcusds File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
111 changes: 111 additions & 0 deletions
111
web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ColumnsSection.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { parseFilesetLocation } from '@nemo/common/src/components/DatasetFileSelect/parseFilesetLocation'; | ||
| import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; | ||
| import { ControlledTextArea } from '@nemo/common/src/components/form/ControlledTextArea'; | ||
| import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; | ||
| import { useFilesListFilesetFiles } from '@nemo/sdk/generated/platform/api'; | ||
| import { Stack, Text } from '@nvidia/foundations-react-core'; | ||
| import { useDatasetFileContent } from '@studio/api/datasets/useDatasetFileContent'; | ||
| import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; | ||
| import { | ||
| MAX_COLUMN_INTROSPECTION_BYTES, | ||
| SOURCE_TYPE_DATASET, | ||
| } from '@studio/routes/AnonymizerBuilderRoute/constants'; | ||
| import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; | ||
| import { getContentColumns, getFileExtension } from '@studio/util/files'; | ||
| import { FC, useEffect, useMemo } from 'react'; | ||
| import { useFormContext, useWatch } from 'react-hook-form'; | ||
|
|
||
| export const ColumnsSection: FC = () => { | ||
| const { control, setValue } = useFormContext<AnonymizerFormData>(); | ||
| const workspace = useWorkspaceFromPath(); | ||
| const source = useWatch({ control, name: 'source' }); | ||
| const sourceType = useWatch({ control, name: 'sourceType' }); | ||
| const textColumn = useWatch({ control, name: 'textColumn' }); | ||
|
|
||
| const parsed = useMemo( | ||
| () => | ||
| sourceType === SOURCE_TYPE_DATASET && source ? parseFilesetLocation(source, workspace) : null, | ||
| [sourceType, source, workspace] | ||
| ); | ||
| const filesetWorkspace = parsed?.workspace ?? ''; | ||
| const filesetName = parsed?.name ?? ''; | ||
| const filePath = parsed?.objectPath ?? ''; | ||
|
|
||
| const { data: filesResponse } = useFilesListFilesetFiles( | ||
| filesetWorkspace, | ||
| filesetName, | ||
| undefined, | ||
| { | ||
| query: { enabled: Boolean(filesetWorkspace && filesetName) }, | ||
| } | ||
| ); | ||
| const fileSize = useMemo( | ||
| () => filesResponse?.data?.find((file) => file.path === filePath)?.size ?? null, | ||
| [filesResponse?.data, filePath] | ||
| ); | ||
| const tooLarge = fileSize != null && fileSize > MAX_COLUMN_INTROSPECTION_BYTES; | ||
|
|
||
| const isParquet = filePath.endsWith('parquet'); | ||
| const canIntrospect = Boolean(filesetWorkspace && filesetName && filePath) && !tooLarge; | ||
| const { data: fileContent } = useDatasetFileContent({ | ||
| workspace: filesetWorkspace, | ||
| name: filesetName, | ||
| path: filePath, | ||
| range: isParquet ? [0, 1] : undefined, | ||
| enabled: canIntrospect, | ||
| }); | ||
|
|
||
| const columns = useMemo(() => { | ||
| if (!fileContent) return []; | ||
| const fileType = isParquet ? 'jsonl' : (getFileExtension(filePath) ?? undefined); | ||
| return getContentColumns(fileContent, fileType); | ||
| }, [fileContent, filePath, isParquet]); | ||
|
|
||
| const columnItems = useMemo( | ||
| () => columns.map((column) => ({ label: column, value: column })), | ||
| [columns] | ||
| ); | ||
| const useColumnDropdown = canIntrospect && columns.length > 0; | ||
|
|
||
| useEffect(() => { | ||
| if (useColumnDropdown && columns.length === 1 && textColumn !== columns[0]) { | ||
| setValue('textColumn', columns[0], { shouldValidate: true }); | ||
| } | ||
| }, [useColumnDropdown, columns, textColumn, setValue]); | ||
|
|
||
| return ( | ||
| <Stack gap="density-lg"> | ||
| <Text kind="label/bold/lg">Columns</Text> | ||
| {useColumnDropdown ? ( | ||
| <ControlledSelect | ||
| aria-label="Text column" | ||
| items={columnItems} | ||
| useControllerProps={{ name: 'textColumn', control }} | ||
| formFieldProps={{ | ||
| slotLabel: 'Text Column', | ||
| slotInfo: 'The column containing the text to anonymize.', | ||
| }} | ||
| /> | ||
| ) : ( | ||
| <ControlledTextInput | ||
| useControllerProps={{ name: 'textColumn', control }} | ||
| placeholder="e.g. biography" | ||
| formFieldProps={{ | ||
| slotLabel: 'Text Column', | ||
| slotInfo: 'The column containing the text to anonymize.', | ||
| }} | ||
| /> | ||
| )} | ||
| <ControlledTextArea | ||
| useControllerProps={{ name: 'dataSummary', control }} | ||
| formFieldProps={{ | ||
| slotLabel: 'Data Summary', | ||
| slotInfo: 'Optional short description of the data. Helps the LLM produce better results.', | ||
| }} | ||
| /> | ||
| </Stack> | ||
| ); | ||
| }; |
59 changes: 59 additions & 0 deletions
59
web/packages/studio/src/routes/AnonymizerBuilderRoute/components/DataSourceSection.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { ControlledDatasetFileSelect } from '@nemo/common/src/components/DatasetFileSelect/ControlledDatasetFileSelect'; | ||
| import { ControlledSelect } from '@nemo/common/src/components/form/ControlledSelect'; | ||
| import { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; | ||
| import { Stack, Text } from '@nvidia/foundations-react-core'; | ||
| import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; | ||
| import { | ||
| SOURCE_TYPE_DATASET, | ||
| SOURCE_TYPE_OPTIONS, | ||
| } from '@studio/routes/AnonymizerBuilderRoute/constants'; | ||
| import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; | ||
| import { FC } from 'react'; | ||
| import { useFormContext, useWatch } from 'react-hook-form'; | ||
|
|
||
| export const DataSourceSection: FC = () => { | ||
| const { control, setValue, setError, clearErrors } = useFormContext<AnonymizerFormData>(); | ||
| const workspace = useWorkspaceFromPath(); | ||
| const sourceType = useWatch({ control, name: 'sourceType' }); | ||
| const isDataset = sourceType === SOURCE_TYPE_DATASET; | ||
|
|
||
| return ( | ||
| <Stack gap="density-lg"> | ||
| <Text kind="label/bold/lg">Data Source</Text> | ||
| <ControlledSelect | ||
| aria-label="Source type" | ||
| items={SOURCE_TYPE_OPTIONS} | ||
| useControllerProps={{ name: 'sourceType', control }} | ||
| onChange={() => { | ||
| setValue('source', ''); | ||
| clearErrors('source'); | ||
| }} | ||
| formFieldProps={{ slotLabel: 'Source', required: true }} | ||
| /> | ||
| {isDataset ? ( | ||
| <ControlledDatasetFileSelect | ||
| label="Dataset" | ||
| acceptedFileTypes={['.csv', '.parquet']} | ||
| useControllerProps={{ name: 'source', control }} | ||
| setError={(error) => setError('source', error)} | ||
| clearError={() => clearErrors('source')} | ||
| workspace={workspace} | ||
| formFieldProps={{ required: true }} | ||
| /> | ||
| ) : ( | ||
| <ControlledTextInput | ||
| useControllerProps={{ name: 'source', control }} | ||
| placeholder="https://example.com/data.csv" | ||
| formFieldProps={{ | ||
| slotLabel: 'URL', | ||
| required: true, | ||
| slotInfo: 'HTTP(S) URL of a CSV or Parquet file to anonymize.', | ||
| }} | ||
| /> | ||
| )} | ||
| </Stack> | ||
| ); | ||
| }; | ||
65 changes: 65 additions & 0 deletions
65
web/packages/studio/src/routes/AnonymizerBuilderRoute/components/EntitiesSection.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { ControlledCheckbox } from '@nemo/common/src/components/form/ControlledCheckbox'; | ||
| import { ControlledCombobox } from '@nemo/common/src/components/form/ControlledCombobox'; | ||
| import { ControlledSegmentedControl } from '@nemo/common/src/components/form/ControlledSegmentedControl'; | ||
| import { useAnonymizerListEntityLabels } from '@nemo/sdk/generated/anonymizer/api'; | ||
| import { Stack, Text } from '@nvidia/foundations-react-core'; | ||
| import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; | ||
| import { | ||
| ENTITY_MODE_AUTO, | ||
| ENTITY_MODE_CUSTOM, | ||
| ENTITY_MODE_OPTIONS, | ||
| } from '@studio/routes/AnonymizerBuilderRoute/constants'; | ||
| import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; | ||
| import { FC } from 'react'; | ||
| import { useFormContext, useWatch } from 'react-hook-form'; | ||
|
|
||
| export const EntitiesSection: FC = () => { | ||
| const { control } = useFormContext<AnonymizerFormData>(); | ||
| const workspace = useWorkspaceFromPath(); | ||
| const entityMode = useWatch({ control, name: 'entityMode' }); | ||
| const includeDefaults = useWatch({ control, name: 'includeDefaultEntities' }); | ||
|
|
||
| const isCustom = entityMode === ENTITY_MODE_CUSTOM; | ||
| const showLabelPicker = isCustom && !includeDefaults; | ||
|
|
||
| const { data, isLoading } = useAnonymizerListEntityLabels(workspace, { query: {} }); | ||
| const labels = data?.data ?? []; | ||
|
|
||
| return ( | ||
| <Stack gap="density-lg"> | ||
| <Text kind="label/bold/lg">Entities</Text> | ||
| <ControlledSegmentedControl | ||
| className="w-full" | ||
| size="tiny" | ||
| items={ENTITY_MODE_OPTIONS} | ||
| useControllerProps={{ name: 'entityMode', control }} | ||
| /> | ||
| <Text kind="body/regular/md"> | ||
| {entityMode === ENTITY_MODE_AUTO | ||
| ? 'Auto-detect lets the augmenter create additional labels beyond the defaults.' | ||
| : 'Custom mode only outputs entities you define. Use Auto-detect to allow additional labels.'} | ||
| </Text> | ||
| {isCustom && ( | ||
| <ControlledCheckbox | ||
| useControllerProps={{ name: 'includeDefaultEntities', control }} | ||
| formFieldProps={{ slotLabel: 'Include all default entities' }} | ||
| /> | ||
| )} | ||
| {showLabelPicker && ( | ||
| <ControlledCombobox | ||
| kind="multiple" | ||
| loading={isLoading} | ||
| items={labels} | ||
| useControllerProps={{ name: 'entityLabels', control }} | ||
| formFieldProps={{ | ||
| slotLabel: 'Entity Labels', | ||
| slotInfo: 'Only these entity types will be detected and replaced.', | ||
| }} | ||
| /> | ||
| )} | ||
| </Stack> | ||
| ); | ||
| }; |
45 changes: 45 additions & 0 deletions
45
web/packages/studio/src/routes/AnonymizerBuilderRoute/components/GenerationSection.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // 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 { ControlledTextInput } from '@nemo/common/src/components/form/ControlledTextInput'; | ||
| import { Stack, Text } from '@nvidia/foundations-react-core'; | ||
| import { | ||
| STRATEGY_DESCRIPTIONS, | ||
| STRATEGY_OPTIONS, | ||
| } from '@studio/routes/AnonymizerBuilderRoute/constants'; | ||
| import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; | ||
| import { FC } from 'react'; | ||
| import { useFormContext, useWatch } from 'react-hook-form'; | ||
|
|
||
| export const GenerationSection: FC = () => { | ||
| const { control } = useFormContext<AnonymizerFormData>(); | ||
| const strategy = useWatch({ control, name: 'strategy' }); | ||
|
|
||
| return ( | ||
| <Stack gap="density-lg"> | ||
| <Text kind="label/bold/lg">Generation</Text> | ||
| <ControlledSelect | ||
| aria-label="Anonymization strategy" | ||
| disabled | ||
| items={STRATEGY_OPTIONS} | ||
| useControllerProps={{ name: 'strategy', control }} | ||
| formFieldProps={{ | ||
| slotLabel: 'Anonymization Strategy', | ||
| required: true, | ||
| slotInfo: 'Only Substitute is available today. Other strategies are coming soon.', | ||
| }} | ||
| /> | ||
| <Text kind="body/regular/md">{STRATEGY_DESCRIPTIONS[strategy]}</Text> | ||
| <ControlledTextInput | ||
| type="number" | ||
| min={1} | ||
| useControllerProps={{ name: 'previewRows', control }} | ||
| formFieldProps={{ | ||
| slotLabel: 'Preview Rows', | ||
| slotInfo: 'Number of records to anonymize when running a preview.', | ||
| }} | ||
| /> | ||
| </Stack> | ||
| ); | ||
| }; |
114 changes: 114 additions & 0 deletions
114
web/packages/studio/src/routes/AnonymizerBuilderRoute/components/ModelSettingsSection.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { ControlledSearchableSelect } from '@nemo/common/src/components/form/ControlledSearchableSelect'; | ||
| import { ParamsDropdown } from '@nemo/common/src/components/ModelSelectV2/ParamsDropdown'; | ||
| import { useModelsListProviders } from '@nemo/sdk/generated/platform/api'; | ||
| import type { InferenceParams } from '@nemo/sdk/generated/platform/schema'; | ||
| import { Divider, Flex, Stack, Text } from '@nvidia/foundations-react-core'; | ||
| import { modelsFromProviders } from '@studio/components/NewDataDesignerJobForm/utils'; | ||
| import { DEFAULT_LARGE_PAGE_SIZE } from '@studio/constants/constants'; | ||
| import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; | ||
| import { | ||
| activeRolesForStrategy, | ||
| GLINER_ROLE, | ||
| ROLE_LABELS, | ||
| } from '@studio/routes/AnonymizerBuilderRoute/constants'; | ||
| import type { AnonymizerFormData } from '@studio/routes/AnonymizerBuilderRoute/schema'; | ||
| import { pickDefaultModelName } from '@studio/util/buildSuggestedModelOptions'; | ||
| import { FC, useEffect, useMemo, useState } from 'react'; | ||
| import { useFormContext, useWatch } from 'react-hook-form'; | ||
|
|
||
| const isGliner = (name: string) => /gliner/i.test(name); | ||
|
|
||
| export const ModelSettingsSection: FC = () => { | ||
| const { control, setValue, getValues } = useFormContext<AnonymizerFormData>(); | ||
| const workspace = useWorkspaceFromPath(); | ||
| const strategy = useWatch({ control, name: 'strategy' }); | ||
| const roleModelsValue = useWatch({ control, name: 'roleModels' }); | ||
| const [openParamsRole, setOpenParamsRole] = useState<string | null>(null); | ||
|
|
||
| const roles = useMemo(() => activeRolesForStrategy(strategy), [strategy]); | ||
|
|
||
| const { data: providersPage, isLoading } = useModelsListProviders( | ||
| workspace, | ||
| { page_size: DEFAULT_LARGE_PAGE_SIZE }, | ||
| { query: {} } | ||
| ); | ||
|
|
||
| const models = useMemo( | ||
| () => modelsFromProviders(providersPage?.data ?? []), | ||
| [providersPage?.data] | ||
| ); | ||
| const items = useMemo( | ||
| () => models.map((model) => ({ label: model.name, value: model.id })), | ||
| [models] | ||
| ); | ||
|
|
||
| const applyModel = (role: string, id: string) => { | ||
| const selected = models.find((model) => model.id === id); | ||
| setValue(`roleModels.${role}.model`, selected?.served_model_name ?? '', { | ||
| shouldValidate: true, | ||
| }); | ||
| setValue(`roleModels.${role}.provider`, selected?.model_providers?.[0] ?? '', { | ||
| shouldValidate: true, | ||
| }); | ||
| }; | ||
|
|
||
| useEffect(() => { | ||
| if (!models.length) return; | ||
| const suggestedName = pickDefaultModelName( | ||
| models.map((model) => ({ name: model.served_model_name ?? model.name })) | ||
| ); | ||
| const llm = | ||
| models.find((model) => (model.served_model_name ?? model.name) === suggestedName) ?? | ||
| models.find((model) => !isGliner(model.name)) ?? | ||
| models[0]; | ||
| const gliner = models.find((model) => isGliner(model.name)) ?? llm; | ||
| for (const role of roles) { | ||
| const current = getValues(`roleModels.${role}.modelId`); | ||
| if (current) continue; | ||
| const pick = role === GLINER_ROLE ? gliner : llm; | ||
| setValue(`roleModels.${role}.modelId`, pick.id); | ||
| applyModel(role, pick.id); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [models, roles, getValues, setValue]); | ||
|
|
||
| return ( | ||
| <Stack gap="density-2xl"> | ||
| {roles.map((role, index) => ( | ||
| <Stack key={role} gap="density-lg"> | ||
| {index > 0 && <Divider orientation="horizontal" width="small" />} | ||
| <Text kind="label/bold/lg">{ROLE_LABELS[role] ?? role}</Text> | ||
| <Flex gap="density-md" align="end"> | ||
| <div className="grow"> | ||
| <ControlledSearchableSelect | ||
| aria-label={ROLE_LABELS[role] ?? role} | ||
| options={items} | ||
| isLoading={isLoading} | ||
| triggerPlaceholder="Select a model" | ||
| searchPlaceholder="Search models..." | ||
| emptyMessage={isLoading ? 'Loading models...' : 'No models in this workspace.'} | ||
| onChange={(value) => applyModel(role, value)} | ||
| useControllerProps={{ | ||
| name: `roleModels.${role}.modelId`, | ||
| control, | ||
| }} | ||
| formFieldProps={{ slotLabel: 'Model', required: true }} | ||
| /> | ||
| </div> | ||
| <ParamsDropdown | ||
| open={openParamsRole === role} | ||
| onOpenChange={(next) => setOpenParamsRole(next ? role : null)} | ||
| inferenceParams={roleModelsValue?.[role]?.params as Partial<InferenceParams>} | ||
| onInferenceParamsChange={(params) => | ||
| setValue(`roleModels.${role}.params`, params as Record<string, unknown>) | ||
| } | ||
| /> | ||
| </Flex> | ||
| </Stack> | ||
| ))} | ||
| </Stack> | ||
| ); | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.