diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index dc9225596572a..45a3f265aa9c3 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog +# [1.0.0-preview.11] - 2024-12-13 + +- Make prop validation more consistent with app behavior +- Relax validation of string props when value is not a string + # [1.0.0-preview.10] - 2024-12-12 - Enforce string length limits diff --git a/packages/connect-react/examples/nextjs/package-lock.json b/packages/connect-react/examples/nextjs/package-lock.json index c56ed62c8171b..c5246f24c095f 100644 --- a/packages/connect-react/examples/nextjs/package-lock.json +++ b/packages/connect-react/examples/nextjs/package-lock.json @@ -23,7 +23,7 @@ }, "../..": { "name": "@pipedream/connect-react", - "version": "1.0.0-preview.9", + "version": "1.0.0-preview.10", "license": "MIT", "dependencies": { "@pipedream/sdk": "workspace:^", diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index d6d7388c8d590..2cfd49d93cde1 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "1.0.0-preview.10", + "version": "1.0.0-preview.11", "description": "Pipedream Connect library for React", "files": [ "dist" diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index a13acc54e74b6..292be68c579d6 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -9,7 +9,10 @@ import type { import { useFrontendClient } from "./frontend-client-context"; import type { ComponentFormProps } from "../components/ComponentForm"; import type { FormFieldContext } from "./form-field-context"; -import { appPropError } from "./use-app"; +import { + appPropErrors, arrayPropErrors, booleanPropErrors, integerPropErrors, + stringPropErrors, +} from "../utils/component"; export type DynamicProps = { id: string; configurableProps: T; }; // TODO @@ -19,6 +22,7 @@ export type FormContext = { configuredProps: ConfiguredProps; dynamicProps?: DynamicProps; // lots of calls require dynamicProps?.id, so need to expose dynamicPropsQueryIsFetching?: boolean; + errors: Record; fields: Record>; id: string; isValid: boolean; @@ -168,55 +172,39 @@ export const FormContextProvider = ({ // so can't rely on that base control form validation const propErrors = (prop: ConfigurableProp, value: unknown): string[] => { const errs: string[] = []; - if (value === undefined) { - if (!prop.optional) { - errs.push("required"); - } - } else if (prop.type === "integer") { // XXX type should be "number"? we don't support floats otherwise... - if (typeof value !== "number") { - errs.push("not a number"); - } else { - if (prop.min != null && value < prop.min) { - errs.push("number too small"); - } - if (prop.max != null && value > prop.max) { - errs.push("number too big"); - } - } - } else if (prop.type === "boolean") { - if (typeof value !== "boolean") { - errs.push("not a boolean"); - } - } else if (prop.type === "string") { - type StringProp = ConfigurableProp & { - min?: number; - max?: number; - } - const { - min = 1, max, - } = prop as StringProp; - if (typeof value !== "string") { - errs.push("not a string"); - } else { - if (value.length < min) { - errs.push(`string length must be at least ${min} characters`); - } - if (max && value.length > max) { - errs.push(`string length must not exceed ${max} characters`); - } - } - } else if (prop.type === "app") { + if (prop.optional || prop.hidden || prop.disabled) return [] + if (prop.type === "app") { const field = fields[prop.name] if (field) { const app = field.extra.app - const err = appPropError({ + errs.push(...(appPropErrors({ + prop, value, app, - }) - if (err) errs.push(err) + }) ?? [])) } else { errs.push("field not registered") } + } else if (prop.type === "boolean") { + errs.push(...(booleanPropErrors({ + prop, + value, + }) ?? [])) + } else if (prop.type === "integer") { + errs.push(...(integerPropErrors({ + prop, + value, + }) ?? [])) + } else if (prop.type === "string") { + errs.push(...(stringPropErrors({ + prop, + value, + }) ?? [])) + } else if (prop.type === "string[]") { + errs.push(...(arrayPropErrors({ + prop, + value, + }) ?? [])) } return errs; }; @@ -377,6 +365,7 @@ export const FormContextProvider = ({ configuredProps, dynamicProps, dynamicPropsQueryIsFetching, + errors, fields, optionalPropIsEnabled, optionalPropSetEnabled, diff --git a/packages/connect-react/src/hooks/use-app.tsx b/packages/connect-react/src/hooks/use-app.tsx index 4dca554fb3246..8437bdb625593 100644 --- a/packages/connect-react/src/hooks/use-app.tsx +++ b/packages/connect-react/src/hooks/use-app.tsx @@ -1,16 +1,13 @@ import { useQuery, type UseQueryOptions, } from "@tanstack/react-query"; +import type { GetAppResponse } from "@pipedream/sdk"; import { useFrontendClient } from "./frontend-client-context"; -import type { - AppRequestResponse, AppResponse, ConfigurablePropApp, - PropValue, -} from "@pipedream/sdk"; /** * Get details about an app */ -export const useApp = (slug: string, opts?:{ useQueryOpts?: Omit, "queryKey" | "queryFn">;}) => { +export const useApp = (slug: string, opts?:{ useQueryOpts?: Omit, "queryKey" | "queryFn">;}) => { const client = useFrontendClient(); const query = useQuery({ queryKey: [ @@ -26,65 +23,3 @@ export const useApp = (slug: string, opts?:{ useQueryOpts?: Omit & { - oauth_access_token?: string -} - -function getCustomFields(app: AppResponse): AppCustomField[] { - const isOauth = app.auth_type === "oauth" - const userDefinedCustomFields = JSON.parse(app.custom_fields_json || "[]") - if ("extracted_custom_fields_names" in app && app.extracted_custom_fields_names) { - const extractedCustomFields = ((app as AppResponseWithExtractedCustomFields).extracted_custom_fields_names || []).map( - (name) => ({ - name, - }), - ) - userDefinedCustomFields.push(...extractedCustomFields) - } - return userDefinedCustomFields.map((cf: AppCustomField) => { - return { - ...cf, - // if oauth, treat all as optional (they are usually needed for getting access token) - optional: cf.optional || isOauth, - } - }) -} - -export function appPropError(opts: { value: any, app: AppResponse | undefined }): string | undefined { - const { app, value } = opts - if (!app) { - return "app field not registered" - } - if (!value) { - return "no app configured" - } - if (typeof value !== "object") { - return "not an app" - } - const _value = value as PropValue<"app"> - if ("authProvisionId" in _value && !_value.authProvisionId) { - if (app.auth_type) { - if (app.auth_type === "oauth" && !(_value as OauthAppPropValue).oauth_access_token) { - return "missing oauth token" - } - if (app.auth_type === "oauth" || app.auth_type === "keys") { - for (const cf of getCustomFields(app)) { - if (!cf.optional && !_value[cf.name]) { - return "missing custom field" - } - } - } - return "no auth provision configured" - } - } -} diff --git a/packages/connect-react/src/utils/component.ts b/packages/connect-react/src/utils/component.ts new file mode 100644 index 0000000000000..7d30ef639687f --- /dev/null +++ b/packages/connect-react/src/utils/component.ts @@ -0,0 +1,186 @@ +import type { + App, ConfigurableProp, ConfigurablePropApp, ConfigurablePropBoolean, ConfigurablePropInteger, ConfigurablePropString, ConfigurablePropStringArray, PropValue, +} from "@pipedream/sdk"; + +export type PropOptionValue = { + __lv: { + value: T + } +} + +export function valueFromOption(value: T | PropOptionValue): T | undefined | null { + if (typeof value === "object" && value && "__lv" in value) { + return (value as PropOptionValue).__lv.value + } + return value +} + +export type PropOption = { + emitValue: T | PropOptionValue +} +export type PropOptions = { + selectedOptions: PropOption[] +} + +export function valuesFromOptions(value: unknown | T[] | PropOptions): T[] { + if (typeof value === "object" && value && "selectedOptions" in value && Array.isArray(value.selectedOptions)) { + const selectedOptions = value.selectedOptions as PropOption[] + const results: T[] = [] + for (const so of selectedOptions) { + if (typeof so === "object" && so && "emitValue" in so) { + const emitValue = so.emitValue as T | PropOptionValue + if (typeof emitValue === "object" && emitValue && "__lv" in emitValue) { + results.push(emitValue.__lv.value) + } else { + results.push(emitValue as T) + } + } else { + throw "unexpected value" + } + } + return results + } + if (!Array.isArray(value)) + throw "unexpected value" + return value as T[] +} + +export type ValidationOpts = { + prop: T + value: unknown + app?: App +} + +export function arrayPropErrors(opts: ValidationOpts): string[] | undefined { + const _values = valuesFromOptions(opts.value) + if (!opts.prop.default && typeof _values === "undefined") { + return [ + "required", + ] + } + if (!opts.prop.default && Array.isArray(_values) && !_values.length) return [ + "empty array", + ] +} + +export function booleanPropErrors(opts: ValidationOpts): string[] | undefined { + const _value = valueFromOption(opts.value) + if (_value == null || typeof _value === "undefined") return [ + "required", + ] +} + +export function integerPropErrors(opts: ValidationOpts): string[] | undefined { + const { + prop, value: valueOpt, + } = opts + const value = valueFromOption(valueOpt) + + if (!prop.default && (value == null || typeof value === "undefined")) return [ + "required", + ] + + const _value: number = typeof value === "number" + ? value + : parseInt(String(value)) + + if (Number.isNaN(_value)) return [ + "not a number", + ] + const errs = [] + if (typeof prop.min === "number" && _value < prop.min) errs.push("number too small") + if (typeof prop.max === "number" && _value > prop.max) errs.push("number too big") + return errs +} + +export function stringPropErrors(opts: ValidationOpts): string[] | undefined { + const _value = valueFromOption(opts.value) + + if (!opts.prop.default) { + if (typeof _value === "undefined" || _value == null) return [ + "required", + ] + if (!String(_value).length) return [ + "string must not be empty", + ] + } +} + +type AppWithExtractedCustomFields = App & { + extracted_custom_fields_names: string[] +} + +type AppCustomField = { + name: string + optional?: boolean +} + +type OauthAppPropValue = PropValue<"app"> & { + oauth_access_token: string +} + +type AppPropValueWithCustomFields = PropValue<"app"> & { + [K in T[number]["name"]]: T[number] +} + +function getCustomFields(app: App): AppCustomField[] { + const isOauth = app.auth_type === "oauth" + const userDefinedCustomFields = JSON.parse(app.custom_fields_json || "[]") + if ("extracted_custom_fields_names" in app && app.extracted_custom_fields_names) { + const extractedCustomFields = ((app as AppWithExtractedCustomFields).extracted_custom_fields_names || []).map( + (name) => ({ + name, + }), + ) + userDefinedCustomFields.push(...extractedCustomFields) + } + return userDefinedCustomFields.map((cf: AppCustomField) => { + return { + ...cf, + // if oauth, treat all as optional (they are usually needed for getting access token) + optional: cf.optional || isOauth, + } + }) +} + +export function appPropErrors(opts: ValidationOpts): string[] | undefined { + const { + app, value, + } = opts + if (!app) { + return [ + "app field not registered", + ] + } + if (!value) { + return [ + "no app configured", + ] + } + if (typeof value !== "object") { + return [ + "not an app", + ] + } + const _value = value as PropValue<"app"> + if ("authProvisionId" in _value && !_value.authProvisionId) { + if (app.auth_type) { + const errs = [] + if (app.auth_type === "oauth" && !(_value as OauthAppPropValue).oauth_access_token) { + errs.push("missing oauth token") + } + if (app.auth_type === "oauth" || app.auth_type === "keys") { + const customFields = getCustomFields(app) + const _valueWithCustomFields = _value as AppPropValueWithCustomFields + for (const cf of customFields) { + if (!cf.optional && !_valueWithCustomFields[cf.name]) { + errs.push(`missing custom field: ${cf.name}`) + } + } + } + if (app.auth_type !== "none") + errs.push("no auth provision configured") + return errs + } + } +}