Merge pull request #373 from automatisch/issue-367
Enhance flow row with used apps, date and context menu
This commit is contained in:
@@ -203,6 +203,8 @@ type Flow {
|
|||||||
name: String
|
name: String
|
||||||
active: Boolean
|
active: Boolean
|
||||||
steps: [Step]
|
steps: [Step]
|
||||||
|
createdAt: String
|
||||||
|
updatedAt: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type Execution {
|
type Execution {
|
||||||
|
@@ -31,9 +31,9 @@ class Base extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise<void> {
|
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise<void> {
|
||||||
await super.$beforeUpdate(opt, queryContext);
|
|
||||||
|
|
||||||
this.updatedAt = new Date().toISOString();
|
this.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await super.$beforeUpdate(opt, queryContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -44,7 +44,9 @@ class Flow extends Base {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
async $beforeUpdate(opt: ModelOptions): Promise<void> {
|
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext): Promise<void> {
|
||||||
|
await super.$beforeUpdate(opt, queryContext);
|
||||||
|
|
||||||
if (!this.active) return;
|
if (!this.active) return;
|
||||||
|
|
||||||
const oldFlow = opt.old as Flow;
|
const oldFlow = opt.old as Flow;
|
||||||
|
@@ -73,6 +73,8 @@ class Step extends Base {
|
|||||||
});
|
});
|
||||||
|
|
||||||
get iconUrl() {
|
get iconUrl() {
|
||||||
|
if (!this.appKey) return null;
|
||||||
|
|
||||||
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
|
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
5
packages/types/index.d.ts
vendored
5
packages/types/index.d.ts
vendored
@@ -26,6 +26,7 @@ export interface IExecutionStep {
|
|||||||
dataOut: IJSONObject;
|
dataOut: IJSONObject;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExecution {
|
export interface IExecution {
|
||||||
@@ -34,6 +35,7 @@ export interface IExecution {
|
|||||||
flow: IFlow;
|
flow: IFlow;
|
||||||
testRun: boolean;
|
testRun: boolean;
|
||||||
executionSteps: IExecutionStep[];
|
executionSteps: IExecutionStep[];
|
||||||
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ export interface IStep {
|
|||||||
flowId: string;
|
flowId: string;
|
||||||
key: string;
|
key: string;
|
||||||
appKey: string;
|
appKey: string;
|
||||||
|
iconUrl: string;
|
||||||
type: 'action' | 'trigger';
|
type: 'action' | 'trigger';
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -61,6 +64,8 @@ export interface IFlow {
|
|||||||
userId: string;
|
userId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
steps: IStep[];
|
steps: IStep[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUser {
|
export interface IUser {
|
||||||
|
@@ -6,6 +6,7 @@ type AppIconProps = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
variant?: AvatarProps['variant'];
|
||||||
};
|
};
|
||||||
|
|
||||||
const inlineImgStyle: React.CSSProperties = {
|
const inlineImgStyle: React.CSSProperties = {
|
||||||
@@ -13,16 +14,26 @@ const inlineImgStyle: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AppIcon(props: AppIconProps & AvatarProps): React.ReactElement {
|
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 (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
component="span"
|
component="span"
|
||||||
variant="square"
|
variant={variant}
|
||||||
sx={{ bgcolor: color, display: 'flex', width: 50, height: 50, ...sx }}
|
sx={{ bgcolor: color, display: 'flex', width: 50, height: 50, ...sx }}
|
||||||
imgProps={{ style: inlineImgStyle }}
|
imgProps={{ style: inlineImgStyle }}
|
||||||
src={url}
|
src={url}
|
||||||
alt={name}
|
alt={name}
|
||||||
|
children={initialLetter}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
37
packages/web/src/components/FlowAppIcons/index.tsx
Normal file
37
packages/web/src/components/FlowAppIcons/index.tsx
Normal file
@@ -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<IStep>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<AppIcon
|
||||||
|
name=" "
|
||||||
|
variant="rounded"
|
||||||
|
url={firstStep.iconUrl}
|
||||||
|
imgProps={{ width: 30, height: 30}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{intermeaditeStepCount > 0 && <IntermediateStepCount count={intermeaditeStepCount} />}
|
||||||
|
|
||||||
|
<AppIcon
|
||||||
|
name=" "
|
||||||
|
variant="rounded"
|
||||||
|
url={lastStep.iconUrl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
58
packages/web/src/components/FlowContextMenu/index.tsx
Normal file
58
packages/web/src/components/FlowContextMenu/index.tsx
Normal file
@@ -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 (
|
||||||
|
<Menu
|
||||||
|
open={true}
|
||||||
|
onClose={onClose}
|
||||||
|
hideBackdrop={false}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
component={Link}
|
||||||
|
to={URLS.FLOW(flowId)}
|
||||||
|
>
|
||||||
|
{formatMessage('flow.view')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={onFlowDelete}>
|
||||||
|
{formatMessage('flow.delete')}
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,38 +1,90 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Card from '@mui/material/Card';
|
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 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 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 * as URLS from 'config/urls';
|
||||||
import { CardContent, Typography } from './style';
|
import { Apps, CardContent, ContextMenu, Title, Typography } from './style';
|
||||||
|
|
||||||
type FlowRowProps = {
|
type FlowRowProps = {
|
||||||
flow: IFlow;
|
flow: IFlow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FlowRow(props: FlowRowProps): React.ReactElement {
|
export default function FlowRow(props: FlowRowProps): React.ReactElement {
|
||||||
|
const formatMessage = useFormatMessage();
|
||||||
|
const contextButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
|
||||||
const { flow } = props;
|
const { flow } = props;
|
||||||
|
|
||||||
return (
|
const handleClose = () => {
|
||||||
<Link to={URLS.FLOW(flow.id)}>
|
setAnchorEl(null);
|
||||||
<Card sx={{ mb: 1 }}>
|
};
|
||||||
<CardActionArea>
|
const onContextMenuClick = (event: React.MouseEvent) => {
|
||||||
<CardContent>
|
event.preventDefault();
|
||||||
<Box display="flex" flex={1}>
|
event.stopPropagation();
|
||||||
<Typography variant="h6" noWrap>
|
event.nativeEvent.stopImmediatePropagation();
|
||||||
{flow.name}
|
setAnchorEl(contextButtonRef.current);
|
||||||
</Typography>
|
}
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
const createdAt = DateTime.fromMillis(parseInt(flow.createdAt, 10));
|
||||||
<ArrowForwardIosIcon sx={{ color: (theme) => theme.palette.primary.main }} />
|
const updatedAt = DateTime.fromMillis(parseInt(flow.updatedAt, 10));
|
||||||
</Box>
|
const isUpdated = updatedAt > createdAt;
|
||||||
|
const relativeCreatedAt = createdAt.toRelative();
|
||||||
|
const relativeUpdatedAt = updatedAt.toRelative();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card sx={{ mb: 1 }}>
|
||||||
|
<CardActionArea component={Link} to={URLS.FLOW(flow.id)}>
|
||||||
|
<CardContent>
|
||||||
|
<Apps direction="row" gap={1} sx={{gridArea:"apps"}}>
|
||||||
|
<FlowAppIcons steps={flow.steps} />
|
||||||
|
</Apps>
|
||||||
|
|
||||||
|
<Title
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="flex-start"
|
||||||
|
spacing={1}
|
||||||
|
sx={{gridArea:"title"}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" noWrap>
|
||||||
|
{flow?.name}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption">
|
||||||
|
{isUpdated && formatMessage('flow.updatedAt', { datetime: relativeUpdatedAt })}
|
||||||
|
{!isUpdated && formatMessage('flow.createdAt', { datetime: relativeCreatedAt })}
|
||||||
|
</Typography>
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<ContextMenu>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open context menu"
|
||||||
|
ref={contextButtonRef}
|
||||||
|
onClick={onContextMenuClick}
|
||||||
|
>
|
||||||
|
<MoreHorizIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ContextMenu>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</CardActionArea>
|
</CardActionArea>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
|
{anchorEl && <FlowContextMenu
|
||||||
|
flowId={flow.id}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
/>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,20 +1,42 @@
|
|||||||
import { styled } from '@mui/material/styles';
|
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 MuiCardContent from '@mui/material/CardContent';
|
||||||
import MuiTypography from '@mui/material/Typography';
|
import MuiTypography from '@mui/material/Typography';
|
||||||
|
|
||||||
export const CardContent = styled(MuiCardContent)(({ theme }) => ({
|
export const CardContent = styled(MuiCardContent)(({ theme }) => ({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateRows: 'auto',
|
gridTemplateRows: 'auto',
|
||||||
gridTemplateColumns: '1fr auto',
|
gridTemplateColumns: 'calc(50px * 3 + 8px * 2) minmax(0, auto) min-content',
|
||||||
gridColumnGap: theme.spacing(2),
|
gridGap: theme.spacing(2),
|
||||||
|
gridTemplateAreas: `
|
||||||
|
"apps title menu"
|
||||||
|
`,
|
||||||
alignItems: 'center',
|
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)(() => ({
|
export const Typography = styled(MuiTypography)(() => ({
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '70%',
|
maxWidth: '85%',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const DesktopOnlyBreakline = styled('br')(({ theme }) => ({
|
export const DesktopOnlyBreakline = styled('br')(({ theme }) => ({
|
||||||
|
@@ -106,7 +106,7 @@ export default function FlowStep(
|
|||||||
);
|
);
|
||||||
const isTrigger = step.type === 'trigger';
|
const isTrigger = step.type === 'trigger';
|
||||||
const formatMessage = useFormatMessage();
|
const formatMessage = useFormatMessage();
|
||||||
const [currentSubstep, setCurrentSubstep] = React.useState<number | null>(2);
|
const [currentSubstep, setCurrentSubstep] = React.useState<number | null>(0);
|
||||||
const { data } = useQuery(GET_APPS, {
|
const { data } = useQuery(GET_APPS, {
|
||||||
variables: { onlyWithTriggers: isTrigger },
|
variables: { onlyWithTriggers: isTrigger },
|
||||||
});
|
});
|
||||||
|
20
packages/web/src/components/IntermediateStepCount/index.tsx
Normal file
20
packages/web/src/components/IntermediateStepCount/index.tsx
Normal file
@@ -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 (
|
||||||
|
<Container>
|
||||||
|
<Typography variant="subtitle1" sx={{ }}>
|
||||||
|
+{count}
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
12
packages/web/src/components/IntermediateStepCount/style.ts
Normal file
12
packages/web/src/components/IntermediateStepCount/style.ts
Normal file
@@ -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,
|
||||||
|
}));
|
7
packages/web/src/graphql/mutations/delete-flow.ts
Normal file
7
packages/web/src/graphql/mutations/delete-flow.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
|
export const DELETE_FLOW = gql`
|
||||||
|
mutation DeleteFlow($input: DeleteFlowInput) {
|
||||||
|
deleteFlow(input: $input)
|
||||||
|
}
|
||||||
|
`;
|
@@ -11,8 +11,13 @@ export const GET_FLOWS = gql`
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
steps {
|
||||||
|
iconUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@@ -39,6 +39,10 @@
|
|||||||
"createFlow.creating": "Creating a flow...",
|
"createFlow.creating": "Creating a flow...",
|
||||||
"flow.active": "ON",
|
"flow.active": "ON",
|
||||||
"flow.inactive": "OFF",
|
"flow.inactive": "OFF",
|
||||||
|
"flow.createdAt": "created {datetime}",
|
||||||
|
"flow.updatedAt": "updated {datetime}",
|
||||||
|
"flow.view": "View",
|
||||||
|
"flow.delete": "Delete",
|
||||||
"flowStep.triggerType": "Trigger",
|
"flowStep.triggerType": "Trigger",
|
||||||
"flowStep.actionType": "Action",
|
"flowStep.actionType": "Action",
|
||||||
"flows.create": "Create flow",
|
"flows.create": "Create flow",
|
||||||
|
Reference in New Issue
Block a user