diff --git a/src/commons/graphql/client.tsx b/src/commons/graphql/client.tsx index 4707adb..2161ba0 100644 --- a/src/commons/graphql/client.tsx +++ b/src/commons/graphql/client.tsx @@ -29,9 +29,12 @@ const cleanTypeName = new ApolloLink((operation, forward) => { if (operation.variables) { operation.variables = deepOmit(["__typename"], operation.variables); } - return forward(operation).map((data) => { - return data; - }); + const rt = forward(operation); + return ( + rt.map?.((data) => { + return data; + }) ?? rt + ); }); export const FennecApolloClientProvider: FC = ({ children }) => { diff --git a/src/generated/graphql.schema.json b/src/generated/graphql.schema.json index 0848f6d..3db904b 100644 --- a/src/generated/graphql.schema.json +++ b/src/generated/graphql.schema.json @@ -718,7 +718,7 @@ "description": null, "args": [ { - "name": "Pipeline", + "name": "pipeline", "description": null, "type": { "kind": "NON_NULL", @@ -2249,7 +2249,7 @@ "name": null, "ofType": { "kind": "SCALAR", - "name": "Float", + "name": "Int", "ofType": null } }, @@ -2286,6 +2286,16 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "WorkUnitMetadataInput", @@ -2297,7 +2307,7 @@ "description": null, "type": { "kind": "SCALAR", - "name": "Float", + "name": "Int", "ofType": null }, "defaultValue": "1", diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 2bb15fd..ce2e159 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -99,7 +99,7 @@ export type MutationCreatePipelineArgs = { export type MutationUpdatePipelineArgs = { - Pipeline: UpdatePipelineInput; + pipeline: UpdatePipelineInput; }; @@ -284,11 +284,11 @@ export type WorkUnitInput = { export type WorkUnitMetadata = { __typename?: 'WorkUnitMetadata'; - version: Scalars['Float']; + version: Scalars['Int']; units: Array; }; export type WorkUnitMetadataInput = { - version?: Maybe; + version?: Maybe; units: Array; }; diff --git a/src/pipeline-tasks/pipeline-task-detail.tsx b/src/pipeline-tasks/pipeline-task-detail.tsx index c8b261e..f76d516 100644 --- a/src/pipeline-tasks/pipeline-task-detail.tsx +++ b/src/pipeline-tasks/pipeline-task-detail.tsx @@ -1,7 +1,7 @@ import { gql, useQuery, useSubscription } from "@apollo/client"; import { LinearProgress, makeStyles, Typography } from "@material-ui/core"; import { format } from "date-fns"; -import React, { FC, useState } from "react"; +import { FC, useState } from "react"; import { ErrorPage } from "../commons/fallbacks/error-page"; import { PipelineTask, @@ -11,7 +11,6 @@ import { } from "../generated/graphql"; import { PIPELINE_TASK_EVENT } from "./subscriptions"; import { clone, find, propEq } from "ramda"; -import { PipelineUnits } from '../generated/graphql'; interface Props { taskId: string; diff --git a/src/pipelines/mutations.ts b/src/pipelines/mutations.ts new file mode 100644 index 0000000..bc3795b --- /dev/null +++ b/src/pipelines/mutations.ts @@ -0,0 +1,43 @@ +import { gql } from "@apollo/client"; + +export const CREATE_PIPELINE = gql` + mutation CreatePipeline($pipeline: CreatePipelineInput!) { + createPipeline(pipeline: $pipeline) { + id + projectId + branch + name + workUnitMetadata { + version + units { + type + scripts + } + } + } + } +`; + +export const UPDATE_PIPELINE = gql` + mutation UpdatePipeline($pipeline: UpdatePipelineInput!) { + updatePipeline(pipeline: $pipeline) { + id + projectId + branch + name + workUnitMetadata { + version + units { + type + scripts + } + } + } + } +`; + +export const DELETE_PIPELINE = gql` + mutation DeletePipeline($id: String!) { + deletePipeline(id: $id) + } +`; diff --git a/src/pipelines/pipeline-editor.tsx b/src/pipelines/pipeline-editor.tsx new file mode 100644 index 0000000..f7911c5 --- /dev/null +++ b/src/pipelines/pipeline-editor.tsx @@ -0,0 +1,285 @@ +import { gql, Reference, useMutation, useQuery } from "@apollo/client"; +import { useRouter } from "@curi/react-dom"; +import { + Button, + Grid, + IconButton, + LinearProgress, + makeStyles, + Paper, + Portal, + Typography, +} from "@material-ui/core"; +import { Delete } from "@material-ui/icons"; +import { FormikHelpers, Formik, Form, Field } from "formik"; +import { TextField, TextFieldProps } from "formik-material-ui"; +import { TextField as MuiTextField } from "@material-ui/core"; +import { useConfirm } from "material-ui-confirm"; +import { useSnackbar } from "notistack"; +import { not, omit } from "ramda"; +import { ChangeEvent, FC } from "react"; +import { + Pipeline, + WorkUnitMetadata, + PipelineUnits, +} from "../generated/graphql"; +import { useHeaderContainer } from "../layouts"; +import { CREATE_PIPELINE, DELETE_PIPELINE, UPDATE_PIPELINE } from "./mutations"; +import { PIPELINE } from "./queries"; +import * as Yup from "yup"; +import { useField } from "formik"; + +type Values = Partial; + +interface Props { + pipeline: Values; +} + +const useStyles = makeStyles((theme) => ({ + root: { + flex: "1 1 100%", + }, + nested: { + paddingLeft: theme.spacing(4), + }, + form: { + margin: 16, + }, + metadataList: { + padding: 0, + }, + metadataItem: { + listStyle: "none", + }, + metadataContainer: { + padding: "10px 30px", + margin: "10px 0", + }, +})); + +export const PipelineEditor: FC = ({ pipeline }) => { + const { enqueueSnackbar } = useSnackbar(); + + const isCreate = not("id" in pipeline); + + const [createPipeline] = useMutation<{ createPipeline: Pipeline }>( + CREATE_PIPELINE, + { + update(cache, { data }) { + cache.modify({ + fields: { + pipelines(exiting = []) { + const pipelineRef = cache.writeFragment({ + data: data!.createPipeline, + fragment: gql` + fragment newPipeline on Pipeline { + id + projectId + branch + name + workUnitMetadata { + version + units { + type + scripts + } + } + } + `, + }); + return [pipelineRef, ...exiting]; + }, + }, + }); + }, + } + ); + const [updatePipeline] = useMutation(UPDATE_PIPELINE); + + const router = useRouter(); + + const submitForm = async ( + values: Values, + formikHelpers: FormikHelpers + ) => { + try { + let pipelineId = pipeline.id; + let projectId = pipeline.projectId; + if (isCreate) { + await createPipeline({ + variables: { + input: values, + }, + }).then(({ data }) => { + pipelineId = data!.createPipeline.id; + projectId = data!.createPipeline.projectId; + }); + } else { + await updatePipeline({ + variables: { + pipeline: omit(["projectId"], values), + }, + }); + } + enqueueSnackbar("Saved successfully", { + variant: "success", + }); + router.navigate({ + url: router.url({ + name: "pipeline-commits", + params: { + pipelineId, + projectId, + }, + }), + method: "replace", + }); + } finally { + formikHelpers.setSubmitting(false); + } + }; + + const [deletePipeline, { loading: deleting }] = useMutation(DELETE_PIPELINE, { + variables: { id: pipeline.id }, + update(cache) { + cache.modify({ + fields: { + projects(exiting: Reference[] = [], { readField }) { + return exiting.filter( + (ref) => pipeline.id !== readField("id", ref) + ); + }, + }, + }); + }, + }); + + const confirm = useConfirm(); + const handleDelete = async () => { + try { + await confirm({ description: `This will delete ${pipeline.name}.` }); + await deletePipeline(); + enqueueSnackbar("Deleted successfully", { + variant: "success", + }); + router.navigate({ + url: router.url({ + name: "dashboard", + }), + }); + } catch {} + }; + + const headerContainer = useHeaderContainer(); + + const units = [ + PipelineUnits.Checkout, + PipelineUnits.InstallDependencies, + PipelineUnits.Test, + PipelineUnits.Deploy, + PipelineUnits.CleanUp, + ]; + const classes = useStyles(); + return ( + + + + + {isCreate ? "Create" : "Edit"} Pipeline + + {isCreate ? null : ( + + + + )} + + + + {({ submitForm, isSubmitting, values }) => { + return ( +
+ + + + +
    + {units.map((unit, index) => { + return ( +
  1. + +
  2. + ); + })} +
+
+ {isSubmitting && } + + + ); + }} +
+
+ ); +}; + +const ScriptsField: FC = ({ field, form, meta, ...props }) => { + return ( + ) => + form.setFieldValue( + field.name, + ev.target.value?.split("\n").map((it) => it.trim()) ?? [] + ), + }} + /> + ); +}; diff --git a/src/pipelines/pipeline-list.tsx b/src/pipelines/pipeline-list.tsx index 9b4cae1..630cee8 100644 --- a/src/pipelines/pipeline-list.tsx +++ b/src/pipelines/pipeline-list.tsx @@ -1,9 +1,18 @@ -import { gql, useQuery } from '@apollo/client'; -import { Link } from '@curi/react-dom'; -import { List, ListItem, Typography, ListItemText } from '@material-ui/core'; -import { FC } from 'react'; -import { Pipeline } from '../generated/graphql'; -import { CallMerge } from '@material-ui/icons'; +import { gql, useQuery } from "@apollo/client"; +import { Link, useRouter } from "@curi/react-dom"; +import { + List, + ListItem, + Typography, + ListItemText, + ListItemSecondaryAction, + IconButton, +} from "@material-ui/core"; +import { FC, MouseEventHandler, useMemo } from "react"; +import { Pipeline, Project } from "../generated/graphql"; +import { CallMerge, Edit } from "@material-ui/icons"; +import { clone } from "ramda"; +import { useEffect } from "react"; interface Props { projectId: string; @@ -16,16 +25,28 @@ const PIPELINES = gql` name branch } + project(id: $projectId) { + id + } } -` +`; export const PipelineList: FC = ({ projectId }) => { - const {data, loading} = useQuery<{pipelines: Pipeline[]}, {projectId: string}>(PIPELINES, { - variables: {projectId} - }) + const { data, loading } = useQuery< + { pipelines: Pipeline[]; project: Project }, + { projectId: string } + >(PIPELINES, { + variables: { projectId }, + }); + const pipelines = useMemo(() => { + return data?.pipelines?.map((pipeline) => ({ + ...pipeline, + project: data?.project, + })); + }, [data]); return ( - {data?.pipelines.map((pipeline) => ( + {pipelines?.map((pipeline) => ( = ({ projectId }) => { ))} ); -} - -const Item = ({pipeline}: {pipeline: Pipeline}) => { +}; +const Item = ({ pipeline }: { pipeline: Pipeline }) => { + const { navigate, url } = useRouter(); + const modify: MouseEventHandler = (ev) => { + ev.preventDefault(); + navigate({ + url: url({ + name: "edit-pipeline", + params: { pipelineId: pipeline.id, projectId: pipeline.project.id }, + }), + }); + }; return ( { } /> + + + + + ); -} \ No newline at end of file +}; diff --git a/src/pipelines/queries.ts b/src/pipelines/queries.ts index 31ddcd1..e6116a5 100644 --- a/src/pipelines/queries.ts +++ b/src/pipelines/queries.ts @@ -5,6 +5,7 @@ export const PIPELINE = gql` pipeline(id: $id) { id name + projectId branch workUnitMetadata { version @@ -15,4 +16,4 @@ export const PIPELINE = gql` } } } -` \ No newline at end of file +`; \ No newline at end of file diff --git a/src/projects/project-detail.tsx b/src/projects/project-detail.tsx index 785dd07..81320de 100644 --- a/src/projects/project-detail.tsx +++ b/src/projects/project-detail.tsx @@ -60,12 +60,17 @@ export const ProjectDetail: FC = ({ project, children }) => { alignItems="stretch" className={classes.root} > - + - + {children} diff --git a/src/routes.tsx b/src/routes.tsx index 0de3eeb..2a90456 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,12 +1,19 @@ import { ApolloClient, InMemoryCache } from "@apollo/client"; import { prepareRoutes } from "@curi/router"; import { omit } from 'ramda'; -import React from 'react'; -import { CreateProjectInput, Pipeline, Project } from './generated/graphql'; +import React from "react"; import { ProjectDetail, ProjectEditor, PROJECT } from "./projects"; import { COMMIT_LIST_QUERY } from './commons/graphql/queries'; import { CommitList } from './commits/commit-list'; import { PipelineTaskDetail } from './pipeline-tasks/pipeline-task-detail'; +import { PipelineEditor } from "./pipelines/pipeline-editor"; +import { + CreatePipelineInput, + CreateProjectInput, + Pipeline, + Project, +} from "./generated/graphql"; +import { PIPELINE } from "./pipelines"; export default prepareRoutes([ { @@ -51,6 +58,49 @@ export default prepareRoutes([ return resolved; }, }, // edit-project + { + name: "create-pipeline", + path: "projects/:projectId/pipelines/create", + async resolve( + matched, + { client }: { client: ApolloClient } + ) { + const input: CreatePipelineInput = { + name: "", + branch: "", + projectId: matched!.params.projectId, + workUnitMetadata: { + version: 1, + units: [], + }, + }; + return { + body: () => , + }; + }, + respond({ resolved }) { + return resolved; + }, + }, // create-pipeline + { + name: "edit-pipeline", + path: "projects/:projectId/pipelines/:pipelineId/edit", + async resolve( + matched, + { client }: { client: ApolloClient } + ) { + const { data } = await client.query<{ pipeline: Pipeline }>({ + query: PIPELINE, + variables: { id: matched?.params.pipelineId }, + }); + return { + body: () => , + }; + }, + respond({ resolved }) { + return resolved; + }, + }, // edit-pipeline { name: "project-detail", path: "projects/:projectId", @@ -100,7 +150,7 @@ export default prepareRoutes([ respond({ resolved, error }) { return resolved ||
Failed
; }, - }, + }, // pipeline-commits { name: "pipeline-task-detail", path: "pipelines/:pipelineId/tasks/:taskId", @@ -129,7 +179,7 @@ export default prepareRoutes([ respond({ resolved, error }) { return resolved ||
Failed
; }, - }, + }, // pipeline-task-detail ], }, // project-detail ]);