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"
+ }
+ }
+}