diff --git a/packages/connect-react/CHANGELOG.md b/packages/connect-react/CHANGELOG.md index 80d81233656f6..a2d8eb451213b 100644 --- a/packages/connect-react/CHANGELOG.md +++ b/packages/connect-react/CHANGELOG.md @@ -2,6 +2,12 @@ # Changelog +# [1.1.0] - 2025-06-04 + +- Adding support for 'object' prop types +- Modifying string and string[] inputs to hide the dropdown in the case of no options +- Added basic styling to hyperlinks in prop descriptions + # [1.0.2] - 2025-04-24 - Updating README to remove note about this package being in early preview diff --git a/packages/connect-react/package.json b/packages/connect-react/package.json index c40a247770dbb..76b1bd5b2d100 100644 --- a/packages/connect-react/package.json +++ b/packages/connect-react/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/connect-react", - "version": "1.0.2", + "version": "1.1.0", "description": "Pipedream Connect library for React", "files": [ "dist" diff --git a/packages/connect-react/src/components/Control.tsx b/packages/connect-react/src/components/Control.tsx index 23007e459a417..3706a06be4a4d 100644 --- a/packages/connect-react/src/components/Control.tsx +++ b/packages/connect-react/src/components/Control.tsx @@ -6,8 +6,10 @@ import { } from "@pipedream/sdk"; // import { ControlAny } from "./ControlAny" import { ControlApp } from "./ControlApp"; +import { ControlArray } from "./ControlArray"; import { ControlBoolean } from "./ControlBoolean"; import { ControlInput } from "./ControlInput"; +import { ControlObject } from "./ControlObject"; import { ControlSelect } from "./ControlSelect"; import { RemoteOptionsContainer } from "./RemoteOptionsContainer"; @@ -54,6 +56,11 @@ export function Control } if (prop.type.endsWith("[]")) { + // If no options are defined, use individual inputs with "Add more" functionality + if (!("options" in prop) || !prop.options) { + return ; + } + // If options are defined, they would have been handled above in the options check return null, }} />; @@ -73,6 +80,8 @@ export function Control case "integer": // XXX split into ControlString, ControlInteger, etc? but want to share autoComplet="off", etc functionality in base one return ; + case "object": + return ; default: // TODO "not supported prop type should bubble up" throw new Error("Unsupported property type: " + prop.type); diff --git a/packages/connect-react/src/components/ControlArray.tsx b/packages/connect-react/src/components/ControlArray.tsx new file mode 100644 index 0000000000000..21bf892cc6c27 --- /dev/null +++ b/packages/connect-react/src/components/ControlArray.tsx @@ -0,0 +1,137 @@ +import { + useState, useEffect, +} from "react"; +import { useFormFieldContext } from "../hooks/form-field-context"; +import { useCustomize } from "../hooks/customization-context"; +import { + getInputStyles, getButtonStyles, getRemoveButtonStyles, getContainerStyles, getItemStyles, +} from "../styles/control-styles"; + +export function ControlArray() { + const formFieldContextProps = useFormFieldContext(); + const { + onChange, prop, value, + } = formFieldContextProps; + const { + getProps, theme, + } = useCustomize(); + + // Initialize values from the current value + const initializeValues = (): string[] => { + if (!value || !Array.isArray(value)) { + return [ + "", + ]; + } + + const stringValues = value.map((v) => typeof v === "string" + ? v + : JSON.stringify(v)); + return stringValues.length > 0 + ? stringValues + : [ + "", + ]; + }; + + const [ + values, + setValues, + ] = useState(initializeValues); + + // Update values when value changes externally + useEffect(() => { + setValues(initializeValues()); + }, [ + value, + ]); + + const updateArray = (newValues: string[]) => { + // Filter out empty values + const validValues = newValues.filter((v) => v.trim() !== ""); + + if (validValues.length === 0) { + onChange(undefined); + return; + } + + onChange(validValues as string[]); + }; + + const handleValueChange = (index: number, newValue: string) => { + const newValues = [ + ...values, + ]; + newValues[index] = newValue; + setValues(newValues); + updateArray(newValues); + }; + + const addValue = () => { + const newValues = [ + ...values, + "", + ]; + setValues(newValues); + }; + + const removeValue = (index: number) => { + const newValues = values.filter((_, i) => i !== index); + setValues(newValues.length > 0 + ? newValues + : [ + "", + ]); + updateArray(newValues); + }; + + const containerStyles = getContainerStyles(); + const itemStyles = getItemStyles(); + const inputStyles = getInputStyles(theme); + const buttonStyles = getButtonStyles(theme); + const removeButtonStyles = getRemoveButtonStyles(theme); + + // Show "Add more" button if the last input has content or if there are multiple inputs + const shouldShowAddMoreButton = values[values.length - 1]?.trim() || values.length > 1; + + return ( +
+ {values.map((value, index) => ( +
+ handleValueChange(index, e.target.value)} + placeholder="" + style={inputStyles} + required={!prop.optional && index === 0} + /> + {values.length > 1 && ( + + )} +
+ ))} + {shouldShowAddMoreButton && ( + + )} +
+ ); +} diff --git a/packages/connect-react/src/components/ControlObject.tsx b/packages/connect-react/src/components/ControlObject.tsx new file mode 100644 index 0000000000000..0bcaa643a8266 --- /dev/null +++ b/packages/connect-react/src/components/ControlObject.tsx @@ -0,0 +1,255 @@ +import { + useState, useEffect, +} from "react"; +import { useFormFieldContext } from "../hooks/form-field-context"; +import { useCustomize } from "../hooks/customization-context"; +import { + getInputStyles, getButtonStyles, getRemoveButtonStyles, getContainerStyles, getItemStyles, +} from "../styles/control-styles"; + +type KeyValuePair = { + key: string; + value: string; +}; + +export function ControlObject() { + const formFieldContextProps = useFormFieldContext(); + const { + onChange, prop, value, + } = formFieldContextProps; + const { + getProps, theme, + } = useCustomize(); + + // Check if the value is a plain object (not Date, Function, etc.) + const isPlainObject = (obj: unknown): boolean => { + if (obj === null || typeof obj !== "object") { + return false; + } + + // Check for Date, Function, RegExp, and other built-in objects + if (obj instanceof Date || obj instanceof Function || obj instanceof RegExp) { + return false; + } + + // Check if it's a plain object with Object.prototype or null prototype + const proto = Object.getPrototypeOf(obj); + return proto === Object.prototype || proto === null; + }; + + // Initialize pairs from the current value + const initializePairs = (): KeyValuePair[] => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return [ + { + key: "", + value: "", + }, + ]; + } + + if (!isPlainObject(value)) { + return [ + { + key: "", + value: "", + }, + ]; + } + + const pairs = Object.entries(value).map(([ + k, + v, + ]) => ({ + key: k, + value: typeof v === "string" + ? v + : (() => { + try { + return JSON.stringify(v); + } catch { + // Handle circular references or non-serializable values + return String(v); + } + })(), + })); + + return pairs.length > 0 + ? pairs + : [ + { + key: "", + value: "", + }, + ]; + }; + + const [ + pairs, + setPairs, + ] = useState(initializePairs); + + // Update pairs when value changes externally + useEffect(() => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + setPairs([ + { + key: "", + value: "", + }, + ]); + return; + } + + if (!isPlainObject(value)) { + setPairs([ + { + key: "", + value: "", + }, + ]); + return; + } + + const newPairs = Object.entries(value).map(([ + k, + v, + ]) => ({ + key: k, + value: typeof v === "string" + ? v + : (() => { + try { + return JSON.stringify(v); + } catch { + // Handle circular references or non-serializable values + return String(v); + } + })(), + })); + + setPairs(newPairs.length > 0 + ? newPairs + : [ + { + key: "", + value: "", + }, + ]); + }, [ + value, + ]); + + const updateObject = (newPairs: KeyValuePair[]) => { + // Filter out empty pairs + const validPairs = newPairs.filter((p) => p.key.trim() !== ""); + + if (validPairs.length === 0) { + onChange(undefined); + return; + } + + // Convert to object + const obj: Record = {}; + validPairs.forEach((pair) => { + if (pair.key.trim()) { + // Try to parse the value as JSON, fallback to string + try { + obj[pair.key] = JSON.parse(pair.value); + } catch { + obj[pair.key] = pair.value; + } + } + }); + + onChange(obj as Record); + }; + + const handlePairChange = (index: number, field: "key" | "value", newValue: string) => { + const newPairs = [ + ...pairs, + ]; + newPairs[index] = { + ...newPairs[index], + [field]: newValue, + }; + setPairs(newPairs); + updateObject(newPairs); + }; + + const addPair = () => { + const newPairs = [ + ...pairs, + { + key: "", + value: "", + }, + ]; + setPairs(newPairs); + }; + + const removePair = (index: number) => { + const newPairs = pairs.filter((_, i) => i !== index); + setPairs(newPairs.length > 0 + ? newPairs + : [ + { + key: "", + value: "", + }, + ]); + updateObject(newPairs); + }; + + const containerStyles = getContainerStyles(); + const pairStyles = getItemStyles(); // Reuse item styles for pairs + const inputStyles = getInputStyles(theme); + const buttonStyles = getButtonStyles(theme); + const removeButtonStyles = getRemoveButtonStyles(theme); + + return ( +
+ {pairs.map((pair, index) => ( +
+ handlePairChange(index, "key", e.target.value)} + placeholder="Key" + style={inputStyles} + required={!prop.optional && index === 0} + /> + handlePairChange(index, "value", e.target.value)} + placeholder="Value" + style={inputStyles} + /> + {pairs.length > 1 && ( + + )} +
+ ))} + +
+ ); +} diff --git a/packages/connect-react/src/components/Description.tsx b/packages/connect-react/src/components/Description.tsx index abd04cdde795a..5dd40444651fa 100644 --- a/packages/connect-react/src/components/Description.tsx +++ b/packages/connect-react/src/components/Description.tsx @@ -1,4 +1,6 @@ -import type { CSSProperties } from "react"; +import { + useState, useMemo, type CSSProperties, +} from "react"; import Markdown from "react-markdown"; import { ConfigurableProp, ConfigurableProps, @@ -13,6 +15,45 @@ export type DescriptionProps; }; +// Custom link component extracted outside to avoid recreation on each render +const DescriptionLink = ({ ...linkProps }) => { + const [ + isHovered, + setIsHovered, + ] = useState(false); + + const linkStyles: CSSProperties = { + textDecoration: "underline", + textUnderlineOffset: "3px", + color: "inherit", + transition: "opacity 0.2s ease", + opacity: isHovered + ? 0.7 + : 1, + }; + + return ( + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + /> + + + ); +}; + // XXX should we rename to FieldDescription (so shared prefix + clear we need field context // eg. cannot be used in OptionalFieldButton, or they need to be set up better) export function Description(props: DescriptionProps) { @@ -24,27 +65,32 @@ export function Description ({ color: theme.colors.neutral50, fontWeight: 400, fontSize: "0.75rem", gridArea: "description", textWrap: "balance", lineHeight: "1.5", - }; + }), [ + theme.colors.neutral50, + ]); + + const markdownComponents = useMemo(() => ({ + a: DescriptionLink, + }), []); + + // Cast props to the expected type for styling functions + const styleProps = props as unknown as DescriptionProps; if (prop.type === "app") { // TODO - return

