refactor: extract form into organism

I want to explore how I could potentially reuse components. Therefore I
extract the form into a component. Given its complexity it could either
be a molecule or an organism. I went with the latter here.

I have to explore how I pass around Objects and Arrays in Svelte so that
I can move server logic up in the component tree.

When I reuse components, I potentially want to define the labels and
placeholders. Therefore the hard-coded strings have to become variables.
Eventually I will define the key on the template level and use the real
values on page level and pass it down. That would break tests currently
so I'm not doing it in this refactoring.

Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
This commit is contained in:
André Jaenisch 2024-02-28 16:57:08 +01:00
parent db1b8822e8
commit dd50af1764
No known key found for this signature in database
GPG key ID: 5A668E771F1ED854
6 changed files with 546 additions and 200 deletions

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
/**
* Form handling by SvelteKit
*/
export let form: unknown = null;
/**
* Translation keys to enable reuse
*/
export let i18n: null;
/**
* Available servers for signin
*/
export let servers: Array = [];
</script>
<form class="space-y-4" name="login" method="POST" action="?/login">
{#if form?.missing}
<p class="error">{$_(i18n.validation.missing)}</p>
{/if}
{#if form?.incorrect}
<p class="error">{$_(i18n.validation.incorrect)}</p>
{/if}
<label class="label">
<span>{$_(i18n.fields.server.label)}</span>
<select class="select" name="server">
<option value="1">fig.fr33domlover.site</option>
<option value="2">grape.fr33domlover.site</option>
<option value="3">walnut.fr33domlover.site</option>
</select>
</label>
<label class="label">
<span>{$_(i18n.fields.account.label)}</span>
<input class="input" name="account" type="text" value={form?.account ?? ''} />
</label>
<label class="label">
<span>{$_(i18n.fields.passphrase.label)}</span>
<input
class="input"
name="passphrase"
type="password"
placeholder={$_(i18n.fields.passphrase.placeholder)}
/>
</label>
<div class="text-right">
<a href="/" class="btn btn-bg-initial">
{$_(i18n.reset)}
</a>
</div>
<button type="submit" class="w-full btn variant-filled-primary">
{$_(i18n.submit)}
</button>
</form>

View file

@ -1,47 +1,43 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import LoginForm from '../organisms/LoginForm.svelte';
/**
* Form handling by SvelteKit
*/
export let form: unknown = null;
const i18n = {
fields: {
account: {
label: 'page.login.form.fields.account.label'
},
passphrase: {
label: 'page.login.form.fields.passphrase.label',
placeholder: 'page.login.form.fields.passphrase.placeholder'
},
server: {
label: 'page.login.form.fields.server.label'
}
},
reset: 'page.login.form.reset',
submit: 'page.login.form.submit',
validation: {
incorrect: 'page.login.form.validation.incorrect',
missing: 'page.login.form.validation.missing'
}
};
const servers = {
'1': 'fig.fr33domlover.site',
'2': 'grape.fr33domlover.site',
'3': 'walnut.fr33domlover.site'
};
</script>
<div class="w-full max-w-md h-full mx-auto flex justify-center items-center py-10">
<div class="space-y-10">
<h1 class="h1 font-bold">{$_('page.login.heading')}</h1>
<p>{$_('page.login.intro')}</p>
<form class="space-y-4" name="login" method="POST" action="?/login">
{#if form?.missing}
<p class="error">{$_('page.login.form.validation.missing')}</p>
{/if}
{#if form?.incorrect}
<p class="error">{$_('page.login.form.validation.incorrect')}</p>
{/if}
<label class="label">
<span>{$_('page.login.form.fields.server.label')}</span>
<select class="select" name="server">
<option value="1">fig.fr33domlover.site</option>
<option value="2">grape.fr33domlover.site</option>
<option value="3">walnut.fr33domlover.site</option>
</select>
</label>
<label class="label">
<span>{$_('page.login.form.fields.account.label')}</span>
<input class="input" name="account" type="text" value={form?.account ?? ''} />
</label>
<label class="label">
<span>{$_('page.login.form.fields.passphrase.label')}</span>
<input class="input" name="passphrase" type="password" placeholder="password" />
</label>
<div class="text-right">
<a href="/" class="btn btn-bg-initial">
{$_('page.login.form.reset')}
</a>
</div>
<button type="submit" class="w-full btn variant-filled-primary">
{$_('page.login.form.submit')}
</button>
</form>
<LoginForm {form} {i18n} {servers} />
</div>
</div>

View file

@ -1,83 +1,84 @@
{
"page": {
"import_project": {
"form": {
"avatar": "Avatar",
"components": "Komponenten",
"fields": {
"avatar": {
"label": "Bild hochladen"
},
"description": {
"label": "Beschreibung",
"placeholder": "Beschreibe das Projekt in unter 100 Zeichen..."
},
"issues": {
"label": "Issues"
},
"name": {
"error": "Dieses Feld ist erforderlich",
"label": "Name",
"placeholder": "Name des Projekts..."
},
"pr": {
"label": "Pullrequest"
},
"repository": {
"hint": "optional: von Git-Repository importieren",
"label": "Repository",
"placeholder": "URL des Git-Repositorys"
}
},
"submit": "Projekt erstellen"
},
"heading": "Projekt erstellen",
"intro": "Neues F2-Projekt zu Anvil hinzufügen."
},
"login": {
"form": {
"validation": {
"incorrect": "Accountname oder Kennwort sind falsch.",
"missing": "Das Account-Feld ist verbindlich."
},
"fields": {
"account": {
"label": "Accountname"
},
"passphrase": {
"label": "Passwort"
},
"server": {
"label": "F2-Server"
}
},
"reset": "Passwort zurücksetzen",
"submit": "Einloggen"
},
"heading": "Einloggen",
"intro": "Die Einwahldaten ausfüllen um Anvil mit deinem F2-Account zu benutzen."
},
"profile": {
"activities": {
"block_or_report": "{blockElementOpen}blockieren{blockElementClose} oder {reportElementOpen}melden{reportElementClose}",
"like": "Gefällt mir"
},
"heading": "Profil für",
"history": {
"activities": {
"setup": {
"description": "Das F2-Konto @{username}@{instance} wurde erfolgreich innerhalb von {created_with} erstellt",
"summary": "Kontoeinstellungen"
}
},
"heading": "Aktivitäten"
},
"projects": {
"add_or_import": "{addElementOpen}Ein Projekt hinzufügen{addElementClose} oder {importElementOpen}Ein Projekt importieren{importElementClose}.",
"empty": "Bisher keine Projekte hinzugefügt.",
"heading": "Projekte"
}
},
"welcome": "Willkommen bei Anvil!"
}
"page": {
"import_project": {
"form": {
"avatar": "Avatar",
"components": "Komponenten",
"fields": {
"avatar": {
"label": "Bild hochladen"
},
"description": {
"label": "Beschreibung",
"placeholder": "Beschreibe das Projekt in unter 100 Zeichen..."
},
"issues": {
"label": "Issues"
},
"name": {
"error": "Dieses Feld ist erforderlich",
"label": "Name",
"placeholder": "Name des Projekts..."
},
"pr": {
"label": "Pullrequest"
},
"repository": {
"hint": "optional: von Git-Repository importieren",
"label": "Repository",
"placeholder": "URL des Git-Repositorys"
}
},
"submit": "Projekt erstellen"
},
"heading": "Projekt erstellen",
"intro": "Neues F2-Projekt zu Anvil hinzufügen."
},
"login": {
"form": {
"fields": {
"account": {
"label": "Accountname"
},
"passphrase": {
"label": "Passwort",
"placeholder": ""
},
"server": {
"label": "F2-Server"
}
},
"reset": "Passwort zurücksetzen",
"submit": "Einloggen",
"validation": {
"incorrect": "Accountname oder Kennwort sind falsch.",
"missing": "Das Account-Feld ist verbindlich."
}
},
"heading": "Einloggen",
"intro": "Die Einwahldaten ausfüllen um Anvil mit deinem F2-Account zu benutzen."
},
"profile": {
"activities": {
"block_or_report": "{blockElementOpen}blockieren{blockElementClose} oder {reportElementOpen}melden{reportElementClose}",
"like": "Gefällt mir"
},
"heading": "Profil für",
"history": {
"activities": {
"setup": {
"description": "Das F2-Konto @{username}@{instance} wurde erfolgreich innerhalb von {created_with} erstellt",
"summary": "Kontoeinstellungen"
}
},
"heading": "Aktivitäten"
},
"projects": {
"add_or_import": "{addElementOpen}Ein Projekt hinzufügen{addElementClose} oder {importElementOpen}Ein Projekt importieren{importElementClose}.",
"empty": "Bisher keine Projekte hinzugefügt.",
"heading": "Projekte"
}
},
"welcome": "Willkommen bei Anvil!"
}
}

View file

@ -36,23 +36,24 @@
},
"login": {
"form": {
"validation": {
"incorrect": "Account or password wrong.",
"missing": "The account field is required."
},
"fields": {
"account": {
"label": "Account name"
},
"passphrase": {
"label": "Passphrase"
"label": "Passphrase",
"placeholder": "password"
},
"server": {
"label": "F2 server"
}
},
"reset": "Reset passphrase",
"submit": "Log in"
"submit": "Log in",
"validation": {
"incorrect": "Account or password wrong.",
"missing": "The account field is required."
}
},
"heading": "Log in",
"intro": "To use Anvil with your F2 account, fill in your credentials."

View file

@ -1,83 +1,84 @@
{
"page": {
"import_project": {
"form": {
"avatar": "תמונה",
"components": "רכיבים",
"fields": {
"avatar": {
"label": "להעלות תמונה?"
},
"description": {
"label": "תיאור",
"placeholder": "על הפרויקט בפחות מ-100 אותיות…"
},
"issues": {
"label": "סוגיות"
},
"name": {
"error": "חובה",
"label": "שם",
"placeholder": "שם הפרויקט…"
},
"pr": {
"label": "פרים"
},
"repository": {
"hint": "אפשר לייבא קוד מקופסת גיט",
"label": "קופסת קוד",
"placeholder": "לינק לקופסת קוד של גיט"
}
},
"submit": "ליצור פרויקט"
},
"heading": "יצירת פרויקט",
"intro": ""
},
"login": {
"form": {
"validation": {
"incorrect": "שם המשתמש או הסיסמה שגויים.",
"missing": "שם המשתמש חובה."
},
"fields": {
"account": {
"label": "שם משתמש"
},
"passphrase": {
"label": "סיסמה"
},
"server": {
"label": ""
}
},
"reset": "לשנות סיסמה?",
"submit": "להיכנס"
},
"heading": "כניסה",
"intro": ""
},
"profile": {
"activities": {
"block_or_report": "{blockElementOpen}לחסום{blockElementClose} או {reportElementOpen}לדווח{reportElementClose}",
"like": "אהבתי"
},
"heading": "הפרופיל של",
"history": {
"activities": {
"setup": {
"description": "",
"summary": ""
}
},
"heading": "פעילות"
},
"projects": {
"add_or_import": "",
"empty": "עדיין אין כאן פרוייקטים.",
"heading": "פרויקטים"
}
},
"welcome": ""
}
"page": {
"import_project": {
"form": {
"avatar": "תמונה",
"components": "רכיבים",
"fields": {
"avatar": {
"label": "להעלות תמונה?"
},
"description": {
"label": "תיאור",
"placeholder": "על הפרויקט בפחות מ-100 אותיות…"
},
"issues": {
"label": "סוגיות"
},
"name": {
"error": "חובה",
"label": "שם",
"placeholder": "שם הפרויקט…"
},
"pr": {
"label": "פרים"
},
"repository": {
"hint": "אפשר לייבא קוד מקופסת גיט",
"label": "קופסת קוד",
"placeholder": "לינק לקופסת קוד של גיט"
}
},
"submit": "ליצור פרויקט"
},
"heading": "יצירת פרויקט",
"intro": ""
},
"login": {
"form": {
"fields": {
"account": {
"label": "שם משתמש"
},
"passphrase": {
"label": "סיסמה",
"placeholder": ""
},
"server": {
"label": ""
}
},
"reset": "לשנות סיסמה?",
"submit": "להיכנס",
"validation": {
"incorrect": "שם המשתמש או הסיסמה שגויים.",
"missing": "שם המשתמש חובה."
}
},
"heading": "כניסה",
"intro": ""
},
"profile": {
"activities": {
"block_or_report": "{blockElementOpen}לחסום{blockElementClose} או {reportElementOpen}לדווח{reportElementClose}",
"like": "אהבתי"
},
"heading": "הפרופיל של",
"history": {
"activities": {
"setup": {
"description": "",
"summary": ""
}
},
"heading": "פעילות"
},
"projects": {
"add_or_import": "",
"empty": "עדיין אין כאן פרוייקטים.",
"heading": "פרויקטים"
}
},
"welcome": ""
}
}

View file

@ -0,0 +1,291 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import { init, locale, register } from 'svelte-i18n';
import LoginForm from '../../../src/lib/components/organisms/LoginForm.svelte';
import enMessages from '../../../src/lib/i18n/locales/en.json';
describe('LoginForm.svelte', () => {
beforeEach(() => {
register('en', () => import('../../../src/lib/i18n/locales/en.json'));
init({ fallbackLocale: 'en', initialLocale: 'en' });
locale.set('en');
});
it('should mount', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
const { container } = render(LoginForm, { form, i18n, servers });
// Assert
expect(container).toBeTruthy();
});
it('should have a form', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const formElement = screen.getByRole('form');
// Assert
expect(formElement).toBeInTheDocument();
});
it('should have a server select control', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const server = screen.getByLabelText(enMessages.page.login.form.fields.server.label);
// Assert
expect(server).toBeInTheDocument();
});
it('should have a account input control', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const account = screen.getByLabelText(enMessages.page.login.form.fields.account.label);
// Assert
expect(account).toBeInTheDocument();
});
it('should have a passphrase input control', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const passphrase = screen.getByLabelText(enMessages.page.login.form.fields.passphrase.label);
// Assert
expect(passphrase).toBeInTheDocument();
});
it('should have a reset passphrase link', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
},
reset: enMessages.page.login.form.reset
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const reset = screen.getByRole('link', { name: enMessages.page.login.form.reset });
// Assert
expect(reset).toBeInTheDocument();
});
it('should have a submit button', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
},
submit: enMessages.page.login.form.submit
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const submit = screen.getByRole('button', { name: enMessages.page.login.form.submit });
// Assert
expect(submit).toBeInTheDocument();
});
it('should have no validation errors', () => {
// Arrange
const form = {};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
// Assert
expect(
screen.queryByText(enMessages.page.login.form.validation.incorrect)
).not.toBeInTheDocument();
expect(
screen.queryByText(enMessages.page.login.form.validation.missing)
).not.toBeInTheDocument();
});
describe('when validation failed', () => {
it('should show an error on required inputs', () => {
// Arrange
const form = {
missing: true
};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
},
validation: {
missing: enMessages.page.login.form.validation.missing
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const intro = screen.getByText(enMessages.page.login.form.validation.missing);
// Assert
expect(intro).toBeInTheDocument();
});
it('should show an error on invalid inputs', () => {
// Arrange
const form = {
incorrect: true
};
const i18n = {
fields: {
account: {
label: enMessages.page.login.form.fields.account.label
},
passphrase: {
label: enMessages.page.login.form.fields.passphrase.label
},
server: {
label: enMessages.page.login.form.fields.server.label
}
},
validation: {
incorrect: enMessages.page.login.form.validation.incorrect
}
};
const servers = [];
// Act
render(LoginForm, { form, i18n, servers });
const intro = screen.getByText(enMessages.page.login.form.validation.incorrect);
// Assert
expect(intro).toBeInTheDocument();
});
});
});