Skip to content

Commit

Permalink
fix(console): refine user profile form
Browse files Browse the repository at this point in the history
  • Loading branch information
xmlking committed Jul 14, 2024
1 parent 5dea00f commit 1a7434f
Show file tree
Hide file tree
Showing 2 changed files with 181 additions and 42 deletions.
32 changes: 31 additions & 1 deletion apps/console/src/lib/schema/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { PUBLIC_DEFAULT_ORGANIZATION } from '$env/static/public';
import { Roles } from '$lib/types';
import { z } from 'zod';

const phoneRegex = new RegExp(/^([+]?[\s0-9]+)?(\d{3}|[(]?[0-9]+[)])?([-]?[\s]?[0-9])+$/);

/**
* General User Schema
*/
Expand Down Expand Up @@ -33,7 +36,15 @@ export const userSchema = z.object({
terms: z
.literal<boolean>(true, { errorMap: () => ({ message: 'Please accept Terms of Service to continue' }) })
.default(false),
role: z.enum(['USER', 'PREMIUM', 'ADMIN'], { required_error: 'You must have a role' }).default('USER'),
displayName: z
.string({ required_error: 'Display Name is required' })
.min(2, { message: 'Display Name must contain at least 2 character(s)' })
.max(256)
.trim(),
phoneNumber: z.string().regex(phoneRegex, 'Invalid Number!'),
avatarUrl: z.string().url(),
defaultRole: z.nativeEnum(Roles, { required_error: 'You must have a role' }).default(Roles.User),
plan: z.enum(['free', 'pro', 'enterprise']),
verified: z.boolean().default(false),
token: z.string().optional(),
receiveEmail: z.boolean().default(true),
Expand All @@ -42,6 +53,25 @@ export const userSchema = z.object({
organization: z.string().default(PUBLIC_DEFAULT_ORGANIZATION),
});

/**
* Update User Details
*/
export const updateUserDetailsSchema = userSchema.omit({
firstName: true,
lastName: true,
password: true,
confirmPassword: true,
terms: true,
verified: true,
token: true,
receiveEmail: true,
createdAt: true,
updatedAt: true,
});
export type UpdateUserDetailsSchema = typeof updateUserDetailsSchema;
export type updateUserDetails = z.infer<typeof updateUserDetailsSchema>;
export const updateUserDetailsKeys = updateUserDetailsSchema.keyof().Enum;

/**
* Sign in with password
*/
Expand Down
191 changes: 150 additions & 41 deletions apps/console/src/routes/(app)/profile/components/user-details.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
<script lang="ts">
import { PendingValue, type UserDetailsFragment, fragment, graphql } from '$houdini';
import { Accordion, AccordionItem, AppBar, Avatar, NoirLight, filter } from '@skeletonlabs/skeleton';
import { camelize } from '@spectacular/utils';
import { page } from '$app/stores';
import { CachePolicy, GetUserStore, PendingValue, type UserDetailsFragment, fragment, graphql } from '$houdini';
import { handleMessage } from '$lib/components/layout/toast-manager';
import { updateUserDetailsSchema } from '$lib/schema/user';
import { getLoadingState } from '$lib/stores/loading';
import { getNhostClient } from '$lib/stores/nhost';
import { AppBar, Avatar, NoirLight, filter, getToastStore } from '@skeletonlabs/skeleton';
import { DebugShell } from '@spectacular/skeleton';
import { Alerts } from '@spectacular/skeleton/components/form';
import { Logger } from '@spectacular/utils';
import * as Form from 'formsnap';
import { UserRound } from 'lucide-svelte';
import SuperDebug, { type ErrorStatus, defaults, setError, setMessage, superForm } from 'sveltekit-superforms';
import { zod, zodClient } from 'sveltekit-superforms/adapters';
export let user: UserDetailsFragment;
$: data = fragment(
Expand All @@ -21,6 +31,84 @@ $: data = fragment(
}
`),
);
// Variables
const log = new Logger('profile:profile:details:browser');
const toastStore = getToastStore();
const loadingState = getLoadingState();
const nhost = getNhostClient();
const form = superForm(defaults(zod(updateUserDetailsSchema)), {
SPA: true,
dataType: 'json',
taintedMessage: null,
clearOnSubmit: 'errors-and-message',
delayMs: 100,
timeoutMs: 4000,
resetForm: true,
invalidateAll: false, // this is key to avoid unnecessary data fetch call while using houdini smart cache.
validators: zodClient(updateUserDetailsSchema),
async onUpdate({ form, cancel }) {
if (!form.valid) return;
// First, check if elevate is required
const error = await nhost.elevate();
if (error) {
log.error('Error elevating user', { error });
setError(form, '', error.message, {
status: error.status as ErrorStatus,
});
return;
}
// Second, update user profile
// TODO
// Finally notify user: successfully added a new security key
const message = {
message: 'User Details Updated',
hideDismiss: true,
timeout: 10000,
type: 'success',
} as const;
setMessage(form, message);
handleMessage(message, toastStore);
// TODO: https://github.com/HoudiniGraphql/houdini/issues/891
// TODO: add { id, personalAccessToken } to cache, instead of reload()
await reload();
},
});
const {
form: formData,
errors,
allErrors,
message,
constraints,
submitting,
delayed,
tainted,
timeout,
posted,
enhance,
} = form;
// Functions
/**
* FIXME: Workaround for refresh page, after first time security token added
* https://github.com/HoudiniGraphql/houdini/issues/891
*/
async function reload() {
const getUserStore = new GetUserStore();
// const userId = '076a79f9-ed08-4e28-a4c3-8d4e0aa269a3'
const userId = $page.data.session.user.id;
console.log({ userId });
const { data, errors } = await getUserStore.fetch({
blocking: true,
policy: CachePolicy.NetworkOnly,
variables: { userId },
});
console.log({ data, errors });
}
// Reactivity
$: valid = $allErrors.length === 0;
$: loadingState.setFormLoading($delayed);
</script>

<AppBar>
Expand All @@ -45,70 +133,91 @@ $: data = fragment(
</svelte:fragment>
</AppBar>

<div class="card p-4">
<dl class="list-dl w-full">
{#each Object.entries($data) as [key, value]}
<div>
<dt class="font-bold">{camelize(key)} :</dt>
<dd>
{#if value === PendingValue}
<div class="placeholder animate-pulse" />
{:else}
{value}
{/if}
</dd>
</div>
{/each}
</dl>
</div>

<div class="card bg-initial card-hover overflow-hidden"
><header>
<!-- Form Level Errors / Messages -->
<Alerts errors={$errors._errors} message={$message} />
<!-- Update User Details Form -->
<div class="card">
<header class="card-header">
<div class="text-2xl">Edit Profile</div>
<div>Update your account information</div>
</header>
<div class="grid grid-cols-2 gap-4">
<div class="p-4 grid grid-cols-2 gap-4">
<div class="space-y-4">
<div class="grid gap-2">
<!-- <Form.Field {form} name="displayName">
<Form.Control let:attrs>
<Form.Label class="label">Display Name</Form.Label>
<input
type="text"
class="input data-[fs-error]:input-error"
{...attrs}
bind:value={$formData.displayName}
/>
</Form.Control>
<Form.Description class="sr-only md:not-sr-only text-sm text-gray-500">Enter name for the PAT</Form.Description>
<Form.FieldErrors class="data-[fs-error]:text-error-500" />
</Form.Field> -->
<label for="displayName">Display Name</label>
<input id="displayName" placeholder="John Doe" />
<input class="input" id="displayName" placeholder="John Doe" bind:value={$data.displayName} />
</div>
<div class="grid gap-2">
<label for="email">Email</label>
<input id="email" type="email" placeholder="[email protected]" />
<input class="input" id="email" type="email" placeholder="[email protected]" bind:value={$data.email} />
</div>
</div>
<div class="space-y-4">
<div class="grid gap-2">
<label for="phoneNumber">Phone Number</label>
<input id="phoneNumber" type="tel" placeholder="+1 (555) 555-5555" />
</div>
<div class="grid gap-2">
<label for="avatarUrl">Avatar URL</label>
<input id="avatarUrl" type="url" placeholder="https://example.com/avatar.jpg" />
</div>
</div>
<div class="col-span-2 space-y-4">
<div class="grid gap-2">
<label for="locale">Locale</label>
<select id="locale">
<select class="select" id="locale">
<option value="en-US">English (US)</option>
<option value="es-ES">Español (España)</option>
<option value="fr-FR">Français (France)</option>
<option value="de-DE">Deutsch (Deutschland)</option>
</select>
</div>
</div>
<div class="space-y-4">
<div class="grid gap-2">
<label for="phoneNumber">Phone Number</label>
<input class="input" id="phoneNumber" type="tel" placeholder="+1 (555) 555-5555" bind:value={$data.phoneNumber} />
</div>
<div class="grid gap-2">
<label for="defaultRole">Default Role</label>
<input class="input" id="defaultRole" type="text" placeholder="Manager" bind:value={$data.defaultRole} />
</div>
<div class="grid gap-2">
<label for="plan">Plan</label>
<select id="plan">
<select class="select" id="plan">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
</div>
<div class="col-span-2 space-y-4">
<div class="grid gap-2">
<label for="avatarUrl">Avatar URL</label>
<input class="input" id="avatarUrl" type="url" placeholder="https://example.com/avatar.jpg" bind:value={$data.avatarUrl} />
</div>
</div>
</div>
<footer class="p-4 flex justify-start items-center space-x-4">
<button class="btn variant-filled w-full">Save Changes</button>
<footer class="card-footer flex justify-end">
<button type="submit" class="btn variant-filled-secondary">Save Changes</button>
</footer>
</div>
<!-- Debug -->
<DebugShell>
<SuperDebug
data={{
message: $message,
submitting: $submitting,
delayed: $delayed,
timeout: $timeout,
posted: $posted,
formData: $formData,
errors: $errors,
constraints: $constraints,
}}
theme="vscode"
--sd-code-date="lightgreen"
/>
<SuperDebug label="$page data" data={page} collapsible />
</DebugShell>

0 comments on commit 1a7434f

Please sign in to comment.