Merge pull request #11154 from angeliski/add-tasks-owner
Add tasks owner
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
- Added an optional `list` method on the `TaskBroker` and `TaskStore` interface to list tasks by an optional `userEntityRef`
|
||||
- Implemented a `list` method on the `DatabaseTaskStore` class to list tasks by an optional `userEntityRef`
|
||||
- Added a route under `/v2/tasks` to list tasks by a `userEntityRef` using the `createdBy` query parameter
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
- Added a new page under `/create/tasks` to show tasks that have been run by the Scaffolder.
|
||||
- Ability to filter these tasks by the signed in user, and all tasks.
|
||||
- Added optional method to the `ScaffolderApi` interface called `listTasks` to get tasks with an required `filterByOwnership` parameter.
|
||||
@@ -406,6 +406,10 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
// (undocumented)
|
||||
heartbeatTask(taskId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
list(options: { createdBy?: string }): Promise<{
|
||||
tasks: SerializedTask[];
|
||||
}>;
|
||||
// (undocumented)
|
||||
listEvents({ taskId, after }: TaskStoreListEventsOptions): Promise<{
|
||||
events: SerializedTaskEvent[];
|
||||
}>;
|
||||
@@ -530,6 +534,10 @@ export interface TaskBroker {
|
||||
// (undocumented)
|
||||
get(taskId: string): Promise<SerializedTask>;
|
||||
// (undocumented)
|
||||
list?(options?: { createdBy?: string }): Promise<{
|
||||
tasks: SerializedTask[];
|
||||
}>;
|
||||
// (undocumented)
|
||||
vacuumTasks(options: { timeoutS: number }): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -629,6 +637,10 @@ export interface TaskStore {
|
||||
// (undocumented)
|
||||
heartbeatTask(taskId: string): Promise<void>;
|
||||
// (undocumented)
|
||||
list?(options: { createdBy?: string }): Promise<{
|
||||
tasks: SerializedTask[];
|
||||
}>;
|
||||
// (undocumented)
|
||||
listEvents({ taskId, after }: TaskStoreListEventsOptions): Promise<{
|
||||
events: SerializedTaskEvent[];
|
||||
}>;
|
||||
|
||||
@@ -64,6 +64,14 @@ export type DatabaseTaskStoreOptions = {
|
||||
database: Knex;
|
||||
};
|
||||
|
||||
const parseSqlDateToIsoString = <T>(input: T): T | string => {
|
||||
if (typeof input === 'string') {
|
||||
return DateTime.fromSQL(input, { zone: 'UTC' }).toISO();
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
/**
|
||||
* DatabaseTaskStore
|
||||
*
|
||||
@@ -85,6 +93,31 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
this.db = options.database;
|
||||
}
|
||||
|
||||
async list(options: {
|
||||
createdBy?: string;
|
||||
}): Promise<{ tasks: SerializedTask[] }> {
|
||||
const queryBuilder = this.db<RawDbTaskRow>('tasks');
|
||||
|
||||
if (options.createdBy) {
|
||||
queryBuilder.where({
|
||||
created_by: options.createdBy,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await queryBuilder.orderBy('created_at', 'desc').select();
|
||||
|
||||
const tasks = results.map(result => ({
|
||||
id: result.id,
|
||||
spec: JSON.parse(result.spec),
|
||||
status: result.status,
|
||||
createdBy: result.created_by ?? undefined,
|
||||
lastHeartbeatAt: parseSqlDateToIsoString(result.last_heartbeat_at),
|
||||
createdAt: parseSqlDateToIsoString(result.created_at),
|
||||
}));
|
||||
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
async getTask(taskId: string): Promise<SerializedTask> {
|
||||
const [result] = await this.db<RawDbTaskRow>('tasks')
|
||||
.where({ id: taskId })
|
||||
@@ -99,8 +132,8 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
id: result.id,
|
||||
spec,
|
||||
status: result.status,
|
||||
lastHeartbeatAt: result.last_heartbeat_at,
|
||||
createdAt: result.created_at,
|
||||
lastHeartbeatAt: parseSqlDateToIsoString(result.last_heartbeat_at),
|
||||
createdAt: parseSqlDateToIsoString(result.created_at),
|
||||
createdBy: result.created_by ?? undefined,
|
||||
secrets,
|
||||
};
|
||||
@@ -292,10 +325,7 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
taskId,
|
||||
body,
|
||||
type: event.event_type,
|
||||
createdAt:
|
||||
typeof event.created_at === 'string'
|
||||
? DateTime.fromSQL(event.created_at, { zone: 'UTC' }).toISO()
|
||||
: event.created_at,
|
||||
createdAt: parseSqlDateToIsoString(event.created_at),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
||||
@@ -202,4 +202,31 @@ describe('StorageTaskBroker', () => {
|
||||
|
||||
expect(task.done).toBe(true);
|
||||
});
|
||||
|
||||
it('should list all tasks', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const { taskId } = await broker.dispatch({ spec: {} as TaskSpec });
|
||||
|
||||
const promise = broker.list();
|
||||
await expect(promise).resolves.toEqual({
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: taskId,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should list only tasks createdBy a specific user', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const { taskId } = await broker.dispatch({
|
||||
spec: {} as TaskSpec,
|
||||
createdBy: 'user:default/foo',
|
||||
});
|
||||
|
||||
const task = await storage.getTask(taskId);
|
||||
|
||||
const promise = broker.list({ createdBy: 'user:default/foo' });
|
||||
await expect(promise).resolves.toEqual({ tasks: [task] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,18 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
private readonly storage: TaskStore,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async list(options?: {
|
||||
createdBy?: string;
|
||||
}): Promise<{ tasks: SerializedTask[] }> {
|
||||
if (!this.storage.list) {
|
||||
throw new Error(
|
||||
'TaskStore does not implement the list method. Please implement the list method to be able to list tasks',
|
||||
);
|
||||
}
|
||||
return await this.storage.list({ createdBy: options?.createdBy });
|
||||
}
|
||||
|
||||
private deferredDispatch = defer();
|
||||
|
||||
/**
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface TaskBroker {
|
||||
after: number | undefined;
|
||||
}): Observable<{ events: SerializedTaskEvent[] }>;
|
||||
get(taskId: string): Promise<SerializedTask>;
|
||||
list?(options?: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +194,7 @@ export interface TaskStore {
|
||||
listStaleTasks(options: { timeoutS: number }): Promise<{
|
||||
tasks: { taskId: string }[];
|
||||
}>;
|
||||
list?(options: { createdBy?: string }): Promise<{ tasks: SerializedTask[] }>;
|
||||
|
||||
emitLogEvent({ taskId, body }: TaskStoreEmitOptions): Promise<void>;
|
||||
listEvents({
|
||||
|
||||
@@ -131,6 +131,7 @@ describe('createRouter', () => {
|
||||
|
||||
jest.spyOn(taskBroker, 'dispatch');
|
||||
jest.spyOn(taskBroker, 'get');
|
||||
jest.spyOn(taskBroker, 'list');
|
||||
jest.spyOn(taskBroker, 'event$');
|
||||
|
||||
const router = await createRouter({
|
||||
@@ -216,23 +217,18 @@ describe('createRouter', () => {
|
||||
const mockToken =
|
||||
'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob';
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.set('Authorization', `Bearer ${mockToken}`)
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.set('Authorization', `Bearer ${mockToken}`)
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
},
|
||||
});
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdBy: 'user:default/guest',
|
||||
@@ -294,6 +290,77 @@ describe('createRouter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /v2/tasks', () => {
|
||||
it('return all tasks', async () => {
|
||||
(
|
||||
taskBroker.list as jest.Mocked<Required<TaskBroker>>['list']
|
||||
).mockResolvedValue({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
createdBy: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(app).get(`/v2/tasks`);
|
||||
expect(taskBroker.list).toBeCalledWith({
|
||||
createdBy: undefined,
|
||||
});
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
createdBy: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('return filtered tasks', async () => {
|
||||
(
|
||||
taskBroker.list as jest.Mocked<Required<TaskBroker>>['list']
|
||||
).mockResolvedValue({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
createdBy: 'user:default/foo',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await request(app).get(
|
||||
`/v2/tasks?createdBy=user:default/foo`,
|
||||
);
|
||||
expect(taskBroker.list).toBeCalledWith({
|
||||
createdBy: 'user:default/foo',
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toStrictEqual({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
createdBy: 'user:default/foo',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /v2/tasks/:taskId', () => {
|
||||
it('does not divulge secrets', async () => {
|
||||
(taskBroker.get as jest.Mocked<TaskBroker>['get']).mockResolvedValue({
|
||||
@@ -302,6 +369,7 @@ describe('createRouter', () => {
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
secrets: { backstageToken: 'secret' },
|
||||
createdBy: '',
|
||||
});
|
||||
|
||||
const response = await request(app).get(`/v2/tasks/a-random-id`);
|
||||
|
||||
@@ -252,6 +252,28 @@ export async function createRouter(
|
||||
|
||||
res.status(201).json({ id: result.taskId });
|
||||
})
|
||||
.get('/v2/tasks', async (req, res) => {
|
||||
const [userEntityRef] = [req.query.createdBy].flat();
|
||||
|
||||
if (
|
||||
typeof userEntityRef !== 'string' &&
|
||||
typeof userEntityRef !== 'undefined'
|
||||
) {
|
||||
throw new InputError('createdBy query parameter must be a string');
|
||||
}
|
||||
|
||||
if (!taskBroker.list) {
|
||||
throw new Error(
|
||||
'TaskBroker does not support listing tasks, please implement the list method on the TaskBroker.',
|
||||
);
|
||||
}
|
||||
|
||||
const tasks = await taskBroker.list({
|
||||
createdBy: userEntityRef,
|
||||
});
|
||||
|
||||
res.status(200).json(tasks);
|
||||
})
|
||||
.get('/v2/tasks/:taskId', async (req, res) => {
|
||||
const { taskId } = req.params;
|
||||
const task = await taskBroker.get(taskId);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { FieldProps } from '@rjsf/core';
|
||||
import { FieldValidation } from '@rjsf/core';
|
||||
import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
@@ -240,6 +241,14 @@ export interface ScaffolderApi {
|
||||
templateRef: string,
|
||||
): Promise<TemplateParameterSchema>;
|
||||
listActions(): Promise<ListActionsResponse>;
|
||||
// (undocumented)
|
||||
listTasks?({
|
||||
filterByOwnership,
|
||||
}: {
|
||||
filterByOwnership: 'owned' | 'all';
|
||||
}): Promise<{
|
||||
tasks: ScaffolderTask[];
|
||||
}>;
|
||||
scaffold(
|
||||
options: ScaffolderScaffoldOptions,
|
||||
): Promise<ScaffolderScaffoldResponse>;
|
||||
@@ -255,6 +264,7 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
fetchApi: FetchApi;
|
||||
identityApi?: IdentityApi;
|
||||
scmIntegrationsApi: ScmIntegrationRegistry;
|
||||
useLongPollingLogs?: boolean;
|
||||
});
|
||||
@@ -272,6 +282,10 @@ export class ScaffolderClient implements ScaffolderApi {
|
||||
): Promise<TemplateParameterSchema>;
|
||||
// (undocumented)
|
||||
listActions(): Promise<ListActionsResponse>;
|
||||
// (undocumented)
|
||||
listTasks(options: { filterByOwnership: 'owned' | 'all' }): Promise<{
|
||||
tasks: ScaffolderTask[];
|
||||
}>;
|
||||
scaffold(
|
||||
options: ScaffolderScaffoldOptions,
|
||||
): Promise<ScaffolderScaffoldResponse>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@react-hookz/web": "^13.0.0",
|
||||
"@rjsf/core": "^3.2.1",
|
||||
"@material-table/core": "^3.1.0",
|
||||
"@rjsf/material-ui": "^3.2.1",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"@uiw/react-codemirror": "^4.7.0",
|
||||
|
||||
@@ -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,7 +58,11 @@ describe('api', () => {
|
||||
scmIntegrationsApi,
|
||||
discoveryApi,
|
||||
fetchApi,
|
||||
identityApi,
|
||||
});
|
||||
|
||||
jest.restoreAllMocks();
|
||||
identityApi.getBackstageIdentity.mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should return default and custom integrations', async () => {
|
||||
@@ -129,6 +140,7 @@ describe('api', () => {
|
||||
scmIntegrationsApi,
|
||||
discoveryApi,
|
||||
fetchApi,
|
||||
identityApi,
|
||||
useLongPollingLogs: true,
|
||||
});
|
||||
});
|
||||
@@ -305,4 +317,78 @@ 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({ filterByOwnership: 'all' });
|
||||
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({
|
||||
tasks: [
|
||||
{
|
||||
createdBy,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
tasks: [
|
||||
{
|
||||
createdBy: null,
|
||||
},
|
||||
{
|
||||
createdBy: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
identityApi.getBackstageIdentity.mockResolvedValueOnce({
|
||||
userEntityRef: 'user:default/foo',
|
||||
});
|
||||
|
||||
const result = await apiClient.listTasks({ filterByOwnership: 'owned' });
|
||||
expect(identityApi.getBackstageIdentity).toBeCalled();
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createApiRef,
|
||||
DiscoveryApi,
|
||||
FetchApi,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
import { ScmIntegrationRegistry } from '@backstage/integration';
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
ScaffolderDryRunOptions,
|
||||
ScaffolderDryRunResponse,
|
||||
} from './types';
|
||||
import queryString from 'qs';
|
||||
|
||||
/**
|
||||
* Utility API reference for the {@link ScaffolderApi}.
|
||||
@@ -58,11 +60,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;
|
||||
}) {
|
||||
@@ -70,6 +74,30 @@ 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(options: {
|
||||
filterByOwnership: 'owned' | 'all';
|
||||
}): Promise<{ tasks: ScaffolderTask[] }> {
|
||||
if (!this.identityApi) {
|
||||
throw new Error(
|
||||
'IdentityApi is not available in the ScaffolderClient, please pass through the IdentityApi to the ScaffolderClient constructor in order to use the listTasks method',
|
||||
);
|
||||
}
|
||||
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
|
||||
const { userEntityRef } = await this.identityApi.getBackstageIdentity();
|
||||
|
||||
const query = queryString.stringify(
|
||||
options.filterByOwnership === 'owned' ? { createdBy: userEntityRef } : {},
|
||||
);
|
||||
|
||||
const response = await this.fetchApi.fetch(`${baseUrl}/v2/tasks?${query}`);
|
||||
if (!response.ok) {
|
||||
throw await ResponseError.fromResponse(response);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getIntegrationsList(
|
||||
|
||||
@@ -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,249 @@
|
||||
/*
|
||||
* 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<Required<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({ tasks: [] });
|
||||
|
||||
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({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {
|
||||
user: { ref: 'user:default/foo' },
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/test',
|
||||
},
|
||||
} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
lastHeartbeatAt: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'One Template',
|
||||
steps: [],
|
||||
});
|
||||
|
||||
const { getByText, findByText } = await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, identityApi],
|
||||
[scaffolderApiRef, scaffolderApiMock],
|
||||
]}
|
||||
>
|
||||
<ListTasksPage />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/root': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(scaffolderApiMock.listTasks).toBeCalledWith({
|
||||
filterByOwnership: 'owned',
|
||||
});
|
||||
expect(getByText('List template tasks')).toBeInTheDocument();
|
||||
expect(getByText('All tasks that have been started')).toBeInTheDocument();
|
||||
expect(getByText('Tasks')).toBeInTheDocument();
|
||||
expect(await findByText('One Template')).toBeInTheDocument();
|
||||
expect(await findByText('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({
|
||||
tasks: [
|
||||
{
|
||||
id: 'a-random-id',
|
||||
spec: {
|
||||
user: { ref: 'user:default/foo' },
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/mock',
|
||||
},
|
||||
} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
lastHeartbeatAt: '',
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
tasks: [
|
||||
{
|
||||
id: 'b-random-id',
|
||||
spec: {
|
||||
templateInfo: {
|
||||
entityRef: 'template:default/mock',
|
||||
},
|
||||
user: {
|
||||
ref: 'user:default/boo',
|
||||
},
|
||||
} as any,
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
lastHeartbeatAt: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
|
||||
title: 'One Template',
|
||||
steps: [],
|
||||
});
|
||||
|
||||
const { getByText, findByText } = 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({
|
||||
filterByOwnership: 'all',
|
||||
});
|
||||
expect(await findByText('One Template')).toBeInTheDocument();
|
||||
expect(await findByText('OtherUser')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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 {
|
||||
CreatedAtColumn,
|
||||
OwnerEntityColumn,
|
||||
TaskStatusColumn,
|
||||
TemplateTitleColumn,
|
||||
} from './columns';
|
||||
|
||||
export interface MyTaskPageProps {
|
||||
initiallySelectedFilter?: 'owned' | 'all';
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (scaffolderApi.listTasks) {
|
||||
return scaffolderApi.listTasks?.({ filterByOwnership: ownerFilter });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'listTasks is not implemented in the scaffolderApi, please make sure to implement this method.',
|
||||
);
|
||||
|
||||
return Promise.resolve({ tasks: [] });
|
||||
}, [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?.tasks ?? []}
|
||||
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?.user?.ref} />
|
||||
),
|
||||
},
|
||||
{
|
||||
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,133 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
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: 'owned' | 'all') => 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 'owned' | 'all')}
|
||||
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 user', 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,49 @@
|
||||
/*
|
||||
* 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 useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { catalogApiRef, EntityRefLink } from '@backstage/plugin-catalog-react';
|
||||
import { parseEntityRef, UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
export const OwnerEntityColumn = ({ entityRef }: { entityRef?: string }) => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
|
||||
const { value, loading, error } = useAsync(
|
||||
() => catalogApi.getEntityByRef(entityRef || ''),
|
||||
[catalogApi, entityRef],
|
||||
);
|
||||
|
||||
if (!entityRef) {
|
||||
return <p>Unknown</p>;
|
||||
}
|
||||
|
||||
if (loading || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityRefLink
|
||||
entityRef={parseEntityRef(entityRef)}
|
||||
title={
|
||||
(value as UserEntity)?.spec?.profile?.displayName ??
|
||||
value?.metadata.name
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,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 { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import React from 'react';
|
||||
import { TemplateTitleColumn } from './TemplateTitleColumn';
|
||||
import { scaffolderApiRef } from '../../../api';
|
||||
import { ScaffolderApi } from '../../../types';
|
||||
import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
|
||||
describe('<TemplateTitleColumn />', () => {
|
||||
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
|
||||
scaffold: jest.fn(),
|
||||
getTemplateParameterSchema: jest.fn(),
|
||||
} as any;
|
||||
|
||||
it('should render the column with the template name', 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>,
|
||||
{ mountedRoutes: { '/test': entityRouteRef } },
|
||||
);
|
||||
|
||||
const text = getByText('One Template');
|
||||
expect(text).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { EntityRefLink } from '@backstage/plugin-catalog-react';
|
||||
|
||||
export const TemplateTitleColumn = ({ entityRef }: { entityRef?: string }) => {
|
||||
const scaffolder = useApi(scaffolderApiRef);
|
||||
const { value, loading, error } = useAsync(
|
||||
() => scaffolder.getTemplateParameterSchema(entityRef || ''),
|
||||
[scaffolder, entityRef],
|
||||
);
|
||||
|
||||
if (loading || error || !entityRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityRefLink entityRef={parseEntityRef(entityRef)} title={value?.title} />
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 { ListTasksPage } from './ListTasksPage';
|
||||
@@ -35,9 +35,11 @@ import { useElementFilter } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
actionsRouteRef,
|
||||
editRouteRef,
|
||||
scaffolderListTaskRouteRef,
|
||||
scaffolderTaskRouteRef,
|
||||
selectedTemplateRouteRef,
|
||||
} from '../routes';
|
||||
import { ListTasksPage } from './ListTasksPage';
|
||||
|
||||
/**
|
||||
* The props for the entrypoint `ScaffolderPage` component the plugin.
|
||||
@@ -118,6 +120,10 @@ export const Router = (props: RouterProps) => {
|
||||
</SecretsContextProvider>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={scaffolderListTaskRouteRef.path}
|
||||
element={<ListTasksPage />}
|
||||
/>
|
||||
<Route path={scaffolderTaskRouteRef.path} element={<TaskPageElement />} />
|
||||
<Route path={actionsRouteRef.path} element={<ActionsPage />} />
|
||||
<Route
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ScaffolderPageProps = {
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -24,10 +24,15 @@ 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';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import {
|
||||
actionsRouteRef,
|
||||
editRouteRef,
|
||||
scaffolderListTaskRouteRef,
|
||||
} from '../../routes';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
button: {
|
||||
@@ -38,6 +43,7 @@ const useStyles = makeStyles({
|
||||
export type ScaffolderPageContextMenuProps = {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
tasks?: boolean;
|
||||
};
|
||||
|
||||
export function ScaffolderPageContextMenu(
|
||||
@@ -45,11 +51,15 @@ export function ScaffolderPageContextMenu(
|
||||
) {
|
||||
const classes = useStyles();
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const pageLink = useRouteRef(rootRouteRef);
|
||||
const editLink = useRouteRef(editRouteRef);
|
||||
const actionsLink = useRouteRef(actionsRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showEditor = props.editor !== false;
|
||||
const showActions = props.actions !== false;
|
||||
const showTasks = props.tasks !== false;
|
||||
|
||||
if (!showEditor && !showActions) {
|
||||
return null;
|
||||
@@ -85,7 +95,7 @@ export function ScaffolderPageContextMenu(
|
||||
>
|
||||
<MenuList>
|
||||
{showEditor && (
|
||||
<MenuItem onClick={() => navigate(`${pageLink()}/edit`)}>
|
||||
<MenuItem onClick={() => navigate(editLink())}>
|
||||
<ListItemIcon>
|
||||
<Edit fontSize="small" />
|
||||
</ListItemIcon>
|
||||
@@ -93,13 +103,21 @@ export function ScaffolderPageContextMenu(
|
||||
</MenuItem>
|
||||
)}
|
||||
{showActions && (
|
||||
<MenuItem onClick={() => navigate(`${pageLink()}/actions`)}>
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -192,6 +192,12 @@ export interface ScaffolderApi {
|
||||
|
||||
getTask(taskId: string): Promise<ScaffolderTask>;
|
||||
|
||||
listTasks?({
|
||||
filterByOwnership,
|
||||
}: {
|
||||
filterByOwnership: 'owned' | 'all';
|
||||
}): Promise<{ tasks: ScaffolderTask[] }>;
|
||||
|
||||
getIntegrationsList(
|
||||
options: ScaffolderGetIntegrationsListOptions,
|
||||
): Promise<ScaffolderGetIntegrationsListResponse>;
|
||||
|
||||
Reference in New Issue
Block a user