Merge pull request #16081 from backstage/blam/next/scaffolder/ongoing
scaffolder/next: start of the new `OngoingTask` page
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
scaffolder/next: Implementing a simple `OngoingTask` page
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { AnsiChunk, AnsiLine, ChunkModifiers } from './AnsiProcessor';
|
||||
import startCase from 'lodash/startCase';
|
||||
import classnames from 'classnames';
|
||||
@@ -159,8 +158,8 @@ export function LogLine({
|
||||
const elements = useMemo(
|
||||
() =>
|
||||
chunks.map(({ text, modifiers, highlight }, index) => (
|
||||
<Typography
|
||||
component="span"
|
||||
// eslint-disable-next-line react/forbid-elements
|
||||
<span
|
||||
key={index}
|
||||
className={classnames(
|
||||
getModifierClasses(classes, modifiers),
|
||||
@@ -171,7 +170,7 @@ export function LogLine({
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</span>
|
||||
)),
|
||||
[chunks, highlightResultIndex, classes],
|
||||
);
|
||||
|
||||
@@ -85,6 +85,11 @@ export type CustomFieldValidator<TFieldReturnValue> = (
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
|
||||
// @alpha
|
||||
export const DefaultTemplateOutputs: (props: {
|
||||
output?: ScaffolderTaskOutput;
|
||||
}) => JSX.Element | null;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const EmbeddableWorkflow: (props: WorkflowProps) => JSX.Element;
|
||||
|
||||
|
||||
@@ -127,6 +127,8 @@ export const Stepper = (stepperProps: StepperProps) => {
|
||||
[setFormState],
|
||||
);
|
||||
|
||||
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
|
||||
|
||||
const handleNext = async ({
|
||||
formData = {},
|
||||
}: {
|
||||
@@ -151,8 +153,6 @@ export const Stepper = (stepperProps: StepperProps) => {
|
||||
setFormState(current => ({ ...current, ...formData }));
|
||||
};
|
||||
|
||||
const currentStep = useTransformSchemaToProps(steps[activeStep], { layouts });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuiStepper activeStep={activeStep} alternativeLabel variant="elevation">
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { Box, Paper } from '@material-ui/core';
|
||||
import { LinkOutputs } from './LinkOutputs';
|
||||
import { ScaffolderTaskOutput } from '../../../api';
|
||||
|
||||
/**
|
||||
* The DefaultOutputs renderer for the scaffolder task output
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export const DefaultTemplateOutputs = (props: {
|
||||
output?: ScaffolderTaskOutput;
|
||||
}) => {
|
||||
if (!props.output?.links) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingBottom={2}>
|
||||
<Paper>
|
||||
<Box padding={2} justifyContent="center" display="flex" gridGap={16}>
|
||||
<LinkOutputs output={props.output} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 { IconComponent, useApp, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import WebIcon from '@material-ui/icons/Web';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { ScaffolderTaskOutput } from '../../../api';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
'&:hover': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const LinkOutputs = (props: { output: ScaffolderTaskOutput }) => {
|
||||
const { links = [] } = props.output;
|
||||
const classes = useStyles();
|
||||
const app = useApp();
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
|
||||
const iconResolver = (key?: string): IconComponent =>
|
||||
app.getSystemIcon(key!) ?? WebIcon;
|
||||
|
||||
return (
|
||||
<>
|
||||
{links
|
||||
.filter(({ url, entityRef }) => url || entityRef)
|
||||
.map(({ url, entityRef, title, icon }) => {
|
||||
if (entityRef) {
|
||||
const entityName = parseEntityRef(entityRef);
|
||||
const target = entityRoute(entityName);
|
||||
return { title, icon, url: target };
|
||||
}
|
||||
return { title, icon, url: url! };
|
||||
})
|
||||
.map(({ url, title, icon }, i) => {
|
||||
const Icon = iconResolver(icon);
|
||||
return (
|
||||
<Link to={url} key={i} classes={{ root: classes.root }}>
|
||||
<Button startIcon={<Icon />} component="div" color="primary">
|
||||
{title}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 { DefaultTemplateOutputs } from './DefaultTemplateOutputs';
|
||||
@@ -18,3 +18,4 @@ export * from './TemplateCard';
|
||||
export * from './ReviewState';
|
||||
export * from './TemplateGroup';
|
||||
export * from './Workflow';
|
||||
export * from './TemplateOutputs';
|
||||
|
||||
@@ -197,9 +197,17 @@ export type NextRouterProps = {
|
||||
template: TemplateEntityV1beta3;
|
||||
}>;
|
||||
TaskPageComponent?: React_2.ComponentType<{}>;
|
||||
TemplateOutputsComponent?: React_2.ComponentType<{
|
||||
output?: ScaffolderTaskOutput_2;
|
||||
}>;
|
||||
};
|
||||
groups?: TemplateGroupFilter[];
|
||||
FormProps?: FormProps_2;
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// @alpha
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { Subscription } from '@backstage/types';
|
||||
|
||||
type Step = {
|
||||
export type Step = {
|
||||
id: string;
|
||||
status: ScaffolderTaskStatus;
|
||||
endedAt?: string;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Popover,
|
||||
} from '@material-ui/core';
|
||||
import Retry from '@material-ui/icons/Repeat';
|
||||
import Toc from '@material-ui/icons/Toc';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
type ContextMenuProps = {
|
||||
logsVisible?: boolean;
|
||||
onToggleLogs?: (state: boolean) => void;
|
||||
onStartOver?: () => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme: BackstageTheme) => ({
|
||||
button: {
|
||||
color: theme.palette.common.white,
|
||||
},
|
||||
}));
|
||||
|
||||
export const ContextMenu = (props: ContextMenuProps) => {
|
||||
const { logsVisible, onToggleLogs, onStartOver } = props;
|
||||
const classes = useStyles();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="more"
|
||||
aria-controls="long-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={(event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
}}
|
||||
data-testid="menu-button"
|
||||
color="inherit"
|
||||
className={classes.button}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(undefined)}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => onToggleLogs?.(!logsVisible)}>
|
||||
<ListItemIcon>
|
||||
<Toc fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={logsVisible ? 'Hide Logs' : 'Show Logs'} />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onStartOver}>
|
||||
<ListItemIcon>
|
||||
<Retry fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Start Over" />
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { Page, Header, Content, ErrorPanel } from '@backstage/core-components';
|
||||
import { useTaskEventStream } from '../../components/hooks/useEventStream';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, makeStyles, Paper } from '@material-ui/core';
|
||||
import { TaskSteps } from './TaskSteps';
|
||||
import { TaskBorder } from './TaskBorder';
|
||||
import { TaskLogStream } from './TaskLogStream';
|
||||
import { nextSelectedTemplateRouteRef } from '../routes';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import qs from 'qs';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import {
|
||||
DefaultTemplateOutputs,
|
||||
ScaffolderTaskOutput,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
contentWrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
export const OngoingTask = (props: {
|
||||
TemplateOutputsComponent?: React.ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
}) => {
|
||||
// todo(blam): check that task Id actually exists, and that it's valid. otherwise redirect to something more useful.
|
||||
const { taskId } = useParams();
|
||||
const templateRouteRef = useRouteRef(nextSelectedTemplateRouteRef);
|
||||
const navigate = useNavigate();
|
||||
const taskStream = useTaskEventStream(taskId!);
|
||||
const classes = useStyles();
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
taskStream.task?.spec.steps.map(step => ({
|
||||
...step,
|
||||
...taskStream?.steps?.[step.id],
|
||||
})) ?? [],
|
||||
[taskStream],
|
||||
);
|
||||
|
||||
const [logsVisible, setLogVisibleState] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
}
|
||||
}, [taskStream.error]);
|
||||
|
||||
const activeStep = useMemo(() => {
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
if (steps[i].status !== 'open') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}, [steps]);
|
||||
|
||||
const startOver = useCallback(() => {
|
||||
const { namespace, name } =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata ?? {};
|
||||
|
||||
const formData = taskStream.task?.spec.parameters ?? {};
|
||||
|
||||
if (!namespace || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({
|
||||
pathname: templateRouteRef({
|
||||
namespace,
|
||||
templateName: name,
|
||||
}),
|
||||
search: `?${qs.stringify({ formData: JSON.stringify(formData) })}`,
|
||||
});
|
||||
}, [
|
||||
navigate,
|
||||
taskStream.task?.spec.parameters,
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata,
|
||||
templateRouteRef,
|
||||
]);
|
||||
|
||||
const Outputs = props.TemplateOutputsComponent ?? DefaultTemplateOutputs;
|
||||
|
||||
const templateName =
|
||||
taskStream.task?.spec.templateInfo?.entity?.metadata.name;
|
||||
|
||||
return (
|
||||
<Page themeId="website">
|
||||
<Header
|
||||
pageTitleOverride={`Run of ${templateName}`}
|
||||
title={
|
||||
<div>
|
||||
Run of <code>{templateName}</code>
|
||||
</div>
|
||||
}
|
||||
subtitle={`Task ${taskId}`}
|
||||
>
|
||||
<ContextMenu
|
||||
onToggleLogs={setLogVisibleState}
|
||||
onStartOver={startOver}
|
||||
logsVisible={logsVisible}
|
||||
/>
|
||||
</Header>
|
||||
<Content className={classes.contentWrapper}>
|
||||
{taskStream.error ? (
|
||||
<Box paddingBottom={2}>
|
||||
<ErrorPanel
|
||||
error={taskStream.error}
|
||||
title={taskStream.error.message}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box paddingBottom={2}>
|
||||
<Paper style={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<TaskBorder
|
||||
isComplete={taskStream.completed}
|
||||
isError={Boolean(taskStream.error)}
|
||||
/>
|
||||
<Box padding={2}>
|
||||
<TaskSteps steps={steps} activeStep={activeStep} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Outputs output={taskStream.output} />
|
||||
|
||||
{logsVisible ? (
|
||||
<Box paddingBottom={2} height="100%">
|
||||
<Paper style={{ height: '100%' }}>
|
||||
<Box padding={2} height="100%">
|
||||
<TaskLogStream logs={taskStream.stepLogs} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
) : null}
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -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,46 @@
|
||||
/*
|
||||
* 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,
|
||||
},
|
||||
}));
|
||||
|
||||
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,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,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 { LogViewer } from '@backstage/core-components';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
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,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,70 @@
|
||||
/*
|
||||
* 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 { Step } from '../../../components/hooks/useEventStream';
|
||||
import { StepIcon } from './StepIcon';
|
||||
import { StepTime } from './StepTime';
|
||||
|
||||
interface StepperProps {
|
||||
steps: (TaskStep & Step)[];
|
||||
activeStep?: number;
|
||||
}
|
||||
|
||||
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 } from './TaskSteps';
|
||||
@@ -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 { OngoingTask } from './OngoingTask';
|
||||
@@ -19,6 +19,7 @@ import { TemplateListPage } from '../TemplateListPage';
|
||||
import { TemplateWizardPage } from '../TemplateWizardPage';
|
||||
import {
|
||||
NextFieldExtensionOptions,
|
||||
ScaffolderTaskOutput,
|
||||
SecretsContextProvider,
|
||||
useCustomFieldExtensions,
|
||||
useCustomLayouts,
|
||||
@@ -28,7 +29,17 @@ import {
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
|
||||
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
|
||||
import { nextSelectedTemplateRouteRef } from '../routes';
|
||||
|
||||
import {
|
||||
nextActionsRouteRef,
|
||||
nextScaffolderListTaskRouteRef,
|
||||
nextScaffolderTaskRouteRef,
|
||||
nextSelectedTemplateRouteRef,
|
||||
} from '../routes';
|
||||
import { ErrorPage } from '@backstage/core-components';
|
||||
import { OngoingTask } from '../OngoingTask';
|
||||
import { ActionsPage } from '../../components/ActionsPage';
|
||||
import { ListTasksPage } from '../../components/ListTasksPage';
|
||||
|
||||
/**
|
||||
* The Props for the Scaffolder Router
|
||||
@@ -41,9 +52,21 @@ export type NextRouterProps = {
|
||||
template: TemplateEntityV1beta3;
|
||||
}>;
|
||||
TaskPageComponent?: React.ComponentType<{}>;
|
||||
TemplateOutputsComponent?: React.ComponentType<{
|
||||
output?: ScaffolderTaskOutput;
|
||||
}>;
|
||||
};
|
||||
groups?: TemplateGroupFilter[];
|
||||
// todo(blam): rename this to formProps
|
||||
FormProps?: FormProps;
|
||||
contextMenu?: {
|
||||
/** Whether to show a link to the template editor */
|
||||
editor?: boolean;
|
||||
/** Whether to show a link to the actions documentation */
|
||||
actions?: boolean;
|
||||
/** Whether to show a link to the tasks page */
|
||||
tasks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,10 +75,17 @@ export type NextRouterProps = {
|
||||
* @alpha
|
||||
*/
|
||||
export const Router = (props: PropsWithChildren<NextRouterProps>) => {
|
||||
const { components: { TemplateCardComponent } = {} } = props;
|
||||
const {
|
||||
components: {
|
||||
TemplateCardComponent,
|
||||
TemplateOutputsComponent,
|
||||
TaskPageComponent = OngoingTask,
|
||||
} = {},
|
||||
} = props;
|
||||
const outlet = useOutlet() || props.children;
|
||||
const customFieldExtensions =
|
||||
useCustomFieldExtensions<NextFieldExtensionOptions>(outlet);
|
||||
|
||||
const fieldExtensions = [
|
||||
...customFieldExtensions,
|
||||
...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter(
|
||||
@@ -75,6 +105,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
|
||||
element={
|
||||
<TemplateListPage
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
contextMenu={props.contextMenu}
|
||||
groups={props.groups}
|
||||
/>
|
||||
}
|
||||
@@ -91,6 +122,23 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={nextScaffolderTaskRouteRef.path}
|
||||
element={
|
||||
<TaskPageComponent
|
||||
TemplateOutputsComponent={TemplateOutputsComponent}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path={nextActionsRouteRef.path} element={<ActionsPage />} />
|
||||
<Route
|
||||
path={nextScaffolderListTaskRouteRef.path}
|
||||
element={<ListTasksPage />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<ErrorPage status="404" statusMessage="Page not found" />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2020 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 { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||
import ListItemText from '@material-ui/core/ListItemText';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
import MenuList from '@material-ui/core/MenuList';
|
||||
import Popover from '@material-ui/core/Popover';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Description from '@material-ui/icons/Description';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import List from '@material-ui/icons/List';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
nextActionsRouteRef,
|
||||
nextEditRouteRef,
|
||||
nextScaffolderListTaskRouteRef,
|
||||
} from '../routes';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
button: {
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
|
||||
export type ScaffolderPageContextMenuProps = {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
|
||||
export function ContextMenu(props: ScaffolderPageContextMenuProps) {
|
||||
const classes = useStyles();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const editLink = useRouteRef(nextEditRouteRef);
|
||||
const actionsLink = useRouteRef(nextActionsRouteRef);
|
||||
const tasksLink = useRouteRef(nextScaffolderListTaskRouteRef);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showEditor = props.editor !== false;
|
||||
const showActions = props.actions !== false;
|
||||
const showTasks = props.tasks !== false;
|
||||
|
||||
if (!showEditor && !showActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setAnchorEl(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="more"
|
||||
aria-controls="long-menu"
|
||||
aria-haspopup="true"
|
||||
onClick={onOpen}
|
||||
data-testid="menu-button"
|
||||
color="inherit"
|
||||
className={classes.button}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
<Popover
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={onClose}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
>
|
||||
<MenuList>
|
||||
{showEditor && (
|
||||
<MenuItem onClick={() => navigate(editLink())}>
|
||||
<ListItemIcon>
|
||||
<Edit fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Template Editor" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{showActions && (
|
||||
<MenuItem onClick={() => navigate(actionsLink())}>
|
||||
<ListItemIcon>
|
||||
<Description fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Installed Actions" />
|
||||
</MenuItem>
|
||||
)}
|
||||
{showTasks && (
|
||||
<MenuItem onClick={() => navigate(tasksLink())}>
|
||||
<ListItemIcon>
|
||||
<List fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Task List" />
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,12 +37,18 @@ import { RegisterExistingButton } from './RegisterExistingButton';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { TemplateGroupFilter, TemplateGroups } from './TemplateGroups';
|
||||
import { registerComponentRouteRef } from '../../routes';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
|
||||
export type TemplateListPageProps = {
|
||||
TemplateCardComponent?: React.ComponentType<{
|
||||
template: TemplateEntityV1beta3;
|
||||
}>;
|
||||
groups?: TemplateGroupFilter[];
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const defaultGroup: TemplateGroupFilter = {
|
||||
@@ -61,7 +67,9 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
|
||||
pageTitleOverride="Create a new component"
|
||||
title="Create a new component"
|
||||
subtitle="Create new software components using standard templates in your organization"
|
||||
/>
|
||||
>
|
||||
<ContextMenu {...props.contextMenu} />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Available Templates">
|
||||
<RegisterExistingButton
|
||||
|
||||
@@ -28,12 +28,17 @@ import {
|
||||
Workflow,
|
||||
type LayoutOptions,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
NextFieldExtensionOptions,
|
||||
FormProps,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { type FormProps } from '../types';
|
||||
import { nextRouteRef } from '../routes';
|
||||
import { scaffolderTaskRouteRef, selectedTemplateRouteRef } from '../../routes';
|
||||
import { Header, Page } from '@backstage/core-components';
|
||||
import {
|
||||
nextRouteRef,
|
||||
nextScaffolderTaskRouteRef,
|
||||
nextSelectedTemplateRouteRef,
|
||||
} from '../routes';
|
||||
|
||||
export type TemplateWizardPageProps = {
|
||||
customFieldExtensions: NextFieldExtensionOptions<any, any>[];
|
||||
@@ -43,12 +48,12 @@ export type TemplateWizardPageProps = {
|
||||
|
||||
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
|
||||
const rootRef = useRouteRef(nextRouteRef);
|
||||
const taskRoute = useRouteRef(scaffolderTaskRouteRef);
|
||||
const taskRoute = useRouteRef(nextScaffolderTaskRouteRef);
|
||||
const { secrets } = useTemplateSecrets();
|
||||
const scaffolderApi = useApi(scaffolderApiRef);
|
||||
const navigate = useNavigate();
|
||||
const { templateName, namespace } = useRouteRefParams(
|
||||
selectedTemplateRouteRef,
|
||||
nextSelectedTemplateRouteRef,
|
||||
);
|
||||
|
||||
const templateRef = stringifyEntityRef({
|
||||
|
||||
@@ -34,3 +34,24 @@ export const nextScaffolderTaskRouteRef = createSubRouteRef({
|
||||
parent: nextRouteRef,
|
||||
path: '/tasks/:taskId',
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export const nextScaffolderListTaskRouteRef = createSubRouteRef({
|
||||
id: 'scaffolder/next/list-tasks',
|
||||
parent: nextRouteRef,
|
||||
path: '/tasks',
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export const nextActionsRouteRef = createSubRouteRef({
|
||||
id: 'scaffolder/next/actions',
|
||||
parent: nextRouteRef,
|
||||
path: '/actions',
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export const nextEditRouteRef = createSubRouteRef({
|
||||
id: 'scaffolder/next/edit',
|
||||
parent: nextRouteRef,
|
||||
path: '/edit',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user