Cleaning Up xStateFlows and abstracting generic interfaces

This commit is contained in:
Samuel Andert 2023-08-01 14:21:43 +02:00
parent 125d7d997e
commit ed351011d5
10 changed files with 403 additions and 19 deletions

View File

View File

@ -0,0 +1,88 @@
// src/lib/components/Recipies/machines/authMachine.ts
import { createMachine } from 'xstate';
export const authMachine = createMachine(
{
id: 'auth',
initial: 'notAuthenticated',
states: {
notAuthenticated: {
meta: {
title: 'Welcome!',
buttons: [
{
type: 'START',
label: 'Login',
disabled: false
}
]
},
on: {
START: {
target: 'signIn',
actions: 'startSignIn'
}
}
},
signIn: {
meta: {
title: 'Sign In',
composite: {
id: 'signInForm',
component: 'oForm'
},
buttons: [
{
type: 'BACK',
label: 'Back',
disabled: true
},
{
type: 'NEXT',
label: 'Authenticate',
disabled: false
}
]
},
on: {
BACK: 'notAuthenticated',
NEXT: {
target: 'authenticated',
actions: 'authenticate'
}
}
},
authenticated: {
meta: {
title: 'Authenticated',
buttons: [
{
type: 'LOGOUT',
label: 'Logout',
disabled: false
}
]
},
on: {
LOGOUT: {
target: 'notAuthenticated',
actions: 'logout'
}
}
}
}
},
{
actions: {
startSignIn: (context, event) => {
console.log('Starting sign in process...');
},
authenticate: (context, event) => {
console.log('Authenticating...');
},
logout: (context, event) => {
console.log('Logging out...');
}
}
}
)

View File

