From d3099248b25e82ca2b0a206a35471dc34e8bcabe Mon Sep 17 00:00:00 2001 From: faiztyanirh Date: Tue, 3 Mar 2026 16:53:03 +0700 Subject: [PATCH] initial version syntax builder test calc --- .../composable/use-form-option.svelte.js | 21 +- .../composable/use-form-validation.svelte.js | 2 +- .../test/config/test-form-config.js | 91 ++++-- .../dictionary/test/page/create-page.svelte | 18 +- .../test/page/tabs/calculation.svelte | 118 +++---- .../form/dictionary-form-renderer.svelte | 293 +++++++++++++++++- src/lib/components/reusable/form/test.js | 290 +++++++++++++++++ 7 files changed, 735 insertions(+), 98 deletions(-) create mode 100644 src/lib/components/reusable/form/test.js diff --git a/src/lib/components/composable/use-form-option.svelte.js b/src/lib/components/composable/use-form-option.svelte.js index d56f5d9..2d2c7e7 100644 --- a/src/lib/components/composable/use-form-option.svelte.js +++ b/src/lib/components/composable/use-form-option.svelte.js @@ -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); diff --git a/src/lib/components/composable/use-form-validation.svelte.js b/src/lib/components/composable/use-form-validation.svelte.js index 2945d53..5a36552 100644 --- a/src/lib/components/composable/use-form-validation.svelte.js +++ b/src/lib/components/composable/use-form-validation.svelte.js @@ -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); diff --git a/src/lib/components/dictionary/test/config/test-form-config.js b/src/lib/components/dictionary/test/config/test-form-config.js index 3881d77..67ce2f1 100644 --- a/src/lib/components/dictionary/test/config/test-form-config.js +++ b/src/lib/components/dictionary/test/config/test-form-config.js @@ -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 = [ diff --git a/src/lib/components/dictionary/test/page/create-page.svelte b/src/lib/components/dictionary/test/page/create-page.svelte index f331302..e28e194 100644 --- a/src/lib/components/dictionary/test/page/create-page.svelte +++ b/src/lib/components/dictionary/test/page/create-page.svelte @@ -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 @@ /> - + group diff --git a/src/lib/components/dictionary/test/page/tabs/calculation.svelte b/src/lib/components/dictionary/test/page/tabs/calculation.svelte index c19182d..cdae944 100644 --- a/src/lib/components/dictionary/test/page/tabs/calculation.svelte +++ b/src/lib/components/dictionary/test/page/tabs/calculation.svelte @@ -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 = {}; + // } -
+
+ +
+ + diff --git a/src/lib/components/reusable/form/dictionary-form-renderer.svelte b/src/lib/components/reusable/form/dictionary-form-renderer.svelte index b8c99f3..cf610f8 100644 --- a/src/lib/components/reusable/form/dictionary-form-renderer.svelte +++ b/src/lib/components/reusable/form/dictionary-form-renderer.svelte @@ -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; + } {#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey, txtKey })} @@ -234,6 +330,68 @@ {/if} + {:else if type === "selectmultiple"} + {@const filteredOptions = getFilteredOptions(key)} + { + 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 + ); + } + }} + > + + {formState.form[key]?.length + ? (formState.selectOptions?.[key] ?? []) + .filter(o => formState.form[key].includes(o.value)) + .map(o => o.label) + .join(', ') + : 'Choose'} + + +
+ +
+ + {#if formState.loadingOptions?.[key]} +
+ Loading... +
+ {:else} + {#if formState.form[key].length > 0} + + + {/if} + {#each filteredOptions as opt (opt.value)} + + {opt.label} + + {/each} + {/if} +
+
+
{:else if type === "date"}
+ {:else if type === "formulabuilder"} +
+
+ +
+
+ {#each expression.split('') as char, i} + {#if i === cursorPosition} + + {/if} + {char} + {/each} + {#if cursorPosition === expression.length} + + {/if} +
+
+ + + + +
+ {#if formState.form.FormulaInput.length > 0} +
+
+ Selected Values +
+ {#each formState.form.FormulaInput as item (item)} + + {/each} +
+
+
+ Operators +
+ {#each operators as op} + + {/each} +
+
+
+ {/if} +
{:else} - {#if formState.errors[key] || formState.errors[txtKey]} - - {formState.errors[key] ?? formState.errors[txtKey]} - + {#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])} + {@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]} + +
+ {errorMessage} +
+ {/if} + {#if key === 'FormulaCode' && formState.form.FormulaInput?.length} + {@const inputStatus = getErrorStatus(expression)} + +
+ Formula parameters: + +
+ {#each inputStatus as item (item.value)} + + {item.value} + + {/each} +
+
{/if} @@ -428,4 +693,20 @@ {/each} {/each} - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/lib/components/reusable/form/test.js b/src/lib/components/reusable/form/test.js new file mode 100644 index 0000000..cb4afaf --- /dev/null +++ b/src/lib/components/reusable/form/test.js @@ -0,0 +1,290 @@ + + +
+ + + {selectedLabel} + + + + Fruits + {#each fruits as fruit (fruit.value)} + + {fruit.label} + + {/each} + + {#if selectedValues.length > 0} + + + {/if} + + + + {#if selectedValues.length > 0} +
+ +
+ {#each selectedValues as item (item)} + + {/each} +
+
+ {/if} + +
+ +
+ +
+
+ {#each expression.split('') as char, i} + {#if i === cursorPosition} + + {/if} + {char} + {/each} + {#if cursorPosition === expression.length} + + {/if} +
+
+ + + +
+
+ +
+ +
+ {#each operators as op} + + {/each} +
+
+ + {#if errors.input} +
+ {errors.input}: +
+ {#each inputStatus as item (item.value)} + + {item.value} + + {/each} +
+
+ {/if} + + +
+ + \ No newline at end of file