Skip to content

Commit

Permalink
Timeline group component (#669)
Browse files Browse the repository at this point in the history
* group history events

* timeline load more

* Initialize Queries UI (#665)

* Init commit

* more commit

* Add more changes

* Add tests

* clean up code

* Init queries UI

* Add unit tests

* Fix unsaved merge conflict

* Fix tests and merge conflicts again

* update snapshot

* Fix snapshot

* Reorder some props in tile

* resolve comments

* Fix tests

* Fix tests and make design responsive

* Remove snapshots

* Update tests for tile input

* get workflow rest api

* add type to decaode url param

* timeline group component

* add animations

* lint fix

---------

Co-authored-by: Adhitya Mamallan <[email protected]>
  • Loading branch information
Assem-Hafez and adhityamamallan authored Sep 20, 2024
1 parent 02e913f commit b693a19
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 8 deletions.
29 changes: 26 additions & 3 deletions src/route-handlers/get-workflow-history/get-workflow-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,33 @@ import * as grpcClient from '@/utils/grpc/grpc-client';
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';

import { type RequestParams } from './get-workflow-history.types';
import {
type RouteParams,
type RequestParams,
} from './get-workflow-history.types';
import getWorkflowHistoryQueryParamSchema from './schemas/get-workflow-history-query-params-schema';

export default async function getWorkflowHistory(
_: NextRequest,
request: NextRequest,
requestParams: RequestParams
) {
const decodedParams = decodeUrlParams(requestParams.params);
const decodedParams = decodeUrlParams<RouteParams>(requestParams.params);
const { data: queryParams, error } =
getWorkflowHistoryQueryParamSchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams)
);

if (error) {
return NextResponse.json(
{
message: 'Invalid query param(s)',
validationErrors: error.errors,
},
{
status: 400,
}
);
}

try {
const res = await grpcClient.clusterMethods[
Expand All @@ -22,6 +42,9 @@ export default async function getWorkflowHistory(
workflowId: decodedParams.workflowId,
runId: decodedParams.runId,
},
pageSize: queryParams.pageSize,
waitForNewEvent: queryParams.waitForNewEvent,
nextPageToken: queryParams.nextPage,
});

return Response.json(res);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { z } from 'zod';

const getWorkflowHistoryQueryParamSchema = z.object({
pageSize: z
.string()
.transform((val) => parseInt(val, 10))
.pipe(
z.number().positive({ message: 'Page size must be a positive integer' })
),
nextPage: z.string().optional(),
waitForNewEvent: z
.string()
.toLowerCase()
.transform((x) => x === 'true')
.pipe(z.boolean())
.optional(),
});

export default getWorkflowHistoryQueryParamSchema;
10 changes: 5 additions & 5 deletions src/utils/decode-url-params.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
const decodeUrlParams = (params: { [k: string]: string }) => {
export default function decodeUrlParams<Params extends { [k: string]: string }>(
params: Params
): Params {
return Object.fromEntries(
Object.entries(params).map(([key, value]) => [
key,
decodeURIComponent(value),
])
);
};

export default decodeUrlParams;
) as Params; // shrink object type to include only keys from params
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { render, screen } from '@/test-utils/rtl';

import { startWorkflowExecutionEvent } from '../../__fixtures__/workflow-history-single-events';
import type WorkflowHistoryEventStatusBadge from '../../workflow-history-event-status-badge/workflow-history-event-status-badge';
import type WorkflowHistoryEventsCard from '../../workflow-history-events-card/workflow-history-events-card';
import WorkflowHistoryTimelineGroup from '../workflow-history-timeline-group';
import { type styled } from '../workflow-history-timeline-group.styles';
import type { Props } from '../workflow-history-timeline-group.types';

jest.mock<typeof WorkflowHistoryEventStatusBadge>(
'../../workflow-history-event-status-badge/workflow-history-event-status-badge',
() => jest.fn((props) => <div>{props.status}</div>)
);

jest.mock<typeof WorkflowHistoryEventsCard>(
'../../workflow-history-events-card/workflow-history-events-card',
() => jest.fn(() => <div>Events Card</div>)
);

jest.mock('../workflow-history-timeline-group.styles', () => {
const actual = jest.requireActual(
'../workflow-history-timeline-group.styles'
);
return {
...actual,
styled: {
...actual.styled,
VerticalDivider: ({ $hidden }: { $hidden?: boolean }) => (
<div>Divider {$hidden ? 'hidden' : 'visible'}</div>
),
} satisfies typeof styled,
};
});

describe('WorkflowHistoryTimelineGroup', () => {
it('renders group label correctly', () => {
setup({ label: 'test label' });

expect(screen.getByText('test label')).toBeInTheDocument();
});

it('renders group status correctly', () => {
setup({ status: 'CANCELED' });

expect(screen.getByText('CANCELED')).toBeInTheDocument();
});

it('renders group timeLabel correctly', () => {
setup({ timeLabel: 'Started at 19 Sep, 11:37:12 GMT+2' });

expect(
screen.getByText('Started at 19 Sep, 11:37:12 GMT+2')
).toBeInTheDocument();
});

it('renders events card', () => {
setup({});

expect(screen.getByText('Events Card')).toBeInTheDocument();
});

it('renders divider when isLastEvent is false', () => {
setup({ isLastEvent: false });
expect(screen.getByText('Divider visible')).toBeInTheDocument();
});

it('hides divider when isLastEvent is true', () => {
setup({ isLastEvent: true });
expect(screen.getByText('Divider hidden')).toBeInTheDocument();
});
});

function setup({
label = 'Workflow Started',
hasMissingEvents = false,
eventsMetadata = [
{
label: 'Started',
status: 'COMPLETED',
timeMs: 1726652232190.7927,
timeLabel: 'Started at 18 Sep, 11:37:12 GMT+2',
},
],
events = [startWorkflowExecutionEvent],
status = 'COMPLETED',
timeLabel = 'Started at 18 Sep, 11:37:12 GMT+2',
isLastEvent = false,
}: Partial<Props>) {
return render(
<WorkflowHistoryTimelineGroup
events={events}
eventsMetadata={eventsMetadata}
isLastEvent={isLastEvent}
label={label}
timeLabel={timeLabel}
hasMissingEvents={hasMissingEvents}
status={status}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { styled as createStyled, type Theme } from 'baseui';

import type {
StyletronCSSObject,
StyletronCSSObjectOf,
} from '@/hooks/use-styletron-classes';

export const styled = {
VerticalDivider: createStyled<'div', { $hidden?: boolean }>(
'div',
({ $theme, $hidden }: { $theme: Theme; $hidden?: boolean }) => ({
...$theme.borders.border200,
borderColor: $theme.colors.borderOpaque,
height: $hidden ? 0 : '100%',
marginLeft: $theme.sizing.scale500,
transition: `height 0.2s ${$theme.animation.easeOutCurve}`,
})
),
};

const cssStylesObj = {
groupContainer: {
display: 'flex',
flexDirection: 'column',
},
timelineEventHeader: {
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '12px 0',
},
timelineEventsLabel: (theme) => ({
...theme.typography.LabelLarge,
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
timelineEventsTime: (theme) => ({
...theme.typography.LabelXSmall,
color: theme.colors.contentTertiary,
wordBreak: 'break-word',
}),
timelineEventCardContainer: {
display: 'flex',
gap: '28px',
},
} satisfies StyletronCSSObject;

export const cssStyles: StyletronCSSObjectOf<typeof cssStylesObj> =
cssStylesObj;
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';
import React from 'react';

import useStyletronClasses from '@/hooks/use-styletron-classes';

import WorkflowHistoryEventStatusBadge from '../workflow-history-event-status-badge/workflow-history-event-status-badge';
import WorkflowHistoryEventsCard from '../workflow-history-events-card/workflow-history-events-card';

import { cssStyles, styled } from './workflow-history-timeline-group.styles';
import { type Props } from './workflow-history-timeline-group.types';

export default function WorkflowHistoryTimelineGroup({
status,
label,
timeLabel,
events,
isLastEvent,
eventsMetadata,
hasMissingEvents,
}: Props) {
const { cls } = useStyletronClasses(cssStyles);

return (
<div className={cls.groupContainer}>
<div className={cls.timelineEventHeader}>
<WorkflowHistoryEventStatusBadge status={status} size="medium" />
<div className={cls.timelineEventsLabel}>{label}</div>
<div suppressHydrationWarning className={cls.timelineEventsTime}>
{timeLabel}
</div>
</div>
<div className={cls.timelineEventCardContainer}>
<styled.VerticalDivider $hidden={isLastEvent} />
<WorkflowHistoryEventsCard
events={events}
eventsMetadata={eventsMetadata}
showEventPlaceholder={hasMissingEvents}
/>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { HistoryEventsGroup } from '../workflow-history.types';

export type Props = Pick<
HistoryEventsGroup,
| 'events'
| 'eventsMetadata'
| 'timeLabel'
| 'label'
| 'hasMissingEvents'
| 'status'
> & {
isLastEvent: boolean;
};

0 comments on commit b693a19

Please sign in to comment.