diff --git a/.agents/docs/architecture/auth-and-permissions.md b/.agents/docs/architecture/auth-and-permissions.md index 31be4d303..9400ca59d 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..2d9a3beab 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 activity log (`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..a2d8a705b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -256,6 +256,40 @@ 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 + + @@index([createdAt]) + @@index([repoId, createdAt]) + @@map("InstrumentRepoSyncLogModel") +} + // Subjects enum Sex { diff --git a/apps/api/src/core/prisma.ts b/apps/api/src/core/prisma.ts index 9bc172ecd..fa55d8f48 100644 --- a/apps/api/src/core/prisma.ts +++ b/apps/api/src/core/prisma.ts @@ -70,6 +70,8 @@ const DATABASE_INDEXES: DatabaseIndex[] = [ { collection: 'InstrumentRecordModel', fields: ['instrumentId'] }, { 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 19d4e4a4d..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 @@ -45,17 +45,55 @@ 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 () => { + service.syncAll.mockResolvedValueOnce([]); + await expect(controller.syncAll(ability)).resolves.toStrictEqual([]); + expect(service.syncAll).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 () => { + service.deleteSyncLogs.mockResolvedValueOnce({ count: 2 }); + await expect(controller.deleteSyncLogs(ability)).resolves.toStrictEqual({ count: 2 }); + expect(service.deleteSyncLogs).toHaveBeenCalledWith({ ability }); + }); + + it.each(['deleteSyncLogs', 'findLatestSyncLogs', '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..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 @@ -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,15 @@ 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 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({ accessToken: encryptedToken, @@ -259,7 +325,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 +333,142 @@ 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('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', () => { + 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(query)).resolves.toStrictEqual({ + data: [{ id: 'log-1' }], + pageCount: 3, + total: 25 + }); + expect(instrumentRepoSyncLogModel.findMany).toHaveBeenCalledWith({ + orderBy: { createdAt: 'desc' }, + skip: 0, + take: 10, + where: { AND: [{}], repoId: undefined } + }); + }); + + 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' } + }); + }); + + 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); + + await expect(service.findLatestSyncLogs()).resolves.toStrictEqual([]); }); it('should download without a token when the repo stores none, so public repos sync', async () => { @@ -283,7 +482,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 +792,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 +800,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 +815,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..12827c580 100644 --- a/apps/api/src/instrument-repos/instrument-repos.controller.ts +++ b/apps/api/src/instrument-repos/instrument-repos.controller.ts @@ -1,9 +1,13 @@ -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 { RouteAccess } from '@/core/decorators/route-access.decorator'; +import { ADMIN_ONLY, RouteAccess } from '@/core/decorators/route-access.decorator'; import { InstrumentReposService } from './instrument-repos.service'; @@ -21,21 +25,59 @@ 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) { + 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) { + 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 + * 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( + @Query(new ParseSchemaPipe({ schema: $InstrumentRepoSyncLogsQueryParams })) + query: InstrumentRepoSyncLogsQueryParams, + @CurrentUser('ability') ability: AppAbility + ) { + return this.instrumentReposService.findSyncLogs(query, { ability }); + } + @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' }) + @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..1f1aa27a3 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,60 @@ 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, + InstrumentRepoSyncLogsPage, + InstrumentRepoSyncLogsQueryParams, + 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 +91,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 +122,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(); @@ -98,6 +159,13 @@ export class InstrumentReposService implements OnModuleInit { return this.stripSecrets(deleted); } + /** 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') + }); + } + async findAll({ ability }: EntityOperationOptions = {}) { const repos = await this.instrumentRepoModel.findMany({ where: accessibleQuery(ability, 'read', 'InstrumentRepo') @@ -105,6 +173,48 @@ export class InstrumentReposService implements OnModuleInit { return repos.map((repo) => this.stripSecrets(repo)); } + /** + * 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) => { + return 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 { // Self-heal any instruments left orphaned by repositories deleted before this logic existed. try { @@ -114,31 +224,33 @@ 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}`); } + 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 +376,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 +405,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 +430,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 +553,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/AGENTS.md b/apps/web/AGENTS.md index 12a6d4af5..fa3030633 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. 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 `pnpm exec vitest --project web`. Environment is happy-dom. There are no setup files anywhere in the 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 new file mode 100644 index 000000000..ecbdc647e --- /dev/null +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/InstrumentRepoSyncLogTable.tsx @@ -0,0 +1,229 @@ +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'; + +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'; +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, + onPageChange, + onSortOrderChange, + pageCount, + sortOrder +}: InstrumentRepoSyncLogTableProps) => { + const { t } = useTranslation(); + const [theme] = useTheme(); + const [logToView, setLogToView] = useState(null); + + 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 }; + + /** 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, taller than five rows. + 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 ( + +
+ ( + + ), + enableSorting: true, + header: ({ column }) => ( + + ), + id: 'date', + 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 + }, + { + accessorKey: 'repoName', + cell: (ctx) => ( + + ), + enableSorting: false, + header: t({ en: 'Repository', es: 'Repositorio', fr: 'Dépôt' }) + }, + { + cell: (ctx) => { + const label = actionLabels[ctx.row.original.action]; + return ; + }, + enableSorting: false, + header: t({ en: 'Action', es: 'Acción', fr: 'Action' }), + id: 'action', + size: 95 + }, + { + cell: (ctx) => { + const status = getSyncStatus(ctx.row.original); + return ( + + ); + }, + enableSorting: false, + header: t({ en: 'Status', es: 'Estado', fr: 'Statut' }), + id: 'status', + size: 140 + }, + { + accessorKey: 'importedCount', + cell: (ctx) => { + const count = String(ctx.row.original.importedCount); + return ; + }, + enableSorting: false, + header: t({ en: 'Imported', es: 'Importados', fr: 'Importés' }), + size: 105 + }, + { + cell: (ctx) => { + const count = String(ctx.row.original.failures.length); + return ; + }, + enableSorting: false, + header: t({ en: 'Failed', es: 'Fallidos', fr: 'Échoués' }), + id: 'failedCount', + size: 85 + }, + { + cell: (ctx) => { + const log = ctx.row.original; + if (getSyncStatus(log) === 'SUCCESS') { + return '-'; + } + return ( + + ); + }, + enableSorting: false, + header: t({ en: 'Messages', es: 'Mensajes', fr: 'Messages' }), + id: 'messages', + size: 95 + } + ]} + 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)} + // Date is the only sortable column, so the state describes it or nothing. + onSortingChange={(state) => onSortOrderChange(state.some(({ desc }) => desc) ? 'desc' : 'asc')} + /> +
+ 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 new file mode 100644 index 000000000..ef12d0fd9 --- /dev/null +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -0,0 +1,255 @@ +import type { InstrumentRepoSyncLog } from '@opendatacapture/schemas/instrument-repo'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +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'), + failures: [], + id: 'log-1', + importedCount: 4, + message: null, + repoId: 'repo-1', + repoName: 'Core Repo', + updatedAt: new Date('2026-10-01T12:00:00Z'), + ...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); + +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(); + expect(screen.queryByTestId('data-table')).toBeNull(); + }); + + 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 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 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( +
+ ); + 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']); + 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', () => { + render( +
+ ); + expect(statuses()).toEqual(['Partial success']); + }); + + it('should mark a sync that could not read the repository as failed', () => { + render(
); + expect(statuses()).toEqual(['Failed']); + }); + + it('should label an import and a compile failure in words rather than as codes', () => { + render( +
+ ); + expect(within(rows()[0]!).getByText('Import')).toBeTruthy(); + 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', () => { + render( +
+ ); + expect(within(rows()[0]!).getByText('Newer')).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 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 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(
); + 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/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/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..363ff324e --- /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; + +describe('SectionTabs', () => { + afterEach(cleanup); + + 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/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 ( + ]} + 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 ( + + + + + ); + }, + header: t({ + en: 'Repository Name', + es: 'Nombre del repositorio', + fr: 'Nom du dépôt' + }) + }, + { + accessorKey: 'url', + cell: (ctx) => ( + + ), + header: 'URL', + size: 210 + }, + { + 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', + size: 118 + }, + { + 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: 88 + }, + { + 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', + size: 112 + } + ]} + 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 }); + }} + /> +
+ )} + {section === 'ACTIVITY' && ( +
+
+ +
+ +
)} - onRowClick={(repo) => setHighlightedRowId(repo.id)} - onRowDoubleClick={(repo) => { - setHighlightedRowId(repo.id); - syncRepoMutation.mutate({ id: repo.id }); - }} - /> + + {isConfirmClearHistoryOpen && ( + + + + {t({ + en: 'Clear the activity log?', + es: '¿Borrar el registro de actividad?', + fr: "Effacer le journal d'activité ?" + })} + + + {t({ + 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." + })} + + + + + + + + )} + {repoToShowLogs && ( + + + + {t({ + 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." + })} + + +
+ +
+
+ )} {isDialogOpen && ( @@ -319,10 +569,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) => { + return instrument.details.title.toLowerCase().includes(searchTerm); + }); if (instrumentInfoQuery.isLoading) { return (
@@ -333,11 +608,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.' + })}

); } @@ -430,7 +711,13 @@ 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 Promise.all([ + context.queryClient.ensureQueryData(instrumentReposQueryOptions()), + context.queryClient.ensureQueryData(groupsQueryOptions()), + context.queryClient.ensureQueryData( + instrumentRepoSyncLogsQueryOptions({ params: { page: INITIAL_PAGE, sortOrder: INITIAL_SORT_ORDER } }) + ), + context.queryClient.ensureQueryData(latestInstrumentRepoSyncLogsQueryOptions()) + ]); } }); 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/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/packages/schemas/src/instrument-repo/instrument-repo.ts b/packages/schemas/src/instrument-repo/instrument-repo.ts index 59171d34a..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'; @@ -23,3 +24,53 @@ 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() +}); + +/** 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: $NumberLike + .pipe(z.number().int().positive().max(INSTRUMENT_REPO_SYNC_LOGS_MAX_PAGE_SIZE)) + .default(INSTRUMENT_REPO_SYNC_LOGS_PAGE_SIZE), + 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') +}); + +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/pnpm-lock.yaml b/pnpm-lock.yaml index feec7bb22..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 @@ -382,7 +382,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.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 @@ -476,7 +476,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.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 @@ -558,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(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 @@ -682,7 +682,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.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) @@ -982,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(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 @@ -1177,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(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 @@ -2254,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 @@ -4202,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: @@ -11184,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'} @@ -11993,17 +11970,21 @@ 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-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) @@ -12016,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) @@ -12039,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' @@ -12050,17 +12031,17 @@ 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.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) @@ -12073,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) @@ -12096,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' @@ -13482,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) @@ -13572,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) @@ -13607,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 @@ -13659,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 @@ -13709,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 @@ -13733,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 @@ -13775,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) @@ -13841,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) @@ -13904,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 @@ -14027,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) @@ -14042,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) @@ -14063,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) @@ -14083,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) @@ -14185,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 @@ -14345,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) @@ -14442,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 @@ -14458,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 @@ -14475,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) @@ -14500,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 @@ -14512,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 @@ -14534,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 @@ -14549,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) @@ -21807,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 a419d2c4d..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' 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..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,18 +4,41 @@ 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, 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.dataTable = page.getByTestId('data-table'); + 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' }); 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' }); } + + 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(); + 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..610aefe41 100644 --- a/testing/src/specs/admin-instrument-repos.spec.ts +++ b/testing/src/specs/admin-instrument-repos.spec.ts @@ -86,3 +86,277 @@ test.describe('admin instrument repos refused import', () => { await expect(reposPage.$ref.getByTestId('error-page')).toBeHidden(); }); }); + +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. + 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: + "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, + repoId: repo.id, + repoName: repo.name, + updatedAt: '2026-10-28T00:00:00.000Z' + }; + + test.beforeEach(async ({ page }) => { + 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) => { + return route.request().method() === 'DELETE' + ? route.fulfill({ json: { count: 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 }) => { + 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 activity log', async ({ getPageModel }) => { + 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'); + 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 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)'); + // 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(); + + 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) => { + 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(); + 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 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) => { + return 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 + }) => { + const reposPage = await getPageModel('/admin/instrument-repos'); + 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("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) => { + return 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); + }); + + 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 reposPage.openActivityLog(); + 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'); + + await reposPage.selectRowAction(0, 'Logs'); + + const dialog = reposPage.$ref.getByRole('dialog'); + 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'); + }); + + 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 activity log.')).toBeVisible(); + expect(syncAllRequested).toBe(true); + }); + + test('should clear the activity log 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.openActivityLog(); + 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('Activity log cleared')).toBeVisible(); + }); +});