Skip to content

Commit

Permalink
chore: show an error panel when an unexpected error occurs
Browse files Browse the repository at this point in the history
  • Loading branch information
YuukanOO committed Nov 21, 2023
1 parent 3f6f280 commit 7f6d044
Show file tree
Hide file tree
Showing 11 changed files with 314 additions and 238 deletions.
17 changes: 17 additions & 0 deletions cmd/serve/front/src/components/form-errors.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { GLOBAL_ERROR_NAME, type SubmitterErrors } from '$lib/form';
import Panel from '$components/panel.svelte';
export let title = 'Error';
export let errors: SubmitterErrors;
let className = '';
export { className as class };
</script>

{#if errors?.[GLOBAL_ERROR_NAME]}
<Panel {title} class={className} format="inline" variant="danger">
{errors[GLOBAL_ERROR_NAME]}
</Panel>
{/if}
25 changes: 5 additions & 20 deletions cmd/serve/front/src/components/form.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script lang="ts">
import { ValidationError } from '$lib/error';
import { submitter } from '$lib/form';
let submitting = false;
let errors: Record<string, Maybe<string>> = {};
let className: string = '';
/** Async handler of the form, will be called upon submit. */
Expand All @@ -14,24 +12,11 @@
/** Additional css classes */
export { className as class };
async function onSubmit() {
try {
submitting = true;
await handler();
} catch (ex) {
if (ex instanceof ValidationError) {
errors = ex.fields;
} else {
console.error(ex);
}
} finally {
submitting = false;
}
}
const { submit, loading, errors } = submitter(handler);
</script>

<form on:submit|preventDefault={onSubmit} {autocomplete}>
<fieldset class={className} disabled={submitting || disabled}>
<slot {submitting} {errors} />
<form on:submit|preventDefault={submit} {autocomplete}>
<fieldset class={className} disabled={$loading || disabled}>
<slot submitting={$loading} errors={$errors} />
</fieldset>
</form>
6 changes: 4 additions & 2 deletions cmd/serve/front/src/lib/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ export type ValidationDetail = {
};

/** Validation error after a form submission */
export class ValidationError extends Error {
export class BadRequestError extends Error {
public readonly fields: Record<string, Maybe<string>>;
public readonly isValidationError: boolean;

public constructor(data: AppError<ValidationDetail>) {
super(data.code);
this.isValidationError = !!data.detail?.fields;
this.fields = Object.entries(data.detail?.fields ?? {}).reduce(
(result, [name, err]) => ({
...result,
Expand All @@ -41,7 +43,7 @@ export class UnauthorizedError extends Error {
export class HttpError extends Error {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private static readonly status: Record<number, Maybe<{ new (data?: any): Error }>> = {
400: ValidationError,
400: BadRequestError,
401: UnauthorizedError,
500: UnexpectedError
};
Expand Down
12 changes: 6 additions & 6 deletions cmd/serve/front/src/lib/fetcher/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,10 @@ export default class CacheFetchService implements FetchService {
private async mutate<TOut, TIn>(
method: HttpMethod,
url: string,
data?: TIn,
body?: TIn,
options?: MutateOptions
): Promise<TOut> {
const result = await api<TOut, TIn>(method, url, data, options);
const result = await api<TOut, TIn>(method, url, body, options);

// Invalidate all the cache entries that matches the base key
const keys = [url, ...(options?.invalidate ?? [])];
Expand All @@ -148,13 +148,13 @@ type HttpMethod = 'POST' | 'PUT' | 'PATCH' | 'GET' | 'DELETE';
async function api<TOut = unknown, TIn = unknown>(
method: HttpMethod,
url: string,
data?: TIn,
body?: TIn,
options?: Omit<FetchOptions, 'params' | 'depends'>
): Promise<TOut> {
const additionalHeaders: HeadersInit = {};
const isFormData = data instanceof FormData;
const isFormData = body instanceof FormData;

if (data && !isFormData) {
if (body && !isFormData) {
additionalHeaders['Content-Type'] = 'application/json';
}

Expand All @@ -165,7 +165,7 @@ async function api<TOut = unknown, TIn = unknown>(
...additionalHeaders
},
cache: options?.cache,
body: data ? (isFormData ? data : JSON.stringify(data)) : undefined
body: body ? (isFormData ? body : JSON.stringify(body)) : undefined
});

if (!response.ok) {
Expand Down
24 changes: 22 additions & 2 deletions cmd/serve/front/src/lib/form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { writable, type Readable } from 'svelte/store';
import { BadRequestError } from '$lib/error';

/**
* Keys inside SubmitterErrors representing an error not tied to a specific field.
*/
export const GLOBAL_ERROR_NAME = '__global';

/**
* Computes the validation message based on HTML attributes on an element.
Expand Down Expand Up @@ -30,9 +36,12 @@ export function buildFormData(data: Record<string, string | Blob>): FormData {
}, new FormData());
}

export type SubmitterErrors = Maybe<Record<string, Maybe<string>>>;

export type Submitter<T> = {
loading: Readable<boolean>;
submit: () => Promise<T>;
errors: Readable<SubmitterErrors>;
};

export type SubmitterOptions = {
Expand All @@ -41,31 +50,42 @@ export type SubmitterOptions = {
};

/**
* Wrap the given function exposing a loading state and a submitter. It also
* handle common options such as displaying a confirmation message before submitting.
* Wrap the given function exposing a loading state, a submitter and formatted errors.
* It also handle common options such as displaying a confirmation message before submitting.
*/
export function submitter<T = unknown>(
fn: () => Promise<T>,
options?: SubmitterOptions
): Submitter<T> {
const loading = writable(false);
const errors = writable<SubmitterErrors>(undefined);

async function submit(): Promise<T> {
if (options?.confirmation && !confirm(options.confirmation)) {
return undefined as T;
}

loading.set(true);
errors.set(undefined);

try {
return await fn();
} catch (ex) {
if (ex instanceof BadRequestError) {
errors.set(ex.isValidationError ? ex.fields : { [GLOBAL_ERROR_NAME]: ex.message });
} else if (ex instanceof Error) {
errors.set({ [GLOBAL_ERROR_NAME]: ex.message });
}

throw ex;
} finally {
loading.set(false);
}
}

return {
loading,
errors,
submit
};
}
8 changes: 6 additions & 2 deletions cmd/serve/front/src/routes/(auth)/signin/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$components/button.svelte';
import FormErrors from '$components/form-errors.svelte';
import Form from '$components/form.svelte';
import PageTitle from '$components/page-title.svelte';
import Stack from '$components/stack.svelte';
Expand All @@ -24,19 +25,22 @@
<h1 class="title">Sign in</h1>
<p>Please fill the form below to access your dashboard.</p>
</div>

<FormErrors {errors} />

<TextInput
label="Email"
type="email"
bind:value={email}
required
remoteError={errors.email}
remoteError={errors?.email}
/>
<TextInput
label="Password"
type="password"
bind:value={password}
required
remoteError={errors.password}
remoteError={errors?.password}
/>
<Stack justify="flex-end">
<Button type="submit" loading={submitting}>Sign in</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import CleanupNotice from '$components/cleanup-notice.svelte';
import Console from '$components/console.svelte';
import DeploymentCard from '$components/deployment-card.svelte';
import FormErrors from '$components/form-errors.svelte';
import Stack from '$components/stack.svelte';
import { submitter } from '$lib/form';
import routes from '$lib/path';
Expand Down Expand Up @@ -42,7 +43,11 @@
}
}
$: ({ loading: redeploying, submit: redeploy } = submitter(
$: ({
loading: redeploying,
errors: redeployErr,
submit: redeploy
} = submitter(
() =>
service
.redeploy(data.app.id, data.deployment.deployment_number)
Expand All @@ -52,7 +57,11 @@
}
));
$: ({ loading: promoting, submit: promote } = submitter(
$: ({
loading: promoting,
errors: promoteErr,
submit: promote
} = submitter(
() =>
service
.promote(data.app.id, data.deployment.deployment_number)
Expand Down Expand Up @@ -81,6 +90,9 @@
</Breadcrumb>

<Stack direction="column">
<FormErrors errors={$redeployErr} title="Redeploy failed" />
<FormErrors errors={$promoteErr} title="Promote failed" />

{#if $deployment}
<DeploymentCard {isStale} data={$deployment} />
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import Button from '$components/button.svelte';
import CleanupNotice from '$components/cleanup-notice.svelte';
import Dropdown, { type DropdownOption } from '$components/dropdown.svelte';
import FormErrors from '$components/form-errors.svelte';
import FormSection from '$components/form-section.svelte';
import Form from '$components/form.svelte';
import InputFile from '$components/input-file.svelte';
Expand Down Expand Up @@ -66,23 +67,23 @@
}
function copyCurlCommand() {
let payload: string = ` `;
const payload = select(kind, {
git: `-H "Content-Type: application/json" -d "{ \\"environment\\":\\"${environment}\\",\\"git\\":{ \\"branch\\": \\"${branch}\\"${
hash ? `, \\"hash\\":\\"${hash}\\"` : ''
} } }" `,
raw: `-H "Content-Type: application/json" -d "{ \\"environment\\":\\"${environment}\\", \\"raw\\":\\"${JSON.stringify(
raw
)
.replaceAll('\\"', '"')
.substring(1)
.slice(0, -1)}\\"}"`,
archive: `-F environment=${environment} -F archive=@${
archive?.[0]?.name ?? '<path_to_a_tar_gz_archive>'
}`
});
switch (kind) {
case 'git':
payload = `-H "Content-Type: application/json" -d "{ \\"environment\\":\\"${environment}\\",\\"git\\":{ \\"branch\\": \\"${branch}\\"${
hash ? `, \\"hash\\":\\"${hash}\\"` : ''
} } }" `;
break;
case 'raw':
const rawAsStr = JSON.stringify(raw).replaceAll('\\"', '"').substring(1).slice(0, -1);
payload = `-H "Content-Type: application/json" -d "{ \\"environment\\":\\"${environment}\\", \\"raw\\":\\"${rawAsStr}\\"}"`;
break;
case 'archive':
payload = `-F environment=${environment} -F archive=@${
archive?.[0]?.name ?? '<path_to_a_tar_gz_archive>'
}`;
break;
if (!payload) {
return;
}
navigator.clipboard.writeText(
Expand All @@ -106,40 +107,49 @@
{/if}
</Breadcrumb>

<FormSection title="Environment">
<Dropdown label="Target" {options} bind:value={environment} />
</FormSection>
<Stack direction="column">
<FormErrors {errors} />

<div>
<FormSection title="Environment">
<Dropdown label="Target" {options} bind:value={environment} />
</FormSection>

<FormSection title="Payload">
<svelte:fragment slot="actions">
<Button variant="outlined" on:click={copyCurlCommand}>Copy cURL command</Button>
</svelte:fragment>
<FormSection title="Payload">
<svelte:fragment slot="actions">
<Button variant="outlined" on:click={copyCurlCommand}>Copy cURL command</Button>
</svelte:fragment>

<Stack direction="column">
<Dropdown label="Kind" options={kindOptions} bind:value={kind} />
{#if kind === 'raw'}
<TextArea
code
rows={20}
required
label="Content"
bind:value={raw}
remoteError={errors.content}
>
<p>
Content of the service file (compose.yml if you're using Docker Compose for example).
</p>
</TextArea>
{:else if kind === 'git'}
<TextInput label="Branch" bind:value={branch} required remoteError={errors.branch} />
<TextInput label="Commit" bind:value={hash} remoteError={errors.hash}>
<p>Optional specific commit to deploy. Leave empty to deploy the latest branch commit.</p>
</TextInput>
{:else if kind === 'archive'}
<InputFile accept="application/gzip" label="File" required bind:files={archive} />
{/if}
</Stack>
</FormSection>
<Stack direction="column">
<Dropdown label="Kind" options={kindOptions} bind:value={kind} />
{#if kind === 'raw'}
<TextArea
code
rows={20}
required
label="Content"
bind:value={raw}
remoteError={errors?.content}
>
<p>
Content of the service file (compose.yml if you're using Docker Compose for
example).
</p>
</TextArea>
{:else if kind === 'git'}
<TextInput label="Branch" bind:value={branch} required remoteError={errors?.branch} />
<TextInput label="Commit" bind:value={hash} remoteError={errors?.hash}>
<p>
Optional specific commit to deploy. Leave empty to deploy the latest branch commit.
</p>
</TextInput>
{:else if kind === 'archive'}
<InputFile accept="application/gzip" label="File" required bind:files={archive} />
{/if}
</Stack>
</FormSection>
</div>
</Stack>
</Form>

<style module>
Expand Down
Loading

0 comments on commit 7f6d044

Please sign in to comment.