mirror of
https://github.com/faiztyanirh/clqms-shadcn-v1.git
synced 2026-04-22 09:35:34 +07:00
initial version syntax builder test calc
This commit is contained in:
parent
8529c91058
commit
d3099248b2
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> -->
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
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