@ -0,0 +1,168 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms/client';
import { UserSchema } from '$lib/types/UserSchema';
import { afterUpdate } from 'svelte';
import { writable } from 'svelte/store';
import { RangeSlider, SlideToggle } from '@skeletonlabs/skeleton';
export let store;
export let services;
let isStoreLoaded = false;
$: if ($store) isStoreLoaded = true;
const initialFormData = {
name: '',
email: '',
about: '',
age: '',
favoriteFood: ''
};
const fields = ['name', 'email', 'about', 'age', 'favoriteFood'];
const { form, errors, validate, constraints } = superForm(initialFormData, {
validators: UserSchema,
warnings: {
noValidationAndConstraints: false
},
validationMethod: 'oninput', // Trigger validation on input events
clearOnSubmit: 'errors-and-message'
});
const successMessage = writable<string | null>(null);
// Update the isValidated property of the store whenever the errors object changes
$: {
$store.isValidated = !(
$errors.name ||
$errors.email ||
$errors.about ||
$errors.age ||
$errors.favoriteFood
);
}
async function handleSubmit() {
// Manually validate the form
const validationResult = await validate();
// Prevent submission if there are errors
if (!validationResult.valid) {
return;
}
// Here, we'll just log the form data
console.log(form);
// Set the success message after successful submission
successMessage.set('Form submitted successfully!');
}
function handleReset() {
// Reset the form and remove the message
form.set({});
successMessage.set(null);
}
// After update, scroll to the message container for better user experience
afterUpdate(() => {
const messageContainer = document.getElementById('message-container');
if (messageContainer) {
messageContainer.scrollIntoView({ behavior: 'smooth' });
}
});
</script>
{#if isStoreLoaded}
<div class="w-full">
{#if $successMessage}
<aside class="w-full max-w-md p-4 alert variant-ghost" id="message-container">
<div class="alert-message">
<h3 class="h3">Success</h3>
<p>{$successMessage}</p>
</div>
<div class="alert-actions">
<button
class="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
on:click={handleReset}
>
Reset
</button>
</div>
</aside>
{:else}
<form on:submit|preventDefault={handleSubmit} class="w-full max-w-md">
{#each fields as field}
<div class="mb-4">
{#if $errors[field]}
<span class="block mb-2 font-semibold text-red-500">{$errors[field]}</span>
{:else}
<label for={field} class="block mb-2 font-semibold text-white"
>{field.charAt(0).toUpperCase() + field.slice(1)}
</label>
{/if}
{#if field === 'about'}
<textarea
name={field}
class="w-full px-3 py-2 bg-transparent border-gray-100 rounded-md border-1 ring-0 ring-white focus:outline-none focus:ring-2 focus:ring-blue-500"
bind:value={$form[field]}
aria-invalid={$errors[field] ? 'true' : undefined}
{...constraints[field]}
/>
{:else if field === 'favoriteFood'}
<select
name={field}
class="w-full px-3 py-2 bg-transparent border-gray-100 rounded-md border-1 ring-0 ring-white focus:outline-none focus:ring-2 focus:ring-blue-500"
bind:value={$form[field]}
aria-invalid={$errors[field] ? 'true' : undefined}
{...constraints[field]}
>
<option value="">Select...</option>
<option value="apple">Apple</option>
<option value="banana">Banana</option>
<option value="coconut">Coconut</option>
<option value="strawberry">Strawberry</option>
<option value="mango">Mango</option>
</select>
{:else if field === 'age'}
<input
name={field}
type="number"
class="w-full px-3 py-2 bg-transparent border-gray-100 rounded-md border-1 ring-0 ring-white focus:outline-none focus:ring-2 focus:ring-blue-500"
bind:value={$form[field]}
aria-invalid={$errors[field] ? 'true' : undefined}
{...constraints[field]}
/>
{:else if field === 'slider'}
<RangeSlider name={field} bind:value={$form[field]} min={0} max={100} step={1} ticked>
<div class="flex items-center justify-between">
<div class="text-xs">{$form[field]} / 100</div>
</div>
</RangeSlider>
{:else if field === 'toggle'}
<SlideToggle name={field} bind:checked={$form[field]} />
{:else}
<input
name={field}
type="text"
class="w-full px-3 py-2 bg-transparent border-gray-100 rounded-md border-1 ring-0 ring-white focus:outline-none focus:ring-2 focus:ring-blue-500"
bind:value={$form[field]}
aria-invalid={$errors[field] ? 'true' : undefined}
{...constraints[field]}
/>
{/if}
</div>
{/each}
<button
type="submit"
class="w-full px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!$store.isValidated}
>
Submit
</button>
</form>
{/if}
</div>
{/if}

View File

@ -0,0 +1,45 @@
<!-- src/lib/components/Recipies/oRecipe.svelte -->
<script>
import { interpret } from 'xstate';
import Composite from '$lib/core/Composite.svelte';
export let machine;
let current = machine.initialState.value;
const service = interpret(machine)
.onTransition((state) => {
current = state.value;
})
.start();
// Derive possible actions from the current state
$: possibleActions = machine.states[current]?.meta.buttons || [];
</script>
<main class="grid w-full h-full grid-rows-layout">
<div class="w-full h-full p-4 overflow-scroll">
<p>Current state: {current}</p>
<h1>{machine.states[current].meta.title}</h1>
{#if machine.states[current].meta.composite}
<Composite composite={machine.states[current].meta.composite} />
{/if}
</div>
<div class="p-4">
{#each possibleActions as { type, label, disabled } (type)}
<button
class="px-4 py-2 text-white bg-blue-500 rounded"
on:click={() => service.send(type)}
{disabled}
>
{label}
</button>
{/each}
</div>
</main>
<style>
.grid-rows-layout {
grid-template-rows: 1fr auto;
}
</style>

View File

@ -16,12 +16,12 @@
email: '',
about: '',
age: '',
favoriteFood: '',
slider: 0,
toggle: false
favoriteFood: ''
// slider: 0,
// toggle: false
};
const fields = ['name', 'email', 'about', 'age', 'favoriteFood', 'slider', 'toggle'];
const fields = ['name', 'email', 'about', 'age', 'favoriteFood'];
const { form, errors, validate, constraints } = superForm(initialFormData, {
validators: UserSchema,
@ -45,20 +45,6 @@
);
}
$: {
if (
!$errors.name &&
!$errors.email &&
!$errors.about &&
!$errors.age &&
!$errors.favoriteFood
) {
services.validationRecipe.validateMe().send('VALIDATE');
} else {
services.validationRecipe.validateMe().send('INVALIDATE');
}
}
async function handleSubmit() {
// Manually validate the form
const validationResult = await validate();

View File

@ -1 +0,0 @@
{"core":{},"composite.Interpreter2":{"id":"validation"},"helloEarthAlert":{}}

View File

@ -0,0 +1,50 @@
<!-- src/lib/components/machines/SignIn.svelte -->
<script lang="ts">
import { useMachine } from '@xstate/svelte';
import { SignInMachine } from './signInMachine';
import { superForm } from 'sveltekit-superforms/client';
import { HelloUserSchema } from '$lib/types/HelloUserSchema';
const { state, send } = useMachine(SignInMachine);
const initialFormData = {
email: '',
name: ''
};
const { form, errors, validate, constraints } = superForm(initialFormData, {
validators: HelloUserSchema,
warnings: {
noValidationAndConstraints: false
},
validationMethod: 'oninput',
clearOnSubmit: 'errors-and-message'
});
async function handleNext(field) {
const validationResult = await validate(field);
if (validationResult.valid) {
send({ type: 'NEXT', value: form[field] });
}
}
</script>
{#if $state.matches('notSignedIn')}
<button on:click={() => send('START')}>Start Sign In</button>
{:else if $state.matches('email')}
<div>
<label for="email">Email:</label>
<input id="email" type="email" bind:value={form.email} {...constraints.email} />
{#if errors.email}<span>{$errors.email}</span>{/if}
<button on:click={() => handleNext('email')}>Next</button>
</div>
{:else if $state.matches('name')}
<div>
<label for="name">Name:</label>
<input id="name" type="text" bind:value={form.name} {...constraints.name} />
{#if errors.name}<span>{$errors.name}</span>{/if}
<button on:click={() => handleNext('name')}>Next</button>
</div>
{:else if $state.matches('signedIn')}
<!-- <Composite id="account" component="Account" /> -->account signed in
{/if}

View File

@ -0,0 +1,35 @@
// src/lib/components/machines/SignInMachine.ts
import { createMachine, assign } from 'xstate';
export const SignInMachine = createMachine({
id: 'signIn',
initial: 'notSignedIn',
context: {
email: '',
name: ''
},
states: {
notSignedIn: {
on: { START: 'email' }
},
email: {
on: {
NEXT: {
target: 'name',
actions: assign({ email: (_, event) => event.value })
}
}
},
name: {
on: {
NEXT: {
target: 'signedIn',
actions: assign({ name: (_, event) => event.value })
}
}
},
signedIn: {
type: 'final'
}
}
});

View File

@ -0,0 +1,7 @@
// src/lib/types/HelloUserSchema.ts
import { z } from 'zod';
export const HelloUserSchema = z.object({
email: z.string().email('Invalid email').nonempty('Email is required'),
name: z.string().min(2, 'Name is too short').max(50, 'Name is too long').nonempty('Name is required')
});

View File

@ -0,0 +1,6 @@
<script lang="ts">
import { authMachine } from '$lib/components/Recipies/machines/authMachine';
import ORecipe from '$lib/components/Recipies/oRecipe.svelte';
</script>
<ORecipe machine={authMachine} />