diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 37b70553..242893ec 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -203,6 +203,8 @@ type Flow { name: String active: Boolean steps: [Step] + createdAt: String + updatedAt: String } type Execution { diff --git a/packages/backend/src/models/base.ts b/packages/backend/src/models/base.ts index 1d982bd6..95c40746 100644 --- a/packages/backend/src/models/base.ts +++ b/packages/backend/src/models/base.ts @@ -31,9 +31,9 @@ class Base extends Model { } async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise { - await super.$beforeUpdate(opt, queryContext); - this.updatedAt = new Date().toISOString(); + + await super.$beforeUpdate(opt, queryContext); } } diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 727f2506..01733ded 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -44,7 +44,9 @@ class Flow extends Base { }, }); - async $beforeUpdate(opt: ModelOptions): Promise { + async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise { + await super.$beforeUpdate(opt, queryContext); + if (!this.active) return; const oldFlow = opt.old as Flow; diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index 57ec44e4..a231066e 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -73,6 +73,8 @@ class Step extends Base { }); get iconUrl() { + if (!this.appKey) return null; + return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`; } diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 67b12115..91130065 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -26,6 +26,7 @@ export interface IExecutionStep { dataOut: IJSONObject; status: string; createdAt: string; + updatedAt: string; } export interface IExecution { @@ -34,6 +35,7 @@ export interface IExecution { flow: IFlow; testRun: boolean; executionSteps: IExecutionStep[]; + updatedAt: string; createdAt: string; } @@ -43,6 +45,7 @@ export interface IStep { flowId: string; key: string; appKey: string; + iconUrl: string; type: 'action' | 'trigger'; connectionId: string; status: string; @@ -61,6 +64,8 @@ export interface IFlow { userId: string; active: boolean; steps: IStep[]; + createdAt: string; + updatedAt: string; } export interface IUser { diff --git a/packages/web/src/components/AppIcon/index.tsx b/packages/web/src/components/AppIcon/index.tsx index 3b940c90..2c114bca 100644 --- a/packages/web/src/components/AppIcon/index.tsx +++ b/packages/web/src/components/AppIcon/index.tsx @@ -6,6 +6,7 @@ type AppIconProps = { name?: string; url?: string; color?: string; + variant?: AvatarProps['variant']; }; const inlineImgStyle: React.CSSProperties = { @@ -13,16 +14,26 @@ const inlineImgStyle: React.CSSProperties = { }; export default function AppIcon(props: AppIconProps & AvatarProps): React.ReactElement { - const { name, url, color, sx = {}, ...restProps } = props; + const { + name, + url, + color, + sx = {}, + variant = "square", + ...restProps + } = props; + + const initialLetter = name?.[0]; return ( ); diff --git a/packages/web/src/components/FlowAppIcons/index.tsx b/packages/web/src/components/FlowAppIcons/index.tsx new file mode 100644 index 00000000..9932b735 --- /dev/null +++ b/packages/web/src/components/FlowAppIcons/index.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import type { IStep } from '@automatisch/types'; + +import AppIcon from 'components/AppIcon'; +import IntermediateStepCount from 'components/IntermediateStepCount'; + +type FlowAppIconsProps = { + steps: Partial[]; +} + +export default function FlowAppIcons(props: FlowAppIconsProps) { + const { steps } = props; + const stepsCount = steps.length; + const firstStep = steps[0]; + const lastStep = steps[stepsCount - 1]; + const intermeaditeStepCount = stepsCount - 2; + + + return ( + <> + + + {intermeaditeStepCount > 0 && } + + + + ) +}; diff --git a/packages/web/src/components/FlowContextMenu/index.tsx b/packages/web/src/components/FlowContextMenu/index.tsx new file mode 100644 index 00000000..95bea760 --- /dev/null +++ b/packages/web/src/components/FlowContextMenu/index.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { useMutation } from '@apollo/client'; +import { Link } from 'react-router-dom'; +import Menu from '@mui/material/Menu'; +import type { PopoverProps } from '@mui/material/Popover'; +import MenuItem from '@mui/material/MenuItem'; + +import { DELETE_FLOW } from 'graphql/mutations/delete-flow'; +import * as URLS from 'config/urls'; +import useFormatMessage from 'hooks/useFormatMessage'; + +type ContextMenuProps = { + flowId: string; + onClose: () => void; + anchorEl: PopoverProps['anchorEl']; +}; + +export default function ContextMenu(props: ContextMenuProps): React.ReactElement { + const { flowId, onClose, anchorEl } = props; + const [deleteFlow] = useMutation(DELETE_FLOW); + const formatMessage = useFormatMessage(); + + const onFlowDelete = React.useCallback(async () => { + await deleteFlow({ + variables: { input: { id: flowId } }, + update: (cache) => { + const flowCacheId = cache.identify({ + __typename: 'Flow', + id: flowId, + }); + + cache.evict({ + id: flowCacheId, + }); + } + }); + }, [flowId, deleteFlow]); + + return ( + + + {formatMessage('flow.view')} + + + + {formatMessage('flow.delete')} + + + ); +}; diff --git a/packages/web/src/components/FlowRow/index.tsx b/packages/web/src/components/FlowRow/index.tsx index b220cb90..a33bdc19 100644 --- a/packages/web/src/components/FlowRow/index.tsx +++ b/packages/web/src/components/FlowRow/index.tsx @@ -1,38 +1,90 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; import Card from '@mui/material/Card'; -import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; import CardActionArea from '@mui/material/CardActionArea'; -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import { DateTime } from 'luxon'; import type { IFlow } from '@automatisch/types'; +import FlowAppIcons from 'components/FlowAppIcons'; +import FlowContextMenu from 'components/FlowContextMenu'; +import useFormatMessage from 'hooks/useFormatMessage'; import * as URLS from 'config/urls'; -import { CardContent, Typography } from './style'; +import { Apps, CardContent, ContextMenu, Title, Typography } from './style'; type FlowRowProps = { flow: IFlow; } export default function FlowRow(props: FlowRowProps): React.ReactElement { + const formatMessage = useFormatMessage(); + const contextButtonRef = React.useRef(null); + const [anchorEl, setAnchorEl] = React.useState(null); const { flow } = props; - return ( - - - - - - - {flow.name} - - + const handleClose = () => { + setAnchorEl(null); + }; + const onContextMenuClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + setAnchorEl(contextButtonRef.current); + } - - theme.palette.primary.main }} /> - + const createdAt = DateTime.fromMillis(parseInt(flow.createdAt, 10)); + const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt, 10)); + const isUpdated = updatedAt > createdAt; + const relativeCreatedAt = createdAt.toRelative(); + const relativeUpdatedAt = updatedAt.toRelative(); + + return ( + <> + + + + + + + + + <Typography variant="h6" noWrap> + {flow?.name} + </Typography> + + <Typography variant="caption"> + {isUpdated && formatMessage('flow.updatedAt', { datetime: relativeUpdatedAt })} + {!isUpdated && formatMessage('flow.createdAt', { datetime: relativeCreatedAt })} + </Typography> + + + + + + + - + + {anchorEl && } + ); -} \ No newline at end of file +} diff --git a/packages/web/src/components/FlowRow/style.ts b/packages/web/src/components/FlowRow/style.ts index e6260438..e2a7f5be 100644 --- a/packages/web/src/components/FlowRow/style.ts +++ b/packages/web/src/components/FlowRow/style.ts @@ -1,20 +1,42 @@ import { styled } from '@mui/material/styles'; +import MuiStack from '@mui/material/Stack'; +import MuiBox from '@mui/material/Box'; import MuiCardContent from '@mui/material/CardContent'; import MuiTypography from '@mui/material/Typography'; export const CardContent = styled(MuiCardContent)(({ theme }) => ({ display: 'grid', gridTemplateRows: 'auto', - gridTemplateColumns: '1fr auto', - gridColumnGap: theme.spacing(2), + gridTemplateColumns: 'calc(50px * 3 + 8px * 2) minmax(0, auto) min-content', + gridGap: theme.spacing(2), + gridTemplateAreas: ` + "apps title menu" + `, alignItems: 'center', + [theme.breakpoints.down('sm')]: { + gridTemplateAreas: ` + "apps menu" + "title menu" + `, + gridTemplateColumns: 'minmax(0, auto) min-content', + gridTemplateRows: 'auto auto', + } })); +export const Apps = styled(MuiStack)(() => ({ + gridArea: 'apps', +})); +export const Title = styled(MuiStack)(() => ({ + gridArea: 'title', +})); +export const ContextMenu = styled(MuiBox)(() => ({ + gridArea: 'menu', +})); export const Typography = styled(MuiTypography)(() => ({ display: 'inline-block', width: '100%', - maxWidth: '70%', + maxWidth: '85%', })); export const DesktopOnlyBreakline = styled('br')(({ theme }) => ({ diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index 185c22b4..8b2c35cb 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -106,7 +106,7 @@ export default function FlowStep( ); const isTrigger = step.type === 'trigger'; const formatMessage = useFormatMessage(); - const [currentSubstep, setCurrentSubstep] = React.useState(2); + const [currentSubstep, setCurrentSubstep] = React.useState(0); const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }, }); diff --git a/packages/web/src/components/IntermediateStepCount/index.tsx b/packages/web/src/components/IntermediateStepCount/index.tsx new file mode 100644 index 00000000..554d2f78 --- /dev/null +++ b/packages/web/src/components/IntermediateStepCount/index.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import Typography from '@mui/material/Typography'; + +import { Container } from './style'; + +type IntermediateStepCountProps = { + count: number; +} + +export default function IntermediateStepCount(props: IntermediateStepCountProps) { + const { count } = props; + + return ( + + + +{count} + + + ); +} \ No newline at end of file diff --git a/packages/web/src/components/IntermediateStepCount/style.ts b/packages/web/src/components/IntermediateStepCount/style.ts new file mode 100644 index 00000000..95ac6760 --- /dev/null +++ b/packages/web/src/components/IntermediateStepCount/style.ts @@ -0,0 +1,12 @@ +import { styled } from '@mui/material/styles'; + +export const Container = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + minWidth: 50, + height: 50, + border: `1px solid ${theme.palette.text.disabled}`, + borderRadius: theme.shape.borderRadius, +})); diff --git a/packages/web/src/graphql/mutations/delete-flow.ts b/packages/web/src/graphql/mutations/delete-flow.ts new file mode 100644 index 00000000..f708a5b7 --- /dev/null +++ b/packages/web/src/graphql/mutations/delete-flow.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_FLOW = gql` + mutation DeleteFlow($input: DeleteFlowInput) { + deleteFlow(input: $input) + } +`; diff --git a/packages/web/src/graphql/queries/get-flows.ts b/packages/web/src/graphql/queries/get-flows.ts index c8d725be..56e18dc2 100644 --- a/packages/web/src/graphql/queries/get-flows.ts +++ b/packages/web/src/graphql/queries/get-flows.ts @@ -11,8 +11,13 @@ export const GET_FLOWS = gql` node { id name + createdAt + updatedAt + steps { + iconUrl + } } } } } -`; \ No newline at end of file +`; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 95f334ee..5e75c89f 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -39,6 +39,10 @@ "createFlow.creating": "Creating a flow...", "flow.active": "ON", "flow.inactive": "OFF", + "flow.createdAt": "created {datetime}", + "flow.updatedAt": "updated {datetime}", + "flow.view": "View", + "flow.delete": "Delete", "flowStep.triggerType": "Trigger", "flowStep.actionType": "Action", "flows.create": "Create flow",