melanjutkan pusing pusing validate rule refnum

This commit is contained in:
faiztyanirh 2026-02-24 16:26:38 +07:00
parent 62b1d9fe45
commit 7fcb5d8507
6 changed files with 254 additions and 102 deletions

View File

@ -1,27 +1,60 @@
const validationMode = {
create: (schema, field, value) => {
const result = schema.shape[field].safeParse(value);
return result.success ? null : result.error.issues[0].message;
// create: (schema, field, value) => {
// const result = schema.shape[field].safeParse(value);
// console.log(result);
// return result.success ? null : result.error.issues[0].message;
// },
create: (schema, field, form) => {
const result = schema.safeParse(form);
if (result.success) return null;
const fieldError = result.error.issues.find(
(issue) => issue.path[0] === field
);
return fieldError ? fieldError.message : null;
},
edit: (schema, field, value, originalValue) => {
if (originalValue !== undefined && value === originalValue) {
// edit: (schema, field, value, originalValue) => {
// if (originalValue !== undefined && value === originalValue) {
// return null;
// }
// const result = schema.shape[field].safeParse(value);
// return result.success ? null : result.error.issues[0].message;
// }
edit: (schema, field, form, originalValue) => {
if (originalValue !== undefined && form[field] === originalValue) {
return null;
}
const result = schema.shape[field].safeParse(value);
return result.success ? null : result.error.issues[0].message;
const result = schema.safeParse(form);
if (result.success) return null;
const fieldError = result.error.issues.find(
(issue) => issue.path[0] === field
);
return fieldError ? fieldError.message : null;
}
};
export function useFormValidation(schema, form, defaultErrors, valMode) {
const errors = $state({...defaultErrors})
// function validateField(field, originalValue) {
// const value = form[field];
// const valFn = validationMode[valMode];
// errors[field] = valFn(schema, field, value, originalValue);
// }
function validateField(field, originalValue) {
const value = form[field];
const valFn = validationMode[valMode];
errors[field] = valFn(schema, field, value, originalValue);
}
const valFn = validationMode[valMode];
const error = valFn(schema, field, form, originalValue);
errors[field] = error;
}
function resetErrors() {
Object.assign(errors, defaultErrors);

View File

@ -2,6 +2,7 @@ import { API } from "$lib/config/api";
import EraserIcon from "@lucide/svelte/icons/eraser";
import { z } from "zod";
import { cleanEmptyStrings } from "$lib/utils/cleanEmptyStrings";
import { toDays } from "$lib/utils/ageUtils";
export const testSchema = z.object({
TestSiteCode: z.string().min(1, "Required"),
@ -13,7 +14,7 @@ export const testSchema = z.object({
return Number(val);
},
z.number({invalid_type_error: "Must be a number"}).min(0, "Min 0").max(7, "Max 7").optional()
)
),
}).superRefine((data, ctx) => {
if (data.Factor && !data.Unit2) {
ctx.addIssue({
@ -42,6 +43,71 @@ export const testCalSchema = z.object({
}
);
export const refNumSchema = z.object({
AgeStart: z.string().optional(),
AgeEnd: z.string().optional(),
Flag: z.string().min(1, "Required"),
Low: z.string().optional(),
High: z.string().optional(),
LowSign: z.string().optional(),
HighSign: z.string().optional(),
NumRefType: z.string().optional()
})
.superRefine((data, ctx) => {
const start = toDays(data.AgeStart);
const end = toDays(data.AgeEnd);
// if (start !== null && start > 0 && end !== null && end <= start) {
if (start !== null && end !== null && end <= start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Age End must be greater than Age Start",
path: ["AgeEnd"],
});
}
})
.superRefine((data, ctx) => {
// console.log(data.NumRefType);
if (data.NumRefType === "RANGE") {
const low = Number(data.Low);
const high = Number(data.High);
if (
data.Low &&
data.High &&
!Number.isNaN(low) &&
!Number.isNaN(high) &&
(high <= low)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "High value must be greater than Low value",
path: ["High"],
});
}
}
if (data.NumRefType === "THOLD") {
if (data.Low && !data.LowSign) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Math sign for Low is required",
path: ["LowSign"],
});
}
if (
data.LowSign &&
data.HighSign &&
data.LowSign === data.HighSign
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "High sign can't be the same as Low sign",
path: ["HighSign"],
});
}
}
})
export const testInitialForm = {
TestSiteID: "",
SiteID: "",
@ -123,7 +189,14 @@ export const testDefaultErrors = {
export const testCalDefaultErrors = {};
export const refNumDefaultErrors = {};
export const refNumDefaultErrors = {
AgeStart: null,
AgeEnd: null,
Low: null,
High: null,
LowSign: null,
HighSign: null,
};
export const refTxtDefaultErrors = {};
@ -482,12 +555,14 @@ export const refNumFormFields = [
label: "Age Start",
required: false,
type: "agejoin",
validateOn: ["input"]
},
{
key: "AgeEnd",
label: "Age End",
required: false,
type: "agejoin",
validateOn: ["input"]
},
]
},
@ -524,6 +599,7 @@ export const refNumFormFields = [
type: "signvalue",
txtKey: "Low",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/math_sign`,
validateOn: ["input"]
},
{
key: "HighSign",
@ -532,6 +608,7 @@ export const refNumFormFields = [
type: "signvalue",
txtKey: "High",
optionsEndpoint: `${API.BASE_URL}${API.VALUESET}/math_sign`,
validateOn: ["input"]
},
]
},

View File

@ -6,7 +6,7 @@
import ReusableAlertDialog from "$lib/components/reusable/reusable-alert-dialog.svelte";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import { useForm } from "$lib/components/composable/use-form.svelte";
import { buildTestPayload, testCalSchema, testCalInitialForm, testCalDefaultErrors, testCalFormFields, refNumInitialForm, refNumFormFields, refTxtInitialForm, refTxtFormFields } from "$lib/components/dictionary/test/config/test-form-config";
import { buildTestPayload, testCalSchema, testCalInitialForm, testCalDefaultErrors, testCalFormFields, refNumSchema, refNumDefaultErrors, refNumInitialForm, refNumFormFields, refTxtInitialForm, refTxtFormFields } from "$lib/components/dictionary/test/config/test-form-config";
import ReusableEmpty from "$lib/components/reusable/reusable-empty.svelte";
import RefNum from "./tabs/ref-num.svelte";
import RefTxt from "./tabs/ref-txt.svelte";
@ -30,8 +30,9 @@
});
const refNumState = useForm({
schema: null,
schema: refNumSchema,
initialForm: refNumInitialForm,
defaultErrors: refNumDefaultErrors,
});
const refTxtState = useForm({

View File

@ -25,14 +25,14 @@
});
let disabledSign = $derived.by(() => {
const refType = props.refType;
const refType = props.refNumState.form.NumRefType;
if (refType === "RANGE") return true;
if (refType === "THOLD") return false;
return false;
});
function snapshotForm() {
const f = props.refNumState.form;
return {
@ -62,9 +62,38 @@
editingId = null;
}
function isOverlapping(newLow, newHigh, existingRows) {
return existingRows.some(row => {
return !(newHigh <= row.Low || newLow >= row.High);
});
}
function handleInsert() {
const row = { id: ++idCounter, ...snapshotForm() };
const low = Number(props.refNumState.form.Low);
const high = Number(props.refNumState.form.High);
console.log(`low: ${low}`);
// const row = { id: ++idCounter, ...snapshotForm() };
// tempNumeric = [...tempNumeric, row];
// resetForm();
const isOverlap = tempNumeric.some(row => {
const existingLow = Number(row.Low);
const existingHigh = Number(row.High);
return !(high < existingLow || low > existingHigh);
});
if (isOverlap) {
props.refNumState.errors.High = "Range overlaps with existing data";
return;
}
const row = {
id: ++idCounter,
...snapshotForm()
};
tempNumeric = [...tempNumeric, row];
resetForm();
}
@ -163,6 +192,18 @@
}
})
});
$effect(() => {
// Sinkronisasi Low dan LowSign
if (!props.refNumState.form.Low || props.refNumState.form.Low === "") {
props.refNumState.form.LowSign = "";
}
// Sinkronisasi High dan HighSign
if (!props.refNumState.form.High || props.refNumState.form.High === "") {
props.refNumState.form.HighSign = "";
}
});
</script>
<div class="flex flex-col gap-4 w-full">
@ -230,8 +271,8 @@
</Table.Cell>
<Table.Cell class="font-medium flex justify-between">
<div>
{row.LowSign ? row.LowSign : ""} {row.Low || ""} &ndash;
{row.HighSign ? row.HighSign : ""} {row.High || ""}
{row.LowSign ? row.LowSign : ""} {row.Low || "null"} &ndash;
{row.HighSign ? row.HighSign : ""} {row.High || "null"}
</div>
<Badge variant="outline" class="border-dashed border-primary border-2">{numRefTypeBadge(row.NumRefType)}</Badge>
</Table.Cell>

View File

@ -92,6 +92,7 @@
type="text"
bind:value={formState.form[key]}
oninput={() => {
// console.log(`key: ${key}, form: ${formState.form[key]}`);
if (validateOn?.includes("input")) {
formState.validateField(key, formState.form[key], false);
}
@ -215,79 +216,6 @@
{/if}
</Select.Content>
</Select.Root>
<!-- {:else if type === "selectmultiple"}
{@const filteredOptions = getFilteredOptions(key)}
{@const selectedValues = Array.isArray(formState.form[key]) ? formState.form[key] : []}
{@const selectedOptions = selectedValues
.map(val => formState.selectOptions?.[key]?.find(opt => opt.value === val))
.filter(Boolean)}
<Select.Root type="multiple" bind:value={formState.form[key]}
onValueChange={(vals) => {
formState.form[key] = vals;
if (validateOn?.includes("input")) {
formState.validateField?.(key, formState.form[key], false);
}
if (key === 'FormulaInput') {
formState.validateField?.('FormulaCode', formState.form['FormulaCode'], false);
}
}}
onOpenChange={(open) => {
if (open && optionsEndpoint) {
formState.fetchOptions?.(
{ key, optionsEndpoint, dependsOn, endpointParamKey, valueKey, labelKey },
formState.form
);
}
}}
>
<Select.Trigger class="w-full min-h-[42px]">
{#if selectedOptions.length === 0}
<span class="text-muted-foreground">Choose</span>
{:else}
<div class="flex flex-wrap gap-1">
{#each selectedOptions as option}
<Badge variant="secondary" class="text-xs">
{option.label}
</Badge>
{/each}
</div>
{/if}
</Select.Trigger>
<Select.Content>
<div class="p-2">
<input
type="text"
placeholder="Search..."
class="w-full border rounded px-2 py-1 text-sm"
bind:value={searchQuery[key]}
/>
</div>
{#if formState.loadingOptions?.[key]}
<Select.Item disabled value="loading">Loading...</Select.Item>
{:else}
{#if !required}
<Select.Item value="">- None -</Select.Item>
{/if}
{#each filteredOptions as option}
<Select.Item value={option.value}
disabled={key === "ResultType" && disabledResultTypes.includes(option.value) ||
key === "RefType" && disabledReferenceTypes.includes(option.value)}
>
{option.label}
</Select.Item>
{/each}
{/if}
</Select.Content>
{#if selectedValues.length > 0}
<button
class="w-full px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground"
onclick={() => formState.form[key] = []}
>
Unselect All
</button>
{/if}
</Select.Root> -->
{:else if type === "date"}
<ReusableCalendar
bind:value={formState.form[key]}
@ -298,11 +226,18 @@
}
}}
/>
{:else if type === "signvalue"}
{:else if type === "signvalue"}
<InputGroup.Root>
<InputGroup.Input
placeholder="Type here"
bind:value={formState.form[txtKey]}
oninput={() => {
if (validateOn?.includes("input")) {
formState.validateField("Low", formState.form[txtKey]);
formState.validateField("LowSign");
formState.validateField("High", formState.form[txtKey]);
}
}}
/>
<InputGroup.Addon align="inline-start">
<DropdownMenu.Root
@ -322,9 +257,7 @@
{...props}
variant="ghost" disabled={disabledSign}
>
{formState.selectOptions?.[key]?.find(
opt => opt.value === formState.form[key]
)?.label || 'Choose'}
{formState.selectOptions?.[key]?.find(opt => opt.value === formState.form[key])?.label || 'Choose'}
<ChevronDownIcon />
</InputGroup.Button>
{/snippet}
@ -335,12 +268,27 @@
Loading...
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item
onSelect={() => {
formState.form[key] = "";
dropdownOpen[key] = false;
formState.validateField("LowSign");
formState.validateField("HighSign");
}}
>
- None -
</DropdownMenu.Item>
{#each formState.selectOptions?.[key] || [] as option}
<DropdownMenu.Item
onclick={() => {
formState.form[key] = option.value;
dropdownOpen[key] = false;
}}
onSelect={() => {
formState.form[key] = option.value;
formState.validateField("LowSign");
formState.validateField("HighSign");
}}
>
{option.label}
</DropdownMenu.Item>
@ -353,19 +301,43 @@
{:else if type === "agejoin"}
<div class="flex items-center gap-2 w-full">
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].YY}/>
<InputGroup.Input type="number" bind:value={joinFields[key].YY}
oninput={() => {
if (validateOn?.includes("input")) {
// formState.validateField(key, formState.form[key], false);
formState.validateField("AgeStart");
formState.validateField("AgeEnd");
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Year</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].MM}/>
<InputGroup.Input type="number" bind:value={joinFields[key].MM}
oninput={() => {
if (validateOn?.includes("input")) {
// formState.validateField(key, formState.form[key], false);
formState.validateField("AgeStart");
formState.validateField("AgeEnd");
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Month</InputGroup.Text>
</InputGroup.Addon>
</InputGroup.Root>
<InputGroup.Root>
<InputGroup.Input type="number" bind:value={joinFields[key].DD}/>
<InputGroup.Input type="number" bind:value={joinFields[key].DD}
oninput={() => {
if (validateOn?.includes("input")) {
// formState.validateField(key, formState.form[key], false);
formState.validateField("AgeStart");
formState.validateField("AgeEnd");
}
}}
/>
<InputGroup.Addon align="inline-end">
<InputGroup.Text>Day</InputGroup.Text>
</InputGroup.Addon>
@ -388,9 +360,9 @@
{formState.errors[key]}
</span>
{/if} -->
{#if formState.errors[key]}
{#if formState.errors[key] || formState.errors[txtKey]}
<span class="text-destructive text-sm leading-none">
{formState.errors[key]}
{formState.errors[key] ?? formState.errors[txtKey]}
</span>
<!-- {:else if dynamicRequiredFields.includes(key)}
<span class="text-destructive text-sm leading-none">

View File

@ -31,4 +31,32 @@ export function daysToAge(totalDays) {
const DD = remainingAfterYear % 30;
return { YY, MM, DD };
}
// export function toDays(ageString) {
// if (!ageString || typeof ageString !== 'string') return null;
// const match = ageString.match(/(\d+)\s*Y?\s*(\d+)\s*M?\s*(\d+)\s*D?/i);
// if (!match) return null;
// const YY = parseInt(match[1]) || 0;
// const MM = parseInt(match[2]) || 0;
// const DD = parseInt(match[3]) || 0;
// return YY * 365 + MM * 30 + DD;
// }
export function toDays(ageString) {
if (!ageString || typeof ageString !== "string") return null;
const match = ageString.match(/(?:(\d+)\s*Y)?\s*(?:(\d+)\s*M)?\s*(?:(\d+)\s*D)?/i);
if (!match) return null;
const YY = parseInt(match[1] || 0);
const MM = parseInt(match[2] || 0);
const DD = parseInt(match[3] || 0);
return YY * 365 + MM * 30 + DD;
}