mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): rebuild custom fields as a roster with an editor sheet
Show each custom field on one row with its type, libraries and how many books are filled, and edit, create, archive and delete it in the settings sheet. Order Edit Metadata and card values by the order set on this page, and add usageByLibrary to the admin field list, an additive field.
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { CustomMetadataRepository } from './custom-metadata.repository';
|
||||
|
||||
describe('CustomMetadataRepository', () => {
|
||||
let repository: CustomMetadataRepository;
|
||||
const query = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
query.mockReset().mockResolvedValue({ rows: [] });
|
||||
const db = drizzle({ client: { query } as never, schema });
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [CustomMetadataRepository, { provide: DB, useValue: db }],
|
||||
}).compile();
|
||||
repository = module.get(CustomMetadataRepository);
|
||||
});
|
||||
|
||||
function sql(): string {
|
||||
const [{ text }] = query.mock.calls.at(-1) as [{ text: string }];
|
||||
return text;
|
||||
}
|
||||
|
||||
// The per-library display_order is the library's index in each field's own list, so ordering by it
|
||||
// ignored the order set on the settings page. Edit Metadata and iOS show values in the order served.
|
||||
it('orders a library’s fields by the order set on the settings page', async () => {
|
||||
await repository.findEnabledFieldsForLibrary(3);
|
||||
|
||||
expect(sql()).toContain('order by "custom_metadata_fields"."display_order" asc');
|
||||
expect(sql()).not.toContain('order by "custom_metadata_library_fields"."display_order"');
|
||||
expect(sql()).toMatch(/"custom_metadata_fields"\."display_order"[^,]*,\s*"custom_metadata_fields"\."archived_at"/);
|
||||
});
|
||||
|
||||
it('orders card values by the order set on the settings page', async () => {
|
||||
await repository.findCardValuesForBooks([5, 6]);
|
||||
|
||||
expect(sql()).toContain('order by "custom_metadata_fields"."display_order" asc');
|
||||
expect(sql()).not.toContain('"custom_metadata_library_fields"."display_order"');
|
||||
});
|
||||
|
||||
it('counts stored values per field and library in one grouped query', async () => {
|
||||
query.mockResolvedValueOnce({ rows: [[7, 1, 12]] });
|
||||
|
||||
await expect(repository.countValuesByFieldAndLibrary()).resolves.toEqual([{ fieldId: 7, libraryId: 1, count: 12 }]);
|
||||
|
||||
expect(query).toHaveBeenCalledOnce();
|
||||
expect(sql()).toContain('inner join "books" on "books"."id" = "book_custom_metadata_values"."book_id"');
|
||||
expect(sql()).toContain('group by "book_custom_metadata_values"."field_id", "books"."library_id"');
|
||||
expect(sql()).not.toContain('where');
|
||||
});
|
||||
|
||||
it('narrows the per-library count to one field when asked', async () => {
|
||||
await repository.countValuesByFieldAndLibrary(7);
|
||||
|
||||
const [, params] = query.mock.calls.at(-1) as [unknown, unknown[]];
|
||||
expect(sql()).toContain('where "book_custom_metadata_values"."field_id" = $1');
|
||||
expect(params).toEqual([7]);
|
||||
});
|
||||
});
|
||||
@@ -38,11 +38,16 @@ export class CustomMetadataRepository {
|
||||
.orderBy(asc(customMetadataLibraryFields.libraryId), asc(customMetadataLibraryFields.displayOrder), asc(customMetadataLibraryFields.fieldId));
|
||||
}
|
||||
|
||||
countValuesByField() {
|
||||
return this.db
|
||||
.select({ fieldId: bookCustomMetadataValues.fieldId, count: count() })
|
||||
/** Stored values per field and library, including libraries the field has since been turned off for. */
|
||||
countValuesByFieldAndLibrary(fieldId?: number) {
|
||||
const query = this.db
|
||||
.select({ fieldId: bookCustomMetadataValues.fieldId, libraryId: books.libraryId, count: count() })
|
||||
.from(bookCustomMetadataValues)
|
||||
.groupBy(bookCustomMetadataValues.fieldId);
|
||||
.innerJoin(books, eq(books.id, bookCustomMetadataValues.bookId));
|
||||
return (fieldId === undefined ? query : query.where(eq(bookCustomMetadataValues.fieldId, fieldId))).groupBy(
|
||||
bookCustomMetadataValues.fieldId,
|
||||
books.libraryId,
|
||||
);
|
||||
}
|
||||
|
||||
findActiveFieldTypes(fieldIds: number[]) {
|
||||
@@ -94,7 +99,7 @@ export class CustomMetadataRepository {
|
||||
key: customMetadataFields.key,
|
||||
label: customMetadataFields.label,
|
||||
type: customMetadataFields.type,
|
||||
displayOrder: customMetadataLibraryFields.displayOrder,
|
||||
displayOrder: customMetadataFields.displayOrder,
|
||||
archivedAt: customMetadataFields.archivedAt,
|
||||
createdAt: customMetadataFields.createdAt,
|
||||
updatedAt: customMetadataFields.updatedAt,
|
||||
@@ -102,7 +107,7 @@ export class CustomMetadataRepository {
|
||||
.from(customMetadataLibraryFields)
|
||||
.innerJoin(customMetadataFields, eq(customMetadataFields.id, customMetadataLibraryFields.fieldId))
|
||||
.where(and(eq(customMetadataLibraryFields.libraryId, libraryId), isNull(customMetadataFields.archivedAt)))
|
||||
.orderBy(asc(customMetadataLibraryFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
|
||||
.orderBy(asc(customMetadataFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
|
||||
}
|
||||
|
||||
findValuesForBook(bookId: number) {
|
||||
@@ -145,7 +150,7 @@ export class CustomMetadataRepository {
|
||||
key: customMetadataFields.key,
|
||||
label: customMetadataFields.label,
|
||||
type: customMetadataFields.type,
|
||||
displayOrder: customMetadataLibraryFields.displayOrder,
|
||||
displayOrder: customMetadataFields.displayOrder,
|
||||
valueText: bookCustomMetadataValues.valueText,
|
||||
valueNumber: bookCustomMetadataValues.valueNumber,
|
||||
valueDate: bookCustomMetadataValues.valueDate,
|
||||
@@ -159,7 +164,7 @@ export class CustomMetadataRepository {
|
||||
and(eq(bookCustomMetadataValues.bookId, books.id), eq(bookCustomMetadataValues.fieldId, customMetadataFields.id)),
|
||||
)
|
||||
.where(inArray(books.id, bookIds))
|
||||
.orderBy(asc(customMetadataLibraryFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
|
||||
.orderBy(asc(customMetadataFields.displayOrder), asc(customMetadataFields.label), asc(customMetadataFields.id));
|
||||
}
|
||||
|
||||
async findBookLibraryId(bookId: number) {
|
||||
|
||||
@@ -13,7 +13,7 @@ function makeRepository() {
|
||||
repository: {
|
||||
listFields: vi.fn(),
|
||||
listEnablements: vi.fn(),
|
||||
countValuesByField: vi.fn().mockResolvedValue([]),
|
||||
countValuesByFieldAndLibrary: vi.fn().mockResolvedValue([]),
|
||||
findFieldById: vi.fn(),
|
||||
findFieldByKey: vi.fn(),
|
||||
createField: vi.fn(),
|
||||
@@ -129,19 +129,46 @@ describe('CustomMetadataService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes usage counts when listing fields', async () => {
|
||||
it('includes usage counts per library when listing fields', async () => {
|
||||
repo.listFields.mockResolvedValue([field({ id: 7 }), field({ id: 8, key: 'mood', label: 'Mood', type: 'text' })]);
|
||||
repo.listEnablements.mockResolvedValue([{ fieldId: 7, libraryId: 2, displayOrder: 0 }]);
|
||||
repo.countValuesByField.mockResolvedValue([{ fieldId: 7, count: 12 }]);
|
||||
repo.countValuesByFieldAndLibrary.mockResolvedValue([
|
||||
{ fieldId: 7, libraryId: 2, count: 12 },
|
||||
{ fieldId: 7, libraryId: 4, count: 3 },
|
||||
]);
|
||||
|
||||
const result = await service.listFields(false);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: 7, enabledLibraryIds: [2], usageCount: 12 }),
|
||||
expect.objectContaining({ id: 8, enabledLibraryIds: [], usageCount: 0 }),
|
||||
expect.objectContaining({ id: 7, enabledLibraryIds: [2], usageCount: 15, usageByLibrary: { 2: 12, 4: 3 } }),
|
||||
expect.objectContaining({ id: 8, enabledLibraryIds: [], usageCount: 0, usageByLibrary: {} }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a new field with no usage', async () => {
|
||||
repo.findFieldByKey.mockResolvedValue(null);
|
||||
repo.findLibrariesByIds.mockResolvedValue([{ id: 2 }]);
|
||||
repo.createField.mockResolvedValue([field({ id: 9, key: 'shelf', label: 'Shelf', type: 'number' })]);
|
||||
|
||||
await expect(service.createField({ label: 'Shelf', type: 'number', enabledLibraryIds: [2] })).resolves.toEqual(
|
||||
expect.objectContaining({ id: 9, enabledLibraryIds: [2], usageCount: 0, usageByLibrary: {} }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the edited field’s usage per library after an update, counting only that field', async () => {
|
||||
repo.findFieldById.mockResolvedValue(field({ id: 7 }));
|
||||
repo.findLibrariesByIds.mockResolvedValue([{ id: 2 }]);
|
||||
repo.countValuesByFieldAndLibrary.mockResolvedValue([
|
||||
{ fieldId: 7, libraryId: 2, count: 5 },
|
||||
{ fieldId: 7, libraryId: 6, count: 1 },
|
||||
]);
|
||||
|
||||
const result = await service.updateField(7, { enabledLibraryIds: [2] });
|
||||
|
||||
expect(repo.countValuesByFieldAndLibrary).toHaveBeenCalledWith(7);
|
||||
expect(result).toEqual(expect.objectContaining({ enabledLibraryIds: [2], usageCount: 6, usageByLibrary: { 2: 5, 6: 1 } }));
|
||||
});
|
||||
|
||||
it('reorders fields by setting display order to the requested index', async () => {
|
||||
repo.listFields.mockResolvedValueOnce([field({ id: 7 }), field({ id: 8 }), field({ id: 9 })]);
|
||||
repo.listFields.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -26,10 +26,10 @@ export class CustomMetadataService {
|
||||
constructor(private readonly repository: CustomMetadataRepository) {}
|
||||
|
||||
async listFields(includeArchived = false): Promise<CustomMetadataFieldDefinition[]> {
|
||||
const [fields, enablements, usageCounts] = await Promise.all([
|
||||
const [fields, enablements, usageRows] = await Promise.all([
|
||||
this.repository.listFields(includeArchived),
|
||||
this.repository.listEnablements(),
|
||||
this.repository.countValuesByField(),
|
||||
this.repository.countValuesByFieldAndLibrary(),
|
||||
]);
|
||||
const enabledByFieldId = new Map<number, number[]>();
|
||||
for (const enablement of enablements) {
|
||||
@@ -37,8 +37,8 @@ export class CustomMetadataService {
|
||||
current.push(enablement.libraryId);
|
||||
enabledByFieldId.set(enablement.fieldId, current);
|
||||
}
|
||||
const usageByFieldId = new Map(usageCounts.map((row) => [row.fieldId, row.count]));
|
||||
return fields.map((field) => this.toDefinition(field, enabledByFieldId.get(field.id) ?? [], usageByFieldId.get(field.id) ?? 0));
|
||||
const usageByFieldId = groupUsage(usageRows);
|
||||
return fields.map((field) => this.toDefinition(field, enabledByFieldId.get(field.id) ?? [], usageByFieldId.get(field.id) ?? {}));
|
||||
}
|
||||
|
||||
async listFieldSummaries(): Promise<CustomMetadataFieldSummary[]> {
|
||||
@@ -82,7 +82,7 @@ export class CustomMetadataService {
|
||||
tx,
|
||||
);
|
||||
await this.repository.replaceEnabledLibraries(field.id, libraryIds, tx);
|
||||
return this.toDefinition(field, libraryIds, 0);
|
||||
return this.toDefinition(field, libraryIds, {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export class CustomMetadataService {
|
||||
await this.repository.replaceEnabledLibraries(fieldId, libraryIds, tx);
|
||||
}
|
||||
const enabledLibraryIds = libraryIds ?? (await this.enabledLibraryIdsForField(fieldId));
|
||||
return this.toDefinition(field, enabledLibraryIds, await this.usageCountForField(fieldId));
|
||||
return this.toDefinition(field, enabledLibraryIds, await this.usageForField(fieldId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,9 +228,9 @@ export class CustomMetadataService {
|
||||
return enablements.filter((enablement) => enablement.fieldId === fieldId).map((enablement) => enablement.libraryId);
|
||||
}
|
||||
|
||||
private async usageCountForField(fieldId: number): Promise<number> {
|
||||
const counts = await this.repository.countValuesByField();
|
||||
return counts.find((row) => row.fieldId === fieldId)?.count ?? 0;
|
||||
private async usageForField(fieldId: number): Promise<Record<number, number>> {
|
||||
const rows = await this.repository.countValuesByFieldAndLibrary(fieldId);
|
||||
return groupUsage(rows).get(fieldId) ?? {};
|
||||
}
|
||||
|
||||
private async normalizeLibraryIds(libraryIds: number[]): Promise<number[]> {
|
||||
@@ -253,7 +253,11 @@ export class CustomMetadataService {
|
||||
throw new BadRequestException('Unable to generate a unique custom metadata key');
|
||||
}
|
||||
|
||||
private toDefinition(field: CustomMetadataField, enabledLibraryIds: number[], usageCount: number): CustomMetadataFieldDefinition {
|
||||
private toDefinition(
|
||||
field: CustomMetadataField,
|
||||
enabledLibraryIds: number[],
|
||||
usageByLibrary: Record<number, number>,
|
||||
): CustomMetadataFieldDefinition {
|
||||
return {
|
||||
id: field.id,
|
||||
key: field.key,
|
||||
@@ -264,11 +268,22 @@ export class CustomMetadataService {
|
||||
createdAt: field.createdAt.toISOString(),
|
||||
updatedAt: field.updatedAt.toISOString(),
|
||||
enabledLibraryIds,
|
||||
usageCount,
|
||||
usageCount: Object.values(usageByLibrary).reduce((total, value) => total + value, 0),
|
||||
usageByLibrary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function groupUsage(rows: Array<{ fieldId: number; libraryId: number; count: number }>): Map<number, Record<number, number>> {
|
||||
const byField = new Map<number, Record<number, number>>();
|
||||
for (const row of rows) {
|
||||
const current = byField.get(row.fieldId) ?? {};
|
||||
current[row.libraryId] = row.count;
|
||||
byField.set(row.fieldId, current);
|
||||
}
|
||||
return byField;
|
||||
}
|
||||
|
||||
function normalizeLabel(label: string): string {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) throw new BadRequestException('Custom metadata field label is required');
|
||||
|
||||
Reference in New Issue
Block a user