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

Lab

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 the LoginComponent

We've done this once before - see the FormLayoutComponent stories for reference!

  • Access the InputComponents 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:

  1. 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;
+  }
}
  1. 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;
⚠️ **GitHub.com Fallback** ⚠️