From 7ad25aeb2a13ef0e47eea2c60568d9822b2f6247 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Mon, 4 May 2026 12:28:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(#251):=20API=20client=20typ=C3=A9=20create?= =?UTF-8?q?/update/delete=20+=20StoreForm=20+=20Create/Edit=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend store API client : - Types StoreInfo (avec slug + isDefault + embedder), StoreDetail, StoreCreatePayload, StoreUpdatePayload - Fonctions createStore (POST), updateStore (PATCH), deleteStore (DELETE), fetchStore (GET /:slug) - Tests Vitest étendus pour les 4 nouveaux endpoints Composants formulaire (features/store/ui/) : - StoreForm : champs communs (name/slug/kind/embedder/isDefault) avec validation slug locale, lock du slug pour 'default' - StoreConfigForm : dispatcher dynamique par kind (extensible) - OpenSearchConfigForm : champ indexName, validation requise Pages CRUD : - StoreCreatePage (/index/new) : formulaire + redirect liste - StoreEditPage (/index/:store/edit) : préremplit via fetchStore, redirige vers la fiche Routes + i18n : - Routes ROUTES.STORE_CREATE et ROUTES.STORE_EDIT ajoutées (names + routes.ts) - Clés i18n FR/EN sous stores.* et storeForm.* --- frontend/src/app/router/routes.ts | 11 + frontend/src/features/store/api.test.ts | 84 +++++- frontend/src/features/store/api.ts | 54 ++++ .../store/ui/OpenSearchConfigForm.vue | 92 ++++++ .../src/features/store/ui/StoreConfigForm.vue | 44 +++ frontend/src/features/store/ui/StoreForm.vue | 261 ++++++++++++++++++ frontend/src/pages/StoreCreatePage.vue | 59 ++++ frontend/src/pages/StoreEditPage.vue | 118 ++++++++ frontend/src/shared/i18n.ts | 60 ++++ frontend/src/shared/routing/names.ts | 2 + 10 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/store/ui/OpenSearchConfigForm.vue create mode 100644 frontend/src/features/store/ui/StoreConfigForm.vue create mode 100644 frontend/src/features/store/ui/StoreForm.vue create mode 100644 frontend/src/pages/StoreCreatePage.vue create mode 100644 frontend/src/pages/StoreEditPage.vue diff --git a/frontend/src/app/router/routes.ts b/frontend/src/app/router/routes.ts index dfe2da2..acae60a 100644 --- a/frontend/src/app/router/routes.ts +++ b/frontend/src/app/router/routes.ts @@ -90,12 +90,23 @@ export const routes: RouteRecordRaw[] = [ name: ROUTES.STORES_LIST, component: () => import('../../pages/StoresListPage.vue'), }, + { + path: '/index/new', + name: ROUTES.STORE_CREATE, + component: () => import('../../pages/StoreCreatePage.vue'), + }, { path: '/index/:store', name: ROUTES.STORE_DETAIL, component: () => import('../../pages/StoreDetailPage.vue'), props: true, }, + { + path: '/index/:store/edit', + name: ROUTES.STORE_EDIT, + component: () => import('../../pages/StoreEditPage.vue'), + props: true, + }, { path: '/index/:store/query', name: ROUTES.STORE_QUERY, diff --git a/frontend/src/features/store/api.test.ts b/frontend/src/features/store/api.test.ts index 72b84f7..1d73045 100644 --- a/frontend/src/features/store/api.test.ts +++ b/frontend/src/features/store/api.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { fetchStores, fetchStoreDocuments, removeDocumentFromStore, queryStore } from './api' +import { + createStore, + deleteStore, + fetchStore, + fetchStores, + fetchStoreDocuments, + queryStore, + removeDocumentFromStore, + updateStore, +} from './api' vi.mock('../../shared/api/http', () => ({ apiFetch: vi.fn(), @@ -13,8 +22,19 @@ describe('store API', () => { }) it('fetchStores calls GET /api/stores', async () => { - const stores = [{ name: 'my-store', type: 'opensearch', connected: true }] - apiFetch.mockResolvedValue(stores) + const stores = [ + { + name: 'my-store', + slug: 'my-store', + type: 'opensearch', + embedder: 'bge-m3', + isDefault: true, + connected: true, + documentCount: 0, + chunkCount: 0, + }, + ] + ;(apiFetch as ReturnType).mockResolvedValue(stores) const result = await fetchStores() @@ -22,9 +42,61 @@ describe('store API', () => { expect(result).toEqual(stores) }) + it('fetchStore calls GET /api/stores/:slug', async () => { + const store = { id: 's-1', slug: 'rh', name: 'rh', kind: 'opensearch' } + ;(apiFetch as ReturnType).mockResolvedValue(store) + + const result = await fetchStore('rh') + + expect(apiFetch).toHaveBeenCalledWith('/api/stores/rh') + expect(result).toEqual(store) + }) + + it('createStore POSTs the payload', async () => { + ;(apiFetch as ReturnType).mockResolvedValue({ slug: 'rh' }) + + await createStore({ + name: 'rh', + slug: 'rh', + kind: 'opensearch', + embedder: 'bge-m3', + config: { indexName: 'rh' }, + }) + + expect(apiFetch).toHaveBeenCalledWith('/api/stores', { + method: 'POST', + body: JSON.stringify({ + name: 'rh', + slug: 'rh', + kind: 'opensearch', + embedder: 'bge-m3', + config: { indexName: 'rh' }, + }), + }) + }) + + it('updateStore PATCHes the payload', async () => { + ;(apiFetch as ReturnType).mockResolvedValue({ slug: 'rh' }) + + await updateStore('rh', { embedder: 'bge-large' }) + + expect(apiFetch).toHaveBeenCalledWith('/api/stores/rh', { + method: 'PATCH', + body: JSON.stringify({ embedder: 'bge-large' }), + }) + }) + + it('deleteStore calls DELETE /api/stores/:slug', async () => { + ;(apiFetch as ReturnType).mockResolvedValue(undefined) + + await deleteStore('rh') + + expect(apiFetch).toHaveBeenCalledWith('/api/stores/rh', { method: 'DELETE' }) + }) + it('fetchStoreDocuments calls GET /api/stores/:store/documents', async () => { const docs = [{ docId: 'doc-1', filename: 'test.pdf', state: 'Ingested', chunkCount: 12 }] - apiFetch.mockResolvedValue(docs) + ;(apiFetch as ReturnType).mockResolvedValue(docs) const result = await fetchStoreDocuments('my-store') @@ -33,7 +105,7 @@ describe('store API', () => { }) it('removeDocumentFromStore calls DELETE /api/stores/:store/documents/:docId', async () => { - apiFetch.mockResolvedValue(undefined) + ;(apiFetch as ReturnType).mockResolvedValue(undefined) await removeDocumentFromStore('my-store', 'doc-1') @@ -44,7 +116,7 @@ describe('store API', () => { it('queryStore calls POST /api/stores/:store/query with body', async () => { const results = [{ chunkId: 'c1', docId: 'd1', filename: 'a.pdf', text: 'hi', score: 0.9 }] - apiFetch.mockResolvedValue(results) + ;(apiFetch as ReturnType).mockResolvedValue(results) const result = await queryStore('my-store', 'what is X?', 3) diff --git a/frontend/src/features/store/api.ts b/frontend/src/features/store/api.ts index ff21ce5..6f2c122 100644 --- a/frontend/src/features/store/api.ts +++ b/frontend/src/features/store/api.ts @@ -3,13 +3,45 @@ import type { DocumentLifecycleState } from '../../shared/types' export interface StoreInfo { name: string + slug: string type: string + embedder: string + isDefault: boolean connected: boolean documentCount: number chunkCount: number errorMessage?: string } +export interface StoreDetail { + id: string + name: string + slug: string + kind: string + embedder: string + isDefault: boolean + config: Record + createdAt: string +} + +export interface StoreCreatePayload { + name: string + slug: string + kind: string + embedder: string + config: Record + isDefault?: boolean +} + +export interface StoreUpdatePayload { + name?: string + slug?: string + kind?: string + embedder?: string + config?: Record + isDefault?: boolean +} + export interface StoreDocEntry { docId: string filename: string @@ -31,6 +63,28 @@ export function fetchStores(): Promise { return apiFetch('/api/stores') } +export function fetchStore(slug: string): Promise { + return apiFetch(`/api/stores/${encodeURIComponent(slug)}`) +} + +export function createStore(payload: StoreCreatePayload): Promise { + return apiFetch('/api/stores', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function updateStore(slug: string, payload: StoreUpdatePayload): Promise { + return apiFetch(`/api/stores/${encodeURIComponent(slug)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export function deleteStore(slug: string): Promise { + return apiFetch(`/api/stores/${encodeURIComponent(slug)}`, { method: 'DELETE' }) +} + export function fetchStoreDocuments(store: string): Promise { return apiFetch(`/api/stores/${encodeURIComponent(store)}/documents`) } diff --git a/frontend/src/features/store/ui/OpenSearchConfigForm.vue b/frontend/src/features/store/ui/OpenSearchConfigForm.vue new file mode 100644 index 0000000..cae0eee --- /dev/null +++ b/frontend/src/features/store/ui/OpenSearchConfigForm.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/frontend/src/features/store/ui/StoreConfigForm.vue b/frontend/src/features/store/ui/StoreConfigForm.vue new file mode 100644 index 0000000..2db8a72 --- /dev/null +++ b/frontend/src/features/store/ui/StoreConfigForm.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/features/store/ui/StoreForm.vue b/frontend/src/features/store/ui/StoreForm.vue new file mode 100644 index 0000000..f55a4fc --- /dev/null +++ b/frontend/src/features/store/ui/StoreForm.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/frontend/src/pages/StoreCreatePage.vue b/frontend/src/pages/StoreCreatePage.vue new file mode 100644 index 0000000..c498917 --- /dev/null +++ b/frontend/src/pages/StoreCreatePage.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/frontend/src/pages/StoreEditPage.vue b/frontend/src/pages/StoreEditPage.vue new file mode 100644 index 0000000..e5f846a --- /dev/null +++ b/frontend/src/pages/StoreEditPage.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index c38aba3..6ecb41f 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -448,6 +448,37 @@ const messages: Messages = { 'stores.connected': 'Connect\u00e9', 'stores.disconnected': 'D\u00e9connect\u00e9', 'stores.error': 'Erreur', + 'stores.new': 'Nouveau store', + 'stores.edit': 'Modifier', + 'stores.delete': 'Supprimer', + 'stores.deleteConfirm': 'Supprimer le store \u00ab\u00a0{name}\u00a0\u00bb\u00a0?', + 'stores.deleteDefaultBlocked': 'Le store par d\u00e9faut ne peut pas \u00eatre supprim\u00e9.', + 'stores.colDefault': 'D\u00e9faut', + 'stores.default.yes': 'Oui', + 'stores.default.no': 'Non', + + // Store form (#251) + 'storeForm.titleCreate': 'Cr\u00e9er un store', + 'storeForm.titleEdit': 'Modifier le store', + 'storeForm.fieldName': 'Nom', + 'storeForm.fieldNameHelp': 'Nom affich\u00e9 dans l\u2019UI.', + 'storeForm.fieldSlug': 'Slug', + 'storeForm.fieldSlugHelp': 'Identifiant URL : minuscules, chiffres, tirets.', + 'storeForm.fieldKind': 'Type', + 'storeForm.fieldEmbedder': 'Embedder', + 'storeForm.fieldEmbedderHelp': 'Identifiant du mod\u00e8le d\u2019embedding.', + 'storeForm.fieldIsDefault': 'Store par d\u00e9faut', + 'storeForm.fieldIsDefaultHelp': 'Un seul store peut \u00eatre par d\u00e9faut.', + 'storeForm.sectionConfig': 'Configuration', + 'storeForm.opensearch.indexName': 'Nom de l\u2019index', + 'storeForm.opensearch.indexNameHelp': + 'Index OpenSearch cible (cr\u00e9\u00e9 \u00e0 la premi\u00e8re ingestion).', + 'storeForm.cancel': 'Annuler', + 'storeForm.save': 'Enregistrer', + 'storeForm.create': 'Cr\u00e9er', + 'storeForm.required': 'Champ requis.', + 'storeForm.invalidSlug': + 'Slug invalide : minuscules, chiffres, tirets uniquement (ex. \u00ab\u00a0rh-corpus-v3\u00a0\u00bb).', // Store detail (#244) 'storeDetail.back': 'Stores', @@ -907,6 +938,35 @@ const messages: Messages = { 'stores.connected': 'Connected', 'stores.disconnected': 'Disconnected', 'stores.error': 'Error', + 'stores.new': 'New store', + 'stores.edit': 'Edit', + 'stores.delete': 'Delete', + 'stores.deleteConfirm': 'Delete store "{name}"?', + 'stores.deleteDefaultBlocked': 'The default store cannot be deleted.', + 'stores.colDefault': 'Default', + 'stores.default.yes': 'Yes', + 'stores.default.no': 'No', + + // Store form (#251) + 'storeForm.titleCreate': 'Create store', + 'storeForm.titleEdit': 'Edit store', + 'storeForm.fieldName': 'Name', + 'storeForm.fieldNameHelp': 'Display name shown in the UI.', + 'storeForm.fieldSlug': 'Slug', + 'storeForm.fieldSlugHelp': 'URL identifier: lowercase, digits, dashes.', + 'storeForm.fieldKind': 'Kind', + 'storeForm.fieldEmbedder': 'Embedder', + 'storeForm.fieldEmbedderHelp': 'Embedding model identifier.', + 'storeForm.fieldIsDefault': 'Default store', + 'storeForm.fieldIsDefaultHelp': 'Only one store can be the default.', + 'storeForm.sectionConfig': 'Configuration', + 'storeForm.opensearch.indexName': 'Index name', + 'storeForm.opensearch.indexNameHelp': 'Target OpenSearch index (created on first ingestion).', + 'storeForm.cancel': 'Cancel', + 'storeForm.save': 'Save', + 'storeForm.create': 'Create', + 'storeForm.required': 'Required field.', + 'storeForm.invalidSlug': 'Invalid slug: lowercase, digits, dashes only (e.g. "rh-corpus-v3").', // Store detail (#244) 'storeDetail.back': 'Stores', diff --git a/frontend/src/shared/routing/names.ts b/frontend/src/shared/routing/names.ts index 4c2e89b..533c815 100644 --- a/frontend/src/shared/routing/names.ts +++ b/frontend/src/shared/routing/names.ts @@ -26,7 +26,9 @@ export const ROUTES = { DOCS_NEW: 'docs-new', DOC_WORKSPACE: 'doc-workspace', STORES_LIST: 'stores-list', + STORE_CREATE: 'store-create', STORE_DETAIL: 'store-detail', + STORE_EDIT: 'store-edit', STORE_QUERY: 'store-query', RUNS: 'runs', RUN_DETAIL: 'run-detail',