mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(shared-stores): add createTagLinkOps factory for junction tables
Reusable factory for tag-entity junction operations (addTag, removeTag, setTags, getTagIds, hasTag) used by all modules. Also loosen ViewStore filter constraint from Record<string, unknown> to object for interface compatibility. Includes 14 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eabd9200a3
commit
48aac82bcb
4 changed files with 273 additions and 3 deletions
|
|
@ -44,3 +44,4 @@ export {
|
|||
type LocalTag,
|
||||
type LocalTagGroup,
|
||||
} from './tags-local.svelte';
|
||||
export { createTagLinkOps, type TagLinkOps, type TagLinkOpsConfig } from './tag-links';
|
||||
|
|
|
|||
145
packages/shared-stores/src/tag-links.test.ts
Normal file
145
packages/shared-stores/src/tag-links.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createTagLinkOps } from './tag-links';
|
||||
|
||||
// Mock Dexie table
|
||||
function createMockTable() {
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
_records: () => records,
|
||||
_reset: () => {
|
||||
records = [];
|
||||
},
|
||||
where: vi.fn((field: string) => ({
|
||||
equals: vi.fn((value: unknown) => ({
|
||||
toArray: vi.fn(async () => records.filter((r) => r[field] === value)),
|
||||
})),
|
||||
})),
|
||||
add: vi.fn(async (record: Record<string, unknown>) => {
|
||||
records.push(record);
|
||||
return record.id;
|
||||
}),
|
||||
update: vi.fn(async (id: string, changes: Record<string, unknown>) => {
|
||||
const idx = records.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) records[idx] = { ...records[idx], ...changes };
|
||||
return 1;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('createTagLinkOps', () => {
|
||||
let mockTable: ReturnType<typeof createMockTable>;
|
||||
let ops: ReturnType<typeof createTagLinkOps>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockTable = createMockTable();
|
||||
ops = createTagLinkOps({
|
||||
table: () => mockTable as never,
|
||||
entityIdField: 'memoId',
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTag', () => {
|
||||
it('adds a tag link to an entity', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
const records = mockTable._records();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0].memoId).toBe('memo-1');
|
||||
expect(records[0].tagId).toBe('tag-1');
|
||||
});
|
||||
|
||||
it('does not add duplicate link', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
const records = mockTable._records();
|
||||
expect(records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows different tags on same entity', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-2');
|
||||
const records = mockTable._records();
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTagIds', () => {
|
||||
it('returns tag IDs for an entity', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-2');
|
||||
const ids = await ops.getTagIds('memo-1');
|
||||
expect(ids).toEqual(['tag-1', 'tag-2']);
|
||||
});
|
||||
|
||||
it('returns empty array for entity with no tags', async () => {
|
||||
const ids = await ops.getTagIds('memo-999');
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted links', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-2');
|
||||
await ops.removeTag('memo-1', 'tag-1');
|
||||
const ids = await ops.getTagIds('memo-1');
|
||||
expect(ids).toEqual(['tag-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTag', () => {
|
||||
it('soft-deletes a tag link', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.removeTag('memo-1', 'tag-1');
|
||||
const records = mockTable._records();
|
||||
expect(records[0].deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does nothing if link does not exist', async () => {
|
||||
await ops.removeTag('memo-1', 'tag-999');
|
||||
// No error, no records
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTags', () => {
|
||||
it('replaces all tags for an entity', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-2');
|
||||
await ops.setTags('memo-1', ['tag-3', 'tag-4']);
|
||||
const ids = await ops.getTagIds('memo-1');
|
||||
expect(ids).toEqual(['tag-3', 'tag-4']);
|
||||
});
|
||||
|
||||
it('keeps existing tags that are in new set', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.addTag('memo-1', 'tag-2');
|
||||
await ops.setTags('memo-1', ['tag-2', 'tag-3']);
|
||||
const ids = await ops.getTagIds('memo-1');
|
||||
expect(ids).toContain('tag-2');
|
||||
expect(ids).toContain('tag-3');
|
||||
expect(ids).not.toContain('tag-1');
|
||||
});
|
||||
|
||||
it('handles empty array (removes all tags)', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.setTags('memo-1', []);
|
||||
const ids = await ops.getTagIds('memo-1');
|
||||
expect(ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTag', () => {
|
||||
it('returns true if entity has the tag', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
expect(await ops.hasTag('memo-1', 'tag-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if entity does not have the tag', async () => {
|
||||
expect(await ops.hasTag('memo-1', 'tag-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false after tag is removed', async () => {
|
||||
await ops.addTag('memo-1', 'tag-1');
|
||||
await ops.removeTag('memo-1', 'tag-1');
|
||||
expect(await ops.hasTag('memo-1', 'tag-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
124
packages/shared-stores/src/tag-links.ts
Normal file
124
packages/shared-stores/src/tag-links.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Tag Link Factory — Reusable junction table operations for modules.
|
||||
*
|
||||
* Each module has its own junction table (memoTags, fileTags, imageTags, etc.)
|
||||
* linking entities to global tags. This factory provides standard CRUD for those junctions.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createTagLinkOps } from '@manacore/shared-stores';
|
||||
* import { db } from '$lib/data/database';
|
||||
*
|
||||
* export const memoTagOps = createTagLinkOps({
|
||||
* table: () => db.table('memoTags'),
|
||||
* entityIdField: 'memoId',
|
||||
* });
|
||||
*
|
||||
* // Usage:
|
||||
* await memoTagOps.addTag('memo-123', 'tag-456');
|
||||
* await memoTagOps.setTags('memo-123', ['tag-1', 'tag-2']);
|
||||
* const tagIds = await memoTagOps.getTagIds('memo-123');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { Table } from 'dexie';
|
||||
|
||||
interface BaseTagLink {
|
||||
id: string;
|
||||
tagId: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TagLinkOpsConfig {
|
||||
/** Dexie table accessor (lazy to avoid import-order issues) */
|
||||
table: () => Table<BaseTagLink, string>;
|
||||
/** Entity ID field name on the junction record (e.g. 'memoId', 'fileId') */
|
||||
entityIdField: string;
|
||||
}
|
||||
|
||||
export interface TagLinkOps {
|
||||
/** Get all tag IDs linked to an entity */
|
||||
getTagIds(entityId: string): Promise<string[]>;
|
||||
/** Add a tag to an entity (no-op if already linked) */
|
||||
addTag(entityId: string, tagId: string): Promise<void>;
|
||||
/** Remove a tag from an entity (soft-delete) */
|
||||
removeTag(entityId: string, tagId: string): Promise<void>;
|
||||
/** Replace all tags for an entity */
|
||||
setTags(entityId: string, tagIds: string[]): Promise<void>;
|
||||
/** Check if entity has a specific tag */
|
||||
hasTag(entityId: string, tagId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createTagLinkOps(config: TagLinkOpsConfig): TagLinkOps {
|
||||
const { entityIdField } = config;
|
||||
|
||||
async function getActive(entityId: string): Promise<BaseTagLink[]> {
|
||||
const all = await config.table().where(entityIdField).equals(entityId).toArray();
|
||||
return all.filter((r) => !r.deletedAt);
|
||||
}
|
||||
|
||||
return {
|
||||
async getTagIds(entityId: string): Promise<string[]> {
|
||||
const links = await getActive(entityId);
|
||||
return links.map((l) => l.tagId);
|
||||
},
|
||||
|
||||
async addTag(entityId: string, tagId: string): Promise<void> {
|
||||
const existing = await getActive(entityId);
|
||||
if (existing.some((l) => l.tagId === tagId)) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await config.table().add({
|
||||
id: crypto.randomUUID(),
|
||||
[entityIdField]: entityId,
|
||||
tagId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as BaseTagLink);
|
||||
},
|
||||
|
||||
async removeTag(entityId: string, tagId: string): Promise<void> {
|
||||
const links = await getActive(entityId);
|
||||
const link = links.find((l) => l.tagId === tagId);
|
||||
if (!link) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await config.table().update(link.id, { deletedAt: now, updatedAt: now });
|
||||
},
|
||||
|
||||
async setTags(entityId: string, tagIds: string[]): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const existing = await getActive(entityId);
|
||||
const existingTagIds = new Set(existing.map((l) => l.tagId));
|
||||
const desiredTagIds = new Set(tagIds);
|
||||
|
||||
// Remove tags no longer desired
|
||||
for (const link of existing) {
|
||||
if (!desiredTagIds.has(link.tagId)) {
|
||||
await config.table().update(link.id, { deletedAt: now, updatedAt: now });
|
||||
}
|
||||
}
|
||||
|
||||
// Add new tags
|
||||
for (const tagId of tagIds) {
|
||||
if (!existingTagIds.has(tagId)) {
|
||||
await config.table().add({
|
||||
id: crypto.randomUUID(),
|
||||
[entityIdField]: entityId,
|
||||
tagId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as BaseTagLink);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async hasTag(entityId: string, tagId: string): Promise<boolean> {
|
||||
const links = await getActive(entityId);
|
||||
return links.some((l) => l.tagId === tagId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export interface SavedFilter<F> {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ViewStoreConfig<V extends string, F extends Record<string, unknown>> {
|
||||
export interface ViewStoreConfig<V extends string, F extends object> {
|
||||
/** Prefix for localStorage keys (e.g. 'inventar' → 'inventar_view_mode') */
|
||||
storagePrefix: string;
|
||||
/** Default view mode */
|
||||
|
|
@ -50,7 +50,7 @@ export interface ViewStoreConfig<V extends string, F extends Record<string, unkn
|
|||
hasActiveFilters?: (filters: F) => boolean;
|
||||
}
|
||||
|
||||
export interface ViewStore<V extends string, F extends Record<string, unknown>> {
|
||||
export interface ViewStore<V extends string, F extends object> {
|
||||
readonly viewMode: V;
|
||||
readonly sort: SortOption;
|
||||
readonly activeFilters: F;
|
||||
|
|
@ -87,7 +87,7 @@ function save(key: string, value: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
export function createViewStore<V extends string, F extends Record<string, unknown>>(
|
||||
export function createViewStore<V extends string, F extends object>(
|
||||
config: ViewStoreConfig<V, F>
|
||||
): ViewStore<V, F> {
|
||||
const VIEW_KEY = `${config.storagePrefix}_view_mode`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue