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:
Pier-Jean Malandrino 2026-05-04 12:28:27 +02:00
parent b722be7f61
commit 93be01db5f
10 changed files with 779 additions and 6 deletions

View file

@ -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,

View file

@ -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)

View file

@ -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`)
}

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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',

View file

@ -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',