Lab 11: The final component - andreaswissel/design-systems-workshop GitHub Wiki
Hint: if you got lost, you can always check out the corresponding branch for the lab. If you want to start over, you can reset it by typing executing git reset --hard origin/lab-10
This is it! The final strech, the last mile, the finishing line! We will now implement the LoginComponent
where all the hard work you've done in the last days comes together!
With everything you've learned so far, you'll now walk a few steps on your own. So some of these action items don't contain all steps. You can always look up the model solution at the end, though.
- Generate and implement a
CardComponent
This is the background layer of our LoginComponent
. It should contain the slot for projecting the FormLayout
and FormElement
components.
- Generate and implement a
LoginComponent
This component composes the components we've built before, to make up the login layout and dispatch the login()
action, once the button is pressed.
- Use the
FormLayoutComponent
to display and layout the inputs in theLoginComponent
We've done this once before - see the FormLayoutComponent
stories for reference!
- Access the
InputComponent
s value
This would be done through ControlValueAccessor
or with template-driven forms in a real project. For simplicity's sake, we're just passing around the native input
's value.
For that, we need to make a few adjustments to our InputComponent
and FormElementComponent
:
- Read the value from the native input
src/app/input/input.component.html
<input
[type]="type"
[placeholder]="placeholder"
[value]="value"
[attr.id]="id"
+ #input
/>
src/app/input/input.component.ts
export class InputComponent implements OnInit {
@Input() inputType: InputType = InputType.text;
@Input() placeholder = 'Input Placeholder';
@Input() value = '';
@Input() id?: string;
+ @ViewChild('input', { static: false }) input!: ElementRef<HTMLInputElement>;
constructor() {}
ngOnInit(): void {}
+ get text(): string {
+ return this.input.nativeElement.value;
+ }
}
- Access the InputComponent value in the FormElementComponent
src/app/form-element/form-element.component.ts
export class FormElementComponent implements OnInit, InputApi {
uuid: string;
inputValue = '';
+ @ViewChild(InputComponent, { static: false }) input!: InputComponent;
@Input() type: InputType = InputType.text;
@Input() placeholder?: string;
@Input()
set value(val: string) {
this.inputValue = val.length > 0 ? val : '';
}
@Input() label = 'Input Label';
constructor() {
// @ts-ignore
this.uuid = crypto.randomUUID();
}
ngOnInit(): void {}
+ get text(): string {
+ return this.input.text;
+ }
}
- Implement a
LoginService
which will be called by a submit button
To get started, we want to generate a service located in our LoginModule
directory.
ng g s login/login
Then, we implement a login
method, which throws an error. In a real world scenario, this would usually
export class LoginService {
+ login(username: string, password: string): Observable<boolean> {
+ return throwError(() => new Error('Not implemented.'));
+ }
}
- Call the
LoginService
on button click
src/app/login/login.component.html
<app-card [title]="'Login'">
<app-form-layout [columns]="1">
<app-form-element
[type]="'text'"
[label]="'Username'"
[placeholder]="'[email protected]'"
+ #user
></app-form-element>
<app-form-element
[type]="'password'"
[label]="'Password'"
[placeholder]="'secure123'"
+ #pw
></app-form-element>
</app-form-layout>
<app-button
[label]="'Submit'"
+ (click)="login(user.text, pw.text)"
></app-button>
</app-card>
- Implement a
MockLoginService
which responds true when user matches password
Currently, we're responding with an error whenever someone tries click the button on our LoginComponent
. In a real world scenario, this would lead to a lot of failed HTTP requests from Storybook users. So let's go ahead and fix that by providing a MockLoginService
which always responds with a sensible value.
First, we want to generate a mock service. We can generate it just like any other service:
ng g s login/mocks/mock-login
Then, we want to add a method to the service that always responds with some value. I chose to respond with true, whenever the username matches password and false when it doesn't. You can come up with your own solution, though! Here's mine:
export class MockLoginService {
+ login(username: string, password: string): Observable<boolean> {
+ return of(!!username && username === password);
+ }
}
Then, we want to make sure that this service is being used in the stories.
src/app/login/login.stories.ts
import { Meta, moduleMetadata } from '@storybook/angular';
import { LoginComponent } from './login.component';
import { LoginService } from './login.service';
import { MockLoginService } from './mocks/mock-login.service';
const meta: Meta<LoginComponent> = {
title: 'Components/Login',
component: LoginComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
+ providers: [
+ {
+ provide: LoginService,
+ useClass: MockLoginService,
+ },
+ ],
}),
],
};
export const Default = {
args: {},
};
export default meta;
- Bonus level: Polishing
There's some CSS level fixes that need to be done to make the login form look perfect. Can you spot and fix the issues? Try it yourself!
- Bonus level: Use Actions to display results
Storybook has a cool addon to display results of UI interactions called actions Try to log the result of the login action in the action panel!
Card Component HTML
src/app/card/card.component.html
<ng-content></ng-content>
Card Component SCSS
src/app/card/card.component.scss
:host {
display: inline-flex;
border-radius: 10px;
border: 1px solid #e4e8f3;
background: var(--alias-white, #fff);
box-shadow: 0px 10px 20px 0px rgba(0, 0, 0, 0.1);
min-width: 400px;
flex-direction: column;
padding: 24px;
align-items: flex-start;
gap: 10px;
flex-shrink: 0;
}
Card Component TS
src/app/card/card.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-card',
templateUrl: './card.component.html',
standalone: true,
imports: [],
styleUrls: ['./card.component.scss'],
})
export class CardComponent {
}
Card Component Stories
src/app/card/card.stories.ts
import { Meta } from '@storybook/angular';
import { CardComponent } from './card.component';
const meta: Meta<CardComponent> = {
title: 'Components/Card',
component: CardComponent,
tags: ['autodocs'],
};
export const Default = {
args: {},
};
export default meta;
Input Component HTML
src/app/input/input.component.html
<input
[type]="inputType"
[placeholder]="placeholder"
[value]="value"
[attr.id]="id"
#input
/>
Input Component TS
src/app/input/input.component.ts
import { Component, ElementRef, Input, ViewChild } from '@angular/core';
export enum InputType {
button = 'button',
checkbox = 'checkbox',
color = 'color',
date = 'date',
datetimelocal = 'datetime-local',
email = 'email',
file = 'file',
hidden = 'hidden',
image = 'image',
month = 'month',
number = 'number',
password = 'password',
radio = 'radio',
range = 'range',
reset = 'reset',
search = 'search',
submit = 'submit',
tel = 'tel',
text = 'text',
time = 'time',
url = 'url',
week = 'week',
}
export interface InputApi {
inputType: InputType;
placeholder?: string;
value?: string;
}
@Component({
selector: 'app-input',
standalone: true,
imports: [],
templateUrl: './input.component.html',
styleUrl: './input.component.scss',
})
export class InputComponent {
@Input() inputType: InputType = InputType.text;
@Input() placeholder: string | undefined;
@Input() value: string | undefined;
@Input() id?: string;
@ViewChild('input', { static: false }) input!: ElementRef<HTMLInputElement>;
get text(): string {
return this.input.nativeElement.value;
}
}
Login Component HTML
src/app/login/login.component.html
<app-card>
<app-form-layout [columns]="1" [heading]="'Login'">
<app-form-element
[inputType]="InputType.email"
[label]="'Email'"
[placeholder]="'Your email'"
#user
></app-form-element>
<app-form-element
[inputType]="InputType.password"
[label]="'Password'"
[placeholder]="'Your password'"
#password
></app-form-element>
</app-form-layout>
<app-button
[label]="'Login'"
(click)="login(user.text, password.text)"
></app-button>
</app-card>
Login Component SCSS
src/app/login/login.component.scss
app-card {
align-items: stretch;
}
app-form-layout {
flex: 1;
}
app-button {
margin-left: auto;
margin-top: 30px;
}
Login Component TS
src/app/login/login.component.ts
import { Component, EventEmitter, Inject, Output, inject } from '@angular/core';
import { FormLayoutComponent } from '../form-layout/form-layout.component';
import { FormElementComponent } from '../form-element/form-element.component';
import { InputType } from '../input/input.component';
import { CardComponent } from '../card/card.component';
import { ButtonComponent } from '../button/button.component';
import { LoginService } from './login.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [
FormLayoutComponent,
FormElementComponent,
CardComponent,
ButtonComponent,
],
templateUrl: './login.component.html',
styleUrl: './login.component.scss',
})
export class LoginComponent {
InputType = InputType;
loginService = inject(LoginService);
@Output() onLogin: EventEmitter<any> = new EventEmitter();
constructor() {}
login(user: string, password: string) {
this.loginService.login(user, password).subscribe((result) => {
console.log(result);
this.onLogin.emit(result);
});
}
}
Login Stories
src/app/login/login.stories.ts
import { Meta, moduleMetadata } from '@storybook/angular';
import { LoginComponent } from './login.component';
import { LoginService } from './login.service';
import { MockLoginService } from './mocks/mock-login.service';
const meta: Meta<LoginComponent> = {
title: 'Components/Login',
component: LoginComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
providers: [
{
provide: LoginService,
useClass: MockLoginService,
},
],
}),
],
};
export const Default = {
args: {},
};
export default meta;
Login Service
src/app/login/login.service.ts
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class LoginService {
login(username: string, password: string): Observable<boolean> {
return throwError(() => new Error('Not implemented.'));
}
}
Login Service Mock
src/app/login/mocks/mock-login.service.ts
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class MockLoginService {
login(username: string, password: string): Observable<boolean> {
return of(!!username && username === password);
}
}
Input Component Stories
import { InputComponent, InputType } from './input.component';
import { Meta, StoryObj } from '@storybook/angular';
const meta: Meta<InputComponent> = {
title: 'Components/Input',
component: InputComponent,
tags: ['autodocs'],
};
export const Default: StoryObj<Omit<InputComponent, 'input'>> = {
args: {
inputType: InputType.text,
placeholder: 'This is a placeholder',
value: '',
id: 'uuid',
},
argTypes: {
placeholder: { control: 'text' },
value: { control: 'text' },
},
};
export default meta;