<template>
<div class="ui-form">
<div v-if="submitted" class="ui-form-submitted">
<div v-if="data.title" class="ui-form-title">{{ data.title }}</div>
<div class="ui-form-submitted-note">
<i class="ph ph-check-circle"></i>
Submitted
</div>
<dl class="ui-form-summary">
<div
v-for="field in visibleFields"
:key="field.name"
class="ui-form-summary-row"
>
<dt>{{ field.label }}</dt>
<dd>{{ formatValue(values[field.name], field) }}</dd>
</div>
</dl>
</div>
<div v-else class="ui-form-body">
<div v-if="data.title" class="ui-form-title">{{ data.title }}</div>
<div v-if="data.description" class="ui-form-description">{{ data.description }}</div>
<form class="ui-form-fields" @submit.prevent="handleSubmit">
<div
v-for="field in visibleFields"
:key="field.name"
class="ui-form-field"
:class="{ 'has-error': showError(field.name) }"
>
<label class="ui-form-label" :for="inputId(field.name)">
{{ field.label }}
<span v-if="field.required" class="ui-form-required">*</span>
</label>
<div v-if="field.description" class="ui-form-field-hint">{{ field.description }}</div>
<textarea
v-if="field.type === 'textarea'"
:id="inputId(field.name)"
v-model="values[field.name]"
class="ui-form-input"
:placeholder="field.placeholder || ''"
:required="field.required"
:minlength="field.minLength"
:maxlength="field.maxLength"
rows="3"
@blur="touch(field.name)"
@input="touch(field.name)"
/>
<select
v-else-if="field.type === 'select'"
:id="inputId(field.name)"
v-model="values[field.name]"
class="ui-form-input"
:required="field.required"
@blur="touch(field.name)"
@change="touch(field.name)"
>
<option v-if="!field.required" value="">—</option>
<option
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<select
v-else-if="field.type === 'multiselect'"
:id="inputId(field.name)"
v-model="values[field.name]"
class="ui-form-input"
multiple
:required="field.required"
@blur="touch(field.name)"
@change="touch(field.name)"
>
<option
v-for="opt in field.options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<label v-else-if="field.type === 'checkbox'" class="ui-form-checkbox">
<input
:id="inputId(field.name)"
v-model="values[field.name]"
type="checkbox"
@change="touch(field.name)"
>
<span>{{ field.label }}</span>
</label>
<input
v-else
:id="inputId(field.name)"
v-model="values[field.name]"
class="ui-form-input"
:type="htmlInputType(field.type)"
:placeholder="field.placeholder || ''"
:required="field.required"
:min="field.min"
:max="field.max"
:minlength="field.minLength"
:maxlength="field.maxLength"
:pattern="field.pattern"
@blur="touch(field.name)"
@input="touch(field.name)"
>
<div v-if="showError(field.name)" class="ui-form-error">
{{ errors[field.name] }}
</div>
</div>
<button
type="submit"
class="ui-form-submit"
:disabled="!valid || submitting || submitted"
>
<span v-if="submitting" class="ui-form-spinner" />
<span>{{ data.submit_label || 'Submit' }}</span>
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted, inject } from 'vue'
import { useChatStore } from '@/stores/chat.js'
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const chat = useChatStore()
const wsSend = inject('wsSend')
const values = reactive({})
const touched = ref(new Set())
const errors = reactive({})
const submitting = ref(false)
const submitted = ref(false)
const formId = computed(() => props.data?.form_id || '')
const visibleFields = computed(() => Array.isArray(props.data?.fields) ? props.data.fields : [])
const storageKey = computed(() => {
const sessionId = chat.currentId || 'unknown'
return `navi_submitted_forms:${sessionId}`
})
function inputId(name) {
return `form-${formId.value}-${name}`
}
function htmlInputType(type) {
if (type === 'email' || type === 'url' || type === 'date' || type === 'number') return type
return 'text'
}
function formatValue(value, field) {
if (value === undefined || value === null || value === '') return '—'
if (field.type === 'checkbox') return value ? 'Yes' : 'No'
if (field.type === 'select' || field.type === 'multiselect') {
const opts = Array.isArray(field.options) ? field.options : []
if (Array.isArray(value)) {
const labels = value.map(v => opts.find(o => o.value === v)?.label || v)
return labels.join(', ') || '—'
}
return opts.find(o => o.value === value)?.label || value
}
if (Array.isArray(value)) return value.join(', ')
return String(value)
}
function isEmpty(value) {
return value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)
}
function validateField(field) {
const name = field.name
const value = values[name]
delete errors[name]
if (field.required && isEmpty(value)) {
errors[name] = `${field.label} is required`
return
}
if (isEmpty(value)) return
if (field.type === 'email') {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!re.test(String(value))) errors[name] = 'Enter a valid email address'
}
if (field.type === 'url') {
try {
const u = new URL(value)
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad protocol')
} catch {
errors[name] = 'Enter a valid http:// or https:// URL'
}
}
if (field.type === 'number') {
const num = Number(value)
if (Number.isNaN(num)) {
errors[name] = 'Must be a number'
} else {
if (field.min !== undefined && field.min !== null && num < field.min) {
errors[name] = `Minimum value is ${field.min}`
}
if (field.max !== undefined && field.max !== null && num > field.max) {
errors[name] = `Maximum value is ${field.max}`
}
}
}
if (field.minLength !== undefined && field.minLength !== null) {
if (String(value).length < field.minLength) {
errors[name] = `Minimum length is ${field.minLength} characters`
}
}
if (field.maxLength !== undefined && field.maxLength !== null) {
if (String(value).length > field.maxLength) {
errors[name] = `Maximum length is ${field.maxLength} characters`
}
}
if (field.pattern) {
try {
const re = new RegExp(field.pattern)
if (!re.test(String(value))) errors[name] = 'Invalid format'
} catch {
// Ignore invalid regex patterns from the backend.
}
}
}
function touch(name) {
touched.value.add(name)
const field = visibleFields.value.find(f => f.name === name)
if (field) validateField(field)
}
function validateAll() {
for (const field of visibleFields.value) {
validateField(field)
}
}
const valid = computed(() => {
validateAll()
return visibleFields.value.every(f => !errors[f.name])
})
function showError(name) {
return touched.value.has(name) && errors[name]
}
function readSubmittedState() {
if (!formId.value) return
try {
const list = JSON.parse(localStorage.getItem(storageKey.value) || '[]')
if (list.includes(formId.value)) {
submitted.value = true
// Restore values from localStorage summary if present.
const summary = JSON.parse(localStorage.getItem(`${storageKey.value}:${formId.value}`) || '{}')
Object.assign(values, summary)
}
} catch {
// Ignore localStorage errors.
}
}
function markSubmitted() {
if (!formId.value) return
try {
const key = storageKey.value
const list = JSON.parse(localStorage.getItem(key) || '[]')
if (!list.includes(formId.value)) {
list.push(formId.value)
localStorage.setItem(key, JSON.stringify(list))
}
localStorage.setItem(`${key}:${formId.value}`, JSON.stringify({ ...values }))
} catch {
// Ignore localStorage errors.
}
}
function buildSubmitPayload() {
const result = {}
for (const field of visibleFields.value) {
const value = values[field.name]
if (field.type === 'number') {
result[field.name] = value === '' || value === undefined ? null : Number(value)
} else if (field.type === 'checkbox') {
result[field.name] = Boolean(value)
} else {
result[field.name] = value === undefined ? null : value
}
}
return result
}
function handleSubmit() {
if (submitting.value || submitted.value || !valid.value) return
submitting.value = true
validateAll()
for (const field of visibleFields.value) {
touched.value.add(field.name)
}
if (!valid.value) {
submitting.value = false
return
}
const payload = {
type: 'form_submit',
form_id: formId.value,
values: buildSubmitPayload(),
}
if (typeof wsSend === 'function') {
wsSend(payload)
} else {
console.error('[Form] wsSend is not available')
}
markSubmitted()
submitted.value = true
submitting.value = false
}
onMounted(() => {
// Initialise defaults.
for (const field of visibleFields.value) {
if (field.type === 'checkbox') {
values[field.name] = Boolean(field.default)
} else if (field.type === 'multiselect') {
values[field.name] = Array.isArray(field.default) ? [...field.default] : []
} else {
values[field.name] = field.default ?? (field.type === 'select' && field.required ? '' : '')
}
}
readSubmittedState()
})
</script>
<style scoped>
.ui-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.ui-form-title {
font-size: 1rem;
font-weight: 600;
color: var(--text, #cdd6f4);
}
.ui-form-description {
font-size: 0.85rem;
color: var(--text-muted, #6c7086);
line-height: 1.4;
}
.ui-form-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.ui-form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ui-form-field.has-error .ui-form-input {
border-color: var(--color-error, #f38ba8);
}
.ui-form-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text, #cdd6f4);
}
.ui-form-required {
color: var(--color-error, #f38ba8);
margin-left: 2px;
}
.ui-form-field-hint {
font-size: 0.75rem;
color: var(--text-muted, #6c7086);
}
.ui-form-input {
padding: 10px 12px;
background: var(--surface, #1e1e2e);
border: 1px solid var(--border, #2a2a3e);
color: var(--text, #cdd6f4);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s;
}
.ui-form-input:focus {
border-color: var(--accent, #4ec9b0);
}
.ui-form-input::placeholder {
color: var(--text-muted, #6c7086);
}
select.ui-form-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236c7086' viewBox='0 0 256 256'%3E%3Cpath d='M213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80A8 8 0 0 1 48 88h160a8 8 0 0 1 5.66 13.66z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 32px;
}
select.ui-form-input[multiple] {
background-image: none;
padding-right: 12px;
min-height: 100px;
}
.ui-form-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--text, #cdd6f4);
cursor: pointer;
}
.ui-form-checkbox input {
width: 18px;
height: 18px;
accent-color: var(--accent, #4ec9b0);
cursor: pointer;
}
.ui-form-error {
font-size: 0.8rem;
color: var(--color-error, #f38ba8);
}
.ui-form-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
align-self: flex-start;
padding: 10px 18px;
background: var(--accent, #4ec9b0);
color: var(--surface, #1e1e2e);
border: none;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.ui-form-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-form-submit:not(:disabled):hover {
opacity: 0.9;
}
.ui-form-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: ui-form-spin 0.8s linear infinite;
}
@keyframes ui-form-spin {
to { transform: rotate(360deg); }
}
.ui-form-submitted {
display: flex;
flex-direction: column;
gap: 10px;
}
.ui-form-submitted-note {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--accent, #4ec9b0);
}
.ui-form-summary {
margin: 0;
display: grid;
gap: 8px;
}
.ui-form-summary-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border, #2a2a3e);
}
.ui-form-summary-row:last-child {
border-bottom: none;
}
.ui-form-summary-row dt {
color: var(--text-muted, #6c7086);
font-size: 0.85rem;
}
.ui-form-summary-row dd {
margin: 0;
color: var(--text, #cdd6f4);
font-size: 0.85rem;
text-align: right;
word-break: break-word;
}
</style>