initial version syntax builder test calc

This commit is contained in:
faiztyanirh 2026-03-03 16:53:03 +07:00
parent 8529c91058
commit d3099248b2
7 changed files with 735 additions and 98 deletions

View File

@ -13,11 +13,22 @@ const optionsMode = {
const data = json?.data ?? [];
const valueKey = field.valueKey ?? 'value';
const labelKey = field.labelKey ?? 'label';
selectOptions[field.key] = data.map((item) => ({
value: item[valueKey],
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
}));
selectOptions[field.key] = data.map((item) => {
const baseOption = {
value: item[valueKey],
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
};
if (field.key === 'FormulaInput') {
return {
...baseOption,
level: item.SeqRpt,
};
}
return baseOption;
});
} catch (err) {
console.error("Failed to fetch options for", field.key, err);

View File

@ -54,7 +54,7 @@ export function useFormValidation(schema, form, defaultErrors, valMode) {
const valFn = validationMode[valMode];
const error = valFn(schema, field, form, originalValue);
errors[field] = error;
}
}
function resetErrors() {
Object.assign(errors, defaultErrors);

View File

@ -22,26 +22,32 @@ export const testSchema = z.object({
message: "Required",
path: ["Unit2"],
});
}
}
});
export const testCalSchema = z.object({
FormulaInput: z.array(z.string()),
FormulaCode: z.string()
}).refine(
(data) => {
if (data.FormulaInput.length === 0) return true;
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
return data.FormulaInput.every((v) => hasExactKeyword(data.FormulaCode, v));
},
{
message: 'Formula must contain all selected input parameters',
path: ['FormulaCode']
FormulaInput: z.array(z.string()).min(1, "Required"),
FormulaCode: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.FormulaInput.length === 0) return;
const hasExactKeyword = (input, keyword) => {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
};
const invalid = data.FormulaInput.some(
(v) => !hasExactKeyword(data.FormulaCode, v)
);
if (invalid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Formula must contain all selected input parameters:${data.FormulaInput.join(',')}`,
path: ['FormulaCode']
});
}
);
});
export const refNumSchema = z.object({
AgeStart: z.string().optional(),
@ -245,7 +251,10 @@ export const testDefaultErrors = {
Decimal: null,
};
export const testCalDefaultErrors = {};
export const testCalDefaultErrors = {
FormulaInput: "Required",
FormulaCode: null,
};
export const refNumDefaultErrors = {
AgeStart: null,
@ -547,11 +556,12 @@ export const testCalFormFields = [
{
key: "FormulaInput",
label: "Input Parameter",
required: false,
required: true,
type: "selectmultiple",
optionsEndpoint: `${API.BASE_URL}${API.TEST}`,
valueKey: "TestSiteID",
valueKey: "TestSiteCode",
labelKey: (item) => `${item.TestSiteCode} - ${item.TestSiteName}`,
validateOn: ["input"]
},
]
},
@ -560,10 +570,9 @@ export const testCalFormFields = [
columns: [
{
key: "FormulaCode",
label: "Formula",
label: "Formula Code",
required: false,
type: "textarea",
validateOn: ["input"]
type: "formulabuilder",
}
]
},
@ -678,11 +687,6 @@ export const refNumFormFields = [
},
]
},
]
},
{
title: "Flag & Interpretation",
rows: [
{
type: "row",
columns: [
@ -713,6 +717,39 @@ export const refNumFormFields = [
},
]
},
// {
// title: "Flag & Interpretation",
// rows: [
// {
// type: "row",
// columns: [
// {
// key: "Flag",
// label: "Flag",
// required: false,
// type: "text",
// },
// {
// key: "Interpretation",
// label: "Interpretation",
// required: false,
// type: "text",
// },
// ]
// },
// {
// type: "row",
// columns: [
// {
// key: "Notes",
// label: "Notes",
// required: false,
// type: "textarea",
// },
// ]
// },
// ]
// },
];
export const refTxtFormFields = [

View File

@ -31,8 +31,9 @@
const { formState } = masterDetail;
const calFormState = useForm({
schema: null,
schema: testCalSchema,
initialForm: testCalInitialForm,
defaultErrors: testCalDefaultErrors,
});
const refNumState = useForm({
@ -268,6 +269,19 @@
}));
});
const activeFormState = $derived.by(() => {
switch (formState.form.TestType) {
case "TEST":
return formState;
case "PARAM":
return formState;
case "CALC":
return calFormState;
default:
return null;
}
});
// $inspect(activeFormState.errors)
let activeTab = $state('definition');
$effect(() => {
@ -412,7 +426,7 @@
/>
</Tabs.Content>
<Tabs.Content value="calculation">
<Calculation {calFormState}/>
<Calculation {calFormState} {testCalFormFields} />
</Tabs.Content>
<Tabs.Content value="group">
group

View File

@ -6,76 +6,80 @@
import { API } from '$lib/config/api';
import { Textarea } from "$lib/components/ui/textarea/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import DictionaryFormRenderer from '$lib/components/reusable/form/dictionary-form-renderer.svelte';
let props = $props();
const formState = props.calFormState;
// const formState = props.calFormState;
let options = $state([]);
let isLoading = $state(false);
// let options = $state([]);
// let isLoading = $state(false);
// let errors = $state({});
let errors = $state({});
// function hasExactKeyword(input, keyword) {
// const regex = new RegExp(`\\b${keyword}\\b`, 'i');
// return regex.test(input);
// }
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
// // 🔹 FETCH OPTIONS
// async function fetchTests() {
// isLoading = true;
// try {
// const res = await fetch(`${API.BASE_URL}${API.TEST}`);
// const data = await res.json();
// console.log(data);
// 🔹 FETCH OPTIONS
async function fetchTests() {
isLoading = true;
try {
const res = await fetch(`${API.BASE_URL}${API.TEST}`);
const data = await res.json();
// options = data.data.map((item) => ({
// value: item.TestSiteCode,
// label: `${item.TestSiteCode} - ${item.TestSiteName}`
// }));
// } catch (err) {
// console.error('Failed to fetch tests', err);
// } finally {
// isLoading = false;
// }
// }
options = data.data.map((item) => ({
value: item.TestSiteCode,
label: `${item.TestSiteCode} - ${item.TestSiteName}`
}));
} catch (err) {
console.error('Failed to fetch tests', err);
} finally {
isLoading = false;
}
}
// $effect(() => {
// fetchTests();
// });
$effect(() => {
fetchTests();
});
// // 🔹 VALIDATION
// $effect(() => {
// const result = testCalSchema.safeParse(formState.form);
// 🔹 VALIDATION
$effect(() => {
const result = testCalSchema.safeParse(formState.form);
// if (!result.success) {
// const fieldErrors = {};
// for (const issue of result.error.issues) {
// fieldErrors[issue.path[0]] = issue.message;
// }
// errors = fieldErrors;
// } else {
// errors = {};
// }
// });
if (!result.success) {
const fieldErrors = {};
for (const issue of result.error.issues) {
fieldErrors[issue.path[0]] = issue.message;
}
errors = fieldErrors;
} else {
errors = {};
}
});
// // 🔹 Badge status
// const inputStatus = $derived.by(() => {
// const inputs = formState.form.FormulaInput || [];
// const code = formState.form.FormulaCode || '';
// 🔹 Badge status
const inputStatus = $derived.by(() => {
const inputs = formState.form.FormulaInput || [];
const code = formState.form.FormulaCode || '';
// return inputs.map((v) => ({
// value: v,
// done: hasExactKeyword(code, v)
// }));
// });
return inputs.map((v) => ({
value: v,
done: hasExactKeyword(code, v)
}));
});
function unselectAll() {
formState.form.FormulaInput = [];
errors = {};
}
// function unselectAll() {
// formState.form.FormulaInput = [];
// errors = {};
// }
</script>
<div class="p-2 space-y-6">
<div class="flex flex-col gap-4 w-full">
<DictionaryFormRenderer formState={props.calFormState} formFields={props.testCalFormFields}/>
</div>
<!-- <div class="p-2 space-y-6">
<div class="grid grid-cols-1 space-y-2 gap-6 md:gap-4">
<div class="flex w-full flex-col gap-1.5">
<div class="flex justify-between items-center w-full">
@ -150,7 +154,7 @@
</div>
</div>
{/if}
</div>
</div> -->

View File

@ -11,6 +11,10 @@
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";
import MoveLeftIcon from "@lucide/svelte/icons/move-left";
import MoveRightIcon from "@lucide/svelte/icons/move-right";
import BrushCleaningIcon from "@lucide/svelte/icons/brush-cleaning";
import DeleteIcon from "@lucide/svelte/icons/delete";
let {
formState,
@ -26,8 +30,12 @@
handleRefTypeChange,
} = $props();
const operators = ['+', '-', '*', '/', '^', '(', ')'];
let searchQuery = $state({});
let dropdownOpen = $state({});
let expression = $state('');
let cursorPosition = $state(0);
function getFilteredOptions(key) {
const query = searchQuery[key] || "";
@ -67,6 +75,94 @@
formState.form[field.key] = field.defaultValue;
}
}
function unselectAll(key) {
formState.form[key] = [];
formState.validateField?.(key, [], false);
}
// function getErrorStatus(key) {
// const error = formState.errors[key];
// if (!error) return [];
// const parts = error.split(':');
// if (parts.length < 2) return [];
// const values = parts[1].split(',').map(v => v.trim());
// return values.map(v => ({ value: v, done: false }));
// }
$inspect(formState.form.FormulaInput)
function getErrorStatus(formulaCode = '') {
const selected = formState.form.FormulaInput;
if (!Array.isArray(selected)) return [];
return selected.map(v => ({
value: v,
done: formulaCode.includes(v)
}));
}
function addToExpression(text) {
console.log(text);
const before = expression.slice(0, cursorPosition);
const after = expression.slice(cursorPosition);
expression = before + text + after;
cursorPosition += text.length;
}
function addOperator(op) {
addToExpression(op);
}
function addValue(val) {
addToExpression(val);
}
function handleInput(e) {
expression = e.target.value;
cursorPosition = e.target.selectionStart;
}
function handleClick(e) {
cursorPosition = e.target.selectionStart;
}
function handleContainerClick(e) {
const rect = e.currentTarget.getBoundingClientRect();
const text = expression;
const charWidth = 8.5;
const padding = 12;
const clickX = e.clientX - rect.left - padding;
let newPos = Math.floor(clickX / charWidth);
newPos = Math.max(0, Math.min(newPos, text.length));
cursorPosition = newPos;
}
function moveCursorLeft() {
if (cursorPosition > 0) {
cursorPosition -= 1;
}
}
function moveCursorRight() {
if (cursorPosition < expression.length) {
cursorPosition += 1;
}
}
function deleteChar() {
if (cursorPosition > 0) {
const before = expression.slice(0, cursorPosition - 1);
const after = expression.slice(cursorPosition);
expression = before + after;
cursorPosition -= 1;
}
}
function clearExpression() {
expression = '';
cursorPosition = 0;
}
</script>
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey, txtKey })}
@ -234,6 +330,68 @@
{/if}
</Select.Content>
</Select.Root>
{:else if type === "selectmultiple"}
{@const filteredOptions = getFilteredOptions(key)}
<Select.Root
type="multiple"
bind:value={formState.form[key]}
onValueChange={(val) => {
formState.form[key] = val;
if (validateOn?.includes("input")) {
formState.validateField?.(key, val, false);
formState.validateField?.("FormulaCode", val, false);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full">
{formState.form[key]?.length
? (formState.selectOptions?.[key] ?? [])
.filter(o => formState.form[key].includes(o.value))
.map(o => o.label)
.join(', ')
: 'Choose'}
</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>
<Select.Group>
{#if formState.loadingOptions?.[key]}
<div class="p-2 text-sm text-muted-foreground">
Loading...
</div>
{:else}
{#if formState.form[key].length > 0}
<Select.Separator />
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => unselectAll(key)}
>
Unselect All
</button>
{/if}
{#each filteredOptions as opt (opt.value)}
<Select.Item value={opt.value} label={opt.label}>
{opt.label}
</Select.Item>
{/each}
{/if}
</Select.Group>
</Select.Content>
</Select.Root>
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
@ -362,6 +520,96 @@
</InputGroup.Addon>
</InputGroup.Root>
</div>
{:else if type === "formulabuilder"}
<div class="flex flex-col gap-8 w-full">
<div class="flex gap-1 w-full">
<Button
type="button"
variant="outline"
size="icon"
onclick={moveCursorLeft}
>
<MoveLeftIcon class="w-4 h-4" />
</Button>
<div class="relative flex-1">
<div
class="flex flex-1 h-9 w-full min-w-0 items-center rounded-md border bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
role="textbox"
tabindex="0"
onclick={handleContainerClick}
>
{#each expression.split('') as char, i}
{#if i === cursorPosition}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
<span class="">{char}</span>
{/each}
{#if cursorPosition === expression.length}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
onclick={moveCursorRight}
>
<MoveRightIcon class="w-4 h-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onclick={deleteChar}
>
<DeleteIcon class="w-4 h-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
onclick={clearExpression}
>
<BrushCleaningIcon class="w-4 h-4" />
</Button>
</div>
{#if formState.form.FormulaInput.length > 0}
<div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">Selected Values</span>
<div class="flex flex-wrap gap-2">
{#each formState.form.FormulaInput as item (item)}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => addValue(item)}
>
{item}
</Button>
{/each}
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium">Operators</span>
<div class="flex flex-wrap gap-2">
{#each operators as op}
<Button
type="button"
variant="outline"
size="icon"
onclick={() => addOperator(op)}
>
{op}
</Button>
{/each}
</div>
</div>
</div>
{/if}
</div>
{:else}
<Input
type="text"
@ -371,13 +619,30 @@
{/if}
<div
class={`absolute min-h-[1rem] w-full ${
key === 'FormulaCode' ? 'top-20' : 'top-8'
key === 'FormulaCode' ? 'top-10' : 'top-8'
}`}
>
{#if formState.errors[key] || formState.errors[txtKey]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key] ?? formState.errors[txtKey]}
</span>
{#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])}
{@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]}
<div class="text-sm text-destructive">
{errorMessage}
</div>
{/if}
{#if key === 'FormulaCode' && formState.form.FormulaInput?.length}
{@const inputStatus = getErrorStatus(expression)}
<div class="flex items-center gap-2 text-sm text-destructive">
<span>Formula parameters:</span>
<div class="flex gap-1 flex-wrap">
{#each inputStatus as item (item.value)}
<Badge variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
</div>
@ -428,4 +693,20 @@
{/each}
</div>
{/each}
</div>
</div>
<style>
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.animate-cursor {
animation: cursor-blink 1s infinite;
}
</style>

View File

@ -0,0 +1,290 @@
<script>
import * as Select from '$lib/components/ui/select/index.js';
import { fruits } from '$lib/components/multiselect/multiselect-form-config';
import { Badge } from '$lib/components/ui/badge/index.js';
import { z } from 'zod';
let selectedValues = $state([]);
let expression = $state('');
let cursorPosition = $state(0);
const operators = ['+', '-', '*', '/', '^', '(', ')'];
function hasExactKeyword(input, keyword) {
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
return regex.test(input);
}
const schema = z
.object({
selected: z.array(z.string()),
input: z.string()
})
.refine(
(data) => {
return data.selected.every((v) => hasExactKeyword(data.input, v));
},
{
message: 'Input must contain all selected values',
path: ['input']
}
);
let errors = $state({});
$effect(() => {
const result = schema.safeParse({ selected: selectedValues, input: expression });
if (!result.success) {
const fieldErrors = {};
for (const issue of result.error.issues) {
fieldErrors[issue.path[0]] = issue.message;
}
errors = fieldErrors;
} else {
errors = {};
}
});
const selectedLabel = $derived(
selectedValues.length === 0
? 'Select a fruit'
: selectedValues.map((v) => fruits.find((f) => f.value === v)?.value).join(', ')
);
const hasErrors = $derived(Object.keys(errors).length > 0);
const inputStatus = $derived(
selectedValues
.filter((v) => v)
.map((v) => ({
value: v,
label: fruits.find((f) => f.value === v)?.label,
done: hasExactKeyword(expression, v)
}))
);
function unselectAll() {
selectedValues = [];
}
function removeSelectedItem(item) {
selectedValues = selectedValues.filter((v) => v !== item);
}
function addToExpression(text) {
const before = expression.slice(0, cursorPosition);
const after = expression.slice(cursorPosition);
expression = before + text + after;
cursorPosition += text.length;
setCursorPosition();
}
function addOperator(op) {
addToExpression(op);
}
function addValue(val) {
addToExpression(val);
}
function handleInput(e) {
expression = e.target.value;
cursorPosition = e.target.selectionStart;
}
function handleKeydown(e) {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
setTimeout(setCursorPosition, 0);
}
}
function setCursorPosition() {}
function handleClick(e) {
cursorPosition = e.target.selectionStart;
}
function handleContainerClick(e) {
const rect = e.currentTarget.getBoundingClientRect();
const text = expression;
const charWidth = 8.5;
const padding = 12;
const clickX = e.clientX - rect.left - padding;
let newPos = Math.floor(clickX / charWidth);
newPos = Math.max(0, Math.min(newPos, text.length));
cursorPosition = newPos;
}
function moveCursorLeft() {
if (cursorPosition > 0) {
cursorPosition -= 1;
setCursorPosition();
}
}
function moveCursorRight() {
if (cursorPosition < expression.length) {
cursorPosition += 1;
setCursorPosition();
}
}
function deleteChar() {
if (cursorPosition > 0) {
const before = expression.slice(0, cursorPosition - 1);
const after = expression.slice(cursorPosition);
expression = before + after;
cursorPosition -= 1;
setCursorPosition();
}
}
function clearExpression() {
expression = '';
cursorPosition = 0;
}
</script>
<div class="flex flex-col gap-4">
<Select.Root type="multiple" name="favoriteFruit" bind:value={selectedValues}>
<Select.Trigger class="w-full">
{selectedLabel}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Label>Fruits</Select.Label>
{#each fruits as fruit (fruit.value)}
<Select.Item value={fruit.value} label={fruit.label}>
{fruit.label}
</Select.Item>
{/each}
</Select.Group>
{#if selectedValues.length > 0}
<Select.Separator />
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={unselectAll}
>
Unselect All
</button>
{/if}
</Select.Content>
</Select.Root>
{#if selectedValues.length > 0}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Selected Values</label>
<div class="flex flex-wrap gap-2">
{#each selectedValues as item (item)}
<button
type="button"
class="flex items-center gap-1 rounded-md bg-blue-100 px-3 py-1.5 text-sm font-medium text-blue-800 hover:bg-blue-200"
onclick={() => addValue(item)}
>
{item}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Expression</label>
<div class="flex gap-1">
<button
type="button"
onclick={moveCursorLeft}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
</button>
<div class="relative flex-1">
<div
class="flex h-10 items-center overflow-x-auto rounded-md border border-input bg-background px-3 py-2 font-mono text-sm whitespace-nowrap"
onclick={handleContainerClick}
role="textbox"
tabindex="0"
>
{#each expression.split('') as char, i}
{#if i === cursorPosition}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
<span class="">{char}</span>
{/each}
{#if cursorPosition === expression.length}
<span class="animate-cursor inline-block h-6 w-0.5 bg-primary"></span>
{/if}
</div>
</div>
<button
type="button"
onclick={moveCursorRight}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
</button>
<button
type="button"
onclick={deleteChar}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
Del
</button>
<button
type="button"
onclick={clearExpression}
class="rounded-md border border-input bg-background px-3 py-2 text-sm hover:bg-accent"
>
Clear
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Operators</label>
<div class="flex flex-wrap gap-2">
{#each operators as op}
<button
type="button"
class="rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
onclick={() => addOperator(op)}
>
{op}
</button>
{/each}
</div>
</div>
{#if errors.input}
<div class="flex items-center gap-2 text-sm text-red-500">
<span>{errors.input}:</span>
<div class="flex gap-1">
{#each inputStatus as item (item.value)}
<Badge variant={item.done ? 'default' : 'destructive'}>
{item.value}
</Badge>
{/each}
</div>
</div>
{/if}
<button disabled={hasErrors} class="rounded bg-blue-500 px-4 py-2 text-white disabled:opacity-50">
Save
</button>
</div>
<style>
@keyframes cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.animate-cursor {
animation: cursor-blink 1s infinite;
}
</style>