Move useTaskStream, TaskBorder, TaskLogStream and TaskSteps into scaffolder-react

Signed-off-by: Paul Cowan <paul.cowan@cutting.scot>
This commit is contained in:
Paul Cowan
2023-02-24 16:24:49 +00:00
parent ca9229805e
commit 8f4d13f21c
20 changed files with 155 additions and 10 deletions
+54
View File
@@ -17,6 +17,10 @@ import { JsonValue } from '@backstage/types';
import { Observable } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ScaffolderTask as ScaffolderTask_2 } from '@backstage/plugin-scaffolder-react';
import { ScaffolderTaskOutput as ScaffolderTaskOutput_2 } from '@backstage/plugin-scaffolder-react';
import { ScaffolderTaskStatus as ScaffolderTaskStatus_2 } from '@backstage/plugin-scaffolder-react';
import { Step as Step_2 } from '@backstage/plugin-scaffolder-react';
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
@@ -277,6 +281,53 @@ export const SecretsContextProvider: ({
children,
}: PropsWithChildren<{}>) => JSX.Element;
// @public
export type Step = {
id: string;
status: ScaffolderTaskStatus_2;
endedAt?: string;
startedAt?: string;
};
// @public (undocumented)
export interface StepperProps {
// (undocumented)
activeStep?: number;
// (undocumented)
steps: (TaskStep & Step_2)[];
}
// @public
export const TaskBorder: (props: {
isComplete: boolean;
isError: boolean;
}) => JSX.Element;
// @public
export const TaskLogStream: (props: {
logs: {
[k: string]: string[];
};
}) => JSX.Element;
// @public
export const TaskSteps: (props: StepperProps) => JSX.Element;
// @public
export type TaskStream = {
loading: boolean;
error?: Error;
stepLogs: {
[stepId in string]: string[];
};
completed: boolean;
task?: ScaffolderTask_2;
steps: {
[stepId in string]: Step;
};
output?: ScaffolderTaskOutput_2;
};
// @public
export type TemplateParameterSchema = {
title: string;
@@ -300,6 +351,9 @@ export const useCustomLayouts: <TComponentDataType = LayoutOptions<any>>(
outlet: React.ReactNode,
) => TComponentDataType[];
// @public
export const useTaskEventStream: (taskId: string) => TaskStream;
// @public
export const useTemplateSecrets: () => ScaffolderUseTemplateSecrets;
+6 -1
View File
@@ -58,6 +58,7 @@
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@react-hookz/web": "^20.0.0",
"@rjsf/core": "^3.2.1",
"@rjsf/core-v5": "npm:@rjsf/core@5.1.0",
"@rjsf/material-ui": "^3.2.1",
@@ -66,10 +67,12 @@
"@rjsf/validator-ajv8": "5.1.0",
"@types/json-schema": "^7.0.9",
"classnames": "^2.2.6",
"humanize-duration": "^3.25.1",
"immer": "^9.0.1",
"json-schema": "^0.4.0",
"json-schema-library": "^7.3.9",
"lodash": "^4.17.21",
"luxon": "^3.0.0",
"qs": "^6.9.4",
"react-use": "^17.2.4",
"use-immer": "^0.8.0",
@@ -91,7 +94,9 @@
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/user-event": "^14.0.0"
"@testing-library/user-event": "^14.0.0",
"@types/humanize-duration": "^3.18.1",
"@types/luxon": "^3.0.0"
},
"files": [
"dist"
@@ -0,0 +1,40 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TaskBorder } from './TaskBorder';
import { render } from '@testing-library/react';
describe('TaskBorder', () => {
it('should render a pending linear progress if the task is not complete', () => {
const { getByRole } = render(
<TaskBorder isComplete={false} isError={false} />,
);
const progressBar = getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveClass('MuiLinearProgress-indeterminate');
});
it('should render a determinate progress bar if the task is complete', () => {
const { getByRole } = render(<TaskBorder isComplete isError />);
const progressBar = getByRole('progressbar');
expect(progressBar).toBeInTheDocument();
expect(progressBar).toHaveClass('MuiLinearProgress-determinate');
});
});
@@ -0,0 +1,51 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { LinearProgress, makeStyles } from '@material-ui/core';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles((theme: BackstageTheme) => ({
failed: {
backgroundColor: theme.palette.error.main,
},
success: {
backgroundColor: theme.palette.success.main,
},
}));
/**
* The visual progress of the task event stream
*
* @public
*/
export const TaskBorder = (props: {
isComplete: boolean;
isError: boolean;
}) => {
const styles = useStyles();
if (!props.isComplete) {
return <LinearProgress variant="indeterminate" />;
}
return (
<LinearProgress
variant="determinate"
classes={{ bar: props.isError ? styles.failed : styles.success }}
value={100}
/>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TaskBorder } from './TaskBorder';
@@ -0,0 +1,50 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React, { ReactNode } from 'react';
import { TaskLogStream } from './TaskLogStream';
// The <AutoSizer> inside <LogViewer> needs mocking to render in jsdom
jest.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: (props: {
children: (size: { width: number; height: number }) => ReactNode;
}) => <>{props.children({ width: 400, height: 200 })}</>,
}));
describe('TaskLogStream', () => {
it('should render a log stream with the correct log lines', async () => {
const logs = { step: ['line 1', 'line 2'], step2: ['line 3'] };
const { findByText } = await renderInTestApp(<TaskLogStream logs={logs} />);
const logLines = Object.values(logs).flat();
for (const line of logLines) {
await expect(findByText(line)).resolves.toBeInTheDocument();
}
});
it('should filter out empty log lines', async () => {
const logs = { step: ['line 1', 'line 2'], step2: [] };
const { findAllByRole } = await renderInTestApp(
<TaskLogStream logs={logs} />,
);
await expect(findAllByRole('row')).resolves.toHaveLength(2);
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { LogViewer } from '@backstage/core-components';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
root: {
width: '100%',
height: '100%',
position: 'relative',
},
});
/**
* The text of the event stream
*
* @public
*/
export const TaskLogStream = (props: { logs: { [k: string]: string[] } }) => {
const styles = useStyles();
return (
<div className={styles.root}>
<LogViewer
text={Object.values(props.logs)
.map(l => l.join('\n'))
.filter(Boolean)
.join('\n')}
/>
</div>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TaskLogStream } from './TaskLogStream';
@@ -0,0 +1,71 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { BackstageTheme } from '@backstage/theme';
import { CircularProgress, makeStyles, StepIconProps } from '@material-ui/core';
import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
import PanoramaFishEyeIcon from '@material-ui/icons/PanoramaFishEye';
import classNames from 'classnames';
import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
import ErrorOutline from '@material-ui/icons/ErrorOutline';
const useStepIconStyles = makeStyles((theme: BackstageTheme) => ({
root: {
color: theme.palette.text.disabled,
},
completed: {
color: theme.palette.status.ok,
},
error: {
color: theme.palette.status.error,
},
}));
export const StepIcon = (props: StepIconProps & { skipped: boolean }) => {
const classes = useStepIconStyles();
const { active, completed, error, skipped } = props;
const getMiddle = () => {
if (active) {
return <CircularProgress size="20px" />;
}
if (completed) {
return <CheckCircleOutline />;
}
if (error) {
return <ErrorOutline />;
}
if (skipped) {
return <RemoveCircleOutline />;
}
return <PanoramaFishEyeIcon />;
};
return (
<div
className={classNames(classes.root, {
[classes.completed]: completed,
[classes.error]: error,
})}
>
{getMiddle()}
</div>
);
};
@@ -0,0 +1,79 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { StepTime } from './StepTime';
import { render, act } from '@testing-library/react';
describe('StepTime', () => {
it('should return empty when there is no start time', () => {
const step = {
id: 'test',
startedAt: undefined,
endedAt: undefined,
name: 'test',
};
const { container } = render(<StepTime step={step} />);
expect(container.querySelector('span')?.textContent).toBe('');
});
it('should format the time between the start and end date properly', async () => {
const step = {
id: 'test',
startedAt: '2021-01-01T00:00:00Z',
endedAt: '2021-01-01T00:00:01Z',
name: 'test',
};
const { findByText } = render(<StepTime step={step} />);
await expect(findByText('1 second')).resolves.toBeInTheDocument();
});
describe('updates', () => {
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2021-01-01T00:00:00Z') });
});
afterEach(() => {
jest.useRealTimers();
});
it('should update the time every second', async () => {
const step = {
id: 'test',
startedAt: '2021-01-01T00:00:00Z',
endedAt: undefined,
name: 'test',
};
const { findByText } = render(<StepTime step={step} />);
await expect(findByText('0 seconds')).resolves.toBeInTheDocument();
act(() => jest.advanceTimersByTime(1000));
await expect(findByText('1 second')).resolves.toBeInTheDocument();
act(() => jest.advanceTimersByTime(1000 * 60));
await expect(
findByText('1 minute, 1 second'),
).resolves.toBeInTheDocument();
});
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useState } from 'react';
import useInterval from 'react-use/lib/useInterval';
import { DateTime, Interval } from 'luxon';
import humanizeDuration from 'humanize-duration';
import { Typography } from '@material-ui/core';
import { useMountEffect } from '@react-hookz/web';
export const StepTime = (props: {
step: {
id: string;
name: string;
startedAt?: string;
endedAt?: string;
};
}) => {
const [time, setTime] = useState('');
const { step } = props;
const calculate = useCallback(() => {
if (!step.startedAt) {
setTime('');
return;
}
const end = step.endedAt
? DateTime.fromISO(step.endedAt)
: DateTime.local();
const startedAt = DateTime.fromISO(step.startedAt);
const formatted = Interval.fromDateTimes(startedAt, end)
.toDuration()
.valueOf();
setTime(humanizeDuration(formatted, { round: true }));
}, [step.endedAt, step.startedAt]);
useMountEffect(() => calculate());
useInterval(() => !step.endedAt && calculate(), 1000);
return <Typography variant="caption">{time}</Typography>;
};
@@ -0,0 +1,64 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { TaskSteps } from './TaskSteps';
import { ScaffolderTaskStatus } from '@backstage/plugin-scaffolder-react';
import { renderInTestApp } from '@backstage/test-utils';
describe('TaskSteps', () => {
it('should render each of the steps', async () => {
const steps = [
{
id: '1',
name: 'Step 1',
status: 'processing' as ScaffolderTaskStatus,
startedAt: Date.now().toLocaleString(),
action: 'create',
},
{
id: '2',
name: 'Step 2',
status: 'failed' as ScaffolderTaskStatus,
startedAt: Date.now().toLocaleString(),
endedAt: Date.now().toLocaleString(),
action: 'create',
},
{
id: '3',
name: 'Step 3',
status: 'completed' as ScaffolderTaskStatus,
startedAt: Date.now().toLocaleString(),
endedAt: Date.now().toLocaleString(),
action: 'create',
},
{
id: '4',
name: 'Step 4',
status: 'skipped' as ScaffolderTaskStatus,
startedAt: Date.now().toLocaleString(),
endedAt: Date.now().toLocaleString(),
action: 'create',
},
];
const { getByText } = await renderInTestApp(<TaskSteps steps={steps} />);
for (const step of steps) {
expect(getByText(step.name)).toBeInTheDocument();
}
});
});
@@ -0,0 +1,79 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Stepper as MuiStepper,
Step as MuiStep,
StepButton as MuiStepButton,
StepLabel as MuiStepLabel,
StepIconProps,
Box,
} from '@material-ui/core';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
import { type Step } from '@backstage/plugin-scaffolder-react';
import { StepIcon } from './StepIcon';
import { StepTime } from './StepTime';
/**
*
* @public
*/
export interface StepperProps {
steps: (TaskStep & Step)[];
activeStep?: number;
}
/**
* The visual stepper of the task event stream
*
* @public
*/
export const TaskSteps = (props: StepperProps) => {
return (
<MuiStepper
activeStep={props.activeStep}
alternativeLabel
variant="elevation"
>
{props.steps.map((step, index) => {
const isCompleted = step.status === 'completed';
const isFailed = step.status === 'failed';
const isActive = step.status === 'processing';
const isSkipped = step.status === 'skipped';
const stepIconProps: Partial<StepIconProps & { skipped: boolean }> = {
completed: isCompleted,
error: isFailed,
active: isActive,
skipped: isSkipped,
};
return (
<MuiStep key={index}>
<MuiStepButton>
<MuiStepLabel
StepIconProps={stepIconProps}
StepIconComponent={StepIcon}
>
<Box>{step.name}</Box>
<StepTime step={step} />
</MuiStepLabel>
</MuiStepButton>
</MuiStep>
);
})}
</MuiStepper>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TaskSteps, type StepperProps } from './TaskSteps';
@@ -0,0 +1,18 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TaskSteps, type StepperProps } from './TaskSteps';
export { TaskBorder } from './TaskBorder';
export { TaskLogStream } from './TaskLogStream';
+1
View File
@@ -20,3 +20,4 @@ export * from './secrets';
export * from './api';
export * from './hooks';
export * from './layouts';
export * from './components';