From c3c5dd3849c0fd6380a559dd0e226890050ebd40 Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 28 Oct 2026 15:59:17 -0400 Subject: [PATCH 01/13] feat: record instrument repository syncs and the instruments that fail them An instrument that failed to compile or load during a repository import or sync was only written to the server log and skipped, so nobody knew it was missing. Every import and sync now writes an InstrumentRepoSyncLog with the number of instruments imported and, for each one that failed, its directory, the stage it failed at and why. A repository that could not be read at all is logged too. Sync now replaces a repository's instrument list with what the repository currently provides instead of adding to it. Instruments that drop off the list stay in the database with their provenance tag, so groups can still see and administer them. On the admin Instrument Repositories page: - a Failed column with the latest sync's failed-instrument count - a Sync All button (POST /v1/instrument-repos/sync/all) - a sortable sync history below the table, plus a per-repository Logs action (GET /v1/instrument-repos/sync/logs, admin only) - a Source link and an instrument search in the View dialog - long repository URLs are truncated instead of running into the next column POST /v1/instrument-repos/:id/sync now returns the sync log rather than the repository. Co-Authored-By: Claude Opus 5.5 --- .../docs/architecture/auth-and-permissions.md | 3 +- .../docs/architecture/instrument-pipeline.md | 5 +- apps/api/AGENTS.md | 4 +- apps/api/prisma/schema.prisma | 32 +++ .../instrument-repos.controller.test.ts | 26 +- .../instrument-repos.service.test.ts | 211 ++++++++++++++-- .../instrument-repos.controller.ts | 16 +- .../instrument-repos.service.ts | 225 +++++++++++++----- .../InstrumentRepoSyncLogTable.tsx | 131 ++++++++++ .../InstrumentRepoSyncLogTable.test.tsx | 105 ++++++++ .../InstrumentRepoSyncLogTable/index.ts | 1 + .../useInstrumentRepoSyncLogsQuery.test.ts | 70 ++++++ .../useSyncAllInstrumentReposMutation.test.ts | 120 ++++++++++ .../useSyncInstrumentRepoMutation.test.ts | 34 ++- .../hooks/useCreateInstrumentRepoMutation.ts | 5 + .../hooks/useInstrumentRepoSyncLogsQuery.ts | 19 ++ .../useSyncAllInstrumentReposMutation.ts | 75 ++++++ .../hooks/useSyncInstrumentRepoMutation.ts | 41 +++- .../instrument-repos/__tests__/index.test.tsx | 125 +++++++++- .../_app/admin/instrument-repos/index.tsx | 171 +++++++++++-- .../src/instrument-repo/instrument-repo.ts | 26 ++ .../_app/admin/instrument-repos/index.page.ts | 13 +- .../src/specs/admin-instrument-repos.spec.ts | 97 ++++++++ 23 files changed, 1433 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx create mode 100644 apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx create mode 100644 apps/web/src/components/InstrumentRepoSyncLogTable/index.ts create mode 100644 apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts create mode 100644 apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts create mode 100644 apps/web/src/hooks/useInstrumentRepoSyncLogsQuery.ts create mode 100644 apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts diff --git a/.agents/docs/architecture/auth-and-permissions.md b/.agents/docs/architecture/auth-and-permissions.md index 36ac2eca1..67bd73b3f 100644 --- a/.agents/docs/architecture/auth-and-permissions.md +++ b/.agents/docs/architecture/auth-and-permissions.md @@ -167,7 +167,8 @@ hand-written: **These two currently agree with each other but not with the derived list.** They contain `all`, `Assignment`, `Group`, `Instrument`, `InstrumentRecord`, `InstrumentRepo`, `Session`, `Subject`, -`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile` and `SetupState`, which +`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile`, `InstrumentRepoSyncLog` +and `SetupState`, which are therefore valid in `@RouteAccess` and in `AbilityFactory` rules (`InstrumentRecordFile` is used in both) but cannot be granted as an `additionalPermission`. Treat that as the existing state, not as a licence to widen it silently. diff --git a/.agents/docs/architecture/instrument-pipeline.md b/.agents/docs/architecture/instrument-pipeline.md index 6f0c79ae3..0d904750b 100644 --- a/.agents/docs/architecture/instrument-pipeline.md +++ b/.agents/docs/architecture/instrument-pipeline.md @@ -53,7 +53,10 @@ be created before its series. **A series that references another repository still fails to import.** Nothing resolves dependencies across repositories or retries afterwards, so whether it works depends on the order the repos were -added. Making this reliable needs a second resolution pass once every repo has been imported. +added. Making this reliable needs a second resolution pass once every repo has been imported. Until +then, the failure and its reason land in the repository's sync history (`InstrumentRepoSyncLog`, on +the admin Instrument Repositories page), and syncing the repository again once its dependencies are +stored imports the series. ## 2. Bundling diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index a9921157a..d60832522 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -121,8 +121,8 @@ CASL subjects are derived automatically from the Prisma `TypeMap`, so every mode without you doing anything. But the subjects a user can be _granted_ through `additionalPermissions` come from **two hand-maintained lists that must agree with each other**: `enum AppSubject` in `prisma/schema.prisma` and `$AppSubjectName` in `packages/schemas/src/core/core.ts`. Both currently -list the same eight models plus `all`. Three models — `AuditLog`, `InstrumentRecordFile` and -`SetupState` — appear in neither, so no user can be granted a permission naming them; they are +list the same eight models plus `all`. Four models — `AuditLog`, `InstrumentRecordFile`, +`InstrumentRepoSyncLog` and `SetupState` — appear in neither, so no user can be granted a permission naming them; they are reachable only through rules the ability factory writes itself. Adding a model that users should be able to hold a permission on means editing both lists. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0340ba29f..4045e8075 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -256,6 +256,38 @@ model InstrumentRepo { @@map("InstrumentRepoModel") } +enum InstrumentRepoSyncAction { + IMPORT + SYNC +} + +enum InstrumentRepoSyncStage { + BUNDLE + LOAD +} + +type InstrumentRepoSyncFailure { + instrumentDir String + message String + stage InstrumentRepoSyncStage +} + +// One entry per import or sync. Not related to InstrumentRepo, so the history outlives a deleted +// repository; `repoName` is denormalized for the same reason. +model InstrumentRepoSyncLog { + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId + action InstrumentRepoSyncAction + failures InstrumentRepoSyncFailure[] + importedCount Int + message String? + repoId String? @db.ObjectId + repoName String + + @@map("InstrumentRepoSyncLogModel") +} + // Subjects enum Sex { diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts index 19d4e4a4d..cafab21b8 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts @@ -51,11 +51,35 @@ describe('InstrumentReposController', () => { expect(service.sync).toHaveBeenCalledWith('repo-1'); }); + it("should sync every repository the current user's ability allows", async () => { + service.syncAll.mockResolvedValueOnce([]); + await expect(controller.syncAll(ability)).resolves.toStrictEqual([]); + expect(service.syncAll).toHaveBeenCalledWith({ ability }); + }); + + it("should scope the sync history to the current user's ability", async () => { + service.findSyncLogs.mockResolvedValueOnce([]); + await expect(controller.findSyncLogs(ability)).resolves.toStrictEqual([]); + expect(service.findSyncLogs).toHaveBeenCalledWith({ ability }); + }); + + it.each(['findSyncLogs'] as const)( + 'should restrict %s to administrators, since the history spans every repository', + (handler) => { + const reflector = new Reflector(); + expect(reflector.get(ROUTE_ACCESS_METADATA_KEY, InstrumentReposController.prototype[handler])).toStrictEqual({ + action: 'manage', + subject: 'all' + }); + } + ); + it.each([ ['create', 'create'], ['deleteById', 'delete'], ['findAll', 'read'], - ['sync', 'update'] + ['sync', 'update'], + ['syncAll', 'update'] ] as const)('should gate %s on the %s InstrumentRepo permission', (handler, action) => { const reflector = new Reflector(); expect(reflector.get(ROUTE_ACCESS_METADATA_KEY, InstrumentReposController.prototype[handler])).toStrictEqual({ diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts index 060227587..21437fe34 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts @@ -6,7 +6,13 @@ import { ConfigService, getModelToken, LoggingService } from '@douglasneuroinfor import type { Model } from '@douglasneuroinformatics/libnest'; import { MockFactory } from '@douglasneuroinformatics/libnest/testing'; import type { MockedInstance } from '@douglasneuroinformatics/libnest/testing'; -import { BadGatewayException, BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { + BadGatewayException, + BadRequestException, + ConflictException, + NotFoundException, + UnprocessableEntityException +} from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { bundle } from '@opendatacapture/instrument-bundler'; import JSZip from 'jszip'; @@ -31,10 +37,11 @@ type InternalService = { owner: string, repoName: string, accessToken?: string - ): Promise<{ createdIds: string[]; instrumentIds: string[] }>; + ): Promise<{ createdIds: string[]; failures: unknown[]; instrumentIds: string[] }>; normalizeUrl(url: string): string; parseGitHubUrl(url: string): { owner: string; repoName: string }; reconcileOrphanedInstruments(): Promise; + syncRepo(repo: unknown): Promise<{ error: unknown; ok: false } | { ok: true; repo: { id: string } }>; }; describe('InstrumentReposService', () => { @@ -43,6 +50,7 @@ describe('InstrumentReposService', () => { let groupModel: MockedInstance>; let instrumentModel: MockedInstance>; let instrumentRepoModel: MockedInstance>; + let instrumentRepoSyncLogModel: MockedInstance>; let configService: MockedInstance; let instrumentsService: MockedInstance; let loggingService: MockedInstance; @@ -54,6 +62,7 @@ describe('InstrumentReposService', () => { MockFactory.createForModelToken(getModelToken('Group')), MockFactory.createForModelToken(getModelToken('Instrument')), MockFactory.createForModelToken(getModelToken('InstrumentRepo')), + MockFactory.createForModelToken(getModelToken('InstrumentRepoSyncLog')), MockFactory.createForService(ConfigService), MockFactory.createForService(InstrumentsService), MockFactory.createForService(LoggingService) @@ -64,6 +73,7 @@ describe('InstrumentReposService', () => { groupModel = moduleRef.get(getModelToken('Group')); instrumentModel = moduleRef.get(getModelToken('Instrument')); instrumentRepoModel = moduleRef.get(getModelToken('InstrumentRepo')); + instrumentRepoSyncLogModel = moduleRef.get(getModelToken('InstrumentRepoSyncLog')); configService = moduleRef.get(ConfigService); instrumentsService = moduleRef.get(InstrumentsService); @@ -76,6 +86,9 @@ describe('InstrumentReposService', () => { groupModel.count.mockResolvedValue(0); instrumentModel.findMany.mockResolvedValue([]); instrumentRepoModel.findMany.mockResolvedValue([]); + instrumentRepoSyncLogModel.create.mockImplementation( + ({ data }) => Promise.resolve({ id: 'log-1', ...data }) as never + ); }); it('should be defined', () => { @@ -172,7 +185,7 @@ describe('InstrumentReposService', () => { it('imports a new repo, parsing owner/repoName from the URL and storing an encrypted token', async () => { const importSpy = vi .spyOn(internal, 'importInstruments') - .mockResolvedValue({ createdIds: ['i1'], instrumentIds: ['i1'] }); + .mockResolvedValue({ createdIds: ['i1'], failures: [], instrumentIds: ['i1'] }); instrumentRepoModel.findFirst.mockResolvedValueOnce(null); instrumentRepoModel.create.mockResolvedValueOnce({ id: 'r1', name: 'repo' }); @@ -195,7 +208,7 @@ describe('InstrumentReposService', () => { }); it('stores a null token when none is provided', async () => { - vi.spyOn(internal, 'importInstruments').mockResolvedValue({ createdIds: [], instrumentIds: [] }); + vi.spyOn(internal, 'importInstruments').mockResolvedValue({ createdIds: [], failures: [], instrumentIds: [] }); instrumentRepoModel.findFirst.mockResolvedValueOnce(null); instrumentRepoModel.create.mockResolvedValueOnce({ id: 'r1', name: 'repo' }); @@ -208,18 +221,63 @@ describe('InstrumentReposService', () => { it('re-syncs an already-registered repo instead of creating a duplicate', async () => { instrumentRepoModel.findFirst.mockResolvedValueOnce({ id: 'existing' }); - const syncSpy = vi.spyOn(service, 'sync').mockResolvedValue({ id: 'existing' } as never); + const syncSpy = vi + .spyOn(internal, 'syncRepo') + .mockResolvedValue({ ok: true, repo: { accessToken: 'secret', id: 'existing' } as never }); const result = await service.create({ url: 'https://github.com/owner/repo' }); - expect(syncSpy).toHaveBeenCalledWith('existing'); + expect(syncSpy).toHaveBeenCalledWith({ id: 'existing' }); expect(instrumentRepoModel.create).not.toHaveBeenCalled(); - expect(result).toMatchObject({ id: 'existing' }); + expect(result).toStrictEqual({ id: 'existing' }); + }); + + it('should report a failed re-sync of an already-registered repo, so re-adding it is not silent', async () => { + const error = new BadGatewayException('GitHub is down'); + instrumentRepoModel.findFirst.mockResolvedValueOnce({ id: 'existing' }); + vi.spyOn(internal, 'syncRepo').mockResolvedValue({ error, ok: false }); + + await expect(service.create({ url: 'https://github.com/owner/repo' })).rejects.toBe(error); + }); + + it('should log a failed import with no repo id, because the repo was never created', async () => { + const error = new BadRequestException('Repository not found'); + instrumentRepoModel.findFirst.mockResolvedValueOnce(null); + vi.spyOn(internal, 'importInstruments').mockRejectedValue(error); + + await expect(service.create({ url: 'https://github.com/owner/repo' })).rejects.toBe(error); + expect(instrumentRepoModel.create).not.toHaveBeenCalled(); + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data).toStrictEqual({ + action: 'IMPORT', + failures: [], + importedCount: 0, + message: 'Repository not found', + repoId: null, + repoName: 'repo' + }); + }); + + it('should log a successful import against the new repo, with the instruments that failed', async () => { + const failures = [{ instrumentDir: 'A', message: 'broken', stage: 'LOAD' }]; + instrumentRepoModel.findFirst.mockResolvedValueOnce(null); + instrumentRepoModel.create.mockResolvedValueOnce({ id: 'r1', name: 'repo' }); + vi.spyOn(internal, 'importInstruments').mockResolvedValue({ createdIds: [], failures, instrumentIds: ['b'] }); + + await service.create({ url: 'https://github.com/owner/repo' }); + + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data).toStrictEqual({ + action: 'IMPORT', + failures, + importedCount: 1, + message: null, + repoId: 'r1', + repoName: 'repo' + }); }); it('persists an updated token before re-syncing an existing repo', async () => { instrumentRepoModel.findFirst.mockResolvedValueOnce({ id: 'existing' }); - vi.spyOn(service, 'sync').mockResolvedValue({ id: 'existing' } as never); + vi.spyOn(internal, 'syncRepo').mockResolvedValue({ ok: true, repo: { id: 'existing' } }); await service.create({ accessToken: 'new-token', url: 'https://github.com/owner/repo' }); @@ -233,7 +291,7 @@ describe('InstrumentReposService', () => { it('does not touch the stored token when re-adding without one', async () => { instrumentRepoModel.findFirst.mockResolvedValueOnce({ id: 'existing' }); - vi.spyOn(service, 'sync').mockResolvedValue({ id: 'existing' } as never); + vi.spyOn(internal, 'syncRepo').mockResolvedValue({ ok: true, repo: { id: 'existing' } }); await service.create({ url: 'https://github.com/owner/repo' }); @@ -247,7 +305,7 @@ describe('InstrumentReposService', () => { await expect(service.sync('missing')).rejects.toBeInstanceOf(NotFoundException); }); - it('decrypts the stored token, unions the instrument ids, and strips secrets', async () => { + it('should replace the instrument list with what the repo provides now, and return the sync log', async () => { const encryptedToken = internal.encrypt('stored-token'); instrumentRepoModel.findFirst.mockResolvedValueOnce({ accessToken: encryptedToken, @@ -259,7 +317,7 @@ describe('InstrumentReposService', () => { }); const importSpy = vi .spyOn(internal, 'importInstruments') - .mockResolvedValue({ createdIds: ['new'], instrumentIds: ['new'] }); + .mockResolvedValue({ createdIds: ['new'], failures: [], instrumentIds: ['new'] }); instrumentRepoModel.update.mockResolvedValueOnce({ accessToken: encryptedToken, id: 'r1', name: 'repo' }); const result = await service.sync('r1'); @@ -267,9 +325,66 @@ describe('InstrumentReposService', () => { // The decrypted token (not the ciphertext) is what gets used to fetch the repo. expect(importSpy).toHaveBeenCalledWith('owner', 'repo', 'stored-token'); expect(instrumentRepoModel.update).toHaveBeenCalledWith( - expect.objectContaining({ data: expect.objectContaining({ instrumentIds: ['old', 'new'] }) }) + expect.objectContaining({ data: expect.objectContaining({ instrumentIds: ['new'] }) }) ); - expect(result).not.toHaveProperty('accessToken'); + expect(result).toMatchObject({ action: 'SYNC', importedCount: 1, message: null, repoId: 'r1' }); + }); + + it('should log a sync that could not read the repo, then report it', async () => { + const error = new BadGatewayException('GitHub is down'); + instrumentRepoModel.findFirst.mockResolvedValueOnce({ + accessToken: null, + id: 'r1', + instrumentIds: ['old'], + name: 'repo', + owner: 'owner', + repoName: 'repo' + }); + vi.spyOn(internal, 'importInstruments').mockRejectedValue(error); + + await expect(service.sync('r1')).rejects.toBe(error); + expect(instrumentRepoModel.update).not.toHaveBeenCalled(); + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data).toMatchObject({ + action: 'SYNC', + message: 'GitHub is down', + repoId: 'r1' + }); + }); + }); + + describe('syncAll', () => { + const repo = (id: string) => ({ accessToken: null, id, instrumentIds: [], name: id, owner: 'owner', repoName: id }); + + it('should sync every repo and carry on past one that cannot be read, returning a log for each', async () => { + instrumentRepoModel.findMany.mockResolvedValueOnce([repo('a'), repo('b')]); + instrumentRepoModel.update.mockResolvedValue({ id: 'b' }); + vi.spyOn(internal, 'importInstruments') + .mockRejectedValueOnce(new BadGatewayException('GitHub is down')) + .mockResolvedValueOnce({ createdIds: [], failures: [], instrumentIds: ['x'] }); + + const logs = await service.syncAll(); + + expect(logs).toMatchObject([ + { message: 'GitHub is down', repoId: 'a' }, + { importedCount: 1, message: null, repoId: 'b' } + ]); + }); + + it('should only sync the repos the caller may update', async () => { + instrumentRepoModel.findMany.mockResolvedValueOnce([]); + + await service.syncAll(); + + expect(instrumentRepoModel.findMany).toHaveBeenCalledWith({ where: {} }); + }); + }); + + describe('findSyncLogs', () => { + it('should return the history newest first', async () => { + instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([{ id: 'log-1' }]); + + await expect(service.findSyncLogs()).resolves.toStrictEqual([{ id: 'log-1' }]); + expect(instrumentRepoSyncLogModel.findMany).toHaveBeenCalledWith({ orderBy: { createdAt: 'desc' }, where: {} }); }); it('should download without a token when the repo stores none, so public repos sync', async () => { @@ -283,7 +398,7 @@ describe('InstrumentReposService', () => { }); const importSpy = vi .spyOn(internal, 'importInstruments') - .mockResolvedValue({ createdIds: [], instrumentIds: [] }); + .mockResolvedValue({ createdIds: [], failures: [], instrumentIds: [] }); instrumentRepoModel.update.mockResolvedValueOnce({ id: 'r1' }); await service.sync('r1'); @@ -593,7 +708,7 @@ describe('InstrumentReposService', () => { expect(instrumentModel.update).not.toHaveBeenCalled(); }); - it('should log a conflict that does not name the existing instrument and leave it out', async () => { + it('should record a conflict that does not name the existing instrument as a failure', async () => { const message = 'An instrument with the same title already exists'; fetchMock.mockResolvedValueOnce(await zipball(instrumentFiles('forms', 'A'))); instrumentsService.create.mockRejectedValueOnce(new ConflictException(message)); @@ -601,11 +716,9 @@ describe('InstrumentReposService', () => { await service.create({ url }); expect(instrumentRepoModel.create.mock.lastCall?.[0].data.instrumentIds).toStrictEqual([]); - expect(loggingService.error).toHaveBeenCalledWith({ - conflictMessage: message, - error: 'Cannot recover instrument id from conflict', - instrumentDir: 'A' - }); + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data.failures).toStrictEqual([ + { instrumentDir: 'A', message: `Cannot recover the existing instrument's id from: ${message}`, stage: 'LOAD' } + ]); }); it('should log an instrument that fails to import and carry on with the rest', async () => { @@ -618,11 +731,65 @@ describe('InstrumentReposService', () => { await service.create({ url }); expect(loggingService.error).toHaveBeenCalledWith({ - cause, error: 'Failed to Import Instrument', - instrumentDir: 'A' + instrumentDir: 'A', + message: 'invalid instrument', + stage: 'LOAD' }); expect(instrumentRepoModel.create.mock.lastCall?.[0].data.instrumentIds).toStrictEqual(['b-id']); + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data).toMatchObject({ + failures: [{ instrumentDir: 'A', message: 'invalid instrument', stage: 'LOAD' }], + importedCount: 1 + }); + }); + + it('should record an instrument whose source does not compile as a bundle failure', async () => { + fetchMock.mockResolvedValueOnce(await zipball(instrumentFiles('forms', 'A'))); + vi.mocked(bundle).mockRejectedValueOnce(new Error('Unexpected token')); + + await service.create({ url }); + + expect(instrumentsService.create).not.toHaveBeenCalled(); + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data.failures).toStrictEqual([ + { instrumentDir: 'A', message: 'Unexpected token', stage: 'BUNDLE' } + ]); + }); + + it.each([ + { + error: new UnprocessableEntityException({ + cause: { message: 'foo is not defined', name: 'ReferenceError' }, + message: 'Failed to interpret instrument bundle' + }), + expected: 'Failed to interpret instrument bundle - foo is not defined', + reason: 'the error the bundle threw when evaluated' + }, + { + error: new UnprocessableEntityException({ + issues: [ + { message: 'Required', path: ['details', 'title'] }, + { message: 'Invalid instrument', path: [] } + ], + message: 'Instrument validation failed' + }), + expected: 'Instrument validation failed - details.title: Required - Invalid instrument', + reason: 'each validation issue and where it is' + }, + { + error: new UnprocessableEntityException("Series item 'PHQ_9' edition 2 does not exist"), + expected: "Series item 'PHQ_9' edition 2 does not exist", + reason: 'the message alone when there is no further detail' + }, + { error: 'not an error', expected: 'not an error', reason: 'whatever was thrown when it is not an Error' } + ])('should explain a load failure with $reason', async ({ error, expected }) => { + fetchMock.mockResolvedValueOnce(await zipball(instrumentFiles('forms', 'A'))); + instrumentsService.create.mockRejectedValueOnce(error); + + await service.create({ url }); + + expect(instrumentRepoSyncLogModel.create.mock.lastCall?.[0].data.failures).toStrictEqual([ + { instrumentDir: 'A', message: expected, stage: 'LOAD' } + ]); }); it('should read media as binary, so images reach the bundler intact', async () => { diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index f6fb6e7a1..bf51195d8 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -3,7 +3,7 @@ import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; import type { AppAbility } from '@/auth/auth.types'; -import { RouteAccess } from '@/core/decorators/route-access.decorator'; +import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; import { InstrumentReposService } from './instrument-repos.service'; @@ -32,10 +32,24 @@ export class InstrumentReposController { return this.instrumentReposService.findAll({ ability }); } + @ApiOperation({ summary: 'Get the Sync History of Every Instrument Repo' }) + @Get('sync/logs') + @RouteAccess(ADMIN_ONLY) + findSyncLogs(@CurrentUser('ability') ability?: AppAbility) { + return this.instrumentReposService.findSyncLogs({ ability }); + } + @ApiOperation({ summary: 'Sync Instrument Repo' }) @Post(':id/sync') @RouteAccess({ action: 'update', subject: 'InstrumentRepo' }) sync(@Param('id') id: string) { return this.instrumentReposService.sync(id); } + + @ApiOperation({ summary: 'Sync Every Instrument Repo' }) + @Post('sync/all') + @RouteAccess({ action: 'update', subject: 'InstrumentRepo' }) + syncAll(@CurrentUser('ability') ability?: AppAbility) { + return this.instrumentReposService.syncAll({ ability }); + } } diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index a55284f99..5753b3c7a 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -8,6 +8,7 @@ import { BadGatewayException, BadRequestException, ConflictException, + HttpException, Injectable, NotFoundException } from '@nestjs/common'; @@ -15,18 +16,58 @@ import type { OnModuleInit } from '@nestjs/common'; import { bundle, BUNDLER_FILE_EXT_REGEX, inferLoader } from '@opendatacapture/instrument-bundler'; import type { BundlerInput } from '@opendatacapture/instrument-bundler'; import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; +import type { + InstrumentRepoSyncAction, + InstrumentRepoSyncFailure, + InstrumentRepoSyncStage +} from '@opendatacapture/schemas/instrument-repo'; import JSZip from 'jszip'; +import { z } from 'zod/v4'; import { accessibleQuery } from '@/auth/ability.utils'; import { decryptSecret, encryptSecret } from '@/core/secret-cipher'; import type { EntityOperationOptions } from '@/core/types'; import { InstrumentsService } from '@/instruments/instruments.service'; +type InstrumentImportResult = + | { created: boolean; id: string; kind: 'imported' } + | { kind: 'failed'; message: string; stage: InstrumentRepoSyncStage } + | { kind: 'skipped' }; + +type RepoImportResult = { createdIds: string[]; failures: InstrumentRepoSyncFailure[]; instrumentIds: string[] }; + +type InstrumentRepoRecord = Awaited['findFirstOrThrow']>>; + +/** + * The detail Nest exceptions thrown by `InstrumentsService.create` carry beyond their message: a VM + * evaluation error as `cause`, schema validation errors as `issues`. + */ +const $ExceptionDetails = z.object({ + cause: z.object({ message: z.string() }).optional(), + issues: z.array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) })).optional() +}); + +function describeImportError(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + const details = $ExceptionDetails.safeParse(error instanceof HttpException ? error.getResponse() : undefined); + if (!details.success) { + return error.message; + } + const reasons = [ + details.data.cause?.message, + ...(details.data.issues ?? []).map(({ message, path }) => (path.length ? `${path.join('.')}: ${message}` : message)) + ].filter((reason) => reason !== undefined); + return [error.message, ...reasons].join(' - '); +} + @Injectable() export class InstrumentReposService implements OnModuleInit { constructor( @InjectModel('Group') private readonly groupModel: Model<'Group'>, @InjectModel('InstrumentRepo') private readonly instrumentRepoModel: Model<'InstrumentRepo'>, + @InjectModel('InstrumentRepoSyncLog') private readonly instrumentRepoSyncLogModel: Model<'InstrumentRepoSyncLog'>, @InjectModel('Instrument') private readonly instrumentModel: Model<'Instrument'>, private readonly configService: ConfigService, private readonly instrumentsService: InstrumentsService, @@ -48,10 +89,21 @@ export class InstrumentReposService implements OnModuleInit { where: { id: existing.id } }); } - return this.sync(existing.id); + const result = await this.syncRepo(existing); + if (!result.ok) { + throw result.error; + } + return this.stripSecrets(result.repo); } - const { createdIds, instrumentIds } = await this.importInstruments(owner, repoName, accessToken); + let result: RepoImportResult; + try { + result = await this.importInstruments(owner, repoName, accessToken); + } catch (err) { + await this.recordSync({ action: 'IMPORT', error: err, repoId: null, repoName }); + throw err; + } + const { createdIds, failures, instrumentIds } = result; const repo = await this.instrumentRepoModel.create({ data: { @@ -68,6 +120,13 @@ export class InstrumentReposService implements OnModuleInit { // Attribute provenance only to instruments this import actually created. Instruments that // already existed (e.g. added manually) keep their original provenance. await this.tagInstruments(createdIds, repo.id, repo.name); + await this.recordSync({ + action: 'IMPORT', + failures, + importedCount: instrumentIds.length, + repoId: repo.id, + repoName + }); // Re-adopt any pre-existing instruments orphaned by a previously-deleted copy of this repo. await this.reconcileOrphanedInstruments(); @@ -105,6 +164,13 @@ export class InstrumentReposService implements OnModuleInit { return repos.map((repo) => this.stripSecrets(repo)); } + async findSyncLogs({ ability }: EntityOperationOptions = {}) { + return this.instrumentRepoSyncLogModel.findMany({ + orderBy: { createdAt: 'desc' }, + where: accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog') + }); + } + async onModuleInit(): Promise { // Self-heal any instruments left orphaned by repositories deleted before this logic existed. try { @@ -119,26 +185,26 @@ export class InstrumentReposService implements OnModuleInit { if (!repo) { throw new NotFoundException(`Failed to find instrument repo with ID: ${id}`); } + const result = await this.syncRepo(repo); + if (!result.ok) { + throw result.error; + } + return result.log; + } - const accessToken = repo.accessToken ? this.decrypt(repo.accessToken) : undefined; - const { createdIds, instrumentIds } = await this.importInstruments(repo.owner, repo.repoName, accessToken); - - const allInstrumentIds = [...new Set([...repo.instrumentIds, ...instrumentIds])]; - - // Only newly-created instruments need provenance set; ones already in the repo already have it. - await this.tagInstruments(createdIds, repo.id, repo.name); - - const updated = await this.instrumentRepoModel.update({ - data: { - instrumentIds: allInstrumentIds, - lastSyncedAt: new Date() - }, - where: { id } + /** + * Sync every repository the caller may update, one at a time. A repository that cannot be read does + * not stop the rest: its failure is in the sync log returned for it. + */ + async syncAll({ ability }: EntityOperationOptions = {}) { + const repos = await this.instrumentRepoModel.findMany({ + where: accessibleQuery(ability, 'update', 'InstrumentRepo') }); - // Re-adopt any instruments that were orphaned by a previously-deleted copy of this repo. - await this.reconcileOrphanedInstruments(); - - return this.stripSecrets(updated); + const logs = []; + for (const repo of repos) { + logs.push((await this.syncRepo(repo)).log); + } + return logs; } /** @@ -264,24 +330,28 @@ export class InstrumentReposService implements OnModuleInit { } /** - * Bundle and create a single instrument from its directory. Returns its id and whether it was newly - * created, or `null` when there is nothing to import (no bundlable files). Re-throws unexpected - * errors so the caller can log and skip just this instrument. + * Bundle and create a single instrument from its directory. A failure is returned rather than thrown, + * with the stage it happened at, so the caller can record it and carry on with the rest of the repo. */ - private async importInstrumentFromDir(dir: string): Promise { + private async importInstrumentFromDir(dir: string): Promise { const dirName = path.basename(dir); - const inputs = this.readInstrumentInputs(dir); - if (inputs.length === 0) { - this.loggingService.debug(`Skipping ${dirName}: no bundlable files found`); - return null; + let bundleStr: string; + try { + const inputs = this.readInstrumentInputs(dir); + if (inputs.length === 0) { + this.loggingService.debug(`Skipping ${dirName}: no bundlable files found`); + return { kind: 'skipped' }; + } + this.loggingService.debug(`Bundling instrument from ${dirName} (${inputs.length} files)`); + bundleStr = await bundle({ inputs, minify: true }); + } catch (err) { + return { kind: 'failed', message: describeImportError(err), stage: 'BUNDLE' }; } - this.loggingService.debug(`Bundling instrument from ${dirName} (${inputs.length} files)`); - const bundleStr = await bundle({ inputs, minify: true }); try { const instrument = await this.instrumentsService.create({ bundle: bundleStr }); this.loggingService.debug(`Created instrument ${instrument.id} from ${dirName}`); - return { created: true, id: instrument.id }; + return { created: true, id: instrument.id, kind: 'imported' }; } catch (err) { // The instrument already exists (provided by another repo or uploaded manually). Recover its id // from the conflict message so we still associate the existing instrument with this repo. @@ -289,26 +359,21 @@ export class InstrumentReposService implements OnModuleInit { const idMatch = /ID '([^']+)'/.exec(err.message); if (!idMatch?.[1]) { // The message is the only channel carrying the existing id. Losing it drops the instrument - // from this repo's list, so fail loudly rather than returning an empty result. - this.loggingService.error({ - conflictMessage: err.message, - error: 'Cannot recover instrument id from conflict', - instrumentDir: dirName - }); - return null; + // from this repo's list, so it is recorded as a failure rather than skipped. + return { + kind: 'failed', + message: `Cannot recover the existing instrument's id from: ${err.message}`, + stage: 'LOAD' + }; } this.loggingService.debug(`Instrument from ${dirName} already exists, skipping creation`); - return { created: false, id: idMatch[1] }; + return { created: false, id: idMatch[1], kind: 'imported' }; } - throw err; + return { kind: 'failed', message: describeImportError(err), stage: 'LOAD' }; } } - private async importInstruments( - owner: string, - repoName: string, - accessToken?: string - ): Promise<{ createdIds: string[]; instrumentIds: string[] }> { + private async importInstruments(owner: string, repoName: string, accessToken?: string): Promise { this.loggingService.log(`Downloading ${owner}/${repoName}...`); const { repoDir, tmpDir } = await this.downloadAndExtractRepo(owner, repoName, accessToken); @@ -319,29 +384,27 @@ export class InstrumentReposService implements OnModuleInit { // instrumentIds: every instrument the repo provides; createdIds: only those newly created here. const instrumentIds: string[] = []; const createdIds: string[] = []; + const failures: InstrumentRepoSyncFailure[] = []; for (const dir of instrumentDirs) { - try { - const result = await this.importInstrumentFromDir(dir); - if (!result) { - continue; - } + const result = await this.importInstrumentFromDir(dir); + if (result.kind === 'failed') { + // One bad instrument should not abort importing the rest of the repository. + const failure = { instrumentDir: path.basename(dir), message: result.message, stage: result.stage }; + this.loggingService.error({ error: 'Failed to Import Instrument', ...failure }); + failures.push(failure); + } else if (result.kind === 'imported') { instrumentIds.push(result.id); if (result.created) { createdIds.push(result.id); } - } catch (err) { - // One bad instrument should not abort importing the rest of the repository. - this.loggingService.error({ - cause: err, - error: 'Failed to Import Instrument', - instrumentDir: path.basename(dir) - }); } } - this.loggingService.log(`Imported ${instrumentIds.length} instruments from ${owner}/${repoName}`); - return { createdIds, instrumentIds }; + this.loggingService.log( + `Imported ${instrumentIds.length} instruments from ${owner}/${repoName} (${failures.length} failed)` + ); + return { createdIds, failures, instrumentIds }; } finally { // Remove the exact temp directory we created (never its parent, the OS temp root). fs.rmSync(tmpDir, { force: true, recursive: true }); @@ -444,12 +507,56 @@ export class InstrumentReposService implements OnModuleInit { } } + private recordSync( + entry: { action: InstrumentRepoSyncAction; repoId: null | string; repoName: string } & ( + { error: unknown } | { failures: InstrumentRepoSyncFailure[]; importedCount: number } + ) + ) { + const { action, repoId, repoName } = entry; + const outcome = + 'error' in entry + ? { failures: [], importedCount: 0, message: describeImportError(entry.error) } + : { failures: entry.failures, importedCount: entry.importedCount, message: null }; + return this.instrumentRepoSyncLogModel.create({ data: { action, repoId, repoName, ...outcome } }); + } + /** Remove the encrypted access token before returning a repo to any client. */ private stripSecrets(repo: T): Omit { const { accessToken: _accessToken, ...rest } = repo; return rest; } + private async syncRepo(repo: InstrumentRepoRecord) { + const accessToken = repo.accessToken ? this.decrypt(repo.accessToken) : undefined; + let result: RepoImportResult; + try { + result = await this.importInstruments(repo.owner, repo.repoName, accessToken); + } catch (error) { + const log = await this.recordSync({ action: 'SYNC', error, repoId: repo.id, repoName: repo.name }); + return { error, log, ok: false as const }; + } + + // Only newly-created instruments need provenance set; ones already in the repo already have it. + await this.tagInstruments(result.createdIds, repo.id, repo.name); + + // Replaced rather than merged, so the list is what the repository provides now. An edition dropped + // here stays in the database with its provenance tag, so groups can still see and administer it. + const updated = await this.instrumentRepoModel.update({ + data: { instrumentIds: result.instrumentIds, lastSyncedAt: new Date() }, + where: { id: repo.id } + }); + const log = await this.recordSync({ + action: 'SYNC', + failures: result.failures, + importedCount: result.instrumentIds.length, + repoId: repo.id, + repoName: repo.name + }); + // Re-adopt any instruments that were orphaned by a previously-deleted copy of this repo. + await this.reconcileOrphanedInstruments(); + return { log, ok: true as const, repo: updated }; + } + /** Record provenance (repo id + denormalized name) on the given instruments. */ private async tagInstruments(instrumentIds: string[], repoId: string, repoName: string): Promise { for (const id of instrumentIds) { diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx new file mode 100644 index 000000000..93b83530d --- /dev/null +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -0,0 +1,131 @@ +import { Badge, DataTable } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentRepoSyncLog, InstrumentRepoSyncStage } from '@opendatacapture/schemas/instrument-repo'; + +import { SortableHeader } from '@/components/SortableHeader'; + +type InstrumentRepoSyncStatus = 'FAILED' | 'PARTIAL' | 'SUCCESS'; + +/** A sync that could not read the repository failed outright; one that skipped instruments partly succeeded. */ +function getSyncStatus(log: InstrumentRepoSyncLog): InstrumentRepoSyncStatus { + if (log.message) { + return 'FAILED'; + } + return log.failures.length ? 'PARTIAL' : 'SUCCESS'; +} + +const STATUS_BADGE_CLASSES = { + FAILED: 'border-red-500/50 text-red-600 dark:text-red-400', + PARTIAL: 'border-amber-500/50 text-amber-600 dark:text-amber-400', + SUCCESS: 'border-green-500/50 text-green-600 dark:text-green-400' +} satisfies { [K in InstrumentRepoSyncStatus]: string }; + +type InstrumentRepoSyncLogTableProps = { + logs: InstrumentRepoSyncLog[]; +}; + +export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableProps) => { + const { t } = useTranslation(); + + const statusLabels = { + FAILED: t({ en: 'Failed', es: 'Fallido', fr: 'Échec' }), + PARTIAL: t({ en: 'Partial success', es: 'Éxito parcial', fr: 'Succès partiel' }), + SUCCESS: t({ en: 'Success', es: 'Éxito', fr: 'Succès' }) + } satisfies { [K in InstrumentRepoSyncStatus]: string }; + + const actionLabels = { + IMPORT: t({ en: 'Import', es: 'Importación', fr: 'Importation' }), + SYNC: t({ en: 'Sync', es: 'Sincronización', fr: 'Synchronisation' }) + } satisfies { [K in InstrumentRepoSyncLog['action']]: string }; + + const stageLabels = { + BUNDLE: t({ en: 'Compile', es: 'Compilación', fr: 'Compilation' }), + LOAD: t({ en: 'Load', es: 'Carga', fr: 'Chargement' }) + } satisfies { [K in InstrumentRepoSyncStage]: string }; + + return ( +
+ log.createdAt.getTime(), + cell: (ctx) => ctx.row.original.createdAt.toLocaleString(), + header: ({ column }) => ( + + ), + id: 'date' + }, + { + accessorKey: 'repoName', + header: ({ column }) => ( + + ) + }, + { + accessorFn: (log: InstrumentRepoSyncLog) => actionLabels[log.action], + header: ({ column }) => ( + + ), + id: 'action' + }, + { + accessorFn: (log: InstrumentRepoSyncLog) => getSyncStatus(log), + cell: (ctx) => { + const status = getSyncStatus(ctx.row.original); + return ( + + {statusLabels[status]} + + ); + }, + header: ({ column }) => ( + + ), + id: 'status' + }, + { + accessorKey: 'importedCount', + header: ({ column }) => ( + + ) + }, + { + accessorFn: (log: InstrumentRepoSyncLog) => log.failures.length, + header: ({ column }) => ( + + ), + id: 'failedCount' + }, + { + cell: (ctx) => { + const { failures, message } = ctx.row.original; + if (!message && failures.length === 0) { + return '-'; + } + return ( +
    + {message &&
  • {message}
  • } + {failures.map(({ instrumentDir, message, stage }) => ( +
  • + {instrumentDir} + + {stageLabels[stage]} + + {message} +
  • + ))} +
+ ); + }, + enableSorting: false, + header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), + id: 'messages', + size: 480 + } + ]} + data={logs} + initialState={{ sorting: [{ desc: true, id: 'date' }] }} + /> +
+ ); +}; diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx new file mode 100644 index 000000000..bc5670abd --- /dev/null +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -0,0 +1,105 @@ +import type { InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { InstrumentRepoSyncLogTable } from '../InstrumentRepoSyncLogTable'; + +import '@/services/i18n'; + +const logFixture = (overrides: Partial = {}): InstrumentRepoSyncLog => ({ + action: 'SYNC', + createdAt: new Date('2026-10-01T12:00:00Z'), + failures: [], + id: 'log-1', + importedCount: 4, + message: null, + repoId: 'repo-1', + repoName: 'Core Repo', + updatedAt: new Date('2026-10-01T12:00:00Z'), + ...overrides +}); + +const rows = () => screen.getAllByTestId('data-table-row'); + +const statuses = () => screen.getAllByTestId('sync-log-status').map((badge) => badge.textContent); + +afterEach(cleanup); + +describe('InstrumentRepoSyncLogTable', () => { + it('should mark a sync with no failed instrument as a success', () => { + render(); + expect(statuses()).toEqual(['Success']); + expect(within(rows()[0]!).getByText('-')).toBeTruthy(); + }); + + it('should mark a sync that skipped some instruments as a partial success, and say which and why', () => { + render( + + ); + expect(statuses()).toEqual(['Partial success']); + const messages = screen.getByTestId('sync-log-messages'); + expect(within(messages).getByText('PHQ_9')).toBeTruthy(); + expect(within(messages).getByText('Load')).toBeTruthy(); + expect(messages.textContent).toContain('Instrument validation failed'); + }); + + it('should mark a sync that could not read the repository as failed, with the reason', () => { + render(); + expect(statuses()).toEqual(['Failed']); + expect(within(screen.getByTestId('sync-log-messages')).getByText('Repository not found')).toBeTruthy(); + }); + + it('should label an import and a compile failure in words rather than as codes', () => { + render( + + ); + expect(within(rows()[0]!).getByText('Import')).toBeTruthy(); + expect(within(rows()[0]!).getByText('Compile')).toBeTruthy(); + }); + + it('should list the newest entry first until another column is sorted', () => { + render( + + ); + expect(within(rows()[0]!).getByText('Newer')).toBeTruthy(); + }); + + it('should sort by a column when its header is clicked', () => { + render( + + ); + fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Repository')); + expect(within(rows()[0]!).getByText('Alpha')).toBeTruthy(); + }); + + it('should group entries by status when the status header is clicked', () => { + render( + + ); + fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Status')); + expect(statuses()).toEqual(['Failed', 'Success']); + }); +}); diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/index.ts b/apps/web/src/components/InstrumentRepoSyncLogTable/index.ts new file mode 100644 index 000000000..98de739a6 --- /dev/null +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/index.ts @@ -0,0 +1 @@ +export * from './InstrumentRepoSyncLogTable'; diff --git a/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts b/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts new file mode 100644 index 000000000..fcdce9972 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts @@ -0,0 +1,70 @@ +import type { PropsWithChildren } from 'react'; +import { createElement, Suspense } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY, + instrumentRepoSyncLogsQueryOptions, + useInstrumentRepoSyncLogsQuery +} from '../useInstrumentRepoSyncLogsQuery'; + +const mockAxios = vi.hoisted(() => ({ get: vi.fn() })); + +vi.mock('axios', () => ({ default: mockAxios })); + +const LOG = { + action: 'SYNC', + createdAt: '2026-10-28T12:00:00.000Z', + failures: [{ instrumentDir: 'PHQ_9', message: 'broken', stage: 'LOAD' }], + id: 'log-1', + importedCount: 3, + message: null, + repoId: 'repo-1', + repoName: 'core', + updatedAt: '2026-10-28T12:00:00.000Z' +}; + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +describe('instrumentRepoSyncLogsQueryOptions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAxios.get.mockResolvedValue({ data: [LOG] }); + }); + + it('should parse the history so each log time arrives as a Date', async () => { + const logs = await createQueryClient().fetchQuery(instrumentRepoSyncLogsQueryOptions()); + expect(mockAxios.get).toHaveBeenCalledWith('/v1/instrument-repos/sync/logs'); + expect(logs[0]!.createdAt).toEqual(new Date(LOG.createdAt)); + }); + + it('should reject a failure from a stage the server does not define', async () => { + mockAxios.get.mockResolvedValue({ data: [{ ...LOG, failures: [{ ...LOG.failures[0], stage: 'RENDER' }] }] }); + await expect(createQueryClient().fetchQuery(instrumentRepoSyncLogsQueryOptions())).rejects.toThrow(); + }); + + it('should cache under the shared key, so the repo mutations can invalidate it', () => { + expect(instrumentRepoSyncLogsQueryOptions().queryKey).toEqual([INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY]); + }); +}); + +describe('useInstrumentRepoSyncLogsQuery', () => { + it('should resolve to the history once the suspended request settles', async () => { + mockAxios.get.mockResolvedValue({ data: [LOG] }); + const queryClient = createQueryClient(); + const wrapper = ({ children }: PropsWithChildren) => + createElement( + QueryClientProvider, + { client: queryClient }, + createElement(Suspense, { fallback: null }, children) + ); + const { result } = renderHook(() => useInstrumentRepoSyncLogsQuery(), { wrapper }); + await waitFor(() => expect(result.current).toBeTruthy()); + expect(result.current.data.map((log) => log.repoName)).toEqual(['core']); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts new file mode 100644 index 000000000..62e75130b --- /dev/null +++ b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts @@ -0,0 +1,120 @@ +import type { PropsWithChildren } from 'react'; +import { createElement } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useSyncAllInstrumentReposMutation } from '../useSyncAllInstrumentReposMutation'; + +const mockAxios = vi.hoisted(() => ({ isAxiosError: vi.fn(() => false), post: vi.fn() })); +const addNotification = vi.hoisted(() => vi.fn()); + +vi.mock('axios', () => ({ default: mockAxios })); + +vi.mock('@douglasneuroinformatics/libui/hooks', () => ({ + useNotificationsStore: vi.fn((selector) => selector({ addNotification })), + useTranslation: vi.fn(() => ({ + resolvedLanguage: 'en', + t: (value: string | { en: string }) => (typeof value === 'string' ? value : value.en) + })) +})); + +const syncLog = ({ + failures = [], + message = null +}: { + failures?: { instrumentDir: string; message: string; stage: 'BUNDLE' | 'LOAD' }[]; + message?: null | string; +} = {}) => ({ + action: 'SYNC', + createdAt: '2026-10-28T00:00:00.000Z', + failures, + id: 'log-1', + importedCount: 3, + message, + repoId: 'repo-1', + repoName: 'core', + updatedAt: '2026-10-28T00:00:00.000Z' +}); + +function renderSyncAllMutation() { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false, throwOnError: true } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: PropsWithChildren) => + createElement(QueryClientProvider, { children, client: queryClient }); + return { ...renderHook(() => useSyncAllInstrumentReposMutation(), { wrapper }), invalidateQueries }; +} + +describe('useSyncAllInstrumentReposMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAxios.post.mockResolvedValue({ data: [syncLog(), syncLog()] }); + }); + + it('should sync every repository without the default timeout, since syncing them all can outlast it', async () => { + const { result } = renderSyncAllMutation(); + await result.current.mutateAsync(); + expect(mockAxios.post).toHaveBeenCalledWith( + '/v1/instrument-repos/sync/all', + undefined, + expect.objectContaining({ + meta: expect.objectContaining({ disableDefaultErrorNotification: true, disableDefaultTimeout: true }) + }) + ); + }); + + it('should announce the sync before it finishes, since syncing every repository takes a while', async () => { + mockAxios.post.mockReturnValueOnce(new Promise(() => undefined)); + const { result } = renderSyncAllMutation(); + result.current.mutate(); + await waitFor(() => + expect(addNotification).toHaveBeenCalledExactlyOnceWith({ message: 'Syncing every repository...', type: 'info' }) + ); + }); + + it('should announce success and refresh the repositories, their history and their instruments', async () => { + const { invalidateQueries, result } = renderSyncAllMutation(); + await result.current.mutateAsync(); + expect(addNotification).toHaveBeenLastCalledWith({ + message: 'Synced 2 repositories successfully', + type: 'success' + }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-repos'] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-repo-sync-logs'] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-info'] }); + }); + + it('should warn with how many repositories had a failed instrument or could not be read', async () => { + mockAxios.post.mockResolvedValueOnce({ + data: [ + syncLog({ failures: [{ instrumentDir: 'PHQ_9', message: 'broken', stage: 'LOAD' }] }), + syncLog({ message: 'Repository not found' }), + syncLog() + ] + }); + const { result } = renderSyncAllMutation(); + await result.current.mutateAsync(); + expect(addNotification).toHaveBeenLastCalledWith({ + message: '2 of 3 repositories had problems. See the sync history.', + type: 'warning' + }); + }); + + it('should report the reason the server gave when the request fails', async () => { + mockAxios.isAxiosError.mockReturnValueOnce(true); + mockAxios.post.mockRejectedValueOnce({ response: { data: { message: 'Forbidden' } } }); + const { result } = renderSyncAllMutation(); + result.current.mutate(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Forbidden', type: 'error' }); + }); + + it('should fall back to a generic message when the server gives no reason', async () => { + mockAxios.post.mockRejectedValueOnce(new Error('network')); + const { result } = renderSyncAllMutation(); + result.current.mutate(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Failed to sync repositories', type: 'error' }); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts b/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts index dd2abc520..dff010aff 100644 --- a/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts @@ -20,6 +20,18 @@ vi.mock('@douglasneuroinformatics/libui/hooks', () => ({ })) })); +const syncLog = (failures: { instrumentDir: string; message: string; stage: 'BUNDLE' | 'LOAD' }[] = []) => ({ + action: 'SYNC', + createdAt: '2026-10-28T00:00:00.000Z', + failures, + id: 'log-1', + importedCount: 3, + message: null, + repoId: 'repo-1', + repoName: 'core', + updatedAt: '2026-10-28T00:00:00.000Z' +}); + function renderSyncMutation() { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false, throwOnError: true } } }); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); @@ -32,7 +44,7 @@ function renderSyncMutation() { describe('useSyncInstrumentRepoMutation', () => { beforeEach(() => { vi.clearAllMocks(); - mockAxios.post.mockResolvedValue({ data: {} }); + mockAxios.post.mockResolvedValue({ data: syncLog() }); }); it('should sync the repository without the default error toast, since it raises its own', async () => { @@ -72,6 +84,26 @@ describe('useSyncInstrumentRepoMutation', () => { expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-info'] }); }); + it('should warn rather than announce success when some instruments failed, so they get fixed', async () => { + mockAxios.post.mockResolvedValueOnce({ + data: syncLog([{ instrumentDir: 'PHQ_9', message: 'broken', stage: 'LOAD' }]) + }); + const { result } = renderSyncMutation(); + await result.current.mutateAsync({ id: 'repo-1' }); + expect(addNotification).toHaveBeenLastCalledWith({ + message: 'Repository synced, but 1 instruments failed. See the sync history.', + type: 'warning' + }); + }); + + it('should refresh the sync history even when the sync fails, because the failure is logged too', async () => { + mockAxios.post.mockRejectedValueOnce(new Error('down')); + const { invalidateQueries, result } = renderSyncMutation(); + result.current.mutate({ id: 'repo-1' }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-repo-sync-logs'] }); + }); + it('should report the reason the server gave when the sync fails', async () => { mockAxios.isAxiosError.mockReturnValueOnce(true); mockAxios.post.mockRejectedValueOnce({ response: { data: { message: 'Repository not found' } } }); diff --git a/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts b/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts index 39146b049..21ddd5e63 100644 --- a/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts +++ b/apps/web/src/hooks/useCreateInstrumentRepoMutation.ts @@ -6,6 +6,7 @@ import axios from 'axios'; import { getApiErrorMessage } from '@/utils/error'; import { INSTRUMENT_REPOS_QUERY_KEY } from './useInstrumentReposQuery'; +import { INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY } from './useInstrumentRepoSyncLogsQuery'; export function useCreateInstrumentRepoMutation() { const queryClient = useQueryClient(); @@ -30,6 +31,10 @@ export function useCreateInstrumentRepoMutation() { type: 'error' }); }, + onSettled() { + // A failed import is logged too, so the history changes either way. + void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY] }); + }, onSuccess() { addNotification({ message: t({ diff --git a/apps/web/src/hooks/useInstrumentRepoSyncLogsQuery.ts b/apps/web/src/hooks/useInstrumentRepoSyncLogsQuery.ts new file mode 100644 index 000000000..29f23a19f --- /dev/null +++ b/apps/web/src/hooks/useInstrumentRepoSyncLogsQuery.ts @@ -0,0 +1,19 @@ +import { $InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY = 'instrument-repo-sync-logs'; + +export const instrumentRepoSyncLogsQueryOptions = () => { + return queryOptions({ + queryFn: async () => { + const response = await axios.get('/v1/instrument-repos/sync/logs'); + return $InstrumentRepoSyncLog.array().parse(response.data); + }, + queryKey: [INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY] + }); +}; + +export function useInstrumentRepoSyncLogsQuery() { + return useSuspenseQuery(instrumentRepoSyncLogsQueryOptions()); +} diff --git a/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts b/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts new file mode 100644 index 000000000..2fb3cbff6 --- /dev/null +++ b/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts @@ -0,0 +1,75 @@ +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +import { getApiErrorMessage } from '@/utils/error'; + +import { INSTRUMENT_REPOS_QUERY_KEY } from './useInstrumentReposQuery'; +import { INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY } from './useInstrumentRepoSyncLogsQuery'; + +export function useSyncAllInstrumentReposMutation() { + const queryClient = useQueryClient(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const { t } = useTranslation(); + return useMutation({ + mutationFn: async () => { + const response = await axios.post('/v1/instrument-repos/sync/all', undefined, { + meta: { disableDefaultErrorNotification: true, disableDefaultTimeout: true } + }); + return $InstrumentRepoSyncLog.array().parse(response.data); + }, + onError(err) { + addNotification({ + message: getApiErrorMessage( + err, + t({ + en: 'Failed to sync repositories', + es: 'Error al sincronizar los repositorios', + fr: 'Échec de la synchronisation des dépôts' + }) + ), + type: 'error' + }); + }, + onMutate() { + addNotification({ + message: t({ + en: 'Syncing every repository...', + es: 'Sincronizando todos los repositorios...', + fr: 'Synchronisation de tous les dépôts...' + }), + type: 'info' + }); + }, + onSettled() { + void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPOS_QUERY_KEY] }); + void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY] }); + void queryClient.invalidateQueries({ queryKey: ['instrument-info'] }); + }, + onSuccess(logs) { + const problemCount = logs.filter((log) => log.failures.length > 0 || Boolean(log.message)).length; + if (problemCount === 0) { + addNotification({ + message: t({ + en: `Synced ${logs.length} repositories successfully`, + es: `${logs.length} repositorios sincronizados correctamente`, + fr: `${logs.length} dépôts synchronisés avec succès` + }), + type: 'success' + }); + return; + } + addNotification({ + message: t({ + en: `${problemCount} of ${logs.length} repositories had problems. See the sync history.`, + es: `${problemCount} de ${logs.length} repositorios tuvieron problemas. Consulte el historial de sincronización.`, + fr: `${problemCount} dépôts sur ${logs.length} ont rencontré des problèmes. Consultez l'historique de synchronisation.` + }), + type: 'warning' + }); + }, + // onError reports the failure; the app default would also hand it to the route error boundary. + throwOnError: false + }); +} diff --git a/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts b/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts index ea79c74c3..1ce684191 100644 --- a/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts +++ b/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts @@ -1,20 +1,23 @@ import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; import { getApiErrorMessage } from '@/utils/error'; import { INSTRUMENT_REPOS_QUERY_KEY } from './useInstrumentReposQuery'; +import { INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY } from './useInstrumentRepoSyncLogsQuery'; export function useSyncInstrumentRepoMutation() { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); const { t } = useTranslation(); return useMutation({ - mutationFn: ({ id }: { id: string }) => { - return axios.post(`/v1/instrument-repos/${id}/sync`, undefined, { + mutationFn: async ({ id }: { id: string }) => { + const response = await axios.post(`/v1/instrument-repos/${id}/sync`, undefined, { meta: { disableDefaultErrorNotification: true, disableDefaultTimeout: true } }); + return $InstrumentRepoSyncLog.parse(response.data); }, onError(err) { addNotification({ @@ -40,15 +43,31 @@ export function useSyncInstrumentRepoMutation() { type: 'info' }); }, - onSuccess() { - addNotification({ - message: t({ - en: 'Repository synced successfully', - es: 'Repositorio sincronizado correctamente', - fr: 'Dépôt synchronisé avec succès' - }), - type: 'success' - }); + onSettled() { + // A failed sync is logged too, so the history changes either way. + void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY] }); + }, + onSuccess(log) { + const failedCount = log.failures.length; + addNotification( + failedCount + ? { + message: t({ + en: `Repository synced, but ${failedCount} instruments failed. See the sync history.`, + es: `Repositorio sincronizado, pero ${failedCount} instrumentos fallaron. Consulte el historial de sincronización.`, + fr: `Dépôt synchronisé, mais ${failedCount} instruments ont échoué. Consultez l'historique de synchronisation.` + }), + type: 'warning' + } + : { + message: t({ + en: 'Repository synced successfully', + es: 'Repositorio sincronizado correctamente', + fr: 'Dépôt synchronisé avec succès' + }), + type: 'success' + } + ); void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPOS_QUERY_KEY] }); // A sync can pull in new instruments; refresh the instrument list shown elsewhere. void queryClient.invalidateQueries({ queryKey: ['instrument-info'] }); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index 6633eacd8..a66ea68bc 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -1,9 +1,9 @@ import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; import type { Group } from '@opendatacapture/schemas/group'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; -import type { InstrumentRepo } from '@opendatacapture/schemas/instrument-repo'; +import type { InstrumentRepo, InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; import { QueryClient } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Route } from '@/routes/_app/admin/instrument-repos/index'; @@ -19,6 +19,9 @@ const mocks = vi.hoisted(() => ({ instrumentReposQueryOptions: vi.fn(() => ({ queryKey: ['instrument-repos'] })), isCreatePending: false, repos: [] as InstrumentRepo[], + syncAllRepos: vi.fn(), + syncLogs: [] as InstrumentRepoSyncLog[], + syncLogsQueryOptions: vi.fn(() => ({ queryKey: ['instrument-repo-sync-logs'] })), syncRepo: vi.fn() })); @@ -40,6 +43,13 @@ vi.mock('@/hooks/useDeleteInstrumentRepoMutation', () => ({ vi.mock('@/hooks/useSyncInstrumentRepoMutation', () => ({ useSyncInstrumentRepoMutation: () => ({ mutate: mocks.syncRepo }) })); +vi.mock('@/hooks/useSyncAllInstrumentReposMutation', () => ({ + useSyncAllInstrumentReposMutation: () => ({ isPending: false, mutate: mocks.syncAllRepos }) +})); +vi.mock('@/hooks/useInstrumentRepoSyncLogsQuery', () => ({ + instrumentRepoSyncLogsQueryOptions: mocks.syncLogsQueryOptions, + useInstrumentRepoSyncLogsQuery: () => ({ data: mocks.syncLogs }) +})); const repoFixture = (overrides: Partial = {}): InstrumentRepo => ({ createdAt: new Date('2026-01-01'), @@ -55,6 +65,19 @@ const repoFixture = (overrides: Partial = {}): InstrumentRepo => ...overrides }); +const syncLogFixture = (overrides: Partial = {}): InstrumentRepoSyncLog => ({ + action: 'SYNC', + createdAt: new Date('2026-10-01'), + failures: [], + id: 'log-1', + importedCount: 2, + message: null, + repoId: 'repo-1', + repoName: 'Core Repo', + updatedAt: new Date('2026-10-01'), + ...overrides +}); + const groupFixture = (instrumentRepoIds: string[]): Group => ({ accessibleInstrumentIds: [], createdAt: new Date('2026-01-01'), @@ -145,16 +168,78 @@ describe('Route', () => { mocks.groups = []; mocks.instrumentInfo = { data: undefined, isLoading: false }; mocks.isCreatePending = false; + mocks.syncLogs = []; useNotificationsStore.setState({ notifications: [] }); }); afterEach(cleanup); - it('should prefetch the repositories and groups in the loader, so the page renders without a waterfall', async () => { + it('should prefetch the repositories, groups and sync history in the loader, so the page renders without a waterfall', async () => { const queryClient = new QueryClient(); const ensureQueryData = vi.spyOn(queryClient, 'ensureQueryData').mockResolvedValue([]); await runLoader(queryClient); - expect(ensureQueryData.mock.calls).toEqual([[{ queryKey: ['instrument-repos'] }], [{ queryKey: ['groups'] }]]); + expect(ensureQueryData.mock.calls).toEqual([ + [{ queryKey: ['instrument-repos'] }], + [{ queryKey: ['groups'] }], + [{ queryKey: ['instrument-repo-sync-logs'] }] + ]); + }); + + it('should truncate a long URL in its cell and keep the full URL on hover, so it cannot run into the next column', () => { + renderPage(); + const cell = within(rows()[0]!).getByTestId('instrument-repo-url'); + expect(cell.className).toContain('text-ellipsis'); + expect(cell.title).toBe('https://github.com/douglasneuroinformatics/core'); + }); + + it('should count the instruments that failed in the latest sync of each repository', () => { + mocks.syncLogs = [ + syncLogFixture({ failures: [{ instrumentDir: 'A', message: 'broken', stage: 'LOAD' }], id: 'newest' }), + syncLogFixture({ id: 'older' }) + ]; + renderPage(); + const failedCount = within(rows()[0]!).getByTestId('instrument-repo-failed-count'); + expect(failedCount.textContent).toBe('1'); + expect(failedCount.className).toContain('text-destructive'); + }); + + it('should show zero failed instruments for a repository with no sync history', () => { + renderPage(); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('0'); + }); + + it('should say the latest sync failed when the repository could not be read at all', () => { + mocks.syncLogs = [syncLogFixture({ message: 'Repository not found' })]; + renderPage(); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('Sync failed'); + }); + + it('should sync every repository from the Sync All button', () => { + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Sync All' })); + expect(mocks.syncAllRepos).toHaveBeenCalledOnce(); + }); + + it('should disable Sync All when there is no repository to sync', () => { + renderPage([]); + expect(screen.getByRole('button', { name: 'Sync All' }).disabled).toBe(true); + }); + + it('should show the sync history of every repository below the table', () => { + mocks.syncLogs = [syncLogFixture(), syncLogFixture({ id: 'log-2', repoId: 'repo-2', repoName: 'Other Repo' })]; + renderPage(); + const history = screen.getByTestId('instrument-repo-sync-log'); + expect(within(history).getByText('Core Repo')).toBeTruthy(); + expect(within(history).getByText('Other Repo')).toBeTruthy(); + }); + + it('should show only the selected repository in its logs dialog', () => { + mocks.syncLogs = [syncLogFixture(), syncLogFixture({ id: 'log-2', repoId: 'repo-2', repoName: 'Other Repo' })]; + renderPage(); + fireEvent.click(rowAction('Logs')); + const dialog = screen.getByRole('dialog'); + expect(within(dialog).getByText('Sync history of "Core Repo"')).toBeTruthy(); + expect(within(dialog).queryByText('Other Repo')).toBeNull(); }); it('should count the instruments a repository contributed', () => { @@ -321,6 +406,38 @@ describe('Route', () => { expect(within(rows()[0]!).getByText('2')).toBeTruthy(); }); + it('should link to the repository source in the view dialog, so it can be opened on GitHub', () => { + renderPage(); + fireEvent.click(rowAction('View')); + const link = screen.getByTestId('instrument-repo-source-link'); + expect(link.href).toBe('https://github.com/douglasneuroinformatics/core'); + expect(link.target).toBe('_blank'); + }); + + it('should filter the view dialog by instrument title', async () => { + mocks.instrumentInfo = { + data: [ + scalarFixture({ id: 'form-1', title: 'Happiness Scale' }), + scalarFixture({ id: 'form-2', title: 'Sleep Diary' }) + ], + isLoading: false + }; + renderPage(); + fireEvent.click(rowAction('View')); + fireEvent.change(screen.getByPlaceholderText('Search instruments...'), { target: { value: 'sleep' } }); + expect(screen.getByText('Sleep Diary')).toBeTruthy(); + // The list animates an instrument out, so it leaves the DOM once its exit transition ends. + await waitFor(() => expect(screen.queryByText('Happiness Scale')).toBeNull()); + }); + + it('should say when no instrument matches the search, rather than that the repository is empty', () => { + mocks.instrumentInfo = { data: [scalarFixture({ id: 'form-1', title: 'Happiness Scale' })], isLoading: false }; + renderPage(); + fireEvent.click(rowAction('View')); + fireEvent.change(screen.getByPlaceholderText('Search instruments...'), { target: { value: 'nothing' } }); + expect(screen.getByText('No instruments match your search.')).toBeTruthy(); + }); + it('should close the view dialog when it is dismissed', () => { renderPage(); fireEvent.click(rowAction('View')); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 134738821..94ace8d5c 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -1,25 +1,41 @@ import { useState } from 'react'; import type { FormEvent } from 'react'; -import { Button, DataTable, Dialog, Heading, Input, Label, Spinner } from '@douglasneuroinformatics/libui/components'; +import { + Button, + DataTable, + Dialog, + Heading, + Input, + Label, + SearchBar, + Spinner +} from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; -import type { InstrumentRepo } from '@opendatacapture/schemas/instrument-repo'; +import type { InstrumentRepo, InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; import { createFileRoute } from '@tanstack/react-router'; import { AnimatePresence, motion } from 'motion/react'; +import { InstrumentRepoSyncLogTable } from '@/components/InstrumentRepoSyncLogTable'; import { PageHeader } from '@/components/PageHeader'; +import { TruncatedCell } from '@/components/TruncatedCell'; import { useCreateInstrumentRepoMutation } from '@/hooks/useCreateInstrumentRepoMutation'; import { useDeleteInstrumentRepoMutation } from '@/hooks/useDeleteInstrumentRepoMutation'; import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { instrumentReposQueryOptions, useInstrumentReposQuery } from '@/hooks/useInstrumentReposQuery'; +import { + instrumentRepoSyncLogsQueryOptions, + useInstrumentRepoSyncLogsQuery +} from '@/hooks/useInstrumentRepoSyncLogsQuery'; +import { useSyncAllInstrumentReposMutation } from '@/hooks/useSyncAllInstrumentReposMutation'; import { useSyncInstrumentRepoMutation } from '@/hooks/useSyncInstrumentRepoMutation'; /** - * The instruments a repository currently provides: only the latest edition of each. A sync never - * drops superseded editions from a repository (records may still reference them), so without this a + * The instruments a repository currently provides: only the latest edition of each. Superseded + * editions keep their repository provenance (records may still reference them), so without this a * bumped form would be listed once per edition it has ever had. */ function currentRepoInstruments(instruments: TranslatedInstrumentInfo[], repoId: string) { @@ -135,6 +151,17 @@ const AddRepoForm = ({ ); }; +/** The most recent log per repository; the API returns the history newest first. */ +function latestSyncLogs(logs: InstrumentRepoSyncLog[]) { + const latest = new Map(); + for (const log of logs) { + if (log.repoId && !latest.has(log.repoId)) { + latest.set(log.repoId, log); + } + } + return latest; +} + const RouteComponent = () => { const { t } = useTranslation(); const addNotification = useNotificationsStore((store) => store.addNotification); @@ -143,6 +170,8 @@ const RouteComponent = () => { const createRepoMutation = useCreateInstrumentRepoMutation(); const deleteRepoMutation = useDeleteInstrumentRepoMutation(); const syncRepoMutation = useSyncInstrumentRepoMutation(); + const syncAllReposMutation = useSyncAllInstrumentReposMutation(); + const syncLogsQuery = useInstrumentRepoSyncLogsQuery(); // Every edition, because the default collapses editions across all sources, not per repository; // `currentRepoInstruments` then keeps the latest edition each repository provides. const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); @@ -150,11 +179,14 @@ const RouteComponent = () => { const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false); const [isViewOpen, setIsViewOpen] = useState(false); const [repoToView, setRepoToView] = useState(null); + const [instrumentSearch, setInstrumentSearch] = useState(''); + const [repoToShowLogs, setRepoToShowLogs] = useState(null); const [repoToDelete, setRepoToDelete] = useState(null); const [highlightedRowId, setHighlightedRowId] = useState(null); // Compute which repos are currently assigned to at least one (live) group. const inUseRepoIds = new Set(groupsQuery.data.flatMap((group) => group.instrumentRepoIds)); + const latestSyncLogByRepoId = latestSyncLogs(syncLogsQuery.data); const handleDeleteClick = (repo: InstrumentRepo) => { if (inUseRepoIds.has(repo.id)) { @@ -174,13 +206,15 @@ const RouteComponent = () => { return ( { setIsDialogOpen(false); setIsConfirmDeleteOpen(false); setIsViewOpen(false); setRepoToView(null); setRepoToDelete(null); + setRepoToShowLogs(null); + setInstrumentSearch(''); }} > @@ -213,6 +247,13 @@ const RouteComponent = () => { }, { accessorKey: 'url', + cell: (ctx) => ( + + ), header: 'URL' }, { @@ -228,6 +269,29 @@ const RouteComponent = () => { }), id: 'instrumentCount' }, + { + cell: (ctx) => { + const log = latestSyncLogByRepoId.get(ctx.row.original.id); + if (log?.message) { + return ( + + {t({ en: 'Sync failed', es: 'Sincronización fallida', fr: 'Échec de la synchronisation' })} + + ); + } + const failedCount = log?.failures.length ?? 0; + return ( + + {failedCount} + + ); + }, + header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), + id: 'failedCount' + }, { accessorFn: (row: InstrumentRepo) => { return row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleDateString() : '-'; @@ -263,19 +327,36 @@ const RouteComponent = () => { setIsViewOpen(true); } }, + { + label: t({ en: 'Logs', es: 'Registros', fr: 'Journaux' }), + onSelect: setRepoToShowLogs + }, { label: t('core.delete'), onSelect: handleDeleteClick } ]} togglesComponent={() => ( - +
+ + +
)} onRowClick={(repo) => setHighlightedRowId(repo.id)} onRowDoubleClick={(repo) => { @@ -283,6 +364,28 @@ const RouteComponent = () => { syncRepoMutation.mutate({ id: repo.id }); }} /> +
+ + {t({ en: 'Sync History', es: 'Historial de sincronización', fr: 'Historique de synchronisation' })} + + +
+ {repoToShowLogs && ( + + + + {t({ + en: `Sync history of "${repoToShowLogs.name}"`, + es: `Historial de sincronización de "${repoToShowLogs.name}"`, + fr: `Historique de synchronisation de « ${repoToShowLogs.name} »` + })} + + +
+ log.repoId === repoToShowLogs.id)} /> +
+
+ )} {isDialogOpen && ( @@ -319,10 +422,35 @@ const RouteComponent = () => { fr: `Instruments dans « ${repoToView.name} »` })} + + {t({ en: 'Source: ', es: 'Fuente: ', fr: 'Source : ' })} + + {repoToView.url} + + + {(() => { + const searchTerm = instrumentSearch.trim().toLowerCase(); const repoInstruments = - instrumentInfoQuery.data && currentRepoInstruments(instrumentInfoQuery.data, repoToView.id); + instrumentInfoQuery.data && + currentRepoInstruments(instrumentInfoQuery.data, repoToView.id).filter((instrument) => + instrument.details.title.toLowerCase().includes(searchTerm) + ); if (instrumentInfoQuery.isLoading) { return (
@@ -333,11 +461,17 @@ const RouteComponent = () => { if (!repoInstruments?.length) { return (

- {t({ - en: 'No instruments found in this repository.', - es: 'No se encontraron instrumentos en este repositorio.', - fr: 'Aucun instrument trouvé dans ce dépôt.' - })} + {searchTerm + ? t({ + en: 'No instruments match your search.', + es: 'Ningún instrumento coincide con su búsqueda.', + fr: 'Aucun instrument ne correspond à votre recherche.' + }) + : t({ + en: 'No instruments found in this repository.', + es: 'No se encontraron instrumentos en este repositorio.', + fr: 'Aucun instrument trouvé dans ce dépôt.' + })}

); } @@ -432,5 +566,6 @@ export const Route = createFileRoute('/_app/admin/instrument-repos/')({ loader: async ({ context }) => { await context.queryClient.ensureQueryData(instrumentReposQueryOptions()); await context.queryClient.ensureQueryData(groupsQueryOptions()); + await context.queryClient.ensureQueryData(instrumentRepoSyncLogsQueryOptions()); } }); diff --git a/packages/schemas/src/instrument-repo/instrument-repo.ts b/packages/schemas/src/instrument-repo/instrument-repo.ts index 59171d34a..bf6f254ec 100644 --- a/packages/schemas/src/instrument-repo/instrument-repo.ts +++ b/packages/schemas/src/instrument-repo/instrument-repo.ts @@ -23,3 +23,29 @@ export const $CreateInstrumentRepoData = z.object({ .url() .regex(/^https:\/\/github\.com\/[^/]+\/[^/]+(\.git)?\/?$/, 'Must be a valid GitHub repository URL') }); + +export type InstrumentRepoSyncAction = z.infer; +export const $InstrumentRepoSyncAction = z.enum(['IMPORT', 'SYNC']); + +/** `BUNDLE`: the source could not be compiled. `LOAD`: the bundle compiled but the server refused it. */ +export type InstrumentRepoSyncStage = z.infer; +export const $InstrumentRepoSyncStage = z.enum(['BUNDLE', 'LOAD']); + +export type InstrumentRepoSyncFailure = z.infer; +export const $InstrumentRepoSyncFailure = z.object({ + instrumentDir: z.string(), + message: z.string(), + stage: $InstrumentRepoSyncStage +}); + +export type InstrumentRepoSyncLog = z.infer; +export const $InstrumentRepoSyncLog = $BaseModel.extend({ + action: $InstrumentRepoSyncAction, + failures: z.array($InstrumentRepoSyncFailure), + importedCount: z.number().int().nonnegative(), + /** Set when the repository as a whole could not be read, in which case nothing was imported. */ + message: z.string().nullish(), + /** Null when importing a repository that was never created because its download failed. */ + repoId: z.string().nullish(), + repoName: z.string() +}); diff --git a/testing/src/pages/_app/admin/instrument-repos/index.page.ts b/testing/src/pages/_app/admin/instrument-repos/index.page.ts index 4c0553796..bf677674d 100644 --- a/testing/src/pages/_app/admin/instrument-repos/index.page.ts +++ b/testing/src/pages/_app/admin/instrument-repos/index.page.ts @@ -4,18 +4,29 @@ import { AppPage } from '../../route.page'; export class InstrumentReposPage extends AppPage { readonly addRepositoryButton: Locator; + /** The repositories table; the sync history below it is a second data table. */ readonly dataTable: Locator; readonly githubUrlInput: Locator; readonly importButton: Locator; readonly pageHeader: Locator; + readonly syncAllButton: Locator; + readonly syncLogTable: Locator; constructor(page: Page) { super(page); this.pageHeader = page.getByTestId('page-header'); - this.dataTable = page.getByTestId('data-table'); + this.dataTable = page.getByTestId('data-table').first(); + this.syncLogTable = page.getByTestId('instrument-repo-sync-log'); + this.syncAllButton = page.getByRole('button', { name: 'Sync All' }); this.addRepositoryButton = page.getByRole('button', { name: 'Add Repository' }); this.githubUrlInput = page.getByLabel('GitHub URL'); // A plain HTML form (not the shared `Form` component), so its accessible name is the literal label. this.importButton = page.getByRole('button', { name: 'Import' }); } + + /** Opens a repository row's action menu and picks one of its entries. */ + async selectRowAction(rowIndex: number, label: string): Promise { + await this.dataTable.getByTestId('row-actions-trigger').nth(rowIndex).click(); + await this.$ref.getByRole('menuitem', { name: label }).click(); + } } diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index e042cae1f..79f57a59e 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -86,3 +86,100 @@ test.describe('admin instrument repos refused import', () => { await expect(reposPage.$ref.getByTestId('error-page')).toBeHidden(); }); }); + +test.describe('admin instrument repos sync history', () => { + test.use({ actingRole: 'ADMIN' }); + + // Answered by the test rather than the API, which must not reach out to GitHub under NODE_ENV=test. + const repo = { + createdAt: '2026-10-01T00:00:00.000Z', + groupIds: [], + id: '6a0000000000000000000001', + instrumentIds: ['a', 'b'], + lastSyncedAt: '2026-10-28T00:00:00.000Z', + name: 'ODC_MontembeaultLab_Instruments', + owner: 'DouglasNeuroInformatics', + repoName: 'ODC_MontembeaultLab_Instruments', + updatedAt: '2026-10-28T00:00:00.000Z', + url: 'https://github.com/DouglasNeuroInformatics/ODC_MontembeaultLab_Instruments' + }; + const partialSync = { + action: 'SYNC', + createdAt: '2026-10-28T00:00:00.000Z', + failures: [{ instrumentDir: 'CPP_SERIES', message: "Series item 'PHQ_9' edition 2 does not exist", stage: 'LOAD' }], + id: '6a0000000000000000000002', + importedCount: 2, + message: null, + repoId: repo.id, + repoName: repo.name, + updatedAt: '2026-10-28T00:00:00.000Z' + }; + + test.beforeEach(async ({ page }) => { + await page.route('**/v1/instrument-repos', (route) => + route.request().method() === 'GET' ? route.fulfill({ json: [repo] }) : route.continue() + ); + await page.route('**/v1/instrument-repos/sync/logs', (route) => route.fulfill({ json: [partialSync] })); + }); + + test('should keep a long repository URL inside its column, even on the selected row', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const url = reposPage.dataTable.getByTestId('instrument-repo-url'); + + await url.click(); + + const overflow = await url.evaluate((element) => ({ + clipped: element.scrollWidth > element.clientWidth, + spill: element.getBoundingClientRect().right - element.parentElement!.getBoundingClientRect().right + })); + expect(overflow.clipped).toBe(true); + expect(overflow.spill).toBeLessThanOrEqual(0); + }); + + test('should count the failed instruments and explain them in the sync history', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + + await expect(reposPage.dataTable.getByTestId('instrument-repo-failed-count')).toHaveText('1'); + await expect(reposPage.syncLogTable.getByTestId('sync-log-status')).toHaveText('Partial success'); + const messages = reposPage.syncLogTable.getByTestId('sync-log-messages'); + await expect(messages).toContainText('CPP_SERIES'); + await expect(messages).toContainText('Load'); + await expect(messages).toContainText("Series item 'PHQ_9' edition 2 does not exist"); + }); + + test('should open the sync history of one repository from its row actions', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + + await reposPage.selectRowAction(0, 'Logs'); + + const dialog = reposPage.$ref.getByRole('dialog'); + await expect(dialog).toContainText(`Sync history of "${repo.name}"`); + await expect(dialog.getByTestId('sync-log-status')).toHaveText('Partial success'); + }); + + test('should link the viewed repository to its source and offer an instrument search', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + + await reposPage.selectRowAction(0, 'View'); + + await expect(reposPage.$ref.getByTestId('instrument-repo-source-link')).toHaveAttribute('href', repo.url); + await expect(reposPage.$ref.getByPlaceholder('Search instruments...')).toBeVisible(); + }); + + test('should sync every repository and warn when one of them had failed instruments', async ({ + getPageModel, + page + }) => { + let syncAllRequested = false; + await page.route('**/v1/instrument-repos/sync/all', (route) => { + syncAllRequested = true; + return route.fulfill({ json: [partialSync] }); + }); + const reposPage = await getPageModel('/admin/instrument-repos'); + + await reposPage.syncAllButton.click(); + + await expect(reposPage.$ref.getByText('1 of 1 repositories had problems. See the sync history.')).toBeVisible(); + expect(syncAllRequested).toBe(true); + }); +}); From 324669aac2f4b935a0fd788dda0df3395279d6fd Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 28 Oct 2026 16:13:05 -0400 Subject: [PATCH 02/13] feat: let administrators clear the instrument repository sync history Adds a Clear History button above the sync history, behind a confirmation that the entries are permanently deleted from the database (DELETE /v1/instrument-repos/sync/logs, admin only). Repositories and their instruments are untouched. Sync status is now shown as a coloured dot beside a plain label, as the datahub tags sex at birth, rather than as coloured text. The Failed column of the repository table is plain text, and its Instruments column is renamed Imported. Co-Authored-By: Claude Opus 5.5 --- .../instrument-repos.controller.test.ts | 8 +- .../instrument-repos.service.test.ts | 10 +++ .../instrument-repos.controller.ts | 7 ++ .../instrument-repos.service.ts | 7 ++ .../InstrumentRepoSyncLogTable.tsx | 31 +++---- .../InstrumentRepoSyncLogTable.test.tsx | 27 +++++- ...learInstrumentRepoSyncLogsMutation.test.ts | 59 +++++++++++++ .../useClearInstrumentRepoSyncLogsMutation.ts | 43 ++++++++++ .../instrument-repos/__tests__/index.test.tsx | 46 ++++++++-- .../_app/admin/instrument-repos/index.tsx | 86 +++++++++++++++---- apps/web/src/utils/instrument-repo-sync.ts | 13 +++ apps/web/src/utils/tag-colors.ts | 15 +++- .../_app/admin/instrument-repos/index.page.ts | 2 + .../src/specs/admin-instrument-repos.spec.ts | 24 +++++- 14 files changed, 329 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts create mode 100644 apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts create mode 100644 apps/web/src/utils/instrument-repo-sync.ts diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts index cafab21b8..134c958bd 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts @@ -63,7 +63,13 @@ describe('InstrumentReposController', () => { expect(service.findSyncLogs).toHaveBeenCalledWith({ ability }); }); - it.each(['findSyncLogs'] as const)( + it("should clear the sync history within the current user's ability", async () => { + service.deleteSyncLogs.mockResolvedValueOnce({ count: 2 }); + await expect(controller.deleteSyncLogs(ability)).resolves.toStrictEqual({ count: 2 }); + expect(service.deleteSyncLogs).toHaveBeenCalledWith({ ability }); + }); + + it.each(['deleteSyncLogs', 'findSyncLogs'] as const)( 'should restrict %s to administrators, since the history spans every repository', (handler) => { const reflector = new Reflector(); diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts index 21437fe34..2d0482dd9 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts @@ -379,6 +379,16 @@ describe('InstrumentReposService', () => { }); }); + describe('deleteSyncLogs', () => { + it('should erase every sync log the caller may delete', async () => { + instrumentRepoSyncLogModel.deleteMany.mockResolvedValueOnce({ count: 3 }); + + await expect(service.deleteSyncLogs()).resolves.toStrictEqual({ count: 3 }); + expect(instrumentRepoSyncLogModel.deleteMany).toHaveBeenCalledWith({ where: {} }); + expect(instrumentRepoModel.update).not.toHaveBeenCalled(); + }); + }); + describe('findSyncLogs', () => { it('should return the history newest first', async () => { instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([{ id: 'log-1' }]); diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index bf51195d8..0e58574a5 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -25,6 +25,13 @@ export class InstrumentReposController { return this.instrumentReposService.deleteById(id, { ability }); } + @ApiOperation({ summary: 'Clear the Sync History of Every Instrument Repo' }) + @Delete('sync/logs') + @RouteAccess(ADMIN_ONLY) + deleteSyncLogs(@CurrentUser('ability') ability?: AppAbility) { + return this.instrumentReposService.deleteSyncLogs({ ability }); + } + @ApiOperation({ summary: 'Get All Instrument Repos' }) @Get() @RouteAccess({ action: 'read', subject: 'InstrumentRepo' }) diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index 5753b3c7a..622c39f73 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -157,6 +157,13 @@ export class InstrumentReposService implements OnModuleInit { return this.stripSecrets(deleted); } + /** Erase the whole sync history. The repositories and their instruments are untouched. */ + async deleteSyncLogs({ ability }: EntityOperationOptions = {}) { + return this.instrumentRepoSyncLogModel.deleteMany({ + where: accessibleQuery(ability, 'delete', 'InstrumentRepoSyncLog') + }); + } + async findAll({ ability }: EntityOperationOptions = {}) { const repos = await this.instrumentRepoModel.findMany({ where: accessibleQuery(ability, 'read', 'InstrumentRepo') diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index 93b83530d..cc735b54b 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -1,24 +1,12 @@ import { Badge, DataTable } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InstrumentRepoSyncLog, InstrumentRepoSyncStage } from '@opendatacapture/schemas/instrument-repo'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { SortableHeader } from '@/components/SortableHeader'; - -type InstrumentRepoSyncStatus = 'FAILED' | 'PARTIAL' | 'SUCCESS'; - -/** A sync that could not read the repository failed outright; one that skipped instruments partly succeeded. */ -function getSyncStatus(log: InstrumentRepoSyncLog): InstrumentRepoSyncStatus { - if (log.message) { - return 'FAILED'; - } - return log.failures.length ? 'PARTIAL' : 'SUCCESS'; -} - -const STATUS_BADGE_CLASSES = { - FAILED: 'border-red-500/50 text-red-600 dark:text-red-400', - PARTIAL: 'border-amber-500/50 text-amber-600 dark:text-amber-400', - SUCCESS: 'border-green-500/50 text-green-600 dark:text-green-400' -} satisfies { [K in InstrumentRepoSyncStatus]: string }; +import { getSyncStatus } from '@/utils/instrument-repo-sync'; +import type { InstrumentRepoSyncStatus } from '@/utils/instrument-repo-sync'; +import { getSyncStatusColor } from '@/utils/tag-colors'; type InstrumentRepoSyncLogTableProps = { logs: InstrumentRepoSyncLog[]; @@ -26,6 +14,7 @@ type InstrumentRepoSyncLogTableProps = { export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableProps) => { const { t } = useTranslation(); + const [theme] = useTheme(); const statusLabels = { FAILED: t({ en: 'Failed', es: 'Fallido', fr: 'Échec' }), @@ -73,9 +62,11 @@ export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableP cell: (ctx) => { const status = getSyncStatus(ctx.row.original); return ( - - {statusLabels[status]} - + ); }, header: ({ column }) => ( diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index bc5670abd..e897c1cff 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -1,11 +1,18 @@ import type { InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { InstrumentRepoSyncLogTable } from '../InstrumentRepoSyncLogTable'; import '@/services/i18n'; +const theme = vi.hoisted(() => ({ current: 'light' })); + +vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ + ...(await importOriginal()), + useTheme: () => [theme.current, vi.fn()] +})); + const logFixture = (overrides: Partial = {}): InstrumentRepoSyncLog => ({ action: 'SYNC', createdAt: new Date('2026-10-01T12:00:00Z'), @@ -23,7 +30,10 @@ const rows = () => screen.getAllByTestId('data-table-row'); const statuses = () => screen.getAllByTestId('sync-log-status').map((badge) => badge.textContent); -afterEach(cleanup); +afterEach(() => { + cleanup(); + theme.current = 'light'; +}); describe('InstrumentRepoSyncLogTable', () => { it('should mark a sync with no failed instrument as a success', () => { @@ -102,4 +112,17 @@ describe('InstrumentRepoSyncLogTable', () => { fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Status')); expect(statuses()).toEqual(['Failed', 'Success']); }); + + it('should colour the status dot for the dark theme when the app is dark', () => { + theme.current = 'dark'; + render(); + const dot = screen.getByTestId('sync-log-status').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#22c55e'); + }); + + it('should colour the status dot for the light theme otherwise', () => { + render(); + const dot = screen.getByTestId('sync-log-status').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#dc2626'); + }); }); diff --git a/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts new file mode 100644 index 000000000..d1e2e3b0d --- /dev/null +++ b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts @@ -0,0 +1,59 @@ +import type { PropsWithChildren } from 'react'; +import { createElement } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useClearInstrumentRepoSyncLogsMutation } from '../useClearInstrumentRepoSyncLogsMutation'; + +const mockAxios = vi.hoisted(() => ({ delete: vi.fn(), isAxiosError: vi.fn(() => false) })); +const addNotification = vi.hoisted(() => vi.fn()); + +vi.mock('axios', () => ({ default: mockAxios })); + +vi.mock('@douglasneuroinformatics/libui/hooks', () => ({ + useNotificationsStore: vi.fn((selector) => selector({ addNotification })), + useTranslation: vi.fn(() => ({ + resolvedLanguage: 'en', + t: (value: string | { en: string }) => (typeof value === 'string' ? value : value.en) + })) +})); + +function renderClearMutation() { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false, throwOnError: true } } }); + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: PropsWithChildren) => + createElement(QueryClientProvider, { children, client: queryClient }); + return { ...renderHook(() => useClearInstrumentRepoSyncLogsMutation(), { wrapper }), invalidateQueries }; +} + +describe('useClearInstrumentRepoSyncLogsMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAxios.delete.mockResolvedValue({ data: { count: 2 } }); + }); + + it('should delete the sync history without the default error toast, since it raises its own', async () => { + const { result } = renderClearMutation(); + await result.current.mutateAsync(); + expect(mockAxios.delete).toHaveBeenCalledWith('/v1/instrument-repos/sync/logs', { + meta: { disableDefaultErrorNotification: true } + }); + }); + + it('should announce the history was cleared and refresh it', async () => { + const { invalidateQueries, result } = renderClearMutation(); + await result.current.mutateAsync(); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Sync history cleared', type: 'success' }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-repo-sync-logs'] }); + }); + + it('should report a failure to clear the history', async () => { + mockAxios.delete.mockRejectedValueOnce(new Error('network')); + const { result } = renderClearMutation(); + result.current.mutate(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Failed to clear the sync history', type: 'error' }); + }); +}); diff --git a/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts new file mode 100644 index 000000000..ab6ad17d5 --- /dev/null +++ b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts @@ -0,0 +1,43 @@ +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; + +import { getApiErrorMessage } from '@/utils/error'; + +import { INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY } from './useInstrumentRepoSyncLogsQuery'; + +export function useClearInstrumentRepoSyncLogsMutation() { + const queryClient = useQueryClient(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const { t } = useTranslation(); + return useMutation({ + mutationFn: () => + axios.delete('/v1/instrument-repos/sync/logs', { meta: { disableDefaultErrorNotification: true } }), + onError(err) { + addNotification({ + message: getApiErrorMessage( + err, + t({ + en: 'Failed to clear the sync history', + es: 'Error al borrar el historial de sincronización', + fr: "Échec de l'effacement de l'historique de synchronisation" + }) + ), + type: 'error' + }); + }, + onSuccess() { + addNotification({ + message: t({ + en: 'Sync history cleared', + es: 'Historial de sincronización borrado', + fr: 'Historique de synchronisation effacé' + }), + type: 'success' + }); + void queryClient.invalidateQueries({ queryKey: [INSTRUMENT_REPO_SYNC_LOGS_QUERY_KEY] }); + }, + // onError reports the failure; the app default would also hand it to the route error boundary. + throwOnError: false + }); +} diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index a66ea68bc..26c46bd68 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -11,6 +11,7 @@ import { Route } from '@/routes/_app/admin/instrument-repos/index'; import '@/services/i18n'; const mocks = vi.hoisted(() => ({ + clearSyncLogs: vi.fn(), createRepo: vi.fn(), deleteRepo: vi.fn(), groups: [] as Group[], @@ -34,6 +35,9 @@ vi.mock('@/hooks/useGroupsQuery', () => ({ useGroupsQuery: () => ({ data: mocks.groups }) })); vi.mock('@/hooks/useInstrumentInfoQuery', () => ({ useInstrumentInfoQuery: () => mocks.instrumentInfo })); +vi.mock('@/hooks/useClearInstrumentRepoSyncLogsMutation', () => ({ + useClearInstrumentRepoSyncLogsMutation: () => ({ isPending: false, mutate: mocks.clearSyncLogs }) +})); vi.mock('@/hooks/useCreateInstrumentRepoMutation', () => ({ useCreateInstrumentRepoMutation: () => ({ isPending: mocks.isCreatePending, mutate: mocks.createRepo }) })); @@ -198,14 +202,12 @@ describe('Route', () => { syncLogFixture({ id: 'older' }) ]; renderPage(); - const failedCount = within(rows()[0]!).getByTestId('instrument-repo-failed-count'); - expect(failedCount.textContent).toBe('1'); - expect(failedCount.className).toContain('text-destructive'); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('1'); }); - it('should show zero failed instruments for a repository with no sync history', () => { + it('should show a dash for the failed count of a repository with no sync history', () => { renderPage(); - expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('0'); + expect(within(rows()[0]!).queryByTestId('instrument-repo-failed-count')).toBeNull(); }); it('should say the latest sync failed when the repository could not be read at all', () => { @@ -233,6 +235,38 @@ describe('Route', () => { expect(within(history).getByText('Other Repo')).toBeTruthy(); }); + it('should not offer to clear an empty sync history', () => { + renderPage(); + expect(screen.getByRole('button', { name: 'Clear History' }).disabled).toBe(true); + }); + + it('should warn that clearing the history deletes it from the database before doing so', () => { + mocks.syncLogs = [syncLogFixture()]; + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); + expect(screen.getByText('Clear the sync history?')).toBeTruthy(); + expect(screen.getByRole('dialog').textContent).toContain('permanently deleted from the database'); + expect(mocks.clearSyncLogs).not.toHaveBeenCalled(); + }); + + it('should clear the history once the warning is accepted', () => { + mocks.syncLogs = [syncLogFixture()]; + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes' })); + expect(mocks.clearSyncLogs).toHaveBeenCalledOnce(); + expect(screen.queryByText('Clear the sync history?')).toBeNull(); + }); + + it('should keep the history when the warning is declined', () => { + mocks.syncLogs = [syncLogFixture()]; + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); + fireEvent.click(screen.getByRole('button', { name: 'No' })); + expect(mocks.clearSyncLogs).not.toHaveBeenCalled(); + expect(screen.queryByText('Clear the sync history?')).toBeNull(); + }); + it('should show only the selected repository in its logs dialog', () => { mocks.syncLogs = [syncLogFixture(), syncLogFixture({ id: 'log-2', repoId: 'repo-2', repoName: 'Other Repo' })]; renderPage(); @@ -248,6 +282,8 @@ describe('Route', () => { }); it('should show a dash for a repository that has never been synced', () => { + // A log keeps the failed count from rendering a dash of its own. + mocks.syncLogs = [syncLogFixture()]; renderPage(); expect(within(rows()[0]!).getByText('-')).toBeTruthy(); }); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 94ace8d5c..38d3c8cba 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -21,6 +21,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { InstrumentRepoSyncLogTable } from '@/components/InstrumentRepoSyncLogTable'; import { PageHeader } from '@/components/PageHeader'; import { TruncatedCell } from '@/components/TruncatedCell'; +import { useClearInstrumentRepoSyncLogsMutation } from '@/hooks/useClearInstrumentRepoSyncLogsMutation'; import { useCreateInstrumentRepoMutation } from '@/hooks/useCreateInstrumentRepoMutation'; import { useDeleteInstrumentRepoMutation } from '@/hooks/useDeleteInstrumentRepoMutation'; import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; @@ -172,6 +173,7 @@ const RouteComponent = () => { const syncRepoMutation = useSyncInstrumentRepoMutation(); const syncAllReposMutation = useSyncAllInstrumentReposMutation(); const syncLogsQuery = useInstrumentRepoSyncLogsQuery(); + const clearSyncLogsMutation = useClearInstrumentRepoSyncLogsMutation(); // Every edition, because the default collapses editions across all sources, not per repository; // `currentRepoInstruments` then keeps the latest edition each repository provides. const instrumentInfoQuery = useInstrumentInfoQuery({ params: { allEditions: true } }); @@ -181,6 +183,7 @@ const RouteComponent = () => { const [repoToView, setRepoToView] = useState(null); const [instrumentSearch, setInstrumentSearch] = useState(''); const [repoToShowLogs, setRepoToShowLogs] = useState(null); + const [isConfirmClearHistoryOpen, setIsConfirmClearHistoryOpen] = useState(false); const [repoToDelete, setRepoToDelete] = useState(null); const [highlightedRowId, setHighlightedRowId] = useState(null); @@ -206,7 +209,7 @@ const RouteComponent = () => { return ( { setIsDialogOpen(false); setIsConfirmDeleteOpen(false); @@ -214,6 +217,7 @@ const RouteComponent = () => { setRepoToView(null); setRepoToDelete(null); setRepoToShowLogs(null); + setIsConfirmClearHistoryOpen(false); setInstrumentSearch(''); }} > @@ -263,29 +267,23 @@ const RouteComponent = () => { : row.instrumentIds.length; }, header: t({ - en: 'Instruments', - es: 'Instrumentos', - fr: 'Instruments' + en: 'Imported', + es: 'Importados', + fr: 'Importés' }), id: 'instrumentCount' }, { cell: (ctx) => { const log = latestSyncLogByRepoId.get(ctx.row.original.id); - if (log?.message) { - return ( - - {t({ en: 'Sync failed', es: 'Sincronización fallida', fr: 'Échec de la synchronisation' })} - - ); + if (!log) { + return '-'; } - const failedCount = log?.failures.length ?? 0; return ( - - {failedCount} + + {log.message + ? t({ en: 'Sync failed', es: 'Sincronización fallida', fr: 'Échec de la synchronisation' }) + : log.failures.length} ); }, @@ -365,11 +363,61 @@ const RouteComponent = () => { }} />
- - {t({ en: 'Sync History', es: 'Historial de sincronización', fr: 'Historique de synchronisation' })} - +
+ + {t({ en: 'Sync History', es: 'Historial de sincronización', fr: 'Historique de synchronisation' })} + + +
+ {isConfirmClearHistoryOpen && ( + + + + {t({ + en: 'Clear the sync history?', + es: '¿Borrar el historial de sincronización?', + fr: "Effacer l'historique de synchronisation ?" + })} + + + {t({ + en: 'Every entry in the sync history will be permanently deleted from the database, including the reasons instruments failed. This cannot be undone. Repositories and their instruments are not affected.', + es: 'Todas las entradas del historial de sincronización se eliminarán permanentemente de la base de datos, incluidos los motivos por los que fallaron los instrumentos. Esta acción no se puede deshacer. Los repositorios y sus instrumentos no se verán afectados.', + fr: "Toutes les entrées de l'historique de synchronisation seront définitivement supprimées de la base de données, y compris les raisons des échecs des instruments. Cette action est irréversible. Les dépôts et leurs instruments ne sont pas touchés." + })} + + + + + + + + )} {repoToShowLogs && ( diff --git a/apps/web/src/utils/instrument-repo-sync.ts b/apps/web/src/utils/instrument-repo-sync.ts new file mode 100644 index 000000000..740007be1 --- /dev/null +++ b/apps/web/src/utils/instrument-repo-sync.ts @@ -0,0 +1,13 @@ +import type { InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; + +type InstrumentRepoSyncStatus = 'FAILED' | 'PARTIAL' | 'SUCCESS'; + +/** A sync that could not read the repository failed outright; one that skipped instruments partly succeeded. */ +function getSyncStatus(log: InstrumentRepoSyncLog): InstrumentRepoSyncStatus { + if (log.message) { + return 'FAILED'; + } + return log.failures.length ? 'PARTIAL' : 'SUCCESS'; +} + +export { getSyncStatus, type InstrumentRepoSyncStatus }; diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index d9072bc6e..b9835ea1e 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,5 +1,7 @@ import type { InstrumentKind } from '@opendatacapture/runtime-core'; +import type { InstrumentRepoSyncStatus } from './instrument-repo-sync'; + type Mode = 'dark' | 'light'; /** @@ -26,6 +28,13 @@ const INSTRUMENT_KIND_COLORS = { SERIES: { dark: '#eab308', light: '#ca8a04' } } as const satisfies { [K in InstrumentKind]: { dark: string; light: string } }; +/** Traffic-light hues for how a repository sync went, from the same scale as the kind colours. */ +const SYNC_STATUS_COLORS = { + FAILED: { dark: '#ef4444', light: '#dc2626' }, + PARTIAL: { dark: '#f59e0b', light: '#d97706' }, + SUCCESS: { dark: '#22c55e', light: '#16a34a' } +} as const satisfies { [K in InstrumentRepoSyncStatus]: { dark: string; light: string } }; + const NEUTRAL_COLOR = { dark: '#a3a3a3', light: '#525252' @@ -42,4 +51,8 @@ function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { return INSTRUMENT_KIND_COLORS[kind][mode]; } -export { getInstrumentKindColor, getSexColor }; +function getSyncStatusColor(status: InstrumentRepoSyncStatus, mode: Mode): string { + return SYNC_STATUS_COLORS[status][mode]; +} + +export { getInstrumentKindColor, getSexColor, getSyncStatusColor }; diff --git a/testing/src/pages/_app/admin/instrument-repos/index.page.ts b/testing/src/pages/_app/admin/instrument-repos/index.page.ts index bf677674d..95c6391c9 100644 --- a/testing/src/pages/_app/admin/instrument-repos/index.page.ts +++ b/testing/src/pages/_app/admin/instrument-repos/index.page.ts @@ -4,6 +4,7 @@ import { AppPage } from '../../route.page'; export class InstrumentReposPage extends AppPage { readonly addRepositoryButton: Locator; + readonly clearHistoryButton: Locator; /** The repositories table; the sync history below it is a second data table. */ readonly dataTable: Locator; readonly githubUrlInput: Locator; @@ -18,6 +19,7 @@ export class InstrumentReposPage extends AppPage { this.dataTable = page.getByTestId('data-table').first(); this.syncLogTable = page.getByTestId('instrument-repo-sync-log'); this.syncAllButton = page.getByRole('button', { name: 'Sync All' }); + this.clearHistoryButton = page.getByRole('button', { name: 'Clear History' }); this.addRepositoryButton = page.getByRole('button', { name: 'Add Repository' }); this.githubUrlInput = page.getByLabel('GitHub URL'); // A plain HTML form (not the shared `Form` component), so its accessible name is the literal label. diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index 79f57a59e..75ff55c3c 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -119,7 +119,11 @@ test.describe('admin instrument repos sync history', () => { await page.route('**/v1/instrument-repos', (route) => route.request().method() === 'GET' ? route.fulfill({ json: [repo] }) : route.continue() ); - await page.route('**/v1/instrument-repos/sync/logs', (route) => route.fulfill({ json: [partialSync] })); + await page.route('**/v1/instrument-repos/sync/logs', (route) => + route.request().method() === 'DELETE' + ? route.fulfill({ json: { count: 1 } }) + : route.fulfill({ json: [partialSync] }) + ); }); test('should keep a long repository URL inside its column, even on the selected row', async ({ getPageModel }) => { @@ -182,4 +186,22 @@ test.describe('admin instrument repos sync history', () => { await expect(reposPage.$ref.getByText('1 of 1 repositories had problems. See the sync history.')).toBeVisible(); expect(syncAllRequested).toBe(true); }); + + test('should clear the sync history only after warning that it is deleted from the database', async ({ + getPageModel, + page + }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const deleteRequest = page.waitForRequest( + (request) => request.method() === 'DELETE' && request.url().endsWith('/v1/instrument-repos/sync/logs') + ); + + await reposPage.clearHistoryButton.click(); + const dialog = reposPage.$ref.getByRole('dialog'); + await expect(dialog).toContainText('permanently deleted from the database'); + await dialog.getByRole('button', { name: 'Yes' }).click(); + + await deleteRequest; + await expect(reposPage.$ref.getByText('Sync history cleared')).toBeVisible(); + }); }); From d2cf1e6aa34c2f00e293a1b69ef87e784b885c02 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 29 Oct 2026 01:33:39 -0400 Subject: [PATCH 03/13] fix: correct the activity log's naming, scoping and presentation The table records both imports and syncs, so "Sync History" was wrong: it is now the Activity Log across the heading, the Clear Log button, both dialogs, the toasts and the API reference. The repositories table's Failed cell said "Sync failed" even for a failed import and now says the repository could not be read, and its instrument count keeps the name Instruments so it no longer collides with the activity log's own Imported column. A failure reason is shown as one plain-text line, clipped rather than wrapped, with every reason in full behind a View button: a long esbuild or validation message made a row several times taller than the rest. An empty log is one line of text instead of a DataTable, whose own empty state is a fixed 288px. POST /:id/sync looked its repository up by id alone, so a user granted update InstrumentRepo for one group could sync a repository belonging only to another; it now goes through accessibleQuery as syncAll already did. Why the log's own route is admin-only while the two sync routes are not is recorded on the handler, since the subject carries no group and cannot be granted. The collection is append-only and the page prefetches it on every visit, so it gains a createdAt index, declared in both the schema and DATABASE_INDEXES because nothing else creates one here, and a request returns at most 500 entries. The page's three prefetches now run together rather than as three round trips, and the newest entry per repository is chosen by its timestamp rather than by its position in the response. Co-Authored-By: Claude Opus 5 --- .../docs/architecture/instrument-pipeline.md | 2 +- apps/api/prisma/schema.prisma | 1 + apps/api/src/core/prisma.ts | 1 + .../instrument-repos.controller.test.ts | 10 +- .../instrument-repos.service.test.ts | 22 +- .../instrument-repos.controller.ts | 14 +- .../instrument-repos.service.ts | 15 +- .../InstrumentRepoSyncLogTable.tsx | 253 ++++++++++++------ .../InstrumentRepoSyncLogTable.test.tsx | 86 +++++- ...learInstrumentRepoSyncLogsMutation.test.ts | 6 +- .../useSyncAllInstrumentReposMutation.test.ts | 2 +- .../useSyncInstrumentRepoMutation.test.ts | 4 +- .../useClearInstrumentRepoSyncLogsMutation.ts | 12 +- .../useSyncAllInstrumentReposMutation.ts | 6 +- .../hooks/useSyncInstrumentRepoMutation.ts | 6 +- .../instrument-repos/__tests__/index.test.tsx | 72 ++++- .../_app/admin/instrument-repos/index.tsx | 53 ++-- .../_app/admin/instrument-repos/index.page.ts | 6 +- .../src/specs/admin-instrument-repos.spec.ts | 61 ++++- 19 files changed, 463 insertions(+), 169 deletions(-) diff --git a/.agents/docs/architecture/instrument-pipeline.md b/.agents/docs/architecture/instrument-pipeline.md index 0d904750b..2d9a3beab 100644 --- a/.agents/docs/architecture/instrument-pipeline.md +++ b/.agents/docs/architecture/instrument-pipeline.md @@ -54,7 +54,7 @@ be created before its series. **A series that references another repository still fails to import.** Nothing resolves dependencies across repositories or retries afterwards, so whether it works depends on the order the repos were added. Making this reliable needs a second resolution pass once every repo has been imported. Until -then, the failure and its reason land in the repository's sync history (`InstrumentRepoSyncLog`, on +then, the failure and its reason land in the repository's activity log (`InstrumentRepoSyncLog`, on the admin Instrument Repositories page), and syncing the repository again once its dependencies are stored imports the series. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4045e8075..7f477e562 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -285,6 +285,7 @@ model InstrumentRepoSyncLog { repoId String? @db.ObjectId repoName String + @@index([createdAt]) @@map("InstrumentRepoSyncLogModel") } diff --git a/apps/api/src/core/prisma.ts b/apps/api/src/core/prisma.ts index 6cb190d8f..42b2795cb 100644 --- a/apps/api/src/core/prisma.ts +++ b/apps/api/src/core/prisma.ts @@ -70,6 +70,7 @@ const DATABASE_INDEXES: DatabaseIndex[] = [ { collection: 'InstrumentRecordModel', fields: ['instrumentId'] }, { collection: 'InstrumentRecordModel', fields: ['sessionId'] }, { collection: 'InstrumentRepoModel', fields: ['url'], unique: true }, + { collection: 'InstrumentRepoSyncLogModel', fields: ['createdAt'] }, { collection: 'SessionModel', fields: ['groupId', 'date'] }, { collection: 'SessionModel', fields: ['subjectId'] }, { collection: 'SubjectModel', fields: ['groupIds'] }, diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts index 134c958bd..0e6b07a87 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts @@ -45,10 +45,10 @@ describe('InstrumentReposController', () => { expect(service.findAll).toHaveBeenCalledWith({ ability }); }); - it('should sync the requested repository', async () => { + it("should scope the sync of one repository to the current user's ability", async () => { service.sync.mockResolvedValueOnce(repo); - await expect(controller.sync('repo-1')).resolves.toBe(repo); - expect(service.sync).toHaveBeenCalledWith('repo-1'); + await expect(controller.sync('repo-1', ability)).resolves.toBe(repo); + expect(service.sync).toHaveBeenCalledWith('repo-1', { ability }); }); it("should sync every repository the current user's ability allows", async () => { @@ -57,13 +57,13 @@ describe('InstrumentReposController', () => { expect(service.syncAll).toHaveBeenCalledWith({ ability }); }); - it("should scope the sync history to the current user's ability", async () => { + it("should scope the activity log to the current user's ability", async () => { service.findSyncLogs.mockResolvedValueOnce([]); await expect(controller.findSyncLogs(ability)).resolves.toStrictEqual([]); expect(service.findSyncLogs).toHaveBeenCalledWith({ ability }); }); - it("should clear the sync history within the current user's ability", async () => { + it("should clear the activity log within the current user's ability", async () => { service.deleteSyncLogs.mockResolvedValueOnce({ count: 2 }); await expect(controller.deleteSyncLogs(ability)).resolves.toStrictEqual({ count: 2 }); expect(service.deleteSyncLogs).toHaveBeenCalledWith({ ability }); diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts index 2d0482dd9..4732e46f0 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts @@ -305,6 +305,14 @@ describe('InstrumentReposService', () => { await expect(service.sync('missing')).rejects.toBeInstanceOf(NotFoundException); }); + it("should look the repo up within the repos the caller may update, so a sync cannot reach another group's", async () => { + instrumentRepoModel.findFirst.mockResolvedValueOnce(null); + + await expect(service.sync('r1')).rejects.toBeInstanceOf(NotFoundException); + + expect(instrumentRepoModel.findFirst).toHaveBeenCalledWith({ where: { AND: [{}], id: 'r1' } }); + }); + it('should replace the instrument list with what the repo provides now, and return the sync log', async () => { const encryptedToken = internal.encrypt('stored-token'); instrumentRepoModel.findFirst.mockResolvedValueOnce({ @@ -394,7 +402,19 @@ describe('InstrumentReposService', () => { instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([{ id: 'log-1' }]); await expect(service.findSyncLogs()).resolves.toStrictEqual([{ id: 'log-1' }]); - expect(instrumentRepoSyncLogModel.findMany).toHaveBeenCalledWith({ orderBy: { createdAt: 'desc' }, where: {} }); + expect(instrumentRepoSyncLogModel.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: 'desc' }, + take: 500, + where: {} + }); + }); + + it('should cap one request, so an append-only collection cannot grow the response without bound', async () => { + instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([]); + + await service.findSyncLogs(); + + expect(instrumentRepoSyncLogModel.findMany.mock.lastCall?.[0]).toMatchObject({ take: 500 }); }); it('should download without a token when the repo stores none, so public repos sync', async () => { diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index 0e58574a5..0c053177c 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -25,7 +25,7 @@ export class InstrumentReposController { return this.instrumentReposService.deleteById(id, { ability }); } - @ApiOperation({ summary: 'Clear the Sync History of Every Instrument Repo' }) + @ApiOperation({ summary: 'Clear the Activity Log of Every Instrument Repo' }) @Delete('sync/logs') @RouteAccess(ADMIN_ONLY) deleteSyncLogs(@CurrentUser('ability') ability?: AppAbility) { @@ -39,7 +39,13 @@ export class InstrumentReposController { return this.instrumentReposService.findAll({ ability }); } - @ApiOperation({ summary: 'Get the Sync History of Every Instrument Repo' }) + /** + * Admin-only where the two sync routes are not, because this is the one that crosses repositories. + * `InstrumentRepoSyncLog` carries no group, and is absent from `$AppSubjectName` so that no user can + * be granted it, which leaves nothing to confine a read of the whole history to the repositories the + * caller may see. The sync routes return only the log of a repository the caller may already update. + */ + @ApiOperation({ summary: 'Get the Activity Log of Every Instrument Repo' }) @Get('sync/logs') @RouteAccess(ADMIN_ONLY) findSyncLogs(@CurrentUser('ability') ability?: AppAbility) { @@ -49,8 +55,8 @@ export class InstrumentReposController { @ApiOperation({ summary: 'Sync Instrument Repo' }) @Post(':id/sync') @RouteAccess({ action: 'update', subject: 'InstrumentRepo' }) - sync(@Param('id') id: string) { - return this.instrumentReposService.sync(id); + sync(@Param('id') id: string, @CurrentUser('ability') ability?: AppAbility) { + return this.instrumentReposService.sync(id, { ability }); } @ApiOperation({ summary: 'Sync Every Instrument Repo' }) diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index 622c39f73..7d633658a 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -38,6 +38,12 @@ type RepoImportResult = { createdIds: string[]; failures: InstrumentRepoSyncFail type InstrumentRepoRecord = Awaited['findFirstOrThrow']>>; +/** + * The newest entries one request returns. The collection is append-only and nothing prunes it, so an + * unbounded read grows with the instance's whole history. + */ +const SYNC_LOG_LIMIT = 500; + /** * The detail Nest exceptions thrown by `InstrumentsService.create` carry beyond their message: a VM * evaluation error as `cause`, schema validation errors as `issues`. @@ -157,7 +163,7 @@ export class InstrumentReposService implements OnModuleInit { return this.stripSecrets(deleted); } - /** Erase the whole sync history. The repositories and their instruments are untouched. */ + /** Erase the whole activity log. The repositories and their instruments are untouched. */ async deleteSyncLogs({ ability }: EntityOperationOptions = {}) { return this.instrumentRepoSyncLogModel.deleteMany({ where: accessibleQuery(ability, 'delete', 'InstrumentRepoSyncLog') @@ -174,6 +180,7 @@ export class InstrumentReposService implements OnModuleInit { async findSyncLogs({ ability }: EntityOperationOptions = {}) { return this.instrumentRepoSyncLogModel.findMany({ orderBy: { createdAt: 'desc' }, + take: SYNC_LOG_LIMIT, where: accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog') }); } @@ -187,8 +194,10 @@ export class InstrumentReposService implements OnModuleInit { } } - async sync(id: string) { - const repo = await this.instrumentRepoModel.findFirst({ where: { id } }); + async sync(id: string, { ability }: EntityOperationOptions = {}) { + const repo = await this.instrumentRepoModel.findFirst({ + where: { AND: [accessibleQuery(ability, 'update', 'InstrumentRepo')], id } + }); if (!repo) { throw new NotFoundException(`Failed to find instrument repo with ID: ${id}`); } diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index cc735b54b..2b0d8a112 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -1,4 +1,6 @@ -import { Badge, DataTable } from '@douglasneuroinformatics/libui/components'; +import { Fragment, useState } from 'react'; + +import { Badge, Button, DataTable, Dialog } from '@douglasneuroinformatics/libui/components'; import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InstrumentRepoSyncLog, InstrumentRepoSyncStage } from '@opendatacapture/schemas/instrument-repo'; @@ -15,6 +17,7 @@ type InstrumentRepoSyncLogTableProps = { export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableProps) => { const { t } = useTranslation(); const [theme] = useTheme(); + const [logToView, setLogToView] = useState(null); const statusLabels = { FAILED: t({ en: 'Failed', es: 'Fallido', fr: 'Échec' }), @@ -32,91 +35,173 @@ export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableP LOAD: t({ en: 'Load', es: 'Carga', fr: 'Chargement' }) } satisfies { [K in InstrumentRepoSyncStage]: string }; + /** + * The cell holds one line of plain text, so a reason long enough to wrap would otherwise make the + * row several times taller than the rest. The whole reason lives in the dialog the View button opens. + */ + const summarize = ({ failures, message }: InstrumentRepoSyncLog): null | string => { + if (message) { + return message; + } + if (failures.length > 1) { + return t({ + en: `${failures.length} instruments failed`, + es: `${failures.length} instrumentos fallaron`, + fr: `${failures.length} instruments ont échoué` + }); + } + const [failure] = failures; + if (!failure) { + return null; + } + return `${failure.instrumentDir} (${stageLabels[failure.stage]}): ${failure.message}`; + }; + + /** Which import or sync the dialog is showing, since the dialog is detached from its row. */ + const describeLog = (log: InstrumentRepoSyncLog) => + `${log.repoName} · ${actionLabels[log.action]} · ${log.createdAt.toLocaleString()}`; + + if (logs.length === 0) { + // Not an empty DataTable: its own empty state is a fixed 288px tall, which dwarfs the heading. + return ( +

+ {t({ + en: 'No imports or syncs recorded yet.', + es: 'Aún no se han registrado importaciones ni sincronizaciones.', + fr: "Aucun import ni synchronisation enregistré pour l'instant." + })} +

+ ); + } + return ( -
- log.createdAt.getTime(), - cell: (ctx) => ctx.row.original.createdAt.toLocaleString(), - header: ({ column }) => ( - - ), - id: 'date' - }, - { - accessorKey: 'repoName', - header: ({ column }) => ( - - ) - }, - { - accessorFn: (log: InstrumentRepoSyncLog) => actionLabels[log.action], - header: ({ column }) => ( - - ), - id: 'action' - }, - { - accessorFn: (log: InstrumentRepoSyncLog) => getSyncStatus(log), - cell: (ctx) => { - const status = getSyncStatus(ctx.row.original); - return ( - - ); + +
+ log.createdAt.getTime(), + cell: (ctx) => ctx.row.original.createdAt.toLocaleString(), + header: ({ column }) => ( + + ), + id: 'date' + }, + { + accessorKey: 'repoName', + header: ({ column }) => ( + + ) + }, + { + accessorFn: (log: InstrumentRepoSyncLog) => actionLabels[log.action], + header: ({ column }) => ( + + ), + id: 'action' + }, + { + accessorFn: (log: InstrumentRepoSyncLog) => getSyncStatus(log), + cell: (ctx) => { + const status = getSyncStatus(ctx.row.original); + return ( + + ); + }, + header: ({ column }) => ( + + ), + id: 'status' + }, + { + accessorKey: 'importedCount', + header: ({ column }) => ( + + ) }, - header: ({ column }) => ( - - ), - id: 'status' - }, - { - accessorKey: 'importedCount', - header: ({ column }) => ( - - ) - }, - { - accessorFn: (log: InstrumentRepoSyncLog) => log.failures.length, - header: ({ column }) => ( - - ), - id: 'failedCount' - }, - { - cell: (ctx) => { - const { failures, message } = ctx.row.original; - if (!message && failures.length === 0) { - return '-'; - } - return ( -
    - {message &&
  • {message}
  • } - {failures.map(({ instrumentDir, message, stage }) => ( -
  • - {instrumentDir} - - {stageLabels[stage]} - - {message} -
  • - ))} -
- ); + { + accessorFn: (log: InstrumentRepoSyncLog) => log.failures.length, + header: ({ column }) => ( + + ), + id: 'failedCount' }, - enableSorting: false, - header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), - id: 'messages', - size: 480 - } - ]} - data={logs} - initialState={{ sorting: [{ desc: true, id: 'date' }] }} - /> -
+ { + cell: (ctx) => { + const log = ctx.row.original; + const summary = summarize(log); + if (!summary) { + return '-'; + } + return ( +
+ + {summary} + + +
+ ); + }, + enableSorting: false, + header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), + id: 'messages', + size: 480 + } + ]} + data={logs} + initialState={{ sorting: [{ desc: true, id: 'date' }] }} + /> +
+ setLogToView(null)}> + {logToView && ( + + + + {t({ en: 'Error Details', es: 'Detalles del error', fr: "Détails de l'erreur" })} + + {describeLog(logToView)} + +
+ {logToView.message && ( +
+                  {logToView.message}
+                
+ )} + {logToView.failures.map(({ instrumentDir, message, stage }) => ( +
+
+ {instrumentDir} + {stageLabels[stage]} +
+
+                    {message}
+                  
+
+ ))} +
+
+ )} +
+ ); }; diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index e897c1cff..b45976e44 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -36,6 +36,83 @@ afterEach(() => { }); describe('InstrumentRepoSyncLogTable', () => { + it('should say so in one line when there is nothing to show, rather than render an empty table', () => { + render(); + expect(screen.getByText('No imports or syncs recorded yet.')).toBeTruthy(); + expect(screen.queryByTestId('data-table')).toBeNull(); + }); + + it('should summarize one failure as a single plain-text line, clipped rather than wrapped', () => { + const message = 'Instrument validation failed - content.0: Series item PHQ_9 edition 2 does not exist'; + render( + + ); + const summary = screen.getByTestId('sync-log-messages'); + expect(summary.textContent).toBe(`CPP_SERIES (Load): ${message}`); + expect(summary.className).toContain('whitespace-nowrap'); + expect(summary.className).toContain('text-ellipsis'); + }); + + it('should summarize several failures as a count, so the row stays one line however many there are', () => { + render( + + ); + expect(screen.getByTestId('sync-log-messages').textContent).toBe('2 instruments failed'); + }); + + it('should show every failure in full when View is clicked, since the cell only holds a summary', () => { + const message = 'Transform failed with 1 error: /BROKEN_FORM/index.ts:14:22: ERROR: Expected ")" but found "{"'; + render( + + ); + fireEvent.click(screen.getByTestId('sync-log-view-messages')); + const details = screen.getByTestId('sync-log-error-details'); + expect(within(details).getByText(message)).toBeTruthy(); + expect(within(details).getByText('details.title: Expected string')).toBeTruthy(); + expect(within(details).getByText('BROKEN_FORM')).toBeTruthy(); + expect(within(details).getByText('Compile')).toBeTruthy(); + }); + + it('should close the error dialog on dismissal, so another row can be inspected next', () => { + render( + + ); + fireEvent.click(screen.getByTestId('sync-log-view-messages')); + expect(screen.queryByTestId('sync-log-error-details')).toBeTruthy(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByTestId('sync-log-error-details')).toBeNull(); + }); + + it('should show the reason the whole repository could not be read in the dialog too', () => { + render(); + fireEvent.click(screen.getByTestId('sync-log-view-messages')); + expect(within(screen.getByTestId('sync-log-error-details')).getByText('Repository not found (404)')).toBeTruthy(); + }); + it('should mark a sync with no failed instrument as a success', () => { render(); expect(statuses()).toEqual(['Success']); @@ -53,16 +130,13 @@ describe('InstrumentRepoSyncLogTable', () => { /> ); expect(statuses()).toEqual(['Partial success']); - const messages = screen.getByTestId('sync-log-messages'); - expect(within(messages).getByText('PHQ_9')).toBeTruthy(); - expect(within(messages).getByText('Load')).toBeTruthy(); - expect(messages.textContent).toContain('Instrument validation failed'); + expect(screen.getByTestId('sync-log-messages').textContent).toBe('PHQ_9 (Load): Instrument validation failed'); }); it('should mark a sync that could not read the repository as failed, with the reason', () => { render(); expect(statuses()).toEqual(['Failed']); - expect(within(screen.getByTestId('sync-log-messages')).getByText('Repository not found')).toBeTruthy(); + expect(screen.getByTestId('sync-log-messages').textContent).toBe('Repository not found'); }); it('should label an import and a compile failure in words rather than as codes', () => { @@ -72,7 +146,7 @@ describe('InstrumentRepoSyncLogTable', () => { /> ); expect(within(rows()[0]!).getByText('Import')).toBeTruthy(); - expect(within(rows()[0]!).getByText('Compile')).toBeTruthy(); + expect(within(rows()[0]!).getByTestId('sync-log-messages').textContent).toBe('A (Compile): x'); }); it('should list the newest entry first until another column is sorted', () => { diff --git a/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts index d1e2e3b0d..b52b467ba 100644 --- a/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts @@ -34,7 +34,7 @@ describe('useClearInstrumentRepoSyncLogsMutation', () => { mockAxios.delete.mockResolvedValue({ data: { count: 2 } }); }); - it('should delete the sync history without the default error toast, since it raises its own', async () => { + it('should delete the activity log without the default error toast, since it raises its own', async () => { const { result } = renderClearMutation(); await result.current.mutateAsync(); expect(mockAxios.delete).toHaveBeenCalledWith('/v1/instrument-repos/sync/logs', { @@ -45,7 +45,7 @@ describe('useClearInstrumentRepoSyncLogsMutation', () => { it('should announce the history was cleared and refresh it', async () => { const { invalidateQueries, result } = renderClearMutation(); await result.current.mutateAsync(); - expect(addNotification).toHaveBeenLastCalledWith({ message: 'Sync history cleared', type: 'success' }); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Activity log cleared', type: 'success' }); expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['instrument-repo-sync-logs'] }); }); @@ -54,6 +54,6 @@ describe('useClearInstrumentRepoSyncLogsMutation', () => { const { result } = renderClearMutation(); result.current.mutate(); await waitFor(() => expect(result.current.isError).toBe(true)); - expect(addNotification).toHaveBeenLastCalledWith({ message: 'Failed to clear the sync history', type: 'error' }); + expect(addNotification).toHaveBeenLastCalledWith({ message: 'Failed to clear the activity log', type: 'error' }); }); }); diff --git a/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts index 62e75130b..73de2f27b 100644 --- a/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts @@ -96,7 +96,7 @@ describe('useSyncAllInstrumentReposMutation', () => { const { result } = renderSyncAllMutation(); await result.current.mutateAsync(); expect(addNotification).toHaveBeenLastCalledWith({ - message: '2 of 3 repositories had problems. See the sync history.', + message: '2 of 3 repositories had problems. See the activity log.', type: 'warning' }); }); diff --git a/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts b/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts index dff010aff..242811915 100644 --- a/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useSyncInstrumentRepoMutation.test.ts @@ -91,12 +91,12 @@ describe('useSyncInstrumentRepoMutation', () => { const { result } = renderSyncMutation(); await result.current.mutateAsync({ id: 'repo-1' }); expect(addNotification).toHaveBeenLastCalledWith({ - message: 'Repository synced, but 1 instruments failed. See the sync history.', + message: 'Repository synced, but 1 instruments failed. See the activity log.', type: 'warning' }); }); - it('should refresh the sync history even when the sync fails, because the failure is logged too', async () => { + it('should refresh the activity log even when the sync fails, because the failure is logged too', async () => { mockAxios.post.mockRejectedValueOnce(new Error('down')); const { invalidateQueries, result } = renderSyncMutation(); result.current.mutate({ id: 'repo-1' }); diff --git a/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts index ab6ad17d5..6078d65a9 100644 --- a/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts +++ b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts @@ -18,9 +18,9 @@ export function useClearInstrumentRepoSyncLogsMutation() { message: getApiErrorMessage( err, t({ - en: 'Failed to clear the sync history', - es: 'Error al borrar el historial de sincronización', - fr: "Échec de l'effacement de l'historique de synchronisation" + en: 'Failed to clear the activity log', + es: 'Error al borrar el registro de actividad', + fr: "Échec de l'effacement du journal d'activité" }) ), type: 'error' @@ -29,9 +29,9 @@ export function useClearInstrumentRepoSyncLogsMutation() { onSuccess() { addNotification({ message: t({ - en: 'Sync history cleared', - es: 'Historial de sincronización borrado', - fr: 'Historique de synchronisation effacé' + en: 'Activity log cleared', + es: 'Registro de actividad borrado', + fr: "Journal d'activité effacé" }), type: 'success' }); diff --git a/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts b/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts index 2fb3cbff6..01afdd81b 100644 --- a/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts +++ b/apps/web/src/hooks/useSyncAllInstrumentReposMutation.ts @@ -62,9 +62,9 @@ export function useSyncAllInstrumentReposMutation() { } addNotification({ message: t({ - en: `${problemCount} of ${logs.length} repositories had problems. See the sync history.`, - es: `${problemCount} de ${logs.length} repositorios tuvieron problemas. Consulte el historial de sincronización.`, - fr: `${problemCount} dépôts sur ${logs.length} ont rencontré des problèmes. Consultez l'historique de synchronisation.` + en: `${problemCount} of ${logs.length} repositories had problems. See the activity log.`, + es: `${problemCount} de ${logs.length} repositorios tuvieron problemas. Consulte el registro de actividad.`, + fr: `${problemCount} dépôts sur ${logs.length} ont rencontré des problèmes. Consultez le journal d'activité.` }), type: 'warning' }); diff --git a/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts b/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts index 1ce684191..94c45f717 100644 --- a/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts +++ b/apps/web/src/hooks/useSyncInstrumentRepoMutation.ts @@ -53,9 +53,9 @@ export function useSyncInstrumentRepoMutation() { failedCount ? { message: t({ - en: `Repository synced, but ${failedCount} instruments failed. See the sync history.`, - es: `Repositorio sincronizado, pero ${failedCount} instrumentos fallaron. Consulte el historial de sincronización.`, - fr: `Dépôt synchronisé, mais ${failedCount} instruments ont échoué. Consultez l'historique de synchronisation.` + en: `Repository synced, but ${failedCount} instruments failed. See the activity log.`, + es: `Repositorio sincronizado, pero ${failedCount} instrumentos fallaron. Consulte el registro de actividad.`, + fr: `Dépôt synchronisé, mais ${failedCount} instruments ont échoué. Consultez le journal d'activité.` }), type: 'warning' } diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index 26c46bd68..5c1917057 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -178,7 +178,7 @@ describe('Route', () => { afterEach(cleanup); - it('should prefetch the repositories, groups and sync history in the loader, so the page renders without a waterfall', async () => { + it('should prefetch the repositories, groups and activity log in the loader, so the page renders without a waterfall', async () => { const queryClient = new QueryClient(); const ensureQueryData = vi.spyOn(queryClient, 'ensureQueryData').mockResolvedValue([]); await runLoader(queryClient); @@ -189,6 +189,14 @@ describe('Route', () => { ]); }); + it('should start all three prefetches before awaiting any, so the page does not wait out three round trips', async () => { + const queryClient = new QueryClient(); + const ensureQueryData = vi.spyOn(queryClient, 'ensureQueryData').mockResolvedValue([]); + const loading = runLoader(queryClient); + expect(ensureQueryData).toHaveBeenCalledTimes(3); + await loading; + }); + it('should truncate a long URL in its cell and keep the full URL on hover, so it cannot run into the next column', () => { renderPage(); const cell = within(rows()[0]!).getByTestId('instrument-repo-url'); @@ -205,15 +213,42 @@ describe('Route', () => { expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('1'); }); - it('should show a dash for the failed count of a repository with no sync history', () => { + it('should read the failed count from the newest log by its timestamp, not by its place in the list', () => { + mocks.syncLogs = [ + syncLogFixture({ createdAt: new Date('2026-10-01'), id: 'older' }), + syncLogFixture({ + createdAt: new Date('2026-10-02'), + failures: [{ instrumentDir: 'A', message: 'broken', stage: 'LOAD' }], + id: 'newest' + }) + ]; + renderPage(); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('1'); + }); + + it('should ignore a log with no repository, since a failed import is recorded before one exists', () => { + mocks.syncLogs = [ + syncLogFixture({ + createdAt: new Date('2026-10-03'), + id: 'orphan', + message: 'Repository not found', + repoId: null + }), + syncLogFixture({ failures: [{ instrumentDir: 'A', message: 'broken', stage: 'LOAD' }], id: 'log-1' }) + ]; + renderPage(); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('1'); + }); + + it('should show a dash for the failed count of a repository with no activity log', () => { renderPage(); expect(within(rows()[0]!).queryByTestId('instrument-repo-failed-count')).toBeNull(); }); - it('should say the latest sync failed when the repository could not be read at all', () => { + it('should say the repository could not be read when the whole import or sync failed', () => { mocks.syncLogs = [syncLogFixture({ message: 'Repository not found' })]; renderPage(); - expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('Sync failed'); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('Could not be read'); }); it('should sync every repository from the Sync All button', () => { @@ -227,7 +262,7 @@ describe('Route', () => { expect(screen.getByRole('button', { name: 'Sync All' }).disabled).toBe(true); }); - it('should show the sync history of every repository below the table', () => { + it('should show the activity log of every repository below the table', () => { mocks.syncLogs = [syncLogFixture(), syncLogFixture({ id: 'log-2', repoId: 'repo-2', repoName: 'Other Repo' })]; renderPage(); const history = screen.getByTestId('instrument-repo-sync-log'); @@ -235,16 +270,16 @@ describe('Route', () => { expect(within(history).getByText('Other Repo')).toBeTruthy(); }); - it('should not offer to clear an empty sync history', () => { + it('should not offer to clear an empty activity log', () => { renderPage(); - expect(screen.getByRole('button', { name: 'Clear History' }).disabled).toBe(true); + expect(screen.getByRole('button', { name: 'Clear Log' }).disabled).toBe(true); }); it('should warn that clearing the history deletes it from the database before doing so', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); - fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); - expect(screen.getByText('Clear the sync history?')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); + expect(screen.getByText('Clear the activity log?')).toBeTruthy(); expect(screen.getByRole('dialog').textContent).toContain('permanently deleted from the database'); expect(mocks.clearSyncLogs).not.toHaveBeenCalled(); }); @@ -252,19 +287,19 @@ describe('Route', () => { it('should clear the history once the warning is accepted', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); - fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); fireEvent.click(screen.getByRole('button', { name: 'Yes' })); expect(mocks.clearSyncLogs).toHaveBeenCalledOnce(); - expect(screen.queryByText('Clear the sync history?')).toBeNull(); + expect(screen.queryByText('Clear the activity log?')).toBeNull(); }); it('should keep the history when the warning is declined', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); - fireEvent.click(screen.getByRole('button', { name: 'Clear History' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); fireEvent.click(screen.getByRole('button', { name: 'No' })); expect(mocks.clearSyncLogs).not.toHaveBeenCalled(); - expect(screen.queryByText('Clear the sync history?')).toBeNull(); + expect(screen.queryByText('Clear the activity log?')).toBeNull(); }); it('should show only the selected repository in its logs dialog', () => { @@ -272,10 +307,19 @@ describe('Route', () => { renderPage(); fireEvent.click(rowAction('Logs')); const dialog = screen.getByRole('dialog'); - expect(within(dialog).getByText('Sync history of "Core Repo"')).toBeTruthy(); + expect(within(dialog).getByText('Activity log of "Core Repo"')).toBeTruthy(); expect(within(dialog).queryByText('Other Repo')).toBeNull(); }); + it('should describe the logs dialog, since a dialog without one is announced with its title alone', () => { + mocks.syncLogs = [syncLogFixture()]; + renderPage(); + fireEvent.click(rowAction('Logs')); + const dialog = screen.getByRole('dialog'); + expect(dialog.getAttribute('aria-describedby')).toBeTruthy(); + expect(within(dialog).getByText('Every import and sync of this repository, newest first.')).toBeTruthy(); + }); + it('should count the instruments a repository contributed', () => { renderPage(); expect(within(rows()[0]!).getByText('2')).toBeTruthy(); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 38d3c8cba..a1c58fd09 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -152,11 +152,15 @@ const AddRepoForm = ({ ); }; -/** The most recent log per repository; the API returns the history newest first. */ +/** The most recent log per repository, by its own timestamp rather than its position in the list. */ function latestSyncLogs(logs: InstrumentRepoSyncLog[]) { const latest = new Map(); for (const log of logs) { - if (log.repoId && !latest.has(log.repoId)) { + if (!log.repoId) { + continue; + } + const current = latest.get(log.repoId); + if (!current || current.createdAt < log.createdAt) { latest.set(log.repoId, log); } } @@ -267,9 +271,9 @@ const RouteComponent = () => { : row.instrumentIds.length; }, header: t({ - en: 'Imported', - es: 'Importados', - fr: 'Importés' + en: 'Instruments', + es: 'Instrumentos', + fr: 'Instruments' }), id: 'instrumentCount' }, @@ -282,7 +286,7 @@ const RouteComponent = () => { return ( {log.message - ? t({ en: 'Sync failed', es: 'Sincronización fallida', fr: 'Échec de la synchronisation' }) + ? t({ en: 'Could not be read', es: 'No se pudo leer', fr: 'Lecture impossible' }) : log.failures.length} ); @@ -365,14 +369,14 @@ const RouteComponent = () => {
- {t({ en: 'Sync History', es: 'Historial de sincronización', fr: 'Historique de synchronisation' })} + {t({ en: 'Activity Log', es: 'Registro de actividad', fr: "Journal d'activité" })}
@@ -382,16 +386,16 @@ const RouteComponent = () => { {t({ - en: 'Clear the sync history?', - es: '¿Borrar el historial de sincronización?', - fr: "Effacer l'historique de synchronisation ?" + en: 'Clear the activity log?', + es: '¿Borrar el registro de actividad?', + fr: "Effacer le journal d'activité ?" })} {t({ - en: 'Every entry in the sync history will be permanently deleted from the database, including the reasons instruments failed. This cannot be undone. Repositories and their instruments are not affected.', - es: 'Todas las entradas del historial de sincronización se eliminarán permanentemente de la base de datos, incluidos los motivos por los que fallaron los instrumentos. Esta acción no se puede deshacer. Los repositorios y sus instrumentos no se verán afectados.', - fr: "Toutes les entrées de l'historique de synchronisation seront définitivement supprimées de la base de données, y compris les raisons des échecs des instruments. Cette action est irréversible. Les dépôts et leurs instruments ne sont pas touchés." + en: 'Every entry in the activity log will be permanently deleted from the database, including the reasons instruments failed. This cannot be undone. Repositories and their instruments are not affected.', + es: 'Todas las entradas del registro de actividad se eliminarán permanentemente de la base de datos, incluidos los motivos por los que fallaron los instrumentos. Esta acción no se puede deshacer. Los repositorios y sus instrumentos no se verán afectados.', + fr: "Toutes les entrées du journal d'activité seront définitivement supprimées de la base de données, y compris les raisons des échecs des instruments. Cette action est irréversible. Les dépôts et leurs instruments ne sont pas touchés." })} @@ -423,11 +427,18 @@ const RouteComponent = () => { {t({ - en: `Sync history of "${repoToShowLogs.name}"`, - es: `Historial de sincronización de "${repoToShowLogs.name}"`, - fr: `Historique de synchronisation de « ${repoToShowLogs.name} »` + en: `Activity log of "${repoToShowLogs.name}"`, + es: `Registro de actividad de "${repoToShowLogs.name}"`, + fr: `Journal d'activité de « ${repoToShowLogs.name} »` })} + + {t({ + en: 'Every import and sync of this repository, newest first.', + es: 'Todas las importaciones y sincronizaciones de este repositorio, las más recientes primero.', + fr: "Tous les imports et synchronisations de ce dépôt, les plus récents d'abord." + })} +
log.repoId === repoToShowLogs.id)} /> @@ -612,8 +623,10 @@ const RouteComponent = () => { export const Route = createFileRoute('/_app/admin/instrument-repos/')({ component: RouteComponent, loader: async ({ context }) => { - await context.queryClient.ensureQueryData(instrumentReposQueryOptions()); - await context.queryClient.ensureQueryData(groupsQueryOptions()); - await context.queryClient.ensureQueryData(instrumentRepoSyncLogsQueryOptions()); + await Promise.all([ + context.queryClient.ensureQueryData(instrumentReposQueryOptions()), + context.queryClient.ensureQueryData(groupsQueryOptions()), + context.queryClient.ensureQueryData(instrumentRepoSyncLogsQueryOptions()) + ]); } }); diff --git a/testing/src/pages/_app/admin/instrument-repos/index.page.ts b/testing/src/pages/_app/admin/instrument-repos/index.page.ts index 95c6391c9..f2745cc53 100644 --- a/testing/src/pages/_app/admin/instrument-repos/index.page.ts +++ b/testing/src/pages/_app/admin/instrument-repos/index.page.ts @@ -4,8 +4,8 @@ import { AppPage } from '../../route.page'; export class InstrumentReposPage extends AppPage { readonly addRepositoryButton: Locator; - readonly clearHistoryButton: Locator; - /** The repositories table; the sync history below it is a second data table. */ + readonly clearLogButton: Locator; + /** The repositories table; the activity log below it is a second data table. */ readonly dataTable: Locator; readonly githubUrlInput: Locator; readonly importButton: Locator; @@ -19,7 +19,7 @@ export class InstrumentReposPage extends AppPage { this.dataTable = page.getByTestId('data-table').first(); this.syncLogTable = page.getByTestId('instrument-repo-sync-log'); this.syncAllButton = page.getByRole('button', { name: 'Sync All' }); - this.clearHistoryButton = page.getByRole('button', { name: 'Clear History' }); + this.clearLogButton = page.getByRole('button', { name: 'Clear Log' }); this.addRepositoryButton = page.getByRole('button', { name: 'Add Repository' }); this.githubUrlInput = page.getByLabel('GitHub URL'); // A plain HTML form (not the shared `Form` component), so its accessible name is the literal label. diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index 75ff55c3c..10d4da87a 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -87,7 +87,7 @@ test.describe('admin instrument repos refused import', () => { }); }); -test.describe('admin instrument repos sync history', () => { +test.describe('admin instrument repos activity log', () => { test.use({ actingRole: 'ADMIN' }); // Answered by the test rather than the API, which must not reach out to GitHub under NODE_ENV=test. @@ -106,7 +106,14 @@ test.describe('admin instrument repos sync history', () => { const partialSync = { action: 'SYNC', createdAt: '2026-10-28T00:00:00.000Z', - failures: [{ instrumentDir: 'CPP_SERIES', message: "Series item 'PHQ_9' edition 2 does not exist", stage: 'LOAD' }], + failures: [ + { + instrumentDir: 'CPP_SERIES', + message: + "Instrument validation failed - content.0.items.1: Series item 'PHQ_9' edition 2 does not exist on this instance, so the series cannot be resolved", + stage: 'LOAD' + } + ], id: '6a0000000000000000000002', importedCount: 2, message: null, @@ -140,7 +147,7 @@ test.describe('admin instrument repos sync history', () => { expect(overflow.spill).toBeLessThanOrEqual(0); }); - test('should count the failed instruments and explain them in the sync history', async ({ getPageModel }) => { + test('should count the failed instruments and explain them in the activity log', async ({ getPageModel }) => { const reposPage = await getPageModel('/admin/instrument-repos'); await expect(reposPage.dataTable.getByTestId('instrument-repo-failed-count')).toHaveText('1'); @@ -148,16 +155,50 @@ test.describe('admin instrument repos sync history', () => { const messages = reposPage.syncLogTable.getByTestId('sync-log-messages'); await expect(messages).toContainText('CPP_SERIES'); await expect(messages).toContainText('Load'); - await expect(messages).toContainText("Series item 'PHQ_9' edition 2 does not exist"); }); - test('should open the sync history of one repository from its row actions', async ({ getPageModel }) => { + test('should keep a long failure reason to one line in its cell, and show it in full on View', async ({ + getPageModel + }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const summary = reposPage.syncLogTable.getByTestId('sync-log-messages'); + + const layout = await summary.evaluate((element) => ({ + clipped: element.scrollWidth > element.clientWidth, + lines: Math.round( + element.getBoundingClientRect().height / Number.parseFloat(window.getComputedStyle(element).lineHeight) + ) + })); + expect(layout.clipped).toBe(true); + expect(layout.lines).toBe(1); + + await reposPage.syncLogTable.getByTestId('sync-log-view-messages').click(); + + const details = reposPage.$ref.getByTestId('sync-log-error-details'); + await expect(details).toContainText('CPP_SERIES'); + await expect(details).toContainText('Load'); + await expect(details).toContainText("Series item 'PHQ_9' edition 2 does not exist on this instance"); + }); + + test('should say the activity log is empty in one line, rather than render an empty table', async ({ + getPageModel, + page + }) => { + await page.route(/\/v1\/instrument-repos\/sync\/logs/, (route) => route.fulfill({ json: [] })); + const reposPage = await getPageModel('/admin/instrument-repos'); + + await expect(reposPage.syncLogTable).toHaveText('No imports or syncs recorded yet.'); + await expect(reposPage.syncLogTable.getByTestId('data-table')).toHaveCount(0); + }); + + test('should open the activity log of one repository from its row actions', async ({ getPageModel }) => { const reposPage = await getPageModel('/admin/instrument-repos'); await reposPage.selectRowAction(0, 'Logs'); const dialog = reposPage.$ref.getByRole('dialog'); - await expect(dialog).toContainText(`Sync history of "${repo.name}"`); + await expect(dialog).toContainText(`Activity log of "${repo.name}"`); + await expect(dialog).toHaveAttribute('aria-describedby', /.+/); await expect(dialog.getByTestId('sync-log-status')).toHaveText('Partial success'); }); @@ -183,11 +224,11 @@ test.describe('admin instrument repos sync history', () => { await reposPage.syncAllButton.click(); - await expect(reposPage.$ref.getByText('1 of 1 repositories had problems. See the sync history.')).toBeVisible(); + await expect(reposPage.$ref.getByText('1 of 1 repositories had problems. See the activity log.')).toBeVisible(); expect(syncAllRequested).toBe(true); }); - test('should clear the sync history only after warning that it is deleted from the database', async ({ + test('should clear the activity log only after warning that it is deleted from the database', async ({ getPageModel, page }) => { @@ -196,12 +237,12 @@ test.describe('admin instrument repos sync history', () => { (request) => request.method() === 'DELETE' && request.url().endsWith('/v1/instrument-repos/sync/logs') ); - await reposPage.clearHistoryButton.click(); + await reposPage.clearLogButton.click(); const dialog = reposPage.$ref.getByRole('dialog'); await expect(dialog).toContainText('permanently deleted from the database'); await dialog.getByRole('button', { name: 'Yes' }).click(); await deleteRequest; - await expect(reposPage.$ref.getByText('Sync history cleared')).toBeVisible(); + await expect(reposPage.$ref.getByText('Activity log cleared')).toBeVisible(); }); }); From ae42b88d100094878d7c3b3bce7acfa2af77453c Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 29 Oct 2026 04:52:30 -0400 Subject: [PATCH 04/13] perf: page the activity log instead of loading its whole history The admin page read every entry on every visit and filtered client side. The collection is append-only and nothing prunes it, so the response grew with the instance's whole history; the 500-entry cap that bounded it also silently hid older entries, and a rarely-synced repository's history could fall off the end of its own dialog. GET /v1/instrument-repos/sync/logs now takes limit, page, repoId and sortOrder and answers with a page, its page count and the total, so each table asks only for the slice it shows and the per-repository dialog asks the server for that repository. A repoId, createdAt index serves it. The repositories table's failed count cannot come from a page, since the newest entry for one repository may be any distance down the history, so GET /v1/instrument-repos/sync/logs/latest answers with the newest entry per repository. It keys under the history's own query key, so the existing invalidations refresh both. Client-side sorting and searching went with it: both would order or filter the ten rows on screen rather than the history, which reads as a table-wide control and is not one. The date column sorts through the server instead, and SortableHeader takes the order the rows were fetched in, because a table in server mode holds no sorting state of its own. Co-Authored-By: Claude Opus 5 --- apps/api/prisma/schema.prisma | 1 + apps/api/src/core/prisma.ts | 1 + .../instrument-repos.controller.test.ts | 18 ++- .../instrument-repos.service.test.ts | 68 +++++++++-- .../instrument-repos.controller.ts | 25 +++- .../instrument-repos.service.ts | 52 ++++++-- .../InstrumentRepoSyncLogTable.tsx | 58 +++++---- .../InstrumentRepoSyncLogTable.test.tsx | 115 +++++++++++------- .../SortableHeader/SortableHeader.tsx | 9 +- .../__tests__/SortableHeader.test.tsx | 6 + .../useInstrumentRepoSyncLogsQuery.test.ts | 59 ++++++--- ...eLatestInstrumentRepoSyncLogsQuery.test.ts | 69 +++++++++++ .../hooks/useInstrumentRepoSyncLogsQuery.ts | 33 +++-- .../useLatestInstrumentRepoSyncLogsQuery.ts | 23 ++++ .../instrument-repos/__tests__/index.test.tsx | 92 ++++++++------ .../_app/admin/instrument-repos/index.tsx | 66 +++++++--- .../src/instrument-repo/instrument-repo.ts | 27 ++++ .../src/specs/admin-instrument-repos.spec.ts | 33 ++++- 18 files changed, 579 insertions(+), 176 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useLatestInstrumentRepoSyncLogsQuery.test.ts create mode 100644 apps/web/src/hooks/useLatestInstrumentRepoSyncLogsQuery.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7f477e562..a2d8a705b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -286,6 +286,7 @@ model InstrumentRepoSyncLog { repoName String @@index([createdAt]) + @@index([repoId, createdAt]) @@map("InstrumentRepoSyncLogModel") } diff --git a/apps/api/src/core/prisma.ts b/apps/api/src/core/prisma.ts index 42b2795cb..0d1e4e0d0 100644 --- a/apps/api/src/core/prisma.ts +++ b/apps/api/src/core/prisma.ts @@ -71,6 +71,7 @@ const DATABASE_INDEXES: DatabaseIndex[] = [ { collection: 'InstrumentRecordModel', fields: ['sessionId'] }, { collection: 'InstrumentRepoModel', fields: ['url'], unique: true }, { collection: 'InstrumentRepoSyncLogModel', fields: ['createdAt'] }, + { collection: 'InstrumentRepoSyncLogModel', fields: ['repoId', 'createdAt'] }, { collection: 'SessionModel', fields: ['groupId', 'date'] }, { collection: 'SessionModel', fields: ['subjectId'] }, { collection: 'SubjectModel', fields: ['groupIds'] }, diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts index 0e6b07a87..e133317ba 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.controller.test.ts @@ -57,10 +57,18 @@ describe('InstrumentReposController', () => { expect(service.syncAll).toHaveBeenCalledWith({ ability }); }); - it("should scope the activity log to the current user's ability", async () => { - service.findSyncLogs.mockResolvedValueOnce([]); - await expect(controller.findSyncLogs(ability)).resolves.toStrictEqual([]); - expect(service.findSyncLogs).toHaveBeenCalledWith({ ability }); + it("should scope the activity log to the current user's ability, and pass the page asked for", async () => { + const query = { limit: 10, page: 2, sortOrder: 'desc' } as const; + const result = { data: [], pageCount: 0, total: 0 }; + service.findSyncLogs.mockResolvedValueOnce(result); + await expect(controller.findSyncLogs(query, ability)).resolves.toBe(result); + expect(service.findSyncLogs).toHaveBeenCalledWith(query, { ability }); + }); + + it("should scope the latest entry per repository to the current user's ability", async () => { + service.findLatestSyncLogs.mockResolvedValueOnce([]); + await expect(controller.findLatestSyncLogs(ability)).resolves.toStrictEqual([]); + expect(service.findLatestSyncLogs).toHaveBeenCalledWith({ ability }); }); it("should clear the activity log within the current user's ability", async () => { @@ -69,7 +77,7 @@ describe('InstrumentReposController', () => { expect(service.deleteSyncLogs).toHaveBeenCalledWith({ ability }); }); - it.each(['deleteSyncLogs', 'findSyncLogs'] as const)( + it.each(['deleteSyncLogs', 'findLatestSyncLogs', 'findSyncLogs'] as const)( 'should restrict %s to administrators, since the history spans every repository', (handler) => { const reflector = new Reflector(); diff --git a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts index 4732e46f0..5339925bf 100644 --- a/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts +++ b/apps/api/src/instrument-repos/__tests__/instrument-repos.service.test.ts @@ -398,23 +398,77 @@ describe('InstrumentReposService', () => { }); describe('findSyncLogs', () => { - it('should return the history newest first', async () => { + const query = { limit: 10, page: 1, sortOrder: 'desc' } as const; + + it('should return the requested page of the history, with how many pages there are', async () => { instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([{ id: 'log-1' }]); + instrumentRepoSyncLogModel.count.mockResolvedValueOnce(25); - await expect(service.findSyncLogs()).resolves.toStrictEqual([{ id: 'log-1' }]); + await expect(service.findSyncLogs(query)).resolves.toStrictEqual({ + data: [{ id: 'log-1' }], + pageCount: 3, + total: 25 + }); expect(instrumentRepoSyncLogModel.findMany).toHaveBeenCalledWith({ orderBy: { createdAt: 'desc' }, - take: 500, - where: {} + skip: 0, + take: 10, + where: { AND: [{}], repoId: undefined } }); }); - it('should cap one request, so an append-only collection cannot grow the response without bound', async () => { + it('should skip the pages before the one asked for, so page three is not page one', async () => { + instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([]); + instrumentRepoSyncLogModel.count.mockResolvedValueOnce(0); + + await service.findSyncLogs({ ...query, page: 3 }); + + expect(instrumentRepoSyncLogModel.findMany.mock.lastCall?.[0]).toMatchObject({ skip: 20, take: 10 }); + }); + + it('should confine the page to one repository when asked, so its dialog shows only its own', async () => { + instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([]); + instrumentRepoSyncLogModel.count.mockResolvedValueOnce(0); + + await service.findSyncLogs({ ...query, repoId: 'r1' }); + + expect(instrumentRepoSyncLogModel.findMany.mock.lastCall?.[0]).toMatchObject({ where: { repoId: 'r1' } }); + }); + + it('should order the history oldest first when asked, since the table sorts on the server', async () => { instrumentRepoSyncLogModel.findMany.mockResolvedValueOnce([]); + instrumentRepoSyncLogModel.count.mockResolvedValueOnce(0); + + await service.findSyncLogs({ ...query, sortOrder: 'asc' }); + + expect(instrumentRepoSyncLogModel.findMany.mock.lastCall?.[0]).toMatchObject({ + orderBy: { createdAt: 'asc' } + }); + }); + }); + + describe('findLatestSyncLogs', () => { + it('should return the newest entry per repository, which no single page of the history holds', async () => { + instrumentRepoModel.findMany.mockResolvedValueOnce([{ id: 'r1' }, { id: 'r2' }]); + instrumentRepoSyncLogModel.findFirst + .mockResolvedValueOnce({ id: 'log-r1', repoId: 'r1' }) + .mockResolvedValueOnce({ id: 'log-r2', repoId: 'r2' }); + + await expect(service.findLatestSyncLogs()).resolves.toStrictEqual([ + { id: 'log-r1', repoId: 'r1' }, + { id: 'log-r2', repoId: 'r2' } + ]); + expect(instrumentRepoSyncLogModel.findFirst.mock.lastCall?.[0]).toMatchObject({ + orderBy: { createdAt: 'desc' }, + where: { repoId: 'r2' } + }); + }); - await service.findSyncLogs(); + it('should leave out a repository that has never been synced, rather than a null entry', async () => { + instrumentRepoModel.findMany.mockResolvedValueOnce([{ id: 'r1' }]); + instrumentRepoSyncLogModel.findFirst.mockResolvedValueOnce(null); - expect(instrumentRepoSyncLogModel.findMany.mock.lastCall?.[0]).toMatchObject({ take: 500 }); + await expect(service.findLatestSyncLogs()).resolves.toStrictEqual([]); }); it('should download without a token when the repo stores none, so public repos sync', async () => { diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index 0c053177c..1b53989f9 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -1,6 +1,10 @@ -import { ApiOperation, CurrentUser } from '@douglasneuroinformatics/libnest'; -import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; -import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; +import { ApiOperation, CurrentUser, ParseSchemaPipe } from '@douglasneuroinformatics/libnest'; +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { + $CreateInstrumentRepoData, + $InstrumentRepoSyncLogsQueryParams +} from '@opendatacapture/schemas/instrument-repo'; +import type { InstrumentRepoSyncLogsQueryParams } from '@opendatacapture/schemas/instrument-repo'; import type { AppAbility } from '@/auth/auth.types'; import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; @@ -39,6 +43,13 @@ export class InstrumentReposController { return this.instrumentReposService.findAll({ ability }); } + @ApiOperation({ summary: 'Get the Latest Activity Log Entry of Every Instrument Repo' }) + @Get('sync/logs/latest') + @RouteAccess(ADMIN_ONLY) + findLatestSyncLogs(@CurrentUser('ability') ability?: AppAbility) { + return this.instrumentReposService.findLatestSyncLogs({ ability }); + } + /** * Admin-only where the two sync routes are not, because this is the one that crosses repositories. * `InstrumentRepoSyncLog` carries no group, and is absent from `$AppSubjectName` so that no user can @@ -48,8 +59,12 @@ export class InstrumentReposController { @ApiOperation({ summary: 'Get the Activity Log of Every Instrument Repo' }) @Get('sync/logs') @RouteAccess(ADMIN_ONLY) - findSyncLogs(@CurrentUser('ability') ability?: AppAbility) { - return this.instrumentReposService.findSyncLogs({ ability }); + findSyncLogs( + @Query(new ParseSchemaPipe({ schema: $InstrumentRepoSyncLogsQueryParams })) + query: InstrumentRepoSyncLogsQueryParams, + @CurrentUser('ability') ability?: AppAbility + ) { + return this.instrumentReposService.findSyncLogs(query, { ability }); } @ApiOperation({ summary: 'Sync Instrument Repo' }) diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index 7d633658a..04c0fff23 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -19,6 +19,8 @@ import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-r import type { InstrumentRepoSyncAction, InstrumentRepoSyncFailure, + InstrumentRepoSyncLogsPage, + InstrumentRepoSyncLogsQueryParams, InstrumentRepoSyncStage } from '@opendatacapture/schemas/instrument-repo'; import JSZip from 'jszip'; @@ -38,12 +40,6 @@ type RepoImportResult = { createdIds: string[]; failures: InstrumentRepoSyncFail type InstrumentRepoRecord = Awaited['findFirstOrThrow']>>; -/** - * The newest entries one request returns. The collection is append-only and nothing prunes it, so an - * unbounded read grows with the instance's whole history. - */ -const SYNC_LOG_LIMIT = 500; - /** * The detail Nest exceptions thrown by `InstrumentsService.create` carry beyond their message: a VM * evaluation error as `cause`, schema validation errors as `issues`. @@ -177,12 +173,46 @@ export class InstrumentReposService implements OnModuleInit { return repos.map((repo) => this.stripSecrets(repo)); } - async findSyncLogs({ ability }: EntityOperationOptions = {}) { - return this.instrumentRepoSyncLogModel.findMany({ - orderBy: { createdAt: 'desc' }, - take: SYNC_LOG_LIMIT, - where: accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog') + /** + * The newest entry per repository, which is what the repositories table reads its failed count + * from. A page of the history cannot answer that question: the newest entry for a rarely-synced + * repository may be any distance down it. + */ + async findLatestSyncLogs({ ability }: EntityOperationOptions = {}) { + const repos = await this.instrumentRepoModel.findMany({ + select: { id: true }, + where: accessibleQuery(ability, 'read', 'InstrumentRepo') }); + const logs = await Promise.all( + repos.map((repo) => + this.instrumentRepoSyncLogModel.findFirst({ + orderBy: { createdAt: 'desc' }, + where: { AND: [accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog')], repoId: repo.id } + }) + ) + ); + return logs.filter((log) => log !== null); + } + + /** + * One page of the history. The collection is append-only and nothing prunes it, so it is never + * returned whole: the client asks for the slice it is showing. + */ + async findSyncLogs( + { limit, page, repoId, sortOrder }: InstrumentRepoSyncLogsQueryParams, + { ability }: EntityOperationOptions = {} + ): Promise { + const where = { AND: [accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog')], repoId }; + const [data, total] = await Promise.all([ + this.instrumentRepoSyncLogModel.findMany({ + orderBy: { createdAt: sortOrder }, + skip: (page - 1) * limit, + take: limit, + where + }), + this.instrumentRepoSyncLogModel.count({ where }) + ]); + return { data, pageCount: Math.ceil(total / limit), total }; } async onModuleInit(): Promise { diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index 2b0d8a112..a088102b4 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -12,9 +12,19 @@ import { getSyncStatusColor } from '@/utils/tag-colors'; type InstrumentRepoSyncLogTableProps = { logs: InstrumentRepoSyncLog[]; + onPageChange: (page: number) => void; + onSortOrderChange: (sortOrder: 'asc' | 'desc') => void; + pageCount: number; + sortOrder: 'asc' | 'desc'; }; -export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableProps) => { +export const InstrumentRepoSyncLogTable = ({ + logs, + onPageChange, + onSortOrderChange, + pageCount, + sortOrder +}: InstrumentRepoSyncLogTableProps) => { const { t } = useTranslation(); const [theme] = useTheme(); const [logToView, setLogToView] = useState(null); @@ -81,30 +91,35 @@ export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableP
log.createdAt.getTime(), cell: (ctx) => ctx.row.original.createdAt.toLocaleString(), + enableSorting: true, header: ({ column }) => ( - + ), - id: 'date' + id: 'date', + // The rows already arrive newest first, so the table's first toggle of its unsorted + // column must go to ascending, or the first click would request what is on screen. + sortDescFirst: false }, { accessorKey: 'repoName', - header: ({ column }) => ( - - ) + enableSorting: false, + header: t({ en: 'Repository', es: 'Repositorio', fr: 'Dépôt' }) }, { accessorFn: (log: InstrumentRepoSyncLog) => actionLabels[log.action], - header: ({ column }) => ( - - ), + enableSorting: false, + header: t({ en: 'Action', es: 'Acción', fr: 'Action' }), id: 'action' }, { - accessorFn: (log: InstrumentRepoSyncLog) => getSyncStatus(log), cell: (ctx) => { const status = getSyncStatus(ctx.row.original); return ( @@ -115,22 +130,19 @@ export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableP /> ); }, - header: ({ column }) => ( - - ), + enableSorting: false, + header: t({ en: 'Status', es: 'Estado', fr: 'Statut' }), id: 'status' }, { accessorKey: 'importedCount', - header: ({ column }) => ( - - ) + enableSorting: false, + header: t({ en: 'Imported', es: 'Importados', fr: 'Importés' }) }, { accessorFn: (log: InstrumentRepoSyncLog) => log.failures.length, - header: ({ column }) => ( - - ), + enableSorting: false, + header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), id: 'failedCount' }, { @@ -169,7 +181,11 @@ export const InstrumentRepoSyncLogTable = ({ logs }: InstrumentRepoSyncLogTableP } ]} data={logs} - initialState={{ sorting: [{ desc: true, id: 'date' }] }} + mode="server" + pageCount={pageCount} + onPaginationChange={({ pageIndex }) => onPageChange(pageIndex + 1)} + // Date is the only sortable column, so the state describes it or nothing. + onSortingChange={(state) => onSortOrderChange(state.some(({ desc }) => desc) ? 'desc' : 'asc')} />
setLogToView(null)}> diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index b45976e44..126b18251 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -26,6 +26,24 @@ const logFixture = (overrides: Partial = {}): InstrumentR ...overrides }); +type TableProps = { + logs: InstrumentRepoSyncLog[]; + onPageChange?: (page: number) => void; + onSortOrderChange?: (sortOrder: 'asc' | 'desc') => void; + pageCount?: number; + sortOrder?: 'asc' | 'desc'; +}; + +const Table = ({ pageCount = 1, sortOrder = 'desc', ...props }: TableProps) => ( + undefined} + onSortOrderChange={() => undefined} + {...props} + /> +); + const rows = () => screen.getAllByTestId('data-table-row'); const statuses = () => screen.getAllByTestId('sync-log-status').map((badge) => badge.textContent); @@ -37,18 +55,14 @@ afterEach(() => { describe('InstrumentRepoSyncLogTable', () => { it('should say so in one line when there is nothing to show, rather than render an empty table', () => { - render(); + render(); expect(screen.getByText('No imports or syncs recorded yet.')).toBeTruthy(); expect(screen.queryByTestId('data-table')).toBeNull(); }); it('should summarize one failure as a single plain-text line, clipped rather than wrapped', () => { const message = 'Instrument validation failed - content.0: Series item PHQ_9 edition 2 does not exist'; - render( - - ); + render(
); const summary = screen.getByTestId('sync-log-messages'); expect(summary.textContent).toBe(`CPP_SERIES (Load): ${message}`); expect(summary.className).toContain('whitespace-nowrap'); @@ -57,7 +71,7 @@ describe('InstrumentRepoSyncLogTable', () => { it('should summarize several failures as a count, so the row stays one line however many there are', () => { render( - { it('should show every failure in full when View is clicked, since the cell only holds a summary', () => { const message = 'Transform failed with 1 error: /BROKEN_FORM/index.ts:14:22: ERROR: Expected ")" but found "{"'; render( - { }); it('should close the error dialog on dismissal, so another row can be inspected next', () => { - render( - - ); + render(
); fireEvent.click(screen.getByTestId('sync-log-view-messages')); expect(screen.queryByTestId('sync-log-error-details')).toBeTruthy(); @@ -108,20 +118,20 @@ describe('InstrumentRepoSyncLogTable', () => { }); it('should show the reason the whole repository could not be read in the dialog too', () => { - render(); + render(
); fireEvent.click(screen.getByTestId('sync-log-view-messages')); expect(within(screen.getByTestId('sync-log-error-details')).getByText('Repository not found (404)')).toBeTruthy(); }); it('should mark a sync with no failed instrument as a success', () => { - render(); + render(
); expect(statuses()).toEqual(['Success']); expect(within(rows()[0]!).getByText('-')).toBeTruthy(); }); it('should mark a sync that skipped some instruments as a partial success, and say which and why', () => { render( - { }); it('should mark a sync that could not read the repository as failed, with the reason', () => { - render(); + render(
); expect(statuses()).toEqual(['Failed']); expect(screen.getByTestId('sync-log-messages').textContent).toBe('Repository not found'); }); it('should label an import and a compile failure in words rather than as codes', () => { render( - ); @@ -149,53 +159,66 @@ describe('InstrumentRepoSyncLogTable', () => { expect(within(rows()[0]!).getByTestId('sync-log-messages').textContent).toBe('A (Compile): x'); }); - it('should list the newest entry first until another column is sorted', () => { + it('should render the page in the order it was given, since the server does the ordering', () => { render( - ); expect(within(rows()[0]!).getByText('Newer')).toBeTruthy(); }); - it('should sort by a column when its header is clicked', () => { - render( - - ); - fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Repository')); - expect(within(rows()[0]!).getByText('Alpha')).toBeTruthy(); + it('should ask for the other order when the date header is clicked, rather than sort the page', () => { + const onSortOrderChange = vi.fn(); + render(
); + + fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Date')); + + expect(onSortOrderChange).toHaveBeenCalledWith('asc'); }); - it('should group entries by status when the status header is clicked', () => { - render( - - ); - fireEvent.click(within(screen.getByTestId('data-table-head')).getByText('Status')); - expect(statuses()).toEqual(['Failed', 'Success']); + it('should cycle the order it asks for, since the column clears back to ascending', () => { + const onSortOrderChange = vi.fn(); + render(
); + const date = within(screen.getByTestId('data-table-head')).getByText('Date'); + + fireEvent.click(date); + fireEvent.click(date); + fireEvent.click(date); + + expect(onSortOrderChange.mock.calls.flat()).toEqual(['asc', 'desc', 'asc']); + }); + + it('should offer no sorting on a column the server cannot order by, so no control can mislead', () => { + render(
); + const head = within(screen.getByTestId('data-table-head')); + expect(head.getByText('Date').tagName).toBe('BUTTON'); + expect(head.getByText('Status').tagName).not.toBe('BUTTON'); + expect(head.getByText('Repository').tagName).not.toBe('BUTTON'); + }); + + it('should ask for the next page rather than hold the whole history', () => { + const onPageChange = vi.fn(); + render(
); + + // The arrows carry no accessible name, so the test drives the numbered page button. + fireEvent.click(screen.getByRole('button', { name: '2' })); + + expect(onPageChange).toHaveBeenCalledWith(2); }); it('should colour the status dot for the dark theme when the app is dark', () => { theme.current = 'dark'; - render(); + render(
); const dot = screen.getByTestId('sync-log-status').firstElementChild as HTMLElement; expect(dot.style.backgroundColor).toBe('#22c55e'); }); it('should colour the status dot for the light theme otherwise', () => { - render(); + render(
); const dot = screen.getByTestId('sync-log-status').firstElementChild as HTMLElement; expect(dot.style.backgroundColor).toBe('#dc2626'); }); diff --git a/apps/web/src/components/SortableHeader/SortableHeader.tsx b/apps/web/src/components/SortableHeader/SortableHeader.tsx index bbff1da0b..822be864f 100644 --- a/apps/web/src/components/SortableHeader/SortableHeader.tsx +++ b/apps/web/src/components/SortableHeader/SortableHeader.tsx @@ -5,6 +5,11 @@ import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react type SortableHeaderProps = { column: TanstackTable.Column; label: string; + /** + * The direction the rows were actually fetched in. Required for a table in server mode, which + * mounts with no sorting state of its own, so the column reads as unsorted however it is ordered. + */ + sortOrder?: 'asc' | 'desc'; }; const SORT_ICONS = { asc: ChevronUpIcon, desc: ChevronDownIcon }; @@ -13,8 +18,8 @@ const SORT_ICONS = { asc: ChevronUpIcon, desc: ChevronDownIcon }; * A clickable column label. `DataTableHead` renders whatever the column supplies, so the sort * affordance lives here rather than coming from the table. */ -export const SortableHeader = ({ column, label }: SortableHeaderProps) => { - const sorted = column.getIsSorted(); +export const SortableHeader = ({ column, label, sortOrder }: SortableHeaderProps) => { + const sorted = sortOrder ?? column.getIsSorted(); const Icon = sorted ? SORT_ICONS[sorted] : ChevronsUpDownIcon; return ( - + {isConfirmClearHistoryOpen && ( @@ -441,7 +472,7 @@ const RouteComponent = () => {
- log.repoId === repoToShowLogs.id)} /> +
)} @@ -626,7 +657,10 @@ export const Route = createFileRoute('/_app/admin/instrument-repos/')({ await Promise.all([ context.queryClient.ensureQueryData(instrumentReposQueryOptions()), context.queryClient.ensureQueryData(groupsQueryOptions()), - context.queryClient.ensureQueryData(instrumentRepoSyncLogsQueryOptions()) + context.queryClient.ensureQueryData( + instrumentRepoSyncLogsQueryOptions({ params: { page: INITIAL_PAGE, sortOrder: INITIAL_SORT_ORDER } }) + ), + context.queryClient.ensureQueryData(latestInstrumentRepoSyncLogsQueryOptions()) ]); } }); diff --git a/packages/schemas/src/instrument-repo/instrument-repo.ts b/packages/schemas/src/instrument-repo/instrument-repo.ts index bf6f254ec..9ed5de38e 100644 --- a/packages/schemas/src/instrument-repo/instrument-repo.ts +++ b/packages/schemas/src/instrument-repo/instrument-repo.ts @@ -49,3 +49,30 @@ export const $InstrumentRepoSyncLog = $BaseModel.extend({ repoId: z.string().nullish(), repoName: z.string() }); + +/** The number of activity log entries shown per page, matching the page size of the client's table. */ +export const INSTRUMENT_REPO_SYNC_LOGS_PAGE_SIZE = 10; + +/** Upper bound on `limit`, so a single request can never load an unbounded slice of the collection. */ +export const INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE = 1000; + +export type InstrumentRepoSyncLogsQueryParams = z.infer; +export const $InstrumentRepoSyncLogsQueryParams = z.object({ + limit: z.coerce + .number() + .int() + .positive() + .max(INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE) + .default(INSTRUMENT_REPO_SYNC_LOGS_PAGE_SIZE), + page: z.coerce.number().int().positive().default(1), + /** Confines the page to one repository, which is what the per-repository dialog asks for. */ + repoId: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('desc') +}); + +export type InstrumentRepoSyncLogsPage = z.infer; +export const $InstrumentRepoSyncLogsPage = z.object({ + data: z.array($InstrumentRepoSyncLog), + pageCount: z.int().nonnegative(), + total: z.int().nonnegative() +}); diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index 10d4da87a..29673bceb 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -126,10 +126,13 @@ test.describe('admin instrument repos activity log', () => { await page.route('**/v1/instrument-repos', (route) => route.request().method() === 'GET' ? route.fulfill({ json: [repo] }) : route.continue() ); - await page.route('**/v1/instrument-repos/sync/logs', (route) => + // Regexes, not globs: the history is now requested with query parameters, and its two routes + // must not match each other. + await page.route(/\/v1\/instrument-repos\/sync\/logs\/latest/, (route) => route.fulfill({ json: [partialSync] })); + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => route.request().method() === 'DELETE' ? route.fulfill({ json: { count: 1 } }) - : route.fulfill({ json: [partialSync] }) + : route.fulfill({ json: { data: [partialSync], pageCount: 1, total: 1 } }) ); }); @@ -184,13 +187,37 @@ test.describe('admin instrument repos activity log', () => { getPageModel, page }) => { - await page.route(/\/v1\/instrument-repos\/sync\/logs/, (route) => route.fulfill({ json: [] })); + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => + route.fulfill({ json: { data: [], pageCount: 0, total: 0 } }) + ); const reposPage = await getPageModel('/admin/instrument-repos'); await expect(reposPage.syncLogTable).toHaveText('No imports or syncs recorded yet.'); await expect(reposPage.syncLogTable.getByTestId('data-table')).toHaveCount(0); }); + test('should ask the server for the next page, rather than hold the whole history', async ({ + getPageModel, + page + }) => { + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => { + const requested = new URL(route.request().url()).searchParams.get('page') ?? '1'; + return route.fulfill({ + json: { + data: [{ ...partialSync, id: `log-${requested}`, repoName: `Repo on page ${requested}` }], + pageCount: 2, + total: 11 + } + }); + }); + const reposPage = await getPageModel('/admin/instrument-repos'); + await expect(reposPage.syncLogTable).toContainText('Repo on page 1'); + + await reposPage.syncLogTable.getByRole('button', { name: '2' }).click(); + + await expect(reposPage.syncLogTable).toContainText('Repo on page 2'); + }); + test('should open the activity log of one repository from its row actions', async ({ getPageModel }) => { const reposPage = await getPageModel('/admin/instrument-repos'); From 5b3cba8cdb1e927d3f9bc7b1adfe66fbf8212426 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 29 Oct 2026 09:09:50 -0400 Subject: [PATCH 05/13] test: measure the clipped failure reason on a live node The assertion read the row Playwright had resolved a moment earlier, which the second query's render can replace: a detached node reports no width at all, so the clipping check failed in CI and nowhere else. The measurement now retries until it lands on an attached node. Co-Authored-By: Claude Opus 5 --- .../src/specs/admin-instrument-repos.spec.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index 29673bceb..f77bca83d 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -166,14 +166,18 @@ test.describe('admin instrument repos activity log', () => { const reposPage = await getPageModel('/admin/instrument-repos'); const summary = reposPage.syncLogTable.getByTestId('sync-log-messages'); - const layout = await summary.evaluate((element) => ({ - clipped: element.scrollWidth > element.clientWidth, - lines: Math.round( - element.getBoundingClientRect().height / Number.parseFloat(window.getComputedStyle(element).lineHeight) - ) - })); - expect(layout.clipped).toBe(true); - expect(layout.lines).toBe(1); + // Retried: the table re-renders when the second query settles, which can detach the row between + // the locator resolving and the measurement, leaving a node that reports no size at all. + await expect(async () => { + const layout = await summary.evaluate((element) => ({ + clipped: element.scrollWidth > element.clientWidth, + lines: Math.round( + element.getBoundingClientRect().height / Number.parseFloat(window.getComputedStyle(element).lineHeight) + ) + })); + expect(layout.clipped).toBe(true); + expect(layout.lines).toBe(1); + }).toPass(); await reposPage.syncLogTable.getByTestId('sync-log-view-messages').click(); From 37a0ab8b5c16445358013633c6617e9bb7c73eed Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Thu, 29 Oct 2026 23:44:25 -0400 Subject: [PATCH 06/13] feat(web): show instrument repositories and their activity log on separate tabs Co-Authored-By: Claude Opus 5.5 --- .../BulkRemoteAssignmentWizard/SourceStep.tsx | 37 +- .../InstrumentRepoSyncLogTable.tsx | 70 ++-- .../InstrumentRepoSyncLogTable.test.tsx | 47 +-- .../SectionTabs/SectionTabs.stories.tsx | 26 ++ .../components/SectionTabs/SectionTabs.tsx | 45 +++ .../__tests__/SectionTabs.test.tsx | 41 +++ apps/web/src/components/SectionTabs/index.ts | 1 + .../instrument-repos/__tests__/index.test.tsx | 70 +++- .../_app/admin/instrument-repos/index.tsx | 343 ++++++++++-------- apps/web/src/utils/data-table.ts | 6 + .../_app/admin/instrument-repos/index.page.ts | 12 +- .../src/specs/admin-instrument-repos.spec.ts | 97 ++++- 12 files changed, 521 insertions(+), 274 deletions(-) create mode 100644 apps/web/src/components/SectionTabs/SectionTabs.stories.tsx create mode 100644 apps/web/src/components/SectionTabs/SectionTabs.tsx create mode 100644 apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx create mode 100644 apps/web/src/components/SectionTabs/index.ts create mode 100644 apps/web/src/utils/data-table.ts diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx index 2f0f11734..a1962c154 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx @@ -1,20 +1,13 @@ import React, { useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; -import { - Button, - Checkbox, - DataTable, - FileDropzone, - Input, - Tabs, - TextArea -} from '@douglasneuroinformatics/libui/components'; +import { Button, Checkbox, DataTable, FileDropzone, Input, TextArea } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { BULK_ASSIGNMENT_MAX_SUBJECTS } from '@opendatacapture/schemas/assignment'; import type { Subject } from '@opendatacapture/schemas/subject'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; +import { SectionTabs } from '@/components/SectionTabs'; import { SortableHeader } from '@/components/SortableHeader'; import { ACCEPTED_FILE_EXTENSIONS, @@ -227,31 +220,15 @@ export const SourceStep = ({ onStepChange={onStepChange} >
- { setErrors([]); - onModeChange(value as SourceMode); + onModeChange(value); }} - > - {/* Underlined rather than libui's filled pill: the pill is one page tint on another, and - the rule keeps to the card's own vocabulary. */} - - {modes.map((option) => ( - - {option.label} - - ))} - - + /> {mode === 'SELECT' && (
diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index a088102b4..40c776212 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -6,6 +6,7 @@ import type { InstrumentRepoSyncLog, InstrumentRepoSyncStage } from '@opendataca import { ColorTagCell } from '@/components/ColorTagCell'; import { SortableHeader } from '@/components/SortableHeader'; +import { MIN_TABLE_BODY_HEIGHT } from '@/utils/data-table'; import { getSyncStatus } from '@/utils/instrument-repo-sync'; import type { InstrumentRepoSyncStatus } from '@/utils/instrument-repo-sync'; import { getSyncStatusColor } from '@/utils/tag-colors'; @@ -45,37 +46,15 @@ export const InstrumentRepoSyncLogTable = ({ LOAD: t({ en: 'Load', es: 'Carga', fr: 'Chargement' }) } satisfies { [K in InstrumentRepoSyncStage]: string }; - /** - * The cell holds one line of plain text, so a reason long enough to wrap would otherwise make the - * row several times taller than the rest. The whole reason lives in the dialog the View button opens. - */ - const summarize = ({ failures, message }: InstrumentRepoSyncLog): null | string => { - if (message) { - return message; - } - if (failures.length > 1) { - return t({ - en: `${failures.length} instruments failed`, - es: `${failures.length} instrumentos fallaron`, - fr: `${failures.length} instruments ont échoué` - }); - } - const [failure] = failures; - if (!failure) { - return null; - } - return `${failure.instrumentDir} (${stageLabels[failure.stage]}): ${failure.message}`; - }; - /** Which import or sync the dialog is showing, since the dialog is detached from its row. */ const describeLog = (log: InstrumentRepoSyncLog) => `${log.repoName} · ${actionLabels[log.action]} · ${log.createdAt.toLocaleString()}`; if (logs.length === 0) { - // Not an empty DataTable: its own empty state is a fixed 288px tall, which dwarfs the heading. + // Not an empty DataTable: its own empty state is a fixed 288px tall, taller than five rows. return (

{t({ @@ -89,12 +68,16 @@ export const InstrumentRepoSyncLogTable = ({ return ( -

+
ctx.row.original.createdAt.toLocaleString(), + cell: (ctx) => + ctx.row.original.createdAt.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }), enableSorting: true, header: ({ column }) => ( { const log = ctx.row.original; - const summary = summarize(log); - if (!summary) { + if (getSyncStatus(log) === 'SUCCESS') { return '-'; } return ( -
- - {summary} - - -
+ ); }, enableSorting: false, header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), - id: 'messages', - size: 480 + id: 'messages' } ]} data={logs} diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index 126b18251..07cb9aa9c 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -60,32 +60,25 @@ describe('InstrumentRepoSyncLogTable', () => { expect(screen.queryByTestId('data-table')).toBeNull(); }); - it('should summarize one failure as a single plain-text line, clipped rather than wrapped', () => { - const message = 'Instrument validation failed - content.0: Series item PHQ_9 edition 2 does not exist'; - render(
); - const summary = screen.getByTestId('sync-log-messages'); - expect(summary.textContent).toBe(`CPP_SERIES (Load): ${message}`); - expect(summary.className).toContain('whitespace-nowrap'); - expect(summary.className).toContain('text-ellipsis'); + it('should keep the empty log the colour and height of a table, so it does not vanish into the card', () => { + render(
); + const empty = screen.getByTestId('instrument-repo-sync-log'); + expect(empty.className).toContain('bg-background'); + expect(empty.className).toContain('min-h-60'); }); - it('should summarize several failures as a count, so the row stays one line however many there are', () => { - render( -
- ); - expect(screen.getByTestId('sync-log-messages').textContent).toBe('2 instruments failed'); + it('should hold the table open to five rows, so a single entry does not collapse it', () => { + render(
); + expect(screen.getByTestId('instrument-repo-sync-log').className).toContain('min-h-[205px]'); }); - it('should show every failure in full when View is clicked, since the cell only holds a summary', () => { + it('should hold no error text in the cell, only a View button, so every row stays one line', () => { + render(
); + expect(within(rows()[0]!).queryByText('broken')).toBeNull(); + expect(within(rows()[0]!).getByTestId('sync-log-view-messages').textContent).toBe('View'); + }); + + it('should show every failure in full when View is clicked, since the cell holds none of it', () => { const message = 'Transform failed with 1 error: /BROKEN_FORM/index.ts:14:22: ERROR: Expected ")" but found "{"'; render(
{ render(
); expect(statuses()).toEqual(['Success']); expect(within(rows()[0]!).getByText('-')).toBeTruthy(); + expect(screen.queryByTestId('sync-log-view-messages')).toBeNull(); }); - it('should mark a sync that skipped some instruments as a partial success, and say which and why', () => { + it('should mark a sync that skipped some instruments as a partial success', () => { render(
{ /> ); expect(statuses()).toEqual(['Partial success']); - expect(screen.getByTestId('sync-log-messages').textContent).toBe('PHQ_9 (Load): Instrument validation failed'); }); - it('should mark a sync that could not read the repository as failed, with the reason', () => { + it('should mark a sync that could not read the repository as failed', () => { render(
); expect(statuses()).toEqual(['Failed']); - expect(screen.getByTestId('sync-log-messages').textContent).toBe('Repository not found'); }); it('should label an import and a compile failure in words rather than as codes', () => { @@ -156,7 +148,8 @@ describe('InstrumentRepoSyncLogTable', () => { /> ); expect(within(rows()[0]!).getByText('Import')).toBeTruthy(); - expect(within(rows()[0]!).getByTestId('sync-log-messages').textContent).toBe('A (Compile): x'); + fireEvent.click(screen.getByTestId('sync-log-view-messages')); + expect(within(screen.getByTestId('sync-log-error-details')).getByText('Compile')).toBeTruthy(); }); it('should render the page in the order it was given, since the server does the ordering', () => { diff --git a/apps/web/src/components/SectionTabs/SectionTabs.stories.tsx b/apps/web/src/components/SectionTabs/SectionTabs.stories.tsx new file mode 100644 index 000000000..79dc7d1f0 --- /dev/null +++ b/apps/web/src/components/SectionTabs/SectionTabs.stories.tsx @@ -0,0 +1,26 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { SectionTabs } from './SectionTabs'; + +type Story = StoryObj; + +export default { component: SectionTabs } as Meta; + +export const Default: Story = { + render: () => { + const [value, setValue] = useState<'ACTIVITY' | 'REPOSITORIES'>('REPOSITORIES'); + return ( + + ); + } +}; diff --git a/apps/web/src/components/SectionTabs/SectionTabs.tsx b/apps/web/src/components/SectionTabs/SectionTabs.tsx new file mode 100644 index 000000000..68a300a6d --- /dev/null +++ b/apps/web/src/components/SectionTabs/SectionTabs.tsx @@ -0,0 +1,45 @@ +import { Tabs } from '@douglasneuroinformatics/libui/components'; + +type SectionTab = { + label: string; + value: TValue; +}; + +type SectionTabsProps = { + /** Set on the list; each tab gets it suffixed with its own value. */ + 'data-testid': string; + onValueChange: (value: TValue) => void; + tabs: readonly SectionTab[]; + value: TValue; +}; + +/** + * Switches between the sections of a page. Only the tab row is rendered: the caller renders the + * section for `value`, so it can keep that section's state when the tab is left and returned to. + */ +export const SectionTabs = ({ + 'data-testid': testId, + onValueChange, + tabs, + value +}: SectionTabsProps) => ( + onValueChange(tabs.find((tab) => tab.value === selected)!.value)}> + {/* Underlined rather than libui's filled pill: the pill is one page tint on another, and the + rule keeps to the surrounding card's own vocabulary. */} + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + +); diff --git a/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx b/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx new file mode 100644 index 000000000..21860b7a0 --- /dev/null +++ b/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx @@ -0,0 +1,41 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { SectionTabs } from '../SectionTabs'; + +import '@/services/i18n'; + +const tabs = [ + { label: 'Repositories', value: 'REPOSITORIES' }, + { label: 'Activity Log', value: 'ACTIVITY' } +] as const; + +afterEach(cleanup); + +describe('SectionTabs', () => { + it('should mark only the current section as active', () => { + render(); + expect(screen.getByTestId('tabs-ACTIVITY').dataset.state).toBe('active'); + expect(screen.getByTestId('tabs-REPOSITORIES').dataset.state).toBe('inactive'); + }); + + it('should set the labels in bold, so they read as headings for the sections below', () => { + render(); + expect(screen.getByTestId('tabs-REPOSITORIES').className).toContain('font-semibold'); + }); + + it('should underline the active tab a shade brighter than the primary colour, so it stands out on a tinted card', () => { + render(); + expect(screen.getByTestId('tabs-ACTIVITY').className).toContain('dark:data-[state=active]:border-sky-500'); + }); + + it('should report the chosen section, so the caller decides what to render', () => { + const onValueChange = vi.fn(); + render(); + + // Radix tabs activate on mouse down, not click. + fireEvent.mouseDown(screen.getByTestId('tabs-ACTIVITY'), { button: 0 }); + + expect(onValueChange).toHaveBeenCalledWith('ACTIVITY'); + }); +}); diff --git a/apps/web/src/components/SectionTabs/index.ts b/apps/web/src/components/SectionTabs/index.ts new file mode 100644 index 000000000..7f3175db6 --- /dev/null +++ b/apps/web/src/components/SectionTabs/index.ts @@ -0,0 +1 @@ +export * from './SectionTabs'; diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index a69d8c8eb..b611fcd08 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -161,6 +161,10 @@ const renderPage = (repos: InstrumentRepo[] = [repoFixture()]) => { const rows = () => screen.getAllByTestId('data-table-row'); +// Radix tabs activate on mouse down, not click. +const openActivityLog = () => + fireEvent.mouseDown(screen.getByTestId('instrument-repos-section-ACTIVITY'), { button: 0 }); + const rowAction = (label: string, rowIndex = 0) => { fireEvent.keyDown(within(rows()[rowIndex]!).getByTestId('row-actions-trigger'), { key: 'Enter' }); return within(screen.getByTestId('row-actions-dropdown')).getByRole('menuitem', { name: label }); @@ -244,21 +248,76 @@ describe('Route', () => { expect(within(rows()[0]!).queryByTestId('instrument-repo-failed-count')).toBeNull(); }); - it('should say the repository could not be read when the whole import or sync failed', () => { + it('should count zero failed instruments when the whole import or sync failed, since the reason belongs to the activity log', () => { mocks.latestSyncLogs = [syncLogFixture({ message: 'Repository not found' })]; renderPage(); - expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('Could not be read'); + expect(within(rows()[0]!).getByTestId('instrument-repo-failed-count').textContent).toBe('0'); + }); + + it('should show only the repositories until the activity log tab is chosen, so one table shows at a time', () => { + mocks.syncLogs = [syncLogFixture()]; + renderPage(); + expect(screen.queryByTestId('instrument-repo-sync-log')).toBeNull(); + + openActivityLog(); + + expect(screen.getByTestId('instrument-repo-sync-log')).toBeTruthy(); + expect(screen.queryByTestId('instrument-repo-url')).toBeNull(); + }); + + it('should title the page after the chosen tab, so each table says what it is for', () => { + renderPage(); + expect(screen.getByTestId('instrument-repos-section-title').textContent).toBe('Manage Repositories'); + + openActivityLog(); + + expect(screen.getByTestId('instrument-repos-section-title').textContent).toBe('Review Activity'); + }); + + it('should fix the width of every column but the name, so the name alone takes up the slack', () => { + renderPage(); + const urlCell = within(rows()[0]!).getByTestId('instrument-repo-url').closest('[style]')!; + const nameCell = within(rows()[0]!).getByText('Core Repo').closest('[style]')!; + expect(urlCell.style.position).toBe('sticky'); + expect(nameCell.style.position).toBe(''); + }); + + it('should draw no card behind either tab, so the tables sit directly on the page', () => { + renderPage(); + const repositoriesCard = screen.getByTestId('instrument-repos-card').className; + + openActivityLog(); + + for (const className of [repositoriesCard, screen.getByTestId('instrument-repos-card').className]) { + expect(className).toContain('bg-transparent'); + expect(className).toContain('border-transparent'); + expect(className).toContain('shadow-none'); + } + }); + + it('should pad the table in its own colour above and beside it, so Sync All and the rows share one edge', () => { + renderPage(); + const wrapperClassName = screen.getByTestId('data-table').parentElement!.className; + expect(wrapperClassName).toContain('[&>[data-testid=data-table]]:pt-6'); + expect(wrapperClassName).toContain('[&>[data-testid=data-table]]:px-6'); + }); + + it('should hold the repositories table open to five rows, so one repository does not collapse it', () => { + renderPage(); + expect(screen.getByTestId('data-table').parentElement!.className).toContain('min-h-[205px]'); }); it('should render an empty activity log while its first page is still in flight', () => { mocks.syncLogsPending = true; renderPage(); + openActivityLog(); expect(screen.getByText('No imports or syncs recorded yet.')).toBeTruthy(); }); it('should ask the server for the other order when the activity log is sorted by date', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); + openActivityLog(); const log = within(screen.getByTestId('instrument-repo-sync-log')); fireEvent.click(within(log.getByTestId('data-table-head')).getByText('Date')); @@ -286,9 +345,10 @@ describe('Route', () => { expect(screen.getByRole('button', { name: 'Sync All' }).disabled).toBe(true); }); - it('should show the activity log of every repository below the table', () => { + it('should show the activity log of every repository on its own tab', () => { mocks.syncLogs = [syncLogFixture(), syncLogFixture({ id: 'log-2', repoId: 'repo-2', repoName: 'Other Repo' })]; renderPage(); + openActivityLog(); const history = screen.getByTestId('instrument-repo-sync-log'); expect(within(history).getByText('Core Repo')).toBeTruthy(); expect(within(history).getByText('Other Repo')).toBeTruthy(); @@ -296,12 +356,14 @@ describe('Route', () => { it('should not offer to clear an empty activity log', () => { renderPage(); + openActivityLog(); expect(screen.getByRole('button', { name: 'Clear Log' }).disabled).toBe(true); }); it('should warn that clearing the history deletes it from the database before doing so', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); + openActivityLog(); fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); expect(screen.getByText('Clear the activity log?')).toBeTruthy(); expect(screen.getByRole('dialog').textContent).toContain('permanently deleted from the database'); @@ -311,6 +373,7 @@ describe('Route', () => { it('should clear the history once the warning is accepted', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); + openActivityLog(); fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); fireEvent.click(screen.getByRole('button', { name: 'Yes' })); expect(mocks.clearSyncLogs).toHaveBeenCalledOnce(); @@ -320,6 +383,7 @@ describe('Route', () => { it('should keep the history when the warning is declined', () => { mocks.syncLogs = [syncLogFixture()]; renderPage(); + openActivityLog(); fireEvent.click(screen.getByRole('button', { name: 'Clear Log' })); fireEvent.click(screen.getByRole('button', { name: 'No' })); expect(mocks.clearSyncLogs).not.toHaveBeenCalled(); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 56988a22e..9cf6044db 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -3,6 +3,7 @@ import type { FormEvent } from 'react'; import { Button, + Card, DataTable, Dialog, Heading, @@ -12,6 +13,7 @@ import { Spinner } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { $CreateInstrumentRepoData } from '@opendatacapture/schemas/instrument-repo'; import type { InstrumentRepo, InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; @@ -20,6 +22,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { InstrumentRepoSyncLogTable } from '@/components/InstrumentRepoSyncLogTable'; import { PageHeader } from '@/components/PageHeader'; +import { SectionTabs } from '@/components/SectionTabs'; import { TruncatedCell } from '@/components/TruncatedCell'; import { useClearInstrumentRepoSyncLogsMutation } from '@/hooks/useClearInstrumentRepoSyncLogsMutation'; import { useCreateInstrumentRepoMutation } from '@/hooks/useCreateInstrumentRepoMutation'; @@ -37,6 +40,7 @@ import { } from '@/hooks/useLatestInstrumentRepoSyncLogsQuery'; import { useSyncAllInstrumentReposMutation } from '@/hooks/useSyncAllInstrumentReposMutation'; import { useSyncInstrumentRepoMutation } from '@/hooks/useSyncInstrumentRepoMutation'; +import { MIN_TABLE_BODY_HEIGHT } from '@/utils/data-table'; /** * The instruments a repository currently provides: only the latest edition of each. Superseded @@ -167,6 +171,8 @@ function syncLogsByRepoId(logs: InstrumentRepoSyncLog[]) { return byRepoId; } +type InstrumentReposSection = 'ACTIVITY' | 'REPOSITORIES'; + const INITIAL_PAGE = 1; const INITIAL_SORT_ORDER = 'desc'; @@ -221,11 +227,31 @@ const RouteComponent = () => { const [isConfirmClearHistoryOpen, setIsConfirmClearHistoryOpen] = useState(false); const [repoToDelete, setRepoToDelete] = useState(null); const [highlightedRowId, setHighlightedRowId] = useState(null); + const [section, setSection] = useState('REPOSITORIES'); // Compute which repos are currently assigned to at least one (live) group. const inUseRepoIds = new Set(groupsQuery.data.flatMap((group) => group.instrumentRepoIds)); const latestSyncLogByRepoId = syncLogsByRepoId(latestSyncLogsQuery.data); + const sectionHeadings = { + ACTIVITY: { + description: t({ + en: 'Every import and sync, newest first. Select View on an entry to see why its instruments failed.', + es: 'Todas las importaciones y sincronizaciones, las más recientes primero. Seleccione Ver en una entrada para saber por qué fallaron sus instrumentos.', + fr: "Tous les imports et synchronisations, les plus récents d'abord. Sélectionnez Voir sur une entrée pour savoir pourquoi ses instruments ont échoué." + }), + title: t({ en: 'Review Activity', es: 'Revisar la actividad', fr: "Consulter l'activité" }) + }, + REPOSITORIES: { + description: t({ + en: 'The GitHub repositories instruments are imported from. Sync a repository to pull its latest instruments, or view the instruments it provides.', + es: 'Los repositorios de GitHub desde los que se importan los instrumentos. Sincronice un repositorio para obtener sus instrumentos más recientes, o vea los instrumentos que proporciona.', + fr: "Les dépôts GitHub d'où les instruments sont importés. Synchronisez un dépôt pour récupérer ses instruments les plus récents, ou consultez les instruments qu'il fournit." + }), + title: t({ en: 'Manage Repositories', es: 'Gestionar los repositorios', fr: 'Gérer les dépôts' }) + } + } satisfies { [K in InstrumentReposSection]: { description: string; title: string } }; + const handleDeleteClick = (repo: InstrumentRepo) => { if (inUseRepoIds.has(repo.id)) { addNotification({ @@ -265,153 +291,182 @@ const RouteComponent = () => { })} - { - const repo = ctx.row.original; - return ( - - {repo.name} - - - ); - }, - header: t({ - en: 'Repository Name', - es: 'Nombre del repositorio', - fr: 'Nom du dépôt' - }) - }, - { - accessorKey: 'url', - cell: (ctx) => ( - - ), - header: 'URL' - }, - { - accessorFn: (row: InstrumentRepo) => { - return instrumentInfoQuery.data - ? currentRepoInstruments(instrumentInfoQuery.data, row.id).length - : row.instrumentIds.length; - }, - header: t({ - en: 'Instruments', - es: 'Instrumentos', - fr: 'Instruments' - }), - id: 'instrumentCount' - }, - { - cell: (ctx) => { - const log = latestSyncLogByRepoId.get(ctx.row.original.id); - if (!log) { - return '-'; - } - return ( - - {log.message - ? t({ en: 'Could not be read', es: 'No se pudo leer', fr: 'Lecture impossible' }) - : log.failures.length} - - ); - }, - header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), - id: 'failedCount' - }, - { - accessorFn: (row: InstrumentRepo) => { - return row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleDateString() : '-'; - }, - header: t({ - en: 'Last Synced', - es: 'Última sincronización', - fr: 'Dernière synchronisation' - }), - id: 'lastSynced' - } - ]} - data={reposQuery.data} - rowActions={[ - { - label: t({ - en: 'Sync', - es: 'Sincronizar', - fr: 'Synchroniser' - }), - onSelect: (repo: InstrumentRepo) => { - syncRepoMutation.mutate({ id: repo.id }); - } - }, - { - label: t({ - en: 'View', - es: 'Ver', - fr: 'Voir' - }), - onSelect: (repo: InstrumentRepo) => { - setRepoToView(repo); - setIsViewOpen(true); + +
+ + {sectionHeadings[section].title} + + {sectionHeadings[section].description} +
+ ( -
- - + ]} + value={section} + onValueChange={setSection} + /> + {section === 'REPOSITORIES' && ( +
[data-testid=data-table]]:px-6 [&>[data-testid=data-table]]:pt-6', MIN_TABLE_BODY_HEIGHT)} + > + { + const repo = ctx.row.original; + return ( + + {repo.name} + + + ); + }, + header: t({ + en: 'Repository Name', + es: 'Nombre del repositorio', + fr: 'Nom du dépôt' + }) + }, + { + accessorKey: 'url', + cell: (ctx) => ( + + ), + header: 'URL', + size: 370 + }, + { + accessorFn: (row: InstrumentRepo) => + instrumentInfoQuery.data + ? currentRepoInstruments(instrumentInfoQuery.data, row.id).length + : row.instrumentIds.length, + header: t({ + en: 'Instruments', + es: 'Instrumentos', + fr: 'Instruments' + }), + id: 'instrumentCount', + size: 120 + }, + { + cell: (ctx) => { + const log = latestSyncLogByRepoId.get(ctx.row.original.id); + if (!log) { + return '-'; + } + return {log.failures.length}; + }, + header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), + id: 'failedCount', + size: 90 + }, + { + accessorFn: (row: InstrumentRepo) => + row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleDateString() : '-', + header: t({ + en: 'Last Synced', + es: 'Última sincronización', + fr: 'Dernière synchronisation' + }), + id: 'lastSynced', + size: 150 + } + ]} + data={reposQuery.data} + // libui shares the width evenly between unpinned columns and honours `size` only on pinned + // ones, so every column but the name is pinned, leaving the name the room the table has left. + initialState={{ columnPinning: { right: ['url', 'instrumentCount', 'failedCount', 'lastSynced'] } }} + rowActions={[ + { + label: t({ + en: 'Sync', + es: 'Sincronizar', + fr: 'Synchroniser' + }), + onSelect: (repo: InstrumentRepo) => { + syncRepoMutation.mutate({ id: repo.id }); + } + }, + { + label: t({ + en: 'View', + es: 'Ver', + fr: 'Voir' + }), + onSelect: (repo: InstrumentRepo) => { + setRepoToView(repo); + setIsViewOpen(true); + } + }, + { + label: t({ en: 'Logs', es: 'Registros', fr: 'Journaux' }), + onSelect: setRepoToShowLogs + }, + { + label: t('core.delete'), + onSelect: handleDeleteClick + } + ]} + togglesComponent={() => ( +
+ + +
+ )} + onRowClick={(repo) => setHighlightedRowId(repo.id)} + onRowDoubleClick={(repo) => { + setHighlightedRowId(repo.id); + syncRepoMutation.mutate({ id: repo.id }); + }} + />
)} - onRowClick={(repo) => setHighlightedRowId(repo.id)} - onRowDoubleClick={(repo) => { - setHighlightedRowId(repo.id); - syncRepoMutation.mutate({ id: repo.id }); - }} - /> -
-
- - {t({ en: 'Activity Log', es: 'Registro de actividad', fr: "Journal d'activité" })} - - -
- -
+ {section === 'ACTIVITY' && ( +
+
+ +
+ +
+ )} + {isConfirmClearHistoryOpen && ( diff --git a/apps/web/src/utils/data-table.ts b/apps/web/src/utils/data-table.ts new file mode 100644 index 000000000..fe0ef2832 --- /dev/null +++ b/apps/web/src/utils/data-table.ts @@ -0,0 +1,6 @@ +/** + * Keeps a libui `DataTable` body at least five rows tall, so a table holding one or two rows does not + * collapse to a sliver. A row holding a button is 41px: a 24px control, 16px of padding, a 1px rule. + * libui exposes no prop for this, so the class reaches its body through the `data-testid` it renders. + */ +export const MIN_TABLE_BODY_HEIGHT = '[&_[data-testid=data-table-body]]:min-h-[205px]'; diff --git a/testing/src/pages/_app/admin/instrument-repos/index.page.ts b/testing/src/pages/_app/admin/instrument-repos/index.page.ts index f2745cc53..bfd19874d 100644 --- a/testing/src/pages/_app/admin/instrument-repos/index.page.ts +++ b/testing/src/pages/_app/admin/instrument-repos/index.page.ts @@ -4,19 +4,25 @@ import { AppPage } from '../../route.page'; export class InstrumentReposPage extends AppPage { readonly addRepositoryButton: Locator; + /** Wraps whichever tab is showing; drawn invisible so the tables sit directly on the page. */ + readonly card: Locator; readonly clearLogButton: Locator; - /** The repositories table; the activity log below it is a second data table. */ + /** The repositories table, shown on the Repositories tab. */ readonly dataTable: Locator; readonly githubUrlInput: Locator; readonly importButton: Locator; readonly pageHeader: Locator; + readonly sectionTitle: Locator; readonly syncAllButton: Locator; + /** The activity log, shown on its own tab in place of the repositories table. */ readonly syncLogTable: Locator; constructor(page: Page) { super(page); this.pageHeader = page.getByTestId('page-header'); + this.card = page.getByTestId('instrument-repos-card'); this.dataTable = page.getByTestId('data-table').first(); + this.sectionTitle = page.getByTestId('instrument-repos-section-title'); this.syncLogTable = page.getByTestId('instrument-repo-sync-log'); this.syncAllButton = page.getByRole('button', { name: 'Sync All' }); this.clearLogButton = page.getByRole('button', { name: 'Clear Log' }); @@ -26,6 +32,10 @@ export class InstrumentReposPage extends AppPage { this.importButton = page.getByRole('button', { name: 'Import' }); } + async openActivityLog(): Promise { + await this.$ref.getByTestId('instrument-repos-section-ACTIVITY').click(); + } + /** Opens a repository row's action menu and picks one of its entries. */ async selectRowAction(rowIndex: number, label: string): Promise { await this.dataTable.getByTestId('row-actions-trigger').nth(rowIndex).click(); diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index f77bca83d..1b252ffea 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -154,36 +154,90 @@ test.describe('admin instrument repos activity log', () => { const reposPage = await getPageModel('/admin/instrument-repos'); await expect(reposPage.dataTable.getByTestId('instrument-repo-failed-count')).toHaveText('1'); + await reposPage.openActivityLog(); await expect(reposPage.syncLogTable.getByTestId('sync-log-status')).toHaveText('Partial success'); - const messages = reposPage.syncLogTable.getByTestId('sync-log-messages'); - await expect(messages).toContainText('CPP_SERIES'); - await expect(messages).toContainText('Load'); + await reposPage.syncLogTable.getByTestId('sync-log-view-messages').click(); + const details = reposPage.$ref.getByTestId('sync-log-error-details'); + await expect(details).toContainText('CPP_SERIES'); + await expect(details).toContainText('Load'); }); - test('should keep a long failure reason to one line in its cell, and show it in full on View', async ({ + test('should show one table at a time, switching between them by tab', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + await expect(reposPage.dataTable.getByTestId('instrument-repo-url')).toBeVisible(); + await expect(reposPage.syncLogTable).toHaveCount(0); + + await expect(reposPage.sectionTitle).toHaveText('Manage Repositories'); + + await reposPage.openActivityLog(); + + await expect(reposPage.syncLogTable).toBeVisible(); + await expect(reposPage.sectionTitle).toHaveText('Review Activity'); + await expect(reposPage.$ref.getByTestId('instrument-repo-url')).toHaveCount(0); + }); + + test('should draw no card behind either tab', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const expectInvisible = async () => { + await expect(reposPage.card).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(reposPage.card).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0)'); + await expect(reposPage.card).toHaveCSS('box-shadow', 'none'); + }; + await expectInvisible(); + + await reposPage.openActivityLog(); + + await expectInvisible(); + }); + + test('should fit both tables to the page without a horizontal scroll bar', async ({ getPageModel }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const overflows = (table: typeof reposPage.dataTable) => + table + .getByTestId('data-table-body') + .evaluate( + (body) => body.parentElement!.parentElement!.scrollWidth > body.parentElement!.parentElement!.clientWidth + ); + + expect(await overflows(reposPage.dataTable)).toBe(false); + await reposPage.openActivityLog(); + expect(await overflows(reposPage.syncLogTable)).toBe(false); + }); + + test('should hold a one-row table open to five rows, so it does not collapse to a sliver', async ({ + getPageModel + }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + const body = reposPage.dataTable.getByTestId('data-table-body'); + + const height = await body.evaluate((element) => element.getBoundingClientRect().height); + const rowHeight = await body + .getByTestId('data-table-row') + .evaluate((element) => element.getBoundingClientRect().height); + expect(height).toBeGreaterThanOrEqual(rowHeight * 5); + }); + + test('should count zero failed instruments when the whole sync failed, rather than its reason', async ({ + getPageModel, + page + }) => { + const failedSync = { ...partialSync, failures: [], importedCount: 0, message: 'Repository not found' }; + await page.route(/\/v1\/instrument-repos\/sync\/logs\/latest/, (route) => route.fulfill({ json: [failedSync] })); + const reposPage = await getPageModel('/admin/instrument-repos'); + + await expect(reposPage.dataTable.getByTestId('instrument-repo-failed-count')).toHaveText('0'); + }); + + test('should keep the failure reason out of the activity log row, and show it in full on View', async ({ getPageModel }) => { const reposPage = await getPageModel('/admin/instrument-repos'); - const summary = reposPage.syncLogTable.getByTestId('sync-log-messages'); - - // Retried: the table re-renders when the second query settles, which can detach the row between - // the locator resolving and the measurement, leaving a node that reports no size at all. - await expect(async () => { - const layout = await summary.evaluate((element) => ({ - clipped: element.scrollWidth > element.clientWidth, - lines: Math.round( - element.getBoundingClientRect().height / Number.parseFloat(window.getComputedStyle(element).lineHeight) - ) - })); - expect(layout.clipped).toBe(true); - expect(layout.lines).toBe(1); - }).toPass(); + await reposPage.openActivityLog(); + await expect(reposPage.syncLogTable).not.toContainText('PHQ_9'); await reposPage.syncLogTable.getByTestId('sync-log-view-messages').click(); const details = reposPage.$ref.getByTestId('sync-log-error-details'); - await expect(details).toContainText('CPP_SERIES'); - await expect(details).toContainText('Load'); await expect(details).toContainText("Series item 'PHQ_9' edition 2 does not exist on this instance"); }); @@ -195,6 +249,7 @@ test.describe('admin instrument repos activity log', () => { route.fulfill({ json: { data: [], pageCount: 0, total: 0 } }) ); const reposPage = await getPageModel('/admin/instrument-repos'); + await reposPage.openActivityLog(); await expect(reposPage.syncLogTable).toHaveText('No imports or syncs recorded yet.'); await expect(reposPage.syncLogTable.getByTestId('data-table')).toHaveCount(0); @@ -215,6 +270,7 @@ test.describe('admin instrument repos activity log', () => { }); }); const reposPage = await getPageModel('/admin/instrument-repos'); + await reposPage.openActivityLog(); await expect(reposPage.syncLogTable).toContainText('Repo on page 1'); await reposPage.syncLogTable.getByRole('button', { name: '2' }).click(); @@ -268,6 +324,7 @@ test.describe('admin instrument repos activity log', () => { (request) => request.method() === 'DELETE' && request.url().endsWith('/v1/instrument-repos/sync/logs') ); + await reposPage.openActivityLog(); await reposPage.clearLogButton.click(); const dialog = reposPage.$ref.getByRole('dialog'); await expect(dialog).toContainText('permanently deleted from the database'); From 496c5de21b4ab75b467e2f805142c7575c279b8d Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 01:54:29 -0400 Subject: [PATCH 07/13] chore: patch libui so a server-mode data table can pin its columns `ServerDataTableStoreParams` types `initialState` as `never`, and `calculateColumnSizing` honours `columnDef.size` only on a pinned column, so a table that pages server-side could not give one column the room its content needs: every column took an identical share of the width. The patch is type-only -- the store already read `initialState` in both modes and `reset()` deliberately never rebuilds it. It carries DouglasNeuroInformatics/libui#129; delete it and the `patchedDependencies` entry when the catalog moves to the release that includes it. Co-Authored-By: Claude Opus 5 --- ...ouglasneuroinformatics__libui@6.16.1.patch | 29 +++++++++++++++++++ pnpm-lock.yaml | 21 +++++++++----- pnpm-workspace.yaml | 6 ++++ 3 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 patches/@douglasneuroinformatics__libui@6.16.1.patch diff --git a/patches/@douglasneuroinformatics__libui@6.16.1.patch b/patches/@douglasneuroinformatics__libui@6.16.1.patch new file mode 100644 index 000000000..138b18c8c --- /dev/null +++ b/patches/@douglasneuroinformatics__libui@6.16.1.patch @@ -0,0 +1,29 @@ +--- a/dist/components/DataTable/types.d.ts ++++ b/dist/components/DataTable/types.d.ts +@@ -68,11 +68,10 @@ + /********************************************************************* + * CLIENT + *********************************************************************/ +-export type ClientDataTableInitialState = { ++export type ClientDataTableInitialState = Simplify; + export type ClientDataTableStoreParams = Simplify & { + initialState?: ClientDataTableInitialState; + mode?: 'client'; +@@ -81,8 +80,11 @@ + /********************************************************************* + * SERVER + *********************************************************************/ ++export type ServerDataTableInitialState = { ++ columnPinning?: ColumnPinningState; ++}; + export type ServerDataTableStoreParams = Simplify & { +- initialState?: never; ++ initialState?: ServerDataTableInitialState; + mode: 'server'; + onPaginationChange: (state: PaginationState) => void; + onSortingChange?: (state: SortingState) => void; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index feec7bb22..65c591902 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,11 @@ overrides: typescript: 6.0.x yaml@2: ^2.9.0 +patchedDependencies: + '@douglasneuroinformatics/libui@6.16.1': + hash: 7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b + path: patches/@douglasneuroinformatics__libui@6.16.1.patch + importers: .: @@ -382,7 +387,7 @@ importers: version: 0.0.6 '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/react-core': specifier: workspace:* version: link:../../packages/react-core @@ -476,7 +481,7 @@ importers: dependencies: '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) '@opendatacapture/licenses': specifier: workspace:* version: link:../../packages/licenses @@ -558,7 +563,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: workspace:* version: link:../../packages/instrument-bundler @@ -682,7 +687,7 @@ importers: version: 0.0.3(typescript@6.0.3) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@vendor+react@19.x) @@ -982,7 +987,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: workspace:* version: link:../instrument-bundler @@ -1177,7 +1182,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: 'workspace:' version: link:../instrument-bundler @@ -11993,7 +11998,7 @@ snapshots: dependencies: type-fest: 4.41.0 - '@douglasneuroinformatics/libui@6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui@6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) @@ -12050,7 +12055,7 @@ snapshots: - neverthrow - use-sync-external-store - '@douglasneuroinformatics/libui@6.16.1(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui@6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a419d2c4d..22b1ed7a2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,6 +63,12 @@ catalogs: hoistPattern: - '*' - '!@storybook/react' +# Type-only, and already merged upstream as libui `fix/server-data-table-column-pinning`: a +# server-mode DataTable typed `initialState` as `never`, so no column could be pinned, and only a +# pinned column honours `size`. Delete this patch and the `patches/` file when the catalog moves to +# the libui release that carries it. +patchedDependencies: + '@douglasneuroinformatics/libui@6.16.1': patches/@douglasneuroinformatics__libui@6.16.1.patch minimumReleaseAge: 10080 # 7 days minimumReleaseAgeExclude: - '@douglasneuroinformatics/*' From 02cedddc583c29f1d8e4a5b0f2ab4f0b55c984a5 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 01:54:41 -0400 Subject: [PATCH 08/13] fix(web): hold every activity log cell to one line and widen its repository column A repository name wrapped onto a second line, making its row taller than the rest. libui wraps a cell in a truncating span only when the content is not a React element, and Tanstack's default cell renderer is a function that `flexRender` renders as a component -- so no column was ever truncated. Every text cell now renders `TruncatedCell`: one line, an ellipsis, and the full value in the native `title` for a hover that names what was clipped. The seven columns also shared the width evenly, which left a repository called `owner/some-long-name` a seventh of the table. Pinning the other six with explicit sizes leaves the repository column the room they do not use, the way the repositories table above it already works. The `accessorFn`s on the action and failed-count columns are gone: with an explicit `cell` neither was reachable, and both ids come from `id`. Co-Authored-By: Claude Opus 5 --- .../InstrumentRepoSyncLogTable.tsx | 58 +++++++++++++++---- .../InstrumentRepoSyncLogTable.test.tsx | 37 ++++++++++++ 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index 40c776212..b82257d16 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -6,6 +6,7 @@ import type { InstrumentRepoSyncLog, InstrumentRepoSyncStage } from '@opendataca import { ColorTagCell } from '@/components/ColorTagCell'; import { SortableHeader } from '@/components/SortableHeader'; +import { TruncatedCell } from '@/components/TruncatedCell'; import { MIN_TABLE_BODY_HEIGHT } from '@/utils/data-table'; import { getSyncStatus } from '@/utils/instrument-repo-sync'; import type { InstrumentRepoSyncStatus } from '@/utils/instrument-repo-sync'; @@ -71,13 +72,17 @@ export const InstrumentRepoSyncLogTable = ({
- ctx.row.original.createdAt.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }), + cell: (ctx) => ( + + ), enableSorting: true, header: ({ column }) => ( ), id: 'date', + size: 170, // The rows already arrive newest first, so the table's first toggle of its unsorted // column must go to ascending, or the first click would request what is on screen. sortDescFirst: false }, { accessorKey: 'repoName', + cell: (ctx) => ( + + ), enableSorting: false, header: t({ en: 'Repository', es: 'Repositorio', fr: 'Dépôt' }) }, { - accessorFn: (log: InstrumentRepoSyncLog) => actionLabels[log.action], + cell: (ctx) => { + const label = actionLabels[ctx.row.original.action]; + return ; + }, enableSorting: false, header: t({ en: 'Action', es: 'Acción', fr: 'Action' }), - id: 'action' + id: 'action', + size: 105 }, { cell: (ctx) => { @@ -115,18 +132,28 @@ export const InstrumentRepoSyncLogTable = ({ }, enableSorting: false, header: t({ en: 'Status', es: 'Estado', fr: 'Statut' }), - id: 'status' + id: 'status', + size: 160 }, { accessorKey: 'importedCount', + cell: (ctx) => { + const count = String(ctx.row.original.importedCount); + return ; + }, enableSorting: false, - header: t({ en: 'Imported', es: 'Importados', fr: 'Importés' }) + header: t({ en: 'Imported', es: 'Importados', fr: 'Importés' }), + size: 110 }, { - accessorFn: (log: InstrumentRepoSyncLog) => log.failures.length, + cell: (ctx) => { + const count = String(ctx.row.original.failures.length); + return ; + }, enableSorting: false, header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), - id: 'failedCount' + id: 'failedCount', + size: 95 }, { cell: (ctx) => { @@ -149,10 +176,17 @@ export const InstrumentRepoSyncLogTable = ({ }, enableSorting: false, header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), - id: 'messages' + id: 'messages', + size: 105 } ]} data={logs} + // libui honours `size` only on a pinned column and splits what is left evenly between the + // rest, so pinning every column but the repository leaves the repository the room a long + // owner/name needs. + initialState={{ + columnPinning: { left: ['date'], right: ['action', 'status', 'importedCount', 'failedCount', 'messages'] } + }} mode="server" pageCount={pageCount} onPaginationChange={({ pageIndex }) => onPageChange(pageIndex + 1)} diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index 07cb9aa9c..f14fccf28 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -203,6 +203,43 @@ describe('InstrumentRepoSyncLogTable', () => { expect(onPageChange).toHaveBeenCalledWith(2); }); + it('should hold every cell to one line, so no row can stand taller than the rest', () => { + const createdAt = new Date('2026-10-01T12:00:00Z'); + render(
); + const row = within(rows()[0]!); + const cells = [ + row.getByText(createdAt.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })), + row.getByText('A Very Long Repository Name'), + row.getByText('Sync'), + row.getByText('7') + ]; + cells.forEach((cell) => expect(cell.className).toContain('whitespace-nowrap')); + }); + + it('should carry the full repository name as a hover title, since the cell may clip it', () => { + const repoName = 'LaboratoireDeRecherche/instruments-cliniques-internes'; + render(
); + expect(screen.getByTestId('sync-log-repo-name').title).toBe(repoName); + }); + + it('should name the unabbreviated date on hover, since the cell shows a short form', () => { + const createdAt = new Date('2026-10-01T12:00:00Z'); + render(
); + const cell = within(rows()[0]!).getByText( + createdAt.toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' }) + ); + expect(cell.title).toBe(createdAt.toLocaleString()); + }); + + it('should pin every column but the repository, so a long repository name has the leftover room', () => { + render(
); + const head = within(screen.getByTestId('data-table-head')); + const cellOf = (label: string) => head.getByText(label).closest('div')!; + expect(cellOf('Date').style.position).toBe('sticky'); + expect(cellOf('Imported').style.position).toBe('sticky'); + expect(cellOf('Repository').style.position).toBe(''); + }); + it('should colour the status dot for the dark theme when the app is dark', () => { theme.current = 'dark'; render(
); From 360571f5382a7f9a1aaa1f10788e74689f0747b6 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 01:54:42 -0400 Subject: [PATCH 09/13] test(testing): check the activity log clips a long repository name to one line Asserts on a live node, since the clipping is layout: the cell carries the full name as its title, its content overflows, and it still stands one line tall. Co-Authored-By: Claude Opus 5 --- .../src/specs/admin-instrument-repos.spec.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index 1b252ffea..db6aab0aa 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -228,6 +228,28 @@ test.describe('admin instrument repos activity log', () => { await expect(reposPage.dataTable.getByTestId('instrument-repo-failed-count')).toHaveText('0'); }); + test('should clip a long repository name to one line and name it in full on hover', async ({ + getPageModel, + page + }) => { + const repoName = 'LaboratoireDeRecherchePsychiatrique/instruments-cliniques-internes-du-laboratoire'; + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => + route.fulfill({ json: { data: [{ ...partialSync, repoName }], pageCount: 1, total: 1 } }) + ); + const reposPage = await getPageModel('/admin/instrument-repos'); + await reposPage.openActivityLog(); + + const cell = reposPage.syncLogTable.getByTestId('sync-log-repo-name'); + + await expect(cell).toHaveAttribute('title', repoName); + const layout = await cell.evaluate((element) => ({ + clipped: element.scrollWidth > element.clientWidth, + lines: Math.round(element.getBoundingClientRect().height / parseFloat(getComputedStyle(element).lineHeight)) + })); + expect(layout.clipped).toBe(true); + expect(layout.lines).toBe(1); + }); + test('should keep the failure reason out of the activity log row, and show it in full on View', async ({ getPageModel }) => { From 7a663e6ed8524767d9413d545c3a6532648305dc Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 11:16:34 -0400 Subject: [PATCH 10/13] fix: address review on the activity log's schema, ability and test hooks - `$InstrumentRepoSyncLogsQueryParams` parses `limit` and `page` with `$NumberLike` instead of `z.coerce.number()`, which eslint now bans because coercion turns `''`, `null` and `true` into valid numbers rather than rejecting them. `$AuditLogsQueryParams` already paged this way. - `InstrumentReposController` takes `ability` as `AppAbility` rather than an optional one. Every route here is behind `@RouteAccess`, so a request reaching a handler always carries an ability, and the rest of the controllers in this app type it the same way. - Satisfies the rules main added: a `beforeEach`/`afterEach` sits inside its `describe`, `toThrow` names what it expects, and a promise executor returns nothing. Co-Authored-By: Claude Opus 5 --- .../instrument-repos.controller.ts | 14 +++++++------- .../instrument-repos.service.ts | 8 ++++---- .../InstrumentRepoSyncLogTable.test.tsx | 10 +++++----- .../SectionTabs/__tests__/SectionTabs.test.tsx | 4 ++-- ...ClearInstrumentRepoSyncLogsMutation.test.ts | 5 +++-- .../useInstrumentRepoSyncLogsQuery.test.ts | 9 ++++++--- ...seLatestInstrumentRepoSyncLogsQuery.test.ts | 5 +++-- .../useSyncAllInstrumentReposMutation.test.ts | 17 +++++++++++------ .../useClearInstrumentRepoSyncLogsMutation.ts | 5 +++-- .../instrument-repos/__tests__/index.test.tsx | 5 +++-- .../_app/admin/instrument-repos/index.tsx | 18 ++++++++++-------- .../src/instrument-repo/instrument-repo.ts | 10 ++++------ 12 files changed, 61 insertions(+), 49 deletions(-) diff --git a/apps/api/src/instrument-repos/instrument-repos.controller.ts b/apps/api/src/instrument-repos/instrument-repos.controller.ts index 1b53989f9..12827c580 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -25,28 +25,28 @@ export class InstrumentReposController { @ApiOperation({ summary: 'Delete Instrument Repo' }) @Delete(':id') @RouteAccess({ action: 'delete', subject: 'InstrumentRepo' }) - deleteById(@Param('id') id: string, @CurrentUser('ability') ability?: AppAbility) { + deleteById(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.deleteById(id, { ability }); } @ApiOperation({ summary: 'Clear the Activity Log of Every Instrument Repo' }) @Delete('sync/logs') @RouteAccess(ADMIN_ONLY) - deleteSyncLogs(@CurrentUser('ability') ability?: AppAbility) { + deleteSyncLogs(@CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.deleteSyncLogs({ ability }); } @ApiOperation({ summary: 'Get All Instrument Repos' }) @Get() @RouteAccess({ action: 'read', subject: 'InstrumentRepo' }) - findAll(@CurrentUser('ability') ability?: AppAbility) { + findAll(@CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.findAll({ ability }); } @ApiOperation({ summary: 'Get the Latest Activity Log Entry of Every Instrument Repo' }) @Get('sync/logs/latest') @RouteAccess(ADMIN_ONLY) - findLatestSyncLogs(@CurrentUser('ability') ability?: AppAbility) { + findLatestSyncLogs(@CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.findLatestSyncLogs({ ability }); } @@ -62,7 +62,7 @@ export class InstrumentReposController { findSyncLogs( @Query(new ParseSchemaPipe({ schema: $InstrumentRepoSyncLogsQueryParams })) query: InstrumentRepoSyncLogsQueryParams, - @CurrentUser('ability') ability?: AppAbility + @CurrentUser('ability') ability: AppAbility ) { return this.instrumentReposService.findSyncLogs(query, { ability }); } @@ -70,14 +70,14 @@ export class InstrumentReposController { @ApiOperation({ summary: 'Sync Instrument Repo' }) @Post(':id/sync') @RouteAccess({ action: 'update', subject: 'InstrumentRepo' }) - sync(@Param('id') id: string, @CurrentUser('ability') ability?: AppAbility) { + sync(@Param('id') id: string, @CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.sync(id, { ability }); } @ApiOperation({ summary: 'Sync Every Instrument Repo' }) @Post('sync/all') @RouteAccess({ action: 'update', subject: 'InstrumentRepo' }) - syncAll(@CurrentUser('ability') ability?: AppAbility) { + syncAll(@CurrentUser('ability') ability: AppAbility) { return this.instrumentReposService.syncAll({ ability }); } } diff --git a/apps/api/src/instrument-repos/instrument-repos.service.ts b/apps/api/src/instrument-repos/instrument-repos.service.ts index 04c0fff23..1f1aa27a3 100644 --- a/apps/api/src/instrument-repos/instrument-repos.service.ts +++ b/apps/api/src/instrument-repos/instrument-repos.service.ts @@ -184,12 +184,12 @@ export class InstrumentReposService implements OnModuleInit { where: accessibleQuery(ability, 'read', 'InstrumentRepo') }); const logs = await Promise.all( - repos.map((repo) => - this.instrumentRepoSyncLogModel.findFirst({ + repos.map((repo) => { + return this.instrumentRepoSyncLogModel.findFirst({ orderBy: { createdAt: 'desc' }, where: { AND: [accessibleQuery(ability, 'read', 'InstrumentRepoSyncLog')], repoId: repo.id } - }) - ) + }); + }) ); return logs.filter((log) => log !== null); } diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index f14fccf28..ef12d0fd9 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -48,12 +48,12 @@ const rows = () => screen.getAllByTestId('data-table-row'); const statuses = () => screen.getAllByTestId('sync-log-status').map((badge) => badge.textContent); -afterEach(() => { - cleanup(); - theme.current = 'light'; -}); - describe('InstrumentRepoSyncLogTable', () => { + afterEach(() => { + cleanup(); + theme.current = 'light'; + }); + it('should say so in one line when there is nothing to show, rather than render an empty table', () => { render(
); expect(screen.getByText('No imports or syncs recorded yet.')).toBeTruthy(); diff --git a/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx b/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx index 21860b7a0..363ff324e 100644 --- a/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx +++ b/apps/web/src/components/SectionTabs/__tests__/SectionTabs.test.tsx @@ -10,9 +10,9 @@ const tabs = [ { label: 'Activity Log', value: 'ACTIVITY' } ] as const; -afterEach(cleanup); - describe('SectionTabs', () => { + afterEach(cleanup); + it('should mark only the current section as active', () => { render(); expect(screen.getByTestId('tabs-ACTIVITY').dataset.state).toBe('active'); diff --git a/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts index b52b467ba..e381b7d34 100644 --- a/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useClearInstrumentRepoSyncLogsMutation.test.ts @@ -23,8 +23,9 @@ vi.mock('@douglasneuroinformatics/libui/hooks', () => ({ function renderClearMutation() { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false, throwOnError: true } } }); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); - const wrapper = ({ children }: PropsWithChildren) => - createElement(QueryClientProvider, { children, client: queryClient }); + const wrapper = ({ children }: PropsWithChildren) => { + return createElement(QueryClientProvider, { children, client: queryClient }); + }; return { ...renderHook(() => useClearInstrumentRepoSyncLogsMutation(), { wrapper }), invalidateQueries }; } diff --git a/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts b/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts index 54609edb2..727810073 100644 --- a/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts +++ b/apps/web/src/hooks/__tests__/useInstrumentRepoSyncLogsQuery.test.ts @@ -62,7 +62,9 @@ describe('instrumentRepoSyncLogsQueryOptions', () => { mockAxios.get.mockResolvedValue({ data: { ...PAGE, data: [{ ...LOG, failures: [{ ...LOG.failures[0], stage: 'RENDER' }] }] } }); - await expect(createQueryClient().fetchQuery(instrumentRepoSyncLogsQueryOptions())).rejects.toThrow(); + await expect(createQueryClient().fetchQuery(instrumentRepoSyncLogsQueryOptions())).rejects.toThrow( + /invalid option/i + ); }); it('should key the cache on every parameter, so paging does not serve a stale page', () => { @@ -83,8 +85,9 @@ describe('useInstrumentRepoSyncLogsQuery', () => { vi.clearAllMocks(); mockAxios.get.mockResolvedValue({ data: PAGE }); const queryClient = createQueryClient(); - const wrapper = ({ children }: PropsWithChildren) => - createElement(QueryClientProvider, { client: queryClient }, children); + const wrapper = ({ children }: PropsWithChildren) => { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; const { result } = renderHook(() => useInstrumentRepoSyncLogsQuery(), { wrapper }); await waitFor(() => expect(result.current.data).toBeTruthy()); expect(result.current.data?.data.map((log) => log.repoName)).toEqual(['core']); diff --git a/apps/web/src/hooks/__tests__/useLatestInstrumentRepoSyncLogsQuery.test.ts b/apps/web/src/hooks/__tests__/useLatestInstrumentRepoSyncLogsQuery.test.ts index 452213fa8..c297cafd6 100644 --- a/apps/web/src/hooks/__tests__/useLatestInstrumentRepoSyncLogsQuery.test.ts +++ b/apps/web/src/hooks/__tests__/useLatestInstrumentRepoSyncLogsQuery.test.ts @@ -56,12 +56,13 @@ describe('useLatestInstrumentRepoSyncLogsQuery', () => { vi.clearAllMocks(); mockAxios.get.mockResolvedValue({ data: [LOG] }); const queryClient = createQueryClient(); - const wrapper = ({ children }: PropsWithChildren) => - createElement( + const wrapper = ({ children }: PropsWithChildren) => { + return createElement( QueryClientProvider, { client: queryClient }, createElement(Suspense, { fallback: null }, children) ); + }; const { result } = renderHook(() => useLatestInstrumentRepoSyncLogsQuery(), { wrapper }); await waitFor(() => expect(result.current).toBeTruthy()); expect(result.current.data.map((log) => log.repoName)).toEqual(['core']); diff --git a/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts index 73de2f27b..5082e209f 100644 --- a/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts +++ b/apps/web/src/hooks/__tests__/useSyncAllInstrumentReposMutation.test.ts @@ -41,8 +41,9 @@ const syncLog = ({ function renderSyncAllMutation() { const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false, throwOnError: true } } }); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); - const wrapper = ({ children }: PropsWithChildren) => - createElement(QueryClientProvider, { children, client: queryClient }); + const wrapper = ({ children }: PropsWithChildren) => { + return createElement(QueryClientProvider, { children, client: queryClient }); + }; return { ...renderHook(() => useSyncAllInstrumentReposMutation(), { wrapper }), invalidateQueries }; } @@ -65,12 +66,16 @@ describe('useSyncAllInstrumentReposMutation', () => { }); it('should announce the sync before it finishes, since syncing every repository takes a while', async () => { - mockAxios.post.mockReturnValueOnce(new Promise(() => undefined)); + mockAxios.post.mockReturnValueOnce( + new Promise(() => { + // never settles, so the mutation stays pending while the notification is asserted + }) + ); const { result } = renderSyncAllMutation(); result.current.mutate(); - await waitFor(() => - expect(addNotification).toHaveBeenCalledExactlyOnceWith({ message: 'Syncing every repository...', type: 'info' }) - ); + await waitFor(() => { + expect(addNotification).toHaveBeenCalledExactlyOnceWith({ message: 'Syncing every repository...', type: 'info' }); + }); }); it('should announce success and refresh the repositories, their history and their instruments', async () => { diff --git a/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts index 6078d65a9..083bd98cc 100644 --- a/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts +++ b/apps/web/src/hooks/useClearInstrumentRepoSyncLogsMutation.ts @@ -11,8 +11,9 @@ export function useClearInstrumentRepoSyncLogsMutation() { const addNotification = useNotificationsStore((store) => store.addNotification); const { t } = useTranslation(); return useMutation({ - mutationFn: () => - axios.delete('/v1/instrument-repos/sync/logs', { meta: { disableDefaultErrorNotification: true } }), + mutationFn: () => { + return axios.delete('/v1/instrument-repos/sync/logs', { meta: { disableDefaultErrorNotification: true } }); + }, onError(err) { addNotification({ message: getApiErrorMessage( diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index b611fcd08..68f9fdfc7 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -162,8 +162,9 @@ const renderPage = (repos: InstrumentRepo[] = [repoFixture()]) => { const rows = () => screen.getAllByTestId('data-table-row'); // Radix tabs activate on mouse down, not click. -const openActivityLog = () => - fireEvent.mouseDown(screen.getByTestId('instrument-repos-section-ACTIVITY'), { button: 0 }); +const openActivityLog = () => { + return fireEvent.mouseDown(screen.getByTestId('instrument-repos-section-ACTIVITY'), { button: 0 }); +}; const rowAction = (label: string, rowIndex = 0) => { fireEvent.keyDown(within(rows()[rowIndex]!).getByTestId('row-actions-trigger'), { key: 'Enter' }); diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 9cf6044db..8bde299fc 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -351,10 +351,11 @@ const RouteComponent = () => { size: 370 }, { - accessorFn: (row: InstrumentRepo) => - instrumentInfoQuery.data + accessorFn: (row: InstrumentRepo) => { + return instrumentInfoQuery.data ? currentRepoInstruments(instrumentInfoQuery.data, row.id).length - : row.instrumentIds.length, + : row.instrumentIds.length; + }, header: t({ en: 'Instruments', es: 'Instrumentos', @@ -376,8 +377,9 @@ const RouteComponent = () => { size: 90 }, { - accessorFn: (row: InstrumentRepo) => - row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleDateString() : '-', + accessorFn: (row: InstrumentRepo) => { + return row.lastSyncedAt ? new Date(row.lastSyncedAt).toLocaleDateString() : '-'; + }, header: t({ en: 'Last Synced', es: 'Última sincronización', @@ -593,9 +595,9 @@ const RouteComponent = () => { const searchTerm = instrumentSearch.trim().toLowerCase(); const repoInstruments = instrumentInfoQuery.data && - currentRepoInstruments(instrumentInfoQuery.data, repoToView.id).filter((instrument) => - instrument.details.title.toLowerCase().includes(searchTerm) - ); + currentRepoInstruments(instrumentInfoQuery.data, repoToView.id).filter((instrument) => { + return instrument.details.title.toLowerCase().includes(searchTerm); + }); if (instrumentInfoQuery.isLoading) { return (
diff --git a/packages/schemas/src/instrument-repo/instrument-repo.ts b/packages/schemas/src/instrument-repo/instrument-repo.ts index 9ed5de38e..08f280a70 100644 --- a/packages/schemas/src/instrument-repo/instrument-repo.ts +++ b/packages/schemas/src/instrument-repo/instrument-repo.ts @@ -1,3 +1,4 @@ +import { $NumberLike } from '@douglasneuroinformatics/libjs'; import { z } from 'zod/v4'; import { $BaseModel, $DateLike } from '../core/core.js'; @@ -58,13 +59,10 @@ export const INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE = 1000; export type InstrumentRepoSyncLogsQueryParams = z.infer; export const $InstrumentRepoSyncLogsQueryParams = z.object({ - limit: z.coerce - .number() - .int() - .positive() - .max(INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE) + limit: $NumberLike + .pipe(z.number().int().positive().max(INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE)) .default(INSTRUMENT_REPO_SYNC_LOGS_PAGE_SIZE), - page: z.coerce.number().int().positive().default(1), + page: $NumberLike.pipe(z.number().int().positive()).default(1), /** Confines the page to one repository, which is what the per-repository dialog asks for. */ repoId: z.string().optional(), sortOrder: z.enum(['asc', 'desc']).default('desc') From b88a63b66abd820217200e2723d33b9a82955a8a Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 11:31:33 -0400 Subject: [PATCH 11/13] fix(web): fit both repository tables to the page at a 1280px viewport Measured on a live node, the table has 764px to work with there: the repositories table's pinned columns alone came to 794, which squeezed its name column to 32px and made the table scroll sideways. Its URL column gives up most of that width, since a URL is truncated with the full value on hover anyway, and the name cell renders `TruncatedCell` rather than wrapping once the column is narrow enough for a long name to need two lines. The activity log's own pinned columns came to 745 of the same 764, leaving its repository column 19px. Each one is trimmed to the width its longest header needs, which gives the repository column back about a hundred pixels at that viewport and proportionally more on a wider screen. The card's invisibility is asserted layer by layer: Tailwind v4 compiles `shadow-none` to transparent shadow layers rather than the keyword `none`, so `toHaveCSS('box-shadow', 'none')` could never have passed. Co-Authored-By: Claude Opus 5 --- .../InstrumentRepoSyncLogTable.tsx | 12 +++---- .../_app/admin/instrument-repos/index.tsx | 12 +++---- .../src/specs/admin-instrument-repos.spec.ts | 36 ++++++++++--------- 3 files changed, 32 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx index b82257d16..ecbdc647e 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -92,7 +92,7 @@ export const InstrumentRepoSyncLogTable = ({ /> ), id: 'date', - size: 170, + size: 150, // The rows already arrive newest first, so the table's first toggle of its unsorted // column must go to ascending, or the first click would request what is on screen. sortDescFirst: false @@ -117,7 +117,7 @@ export const InstrumentRepoSyncLogTable = ({ enableSorting: false, header: t({ en: 'Action', es: 'Acción', fr: 'Action' }), id: 'action', - size: 105 + size: 95 }, { cell: (ctx) => { @@ -133,7 +133,7 @@ export const InstrumentRepoSyncLogTable = ({ enableSorting: false, header: t({ en: 'Status', es: 'Estado', fr: 'Statut' }), id: 'status', - size: 160 + size: 140 }, { accessorKey: 'importedCount', @@ -143,7 +143,7 @@ export const InstrumentRepoSyncLogTable = ({ }, enableSorting: false, header: t({ en: 'Imported', es: 'Importados', fr: 'Importés' }), - size: 110 + size: 105 }, { cell: (ctx) => { @@ -153,7 +153,7 @@ export const InstrumentRepoSyncLogTable = ({ enableSorting: false, header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), id: 'failedCount', - size: 95 + size: 85 }, { cell: (ctx) => { @@ -177,7 +177,7 @@ export const InstrumentRepoSyncLogTable = ({ enableSorting: false, header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), id: 'messages', - size: 105 + size: 95 } ]} data={logs} diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx index 8bde299fc..293b3c657 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx @@ -326,8 +326,8 @@ const RouteComponent = () => { cell: (ctx) => { const repo = ctx.row.original; return ( - - {repo.name} + + ); @@ -348,7 +348,7 @@ const RouteComponent = () => { /> ), header: 'URL', - size: 370 + size: 210 }, { accessorFn: (row: InstrumentRepo) => { @@ -362,7 +362,7 @@ const RouteComponent = () => { fr: 'Instruments' }), id: 'instrumentCount', - size: 120 + size: 118 }, { cell: (ctx) => { @@ -374,7 +374,7 @@ const RouteComponent = () => { }, header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), id: 'failedCount', - size: 90 + size: 88 }, { accessorFn: (row: InstrumentRepo) => { @@ -386,7 +386,7 @@ const RouteComponent = () => { fr: 'Dernière synchronisation' }), id: 'lastSynced', - size: 150 + size: 112 } ]} data={reposQuery.data} diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts index db6aab0aa..610aefe41 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -123,17 +123,17 @@ test.describe('admin instrument repos activity log', () => { }; test.beforeEach(async ({ page }) => { - await page.route('**/v1/instrument-repos', (route) => - route.request().method() === 'GET' ? route.fulfill({ json: [repo] }) : route.continue() - ); + await page.route('**/v1/instrument-repos', (route) => { + return route.request().method() === 'GET' ? route.fulfill({ json: [repo] }) : route.continue(); + }); // Regexes, not globs: the history is now requested with query parameters, and its two routes // must not match each other. await page.route(/\/v1\/instrument-repos\/sync\/logs\/latest/, (route) => route.fulfill({ json: [partialSync] })); - await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => - route.request().method() === 'DELETE' + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => { + return route.request().method() === 'DELETE' ? route.fulfill({ json: { count: 1 } }) - : route.fulfill({ json: { data: [partialSync], pageCount: 1, total: 1 } }) - ); + : route.fulfill({ json: { data: [partialSync], pageCount: 1, total: 1 } }); + }); }); test('should keep a long repository URL inside its column, even on the selected row', async ({ getPageModel }) => { @@ -181,7 +181,10 @@ test.describe('admin instrument repos activity log', () => { const expectInvisible = async () => { await expect(reposPage.card).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); await expect(reposPage.card).toHaveCSS('border-top-color', 'rgba(0, 0, 0, 0)'); - await expect(reposPage.card).toHaveCSS('box-shadow', 'none'); + // Tailwind v4 compiles `shadow-none` to transparent layers rather than the keyword `none`, + // so the assertion is that every layer is invisible, not that there are no layers. + const shadow = await reposPage.card.evaluate((card) => getComputedStyle(card).boxShadow); + expect(shadow.replace(/rgba\(0, 0, 0, 0\)|0px|,|\s/g, '')).toBe(''); }; await expectInvisible(); @@ -192,12 +195,13 @@ test.describe('admin instrument repos activity log', () => { test('should fit both tables to the page without a horizontal scroll bar', async ({ getPageModel }) => { const reposPage = await getPageModel('/admin/instrument-repos'); - const overflows = (table: typeof reposPage.dataTable) => - table + const overflows = (table: typeof reposPage.dataTable) => { + return table .getByTestId('data-table-body') .evaluate( (body) => body.parentElement!.parentElement!.scrollWidth > body.parentElement!.parentElement!.clientWidth ); + }; expect(await overflows(reposPage.dataTable)).toBe(false); await reposPage.openActivityLog(); @@ -233,9 +237,9 @@ test.describe('admin instrument repos activity log', () => { page }) => { const repoName = 'LaboratoireDeRecherchePsychiatrique/instruments-cliniques-internes-du-laboratoire'; - await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => - route.fulfill({ json: { data: [{ ...partialSync, repoName }], pageCount: 1, total: 1 } }) - ); + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => { + return route.fulfill({ json: { data: [{ ...partialSync, repoName }], pageCount: 1, total: 1 } }); + }); const reposPage = await getPageModel('/admin/instrument-repos'); await reposPage.openActivityLog(); @@ -267,9 +271,9 @@ test.describe('admin instrument repos activity log', () => { getPageModel, page }) => { - await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => - route.fulfill({ json: { data: [], pageCount: 0, total: 0 } }) - ); + await page.route(/\/v1\/instrument-repos\/sync\/logs(\?|$)/, (route) => { + return route.fulfill({ json: { data: [], pageCount: 0, total: 0 } }); + }); const reposPage = await getPageModel('/admin/instrument-repos'); await reposPage.openActivityLog(); From 96cdfbc12fcf75292c42ae0c75d53236c875ab54 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 11:51:55 -0400 Subject: [PATCH 12/13] docs(web): record how the libui DataTable sizes and clips its cells Both facts were load-bearing in this change and neither is visible from a call site: cell content that is a React element never reaches `DataTableBody`'s truncating span, which is every ordinary column, and `columnDef.size` applies only to a pinned column. Nothing in the repo stated either, so they were worked out from the vendor source twice. Co-Authored-By: Claude Opus 5 --- apps/web/AGENTS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 12a6d4af5..8a0e81993 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -181,6 +181,34 @@ Put something in `packages/react-core` instead **only if `apps/gateway` also use `data-testid` attributes are the Playwright suite's selectors. Don't remove them; add one when you add UI that an e2e test will drive. +## Tables + +Every table here is the libui `DataTable`. Two of its behaviours are load-bearing and neither is +visible from the call site. + +**A cell wraps unless it truncates itself.** `DataTableBody` wraps cell content in a truncating span +only when that content is _not_ a React element, and Tanstack's default cell renderer is a function, +which libui's `flexRender` renders as a component. So that span never applies to an ordinary column: +one long value makes its row two lines tall. Render `@/components/TruncatedCell` — one line, an +ellipsis, and the full value in the native `title` — in any column whose content can outgrow it; +`ColorTagCell` already clips the same way. `src/components/InstrumentRepoSyncLogTable` renders every +column that way. + +**`columnDef.size` is honoured only on a pinned column.** `calculateColumnSizing` gives each pinned +column its `size` and splits what is left _evenly_ between the unpinned ones, so the way to give one +column the slack is to pin every other column and leave that one alone — pin them all and the +function divides the container evenly and ignores every `size`. A column's size has a floor: header +cells get no truncating span either, so a size under the width of the longest translated header wraps +the header instead of the cell. Budget against the width the table actually has rather than the +viewport's: measure it in the browser, or from a spec with +`locator.evaluate((node) => node.clientWidth)`. The admin pages get 764px of a 1280px viewport once +the sidebar and the card's padding are taken out, which is what +`testing/src/specs/admin-instrument-repos.spec.ts` holds both of their tables to. + +`initialState` is where pinning goes, which libui 6.16.1 types as `never` in `mode="server"`; +`patches/@douglasneuroinformatics__libui@6.16.1.patch` carries +[libui#129](https://github.com/DouglasNeuroInformatics/libui/pull/129) until a release includes it. + ## Tests `pnpm exec vitest --project web`. Environment is happy-dom. There are no setup files anywhere in the From 151d6a122c673337dbbb692cc7b3f3f96f801ff5 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Fri, 30 Oct 2026 17:20:04 -0400 Subject: [PATCH 13/13] chore: take libui 6.19.2 and drop the data table patch 6.19.2 carries the type change this branch patched in locally (libui#129), so a server-mode `DataTable` accepts `initialState` and the activity log's columns can keep their own widths from a released dependency. 6.19.0 and 6.19.1 could not be taken: moving off `forwardRef` left published declarations naming `React` unqualified, which resolves only through `@types/react`'s UMD global. This repo has no `@types/react` and vendors React's types as a plain module, so props silently became `any` and surfaced as 26 then 18 `TS7006` errors in unrelated files. Fixed upstream in libui#130 and libui#131. Co-Authored-By: Claude Opus 5 --- apps/web/AGENTS.md | 6 +- ...ouglasneuroinformatics__libui@6.16.1.patch | 29 -- pnpm-lock.yaml | 286 +++++++++++++----- pnpm-workspace.yaml | 8 +- 4 files changed, 209 insertions(+), 120 deletions(-) delete mode 100644 patches/@douglasneuroinformatics__libui@6.16.1.patch diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 8a0e81993..fa3030633 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -205,9 +205,9 @@ viewport's: measure it in the browser, or from a spec with the sidebar and the card's padding are taken out, which is what `testing/src/specs/admin-instrument-repos.spec.ts` holds both of their tables to. -`initialState` is where pinning goes, which libui 6.16.1 types as `never` in `mode="server"`; -`patches/@douglasneuroinformatics__libui@6.16.1.patch` carries -[libui#129](https://github.com/DouglasNeuroInformatics/libui/pull/129) until a release includes it. +`initialState` is where pinning goes. libui typed it as `never` in `mode="server"` until 6.19.0 +([libui#129](https://github.com/DouglasNeuroInformatics/libui/pull/129)), so the catalog cannot go +back below that release. ## Tests diff --git a/patches/@douglasneuroinformatics__libui@6.16.1.patch b/patches/@douglasneuroinformatics__libui@6.16.1.patch deleted file mode 100644 index 138b18c8c..000000000 --- a/patches/@douglasneuroinformatics__libui@6.16.1.patch +++ /dev/null @@ -1,29 +0,0 @@ ---- a/dist/components/DataTable/types.d.ts -+++ b/dist/components/DataTable/types.d.ts -@@ -68,11 +68,10 @@ - /********************************************************************* - * CLIENT - *********************************************************************/ --export type ClientDataTableInitialState = { -+export type ClientDataTableInitialState = Simplify; - export type ClientDataTableStoreParams = Simplify & { - initialState?: ClientDataTableInitialState; - mode?: 'client'; -@@ -81,8 +80,11 @@ - /********************************************************************* - * SERVER - *********************************************************************/ -+export type ServerDataTableInitialState = { -+ columnPinning?: ColumnPinningState; -+}; - export type ServerDataTableStoreParams = Simplify & { -- initialState?: never; -+ initialState?: ServerDataTableInitialState; - mode: 'server'; - onPaginationChange: (state: PaginationState) => void; - onSortingChange?: (state: SortingState) => void; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65c591902..de66164c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,8 +31,8 @@ catalogs: specifier: ^0.2.1 version: 0.2.1 '@douglasneuroinformatics/libui': - specifier: ^6.16.1 - version: 6.16.1 + specifier: ^6.19.2 + version: 6.19.2 '@douglasneuroinformatics/libui-form-types': specifier: ^1.4.0 version: 1.4.0 @@ -141,11 +141,6 @@ overrides: typescript: 6.0.x yaml@2: ^2.9.0 -patchedDependencies: - '@douglasneuroinformatics/libui@6.16.1': - hash: 7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b - path: patches/@douglasneuroinformatics__libui@6.16.1.patch - importers: .: @@ -387,7 +382,7 @@ importers: version: 0.0.6 '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/react-core': specifier: workspace:* version: link:../../packages/react-core @@ -481,7 +476,7 @@ importers: dependencies: '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x) '@opendatacapture/licenses': specifier: workspace:* version: link:../../packages/licenses @@ -563,7 +558,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: workspace:* version: link:../../packages/instrument-bundler @@ -687,7 +682,7 @@ importers: version: 0.0.3(typescript@6.0.3) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@vendor+react@19.x) @@ -987,7 +982,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: workspace:* version: link:../instrument-bundler @@ -1182,7 +1177,7 @@ importers: version: 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) '@douglasneuroinformatics/libui': specifier: 'catalog:' - version: 6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) + version: 6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x) '@opendatacapture/instrument-bundler': specifier: 'workspace:' version: link:../instrument-bundler @@ -2259,8 +2254,16 @@ packages: '@types/react': optional: true - '@douglasneuroinformatics/libui@6.16.1': - resolution: {integrity: sha512-HNjjO1OHdUfvZRuJU0hpnUAaCOdAFEKRhsPhT07u4gy9CifzVR1GFnKD1tuJeJkgBDoRyxrXtVdmKiU1G6VKZQ==} + '@douglasneuroinformatics/libui-form-types@1.5.0': + resolution: {integrity: sha512-OvraW/G9kwwMoVTXjDJmBtXMM+uwk/JqRX7/Extek6rBaznStAN3prR2fXiz7lnYA3sRluMSBgEH5uMU4lVg/A==} + peerDependencies: + '@types/react': '*' + peerDependenciesMeta: + '@types/react': + optional: true + + '@douglasneuroinformatics/libui@6.19.2': + resolution: {integrity: sha512-sKm4hBq6+krTJxNiGFwXSfATy2mNrApa8ZLqhjixgi5uN5ERaBco6H/C6AGGCzn6r9Yza3asN6P0/b+dyyhSVg==} engines: {node: 24.x} peerDependencies: react: ^19.1.0 @@ -4207,19 +4210,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.2.16': - resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-context-menu@2.3.7': resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} peerDependencies: @@ -11189,24 +11179,6 @@ packages: zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} - zustand@5.0.14: - resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@types/react': '*' - immer: '>=9.0.6' - react: '>=18.0.0' - use-sync-external-store: '>=1.2.0' - peerDependenciesMeta: - '@types/react': - optional: true - immer: - optional: true - react: - optional: true - use-sync-external-store: - optional: true - zustand@5.0.15: resolution: {integrity: sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==} engines: {node: '>=12.20.0'} @@ -11998,17 +11970,21 @@ snapshots: dependencies: type-fest: 4.41.0 - '@douglasneuroinformatics/libui@6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui-form-types@1.5.0': + dependencies: + type-fest: 4.41.0 + + '@douglasneuroinformatics/libui@6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@19.1.0))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) - '@douglasneuroinformatics/libui-form-types': 1.4.0 + '@douglasneuroinformatics/libui-form-types': 1.5.0 '@radix-ui/react-accordion': 1.2.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-alert-dialog': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-avatar': 1.1.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-checkbox': 1.3.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-collapsible': 1.1.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-context-menu': 2.2.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-context-menu': 2.3.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dropdown-menu': 2.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-hover-card': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -12021,7 +11997,7 @@ snapshots: '@radix-ui/react-select': 2.2.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-separator': 1.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slider': 1.3.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-slot': 1.2.4(react@19.1.0) + '@radix-ui/react-slot': 1.3.3(react@19.1.0) '@radix-ui/react-switch': 1.2.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tabs': 1.1.13(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-tooltip': 1.2.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -12044,7 +12020,7 @@ snapshots: type-fest: 4.41.0 vaul: 1.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) zod: link:vendor/zod@3.x - zustand: 5.0.14(immer@11.1.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) + zustand: 5.0.15(immer@11.1.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) transitivePeerDependencies: - '@date-fns/tz' - '@emotion/is-prop-valid' @@ -12055,17 +12031,17 @@ snapshots: - neverthrow - use-sync-external-store - '@douglasneuroinformatics/libui@6.16.1(patch_hash=7ca5be437b2472e814db6bb65cb77be821fb401a909497dfac93c9e044f37f3b)(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': + '@douglasneuroinformatics/libui@6.19.2(immer@11.1.18)(neverthrow@8.2.0)(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)(tailwindcss@4.3.3)(use-sync-external-store@1.6.0(react@vendor+react@19.x))(zod@vendor+zod@3.x)': dependencies: '@base-ui/react': 1.6.0(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@douglasneuroinformatics/libjs': 3.2.1(neverthrow@8.2.0)(zod@vendor+zod@3.x) - '@douglasneuroinformatics/libui-form-types': 1.4.0 + '@douglasneuroinformatics/libui-form-types': 1.5.0 '@radix-ui/react-accordion': 1.2.12(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-alert-dialog': 1.1.15(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-avatar': 1.1.11(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-checkbox': 1.3.3(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-collapsible': 1.1.12(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) - '@radix-ui/react-context-menu': 2.2.16(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) + '@radix-ui/react-context-menu': 2.3.7(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-dialog': 1.1.15(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-dropdown-menu': 2.1.16(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-hover-card': 1.1.15(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -12078,7 +12054,7 @@ snapshots: '@radix-ui/react-select': 2.2.6(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-separator': 1.1.8(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-slider': 1.3.6(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) - '@radix-ui/react-slot': 1.2.4(react@vendor+react@19.x) + '@radix-ui/react-slot': 1.3.3(react@vendor+react@19.x) '@radix-ui/react-switch': 1.2.6(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-tabs': 1.1.13(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) '@radix-ui/react-tooltip': 1.2.8(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -12101,7 +12077,7 @@ snapshots: type-fest: 4.41.0 vaul: 1.1.2(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) zod: link:vendor/zod@3.x - zustand: 5.0.14(immer@11.1.18)(react@vendor+react@19.x)(use-sync-external-store@1.6.0(react@vendor+react@19.x)) + zustand: 5.0.15(immer@11.1.18)(react@vendor+react@19.x)(use-sync-external-store@1.6.0(react@vendor+react@19.x)) transitivePeerDependencies: - '@date-fns/tz' - '@emotion/is-prop-valid' @@ -13487,6 +13463,12 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-arrow@1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-arrow@1.1.15(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-primitive': 2.1.10(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -13577,6 +13559,15 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-collection@1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-context': 1.2.2(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.3.3(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-collection@1.1.15(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(react@vendor+react@19.x) @@ -13612,32 +13603,24 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-compose-refs@1.1.5(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-compose-refs@1.1.5(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x - '@radix-ui/react-context-menu@2.2.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@radix-ui/react-context-menu@2.3.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(react@19.1.0) - '@radix-ui/react-menu': 2.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-primitive': 2.1.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@radix-ui/react-use-callback-ref': 1.1.1(react@19.1.0) - '@radix-ui/react-use-controllable-state': 1.2.2(react@19.1.0) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(react@19.1.0) + '@radix-ui/react-menu': 2.1.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.6(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@radix-ui/react-context-menu@2.2.16(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(react@vendor+react@19.x) - '@radix-ui/react-menu': 2.1.16(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) - '@radix-ui/react-primitive': 2.1.3(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) - '@radix-ui/react-use-callback-ref': 1.1.1(react@vendor+react@19.x) - '@radix-ui/react-use-controllable-state': 1.2.2(react@vendor+react@19.x) - react: link:vendor/react@19.x - react-dom: link:vendor/react-dom@19.x - '@radix-ui/react-context-menu@2.3.7(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -13664,6 +13647,10 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-context@1.2.2(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-context@1.2.2(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x @@ -13714,6 +13701,10 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-direction@1.1.4(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-direction@1.1.4(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x @@ -13738,6 +13729,16 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-dismissable-layer@1.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.4(react@19.1.0) + '@radix-ui/react-use-effect-event': 0.0.5(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-dismissable-layer@1.1.19(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -13780,10 +13781,22 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-focus-guards@1.1.6(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-focus-guards@1.1.6(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x + '@radix-ui/react-focus-scope@1.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.4(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-focus-scope@1.1.16(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(react@vendor+react@19.x) @@ -13846,6 +13859,11 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(react@vendor+react@19.x) react: link:vendor/react@19.x + '@radix-ui/react-id@1.1.4(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + '@radix-ui/react-id@1.1.4(react@vendor+react@19.x)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(react@vendor+react@19.x) @@ -13909,6 +13927,29 @@ snapshots: react-dom: link:vendor/react-dom@19.x react-remove-scroll: 2.7.2(react@vendor+react@19.x) + '@radix-ui/react-menu@2.1.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-context': 1.2.2(react@19.1.0) + '@radix-ui/react-direction': 1.1.4(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-focus-guards': 1.1.6(react@19.1.0) + '@radix-ui/react-focus-scope': 1.1.16(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.4(react@19.1.0) + '@radix-ui/react-popper': 1.3.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-portal': 1.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-presence': 1.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-roving-focus': 1.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.3.3(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.4(react@19.1.0) + aria-hidden: 1.2.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-remove-scroll: 2.7.2(react@19.1.0) + '@radix-ui/react-menu@2.1.24(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -14032,6 +14073,21 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-popper@1.3.7(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-arrow': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-context': 1.2.2(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.4(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + '@radix-ui/react-use-rect': 1.1.4(react@19.1.0) + '@radix-ui/react-use-size': 1.1.4(react@19.1.0) + '@radix-ui/rect': 1.1.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-popper@1.3.7(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -14047,6 +14103,13 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-portal@1.1.17(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-portal@1.1.17(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-primitive': 2.1.10(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x) @@ -14068,6 +14131,12 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-presence@1.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-presence@1.1.10(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(react@vendor+react@19.x) @@ -14088,6 +14157,12 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-primitive@2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.3.3(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-primitive@2.1.10(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/react-slot': 1.3.3(react@vendor+react@19.x) @@ -14190,6 +14265,22 @@ snapshots: react: link:vendor/react@19.x react-dom: link:vendor/react-dom@19.x + '@radix-ui/react-roving-focus@1.1.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + '@radix-ui/react-context': 1.2.2(react@19.1.0) + '@radix-ui/react-direction': 1.1.4(react@19.1.0) + '@radix-ui/react-id': 1.1.4(react@19.1.0) + '@radix-ui/react-primitive': 2.1.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.4(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.6(react@19.1.0) + '@radix-ui/react-use-is-hydrated': 0.1.3(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@radix-ui/react-roving-focus@1.1.19(react-dom@vendor+react-dom@19.x)(react@vendor+react@19.x)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -14350,6 +14441,11 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(react@vendor+react@19.x) react: link:vendor/react@19.x + '@radix-ui/react-slot@1.3.3(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(react@19.1.0) + react: 19.1.0 + '@radix-ui/react-slot@1.3.3(react@vendor+react@19.x)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(react@vendor+react@19.x) @@ -14447,6 +14543,10 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-use-callback-ref@1.1.4(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-use-callback-ref@1.1.4(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x @@ -14463,6 +14563,13 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(react@vendor+react@19.x) react: link:vendor/react@19.x + '@radix-ui/react-use-controllable-state@1.2.6(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + '@radix-ui/react-use-controllable-state@1.2.6(react@vendor+react@19.x)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -14480,6 +14587,11 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(react@vendor+react@19.x) react: link:vendor/react@19.x + '@radix-ui/react-use-effect-event@0.0.5(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + '@radix-ui/react-use-effect-event@0.0.5(react@vendor+react@19.x)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(react@vendor+react@19.x) @@ -14505,6 +14617,10 @@ snapshots: react: link:vendor/react@19.x use-sync-external-store: 1.6.0(react@vendor+react@19.x) + '@radix-ui/react-use-is-hydrated@0.1.3(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-use-is-hydrated@0.1.3(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x @@ -14517,6 +14633,10 @@ snapshots: dependencies: react: link:vendor/react@19.x + '@radix-ui/react-use-layout-effect@1.1.4(react@19.1.0)': + dependencies: + react: 19.1.0 + '@radix-ui/react-use-layout-effect@1.1.4(react@vendor+react@19.x)': dependencies: react: link:vendor/react@19.x @@ -14539,6 +14659,11 @@ snapshots: '@radix-ui/rect': 1.1.1 react: link:vendor/react@19.x + '@radix-ui/react-use-rect@1.1.4(react@19.1.0)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.1.0 + '@radix-ui/react-use-rect@1.1.4(react@vendor+react@19.x)': dependencies: '@radix-ui/rect': 1.1.3 @@ -14554,6 +14679,11 @@ snapshots: '@radix-ui/react-use-layout-effect': 1.1.1(react@vendor+react@19.x) react: link:vendor/react@19.x + '@radix-ui/react-use-size@1.1.4(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(react@19.1.0) + react: 19.1.0 + '@radix-ui/react-use-size@1.1.4(react@vendor+react@19.x)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(react@vendor+react@19.x) @@ -21812,18 +21942,12 @@ snapshots: zod@4.6.5: {} - zustand@5.0.14(immer@11.1.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): + zustand@5.0.15(immer@11.1.18)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): optionalDependencies: immer: 11.1.18 react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0) - zustand@5.0.14(immer@11.1.18)(react@vendor+react@19.x)(use-sync-external-store@1.6.0(react@vendor+react@19.x)): - optionalDependencies: - immer: 11.1.18 - react: link:vendor/react@19.x - use-sync-external-store: 1.6.0(react@vendor+react@19.x) - zustand@5.0.15(immer@11.1.18)(react@vendor+react@19.x)(use-sync-external-store@1.6.0(react@vendor+react@19.x)): optionalDependencies: immer: 11.1.18 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22b1ed7a2..1fa60841f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,7 +21,7 @@ catalog: '@douglasneuroinformatics/libjs': ^3.2.1 '@douglasneuroinformatics/libpasswd': '^0.0.3' '@douglasneuroinformatics/libstats': '^0.2.1' - '@douglasneuroinformatics/libui': ^6.16.1 + '@douglasneuroinformatics/libui': ^6.19.2 '@douglasneuroinformatics/libui-form-types': ^1.4.0 '@microsoft/api-extractor': '^7.59.2' '@prisma/client': '^6.9.0' @@ -63,12 +63,6 @@ catalogs: hoistPattern: - '*' - '!@storybook/react' -# Type-only, and already merged upstream as libui `fix/server-data-table-column-pinning`: a -# server-mode DataTable typed `initialState` as `never`, so no column could be pinned, and only a -# pinned column honours `size`. Delete this patch and the `patches/` file when the catalog moves to -# the libui release that carries it. -patchedDependencies: - '@douglasneuroinformatics/libui@6.16.1': patches/@douglasneuroinformatics__libui@6.16.1.patch minimumReleaseAge: 10080 # 7 days minimumReleaseAgeExclude: - '@douglasneuroinformatics/*'