From 98dd4631e863678115d9e2a6617855ef4c75bfac Mon Sep 17 00:00:00 2001 From: Cheikh Gueye Wane Date: Sat, 18 Nov 2023 08:43:04 +0000 Subject: [PATCH] feat(Pipelines): Add connection picker (#476) --- public/images/cog.svg | 4 + public/locales/en/messages.json | 1 + public/locales/fr/messages.json | 1 + .../pipelines/[pipelineCode]/runs/[runId].tsx | 9 +- .../RunPipelineDialog/ParameterField.tsx | 19 ++- .../RunPipelineDialog/RunPipelineDialog.tsx | 11 +- .../WorkspaceConnectionPicker.generated.tsx | 61 +++++++++ .../WorkspaceConnectionPicker.test.tsx | 98 ++++++++++++++ .../WorkspaceConnectionPicker.tsx | 127 ++++++++++++++++++ .../WorkspaceConnectionPicker.test.tsx.snap | 80 +++++++++++ .../WorkspaceConnectionPicker/index.tsx | 3 + src/workspaces/helpers/connections/custom.tsx | 1 + src/workspaces/helpers/pipelines.ts | 15 ++- 13 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 public/images/cog.svg create mode 100644 src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.generated.tsx create mode 100644 src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.test.tsx create mode 100644 src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.tsx create mode 100644 src/workspaces/features/WorkspaceConnectionPicker/__snapshots__/WorkspaceConnectionPicker.test.tsx.snap create mode 100644 src/workspaces/features/WorkspaceConnectionPicker/index.tsx diff --git a/public/images/cog.svg b/public/images/cog.svg new file mode 100644 index 00000000..10728321 --- /dev/null +++ b/public/images/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/locales/en/messages.json b/public/locales/en/messages.json index 90f35b69..66f155b4 100644 --- a/public/locales/en/messages.json +++ b/public/locales/en/messages.json @@ -298,6 +298,7 @@ "Select a workspace": "", "Select all": "", "Select columns": "", + "Select connection": "", "Select datasources": "", "Select files": "", "Select none": "", diff --git a/public/locales/fr/messages.json b/public/locales/fr/messages.json index 5e2ac874..2b19c2fe 100644 --- a/public/locales/fr/messages.json +++ b/public/locales/fr/messages.json @@ -300,6 +300,7 @@ "Select a workspace": "", "Select all": "", "Select columns": "", + "Select connection": "", "Select datasources": "", "Select files": "", "Select none": "", diff --git a/src/pages/workspaces/[workspaceSlug]/pipelines/[pipelineCode]/runs/[runId].tsx b/src/pages/workspaces/[workspaceSlug]/pipelines/[pipelineCode]/runs/[runId].tsx index a4eba0f6..35b44a0a 100644 --- a/src/pages/workspaces/[workspaceSlug]/pipelines/[pipelineCode]/runs/[runId].tsx +++ b/src/pages/workspaces/[workspaceSlug]/pipelines/[pipelineCode]/runs/[runId].tsx @@ -12,6 +12,7 @@ import { createGetServerSideProps } from "core/helpers/page"; import { formatDuration } from "core/helpers/time"; import { NextPageWithLayout } from "core/helpers/types"; import { + ConnectionType, PipelineParameter, PipelineRunStatus, PipelineRunTrigger, @@ -30,7 +31,10 @@ import { WorkspacePipelineRunPageQuery, WorkspacePipelineRunPageQueryVariables, } from "workspaces/graphql/queries.generated"; -import { getPipelineRunConfig } from "workspaces/helpers/pipelines"; +import { + getPipelineRunConfig, + isConnectionParameter, +} from "workspaces/helpers/pipelines"; import WorkspaceLayout from "workspaces/layouts/WorkspaceLayout"; import Button from "core/components/Button"; import { ArrowPathIcon } from "@heroicons/react/24/outline"; @@ -95,6 +99,9 @@ const WorkspacePipelineRunPage: NextPageWithLayout = (props: Props) => { ) { return entry.multiple ? entry.value.join(", ") : entry.value; } + if (isConnectionParameter(entry.type) && entry.value) { + return entry.value; + } return "-"; }; diff --git a/src/workspaces/features/RunPipelineDialog/ParameterField.tsx b/src/workspaces/features/RunPipelineDialog/ParameterField.tsx index e26fcc04..35f258bd 100644 --- a/src/workspaces/features/RunPipelineDialog/ParameterField.tsx +++ b/src/workspaces/features/RunPipelineDialog/ParameterField.tsx @@ -3,19 +3,22 @@ import Switch from "core/components/Switch/Switch"; import Input from "core/components/forms/Input/Input"; import Select from "core/components/forms/Select"; import Textarea from "core/components/forms/Textarea/Textarea"; -import { ensureArray } from "core/helpers/array"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; +import WorkspaceConnectionPicker from "../WorkspaceConnectionPicker/WorkspaceConnectionPicker"; +import { ConnectionType } from "graphql-types"; +import { isConnectionParameter } from "workspaces/helpers/pipelines"; type ParameterFieldProps = { parameter: any; value: any; onChange(value: any): void; + workspaceSlug?: string; }; const ParameterField = (props: ParameterFieldProps) => { const { t } = useTranslation(); - const { parameter, value, onChange } = props; + const { parameter, value, onChange, workspaceSlug } = props; const handleChange = useCallback( (value: any) => { @@ -39,6 +42,18 @@ const ParameterField = (props: ParameterFieldProps) => { /> ); } + + if (isConnectionParameter(parameter.type)) { + return ( + handleChange(option)} + withPortal + type={parameter.type} + /> + ); + } if (parameter.choices?.length) { const choices = parameter.type !== "str" diff --git a/src/workspaces/features/RunPipelineDialog/RunPipelineDialog.tsx b/src/workspaces/features/RunPipelineDialog/RunPipelineDialog.tsx index 1598709d..97da0f43 100644 --- a/src/workspaces/features/RunPipelineDialog/RunPipelineDialog.tsx +++ b/src/workspaces/features/RunPipelineDialog/RunPipelineDialog.tsx @@ -7,12 +7,13 @@ import Dialog from "core/components/Dialog"; import Field from "core/components/forms/Field"; import useCacheKey from "core/hooks/useCacheKey"; import useForm from "core/hooks/useForm"; -import { PipelineVersion } from "graphql-types"; +import { ConnectionType, PipelineVersion } from "graphql-types"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { convertParametersToPipelineInput, + isConnectionParameter, runPipeline, } from "workspaces/helpers/pipelines"; import PipelineVersionPicker from "../PipelineVersionPicker"; @@ -122,6 +123,13 @@ const RunPipelineDialog = (props: RunPipelineDialogProps) => { ) { errors[parameter.code] = t("This field is required"); } + if ( + isConnectionParameter(parameter.type) && + parameter.required && + !val + ) { + errors[parameter.code] = t("This field is required"); + } } return errors; }, @@ -260,6 +268,7 @@ const RunPipelineDialog = (props: RunPipelineDialogProps) => { onChange={(value: any) => { form.setFieldValue(param.code, value); }} + workspaceSlug={pipeline.workspace?.slug} /> ))} diff --git a/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.generated.tsx b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.generated.tsx new file mode 100644 index 00000000..c25bc4be --- /dev/null +++ b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.generated.tsx @@ -0,0 +1,61 @@ +import * as Types from '../../../graphql-types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type WorkspaceConnectionPickerQueryVariables = Types.Exact<{ + slug: Types.Scalars['String']['input']; +}>; + + +export type WorkspaceConnectionPickerQuery = { __typename?: 'Query', workspace?: { __typename?: 'Workspace', slug: string, connections: Array<{ __typename?: 'Connection', id: string, name: string, slug: string, type: Types.ConnectionType }> } | null }; + +export type WorkspaceConnectionPicker_WorkspaceFragment = { __typename?: 'Workspace', slug: string, connections: Array<{ __typename?: 'Connection', id: string, name: string, slug: string, type: Types.ConnectionType }> }; + +export const WorkspaceConnectionPicker_WorkspaceFragmentDoc = gql` + fragment WorkspaceConnectionPicker_workspace on Workspace { + slug + connections { + id + name + slug + type + } +} + `; +export const WorkspaceConnectionPickerDocument = gql` + query WorkspaceConnectionPicker($slug: String!) { + workspace(slug: $slug) { + slug + ...WorkspaceConnectionPicker_workspace + } +} + ${WorkspaceConnectionPicker_WorkspaceFragmentDoc}`; + +/** + * __useWorkspaceConnectionPickerQuery__ + * + * To run a query within a React component, call `useWorkspaceConnectionPickerQuery` and pass it any options that fit your needs. + * When your component renders, `useWorkspaceConnectionPickerQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useWorkspaceConnectionPickerQuery({ + * variables: { + * slug: // value for 'slug' + * }, + * }); + */ +export function useWorkspaceConnectionPickerQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(WorkspaceConnectionPickerDocument, options); + } +export function useWorkspaceConnectionPickerLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(WorkspaceConnectionPickerDocument, options); + } +export type WorkspaceConnectionPickerQueryHookResult = ReturnType; +export type WorkspaceConnectionPickerLazyQueryHookResult = ReturnType; +export type WorkspaceConnectionPickerQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.test.tsx b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.test.tsx new file mode 100644 index 00000000..f70dd42f --- /dev/null +++ b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.test.tsx @@ -0,0 +1,98 @@ +import WorkspaceConnectionPicker from "./WorkspaceConnectionPicker"; +import { queryByRole, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MockedProvider } from "@apollo/client/testing"; +import { faker } from "@faker-js/faker"; +import { useQuery } from "@apollo/client"; +import { ConnectionType } from "graphql-types"; + +jest.mock("@apollo/client", () => ({ + __esModule: true, + useQuery: jest.fn(), + gql: jest.fn(() => "GQL"), +})); + +const useQueryMock = useQuery as jest.Mock; + +const WORKSPACE = { + slug: faker.datatype.uuid(), + connections: [ + { + id: faker.datatype.uuid(), + name: "dhis2-dev", + slug: "dhis2-dev", + type: ConnectionType.Dhis2, + }, + { + id: faker.datatype.uuid(), + name: "dhis2-staging", + slug: "dhis2-staging", + type: ConnectionType.Dhis2, + }, + { + id: faker.datatype.uuid(), + name: "iaso-dev", + slug: "iaso-dev", + type: ConnectionType.Iaso, + }, + ], +}; + +describe("WorkspaceConnectionPicker", () => { + it("display all connections", async () => { + const user = userEvent.setup(); + + useQueryMock.mockReturnValue({ + loading: true, + data: { + workspace: WORKSPACE, + }, + }); + const onChange = jest.fn(); + + const { container } = render( + , + ); + + await user.click(await screen.findByTestId("combobox-button")); + const option = await screen.queryAllByRole("option"); + expect(option.length).toBe(WORKSPACE.connections.length); + + await user.click(await screen.findByText(WORKSPACE.connections[0].name)); + expect(onChange).toHaveBeenCalledWith(WORKSPACE.connections[0]); + expect(container).toMatchSnapshot(); + }); + + it("display only connections with a given type", async () => { + const user = userEvent.setup(); + + useQueryMock.mockReturnValue({ + loading: true, + data: { + workspace: WORKSPACE, + }, + }); + const onChange = jest.fn(); + + render( + , + ); + + await user.click(await screen.findByTestId("combobox-button")); + const option = await screen.queryAllByRole("option"); + const dhis2Connections = WORKSPACE.connections.filter( + (c) => c.type === ConnectionType.Dhis2, + ); + + expect(option.length).toBe(dhis2Connections.length); + }); +}); diff --git a/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.tsx b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.tsx new file mode 100644 index 00000000..0473a4e8 --- /dev/null +++ b/src/workspaces/features/WorkspaceConnectionPicker/WorkspaceConnectionPicker.tsx @@ -0,0 +1,127 @@ +import { gql, useQuery } from "@apollo/client"; +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import useDebounce from "core/hooks/useDebounce"; +import { Combobox } from "core/components/forms/Combobox"; +import { ConnectionType } from "graphql-types"; +import Connections from "workspaces/helpers/connections"; +import { WorkspaceConnectionPickerQuery } from "./WorkspaceConnectionPicker.generated"; + +type Option = { + id: string; + slug: string; + name: string; + type: ConnectionType; +}; + +type WorkspaceConnectionPickerProps = { + value: Option | Option[]; + workspaceSlug: string; + placeholder?: string; + onChange(value?: Option): void; + required?: boolean; + disabled?: boolean; + withPortal?: boolean; + type?: ConnectionType; +}; + +const WorkspaceConnectionPicker = (props: WorkspaceConnectionPickerProps) => { + const { t } = useTranslation(); + const { + workspaceSlug, + value, + disabled = false, + required = false, + withPortal = false, + onChange, + placeholder = t("Select connection"), + type, + } = props; + + const { data, loading } = useQuery( + gql` + query WorkspaceConnectionPicker($slug: String!) { + workspace(slug: $slug) { + slug + ...WorkspaceConnectionPicker_workspace + } + } + ${WorkspaceConnectionPicker.fragments.workspace} + `, + { variables: { slug: workspaceSlug } }, + ); + + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 150); + + const options = useMemo(() => { + const lowercaseQuery = debouncedQuery.toLowerCase(); + return ( + data?.workspace?.connections?.filter((c) => { + if (type) { + return ( + c.name.toLowerCase().includes(lowercaseQuery) && + c.type.toLowerCase() === type.toLowerCase() + ); + } + return c.name.toLowerCase().includes(lowercaseQuery); + }) ?? [] + ); + }, [data, debouncedQuery, type]); + + const displayValue = useCallback( + (option: Option) => (option ? option.name : ""), + [], + ); + + return ( + a.id === b.id} + onInputChange={useCallback( + (event: any) => setQuery(event.target.value), + [], + )} + placeholder={placeholder} + value={value as any} + onClose={useCallback(() => setQuery(""), [])} + disabled={disabled} + > + {options.map((option: Option) => ( + +
+ Connection logo + {option.name} +
+
+ ))} +
+ ); +}; + +WorkspaceConnectionPicker.fragments = { + workspace: gql` + fragment WorkspaceConnectionPicker_workspace on Workspace { + slug + connections { + id + name + slug + type + } + } + `, +}; + +export default WorkspaceConnectionPicker; diff --git a/src/workspaces/features/WorkspaceConnectionPicker/__snapshots__/WorkspaceConnectionPicker.test.tsx.snap b/src/workspaces/features/WorkspaceConnectionPicker/__snapshots__/WorkspaceConnectionPicker.test.tsx.snap new file mode 100644 index 00000000..4f31df16 --- /dev/null +++ b/src/workspaces/features/WorkspaceConnectionPicker/__snapshots__/WorkspaceConnectionPicker.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WorkspaceConnectionPicker display all connections 1`] = ` +
+
+
+
+ +
+ +
+
+
+`; diff --git a/src/workspaces/features/WorkspaceConnectionPicker/index.tsx b/src/workspaces/features/WorkspaceConnectionPicker/index.tsx new file mode 100644 index 00000000..ee911b7f --- /dev/null +++ b/src/workspaces/features/WorkspaceConnectionPicker/index.tsx @@ -0,0 +1,3 @@ +import WorkspaceConnectionPage from "pages/workspaces/[workspaceSlug]/connections/[connectionId]"; + +export default WorkspaceConnectionPage; diff --git a/src/workspaces/helpers/connections/custom.tsx b/src/workspaces/helpers/connections/custom.tsx index ec5da5d8..7801949c 100644 --- a/src/workspaces/helpers/connections/custom.tsx +++ b/src/workspaces/helpers/connections/custom.tsx @@ -109,6 +109,7 @@ function CustomForm(props: { form: FormInstance }) { export default { label: "Custom", color: "bg-gray-200", + iconSrc: "/images/cog.svg", Form: CustomForm, fields: [], validate(fields: { [key: string]: any }) { diff --git a/src/workspaces/helpers/pipelines.ts b/src/workspaces/helpers/pipelines.ts index 8e3f4a55..238f56e9 100644 --- a/src/workspaces/helpers/pipelines.ts +++ b/src/workspaces/helpers/pipelines.ts @@ -8,7 +8,12 @@ import { UpdateWorkspacePipelineMutation, UpdateWorkspacePipelineMutationVariables, } from "./pipelines.generated"; -import { PipelineParameter, PipelineVersion } from "graphql-types"; +import { + ConnectionType, + PipelineParameter, + PipelineVersion, +} from "graphql-types"; +import Connections from "./connections"; export async function updatePipeline(pipelineId: string, values: any) { const client = getApolloClient(); @@ -57,6 +62,12 @@ export function validateCronExpression(cronExpression: string) { } } +export const isConnectionParameter = (type: string) => { + return Object.values(ConnectionType) + .map((c) => c.toLowerCase()) + .includes(type.toLowerCase()); +}; + export const convertParametersToPipelineInput = ( version: PipelineVersion, fields: { [key: string]: any }, @@ -83,6 +94,8 @@ export const convertParametersToPipelineInput = ( } } else if (parameter.type === "str" && parameter.multiple && val) { params[parameter.code] = val.filter((s: string) => s !== ""); + } else if (isConnectionParameter(parameter.type) && val) { + params[parameter.code] = val.slug; } else { params[parameter.code] = val; }