feat: update tasks page

Signed-off-by: Rogerio Angeliski <angeliski@hotmail.com>
This commit is contained in:
Rogerio Angeliski
2022-04-28 09:15:41 -03:00
committed by blam
parent 944651e021
commit 6c02fd3c29
24 changed files with 1051 additions and 144 deletions
+13 -3
View File
@@ -25,7 +25,11 @@ import {
import React from 'react';
import { scaffolderApiRef, ScaffolderClient } from '../src';
import { ScaffolderPage } from '../src/plugin';
import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api';
import {
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { CatalogEntityPage } from '@backstage/plugin-catalog';
createDevApp()
@@ -49,9 +53,15 @@ createDevApp()
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
scmIntegrationsApi: scmIntegrationsApiRef,
identityApi: identityApiRef,
},
factory: ({ discoveryApi, fetchApi, scmIntegrationsApi }) =>
new ScaffolderClient({ discoveryApi, fetchApi, scmIntegrationsApi }),
factory: ({ discoveryApi, fetchApi, scmIntegrationsApi, identityApi }) =>
new ScaffolderClient({
discoveryApi,
fetchApi,
scmIntegrationsApi,
identityApi,
}),
})
.addPage({
path: '/create',
+80
View File
@@ -33,6 +33,13 @@ describe('api', () => {
const discoveryApi = { getBaseUrl: async () => mockBaseUrl };
const fetchApi = new MockFetchApi();
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
const scmIntegrationsApi = ScmIntegrations.fromConfig(
new ConfigReader({
integrations: {
@@ -51,6 +58,7 @@ describe('api', () => {
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
});
});
@@ -129,6 +137,7 @@ describe('api', () => {
scmIntegrationsApi,
discoveryApi,
fetchApi,
identityApi,
useLongPollingLogs: true,
});
});
@@ -305,4 +314,75 @@ describe('api', () => {
});
});
});
describe('listTasks', () => {
it('should list all tasks', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json([
{
createdBy,
},
]),
);
}
return res(
ctx.json([
{
createdBy: null,
},
{
createdBy: null,
},
]),
);
}),
);
const result = await apiClient.listTasks('all');
expect(identityApi.getBackstageIdentity).not.toBeCalled();
expect(result).toHaveLength(2);
});
it('should list task using the current user as owner', async () => {
server.use(
rest.get(`${mockBaseUrl}/v2/tasks`, (req, res, ctx) => {
const createdBy = req.url.searchParams.get('createdBy');
if (createdBy) {
return res(
ctx.json([
{
createdBy,
},
]),
);
}
return res(
ctx.json([
{
createdBy: null,
},
{
createdBy: null,
},
]),
);
}),
);
identityApi.getBackstageIdentity.mockResolvedValueOnce({
userEntityRef: 'user:default/foo',
});
const result = await apiClient.listTasks('owned');
expect(identityApi.getBackstageIdentity).toBeCalled();
expect(result).toHaveLength(1);
});
});
});
+14 -2
View File
@@ -19,6 +19,7 @@ import {
createApiRef,
DiscoveryApi,
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { ResponseError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -36,6 +37,7 @@ import {
ScaffolderGetIntegrationsListOptions,
ScaffolderGetIntegrationsListResponse,
ScaffolderTask,
TasksOwnerFilterKind,
} from './types';
/**
@@ -56,11 +58,13 @@ export class ScaffolderClient implements ScaffolderApi {
private readonly discoveryApi: DiscoveryApi;
private readonly scmIntegrationsApi: ScmIntegrationRegistry;
private readonly fetchApi: FetchApi;
private readonly identityApi: IdentityApi;
private readonly useLongPollingLogs: boolean;
constructor(options: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
identityApi: IdentityApi;
scmIntegrationsApi: ScmIntegrationRegistry;
useLongPollingLogs?: boolean;
}) {
@@ -68,11 +72,19 @@ export class ScaffolderClient implements ScaffolderApi {
this.fetchApi = options.fetchApi ?? { fetch };
this.scmIntegrationsApi = options.scmIntegrationsApi;
this.useLongPollingLogs = options.useLongPollingLogs ?? false;
this.identityApi = options.identityApi;
}
async listTasks(): Promise<ScaffolderTask[]> {
async listTasks(createdBy: TasksOwnerFilterKind): Promise<ScaffolderTask[]> {
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v2/tasks`;
let query = '';
if (createdBy === 'owned') {
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
query = `createdBy=${encodeURIComponent(userEntityRef)}`;
}
const url = `${baseUrl}/v2/tasks?${query}`;
const response = await this.fetchApi.fetch(url);
if (!response.ok) {
@@ -28,6 +28,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]);
@@ -0,0 +1,226 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { identityApiRef } from '@backstage/core-plugin-api';
import { ListTasksPage } from './ListTasksPage';
import { ScaffolderApi } from '../../types';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { act, fireEvent } from '@testing-library/react';
describe('<ListTasksPage />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
listTasks: jest.fn(),
} as any;
it('should render the page', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'service',
metadata: {
name: 'test',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
scaffolderApiMock.listTasks.mockResolvedValue([]);
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
expect(getByText('List template tasks')).toBeInTheDocument();
expect(getByText('All tasks that have been started')).toBeInTheDocument();
expect(getByText('Tasks')).toBeInTheDocument();
});
it('should render the task I am owner', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'foo',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
scaffolderApiMock.listTasks.mockResolvedValue([
{
id: 'a-random-id',
spec: {
createdBy: 'user:default/foo',
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
]);
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
expect(scaffolderApiMock.listTasks).toBeCalledWith('owned');
expect(getByText('List template tasks')).toBeInTheDocument();
expect(getByText('All tasks that have been started')).toBeInTheDocument();
expect(getByText('Tasks')).toBeInTheDocument();
expect(getByText('One Template')).toBeInTheDocument();
expect(getByText('BackUser')).toBeInTheDocument();
});
it('should render all tasks', async () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'foo',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef
.mockResolvedValue(entity)
.mockResolvedValue(entity)
.mockResolvedValue({
...entity,
spec: {
profile: {
displayName: 'OtherUser',
},
},
});
scaffolderApiMock.listTasks
.mockResolvedValue([
{
id: 'a-random-id',
spec: {
createdBy: 'user:default/foo',
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
])
.mockResolvedValue([
{
id: 'b-random-id',
spec: {
createdBy: 'user:default/boo',
} as any,
status: 'completed',
createdAt: '',
lastHeartbeatAt: '',
},
]);
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
[scaffolderApiRef, scaffolderApiMock],
]}
>
<ListTasksPage />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/root': rootRouteRef,
},
},
);
await act(async () => {
const allButton = await getByText('All');
fireEvent.click(allButton);
});
expect(scaffolderApiMock.listTasks).toBeCalledWith('all');
expect(getByText('One Template')).toBeInTheDocument();
expect(getByText('OtherUser')).toBeInTheDocument();
});
});
@@ -0,0 +1,144 @@
/*
* 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 {
Content,
EmptyState,
ErrorPanel,
Header,
Lifecycle,
Link,
Page,
Progress,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { CatalogFilterLayout } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
import MaterialTable from '@material-table/core';
import React, { useState } from 'react';
import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { OwnerListPicker } from './OwnerListPicker';
import { TasksOwnerFilterKind } from '../../types';
import {
CreatedAtColumn,
OwnerEntityColumn,
TaskStatusColumn,
TemplateTitleColumn,
} from './columns';
export interface MyTaskPageProps {
initiallySelectedFilter?: TasksOwnerFilterKind;
}
const ListTaskPageContent = (props: MyTaskPageProps) => {
const { initiallySelectedFilter = 'owned' } = props;
const scaffolderApi = useApi(scaffolderApiRef);
const rootLink = useRouteRef(rootRouteRef);
const [ownerFilter, setOwnerFilter] = useState(initiallySelectedFilter);
const { value, loading, error } = useAsync(
() => scaffolderApi.listTasks(ownerFilter),
[scaffolderApi, ownerFilter],
);
if (loading) {
return <Progress />;
}
if (error) {
return (
<>
<ErrorPanel error={error} />
<EmptyState
missing="info"
title="No information to display"
description="There is no Tasks or there was an issue communicating with backend."
/>
</>
);
}
return (
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<OwnerListPicker
filter={ownerFilter}
onSelectOwner={id => setOwnerFilter(id)}
/>
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<MaterialTable
data={value!}
title="Tasks"
columns={[
{
title: 'Task ID',
field: 'id',
render: row => (
<Link to={`${rootLink()}/tasks/${row.id}`}>{row.id}</Link>
),
},
{
title: 'Template',
render: row => (
<TemplateTitleColumn
entityRef={row.spec.templateInfo?.entityRef}
/>
),
},
{
title: 'Created',
field: 'createdAt',
render: row => <CreatedAtColumn createdAt={row.createdAt} />,
},
{
title: 'Owner',
field: 'createdBy',
render: row => (
<OwnerEntityColumn entityRef={row.spec?.createdBy} />
),
},
{
title: 'Status',
field: 'status',
render: row => <TaskStatusColumn status={row.status} />,
},
]}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
);
};
export const ListTasksPage = (props: MyTaskPageProps) => {
return (
<Page themeId="home">
<Header
pageTitleOverride="Templates Tasks"
title={
<>
List template tasks <Lifecycle shorthand alpha />
</>
}
subtitle="All tasks that have been started"
/>
<Content>
<ListTaskPageContent {...props} />
</Content>
</Page>
);
};
@@ -0,0 +1,47 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { OwnerListPicker } from './OwnerListPicker';
import { fireEvent } from '@testing-library/react';
describe('<OwnerListPicker />', () => {
it('should render the tasks owner filter', async () => {
const props = {
filter: 'owned',
onSelectOwner: jest.fn(),
};
const { getByText } = await renderInTestApp(<OwnerListPicker {...props} />);
expect(await getByText('Owned')).toBeDefined();
expect(await getByText('All')).toBeDefined();
});
it('should call the function on select other item', async () => {
const props = {
filter: 'owned',
onSelectOwner: jest.fn(),
};
const { getByText } = await renderInTestApp(<OwnerListPicker {...props} />);
fireEvent.click(await getByText('All'));
expect(props.onSelectOwner).toBeCalledWith('all');
});
});
@@ -0,0 +1,135 @@
/*
* 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 { IconComponent } from '@backstage/core-plugin-api';
import {
Card,
List,
ListItemIcon,
ListItemText,
makeStyles,
MenuItem,
Theme,
Typography,
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import React, { Fragment } from 'react';
import AllIcon from '@material-ui/icons/FontDownload';
import { TasksOwnerFilterKind } from '../../types';
const useStyles = makeStyles<Theme>(
theme => ({
root: {
backgroundColor: 'rgba(0, 0, 0, .11)',
boxShadow: 'none',
margin: theme.spacing(1, 0, 1, 0),
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
listIcon: {
minWidth: 30,
color: theme.palette.text.primary,
},
menuItem: {
minHeight: theme.spacing(6),
},
groupWrapper: {
margin: theme.spacing(1, 1, 2, 1),
},
}),
{
name: 'ScaffolderReactOwnerListPicker',
},
);
export type ButtonGroup = {
name: string;
items: {
id: 'owned' | 'starred' | 'all';
label: string;
icon?: IconComponent;
}[];
};
function getFilterGroups(): ButtonGroup[] {
return [
{
name: 'Task Owner',
items: [
{
id: 'owned',
label: 'Owned',
icon: SettingsIcon,
},
{
id: 'all',
label: 'All',
icon: AllIcon,
},
],
},
];
}
export const OwnerListPicker = (props: {
filter: string;
onSelectOwner: (id: TasksOwnerFilterKind) => void;
}) => {
const { filter, onSelectOwner } = props;
const classes = useStyles();
const filterGroups = getFilterGroups();
return (
<Card className={classes.root}>
{filterGroups.map(group => (
<Fragment key={group.name}>
<Typography variant="subtitle2" className={classes.title}>
{group.name}
</Typography>
<Card className={classes.groupWrapper}>
<List disablePadding dense>
{group.items.map(item => (
<MenuItem
key={item.id}
button
divider
onClick={() => onSelectOwner(item.id as TasksOwnerFilterKind)}
selected={item.id === filter}
className={classes.menuItem}
data-testid={`owner-picker-${item.id}`}
>
{item.icon && (
<ListItemIcon className={classes.listIcon}>
<item.icon fontSize="small" />
</ListItemIcon>
)}
<ListItemText>
<Typography variant="body1">{item.label}</Typography>
</ListItemText>
</MenuItem>
))}
</List>
</Card>
</Fragment>
))}
</Card>
);
};
@@ -0,0 +1,34 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { CreatedAtColumn } from './CreatedAtColumn';
import { DateTime } from 'luxon';
describe('<CreatedAtColumn />', () => {
it('should render the column with the time', async () => {
const props = {
createdAt: DateTime.now().toISO(),
};
const { getByText } = await renderInTestApp(<CreatedAtColumn {...props} />);
const text = getByText('0 seconds ago');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,27 @@
/*
* 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 { DateTime, Interval } from 'luxon';
import humanizeDuration from 'humanize-duration';
import React from 'react';
export const CreatedAtColumn = ({ createdAt }: { createdAt: string }) => {
const createdAtTime = DateTime.fromISO(createdAt);
const formatted = Interval.fromDateTimes(createdAtTime, DateTime.local())
.toDuration()
.valueOf();
return <p>{humanizeDuration(formatted, { round: true })} ago</p>;
};
@@ -0,0 +1,82 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import {
CatalogApi,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { OwnerEntityColumn } from './OwnerEntityColumn';
import { identityApiRef } from '@backstage/core-plugin-api';
describe('<OwnerEntityColumn />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getEntityByRef: jest.fn(),
} as any;
const identityApi = {
getBackstageIdentity: jest.fn(),
getProfileInfo: jest.fn(),
getCredentials: jest.fn(),
signOut: jest.fn(),
};
it('should render the column with the time', async () => {
const props = {
entityRef: 'user:default/foo',
};
const entity: Entity = {
apiVersion: 'v1',
kind: 'User',
metadata: {
name: 'test',
},
spec: {
profile: {
displayName: 'BackUser',
},
},
};
catalogApi.getEntityByRef.mockResolvedValue(entity);
const { getByText, getByRole } = await renderInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[identityApiRef, identityApi],
]}
>
<OwnerEntityColumn {...props} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
);
expect(getByRole('link')).toHaveAttribute(
'href',
'/catalog/default/user/foo',
);
const text = getByText('BackUser');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,50 @@
/*
* 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 { Link } from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import React from 'react';
import useAsync from 'react-use/lib/useAsync';
import { ListItemText } from '@material-ui/core';
import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react';
import { parseEntityRef, UserEntity } from '@backstage/catalog-model';
export const OwnerEntityColumn = ({
entityRef,
}: {
entityRef?: string | null;
}) => {
const catalogApi = useApi(catalogApiRef);
const catalogEntityRoute = useRouteRef(entityRouteRef);
const { value, loading, error } = useAsync(
() => catalogApi.getEntityByRef(entityRef || ''),
[catalogApi, entityRef],
);
if (loading || error) {
return null;
}
return (
<Link to={catalogEntityRoute(parseEntityRef(entityRef || ''))}>
<ListItemText
primary={(value as UserEntity)?.spec?.profile?.displayName}
/>
</Link>
);
};
@@ -0,0 +1,38 @@
/*
* 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 { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { TaskStatusColumn } from './TaskStatusColumn';
describe('<TaskStatusColumn />', () => {
it.each(['processing', 'error', 'completed'])(
'should render the column with the status %s',
async status => {
const props = {
status: status,
};
const { getByText } = await renderInTestApp(
<TaskStatusColumn {...props} />,
);
const text = getByText(status);
expect(text).toBeDefined();
},
);
});
@@ -0,0 +1,33 @@
/*
* 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 {
StatusError,
StatusOK,
StatusPending,
} from '@backstage/core-components';
import React from 'react';
export const TaskStatusColumn = ({ status }: { status: string }) => {
switch (status) {
case 'processing':
return <StatusPending>{status}</StatusPending>;
case 'completed':
return <StatusOK>{status}</StatusOK>;
case 'error':
default:
return <StatusError>{status}</StatusError>;
}
};
@@ -0,0 +1,48 @@
/*
* 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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { TemplateTitleColumn } from './TemplateTitleColumn';
import { scaffolderApiRef } from '../../../api';
import { ScaffolderApi } from '../../../types';
describe('<TemplateTitleColumn />', () => {
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
} as any;
it('should render the column with the time', async () => {
const props = {
entityRef: 'template:default/one-template',
};
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'One Template',
steps: [],
});
const { getByText } = await renderInTestApp(
<TestApiProvider apis={[[scaffolderApiRef, scaffolderApiMock]]}>
<TemplateTitleColumn {...props} />
</TestApiProvider>,
);
const text = getByText('One Template');
expect(text).toBeDefined();
});
});
@@ -0,0 +1,33 @@
/*
* 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 { useApi } from '@backstage/core-plugin-api';
import React from 'react';
import { scaffolderApiRef } from '../../../api';
import useAsync from 'react-use/lib/useAsync';
export const TemplateTitleColumn = ({ entityRef }: { entityRef?: string }) => {
const scaffolder = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() => scaffolder.getTemplateParameterSchema(entityRef || ''),
[scaffolder, entityRef],
);
if (loading || error) {
return null;
}
return <p>{value?.title}</p>;
};
@@ -0,0 +1,19 @@
/*
* 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.
*/
export { CreatedAtColumn } from './CreatedAtColumn';
export { OwnerEntityColumn } from './OwnerEntityColumn';
export { TaskStatusColumn } from './TaskStatusColumn';
export { TemplateTitleColumn } from './TemplateTitleColumn';
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { MyTaskPage } from './MyTaskPage';
export { ListTasksPage } from './ListTasksPage';
@@ -1,135 +0,0 @@
/*
* 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 {
Content,
Header,
Lifecycle,
Link,
Page,
Progress,
} from '@backstage/core-components';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { Grid } from '@material-ui/core';
import React from 'react';
import { scaffolderApiRef } from '../../api';
import useAsync from 'react-use/lib/useAsync';
import MaterialTable, { Column } from '@material-table/core';
import { rootRouteRef } from '../../routes';
import { Duration, Interval, DateTime } from 'luxon';
import humanizeDuration from 'humanize-duration';
import {
StatusError,
StatusPending,
StatusOK,
} from '@backstage/core-components';
const CreatedAtColumn = ({ createdAt }: { createdAt: string }) => {
const createdAtTime = DateTime.fromISO(createdAt);
const formatted = Interval.fromDateTimes(createdAtTime, DateTime.local())
.toDuration()
.valueOf();
return <p>{humanizeDuration(formatted, { round: true })} ago</p>;
};
const TemplateTitle = ({ templateName }: { templateName?: string }) => {
const scaffolder = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() =>
scaffolder.getTemplateParameterSchema({
kind: 'Template',
namespace: 'default',
name: templateName,
}),
[scaffolder, templateName],
);
if (loading) {
return null;
}
return <p>{value?.title}</p>;
};
const Status = ({ status }) => {
switch (status) {
case 'processing':
return <StatusPending>{status}</StatusPending>;
case 'completed':
return <StatusOK>{status}</StatusOK>;
case 'error':
default:
return <StatusError>{status}</StatusError>;
}
};
export const MyTaskPage = () => {
const scaffolderApi = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() => scaffolderApi.listTasks(),
[scaffolderApi],
);
const rootLink = useRouteRef(rootRouteRef);
return (
<Page themeId="home">
<Header
pageTitleOverride="My Tasks"
title={
<>
All my tasks <Lifecycle shorthand />
</>
}
subtitle="All tasks that have been started by me"
/>
<Content>
{loading ? (
<Progress />
) : (
<MaterialTable
data={value!}
title="My Tasks"
columns={[
{
title: 'Task ID',
field: 'id',
render: row => (
<Link to={`${rootLink()}/tasks/${row.id}`}>{row.id}</Link>
),
},
{
title: 'Name',
render: row => (
<TemplateTitle templateName={row.spec.metadata?.name} />
),
},
{
title: 'Created',
field: 'createdAt',
render: row => <CreatedAtColumn createdAt={row.createdAt} />,
},
{
title: 'Status',
field: 'status',
render: row => <Status status={row.status} />,
},
]}
/>
)}
</Content>
</Page>
);
};
+6 -2
View File
@@ -35,10 +35,11 @@ import { useElementFilter } from '@backstage/core-plugin-api';
import {
actionsRouteRef,
editRouteRef,
scaffolderListTaskRouteRef,
scaffolderTaskRouteRef,
selectedTemplateRouteRef,
} from '../routes';
import { MyTaskPage } from './MyTasksPage';
import { ListTasksPage } from './ListTasksPage';
/**
* The props for the entrypoint `ScaffolderPage` component the plugin.
@@ -119,7 +120,10 @@ export const Router = (props: RouterProps) => {
</SecretsContextProvider>
}
/>
<Route path="/tasks/me" element={<MyTaskPage />} />
<Route
path={scaffolderListTaskRouteRef.path}
element={<ListTasksPage />}
/>
<Route path={scaffolderTaskRouteRef.path} element={<TaskPageElement />} />
<Route path={actionsRouteRef.path} element={<ActionsPage />} />
<Route
@@ -51,6 +51,7 @@ const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
getTask: jest.fn(),
streamLogs: jest.fn(),
listActions: jest.fn(),
listTasks: jest.fn(),
};
const featureFlagsApiMock: jest.Mocked<FeatureFlagsApi> = {
+4 -1
View File
@@ -30,6 +30,7 @@ import {
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { OwnedEntityPicker } from './components/fields/OwnedEntityPicker/OwnedEntityPicker';
import { EntityTagsPicker } from './components/fields/EntityTagsPicker/EntityTagsPicker';
@@ -47,12 +48,14 @@ export const scaffolderPlugin = createPlugin({
discoveryApi: discoveryApiRef,
scmIntegrationsApi: scmIntegrationsApiRef,
fetchApi: fetchApiRef,
identityApi: identityApiRef,
},
factory: ({ discoveryApi, scmIntegrationsApi, fetchApi }) =>
factory: ({ discoveryApi, scmIntegrationsApi, fetchApi, identityApi }) =>
new ScaffolderClient({
discoveryApi,
scmIntegrationsApi,
fetchApi,
identityApi,
}),
}),
],
+6
View File
@@ -40,6 +40,12 @@ export const scaffolderTaskRouteRef = createSubRouteRef({
path: '/tasks/:taskId',
});
export const scaffolderListTaskRouteRef = createSubRouteRef({
id: 'scaffolder/list-tasks',
parent: rootRouteRef,
path: '/tasks',
});
export const actionsRouteRef = createSubRouteRef({
id: 'scaffolder/actions',
parent: rootRouteRef,
+9
View File
@@ -102,6 +102,13 @@ export type LogEvent = {
taskId: string;
};
/**
* The status of each task you can filter from `ScaffolderClient`
*
* @public
*/
export type TasksOwnerFilterKind = 'owned' | 'all';
/**
* The input options to the `scaffold` method of the `ScaffolderClient`.
*
@@ -171,6 +178,8 @@ export interface ScaffolderApi {
getTask(taskId: string): Promise<ScaffolderTask>;
listTasks(createdBy: TasksOwnerFilterKind): Promise<ScaffolderTask[]>;
getIntegrationsList(
options: ScaffolderGetIntegrationsListOptions,
): Promise<ScaffolderGetIntegrationsListResponse>;