feat(#251): API client typé create/update/delete + StoreForm + Create/Edit pages
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.*
This commit is contained in:
parent
b722be7f61
commit
93be01db5f
10 changed files with 779 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(results)
|
||||
|
||||
const result = await queryStore('my-store', 'what is X?', 3)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface StoreCreatePayload {
|
||||
name: string
|
||||
slug: string
|
||||
kind: string
|
||||
embedder: string
|
||||
config: Record<string, unknown>
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export interface StoreUpdatePayload {
|
||||
name?: string
|
||||
slug?: string
|
||||
kind?: string
|
||||
embedder?: string
|
||||
config?: Record<string, unknown>
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export interface StoreDocEntry {
|
||||
docId: string
|
||||
filename: string
|
||||
|
|
@ -31,6 +63,28 @@ export function fetchStores(): Promise<StoreInfo[]> {
|
|||
return apiFetch<StoreInfo[]>('/api/stores')
|
||||
}
|
||||
|
||||
export function fetchStore(slug: string): Promise<StoreDetail> {
|
||||
return apiFetch<StoreDetail>(`/api/stores/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function createStore(payload: StoreCreatePayload): Promise<StoreDetail> {
|
||||
return apiFetch<StoreDetail>('/api/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateStore(slug: string, payload: StoreUpdatePayload): Promise<StoreDetail> {
|
||||
return apiFetch<StoreDetail>(`/api/stores/${encodeURIComponent(slug)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteStore(slug: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/stores/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function fetchStoreDocuments(store: string): Promise<StoreDocEntry[]> {
|
||||
return apiFetch<StoreDocEntry[]>(`/api/stores/${encodeURIComponent(store)}/documents`)
|
||||
}
|
||||
|
|
|
|||
92
frontend/src/features/store/ui/OpenSearchConfigForm.vue
Normal file
92
frontend/src/features/store/ui/OpenSearchConfigForm.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div class="config-form">
|
||||
<div class="field">
|
||||
<label class="field-label" for="store-os-index">{{
|
||||
t('storeForm.opensearch.indexName')
|
||||
}}</label>
|
||||
<input
|
||||
id="store-os-index"
|
||||
v-model="indexName"
|
||||
class="field-input"
|
||||
type="text"
|
||||
:placeholder="'rh-corpus-v3'"
|
||||
:aria-invalid="showError && !indexName.trim()"
|
||||
@input="emitChange"
|
||||
/>
|
||||
<p v-if="showError && !indexName.trim()" class="field-error">
|
||||
{{ t('storeForm.required') }}
|
||||
</p>
|
||||
<p v-else class="field-help">{{ t('storeForm.opensearch.indexNameHelp') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Record<string, unknown>
|
||||
showError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Record<string, unknown>): void
|
||||
(e: 'valid', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const indexName = ref(String(props.modelValue.indexName ?? props.modelValue.index_name ?? ''))
|
||||
|
||||
function emitChange() {
|
||||
emit('update:modelValue', { indexName: indexName.value })
|
||||
emit('valid', indexName.value.trim().length > 0)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(next) => {
|
||||
const incoming = String(next.indexName ?? next.index_name ?? '')
|
||||
if (incoming !== indexName.value) {
|
||||
indexName.value = incoming
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
emit('valid', indexName.value.trim().length > 0)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field-input[aria-invalid='true'] {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.field-help {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
.field-error {
|
||||
font-size: 0.75rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/features/store/ui/StoreConfigForm.vue
Normal file
44
frontend/src/features/store/ui/StoreConfigForm.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
v-model="config"
|
||||
:show-error="showError"
|
||||
@valid="(v: boolean) => emit('valid', v)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import OpenSearchConfigForm from './OpenSearchConfigForm.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: string
|
||||
modelValue: Record<string, unknown>
|
||||
showError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Record<string, unknown>): void
|
||||
(e: 'valid', value: boolean): void
|
||||
}>()
|
||||
|
||||
const config = ref({ ...props.modelValue })
|
||||
|
||||
watch(config, (next) => emit('update:modelValue', next), { deep: true })
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(next) => {
|
||||
config.value = { ...next }
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const component = computed(() => {
|
||||
switch (props.kind) {
|
||||
case 'opensearch':
|
||||
return OpenSearchConfigForm
|
||||
default:
|
||||
return OpenSearchConfigForm
|
||||
}
|
||||
})
|
||||
</script>
|
||||
261
frontend/src/features/store/ui/StoreForm.vue
Normal file
261
frontend/src/features/store/ui/StoreForm.vue
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
<template>
|
||||
<form class="store-form" @submit.prevent="onSubmit">
|
||||
<div class="field">
|
||||
<label class="field-label" for="store-name">{{ t('storeForm.fieldName') }}</label>
|
||||
<input
|
||||
id="store-name"
|
||||
v-model="form.name"
|
||||
class="field-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:aria-invalid="showError && !form.name.trim()"
|
||||
/>
|
||||
<p v-if="showError && !form.name.trim()" class="field-error">
|
||||
{{ t('storeForm.required') }}
|
||||
</p>
|
||||
<p v-else class="field-help">{{ t('storeForm.fieldNameHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="store-slug">{{ t('storeForm.fieldSlug') }}</label>
|
||||
<input
|
||||
id="store-slug"
|
||||
v-model="form.slug"
|
||||
class="field-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="lockSlug"
|
||||
:aria-invalid="showError && !slugIsValid"
|
||||
/>
|
||||
<p v-if="showError && !form.slug.trim()" class="field-error">
|
||||
{{ t('storeForm.required') }}
|
||||
</p>
|
||||
<p v-else-if="showError && !slugIsValid" class="field-error">
|
||||
{{ t('storeForm.invalidSlug') }}
|
||||
</p>
|
||||
<p v-else class="field-help">{{ t('storeForm.fieldSlugHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="store-kind">{{ t('storeForm.fieldKind') }}</label>
|
||||
<select id="store-kind" v-model="form.kind" class="field-input">
|
||||
<option v-for="opt in kindOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="store-embedder">{{ t('storeForm.fieldEmbedder') }}</label>
|
||||
<input
|
||||
id="store-embedder"
|
||||
v-model="form.embedder"
|
||||
class="field-input"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="'bge-m3'"
|
||||
:aria-invalid="showError && !form.embedder.trim()"
|
||||
/>
|
||||
<p v-if="showError && !form.embedder.trim()" class="field-error">
|
||||
{{ t('storeForm.required') }}
|
||||
</p>
|
||||
<p v-else class="field-help">{{ t('storeForm.fieldEmbedderHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--checkbox">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
<span>{{ t('storeForm.fieldIsDefault') }}</span>
|
||||
</label>
|
||||
<p class="field-help">{{ t('storeForm.fieldIsDefaultHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="config-section">
|
||||
<legend>{{ t('storeForm.sectionConfig') }}</legend>
|
||||
<StoreConfigForm
|
||||
v-model="form.config"
|
||||
:kind="form.kind"
|
||||
:show-error="showError"
|
||||
@valid="onConfigValid"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<p v-if="errorMessage" class="form-error" role="alert">{{ errorMessage }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" :disabled="submitting" @click="emit('cancel')">
|
||||
{{ t('storeForm.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import type { StoreCreatePayload, StoreDetail } from '../api'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import StoreConfigForm from './StoreConfigForm.vue'
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
||||
|
||||
const props = defineProps<{
|
||||
initialValue?: Partial<StoreDetail>
|
||||
mode: 'create' | 'edit'
|
||||
submitting?: boolean
|
||||
errorMessage?: string | null
|
||||
/** When true, the slug input is disabled (typically in edit mode for the seeded default). */
|
||||
lockSlug?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', payload: StoreCreatePayload): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const kindOptions = [{ value: 'opensearch', label: 'OpenSearch' }]
|
||||
|
||||
const form = reactive<StoreCreatePayload>({
|
||||
name: props.initialValue?.name ?? '',
|
||||
slug: props.initialValue?.slug ?? '',
|
||||
kind: props.initialValue?.kind ?? 'opensearch',
|
||||
embedder: props.initialValue?.embedder ?? '',
|
||||
config: { ...(props.initialValue?.config ?? {}) },
|
||||
isDefault: props.initialValue?.isDefault ?? false,
|
||||
})
|
||||
|
||||
const showError = ref(false)
|
||||
const configValid = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.initialValue,
|
||||
(next) => {
|
||||
if (!next) return
|
||||
form.name = next.name ?? form.name
|
||||
form.slug = next.slug ?? form.slug
|
||||
form.kind = next.kind ?? form.kind
|
||||
form.embedder = next.embedder ?? form.embedder
|
||||
form.config = { ...(next.config ?? {}) }
|
||||
form.isDefault = next.isDefault ?? form.isDefault
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const slugIsValid = computed(() => SLUG_PATTERN.test(form.slug.trim()))
|
||||
|
||||
const submitLabel = computed(() =>
|
||||
props.mode === 'create' ? t('storeForm.create') : t('storeForm.save'),
|
||||
)
|
||||
|
||||
function onConfigValid(valid: boolean) {
|
||||
configValid.value = valid
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
showError.value = true
|
||||
if (!form.name.trim() || !form.embedder.trim() || !slugIsValid.value || !configValid.value) {
|
||||
return
|
||||
}
|
||||
emit('submit', { ...form, slug: form.slug.trim().toLowerCase() })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 640px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.field--checkbox {
|
||||
flex-direction: column;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.field-input[aria-invalid='true'] {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.field-input:disabled {
|
||||
background: var(--color-bg-muted, #f3f4f6);
|
||||
}
|
||||
.field-help {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
.field-error {
|
||||
font-size: 0.75rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.config-section {
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.config-section legend {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
.form-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: var(--color-text, #111827);
|
||||
border: 1px solid var(--color-border, #d1d5db);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
59
frontend/src/pages/StoreCreatePage.vue
Normal file
59
frontend/src/pages/StoreCreatePage.vue
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<template>
|
||||
<div class="store-edit-page">
|
||||
<header class="header">
|
||||
<h1 class="title">{{ t('storeForm.titleCreate') }}</h1>
|
||||
</header>
|
||||
<StoreForm
|
||||
mode="create"
|
||||
:submitting="submitting"
|
||||
:error-message="errorMessage"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StoreForm from '../features/store/ui/StoreForm.vue'
|
||||
import { createStore, type StoreCreatePayload } from '../features/store/api'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
async function onSubmit(payload: StoreCreatePayload) {
|
||||
submitting.value = true
|
||||
errorMessage.value = null
|
||||
try {
|
||||
await createStore(payload)
|
||||
router.push({ name: ROUTES.STORES_LIST })
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.push({ name: ROUTES.STORES_LIST })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-edit-page {
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
118
frontend/src/pages/StoreEditPage.vue
Normal file
118
frontend/src/pages/StoreEditPage.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<div class="store-edit-page">
|
||||
<header class="header">
|
||||
<h1 class="title">{{ t('storeForm.titleEdit') }}</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="error-state">
|
||||
<p class="error-text">{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<StoreForm
|
||||
v-else-if="initial"
|
||||
mode="edit"
|
||||
:initial-value="initial"
|
||||
:submitting="submitting"
|
||||
:error-message="errorMessage"
|
||||
:lock-slug="initial.slug === 'default'"
|
||||
@submit="onSubmit"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StoreForm from '../features/store/ui/StoreForm.vue'
|
||||
import {
|
||||
fetchStore,
|
||||
updateStore,
|
||||
type StoreCreatePayload,
|
||||
type StoreDetail,
|
||||
} from '../features/store/api'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
const props = defineProps<{ store: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const initial = ref<StoreDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
initial.value = await fetchStore(props.store)
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function onSubmit(payload: StoreCreatePayload) {
|
||||
submitting.value = true
|
||||
errorMessage.value = null
|
||||
try {
|
||||
const updated = await updateStore(props.store, payload)
|
||||
if (updated.slug !== props.store) {
|
||||
router.push({ name: ROUTES.STORE_DETAIL, params: { store: updated.slug } })
|
||||
} else {
|
||||
router.push({ name: ROUTES.STORE_DETAIL, params: { store: updated.slug } })
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
router.push({ name: ROUTES.STORES_LIST })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.store-edit-page {
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
.loading-state,
|
||||
.error-state {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.error-text {
|
||||
color: #dc2626;
|
||||
}
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue