Kyoso-Team / kyoso

A web application that takes osu! tournaments beyonds spreadsheets.
http://kyoso.sh
GNU Affero General Public License v3.0
1 stars 1 forks source link

Form handling overhaul #15

Closed L-Mario564 closed 4 months ago

L-Mario564 commented 9 months ago

Aims to solve and implement #11.

The main issues with the current implementation of forms can be summed up in this paragraph mentioned in the above issue:

A lot of stuff is handled by client-side Typescript, which leads to a ton of abstraction and bloats up the bundle size as many of these things can be handled by using more native Svelte and HTML functionality. This also leads to a difficulty in implementing anything that may be a "one off" like putting a disclaimer or warning, as that would require fiddling further with Typescript and updating the Form component that works under the hood.

This PR has the goal of improving dyamic forms.

How would I create a new form now?

Let's take the same example as the mentioned issue, which is about creating new staff role. In the +page.svelte file, we'd include this:

<script lang="ts">
  import { CreateStaffRoleForm } from '$forms';
  import type { CreateStaffRole } from '$forms';

  // Triggered by a button just like before
  async function onCreateRole(defaultValue?: CreateStaffRole) {
    form.init.createStaffRole(CreateStaffRoleForm, {
      defaultValue,
      afterSubmit: () => {
        selectedIndex = data.roles.length - 1;
        selectedRole = Object.assign({}, data.roles[selectedIndex]);
      },
      context: {
        tournamentId: data.tournament.id
      },
      onFormReopen: (value) => onCreateRole(value)
    });
  }
</script>

Each argument has its purpose, for the most part, it should be clear what each does, like afterSubmit runs after the form's submission. In this case, we're selecting the created staff role we just created in the dashboard. onFormReopen runs when an error is thrown and the user closes the error message. In this case, duplicate staff role names aren't allowed, so it will throw an error if a role with that name already exists. After the user closes the error message, the form will reopen with the previous data so they don't have to reinput everything and make the necessary changes from there.

Each property has JSDoc comments written in the form store file (src/lib/stores/form.ts) which you can take a look for more info.

Oh, and createStaffRole is just an alias for create, with the benefit of adding types specific to the create staff role form. To achieve this for another form, we just add it to the form registry which is just a Typescript type.

import type { CreateStaffRole, CreateStaffRoleCtx, CreateStaffRoleForm } from '$forms';
import type { FormCreate } from '$types';

export type FormRegistry = {
  createStaffRole: FormCreate<typeof CreateStaffRoleForm, CreateStaffRole, CreateStaffRole, CreateStaffRoleCtx>;
};

Now, onto the form itself.

<script lang="ts" context="module">
  import { z } from 'zod';
  import type { FormValue, FormSubmit } from '$types';

  const createStaffRoleSchemas = {
    name: z.string().max(45)
  };

  export type CreateStaffRole = FormValue<typeof createStaffRoleSchemas>
  export type CreateStaffRoleCtx = {
    tournamentId: number;
  };
</script>
<script lang="ts">
  import { Form, Text } from '$components/form';

  let value: Partial<CreateStaffRole> = {};

  const submit: FormSubmit<
    CreateStaffRole,
    CreateStaffRoleCtx
  > = async (value, { ctx, trpc, page, showFormError, invalidateAll }) => {
    let isNameUnique = await trpc(page).validation.isStaffRoleNameUniqueInTournament.query({
      name: value.name,
      tournamentId: ctx.tournamentId
    });

    if (!isNameUnique) {
      showFormError({
        value,
        message: `Staff role "${value.name}" already exists in this tournament.`,
      });
      return;
    }

    await trpc(page).staffRoles.createRole.mutate({
      tournamentId: ctx.tournamentId,
      data: {
        name: value.name,
        color: 'red'
      }
    });

    await invalidateAll();
  };
</script>

<Form {value} {submit}>
  <svelte:fragment slot="header">
    <h2>Create Staff Role</h2>
  </svelte:fragment>
  <Text label="Role name" name="name" schema={createStaffRoleSchemas.name} bind:value={value.name} />
</Form>

Here we handle pretty much everything, form layout, validation, creating types specific to the form (which we saw is used in the registry) and also the submission itself, with the submit function having included utilities commonly used in other forms, like the tRPC client, the current page store, the invalidateAll function and the context, which we passed in the +page.svelte file as this file doesn't know about the tournament ID, but requires it to submit the form. These utilities are present to avoid importing commonly used utilities cross different forms.

This new implementation separates the concerns between pages and forms, and by making it a component, you introduce any custom elements you want inside the Form component, all while still keeping everything that used to work with the previous implementation, including the type safety.

L-Mario564 commented 9 months ago

Now, custom FormError exceptions can be thrown within the the submit function in the form component to have it be displayed in the UI. For example:

If this errors:

if (!isNameUnique) {
  throw new FormError(`Staff role "${value.name}" already exists in this tournament.`);
}

It display as such:

Screenshot_2023-10-01_211634

If any other exception is thrown, the error modal will appear and the form component will unmount.

L-Mario564 commented 4 months ago

Will be handled differently, but taking a lot of inspiration from this implementation.