diff --git a/workspaces/x2a/.changeset/eager-rats-jam.md b/workspaces/x2a/.changeset/eager-rats-jam.md new file mode 100644 index 0000000000..dc96cd4dd4 --- /dev/null +++ b/workspaces/x2a/.changeset/eager-rats-jam.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +The UI shows projects list with sorting and pagination. diff --git a/workspaces/x2a/.changeset/proud-months-smash.md b/workspaces/x2a/.changeset/proud-months-smash.md new file mode 100644 index 0000000000..a48787fa13 --- /dev/null +++ b/workspaces/x2a/.changeset/proud-months-smash.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +Adding pagination and filtering by permissions to the GET|POST /projects and GET /project/[id] endpoints. diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index a3d7756f76..3f44187d84 100644 --- a/workspaces/x2a/package.json +++ b/workspaces/x2a/package.json @@ -23,6 +23,7 @@ "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", + "chores": "yarn prettier:fix && yarn lint:all --fix && yarn tsc:full && yarn build:api-reports && yarn test:all", "new": "backstage-cli new --scope @red-hat-developer-hub", "postinstall": "cd ../../ && yarn install" }, diff --git a/workspaces/x2a/plugins/x2a-backend/package.json b/workspaces/x2a/plugins/x2a-backend/package.json index af7725bfa5..c956ef5b01 100644 --- a/workspaces/x2a/plugins/x2a-backend/package.json +++ b/workspaces/x2a/plugins/x2a-backend/package.json @@ -40,6 +40,7 @@ "@backstage/catalog-client": "^1.12.1", "@backstage/errors": "^1.2.7", "@backstage/plugin-catalog-node": "^1.20.0", + "@backstage/plugin-permission-common": "^0.9.4", "@backstage/types": "^1.2.2", "@kubernetes/client-node": "^1.4.0", "@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*", @@ -51,7 +52,6 @@ "devDependencies": { "@backstage/backend-test-utils": "^1.10.1", "@backstage/cli": "^0.34.5", - "@backstage/plugin-permission-common": "^0.9.4", "@backstage/repo-tools": "^0.16.2", "@types/express": "^4.17.6", "@types/knex": "^0.16.0", diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts index 5968f331c1..e525955a32 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts @@ -36,11 +36,13 @@ export const x2APlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, database: coreServices.database, logger: coreServices.logger, + permissionsSvc: coreServices.permissions, x2aDatabase: x2aDatabaseServiceRef, kubeService: kubeServiceRef, }, async init({ httpRouter, + permissionsSvc, x2aDatabase, logger, httpAuth, @@ -53,6 +55,7 @@ export const x2APlugin = createBackendPlugin({ await createRouter({ httpAuth, logger, + permissionsSvc, x2aDatabase, kubeService, }), diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index 61fa57460f..e62f3c8974 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -22,6 +22,7 @@ import { } from '@backstage/backend-test-utils'; import express from 'express'; import request from 'supertest'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { createRouter } from './router'; import { X2ADatabaseService } from './services/X2ADatabaseService'; @@ -40,7 +41,11 @@ const mockInputProject: ProjectsPostRequest = { abbreviation: 'MP', }; -async function createApp(client: Knex): Promise { +async function createApp( + client: Knex, + authorizeResult?: AuthorizeResult, + adminWriteResult?: AuthorizeResult, +): Promise { const x2aDatabase = X2ADatabaseService.create({ logger: mockServices.logger.mock(), dbClient: client, @@ -48,6 +53,26 @@ async function createApp(client: Knex): Promise { const router = await createRouter({ httpAuth: mockServices.httpAuth(), logger: mockServices.logger.mock(), + permissionsSvc: mockServices.permissions.mock({ + authorize: async (requests: any[]) => { + // Check which permission is being requested + const permission = requests[0]?.permission; + if ( + permission?.name === 'x2a.admin' && + permission?.attributes?.action === 'update' + ) { + // This is x2aAdminWritePermission + return [ + { + result: + adminWriteResult ?? authorizeResult ?? AuthorizeResult.ALLOW, + }, + ] as any; + } + // Default to the provided authorizeResult or ALLOW + return [{ result: authorizeResult ?? AuthorizeResult.ALLOW }] as any; + }, + }), x2aDatabase, kubeService: { getPods: jest.fn().mockResolvedValue({ items: [] }), @@ -109,7 +134,7 @@ describe('createRouter', () => { ); it.each(databases.eachSupportedId())( - 'should not allow unauthenticated requests to create a migration - %p', + 'should not allow unauthenticated requests to create a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); const app = await createApp(client); @@ -127,6 +152,62 @@ describe('createRouter', () => { }, ); + it.each(databases.eachSupportedId())( + 'should allow users with x2aUserPermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users with x2aAdminWritePermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should deny users without permissions from creating projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.DENY); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to create a project', + }, + }); + }, + ); + it.each(databases.eachSupportedId())( 'should get a project by id - %p', async databaseId => { @@ -225,4 +306,179 @@ describe('createRouter', () => { }); }, ); + + it.each(databases.eachSupportedId())( + 'should allow users with admin write permission to delete any project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + + // Create a project as user1 + // const user1Credentials = mockCredentials.user('user:default/user1'); + const user1CredentialsHeader = + mockCredentials.user.header('user:default/user1'); + const appWithCreate = await createApp(client, AuthorizeResult.ALLOW); + const createResponse = await request(appWithCreate) + .post('/projects') + .set('Authorization', user1CredentialsHeader) + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/user1'); + + // Verify project exists + const getResponse = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getResponse.status).toBe(200); + + // Try to delete as user2 (non-admin) - should fail because they didn't create it + // const user2Credentials = mockCredentials.user('user:default/user2'); + const user2CredentialsHeader = + mockCredentials.user.header('user:default/user2'); + const appNonAdmin = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + const deleteResponseNonAdmin = await request(appNonAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', user2CredentialsHeader) + .send(); + + // Non-admin user2 cannot delete project created by user1 + // The deleteProject filters by created_by, so deletedCount will be 0 + expect(deleteResponseNonAdmin.status).toBe(404); + expect(deleteResponseNonAdmin.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + + // Verify project still exists + const getAfterFailedDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterFailedDelete.status).toBe(200); + expect(getAfterFailedDelete.body.id).toBe(projectId); + + // Now delete as admin user (should succeed even though created by user1) + // const adminCredentials = mockCredentials.user('user:default/admin'); + const adminCredentialsHeader = + mockCredentials.user.header('user:default/admin'); + const appAdmin = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + const deleteResponseAdmin = await request(appAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', adminCredentialsHeader) + .send(); + + expect(deleteResponseAdmin.status).toBe(200); + expect(deleteResponseAdmin.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterDelete.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users without admin write permission to delete their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission, but can create projects + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create (has x2aUserPermission) + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project (as the default mock user) + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/mock'); + + // Delete own project (should succeed) + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDeleteResponse = await request(app) + .get(`/projects/${projectId}`) + .send(); + expect(getAfterDeleteResponse.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deletion fails due to permission filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + + // Note: The permission filtering happens at the database service level. + // When canWriteAll is false, deleteProject filters by created_by. + // Since the same user created and is deleting, it should succeed. + // Cross-user deletion prevention is tested in X2ADatabaseService tests. + // This test verifies the endpoint integration works correctly. + + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + // Same user can delete their own project + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deleting non-existent project even with admin write permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User has admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await request(app) + .delete(`/projects/${nonExistentId}`) + .send(); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + }, + ); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.ts b/workspaces/x2a/plugins/x2a-backend/src/router.ts index 303323b3a3..3f08208b87 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.ts @@ -14,10 +14,24 @@ * limitations under the License. */ -import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api'; -import { InputError, NotFoundError } from '@backstage/errors'; import { z } from 'zod'; -import express from 'express'; +import express, { Request } from 'express'; +import { + HttpAuthService, + LoggerService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + AuthorizePermissionResponse, + AuthorizeResult, + BasicPermission, +} from '@backstage/plugin-permission-common'; +import { + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { x2aDatabaseServiceRef } from './services/X2ADatabaseService'; import { @@ -27,25 +41,90 @@ import { } from './schema/openapi'; import { kubeServiceRef } from './services/KubeService'; +const isUserOfAdminViewPermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminViewPermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const isUserOfAdminWritePermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminWritePermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const authorize = async ( + request: Request, + anyOfPermissions: BasicPermission[], + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const decisionResponses: AuthorizePermissionResponse[][] = await Promise.all( + anyOfPermissions.map(permission => + permissionsSvc.authorize([{ permission }], { + credentials, + }), + ), + ); + + const decisions: AuthorizePermissionResponse[] = decisionResponses.map( + d => d?.[0] ?? { result: AuthorizeResult.DENY }, + ); + const allow = decisions.find(d => d.result === AuthorizeResult.ALLOW); + return ( + allow || { + result: AuthorizeResult.DENY, + } + ); +}; + export async function createRouter({ httpAuth, x2aDatabase, logger, + permissionsSvc, }: { httpAuth: HttpAuthService; x2aDatabase: typeof x2aDatabaseServiceRef.T; kubeService: typeof kubeServiceRef.T; logger: LoggerService; + permissionsSvc: PermissionsService; }): Promise { const router = await createOpenApiRouter(); router.get('/projects', async (req, res) => { const endpoint = 'GET /projects'; + logger.info(`${endpoint} request received`); + + // parse request query const projectsGetRequestSchema = z.object({ page: z.number().optional(), pageSize: z.number().optional(), + order: z.enum(['asc', 'desc']).optional(), sort: z - .enum(['createdAt', 'name', 'description', 'createdBy']) + .enum([ + 'createdAt', + 'name', + 'abbreviation', + 'status', + 'description', + 'createdBy', + ]) .optional(), }); @@ -61,7 +140,15 @@ export async function createRouter({ logger.info(`${endpoint} request received: query=${JSON.stringify(query)}`); - const { projects, totalCount } = await x2aDatabase.listProjects(); + // list projects + const { projects, totalCount } = await x2aDatabase.listProjects(query, { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }); const response: ProjectsGet['response'] = { totalCount, @@ -72,6 +159,20 @@ export async function createRouter({ router.post('/projects', async (req, res) => { const endpoint = 'POST /projects'; + logger.info(`${endpoint} request received`); + + // authorize request + const decision = await authorize( + req, + [x2aAdminWritePermission, x2aUserPermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('You are not allowed to create a project'); + } + + // parse request body const projectCreateRequestSchema = z.object({ name: z.string(), description: z.string(), @@ -86,6 +187,7 @@ export async function createRouter({ } const requestBody: ProjectsPost['body'] = parsedBody.data; + // create project const newProject = await x2aDatabase.createProject(requestBody, { credentials: await httpAuth.credentials(req, { allow: ['user'] }), }); @@ -99,7 +201,17 @@ export async function createRouter({ const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const project = await x2aDatabase.getProject({ projectId }); + const project = await x2aDatabase.getProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (!project) { throw new NotFoundError(`Project not found`); } @@ -110,7 +222,17 @@ export async function createRouter({ const endpoint = 'DELETE /projects/:projectId'; const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const deletedCount = await x2aDatabase.deleteProject({ projectId }); + const deletedCount = await x2aDatabase.deleteProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canWriteAll: await isUserOfAdminWritePermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (deletedCount === 0) { throw new NotFoundError(`Project not found`); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 343070a601..edf8a9d5a8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -24,6 +24,15 @@ paths: type: integer required: false description: Page size + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + required: false + description: Sort order, either ascending ("asc") or descending ("desc") - in: query name: sort schema: @@ -31,6 +40,8 @@ paths: enum: - createdAt - name + - abbreviation + - status - description - createdBy required: false diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 3a04fc68e8..72b291dc40 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -31,7 +31,14 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + order?: 'asc' | 'desc'; + sort?: + | 'createdAt' + | 'name' + | 'abbreviation' + | 'status' + | 'description' + | 'createdBy'; }; response: ProjectsGet200Response; }; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 2aa3634ebf..221a0154aa 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -56,6 +56,19 @@ export const spec = { "required": false, "description": "Page size" }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "required": false, + "description": "Sort order, either ascending (\"asc\") or descending (\"desc\")" + }, { "in": "query", "name": "sort", @@ -64,6 +77,8 @@ export const spec = { "enum": [ "createdAt", "name", + "abbreviation", + "status", "description", "createdBy" ] diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index e0ebcbe4dd..1bd52282b9 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -23,7 +23,7 @@ import { import { Knex } from 'knex'; import { X2ADatabaseService } from './X2ADatabaseService'; import { migrate } from './dbMigrate'; -import { toSorted } from '../utils'; +import { delay, toSorted } from '../utils'; const databases = TestDatabases.create({ // TODO: Reenable for 'POSTGRES_18' @@ -150,7 +150,8 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const result = await service.listProjects(); + const credentials = mockCredentials.user(); + const result = await service.listProjects({}, { credentials }); expect(result.projects).toEqual([]); expect(result.totalCount).toBe(0); @@ -172,6 +173,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); // To make sure the projects are created in the correct order await service.createProject( { name: 'Project 2', @@ -180,6 +182,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); await service.createProject( { name: 'Project 3', @@ -189,7 +192,10 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const result = await service.listProjects(); + const result = await service.listProjects( + { order: 'desc', sort: 'createdAt' }, + { credentials }, + ); expect(result.totalCount).toBe(3); expect(result.projects).toHaveLength(3); @@ -198,6 +204,428 @@ describe('X2ADatabaseService', () => { expect(result.projects[2].name).toBe('Project 1'); }, ); + + it.each(databases.eachSupportedId())( + 'should paginate results with page and pageSize - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + await delay(10); + } + + // First page with pageSize 2 + const page1 = await service.listProjects( + { page: 0, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page1.totalCount).toBe(5); + expect(page1.projects).toHaveLength(2); + expect(page1.projects[0].name).toBe('Project 5'); + expect(page1.projects[1].name).toBe('Project 4'); + + // Second page + const page2 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page2.totalCount).toBe(5); + expect(page2.projects).toHaveLength(2); + expect(page2.projects[0].name).toBe('Project 3'); + expect(page2.projects[1].name).toBe('Project 2'); + + // Third page (last page with 1 item) + const page3 = await service.listProjects( + { page: 2, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page3.totalCount).toBe(5); + expect(page3.projects).toHaveLength(1); + expect(page3.projects[0].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default pageSize when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create more than DEFAULT_PAGE_SIZE (10) projects + for (let i = 1; i <= 15; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + if (i < 15) await delay(10); + } + + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + + expect(result.totalCount).toBe(15); + expect(result.projects).toHaveLength(10); // Default page size + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name ascending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'asc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Alpha Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Zebra Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name descending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'desc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Zebra Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Alpha Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by createdBy - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials3 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials2 }, + ); + + const result = await service.listProjects( + { sort: 'createdBy', order: 'asc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.projects).toHaveLength(3); + // Should be sorted by created_by (userEntityRef) ascending + expect(result.projects[0].createdBy).toBe('user:default/user1'); + expect(result.projects[1].createdBy).toBe('user:default/user2'); + expect(result.projects[2].createdBy).toBe('user:default/user3'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is false - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 2 projects + await service.createProject( + { name: 'User1 Project 1', abbreviation: 'U1P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User1 Project 2', abbreviation: 'U1P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + + // User2 creates 2 projects + await service.createProject( + { name: 'User2 Project 1', abbreviation: 'U2P1', description: 'D3' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project 2', abbreviation: 'U2P2', description: 'D4' }, + { credentials: credentials2 }, + ); + + // User1 should only see their own projects + const user1Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(user1Result.totalCount).toBe(2); + expect(user1Result.projects).toHaveLength(2); + expect( + user1Result.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + + // User2 should only see their own projects + const user2Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials2, canViewAll: false }, + ); + expect(user2Result.totalCount).toBe(2); + expect(user2Result.projects).toHaveLength(2); + expect( + user2Result.projects.every(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return all projects when canViewAll is true - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials3 }, + ); + + // User1 with canViewAll should see all projects + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.totalCount).toBe(3); + expect(result.projects).toHaveLength(3); + expect( + result.projects.some(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user3'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + await service.createProject( + { name: 'User1 Project', abbreviation: 'U1P', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project', abbreviation: 'U2P', description: 'D2' }, + { credentials: credentials2 }, + ); + + // When canViewAll is undefined, should default to filtering + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1 }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(1); + expect(result.projects[0].createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should combine pagination and user filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `User1 Project ${i}`, + abbreviation: `U1P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials1 }, + ); + await delay(10); + } + + // User2 creates 3 projects + for (let i = 1; i <= 3; i++) { + await service.createProject( + { + name: `User2 Project ${i}`, + abbreviation: `U2P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials2 }, + ); + if (i < 3) await delay(10); + } + + // User1 should only see their 5 projects, paginated + const page1 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(page1.totalCount).toBe(5); // Total count should be 5, not 8 + expect(page1.projects).toHaveLength(2); + expect( + page1.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default sort and order when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials }, + ); + + const result = await service.listProjects({}, { credentials }); + + expect(result.projects).toHaveLength(3); + // Should default to created_at desc (newest first) + expect(result.projects[0].name).toBe('Project 3'); + expect(result.projects[1].name).toBe('Project 2'); + expect(result.projects[2].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should handle empty page gracefully - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + + // Request page 2 which doesn't exist + const result = await service.listProjects( + { page: 2, pageSize: 10 }, + { credentials }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(0); + }, + ); }); describe('getProject', () => { @@ -207,9 +635,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const project = await service.getProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const project = await service.getProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(project).toBeUndefined(); }, @@ -231,9 +663,12 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrievedProject = await service.getProject({ - projectId: createdProject.id, - }); + const retrievedProject = await service.getProject( + { + projectId: createdProject.id, + }, + { credentials }, + ); expect(retrievedProject).toBeDefined(); expect(retrievedProject?.id).toBe(createdProject.id); @@ -271,8 +706,14 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrieved1 = await service.getProject({ projectId: project1.id }); - const retrieved2 = await service.getProject({ projectId: project2.id }); + const retrieved1 = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); + const retrieved2 = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(retrieved1?.id).toBe(project1.id); expect(retrieved1?.name).toBe('Project 1'); @@ -280,6 +721,132 @@ describe('X2ADatabaseService', () => { expect(retrieved2?.name).toBe('Project 2'); }, ); + + it.each(databases.eachSupportedId())( + 'should return undefined when user tries to access project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to access user1's project (should be denied) + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: false }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when user accesses their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to access their own project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials, canViewAll: false }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when canViewAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canViewAll should be able to access user1's project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: true }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canViewAll is undefined, should default to filtering + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); }); describe('deleteProject', () => { @@ -289,9 +856,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const deletedCount = await service.deleteProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const deletedCount = await service.deleteProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(deletedCount).toBe(0); }, @@ -314,20 +885,29 @@ describe('X2ADatabaseService', () => { ); // Verify it exists - const beforeDelete = await service.getProject({ - projectId: project.id, - }); + const beforeDelete = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(beforeDelete).toBeDefined(); // Delete it - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify it's gone - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); }, ); @@ -356,27 +936,196 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const deletedCount = await service.deleteProject({ - projectId: project1.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project1.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project1 is deleted - const deleted = await service.getProject({ projectId: project1.id }); + const deleted = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); expect(deleted).toBeUndefined(); // Verify project2 still exists - const remaining = await service.getProject({ projectId: project2.id }); + const remaining = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(remaining).toBeDefined(); expect(remaining?.name).toBe('Project 2'); // Verify total count - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project2.id); }, ); + + it.each(databases.eachSupportedId())( + 'should return 0 when user tries to delete project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to delete user1's project (should fail - returns 0) + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: false }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when user deletes their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to delete their own project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials, canWriteAll: false }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when canWriteAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canWriteAll should be able to delete user1's project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: true }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canWriteAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canWriteAll is undefined, should default to filtering + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); }); describe('integration tests', () => { @@ -399,26 +1148,35 @@ describe('X2ADatabaseService', () => { ); // Read - const retrieved = await service.getProject({ projectId: project.id }); + const retrieved = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(retrieved).toBeDefined(); expect(retrieved?.name).toBe('Lifecycle Test'); // List - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project.id); // Delete - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify deletion - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); - const finalList = await service.listProjects(); + const finalList = await service.listProjects({}, { credentials }); expect(finalList.totalCount).toBe(0); }, ); @@ -894,15 +1652,21 @@ describe('X2ADatabaseService', () => { expect(modulesBefore).toHaveLength(3); // Delete the project - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project is deleted - const projectAfter = await service.getProject({ - projectId: project.id, - }); + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(projectAfter).toBeUndefined(); // Verify all modules are cascade deleted @@ -965,7 +1729,10 @@ describe('X2ADatabaseService', () => { }); // Delete project1 - await service.deleteProject({ projectId: project1.id }); + await service.deleteProject( + { projectId: project1.id }, + { credentials }, + ); // Verify project1 modules are cascade deleted expect( diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts index 27597c67e5..a921e7d8a8 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts @@ -24,8 +24,14 @@ import { BackstageUserPrincipal, } from '@backstage/backend-plugin-api'; import { Expand } from '@backstage/types'; -import { Project } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; +import { + Project, + DEFAULT_PAGE_ORDER, + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SORT, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { Knex } from 'knex'; +import { ProjectsGet } from '../schema/openapi'; // TODO: model via openapi schema export interface Module { @@ -74,6 +80,18 @@ export class X2ADatabaseService { }; } + // Map REST sort param to database column name + private mapSortToDatabaseColumn(sort?: string): string | undefined { + const mapping = { + createdAt: 'created_at', + createdBy: 'created_by', + finishedAt: 'finished_at', + startedAt: 'started_at', + }; + + return sort ? mapping[sort as keyof typeof mapping] || sort : undefined; + } + // Map a database row to a Module object private mapRowToModule(row: any): Module { return { @@ -96,6 +114,21 @@ export class X2ADatabaseService { }; } + // Filter query by user permissions + private filterPermissions( + queryBuilder: Knex.QueryBuilder, + canDoAll: boolean | undefined, + userEntityRef: string, + ): void { + if (!canDoAll) { + // Filter by the user who created the project. + // For admins, the WHERE clause is not applied so matching all records. + // If multiple non-admin users are allowed to do the action, we need to + // either pass their full list here or do the permission check outside of the DB + queryBuilder.where('created_by', userEntityRef); + } + } + async createProject( input: { name: string; @@ -135,38 +168,106 @@ export class X2ADatabaseService { return newProject; } - async listProjects(): Promise<{ projects: Project[]; totalCount: number }> { - this.#logger.info('listProjects called'); + async listProjects( + query: ProjectsGet['query'], + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise<{ projects: Project[]; totalCount: number }> { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info(`listProjects called by ${calledByUserRef}`); + const pageSize = query.pageSize || DEFAULT_PAGE_SIZE; // Fetch all records from the database const rows = await this.#dbClient('projects') + .limit(pageSize) + .offset((query.page || 0) * pageSize) .select('*') - .orderBy('created_at', 'desc'); + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .orderBy( + this.mapSortToDatabaseColumn(query.sort) || DEFAULT_PAGE_SORT, + query.order || DEFAULT_PAGE_ORDER, + ); + + const totalCount = (await this.#dbClient('projects') + .count('*', { as: 'count' }) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first()) as { count: number }; const projects: Project[] = rows.map(this.mapRowToProject); - const totalCount = projects.length; - this.#logger.debug(`Fetched ${totalCount} projects from database`); + this.#logger.debug( + `Fetched ${projects.length} out of ${totalCount.count} projects from database (permissions applied)`, + ); - return { projects, totalCount }; + return { projects, totalCount: totalCount.count }; } - async getProject({ - projectId, - }: { - projectId: string; - }): Promise { - this.#logger.info(`getProject called for projectId: ${projectId}`); - const row = await this.#dbClient('projects').where('id', projectId).first(); + async getProject( + { + projectId, + }: { + projectId: string; + }, + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `getProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); + + const row = await this.#dbClient('projects') + .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first(); + return row ? this.mapRowToProject(row) : undefined; } - async deleteProject({ projectId }: { projectId: string }): Promise { - this.#logger.info(`deleteProject called for projectId: ${projectId}`); + async deleteProject( + { projectId }: { projectId: string }, + options: { + credentials: BackstageCredentials; + canWriteAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `deleteProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); // Delete from the database const deletedCount = await this.#dbClient('projects') .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canWriteAll, + calledByUserRef, + ), + ) .delete(); if (deletedCount === 0) { diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts new file mode 100644 index 0000000000..d0b905867b --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts @@ -0,0 +1,25 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ + +/** + * Creates a promise that resolves after the specified number of milliseconds. + * + * @param ms - The number of milliseconds to wait before resolving + * @returns A promise that resolves after the specified delay + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts index 4a17edc5cb..5b4465bcac 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './toSorted'; +export * from './delay'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index 206d2bb9ef..2eed5f50f6 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -51,7 +51,14 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + order?: 'asc' | 'desc'; + sort?: + | 'createdAt' + | 'name' + | 'abbreviation' + | 'status' + | 'description' + | 'createdBy'; }; }; /** @@ -96,6 +103,7 @@ export class DefaultApiClient { * Returns a list of projects. * @param page - Page number * @param pageSize - Page size + * @param order - Sort order, either ascending (\"asc\") or descending (\"desc\") * @param sort - Sort by field */ public async projectsGet( @@ -105,7 +113,7 @@ export class DefaultApiClient { ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/projects{?page,pageSize,sort}`; + const uriTemplate = `/projects{?page,pageSize,order,sort}`; const uri = parser.parse(uriTemplate).expand({ ...request.query, diff --git a/workspaces/x2a/plugins/x2a-common/package.json b/workspaces/x2a/plugins/x2a-common/package.json index 3ae68373e9..0ca3e0a8fb 100644 --- a/workspaces/x2a/plugins/x2a-common/package.json +++ b/workspaces/x2a/plugins/x2a-common/package.json @@ -46,5 +46,8 @@ "@mareklibra", "@elai-shalev", "@eloycoto" - ] + ], + "dependencies": { + "@backstage/plugin-permission-common": "^0.9.4" + } } diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 36c0d7c16b..52f9831000 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -4,6 +4,17 @@ ```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; + +// @public +export const DEFAULT_PAGE_ORDER = "desc"; + +// @public +export const DEFAULT_PAGE_SIZE = 10; + +// @public +export const DEFAULT_PAGE_SORT = "created_at"; + // @public (undocumented) export class DefaultApiClient { constructor(options: { @@ -35,7 +46,8 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; - sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; + order?: 'asc' | 'desc'; + sort?: 'createdAt' | 'name' | 'abbreviation' | 'status' | 'description' | 'createdBy'; }; }; @@ -87,4 +99,16 @@ export type TypedResponse = Omit & { json: () => Promise; }; +// @public +export const x2aAdminViewPermission: BasicPermission; + +// @public +export const x2aAdminWritePermission: BasicPermission; + +// @public +export const x2aPermissions: BasicPermission[]; + +// @public +export const x2aUserPermission: BasicPermission; + ``` diff --git a/workspaces/x2a/plugins/x2a-common/src/constants.ts b/workspaces/x2a/plugins/x2a-common/src/constants.ts new file mode 100644 index 0000000000..70a73eed18 --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/constants.ts @@ -0,0 +1,32 @@ +/* + * Copyright Red Hat, Inc. + * + * 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. + */ +/** + * Default table page size in the UI. + * @public + */ +export const DEFAULT_PAGE_SIZE = 10; + +/** + * Default table page sort in the UI. + * @public + */ +export const DEFAULT_PAGE_SORT = 'created_at'; + +/** + * Default table page order in the UI. + * @public + */ +export const DEFAULT_PAGE_ORDER = 'desc'; diff --git a/workspaces/x2a/plugins/x2a-common/src/index.ts b/workspaces/x2a/plugins/x2a-common/src/index.ts index 3a1b21dcc5..8df814da10 100644 --- a/workspaces/x2a/plugins/x2a-common/src/index.ts +++ b/workspaces/x2a/plugins/x2a-common/src/index.ts @@ -20,3 +20,5 @@ * @packageDocumentation */ export * from '../client/src/schema/openapi'; +export * from './permissions'; +export * from './constants'; diff --git a/workspaces/x2a/plugins/x2a-common/src/permissions.ts b/workspaces/x2a/plugins/x2a-common/src/permissions.ts new file mode 100644 index 0000000000..122d7c120d --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/permissions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { createPermission } from '@backstage/plugin-permission-common'; + +/** + * Can view (read-only) all x2a projects and modules. + * + * @public + */ +export const x2aAdminViewPermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'read', + }, +}); + +/** + * Can create, update and delete x2a projects and modules. + * + * @public + */ +export const x2aAdminWritePermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'update', + }, +}); + +/** + * Can view and manage (read-write) x2a projects created by the user. + * + * @public + */ +export const x2aUserPermission = createPermission({ + name: 'x2a.user', + attributes: {}, +}); + +/** + * All x2a permissions. + * + * @public + */ +export const x2aPermissions = [ + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +]; diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx index e8163aa94f..40ce5b2675 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.test.tsx @@ -20,20 +20,64 @@ import { } from '@backstage/test-utils'; import { ProjectList } from './ProjectList'; import { discoveryApiRef, fetchApiRef } from '@backstage/core-plugin-api'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + Project, + ProjectsGet200Response, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; + +// Mock useSeedTestData to prevent it from making API calls during tests +jest.mock('../../useSeedTestData', () => ({ + useSeedTestData: jest.fn(), +})); + +const createMockProjects = (count: number, offset: number = 0): Project[] => { + return Array.from({ length: count }, (_, i) => { + const index = offset + i; + return { + id: `project-${index}`, + name: `Project ${index}`, + abbreviation: `P${index}`, + description: `Description ${index}`, + createdAt: new Date( + `2024-01-${String(index + 1).padStart(2, '0')}T00:00:00Z`, + ), + createdBy: `user:default/user${index}`, + }; + }); +}; + +const createMockResponse = ( + items: Project[], + totalCount: number, +): ProjectsGet200Response => ({ + items, + totalCount, +}); describe('ProjectList component', () => { - it('renders the progressbar', async () => { - const discoveryApiMock = mockApis.discovery({ + let fetchApiMock: jest.Mock; + let discoveryApiMock: ReturnType; + + beforeEach(() => { + discoveryApiMock = mockApis.discovery({ baseUrl: 'http://localhost:1234', }); - const fetchApiMock = { - fetch: jest.fn().mockReturnValue(new Promise(() => {})), - }; + fetchApiMock = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the progressbar', async () => { + fetchApiMock.mockReturnValue(new Promise(() => {})); const { findByRole } = await renderInTestApp( @@ -44,13 +88,360 @@ describe('ProjectList component', () => { // Wait for the progressbar to render const progressbar = await findByRole('progressbar'); expect(progressbar).toBeInTheDocument(); + }); + + describe('Columns', () => { + it('renders all expected columns', async () => { + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Check that all column headers are present + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Abbreviation')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Created At')).toBeInTheDocument(); + }); + + it('displays project data in columns', async () => { + const mockProjects = createMockProjects(2); + const mockResponse = createMockResponse(mockProjects, 2); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Check that project data is displayed + expect(screen.getByText('Project 0')).toBeInTheDocument(); + expect(screen.getByText('P0')).toBeInTheDocument(); + expect(screen.getByText('Description 0')).toBeInTheDocument(); + expect(screen.getByText('Project 1')).toBeInTheDocument(); + expect(screen.getByText('P1')).toBeInTheDocument(); + }); + }); + + describe('Sorting', () => { + it('calls API with correct sort parameters when Name column is clicked', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // Clear previous calls + fetchApiMock.mockClear(); + + // Click on Name column header to sort + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify the API was called with sort=name + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=name'); + }); + + it('calls API with correct sort parameters when Abbreviation column is clicked', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(5); + const mockResponse = createMockResponse(mockProjects, 5); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Click on Abbreviation column header + const abbreviationHeader = screen.getByText('Abbreviation'); + await user.click(abbreviationHeader); - // TODO: test on mock data + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); - // const table = await findByRole('table'); + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=abbreviation'); + }); + }); + + describe('Pagination', () => { + it('calls API with default page and pageSize on initial load', async () => { + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify initial call has page=0 and pageSize=10 (DEFAULT_PAGE_SIZE) + const firstCall = fetchApiMock.mock.calls[0]; + const url = firstCall[0] as string; + expect(url).toContain('page=0'); + expect(url).toContain('pageSize=10'); + }); + + it('calls API with updated page when navigating to next page', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Find and click the "Next Page" button + // Material Table uses aria-label for navigation buttons + const nextPageButton = screen.getByLabelText(/next page/i); + await user.click(nextPageButton); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('page=1'); + }); + + it('calls API with updated pageSize when changing rows per page', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); - // // Assert that the table contains the expected user data - // expect(table).toBeInTheDocument(); - // expect(getByText('Migration 1')).toBeInTheDocument(); + // Find the rows per page selector and change it + // Material Table typically uses a select element for page size + const rowsPerPageSelect = screen.getByLabelText(/rows per page/i); + await user.click(rowsPerPageSelect); + + const option20 = await screen.findByText('20'); + await user.click(option20); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('pageSize=20'); + }); + + it('displays correct total count in table title', async () => { + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 20); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + // The table title should show the number of items on current page, not totalCount + expect(screen.getByText(/Projects \(10\)/)).toBeInTheDocument(); + }); + }); + + describe('Combined functionality', () => { + it('maintains sort order when changing pages', async () => { + const user = userEvent.setup(); + const mockProjects = createMockProjects(10); + const mockResponse = createMockResponse(mockProjects, 25); + + fetchApiMock.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + fetchApiMock.mockClear(); + + // Sort by name descending + const nameHeader = screen.getByText('Name'); + await user.click(nameHeader); + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + fetchApiMock.mockClear(); + + // Navigate to next page + const nextPageButton = screen.getByLabelText(/next page/i); + await user.click(nextPageButton); + + await waitFor(() => { + expect(fetchApiMock).toHaveBeenCalled(); + }); + + // Verify that sort parameters are still present + const lastCall = + fetchApiMock.mock.calls[fetchApiMock.mock.calls.length - 1]; + const url = lastCall[0] as string; + expect(url).toContain('sort=name'); + expect(url).toContain('order=desc'); + expect(url).toContain('page=1'); + }); }); }); diff --git a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx index 77cfd3a89e..f40b4948b7 100644 --- a/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx +++ b/workspaces/x2a/plugins/x2a/src/components/ProjectList/ProjectList.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { @@ -25,20 +25,107 @@ import { } from '@backstage/core-components'; import DeleteIcon from '@material-ui/icons/Delete'; +import { Box, Grid } from '@material-ui/core'; import { + DEFAULT_PAGE_SIZE, Project, + ProjectsGet, ProjectsGet200Response, } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { useClientService } from '../../ClientService'; -import { Box, Grid } from '@material-ui/core'; + +type OrderDirection = ProjectsGet['query']['order']; + +const useColumns = ( + orderBy: number, + orderDirection: OrderDirection, +): TableColumn[] => + useMemo(() => { + const getDefaultSort = (index: number): OrderDirection => { + if (index === orderBy) { + return orderDirection; + } + return undefined; + }; + + const columns: TableColumn[] = [ + { title: 'Name', field: 'name', defaultSort: getDefaultSort(0) }, + { + title: 'Abbreviation', + field: 'abbreviation', + defaultSort: getDefaultSort(1), + }, + { title: 'Status', field: 'status', defaultSort: getDefaultSort(2) }, + { + title: 'Description', + field: 'description', + defaultSort: getDefaultSort(3), + }, + { + title: 'Created At', + render: (rowData: Project) => { + // TODO: Show human-readable duration instead, make sure sorting still works + return
{rowData.createdAt.toLocaleString()}
; + }, + defaultSort: getDefaultSort(4), + }, + // { + // title: 'Source Repository', + // field: 'sourceRepository', + // defaultSort: getDefaultSort(5), + // }, + ]; + return columns; + }, [orderBy, orderDirection]); + +const mapOrderByToSort = (orderBy: number): ProjectsGet['query']['sort'] => { + const mapping: ProjectsGet['query']['sort'][] = [ + 'name', + 'abbreviation', + 'status', + 'description', + 'createdAt', + ]; + + if (orderBy < 0) { + return mapping[0]; + } + + const result = mapping[orderBy]; + if (!result) { + throw new Error(`Invalid orderBy: ${orderBy}`); + } + return result; +}; type DenseTableProps = { forceRefresh: () => void; projects: Project[]; + totalCount: number; + page: number; + pageSize: number; + onPageChange: (page: number) => void; + onRowsPerPageChange: (pageSize: number) => void; + orderBy: number; + orderDirection: OrderDirection; + setOrderBy: (orderBy: number) => void; + setOrderDirection: (orderDirection: ProjectsGet['query']['order']) => void; }; -export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { +export const DenseTable = ({ + projects, + forceRefresh, + totalCount, + page, + pageSize, + onPageChange, + onRowsPerPageChange, + orderBy, + orderDirection, + setOrderBy, + setOrderDirection, +}: DenseTableProps) => { const clientService = useClientService(); const [error, setError] = useState(null); @@ -54,12 +141,12 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { } }; - const columns: TableColumn[] = [ - { title: 'Name', field: 'name' }, - { title: 'Status', field: 'status' }, - { title: 'Source Repository', field: 'sourceRepository' }, - ]; + const handleOrderChange = (sortBy: number, od: OrderDirection) => { + setOrderBy(sortBy); + setOrderDirection(od); + }; + const columns = useColumns(orderBy, orderDirection); const data = projects; const actions = [ @@ -95,14 +182,20 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { title={`Projects (${projects.length})`} options={{ search: false, - paging: false, - actionsColumnIndex: -1, + paging: true, + actionsColumnIndex: -1 /* to the row end */, padding: 'default', + pageSize: pageSize, }} columns={columns} data={data} actions={actions} detailPanel={getDetailPanel} + onOrderChange={handleOrderChange} + page={page} + onPageChange={onPageChange} + onRowsPerPageChange={onRowsPerPageChange} + totalCount={totalCount} /> @@ -110,18 +203,31 @@ export const DenseTable = ({ projects, forceRefresh }: DenseTableProps) => { }; export const ProjectList = () => { - const [refresh, setRefresh] = useState(0); const clientService = useClientService(); + const [refresh, setRefresh] = useState(0); + + const [orderBy, setOrderBy] = useState(0); + const [orderDirection, setOrderDirection] = useState('asc'); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const forceRefresh = useCallback(() => { + setRefresh(refresh + 1); + }, [refresh]); + const { value, loading, error } = useAsync(async (): Promise => { const response = await clientService.projectsGet({ query: { - /* TODO: pagination */ + order: orderDirection || 'asc', + sort: mapOrderByToSort(orderBy), + page, + pageSize, }, }); return await response.json(); - }, [refresh, clientService]); + }, [refresh, clientService, orderBy, orderDirection, page, pageSize]); if (loading) { return ; @@ -132,7 +238,16 @@ export const ProjectList = () => { return ( setRefresh(refresh + 1)} + totalCount={value?.totalCount || 0} + forceRefresh={forceRefresh} + orderBy={orderBy} + orderDirection={orderDirection} + setOrderBy={setOrderBy} + setOrderDirection={setOrderDirection} + page={page} + pageSize={pageSize} + onPageChange={setPage} + onRowsPerPageChange={setPageSize} /> ); }; diff --git a/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts b/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts new file mode 100644 index 0000000000..b5578a894f --- /dev/null +++ b/workspaces/x2a/plugins/x2a/src/useSeedTestData.ts @@ -0,0 +1,41 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { useEffect } from 'react'; +import { useClientService } from './ClientService'; + +/** + * Seed the database with test data. + * + * Never use in production. + */ +export const useSeedTestData = () => { + const clientService = useClientService(); + + useEffect(() => { + const doItAsync = async () => { + for (let i = 0; i < 10; i++) { + await clientService.projectsPost({ + body: { + name: `Test Project ${i}`, + description: `Test Description ${i}`, + abbreviation: `TP${i}`, + }, + }); + } + }; + doItAsync(); + }, [clientService]); +}; diff --git a/workspaces/x2a/yarn.lock b/workspaces/x2a/yarn.lock index a952b7aa1e..3e4bf96157 100644 --- a/workspaces/x2a/yarn.lock +++ b/workspaces/x2a/yarn.lock @@ -7831,6 +7831,7 @@ __metadata: resolution: "@red-hat-developer-hub/backstage-plugin-x2a-common@workspace:plugins/x2a-common" dependencies: "@backstage/cli": ^0.34.5 + "@backstage/plugin-permission-common": ^0.9.4 cross-fetch: ^4.1.0 uri-template: ^2.0.0 languageName: unknown