From 718f4435d29dadd74ebe9e44ec02217de4881f18 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Wed, 4 Mar 2026 08:38:35 -0600 Subject: [PATCH 01/10] feat: update consumers for new input setting --- src/app.tsx | 118 ++++++++++++ src/components/feature/combo-input.tsx | 22 ++- .../feature/dynamic-combo-input.tsx | 34 ++-- src/components/feature/dynamic-enum.tsx | 25 ++- src/components/feature/field-mapper.tsx | 168 ++++++++---------- .../serialized-connect-input-picker.tsx | 115 ++++++++++-- src/components/feature/variable-input.tsx | 20 +-- src/lib/hooks.ts | 58 ++++-- 8 files changed, 399 insertions(+), 161 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index d1fb776..9181d50 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,6 +34,124 @@ async function authenticate() { ); paragon.setHeadless(true); + paragon.setDataSources({ + // Static dropdown options (available to all integrations) + dropdowns: { + 'priority-level': [ + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + ], + 'pokemon-picker': { + loadOptions: async (cursor, search) => { + const offset = cursor ? parseInt(cursor, 10) : 0; + const limit = 20; + const res = await fetch( + `https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`, + ); + const data = await res.json(); + const options = data.results.map((p: any) => ({ + label: p.name.charAt(0).toUpperCase() + p.name.slice(1), + value: p.name, + })); + const filtered = search + ? options.filter((o: any) => + o.label.toLowerCase().includes(search.toLowerCase()), + ) + : options; + const nextPageCursor = data.next ? String(offset + limit) : null; + return { options: filtered, nextPageCursor }; + }, + }, + }, + + // Field mapping sources (available to all integrations) + mapObjectFields: { + // Static field mapping + Test: { + fields: [ + { label: 'Title', value: 'title' }, + { label: 'Description', value: 'description' }, + { label: 'Completed?', value: 'isCompleted' }, + ], + }, + + // Dynamic field mapping (PokeAPI) + Task: { + objectTypes: { + get: async (cursor, search) => { + const offset = cursor ? parseInt(cursor, 10) : 0; + const limit = 10; + const res = await fetch( + `https://pokeapi.co/api/v2/type?limit=${limit}&offset=${offset}`, + ); + const data = await res.json(); + const options = data.results.map((t: any) => ({ + label: t.name.charAt(0).toUpperCase() + t.name.slice(1), + value: t.url.split('/').filter(Boolean).pop(), + })); + const filtered = search + ? options.filter((o: any) => + o.label.toLowerCase().includes(search.toLowerCase()), + ) + : options; + const nextPageCursor = data.next ? String(offset + limit) : null; + return { options: filtered, nextPageCursor }; + }, + }, + integrationFields: { + get: async ({ objectType }, cursor, search) => { + const offset = cursor ? parseInt(cursor, 10) : 0; + const limit = 20; + const res = await fetch( + `https://pokeapi.co/api/v2/type/${objectType}`, + ); + const data = await res.json(); + const all = data.pokemon.map((p: any) => ({ + label: + p.pokemon.name.charAt(0).toUpperCase() + + p.pokemon.name.slice(1), + value: p.pokemon.name, + })); + const filtered = search + ? all.filter((o: any) => + o.label.toLowerCase().includes(search.toLowerCase()), + ) + : all; + const page = filtered.slice(offset, offset + limit); + const nextPageCursor = + offset + limit < filtered.length ? String(offset + limit) : null; + return { options: page, nextPageCursor }; + }, + }, + applicationFields: { + fields: [ + { label: 'Name', value: 'name' }, + { label: 'Type', value: 'type' }, + { label: 'Base Experience', value: 'base_experience' }, + ], + defaultFields: [], + userCanRemoveMappings: true, + }, + }, + }, + + // Integration-specific sources (override global sources for a given integration) + integrationSpecificSources: { + jira: { + dropdowns: { + 'priority-level': [ + { label: 'Lowest', value: 'lowest' }, + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Highest', value: 'highest' }, + ], + }, + }, + }, + }); + return null; } diff --git a/src/components/feature/combo-input.tsx b/src/components/feature/combo-input.tsx index 69b64b4..2238f52 100644 --- a/src/components/feature/combo-input.tsx +++ b/src/components/feature/combo-input.tsx @@ -1,12 +1,12 @@ import { - ComboInputDataSource, SidebarInputType, + type DefaultFieldValueSources, type SerializedConnectInput, } from '@useparagon/connect'; import { useMemo, useState } from 'react'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useDataSourceOptions, useFieldOptions } from '@/lib/hooks'; +import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; export type ComboInputValue = { mainInput: string | undefined; @@ -25,15 +25,21 @@ export function ComboInputField(props: Props) { const [mainInputSearch, setMainInputSearch] = useState(''); const [dependentInputSearch, setDependentInputSearch] = useState(''); - const { data: options } = useDataSourceOptions( + const sources = useSourcesForInput( props.integration, props.field.sourceType as string, + props.field, ); + const comboSources = + sources?.kind === 'defaultFieldValue' + ? (sources as DefaultFieldValueSources) + : null; + const { data: mainInputOptions, isFetching: isFetchingMainInput } = useFieldOptions({ integration: props.integration, - sourceType: options?.mainInputSource.cacheKey as string, + source: comboSources?.mainInputSource, search: mainInputSearch, }); @@ -49,10 +55,10 @@ export function ComboInputField(props: Props) { useFieldOptions({ enabled: Boolean(props.value.mainInput), integration: props.integration, - sourceType: options?.dependentInputSource.cacheKey as string, + source: comboSources?.dependentInputSource, parameters: [ { - cacheKey: options?.mainInputSource.cacheKey as string, + cacheKey: comboSources?.mainInputSource.cacheKey as string, value: props.value.mainInput, }, ], @@ -67,8 +73,8 @@ export function ComboInputField(props: Props) { [dependentInputOptions.data, props.value], ); - const mainInputMeta = options?.mainInputSource; - const dependentInputMeta = options?.dependentInputSource; + const mainInputMeta = comboSources?.mainInputSource; + const dependentInputMeta = comboSources?.dependentInputSource; if (!mainInputMeta || !dependentInputMeta) { return null; diff --git a/src/components/feature/dynamic-combo-input.tsx b/src/components/feature/dynamic-combo-input.tsx index bb20e22..a18a500 100644 --- a/src/components/feature/dynamic-combo-input.tsx +++ b/src/components/feature/dynamic-combo-input.tsx @@ -1,12 +1,12 @@ import { - DynamicComboInputDataSource, SidebarInputType, + type DefaultFieldValueSources, type SerializedConnectInput, } from '@useparagon/connect'; import { useMemo, useState } from 'react'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useDataSourceOptions, useFieldOptions } from '@/lib/hooks'; +import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; import { VariableInput } from './variable-input'; import { omit } from 'lodash'; @@ -31,17 +31,23 @@ export function DynamicComboInputField(props: Props) { const [mainInputSearch, setMainInputSearch] = useState(''); const [dependentInputSearch, setDependentInputSearch] = useState(''); - const { data: options } = useDataSourceOptions( + const sources = useSourcesForInput( props.integration, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error props.field.sourceType as string, + props.field, ); + const comboSources = + sources?.kind === 'defaultFieldValue' + ? (sources as DefaultFieldValueSources) + : null; + const { data: mainInputOptions, isFetching: isFetchingMainInput } = useFieldOptions({ integration: props.integration, - sourceType: options?.mainInputSource.cacheKey as string, + source: comboSources?.mainInputSource, search: mainInputSearch, }); @@ -57,10 +63,10 @@ export function DynamicComboInputField(props: Props) { useFieldOptions({ enabled: Boolean(props.value.mainInput), integration: props.integration, - sourceType: options?.dependentInputSource.cacheKey as string, + source: comboSources?.dependentInputSource, parameters: [ { - cacheKey: options?.mainInputSource.cacheKey as string, + cacheKey: comboSources?.mainInputSource.cacheKey as string, value: props.value.mainInput, }, ], @@ -75,8 +81,8 @@ export function DynamicComboInputField(props: Props) { [dependentInputOptions.data, props.value], ); - const mainInputMeta = options?.mainInputSource; - const dependentInputMeta = options?.dependentInputSource; + const mainInputMeta = comboSources?.mainInputSource; + const dependentInputMeta = comboSources?.dependentInputSource; if (!mainInputMeta || !dependentInputMeta) { return null; @@ -139,14 +145,14 @@ export function DynamicComboInputField(props: Props) { {props.value.mainInput && props.value.dependentInput && - options?.variableInputSource?.cacheKey && - options?.mainInputSource?.cacheKey && - options?.dependentInputSource?.cacheKey && ( + comboSources?.variableInputSource && + comboSources?.mainInputSource && + comboSources?.dependentInputSource && ( void; }; -/** - * Local filtering function for non-paginated sources - */ function filterOptions( items: T[], searchString: string, @@ -39,23 +38,23 @@ export function DynamicEnumField(props: Props) { const [search, setSearch] = useState(''); const sourceType = props.field.sourceType as string; + const sources = useSourcesForInput(props.integration, sourceType, props.field); - // Get dataSourceOptions which contains supportPagination - const { data: dataSourceOptions } = useDataSourceOptions<{ - supportPagination?: boolean; - }>(props.integration, sourceType); + const dynamicSource = + sources?.kind === 'single' + ? (sources as SingleSource).source as DynamicDataSource + : undefined; - // Get supportPagination from dataSourceOptions - const supportPagination = dataSourceOptions?.supportPagination ?? false; + const supportPagination = + (dynamicSource as DynamicDataSource & { supportPagination?: boolean }) + ?.supportPagination ?? false; - // Only pass search to API if pagination is supported, otherwise filter locally const { data: options, isFetching } = useFieldOptions({ integration: props.integration, - sourceType, + source: dynamicSource, search: supportPagination ? search || undefined : undefined, }); - // For non-paginated sources, filter locally instead of making API calls const filteredOptions = supportPagination ? (options?.data ?? []) : filterOptions(options?.data ?? [], search); diff --git a/src/components/feature/field-mapper.tsx b/src/components/feature/field-mapper.tsx index d266119..fc83d0d 100644 --- a/src/components/feature/field-mapper.tsx +++ b/src/components/feature/field-mapper.tsx @@ -1,13 +1,13 @@ import { MoveHorizontal } from 'lucide-react'; import { - FieldMapperDataSource, SidebarInputType, + type FieldMapperSources, type SerializedConnectInput, } from '@useparagon/connect'; import { useMemo, useState } from 'react'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useDataSourceOptions, useFieldOptions } from '@/lib/hooks'; +import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; import { Label } from '../ui/label'; import { CommandGroup } from '../ui/command'; import { FieldLabel } from '../form/field-label'; @@ -31,52 +31,52 @@ export function FieldMapperField(props: Props) { const [dependentInputSearch, setDependentInputSearch] = useState(''); const [fieldInputSearch, setFieldInputSearch] = useState(''); - const { data: options } = useDataSourceOptions( + const sources = useSourcesForInput( props.integration, props.field.sourceType as string, + props.field, ); + const fieldMapperSources = + sources?.kind === 'fieldMapper' ? (sources as FieldMapperSources) : null; + const { data: mainInputOptions, isFetching: isFetchingMainInput } = useFieldOptions({ integration: props.integration, - sourceType: options?.recordSource.cacheKey as string, + source: fieldMapperSources?.recordSource, search: mainInputSearch, }); const selectedMainOption = useMemo(() => { - let result; - - result = mainInputOptions.data.find( + const flatResult = mainInputOptions.data.find( (option) => option.value === props.value.mainInput, ); - if (result) { - return result; + if (flatResult) { + return flatResult; } - for (let index = 0; index < mainInputOptions.nestedData?.length; index++) { - const group = mainInputOptions.nestedData[index]; - - result = group.items.find( + for (const group of mainInputOptions.nestedData ?? []) { + const nestedResult = group.items.find( (option) => option.value === props.value.mainInput, ); - if (result) { - break; + if (nestedResult) { + return nestedResult; } } - return result; + return undefined; }, [mainInputOptions, props.value]); const { data: dependentInputOptions, isFetching: isFetchingDependentInput } = useFieldOptions({ enabled: Boolean(props.value.mainInput), integration: props.integration, - sourceType: options?.dependentInputSource?.cacheKey as string, + source: fieldMapperSources?.dependentInputSource, parameters: [ { - cacheKey: options?.recordSource.cacheKey as string, + cacheKey: fieldMapperSources?.recordSource.cacheKey as string, value: props.value.mainInput, }, ], @@ -91,56 +91,92 @@ export function FieldMapperField(props: Props) { [dependentInputOptions?.data, props.value], ); - const parameters = useMemo(() => { + const fieldParameters = useMemo(() => { const params = [ { - cacheKey: options?.recordSource.cacheKey as string, + cacheKey: fieldMapperSources?.recordSource.cacheKey as string, value: props.value.mainInput, }, ]; - if (options?.dependentInputSource) { + if (fieldMapperSources?.dependentInputSource) { params.push({ - cacheKey: options?.dependentInputSource.cacheKey as string, + cacheKey: fieldMapperSources.dependentInputSource.cacheKey as string, value: props.value.dependentInput, }); } return params; - }, [options, props.value]); + }, [fieldMapperSources, props.value]); const { data: fieldInputOptions, isFetching: isFetchingFieldInput } = useFieldOptions({ enabled: Boolean( props.value.mainInput && - (!options?.dependentInputSource || props.value.dependentInput), + (!fieldMapperSources?.dependentInputSource || + props.value.dependentInput), ), integration: props.integration, - sourceType: options?.fieldSource.cacheKey as string, - parameters, + source: fieldMapperSources?.fieldSource, + parameters: fieldParameters, search: fieldInputSearch, }); const selectedFieldInputOptions = useMemo(() => { const result: Record = {}; - if (props.value.fieldMappings) { - for (const [key, value] of Object.entries(props.value.fieldMappings)) { - const option = fieldInputOptions.data.find( - (option) => option.value === value, - ); + if (!props.value.fieldMappings) { + return result; + } - if (!option) { - continue; - } + for (const [key, value] of Object.entries(props.value.fieldMappings)) { + const option = fieldInputOptions.data.find( + (option) => option.value === value, + ); - result[key] = option; + if (!option) { + continue; } + + result[key] = option; } return result; }, [fieldInputOptions?.data, props.value.fieldMappings]); - const mainInputMeta = options?.recordSource; - const dependentInputMeta = options?.dependentInputSource; + const fieldMappingEntries = useMemo(() => { + if (!fieldMapperSources?.mapObjectFieldOptions) { + return props.field.savedFieldMappings; + } + + const options = fieldMapperSources.mapObjectFieldOptions; + if (Array.isArray(options)) { + return options; + } + + return options.fields; + }, [fieldMapperSources?.mapObjectFieldOptions, props.field.savedFieldMappings]); + + const mainInputMeta = fieldMapperSources?.recordSource; + const dependentInputMeta = fieldMapperSources?.dependentInputSource; + + function renderMainInputOptions() { + if (mainInputOptions.nestedData.length) { + return mainInputOptions.nestedData.map((category) => ( + + {category.items.map((option) => ( + + {option.label} + + ))} + + )); + } + + return mainInputOptions.data.map((option) => ( + + {option.label} + + )); + } return (
@@ -149,7 +185,7 @@ export function FieldMapperField(props: Props) { {props.field.title} )} -
+
- {mainInputOptions.nestedData && - mainInputOptions.nestedData.map((category) => { - return ( - - {category.items.map((option) => { - return ( - - {option.label} - - ); - })} - - ); - })} - {mainInputOptions.data && - mainInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} - {mainInputOptions.nestedData - ? mainInputOptions.nestedData.map((category) => { - return ( - - {category.items.map((option) => { - return ( - - {option.label} - - ); - })} - - ); - }) - : mainInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} + {renderMainInputOptions()} {dependentInputMeta && ( )}
- {props.field.savedFieldMappings.map((fieldMap) => { + {fieldMappingEntries.map((fieldMap) => { const placeholder = selectedFieldInputOptions[fieldMap.label]; return ( -
+
onChange(value ?? undefined)} - allowClear - > - {options.map((option) => ( - - {option.label} - - ))} - + /> ); } @@ -252,3 +250,94 @@ export function SerializedConnectInputPicker(props: Props) {
); } + +function CustomDropdownInput(props: { + integration: string; + field: SerializedConnectInput; + required: boolean; + value: string | null; + onChange: (value: string | null | undefined) => void; +}) { + const [search, setSearch] = useState(''); + + const sources = useSourcesForInput( + props.integration, + props.field.key ?? props.field.id, + props.field, + ); + + const singleSource = + sources?.kind === 'single' ? (sources as SingleSource) : null; + const isStatic = + singleSource?.source.type === DataSourceType.STATIC_ENUM; + const isDynamic = + singleSource?.source.type === DataSourceType.DYNAMIC; + + const staticOptions = isStatic + ? (singleSource!.source as StaticEnumDataSource).values + : []; + + const { data: dynamicOptions, isFetching } = useFieldOptions({ + enabled: isDynamic, + integration: props.integration, + source: isDynamic + ? (singleSource!.source as DynamicDataSource) + : undefined, + search: search || undefined, + }); + + const dynamicItems = dynamicOptions?.data ?? []; + const selectedDynamicOption = useMemo( + () => dynamicItems.find((option) => option.value === props.value), + [dynamicItems, props.value], + ); + + const flatOptions = useMemo(() => { + if (!Array.isArray(staticOptions)) { + return []; + } + + return staticOptions.flatMap((item) => + 'items' in item ? item.items : [item], + ); + }, [staticOptions]); + + if (isDynamic) { + return ( + + {dynamicItems.map((option) => ( + + {option.label} + + ))} + + ); + } + + return ( + props.onChange(value ?? undefined)} + allowClear + > + {flatOptions.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/src/components/feature/variable-input.tsx b/src/components/feature/variable-input.tsx index 1c230da..15f5818 100644 --- a/src/components/feature/variable-input.tsx +++ b/src/components/feature/variable-input.tsx @@ -4,7 +4,7 @@ import { MultiSelectField } from '../form/multi-select-field'; import { TextInputField } from '../form/text-input-field'; import { ComboboxField } from '@/components/form/combobox-field'; import { LoaderCircle, MinusCircleIcon } from 'lucide-react'; -import { DynamicDefaultInput } from '@useparagon/connect'; +import { DynamicDefaultInput, type DynamicDataSource } from '@useparagon/connect'; import { Button } from '../ui/button'; import { useState } from 'react'; @@ -12,9 +12,9 @@ type VariableInputValue = string | string[] | undefined; type Props = { integration: string; - sourceType: string; - mainInputKey: string; - dependantInputKey: string; + variableInputSource: DynamicDataSource; + mainInputSource: DynamicDataSource; + dependantInputSource: DynamicDataSource; mainInputValue: string; dependantInputValue: string; variableInputsValues: Record; @@ -26,11 +26,11 @@ type Props = { export const VariableInput = ({ integration, - sourceType, + variableInputSource, + mainInputSource, + dependantInputSource, mainInputValue, - mainInputKey, dependantInputValue, - dependantInputKey, variableInputsValues, onVariableInputsValuesChange, onDeleteVariableInput, @@ -40,15 +40,15 @@ export const VariableInput = ({ const { data: options, isFetching } = useFieldOptions({ integration: integration, - sourceType: sourceType, + source: variableInputSource, search: '', parameters: [ { - cacheKey: mainInputKey, + cacheKey: mainInputSource.cacheKey as string, value: mainInputValue, }, { - cacheKey: dependantInputKey, + cacheKey: dependantInputSource.cacheKey as string, value: dependantInputValue, }, ], diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 88af681..bebe50b 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,5 +1,10 @@ +import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { paragon } from '@useparagon/connect'; +import { + paragon, + type DynamicDataSource, + type SerializedConnectInput, +} from '@useparagon/connect'; export function useIntegrationMetadata() { return useQuery({ @@ -41,8 +46,21 @@ const fieldOptionsInitialData: FieldOptionsResponse = { nextPageCursor: null, }; +export function useSourcesForInput( + integration: string, + action: string | undefined, + input: SerializedConnectInput, +) { + return useMemo( + () => + action ? paragon.getSourcesForInput(integration, action, input) : null, + [integration, action, input], + ); +} + export function useFieldOptions({ integration, + source, sourceType, search, cursor, @@ -50,33 +68,47 @@ export function useFieldOptions({ enabled = true, }: { integration: string; - sourceType: string; + source?: DynamicDataSource; + sourceType?: string; search?: string; cursor?: string | number | false; parameters?: { cacheKey: string; value: string | undefined }[]; enabled?: boolean; }) { + const queryKey = source?.cacheKey ?? sourceType; + return useQuery({ - enabled: enabled, - queryKey: ['fieldOptions', integration, sourceType, search, parameters], + enabled: enabled && !!queryKey, + queryKey: ['fieldOptions', integration, queryKey, search, parameters], queryFn: () => { + const mappedParameters = parameters.map((parameter) => ({ + key: parameter.cacheKey, + source: { + type: 'VALUE' as const, + value: parameter.value, + }, + })); + + if (source) { + return paragon.getFieldOptions({ + integration, + source, + search, + cursor, + parameters: mappedParameters, + }); + } + if (sourceType) { return paragon.getFieldOptions({ integration, action: sourceType, search, cursor, - parameters: parameters.map((parameter) => { - return { - key: parameter.cacheKey, - source: { - type: 'VALUE', - value: parameter.value, - }, - }; - }), + parameters: mappedParameters, }); } + return fieldOptionsInitialData; }, initialData: fieldOptionsInitialData, From ce614cb92fd6d01833024caebabb64bce753ee79 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Wed, 4 Mar 2026 08:54:01 -0600 Subject: [PATCH 02/10] fix(sdk): fix linting --- src/app.tsx | 234 +++++++++--------- src/components/feature/dynamic-enum.tsx | 14 +- .../serialized-connect-input-picker.tsx | 26 +- src/components/feature/variable-input.tsx | 11 +- src/lib/hooks.ts | 2 +- 5 files changed, 153 insertions(+), 134 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index 9181d50..faa5fe1 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,123 +34,123 @@ async function authenticate() { ); paragon.setHeadless(true); - paragon.setDataSources({ - // Static dropdown options (available to all integrations) - dropdowns: { - 'priority-level': [ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - ], - 'pokemon-picker': { - loadOptions: async (cursor, search) => { - const offset = cursor ? parseInt(cursor, 10) : 0; - const limit = 20; - const res = await fetch( - `https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`, - ); - const data = await res.json(); - const options = data.results.map((p: any) => ({ - label: p.name.charAt(0).toUpperCase() + p.name.slice(1), - value: p.name, - })); - const filtered = search - ? options.filter((o: any) => - o.label.toLowerCase().includes(search.toLowerCase()), - ) - : options; - const nextPageCursor = data.next ? String(offset + limit) : null; - return { options: filtered, nextPageCursor }; - }, - }, - }, - - // Field mapping sources (available to all integrations) - mapObjectFields: { - // Static field mapping - Test: { - fields: [ - { label: 'Title', value: 'title' }, - { label: 'Description', value: 'description' }, - { label: 'Completed?', value: 'isCompleted' }, - ], - }, - - // Dynamic field mapping (PokeAPI) - Task: { - objectTypes: { - get: async (cursor, search) => { - const offset = cursor ? parseInt(cursor, 10) : 0; - const limit = 10; - const res = await fetch( - `https://pokeapi.co/api/v2/type?limit=${limit}&offset=${offset}`, - ); - const data = await res.json(); - const options = data.results.map((t: any) => ({ - label: t.name.charAt(0).toUpperCase() + t.name.slice(1), - value: t.url.split('/').filter(Boolean).pop(), - })); - const filtered = search - ? options.filter((o: any) => - o.label.toLowerCase().includes(search.toLowerCase()), - ) - : options; - const nextPageCursor = data.next ? String(offset + limit) : null; - return { options: filtered, nextPageCursor }; - }, - }, - integrationFields: { - get: async ({ objectType }, cursor, search) => { - const offset = cursor ? parseInt(cursor, 10) : 0; - const limit = 20; - const res = await fetch( - `https://pokeapi.co/api/v2/type/${objectType}`, - ); - const data = await res.json(); - const all = data.pokemon.map((p: any) => ({ - label: - p.pokemon.name.charAt(0).toUpperCase() + - p.pokemon.name.slice(1), - value: p.pokemon.name, - })); - const filtered = search - ? all.filter((o: any) => - o.label.toLowerCase().includes(search.toLowerCase()), - ) - : all; - const page = filtered.slice(offset, offset + limit); - const nextPageCursor = - offset + limit < filtered.length ? String(offset + limit) : null; - return { options: page, nextPageCursor }; - }, - }, - applicationFields: { - fields: [ - { label: 'Name', value: 'name' }, - { label: 'Type', value: 'type' }, - { label: 'Base Experience', value: 'base_experience' }, - ], - defaultFields: [], - userCanRemoveMappings: true, - }, - }, - }, - - // Integration-specific sources (override global sources for a given integration) - integrationSpecificSources: { - jira: { - dropdowns: { - 'priority-level': [ - { label: 'Lowest', value: 'lowest' }, - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - { label: 'Highest', value: 'highest' }, - ], - }, - }, - }, - }); + // paragon.setDataSources({ + // // Static dropdown options (available to all integrations) + // dropdowns: { + // 'priority-level': [ + // { label: 'Low', value: 'low' }, + // { label: 'Medium', value: 'medium' }, + // { label: 'High', value: 'high' }, + // ], + // 'pokemon-picker': { + // loadOptions: async (cursor, search) => { + // const offset = cursor ? parseInt(cursor, 10) : 0; + // const limit = 20; + // const res = await fetch( + // `https://pokeapi.co/api/v2/pokemon?limit=${limit}&offset=${offset}`, + // ); + // const data: PokeApiListResponse = await res.json(); + // const options = data.results.map((p) => ({ + // label: p.name.charAt(0).toUpperCase() + p.name.slice(1), + // value: p.name, + // })); + // const filtered = search + // ? options.filter((o) => + // o.label.toLowerCase().includes(search.toLowerCase()), + // ) + // : options; + // const nextPageCursor = data.next ? String(offset + limit) : null; + // return { options: filtered, nextPageCursor }; + // }, + // }, + // }, + // + // // Field mapping sources (available to all integrations) + // mapObjectFields: { + // // Static field mapping + // Test: { + // fields: [ + // { label: 'Title', value: 'title' }, + // { label: 'Description', value: 'description' }, + // { label: 'Completed?', value: 'isCompleted' }, + // ], + // }, + // + // // Dynamic field mapping (PokeAPI) + // Task: { + // objectTypes: { + // get: async (cursor, search) => { + // const offset = cursor ? parseInt(cursor, 10) : 0; + // const limit = 10; + // const res = await fetch( + // `https://pokeapi.co/api/v2/type?limit=${limit}&offset=${offset}`, + // ); + // const data: PokeApiListResponse = await res.json(); + // const options = data.results.map((t) => ({ + // label: t.name.charAt(0).toUpperCase() + t.name.slice(1), + // value: t.url.split('/').filter(Boolean).pop() as string, + // })); + // const filtered = search + // ? options.filter((o) => + // o.label.toLowerCase().includes(search.toLowerCase()), + // ) + // : options; + // const nextPageCursor = data.next ? String(offset + limit) : null; + // return { options: filtered, nextPageCursor }; + // }, + // }, + // integrationFields: { + // get: async ({ objectType }, cursor, search) => { + // const offset = cursor ? parseInt(cursor, 10) : 0; + // const limit = 20; + // const res = await fetch( + // `https://pokeapi.co/api/v2/type/${objectType}`, + // ); + // const data: PokeApiTypeResponse = await res.json(); + // const all = data.pokemon.map((p) => ({ + // label: + // p.pokemon.name.charAt(0).toUpperCase() + + // p.pokemon.name.slice(1), + // value: p.pokemon.name, + // })); + // const filtered = search + // ? all.filter((o) => + // o.label.toLowerCase().includes(search.toLowerCase()), + // ) + // : all; + // const page = filtered.slice(offset, offset + limit); + // const nextPageCursor = + // offset + limit < filtered.length ? String(offset + limit) : null; + // return { options: page, nextPageCursor }; + // }, + // }, + // applicationFields: { + // fields: [ + // { label: 'Name', value: 'name' }, + // { label: 'Type', value: 'type' }, + // { label: 'Base Experience', value: 'base_experience' }, + // ], + // defaultFields: [], + // userCanRemoveMappings: true, + // }, + // }, + // }, + // + // // Integration-specific sources (override global sources for a given integration) + // integrationSpecificSources: { + // jira: { + // dropdowns: { + // 'priority-level': [ + // { label: 'Lowest', value: 'lowest' }, + // { label: 'Low', value: 'low' }, + // { label: 'Medium', value: 'medium' }, + // { label: 'High', value: 'high' }, + // { label: 'Highest', value: 'highest' }, + // ], + // }, + // }, + // }, + // }); return null; } diff --git a/src/components/feature/dynamic-enum.tsx b/src/components/feature/dynamic-enum.tsx index a3d1418..610945b 100644 --- a/src/components/feature/dynamic-enum.tsx +++ b/src/components/feature/dynamic-enum.tsx @@ -42,11 +42,11 @@ export function DynamicEnumField(props: Props) { const dynamicSource = sources?.kind === 'single' - ? (sources as SingleSource).source as DynamicDataSource + ? ((sources as SingleSource).source as DynamicDataSource) : undefined; const supportPagination = - (dynamicSource as DynamicDataSource & { supportPagination?: boolean }) + (dynamicSource as DynamicDataSource & { supportPagination?: boolean }) ?.supportPagination ?? false; const { data: options, isFetching } = useFieldOptions({ @@ -55,9 +55,13 @@ export function DynamicEnumField(props: Props) { search: supportPagination ? search || undefined : undefined, }); - const filteredOptions = supportPagination - ? (options?.data ?? []) - : filterOptions(options?.data ?? [], search); + const filteredOptions = useMemo( + () => + supportPagination + ? (options?.data ?? []) + : filterOptions(options?.data ?? [], search), + [supportPagination, options?.data, search], + ); const selectedOption = useMemo( () => filteredOptions.find((option) => option.value === props.value), diff --git a/src/components/feature/serialized-connect-input-picker.tsx b/src/components/feature/serialized-connect-input-picker.tsx index a8ce2f9..d29f706 100644 --- a/src/components/feature/serialized-connect-input-picker.tsx +++ b/src/components/feature/serialized-connect-input-picker.tsx @@ -273,20 +273,32 @@ function CustomDropdownInput(props: { const isDynamic = singleSource?.source.type === DataSourceType.DYNAMIC; - const staticOptions = isStatic - ? (singleSource!.source as StaticEnumDataSource).values - : []; + const staticOptions = useMemo( + () => + isStatic ? (singleSource!.source as StaticEnumDataSource).values : [], + [isStatic, singleSource], + ); + + const dynamicSource = useMemo( + () => + isDynamic + ? (singleSource!.source as DynamicDataSource) + : undefined, + [isDynamic, singleSource], + ); const { data: dynamicOptions, isFetching } = useFieldOptions({ enabled: isDynamic, integration: props.integration, - source: isDynamic - ? (singleSource!.source as DynamicDataSource) - : undefined, + source: dynamicSource, search: search || undefined, }); - const dynamicItems = dynamicOptions?.data ?? []; + const dynamicItems = useMemo( + () => dynamicOptions?.data ?? [], + [dynamicOptions?.data], + ); + const selectedDynamicOption = useMemo( () => dynamicItems.find((option) => option.value === props.value), [dynamicItems, props.value], diff --git a/src/components/feature/variable-input.tsx b/src/components/feature/variable-input.tsx index 15f5818..c38dc9e 100644 --- a/src/components/feature/variable-input.tsx +++ b/src/components/feature/variable-input.tsx @@ -4,7 +4,10 @@ import { MultiSelectField } from '../form/multi-select-field'; import { TextInputField } from '../form/text-input-field'; import { ComboboxField } from '@/components/form/combobox-field'; import { LoaderCircle, MinusCircleIcon } from 'lucide-react'; -import { DynamicDefaultInput, type DynamicDataSource } from '@useparagon/connect'; +import { + DynamicDefaultInput, + type DynamicDataSource, +} from '@useparagon/connect'; import { Button } from '../ui/button'; import { useState } from 'react'; @@ -12,9 +15,9 @@ type VariableInputValue = string | string[] | undefined; type Props = { integration: string; - variableInputSource: DynamicDataSource; - mainInputSource: DynamicDataSource; - dependantInputSource: DynamicDataSource; + variableInputSource: DynamicDataSource; + mainInputSource: DynamicDataSource; + dependantInputSource: DynamicDataSource; mainInputValue: string; dependantInputValue: string; variableInputsValues: Record; diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index bebe50b..a2ce780 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -68,7 +68,7 @@ export function useFieldOptions({ enabled = true, }: { integration: string; - source?: DynamicDataSource; + source?: DynamicDataSource; sourceType?: string; search?: string; cursor?: string | number | false; From 35e701876339ecdf32790c4dee46c76515e03e72 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 12:19:47 -0600 Subject: [PATCH 03/10] chore: bump @useparagon/connect and add react-intersection-observer Made-with: Cursor --- package-lock.json | 25 +++++++++++++++++++++---- package.json | 3 ++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65ba387..19bc1c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^4.36.1", "@tanstack/react-query-devtools": "^4.42.0", - "@useparagon/connect": "2.2.3-experimental.3", + "@useparagon/connect": "2.2.8-experimental-18633.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -24,6 +24,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.60.0", + "react-intersection-observer": "^10.0.3", "react-markdown": "^10.1.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", @@ -3662,9 +3663,10 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" }, "node_modules/@useparagon/connect": { - "version": "2.2.3-experimental.3", - "resolved": "https://registry.npmjs.org/@useparagon/connect/-/connect-2.2.3-experimental.3.tgz", - "integrity": "sha512-/WLyVpPOCxr89PwTBA6xFlLwgb57JKYsCos0njH9NAyYXDBA0Ry77+Awa72p6swOotKml7euHFRWdKiv3ku5HA==", + "version": "2.2.8-experimental-18633.2", + "resolved": "https://registry.npmjs.org/@useparagon/connect/-/connect-2.2.8-experimental-18633.2.tgz", + "integrity": "sha512-zXNhzKhMRBARyuFrNOnEtj3IV5gMTGtkd2d02rs586ZsHkUHnxg6ldJKhceAFS903ccBWRCRDxSSqwcIjGiioA==", + "license": "MIT", "dependencies": { "clone-deep": "^4.0.1", "hash.js": "^1.1.7", @@ -6139,6 +6141,21 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-intersection-observer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.0.3.tgz", + "integrity": "sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 4a430a9..6989054 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^4.36.1", - "@useparagon/connect": "2.2.3-experimental.3", "@tanstack/react-query-devtools": "^4.42.0", + "@useparagon/connect": "2.2.8-experimental-18633.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -28,6 +28,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.60.0", + "react-intersection-observer": "^10.0.3", "react-markdown": "^10.1.0", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", From 4d8ec89da567ed7940dc7f7d3c3ca68ced138c95 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 12:20:01 -0600 Subject: [PATCH 04/10] fix: add field label to combo input and clean up minor issues Made-with: Cursor --- src/components/feature/combo-input.tsx | 4 ++++ src/components/feature/serialized-connect-input-picker.tsx | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/feature/combo-input.tsx b/src/components/feature/combo-input.tsx index 2238f52..22e8f2a 100644 --- a/src/components/feature/combo-input.tsx +++ b/src/components/feature/combo-input.tsx @@ -7,6 +7,7 @@ import { useMemo, useState } from 'react'; import { ComboboxField } from '@/components/form/combobox-field'; import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; +import { FieldLabel } from '../form/field-label'; export type ComboInputValue = { mainInput: string | undefined; @@ -82,6 +83,9 @@ export function ComboInputField(props: Props) { return ( <> + + {props.field.title} +
From 00f8f5c084210d47ef31fbb13b9cde04ff74a3da Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 12:20:17 -0600 Subject: [PATCH 05/10] feat: add infinite scroll pagination to dynamic enum fields Made-with: Cursor --- src/components/feature/dynamic-enum.tsx | 144 ++++++++++++++++++++---- src/components/form/combobox-field.tsx | 2 + src/lib/hooks.ts | 43 ++++++- 3 files changed, 168 insertions(+), 21 deletions(-) diff --git a/src/components/feature/dynamic-enum.tsx b/src/components/feature/dynamic-enum.tsx index 610945b..8905463 100644 --- a/src/components/feature/dynamic-enum.tsx +++ b/src/components/feature/dynamic-enum.tsx @@ -4,11 +4,17 @@ import { type SingleSource, type SerializedConnectInput, } from '@useparagon/connect'; -import { useMemo, useState } from 'react'; +import { LoaderCircle } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; import Fuse from 'fuse.js'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; +import { + useFieldOptions, + useInfiniteFieldOptions, + useSourcesForInput, +} from '@/lib/hooks'; type Props = { integration: string; @@ -38,7 +44,11 @@ export function DynamicEnumField(props: Props) { const [search, setSearch] = useState(''); const sourceType = props.field.sourceType as string; - const sources = useSourcesForInput(props.integration, sourceType, props.field); + const sources = useSourcesForInput( + props.integration, + sourceType, + props.field, + ); const dynamicSource = sources?.kind === 'single' @@ -46,21 +56,48 @@ export function DynamicEnumField(props: Props) { : undefined; const supportPagination = - (dynamicSource as DynamicDataSource & { supportPagination?: boolean }) - ?.supportPagination ?? false; + ( + dynamicSource as DynamicDataSource & { + supportPagination?: boolean; + } + )?.supportPagination ?? false; + + if (supportPagination) { + return ( + + ); + } + + return ( + + ); +} +function StaticDynamicEnum( + props: Props & { + search: string; + onSearchChange: (value: string) => void; + dynamicSource?: DynamicDataSource; + }, +) { const { data: options, isFetching } = useFieldOptions({ integration: props.integration, - source: dynamicSource, - search: supportPagination ? search || undefined : undefined, + source: props.dynamicSource, }); const filteredOptions = useMemo( - () => - supportPagination - ? (options?.data ?? []) - : filterOptions(options?.data ?? [], search), - [supportPagination, options?.data, search], + () => filterOptions(options?.data ?? [], props.search), + [options?.data, props.search], ); const selectedOption = useMemo( @@ -77,16 +114,83 @@ export function DynamicEnumField(props: Props) { placeholder={selectedOption?.label ?? 'Select an option...'} onSelect={props.onChange} isFetching={isFetching} - onDebouncedChange={setSearch} + onDebouncedChange={props.onSearchChange} + allowClear + > + {filteredOptions.map((option) => ( + + {option.label} + + ))} + + ); +} + +function PaginatedDynamicEnum( + props: Props & { + search: string; + onSearchChange: (value: string) => void; + dynamicSource?: DynamicDataSource; + }, +) { + const { ref, inView } = useInView(); + + const { + data: allOptions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + } = useInfiniteFieldOptions({ + integration: props.integration, + source: props.dynamicSource, + search: props.search || undefined, + }); + + useEffect(() => { + if (!inView || !hasNextPage || isFetchingNextPage) { + return; + } + fetchNextPage(); + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const selectedOption = useMemo( + () => allOptions.find((option) => option.value === props.value), + [allOptions, props.value], + ); + + return ( + + {isFetchingNextPage && ( +
+ + Loading more... +
+ )} +
+ ) : undefined + } > - {filteredOptions.map((option) => { - return ( - - {option.label} - - ); - })} + {allOptions.map((option) => ( + + {option.label} + + ))} ); } diff --git a/src/components/form/combobox-field.tsx b/src/components/form/combobox-field.tsx index c90be31..d936050 100644 --- a/src/components/form/combobox-field.tsx +++ b/src/components/form/combobox-field.tsx @@ -51,6 +51,7 @@ type Props = { title?: string; disabled?: boolean; allowClear?: boolean; + listFooter?: ReactNode; className?: string; } & VariantProps; @@ -138,6 +139,7 @@ export function ComboboxField({ size, className, ...props }: Props) { {props.isFetching ? null : 'No option found.'} {props.children} + {props.listFooter} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index a2ce780..82df444 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import { paragon, type DynamicDataSource, @@ -115,6 +115,47 @@ export function useFieldOptions({ }); } +export function useInfiniteFieldOptions({ + integration, + source, + search, + enabled = true, +}: { + integration: string; + source?: DynamicDataSource; + search?: string; + enabled?: boolean; +}) { + const queryKey = source?.cacheKey; + + const query = useInfiniteQuery({ + enabled: enabled && !!queryKey, + queryKey: ['infiniteFieldOptions', integration, queryKey, search], + queryFn: ({ pageParam }) => { + return paragon.getFieldOptions({ + integration, + source: source!, + search, + cursor: pageParam, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextPageCursor ?? undefined, + }); + + const flatData = useMemo( + () => query.data?.pages.flatMap((page) => page.data) ?? [], + [query.data?.pages], + ); + + return { + data: flatData, + fetchNextPage: query.fetchNextPage, + hasNextPage: query.hasNextPage ?? false, + isFetchingNextPage: query.isFetchingNextPage, + isFetching: query.isFetching, + }; +} + export function useDataSourceOptions( integration: string, sourceType: string, From 6b067187a3b146d07ee0cc91300e6eb8cd4dd1e2 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 12:43:00 -0600 Subject: [PATCH 06/10] refactor: extract shared PaginatedCombobox and hasSourcePagination Made-with: Cursor --- src/components/feature/dynamic-enum.tsx | 99 ++++------------------ src/components/form/paginated-combobox.tsx | 89 +++++++++++++++++++ src/lib/hooks.ts | 28 +++++- 3 files changed, 132 insertions(+), 84 deletions(-) create mode 100644 src/components/form/paginated-combobox.tsx diff --git a/src/components/feature/dynamic-enum.tsx b/src/components/feature/dynamic-enum.tsx index 8905463..d7388e5 100644 --- a/src/components/feature/dynamic-enum.tsx +++ b/src/components/feature/dynamic-enum.tsx @@ -4,15 +4,14 @@ import { type SingleSource, type SerializedConnectInput, } from '@useparagon/connect'; -import { LoaderCircle } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; -import { useInView } from 'react-intersection-observer'; +import { useMemo, useState } from 'react'; import Fuse from 'fuse.js'; import { ComboboxField } from '@/components/form/combobox-field'; +import { PaginatedCombobox } from '@/components/form/paginated-combobox'; import { + hasSourcePagination, useFieldOptions, - useInfiniteFieldOptions, useSourcesForInput, } from '@/lib/hooks'; @@ -55,20 +54,23 @@ export function DynamicEnumField(props: Props) { ? ((sources as SingleSource).source as DynamicDataSource) : undefined; - const supportPagination = - ( - dynamicSource as DynamicDataSource & { - supportPagination?: boolean; - } - )?.supportPagination ?? false; + const supportPagination = dynamicSource + ? hasSourcePagination(dynamicSource) + : false; if (supportPagination) { return ( - ); } @@ -125,72 +127,3 @@ function StaticDynamicEnum( ); } - -function PaginatedDynamicEnum( - props: Props & { - search: string; - onSearchChange: (value: string) => void; - dynamicSource?: DynamicDataSource; - }, -) { - const { ref, inView } = useInView(); - - const { - data: allOptions, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isFetching, - } = useInfiniteFieldOptions({ - integration: props.integration, - source: props.dynamicSource, - search: props.search || undefined, - }); - - useEffect(() => { - if (!inView || !hasNextPage || isFetchingNextPage) { - return; - } - fetchNextPage(); - }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); - - const selectedOption = useMemo( - () => allOptions.find((option) => option.value === props.value), - [allOptions, props.value], - ); - - return ( - - {isFetchingNextPage && ( -
- - Loading more... -
- )} -
- ) : undefined - } - > - {allOptions.map((option) => ( - - {option.label} - - ))} - - ); -} diff --git a/src/components/form/paginated-combobox.tsx b/src/components/form/paginated-combobox.tsx new file mode 100644 index 0000000..e9de444 --- /dev/null +++ b/src/components/form/paginated-combobox.tsx @@ -0,0 +1,89 @@ +import { type DynamicDataSource } from '@useparagon/connect'; +import { LoaderCircle } from 'lucide-react'; +import { useEffect, useMemo } from 'react'; +import { useInView } from 'react-intersection-observer'; + +import { ComboboxField } from '@/components/form/combobox-field'; +import { useInfiniteFieldOptions } from '@/lib/hooks'; + +export type ComboDropdownProps = { + id: string; + title?: string; + required: boolean; + value: string | null; + onSelect: (value: string | null) => void; + onSearchChange: (value: string) => void; + integration: string; + source?: DynamicDataSource; + search: string; + parameters?: { cacheKey: string; value: string | undefined }[]; + enabled?: boolean; + disabled?: boolean; + allowClear?: boolean; +}; + +export function PaginatedCombobox(props: ComboDropdownProps) { + const { ref, inView } = useInView(); + + const { + data: allOptions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + } = useInfiniteFieldOptions({ + integration: props.integration, + source: props.source, + search: props.search || undefined, + parameters: props.parameters, + enabled: props.enabled, + }); + + useEffect(() => { + if (!inView || !hasNextPage || isFetchingNextPage) { + return; + } + fetchNextPage(); + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + const selectedOption = useMemo( + () => allOptions.find((option) => option.value === props.value), + [allOptions, props.value], + ); + + return ( + + {isFetchingNextPage && ( +
+ + Loading more... +
+ )} +
+ ) : undefined + } + > + {allOptions.map((option) => ( + + {option.label} + + ))} + + ); +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 82df444..3f22f8c 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -46,6 +46,15 @@ const fieldOptionsInitialData: FieldOptionsResponse = { nextPageCursor: null, }; +export function hasSourcePagination( + source: DynamicDataSource, +): boolean { + return ( + (source as DynamicDataSource & { supportPagination?: boolean }) + .supportPagination ?? false + ); +} + export function useSourcesForInput( integration: string, action: string | undefined, @@ -119,24 +128,41 @@ export function useInfiniteFieldOptions({ integration, source, search, + parameters = [], enabled = true, }: { integration: string; source?: DynamicDataSource; search?: string; + parameters?: { cacheKey: string; value: string | undefined }[]; enabled?: boolean; }) { const queryKey = source?.cacheKey; const query = useInfiniteQuery({ enabled: enabled && !!queryKey, - queryKey: ['infiniteFieldOptions', integration, queryKey, search], + queryKey: [ + 'infiniteFieldOptions', + integration, + queryKey, + search, + parameters, + ], queryFn: ({ pageParam }) => { + const mappedParameters = parameters.map((parameter) => ({ + key: parameter.cacheKey, + source: { + type: 'VALUE' as const, + value: parameter.value, + }, + })); + return paragon.getFieldOptions({ integration, source: source!, search, cursor: pageParam, + parameters: mappedParameters, }); }, getNextPageParam: (lastPage) => lastPage.nextPageCursor ?? undefined, From cc4e1d261d0f7dad28f0b78bd34559fd668bc661 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 12:43:11 -0600 Subject: [PATCH 07/10] feat: add infinite scroll pagination to combo input Made-with: Cursor --- src/components/feature/combo-input.tsx | 138 +++++++++++++------------ 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/src/components/feature/combo-input.tsx b/src/components/feature/combo-input.tsx index 22e8f2a..9ca65da 100644 --- a/src/components/feature/combo-input.tsx +++ b/src/components/feature/combo-input.tsx @@ -6,7 +6,15 @@ import { import { useMemo, useState } from 'react'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; +import { + PaginatedCombobox, + type ComboDropdownProps, +} from '@/components/form/paginated-combobox'; +import { + hasSourcePagination, + useFieldOptions, + useSourcesForInput, +} from '@/lib/hooks'; import { FieldLabel } from '../form/field-label'; export type ComboInputValue = { @@ -37,43 +45,6 @@ export function ComboInputField(props: Props) { ? (sources as DefaultFieldValueSources) : null; - const { data: mainInputOptions, isFetching: isFetchingMainInput } = - useFieldOptions({ - integration: props.integration, - source: comboSources?.mainInputSource, - search: mainInputSearch, - }); - - const selectedMainOption = useMemo( - () => - mainInputOptions.data.find( - (option) => option.value === props.value.mainInput, - ), - [mainInputOptions.data, props.value], - ); - - const { data: dependentInputOptions, isFetching: isFetchingDependentInput } = - useFieldOptions({ - enabled: Boolean(props.value.mainInput), - integration: props.integration, - source: comboSources?.dependentInputSource, - parameters: [ - { - cacheKey: comboSources?.mainInputSource.cacheKey as string, - value: props.value.mainInput, - }, - ], - search: dependentInputSearch, - }); - - const selectedDependentInputOption = useMemo( - () => - dependentInputOptions.data.find( - (option) => option.value === props.value.dependentInput, - ), - [dependentInputOptions.data, props.value], - ); - const mainInputMeta = comboSources?.mainInputSource; const dependentInputMeta = comboSources?.dependentInputSource; @@ -81,64 +52,99 @@ export function ComboInputField(props: Props) { return null; } + const MainDropdown = hasSourcePagination(mainInputMeta) + ? PaginatedCombobox + : StaticComboDropdown; + + const DependentDropdown = hasSourcePagination(dependentInputMeta) + ? PaginatedCombobox + : StaticComboDropdown; + return ( <> {props.field.title}
- props.onChange({ mainInput: value ?? undefined, dependentInput: undefined, }) } - isFetching={isFetchingMainInput} - onDebouncedChange={setMainInputSearch} + search={mainInputSearch} + onSearchChange={setMainInputSearch} + integration={props.integration} + source={mainInputMeta} allowClear - > - {mainInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} - - + props.onChange({ mainInput: props.value.mainInput, dependentInput: value ?? undefined, }) } - isFetching={isFetchingDependentInput} - onDebouncedChange={setDependentInputSearch} + search={dependentInputSearch} + onSearchChange={setDependentInputSearch} + integration={props.integration} + source={dependentInputMeta} + parameters={[ + { + cacheKey: mainInputMeta.cacheKey as string, + value: props.value.mainInput, + }, + ]} + enabled={Boolean(props.value.mainInput)} disabled={!props.value.mainInput} allowClear - > - {dependentInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} - + />
); } + +function StaticComboDropdown(props: ComboDropdownProps) { + const { data: options, isFetching } = useFieldOptions({ + integration: props.integration, + source: props.source, + parameters: props.parameters, + enabled: props.enabled, + search: props.search, + }); + + const selectedOption = useMemo( + () => options.data.find((option) => option.value === props.value), + [options.data, props.value], + ); + + return ( + + {options.data.map((option) => ( + + {option.label} + + ))} + + ); +} From b0f74dc42912ee1ce7b2673e6d61329b43acee0d Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 13:41:02 -0600 Subject: [PATCH 08/10] refactor: co-locate StaticComboDropdown with PaginatedCombobox Made-with: Cursor --- src/components/feature/combo-input.tsx | 42 ++-------------------- src/components/form/paginated-combobox.tsx | 38 +++++++++++++++++++- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/components/feature/combo-input.tsx b/src/components/feature/combo-input.tsx index 9ca65da..30b33a6 100644 --- a/src/components/feature/combo-input.tsx +++ b/src/components/feature/combo-input.tsx @@ -3,16 +3,14 @@ import { type DefaultFieldValueSources, type SerializedConnectInput, } from '@useparagon/connect'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; -import { ComboboxField } from '@/components/form/combobox-field'; import { PaginatedCombobox, - type ComboDropdownProps, + StaticComboDropdown, } from '@/components/form/paginated-combobox'; import { hasSourcePagination, - useFieldOptions, useSourcesForInput, } from '@/lib/hooks'; import { FieldLabel } from '../form/field-label'; @@ -112,39 +110,3 @@ export function ComboInputField(props: Props) { ); } - -function StaticComboDropdown(props: ComboDropdownProps) { - const { data: options, isFetching } = useFieldOptions({ - integration: props.integration, - source: props.source, - parameters: props.parameters, - enabled: props.enabled, - search: props.search, - }); - - const selectedOption = useMemo( - () => options.data.find((option) => option.value === props.value), - [options.data, props.value], - ); - - return ( - - {options.data.map((option) => ( - - {option.label} - - ))} - - ); -} diff --git a/src/components/form/paginated-combobox.tsx b/src/components/form/paginated-combobox.tsx index e9de444..585eb70 100644 --- a/src/components/form/paginated-combobox.tsx +++ b/src/components/form/paginated-combobox.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react'; import { useInView } from 'react-intersection-observer'; import { ComboboxField } from '@/components/form/combobox-field'; -import { useInfiniteFieldOptions } from '@/lib/hooks'; +import { useFieldOptions, useInfiniteFieldOptions } from '@/lib/hooks'; export type ComboDropdownProps = { id: string; @@ -87,3 +87,39 @@ export function PaginatedCombobox(props: ComboDropdownProps) { ); } + +export function StaticComboDropdown(props: ComboDropdownProps) { + const { data: options, isFetching } = useFieldOptions({ + integration: props.integration, + source: props.source, + parameters: props.parameters, + enabled: props.enabled, + search: props.search, + }); + + const selectedOption = useMemo( + () => options.data.find((option) => option.value === props.value), + [options.data, props.value], + ); + + return ( + + {options.data.map((option) => ( + + {option.label} + + ))} + + ); +} From 18145015a5ff19340a603f66a5db456e4423575e Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 13:42:26 -0600 Subject: [PATCH 09/10] feat: add infinite scroll pagination to dynamic combo input Made-with: Cursor --- .../feature/dynamic-combo-input.tsx | 105 ++++++------------ 1 file changed, 34 insertions(+), 71 deletions(-) diff --git a/src/components/feature/dynamic-combo-input.tsx b/src/components/feature/dynamic-combo-input.tsx index a18a500..848a725 100644 --- a/src/components/feature/dynamic-combo-input.tsx +++ b/src/components/feature/dynamic-combo-input.tsx @@ -3,13 +3,16 @@ import { type DefaultFieldValueSources, type SerializedConnectInput, } from '@useparagon/connect'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; +import { omit } from 'lodash'; -import { ComboboxField } from '@/components/form/combobox-field'; -import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; +import { + PaginatedCombobox, + StaticComboDropdown, +} from '@/components/form/paginated-combobox'; +import { hasSourcePagination, useSourcesForInput } from '@/lib/hooks'; import { VariableInput } from './variable-input'; -import { omit } from 'lodash'; type VariableInputValue = Record; @@ -33,8 +36,6 @@ export function DynamicComboInputField(props: Props) { const sources = useSourcesForInput( props.integration, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error props.field.sourceType as string, props.field, ); @@ -44,43 +45,6 @@ export function DynamicComboInputField(props: Props) { ? (sources as DefaultFieldValueSources) : null; - const { data: mainInputOptions, isFetching: isFetchingMainInput } = - useFieldOptions({ - integration: props.integration, - source: comboSources?.mainInputSource, - search: mainInputSearch, - }); - - const selectedMainOption = useMemo( - () => - mainInputOptions.data.find( - (option) => option.value === props.value.mainInput, - ), - [mainInputOptions.data, props.value], - ); - - const { data: dependentInputOptions, isFetching: isFetchingDependentInput } = - useFieldOptions({ - enabled: Boolean(props.value.mainInput), - integration: props.integration, - source: comboSources?.dependentInputSource, - parameters: [ - { - cacheKey: comboSources?.mainInputSource.cacheKey as string, - value: props.value.mainInput, - }, - ], - search: dependentInputSearch, - }); - - const selectedDependentInputOption = useMemo( - () => - dependentInputOptions.data.find( - (option) => option.value === props.value.dependentInput, - ), - [dependentInputOptions.data, props.value], - ); - const mainInputMeta = comboSources?.mainInputSource; const dependentInputMeta = comboSources?.dependentInputSource; @@ -88,60 +52,59 @@ export function DynamicComboInputField(props: Props) { return null; } + const MainDropdown = hasSourcePagination(mainInputMeta) + ? PaginatedCombobox + : StaticComboDropdown; + + const DependentDropdown = hasSourcePagination(dependentInputMeta) + ? PaginatedCombobox + : StaticComboDropdown; + return ( <>
- props.onChange({ mainInput: value ?? undefined, dependentInput: value ? props.value.dependentInput : undefined, }) } - isFetching={isFetchingMainInput} - onDebouncedChange={setMainInputSearch} + search={mainInputSearch} + onSearchChange={setMainInputSearch} + integration={props.integration} + source={mainInputMeta} allowClear - > - {mainInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} - - + props.onChange({ mainInput: props.value.mainInput, dependentInput: value ?? undefined, }) } - isFetching={isFetchingDependentInput} - onDebouncedChange={setDependentInputSearch} + search={dependentInputSearch} + onSearchChange={setDependentInputSearch} + integration={props.integration} + source={dependentInputMeta} + parameters={[ + { + cacheKey: mainInputMeta.cacheKey as string, + value: props.value.mainInput, + }, + ]} + enabled={Boolean(props.value.mainInput)} disabled={!props.value.mainInput} allowClear - > - {dependentInputOptions.data.map((option) => { - return ( - - {option.label} - - ); - })} - + />
{props.value.mainInput && props.value.dependentInput && From 19a9078b570255b2c8f9bb02f11482d15f353642 Mon Sep 17 00:00:00 2001 From: Albert Hassey Date: Tue, 10 Mar 2026 14:50:55 -0600 Subject: [PATCH 10/10] feat: add infinite scroll pagination to custom dropdown input --- .../serialized-connect-input-picker.tsx | 43 ++++++------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/src/components/feature/serialized-connect-input-picker.tsx b/src/components/feature/serialized-connect-input-picker.tsx index d2ee4a5..9b34e0b 100644 --- a/src/components/feature/serialized-connect-input-picker.tsx +++ b/src/components/feature/serialized-connect-input-picker.tsx @@ -18,8 +18,11 @@ import { CopyableInput } from '../form/copyable-input'; import { DynamicComboInputField } from './dynamic-combo-input'; import { ScopesSelectField } from '../form/scopes-select-field'; import { FileUploadField } from '../form/file-upload-field'; -import { ComboboxField } from '../form/combobox-field'; -import { useFieldOptions, useSourcesForInput } from '@/lib/hooks'; +import { + PaginatedCombobox, + StaticComboDropdown, +} from '../form/paginated-combobox'; +import { useSourcesForInput } from '@/lib/hooks'; type Props = { integration: string; @@ -285,23 +288,6 @@ function CustomDropdownInput(props: { [isDynamic, singleSource], ); - const { data: dynamicOptions, isFetching } = useFieldOptions({ - enabled: isDynamic, - integration: props.integration, - source: dynamicSource, - search: search || undefined, - }); - - const dynamicItems = useMemo( - () => dynamicOptions?.data ?? [], - [dynamicOptions?.data], - ); - - const selectedDynamicOption = useMemo( - () => dynamicItems.find((option) => option.value === props.value), - [dynamicItems, props.value], - ); - const flatOptions = useMemo(() => { if (!Array.isArray(staticOptions)) { return []; @@ -313,24 +299,21 @@ function CustomDropdownInput(props: { }, [staticOptions]); if (isDynamic) { + const Dropdown = dynamicSource ? PaginatedCombobox : StaticComboDropdown; + return ( - - {dynamicItems.map((option) => ( - - {option.label} - - ))} - + /> ); }