Newer
Older
navi-1 / webclient / src / components / ui / Form.vue
<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>