docling-studio/frontend/src/features/store/ui/StoreForm.vue
Pier-Jean Malandrino 3fe0d3daa5 feat(#251): add Neo4j as a StoreKind
- domain : StoreKind.NEO4J ajouté à l'enum
- service : validation config par kind étendue (Neo4j requires non-empty index_name)
- frontend : Neo4jConfigForm (index_name + database optionnelle), dispatcher StoreConfigForm, dropdown kind, i18n FR/EN
- tests service : 2 cas Neo4j (create OK + missing index_name 422)

Auth (URI/user/password) reste pilotée par les variables d'environnement
globales (cf. infra/neo4j.py) — la config par store ne porte que les
paramètres routables (index, database).
2026-05-04 13:37:44 +02:00

264 lines
6.8 KiB
Vue

<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' },
{ value: 'neo4j', label: 'Neo4j' },
]
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>