diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.module.scss b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.module.scss new file mode 100644 index 000000000000..37f2047a4c50 --- /dev/null +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.module.scss @@ -0,0 +1,11 @@ +@use "../../scss/colors"; +@use "../../scss/variables"; + +.container { + margin-bottom: variables.$spacing-xl; +} + +.list { + background-color: colors.$grey-50; + border-radius: 4px; +} diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx index e1ace8fcbdda..8640d3741392 100644 --- a/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/ArrayOfObjectsEditor.tsx @@ -1,111 +1,93 @@ import React from "react"; import { FormattedMessage } from "react-intl"; -import styled from "styled-components"; -import { Button } from "components"; +import Modal, { ModalProps } from "components/Modal"; import { ConnectionFormMode } from "views/Connection/ConnectionForm/ConnectionForm"; +import styles from "./ArrayOfObjectsEditor.module.scss"; import { EditorHeader } from "./components/EditorHeader"; import { EditorRow } from "./components/EditorRow"; -const ItemsList = styled.div` - background: ${({ theme }) => theme.greyColor0}; - border-radius: 4px; -`; - -const ButtonContainer = styled.div` - display: flex; - justify-content: flex-end; -`; - -const SmallButton = styled(Button)` - margin-left: 8px; - padding: 6px 8px 7px; -`; - -const Content = styled.div` - margin-bottom: 20px; -`; +interface ItemBase { + name?: string; + description?: string; +} -export interface ArrayOfObjectsEditorProps { +export interface ArrayOfObjectsEditorProps { items: T[]; editableItemIndex?: number | string | null; - children: (item?: T) => React.ReactNode; mainTitle?: React.ReactNode; addButtonText?: React.ReactNode; + renderItemName?: (item: T, index: number) => React.ReactNode | undefined; + renderItemDescription?: (item: T, index: number) => React.ReactNode | undefined; + renderItemEditorForm: (item?: T) => React.ReactNode; onStartEdit: (n: number) => void; - onCancelEdit?: () => void; - onDone?: () => void; onRemove: (index: number) => void; mode?: ConnectionFormMode; disabled?: boolean; + editModalSize?: ModalProps["size"]; } -export const ArrayOfObjectsEditor = ({ +export const ArrayOfObjectsEditor = ({ onStartEdit, - onDone, onRemove, - onCancelEdit, + renderItemName = (item) => item.name, + renderItemDescription = (item) => item.description, + renderItemEditorForm, items, editableItemIndex, - children, mainTitle, addButtonText, mode, disabled, + editModalSize, }: ArrayOfObjectsEditorProps): JSX.Element => { const onAddItem = React.useCallback(() => onStartEdit(items.length), [onStartEdit, items]); - const isEditable = editableItemIndex !== null && editableItemIndex !== undefined; - if (mode !== "readonly" && isEditable) { + const renderEditModal = () => { const item = typeof editableItemIndex === "number" ? items[editableItemIndex] : undefined; + return ( - - {children(item)} - {onCancelEdit || onDone ? ( - - {onCancelEdit && ( - - - - )} - {onDone && ( - - - - )} - - ) : null} - + } + size={editModalSize} + testId="arrayOfObjects-editModal" + > + {renderItemEditorForm(item)} + ); - } + }; return ( - - - {items.length ? ( - - {items.map((item, key) => ( - - ))} - - ) : null} - + <> +
+ + {items.length ? ( +
+ {items.map((item, index) => ( + + ))} +
+ ) : null} +
+ {mode !== "readonly" && isEditable && renderEditModal()} + ); }; diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx index b3cba02ac956..22baa371665f 100644 --- a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorHeader.tsx @@ -15,7 +15,7 @@ const Content = styled.div` font-weight: 500; font-size: 14px; line-height: 17px; - margin: 5px 0; + margin: 5px 0 10px; `; interface EditorHeaderProps { diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.module.scss b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.module.scss new file mode 100644 index 000000000000..290bbf626367 --- /dev/null +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.module.scss @@ -0,0 +1,32 @@ +@use "../../../scss/colors"; +@use "../../../scss/variables"; + +.container + .container { + border-top: 1px solid colors.$white; +} + +.body { + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: row; + color: colors.$dark-blue; + font-weight: 400; + font-size: 12px; + line-height: 17px; + padding: variables.$spacing-md 8px; + gap: variables.$spacing-md; +} + +.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: variables.$spacing-md; +} diff --git a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.tsx b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.tsx index 19c35b5f0a72..b7ff6561a4d4 100644 --- a/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.tsx +++ b/airbyte-webapp/src/components/ArrayOfObjectsEditor/components/EditorRow.tsx @@ -1,57 +1,50 @@ -import { faTimes } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import React from "react"; -import { FormattedMessage, useIntl } from "react-intl"; -import styled from "styled-components"; +import { useIntl } from "react-intl"; import { Button } from "components"; +import { CrossIcon } from "components/icons/CrossIcon"; +import { PencilIcon } from "components/icons/PencilIcon"; +import ToolTip from "components/ToolTip"; -const Content = styled.div` - display: flex; - justify-content: space-between; - align-items: center; - flex-direction: row; - color: ${({ theme }) => theme.textColor}; - font-weight: 500; - font-size: 14px; - line-height: 17px; - padding: 5px 12px 6px 14px; - border-bottom: 1px solid ${({ theme }) => theme.greyColor20}; - - &:last-child { - border: none; - } -`; - -const DeleteButton = styled(Button)` - margin-left: 7px; -`; +import styles from "./EditorRow.module.scss"; interface EditorRowProps { - name: string; + name?: React.ReactNode; + description?: React.ReactNode; id: number; onEdit: (id: number) => void; onRemove: (id: number) => void; disabled?: boolean; } -const EditorRow: React.FC = ({ name, id, onEdit, onRemove, disabled }) => { +export const EditorRow: React.FC = ({ name, id, description, onEdit, onRemove, disabled }) => { const { formatMessage } = useIntl(); - const buttonLabel = formatMessage({ id: "form.delete" }); - return ( - -
{name || id}
-
- + - onRemove(id)} disabled={disabled} aria-label={buttonLabel}> - -
-
+ ); -}; -export { EditorRow }; + return
{description ? {description} : body}
; +}; diff --git a/airbyte-webapp/src/components/Modal/Modal.tsx b/airbyte-webapp/src/components/Modal/Modal.tsx index 3fc3dfc33084..1ff2bc3dd87a 100644 --- a/airbyte-webapp/src/components/Modal/Modal.tsx +++ b/airbyte-webapp/src/components/Modal/Modal.tsx @@ -12,6 +12,7 @@ export interface ModalProps { clear?: boolean; closeOnBackground?: boolean; size?: "sm" | "md" | "lg" | "xl"; + testId?: string; } const cardStyleBySize = { @@ -21,7 +22,7 @@ const cardStyleBySize = { xl: styles.xl, }; -const Modal: React.FC = ({ children, title, onClose, clear, closeOnBackground, size }) => { +const Modal: React.FC = ({ children, title, onClose, clear, closeOnBackground, size, testId }) => { const handleUserKeyPress = useCallback((event: KeyboardEvent, closeModal: () => void) => { const { key } = event; // Escape key @@ -44,7 +45,11 @@ const Modal: React.FC = ({ children, title, onClose, clear, closeOnB }, [handleUserKeyPress, onClose]); return createPortal( -
(closeOnBackground && onClose ? onClose() : null)}> +
(closeOnBackground && onClose ? onClose() : null)} + data-testid={testId} + > {clear ? ( children ) : ( diff --git a/airbyte-webapp/src/components/Modal/index.tsx b/airbyte-webapp/src/components/Modal/index.tsx index 5edf89b7bdcc..3e1b7bb02d9c 100644 --- a/airbyte-webapp/src/components/Modal/index.tsx +++ b/airbyte-webapp/src/components/Modal/index.tsx @@ -1,7 +1,8 @@ import Modal from "./Modal"; +export * from "./Modal"; export * from "./ModalBody"; export * from "./ModalFooter"; -export default Modal; export { Modal }; +export default Modal; diff --git a/airbyte-webapp/src/components/ToolTip/ToolTip.tsx b/airbyte-webapp/src/components/ToolTip/ToolTip.tsx index 00ddafa62787..acdf09917066 100644 --- a/airbyte-webapp/src/components/ToolTip/ToolTip.tsx +++ b/airbyte-webapp/src/components/ToolTip/ToolTip.tsx @@ -9,9 +9,9 @@ interface ToolTipProps { } const Control = styled.div<{ $cursor?: "pointer" | "help" | "not-allowed"; $showCursor?: boolean }>` - display: inline-block; + display: inline; position: relative; - cursor: ${({ $cursor, $showCursor = true }) => ($showCursor && $cursor) ?? "pointer"}; + ${({ $cursor, $showCursor = true }) => ($showCursor && $cursor ? `cursor: ${$cursor}` : "")}; `; const ToolTipView = styled.div<{ $disabled?: boolean }>` diff --git a/airbyte-webapp/src/components/icons/CrossIcon.tsx b/airbyte-webapp/src/components/icons/CrossIcon.tsx new file mode 100644 index 000000000000..a3e52884b1cd --- /dev/null +++ b/airbyte-webapp/src/components/icons/CrossIcon.tsx @@ -0,0 +1,12 @@ +interface CrossIconProps { + color?: string; +} + +export const CrossIcon = ({ color = "currentColor" }: CrossIconProps) => ( + + + +); diff --git a/airbyte-webapp/src/components/icons/PencilIcon.tsx b/airbyte-webapp/src/components/icons/PencilIcon.tsx new file mode 100644 index 000000000000..587d08045120 --- /dev/null +++ b/airbyte-webapp/src/components/icons/PencilIcon.tsx @@ -0,0 +1,12 @@ +interface PencilIconProps { + color?: string; +} + +export const PencilIcon = ({ color = "currentColor" }: PencilIconProps) => ( + + + +); diff --git a/airbyte-webapp/src/config/links.ts b/airbyte-webapp/src/config/links.ts index 9d71d0b13787..33e6bdd2928a 100644 --- a/airbyte-webapp/src/config/links.ts +++ b/airbyte-webapp/src/config/links.ts @@ -5,6 +5,7 @@ const BASE_DOCS_LINK = "https://docs.airbyte.com"; export const links = { + dbtCommandsReference: "https://docs.getdbt.com/reference/dbt-commands", technicalSupport: `${BASE_DOCS_LINK}/troubleshooting/on-deploying`, termsLink: "https://airbyte.com/terms", privacyLink: "https://airbyte.com/privacy-policy", diff --git a/airbyte-webapp/src/core/form/types.ts b/airbyte-webapp/src/core/form/types.ts index 3df1d164cc7c..30b9baf6b46c 100644 --- a/airbyte-webapp/src/core/form/types.ts +++ b/airbyte-webapp/src/core/form/types.ts @@ -9,41 +9,37 @@ interface FormItem { order?: number; title?: string; description?: string; - airbyte_hidden?: boolean; } -export type FormBaseItem = { +export interface FormBaseItem extends FormItem, AirbyteJSONSchema { _type: "formItem"; type: JSONSchema7TypeName; isSecret?: boolean; multiline?: boolean; -} & FormItem & - AirbyteJSONSchema; + default?: JSONSchema7Type; +} -type FormGroupItem = { +export interface FormGroupItem extends FormItem { _type: "formGroup"; jsonSchema: AirbyteJSONSchema; properties: FormBlock[]; isLoading?: boolean; hasOauth?: boolean; - default?: JSONSchema7Type; examples?: JSONSchema7Type; -} & FormItem; +} -type FormConditionItem = { +export interface FormConditionItem extends FormItem { _type: "formCondition"; conditions: Record; -} & FormItem; +} -type FormObjectArrayItem = { +export interface FormObjectArrayItem extends FormItem { _type: "objectArray"; properties: FormBlock; -} & FormItem; - -type FormBlock = FormGroupItem | FormBaseItem | FormConditionItem | FormObjectArrayItem; +} -export type { FormBlock, FormConditionItem, FormGroupItem, FormObjectArrayItem }; +export type FormBlock = FormGroupItem | FormBaseItem | FormConditionItem | FormObjectArrayItem; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WidgetConfig = Record; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index 8ce44683a9cf..a544ac98307c 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -103,7 +103,7 @@ "form.sourceConnector": "Source connector", "form.destinationConnector": "Destination connector", "form.addItems": "Add", - "form.items": "Items", + "form.items": "{count, plural, =0 {No items} one {# item} other {# items}}", "form.pkSelected": "{count, plural, =0 { } one {{items}} other {# keys selected}}", "form.url.error": "field must be a valid URL", "form.setupGuide": "Setup Guide", diff --git a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx index 1567f2781b2d..cfbe9c0bc8ce 100644 --- a/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx +++ b/airbyte-webapp/src/views/Connection/ConnectionForm/components/TransformationField.tsx @@ -44,8 +44,8 @@ const TransformationField: React.FC = ({ onStartEdit?.(); }} mode={mode} - > - {(editableItem) => ( + editModalSize="xl" + renderItemEditorForm={(editableItem) => ( = ({ }} /> )} - + /> ); }; diff --git a/airbyte-webapp/src/views/Connection/TransformationForm/TransformationForm.tsx b/airbyte-webapp/src/views/Connection/TransformationForm/TransformationForm.tsx index 74c0f470d5bb..020ca103765c 100644 --- a/airbyte-webapp/src/views/Connection/TransformationForm/TransformationForm.tsx +++ b/airbyte-webapp/src/views/Connection/TransformationForm/TransformationForm.tsx @@ -6,9 +6,10 @@ import { FormattedMessage, useIntl } from "react-intl"; import styled from "styled-components"; import * as yup from "yup"; -import { Button, ControlLabels, DropDown, Input } from "components"; +import { Button, ControlLabels, DropDown, Input, ModalBody, ModalFooter } from "components"; import { FormChangeTracker } from "components/FormChangeTracker"; +import { useConfig } from "config"; import { OperationService } from "core/domain/connection"; import { OperationCreate, OperationRead } from "core/request/AirbyteClient"; import { useGetService } from "core/servicesProvider"; @@ -32,16 +33,6 @@ const Label = styled(ControlLabels)` margin-bottom: 20px; `; -const ButtonContainer = styled.div` - display: flex; - justify-content: flex-end; -`; - -const SmallButton = styled(Button)` - margin-left: 8px; - padding: 6px 8px 7px; -`; - interface TransformationProps { transformation: OperationCreate; onCancel: () => void; @@ -87,6 +78,7 @@ const TransformationForm: React.FC = ({ isNewTransformation, }) => { const { formatMessage } = useIntl(); + const config = useConfig(); const operationService = useGetService("OperationService"); const { clearFormChange } = useFormChangeTrackerService(); const formId = useUniqueFormId(); @@ -109,63 +101,65 @@ const TransformationForm: React.FC = ({ return ( <> - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); }; diff --git a/airbyte-webapp/src/views/Connector/ConnectorDocumentationLayout/DocumentationPanelContext.tsx b/airbyte-webapp/src/views/Connector/ConnectorDocumentationLayout/DocumentationPanelContext.tsx index 489b3c9af06d..bf84265e9ddb 100644 --- a/airbyte-webapp/src/views/Connector/ConnectorDocumentationLayout/DocumentationPanelContext.tsx +++ b/airbyte-webapp/src/views/Connector/ConnectorDocumentationLayout/DocumentationPanelContext.tsx @@ -1,7 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; -// @ts-expect-error Default value provided at implementation -const DocumentationPanelContext = createContext>(); +export type DocumentationPanelContext = ReturnType; export const useDocumentationPanelState = () => { const [documentationPanelOpen, setDocumentationPanelOpen] = useState(false); @@ -27,6 +26,9 @@ export const useDocumentationPanelState = () => { }; }; +// @ts-expect-error Default value provided at implementation +export const documentationPanelContext = createContext(); + export const useCloseDocumentationPanelEffect = () => { const { setDocumentationPanelOpen } = useDocumentationPanelState(); useEffect(() => { @@ -34,12 +36,12 @@ export const useCloseDocumentationPanelEffect = () => { }, [setDocumentationPanelOpen]); }; -export const useDocumentationPanelContext = () => useContext(DocumentationPanelContext); +export const useDocumentationPanelContext = () => useContext(documentationPanelContext); export const DocumentationPanelProvider: React.FC = ({ children }) => { return ( - + {children} - + ); }; diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.test.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.test.tsx index b9a985720390..c42b86b597f0 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.test.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.test.tsx @@ -8,20 +8,56 @@ import { render } from "utils/testutils"; import { ServiceForm } from "views/Connector/ServiceForm"; import { DestinationDefinitionSpecificationRead } from "../../../core/request/AirbyteClient"; -import { ConnectorDocumentationWrapper } from "../ConnectorDocumentationLayout"; +import { DocumentationPanelContext } from "../ConnectorDocumentationLayout/DocumentationPanelContext"; import { ServiceFormValues } from "./types"; // hack to fix tests. https://github.com/remarkjs/react-markdown/issues/635 -jest.mock( - "components/Markdown", - () => - function ReactMarkdown({ children }: React.PropsWithChildren) { - return <>{children}; - } -); +jest.mock("components/Markdown", () => ({ children }: React.PropsWithChildren) => <>{children}); + +jest.mock("../ConnectorDocumentationLayout/DocumentationPanelContext", () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const emptyFn = () => {}; + + const useDocumentationPanelContext: () => DocumentationPanelContext = () => ({ + documentationPanelOpen: false, + documentationUrl: "", + setDocumentationPanelOpen: emptyFn, + setDocumentationUrl: emptyFn, + }); + + return { + useDocumentationPanelContext, + }; +}); jest.setTimeout(10000); +const useAddPriceListItem = (container: HTMLElement) => { + const priceList = getByTestId(container, "connectionConfiguration.priceList"); + let index = 0; + + return async (name: string, price: string) => { + const addButton = getByTestId(priceList, "addItemButton"); + await waitFor(() => userEvent.click(addButton)); + + const arrayOfObjectsEditModal = getByTestId(document.body, "arrayOfObjects-editModal"); + const getPriceListInput = (index: number, key: string) => + arrayOfObjectsEditModal.querySelector(`input[name='__temp__connectionConfiguration_priceList${index}.${key}']`); + + // Type items into input + const nameInput = getPriceListInput(index, "name"); + userEvent.type(nameInput!, name); + + const priceInput = getPriceListInput(index, "price"); + userEvent.type(priceInput!, price); + + const doneButton = getByTestId(arrayOfObjectsEditModal, "done-button"); + await waitFor(() => userEvent.click(doneButton)); + + index++; + }; +}; + const schema: AirbyteJSONSchema = { type: "object", properties: { @@ -117,21 +153,19 @@ describe("Service Form", () => { beforeEach(async () => { const handleSubmit = jest.fn(); const renderResult = await render( - - - + ); container = renderResult.container; }); @@ -202,22 +236,20 @@ describe("Service Form", () => { let container: HTMLElement; beforeEach(async () => { const renderResult = await render( - - (result = values)} - selectedConnectorDefinitionSpecification={ - // @ts-expect-error Partial objects for testing - { - connectionSpecification: schema, - sourceDefinitionId: "test-service-type", - documentationUrl: "", - } as DestinationDefinitionSpecificationRead - } - availableServices={[]} - /> - + (result = values)} + selectedConnectorDefinitionSpecification={ + // @ts-expect-error Partial objects for testing + { + connectionSpecification: schema, + sourceDefinitionId: "test-service-type", + documentationUrl: "", + } as DestinationDefinitionSpecificationRead + } + availableServices={[]} + /> ); container = renderResult.container; }); @@ -231,8 +263,6 @@ describe("Service Form", () => { const apiKey = container.querySelector("input[name='connectionConfiguration.credentials.api_key']"); const emails = container.querySelector("input[name='connectionConfiguration.emails']"); const workTime = container.querySelector("div[name='connectionConfiguration.workTime']"); - const priceList = getByTestId(container, "connectionConfiguration.priceList"); - const addButton = getByTestId(priceList, "addItemButton"); userEvent.type(name!, "{selectall}{del}name"); userEvent.type(host!, "test-host"); @@ -243,13 +273,8 @@ describe("Service Form", () => { userEvent.type(emails!, "test@test.com{enter}"); userEvent.type(workTime!.querySelector("input")!, "day{enter}"); - await waitFor(() => userEvent.click(addButton)); - const listName = container.querySelector("input[name='connectionConfiguration.priceList.0.name']"); - const listPrice = container.querySelector("input[name='connectionConfiguration.priceList.0.price']"); - const done = getByTestId(container, "done-button"); - userEvent.type(listName!, "test-price-list-name"); - userEvent.type(listPrice!, "1"); - await waitFor(() => userEvent.click(done)); + const addPriceListItem = useAddPriceListItem(container); + await addPriceListItem("test-price-list-name", "1"); const submit = container.querySelector("button[type='submit']"); await waitFor(() => userEvent.click(submit!)); @@ -327,32 +352,18 @@ describe("Service Form", () => { }); test("should fill right values in array of objects field", async () => { - const priceList = container.querySelector("div[data-testid='connectionConfiguration.priceList']"); - let addButton = priceList?.querySelector("button[data-testid='addItemButton']"); - await waitFor(() => userEvent.click(addButton!)); - - const done = priceList!.querySelector("button[data-testid='done-button']"); - - const name1 = container.querySelector("input[name='connectionConfiguration.priceList.0.name']"); - const price1 = container.querySelector("input[name='connectionConfiguration.priceList.0.price']"); - userEvent.type(name1!, "test-1"); - userEvent.type(price1!, "1"); - await waitFor(() => userEvent.click(done!)); - addButton = priceList?.querySelector("button[data-testid='addItemButton']"); - await waitFor(() => userEvent.click(addButton!)); - - const name2 = container.querySelector("input[name='connectionConfiguration.priceList.1.name']"); - const price2 = container.querySelector("input[name='connectionConfiguration.priceList.1.price']"); - - userEvent.type(name2!, "test-2"); - userEvent.type(price2!, "2"); - await waitFor(() => userEvent.click(done!)); + const addPriceListItem = useAddPriceListItem(container); + await addPriceListItem("test-1", "1"); + await addPriceListItem("test-2", "2"); const submit = container.querySelector("button[type='submit']"); await waitFor(() => userEvent.click(submit!)); - // @ts-expect-error typed unknown, okay in test file - expect(result.connectionConfiguration.priceList).toEqual([ + const { connectionConfiguration } = result as { + connectionConfiguration: { priceList: Array<{ name: string; price: number }> }; + }; + + expect(connectionConfiguration.priceList).toEqual([ { name: "test-1", price: 1 }, { name: "test-2", price: 2 }, ]); diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.tsx index 4ec090470900..dc9c3edb39af 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/ServiceForm.tsx @@ -265,6 +265,7 @@ const ServiceForm: React.FC = (props) => { availableServices={props.availableServices} isEditMode={props.isEditMode} isLoadingSchema={props.isLoading} + validationSchema={validationSchema} > {!props.isEditMode && } diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.module.scss b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.module.scss new file mode 100644 index 000000000000..02f69203117e --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.module.scss @@ -0,0 +1,21 @@ +@use "../../../../../scss/colors"; +@use "../../../../../scss/variables"; + +.description { + tbody { + .name, + .value { + font-size: 11px; + font-weight: 500; + } + + .name { + padding-right: variables.$spacing-md; + color: rgba(colors.$white, 0.7); + } + + .value { + color: colors.$white; + } + } +} diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.tsx index 19a5f3b39ff3..54d903e5c33d 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/ArraySection.tsx @@ -1,14 +1,14 @@ import { FieldArray, useField } from "formik"; -import React from "react"; +import React, { useMemo, useState } from "react"; import { ArrayOfObjectsEditor } from "components"; import GroupControls from "components/GroupControls"; -import { FormObjectArrayItem } from "core/form/types"; +import { FormBlock, FormGroupItem, FormObjectArrayItem } from "core/form/types"; -import { useServiceForm } from "../../serviceFormContext"; +import styles from "./ArraySection.module.scss"; import { SectionContainer } from "./common"; -import { FormSection } from "./FormSection"; +import { VariableInputFieldForm } from "./VariableInputFieldForm"; interface ArraySectionProps { formField: FormObjectArrayItem; @@ -16,18 +16,64 @@ interface ArraySectionProps { disabled?: boolean; } -/** - * ArraySection is responsible for handling array of objects - * @param formField - * @param path - * @constructor - */ +const getItemName = (item: Record, properties: FormBlock[]): string => { + return Object.keys(item) + .sort() + .map((key) => { + const property = properties.find(({ fieldKey }) => fieldKey === key); + const name = property?.title ?? key; + return `${name}: ${item[key]}`; + }) + .join(" | "); +}; + +const getItemDescription = (item: Record, properties: FormBlock[]): React.ReactNode => { + const rows = Object.keys(item) + .sort() + .map((key) => { + const property = properties.find(({ fieldKey }) => fieldKey === key); + const name = property?.title ?? key; + const value = item[key]; + return ( + + {name}: + {value} + + ); + }); + + return ( +
+ + {rows} +
+
+ ); +}; + export const ArraySection: React.FC = ({ formField, path, disabled }) => { - const { addUnfinishedFlow, removeUnfinishedFlow, unfinishedFlows } = useServiceForm(); - const [field, , form] = useField(path); + const [field, , fieldHelper] = useField(path); + const [editIndex, setEditIndex] = useState(); + + const items = useMemo(() => field.value ?? [], [field.value]); + + const { renderItemName, renderItemDescription } = useMemo(() => { + const { properties } = formField.properties as FormGroupItem; - const items = field.value ?? []; - const flow = unfinishedFlows[path]; + const details = items.map((item: Record) => { + const name = getItemName(item, properties); + const description = getItemDescription(item, properties); + return { + name, + description, + }; + }); + + return { + renderItemName: (_: unknown, index: number) => details[index].name, + renderItemDescription: (_: unknown, index: number) => details[index].description, + }; + }, [items, formField.properties]); return ( = ({ formField, path, dis name={path} render={(arrayHelpers) => ( - addUnfinishedFlow(path, { - id: index, - startValue: index < items.length ? items : null, - }) - } - onDone={() => removeUnfinishedFlow(path)} - onCancelEdit={() => { - removeUnfinishedFlow(path); - - if (flow.startValue) { - form.setValue(flow.startValue); - } - }} + editableItemIndex={editIndex} + onStartEdit={setEditIndex} onRemove={arrayHelpers.remove} items={items} + renderItemName={renderItemName} + renderItemDescription={renderItemDescription} disabled={disabled} - > - {() => ( - + editModalSize="sm" + renderItemEditorForm={(item) => ( + { + const updatedValue = + editIndex !== undefined && editIndex < items.length + ? items.map((item: unknown, index: number) => (index === editIndex ? updatedItem : item)) + : [...items, updatedItem]; + + fieldHelper.setValue(updatedValue); + setEditIndex(undefined); + }} + onCancel={() => { + setEditIndex(undefined); + }} + /> )} - + /> )} /> diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/VariableInputFieldForm.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/VariableInputFieldForm.tsx new file mode 100644 index 000000000000..fc05419d7d71 --- /dev/null +++ b/airbyte-webapp/src/views/Connector/ServiceForm/components/Sections/VariableInputFieldForm.tsx @@ -0,0 +1,91 @@ +import { useField } from "formik"; +import { useMemo } from "react"; +import { FormattedMessage } from "react-intl"; +import { useAsync, useEffectOnce } from "react-use"; +import * as yup from "yup"; + +import { Button, ModalBody, ModalFooter } from "components"; + +import { FormGroupItem, FormObjectArrayItem } from "core/form/types"; + +import { useServiceForm } from "../../serviceFormContext"; +import { FormSection } from "./FormSection"; + +interface VariableInputFormProps { + formField: FormObjectArrayItem; + path: string; + item?: unknown; + disabled?: boolean; + onDone: (value: unknown) => void; + onCancel: () => void; +} + +export const VariableInputFieldForm: React.FC = ({ + formField, + path, + item, + disabled, + onDone, + onCancel, +}) => { + // This form creates a temporary field for Formik to prevent the field from rendering in + // the service form while it's being created or edited since it reuses the FormSection component. + // The temp field is cleared when this form is done or canceled. + const variableInputFieldPath = useMemo(() => `__temp__${path.replace(/\./g, "_").replace(/\[|\]/g, "")}`, [path]); + const [field, , fieldHelper] = useField(variableInputFieldPath); + const { validationSchema } = useServiceForm(); + + // Copy the validation from the original field to ensure that the form has all the required values field out correctly. + // One side effect of this is that validation errors will not be shown in this form because the validationSchema does not + // contain info about the temp field. + const { value: isValid } = useAsync( + async (): Promise => yup.reach(validationSchema, path).isValid(field.value), + [field.value, path, validationSchema] + ); + + useEffectOnce(() => { + const initialValue = + item ?? + // Set initial default values when user is creating a new item + (formField.properties as FormGroupItem).properties.reduce((acc, item) => { + if (item._type === "formItem" && item.default) { + // Only "formItem" types have a default value + acc[item.fieldKey] = item.default; + } + + return acc; + }, {} as Record); + + fieldHelper.setValue(initialValue); + }); + + return ( + <> + + + + + + + + + ); +}; diff --git a/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx b/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx index bd50e851d33e..854a77739af9 100644 --- a/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx +++ b/airbyte-webapp/src/views/Connector/ServiceForm/serviceFormContext.tsx @@ -1,5 +1,6 @@ import { getIn, useFormikContext } from "formik"; import React, { useContext, useMemo } from "react"; +import { AnySchema } from "yup"; import { Connector, ConnectorDefinition, ConnectorDefinitionSpecification } from "core/domain/connector"; import { WidgetConfigMap } from "core/form/types"; @@ -23,6 +24,7 @@ interface ServiceFormContext { isEditMode?: boolean; isAuthFlowSelected?: boolean; authFieldsToHide: string[]; + validationSchema: AnySchema; } const serviceFormContext = React.createContext(null); @@ -45,6 +47,7 @@ interface ServiceFormContextProviderProps { availableServices: ConnectorDefinition[]; getValues: (values: ServiceFormValues) => ServiceFormValues; selectedConnector?: ConnectorDefinitionSpecification; + validationSchema: AnySchema; } export const ServiceFormContextProvider: React.FC = ({ @@ -57,6 +60,7 @@ export const ServiceFormContextProvider: React.FC { const { values, resetForm } = useFormikContext(); @@ -97,6 +101,7 @@ export const ServiceFormContextProvider: React.FC @@ -124,6 +129,7 @@ export const ServiceFormContextProvider: React.FC