mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-27 11:25:53 +07:00
initial version syntax builder test calc
This commit is contained in:
parent
8529c91058
commit
d3099248b2
@ -14,10 +14,21 @@ const optionsMode = {
|
|||||||
const valueKey = field.valueKey ?? 'value';
|
const valueKey = field.valueKey ?? 'value';
|
||||||
const labelKey = field.labelKey ?? 'label';
|
const labelKey = field.labelKey ?? 'label';
|
||||||
|
|
||||||
selectOptions[field.key] = data.map((item) => ({
|
selectOptions[field.key] = data.map((item) => {
|
||||||
value: item[valueKey],
|
const baseOption = {
|
||||||
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
|
value: item[valueKey],
|
||||||
}));
|
label: typeof labelKey === 'function' ? labelKey(item) : item[labelKey],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (field.key === 'FormulaInput') {
|
||||||
|
return {
|
||||||
|
...baseOption,
|
||||||
|
level: item.SeqRpt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseOption;
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch options for", field.key, err);
|
console.error("Failed to fetch options for", field.key, err);
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export function useFormValidation(schema, form, defaultErrors, valMode) {
|
|||||||
const valFn = validationMode[valMode];
|
const valFn = validationMode[valMode];
|
||||||
const error = valFn(schema, field, form, originalValue);
|
const error = valFn(schema, field, form, originalValue);
|
||||||
errors[field] = error;
|
errors[field] = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetErrors() {
|
function resetErrors() {
|
||||||
Object.assign(errors, defaultErrors);
|
Object.assign(errors, defaultErrors);
|
||||||
|
|||||||
@ -26,22 +26,28 @@ export const testSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const testCalSchema = z.object({
|
export const testCalSchema = z.object({
|
||||||
FormulaInput: z.array(z.string()),
|
FormulaInput: z.array(z.string()).min(1, "Required"),
|
||||||
FormulaCode: z.string()
|
FormulaCode: z.string().optional(),
|
||||||
}).refine(
|
}).superRefine((data, ctx) => {
|
||||||
(data) => {
|
if (data.FormulaInput.length === 0) return;
|
||||||
if (data.FormulaInput.length === 0) return true;
|
|
||||||
function hasExactKeyword(input, keyword) {
|
const hasExactKeyword = (input, keyword) => {
|
||||||
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
||||||
return regex.test(input);
|
return regex.test(input);
|
||||||
}
|
};
|
||||||
return data.FormulaInput.every((v) => hasExactKeyword(data.FormulaCode, v));
|
|
||||||
},
|
const invalid = data.FormulaInput.some(
|
||||||
{
|
(v) => !hasExactKeyword(data.FormulaCode, v)
|
||||||
message: 'Formula must contain all selected input parameters',
|
);
|
||||||
path: ['FormulaCode']
|
|
||||||
|
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({
|
export const refNumSchema = z.object({
|
||||||
AgeStart: z.string().optional(),
|
AgeStart: z.string().optional(),
|
||||||
@ -245,7 +251,10 @@ export const testDefaultErrors = {
|
|||||||
Decimal: null,
|
Decimal: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testCalDefaultErrors = {};
|
export const testCalDefaultErrors = {
|
||||||
|
FormulaInput: "Required",
|
||||||
|
FormulaCode: null,
|
||||||
|
};
|
||||||
|
|
||||||
export const refNumDefaultErrors = {
|
export const refNumDefaultErrors = {
|
||||||
AgeStart: null,
|
AgeStart: null,
|
||||||
@ -547,11 +556,12 @@ export const testCalFormFields = [
|
|||||||
{
|
{
|
||||||
key: "FormulaInput",
|
key: "FormulaInput",
|
||||||
label: "Input Parameter",
|
label: "Input Parameter",
|
||||||
required: false,
|
required: true,
|
||||||
type: "selectmultiple",
|
type: "selectmultiple",
|
||||||
optionsEndpoint: `${API.BASE_URL}${API.TEST}`,
|
optionsEndpoint: `${API.BASE_URL}${API.TEST}`,
|
||||||
valueKey: "TestSiteID",
|
valueKey: "TestSiteCode",
|
||||||
labelKey: (item) => `${item.TestSiteCode} - ${item.TestSiteName}`,
|
labelKey: (item) => `${item.TestSiteCode} - ${item.TestSiteName}`,
|
||||||
|
validateOn: ["input"]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -560,10 +570,9 @@ export const testCalFormFields = [
|
|||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
key: "FormulaCode",
|
key: "FormulaCode",
|
||||||
label: "Formula",
|
label: "Formula Code",
|
||||||
required: false,
|
required: false,
|
||||||
type: "textarea",
|
type: "formulabuilder",
|
||||||
validateOn: ["input"]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -678,11 +687,6 @@ export const refNumFormFields = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Flag & Interpretation",
|
|
||||||
rows: [
|
|
||||||
{
|
{
|
||||||
type: "row",
|
type: "row",
|
||||||
columns: [
|
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 = [
|
export const refTxtFormFields = [
|
||||||
|
|||||||
@ -31,8 +31,9 @@
|
|||||||
const { formState } = masterDetail;
|
const { formState } = masterDetail;
|
||||||
|
|
||||||
const calFormState = useForm({
|
const calFormState = useForm({
|
||||||
schema: null,
|
schema: testCalSchema,
|
||||||
initialForm: testCalInitialForm,
|
initialForm: testCalInitialForm,
|
||||||
|
defaultErrors: testCalDefaultErrors,
|
||||||
});
|
});
|
||||||
|
|
||||||
const refNumState = useForm({
|
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');
|
let activeTab = $state('definition');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -412,7 +426,7 @@
|
|||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="calculation">
|
<Tabs.Content value="calculation">
|
||||||
<Calculation {calFormState}/>
|
<Calculation {calFormState} {testCalFormFields} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="group">
|
<Tabs.Content value="group">
|
||||||
group
|
group
|
||||||
|
|||||||
@ -6,76 +6,80 @@
|
|||||||
import { API } from '$lib/config/api';
|
import { API } from '$lib/config/api';
|
||||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
||||||
import { Label } from "$lib/components/ui/label/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();
|
let props = $props();
|
||||||
const formState = props.calFormState;
|
// const formState = props.calFormState;
|
||||||
|
|
||||||
let options = $state([]);
|
// let options = $state([]);
|
||||||
let isLoading = $state(false);
|
// 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) {
|
// // 🔹 FETCH OPTIONS
|
||||||
const regex = new RegExp(`\\b${keyword}\\b`, 'i');
|
// async function fetchTests() {
|
||||||
return regex.test(input);
|
// isLoading = true;
|
||||||
}
|
// try {
|
||||||
|
// const res = await fetch(`${API.BASE_URL}${API.TEST}`);
|
||||||
|
// const data = await res.json();
|
||||||
|
// console.log(data);
|
||||||
|
|
||||||
// 🔹 FETCH OPTIONS
|
// options = data.data.map((item) => ({
|
||||||
async function fetchTests() {
|
// value: item.TestSiteCode,
|
||||||
isLoading = true;
|
// label: `${item.TestSiteCode} - ${item.TestSiteName}`
|
||||||
try {
|
// }));
|
||||||
const res = await fetch(`${API.BASE_URL}${API.TEST}`);
|
// } catch (err) {
|
||||||
const data = await res.json();
|
// console.error('Failed to fetch tests', err);
|
||||||
|
// } finally {
|
||||||
|
// isLoading = false;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
options = data.data.map((item) => ({
|
// $effect(() => {
|
||||||
value: item.TestSiteCode,
|
// fetchTests();
|
||||||
label: `${item.TestSiteCode} - ${item.TestSiteName}`
|
// });
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch tests', err);
|
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
// // 🔹 VALIDATION
|
||||||
fetchTests();
|
// $effect(() => {
|
||||||
});
|
// const result = testCalSchema.safeParse(formState.form);
|
||||||
|
|
||||||
// 🔹 VALIDATION
|
// if (!result.success) {
|
||||||
$effect(() => {
|
// const fieldErrors = {};
|
||||||
const result = testCalSchema.safeParse(formState.form);
|
// for (const issue of result.error.issues) {
|
||||||
|
// fieldErrors[issue.path[0]] = issue.message;
|
||||||
|
// }
|
||||||
|
// errors = fieldErrors;
|
||||||
|
// } else {
|
||||||
|
// errors = {};
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
if (!result.success) {
|
// // 🔹 Badge status
|
||||||
const fieldErrors = {};
|
// const inputStatus = $derived.by(() => {
|
||||||
for (const issue of result.error.issues) {
|
// const inputs = formState.form.FormulaInput || [];
|
||||||
fieldErrors[issue.path[0]] = issue.message;
|
// const code = formState.form.FormulaCode || '';
|
||||||
}
|
|
||||||
errors = fieldErrors;
|
|
||||||
} else {
|
|
||||||
errors = {};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔹 Badge status
|
// return inputs.map((v) => ({
|
||||||
const inputStatus = $derived.by(() => {
|
// value: v,
|
||||||
const inputs = formState.form.FormulaInput || [];
|
// done: hasExactKeyword(code, v)
|
||||||
const code = formState.form.FormulaCode || '';
|
// }));
|
||||||
|
// });
|
||||||
|
|
||||||
return inputs.map((v) => ({
|
// function unselectAll() {
|
||||||
value: v,
|
// formState.form.FormulaInput = [];
|
||||||
done: hasExactKeyword(code, v)
|
// errors = {};
|
||||||
}));
|
// }
|
||||||
});
|
|
||||||
|
|
||||||
function unselectAll() {
|
|
||||||
formState.form.FormulaInput = [];
|
|
||||||
errors = {};
|
|
||||||
}
|
|
||||||
</script>
|
</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="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 w-full flex-col gap-1.5">
|
||||||
<div class="flex justify-between items-center w-full">
|
<div class="flex justify-between items-center w-full">
|
||||||
@ -150,7 +154,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,10 @@
|
|||||||
import * as InputGroup from "$lib/components/ui/input-group/index.js";
|
import * as InputGroup from "$lib/components/ui/input-group/index.js";
|
||||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
import { Textarea } from "$lib/components/ui/textarea/index.js";
|
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 {
|
let {
|
||||||
formState,
|
formState,
|
||||||
@ -26,8 +30,12 @@
|
|||||||
handleRefTypeChange,
|
handleRefTypeChange,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const operators = ['+', '-', '*', '/', '^', '(', ')'];
|
||||||
|
|
||||||
let searchQuery = $state({});
|
let searchQuery = $state({});
|
||||||
let dropdownOpen = $state({});
|
let dropdownOpen = $state({});
|
||||||
|
let expression = $state('');
|
||||||
|
let cursorPosition = $state(0);
|
||||||
|
|
||||||
function getFilteredOptions(key) {
|
function getFilteredOptions(key) {
|
||||||
const query = searchQuery[key] || "";
|
const query = searchQuery[key] || "";
|
||||||
@ -67,6 +75,94 @@
|
|||||||
formState.form[field.key] = field.defaultValue;
|
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>
|
</script>
|
||||||
|
|
||||||
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey, txtKey })}
|
{#snippet Fieldset({ key, label, required, type, optionsEndpoint, options, validateOn, dependsOn, endpointParamKey, valueKey, labelKey, txtKey })}
|
||||||
@ -234,6 +330,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</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"}
|
{:else if type === "date"}
|
||||||
<ReusableCalendar
|
<ReusableCalendar
|
||||||
bind:value={formState.form[key]}
|
bind:value={formState.form[key]}
|
||||||
@ -362,6 +520,96 @@
|
|||||||
</InputGroup.Addon>
|
</InputGroup.Addon>
|
||||||
</InputGroup.Root>
|
</InputGroup.Root>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
@ -371,13 +619,30 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class={`absolute min-h-[1rem] w-full ${
|
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]}
|
{#if key !== 'FormulaCode' && (formState.errors[key] || formState.errors[txtKey])}
|
||||||
<span class="text-destructive text-sm leading-none">
|
{@const errorMessage = formState.errors[key] ?? formState.errors[txtKey]}
|
||||||
{formState.errors[key] ?? formState.errors[txtKey]}
|
|
||||||
</span>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -429,3 +694,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%,
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
51%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-cursor {
|
||||||
|
animation: cursor-blink 1s infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
290
src/lib/components/reusable/form/test.js
Normal file
290
src/lib/components/reusable/form/test.js
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user