refactor zo zod and felte

This commit is contained in:
Christian Beutel
2024-12-30 19:52:44 +01:00
parent 75c94a3ce4
commit 871481a84e
45 changed files with 659 additions and 1040 deletions

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = "";
export let value: string | Date = "";
export let label: string = "";
export let error: string = "";
export let error: string | string[] | null = "";
</script>
<div>
@@ -15,15 +17,17 @@
<input
{name}
class="bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full"
class:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0}
class:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0}
type="date"
bind:value
on:change
/>
</div>
<span class="textfield-error text-xs text-red-400">
{error}
</span>
{#if error}
<span class="textfield-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div>

View File

@@ -9,8 +9,8 @@
import { createEventDispatcher } from "svelte";
export let name: string = "";
export let value: any;
export let items: SelectItem[] = [];
export let value: any = items.at(0)?.value ?? "";
export let label: string = "";
export let disabled: boolean = false;

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = "";
export let value: string | number = "";
export let placeholder: string = "";
export let disabled: boolean = false;
export let label: string = "";
export let error: string = "";
export let error: string | string[] | null = "";
export let icon: string = "";
export let extraClasses: string = "";
export let type: "text" | "password" | "search" = "text";
@@ -29,8 +31,8 @@
<input
{name}
class="bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}"
class:border-red-400={error.length > 0}
class:bg-input-background-error={error.length > 0}
class:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0}
class:text-gray-500={disabled}
{disabled}
{autocomplete}
@@ -44,7 +46,9 @@
/>
</div>
<span class="textfield-error text-xs text-red-400">
{error}
</span>
{#if error}
<span class="textfield-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div>

View File

@@ -1,10 +1,12 @@
<script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = "";
export let value: string | number = "";
export let placeholder: string = "";
export let rows: number = 3;
export let label: string = "";
export let error: string = "";
export let error: string | string[] | null = "";
export let extraClasses: string = "";
</script>
@@ -19,12 +21,14 @@
class="bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}"
{rows}
{placeholder}
class:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0}
class:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0}
bind:value
on:change
/>
<span class="textfield-error text-xs text-red-400">
{error}
</span>
{#if error}
<span class="textfield-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div>

View File

@@ -125,13 +125,17 @@
{#each { length: 5 } as _, index}
<SkeletonNotificationCard></SkeletonNotificationCard>
{/each}
{:else}
{:else if notifications.length}
{#each notifications as notification}
<NotificationCard
on:click={handleNotificationClick}
{notification}
></NotificationCard>
{/each}
{:else}
<p class="text-gray-500 text-sm text-center py-6">
No notifications
</p>
{/if}
</ul>
{/if}

View File

@@ -1,16 +1,17 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import { z } from "zod";
import TextField from "../base/text_field.svelte";
import { createForm } from "$lib/vendor/svelte-form-lib";
import { object, string } from "yup";
let _openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openModal() {
$form.email = email
$errors.email = "";
setFields("email", email);
setErrors("email", []);
_openModal!();
}
@@ -18,17 +19,20 @@
const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<{
const { form, errors, setFields, setErrors } = createForm<{
email: string;
}>({
initialValues: { email: email },
validationSchema: object({
email: string()
.email($_("not-a-valid-email-address"))
.required($_("required")),
extend: validator({
schema: z.object({
email: z
.string()
.min(1, "required")
.email("not-a-valid-email-address"),
}),
}),
onSubmit: async (_) => {
dispatch("save", $form.email);
onSubmit: async (form) => {
dispatch("save", form.email);
closeModal!();
},
});
@@ -41,13 +45,8 @@
bind:openModal={_openModal}
bind:closeModal
>
<form id="email-form" slot="content" on:submit={handleSubmit}>
<TextField
name="email"
bind:value={$form.email}
error={$errors.email}
on:change={handleChange}
></TextField>
<form id="email-form" slot="content" use:form>
<TextField name="email" error={$errors.email}></TextField>
</form>
<div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal}

View File

@@ -3,46 +3,61 @@
import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n";
import TextField from "../base/text_field.svelte";
import { createForm } from "$lib/vendor/svelte-form-lib";
import { object, string } from "yup";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { z } from "zod";
let _openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
export function openModal() {
$form.password = ""
$errors.password = "";
$form.oldPassword = ""
$errors.oldPassword = "";
setFields("password", "")
setFields("oldPassword", "")
setErrors("password", [])
setErrors("oldPassword", [])
_openModal!();
}
const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<{
const { form, errors, setFields, setErrors } = createForm<{
oldPassword: string;
password: string;
passwordConfirm: string;
}>({
initialValues: { oldPassword: "", password: "" },
validationSchema: object({
oldPassword: string()
.min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
)
.required($_("required")),
password: string()
.min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
)
.required($_("required")),
extend: validator({
schema: z
.object({
oldPassword: z.string().min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
password: z.string().min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
passwordConfirm: z.string().min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
})
.refine((d) => d.password === d.passwordConfirm, {
message: "passwords-must-match",
path: ["passwordConfirm"],
}),
}),
onSubmit: async (_) => {
dispatch("save", {oldPassword: $form.oldPassword, password: $form.password, passwordConfirm: $form.password});
onSubmit: async (form) => {
dispatch("save", {
oldPassword: form.oldPassword,
password: form.password,
passwordConfirm: form.password,
});
closeModal!();
},
});
@@ -55,30 +70,35 @@
bind:openModal={_openModal}
bind:closeModal
>
<form id="password-form" slot="content" on:submit={handleSubmit}>
<form id="password-form" slot="content" use:form>
<TextField
name="oldPassword"
label={$_("current-password")}
type="password"
bind:value={$form.oldPassword}
error={$errors.oldPassword}
on:change={handleChange}
></TextField>
<TextField
name="password"
label={$_("new-password")}
type="password"
bind:value={$form.password}
error={$errors.password}
on:change={handleChange}
></TextField>
<TextField
name="passwordConfirm"
label={$_("password-confirm")}
type="password"
error={$errors.passwordConfirm}
></TextField>
</form>
<div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal}
>{$_("cancel")}</button
>
<button class="btn-primary" type="submit" form="password-form" name="save"
>{$_("save")}</button
<button
class="btn-primary"
type="submit"
form="password-form"
name="save">{$_("save")}</button
>
</div></Modal
>

View File

@@ -1,41 +1,50 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { type SummitLog } from "$lib/models/summit_log";
import {
SummitLogCreateSchema,
} from "$lib/models/api/summit_log_schema";
import GPX from "$lib/models/gpx/gpx";
import { summitLog } from "$lib/stores/summit_log_store";
import { createForm } from "$lib/vendor/svelte-form-lib/index";
import { util } from "$lib/vendor/svelte-form-lib/util";
import { createForm } from "felte";
import { validator } from "@felte/validator-zod";
import { _ } from "svelte-i18n";
import { date, object, string } from "yup";
import { z } from "zod";
import Datepicker from "../base/datepicker.svelte";
import Modal from "../base/modal.svelte";
import Textarea from "../base/textarea.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
import GPX from "$lib/models/gpx/gpx";
import PhotoPicker from "../trail/photo_picker.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
import { cloneDeep } from "$lib/util/deep_util";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher();
const summitLogSchema = object<SummitLog>({
id: string().optional(),
date: date().required("Required").typeError($_("invalid-date")),
text: string().optional(),
const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
_gpx: z.instanceof(File).optional().nullable(),
expand: z
.object({
gpx_data: z.string().optional(),
})
.optional(),
});
const { form, errors, handleChange, handleSubmit } = createForm<SummitLog>({
const { form, errors, data, setFields } = createForm<
z.infer<typeof ClientSummitLogCreateSchema>
>({
initialValues: $summitLog,
validationSchema: summitLogSchema,
onSubmit: async (submittedValues) => {
if (!$form.expand.gpx_data) {
$form.gpx = "";
extend: validator({ schema: ClientSummitLogCreateSchema }),
onSubmit: async (form) => {
if (!form.expand?.gpx_data) {
form.gpx = "";
}
if (
!$form._photos?.length &&
!$form.photos?.length &&
$form.expand?.gpx_data
!form._photos?.length &&
!form.photos?.length &&
form.expand?.gpx_data
) {
const canvas = document.querySelector(
"#trail-picker-map .maplibregl-canvas",
@@ -44,25 +53,25 @@
const dataURL = canvas.toDataURL();
const response = await fetch(dataURL);
const blob = await response.blob();
$form._photos = [new File([blob], "route")];
form._photos = [new File([blob], "route")];
}
dispatch("save", submittedValues);
dispatch("save", form);
closeModal!();
},
});
$: form.set(util.cloneDeep($summitLog));
$: setFields(cloneDeep($summitLog));
$: if ($summitLog._gpx) {
$form._gpx = $summitLog._gpx;
$data._gpx = $summitLog._gpx;
}
async function handleTrailSelection(trailData: string | null) {
if (!trailData) {
$form.duration = undefined;
$form.elevation_gain = undefined;
$form.elevation_loss = undefined;
$form.distance = undefined;
$data.duration = undefined;
$data.elevation_gain = undefined;
$data.elevation_loss = undefined;
$data.distance = undefined;
return;
}
const gpxObject = await GPX.parse(trailData);
@@ -72,16 +81,15 @@
const totals = gpxObject.getTotals();
$form.duration = totals.duration / 1000;
$form.elevation_gain = totals.elevationGain;
$form.elevation_loss = totals.elevationLoss;
$form.distance = totals.distance;
$data.duration = totals.duration / 1000;
$data.elevation_gain = totals.elevationGain;
$data.elevation_loss = totals.elevationLoss;
$data.distance = totals.distance;
}
</script>
<Modal
id="summit-log-modal"
title={$form.id ? $_("edit-entry") : $_("add-entry")}
title={$data.id ? $_("edit-entry") : $_("add-entry")}
let:openModal
bind:openModal
bind:closeModal
@@ -91,15 +99,10 @@
id="summit-log-form"
slot="content"
class="modal-content space-y-4"
on:submit={handleSubmit}
use:form
>
<div class="flex">
<Datepicker
name="date"
label={$_("date")}
bind:value={$form.date}
error={$errors.date}
on:change={handleChange}
<Datepicker name="date" label={$_("date")} error={$errors.date}
></Datepicker>
</div>
<div>
@@ -108,27 +111,27 @@
</label>
<PhotoPicker
id="summitlog-photo-input"
parent={$form}
bind:photos={$form.photos}
bind:photoFiles={$form._photos}
parent={$data}
bind:photos={$data.photos}
bind:photoFiles={$data._photos}
showThumbnailControls={false}
></PhotoPicker>
</div>
<div class="flex gap-4">
<TrailPicker
bind:trailFile={$form._gpx}
bind:trailData={$form.expand.gpx_data}
label={$_("trail", { values: { n: 1 } })}
on:change={(e) => handleTrailSelection(e.detail)}
></TrailPicker>
{#if $data.expand}
<TrailPicker
bind:trailFile={$data._gpx}
bind:trailData={$data.expand.gpx_data}
label={$_("trail", { values: { n: 1 } })}
on:change={(e) => handleTrailSelection(e.detail)}
></TrailPicker>
{/if}
<div class="basis-full">
<Textarea
name="text"
extraClasses="h-28"
label={$_("text")}
bind:value={$form.text}
error={$errors.text}
on:change={handleChange}
></Textarea>
</div>
</div>

View File

@@ -3,7 +3,7 @@
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
import { createEventDispatcher, onMount } from "svelte";
export let trailFile: File | null;
export let trailFile: File | undefined | null;
export let trailData: string | undefined;
export let label: string = "";
@@ -31,7 +31,7 @@
attributionControl: false,
dragPan: false,
scrollZoom: false,
preserveDrawingBuffer: true
preserveDrawingBuffer: true,
});
}
@@ -95,9 +95,12 @@
},
});
layer = map.getLayer(layerId) as M.LineLayerSpecification;
source = map.getSource(sourceId) as M.GeoJSONSource;
map.resize()
map.fitBounds(geojson.bbox as M.LngLatBoundsLike, {animate: false, padding: 8});
source = map.getSource(sourceId) as M.GeoJSONSource;
map.resize();
map.fitBounds(geojson.bbox as M.LngLatBoundsLike, {
animate: false,
padding: 8,
});
}
function removeTrailFromMap() {

View File

@@ -1,42 +1,58 @@
<script lang="ts">
import { Waypoint, waypointSchema } from "$lib/models/waypoint";
import { createEventDispatcher } from "svelte";
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
import { convertDMSToDD } from "$lib/models/gpx/utils";
import { show_toast } from "$lib/stores/toast_store";
import { waypoint } from "$lib/stores/waypoint_store";
import { cloneDeep } from "$lib/util/deep_util";
import { icons } from "$lib/util/icon_util";
import EXIF from "$lib/vendor/exif-js/exif";
import { createForm } from "$lib/vendor/svelte-form-lib/index";
import { util } from "$lib/vendor/svelte-form-lib/util";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
import { z } from "zod";
import Combobox from "../base/combobox.svelte";
import Modal from "../base/modal.svelte";
import TextField from "../base/text_field.svelte";
import Textarea from "../base/textarea.svelte";
import PhotoPicker from "../trail/photo_picker.svelte";
import { show_toast } from "$lib/stores/toast_store";
import { convertDMSToDD } from "$lib/models/gpx/utils";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<Waypoint>({
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
});
const { form, errors, data, setFields } = createForm<
z.infer<typeof ClientWaypointCreateSchema>
>({
initialValues: $waypoint,
validationSchema: waypointSchema,
onSubmit: async (submittedWaypoint) => {
dispatch("save", submittedWaypoint);
extend: validator({ schema: ClientWaypointCreateSchema }),
onSubmit: async (form) => {
dispatch("save", form);
closeModal!();
},
transform: (values: unknown) => {
const v = values as any;
return {
...v,
lat: parseFloat(v.lat),
lon: parseFloat(v.lon),
};
},
});
$: form.set(util.cloneDeep($waypoint));
$: setFields(cloneDeep($waypoint));
$: filteredIcons =
($form.icon?.length ?? 0) > 2
($data.icon?.length ?? 0) > 2
? icons
.filter((i) => i.includes($form.icon ?? ""))
.filter((i) => i.includes($data.icon ?? ""))
.map((i) => ({ text: i, value: i, icon: i }))
: [];
@@ -48,8 +64,8 @@
const lonDir = EXIF.getTag(p, "GPSLongitudeRef");
if (lat && lon) {
$form.lat = convertDMSToDD(lat, latDir);
$form.lon = convertDMSToDD(lon, lonDir);
setFields("lat", convertDMSToDD(lat, latDir));
setFields("lon", convertDMSToDD(lon, lonDir));
} else {
show_toast({
text: "No GPS data in image",
@@ -63,7 +79,7 @@
<Modal
id="waypoint-modal"
title={$form.id ? $_("edit-waypoint") : $_("add-waypoint")}
title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
let:openModal
bind:openModal
bind:closeModal
@@ -73,50 +89,31 @@
id="waypoint-form"
slot="content"
class="modal-content space-y-4"
on:submit={handleSubmit}
use:form
>
<div class="flex gap-4">
<div class="basis-2/3">
<TextField
name="name"
label={$_("name")}
bind:value={$form.name}
error={$errors.name}
on:change={handleChange}
<TextField name="name" label={$_("name")} error={$errors.name}
></TextField>
</div>
<Combobox
name="icon"
bind:value={$form.icon}
icon={$form.icon}
icon={$data.icon}
items={filteredIcons}
label={$_("icon")}
on:change={handleChange}
></Combobox>
</div>
<Textarea
name="description"
label={$_("description")}
bind:value={$form.description}
error={$errors.description}
on:change={handleChange}
></Textarea>
<div class="flex gap-4">
<TextField
name="lat"
label={$_("latitude")}
bind:value={$form.lat}
error={$errors.lat}
on:change={handleChange}
<TextField name="lat" label={$_("latitude")} error={$errors.lat}
></TextField>
<TextField
name="lon"
label={$_("longitude")}
bind:value={$form.lon}
error={$errors.lat}
on:change={handleChange}
<TextField name="lon" label={$_("longitude")} error={$errors.lat}
></TextField>
</div>
<div>
@@ -125,10 +122,10 @@
</label>
<PhotoPicker
id="waypoint-photo-input"
parent={$form}
parent={$data}
on:exif={(e) => getCoordinatesFromPhoto(e.detail)}
bind:photos={$form.photos}
bind:photoFiles={$form._photos}
bind:photos={$data.photos}
bind:photoFiles={$data._photos}
showThumbnailControls={false}
showExifControls={true}
></PhotoPicker>