diff --git a/packages/backend/src/graphql/queries/get-execution-steps.ts b/packages/backend/src/graphql/queries/get-execution-steps.ts index 7c419627..33fdd3f5 100644 --- a/packages/backend/src/graphql/queries/get-execution-steps.ts +++ b/packages/backend/src/graphql/queries/get-execution-steps.ts @@ -19,7 +19,8 @@ const getExecutionSteps = async ( const executionSteps = execution .$relatedQuery('executionSteps') - .orderBy('created_at', 'desc'); + .withGraphFetched('step') + .orderBy('created_at', 'asc'); return paginate(executionSteps, params.limit, params.offset); }; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 34c04984..a502fd83 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -163,9 +163,12 @@ type ExecutionStep { id: String executionId: String stepId: String + step: Step status: String dataIn: JSONObject dataOut: JSONObject + createdAt: String + updatedAt: String } type Field { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index ba814e5b..1e26b71d 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -20,10 +20,12 @@ export interface IConnection { export interface IExecutionStep { id: string; executionId: string; - stepId: string; + stepId: IStep["id"]; + step: IStep; dataIn: IJSONObject; dataOut: IJSONObject; status: string; + createdAt: string; } export interface IExecution { diff --git a/packages/web/package.json b/packages/web/package.json index 7e19adde..4d5ed509 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "description": "> TODO: description", "dependencies": { + "@alenaksu/json-viewer": "^1.0.0", "@apollo/client": "^3.4.15", "@automatisch/types": "0.1.0", "@emotion/react": "^11.4.1", diff --git a/packages/web/src/components/AppConnectionRow/index.tsx b/packages/web/src/components/AppConnectionRow/index.tsx index 412b4d98..d7d8d08c 100644 --- a/packages/web/src/components/AppConnectionRow/index.tsx +++ b/packages/web/src/components/AppConnectionRow/index.tsx @@ -11,15 +11,15 @@ import ErrorIcon from '@mui/icons-material/Error'; import { useSnackbar } from 'notistack'; import { DateTime } from 'luxon'; +import type { IConnection } from '@automatisch/types'; import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection'; import { TEST_CONNECTION } from 'graphql/queries/test-connection'; import ConnectionContextMenu from 'components/AppConnectionContextMenu'; import useFormatMessage from 'hooks/useFormatMessage'; -import type { Connection } from 'types/connection'; import { CardContent, Typography } from './style'; type AppConnectionRowProps = { - connection: Connection; + connection: IConnection; } const countTranslation = (value: React.ReactNode) => ( diff --git a/packages/web/src/components/AppConnections/index.tsx b/packages/web/src/components/AppConnections/index.tsx index 42fc945f..82e3ffa8 100644 --- a/packages/web/src/components/AppConnections/index.tsx +++ b/packages/web/src/components/AppConnections/index.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { useQuery } from '@apollo/client'; +import type { IConnection } from '@automatisch/types'; import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections'; import AppConnectionRow from 'components/AppConnectionRow'; import NoResultFound from 'components/NoResultFound'; import useFormatMessage from 'hooks/useFormatMessage'; -import type { Connection } from 'types/connection'; import * as URLS from 'config/urls'; type AppConnectionsProps = { @@ -16,7 +16,7 @@ export default function AppConnections(props: AppConnectionsProps): React.ReactE const { appKey } = props; const formatMessage = useFormatMessage(); const { data } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey } }); - const appConnections: Connection[] = data?.getApp?.connections || []; + const appConnections: IConnection[] = data?.getApp?.connections || []; const hasConnections = appConnections?.length; @@ -31,7 +31,7 @@ export default function AppConnections(props: AppConnectionsProps): React.ReactE return ( <> - {appConnections.map((appConnection: Connection) => ( + {appConnections.map((appConnection: IConnection) => ( ))} > diff --git a/packages/web/src/components/ExecutionRow/index.tsx b/packages/web/src/components/ExecutionRow/index.tsx index 004ef786..01db1847 100644 --- a/packages/web/src/components/ExecutionRow/index.tsx +++ b/packages/web/src/components/ExecutionRow/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; import Card from '@mui/material/Card'; import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; import CardActionArea from '@mui/material/CardActionArea'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import { DateTime } from 'luxon'; @@ -21,23 +22,23 @@ export default function ExecutionRow(props: ExecutionRowProps): React.ReactEleme const { flow } = execution; return ( - + - {flow.name} - + {getHumanlyDate(parseInt(execution.createdAt, 10))} - + theme.palette.primary.main }} /> diff --git a/packages/web/src/components/ExecutionStep/index.tsx b/packages/web/src/components/ExecutionStep/index.tsx new file mode 100644 index 00000000..d7d2bb2a --- /dev/null +++ b/packages/web/src/components/ExecutionStep/index.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { useQuery } from '@apollo/client'; +import Stack from '@mui/material/Stack'; +import ErrorIcon from '@mui/icons-material/Error'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import type { IApp, IJSONObject, IExecutionStep, IStep } from '@automatisch/types'; + +import TabPanel from 'components/TabPanel'; +import JSONViewer from 'components/JSONViewer'; +import AppIcon from 'components/AppIcon'; +import { GET_APPS } from 'graphql/queries/get-apps'; +import useFormatMessage from 'hooks/useFormatMessage'; +import { AppIconWrapper, AppIconStatusIconWrapper, Content, Header, Wrapper } from './style'; + +type ExecutionStepProps = { + collapsed?: boolean; + step: IStep; + index?: number; + executionStep: IExecutionStep; +} + +const validIcon = ; +const errorIcon = ; + +export default function ExecutionStep(props: ExecutionStepProps): React.ReactElement | null { + const { executionStep, index, } = props; + const [activeTabIndex, setActiveTabIndex] = React.useState(0); + const step: IStep = executionStep.step; + const isTrigger = step.type === 'trigger'; + const formatMessage = useFormatMessage(); + const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); + const apps: IApp[] = data?.getApps; + const app = apps?.find((currentApp: IApp) => currentApp.key === step.appKey); + + if (!apps) return null; + + const validationStatusIcon = executionStep.status === 'success' ? validIcon : errorIcon; + + return ( + + + + + + + + {validationStatusIcon} + + + + + + { + isTrigger ? + formatMessage('flowStep.triggerType') : + formatMessage('flowStep.actionType') + } + + + + {step.position}. {app?.name} + + + + + + + + setActiveTabIndex(tabIndex)}> + + + + + + + + + + + + + + + + + + + + + ) +}; diff --git a/packages/web/src/components/ExecutionStep/style.ts b/packages/web/src/components/ExecutionStep/style.ts new file mode 100644 index 00000000..7a7ffaf3 --- /dev/null +++ b/packages/web/src/components/ExecutionStep/style.ts @@ -0,0 +1,42 @@ +import { styled, alpha } from '@mui/material/styles'; +import Card from '@mui/material/Card'; + +export const AppIconWrapper = styled('div')` + position: relative; +`; + +export const AppIconStatusIconWrapper = styled('span')` + position: absolute; + right: 0; + top: 0; + transform: translate(50%, -50%); + display: inline-flex; + + svg { + // to make it distinguishable over an app icon + background: white; + border-radius: 100%; + overflow: hidden; + } +`; + +export const Wrapper = styled(Card)` + width: 100%; + overflow: unset; +`; + +type HeaderProps = { + collapsed?: boolean; +} + +export const Header = styled('div', { shouldForwardProp: prop => prop !== 'collapsed' })` + padding: ${({ theme }) => theme.spacing(2)}; + cursor: ${({ collapsed }) => collapsed ? 'pointer' : 'unset'}; +`; + +export const Content = styled('div')` + border: 1px solid ${({ theme }) => alpha(theme.palette.divider, .8)}; + border-left: none; + border-right: none; + padding: ${({ theme }) => theme.spacing(2, 0)}; +`; diff --git a/packages/web/src/components/FlowRow/index.tsx b/packages/web/src/components/FlowRow/index.tsx index 32d50a95..b220cb90 100644 --- a/packages/web/src/components/FlowRow/index.tsx +++ b/packages/web/src/components/FlowRow/index.tsx @@ -17,7 +17,7 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement { const { flow } = props; return ( - + diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index 7d1524bb..a3662943 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -144,7 +144,7 @@ export default function FlowStep( - {index}. {app?.name} + {step.position}. {app?.name} diff --git a/packages/web/src/components/JSONViewer/index.tsx b/packages/web/src/components/JSONViewer/index.tsx new file mode 100644 index 00000000..34d3e566 --- /dev/null +++ b/packages/web/src/components/JSONViewer/index.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import '@alenaksu/json-viewer'; + +import type { IJSONObject } from '@automatisch/types'; +import { jsonViewerStyles } from './style'; + +type JSONViewerProps = { + data: IJSONObject; +} +function JSONViewer(props: JSONViewerProps) { + const { data } = props; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const viewerRef = React.useRef(null); + + React.useEffect(() => { + if (viewerRef.current){ + viewerRef.current.data = data; + } + }, [data]); + + return ( + + {jsonViewerStyles} + + + + ); +} + + +export default JSONViewer; diff --git a/packages/web/src/components/JSONViewer/style.tsx b/packages/web/src/components/JSONViewer/style.tsx new file mode 100644 index 00000000..0e3d5ad9 --- /dev/null +++ b/packages/web/src/components/JSONViewer/style.tsx @@ -0,0 +1,29 @@ +import GlobalStyles from '@mui/material/GlobalStyles'; + +export const jsonViewerStyles = ( ({ + 'json-viewer': { + '--background-color': 'transparent', + '--font-family': 'monaco, Consolas, Lucida Console, monospace', + '--font-size': '1rem', + '--indent-size': '1.5em', + '--indentguide-size': '1px', + '--indentguide-style': 'solid', + '--indentguide-color': theme.palette.text.primary, + '--indentguide-color-active': '#666', + '--indentguide': 'var(--indentguide-size) var(--indentguide-style) var(--indentguide-color)', + '--indentguide-active': 'var(--indentguide-size) var(--indentguide-style) var(--indentguide-color-active)', + + /* Types colors */ + '--string-color': theme.palette.text.secondary, + '--number-color': theme.palette.text.primary, + '--boolean-color': theme.palette.text.primary, + '--null-color': theme.palette.text.primary, + '--property-color': theme.palette.text.primary, + + /* Collapsed node preview */ + '--preview-color': theme.palette.text.primary, + + /* Search highlight color */ + '--highlight-color': '#6fb3d2', + } +})} />) diff --git a/packages/web/src/components/TestSubstep/index.tsx b/packages/web/src/components/TestSubstep/index.tsx index 66da2cb7..1738eef3 100644 --- a/packages/web/src/components/TestSubstep/index.tsx +++ b/packages/web/src/components/TestSubstep/index.tsx @@ -4,6 +4,7 @@ import Collapse from '@mui/material/Collapse'; import ListItem from '@mui/material/ListItem'; import Button from '@mui/material/Button'; +import JSONViewer from 'components/JSONViewer'; import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow'; import FlowSubstepTitle from 'components/FlowSubstepTitle'; import type { IStep, ISubstep } from '@automatisch/types'; @@ -58,9 +59,7 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement { {response && ( - - {JSON.stringify(response, null, 2)} - + )} `/executions/${executionId}`; export const LOGIN = '/login'; diff --git a/packages/web/src/graphql/cache.ts b/packages/web/src/graphql/cache.ts index d30cffd0..8bd32ef2 100644 --- a/packages/web/src/graphql/cache.ts +++ b/packages/web/src/graphql/cache.ts @@ -1,4 +1,5 @@ import { InMemoryCache } from '@apollo/client'; +import offsetLimitPagination from './pagination'; const cache = new InMemoryCache({ typePolicies: { @@ -30,6 +31,11 @@ const cache = new InMemoryCache({ } } }, + Query: { + fields: { + getExecutionSteps: offsetLimitPagination(['executionId', 'limit']), + } + } } }); diff --git a/packages/web/src/graphql/queries/get-execution-steps.ts b/packages/web/src/graphql/queries/get-execution-steps.ts new file mode 100644 index 00000000..8c3d3b82 --- /dev/null +++ b/packages/web/src/graphql/queries/get-execution-steps.ts @@ -0,0 +1,30 @@ +import { gql } from '@apollo/client'; + +export const GET_EXECUTION_STEPS = gql` + query GetExecutionSteps($executionId: String!, $limit: Int!, $offset: Int!) { + getExecutionSteps(executionId: $executionId, limit: $limit, offset: $offset) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + executionId + status + dataIn + dataOut + createdAt + updatedAt + step { + id + appKey + type + status + position + } + } + } + } + } +`; diff --git a/packages/web/src/graphql/queries/get-flow.ts b/packages/web/src/graphql/queries/get-flow.ts index f2e009f3..34345984 100644 --- a/packages/web/src/graphql/queries/get-flow.ts +++ b/packages/web/src/graphql/queries/get-flow.ts @@ -12,6 +12,7 @@ export const GET_FLOW = gql` key appKey status + position connection { id verified diff --git a/packages/web/src/pages/Execution/index.tsx b/packages/web/src/pages/Execution/index.tsx new file mode 100644 index 00000000..94a2c699 --- /dev/null +++ b/packages/web/src/pages/Execution/index.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@apollo/client'; +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import type { IExecutionStep } from '@automatisch/types'; + +import ExecutionStep from 'components/ExecutionStep'; +import Container from 'components/Container'; +import { GET_EXECUTION_STEPS } from 'graphql/queries/get-execution-steps'; + +type ExecutionParams = { + executionId: string; +}; + +const EXECUTION_PER_PAGE = 5; + +const getLimitAndOffset = (page: number) => ({ + limit: EXECUTION_PER_PAGE, + offset: (page - 1) * EXECUTION_PER_PAGE, +}); + +export default function Execution(): React.ReactElement { + const { executionId } = useParams() as ExecutionParams; + const { data, fetchMore } = useQuery(GET_EXECUTION_STEPS, { variables: { executionId, ...getLimitAndOffset(1) } }); + + const { edges, pageInfo } = data?.getExecutionSteps || {}; + const executionSteps: IExecutionStep[] = edges?.map((edge: { node: IExecutionStep }) => edge.node); + + React.useEffect(() => { + if (pageInfo?.currentPage < pageInfo?.totalPages) { + fetchMore({ + variables: { + executionId, + ...getLimitAndOffset(pageInfo.currentPage + 1), + } + }); + } + }, [executionId, fetchMore, pageInfo]); + + return ( + + + + {executionSteps?.map((executionStep) => ( + + ))} + + + + ); +}; diff --git a/packages/web/src/pages/Flow/index.tsx b/packages/web/src/pages/Flow/index.tsx index 23a30c38..6a032d45 100644 --- a/packages/web/src/pages/Flow/index.tsx +++ b/packages/web/src/pages/Flow/index.tsx @@ -5,24 +5,22 @@ import Grid from '@mui/material/Grid'; import Container from 'components/Container'; -type ApplicationParams = { +type FlowParams = { flowId: string; }; export default function Flow(): React.ReactElement { - const { flowId } = useParams() as ApplicationParams; + const { flowId } = useParams() as FlowParams; return ( - <> - - - - - {flowId} - + + + + + {flowId} - - - > + + + ); }; diff --git a/packages/web/src/react-app-env.d.ts b/packages/web/src/react-app-env.d.ts index 6431bc5f..9fa1a8b9 100644 --- a/packages/web/src/react-app-env.d.ts +++ b/packages/web/src/react-app-env.d.ts @@ -1 +1,8 @@ /// + +declare namespace JSX { + interface IntrinsicElements { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + "json-viewer": any; + } +} diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index 1a0c3fca..188c1e79 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -3,8 +3,9 @@ import Layout from 'components/Layout'; import PublicLayout from 'components/PublicLayout'; import Applications from 'pages/Applications'; import Application from 'pages/Application'; -import Flows from 'pages/Flows'; import Executions from 'pages/Executions'; +import Execution from 'pages/Execution'; +import Flows from 'pages/Flows'; import Flow from 'pages/Flow'; import Explore from 'pages/Explore'; import Login from 'pages/Login'; @@ -15,9 +16,11 @@ export default ( } /> + } /> + } /> - } /> + } /> } /> diff --git a/packages/web/src/types/connection.ts b/packages/web/src/types/connection.ts deleted file mode 100644 index 4f0d85ee..00000000 --- a/packages/web/src/types/connection.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { IConnection } from '@automatisch/types'; - -export type Connection = IConnection; diff --git a/yarn.lock b/yarn.lock index 278ad8c4..5a231a64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@alenaksu/json-viewer@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@alenaksu/json-viewer/-/json-viewer-1.0.0.tgz#4451d80f581f4eb4e0b4f030441c56ffb5420869" + integrity sha512-eG4vPLGrjAkx3qyM5ub89POG/ySbXu46tz8ANzdFw9JnZaTrKXBwRxTo6zgC+6T5InXU5wpLvecPe/t+1vwlqQ== + dependencies: + lit "^2.2.0" + "@algolia/autocomplete-core@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.5.0.tgz#6c91c9de7748e9c103846828a58dfe92bd4d6689" @@ -3112,6 +3119,11 @@ npmlog "^4.1.2" write-file-atomic "^3.0.3" +"@lit/reactive-element@^1.3.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.1.tgz#3021ad0fa30a75a41212c5e7f1f169c5762ef8bb" + integrity sha512-nOJARIr3pReqK3hfFCSW2Zg/kFcFsSAlIE7z4a0C9D2dPrgD/YSn3ZP2ET/rxKB65SXyG7jJbkynBRm+tGlacw== + "@mapbox/node-pre-gyp@^1.0.0": version "1.0.8" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.8.tgz#32abc8a5c624bc4e46c43d84dfb8b26d33a96f58" @@ -11991,6 +12003,30 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +lit-element@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7" + integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g== + dependencies: + "@lit/reactive-element" "^1.3.0" + lit-html "^2.2.0" + +lit-html@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.1.tgz#762f112a8b54eaf0bbae3f516de935a25dcc12d1" + integrity sha512-AiJ/Rs0awjICs2FioTnHSh+Np5dhYSkyRczKy3wKjp8qjLhr1Ov+GiHrUQNdX8ou1LMuznpIME990AZsa/tR8g== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.1.tgz#4b679e1d8cb6c7977b64921b1ea3eca7850ca1dd" + integrity sha512-dSe++R50JqrvNGXmI9OE13de1z5U/Y3J2dTm/9GC86vedI8ILoR8ZGnxfThFpvQ9m0lR0qRnIR4IiKj/jDCfYw== + dependencies: + "@lit/reactive-element" "^1.3.0" + lit-element "^3.2.0" + lit-html "^2.2.0" + load-json-file@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b"
- {JSON.stringify(response, null, 2)} -