clqms-shadcn-v1/src/lib/components/reusable/form/dictionary-form-renderer.svelte

487 lines
23 KiB
Svelte

<script>
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import { Toggle } from "$lib/components/ui/toggle/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Spinner } from "$lib/components/ui/spinner/index.js";
import ReusableCalendar from "$lib/components/reusable/reusable-calendar.svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
import * as InputGroup from "$lib/components/ui/input-group/index.js";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Textarea } from "$lib/components/ui/textarea/index.js";
let {
formState,
formFields,
mode = 'create',
disabledResultTypes = [],
disabledReferenceTypes = [],
disabledSign = false,
joinFields = $bindable(),
hiddenFields,
handleTestTypeChange,
handleResultTypeChange,
handleRefTypeChange,
} = $props();
let searchQuery = $state({});
let dropdownOpen = $state({});
function getFilteredOptions(key) {
const query = searchQuery[key] || "";
if (!query) return formState.selectOptions?.[key] ?? [];
return (formState.selectOptions?.[key] ?? []).filter(opt =>
opt.label.toLowerCase().includes(query.toLowerCase())
);
}
$effect(() => {
initializeDefaultValues();
});
async function initializeDefaultValues() {
for (const group of formFields) {
for (const row of group.rows) {
for (const col of row.columns) {
if (col.type === "group") {
for (const child of col.columns) {
await handleDefaultValue(child);
}
} else {
await handleDefaultValue(col);
}
}
}
}
}
async function handleDefaultValue(field) {
if (!field.defaultValue || !field.optionsEndpoint) return;
await formState.fetchOptions(field, formState.form);
if (!formState.form[field.key]) {
formState.form[field.key] = field.defaultValue;
}
}
// function hasExactKeyword(input, keyword) {
// const regex = new RegExp(`\\b${keyword}\\b`, 'i');
// return regex.test(input);
// }
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey, txtKey })}
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
<Label>{label}</Label>
{#if required}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if}
<!-- {#if required || dynamicRequiredFields.includes(key)}
<span class="text-destructive text-xl leading-none h-3.5">*</span>
{/if} -->
</div>
<div class="relative flex flex-col items-center w-full">
{#if type === "text"}
<Input
type="text"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
validateFieldAsync(key, mode, originalData?.[key]);
}
}}
readonly={key === "NumRefType" || key === "TxtRefType" || key === "Level"}
/>
{:else if type === "email"}
<Input
type="email"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
/>
{:else if type === "number"}
<Input
type="number"
bind:value={formState.form[key]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
onkeydown={(e) => ['e', 'E', '+', '-'].includes(e.key) && e.preventDefault()}
/>
{:else if type === "textarea"}
<Textarea
class="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
}}
onblur={() => {
if (validateOn?.includes("blur")) {
formState.validateField(key, formState.form[key], false);
}
}}
bind:value={formState.form[key]}
/>
{:else if type === "select"}
{@const selectedLabel = formState.selectOptions?.[key]?.find(opt => opt.value === formState.form[key])?.label || "Choose"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root type="single" bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField?.(key, formState.form[key], false);
}
if (key === "TestType") {
handleTestTypeChange(val);
}
if (key === "Province") {
formState.form.City = "";
if (formState.selectOptions) {
formState.selectOptions.City = [];
}
if (formState.lastFetched) {
formState.lastFetched.City = null;
}
}
if (key === "ResultType") {
handleResultTypeChange(val);
}
if (key === "RefType") {
handleRefTypeChange(val);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full truncate">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}
disabled={key === "ResultType" && disabledResultTypes.includes(option.value) ||
key === "RefType" && disabledReferenceTypes.includes(option.value)}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
<!-- {:else if type === "selectmultiple"}
{@const filteredOptions = getFilteredOptions(key)}
{@const selectedValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
{@const selectedOptions = selectedValues
.map(val => formState.selectOptions?.[key]?.find(opt => opt.value === val))
.filter(Boolean)}
<Select.Root type="multiple" bind:value={formState.form[key]}
onValueChange={(vals) => {
formState.form[key] = vals;
if (validateOn?.includes("input")) {
formState.validateField?.(key, formState.form[key], false);
}
if (key === 'FormulaInput') {
formState.validateField?.('FormulaCode', formState.form['FormulaCode'], false);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full min-h-[42px]">
{#if selectedOptions.length === 0}
<span class="text-muted-foreground">Choose</span>
{:else}
<div class="flex flex-wrap gap-1">
{#each selectedOptions as option}
<Badge variant="secondary" class="text-xs">
{option.label}
</Badge>
{/each}
</div>
{/if}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}
disabled={key === "ResultType" && disabledResultTypes.includes(option.value) ||
key === "RefType" && disabledReferenceTypes.includes(option.value)}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
{#if selectedValues.length > 0}
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => formState.form[key] = []}
>
Unselect All
</button>
{/if}
</Select.Root> -->
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
parentFunction={(dateStr) => {
formState.form[key] = dateStr;
if (validateOn?.includes("input")) {
formState.validateField(key, dateStr, false);
}
}}
/>
{:else if type === "signvalue"}
<InputGroup.Root>
<InputGroup.Input
placeholder="Type here"
bind:value={formState.form[txtKey]}
/>
<InputGroup.Addon align="inline-start">
<DropdownMenu.Root
onOpenChange={(open) => {
dropdownOpen[key] = open;
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, valueKey, labelKey },
formState.form
);
}
}}
>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<InputGroup.Button
{...props}
variant="ghost" disabled={disabledSign}
>
{formState.selectOptions?.[key]?.find(
opt => opt.value === formState.form[key]
)?.label || 'Choose'}
<ChevronDownIcon />
</InputGroup.Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
{#if formState.loadingOptions?.[key]}
<DropdownMenu.Item disabled>
Loading...
</DropdownMenu.Item>
{:else}
{#each formState.selectOptions?.[key] || [] as option}
<DropdownMenu.Item
onclick={() => {
formState.form[key] = option.value;
dropdownOpen[key] = false;
}}
>
{option.label}
</DropdownMenu.Item>
{/each}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</InputGroup.Addon>
</InputGroup.Root>
{:else if type === "agejoin"}
<div class="flex items-center gap-2 w-full">
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].YY}/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Year</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].MM}/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Month</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].DD}/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Day</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
</div>
{:else}
<Input
type="text"
bind:value={formState.form[key]}
placeholder="Custom field type: {type}"
/>
{/if}
<div
class={`absolute min-h-[1rem] w-full ${
key === 'FormulaCode' ? 'top-20' : 'top-8'
}`}
>
<!-- {#if formState.errors[key] || dynamicRequiredFields.includes(key)}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
{/if} -->
{#if formState.errors[key]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
</span>
<!-- {:else if dynamicRequiredFields.includes(key)}
<span class="text-destructive text-sm leading-none">
Required
</span> -->
{/if}
</div>
</div>
</div>
{/snippet}
<div class="p-2 space-y-6">
{#each formFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
{@const visibleColumns = row.columns.filter(col => !hiddenFields?.includes(col.key))}
{#if visibleColumns.length > 0}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={visibleColumns.length === 1 && visibleColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleColumns.length === 2 || (visibleColumns.length === 1 && visibleColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleColumns.length === 3}
>
{#each visibleColumns as col}
{#if col.type === "group"}
{@const visibleChildColumns = col.columns.filter(child => !hiddenFields?.includes(child.key))}
{#if visibleChildColumns.length > 0}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={visibleChildColumns.length === 1 && visibleChildColumns[0].fullWidth !== false}
class:md:grid-cols-2={visibleChildColumns.length === 2 || (visibleChildColumns.length === 1 && visibleChildColumns[0].fullWidth === false)}
class:md:grid-cols-3={visibleChildColumns.length === 3}
>
{#each visibleChildColumns as child}
{@render Fieldset(child)}
{/each}
</div>
{/if}
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/each}
</div>
<!-- <div class="p-2 space-y-6">
{#each formFields as group}
<div class="space-y-6">
{#if group.title}
<div class="text-md 2xl:text-lg font-semibold italic">
<span class="border-b-2 border-primary">{group.title}</span>
</div>
{/if}
{#each group.rows as row}
<div
class="grid grid-cols-1 space-y-2 gap-6 md:gap-4"
class:md:grid-cols-1={row.columns.length === 1 && row.columns[0].fullWidth !== false}
class:md:grid-cols-2={row.columns.length === 2 || (row.columns.length === 1 && row.columns[0].fullWidth === false)}
class:md:grid-cols-3={row.columns.length === 3}
>
{#each row.columns as col}
{#if col.type === "group"}
<div
class="grid grid-cols-1 gap-6 md:gap-2"
class:md:grid-cols-1={col.columns.length === 1 && col.columns[0].fullWidth !== false}
class:md:grid-cols-2={col.columns.length === 2 || (col.columns.length === 1 && col.columns[0].fullWidth === false)}
class:md:grid-cols-3={col.columns.length === 3}
>
{#each col.columns as child}
{@render Fieldset(child)}
{/each}
</div>
{:else}
{@render Fieldset(col)}
{/if}
{/each}
</div>
{/each}
</div>
{/each}
</div> -->