diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 66ac0dbf4c..994866db93 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -36,6 +36,13 @@ type Options = { defaultPageSize?: number; }; +function wildcardRegex(str: string): RegExp { + const pattern = str + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*'); + return new RegExp(`^${pattern}$`, 'i'); +} + export class TodoReaderService implements TodoService { private readonly todoReader: TodoReader; private readonly catalogClient: CatalogClient; @@ -62,7 +69,6 @@ export class TodoReaderService implements TodoService { const url = this.getEntitySourceUrl(entity); const todos = await this.todoReader.readTodos({ url }); - const totalCount = todos.items.length; let limit = req.limit ?? this.defaultPageSize; if (limit < 0) { @@ -77,7 +83,15 @@ export class TodoReaderService implements TodoService { } let items = todos.items; - const { orderBy } = req; + const { orderBy, filters } = req; + + if (filters) { + for (const { field, value } of filters) { + const pattern = wildcardRegex(value); + items = items.filter(item => item[field]?.match(pattern)); + } + } + if (orderBy) { const dir = orderBy.direction === 'asc' ? 1 : -1; const field = orderBy.field; @@ -98,7 +112,7 @@ export class TodoReaderService implements TodoService { return { items: items.slice(offset, offset + limit), - totalCount, + totalCount: items.length, offset, limit, }; diff --git a/plugins/todo-backend/src/service/router.ts b/plugins/todo-backend/src/service/router.ts index 7b73ff93af..d2eb7278f8 100644 --- a/plugins/todo-backend/src/service/router.ts +++ b/plugins/todo-backend/src/service/router.ts @@ -20,7 +20,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { TodoService } from './types'; -const ALLOWED_ORDER_BY_FIELDS = [ +const TODO_FIELDS = [ 'text', 'tag', 'author', @@ -43,11 +43,8 @@ export async function createRouter( router.get('/v1/todos', async (req, res) => { const offset = parseIntegerParam(req.query.offset, 'offset query'); const limit = parseIntegerParam(req.query.limit, 'limit query'); - const orderBy = parseOrderByParam( - req.query.orderBy, - 'orderBy query', - ALLOWED_ORDER_BY_FIELDS, - ); + const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS); + const filters = parseFilterParam(req.query.filter, TODO_FIELDS); const entityRef = req.query.entity; if (entityRef && typeof entityRef !== 'string') { @@ -67,6 +64,7 @@ export async function createRouter( offset, limit, orderBy, + filters, }); res.json(todos); }); @@ -90,22 +88,21 @@ function parseIntegerParam(str: unknown, ctx: string): number | undefined { function parseOrderByParam( str: unknown, - ctx: string, allowedFields: T, ): { field: T[number]; direction: 'asc' | 'desc' } | undefined { if (str === undefined) { return undefined; } if (typeof str !== 'string') { - throw new InputError(`invalid ${ctx}, must be a string`); + throw new InputError(`invalid orderBy query, must be a string`); } const [field, direction] = str.split('='); if (!field) { - throw new InputError(`invalid ${ctx}, field name is empty`); + throw new InputError(`invalid orderBy query, field name is empty`); } if (direction !== 'asc' && direction !== 'desc') { throw new InputError( - `invalid ${ctx}, order direction must be 'asc' or 'desc'`, + `invalid orderBy query, order direction must be 'asc' or 'desc'`, ); } @@ -116,3 +113,44 @@ function parseOrderByParam( } return { field, direction }; } + +function parseFilterParam( + str: unknown, + allowedFields: T, +): { field: T[number]; value: string }[] | undefined { + if (str === undefined) { + return undefined; + } + + const filters = new Array<{ field: T[number]; value: string }>(); + + const strs = [str].flat(); + for (const filterStr of strs) { + if (typeof filterStr !== 'string') { + throw new InputError( + `invalid filter query, must be a string or list of strings`, + ); + } + const splitIndex = filterStr.indexOf('='); + if (splitIndex <= 0) { + throw new InputError( + `invalid filter query, must separate field from value using '='`, + ); + } + + const field = filterStr.slice(0, splitIndex); + if (!allowedFields.includes(field)) { + throw new InputError( + `invalid filter field, must be one of ${allowedFields.join(', ')}`, + ); + } + + const value = filterStr.slice(splitIndex + 1); + if (!value) { + throw new InputError(`invalid filter query, value may not be empty`); + } + filters.push({ field, value }); + } + + return filters; +} diff --git a/plugins/todo-backend/src/service/types.ts b/plugins/todo-backend/src/service/types.ts index e37307a4cf..9216fe733d 100644 --- a/plugins/todo-backend/src/service/types.ts +++ b/plugins/todo-backend/src/service/types.ts @@ -17,14 +17,21 @@ import { EntityName } from '@backstage/catalog-model'; import { TodoItem } from '../lib'; +type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + export type ListTodosRequest = { entity?: EntityName; offset?: number; limit?: number; orderBy?: { - field: 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + field: Fields; direction: 'asc' | 'desc'; }; + filters?: { + field: Fields; + /** Value to filter by, with '*' used as wildcard */ + value: string; + }[]; }; export type ListTodosResponse = { diff --git a/plugins/todo/src/api/TodoClient.ts b/plugins/todo/src/api/TodoClient.ts index 07b5eabead..9d9fff0144 100644 --- a/plugins/todo/src/api/TodoClient.ts +++ b/plugins/todo/src/api/TodoClient.ts @@ -38,6 +38,7 @@ export class TodoClient implements TodoApi { offset, limit, orderBy, + filters, }: TodoListOptions): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('todo'); const token = await this.identityApi.getIdToken(); @@ -55,6 +56,11 @@ export class TodoClient implements TodoApi { if (orderBy) { query.set('orderBy', `${orderBy.field}=${orderBy.direction}`); } + if (filters) { + for (const filter of filters) { + query.append('filter', `${filter.field}=${filter.value}`); + } + } const res = await fetch(`${baseUrl}/v1/todos?${query}`, { headers: token diff --git a/plugins/todo/src/api/types.ts b/plugins/todo/src/api/types.ts index e615532113..10c810592c 100644 --- a/plugins/todo/src/api/types.ts +++ b/plugins/todo/src/api/types.ts @@ -37,14 +37,21 @@ export type TodoItem = { repoFilePath?: string; }; +type Fields = 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + export type TodoListOptions = { entity?: Entity; offset?: number; limit?: number; orderBy?: { - field: 'text' | 'tag' | 'author' | 'viewUrl' | 'repoFilePath'; + field: Fields; direction: 'asc' | 'desc'; }; + filters?: { + field: Fields; + /** Value to filter by, with '*' used as wildcard */ + value: string; + }[]; }; export type TodoListResult = { diff --git a/plugins/todo/src/components/TodoList/TodoList.tsx b/plugins/todo/src/components/TodoList/TodoList.tsx index 2ef8fceeef..101f1caac0 100644 --- a/plugins/todo/src/components/TodoList/TodoList.tsx +++ b/plugins/todo/src/components/TodoList/TodoList.tsx @@ -33,6 +33,7 @@ const columns: TableColumn[] = [ { title: 'Tag', field: 'tag', + filtering: false, }, { title: 'Text', @@ -57,6 +58,7 @@ const columns: TableColumn[] = [ { title: 'Author', field: 'author', + width: '20%', render: ({ author }) => , }, ]; @@ -80,7 +82,9 @@ export const TodoList = () => { sorting: true, draggable: false, paging: true, - paginationType: 'stepped', + filtering: true, + debounceInterval: 500, + filterCellStyle: { padding: '0 16px 0 20px' }, }} columns={columns} data={async query => { @@ -97,6 +101,10 @@ export const TodoList = () => { field: query.orderBy.field, direction: query.orderDirection, } as TodoListOptions['orderBy']), + filters: query?.filters?.map(filter => ({ + field: filter.column.field!, + value: `*${filter.value}*`, + })) as TodoListOptions['filters'], }); return { data: result.items,