diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index 50e954c04223e..bb4b1a2a29f80 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +# [1.0.0-preview.8] - 2024-12-09 + +- Disabled submit button when form is incomplete + # [1.0.0-preview.7] - 2024-12-05 - Use proper casing for `stringOptions` now that configure prop is properly async diff --git a/packages/connect-react/examples/nextjs/package-lock.json b/packages/connect-react/examples/nextjs/package-lock.json index d4fed8c62dcb0..0351814ee6464 100644 --- a/packages/connect-react/examples/nextjs/package-lock.json +++ b/packages/connect-react/examples/nextjs/package-lock.json @@ -23,10 +23,10 @@ }, "../..": { "name": "@pipedream/connect-react", - "version": "1.0.0-preview.6", + "version": "1.0.0-preview.8", "license": "MIT", "dependencies": { - "@pipedream/sdk": "^1.0.6", + "@pipedream/sdk": "workspace:^", "@tanstack/react-query": "^5.59.16", "lodash.isequal": "^4.5.0", "react-markdown": "^9.0.1", diff --git a/packages/connect-react/examples/nextjs/src/app/page.tsx b/packages/connect-react/examples/nextjs/src/app/page.tsx index 283e783623c1f..6de7fa73bebd0 100644 --- a/packages/connect-react/examples/nextjs/src/app/page.tsx +++ b/packages/connect-react/examples/nextjs/src/app/page.tsx @@ -30,6 +30,17 @@ export default function Home() { componentKey="slack-send-message" configuredProps={configuredProps} onUpdateConfiguredProps={setConfiguredProps} + onSubmit={async () => { + try { + await client.actionRun({ + userId, + actionId: "slack-send-message", + configuredProps, + }); + } catch (error) { + console.error("Action run failed:", error); + } + }} /> diff --git a/packages/connect-react/src/components/ControlSubmit.tsx b/packages/connect-react/src/components/ControlSubmit.tsx index 83e6e3f789769..a25f39c7f8ae8 100644 --- a/packages/connect-react/src/components/ControlSubmit.tsx +++ b/packages/connect-react/src/components/ControlSubmit.tsx @@ -8,16 +8,22 @@ export type ControlSubmitProps = { export function ControlSubmit(props: ControlSubmitProps) { const { form } = props; - const { submitting } = form; + const { + propsNeedConfiguring, submitting, + } = form; const { getProps, theme, } = useCustomize(); - const baseStyles: CSSProperties = { + const baseStyles = (disabled: boolean): CSSProperties => ({ width: "fit-content", textTransform: "capitalize", - backgroundColor: theme.colors.primary, - color: theme.colors.neutral0, + backgroundColor: disabled + ? theme.colors.neutral10 + : theme.colors.primary, + color: disabled + ? theme.colors.neutral40 + : theme.colors.neutral0, padding: `${theme.spacing.baseUnit * 1.75}px ${ theme.spacing.baseUnit * 16 }px`, @@ -29,9 +35,9 @@ export function ControlSubmit(props: ControlSubmitProps) { ? 0.5 : undefined, margin: "0.5rem 0 0 0", - }; + }); return ; + : "Submit"} {...getProps("controlSubmit", baseStyles(propsNeedConfiguring.length || submitting), props)} disabled={propsNeedConfiguring.length || submitting} />; } diff --git a/packages/connect-react/src/components/InternalField.tsx b/packages/connect-react/src/components/InternalField.tsx index a3bee58dac710..047d714914891 100644 --- a/packages/connect-react/src/components/InternalField.tsx +++ b/packages/connect-react/src/components/InternalField.tsx @@ -3,6 +3,7 @@ import { FormFieldContext } from "../hooks/form-field-context"; import { useFormContext } from "../hooks/form-context"; import { Field } from "./Field"; import { useApp } from "../hooks/use-app"; +import { useEffect } from "react"; type FieldInternalProps = { prop: T; @@ -14,7 +15,7 @@ export function InternalField({ }: FieldInternalProps) { const formCtx = useFormContext(); const { - id: formId, configuredProps, setConfiguredProp, + id: formId, configuredProps, registerField, setConfiguredProp, } = formCtx; const appSlug = prop.type === "app" && "app" in prop @@ -44,7 +45,9 @@ export function InternalField({ app, // XXX fix ts }, }; - + useEffect(() => registerField(fieldCtx), [ + fieldCtx, + ]) return ( diff --git a/packages/connect-react/src/hooks/form-context.tsx b/packages/connect-react/src/hooks/form-context.tsx index 19cfde742b473..374203ca4d8fd 100644 --- a/packages/connect-react/src/hooks/form-context.tsx +++ b/packages/connect-react/src/hooks/form-context.tsx @@ -4,10 +4,12 @@ import { import isEqual from "lodash.isequal"; import { useQuery } from "@tanstack/react-query"; import type { - ComponentReloadPropsOpts, ConfigurableProp, ConfigurableProps, ConfiguredProps, V1Component, + ComponentReloadPropsOpts, ConfigurableProp, ConfigurableProps, ConfiguredProps, V1Component, PropValue, } from "@pipedream/sdk"; import { useFrontendClient } from "./frontend-client-context"; import type { ComponentFormProps } from "../components/ComponentForm"; +import type { FormFieldContext } from "./form-field-context"; +import { appPropError } from "./use-app"; export type DynamicProps = { id: string; configurableProps: T; }; // TODO @@ -17,12 +19,15 @@ export type FormContext = { configuredProps: ConfiguredProps; dynamicProps?: DynamicProps; // lots of calls require dynamicProps?.id, so need to expose dynamicPropsQueryIsFetching?: boolean; + fields: Record>; id: string; isValid: boolean; optionalPropIsEnabled: (prop: ConfigurableProp) => boolean; optionalPropSetEnabled: (prop: ConfigurableProp, enabled: boolean) => void; props: ComponentFormProps; + propsNeedConfiguring: string[]; queryDisabledIdx?: number; + registerField: (field: FormFieldContext) => void; setConfiguredProp: (idx: number, value: unknown) => void; // XXX type safety for value (T will rarely be static right?) setSubmitting: (submitting: boolean) => void; submitting: boolean; @@ -64,6 +69,10 @@ export const FormContextProvider = ({ queryDisabledIdx, setQueryDisabledIdx, ] = useState(0); + const [ + fields, + setFields, + ] = useState>>({}); const [ submitting, setSubmitting, @@ -129,6 +138,16 @@ export const FormContextProvider = ({ enabled: reloadPropIdx != null, // TODO or props.dynamicPropsId && !dynamicProps }); + const [ + propsNeedConfiguring, + setPropsNeedConfiguring, + ] = useState([]); + useEffect(() => { + checkPropsNeedConfiguring() + }, [ + configuredProps, + ]); + // XXX fix types of dynamicProps, props.component so this type decl not needed let configurableProps: T = dynamicProps?.configurableProps || formProps.component.configurable_props || []; if (propNames?.length) { @@ -147,7 +166,7 @@ export const FormContextProvider = ({ // these validations are necessary because they might override PropInput for number case for instance // so can't rely on that base control form validation - const propErrors = (prop: ConfigurableProp, value: unknown): string[] => { + const propErrors = (prop: ConfigurableProp, value: unknown): string[] => { const errs: string[] = []; if (value === undefined) { if (!prop.optional) { @@ -173,7 +192,14 @@ export const FormContextProvider = ({ errs.push("not a string"); } } else if (prop.type === "app") { - // TODO need to know about auth type + const field = fields[prop.name] + if (field) { + const app = field.extra.app + const err = appPropError({ value, app }) + if (err) errs.push(err) + } else { + errs.push("field not registered") + } } return errs; }; @@ -302,6 +328,27 @@ export const FormContextProvider = ({ setEnabledOptionalProps(newEnabledOptionalProps); }; + const checkPropsNeedConfiguring = () => { + const _propsNeedConfiguring = [] + for (const prop of configurableProps) { + if (!prop || prop.optional || prop.hidden) continue + const value = configuredProps[prop.name as keyof ConfiguredProps] + const errors = propErrors(prop, value) + if (errors.length) { + _propsNeedConfiguring.push(prop.name) + } + } + // propsNeedConfiguring.splice(0, propsNeedConfiguring.length, ..._propsNeedConfiguring) + setPropsNeedConfiguring(_propsNeedConfiguring) + } + + const registerField = (field: FormFieldContext) => { + setFields((fields) => { + fields[field.prop.name] = field + return fields + }); + }; + // console.log("***", configurableProps, configuredProps) const value: FormContext = { id, @@ -313,9 +360,12 @@ export const FormContextProvider = ({ configuredProps, dynamicProps, dynamicPropsQueryIsFetching, + fields, optionalPropIsEnabled, optionalPropSetEnabled, + propsNeedConfiguring, queryDisabledIdx, + registerField, setConfiguredProp, setSubmitting, submitting, diff --git a/packages/connect-react/src/hooks/use-app.tsx b/packages/connect-react/src/hooks/use-app.tsx index a41252d615f12..4dca554fb3246 100644 --- a/packages/connect-react/src/hooks/use-app.tsx +++ b/packages/connect-react/src/hooks/use-app.tsx @@ -2,7 +2,10 @@ import { useQuery, type UseQueryOptions, } from "@tanstack/react-query"; import { useFrontendClient } from "./frontend-client-context"; -import type { AppRequestResponse } from "@pipedream/sdk"; +import type { + AppRequestResponse, AppResponse, ConfigurablePropApp, + PropValue, +} from "@pipedream/sdk"; /** * Get details about an app @@ -23,3 +26,65 @@ 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" + } + } +}