Skip to content

Commit 84bba87

Browse files
adolfo-pdTheBestMoshecoderabbitai[bot]
authored
Disable submit button when form is incomplete
Co-authored-by: Moshe Grunwald <34072688+TheBestMoshe@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent a174427 commit 84bba87

File tree

7 files changed

+153
-14
lines changed

7 files changed

+153
-14
lines changed

packages/connect-react/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<!-- markdownlint-disable MD024 -->
22
# Changelog
33

4+
# [1.0.0-preview.8] - 2024-12-09
5+
6+
- Disabled submit button when form is incomplete
7+
48
# [1.0.0-preview.7] - 2024-12-05
59

610
- Use proper casing for `stringOptions` now that configure prop is properly async

packages/connect-react/examples/nextjs/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/connect-react/examples/nextjs/src/app/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ export default function Home() {
3030
componentKey="slack-send-message"
3131
configuredProps={configuredProps}
3232
onUpdateConfiguredProps={setConfiguredProps}
33+
onSubmit={async () => {
34+
try {
35+
await client.actionRun({
36+
userId,
37+
actionId: "slack-send-message",
38+
configuredProps,
39+
});
40+
} catch (error) {
41+
console.error("Action run failed:", error);
42+
}
43+
}}
3344
/>
3445
</FrontendClientProvider>
3546
</>

packages/connect-react/src/components/ControlSubmit.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@ export type ControlSubmitProps = {
88

99
export function ControlSubmit(props: ControlSubmitProps) {
1010
const { form } = props;
11-
const { submitting } = form;
11+
const {
12+
propsNeedConfiguring, submitting,
13+
} = form;
1214

1315
const {
1416
getProps, theme,
1517
} = useCustomize();
16-
const baseStyles: CSSProperties = {
18+
const baseStyles = (disabled: boolean): CSSProperties => ({
1719
width: "fit-content",
1820
textTransform: "capitalize",
19-
backgroundColor: theme.colors.primary,
20-
color: theme.colors.neutral0,
21+
backgroundColor: disabled
22+
? theme.colors.neutral10
23+
: theme.colors.primary,
24+
color: disabled
25+
? theme.colors.neutral40
26+
: theme.colors.neutral0,
2127
padding: `${theme.spacing.baseUnit * 1.75}px ${
2228
theme.spacing.baseUnit * 16
2329
}px`,
@@ -29,9 +35,9 @@ export function ControlSubmit(props: ControlSubmitProps) {
2935
? 0.5
3036
: undefined,
3137
margin: "0.5rem 0 0 0",
32-
};
38+
});
3339

3440
return <input type="submit" value={submitting
3541
? "Submitting..."
36-
: "Submit"} {...getProps("controlSubmit", baseStyles, props)} disabled={submitting} />;
42+
: "Submit"} {...getProps("controlSubmit", baseStyles(propsNeedConfiguring.length || submitting), props)} disabled={propsNeedConfiguring.length || submitting} />;
3743
}

packages/connect-react/src/components/InternalField.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { FormFieldContext } from "../hooks/form-field-context";
33
import { useFormContext } from "../hooks/form-context";
44
import { Field } from "./Field";
55
import { useApp } from "../hooks/use-app";
6+
import { useEffect } from "react";
67

78
type FieldInternalProps<T extends ConfigurableProp> = {
89
prop: T;
@@ -14,7 +15,7 @@ export function InternalField<T extends ConfigurableProp>({
1415
}: FieldInternalProps<T>) {
1516
const formCtx = useFormContext();
1617
const {
17-
id: formId, configuredProps, setConfiguredProp,
18+
id: formId, configuredProps, registerField, setConfiguredProp,
1819
} = formCtx;
1920

2021
const appSlug = prop.type === "app" && "app" in prop
@@ -44,7 +45,9 @@ export function InternalField<T extends ConfigurableProp>({
4445
app, // XXX fix ts
4546
},
4647
};
47-
48+
useEffect(() => registerField(fieldCtx), [
49+
fieldCtx,
50+
])
4851
return (
4952
<FormFieldContext.Provider value={fieldCtx}>
5053
<Field field={fieldCtx} form={formCtx} />

packages/connect-react/src/hooks/form-context.tsx

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
import isEqual from "lodash.isequal";
55
import { useQuery } from "@tanstack/react-query";
66
import type {
7-
ComponentReloadPropsOpts, ConfigurableProp, ConfigurableProps, ConfiguredProps, V1Component,
7+
ComponentReloadPropsOpts, ConfigurableProp, ConfigurableProps, ConfiguredProps, V1Component, PropValue,
88
} from "@pipedream/sdk";
99
import { useFrontendClient } from "./frontend-client-context";
1010
import type { ComponentFormProps } from "../components/ComponentForm";
11+
import type { FormFieldContext } from "./form-field-context";
12+
import { appPropError } from "./use-app";
1113

1214
export type DynamicProps<T extends ConfigurableProps> = { id: string; configurableProps: T; }; // TODO
1315

@@ -17,12 +19,15 @@ export type FormContext<T extends ConfigurableProps> = {
1719
configuredProps: ConfiguredProps<T>;
1820
dynamicProps?: DynamicProps<T>; // lots of calls require dynamicProps?.id, so need to expose
1921
dynamicPropsQueryIsFetching?: boolean;
22+
fields: Record<string, FormFieldContext<ConfigurableProp>>;
2023
id: string;
2124
isValid: boolean;
2225
optionalPropIsEnabled: (prop: ConfigurableProp) => boolean;
2326
optionalPropSetEnabled: (prop: ConfigurableProp, enabled: boolean) => void;
2427
props: ComponentFormProps<T>;
28+
propsNeedConfiguring: string[];
2529
queryDisabledIdx?: number;
30+
registerField: <T extends ConfigurableProp>(field: FormFieldContext<T>) => void;
2631
setConfiguredProp: (idx: number, value: unknown) => void; // XXX type safety for value (T will rarely be static right?)
2732
setSubmitting: (submitting: boolean) => void;
2833
submitting: boolean;
@@ -64,6 +69,10 @@ export const FormContextProvider = <T extends ConfigurableProps>({
6469
queryDisabledIdx,
6570
setQueryDisabledIdx,
6671
] = useState<number | undefined>(0);
72+
const [
73+
fields,
74+
setFields,
75+
] = useState<Record<string, FormFieldContext<ConfigurableProp>>>({});
6776
const [
6877
submitting,
6978
setSubmitting,
@@ -129,6 +138,16 @@ export const FormContextProvider = <T extends ConfigurableProps>({
129138
enabled: reloadPropIdx != null, // TODO or props.dynamicPropsId && !dynamicProps
130139
});
131140

141+
const [
142+
propsNeedConfiguring,
143+
setPropsNeedConfiguring,
144+
] = useState<string[]>([]);
145+
useEffect(() => {
146+
checkPropsNeedConfiguring()
147+
}, [
148+
configuredProps,
149+
]);
150+
132151
// XXX fix types of dynamicProps, props.component so this type decl not needed
133152
let configurableProps: T = dynamicProps?.configurableProps || formProps.component.configurable_props || [];
134153
if (propNames?.length) {
@@ -147,7 +166,7 @@ export const FormContextProvider = <T extends ConfigurableProps>({
147166

148167
// these validations are necessary because they might override PropInput for number case for instance
149168
// so can't rely on that base control form validation
150-
const propErrors = (prop: ConfigurableProp, value: unknown): string[] => {
169+
const propErrors = <T extends ConfigurableProps>(prop: ConfigurableProp, value: unknown): string[] => {
151170
const errs: string[] = [];
152171
if (value === undefined) {
153172
if (!prop.optional) {
@@ -173,7 +192,14 @@ export const FormContextProvider = <T extends ConfigurableProps>({
173192
errs.push("not a string");
174193
}
175194
} else if (prop.type === "app") {
176-
// TODO need to know about auth type
195+
const field = fields[prop.name]
196+
if (field) {
197+
const app = field.extra.app
198+
const err = appPropError({ value, app })
199+
if (err) errs.push(err)
200+
} else {
201+
errs.push("field not registered")
202+
}
177203
}
178204
return errs;
179205
};
@@ -302,6 +328,27 @@ export const FormContextProvider = <T extends ConfigurableProps>({
302328
setEnabledOptionalProps(newEnabledOptionalProps);
303329
};
304330

331+
const checkPropsNeedConfiguring = () => {
332+
const _propsNeedConfiguring = []
333+
for (const prop of configurableProps) {
334+
if (!prop || prop.optional || prop.hidden) continue
335+
const value = configuredProps[prop.name as keyof ConfiguredProps<T>]
336+
const errors = propErrors(prop, value)
337+
if (errors.length) {
338+
_propsNeedConfiguring.push(prop.name)
339+
}
340+
}
341+
// propsNeedConfiguring.splice(0, propsNeedConfiguring.length, ..._propsNeedConfiguring)
342+
setPropsNeedConfiguring(_propsNeedConfiguring)
343+
}
344+
345+
const registerField = <T extends ConfigurableProp>(field: FormFieldContext<T>) => {
346+
setFields((fields) => {
347+
fields[field.prop.name] = field
348+
return fields
349+
});
350+
};
351+
305352
// console.log("***", configurableProps, configuredProps)
306353
const value: FormContext<T> = {
307354
id,
@@ -313,9 +360,12 @@ export const FormContextProvider = <T extends ConfigurableProps>({
313360
configuredProps,
314361
dynamicProps,
315362
dynamicPropsQueryIsFetching,
363+
fields,
316364
optionalPropIsEnabled,
317365
optionalPropSetEnabled,
366+
propsNeedConfiguring,
318367
queryDisabledIdx,
368+
registerField,
319369
setConfiguredProp,
320370
setSubmitting,
321371
submitting,

packages/connect-react/src/hooks/use-app.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import {
22
useQuery, type UseQueryOptions,
33
} from "@tanstack/react-query";
44
import { useFrontendClient } from "./frontend-client-context";
5-
import type { AppRequestResponse } from "@pipedream/sdk";
5+
import type {
6+
AppRequestResponse, AppResponse, ConfigurablePropApp,
7+
PropValue,
8+
} from "@pipedream/sdk";
69

710
/**
811
* Get details about an app
@@ -23,3 +26,65 @@ export const useApp = (slug: string, opts?:{ useQueryOpts?: Omit<UseQueryOptions
2326
app: query.data?.data,
2427
};
2528
};
29+
30+
type AppResponseWithExtractedCustomFields = AppResponse & {
31+
extracted_custom_fields_names: string[]
32+
}
33+
34+
type AppCustomField = {
35+
name: string
36+
optional?: boolean
37+
}
38+
39+
type OauthAppPropValue = PropValue<"app"> & {
40+
oauth_access_token?: string
41+
}
42+
43+
function getCustomFields(app: AppResponse): AppCustomField[] {
44+
const isOauth = app.auth_type === "oauth"
45+
const userDefinedCustomFields = JSON.parse(app.custom_fields_json || "[]")
46+
if ("extracted_custom_fields_names" in app && app.extracted_custom_fields_names) {
47+
const extractedCustomFields = ((app as AppResponseWithExtractedCustomFields).extracted_custom_fields_names || []).map(
48+
(name) => ({
49+
name,
50+
}),
51+
)
52+
userDefinedCustomFields.push(...extractedCustomFields)
53+
}
54+
return userDefinedCustomFields.map((cf: AppCustomField) => {
55+
return {
56+
...cf,
57+
// if oauth, treat all as optional (they are usually needed for getting access token)
58+
optional: cf.optional || isOauth,
59+
}
60+
})
61+
}
62+
63+
export function appPropError(opts: { value: any, app: AppResponse | undefined }): string | undefined {
64+
const { app, value } = opts
65+
if (!app) {
66+
return "app field not registered"
67+
}
68+
if (!value) {
69+
return "no app configured"
70+
}
71+
if (typeof value !== "object") {
72+
return "not an app"
73+
}
74+
const _value = value as PropValue<"app">
75+
if ("authProvisionId" in _value && !_value.authProvisionId) {
76+
if (app.auth_type) {
77+
if (app.auth_type === "oauth" && !(_value as OauthAppPropValue).oauth_access_token) {
78+
return "missing oauth token"
79+
}
80+
if (app.auth_type === "oauth" || app.auth_type === "keys") {
81+
for (const cf of getCustomFields(app)) {
82+
if (!cf.optional && !_value[cf.name]) {
83+
return "missing custom field"
84+
}
85+
}
86+
}
87+
return "no auth provision configured"
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)