<repo_name>theblack1987/peax-demo<file_sep>/src/app/state/app.reducer.ts
import { createReducer, on } from '@ngrx/store';
import * as AppActions from './app.action';
import { CategoryState } from './app.state';

export const initialState: CategoryState = {
  category: [
    { id: 1, name: 'Rocket', pendingTask: 5 },
    { id: 2, name: 'Onboarding', pendingTask: 5 },
    { id: 3, name: 'Mailbox', pendingTask: 3 },
    { id: 4, name: 'Office', pendingTask: 10 },
  ],
  data: [
    {
      id: 1,
      data: [
        {
          id: '101',
          title: 'Order confirmation',
          content: `Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '102',
          title: 'Credit card from Swiss Bank',
          content: `It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '103',
          title: 'Electricity bill',
          content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '104',
          title: 'Gas bill',
          content: `Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '105',
          title: 'Internet bill',
          content: `But I must explain to you how all this mistaken idea of denouncing pleasure and praising pain was born and I will give you a complete account of the system`,
          img: '/assets/icons/dummy.png',
        },
      ],
    },
    {
      id: 2,
      data: [
        {
          id: '201',
          title: 'Verify Email Address',
          content: `At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '202',
          title: 'Upgrade ID level 2',
          content: `voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '303',
          title: 'Turn on notification',
          content: `qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '404',
          title: 'Subscription detail',
          content: `cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '505',
          title: 'New mail from PEAX',
          content: `dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus`,
          img: '/assets/icons/dummy.png',
        },
      ],
    },
    {
      id: 3,
      data: [
        {
          id: '301',
          title: 'Add payment',
          content:
            'The Task Manager helps the user to complete pending tasks easily and clearly. Tasks that have been completed disappear from the Task Manager.',
          img: '/assets/icons/dummy.png',
        },
        {
          id: '302',
          title: 'News',
          content:
            'The Task Manager helps the user to complete pending tasks easily and clearly. Tasks that have been completed disappear from the Task Manager.',
          img: '/assets/icons/dummy.png',
        },
        {
          id: '303',
          title: 'Confirm change address',
          content:
            'The Task Manager helps the user to complete pending tasks easily and clearly. Tasks that have been completed disappear from the Task Manager.',
          img: '/assets/icons/dummy.png',
        },
      ],
    },
    {
      id: 4,
      data: [
        {
          id: '401',
          title: 'Order confirmation',
          content: `saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '402',
          title: 'Credit card from Swiss Bank',
          content: `reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '403',
          title: 'Electricity bill',
          content: `On the other hand, we denounce with righteous indignation and dislike men who are so beguiled and demoralized by the charms of pleasure of the moment, so blinded by desire`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '404',
          title: 'Gas bill',
          content: `that they cannot foresee the pain and trouble that are bound to ensue; and equal blame belongs to those who fail in their duty through weakness`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '405',
          title: 'Internet bill',
          content: `of will, which is the same as saying through shrinking from toil and pain. These cases are perfectly simple and easy to distinguish`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '406',
          title: 'Money transfer detail',
          content: `In a free hour, when our power of choice is untrammelled and when`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '407',
          title: 'ABC Service',
          content: `nothing prevents our being able to do what we like best`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '408',
          title: 'Spa bill',
          content: `every pleasure is to be welcomed and every pain avoided. But in certain circumstances and owing to the claims of duty or the obligations of business`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '409',
          title: 'Promotion from PEAX',
          content: `it will frequently occur that pleasures have to be repudiated and annoyances accepted.`,
          img: '/assets/icons/dummy.png',
        },
        {
          id: '410',
          title: 'New friend on PEAX',
          content: `The wise man therefore always holds in these matters to this principle of selection: he rejects pleasures to secure other greater pleasures, or else he endures pains to avoid worse pains.`,
          img: '/assets/icons/dummy.png',
        },
      ],
    },
  ],
  currentCategoryData: {
    id: 0,
    data: [],
  },
  currentTask: { id: '', content: '', img: '', title: '' },
};

export const appReducer = createReducer(
  initialState,
  on(AppActions.getCurrentCategoryData, (state, action) => {
    return {
      ...state,
      currentCategoryData:
        state.data.filter((item) => item.id === action.id)[0] || {},
    };
  }),
  on(AppActions.removeCategory, (state, action) => {
    return {
      ...state,
      category: state.category.filter((item) => item.id !== action.id),
    };
  }),
  on(AppActions.getCurrentTaskById, (state, action) => {
    return {
      ...state,
      currentTask:
        state.currentCategoryData.data.filter(
          (item) => item.id === action.id
        )[0] || {},
    };
  })
);
<file_sep>/src/app/task-content/task-content.component.ts
import {
  Component,
  Input,
  OnInit,
  HostListener,
  ViewChild,
} from '@angular/core';

@Component({
  selector: 'app-task-content',
  templateUrl: './task-content.component.html',
  styleUrls: ['./task-content.component.scss'],
})
export class TaskContentComponent implements OnInit {
  @Input() content = '';
  @Input() img = '';
  @ViewChild('activationCode') activationCode;
  numbers;
  constructor() {
    this.numbers = [0,1,2,3,4,5,6,7];
  }

  ngOnInit(): void {}

  addError() {
    const numberArray = document.querySelectorAll('.activation-code');

    numberArray.forEach((element) => {
      element.classList.add('error');
    });
    setTimeout(() => {
      this.removeError(numberArray);
    }, 850);
    this.clearNumberField();
  }

  removeError(numberArray: any) {
    numberArray.forEach((element) => {
      element.classList.remove('error');
    });
  }
  @HostListener('keyup', ['$event']) onKeyDown(e: any) {
    var key = e.keyCode || e.charCode;
    let previous: any = e.srcElement.previousSibling;
    if (key == 8 || key == 46) {
      if (previous != null) {
        previous.focus();
        return;
      }
    }
    if (e.srcElement.maxLength === e.srcElement.value.length) {
      e.preventDefault();

      let nextControl: any = e.srcElement.nextElementSibling;
      while (true) {
        if (nextControl) {
          if (nextControl.type === e.srcElement.type) {
            nextControl.focus();
            return;
          } else {
            nextControl = nextControl.nextElementSibling;
          }
        } else {
          return;
        }
      }
    }
  }

  clearNumberField() {
    const children = this.activationCode.nativeElement.children;
    for (let child of children) {
      child.value = '';
    }
    children[0].focus();
  }

  execute(event: string) {
    //console.log('this.store.dispatch: ', event);
    this.addError();
  }
}
<file_sep>/src/app/state/app.state.ts
import { Category, CategoryData, Task } from '../_model';

export interface State {
  categoryState: CategoryState;
}

export interface CategoryState {
  category: Category[];
  data: CategoryData[];
  currentCategoryData: CategoryData;
  currentTask: Task;
}
<file_sep>/src/app/task-manager/task-manager.component.ts
import { Component, OnInit, HostListener } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable, of } from 'rxjs';
import { getCurrentTaskSelector } from '../state/app.selector';
import * as AppState from '../state/app.state';
import { Task } from '../_model';

@Component({
  selector: 'app-task-manager',
  templateUrl: './task-manager.component.html',
  styleUrls: ['./task-manager.component.scss'],
})
export class TaskManagerComponent implements OnInit {
  currentTask$: Observable<Task> = of({
    id: '',
    title: '',
    img: '',
    content: '',
  });
  isCompleted = false;

  constructor(private appState: Store<AppState.State>) {}

  ngOnInit(): void {
    this.currentTask$ = this.appState.select(getCurrentTaskSelector);
  }

  @HostListener('mouseover') onMouseOver() {
    const element = document.querySelector('.task-indicator');
    element?.classList.add('pause');
  }

  @HostListener('mouseout') onMouseOut() {
    const element = document.querySelector('.task-indicator');
    element?.classList.remove('pause');
  }
}
<file_sep>/src/app/app.component.spec.ts
import { AppComponent } from './app.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CategoryComponent } from './category/category.component';
import { TaskContentComponent } from './task-content/task-content.component';
import { TaskIndicatorComponent } from './task-indicator/task-indicator.component';
import { TaskManagerComponent } from './task-manager/task-manager.component';
import { TaskTitleComponent } from './task-title/task-title.component';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Observable } from 'rxjs';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from 'src/environments/environment';
import { appReducer } from './state/app.reducer';
import { PrimaryButtonComponent } from './_shared/primary-button/primary-button.component';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

describe('AppComponent', () => {
  let component: AppComponent;
  let fixture: ComponentFixture<AppComponent>;
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        AppComponent,
        CategoryComponent,
        TaskIndicatorComponent,
        TaskManagerComponent,
        TaskTitleComponent,
        TaskContentComponent,
        PrimaryButtonComponent,
      ],
      imports: [
        MatTooltipModule,
        MatButtonModule,
        StoreModule.forRoot({}),
        StoreModule.forFeature('app', appReducer),
        StoreDevtoolsModule.instrument({
          name: 'Peax demo DevTools',
          maxAge: 25,
          logOnly: environment.production,
        }),
      ],
      schemas: [CUSTOM_ELEMENTS_SCHEMA],
    }).compileComponents();
    fixture = TestBed.createComponent(AppComponent);
    component = fixture.debugElement.componentInstance;

    fixture.detectChanges();
  });

  test('should create the app', () => {
    expect(component).toBeTruthy();
  });

  test('categories and currentTask should be instance of Observable after ngOnInit', () => {
    component.ngOnInit();
    expect(component.categories$).toBeInstanceOf(Observable);
    expect(component.currentTask$).toBeInstanceOf(Observable);
  });
});
<file_sep>/src/app/state/app.action.ts
import { createAction, props } from '@ngrx/store';

export const getCategoryData = createAction('[App] Get Category Data');

export const getCurrentCategoryData = createAction(
  '[App] Get Current Task',
  props<{ id: number }>()
);

export const removeCategory = createAction(
  '[App] Remove Category',
  props<{ id: number }>()
);

export const getCurrentTaskById = createAction(
  '[App] Get Current Task By Id',
  props<{ id: string }>()
);

export const getCurrentTask = createAction('[App] Get Current Task');
export const setCategoryId = createAction(
  '[App] Set Category Id',
  props<{ id: number }>()
);
<file_sep>/src/app/state/app.selector.ts
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CategoryState, State } from './app.state';

export const getCategoryFeatureState = createFeatureSelector<
  State,
  CategoryState
>('categoryState');

export const getCategoriesSelector = createSelector(
  getCategoryFeatureState,
  (state) => state.category
);

export const getCategoryDataSelector = createSelector(
  getCategoryFeatureState,
  (state) => state.data
);

export const getCurrentTaskSelector = createSelector(
  getCategoryFeatureState,
  (state) => state.currentCategoryData.data[0]
);

export const getCategories = createSelector(
  getCategoryFeatureState,
  (state) => state.category
);

export const getCategoryId = createSelector(
  getCategoryFeatureState,
  (state) => state.category[0].id
);

export const getCategoryData = createSelector(
  getCategoryFeatureState,
  (state) => state.data
);

export const getCurrentCategoryDataSelector = createSelector(
  getCategoryFeatureState,
  (state) => state.currentCategoryData
);
<file_sep>/src/app/app.module.ts
import { appReducer } from './state/app.reducer';
import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CategoryComponent } from './category/category.component';
import { TaskIndicatorComponent } from './task-indicator/task-indicator.component';
import { TaskManagerComponent } from './task-manager/task-manager.component';
import { TaskTitleComponent } from './task-title/task-title.component';
import { TaskContentComponent } from './task-content/task-content.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

import { MatTooltipModule } from '@angular/material/tooltip';
import { MatButtonModule } from '@angular/material/button';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { environment } from 'src/environments/environment';
import { PrimaryButtonComponent } from './_shared/primary-button/primary-button.component';

@NgModule({
  declarations: [
    AppComponent,
    CategoryComponent,
    TaskIndicatorComponent,
    TaskManagerComponent,
    TaskTitleComponent,
    TaskContentComponent,
    PrimaryButtonComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,

    MatTooltipModule,
    MatButtonModule,
    StoreModule.forRoot({}),
    StoreModule.forFeature('categoryState', appReducer),
    StoreDevtoolsModule.instrument({
      name: 'Peax demo DevTools',
      maxAge: 25,
      logOnly: environment.production,
    }),
  ],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
<file_sep>/src/app/_model/category.model.ts
export interface Category {
  id: number;
  name: string;
  pendingTask: number;
}

export interface Task {
  id: string;
  title: string;
  content: string;
  img: string;
}

export interface CategoryData {
  id: number;
  data: Task[];
}
<file_sep>/src/app/task-indicator/task-indicator.component.ts
import { Component, OnInit, AfterViewInit, Input } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable, of } from 'rxjs';
import { CategoryData, Task } from '../_model';

// State
import * as AppState from '../state/app.state';

// Actions
import * as AppActions from '../state/app.action';

// Selectors
import { getCurrentCategoryDataSelector } from '../state/app.selector';

@Component({
  selector: 'app-task-indicator',
  templateUrl: './task-indicator.component.html',
  styleUrls: ['./task-indicator.component.scss'],
})
export class TaskIndicatorComponent implements OnInit, AfterViewInit {
  // TODO: ngOnChanges not able to triggered if remove Input
  @Input() currentTask: CategoryData = { id: 0, data: [] };

  currentCategoryData$: Observable<CategoryData> = of({ id: 0, data: [] });
  progress;

  constructor(private appState: Store<AppState.State>) {}

  ngOnInit() {
    this.currentCategoryData$ = this.appState.select(
      getCurrentCategoryDataSelector
    );
  }

  ngOnChanges(change) {
    if (change) {
      this.focusIcon();

      setTimeout(() => {
        this.runAnimation();
      }, 100);
    }
  }

  ngAfterViewInit() {
    this.focusIcon();
  }

  selectTask(data: Task, index: number) {
    if (index === 9) {
      return;
    }
    const idTemp = 'progress-bar_';
    const classElement = this.getElementWithClass('progress-bar');
    const classLength = classElement.length;
    const item: Element[] = [];

    for (let i = 0; i < classLength; i++) {
      // Reset style
      this.removeClass(classElement[i], 'bg-grey');

      // Find elements to run animation
      if (parseInt(classElement[i].id.replace(idTemp, ''), 0) >= index) {
        item.push(classElement[i].children[0]);
      } else {
        // Add background color for elements, which do not run animation
        this.addClass(classElement[i], 'bg-grey');
      }
    }

    this.runAnimation(item);
    this.appState.dispatch(AppActions.getCurrentTaskById({ id: data.id }));
  }

  private runAnimation(currentElement: Element[] = []) {
    this.progress =
      currentElement.length > 0
        ? currentElement
        : Array.from(document.querySelectorAll('.progress-value'));

    this.playNext();
    this.progress.map((el) => {
      el.addEventListener('animationend', (e) => this.playNext(e));
    });
  }

  private playNext = (e?: any) => {
    const current = e && e.target;
    let next;
    if (current) {
      const currentIndex = this.progress.indexOf(current);
      if (currentIndex < this.progress.length) {
        next = this.progress[currentIndex + 1];
      }
      this.removeClass(current, 'active');
      this.addClass(current, 'passed');
      if (currentIndex === this.progress.length - 1 && e !== undefined) {
        let id = 0;
        this.currentCategoryData$.subscribe((item) => (id = item.id));
        if (id) {
          const iconId = 'icon-' + (id + 1);
          setTimeout(() => {
            this.getElementWithId(iconId)?.click();
          }, 200);
        }
      }
    }
    if (!next) {
      next = this.removeAnimation();
    }
    if (next) {
      this.addClass(next, 'active');
      // Get task content
      setTimeout(() => {
        // Fix: Expression has changed after it was checked
        this.getTaskContent(next.id);
      }, 100);
    }
  };

  private removeAnimation() {
    this.progress.map((el) => {
      this.removeClass(el, 'active');
      this.removeClass(el, 'passed');
    });
    return this.progress[0];
  }

  private focusIcon() {
    let id = 0;
    this.currentCategoryData$.subscribe((item) => (id = item.id));
    if (id) {
      const iconId = 'bgicon-' + id;
      const element = this.getElementWithId(iconId);

      // Remove focus
      const bgIcon = document.querySelectorAll('.bgicon') as any;
      if (bgIcon.length > 0) {
        bgIcon.forEach((ele) => this.removeClass(ele, 'border-icon'));
      }

      // Add focus to active category
      if (element) {
        this.addClass(element, 'border-icon');
      }
    }
  }

  private getTaskContent(id: string) {
    this.appState.dispatch(AppActions.getCurrentTaskById({ id }));
  }

  private removeClass(element: HTMLElement | Element, className: string = '') {
    if (!element) {
      return;
    }
    element.classList.remove(className);
  }

  private addClass(element: HTMLElement | Element, className: string = '') {
    if (!element) {
      return;
    }
    element.classList.add(className);
  }

  private getElementWithId(id: string) {
    return document.getElementById(id);
  }

  private getElementWithClass(className: string) {
    return document.getElementsByClassName(className);
  }
}
<file_sep>/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable, of } from 'rxjs';
import { Category, CategoryData } from './_model';
import { State } from './state/app.state';
import * as AppSelectors from '../app/state/app.selector';
import { getCurrentCategoryData } from './state/app.action';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {
  title = 'my-app';

  categories$: Observable<Category[]> = of([]);
  currentTask$: Observable<CategoryData> = of({
    id: 0,
    data: [],
  });
  constructor(private store: Store<State>) {}

  ngOnInit() {
    this.categories$ = this.store.select(AppSelectors.getCategoriesSelector);
    this.currentTask$ = this.store.select(
      AppSelectors.getCurrentCategoryDataSelector
    );

    this.store.dispatch(getCurrentCategoryData({ id: 1 }));
  }
}