Credentials are encrypted.

; + return

Credentials are encrypted.

; } if (!prop.description) { return null; } - return
{ - return ; - }, - }}> + return
{markdown}
; } diff --git a/packages/connect-react/src/hooks/customization-context.tsx b/packages/connect-react/src/hooks/customization-context.tsx index e720045941ff2..7169780a8db43 100644 --- a/packages/connect-react/src/hooks/customization-context.tsx +++ b/packages/connect-react/src/hooks/customization-context.tsx @@ -24,8 +24,10 @@ import type { FormFieldContext } from "./form-field-context"; import { ComponentForm } from "../components/ComponentForm"; import { ControlAny } from "../components/ControlAny"; import { ControlApp } from "../components/ControlApp"; +import { ControlArray } from "../components/ControlArray"; import { ControlBoolean } from "../components/ControlBoolean"; import { ControlInput } from "../components/ControlInput"; +import { ControlObject } from "../components/ControlObject"; import { ControlSelect } from "../components/ControlSelect"; import { ControlSubmit } from "../components/ControlSubmit"; import { Description } from "../components/Description"; @@ -67,8 +69,10 @@ export type CustomizableProps = { connectButton: ComponentProps & FormFieldContext; controlAny: ComponentProps & FormFieldContext; controlApp: ComponentProps & FormFieldContext; + controlArray: ComponentProps & FormFieldContext; controlBoolean: ComponentProps & FormFieldContext; controlInput: ComponentProps & FormFieldContext; + controlObject: ComponentProps & FormFieldContext; controlSubmit: ComponentProps; description: ComponentProps; error: ComponentProps; diff --git a/packages/connect-react/src/styles/control-styles.ts b/packages/connect-react/src/styles/control-styles.ts new file mode 100644 index 0000000000000..6eb9a07c24800 --- /dev/null +++ b/packages/connect-react/src/styles/control-styles.ts @@ -0,0 +1,48 @@ +import type { CSSProperties } from "react"; +import type { Theme } from "../theme"; + +export const getInputStyles = (theme: Theme): CSSProperties => ({ + color: theme.colors.neutral60, + border: "1px solid", + borderColor: theme.colors.neutral20, + padding: 6, + borderRadius: theme.borderRadius, + boxShadow: theme.boxShadow.input, + flex: 1, +}); + +export const getButtonStyles = (theme: Theme): CSSProperties => ({ + color: theme.colors.neutral60, + display: "inline-flex", + alignItems: "center", + padding: `${theme.spacing.baseUnit}px ${theme.spacing.baseUnit * 1.5}px ${ + theme.spacing.baseUnit + }px ${theme.spacing.baseUnit * 2.5}px`, + border: `1px solid ${theme.colors.neutral30}`, + borderRadius: theme.borderRadius, + cursor: "pointer", + fontSize: "0.8125rem", + fontWeight: 450, + gap: theme.spacing.baseUnit * 2, + textWrap: "nowrap", + backgroundColor: "white", +}); + +export const getRemoveButtonStyles = (theme: Theme): CSSProperties => ({ + ...getButtonStyles(theme), + flex: "0 0 auto", + padding: "6px 8px", +}); + +export const getContainerStyles = (): CSSProperties => ({ + gridArea: "control", + display: "flex", + flexDirection: "column", + gap: "0.5rem", +}); + +export const getItemStyles = (): CSSProperties => ({ + display: "flex", + gap: "0.5rem", + alignItems: "center", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5318e3f288dfa..223aab8e6b264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2914,8 +2914,7 @@ importers: specifier: ^1.6.2 version: 1.6.6 - components/contactout: - specifiers: {} + components/contactout: {} components/contacts: dependencies: @@ -10492,8 +10491,7 @@ importers: components/proprofs_quiz_maker: {} - components/prospeo: - specifiers: {} + components/prospeo: {} components/provesource: {} @@ -29157,22 +29155,22 @@ packages: superagent@3.8.1: resolution: {integrity: sha512-VMBFLYgFuRdfeNQSMLbxGSLfmXL/xc+OO+BZp41Za/NRDBet/BNbkRJrYzCUu0u4GU0i/ml2dtT8b9qgkw9z6Q==} engines: {node: '>= 4.0'} - deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@4.1.0: resolution: {integrity: sha512-FT3QLMasz0YyCd4uIi5HNe+3t/onxMyEho7C3PSqmti3Twgy2rXT4fmkTz6wRL6bTF4uzPcfkUCa8u4JWHw8Ag==} engines: {node: '>= 6.0'} - deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@5.3.1: resolution: {integrity: sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==} engines: {node: '>= 7.0.0'} - deprecated: Please upgrade to v7.0.2+ of superagent. We have fixed numerous issues with streams, form-data, attach(), filesystem errors not bubbling up (ENOENT on attach()), and all tests are now passing. See the releases tab for more information at . + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net superagent@7.1.6: resolution: {integrity: sha512-gZkVCQR1gy/oUXr+kxJMLDjla434KmSOKbx5iGD30Ql+AkJQ/YlPKECJy2nhqOsHLjGHzoDTXNSjhnvWhzKk7g==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please downgrade to v7.1.5 if you need IE/ActiveXObject support OR upgrade to v8.0.0 as we no longer support IE and published an incorrect patch version (see https://github.com/visionmedia/superagent/issues/1731) + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net supports-color@2.0.0: resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==}