From f23fdb2dd9d5e383818486c7f4308ffb439d5e5b Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Tue, 6 Dec 2022 21:48:05 +0100 Subject: [PATCH 01/60] Combine page entity icon and status icon into one component --- .../components/atoms/icons/icons.module.ts | 3 -- .../page-entity-icon.component.html | 4 +- .../page-entity-icon.component.ts | 51 ++++++++++++++++++- .../status-icon/status-icon.component.html | 1 - .../status-icon/status-icon.component.ts | 29 ----------- .../organisms/task/task.component.html | 2 +- .../organisms/task/task.component.ts | 4 +- 7 files changed, 53 insertions(+), 41 deletions(-) delete mode 100644 client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.html delete mode 100644 client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.ts diff --git a/client-v2/src/app/components/atoms/icons/icons.module.ts b/client-v2/src/app/components/atoms/icons/icons.module.ts index 0eadf5ac..d7d859ca 100644 --- a/client-v2/src/app/components/atoms/icons/icons.module.ts +++ b/client-v2/src/app/components/atoms/icons/icons.module.ts @@ -5,7 +5,6 @@ import { IconComponent } from './icon/icon.component' import { LoadingSpinnerComponent } from './loading-spinner/loading-spinner.component' import { PageEntityIconComponent } from './page-entity-icon/page-entity-icon.component' import { PriorityIconComponent } from './priority-icon/priority-icon.component' -import { StatusIconComponent } from './status-icon/status-icon.component' @NgModule({ declarations: [ @@ -14,7 +13,6 @@ import { StatusIconComponent } from './status-icon/status-icon.component' LoadingSpinnerComponent, PageEntityIconComponent, PriorityIconComponent, - StatusIconComponent, ], imports: [CommonModule], exports: [ @@ -23,7 +21,6 @@ import { StatusIconComponent } from './status-icon/status-icon.component' LoadingSpinnerComponent, PageEntityIconComponent, PriorityIconComponent, - StatusIconComponent, ], }) export class IconsModule {} diff --git a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.html b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.html index 1b4851c6..9a3985dc 100644 --- a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.html +++ b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.html @@ -1,3 +1 @@ - - - + diff --git a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts index c193685c..15b30b4a 100644 --- a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts +++ b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts @@ -1,8 +1,53 @@ import { Component, Input } from '@angular/core' -import { TaskDisplayState } from '../status-icon/status-icon.component' +import { TaskStatus } from 'src/app/models/task.model' -export type PageEntityIconKey = TaskDisplayState | 'tasklist' +/** This will come from the db */ +// export enum EntityType { +// TASKLIST = 'Tasklist', +// TASK = 'Task', +// DOCUMENT = 'Document', +// VIEW = 'View', +// } +export enum EntityType { + TASKLIST = 'Tasklist', + DOCUMENT = 'Document', + VIEW = 'View', +} +export enum PageEntityState { + LOADING = 'Loading', +} + +export enum TaskState { + BLOCKED = 'Blocked', + // LOADING = 'Loading', +} +export type TaskIconKey = TaskStatus | TaskState + +// const entityTypeWithoutTask = { +// [EntityType.TASKLIST]: 'Tasklist', +// [EntityType.DOCUMENT]: 'Document', +// [EntityType.VIEW]: 'View', +// } +// type EntityTypeWihoutTask = keyof typeof entityTypeWithoutTask +export type PageEntityIconKey = TaskIconKey | EntityType | PageEntityState +export const taskStatusIconClassMap: Record = { + [TaskStatus.OPEN]: 'far fa-circle text-tinted-300', + [TaskStatus.IN_PROGRESS]: 'far fa-clock text-secondary-400', + [TaskStatus.BACKLOG]: 'fas fa-spinner text-tinted-300 rotate-[-45deg]', + [TaskStatus.COMPLETED]: 'fas fa-check-circle text-submit-400', + [TaskStatus.NOT_PLANNED]: 'fas fa-times-circle text-danger-400', + [TaskState.BLOCKED]: 'far fa-ban text-tinted-300', + // [TaskState.LOADING]: 'far fa-spinner-third animate-spin text-tinted-200', +} + +export const entityIconClassMap: Record = { + [EntityType.TASKLIST]: 'far fa-tasks text-tinted-400', + [EntityType.DOCUMENT]: 'far fa-file-alt text-tinted-400', + [EntityType.VIEW]: 'far fa-binoculars text-tinted-400', + [PageEntityState.LOADING]: 'far fa-spinner-third animate-spin text-tinted-200', + ...taskStatusIconClassMap, +} @Component({ selector: 'page-entity-icon', templateUrl: './page-entity-icon.component.html', @@ -10,4 +55,6 @@ export type PageEntityIconKey = TaskDisplayState | 'tasklist' }) export class PageEntityIconComponent { @Input() icon!: PageEntityIconKey + + entityIconClassMap = entityIconClassMap } diff --git a/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.html b/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.html deleted file mode 100644 index 67516b74..00000000 --- a/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.ts b/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.ts deleted file mode 100644 index eeea9bf7..00000000 --- a/client-v2/src/app/components/atoms/icons/status-icon/status-icon.component.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, Input } from '@angular/core' -import { TaskStatus } from 'src/app/models/task.model' - -export enum TaskState { - BLOCKED = 'Blocked', - LOADING = 'Loading', -} - -export type TaskDisplayState = TaskStatus | TaskState - -const statusIconClassMap: Record = { - [TaskStatus.OPEN]: 'far fa-circle text-tinted-300', - [TaskStatus.IN_PROGRESS]: 'far fa-clock text-secondary-400', - [TaskStatus.BACKLOG]: 'fas fa-spinner text-tinted-300 rotate-[-45deg]', - [TaskStatus.COMPLETED]: 'fas fa-check-circle text-submit-400', - [TaskStatus.NOT_PLANNED]: 'fas fa-times-circle text-danger-400', - [TaskState.BLOCKED]: 'far fa-ban text-tinted-300', - [TaskState.LOADING]: 'far fa-spinner-third animate-spin text-tinted-200', -} - -@Component({ - selector: 'status-icon', - templateUrl: './status-icon.component.html', - styleUrls: [], -}) -export class StatusIconComponent { - @Input() status: TaskDisplayState = TaskStatus.OPEN - statusIconClassMap = statusIconClassMap -} diff --git a/client-v2/src/app/components/organisms/task/task.component.html b/client-v2/src/app/components/organisms/task/task.component.html index ad480ab4..4484714b 100644 --- a/client-v2/src/app/components/organisms/task/task.component.html +++ b/client-v2/src/app/components/organisms/task/task.component.html @@ -15,7 +15,7 @@ [appTooltip]="blocked || data.status" [tooltipOptions]="{ avoidPositions: ['right'] }" > - + Date: Tue, 6 Dec 2022 22:05:46 +0100 Subject: [PATCH 02/60] Spike: add sidebar tree demo, add entity page label --- client-v2/src/app/app.module.ts | 4 + .../entity-page-label.component.ts | 23 ++ .../src/app/pages/home/home.component.css | 64 ++++ .../src/app/pages/home/home.component.html | 86 +++--- .../src/app/pages/home/home.component.ts | 278 +++++++++++++++++- 5 files changed, 414 insertions(+), 41 deletions(-) create mode 100644 client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index 66e0598e..528e90b6 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -39,6 +39,8 @@ import { IconsModule } from './components/atoms/icons/icons.module' import { OverlayModule } from '@angular/cdk/overlay' import { TooltipDirective } from './directives/tooltip.directive' import { TooltipComponent } from './components/atoms/tooltip/tooltip.component' +import { CdkTreeModule } from '@angular/cdk/tree' +import { EntityPageLabelComponent } from './components/atoms/entity-page-label/entity-page-label.component' @NgModule({ declarations: [ @@ -67,6 +69,7 @@ import { TooltipComponent } from './components/atoms/tooltip/tooltip.component' DropDownComponent, TooltipDirective, TooltipComponent, + EntityPageLabelComponent, ], imports: [ BrowserModule, @@ -104,6 +107,7 @@ import { TooltipComponent } from './components/atoms/tooltip/tooltip.component' IconsModule, ModalModule, OverlayModule, + CdkTreeModule, ], providers: [], bootstrap: [AppComponent], diff --git a/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts b/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts new file mode 100644 index 00000000..a6918ffa --- /dev/null +++ b/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core' +import { PageEntityIconKey } from '../icons/page-entity-icon/page-entity-icon.component' + +@Component({ + selector: 'app-entity-page-label', + template: ` + + + + {{ title }} + `, + styles: [ + ` + :host { + @apply truncate; + } + `, + ], +}) +export class EntityPageLabelComponent { + @Input() title!: string + @Input() pageIcon!: PageEntityIconKey +} diff --git a/client-v2/src/app/pages/home/home.component.css b/client-v2/src/app/pages/home/home.component.css index 7f4e1e75..820dc7d5 100644 --- a/client-v2/src/app/pages/home/home.component.css +++ b/client-v2/src/app/pages/home/home.component.css @@ -4,3 +4,67 @@ .progress-container.glow span { @apply text-submit-400; } + +.tree-node { + @apply flex + w-full + items-center + justify-start + rounded + text-tinted-300 + transition-colors + duration-75 + hover:bg-tinted-700 + hover:text-tinted-100; + + line-height: 1.7; +} +.tree-node.nested { + @apply pl-2.5; +} + +.indent-line { + @apply ml-3.5 + inline-block + h-full + border-l + border-tinted-700; +} +.indent-line:first-child { + @apply ml-1; +} + +.content { + @apply flex + grow + gap-1 + truncate + pr-1; +} + +.node-toggle { + @apply aspect-square + rounded + pl-2.5 + pr-1.5 + text-sm + text-tinted-600 + transition-colors + hover:text-tinted-400; +} + +.tree-node:not(:hover, :active, :focus-visible, :focus-within) .btn-group { + @apply hidden animate-none; +} +.btn-group { + @apply flex gap-0.5; + animation: reveal 75ms forwards; +} +@keyframes reveal { + 100% { + @apply opacity-100; + } +} +.btn-group button { + @apply rounded px-1.5 transition-colors hover:bg-tinted-600 duration-75; +} diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index aaba54db..31beb7cf 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -41,42 +41,45 @@ -

Tasklists

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

-

The content

+

Tasklists

+ + + + + + + + +
+ + +
+ +
+
@@ -98,8 +101,15 @@ appTooltip="Here will be more information" cdkMenuItem > - - {{ breadcrumb.text }} + + + + {{ breadcrumb.text }} + / diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 34d5680e..1f4e812f 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -1,14 +1,286 @@ +import { ArrayDataSource } from '@angular/cdk/collections' +import { FlatTreeControl } from '@angular/cdk/tree' import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' -import { PageEntityIconKey } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' +import { + EntityType, + PageEntityIconKey, +} from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { TaskStatus } from 'src/app/models/task.model' +const TREE_DATA: ExampleFlatNode[] = [ + { + name: 'Fruit', + expandable: false, + level: 0, + }, + { + name: 'Fruit', + expandable: true, + level: 0, + }, + { + name: 'Apple', + expandable: false, + level: 1, + }, + { + name: 'Banana', + expandable: false, + level: 1, + }, + { + name: 'Fruit loops', + expandable: false, + level: 1, + }, + { + name: 'Vegetables', + expandable: true, + isExpanded: true, + level: 0, + }, + { + name: 'Yellow', + expandable: false, + entityType: EntityType.DOCUMENT, + level: 1, + }, + { + name: 'Green', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Broccoli', + expandable: false, + entityType: EntityType.DOCUMENT, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + level: 2, + }, + { + name: 'Other', + expandable: true, + isExpanded: true, + entityType: EntityType.VIEW, + level: 2, + }, + { + name: 'Other', + expandable: false, + entityType: EntityType.VIEW, + level: 3, + }, + { + name: 'Other', + expandable: false, + entityType: EntityType.VIEW, + level: 3, + }, + { + name: 'Orange', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + level: 2, + }, + { + name: 'Vegetables', + expandable: true, + isExpanded: true, + level: 0, + }, + { + name: 'Yellow', + expandable: false, + level: 1, + }, + { + name: 'Green', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Broccoli', + entityType: EntityType.DOCUMENT, + expandable: false, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + entityType: EntityType.DOCUMENT, + level: 2, + }, + { + name: 'Other', + expandable: true, + isExpanded: true, + level: 2, + }, + { + name: 'Other', + expandable: false, + level: 3, + }, + { + name: 'Other', + expandable: false, + level: 3, + }, + { + name: 'Orange', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + entityType: EntityType.DOCUMENT, + level: 2, + }, + { + name: 'Vegetables', + expandable: true, + isExpanded: true, + level: 0, + }, + { + name: 'Yellow', + expandable: false, + level: 1, + }, + { + name: 'Green', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Broccoli', + expandable: false, + level: 2, + }, + { + name: 'Brussels sprouts', + expandable: false, + level: 2, + }, + { + name: 'Other', + expandable: true, + isExpanded: true, + level: 2, + }, + { + name: 'Other', + expandable: false, + level: 3, + }, + { + name: 'Other', + expandable: false, + level: 3, + entityType: EntityType.DOCUMENT, + }, + { + name: 'Orange', + expandable: true, + isExpanded: true, + level: 1, + }, + { + name: 'Pumpkins', + expandable: false, + level: 2, + }, + { + name: 'Carrots', + expandable: false, + level: 2, + }, +] + +/** Flat node with expandable and level information */ +interface ExampleFlatNode { + expandable: boolean + name: string + level: number + isExpanded?: boolean + entityType?: EntityType +} + @Component({ selector: 'app-home', templateUrl: './home.component.html', styleUrls: ['./home.component.css'], }) export class HomeComponent implements AfterViewInit, OnDestroy { + treeControl = new FlatTreeControl( + node => node.level, + node => node.expandable + ) + + dataSource = new ArrayDataSource(TREE_DATA) + + hasChild = (_: number, node: ExampleFlatNode) => node.expandable + + getParentNode(node: ExampleFlatNode) { + const nodeIndex = TREE_DATA.indexOf(node) + + for (let i = nodeIndex - 1; i >= 0; i--) { + if (TREE_DATA[i].level === node.level - 1) { + return TREE_DATA[i] + } + } + + return null + } + + shouldRender(node: ExampleFlatNode) { + let parent = this.getParentNode(node) + while (parent) { + if (!parent.isExpanded) { + return false + } + parent = this.getParentNode(parent) + } + return true + } + + range(number: number) { + return new Array(number) + } + + log(str: string) { + console.log(str) + } + + ///////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////// + TaskStatus = TaskStatus + EntityType = EntityType closedTasks = 16 allTasks = 37 @@ -16,8 +288,8 @@ export class HomeComponent implements AfterViewInit, OnDestroy { isShownAsPercentage = true breadcrumbs: { text: string; icon?: PageEntityIconKey }[] = [ - { text: 'Rootlist', icon: 'tasklist' }, - { text: 'Listname', icon: 'tasklist' }, + { text: 'Rootlist', icon: EntityType.TASKLIST }, + { text: 'Listname', icon: EntityType.TASKLIST }, { text: 'Task', icon: TaskStatus.OPEN }, { text: 'Taskname, which you can edit', icon: TaskStatus.IN_PROGRESS }, // { text: 'Taskname, which you can edit. what if we get bungos though?', icon: TaskStatus.IN_PROGRESS }, From 1aec660f57e67cbe155f4e4818011a0fac920381 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Thu, 15 Dec 2022 21:31:39 +0100 Subject: [PATCH 03/60] Add task and tasklist models --- .../organisms/task/task.component.spec.ts | 10 +++ .../organisms/task/task.component.ts | 4 +- client-v2/src/app/models/task.model.ts | 69 ++++++++++++++++++- .../tasks-demo/tasks-demo.component.ts | 10 +-- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/client-v2/src/app/components/organisms/task/task.component.spec.ts b/client-v2/src/app/components/organisms/task/task.component.spec.ts index 937c56fb..d7150289 100644 --- a/client-v2/src/app/components/organisms/task/task.component.spec.ts +++ b/client-v2/src/app/components/organisms/task/task.component.spec.ts @@ -33,6 +33,16 @@ describe('TaskComponent', () => { priority: TaskPriority.NONE, status: TaskStatus.OPEN, description: '', + listId: '', + ownerId: '', + closedAt: '', + deadline: '', + openedAt: '', + createdAt: '', + subtaskIds: [], + blockedById: '', + parentTaskId: '', + inProgressSince: '', } fixture.detectChanges() }) diff --git a/client-v2/src/app/components/organisms/task/task.component.ts b/client-v2/src/app/components/organisms/task/task.component.ts index e55538e1..815dccea 100644 --- a/client-v2/src/app/components/organisms/task/task.component.ts +++ b/client-v2/src/app/components/organisms/task/task.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnInit } from '@angular/core' -import { ITask, TaskPriority, TaskStatus } from '../../../models/task.model' +import { Task, TaskPriority, TaskStatus } from '../../../models/task.model' import { PageEntityState, TaskState } from '../../atoms/icons/page-entity-icon/page-entity-icon.component' @Component({ @@ -13,7 +13,7 @@ export class TaskComponent implements OnInit { if (this.data.description) setTimeout(() => (this.loading = false), Math.random() * 5000) else this.loading = false } - @Input() data!: ITask + @Input() data!: Task TaskStatus = TaskStatus TaskPriority = TaskPriority diff --git a/client-v2/src/app/models/task.model.ts b/client-v2/src/app/models/task.model.ts index d1c3f511..70dd995e 100644 --- a/client-v2/src/app/models/task.model.ts +++ b/client-v2/src/app/models/task.model.ts @@ -29,10 +29,75 @@ export const prioritySortingMap: Record = { [TaskPriority.OPTIONAL]: 4, } -export interface ITask { +export interface Task { title: string description: string status: TaskStatus priority: TaskPriority - // more to come: createdAt, openedAt, inProgressSince, closedAt, events, deadline, blockedBy + + createdAt: string + openedAt: string + deadline: string + inProgressSince: string + closedAt: string + + ownerId: string + listId: string + parentTaskId: string + blockedById: string + + subtaskIds: string[] +} + +// @TODO: ITaskEvent + +type TaskUpdatable = Pick< + Task, + 'title' | 'description' | 'status' | 'priority' | 'listId' | 'parentTaskId' | 'deadline' | 'blockedById' +> +export type CreateTaskDto = Pick & Partial +export type UpdateTaskDto = Partial + +// Task comments +export interface TaskComment { + id: string + taskId: string + text: string +} +export type CreateTaskCommentDto = Pick +export type UpdateTaskCommentDto = CreateTaskCommentDto + +// TaskList +export interface TaskList { + id: string + name: string + description: string + createdAt: string + ownerId: string + + parentListId: string + childLists: string[] + taskIds: string[] + // participants: string[] // maybe this one as well +} + +export type TasklistPreview = Pick + +export interface CreateTasklistDto { + name: string + description?: string + parentListId?: string +} +export type UpdateTasklistDto = Partial + +export interface PermissionsDto { + permission: ListPermissions +} +export type ShareTasklistDto = Partial + +export enum ListPermissions { + Manage = 'Manage', + Edit = 'Edit', + Comment = 'Comment', + View = 'View', } diff --git a/client-v2/src/app/pages/component-playground/tasks-demo/tasks-demo.component.ts b/client-v2/src/app/pages/component-playground/tasks-demo/tasks-demo.component.ts index 1f9d4e28..7b929050 100644 --- a/client-v2/src/app/pages/component-playground/tasks-demo/tasks-demo.component.ts +++ b/client-v2/src/app/pages/component-playground/tasks-demo/tasks-demo.component.ts @@ -1,14 +1,14 @@ import { Component } from '@angular/core' -import { ITask, prioritySortingMap, statusSortingMap, TaskPriority, TaskStatus } from '../../../models/task.model' +import { Task, prioritySortingMap, statusSortingMap, TaskPriority, TaskStatus } from '../../../models/task.model' -type TaskSorter = (a: ITask, b: ITask) => number +type TaskSorter = (a: Task, b: Task) => number const sortByStatus: TaskSorter = (a, b) => statusSortingMap[a.status] - statusSortingMap[b.status] const sortByPriority: TaskSorter = (a, b) => prioritySortingMap[a.priority] - prioritySortingMap[b.priority] const tasklist = Object.values(TaskStatus) .map(status => - Object.values(TaskPriority).map>((priority, i) => ({ + Object.values(TaskPriority).map>((priority, i) => ({ title: `This is a task (${status}, ${priority})`, description: i % 2 == 0 @@ -20,7 +20,7 @@ const tasklist = Object.values(TaskStatus) ) .flat() -const sortTasklist = (tasklist: ITask[]) => { +const sortTasklist = (tasklist: Task[]) => { const openTasks = tasklist .filter( // prettier-ignore @@ -42,5 +42,5 @@ const sortTasklist = (tasklist: ITask[]) => { styleUrls: [], }) export class TasksDemoComponent { - tasks: ITask[] = sortTasklist(tasklist as unknown as ITask[]) + tasks: Task[] = sortTasklist(tasklist as unknown as Task[]) } From 0e0be25373bf1b31f8c26c017dfb91b682e59470 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Thu, 15 Dec 2022 21:47:42 +0100 Subject: [PATCH 04/60] Add task/list store --- .../src/app/services/task.service.spec.ts | 16 +++++++++ client-v2/src/app/services/task.service.ts | 14 ++++++++ client-v2/src/app/store/index.ts | 7 +++- client-v2/src/app/store/task/task.actions.ts | 13 +++++++ client-v2/src/app/store/task/task.effects.ts | 27 ++++++++++++++ client-v2/src/app/store/task/task.model.ts | 9 +++++ client-v2/src/app/store/task/task.reducer.ts | 19 ++++++++++ client-v2/src/app/store/task/utils.ts | 35 +++++++++++++++++++ server/src/task/list/list.controller.ts | 5 +++ server/src/task/list/list.repository.ts | 19 ++++++++++ server/src/task/list/list.service.ts | 4 +++ 11 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 client-v2/src/app/services/task.service.spec.ts create mode 100644 client-v2/src/app/services/task.service.ts create mode 100644 client-v2/src/app/store/task/task.actions.ts create mode 100644 client-v2/src/app/store/task/task.effects.ts create mode 100644 client-v2/src/app/store/task/task.model.ts create mode 100644 client-v2/src/app/store/task/task.reducer.ts create mode 100644 client-v2/src/app/store/task/utils.ts diff --git a/client-v2/src/app/services/task.service.spec.ts b/client-v2/src/app/services/task.service.spec.ts new file mode 100644 index 00000000..8cd6cd39 --- /dev/null +++ b/client-v2/src/app/services/task.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing' + +import { TaskService } from './task.service' + +describe('TaskService', () => { + let service: TaskService + + beforeEach(() => { + TestBed.configureTestingModule({}) + service = TestBed.inject(TaskService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/services/task.service.ts b/client-v2/src/app/services/task.service.ts new file mode 100644 index 00000000..29c7b813 --- /dev/null +++ b/client-v2/src/app/services/task.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core' +import { HttpService } from '../http/http.service' +import { TasklistPreview } from '../models/task.model' + +@Injectable({ + providedIn: 'root', +}) +export class TaskService { + constructor(private http: HttpService) {} + + getRootListPreviews() { + return this.http.get('/all-lists') + } +} diff --git a/client-v2/src/app/store/index.ts b/client-v2/src/app/store/index.ts index d973a30d..07ff8c2b 100644 --- a/client-v2/src/app/store/index.ts +++ b/client-v2/src/app/store/index.ts @@ -5,15 +5,20 @@ import { AuthEffects } from './user/auth.effects' import { AccountEffects } from './user/account.effects' import { UserState } from './user/user.model' import { userReducer } from './user/user.reducer' +import { taskReducer } from './task/task.reducer' +import { TaskState } from './task/task.model' +import { TaskEffects } from './task/task.effects' export interface AppState { user: UserState + task: TaskState } export const reducers: ActionReducerMap = { user: userReducer, + task: taskReducer, } -export const effects = [AppEffects, AuthEffects, AccountEffects] +export const effects = [AppEffects, AuthEffects, AccountEffects, TaskEffects] const actionLogger: MetaReducer = reducer => (state, action) => { console.info('%caction: %c' + action.type, 'color: hsl(130, 0%, 50%);', 'color: hsl(155, 100%, 50%);') diff --git a/client-v2/src/app/store/task/task.actions.ts b/client-v2/src/app/store/task/task.actions.ts new file mode 100644 index 00000000..199981c1 --- /dev/null +++ b/client-v2/src/app/store/task/task.actions.ts @@ -0,0 +1,13 @@ +import { createActionGroup, emptyProps, props } from '@ngrx/store' +import { CreateTasklistDto, TasklistPreview } from 'src/app/models/task.model' + +export const listActions = createActionGroup({ + source: 'Task/Lists', + events: { + 'load list previews': emptyProps(), + 'load list previews success': props<{ previews: TasklistPreview[] }>(), + 'load list previews error': emptyProps(), + + 'create task list': props(), + }, +}) diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/task/task.effects.ts new file mode 100644 index 00000000..c20ac79a --- /dev/null +++ b/client-v2/src/app/store/task/task.effects.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core' +import { Actions, createEffect, ofType } from '@ngrx/effects' +import { catchError, map, mergeMap, of } from 'rxjs' +import { TaskService } from 'src/app/services/task.service' +import { listActions } from './task.actions' + +@Injectable() +export class TaskEffects { + constructor(private actions$: Actions, private taskService: TaskService) {} + + loadRootListPreviews = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.loadListPreviews), + mergeMap(() => { + const res$ = this.taskService.getRootListPreviews() + + return res$.pipe( + map(listPreviews => listActions.loadListPreviewsSuccess({ previews: listPreviews })), + catchError(err => { + console.error(err) + return of(listActions.loadListPreviewsError()) + }) + ) + }) + ) + }) +} diff --git a/client-v2/src/app/store/task/task.model.ts b/client-v2/src/app/store/task/task.model.ts new file mode 100644 index 00000000..0d778636 --- /dev/null +++ b/client-v2/src/app/store/task/task.model.ts @@ -0,0 +1,9 @@ +import { TasklistPreview } from 'src/app/models/task.model' + +export type TaskListPreviewRecursive = Omit & { + childLists: TaskListPreviewRecursive[] +} + +export interface TaskState { + listPreviews: TaskListPreviewRecursive[] | null +} diff --git a/client-v2/src/app/store/task/task.reducer.ts b/client-v2/src/app/store/task/task.reducer.ts new file mode 100644 index 00000000..84eaeeec --- /dev/null +++ b/client-v2/src/app/store/task/task.reducer.ts @@ -0,0 +1,19 @@ +import { createReducer, on } from '@ngrx/store' +import { listActions } from './task.actions' +import { TaskState } from './task.model' +import { getTasklistTree } from './utils' + +const initialState: TaskState = { + listPreviews: null, +} + +export const taskReducer = createReducer( + initialState, + + on(listActions.loadListPreviewsSuccess, (state, { previews }) => { + return { + ...state, + listPreviews: getTasklistTree(previews), + } + }) +) diff --git a/client-v2/src/app/store/task/utils.ts b/client-v2/src/app/store/task/utils.ts new file mode 100644 index 00000000..0c2281f4 --- /dev/null +++ b/client-v2/src/app/store/task/utils.ts @@ -0,0 +1,35 @@ +import { TasklistPreview } from 'src/app/models/task.model' +import { TaskListPreviewRecursive } from './task.model' + +export const getTasklistTree = (allLists: TasklistPreview[]) => { + const getChildren = (childIds: string[]): TaskListPreviewRecursive[] => { + const children = childIds + .map(childId => allLists.find(list => list.id == childId)) + .filter(childList => !!childList) as TasklistPreview[] + + return children.map(child => { + const grandChildren = getChildren(child.childLists) + return { ...child, childLists: grandChildren } + }) + } + + const listTree = allLists + .filter(list => !list.parentListId) + .map(list => ({ ...list, childLists: getChildren(list.childLists) })) + return listTree +} + +export type TasklistFlattend = Omit & { + path: string[] + childrenCount: number +} + +export const flattenListTree = (lists: TaskListPreviewRecursive[], path: string[] = []): TasklistFlattend[] => { + return lists.flatMap(list => { + const { childLists, ...rest } = list + const flatList = { ...rest, path, childrenCount: childLists.length } + const subpath = [...path, list.id] + + return [flatList, ...flattenListTree(childLists, subpath)] + }) +} diff --git a/server/src/task/list/list.controller.ts b/server/src/task/list/list.controller.ts index b30fa4ea..9a360477 100644 --- a/server/src/task/list/list.controller.ts +++ b/server/src/task/list/list.controller.ts @@ -33,6 +33,11 @@ export class ListController { getRootLevelTasklists(@GetUser() user: User) { return this.listService.getRootLevelTasklists(user.id) } + @Get('all-lists') + getAllTasklists(@GetUser() user: User) { + return this.listService.getAllTasklists(user.id) + } + // nested child list previews @Get('list/:listId/child-lists') getChildTasklists(@GetUser() user: User, @Param('listId') listId: string) { diff --git a/server/src/task/list/list.repository.ts b/server/src/task/list/list.repository.ts index d848491e..19aa9747 100644 --- a/server/src/task/list/list.repository.ts +++ b/server/src/task/list/list.repository.ts @@ -78,6 +78,25 @@ export class ListRepository { })) } + async getAllTasklists(userId: string) { + const lists = await this.prisma.tasklist.findMany({ + where: { + participants: { some: { userId } }, + }, + select: { + id: true, + parentListId: true, + name: true, + childLists: { select: { id: true } }, + }, + }) + + return lists.map(({ childLists, ...list }) => ({ + ...list, + childLists: childLists.map((l) => l.id), + })) + } + async getChildTasklists(listId: string) { return await this.prisma.tasklist.findMany({ where: { parentListId: listId }, diff --git a/server/src/task/list/list.service.ts b/server/src/task/list/list.service.ts index 49534dad..4c175a35 100644 --- a/server/src/task/list/list.service.ts +++ b/server/src/task/list/list.service.ts @@ -45,6 +45,10 @@ export class ListService { async getRootLevelTasklists(userId: string) { return this.listRepository.getRootLevelTasklists(userId) } + async getAllTasklists(userId: string) { + return this.listRepository.getAllTasklists(userId) + } + async getChildTasklists(userId: string, listId: string) { const hasPermissions = await this.permissions.hasPermissionForList( userId, From 5113c0ee7e7de71b47dc7eb03e2b4a8fd3042238 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Thu, 15 Dec 2022 21:49:29 +0100 Subject: [PATCH 05/60] Integrate tasklists into sidebar tree --- .../src/app/pages/home/home.component.html | 21 +- .../src/app/pages/home/home.component.ts | 284 ++++-------------- 2 files changed, 66 insertions(+), 239 deletions(-) diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index 31beb7cf..73896a5b 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -45,8 +45,14 @@ - - + + + +
diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 1f4e812f..b46d1178 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -1,263 +1,60 @@ import { ArrayDataSource } from '@angular/cdk/collections' import { FlatTreeControl } from '@angular/cdk/tree' -import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { Store } from '@ngrx/store' +import { map, tap } from 'rxjs' import { EntityType, PageEntityIconKey, } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { TaskStatus } from 'src/app/models/task.model' +import { AppState } from 'src/app/store' +import { listActions } from 'src/app/store/task/task.actions' +import { flattenListTree, TasklistFlattend } from 'src/app/store/task/utils' -const TREE_DATA: ExampleFlatNode[] = [ - { - name: 'Fruit', - expandable: false, - level: 0, - }, - { - name: 'Fruit', - expandable: true, - level: 0, - }, - { - name: 'Apple', - expandable: false, - level: 1, - }, - { - name: 'Banana', - expandable: false, - level: 1, - }, - { - name: 'Fruit loops', - expandable: false, - level: 1, - }, - { - name: 'Vegetables', - expandable: true, - isExpanded: true, - level: 0, - }, - { - name: 'Yellow', - expandable: false, - entityType: EntityType.DOCUMENT, - level: 1, - }, - { - name: 'Green', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Broccoli', - expandable: false, - entityType: EntityType.DOCUMENT, - level: 2, - }, - { - name: 'Brussels sprouts', - expandable: false, - level: 2, - }, - { - name: 'Other', - expandable: true, - isExpanded: true, - entityType: EntityType.VIEW, - level: 2, - }, - { - name: 'Other', - expandable: false, - entityType: EntityType.VIEW, - level: 3, - }, - { - name: 'Other', - expandable: false, - entityType: EntityType.VIEW, - level: 3, - }, - { - name: 'Orange', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Pumpkins', - expandable: false, - level: 2, - }, - { - name: 'Carrots', - expandable: false, - level: 2, - }, - { - name: 'Vegetables', - expandable: true, - isExpanded: true, - level: 0, - }, - { - name: 'Yellow', - expandable: false, - level: 1, - }, - { - name: 'Green', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Broccoli', - entityType: EntityType.DOCUMENT, - expandable: false, - level: 2, - }, - { - name: 'Brussels sprouts', - expandable: false, - entityType: EntityType.DOCUMENT, - level: 2, - }, - { - name: 'Other', - expandable: true, - isExpanded: true, - level: 2, - }, - { - name: 'Other', - expandable: false, - level: 3, - }, - { - name: 'Other', - expandable: false, - level: 3, - }, - { - name: 'Orange', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Pumpkins', - expandable: false, - level: 2, - }, - { - name: 'Carrots', - expandable: false, - entityType: EntityType.DOCUMENT, - level: 2, - }, - { - name: 'Vegetables', - expandable: true, - isExpanded: true, - level: 0, - }, - { - name: 'Yellow', - expandable: false, - level: 1, - }, - { - name: 'Green', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Broccoli', - expandable: false, - level: 2, - }, - { - name: 'Brussels sprouts', - expandable: false, - level: 2, - }, - { - name: 'Other', - expandable: true, - isExpanded: true, - level: 2, - }, - { - name: 'Other', - expandable: false, - level: 3, - }, - { - name: 'Other', - expandable: false, - level: 3, - entityType: EntityType.DOCUMENT, - }, - { - name: 'Orange', - expandable: true, - isExpanded: true, - level: 1, - }, - { - name: 'Pumpkins', - expandable: false, - level: 2, - }, - { - name: 'Carrots', - expandable: false, - level: 2, - }, -] - -/** Flat node with expandable and level information */ -interface ExampleFlatNode { - expandable: boolean +export interface EntityTreeNode { + id: string name: string - level: number + path: string[] // <-- level: path.length + expandable: boolean + isExpanded?: boolean entityType?: EntityType } +export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode => { + const { childrenCount, ...rest } = list + const node: EntityTreeNode = { + ...rest, + expandable: childrenCount > 0, + + isExpanded: Math.random() > 0.5, + entityType: EntityType.TASKLIST, + } + return node +} + @Component({ selector: 'app-home', templateUrl: './home.component.html', styleUrls: ['./home.component.css'], }) -export class HomeComponent implements AfterViewInit, OnDestroy { - treeControl = new FlatTreeControl( - node => node.level, - node => node.expandable - ) - - dataSource = new ArrayDataSource(TREE_DATA) - - hasChild = (_: number, node: ExampleFlatNode) => node.expandable +export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { + constructor(private store: Store) {} - getParentNode(node: ExampleFlatNode) { - const nodeIndex = TREE_DATA.indexOf(node) + getParentNode(node: EntityTreeNode) { + const nodeIndex = this.listPreviewsTransformed.indexOf(node) for (let i = nodeIndex - 1; i >= 0; i--) { - if (TREE_DATA[i].level === node.level - 1) { - return TREE_DATA[i] + if (this.listPreviewsTransformed[i].path.length === node.path.length - 1) { + return this.listPreviewsTransformed[i] } } return null } - shouldRender(node: ExampleFlatNode) { + shouldRender(node: EntityTreeNode) { let parent = this.getParentNode(node) while (parent) { if (!parent.isExpanded) { @@ -276,6 +73,24 @@ export class HomeComponent implements AfterViewInit, OnDestroy { console.log(str) } + listPreviewsTransformed: EntityTreeNode[] = [] + listPreviewsTransformed$ = this.store + .select(state => state.task.listPreviews) + .pipe( + map(listTree => { + if (!listTree) return [] + + return flattenListTree(listTree).map(convertToEntityTreeNode) + }), + tap(transformed => (this.listPreviewsTransformed = transformed)) + ) + + dataSource = new ArrayDataSource(this.listPreviewsTransformed$) + treeControl = new FlatTreeControl( + node => node.path.length, + node => node.expandable + ) + ///////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////// @@ -305,6 +120,9 @@ export class HomeComponent implements AfterViewInit, OnDestroy { { threshold: [0.5] } ) + ngOnInit(): void { + this.store.dispatch(listActions.loadListPreviews()) + } ngAfterViewInit(): void { this.progressBarObserver.observe(this.progressBar.nativeElement) } From 759ef04db618f1534197d418d71d9dab90b79da2 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Thu, 15 Dec 2022 21:52:20 +0100 Subject: [PATCH 06/60] Hide sidebar scroll bar when unused --- .../templates/sidebar-layout/sidebar-layout.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html index 214e7c3a..7f0a6636 100644 --- a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html +++ b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html @@ -36,7 +36,7 @@ -
- + +
+ +
+
Date: Fri, 16 Dec 2022 18:55:38 +0100 Subject: [PATCH 11/60] Add loading spinner, placeholder to tree --- client-v2/src/app/pages/home/home.component.html | 11 ++++++++++- client-v2/src/app/pages/home/home.component.ts | 13 +++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index 63b3dbdc..c264233d 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -41,7 +41,15 @@ -

Tasklists

+ +
+ +
+ + +

+ No lists so far... +

@@ -97,6 +105,7 @@ +
diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 82514d97..28e33ed6 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -1,6 +1,7 @@ import { ArrayDataSource } from '@angular/cdk/collections' import { FlatTreeControl } from '@angular/cdk/tree' import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' import { map, tap } from 'rxjs' import { @@ -11,6 +12,8 @@ import { TaskStatus } from 'src/app/models/task.model' import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' import { flattenListTree, TasklistFlattend } from 'src/app/store/task/utils' +import { moveToMacroQueue } from 'src/app/utils' +import { getLoadingUpdates } from 'src/app/utils/store.helpers' export interface EntityTreeNode { id: string @@ -40,7 +43,7 @@ export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode styleUrls: ['./home.component.css'], }) export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { - constructor(private store: Store) {} + constructor(private store: Store, private actions$: Actions) {} getParentNode(node: EntityTreeNode) { const nodeIndex = this.listPreviewsTransformed.indexOf(node) @@ -85,6 +88,12 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { tap(transformed => (this.listPreviewsTransformed = transformed)) ) + isTreeLoading$ = getLoadingUpdates(this.actions$, [ + listActions.loadListPreviews, + listActions.loadListPreviewsSuccess, + listActions.loadListPreviewsError, + ]) + dataSource = new ArrayDataSource(this.listPreviewsTransformed$) treeControl = new FlatTreeControl( node => node.path.length, @@ -128,7 +137,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { ) ngOnInit(): void { - this.store.dispatch(listActions.loadListPreviews()) + moveToMacroQueue(() => this.store.dispatch(listActions.loadListPreviews())) } ngAfterViewInit(): void { this.progressBarObserver.observe(this.progressBar.nativeElement) From edb4dcbb4257d7424db0e9cea10d5046f8c66702 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 16 Dec 2022 19:01:28 +0100 Subject: [PATCH 12/60] Add create root list button --- client-v2/src/app/pages/home/home.component.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index c264233d..5d1b5eac 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -41,6 +41,16 @@ +

+ Tasklists + +

From 75e9f94998517f72c1f135663fc62ab1e88d3288 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 16 Dec 2022 19:05:24 +0100 Subject: [PATCH 13/60] Add tree node drop down menu --- .../drop-down/drop-down.component.html | 2 +- .../drop-down/drop-down.component.ts | 6 +- .../src/app/pages/home/home.component.html | 109 ++++++++++-------- .../src/app/pages/home/home.component.ts | 17 +++ 4 files changed, 80 insertions(+), 54 deletions(-) diff --git a/client-v2/src/app/components/molecules/drop-down/drop-down.component.html b/client-v2/src/app/components/molecules/drop-down/drop-down.component.html index 285312e1..fdb68737 100644 --- a/client-v2/src/app/components/molecules/drop-down/drop-down.component.html +++ b/client-v2/src/app/components/molecules/drop-down/drop-down.component.html @@ -4,7 +4,7 @@ - + void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + action?: (data?: any) => void children?: MenuItem[] isSeperator?: boolean variant?: MenuItemVariant @@ -27,10 +28,11 @@ export enum MenuItemVariant { export class DropDownComponent { @Input() items!: MenuItem[] @Input() rootTrigger?: CdkMenuTrigger + @Input() data?: unknown triggerAction(action: MenuItem['action']) { // this ensures that the keydown event doesn't get picked up by another component - moveToMacroQueue(() => action?.()) + moveToMacroQueue(() => action?.(this.data)) } MenuItemVariant = MenuItemVariant diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index 5d1b5eac..f1d06c83 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -61,60 +61,67 @@ No lists so far...

- - - - -
+ + +
+ + +
+ +
+
+ + + + + +
+ +
+ + +
+ + - - - - - -
- -
- - -
- - - + + + diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 28e33ed6..85fa95d3 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -8,6 +8,7 @@ import { EntityType, PageEntityIconKey, } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' +import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' import { TaskStatus } from 'src/app/models/task.model' import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' @@ -106,6 +107,22 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { this.store.dispatch(listActions.createTaskList({ name, parentListId })) } + nodeMenuItems: MenuItem[] = [ + { + title: 'Rename', + }, + { + title: 'Create new list inside', + }, + { + title: 'Duplicate', + }, + { isSeperator: true }, + { + title: 'Delete', + variant: MenuItemVariant.DANGER, + }, + ] ///////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////// From 2f10ae6baa73dce6947d6472e8c6813b453f47fa Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 16 Dec 2022 19:30:04 +0100 Subject: [PATCH 14/60] Add more crud operations --- .../src/app/pages/home/home.component.ts | 20 +++- client-v2/src/app/services/task.service.ts | 11 ++- client-v2/src/app/store/task/task.actions.ts | 14 +++ client-v2/src/app/store/task/task.effects.ts | 93 ++++++++++++++++++- client-v2/src/app/store/task/task.reducer.ts | 25 ++++- client-v2/src/app/store/task/utils.ts | 22 +++-- server/src/task/list/list.service.ts | 4 +- 7 files changed, 177 insertions(+), 12 deletions(-) diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 85fa95d3..6f31148a 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -32,7 +32,7 @@ export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode ...rest, expandable: childrenCount > 0, - isExpanded: Math.random() > 0.5, + isExpanded: true, entityType: EntityType.TASKLIST, } return node @@ -107,20 +107,38 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { this.store.dispatch(listActions.createTaskList({ name, parentListId })) } + renameList(id: string) { + const prevName = '' + const newName = prompt('Rename the list', prevName)?.trim() + if (!newName) return + + this.store.dispatch(listActions.renameList({ id, newName })) + } + duplicateList(id: string) { + this.store.dispatch(listActions.duplicateList({ id })) + } + deleteList(id: string) { + this.store.dispatch(listActions.deleteList({ id })) + } + nodeMenuItems: MenuItem[] = [ { title: 'Rename', + action: (id: string) => this.renameList(id), }, { title: 'Create new list inside', + action: (id: string) => this.createNewList(id), }, { title: 'Duplicate', + action: (id: string) => this.duplicateList(id), }, { isSeperator: true }, { title: 'Delete', variant: MenuItemVariant.DANGER, + action: (id: string) => this.deleteList(id), }, ] diff --git a/client-v2/src/app/services/task.service.ts b/client-v2/src/app/services/task.service.ts index 13f045ab..591b6748 100644 --- a/client-v2/src/app/services/task.service.ts +++ b/client-v2/src/app/services/task.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core' import { HttpService } from '../http/http.service' -import { CreateTasklistDto, TaskList, TasklistPreview } from '../models/task.model' +import { HttpSuccessResponse } from '../http/types' +import { CreateTasklistDto, TaskList, TasklistPreview, UpdateTasklistDto } from '../models/task.model' @Injectable({ providedIn: 'root', @@ -15,4 +16,12 @@ export class TaskService { createTaskList(dto: CreateTasklistDto) { return this.http.post('/list', dto) } + + updateTaskList(id: string, dto: UpdateTasklistDto) { + return this.http.patch('/list/' + id, dto) + } + + deleteTaskList(id: string) { + return this.http.delete('/list/' + id) + } } diff --git a/client-v2/src/app/store/task/task.actions.ts b/client-v2/src/app/store/task/task.actions.ts index 11a9a7a3..8a97d359 100644 --- a/client-v2/src/app/store/task/task.actions.ts +++ b/client-v2/src/app/store/task/task.actions.ts @@ -12,5 +12,19 @@ export const listActions = createActionGroup({ 'create task list': props(), 'create task list success': props<{ createdList: TaskList }>(), 'create task list error': props(), + + 'rename list': props<{ id: string; newName: string }>(), + 'rename list success': props<{ id: string; newName: string }>(), + 'rename list error': props(), + + 'delete list': props<{ id: string }>(), + 'delete list abort': emptyProps(), + 'delete list proceed': props<{ id: string }>(), + 'delete list success': props<{ id: string }>(), + 'delete list error': props(), + + 'duplicate list': props<{ id: string }>(), + 'duplicate list success': props<{ id: string }>(), + 'duplicate list error': props<{ id: string }>(), }, }) diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/task/task.effects.ts index c5c51ff6..9b458b1d 100644 --- a/client-v2/src/app/store/task/task.effects.ts +++ b/client-v2/src/app/store/task/task.effects.ts @@ -1,14 +1,25 @@ import { Injectable } from '@angular/core' import { HotToastService } from '@ngneat/hot-toast' import { Actions, createEffect, ofType } from '@ngrx/effects' -import { catchError, map, mergeMap, of } from 'rxjs' +import { Store } from '@ngrx/store' +import { catchError, concatMap, map, mergeMap, of, switchMap } from 'rxjs' +import { DialogService } from 'src/app/modal/dialog.service' import { TaskService } from 'src/app/services/task.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' +import { AppState } from '..' +import { appActions } from '../app.actions' import { listActions } from './task.actions' +import { getTaskListById } from './utils' @Injectable() export class TaskEffects { - constructor(private actions$: Actions, private taskService: TaskService, private toast: HotToastService) {} + constructor( + private actions$: Actions, + private taskService: TaskService, + private toast: HotToastService, + private store: Store, + private dialogService: DialogService + ) {} loadListPreviews = createEffect(() => { return this.actions$.pipe( @@ -45,4 +56,82 @@ export class TaskEffects { }) ) }) + + renameTaskList = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.renameList), + mergeMap(({ id, newName }) => { + const res$ = this.taskService.updateTaskList(id, { name: newName }) + + return res$.pipe( + this.toast.observe({ + loading: 'Renaming tasklist...', + success: `Renamed tasklist to '${newName}'`, + error: getMessageFromHttpError, + }), + map(() => listActions.renameListSuccess({ id, newName })), + catchError(err => of(listActions.renameListError(err))) + ) + }) + ) + }) + + duplicateList = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.duplicateList), + mergeMap(() => { + this.toast.info('Duplicating lists is not supported yet.') + return of(appActions.nothing()) + }) + ) + }) + + showDeleteListDialog = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.deleteList), + switchMap(({ id }) => { + return this.store + .select(state => state.task.listPreviews) + .pipe( + concatMap(listPreviews => { + if (!listPreviews) return of(listActions.deleteListAbort()) + + const taskList = getTaskListById(listPreviews, id) + if (!taskList) return of(listActions.deleteListAbort()) + + const closed$ = this.dialogService.confirm({ + title: 'Delete this tasklist?', + text: `Are you sure you want to delete '${taskList.name}'?`, + buttons: [{ text: 'Cancel' }, { text: 'Delete', className: 'button--danger' }], + }).closed + + return closed$.pipe( + map(response => { + if (response == 'Delete') return listActions.deleteListProceed({ id }) + return listActions.deleteListAbort() + }) + ) + }) + ) + }) + ) + }) + deleteList = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.deleteListProceed), + mergeMap(({ id }) => { + const res$ = this.taskService.deleteTaskList(id) + + return res$.pipe( + this.toast.observe({ + loading: 'Deleting tasklist...', + success: res => res.successMessage, + error: getMessageFromHttpError, + }), + map(() => listActions.deleteListSuccess({ id })), + catchError(err => of(listActions.deleteListError(err))) + ) + }) + ) + }) } diff --git a/client-v2/src/app/store/task/task.reducer.ts b/client-v2/src/app/store/task/task.reducer.ts index 8378834d..2e02e19e 100644 --- a/client-v2/src/app/store/task/task.reducer.ts +++ b/client-v2/src/app/store/task/task.reducer.ts @@ -1,7 +1,7 @@ import { createReducer, on } from '@ngrx/store' import { listActions } from './task.actions' import { TaskState } from './task.model' -import { getTaskListById, getTasklistTree } from './utils' +import { getParentListByChildId, getTaskListById, getTasklistTree } from './utils' const initialState: TaskState = { listPreviews: null, @@ -39,5 +39,28 @@ export const taskReducer = createReducer( ...state, listPreviews: listPreviewsCopy, } + }), + + on(listActions.renameListSuccess, (state, { id, newName }) => { + const listPreviewsCopy = structuredClone(state.listPreviews) + const list = getTaskListById(listPreviewsCopy, id) + if (!list) return state + + list.name = newName + + return { + ...state, + listPreviews: listPreviewsCopy, + } + }), + + on(listActions.deleteListSuccess, (state, { id }) => { + const listPreviewsCopy = structuredClone(state.listPreviews) + const result = getParentListByChildId(listPreviewsCopy, id) + if (!result) return state + + result.list.splice(result.index, 1) + + return { ...state, listPreviews: listPreviewsCopy } }) ) diff --git a/client-v2/src/app/store/task/utils.ts b/client-v2/src/app/store/task/utils.ts index 7fba2c2c..9afdd874 100644 --- a/client-v2/src/app/store/task/utils.ts +++ b/client-v2/src/app/store/task/utils.ts @@ -34,15 +34,25 @@ export const flattenListTree = (lists: TaskListPreviewRecursive[], path: string[ }) } -export const getTaskListById = (taskLists: TaskListPreviewRecursive[], id: string): TaskListPreviewRecursive | void => { - for (let index = 0; index < taskLists.length; index++) { - const tasklist = taskLists[index] +export const getParentListByChildId = ( + list: TaskListPreviewRecursive[], + id: string +): { list: TaskListPreviewRecursive[]; index: number } | void => { + for (let index = 0; index < list.length; index++) { + const tasklist = list[index] - if (tasklist.id == id) return tasklist + if (tasklist.id == id) return { list, index } if (tasklist.childLists.length) { - const taskList = getTaskListById(tasklist.childLists, id) - if (taskList) return taskList + const result = getParentListByChildId(tasklist.childLists, id) + if (result) return result } } } + +export const getTaskListById = (taskLists: TaskListPreviewRecursive[], id: string): TaskListPreviewRecursive | void => { + const res = getParentListByChildId(taskLists, id) + if (!res) return + + return res.list[res.index] +} diff --git a/server/src/task/list/list.service.ts b/server/src/task/list/list.service.ts index 4c175a35..fc3fed78 100644 --- a/server/src/task/list/list.service.ts +++ b/server/src/task/list/list.service.ts @@ -39,7 +39,9 @@ export class ListService { ) if (!hasPermissions) throw new ForbiddenException("You don't have permission to delete this tasklist") - return this.listRepository.deleteTasklist(listId) + await this.listRepository.deleteTasklist(listId) + + return { successMessage: 'List deleted successfully' } } async getRootLevelTasklists(userId: string) { From d95a6d0e12305e7c241b6d22e07e4de456bd6efa Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:03:30 +0100 Subject: [PATCH 15/60] Extract out main pane from sidebar into own component, extract out entity view from home into own component --- client-v2/src/app/app-routing.module.ts | 2 + client-v2/src/app/app.module.ts | 6 ++ .../main-pane/main-pane.component.html | 17 ++++ .../main-pane/main-pane.component.spec.ts | 22 +++++ .../main-pane/main-pane.component.ts | 44 +++++++++ .../menu-toggle/menu-toggle.component.spec.ts | 22 +++++ .../menu-toggle/menu-toggle.component.ts | 24 +++++ .../sidebar-layout/menu.service.spec.ts | 16 ++++ .../templates/sidebar-layout/menu.service.ts | 20 ++++ .../sidebar-layout.component.css | 9 -- .../sidebar-layout.component.html | 36 +------ .../sidebar-layout.component.ts | 12 +-- .../entity-page/entity-page.component.css | 10 ++ .../entity-page/entity-page.component.html | 74 +++++++++++++++ .../entity-page/entity-page.component.spec.ts | 22 +++++ .../home/entity-page/entity-page.component.ts | 92 ++++++++++++++++++ .../src/app/pages/home/home.component.css | 7 -- .../src/app/pages/home/home.component.html | 93 +------------------ .../src/app/pages/home/home.component.ts | 53 ++--------- .../pages/settings/settings.component.html | 8 +- client-v2/src/app/store/task/utils.ts | 19 ++++ 21 files changed, 414 insertions(+), 194 deletions(-) create mode 100644 client-v2/src/app/components/templates/main-pane/main-pane.component.html create mode 100644 client-v2/src/app/components/templates/main-pane/main-pane.component.spec.ts create mode 100644 client-v2/src/app/components/templates/main-pane/main-pane.component.ts create mode 100644 client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.spec.ts create mode 100644 client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts create mode 100644 client-v2/src/app/components/templates/sidebar-layout/menu.service.spec.ts create mode 100644 client-v2/src/app/components/templates/sidebar-layout/menu.service.ts create mode 100644 client-v2/src/app/pages/home/entity-page/entity-page.component.css create mode 100644 client-v2/src/app/pages/home/entity-page/entity-page.component.html create mode 100644 client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts create mode 100644 client-v2/src/app/pages/home/entity-page/entity-page.component.ts diff --git a/client-v2/src/app/app-routing.module.ts b/client-v2/src/app/app-routing.module.ts index 2359ec31..2192f10a 100644 --- a/client-v2/src/app/app-routing.module.ts +++ b/client-v2/src/app/app-routing.module.ts @@ -6,6 +6,7 @@ import { LoginLoadingComponent } from './pages/auth/login-loading/login-loading. import { LoginComponent } from './pages/auth/login/login.component' import { SignupComponent } from './pages/auth/signup/signup.component' import { ComponentPlaygroundComponent } from './pages/component-playground/component-playground.component' +import { EntityPageComponent } from './pages/home/entity-page/entity-page.component' import { HomeComponent } from './pages/home/home.component' import { LandingPageComponent } from './pages/landing-page/landing-page.component' import { NotFoundPageComponent } from './pages/not-found-page/not-found-page.component' @@ -47,6 +48,7 @@ const routes: Routes = [ path: 'home', component: HomeComponent, canActivate: [AuthGuard], + children: [{ path: ':id', component: EntityPageComponent }], }, { path: 'settings', diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index 528e90b6..93491b5e 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -41,6 +41,9 @@ import { TooltipDirective } from './directives/tooltip.directive' import { TooltipComponent } from './components/atoms/tooltip/tooltip.component' import { CdkTreeModule } from '@angular/cdk/tree' import { EntityPageLabelComponent } from './components/atoms/entity-page-label/entity-page-label.component' +import { EntityPageComponent } from './pages/home/entity-page/entity-page.component' +import { MainPaneComponent } from './components/templates/main-pane/main-pane.component' +import { MenuToggleComponent } from './components/templates/sidebar-layout/menu-toggle/menu-toggle.component' @NgModule({ declarations: [ @@ -70,6 +73,9 @@ import { EntityPageLabelComponent } from './components/atoms/entity-page-label/e TooltipDirective, TooltipComponent, EntityPageLabelComponent, + EntityPageComponent, + MainPaneComponent, + MenuToggleComponent, ], imports: [ BrowserModule, diff --git a/client-v2/src/app/components/templates/main-pane/main-pane.component.html b/client-v2/src/app/components/templates/main-pane/main-pane.component.html new file mode 100644 index 00000000..ffc415a6 --- /dev/null +++ b/client-v2/src/app/components/templates/main-pane/main-pane.component.html @@ -0,0 +1,17 @@ +
+
+ + +
+
+
+
+ + +
+
+
diff --git a/client-v2/src/app/components/templates/main-pane/main-pane.component.spec.ts b/client-v2/src/app/components/templates/main-pane/main-pane.component.spec.ts new file mode 100644 index 00000000..d034863e --- /dev/null +++ b/client-v2/src/app/components/templates/main-pane/main-pane.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { MainPaneComponent } from './main-pane.component' + +describe('MainPaneComponent', () => { + let component: MainPaneComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MainPaneComponent], + }).compileComponents() + + fixture = TestBed.createComponent(MainPaneComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/templates/main-pane/main-pane.component.ts b/client-v2/src/app/components/templates/main-pane/main-pane.component.ts new file mode 100644 index 00000000..124a0868 --- /dev/null +++ b/client-v2/src/app/components/templates/main-pane/main-pane.component.ts @@ -0,0 +1,44 @@ +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core' + +@Component({ + selector: 'app-main-pane', + templateUrl: './main-pane.component.html', + styles: [ + ` + :host { + @apply contents; + } + + .main-header { + @apply z-30 shadow-header; + } + @media (max-width: 768px) { + .main-header--mobile.hide { + @apply -translate-y-full; + } + } + `, + ], +}) +export class MainPaneComponent implements AfterViewInit, OnDestroy { + // @TODO: Make the prose width adjustable with a drag, or have a couple presets + @Input() prose = true + + isScrolled = false + @ViewChild('scrollSpy') scrollSpy!: ElementRef + + observer = new IntersectionObserver( + entries => { + entries.forEach(entry => { + if (entry.target == this.scrollSpy.nativeElement) this.isScrolled = !entry.isIntersecting + }) + }, + { threshold: [1] } + ) + ngAfterViewInit(): void { + this.observer.observe(this.scrollSpy.nativeElement) + } + ngOnDestroy(): void { + this.observer.disconnect() + } +} diff --git a/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.spec.ts b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.spec.ts new file mode 100644 index 00000000..6d164c9f --- /dev/null +++ b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { MenuToggleComponent } from './menu-toggle.component' + +describe('MenuToggleComponent', () => { + let component: MenuToggleComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MenuToggleComponent], + }).compileComponents() + + fixture = TestBed.createComponent(MenuToggleComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts new file mode 100644 index 00000000..497e430d --- /dev/null +++ b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { MenuService } from '../menu.service' + +@Component({ + selector: 'app-menu-toggle', + template: ` + + `, + styleUrls: [], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MenuToggleComponent { + constructor(private menuService: MenuService) {} + + isMenuOpen$ = this.menuService.isMenuOpen$ + toggleIsOpen() { + this.menuService.toggleIsOpen() + } +} diff --git a/client-v2/src/app/components/templates/sidebar-layout/menu.service.spec.ts b/client-v2/src/app/components/templates/sidebar-layout/menu.service.spec.ts new file mode 100644 index 00000000..e8694176 --- /dev/null +++ b/client-v2/src/app/components/templates/sidebar-layout/menu.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing' + +import { MenuService } from './menu.service' + +describe('MenuService', () => { + let service: MenuService + + beforeEach(() => { + TestBed.configureTestingModule({}) + service = TestBed.inject(MenuService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/templates/sidebar-layout/menu.service.ts b/client-v2/src/app/components/templates/sidebar-layout/menu.service.ts new file mode 100644 index 00000000..2b8ac256 --- /dev/null +++ b/client-v2/src/app/components/templates/sidebar-layout/menu.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core' +import { BehaviorSubject } from 'rxjs' + +@Injectable({ + providedIn: 'root', +}) +export class MenuService { + private _isMenuOpen = new BehaviorSubject(true) + get isMenuOpen$() { + return this._isMenuOpen + } + + setIsOpen(isOpen: boolean) { + this._isMenuOpen.next(isOpen) + } + + toggleIsOpen() { + this._isMenuOpen.next(!this._isMenuOpen.value) + } +} diff --git a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.css b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.css index 15eb3ef8..ab96a91e 100644 --- a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.css +++ b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.css @@ -5,13 +5,4 @@ .sidebar--mobile.hide { @apply -translate-x-full; } -} - -.main-header { - @apply z-30 shadow-header; -} -@media (max-width: 768px) { - .main-header--mobile.hide { - @apply -translate-y-full; - } } \ No newline at end of file diff --git a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html index 7f0a6636..024cabf9 100644 --- a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html +++ b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.html @@ -1,8 +1,8 @@
-
-
- - - -
-
-
-
- - -
-
-
+
diff --git a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.ts b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.ts index 8c4f3ad1..950579e4 100644 --- a/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.ts +++ b/client-v2/src/app/components/templates/sidebar-layout/sidebar-layout.component.ts @@ -1,4 +1,5 @@ import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core' +import { MenuService } from './menu.service' @Component({ selector: 'app-sidebar-layout', @@ -6,12 +7,12 @@ import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } fro styleUrls: ['./sidebar-layout.component.css'], }) export class SidebarLayoutComponent implements AfterViewInit, OnDestroy { - @Input() prose = true + constructor(private menuService: MenuService) {} + @Input() enableResize = true - isMenuOpen = true + isMenuOpen$ = this.menuService.isMenuOpen$ - // @TODO: Make the prose width adjustable with a drag, or have a couple presets @ViewChild('resizeHandle') resizeHandle!: ElementRef ngAfterViewInit(): void { @@ -21,7 +22,6 @@ export class SidebarLayoutComponent implements AfterViewInit, OnDestroy { } this.observer.observe(this.sidebarScrollSpy.nativeElement) - this.observer.observe(this.mainScrollSpy.nativeElement) } ngOnDestroy(): void { if (this.enableResize) { @@ -47,14 +47,10 @@ export class SidebarLayoutComponent implements AfterViewInit, OnDestroy { isSidebarScrolled = false @ViewChild('sidebarScrollSpy') sidebarScrollSpy!: ElementRef - isMainScrolled = false - @ViewChild('mainScrollSpy') mainScrollSpy!: ElementRef - observer = new IntersectionObserver( entries => { entries.forEach(entry => { if (entry.target == this.sidebarScrollSpy.nativeElement) this.isSidebarScrolled = !entry.isIntersecting - if (entry.target == this.mainScrollSpy.nativeElement) this.isMainScrolled = !entry.isIntersecting }) }, { threshold: [1] } diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.css b/client-v2/src/app/pages/home/entity-page/entity-page.component.css new file mode 100644 index 00000000..1c6190e1 --- /dev/null +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.css @@ -0,0 +1,10 @@ +:host { + @apply block w-full; +} + +.progress-container:is(:hover, .glow) .progress :first-child { + box-shadow: 0 0 10px theme('colors.submit.400'); +} +.progress-container.glow span { + @apply text-submit-400; +} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html new file mode 100644 index 00000000..77445335 --- /dev/null +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -0,0 +1,74 @@ + + + + + +
+ +
+ +
+ + +
+ + +
+ + + + + + + + +

+ + + + {{ activeListName$ | async }} +

+ +
+

And this is the part for the description

+

Where you can

+
    +
  • Describe the list/task in further detail
  • +
  • Take notes for yourself
  • +
  • Quickly write down / sketch out ideas
  • +
+
+
+ +
+
+
+
+ +
+ + +
+
diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts new file mode 100644 index 00000000..b0dc72b3 --- /dev/null +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { EntityPageComponent } from './entity-page.component' + +describe('EntityPageComponent', () => { + let component: EntityPageComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EntityPageComponent], + }).compileComponents() + + fixture = TestBed.createComponent(EntityPageComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts new file mode 100644 index 00000000..9e261e0c --- /dev/null +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -0,0 +1,92 @@ +import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { Store } from '@ngrx/store' +import { + BehaviorSubject, + debounceTime, + distinctUntilChanged, + EMPTY, + filter, + first, + map, + merge, + switchMap, + tap, +} from 'rxjs' +import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' +import { Breadcrumb } from 'src/app/components/molecules/breadcrumbs/breadcrumbs.component' +import { TaskStatus } from 'src/app/models/task.model' +import { AppState } from 'src/app/store' +import { listActions } from 'src/app/store/task/task.actions' +import { traceTaskList } from 'src/app/store/task/utils' + +@Component({ + selector: 'app-entity-page', + templateUrl: './entity-page.component.html', + styleUrls: ['./entity-page.component.css'], +}) +export class EntityPageComponent implements AfterViewInit, OnDestroy { + constructor(private store: Store, private route: ActivatedRoute) {} + + activeListAndTrace$ = this.route.paramMap.pipe( + switchMap(paramMap => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const activeId = paramMap.get('id')! + + return this.store + .select(state => state.task.listPreviews) + .pipe( + map(listPreviews => { + if (!listPreviews) return null + + const trace = traceTaskList(listPreviews, activeId) + const activeTaskList = trace[trace.length - 1] + + return { activeTaskList, trace } + }) + ) + }) + ) + + activeTaskList$ = this.activeListAndTrace$.pipe(map(derived => derived?.activeTaskList)) + activeListName$ = this.activeTaskList$.pipe( + map(list => list?.name), + distinctUntilChanged(), + switchMap(listName => { + return this.listnameChanges$.pipe( + first(), + filter(newListname => { + if (!newListname) return true + + return listName != newListname + }), + map(() => listName) + ) + }) + ) + + TaskStatus = TaskStatus + EntityType = EntityType + + closedTasks = 16 + allTasks = 37 + progress = Math.round((this.closedTasks / this.allTasks) * 100) + isShownAsPercentage = true + + isSecondaryProgressBarVisible = false + @ViewChild('progressBar') progressBar!: ElementRef + progressBarObserver = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) this.isSecondaryProgressBarVisible = false + else this.isSecondaryProgressBarVisible = true + }, + { threshold: [0.5] } + ) + + ngAfterViewInit(): void { + this.progressBarObserver.observe(this.progressBar.nativeElement) + } + ngOnDestroy(): void { + this.progressBarObserver.disconnect() + } +} diff --git a/client-v2/src/app/pages/home/home.component.css b/client-v2/src/app/pages/home/home.component.css index 820dc7d5..0b2d90de 100644 --- a/client-v2/src/app/pages/home/home.component.css +++ b/client-v2/src/app/pages/home/home.component.css @@ -1,10 +1,3 @@ -.progress-container:is(:hover, .glow) .progress :first-child { - box-shadow: 0 0 10px theme(colors.submit.400); -} -.progress-container.glow span { - @apply text-submit-400; -} - .tree-node { @apply flex w-full diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index f1d06c83..d7014404 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -125,97 +125,6 @@ - - - - -
- - - - -
- -
-
- -
- - - - - - - - - -

- - - - Taskname, which you can edit -

- -
-

And this is the part for the description

-

Where you can

-
    -
  • Describe the list/task in further detail
  • -
  • Take notes for yourself
  • -
  • Quickly write down / sketch out ideas
  • -
-
-
- -
-
-
-
- -
- - + diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 6f31148a..36280531 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -1,15 +1,11 @@ import { ArrayDataSource } from '@angular/cdk/collections' import { FlatTreeControl } from '@angular/cdk/tree' -import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core' +import { Component, OnInit } from '@angular/core' import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' -import { map, tap } from 'rxjs' -import { - EntityType, - PageEntityIconKey, -} from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' +import { first, map, tap } from 'rxjs' +import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' -import { TaskStatus } from 'src/app/models/task.model' import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' import { flattenListTree, TasklistFlattend } from 'src/app/store/task/utils' @@ -43,9 +39,13 @@ export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode templateUrl: './home.component.html', styleUrls: ['./home.component.css'], }) -export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { +export class HomeComponent implements OnInit { constructor(private store: Store, private actions$: Actions) {} + ngOnInit(): void { + moveToMacroQueue(() => this.store.dispatch(listActions.loadListPreviews())) + } + getParentNode(node: EntityTreeNode) { const nodeIndex = this.listPreviewsTransformed.indexOf(node) @@ -142,42 +142,5 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy { }, ] - ///////////////////////////////////////////////////////////// - ///////////////////////////////////////////////////////////// - - TaskStatus = TaskStatus EntityType = EntityType - - closedTasks = 16 - allTasks = 37 - progress = Math.round((this.closedTasks / this.allTasks) * 100) - isShownAsPercentage = true - - breadcrumbs: { text: string; icon?: PageEntityIconKey }[] = [ - { text: 'Rootlist', icon: EntityType.TASKLIST }, - { text: 'Listname', icon: EntityType.TASKLIST }, - { text: 'Task', icon: TaskStatus.OPEN }, - { text: 'Taskname, which you can edit', icon: TaskStatus.IN_PROGRESS }, - // { text: 'Taskname, which you can edit. what if we get bungos though?', icon: TaskStatus.IN_PROGRESS }, - ] - - isSecondaryProgressBarVisible = false - @ViewChild('progressBar') progressBar!: ElementRef - progressBarObserver = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) this.isSecondaryProgressBarVisible = false - else this.isSecondaryProgressBarVisible = true - }, - { threshold: [0.5] } - ) - - ngOnInit(): void { - moveToMacroQueue(() => this.store.dispatch(listActions.loadListPreviews())) - } - ngAfterViewInit(): void { - this.progressBarObserver.observe(this.progressBar.nativeElement) - } - ngOnDestroy(): void { - this.progressBarObserver.disconnect() - } } diff --git a/client-v2/src/app/pages/settings/settings.component.html b/client-v2/src/app/pages/settings/settings.component.html index 52bbbc20..adfdcb0c 100644 --- a/client-v2/src/app/pages/settings/settings.component.html +++ b/client-v2/src/app/pages/settings/settings.component.html @@ -13,7 +13,13 @@
-
Settings / Breadcrumbs
+ + + + + + + diff --git a/client-v2/src/app/store/task/utils.ts b/client-v2/src/app/store/task/utils.ts index 9afdd874..911168b6 100644 --- a/client-v2/src/app/store/task/utils.ts +++ b/client-v2/src/app/store/task/utils.ts @@ -34,6 +34,25 @@ export const flattenListTree = (lists: TaskListPreviewRecursive[], path: string[ }) } +export const traceTaskList = ( + lists: TaskListPreviewRecursive[], + id: string, + trace: TaskListPreviewRecursive[] = [] +): TaskListPreviewRecursive[] => { + return lists.flatMap(list => { + if (list.id == id) return [list] + + if (list.childLists.length) { + const subtrace = traceTaskList(list.childLists, id, [...trace, list]) + if (subtrace.length) return [list, ...subtrace] + + return [] + } + + return [] + }) +} + export const getParentListByChildId = ( list: TaskListPreviewRecursive[], id: string From 1e64da59767ddea10e960f2b98885bd20f1529ed Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:06:46 +0100 Subject: [PATCH 16/60] Add breadcrumbs component --- client-v2/src/app/app.module.ts | 2 ++ .../page-entity-icon.component.ts | 9 ++++++- .../breadcrumbs/breadcrumbs.component.html | 24 +++++++++++++++++ .../breadcrumbs/breadcrumbs.component.spec.ts | 22 ++++++++++++++++ .../breadcrumbs/breadcrumbs.component.ts | 26 +++++++++++++++++++ .../entity-page/entity-page.component.html | 2 ++ .../home/entity-page/entity-page.component.ts | 9 +++++++ .../pages/settings/settings.component.html | 4 ++- .../app/pages/settings/settings.component.ts | 25 ++++++++++++++++++ 9 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html create mode 100644 client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.spec.ts create mode 100644 client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.ts diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index 93491b5e..d8967296 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -42,6 +42,7 @@ import { TooltipComponent } from './components/atoms/tooltip/tooltip.component' import { CdkTreeModule } from '@angular/cdk/tree' import { EntityPageLabelComponent } from './components/atoms/entity-page-label/entity-page-label.component' import { EntityPageComponent } from './pages/home/entity-page/entity-page.component' +import { BreadcrumbsComponent } from './components/molecules/breadcrumbs/breadcrumbs.component' import { MainPaneComponent } from './components/templates/main-pane/main-pane.component' import { MenuToggleComponent } from './components/templates/sidebar-layout/menu-toggle/menu-toggle.component' @@ -74,6 +75,7 @@ import { MenuToggleComponent } from './components/templates/sidebar-layout/menu- TooltipComponent, EntityPageLabelComponent, EntityPageComponent, + BreadcrumbsComponent, MainPaneComponent, MenuToggleComponent, ], diff --git a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts index 15b30b4a..a92d7856 100644 --- a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts +++ b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { TaskStatus } from 'src/app/models/task.model' /** This will come from the db */ @@ -48,10 +48,17 @@ export const entityIconClassMap: Record = { [PageEntityState.LOADING]: 'far fa-spinner-third animate-spin text-tinted-200', ...taskStatusIconClassMap, } + +export const isPageEntityIcon = (iconClass: string): iconClass is PageEntityIconKey => { + console.log(iconClass) + return iconClass in entityIconClassMap +} + @Component({ selector: 'page-entity-icon', templateUrl: './page-entity-icon.component.html', styleUrls: ['./page-entity-icon.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PageEntityIconComponent { @Input() icon!: PageEntityIconKey diff --git a/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 00000000..3c5b222d --- /dev/null +++ b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,24 @@ + + diff --git a/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.spec.ts b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.spec.ts new file mode 100644 index 00000000..30b1aa20 --- /dev/null +++ b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { BreadcrumbsComponent } from './breadcrumbs.component' + +describe('BreadcrumbsComponent', () => { + let component: BreadcrumbsComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BreadcrumbsComponent], + }).compileComponents() + + fixture = TestBed.createComponent(BreadcrumbsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.ts b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 00000000..82b53a86 --- /dev/null +++ b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,26 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { isPageEntityIcon, PageEntityIconKey } from '../../atoms/icons/page-entity-icon/page-entity-icon.component' + +export interface Breadcrumb { + title: string + icon: PageEntityIconKey | string + route: string +} + +@Component({ + selector: 'app-breadcrumbs', + templateUrl: './breadcrumbs.component.html', + styles: [ + ` + :host { + @apply contents; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BreadcrumbsComponent { + @Input() breadcrumbs!: Breadcrumb[] + + isPageEntityIcon = isPageEntityIcon +} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index 77445335..9e8cc714 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -9,6 +9,8 @@ [style.width]="progress + '%'" > + +
+ +
+ + +
+ +
+ + +

+ No + lists inside +

+
+
, private route: ActivatedRoute) {} - activeListAndTrace$ = this.route.paramMap.pipe( - switchMap(paramMap => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const activeId = paramMap.get('id')! + activeTasklistId$ = this.route.paramMap.pipe(map(paramMap => paramMap.get('id')!)) + activeListAndTrace$ = this.activeTasklistId$.pipe( + switchMap(activeId => { return this.store .select(state => state.task.listPreviews) .pipe( @@ -82,6 +82,12 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { progress = Math.round((this.closedTasks / this.allTasks) * 100) isShownAsPercentage = true + createNewSublist() { + this.activeTasklistId$.pipe(first()).subscribe(activeId => { + this.store.dispatch(listActions.createTaskList({ parentListId: activeId })) + }) + } + isSecondaryProgressBarVisible = false @ViewChild('progressBar') progressBar!: ElementRef progressBarObserver = new IntersectionObserver( diff --git a/client-v2/src/app/store/task/task.actions.ts b/client-v2/src/app/store/task/task.actions.ts index 8a97d359..469009f0 100644 --- a/client-v2/src/app/store/task/task.actions.ts +++ b/client-v2/src/app/store/task/task.actions.ts @@ -9,7 +9,7 @@ export const listActions = createActionGroup({ 'load list previews success': props<{ previews: TasklistPreview[] }>(), 'load list previews error': props(), - 'create task list': props(), + 'create task list': props>(), 'create task list success': props<{ createdList: TaskList }>(), 'create task list error': props(), diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/task/task.effects.ts index 9b458b1d..d3641d56 100644 --- a/client-v2/src/app/store/task/task.effects.ts +++ b/client-v2/src/app/store/task/task.effects.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core' +import { Router } from '@angular/router' import { HotToastService } from '@ngneat/hot-toast' import { Actions, createEffect, ofType } from '@ngrx/effects' import { Store } from '@ngrx/store' -import { catchError, concatMap, map, mergeMap, of, switchMap } from 'rxjs' +import { catchError, concatMap, map, mergeMap, of, switchMap, tap } from 'rxjs' import { DialogService } from 'src/app/modal/dialog.service' import { TaskService } from 'src/app/services/task.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' @@ -18,9 +19,9 @@ export class TaskEffects { private taskService: TaskService, private toast: HotToastService, private store: Store, - private dialogService: DialogService + private dialogService: DialogService, + private router: Router ) {} - loadListPreviews = createEffect(() => { return this.actions$.pipe( ofType(listActions.loadListPreviews), @@ -42,15 +43,17 @@ export class TaskEffects { return this.actions$.pipe( ofType(listActions.createTaskList), mergeMap(dto => { - const res$ = this.taskService.createTaskList(dto) + const name = dto.name || 'Untitled tasklist' + const res$ = this.taskService.createTaskList({ ...dto, name }) return res$.pipe( this.toast.observe({ loading: 'Creating tasklist...', - success: `Created tasklist '${dto.name}'`, + success: `Created tasklist '${name}'`, error: getMessageFromHttpError, }), map(tasklist => listActions.createTaskListSuccess({ createdList: tasklist })), + tap(({ createdList }) => this.router.navigate(['/home', createdList.id])), catchError(err => of(listActions.createTaskListError(err))) ) }) From 8c293a8c7080c604691d4f8cec4c0103f09e497c Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:27:44 +0100 Subject: [PATCH 18/60] Add reactive rename feature to entity page --- client-v2/src/app/app.module.ts | 2 + .../app/directives/mutation.directive.spec.ts | 8 +++ .../src/app/directives/mutation.directive.ts | 52 +++++++++++++++++++ .../entity-page/entity-page.component.html | 14 ++++- .../home/entity-page/entity-page.component.ts | 41 ++++++++++++++- client-v2/src/app/store/task/task.effects.ts | 10 ++-- client-v2/src/css/components.css | 6 ++- 7 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 client-v2/src/app/directives/mutation.directive.spec.ts create mode 100644 client-v2/src/app/directives/mutation.directive.ts diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index d8967296..662e790b 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -44,6 +44,7 @@ import { EntityPageLabelComponent } from './components/atoms/entity-page-label/e import { EntityPageComponent } from './pages/home/entity-page/entity-page.component' import { BreadcrumbsComponent } from './components/molecules/breadcrumbs/breadcrumbs.component' import { MainPaneComponent } from './components/templates/main-pane/main-pane.component' +import { MutationDirective } from './directives/mutation.directive' import { MenuToggleComponent } from './components/templates/sidebar-layout/menu-toggle/menu-toggle.component' @NgModule({ @@ -77,6 +78,7 @@ import { MenuToggleComponent } from './components/templates/sidebar-layout/menu- EntityPageComponent, BreadcrumbsComponent, MainPaneComponent, + MutationDirective, MenuToggleComponent, ], imports: [ diff --git a/client-v2/src/app/directives/mutation.directive.spec.ts b/client-v2/src/app/directives/mutation.directive.spec.ts new file mode 100644 index 00000000..c9f4c664 --- /dev/null +++ b/client-v2/src/app/directives/mutation.directive.spec.ts @@ -0,0 +1,8 @@ +import { MutationDirective } from './mutation.directive' + +describe('MutationDirective', () => { + it('should create an instance', () => { + const directive = new MutationDirective() + expect(directive).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/directives/mutation.directive.ts b/client-v2/src/app/directives/mutation.directive.ts new file mode 100644 index 00000000..7e46ab02 --- /dev/null +++ b/client-v2/src/app/directives/mutation.directive.ts @@ -0,0 +1,52 @@ +import { Directive, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges } from '@angular/core' + +@Directive({ + selector: '[domChanges], [textChanges]', +}) +export class MutationDirective implements OnDestroy, OnChanges { + constructor(private elementRef: ElementRef) { + const element = this.elementRef.nativeElement + + this.observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (this.mutationOptions.plainOnly && element.childNodes.length > 1) { + element.innerHTML = element.innerText.trim() + return + } + + this.domChanges.emit(mutation) + if (this.mutationOptions.plainOnly) this.textChanges.emit(element.innerText.trim()) + else this.textChanges.emit(element.innerText.trim() ? element.innerHTML.trim() : '') + }) + }) + } + + ngOnChanges(changes: SimpleChanges): void { + if ('mutationOptions' in changes) + if (!this.mutationOptions.observe) this.observer.disconnect() + else { + this.observer.observe(this.elementRef.nativeElement, { + attributes: false, + childList: true, + subtree: true, + characterData: true, + }) + } + } + ngOnDestroy(): void { + this.observer.disconnect() + } + + private observer: MutationObserver + + @Output() domChanges = new EventEmitter() + @Output() textChanges = new EventEmitter() + + @Input() mutationOptions: { + plainOnly?: boolean + observe?: boolean + } = { + plainOnly: false, + observe: true, + } +} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index f1b93bc5..ac51721e 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -38,7 +38,19 @@

- {{ activeListName$ | async }} + {{ activeListName$ | async }}

diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts index d238a502..81b48f29 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -1,5 +1,6 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' import { ActivatedRoute } from '@angular/router' +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' import { Store } from '@ngrx/store' import { BehaviorSubject, @@ -20,6 +21,7 @@ import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' import { traceTaskList } from 'src/app/store/task/utils' +@UntilDestroy() @Component({ selector: 'app-entity-page', templateUrl: './entity-page.component.html', @@ -28,7 +30,7 @@ import { traceTaskList } from 'src/app/store/task/utils' export class EntityPageComponent implements AfterViewInit, OnDestroy { constructor(private store: Store, private route: ActivatedRoute) {} - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion activeTasklistId$ = this.route.paramMap.pipe(map(paramMap => paramMap.get('id')!)) activeListAndTrace$ = this.activeTasklistId$.pipe( @@ -88,6 +90,43 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { }) } + keydownEvents$ = new BehaviorSubject(null) + blurEvents$ = new BehaviorSubject(null) + listnameChanges$ = new BehaviorSubject('') + + listNameUpdatesSubscription = merge( + this.keydownEvents$.pipe( + filter(event => { + if (event?.code == 'Enter') { + event.preventDefault() + return true + } + return false + }), + switchMap(() => this.listnameChanges$.pipe(first())) + ), + this.blurEvents$.pipe( + filter(e => !!e), + switchMap(() => this.listnameChanges$.pipe(first())) + ), + this.listnameChanges$.pipe(debounceTime(600)) + ) + .pipe( + distinctUntilChanged(), + switchMap(newName => { + return this.activeTaskList$.pipe( + first(), + tap(activeTaskList => { + if (!activeTaskList || !newName) return EMPTY + + return this.store.dispatch(listActions.renameList({ id: activeTaskList.id, newName })) + }) + ) + }), + untilDestroyed(this) + ) + .subscribe() + isSecondaryProgressBarVisible = false @ViewChild('progressBar') progressBar!: ElementRef progressBarObserver = new IntersectionObserver( diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/task/task.effects.ts index d3641d56..03f4db00 100644 --- a/client-v2/src/app/store/task/task.effects.ts +++ b/client-v2/src/app/store/task/task.effects.ts @@ -67,13 +67,11 @@ export class TaskEffects { const res$ = this.taskService.updateTaskList(id, { name: newName }) return res$.pipe( - this.toast.observe({ - loading: 'Renaming tasklist...', - success: `Renamed tasklist to '${newName}'`, - error: getMessageFromHttpError, - }), map(() => listActions.renameListSuccess({ id, newName })), - catchError(err => of(listActions.renameListError(err))) + catchError(err => { + this.toast.error(getMessageFromHttpError(err)) + return of(listActions.renameListError(err)) + }) ) }) ) diff --git a/client-v2/src/css/components.css b/client-v2/src/css/components.css index 86186a43..ac97fd51 100644 --- a/client-v2/src/css/components.css +++ b/client-v2/src/css/components.css @@ -4,12 +4,16 @@ @apply text-submit-500 hover:text-submit-400 hover:underline; } + .button-m { + @apply rounded-md bg-tinted-800 px-2 py-0.5 text-tinted-300 transition-colors hover:bg-tinted-700 hover:text-tinted-100; + } + /* This should be button-lg */ .button { @apply rounded-md bg-tinted-500 py-2 px-5 font-semibold text-tinted-100 transition-colors hover:bg-tinted-600; } .button-naked { - @apply rounded py-0.5 px-2 transition-colors hover:bg-tinted-700; + @apply rounded-md py-0.5 px-2 transition-colors hover:bg-tinted-700; /* hover:text-tinted-100 */ } .button-naked i { From d3993f03c0152ad6ae5f035741774b29f150837b Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:32:46 +0100 Subject: [PATCH 19/60] Make tree nodes clickable links --- client-v2/src/app/pages/home/home.component.css | 14 ++++++++++++++ client-v2/src/app/pages/home/home.component.html | 7 ++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/client-v2/src/app/pages/home/home.component.css b/client-v2/src/app/pages/home/home.component.css index 0b2d90de..7576b3c4 100644 --- a/client-v2/src/app/pages/home/home.component.css +++ b/client-v2/src/app/pages/home/home.component.css @@ -16,6 +16,13 @@ @apply pl-2.5; } +.tree-node.active { + @apply bg-tinted-700; +} +.tree-node.active .content { + @apply text-primary-300; +} + .indent-line { @apply ml-3.5 inline-block @@ -46,6 +53,13 @@ hover:text-tinted-400; } +.no-toggle-icon app-icon { + @apply text-tinted-700 transition-colors duration-75; +} +.tree-node:is(:hover, :active, :focus-visible, :focus-within) .no-toggle-icon app-icon { + @apply text-tinted-600; +} + .tree-node:not(:hover, :active, :focus-visible, :focus-within) .btn-group { @apply hidden animate-none; } diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index d7014404..f5996ac7 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -66,9 +66,10 @@ -
+
From f8caa5a8279b25fdb1c3248605bcd39700505ee3 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:34:18 +0100 Subject: [PATCH 20/60] Prefill rename prompt with current title, create new lists as untitled --- .../src/app/pages/home/home.component.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 36280531..53bb33f8 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -8,7 +8,7 @@ import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' -import { flattenListTree, TasklistFlattend } from 'src/app/store/task/utils' +import { flattenListTree, getTaskListById, TasklistFlattend } from 'src/app/store/task/utils' import { moveToMacroQueue } from 'src/app/utils' import { getLoadingUpdates } from 'src/app/utils/store.helpers' @@ -73,10 +73,6 @@ export class HomeComponent implements OnInit { return new Array(number) } - log(str: string) { - console.log(str) - } - listPreviewsTransformed: EntityTreeNode[] = [] listPreviewsTransformed$ = this.store .select(state => state.task.listPreviews) @@ -102,17 +98,21 @@ export class HomeComponent implements OnInit { ) createNewList(parentListId?: string) { - const name = prompt('New Tasklist name')?.trim() - if (!name) return - - this.store.dispatch(listActions.createTaskList({ name, parentListId })) + this.store.dispatch(listActions.createTaskList({ parentListId })) } renameList(id: string) { - const prevName = '' - const newName = prompt('Rename the list', prevName)?.trim() - if (!newName) return - - this.store.dispatch(listActions.renameList({ id, newName })) + this.store + .select(state => getTaskListById(state.task.listPreviews || [], id)) + .pipe(first()) + .subscribe(list => { + if (!list) return + + const prevName = list.name + const newName = prompt('Rename the list', prevName)?.trim() + if (!newName) return + + this.store.dispatch(listActions.renameList({ id, newName })) + }) } duplicateList(id: string) { this.store.dispatch(listActions.duplicateList({ id })) From a31cad3910b78c1abb4cb8a1f49a04323ee2363f Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:36:05 +0100 Subject: [PATCH 21/60] Add forgotten package declaration --- client-v2/package-lock.json | 23 ++++++++++++++++++++++- client-v2/package.json | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/client-v2/package-lock.json b/client-v2/package-lock.json index cdd151fd..2512556a 100644 --- a/client-v2/package-lock.json +++ b/client-v2/package-lock.json @@ -19,6 +19,7 @@ "@angular/router": "^14.2.12", "@ngneat/hot-toast": "^4.1.0", "@ngneat/overview": "^3.0.0", + "@ngneat/until-destroy": "^9.2.2", "@ngrx/effects": "^14.3.2", "@ngrx/store": "^14.3.2", "@ngrx/store-devtools": "^14.3.2", @@ -38,7 +39,7 @@ "@typescript-eslint/eslint-plugin": "^5.44.0", "@typescript-eslint/parser": "^5.36.2", "autoprefixer": "^10.4.13", - "cypress": "*", + "cypress": "latest", "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-prettier": "^4.2.1", @@ -2905,6 +2906,18 @@ "@angular/core": ">=13" } }, + "node_modules/@ngneat/until-destroy": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@ngneat/until-destroy/-/until-destroy-9.2.2.tgz", + "integrity": "sha512-pD5idTgUdF0XZMuaV1n0BZTnE2BvxymutoXhwfZbO3uxjh63wS6Pzzzwv+pkXalKhuSwdf6uA1gRx7DOvlj/Kw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">=13", + "rxjs": "^6.4.0 || ^7.0.0" + } + }, "node_modules/@ngrx/effects": { "version": "14.3.2", "license": "MIT", @@ -16979,6 +16992,14 @@ "tslib": "^2.0.0" } }, + "@ngneat/until-destroy": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@ngneat/until-destroy/-/until-destroy-9.2.2.tgz", + "integrity": "sha512-pD5idTgUdF0XZMuaV1n0BZTnE2BvxymutoXhwfZbO3uxjh63wS6Pzzzwv+pkXalKhuSwdf6uA1gRx7DOvlj/Kw==", + "requires": { + "tslib": "^2.3.0" + } + }, "@ngrx/effects": { "version": "14.3.2", "requires": { diff --git a/client-v2/package.json b/client-v2/package.json index 18363257..cd983557 100644 --- a/client-v2/package.json +++ b/client-v2/package.json @@ -34,6 +34,7 @@ "@angular/router": "^14.2.12", "@ngneat/hot-toast": "^4.1.0", "@ngneat/overview": "^3.0.0", + "@ngneat/until-destroy": "^9.2.2", "@ngrx/effects": "^14.3.2", "@ngrx/store": "^14.3.2", "@ngrx/store-devtools": "^14.3.2", From 6a546c471ef314cac89ffc3ba2cac7bdb9d7b2d3 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 22:38:42 +0100 Subject: [PATCH 22/60] Random minor changes --- client-v2/angular.json | 7 +++++++ client-v2/package.json | 2 +- .../atoms/entity-page-label/entity-page-label.component.ts | 3 ++- .../components/molecules/drop-down/drop-down.component.css | 2 +- .../src/app/components/organisms/task/task.component.html | 2 +- client-v2/src/app/directives/tooltip.directive.ts | 1 + client-v2/src/css/cdk-styles.css | 2 +- 7 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client-v2/angular.json b/client-v2/angular.json index 421b2655..a2e503bd 100644 --- a/client-v2/angular.json +++ b/client-v2/angular.json @@ -1,5 +1,12 @@ { "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "schematics": { + "@schematics/angular": { + "component": { + "changeDetection": "OnPush" + } + } + }, "version": 1, "newProjectRoot": "projects", "projects": { diff --git a/client-v2/package.json b/client-v2/package.json index cd983557..32791561 100644 --- a/client-v2/package.json +++ b/client-v2/package.json @@ -3,7 +3,7 @@ "version": "2.0.0", "scripts": { "dev": "ng serve", - "dev:lan": "ng serve --host 0.0.0.0", + "dev:lan": "echo \"Listening on http://$(ipconfig getifaddr en0):4200\" && NG_APP_SERVER_BASE_URL=http://$(ipconfig getifaddr en0):3000 ng serve --host 0.0.0.0", "build": "ng build", "watch": "ng build --watch --configuration development", "update-colors": "ts-node ./tools/update-colors.cts", diff --git a/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts b/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts index a6918ffa..1a419fda 100644 --- a/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts +++ b/client-v2/src/app/components/atoms/entity-page-label/entity-page-label.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { PageEntityIconKey } from '../icons/page-entity-icon/page-entity-icon.component' @Component({ @@ -16,6 +16,7 @@ import { PageEntityIconKey } from '../icons/page-entity-icon/page-entity-icon.co } `, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class EntityPageLabelComponent { @Input() title!: string diff --git a/client-v2/src/app/components/molecules/drop-down/drop-down.component.css b/client-v2/src/app/components/molecules/drop-down/drop-down.component.css index 09484ff8..c063770d 100644 --- a/client-v2/src/app/components/molecules/drop-down/drop-down.component.css +++ b/client-v2/src/app/components/molecules/drop-down/drop-down.component.css @@ -1,5 +1,5 @@ .route-active .title { - text-shadow: 0 0 10px theme(colors.primary.400); + text-shadow: 0 0 10px theme('colors.primary.400'); } :where(.route-active) { @apply text-primary-300; diff --git a/client-v2/src/app/components/organisms/task/task.component.html b/client-v2/src/app/components/organisms/task/task.component.html index 4484714b..008b64a5 100644 --- a/client-v2/src/app/components/organisms/task/task.component.html +++ b/client-v2/src/app/components/organisms/task/task.component.html @@ -58,7 +58,7 @@ focusable #notesFocusable="focusable" [class]="{ hidden: !data.description && !notesFocusable.isFocused }" - class="notes | mt-1 rounded-lg bg-tinted-900 py-1 px-2 text-tinted-300 outline-none ring-primary-400 transition-colors duration-75 focus:ring-2" + class="notes | mt-1 rounded-lg bg-tinted-900 py-1 px-2 text-tinted-300 outline-none ring-primary-400 transition-all duration-75 focus:ring-2" contenteditable > {{ this.loading || data.description }} diff --git a/client-v2/src/app/directives/tooltip.directive.ts b/client-v2/src/app/directives/tooltip.directive.ts index 1469a576..84fd023f 100644 --- a/client-v2/src/app/directives/tooltip.directive.ts +++ b/client-v2/src/app/directives/tooltip.directive.ts @@ -59,6 +59,7 @@ export class TooltipDirective { showTooltip(): void { if (this.overlayRef?.hasAttached()) return + // @TODO: skip this on mobile this.attachTooltip() } diff --git a/client-v2/src/css/cdk-styles.css b/client-v2/src/css/cdk-styles.css index 8baccc8e..6a496a8a 100644 --- a/client-v2/src/css/cdk-styles.css +++ b/client-v2/src/css/cdk-styles.css @@ -2,7 +2,7 @@ .dropdown-menu { min-width: 180px; max-width: 280px; - @apply mx-2 flex flex-col gap-0.5 rounded-lg border border-tinted-800 bg-tinted-900 p-2 shadow-lg; + @apply mx-2 flex flex-col gap-0.5 rounded-xl border border-tinted-800 bg-tinted-900 p-2 shadow-lg; } .menu-item { From 992fa8c3cafde3a83215d8cce53eddfafc9d460f Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 23:12:03 +0100 Subject: [PATCH 23/60] Fix unit tests --- .../app/directives/mutation.directive.spec.ts | 14 ++++---- .../entity-page/entity-page.component.spec.ts | 2 ++ .../src/app/pages/home/home.component.spec.ts | 4 +-- .../pages/settings/settings.component.spec.ts | 2 ++ .../app/pages/settings/settings.component.ts | 2 +- client-v2/src/app/utils/unit-test.mocks.ts | 32 ++++++++++++++++++- 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/client-v2/src/app/directives/mutation.directive.spec.ts b/client-v2/src/app/directives/mutation.directive.spec.ts index c9f4c664..25d0ea37 100644 --- a/client-v2/src/app/directives/mutation.directive.spec.ts +++ b/client-v2/src/app/directives/mutation.directive.spec.ts @@ -1,8 +1,8 @@ -import { MutationDirective } from './mutation.directive' +// import { MutationDirective } from './mutation.directive' -describe('MutationDirective', () => { - it('should create an instance', () => { - const directive = new MutationDirective() - expect(directive).toBeTruthy() - }) -}) +// describe('MutationDirective', () => { +// it('should create an instance', () => { +// const directive = new MutationDirective() +// expect(directive).toBeTruthy() +// }) +// }) diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts index b0dc72b3..9d7b1719 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' +import { activatedRouteMock, storeMock } from 'src/app/utils/unit-test.mocks' import { EntityPageComponent } from './entity-page.component' @@ -9,6 +10,7 @@ describe('EntityPageComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EntityPageComponent], + providers: [storeMock, activatedRouteMock], }).compileComponents() fixture = TestBed.createComponent(EntityPageComponent) diff --git a/client-v2/src/app/pages/home/home.component.spec.ts b/client-v2/src/app/pages/home/home.component.spec.ts index 0b59c4b4..bc9be890 100644 --- a/client-v2/src/app/pages/home/home.component.spec.ts +++ b/client-v2/src/app/pages/home/home.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' -import { storeMock } from 'src/app/utils/unit-test.mocks' +import { actionsMock, storeMock } from 'src/app/utils/unit-test.mocks' import { HomeComponent } from './home.component' @@ -10,7 +10,7 @@ describe('HomeComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [HomeComponent], - providers: [storeMock], + providers: [storeMock, actionsMock], }).compileComponents() }) diff --git a/client-v2/src/app/pages/settings/settings.component.spec.ts b/client-v2/src/app/pages/settings/settings.component.spec.ts index f3679976..085abacc 100644 --- a/client-v2/src/app/pages/settings/settings.component.spec.ts +++ b/client-v2/src/app/pages/settings/settings.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' +import { activatedRouteMock } from 'src/app/utils/unit-test.mocks' import { SettingsComponent } from './settings.component' @@ -9,6 +10,7 @@ describe('SettingsComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [SettingsComponent], + providers: [activatedRouteMock], }).compileComponents() }) diff --git a/client-v2/src/app/pages/settings/settings.component.ts b/client-v2/src/app/pages/settings/settings.component.ts index 2e7f1dcc..caafa1d6 100644 --- a/client-v2/src/app/pages/settings/settings.component.ts +++ b/client-v2/src/app/pages/settings/settings.component.ts @@ -14,7 +14,7 @@ interface SettingsPageItem { styleUrls: [], }) export class SettingsComponent { - constructor(private route: ActivatedRoute, private router: Router) {} + constructor(private route: ActivatedRoute) {} settingsPages: SettingsPageItem[] = [ { diff --git a/client-v2/src/app/utils/unit-test.mocks.ts b/client-v2/src/app/utils/unit-test.mocks.ts index 149fd0cf..89bb8f73 100644 --- a/client-v2/src/app/utils/unit-test.mocks.ts +++ b/client-v2/src/app/utils/unit-test.mocks.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-empty-function */ +import { ActivatedRoute } from '@angular/router' +import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' +import { Observable } from 'rxjs' import { HttpService } from '../http/http.service' export const storeMock = { @@ -9,7 +12,9 @@ export const storeMock = { select() { return this }, - pipe() {}, + pipe() { + return new Observable() + }, dispatch() {}, }, } @@ -23,3 +28,28 @@ export const httpServiceMock = { delete() {}, }, } + +export const activatedRouteMock = { + provide: ActivatedRoute, + useValue: { + url: { + pipe() { + return new Observable() + }, + }, + paramMap: { + pipe() { + return new Observable() + }, + }, + }, +} + +export const actionsMock = { + provide: Actions, + useValue: { + pipe() { + return new Observable() + }, + }, +} From 1e57792d2179e764703ba62e658d6d5de361dad0 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 23 Dec 2022 23:31:03 +0100 Subject: [PATCH 24/60] Fix component tests --- .../menu-toggle/menu-toggle.component.ts | 11 +++++++-- .../sidebar-layout.component.html | 2 +- .../sidebar-layout.component.test.ts | 23 ++++++++++++++----- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts index 497e430d..3f49a588 100644 --- a/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts +++ b/client-v2/src/app/components/templates/sidebar-layout/menu-toggle/menu-toggle.component.ts @@ -1,10 +1,15 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core' +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { MenuService } from '../menu.service' @Component({ selector: 'app-menu-toggle', template: ` -

+
+ + diff --git a/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts b/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts new file mode 100644 index 00000000..c3d71860 --- /dev/null +++ b/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { Store } from '@ngrx/store' +import { AppState } from 'src/app/store' +import { listActions } from 'src/app/store/task/task.actions' + +@Component({ + selector: 'app-dashboard', + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardComponent { + constructor(private store: Store) {} + + createNewTasklist() { + this.store.dispatch(listActions.createTaskList({})) + } +} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index ac51721e..6dee53a7 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -65,7 +65,7 @@

+ + + +
@@ -80,11 +89,21 @@

*ngFor="let child of activeTaskList.childLists" routerLink="/home/{{ child.id }}" cdkMenuItem + [cdkContextMenuTriggerFor]="options" + #trigger="cdkContextMenuTriggerFor" > + + + +

diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts index 81b48f29..6aef0ef6 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -1,5 +1,5 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' -import { ActivatedRoute } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' import { Store } from '@ngrx/store' import { @@ -11,11 +11,13 @@ import { first, map, merge, + of, switchMap, tap, } from 'rxjs' import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { Breadcrumb } from 'src/app/components/molecules/breadcrumbs/breadcrumbs.component' +import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' import { TaskStatus } from 'src/app/models/task.model' import { AppState } from 'src/app/store' import { listActions } from 'src/app/store/task/task.actions' @@ -28,12 +30,55 @@ import { traceTaskList } from 'src/app/store/task/utils' styleUrls: ['./entity-page.component.css'], }) export class EntityPageComponent implements AfterViewInit, OnDestroy { - constructor(private store: Store, private route: ActivatedRoute) {} + constructor(private store: Store, private route: ActivatedRoute, private router: Router) {} + + TaskStatus = TaskStatus + EntityType = EntityType + + isPrimaryProgressBarHidden = false + @ViewChild('progressBar') progressBar!: ElementRef + progressBarObserver = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) this.isPrimaryProgressBarHidden = false + else this.isPrimaryProgressBarHidden = true + }, + { threshold: [0.5] } + ) + + ngAfterViewInit(): void { + this.progressBarObserver.observe(this.progressBar.nativeElement) + } + ngOnDestroy(): void { + this.progressBarObserver.disconnect() + } + + closedTasks = 16 + allTasks = 37 + progress = Math.round((this.closedTasks / this.allTasks) * 100) + isShownAsPercentage = true + + tasklistOptionsItems: MenuItem[] = [ + { + title: `Rename`, + action: (id: string) => this.store.dispatch(listActions.renameListDialog({ id })), + }, + { + title: `Export`, + action: (id: string) => this.store.dispatch(listActions.exportList({ id })), + }, + { isSeperator: true }, + { + title: `Delete`, + variant: MenuItemVariant.DANGER, + action: (id: string) => this.store.dispatch(listActions.deleteListDialog({ id })), + }, + ] + tasklistOptionsItems$ = of(this.tasklistOptionsItems) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion activeTasklistId$ = this.route.paramMap.pipe(map(paramMap => paramMap.get('id')!)) - activeListAndTrace$ = this.activeTasklistId$.pipe( + activeTasklistTrace$ = this.activeTasklistId$.pipe( switchMap(activeId => { return this.store .select(state => state.task.listPreviews) @@ -41,16 +86,13 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { map(listPreviews => { if (!listPreviews) return null - const trace = traceTaskList(listPreviews, activeId) - const activeTaskList = trace[trace.length - 1] - - return { activeTaskList, trace } + return traceTaskList(listPreviews, activeId) }) ) }) ) - activeTaskList$ = this.activeListAndTrace$.pipe(map(derived => derived?.activeTaskList)) + activeTaskList$ = this.activeTasklistTrace$.pipe(map(trace => trace?.[trace?.length - 1])) activeListName$ = this.activeTaskList$.pipe( map(list => list?.name), distinctUntilChanged(), @@ -66,9 +108,10 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ) }) ) - breadcrumbs$ = this.activeListAndTrace$.pipe( - map(derived => - derived?.trace.map(list => ({ + + breadcrumbs$ = this.activeTasklistTrace$.pipe( + map(trace => + trace?.map(list => ({ title: list.name, icon: EntityType.TASKLIST, route: `/home/${list.id}`, @@ -76,25 +119,11 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ) ) - TaskStatus = TaskStatus - EntityType = EntityType - - closedTasks = 16 - allTasks = 37 - progress = Math.round((this.closedTasks / this.allTasks) * 100) - isShownAsPercentage = true - - createNewSublist() { - this.activeTasklistId$.pipe(first()).subscribe(activeId => { - this.store.dispatch(listActions.createTaskList({ parentListId: activeId })) - }) - } - keydownEvents$ = new BehaviorSubject(null) blurEvents$ = new BehaviorSubject(null) listnameChanges$ = new BehaviorSubject('') - listNameUpdatesSubscription = merge( + listNameUpdateEvents$ = merge( this.keydownEvents$.pipe( filter(event => { if (event?.code == 'Enter') { @@ -111,6 +140,8 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ), this.listnameChanges$.pipe(debounceTime(600)) ) + + listNameUpdatesSubscription = this.listNameUpdateEvents$ .pipe( distinctUntilChanged(), switchMap(newName => { @@ -127,20 +158,9 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ) .subscribe() - isSecondaryProgressBarVisible = false - @ViewChild('progressBar') progressBar!: ElementRef - progressBarObserver = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) this.isSecondaryProgressBarVisible = false - else this.isSecondaryProgressBarVisible = true - }, - { threshold: [0.5] } - ) - - ngAfterViewInit(): void { - this.progressBarObserver.observe(this.progressBar.nativeElement) - } - ngOnDestroy(): void { - this.progressBarObserver.disconnect() + createNewSublist() { + this.activeTasklistId$.pipe(first()).subscribe(activeId => { + this.store.dispatch(listActions.createTaskList({ parentListId: activeId })) + }) } } diff --git a/client-v2/src/app/store/task/task.actions.ts b/client-v2/src/app/store/task/task.actions.ts index c189c010..a70924fd 100644 --- a/client-v2/src/app/store/task/task.actions.ts +++ b/client-v2/src/app/store/task/task.actions.ts @@ -30,5 +30,7 @@ export const listActions = createActionGroup({ 'duplicate list': props<{ id: string }>(), 'duplicate list success': props<{ id: string }>(), 'duplicate list error': props<{ id: string }>(), + + 'export list': props<{ id: string }>(), }, }) diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/task/task.effects.ts index a0d03bdc..88e28e18 100644 --- a/client-v2/src/app/store/task/task.effects.ts +++ b/client-v2/src/app/store/task/task.effects.ts @@ -112,6 +112,16 @@ export class TaskEffects { ) }) + exportList = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.exportList), + mergeMap(() => { + this.toast.info('Exporting lists is not supported yet.') + return of(appActions.nothing()) + }) + ) + }) + showDeleteListDialog = createEffect(() => { return this.actions$.pipe( ofType(listActions.deleteListDialog), From 510dac997c0f8a352354fa6b15bc95c76cbc0876 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 25 Dec 2022 02:34:14 +0100 Subject: [PATCH 29/60] Fix unit tests --- .../app/pages/home/entity-page/entity-page.component.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts index 9d7b1719..765236fa 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.spec.ts @@ -1,3 +1,4 @@ +import { CdkMenuModule } from '@angular/cdk/menu' import { ComponentFixture, TestBed } from '@angular/core/testing' import { activatedRouteMock, storeMock } from 'src/app/utils/unit-test.mocks' @@ -9,6 +10,7 @@ describe('EntityPageComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ + imports: [CdkMenuModule], declarations: [EntityPageComponent], providers: [storeMock, activatedRouteMock], }).compileComponents() From 61239381818ebf920c64b282b8a04d740892871b Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 25 Dec 2022 23:11:15 +0100 Subject: [PATCH 30/60] Improve default listname behaviour, add placeholder, improve performance --- client-v2/src/app/models/task.model.ts | 2 + .../entity-page/entity-page.component.css | 5 ++ .../entity-page/entity-page.component.html | 10 ++-- .../home/entity-page/entity-page.component.ts | 52 ++++++++++++++++--- client-v2/src/app/store/task/task.effects.ts | 3 +- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/client-v2/src/app/models/task.model.ts b/client-v2/src/app/models/task.model.ts index 70dd995e..8ca5830d 100644 --- a/client-v2/src/app/models/task.model.ts +++ b/client-v2/src/app/models/task.model.ts @@ -81,6 +81,8 @@ export interface TaskList { // participants: string[] // maybe this one as well } +export const DEFAULT_TASKLIST_NAME = 'Untitled tasklist' + export type TasklistPreview = Pick export interface CreateTasklistDto { diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.css b/client-v2/src/app/pages/home/entity-page/entity-page.component.css index 1c6190e1..212bf35b 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.css +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.css @@ -8,3 +8,8 @@ .progress-container.glow span { @apply text-submit-400; } + +.show-placeholder::before { + content: attr(data-placeholder); + @apply text-tinted-400; +} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index 06e6cbb4..a7a132a3 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -48,7 +48,10 @@

[mutationOptions]="{ plainOnly: true, observe: listnameFocusable.isFocused }" (keydown)="keydownEvents$.next($event)" (blur)="blurEvents$.next($event)" - >{{ activeListName$ | async }} + >

@@ -83,7 +85,7 @@

New sublist -
+
@@ -34,7 +34,7 @@ -
+
@@ -49,16 +49,16 @@

@@ -85,10 +85,10 @@

New sublist -
+
-

+

No lists so far...

diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 55517443..ff27ab76 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -8,8 +8,8 @@ import { map, startWith, switchMap, tap } from 'rxjs' import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' import { AppState } from 'src/app/store' -import { listActions } from 'src/app/store/task/task.actions' -import { flattenListTree, TasklistFlattend, traceTaskList } from 'src/app/store/task/utils' +import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' +import { flattenEntityTree, EntityPreviewFlattend, traceEntity } from 'src/app/store/entities/utils' import { moveToMacroQueue } from 'src/app/utils' import { getLoadingUpdates } from 'src/app/utils/store.helpers' @@ -23,7 +23,7 @@ export interface EntityTreeNode { entityType?: EntityType } -export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode => { +export const convertToEntityTreeNode = (list: EntityPreviewFlattend): EntityTreeNode => { const { childrenCount, ...rest } = list const node: EntityTreeNode = { ...rest, @@ -43,16 +43,18 @@ export const convertToEntityTreeNode = (list: TasklistFlattend): EntityTreeNode export class HomeComponent implements OnInit { constructor(private store: Store, private actions$: Actions, private route: ActivatedRoute) {} + EntityType = EntityType + ngOnInit(): void { - moveToMacroQueue(() => this.store.dispatch(listActions.loadListPreviews())) + moveToMacroQueue(() => this.store.dispatch(entitiesActions.loadPreviews())) } getParentNode(node: EntityTreeNode) { - const nodeIndex = this.listPreviewsTransformed.indexOf(node) + const nodeIndex = this.entityPreviewsTransformed.indexOf(node) for (let i = nodeIndex - 1; i >= 0; i--) { - if (this.listPreviewsTransformed[i].path.length === node.path.length - 1) { - return this.listPreviewsTransformed[i] + if (this.entityPreviewsTransformed[i].path.length === node.path.length - 1) { + return this.entityPreviewsTransformed[i] } } @@ -74,9 +76,9 @@ export class HomeComponent implements OnInit { return new Array(number) } - listPreviewsTransformed: EntityTreeNode[] = [] - listPreviewsTransformed$ = this.store - .select(state => state.task.listPreviews) + entityPreviewsTransformed: EntityTreeNode[] = [] + entityPreviewsTransformed$ = this.store + .select(state => state.entities.entityTree) .pipe( switchMap(tree => { return this.route.url.pipe( @@ -92,8 +94,8 @@ export class HomeComponent implements OnInit { if (!entityTree) return [] - const treeNodes = flattenListTree(entityTree).map(convertToEntityTreeNode) - const [, ...entityTraceWithoutActive] = traceTaskList(entityTree, activeId).reverse() + const treeNodes = flattenEntityTree(entityTree).map(convertToEntityTreeNode) + const [, ...entityTraceWithoutActive] = traceEntity(entityTree, activeId).reverse() return treeNodes.map(node => { const isContainedInTrace = entityTraceWithoutActive.some(entity => entity.id == node.id) @@ -103,16 +105,16 @@ export class HomeComponent implements OnInit { } }) }), - tap(transformed => (this.listPreviewsTransformed = transformed)) + tap(transformed => (this.entityPreviewsTransformed = transformed)) ) isTreeLoading$ = getLoadingUpdates(this.actions$, [ - listActions.loadListPreviews, - listActions.loadListPreviewsSuccess, - listActions.loadListPreviewsError, + entitiesActions.loadPreviews, + entitiesActions.loadPreviewsSuccess, + entitiesActions.loadPreviewsError, ]) - dataSource = new ArrayDataSource(this.listPreviewsTransformed$) + dataSource = new ArrayDataSource(this.entityPreviewsTransformed$) treeControl = new FlatTreeControl( node => node.path.length, node => node.expandable @@ -151,6 +153,4 @@ export class HomeComponent implements OnInit { action: (id: string) => this.deleteList(id), }, ] - - EntityType = EntityType } diff --git a/client-v2/src/app/services/entities.service.spec.ts b/client-v2/src/app/services/entities.service.spec.ts new file mode 100644 index 00000000..f44645ed --- /dev/null +++ b/client-v2/src/app/services/entities.service.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing' +import { httpServiceMock } from '../utils/unit-test.mocks' + +import { EntitiesService } from './entities.service' + +describe('EntitiesService', () => { + let service: EntitiesService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [httpServiceMock], + }) + service = TestBed.inject(EntitiesService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/services/entities.service.ts b/client-v2/src/app/services/entities.service.ts new file mode 100644 index 00000000..e32c9398 --- /dev/null +++ b/client-v2/src/app/services/entities.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core' +import { HttpService } from '../http/http.service' +import { TasklistPreview } from '../models/task.model' + +@Injectable({ + providedIn: 'root', +}) +export class EntitiesService { + constructor(private http: HttpService) {} + + getEntityPreviews() { + return this.http.get('/all-lists') + } +} diff --git a/client-v2/src/app/services/task.service.spec.ts b/client-v2/src/app/services/list.service.spec.ts similarity index 72% rename from client-v2/src/app/services/task.service.spec.ts rename to client-v2/src/app/services/list.service.spec.ts index 565ba897..4199f2fa 100644 --- a/client-v2/src/app/services/task.service.spec.ts +++ b/client-v2/src/app/services/list.service.spec.ts @@ -1,17 +1,17 @@ import { TestBed } from '@angular/core/testing' import { httpServiceMock } from '../utils/unit-test.mocks' -import { TaskService } from './task.service' +import { ListService } from './list.service' -describe('TaskService', () => { - let service: TaskService +describe('ListService', () => { + let service: ListService beforeEach(() => { TestBed.configureTestingModule({ // eslint-disable-next-line @typescript-eslint/no-empty-function providers: [httpServiceMock], }) - service = TestBed.inject(TaskService) + service = TestBed.inject(ListService) }) it('should be created', () => { diff --git a/client-v2/src/app/services/task.service.ts b/client-v2/src/app/services/list.service.ts similarity index 71% rename from client-v2/src/app/services/task.service.ts rename to client-v2/src/app/services/list.service.ts index 591b6748..1ef5ad04 100644 --- a/client-v2/src/app/services/task.service.ts +++ b/client-v2/src/app/services/list.service.ts @@ -1,18 +1,14 @@ import { Injectable } from '@angular/core' import { HttpService } from '../http/http.service' import { HttpSuccessResponse } from '../http/types' -import { CreateTasklistDto, TaskList, TasklistPreview, UpdateTasklistDto } from '../models/task.model' +import { CreateTasklistDto, TaskList, UpdateTasklistDto } from '../models/task.model' @Injectable({ providedIn: 'root', }) -export class TaskService { +export class ListService { constructor(private http: HttpService) {} - getListPreviews() { - return this.http.get('/all-lists') - } - createTaskList(dto: CreateTasklistDto) { return this.http.post('/list', dto) } diff --git a/client-v2/src/app/store/task/task.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts similarity index 54% rename from client-v2/src/app/store/task/task.actions.ts rename to client-v2/src/app/store/entities/entities.actions.ts index a70924fd..b49092dd 100644 --- a/client-v2/src/app/store/task/task.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -2,12 +2,35 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store' import { HttpServerErrorResponse } from 'src/app/http/types' import { CreateTasklistDto, TaskList, TasklistPreview } from 'src/app/models/task.model' +export const entitiesActions = createActionGroup({ + source: 'Entities', + events: { + 'load previews': emptyProps(), + 'load previews success': props<{ previews: TasklistPreview[] }>(), + 'load previews error': props(), + + // 'open rename dialog': props<{ id: string }>(), + // 'abort rename dialog': emptyProps(), + // // + // rename: props<{ id: string; newName: string }>(), + // 'rename success': props<{ id: string; newName: string }>(), + // 'rename error': props(), + + // 'open delete dialog': props<{ id: string }>(), + // 'abort delete dialog': emptyProps(), + // // + // delete: props<{ id: string }>(), + // 'delete success': props<{ id: string }>(), + // 'delete error': props(), + }, +}) + export const listActions = createActionGroup({ - source: 'Task/Lists', + source: 'Entity/Lists', events: { - 'load list previews': emptyProps(), - 'load list previews success': props<{ previews: TasklistPreview[] }>(), - 'load list previews error': props(), + // 'load previews': emptyProps(), + // 'load previews success': props<{ previews: TasklistPreview[] }>(), + // 'load previews error': props(), 'create task list': props>(), 'create task list success': props<{ createdList: TaskList }>(), diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts new file mode 100644 index 00000000..e5536d57 --- /dev/null +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core' +import { Actions, createEffect, ofType } from '@ngrx/effects' +import { catchError, map, mergeMap, of } from 'rxjs' +import { EntitiesService } from 'src/app/services/entities.service' +import { entitiesActions } from './entities.actions' + +@Injectable() +export class EntitiesEffects { + constructor(private actions$: Actions, private entitiesService: EntitiesService) {} + + loadEntityPreviews = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.loadPreviews), + mergeMap(() => { + const res$ = this.entitiesService.getEntityPreviews() + + return res$.pipe( + map(listPreviews => entitiesActions.loadPreviewsSuccess({ previews: listPreviews })), + catchError(err => { + console.error(err) + return of(entitiesActions.loadPreviewsError(err)) + }) + ) + }) + ) + }) +} diff --git a/client-v2/src/app/store/entities/entities.model.ts b/client-v2/src/app/store/entities/entities.model.ts new file mode 100644 index 00000000..e6bb59dc --- /dev/null +++ b/client-v2/src/app/store/entities/entities.model.ts @@ -0,0 +1,16 @@ +import { TasklistPreview } from 'src/app/models/task.model' + +export type EntityPreviewRecursive = Omit & { + children: EntityPreviewRecursive[] +} + +// export interface EntityPreviewRecursive { +// id: string +// name: string +// parentId: string | undefined +// children: EntityPreviewRecursive[] +// } + +export interface EntitiesState { + entityTree: EntityPreviewRecursive[] | null +} diff --git a/client-v2/src/app/store/entities/entities.reducer.ts b/client-v2/src/app/store/entities/entities.reducer.ts new file mode 100644 index 00000000..7f34f728 --- /dev/null +++ b/client-v2/src/app/store/entities/entities.reducer.ts @@ -0,0 +1,66 @@ +import { createReducer, on } from '@ngrx/store' +import { entitiesActions, listActions } from './entities.actions' +import { EntitiesState } from './entities.model' +import { getParentByChildId, getEntityById, getEntityTree } from './utils' + +const initialState: EntitiesState = { + entityTree: null, +} + +export const entitiesReducer = createReducer( + initialState, + + on(entitiesActions.loadPreviewsSuccess, (state, { previews }) => { + return { + ...state, + entityTree: getEntityTree(previews), + } + }), + + on(listActions.createTaskListSuccess, (state, { createdList }) => { + if (!createdList.parentListId) + return { + ...state, + entityTree: [...(state.entityTree || []), { ...createdList, children: [] }], + } + + const listPreviewsCopy = structuredClone(state.entityTree) + const parentTaskList = getEntityById(listPreviewsCopy, createdList.parentListId) + + if (!parentTaskList) + return { + ...state, + entityTree: [...(state.entityTree || []), { ...createdList, children: [] }], + } + + parentTaskList.children.push({ ...createdList, children: [] }) + + return { + ...state, + entityTree: listPreviewsCopy, + } + }), + + on(listActions.renameListSuccess, (state, { id, newName }) => { + const listPreviewsCopy = structuredClone(state.entityTree) + const list = getEntityById(listPreviewsCopy, id) + if (!list) return state + + list.name = newName + + return { + ...state, + entityTree: listPreviewsCopy, + } + }), + + on(listActions.deleteListSuccess, (state, { id }) => { + const listPreviewsCopy = structuredClone(state.entityTree) + const result = getParentByChildId(listPreviewsCopy, id) + if (!result) return state + + result.subTree.splice(result.index, 1) + + return { ...state, entityTree: listPreviewsCopy } + }) +) diff --git a/client-v2/src/app/store/task/task.effects.ts b/client-v2/src/app/store/entities/list.effects.ts similarity index 76% rename from client-v2/src/app/store/task/task.effects.ts rename to client-v2/src/app/store/entities/list.effects.ts index ff2fad55..ef89c3e6 100644 --- a/client-v2/src/app/store/task/task.effects.ts +++ b/client-v2/src/app/store/entities/list.effects.ts @@ -6,47 +6,30 @@ import { Store } from '@ngrx/store' import { catchError, concatMap, first, map, mergeMap, of, switchMap, tap } from 'rxjs' import { DialogService } from 'src/app/modal/dialog.service' import { DEFAULT_TASKLIST_NAME } from 'src/app/models/task.model' -import { TaskService } from 'src/app/services/task.service' +import { ListService } from 'src/app/services/list.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' import { AppState } from '..' import { appActions } from '../app.actions' -import { listActions } from './task.actions' -import { getTaskListById, traceTaskList } from './utils' +import { listActions } from './entities.actions' +import { getEntityById, traceEntity } from './utils' @Injectable() -export class TaskEffects { +export class ListEffects { constructor( private actions$: Actions, - private taskService: TaskService, + private listService: ListService, private toast: HotToastService, private store: Store, private dialogService: DialogService, private router: Router ) {} - loadListPreviews = createEffect(() => { - return this.actions$.pipe( - ofType(listActions.loadListPreviews), - mergeMap(() => { - const res$ = this.taskService.getListPreviews() - - return res$.pipe( - map(listPreviews => listActions.loadListPreviewsSuccess({ previews: listPreviews })), - catchError(err => { - console.error(err) - return of(listActions.loadListPreviewsError(err)) - }) - ) - }) - ) - }) - createTaskList = createEffect(() => { return this.actions$.pipe( ofType(listActions.createTaskList), mergeMap(dto => { const name = dto.name || DEFAULT_TASKLIST_NAME - const res$ = this.taskService.createTaskList({ ...dto, name }) + const res$ = this.listService.createTaskList({ ...dto, name }) return res$.pipe( this.toast.observe({ @@ -67,13 +50,13 @@ export class TaskEffects { ofType(listActions.renameListDialog), switchMap(({ id }) => { return this.store - .select(state => state.task.listPreviews) + .select(state => state.entities.entityTree) .pipe( first(), map(listPreviews => { if (!listPreviews) return listActions.renameListDialogAbort() - const taskList = getTaskListById(listPreviews, id) + const taskList = getEntityById(listPreviews, id) if (!taskList) return listActions.renameListDialogAbort() const newName = prompt('Rename the list', taskList.name)?.trim() @@ -90,7 +73,7 @@ export class TaskEffects { return this.actions$.pipe( ofType(listActions.renameList), mergeMap(({ id, newName }) => { - const res$ = this.taskService.updateTaskList(id, { name: newName }) + const res$ = this.listService.updateTaskList(id, { name: newName }) return res$.pipe( map(() => listActions.renameListSuccess({ id, newName })), @@ -128,13 +111,13 @@ export class TaskEffects { ofType(listActions.deleteListDialog), switchMap(({ id }) => { return this.store - .select(state => state.task.listPreviews) + .select(state => state.entities.entityTree) .pipe( first(), concatMap(listPreviews => { if (!listPreviews) return of(listActions.deleteListDialogAbort()) - const taskList = getTaskListById(listPreviews, id) + const taskList = getEntityById(listPreviews, id) if (!taskList) return of(listActions.deleteListDialogAbort()) const closed$ = this.dialogService.confirm({ @@ -159,7 +142,7 @@ export class TaskEffects { return this.actions$.pipe( ofType(listActions.deleteList), mergeMap(({ id }) => { - const res$ = this.taskService.deleteTaskList(id) + const res$ = this.listService.deleteTaskList(id) return res$.pipe( this.toast.observe({ @@ -168,17 +151,17 @@ export class TaskEffects { error: getMessageFromHttpError, }), switchMap(() => { - return this.activeTaskListTrace$.pipe( + return this.activeEntityTrace$.pipe( first(), tap(trace => { if (!trace) return - const activeTaskList = trace[trace.length - 1] - if (activeTaskList.id != id) return + const activeEntity = trace[trace.length - 1] + if (activeEntity.id != id) return - const parentList = trace[trace.length - 2] + const parentEntity = trace[trace.length - 2] - this.router.navigateByUrl(parentList ? `/home/${parentList.id}` : '/home') + this.router.navigateByUrl(parentEntity ? `/home/${parentEntity.id}` : '/home') }), map(() => listActions.deleteListSuccess({ id })) ) @@ -189,18 +172,18 @@ export class TaskEffects { ) }) - activeTaskListTrace$ = this.store - .select(state => state.task.listPreviews) + activeEntityTrace$ = this.store + .select(state => state.entities.entityTree) .pipe( - map(listPreviews => { + map(entityTree => { // @TODO: come up with a better solution for this // NOTE: using `ActivatedRoute` doesn't work in effects const segments = location.pathname.split('/') const activeId = segments[segments.length - 1] - if (!listPreviews || !activeId) return null + if (!entityTree || !activeId) return null - return traceTaskList(listPreviews, activeId) + return traceEntity(entityTree, activeId) }) ) } diff --git a/client-v2/src/app/store/entities/utils.ts b/client-v2/src/app/store/entities/utils.ts new file mode 100644 index 00000000..2017b4c8 --- /dev/null +++ b/client-v2/src/app/store/entities/utils.ts @@ -0,0 +1,75 @@ +import { TasklistPreview } from 'src/app/models/task.model' +import { EntityPreviewRecursive } from './entities.model' + +export const getEntityTree = (allEntities: TasklistPreview[]) => { + const getChildren = (childIds: string[]): EntityPreviewRecursive[] => { + const children = allEntities.filter(entity => childIds.includes(entity.id)) + + return children.map(child => { + const grandChildren = getChildren(child.childLists) + return { ...child, children: grandChildren } + }) + } + + const entityTree = allEntities + .filter(entity => !entity.parentListId) + .map(entity => ({ ...entity, children: getChildren(entity.childLists) })) + return entityTree +} + +export type EntityPreviewFlattend = Omit & { + path: string[] + childrenCount: number +} + +export const flattenEntityTree = (lists: EntityPreviewRecursive[], path: string[] = []): EntityPreviewFlattend[] => { + return lists.flatMap(list => { + const { children: childLists, ...rest } = list + const flatEntity = { ...rest, path, childrenCount: childLists.length } + const subpath = [...path, list.id] + + return [flatEntity, ...flattenEntityTree(childLists, subpath)] + }) +} + +export const traceEntity = ( + enitityTree: EntityPreviewRecursive[], + id: string, + trace: EntityPreviewRecursive[] = [] +): EntityPreviewRecursive[] => { + return enitityTree.flatMap(entity => { + if (entity.id == id) return [entity] + + if (entity.children.length) { + const subtrace = traceEntity(entity.children, id, [...trace, entity]) + if (subtrace.length) return [entity, ...subtrace] + + return [] + } + + return [] + }) +} + +export const getParentByChildId = ( + entityTree: EntityPreviewRecursive[], + id: string +): { subTree: EntityPreviewRecursive[]; index: number } | void => { + for (let index = 0; index < entityTree.length; index++) { + const entity = entityTree[index] + + if (entity.id == id) return { subTree: entityTree, index } + + if (entity.children.length) { + const result = getParentByChildId(entity.children, id) + if (result) return result + } + } +} + +export const getEntityById = (taskLists: EntityPreviewRecursive[], id: string): EntityPreviewRecursive | void => { + const res = getParentByChildId(taskLists, id) + if (!res) return + + return res.subTree[res.index] +} diff --git a/client-v2/src/app/store/index.ts b/client-v2/src/app/store/index.ts index 07ff8c2b..937e3663 100644 --- a/client-v2/src/app/store/index.ts +++ b/client-v2/src/app/store/index.ts @@ -5,20 +5,21 @@ import { AuthEffects } from './user/auth.effects' import { AccountEffects } from './user/account.effects' import { UserState } from './user/user.model' import { userReducer } from './user/user.reducer' -import { taskReducer } from './task/task.reducer' -import { TaskState } from './task/task.model' -import { TaskEffects } from './task/task.effects' +import { entitiesReducer } from './entities/entities.reducer' +import { EntitiesState } from './entities/entities.model' +import { ListEffects } from './entities/list.effects' +import { EntitiesEffects } from './entities/entities.effects' export interface AppState { user: UserState - task: TaskState + entities: EntitiesState } export const reducers: ActionReducerMap = { user: userReducer, - task: taskReducer, + entities: entitiesReducer, } -export const effects = [AppEffects, AuthEffects, AccountEffects, TaskEffects] +export const effects = [AppEffects, AuthEffects, AccountEffects, ListEffects, EntitiesEffects] const actionLogger: MetaReducer = reducer => (state, action) => { console.info('%caction: %c' + action.type, 'color: hsl(130, 0%, 50%);', 'color: hsl(155, 100%, 50%);') diff --git a/client-v2/src/app/store/task/task.model.ts b/client-v2/src/app/store/task/task.model.ts deleted file mode 100644 index 0d778636..00000000 --- a/client-v2/src/app/store/task/task.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { TasklistPreview } from 'src/app/models/task.model' - -export type TaskListPreviewRecursive = Omit & { - childLists: TaskListPreviewRecursive[] -} - -export interface TaskState { - listPreviews: TaskListPreviewRecursive[] | null -} diff --git a/client-v2/src/app/store/task/task.reducer.ts b/client-v2/src/app/store/task/task.reducer.ts deleted file mode 100644 index 2e02e19e..00000000 --- a/client-v2/src/app/store/task/task.reducer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createReducer, on } from '@ngrx/store' -import { listActions } from './task.actions' -import { TaskState } from './task.model' -import { getParentListByChildId, getTaskListById, getTasklistTree } from './utils' - -const initialState: TaskState = { - listPreviews: null, -} - -export const taskReducer = createReducer( - initialState, - - on(listActions.loadListPreviewsSuccess, (state, { previews }) => { - return { - ...state, - listPreviews: getTasklistTree(previews), - } - }), - - on(listActions.createTaskListSuccess, (state, { createdList }) => { - if (!createdList.parentListId) - return { - ...state, - listPreviews: [...(state.listPreviews || []), { ...createdList, childLists: [] }], - } - - const listPreviewsCopy = structuredClone(state.listPreviews) - const parentTaskList = getTaskListById(listPreviewsCopy, createdList.parentListId) - - if (!parentTaskList) - return { - ...state, - listPreviews: [...(state.listPreviews || []), { ...createdList, childLists: [] }], - } - - parentTaskList.childLists.push({ ...createdList, childLists: [] }) - - return { - ...state, - listPreviews: listPreviewsCopy, - } - }), - - on(listActions.renameListSuccess, (state, { id, newName }) => { - const listPreviewsCopy = structuredClone(state.listPreviews) - const list = getTaskListById(listPreviewsCopy, id) - if (!list) return state - - list.name = newName - - return { - ...state, - listPreviews: listPreviewsCopy, - } - }), - - on(listActions.deleteListSuccess, (state, { id }) => { - const listPreviewsCopy = structuredClone(state.listPreviews) - const result = getParentListByChildId(listPreviewsCopy, id) - if (!result) return state - - result.list.splice(result.index, 1) - - return { ...state, listPreviews: listPreviewsCopy } - }) -) diff --git a/client-v2/src/app/store/task/utils.ts b/client-v2/src/app/store/task/utils.ts deleted file mode 100644 index 911168b6..00000000 --- a/client-v2/src/app/store/task/utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { TasklistPreview } from 'src/app/models/task.model' -import { TaskListPreviewRecursive } from './task.model' - -export const getTasklistTree = (allLists: TasklistPreview[]) => { - const getChildren = (childIds: string[]): TaskListPreviewRecursive[] => { - const children = childIds - .map(childId => allLists.find(list => list.id == childId)) - .filter(childList => !!childList) as TasklistPreview[] - - return children.map(child => { - const grandChildren = getChildren(child.childLists) - return { ...child, childLists: grandChildren } - }) - } - - const listTree = allLists - .filter(list => !list.parentListId) - .map(list => ({ ...list, childLists: getChildren(list.childLists) })) - return listTree -} - -export type TasklistFlattend = Omit & { - path: string[] - childrenCount: number -} - -export const flattenListTree = (lists: TaskListPreviewRecursive[], path: string[] = []): TasklistFlattend[] => { - return lists.flatMap(list => { - const { childLists, ...rest } = list - const flatList = { ...rest, path, childrenCount: childLists.length } - const subpath = [...path, list.id] - - return [flatList, ...flattenListTree(childLists, subpath)] - }) -} - -export const traceTaskList = ( - lists: TaskListPreviewRecursive[], - id: string, - trace: TaskListPreviewRecursive[] = [] -): TaskListPreviewRecursive[] => { - return lists.flatMap(list => { - if (list.id == id) return [list] - - if (list.childLists.length) { - const subtrace = traceTaskList(list.childLists, id, [...trace, list]) - if (subtrace.length) return [list, ...subtrace] - - return [] - } - - return [] - }) -} - -export const getParentListByChildId = ( - list: TaskListPreviewRecursive[], - id: string -): { list: TaskListPreviewRecursive[]; index: number } | void => { - for (let index = 0; index < list.length; index++) { - const tasklist = list[index] - - if (tasklist.id == id) return { list, index } - - if (tasklist.childLists.length) { - const result = getParentListByChildId(tasklist.childLists, id) - if (result) return result - } - } -} - -export const getTaskListById = (taskLists: TaskListPreviewRecursive[], id: string): TaskListPreviewRecursive | void => { - const res = getParentListByChildId(taskLists, id) - if (!res) return - - return res.list[res.index] -} From b59790ee4a9df8328874505f721c5ffcfe895566 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Mon, 26 Dec 2022 17:40:56 +0100 Subject: [PATCH 37/60] Update dashboard component folder name to match --- client-v2/src/app/app.module.ts | 2 +- .../dashboard.component.css | 0 .../dashboard.component.html | 0 .../dashboard.component.ts | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename client-v2/src/app/pages/home/{entity-page-placeholder => dashboard}/dashboard.component.css (100%) rename client-v2/src/app/pages/home/{entity-page-placeholder => dashboard}/dashboard.component.html (100%) rename client-v2/src/app/pages/home/{entity-page-placeholder => dashboard}/dashboard.component.ts (88%) diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index 7ffdefba..9f3afe28 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -46,7 +46,7 @@ import { BreadcrumbsComponent } from './components/molecules/breadcrumbs/breadcr import { MainPaneComponent } from './components/templates/main-pane/main-pane.component' import { MutationDirective } from './directives/mutation.directive' import { MenuToggleComponent } from './components/templates/sidebar-layout/menu-toggle/menu-toggle.component' -import { DashboardComponent } from './pages/home/entity-page-placeholder/dashboard.component' +import { DashboardComponent } from './pages/home/dashboard/dashboard.component' @NgModule({ declarations: [ diff --git a/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.css b/client-v2/src/app/pages/home/dashboard/dashboard.component.css similarity index 100% rename from client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.css rename to client-v2/src/app/pages/home/dashboard/dashboard.component.css diff --git a/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.html b/client-v2/src/app/pages/home/dashboard/dashboard.component.html similarity index 100% rename from client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.html rename to client-v2/src/app/pages/home/dashboard/dashboard.component.html diff --git a/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts b/client-v2/src/app/pages/home/dashboard/dashboard.component.ts similarity index 88% rename from client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts rename to client-v2/src/app/pages/home/dashboard/dashboard.component.ts index c3d71860..abcf9705 100644 --- a/client-v2/src/app/pages/home/entity-page-placeholder/dashboard.component.ts +++ b/client-v2/src/app/pages/home/dashboard/dashboard.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core' import { Store } from '@ngrx/store' import { AppState } from 'src/app/store' -import { listActions } from 'src/app/store/task/task.actions' +import { listActions } from 'src/app/store/entities/entities.actions' @Component({ selector: 'app-dashboard', From 7068aae099b7491658f65c5b53bac8cf893bf05f Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Wed, 28 Dec 2022 00:36:14 +0100 Subject: [PATCH 38/60] Abstact out entity services into a generic API, put interfaces into more suiting files --- .../page-entity-icon.component.ts | 11 +- client-v2/src/app/models/auth.model.ts | 2 +- client-v2/src/app/models/defaults.ts | 8 + client-v2/src/app/models/entities.model.ts | 32 ++++ client-v2/src/app/models/list.model.ts | 33 ++++ client-v2/src/app/models/task.model.ts | 37 ----- .../app/models/{index.ts => user.model.ts} | 0 .../home/entity-page/entity-page.component.ts | 16 +- .../src/app/pages/home/home.component.ts | 18 +-- .../src/app/services/entities.service.ts | 55 ++++++- .../list.service.spec.ts | 2 +- .../services/entity.services/list.service.ts | 24 +++ client-v2/src/app/services/list.service.ts | 23 --- .../app/store/entities/entities.actions.ts | 50 +++--- .../app/store/entities/entities.effects.ts | 145 +++++++++++++++++- .../src/app/store/entities/entities.model.ts | 13 +- .../app/store/entities/entities.reducer.ts | 30 ++-- .../src/app/store/entities/list.effects.ts | 131 +--------------- client-v2/src/app/store/entities/utils.ts | 9 +- client-v2/src/app/store/user/user.model.ts | 2 +- 20 files changed, 365 insertions(+), 276 deletions(-) create mode 100644 client-v2/src/app/models/defaults.ts create mode 100644 client-v2/src/app/models/entities.model.ts create mode 100644 client-v2/src/app/models/list.model.ts rename client-v2/src/app/models/{index.ts => user.model.ts} (100%) rename client-v2/src/app/services/{ => entity.services}/list.service.spec.ts (88%) create mode 100644 client-v2/src/app/services/entity.services/list.service.ts delete mode 100644 client-v2/src/app/services/list.service.ts diff --git a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts index 1f18aa77..eee8ffb9 100644 --- a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts +++ b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { EntityType } from 'src/app/models/entities.model' import { TaskStatus } from 'src/app/models/task.model' /** This will come from the db */ @@ -8,11 +9,7 @@ import { TaskStatus } from 'src/app/models/task.model' // DOCUMENT = 'Document', // VIEW = 'View', // } -export enum EntityType { - TASKLIST = 'Tasklist', - DOCUMENT = 'Document', - VIEW = 'View', -} + export enum PageEntityState { LOADING = 'Loading', } @@ -43,8 +40,8 @@ export const taskStatusIconClassMap: Record = { export const entityIconClassMap: Record = { [EntityType.TASKLIST]: 'far fa-tasks text-tinted-400', - [EntityType.DOCUMENT]: 'far fa-file-alt text-tinted-400', - [EntityType.VIEW]: 'far fa-binoculars text-tinted-400', + // [EntityType.DOCUMENT]: 'far fa-file-alt text-tinted-400', + // [EntityType.VIEW]: 'far fa-binoculars text-tinted-400', [PageEntityState.LOADING]: 'far fa-spinner-third animate-spin text-tinted-200', ...taskStatusIconClassMap, } diff --git a/client-v2/src/app/models/auth.model.ts b/client-v2/src/app/models/auth.model.ts index c25f8da3..79fc63c7 100644 --- a/client-v2/src/app/models/auth.model.ts +++ b/client-v2/src/app/models/auth.model.ts @@ -1,4 +1,4 @@ -import { UserPreview } from '.' +import { UserPreview } from './user.model' import { HttpSuccessResponse } from '../http/types' export interface SignupCredentialsDto { diff --git a/client-v2/src/app/models/defaults.ts b/client-v2/src/app/models/defaults.ts new file mode 100644 index 00000000..6331000c --- /dev/null +++ b/client-v2/src/app/models/defaults.ts @@ -0,0 +1,8 @@ +import { EntityType } from './entities.model' + +export const ENTITY_NAME_DEFAULTS: Record = { + [EntityType.TASKLIST]: 'Untitled tasklist', +} + +// @TODO: remove placeholder +export const DEFAULT_TASKLIST_NAME = ENTITY_NAME_DEFAULTS[EntityType.TASKLIST] diff --git a/client-v2/src/app/models/entities.model.ts b/client-v2/src/app/models/entities.model.ts new file mode 100644 index 00000000..2da6f66b --- /dev/null +++ b/client-v2/src/app/models/entities.model.ts @@ -0,0 +1,32 @@ +import { TasklistPreview } from './list.model' + +export enum EntityType { + TASKLIST = 'Tasklist', + // TASK = 'Task', + // DOCUMENT = 'Document', + // VIEW = 'View', +} + +// export interface EntityPreview { +// id: string +// type: EntityType +// name: string +// parentId: string | undefined +// children: string[] +// } +// export type EntityPreviewRecursive = EntityPreview & { +// children: EntityPreviewRecursive[] +// } +// export type EntityPreviewFlattend = Omit & { +// path: string[] +// childrenCount: number +// } + +// @TODO: convert to real Entity-interfaces above +export type EntityPreviewRecursive = Omit & { + children: EntityPreviewRecursive[] +} +export type EntityPreviewFlattend = Omit & { + path: string[] + childrenCount: number +} diff --git a/client-v2/src/app/models/list.model.ts b/client-v2/src/app/models/list.model.ts new file mode 100644 index 00000000..319b329b --- /dev/null +++ b/client-v2/src/app/models/list.model.ts @@ -0,0 +1,33 @@ +export interface TaskList { + id: string + name: string + description: string + createdAt: string + ownerId: string + + parentListId: string + childLists: string[] + taskIds: string[] + // participants: string[] // maybe this one as well +} + +export type TasklistPreview = Pick + +export interface CreateTasklistDto { + name: string + description?: string + parentListId?: string +} +export type UpdateTasklistDto = Partial + +export interface PermissionsDto { + permission: ListPermissions +} +export type ShareTasklistDto = Partial + +export enum ListPermissions { + Manage = 'Manage', + Edit = 'Edit', + Comment = 'Comment', + View = 'View', +} diff --git a/client-v2/src/app/models/task.model.ts b/client-v2/src/app/models/task.model.ts index 8ca5830d..c3196e9e 100644 --- a/client-v2/src/app/models/task.model.ts +++ b/client-v2/src/app/models/task.model.ts @@ -66,40 +66,3 @@ export interface TaskComment { } export type CreateTaskCommentDto = Pick export type UpdateTaskCommentDto = CreateTaskCommentDto - -// TaskList -export interface TaskList { - id: string - name: string - description: string - createdAt: string - ownerId: string - - parentListId: string - childLists: string[] - taskIds: string[] - // participants: string[] // maybe this one as well -} - -export const DEFAULT_TASKLIST_NAME = 'Untitled tasklist' - -export type TasklistPreview = Pick - -export interface CreateTasklistDto { - name: string - description?: string - parentListId?: string -} -export type UpdateTasklistDto = Partial - -export interface PermissionsDto { - permission: ListPermissions -} -export type ShareTasklistDto = Partial - -export enum ListPermissions { - Manage = 'Manage', - Edit = 'Edit', - Comment = 'Comment', - View = 'View', -} diff --git a/client-v2/src/app/models/index.ts b/client-v2/src/app/models/user.model.ts similarity index 100% rename from client-v2/src/app/models/index.ts rename to client-v2/src/app/models/user.model.ts diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts index a2ef54b7..7b6ad885 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -15,12 +15,13 @@ import { switchMap, tap, } from 'rxjs' -import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { Breadcrumb } from 'src/app/components/molecules/breadcrumbs/breadcrumbs.component' import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' -import { DEFAULT_TASKLIST_NAME, TaskStatus } from 'src/app/models/task.model' +import { DEFAULT_TASKLIST_NAME, ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' +import { EntityType } from 'src/app/models/entities.model' +import { TaskStatus } from 'src/app/models/task.model' import { AppState } from 'src/app/store' -import { listActions } from 'src/app/store/entities/entities.actions' +import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' import { traceEntity } from 'src/app/store/entities/utils' @UntilDestroy() @@ -63,7 +64,7 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { entityOptionsItems: MenuItem[] = [ { title: `Rename`, - action: (id: string) => this.store.dispatch(listActions.renameListDialog({ id })), + action: (id: string) => this.store.dispatch(entitiesActions.openRenameDialog({ id })), }, { title: `Export`, @@ -73,7 +74,7 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { { title: `Delete`, variant: MenuItemVariant.DANGER, - action: (id: string) => this.store.dispatch(listActions.deleteListDialog({ id })), + action: (id: string) => this.store.dispatch(entitiesActions.openDeleteDialog({ id })), }, ] entityOptionsItems$ = of(this.entityOptionsItems) @@ -109,7 +110,8 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { return entityName != newEntityName }), map(() => { - if (entityName == DEFAULT_TASKLIST_NAME) return '' + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (Object.values(ENTITY_NAME_DEFAULTS).includes(entityName!)) return '' return entityName }) @@ -192,7 +194,7 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { tap(activeEntity => { if (!activeEntity || !newName) return - return this.store.dispatch(listActions.renameList({ id: activeEntity.id, newName })) + return this.store.dispatch(entitiesActions.rename({ id: activeEntity.id, newName })) }) ) }), diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index ff27ab76..3964cc2b 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -5,11 +5,11 @@ import { ActivatedRoute } from '@angular/router' import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' import { map, startWith, switchMap, tap } from 'rxjs' -import { EntityType } from 'src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component' import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' +import { EntityType, EntityPreviewFlattend } from 'src/app/models/entities.model' import { AppState } from 'src/app/store' import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' -import { flattenEntityTree, EntityPreviewFlattend, traceEntity } from 'src/app/store/entities/utils' +import { flattenEntityTree, traceEntity } from 'src/app/store/entities/utils' import { moveToMacroQueue } from 'src/app/utils' import { getLoadingUpdates } from 'src/app/utils/store.helpers' @@ -123,20 +123,20 @@ export class HomeComponent implements OnInit { createNewList(parentListId?: string) { this.store.dispatch(listActions.createTaskList({ parentListId })) } - renameList(id: string) { - this.store.dispatch(listActions.renameListDialog({ id })) - } duplicateList(id: string) { this.store.dispatch(listActions.duplicateList({ id })) } - deleteList(id: string) { - this.store.dispatch(listActions.deleteListDialog({ id })) + renameEntity(id: string) { + this.store.dispatch(entitiesActions.openRenameDialog({ id })) + } + deleteEntity(id: string) { + this.store.dispatch(entitiesActions.openDeleteDialog({ id })) } nodeMenuItems: MenuItem[] = [ { title: 'Rename', - action: (id: string) => this.renameList(id), + action: (id: string) => this.renameEntity(id), }, { title: 'Create new list inside', @@ -150,7 +150,7 @@ export class HomeComponent implements OnInit { { title: 'Delete', variant: MenuItemVariant.DANGER, - action: (id: string) => this.deleteList(id), + action: (id: string) => this.deleteEntity(id), }, ] } diff --git a/client-v2/src/app/services/entities.service.ts b/client-v2/src/app/services/entities.service.ts index e32c9398..f4ed03f5 100644 --- a/client-v2/src/app/services/entities.service.ts +++ b/client-v2/src/app/services/entities.service.ts @@ -1,6 +1,40 @@ -import { Injectable } from '@angular/core' +import { inject, Injectable } from '@angular/core' +import { Observable } from 'rxjs' import { HttpService } from '../http/http.service' -import { TasklistPreview } from '../models/task.model' +import { HttpSuccessResponse } from '../http/types' +import { EntityType } from '../models/entities.model' +import { TasklistPreview } from '../models/list.model' +import { ListService } from './entity.services/list.service' + +export interface EntityService { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create(dto: Record): Observable> + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update(id: string, dto: Record): Observable> + + delete(id: string): Observable +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EntityServiceConstructor = new (...args: any[]) => EntityService + +export type EntityServiceInjectorMap = typeof entityServiceInjectorMap +export type EntityServiceInstanceMap = { + [key in keyof EntityServiceInjectorMap]: InstanceType +} + +const entityServiceInjectorMap = { + [EntityType.TASKLIST]: ListService, +} // satisfies Record // @TODO: upgrade to typescript@4.9 => angular@15 + +export const getEntityServiceInjector = (entityType: T): EntityServiceInjectorMap[T] => { + return entityServiceInjectorMap[entityType] +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type EntityCrudDto = object> = { + entityType: EntityType + id: string +} & T @Injectable({ providedIn: 'root', @@ -8,7 +42,24 @@ import { TasklistPreview } from '../models/task.model' export class EntitiesService { constructor(private http: HttpService) {} + private entityServices = Object.fromEntries( + Object.entries(entityServiceInjectorMap).map(([entityType, injectionToken]) => { + return [entityType, inject(injectionToken as EntityServiceConstructor)] + }) + ) as EntityServiceInstanceMap + + injectEntityService(entityType: T) { + return this.entityServices[entityType] + } + getEntityPreviews() { return this.http.get('/all-lists') } + + rename({ entityType, id, newName }: EntityCrudDto<{ newName: string }>) { + return this.injectEntityService(entityType).update(id, { name: newName }) + } + delete({ entityType, id }: EntityCrudDto) { + return this.injectEntityService(entityType).delete(id) + } } diff --git a/client-v2/src/app/services/list.service.spec.ts b/client-v2/src/app/services/entity.services/list.service.spec.ts similarity index 88% rename from client-v2/src/app/services/list.service.spec.ts rename to client-v2/src/app/services/entity.services/list.service.spec.ts index 4199f2fa..5dd119f2 100644 --- a/client-v2/src/app/services/list.service.spec.ts +++ b/client-v2/src/app/services/entity.services/list.service.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing' -import { httpServiceMock } from '../utils/unit-test.mocks' +import { httpServiceMock } from '../../utils/unit-test.mocks' import { ListService } from './list.service' diff --git a/client-v2/src/app/services/entity.services/list.service.ts b/client-v2/src/app/services/entity.services/list.service.ts new file mode 100644 index 00000000..6a08e3c5 --- /dev/null +++ b/client-v2/src/app/services/entity.services/list.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core' +import { CreateTasklistDto, TaskList, UpdateTasklistDto } from 'src/app/models/list.model' +import { HttpService } from '../../http/http.service' +import { HttpSuccessResponse } from '../../http/types' +import { EntityService } from '../entities.service' + +@Injectable({ + providedIn: 'root', +}) +export class ListService implements EntityService { + constructor(private http: HttpService) {} + + create(dto: CreateTasklistDto) { + return this.http.post('/list', dto) + } + + update(id: string, dto: UpdateTasklistDto) { + return this.http.patch('/list/' + id, dto) + } + + delete(id: string) { + return this.http.delete('/list/' + id) + } +} diff --git a/client-v2/src/app/services/list.service.ts b/client-v2/src/app/services/list.service.ts deleted file mode 100644 index 1ef5ad04..00000000 --- a/client-v2/src/app/services/list.service.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injectable } from '@angular/core' -import { HttpService } from '../http/http.service' -import { HttpSuccessResponse } from '../http/types' -import { CreateTasklistDto, TaskList, UpdateTasklistDto } from '../models/task.model' - -@Injectable({ - providedIn: 'root', -}) -export class ListService { - constructor(private http: HttpService) {} - - createTaskList(dto: CreateTasklistDto) { - return this.http.post('/list', dto) - } - - updateTaskList(id: string, dto: UpdateTasklistDto) { - return this.http.patch('/list/' + id, dto) - } - - deleteTaskList(id: string) { - return this.http.delete('/list/' + id) - } -} diff --git a/client-v2/src/app/store/entities/entities.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts index b49092dd..d967d699 100644 --- a/client-v2/src/app/store/entities/entities.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -1,6 +1,6 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store' import { HttpServerErrorResponse } from 'src/app/http/types' -import { CreateTasklistDto, TaskList, TasklistPreview } from 'src/app/models/task.model' +import { TasklistPreview, CreateTasklistDto, TaskList } from 'src/app/models/list.model' export const entitiesActions = createActionGroup({ source: 'Entities', @@ -9,19 +9,19 @@ export const entitiesActions = createActionGroup({ 'load previews success': props<{ previews: TasklistPreview[] }>(), 'load previews error': props(), - // 'open rename dialog': props<{ id: string }>(), - // 'abort rename dialog': emptyProps(), - // // - // rename: props<{ id: string; newName: string }>(), - // 'rename success': props<{ id: string; newName: string }>(), - // 'rename error': props(), + 'open rename dialog': props<{ id: string }>(), + 'abort rename dialog': emptyProps(), + // + rename: props<{ id: string; newName: string }>(), + 'rename success': props<{ id: string; newName: string }>(), + 'rename error': props(), - // 'open delete dialog': props<{ id: string }>(), - // 'abort delete dialog': emptyProps(), - // // - // delete: props<{ id: string }>(), - // 'delete success': props<{ id: string }>(), - // 'delete error': props(), + 'open delete dialog': props<{ id: string }>(), + 'abort delete dialog': emptyProps(), + // + delete: props<{ id: string }>(), + 'delete success': props<{ id: string }>(), + 'delete error': props(), }, }) @@ -36,19 +36,19 @@ export const listActions = createActionGroup({ 'create task list success': props<{ createdList: TaskList }>(), 'create task list error': props(), - 'rename list dialog': props<{ id: string }>(), - 'rename list dialog abort': emptyProps(), - // - 'rename list': props<{ id: string; newName: string }>(), - 'rename list success': props<{ id: string; newName: string }>(), - 'rename list error': props(), + // 'rename list dialog': props<{ id: string }>(), + // 'rename list dialog abort': emptyProps(), + // // + // 'rename list': props<{ id: string; newName: string }>(), + // 'rename list success': props<{ id: string; newName: string }>(), + // 'rename list error': props(), - 'delete list dialog': props<{ id: string }>(), - 'delete list dialog abort': emptyProps(), - // - 'delete list': props<{ id: string }>(), - 'delete list success': props<{ id: string }>(), - 'delete list error': props(), + // 'delete list dialog': props<{ id: string }>(), + // 'delete list dialog abort': emptyProps(), + // // + // 'delete list': props<{ id: string }>(), + // 'delete list success': props<{ id: string }>(), + // 'delete list error': props(), 'duplicate list': props<{ id: string }>(), 'duplicate list success': props<{ id: string }>(), diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts index e5536d57..0f697a23 100644 --- a/client-v2/src/app/store/entities/entities.effects.ts +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -1,12 +1,42 @@ import { Injectable } from '@angular/core' +import { Router } from '@angular/router' +import { HotToastService } from '@ngneat/hot-toast' import { Actions, createEffect, ofType } from '@ngrx/effects' -import { catchError, map, mergeMap, of } from 'rxjs' +import { Store } from '@ngrx/store' +import { catchError, concatMap, first, map, mergeMap, of, switchMap, tap } from 'rxjs' +import { DialogService } from 'src/app/modal/dialog.service' +import { EntityType } from 'src/app/models/entities.model' import { EntitiesService } from 'src/app/services/entities.service' +import { getMessageFromHttpError } from 'src/app/utils/store.helpers' +import { AppState } from '..' import { entitiesActions } from './entities.actions' +import { getEntityById, traceEntity } from './utils' @Injectable() export class EntitiesEffects { - constructor(private actions$: Actions, private entitiesService: EntitiesService) {} + constructor( + private actions$: Actions, + private entitiesService: EntitiesService, + private store: Store, + private toast: HotToastService, + private dialogService: DialogService, + private router: Router + ) {} + + activeEntityTrace$ = this.store + .select(state => state.entities.entityTree) + .pipe( + map(entityTree => { + // @TODO: come up with a better solution for this + // NOTE: using `ActivatedRoute` doesn't work in effects + const segments = location.pathname.split('/') + const activeId = segments[segments.length - 1] + + if (!entityTree || !activeId) return null + + return traceEntity(entityTree, activeId) + }) + ) loadEntityPreviews = createEffect(() => { return this.actions$.pipe( @@ -24,4 +54,115 @@ export class EntitiesEffects { }) ) }) + + showRenameDialog = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.openRenameDialog), + switchMap(({ id }) => { + return this.store + .select(state => state.entities.entityTree) + .pipe( + first(), + map(entityTree => { + if (!entityTree) return entitiesActions.abortRenameDialog() + + const entity = getEntityById(entityTree, id) + if (!entity) return entitiesActions.abortRenameDialog() + + const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value + const newName = prompt(`Rename the ${entityType}`, entity.name)?.trim() + if (!newName) return entitiesActions.abortRenameDialog() + + return entitiesActions.rename({ id, newName }) + }) + ) + }) + ) + }) + // + rename = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.rename), + mergeMap(({ id, newName }) => { + const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value + const res$ = this.entitiesService.rename({ entityType, id, newName }) + + return res$.pipe( + map(() => entitiesActions.renameSuccess({ id, newName })), + catchError(err => { + this.toast.error(getMessageFromHttpError(err)) + return of(entitiesActions.renameError(err)) + }) + ) + }) + ) + }) + + showDeleteDialog = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.openDeleteDialog), + switchMap(({ id }) => { + return this.store + .select(state => state.entities.entityTree) + .pipe( + first(), + concatMap(entityTree => { + if (!entityTree) return of(entitiesActions.abortDeleteDialog()) + + const entity = getEntityById(entityTree, id) + if (!entity) return of(entitiesActions.abortDeleteDialog()) + + const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value + const closed$ = this.dialogService.confirm({ + title: `Delete this ${entityType}?`, + text: `Are you sure you want to delete '${entity.name}'?`, + buttons: [{ text: 'Cancel' }, { text: 'Delete', className: 'button--danger' }], + }).closed + + return closed$.pipe( + map(response => { + if (response == 'Delete') return entitiesActions.delete({ id }) + return entitiesActions.abortDeleteDialog() + }) + ) + }) + ) + }) + ) + }) + // + delete = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.delete), + mergeMap(({ id }) => { + const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value + const res$ = this.entitiesService.delete({ entityType, id }) + + return res$.pipe( + this.toast.observe({ + loading: 'Deleting tasklist...', + success: res => res.successMessage, + error: getMessageFromHttpError, + }), + switchMap(() => { + return this.activeEntityTrace$.pipe( + first(), + tap(trace => { + if (!trace) return + + const activeEntity = trace[trace.length - 1] + if (activeEntity.id != id) return + + const parentEntity = trace[trace.length - 2] + + this.router.navigateByUrl(parentEntity ? `/home/${parentEntity.id}` : '/home') + }), + map(() => entitiesActions.deleteSuccess({ id })) + ) + }), + catchError(err => of(entitiesActions.deleteError(err))) + ) + }) + ) + }) } diff --git a/client-v2/src/app/store/entities/entities.model.ts b/client-v2/src/app/store/entities/entities.model.ts index e6bb59dc..1bc6f554 100644 --- a/client-v2/src/app/store/entities/entities.model.ts +++ b/client-v2/src/app/store/entities/entities.model.ts @@ -1,15 +1,4 @@ -import { TasklistPreview } from 'src/app/models/task.model' - -export type EntityPreviewRecursive = Omit & { - children: EntityPreviewRecursive[] -} - -// export interface EntityPreviewRecursive { -// id: string -// name: string -// parentId: string | undefined -// children: EntityPreviewRecursive[] -// } +import { EntityPreviewRecursive } from 'src/app/models/entities.model' export interface EntitiesState { entityTree: EntityPreviewRecursive[] | null diff --git a/client-v2/src/app/store/entities/entities.reducer.ts b/client-v2/src/app/store/entities/entities.reducer.ts index 7f34f728..9fde9bb1 100644 --- a/client-v2/src/app/store/entities/entities.reducer.ts +++ b/client-v2/src/app/store/entities/entities.reducer.ts @@ -24,43 +24,43 @@ export const entitiesReducer = createReducer( entityTree: [...(state.entityTree || []), { ...createdList, children: [] }], } - const listPreviewsCopy = structuredClone(state.entityTree) - const parentTaskList = getEntityById(listPreviewsCopy, createdList.parentListId) + const entityTreeCopy = structuredClone(state.entityTree) + const parentList = getEntityById(entityTreeCopy, createdList.parentListId) - if (!parentTaskList) + if (!parentList) return { ...state, entityTree: [...(state.entityTree || []), { ...createdList, children: [] }], } - parentTaskList.children.push({ ...createdList, children: [] }) + parentList.children.push({ ...createdList, children: [] }) return { ...state, - entityTree: listPreviewsCopy, + entityTree: entityTreeCopy, } }), - on(listActions.renameListSuccess, (state, { id, newName }) => { - const listPreviewsCopy = structuredClone(state.entityTree) - const list = getEntityById(listPreviewsCopy, id) - if (!list) return state + on(entitiesActions.renameSuccess, (state, { id, newName }) => { + const entityTreeCopy = structuredClone(state.entityTree) + const entity = getEntityById(entityTreeCopy, id) + if (!entity) return state - list.name = newName + entity.name = newName return { ...state, - entityTree: listPreviewsCopy, + entityTree: entityTreeCopy, } }), - on(listActions.deleteListSuccess, (state, { id }) => { - const listPreviewsCopy = structuredClone(state.entityTree) - const result = getParentByChildId(listPreviewsCopy, id) + on(entitiesActions.deleteSuccess, (state, { id }) => { + const entityTreeCopy = structuredClone(state.entityTree) + const result = getParentByChildId(entityTreeCopy, id) if (!result) return state result.subTree.splice(result.index, 1) - return { ...state, entityTree: listPreviewsCopy } + return { ...state, entityTree: entityTreeCopy } }) ) diff --git a/client-v2/src/app/store/entities/list.effects.ts b/client-v2/src/app/store/entities/list.effects.ts index ef89c3e6..1664a1b6 100644 --- a/client-v2/src/app/store/entities/list.effects.ts +++ b/client-v2/src/app/store/entities/list.effects.ts @@ -3,15 +3,14 @@ import { Router } from '@angular/router' import { HotToastService } from '@ngneat/hot-toast' import { Actions, createEffect, ofType } from '@ngrx/effects' import { Store } from '@ngrx/store' -import { catchError, concatMap, first, map, mergeMap, of, switchMap, tap } from 'rxjs' +import { catchError, map, mergeMap, of, tap } from 'rxjs' import { DialogService } from 'src/app/modal/dialog.service' -import { DEFAULT_TASKLIST_NAME } from 'src/app/models/task.model' -import { ListService } from 'src/app/services/list.service' +import { DEFAULT_TASKLIST_NAME } from 'src/app/models/defaults' +import { ListService } from 'src/app/services/entity.services/list.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' import { AppState } from '..' import { appActions } from '../app.actions' import { listActions } from './entities.actions' -import { getEntityById, traceEntity } from './utils' @Injectable() export class ListEffects { @@ -29,7 +28,7 @@ export class ListEffects { ofType(listActions.createTaskList), mergeMap(dto => { const name = dto.name || DEFAULT_TASKLIST_NAME - const res$ = this.listService.createTaskList({ ...dto, name }) + const res$ = this.listService.create({ ...dto, name }) return res$.pipe( this.toast.observe({ @@ -45,47 +44,6 @@ export class ListEffects { ) }) - showRenameListDialog = createEffect(() => { - return this.actions$.pipe( - ofType(listActions.renameListDialog), - switchMap(({ id }) => { - return this.store - .select(state => state.entities.entityTree) - .pipe( - first(), - map(listPreviews => { - if (!listPreviews) return listActions.renameListDialogAbort() - - const taskList = getEntityById(listPreviews, id) - if (!taskList) return listActions.renameListDialogAbort() - - const newName = prompt('Rename the list', taskList.name)?.trim() - if (!newName) return listActions.renameListDialogAbort() - - return listActions.renameList({ id, newName }) - }) - ) - }) - ) - }) - // - renameTaskList = createEffect(() => { - return this.actions$.pipe( - ofType(listActions.renameList), - mergeMap(({ id, newName }) => { - const res$ = this.listService.updateTaskList(id, { name: newName }) - - return res$.pipe( - map(() => listActions.renameListSuccess({ id, newName })), - catchError(err => { - this.toast.error(getMessageFromHttpError(err)) - return of(listActions.renameListError(err)) - }) - ) - }) - ) - }) - duplicateList = createEffect(() => { return this.actions$.pipe( ofType(listActions.duplicateList), @@ -105,85 +63,4 @@ export class ListEffects { }) ) }) - - showDeleteListDialog = createEffect(() => { - return this.actions$.pipe( - ofType(listActions.deleteListDialog), - switchMap(({ id }) => { - return this.store - .select(state => state.entities.entityTree) - .pipe( - first(), - concatMap(listPreviews => { - if (!listPreviews) return of(listActions.deleteListDialogAbort()) - - const taskList = getEntityById(listPreviews, id) - if (!taskList) return of(listActions.deleteListDialogAbort()) - - const closed$ = this.dialogService.confirm({ - title: 'Delete this tasklist?', - text: `Are you sure you want to delete '${taskList.name}'?`, - buttons: [{ text: 'Cancel' }, { text: 'Delete', className: 'button--danger' }], - }).closed - - return closed$.pipe( - map(response => { - if (response == 'Delete') return listActions.deleteList({ id }) - return listActions.deleteListDialogAbort() - }) - ) - }) - ) - }) - ) - }) - // - deleteList = createEffect(() => { - return this.actions$.pipe( - ofType(listActions.deleteList), - mergeMap(({ id }) => { - const res$ = this.listService.deleteTaskList(id) - - return res$.pipe( - this.toast.observe({ - loading: 'Deleting tasklist...', - success: res => res.successMessage, - error: getMessageFromHttpError, - }), - switchMap(() => { - return this.activeEntityTrace$.pipe( - first(), - tap(trace => { - if (!trace) return - - const activeEntity = trace[trace.length - 1] - if (activeEntity.id != id) return - - const parentEntity = trace[trace.length - 2] - - this.router.navigateByUrl(parentEntity ? `/home/${parentEntity.id}` : '/home') - }), - map(() => listActions.deleteListSuccess({ id })) - ) - }), - catchError(err => of(listActions.deleteListError(err))) - ) - }) - ) - }) - - activeEntityTrace$ = this.store - .select(state => state.entities.entityTree) - .pipe( - map(entityTree => { - // @TODO: come up with a better solution for this - // NOTE: using `ActivatedRoute` doesn't work in effects - const segments = location.pathname.split('/') - const activeId = segments[segments.length - 1] - - if (!entityTree || !activeId) return null - - return traceEntity(entityTree, activeId) - }) - ) } diff --git a/client-v2/src/app/store/entities/utils.ts b/client-v2/src/app/store/entities/utils.ts index 2017b4c8..47dcce6e 100644 --- a/client-v2/src/app/store/entities/utils.ts +++ b/client-v2/src/app/store/entities/utils.ts @@ -1,5 +1,5 @@ -import { TasklistPreview } from 'src/app/models/task.model' -import { EntityPreviewRecursive } from './entities.model' +import { EntityPreviewFlattend, EntityPreviewRecursive } from 'src/app/models/entities.model' +import { TasklistPreview } from 'src/app/models/list.model' export const getEntityTree = (allEntities: TasklistPreview[]) => { const getChildren = (childIds: string[]): EntityPreviewRecursive[] => { @@ -17,11 +17,6 @@ export const getEntityTree = (allEntities: TasklistPreview[]) => { return entityTree } -export type EntityPreviewFlattend = Omit & { - path: string[] - childrenCount: number -} - export const flattenEntityTree = (lists: EntityPreviewRecursive[], path: string[] = []): EntityPreviewFlattend[] => { return lists.flatMap(list => { const { children: childLists, ...rest } = list diff --git a/client-v2/src/app/store/user/user.model.ts b/client-v2/src/app/store/user/user.model.ts index 27fce361..a4e057c6 100644 --- a/client-v2/src/app/store/user/user.model.ts +++ b/client-v2/src/app/store/user/user.model.ts @@ -1,4 +1,4 @@ -import { User } from 'src/app/models' +import { User } from 'src/app/models/user.model' import { PartialRequired } from 'src/app/utils/type.helpers' export interface UserState { From 2b145007012d2bc3a8e70b996d49db8ebe0f64d1 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Wed, 28 Dec 2022 00:47:20 +0100 Subject: [PATCH 39/60] Split user.service into auth and account service, rename state files from .model to .state --- .../src/app/services/account.service.spec.ts | 19 +++++++++++++ client-v2/src/app/services/account.service.ts | 28 +++++++++++++++++++ .../src/app/services/auth.service.spec.ts | 19 +++++++++++++ .../{user.service.ts => auth.service.ts} | 23 +-------------- .../src/app/services/user.service.spec.ts | 22 --------------- .../app/store/entities/entities.reducer.ts | 2 +- .../{entities.model.ts => entities.state.ts} | 0 client-v2/src/app/store/index.ts | 4 +-- .../src/app/store/user/account.effects.ts | 16 +++++------ client-v2/src/app/store/user/auth.effects.ts | 16 +++++------ client-v2/src/app/store/user/user.reducer.ts | 2 +- .../src/app/store/user/user.selectors.ts | 2 +- .../user/{user.model.ts => user.state.ts} | 0 13 files changed, 88 insertions(+), 65 deletions(-) create mode 100644 client-v2/src/app/services/account.service.spec.ts create mode 100644 client-v2/src/app/services/account.service.ts create mode 100644 client-v2/src/app/services/auth.service.spec.ts rename client-v2/src/app/services/{user.service.ts => auth.service.ts} (53%) delete mode 100644 client-v2/src/app/services/user.service.spec.ts rename client-v2/src/app/store/entities/{entities.model.ts => entities.state.ts} (100%) rename client-v2/src/app/store/user/{user.model.ts => user.state.ts} (100%) diff --git a/client-v2/src/app/services/account.service.spec.ts b/client-v2/src/app/services/account.service.spec.ts new file mode 100644 index 00000000..90f52e3e --- /dev/null +++ b/client-v2/src/app/services/account.service.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing' +import { httpServiceMock } from '../utils/unit-test.mocks' + +import { AccountService } from './account.service' + +describe('AccountService', () => { + let service: AccountService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [httpServiceMock], + }) + service = TestBed.inject(AccountService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/services/account.service.ts b/client-v2/src/app/services/account.service.ts new file mode 100644 index 00000000..a7e21dea --- /dev/null +++ b/client-v2/src/app/services/account.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core' +import { HttpService } from '../http/http.service' +import { HttpSuccessResponse } from '../http/types' + +@Injectable({ + providedIn: 'root', +}) +export class AccountService { + constructor(private http: HttpService) {} + + updateUsername(dto: { username: string }) { + return this.http.patch('/user', dto) + } + updateEmail(dto: { password: string; email: string }) { + return this.http.patch('/user/email', dto) + } + updatePassword(dto: { password: string; newPassword: string }) { + return this.http.patch('/user/password', dto) + } + + deleteAccount(dto: { password: string }) { + return this.http.delete('/user', { body: dto }) + } + + loadEmail() { + return this.http.get<{ email: string }>('/user/email') + } +} diff --git a/client-v2/src/app/services/auth.service.spec.ts b/client-v2/src/app/services/auth.service.spec.ts new file mode 100644 index 00000000..61ec1e5c --- /dev/null +++ b/client-v2/src/app/services/auth.service.spec.ts @@ -0,0 +1,19 @@ +import { TestBed } from '@angular/core/testing' +import { httpServiceMock } from '../utils/unit-test.mocks' + +import { AuthService } from './auth.service' + +describe('AuthService', () => { + let service: AuthService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [httpServiceMock], + }) + service = TestBed.inject(AuthService) + }) + + it('should be created', () => { + expect(service).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/services/user.service.ts b/client-v2/src/app/services/auth.service.ts similarity index 53% rename from client-v2/src/app/services/user.service.ts rename to client-v2/src/app/services/auth.service.ts index e0863e5a..f8a9bbb2 100644 --- a/client-v2/src/app/services/user.service.ts +++ b/client-v2/src/app/services/auth.service.ts @@ -1,15 +1,13 @@ import { Injectable } from '@angular/core' import { HttpService } from '../http/http.service' -import { HttpSuccessResponse } from '../http/types' import { SignupCredentialsDto, AuthSuccessResponse, LoginCredentialsDto } from '../models/auth.model' @Injectable({ providedIn: 'root', }) -export class UserService { +export class AuthService { constructor(private http: HttpService) {} - ////////////// Auth //////////////// signup(credentials: SignupCredentialsDto) { return this.http.post('/auth/signup', credentials) } @@ -29,23 +27,4 @@ export class UserService { deleteToken() { localStorage.removeItem('todo-authToken') } - - ////////////// Account //////////////// - updateUsername(dto: { username: string }) { - return this.http.patch('/user', dto) - } - updateEmail(dto: { password: string; email: string }) { - return this.http.patch('/user/email', dto) - } - updatePassword(dto: { password: string; newPassword: string }) { - return this.http.patch('/user/password', dto) - } - - deleteAccount(dto: { password: string }) { - return this.http.delete('/user', { body: dto }) - } - - loadEmail() { - return this.http.get<{ email: string }>('/user/email') - } } diff --git a/client-v2/src/app/services/user.service.spec.ts b/client-v2/src/app/services/user.service.spec.ts deleted file mode 100644 index fab1051b..00000000 --- a/client-v2/src/app/services/user.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { HttpService } from '../http/http.service' - -import { UserService } from './user.service' - -describe('UserService', () => { - let service: UserService - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - // eslint-disable-next-line @typescript-eslint/no-empty-function - { provide: HttpService, useValue: { post() {}, get() {} } }, - ], - }) - service = TestBed.inject(UserService) - }) - - it('should be created', () => { - expect(service).toBeTruthy() - }) -}) diff --git a/client-v2/src/app/store/entities/entities.reducer.ts b/client-v2/src/app/store/entities/entities.reducer.ts index 9fde9bb1..7043b0d6 100644 --- a/client-v2/src/app/store/entities/entities.reducer.ts +++ b/client-v2/src/app/store/entities/entities.reducer.ts @@ -1,6 +1,6 @@ import { createReducer, on } from '@ngrx/store' import { entitiesActions, listActions } from './entities.actions' -import { EntitiesState } from './entities.model' +import { EntitiesState } from './entities.state' import { getParentByChildId, getEntityById, getEntityTree } from './utils' const initialState: EntitiesState = { diff --git a/client-v2/src/app/store/entities/entities.model.ts b/client-v2/src/app/store/entities/entities.state.ts similarity index 100% rename from client-v2/src/app/store/entities/entities.model.ts rename to client-v2/src/app/store/entities/entities.state.ts diff --git a/client-v2/src/app/store/index.ts b/client-v2/src/app/store/index.ts index 937e3663..352dc994 100644 --- a/client-v2/src/app/store/index.ts +++ b/client-v2/src/app/store/index.ts @@ -3,10 +3,10 @@ import { environment } from '../../environments/environment' import { AppEffects } from './app.effects' import { AuthEffects } from './user/auth.effects' import { AccountEffects } from './user/account.effects' -import { UserState } from './user/user.model' +import { UserState } from './user/user.state' import { userReducer } from './user/user.reducer' import { entitiesReducer } from './entities/entities.reducer' -import { EntitiesState } from './entities/entities.model' +import { EntitiesState } from './entities/entities.state' import { ListEffects } from './entities/list.effects' import { EntitiesEffects } from './entities/entities.effects' diff --git a/client-v2/src/app/store/user/account.effects.ts b/client-v2/src/app/store/user/account.effects.ts index 3e1b3e48..25136595 100644 --- a/client-v2/src/app/store/user/account.effects.ts +++ b/client-v2/src/app/store/user/account.effects.ts @@ -4,18 +4,18 @@ import { Actions, createEffect, ofType } from '@ngrx/effects' import { Store } from '@ngrx/store' import { catchError, map, mergeMap, of } from 'rxjs' import { HttpServerErrorResponse } from 'src/app/http/types' -import { UserService } from 'src/app/services/user.service' +import { AccountService } from 'src/app/services/account.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' import { AppState } from '..' import { appActions } from '../app.actions' import { accountActions, authActions } from './user.actions' -import { UserState } from './user.model' +import { UserState } from './user.state' @Injectable() export class AccountEffects { constructor( private actions$: Actions, - private userService: UserService, + private accountService: AccountService, private toast: HotToastService, private store: Store ) { @@ -28,7 +28,7 @@ export class AccountEffects { return this.actions$.pipe( ofType(accountActions.updateUsername), mergeMap(dto => { - const res$ = this.userService.updateUsername(dto) + const res$ = this.accountService.updateUsername(dto) return res$.pipe( this.toast.observe({ @@ -47,7 +47,7 @@ export class AccountEffects { return this.actions$.pipe( ofType(accountActions.updateEmail), mergeMap(dto => { - const res$ = this.userService.updateEmail(dto) + const res$ = this.accountService.updateEmail(dto) return res$.pipe( this.toast.observe({ @@ -66,7 +66,7 @@ export class AccountEffects { return this.actions$.pipe( ofType(accountActions.updatePassword), mergeMap(dto => { - const res$ = this.userService.updatePassword(dto) + const res$ = this.accountService.updatePassword(dto) return res$.pipe( this.toast.observe({ @@ -106,7 +106,7 @@ export class AccountEffects { return this.actions$.pipe( ofType(accountActions.deleteAccount), mergeMap(({ password }) => { - const res$ = this.userService.deleteAccount({ password }) + const res$ = this.accountService.deleteAccount({ password }) return res$.pipe( this.toast.observe({ @@ -134,7 +134,7 @@ export class AccountEffects { if (this.userState?.me?.email) { if (!ignoreCache) return of(appActions.nothing()) } - const res$ = this.userService.loadEmail() + const res$ = this.accountService.loadEmail() return res$.pipe(map(({ email }) => accountActions.loadEmailSuccess({ email }))) }) diff --git a/client-v2/src/app/store/user/auth.effects.ts b/client-v2/src/app/store/user/auth.effects.ts index b1c269b3..b6d8e12c 100644 --- a/client-v2/src/app/store/user/auth.effects.ts +++ b/client-v2/src/app/store/user/auth.effects.ts @@ -6,7 +6,7 @@ import { catchError, map, mergeMap, Observable, of, tap } from 'rxjs' import { HttpServerErrorResponse } from 'src/app/http/types' import { DialogService } from 'src/app/modal/dialog.service' import { AuthSuccessResponse, SignupCredentialsDto } from 'src/app/models/auth.model' -import { UserService } from 'src/app/services/user.service' +import { AuthService } from 'src/app/services/auth.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' import { appActions } from '../app.actions' import { authActions } from './user.actions' @@ -15,7 +15,7 @@ import { authActions } from './user.actions' export class AuthEffects { constructor( private actions$: Actions, - private userService: UserService, + private authService: AuthService, private toast: HotToastService, private router: Router, private dialogService: DialogService @@ -27,8 +27,8 @@ export class AuthEffects { mergeMap(({ type, credentials, callbackUrl }) => { const isLogin = /login/.test(type) const res$: Observable = isLogin - ? this.userService.login(credentials) - : this.userService.signup(credentials as SignupCredentialsDto) + ? this.authService.login(credentials) + : this.authService.signup(credentials as SignupCredentialsDto) return res$.pipe( this.toast.observe({ @@ -49,7 +49,7 @@ export class AuthEffects { () => this.actions$.pipe( ofType(authActions.loginOrSignupSuccess), - tap(({ authToken }) => this.userService.saveToken(authToken)) + tap(({ authToken }) => this.authService.saveToken(authToken)) ), { dispatch: false } ) @@ -58,7 +58,7 @@ export class AuthEffects { return this.actions$.pipe( ofType(ROOT_EFFECTS_INIT), map(() => { - const authToken = this.userService.getToken() + const authToken = this.authService.getToken() if (authToken) return authActions.loadAuthTokenSuccess({ authToken }) else return appActions.nothing() }) @@ -75,7 +75,7 @@ export class AuthEffects { return this.actions$.pipe( ofType(authActions.confirmLogin), mergeMap(() => { - const res$ = this.userService.confirmLogin() + const res$ = this.authService.confirmLogin() return res$.pipe( this.toast.observe({ @@ -128,7 +128,7 @@ export class AuthEffects { return this.actions$.pipe( ofType(authActions.logoutProceed), tap(() => { - this.userService.deleteToken() + this.authService.deleteToken() this.router.navigateByUrl('/auth') }) ) diff --git a/client-v2/src/app/store/user/user.reducer.ts b/client-v2/src/app/store/user/user.reducer.ts index 4abbbd56..4fe74b2a 100644 --- a/client-v2/src/app/store/user/user.reducer.ts +++ b/client-v2/src/app/store/user/user.reducer.ts @@ -1,6 +1,6 @@ import { createReducer, on } from '@ngrx/store' import { accountActions, authActions } from './user.actions' -import { UserState } from './user.model' +import { UserState } from './user.state' const initialState: UserState = { me: null, diff --git a/client-v2/src/app/store/user/user.selectors.ts b/client-v2/src/app/store/user/user.selectors.ts index 2bf63024..dfc6bd1f 100644 --- a/client-v2/src/app/store/user/user.selectors.ts +++ b/client-v2/src/app/store/user/user.selectors.ts @@ -1,5 +1,5 @@ import { createFeatureSelector, createSelector } from '@ngrx/store' -import { UserState } from './user.model' +import { UserState } from './user.state' export const userFeature = createFeatureSelector('user') diff --git a/client-v2/src/app/store/user/user.model.ts b/client-v2/src/app/store/user/user.state.ts similarity index 100% rename from client-v2/src/app/store/user/user.model.ts rename to client-v2/src/app/store/user/user.state.ts From 7e061448801cb4c9e8827d3dbe3fd4c2475601e0 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Wed, 28 Dec 2022 01:34:35 +0100 Subject: [PATCH 40/60] Scroll entity-page to top on navigation --- .../pages/home/entity-page/entity-page.component.html | 1 + .../app/pages/home/entity-page/entity-page.component.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index 9a06b389..ac67b757 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -34,6 +34,7 @@ +
diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts index 7b6ad885..d985476d 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -38,6 +38,7 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { DEFAULT_TASKLIST_NAME = DEFAULT_TASKLIST_NAME @ViewChild('editableEntityName') editableEntityName!: ElementRef + @ViewChild('top') topElement!: ElementRef isPrimaryProgressBarHidden = false @ViewChild('progressBar') progressBar!: ElementRef @@ -79,8 +80,12 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ] entityOptionsItems$ = of(this.entityOptionsItems) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - activeEntityId$ = this.route.paramMap.pipe(map(paramMap => paramMap.get('id')!)) + activeEntityId$ = this.route.paramMap.pipe( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + map(paramMap => paramMap.get('id')!), + tap(() => this.topElement?.nativeElement?.scrollIntoView({ behavior: 'smooth' })), // lets see how the 'smooth' behaviour feels after a while + shareReplay({ bufferSize: 1, refCount: true }) + ) activeEntityTrace$ = this.activeEntityId$.pipe( switchMap(activeId => { From d8edca318d95c3beb59c8dff55be87534e2ed223 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Wed, 28 Dec 2022 01:55:50 +0100 Subject: [PATCH 41/60] Add "remove hardcode" comments --- client-v2/src/app/models/entities.model.ts | 2 +- .../src/app/pages/home/entity-page/entity-page.component.html | 2 ++ .../src/app/pages/home/entity-page/entity-page.component.ts | 2 +- client-v2/src/app/pages/home/home.component.html | 1 + client-v2/src/app/pages/home/home.component.ts | 2 +- client-v2/src/app/store/entities/entities.effects.ts | 2 +- 6 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client-v2/src/app/models/entities.model.ts b/client-v2/src/app/models/entities.model.ts index 2da6f66b..b04515f0 100644 --- a/client-v2/src/app/models/entities.model.ts +++ b/client-v2/src/app/models/entities.model.ts @@ -22,7 +22,7 @@ export enum EntityType { // childrenCount: number // } -// @TODO: convert to real Entity-interfaces above +// @TODO: migrate to real Entity-interfaces above export type EntityPreviewRecursive = Omit & { children: EntityPreviewRecursive[] } diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index ac67b757..8ed9db62 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -45,6 +45,7 @@

+ @@ -95,6 +96,7 @@

[cdkContextMenuTriggerFor]="options" #trigger="cdkContextMenuTriggerFor" > + trace?.map(entity => ({ title: entity.name, - icon: EntityType.TASKLIST, + icon: EntityType.TASKLIST, // @TODO: Remove hardcoded value route: `/home/${entity.id}`, contextMenuItems: this.entityOptionsItems.map(({ action, ...item }) => { return { diff --git a/client-v2/src/app/pages/home/home.component.html b/client-v2/src/app/pages/home/home.component.html index 8b049822..f6e914c1 100644 --- a/client-v2/src/app/pages/home/home.component.html +++ b/client-v2/src/app/pages/home/home.component.html @@ -99,6 +99,7 @@ + 0, isExpanded: rest.path.length < 2, - entityType: EntityType.TASKLIST, + entityType: EntityType.TASKLIST, // @TODO: Remove hardcoded value } return node } diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts index 0f697a23..0c86e2e3 100644 --- a/client-v2/src/app/store/entities/entities.effects.ts +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -115,7 +115,7 @@ export class EntitiesEffects { const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value const closed$ = this.dialogService.confirm({ title: `Delete this ${entityType}?`, - text: `Are you sure you want to delete '${entity.name}'?`, + text: `Are you sure you want to delete the ${entityType} '${entity.name}'?`, buttons: [{ text: 'Cancel' }, { text: 'Delete', className: 'button--danger' }], }).closed From 71b2eb776d016ba34d0d14e84041cb5777095fc4 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Fri, 30 Dec 2022 04:16:13 +0100 Subject: [PATCH 42/60] Abstract out entity-page into a separate view and dynamic components per entity, globalize entity menu items --- client-v2/src/app/app.module.ts | 6 + .../drop-down/drop-down.component.ts | 8 + .../editable-entity-name.component.css | 4 + .../editable-entity-name.component.html | 20 ++ .../editable-entity-name.component.spec.ts | 25 +++ .../editable-entity-name.component.ts | 131 ++++++++++++ .../entity-view/entity-view.component.css | 0 .../entity-view/entity-view.component.html | 40 ++++ .../entity-view/entity-view.component.spec.ts | 29 +++ .../entity-view/entity-view.component.ts | 104 ++++++++++ .../tasklist-view/tasklist-view.component.css | 6 + .../tasklist-view.component.html | 86 ++++++++ .../tasklist-view.component.spec.ts | 33 ++++ .../tasklist-view/tasklist-view.component.ts | 70 +++++++ .../main-pane/main-pane.component.ts | 18 +- .../entity-page/entity-page.component.css | 12 -- .../entity-page/entity-page.component.html | 146 +------------- .../home/entity-page/entity-page.component.ts | 187 ++---------------- .../src/app/pages/home/home.component.ts | 35 +--- client-v2/src/app/shared/entity-menu-items.ts | 46 +++++ 20 files changed, 647 insertions(+), 359 deletions(-) create mode 100644 client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css create mode 100644 client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html create mode 100644 client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts create mode 100644 client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts create mode 100644 client-v2/src/app/components/organisms/entity-view/entity-view.component.css create mode 100644 client-v2/src/app/components/organisms/entity-view/entity-view.component.html create mode 100644 client-v2/src/app/components/organisms/entity-view/entity-view.component.spec.ts create mode 100644 client-v2/src/app/components/organisms/entity-view/entity-view.component.ts create mode 100644 client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.css create mode 100644 client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html create mode 100644 client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts create mode 100644 client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts create mode 100644 client-v2/src/app/shared/entity-menu-items.ts diff --git a/client-v2/src/app/app.module.ts b/client-v2/src/app/app.module.ts index 9f3afe28..0b02c1bf 100644 --- a/client-v2/src/app/app.module.ts +++ b/client-v2/src/app/app.module.ts @@ -47,6 +47,9 @@ import { MainPaneComponent } from './components/templates/main-pane/main-pane.co import { MutationDirective } from './directives/mutation.directive' import { MenuToggleComponent } from './components/templates/sidebar-layout/menu-toggle/menu-toggle.component' import { DashboardComponent } from './pages/home/dashboard/dashboard.component' +import { EntityViewComponent } from './components/organisms/entity-view/entity-view.component' +import { TasklistViewComponent } from './components/organisms/entity-view/views/tasklist-view/tasklist-view.component' +import { EditableEntityNameComponent } from './components/molecules/editable-entity-heading/editable-entity-name.component' @NgModule({ declarations: [ @@ -82,6 +85,9 @@ import { DashboardComponent } from './pages/home/dashboard/dashboard.component' MutationDirective, MenuToggleComponent, DashboardComponent, + EntityViewComponent, + TasklistViewComponent, + EditableEntityNameComponent, ], imports: [ BrowserModule, diff --git a/client-v2/src/app/components/molecules/drop-down/drop-down.component.ts b/client-v2/src/app/components/molecules/drop-down/drop-down.component.ts index 1aefea67..9e342474 100644 --- a/client-v2/src/app/components/molecules/drop-down/drop-down.component.ts +++ b/client-v2/src/app/components/molecules/drop-down/drop-down.component.ts @@ -20,6 +20,14 @@ export enum MenuItemVariant { SUBMIT = 'menu-item--submit', } +export const useDataForAction = (data: unknown) => { + return ({ action, children, ...item }: MenuItem): MenuItem => ({ + ...item, + action: action ? (localData: unknown) => action(localData || data) : undefined, + children: children?.map(useDataForAction(data)), + }) +} + @Component({ selector: 'app-drop-down', templateUrl: './drop-down.component.html', diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css new file mode 100644 index 00000000..9608ec36 --- /dev/null +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css @@ -0,0 +1,4 @@ +.show-placeholder::before { + content: attr(data-placeholder); + @apply text-tinted-400; +} diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html new file mode 100644 index 00000000..3e3a2540 --- /dev/null +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html @@ -0,0 +1,20 @@ + + + + + + diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts new file mode 100644 index 00000000..1c7a2cff --- /dev/null +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { FocusableDirective } from 'src/app/directives/focusable.directive' +import { storeMock } from 'src/app/utils/unit-test.mocks' + +import { EditableEntityNameComponent } from './editable-entity-name.component' + +describe('EditableEntityHeadingComponent', () => { + let component: EditableEntityNameComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EditableEntityNameComponent, FocusableDirective], + providers: [storeMock], + }).compileComponents() + + fixture = TestBed.createComponent(EditableEntityNameComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts new file mode 100644 index 00000000..da184608 --- /dev/null +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts @@ -0,0 +1,131 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Input, ViewChild } from '@angular/core' +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { Store } from '@ngrx/store' +import { + BehaviorSubject, + debounceTime, + distinctUntilChanged, + filter, + first, + map, + merge, + shareReplay, + switchMap, + tap, +} from 'rxjs' +import { DEFAULT_TASKLIST_NAME, ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' +import { EntityPreviewRecursive, EntityType } from 'src/app/models/entities.model' +import { AppState } from 'src/app/store' +import { entitiesActions } from 'src/app/store/entities/entities.actions' + +@UntilDestroy() +@Component({ + selector: 'app-editable-entity-name', + templateUrl: './editable-entity-name.component.html', + styleUrls: ['./editable-entity-name.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditableEntityNameComponent { + constructor(private store: Store) {} + + EntityType = EntityType + ENTITY_NAME_DEFAULTS = ENTITY_NAME_DEFAULTS + + @ViewChild('editableEntityName') editableEntityName!: ElementRef + + @Input() set entity(activeEntity: EntityPreviewRecursive | undefined | null) { + this.entity$.next(activeEntity) + } + entity$ = new BehaviorSubject(null) + + entityName$ = this.entity$.pipe( + map(entity => { + if (Object.values(ENTITY_NAME_DEFAULTS).includes(entity?.name as string)) return '' + + return entity?.name + }), + distinctUntilChanged((prev, currEntityName) => { + if (prev === '' && currEntityName === '') return false + + return currEntityName == prev + }), + switchMap(entityName => { + return this.entityNameChanges$.pipe( + first(), + filter(newEntityName => { + if (!newEntityName) return true + + return entityName != newEntityName + }), + map(() => entityName) + ) + }), + tap(entityName => { + /* This is necessary in case of updating the entityname from empty to also empty. + Because apparently, angular does not do the update, which is bad when the entityname was edited before, + meaning, the edited entityname won't be overwritten. So we have to do that manually. + + We could narrow this down even further with comparing to the previous entityname (`pairwise()` operator), + and only update if both are empty, but this should suffice for now. */ + if (entityName === '' && this.editableEntityName?.nativeElement) { + this.editableEntityName.nativeElement.innerText = '' + } + }), + shareReplay({ bufferSize: 1, refCount: true }) + ) + + keydownEvents$ = new BehaviorSubject(null) + blurEvents$ = new BehaviorSubject(null) + entityNameChanges$ = new BehaviorSubject(null) + + entityNameDomState$ = merge( + this.entityNameChanges$, + this.entityName$.pipe( + tap(() => { + if (this.entityNameChanges$.value !== null) this.entityNameChanges$.next(null) + }) + ) + ).pipe(shareReplay({ bufferSize: 1, refCount: true })) + + entityNameUpdateEvents$ = merge( + this.keydownEvents$.pipe( + filter(event => { + if (event?.code == 'Enter') { + event.preventDefault() + return true + } + return false + }), + switchMap(() => this.entityNameChanges$.pipe(first())) + ), + this.blurEvents$.pipe( + filter(e => !!e), + switchMap(() => this.entityNameChanges$.pipe(first())) + ), + this.entityNameChanges$.pipe(debounceTime(600)) + ).pipe( + map(newEntityName => { + if (newEntityName === null) return null + + return newEntityName || DEFAULT_TASKLIST_NAME + }), + shareReplay({ bufferSize: 1, refCount: true }) + ) + + entityNameUpdatesSubscription = this.entityNameUpdateEvents$ + .pipe( + distinctUntilChanged(), + switchMap(newName => { + return this.entity$.pipe( + first(), + tap(entity => { + if (!entity || !newName) return + + return this.store.dispatch(entitiesActions.rename({ id: entity.id, newName })) + }) + ) + }), + untilDestroyed(this) + ) + .subscribe() +} diff --git a/client-v2/src/app/components/organisms/entity-view/entity-view.component.css b/client-v2/src/app/components/organisms/entity-view/entity-view.component.css new file mode 100644 index 00000000..e69de29b diff --git a/client-v2/src/app/components/organisms/entity-view/entity-view.component.html b/client-v2/src/app/components/organisms/entity-view/entity-view.component.html new file mode 100644 index 00000000..bc6dcd33 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/entity-view.component.html @@ -0,0 +1,40 @@ + + +
+ +
+ +
+ +
+ + + + + +
+ + +
+ + +
+ + +
+
diff --git a/client-v2/src/app/components/organisms/entity-view/entity-view.component.spec.ts b/client-v2/src/app/components/organisms/entity-view/entity-view.component.spec.ts new file mode 100644 index 00000000..3bbc2cf8 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/entity-view.component.spec.ts @@ -0,0 +1,29 @@ +import { CdkMenuModule } from '@angular/cdk/menu' +import { AsyncPipe } from '@angular/common' +import { Injector } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { storeMock } from 'src/app/utils/unit-test.mocks' + +import { EntityViewComponent } from './entity-view.component' +import { TasklistViewComponent } from './views/tasklist-view/tasklist-view.component' + +describe('EntityViewComponent', () => { + let component: EntityViewComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [EntityViewComponent, TasklistViewComponent], + providers: [Injector, storeMock], + imports: [CdkMenuModule, AsyncPipe], + }).compileComponents() + + fixture = TestBed.createComponent(EntityViewComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts b/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts new file mode 100644 index 00000000..66348cca --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts @@ -0,0 +1,104 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + InjectionToken, + Injector, + Input, + Type, + ViewChild, +} from '@angular/core' +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { + BehaviorSubject, + combineLatest, + combineLatestWith, + distinctUntilChanged, + map, + Observable, + shareReplay, + tap, +} from 'rxjs' +import { EntityPreviewRecursive, EntityType } from 'src/app/models/entities.model' +import { EntityMenuItemsMap } from '../../../shared/entity-menu-items' +import { MenuItem } from '../../molecules/drop-down/drop-down.component' +import { TasklistViewComponent } from './views/tasklist-view/tasklist-view.component' + +const entityViewComponentMap: Record> = { + [EntityType.TASKLIST]: TasklistViewComponent, +} + +export const ENTITY_VIEW_DATA = new InjectionToken('app.entity.view-data') +export type EntityViewData = Observable<{ + entity: EntityPreviewRecursive | null | undefined + options: MenuItem[] | null | undefined +}> + +@UntilDestroy() +@Component({ + selector: 'app-entity-view', + templateUrl: './entity-view.component.html', + styleUrls: ['./entity-view.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EntityViewComponent { + constructor(private injector: Injector) {} + + @Input() set entity(activeEntity: EntityPreviewRecursive | undefined | null) { + this.entity$.next(activeEntity) + } + entity$ = new BehaviorSubject(null) + + @Input() set entityOptionsMap(menuItems: EntityMenuItemsMap | undefined | null) { + this.entityOptionsMap$.next(menuItems) + } + entityOptionsMap$ = new BehaviorSubject(null) + entityOptionsItems$ = this.entity$.pipe( + distinctUntilChanged((previous, current) => previous?.id == current?.id), + combineLatestWith(this.entityOptionsMap$), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + map(([entity, optionsMap]) => { + // @TODO: Remove hardcoded value + const entityType = EntityType.TASKLIST + return optionsMap?.[entityType] + }), + shareReplay({ bufferSize: 1, refCount: true }) + ) + + entityViewComponent$ = this.entity$.pipe( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + distinctUntilChanged((previous, current) => { + // @TODO: Remove hardcoded value + const entityType = EntityType.TASKLIST + return entityType == entityType + }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + map(entity => { + // @TODO: Remove hardcoded value + const entityType = EntityType.TASKLIST + return entityViewComponentMap[entityType] + }) + ) + entityViewData$: EntityViewData = combineLatest([this.entity$, this.entityOptionsMap$]).pipe( + map(([entity, optionsMap]) => { + // @TODO: Remove hardcoded value + const entityType = EntityType.TASKLIST + return { entity, options: optionsMap?.[entityType] } + }) + ) + entityViewInjector = Injector.create({ + providers: [{ provide: ENTITY_VIEW_DATA, useValue: this.entityViewData$ as EntityViewData }], + parent: this.injector, + }) + + @ViewChild('top') topElement!: ElementRef + entitySubscription = this.entity$ + .pipe( + distinctUntilChanged((previous, current) => previous?.id == current?.id), + tap(() => this.topElement?.nativeElement?.scrollIntoView({ behavior: 'smooth' })), // lets see how the 'smooth' behaviour feels after a while + untilDestroyed(this) + ) + .subscribe() + + progress$ = new BehaviorSubject(null) +} diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.css b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.css new file mode 100644 index 00000000..f18a1d66 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.css @@ -0,0 +1,6 @@ +.progress-container:is(:hover, .glow) .progress :first-child { + box-shadow: 0 0 10px theme('colors.submit.400'); +} +.progress-container.glow span { + @apply text-submit-400; +} diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html new file mode 100644 index 00000000..f727b916 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html @@ -0,0 +1,86 @@ +
+ + + + + + + + +

+ +

+ +
+

And this is the part for the description

+

Where you can

+
    +
  • Describe the list/task in further detail
  • +
  • Take notes for yourself
  • +
  • Quickly write down / sketch out ideas
  • +
+
+ +
+ + +
+ +
+ + +

+ No lists + inside +

+
+
+
+ +
+
+
+
+ +
+ + diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts new file mode 100644 index 00000000..7144cb22 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts @@ -0,0 +1,33 @@ +import { AsyncPipe } from '@angular/common' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { BehaviorSubject } from 'rxjs' +import { storeMock } from 'src/app/utils/unit-test.mocks' +import { EntityViewComponent, ENTITY_VIEW_DATA } from '../../entity-view.component' + +import { TasklistViewComponent } from './tasklist-view.component' + +describe('TasklistViewComponent', () => { + let component: TasklistViewComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TasklistViewComponent], + providers: [ + { provide: ENTITY_VIEW_DATA, useValue: new BehaviorSubject(null) }, + storeMock, + // eslint-disable-next-line @typescript-eslint/no-empty-function + { provide: EntityViewComponent, useValue: { progress$: { next() {} } } }, + ], + imports: [AsyncPipe], + }).compileComponents() + + fixture = TestBed.createComponent(TasklistViewComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) +}) diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts new file mode 100644 index 00000000..318ea9a8 --- /dev/null +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts @@ -0,0 +1,70 @@ +import { ChangeDetectionStrategy, Component, ElementRef, Inject, ViewChild } from '@angular/core' +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { Store } from '@ngrx/store' +import { BehaviorSubject, combineLatest, of } from 'rxjs' +import { first, map, tap } from 'rxjs/operators' +import { DEFAULT_TASKLIST_NAME } from 'src/app/models/defaults' +import { EntityType } from 'src/app/models/entities.model' +import { AppState } from 'src/app/store' +import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' +import { EntityViewComponent, EntityViewData, ENTITY_VIEW_DATA } from '../../entity-view.component' + +@UntilDestroy() +@Component({ + selector: 'app-tasklist-view', + templateUrl: './tasklist-view.component.html', + styleUrls: ['./tasklist-view.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TasklistViewComponent { + constructor( + @Inject(ENTITY_VIEW_DATA) private viewData$: EntityViewData, + private store: Store, + private entityView: EntityViewComponent + ) {} + + EntityType = EntityType + DEFAULT_TASKLIST_NAME = DEFAULT_TASKLIST_NAME + + entity$ = this.viewData$.pipe(map(viewData => viewData.entity)) + options$ = this.viewData$.pipe(map(viewData => viewData.options)) + + closedTasks = 16 + allTasks = 37 + progress$ = of(Math.round((this.closedTasks / this.allTasks) * 100)) + isShownAsPercentage = true + + isProgressBarHidden$ = new BehaviorSubject(false) + progressOutputSubscription = combineLatest([this.progress$, this.isProgressBarHidden$]) + .pipe( + map(([progress, isProgressBarHidden]) => { + if (!isProgressBarHidden) return null + + return progress + }), + untilDestroyed(this) + ) + .subscribe(progress => this.entityView.progress$.next(progress)) + + @ViewChild('progressBar') progressBar!: ElementRef + progressBarObserver = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting) this.isProgressBarHidden$.next(false) + else this.isProgressBarHidden$.next(true) + }, + { threshold: [0.5] } + ) + ngAfterViewInit(): void { + this.progressBarObserver.observe(this.progressBar.nativeElement) + } + ngOnDestroy(): void { + this.progressBarObserver.disconnect() + } + + createNewSublist() { + this.entity$.pipe(first()).subscribe(entity => { + if (!entity) return + this.store.dispatch(listActions.createTaskList({ parentListId: entity.id })) + }) + } +} diff --git a/client-v2/src/app/components/templates/main-pane/main-pane.component.ts b/client-v2/src/app/components/templates/main-pane/main-pane.component.ts index 124a0868..a4748316 100644 --- a/client-v2/src/app/components/templates/main-pane/main-pane.component.ts +++ b/client-v2/src/app/components/templates/main-pane/main-pane.component.ts @@ -1,4 +1,13 @@ -import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core' +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnDestroy, + ViewChild, +} from '@angular/core' @Component({ selector: 'app-main-pane', @@ -19,8 +28,10 @@ import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild } fro } `, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class MainPaneComponent implements AfterViewInit, OnDestroy { + constructor(private changeDetector: ChangeDetectorRef) {} // @TODO: Make the prose width adjustable with a drag, or have a couple presets @Input() prose = true @@ -30,7 +41,10 @@ export class MainPaneComponent implements AfterViewInit, OnDestroy { observer = new IntersectionObserver( entries => { entries.forEach(entry => { - if (entry.target == this.scrollSpy.nativeElement) this.isScrolled = !entry.isIntersecting + if (entry.target == this.scrollSpy.nativeElement) { + this.isScrolled = !entry.isIntersecting + this.changeDetector.markForCheck() + } }) }, { threshold: [1] } diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.css b/client-v2/src/app/pages/home/entity-page/entity-page.component.css index 212bf35b..1a301c3d 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.css +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.css @@ -1,15 +1,3 @@ :host { @apply block w-full; } - -.progress-container:is(:hover, .glow) .progress :first-child { - box-shadow: 0 0 10px theme('colors.submit.400'); -} -.progress-container.glow span { - @apply text-submit-400; -} - -.show-placeholder::before { - content: attr(data-placeholder); - @apply text-tinted-400; -} diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.html b/client-v2/src/app/pages/home/entity-page/entity-page.component.html index 8ed9db62..3a7630bd 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.html +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.html @@ -1,145 +1,9 @@ - - - - - -
- + + - -
- - - - - -
- -
- -
-
- - - - - - - - -

- - - - - -

- -
-

And this is the part for the description

-

Where you can

-
    -
  • Describe the list/task in further detail
  • -
  • Take notes for yourself
  • -
  • Quickly write down / sketch out ideas
  • -
-
- -
- - -
- -
- - -

- No - lists inside -

-
-
-
- -
-
-
-
- -
- - + + -
+ diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts index d33a7593..22839806 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.ts +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.ts @@ -1,27 +1,13 @@ -import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { Component } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { UntilDestroy } from '@ngneat/until-destroy' import { Store } from '@ngrx/store' -import { - BehaviorSubject, - debounceTime, - distinctUntilChanged, - filter, - first, - map, - merge, - of, - shareReplay, - switchMap, - tap, -} from 'rxjs' +import { map, of, shareReplay, switchMap } from 'rxjs' import { Breadcrumb } from 'src/app/components/molecules/breadcrumbs/breadcrumbs.component' -import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' -import { DEFAULT_TASKLIST_NAME, ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' +import { useDataForAction } from 'src/app/components/molecules/drop-down/drop-down.component' import { EntityType } from 'src/app/models/entities.model' -import { TaskStatus } from 'src/app/models/task.model' +import { getEntityMenuItemsMap } from 'src/app/shared/entity-menu-items' import { AppState } from 'src/app/store' -import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' import { traceEntity } from 'src/app/store/entities/utils' @UntilDestroy() @@ -30,62 +16,14 @@ import { traceEntity } from 'src/app/store/entities/utils' templateUrl: './entity-page.component.html', styleUrls: ['./entity-page.component.css'], }) -export class EntityPageComponent implements AfterViewInit, OnDestroy { - constructor(private store: Store, private route: ActivatedRoute, private router: Router) {} +export class EntityPageComponent { + constructor(private store: Store, private route: ActivatedRoute) {} - TaskStatus = TaskStatus - EntityType = EntityType - DEFAULT_TASKLIST_NAME = DEFAULT_TASKLIST_NAME + entityOptionsMap = getEntityMenuItemsMap(this.store) + entityOptionsMap$ = of(this.entityOptionsMap) - @ViewChild('editableEntityName') editableEntityName!: ElementRef - @ViewChild('top') topElement!: ElementRef - - isPrimaryProgressBarHidden = false - @ViewChild('progressBar') progressBar!: ElementRef - progressBarObserver = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) this.isPrimaryProgressBarHidden = false - else this.isPrimaryProgressBarHidden = true - }, - { threshold: [0.5] } - ) - - ngAfterViewInit(): void { - this.progressBarObserver.observe(this.progressBar.nativeElement) - } - ngOnDestroy(): void { - this.progressBarObserver.disconnect() - } - - closedTasks = 16 - allTasks = 37 - progress = Math.round((this.closedTasks / this.allTasks) * 100) - isShownAsPercentage = true - - entityOptionsItems: MenuItem[] = [ - { - title: `Rename`, - action: (id: string) => this.store.dispatch(entitiesActions.openRenameDialog({ id })), - }, - { - title: `Export`, - action: (id: string) => this.store.dispatch(listActions.exportList({ id })), - }, - { isSeperator: true }, - { - title: `Delete`, - variant: MenuItemVariant.DANGER, - action: (id: string) => this.store.dispatch(entitiesActions.openDeleteDialog({ id })), - }, - ] - entityOptionsItems$ = of(this.entityOptionsItems) - - activeEntityId$ = this.route.paramMap.pipe( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - map(paramMap => paramMap.get('id')!), - tap(() => this.topElement?.nativeElement?.scrollIntoView({ behavior: 'smooth' })), // lets see how the 'smooth' behaviour feels after a while - shareReplay({ bufferSize: 1, refCount: true }) - ) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + activeEntityId$ = this.route.paramMap.pipe(map(paramMap => paramMap.get('id')!)) activeEntityTrace$ = this.activeEntityId$.pipe( switchMap(activeId => { @@ -103,38 +41,6 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { ) activeEntity$ = this.activeEntityTrace$.pipe(map(trace => trace?.[trace.length - 1])) - activeEntityName$ = this.activeEntity$.pipe( - map(entity => entity?.name), - distinctUntilChanged(), - switchMap(entityName => { - return this.entityNameChanges$.pipe( - first(), - filter(newEntityName => { - if (!newEntityName) return true - - return entityName != newEntityName - }), - map(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (Object.values(ENTITY_NAME_DEFAULTS).includes(entityName!)) return '' - - return entityName - }) - ) - }), - tap(entityName => { - /* This is necessary in case of updating the entityname from empty to also empty. - Because apparently, angular does not do the update, which is bad when the entityname was edited before, - meaning, the edited entityname won't be overwritten. So we have to do that manually. - - We could narrow this down even further with comparing to the previous entityname (`pairwise()` operator), - and only update if both are empty, but this should suffice for now. */ - if (entityName === '' && this.editableEntityName?.nativeElement) { - this.editableEntityName.nativeElement.innerText = '' - } - }), - shareReplay({ bufferSize: 1, refCount: true }) - ) breadcrumbs$ = this.activeEntityTrace$.pipe( map(trace => @@ -142,74 +48,9 @@ export class EntityPageComponent implements AfterViewInit, OnDestroy { title: entity.name, icon: EntityType.TASKLIST, // @TODO: Remove hardcoded value route: `/home/${entity.id}`, - contextMenuItems: this.entityOptionsItems.map(({ action, ...item }) => { - return { - ...item, - action: action ? () => action(entity.id) : undefined, - } - }), + // @TODO: Remove hardcoded value + contextMenuItems: this.entityOptionsMap[EntityType.TASKLIST].map(useDataForAction(entity.id)), })) ) ) - - keydownEvents$ = new BehaviorSubject(null) - blurEvents$ = new BehaviorSubject(null) - entityNameChanges$ = new BehaviorSubject(null) - - entityNameDomState$ = merge( - this.entityNameChanges$, - this.activeEntityName$.pipe( - tap(() => { - if (this.entityNameChanges$.value !== null) this.entityNameChanges$.next(null) - }) - ) - ).pipe(shareReplay({ bufferSize: 1, refCount: true })) - - entityNameUpdateEvents$ = merge( - this.keydownEvents$.pipe( - filter(event => { - if (event?.code == 'Enter') { - event.preventDefault() - return true - } - return false - }), - switchMap(() => this.entityNameChanges$.pipe(first())) - ), - this.blurEvents$.pipe( - filter(e => !!e), - switchMap(() => this.entityNameChanges$.pipe(first())) - ), - this.entityNameChanges$.pipe(debounceTime(600)) - ).pipe( - map(newEntityName => { - if (newEntityName === null) return null - - return newEntityName || DEFAULT_TASKLIST_NAME - }), - shareReplay({ bufferSize: 1, refCount: true }) - ) - - entityNameUpdatesSubscription = this.entityNameUpdateEvents$ - .pipe( - distinctUntilChanged(), - switchMap(newName => { - return this.activeEntity$.pipe( - first(), - tap(activeEntity => { - if (!activeEntity || !newName) return - - return this.store.dispatch(entitiesActions.rename({ id: activeEntity.id, newName })) - }) - ) - }), - untilDestroyed(this) - ) - .subscribe() - - createNewSublist() { - this.activeEntityId$.pipe(first()).subscribe(activeId => { - this.store.dispatch(listActions.createTaskList({ parentListId: activeId })) - }) - } } diff --git a/client-v2/src/app/pages/home/home.component.ts b/client-v2/src/app/pages/home/home.component.ts index 4a51fd88..64049563 100644 --- a/client-v2/src/app/pages/home/home.component.ts +++ b/client-v2/src/app/pages/home/home.component.ts @@ -5,8 +5,8 @@ import { ActivatedRoute } from '@angular/router' import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' import { map, startWith, switchMap, tap } from 'rxjs' -import { MenuItem, MenuItemVariant } from 'src/app/components/molecules/drop-down/drop-down.component' -import { EntityType, EntityPreviewFlattend } from 'src/app/models/entities.model' +import { EntityPreviewFlattend, EntityType } from 'src/app/models/entities.model' +import { getEntityMenuItemsMap } from 'src/app/shared/entity-menu-items' import { AppState } from 'src/app/store' import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' import { flattenEntityTree, traceEntity } from 'src/app/store/entities/utils' @@ -123,34 +123,7 @@ export class HomeComponent implements OnInit { createNewList(parentListId?: string) { this.store.dispatch(listActions.createTaskList({ parentListId })) } - duplicateList(id: string) { - this.store.dispatch(listActions.duplicateList({ id })) - } - renameEntity(id: string) { - this.store.dispatch(entitiesActions.openRenameDialog({ id })) - } - deleteEntity(id: string) { - this.store.dispatch(entitiesActions.openDeleteDialog({ id })) - } - nodeMenuItems: MenuItem[] = [ - { - title: 'Rename', - action: (id: string) => this.renameEntity(id), - }, - { - title: 'Create new list inside', - action: (id: string) => this.createNewList(id), - }, - { - title: 'Duplicate', - action: (id: string) => this.duplicateList(id), - }, - { isSeperator: true }, - { - title: 'Delete', - variant: MenuItemVariant.DANGER, - action: (id: string) => this.deleteEntity(id), - }, - ] + // @TODO: Remove hardcoded value + nodeMenuItems = getEntityMenuItemsMap(this.store)[EntityType.TASKLIST] } diff --git a/client-v2/src/app/shared/entity-menu-items.ts b/client-v2/src/app/shared/entity-menu-items.ts new file mode 100644 index 00000000..0c93b96f --- /dev/null +++ b/client-v2/src/app/shared/entity-menu-items.ts @@ -0,0 +1,46 @@ +import { Store } from '@ngrx/store' +import { MenuItem, MenuItemVariant } from '../components/molecules/drop-down/drop-down.component' +import { EntityType } from '../models/entities.model' +import { AppState } from '../store' +import { entitiesActions, listActions } from '../store/entities/entities.actions' + +export type EntityMenuItemsMap = Record + +export const getGeneralMenuItems = (store: Store): MenuItem[] => [ + { + title: `Rename`, + action: (id: string) => store.dispatch(entitiesActions.openRenameDialog({ id })), + }, +] +export const getDangerMenuItems = (store: Store): MenuItem[] => [ + { isSeperator: true }, + { + title: `Delete`, + variant: MenuItemVariant.DANGER, + action: (id: string) => store.dispatch(entitiesActions.openDeleteDialog({ id })), + }, +] + +export const getEntityMenuItemsMap = (store: Store): EntityMenuItemsMap => ({ + [EntityType.TASKLIST]: [ + ...getGeneralMenuItems(store), + { + title: 'New inside', + children: [ + { + title: 'Tasklist', + action: (id: string) => store.dispatch(listActions.createTaskList({ parentListId: id })), + }, + ], + }, + { + title: 'Duplicate', + action: (id: string) => store.dispatch(listActions.duplicateList({ id })), + }, + { + title: `Export`, + action: (id: string) => store.dispatch(listActions.exportList({ id })), + }, + ...getDangerMenuItems(store), + ], +}) From ad0ca3bab290e9de6142a74cfcc8459b0686e465 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sat, 31 Dec 2022 00:14:55 +0100 Subject: [PATCH 43/60] Add loading state to editable-entity-name, fix buggy auto updates while another update is pending --- .../editable-entity-name.component.html | 45 ++++++++------- .../editable-entity-name.component.ts | 57 ++++++++++++++++--- .../app/store/entities/entities.actions.ts | 6 +- .../app/store/entities/entities.effects.ts | 6 +- client-v2/src/app/utils/store.helpers.ts | 41 +++++++++---- 5 files changed, 112 insertions(+), 43 deletions(-) diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html index 3e3a2540..940667fb 100644 --- a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.html @@ -1,20 +1,27 @@ - - - +
+ + + + + - - + + +
diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts index da184608..cd1fcb4b 100644 --- a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts @@ -1,9 +1,12 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, ViewChild } from '@angular/core' import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { Actions } from '@ngrx/effects' import { Store } from '@ngrx/store' import { BehaviorSubject, + combineLatest, debounceTime, + delay, distinctUntilChanged, filter, first, @@ -13,10 +16,12 @@ import { switchMap, tap, } from 'rxjs' -import { DEFAULT_TASKLIST_NAME, ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' +import { ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' import { EntityPreviewRecursive, EntityType } from 'src/app/models/entities.model' import { AppState } from 'src/app/store' import { entitiesActions } from 'src/app/store/entities/entities.actions' +import { getLoadingUpdates } from 'src/app/utils/store.helpers' +import { PageEntityState } from '../../atoms/icons/page-entity-icon/page-entity-icon.component' @UntilDestroy() @Component({ @@ -26,9 +31,10 @@ import { entitiesActions } from 'src/app/store/entities/entities.actions' changeDetection: ChangeDetectionStrategy.OnPush, }) export class EditableEntityNameComponent { - constructor(private store: Store) {} + constructor(private store: Store, private actions$: Actions) {} EntityType = EntityType + PageEntityState = PageEntityState ENTITY_NAME_DEFAULTS = ENTITY_NAME_DEFAULTS @ViewChild('editableEntityName') editableEntityName!: ElementRef @@ -39,8 +45,9 @@ export class EditableEntityNameComponent { entity$ = new BehaviorSubject(null) entityName$ = this.entity$.pipe( + filter(entity => entity?.name != this.lastSentStoreUpdate), map(entity => { - if (Object.values(ENTITY_NAME_DEFAULTS).includes(entity?.name as string)) return '' + if (entity?.name == ENTITY_NAME_DEFAULTS[EntityType.TASKLIST]) return '' // @TODO: Remove hardcoded value return entity?.name }), @@ -107,7 +114,7 @@ export class EditableEntityNameComponent { map(newEntityName => { if (newEntityName === null) return null - return newEntityName || DEFAULT_TASKLIST_NAME + return newEntityName || ENTITY_NAME_DEFAULTS[EntityType.TASKLIST] // @TODO: Remove hardcoded value }), shareReplay({ bufferSize: 1, refCount: true }) ) @@ -116,16 +123,52 @@ export class EditableEntityNameComponent { .pipe( distinctUntilChanged(), switchMap(newName => { - return this.entity$.pipe( + return combineLatest([this.entity$, this.isLoading$]).pipe( first(), - tap(entity => { + tap(([entity, isLoading]) => { if (!entity || !newName) return - return this.store.dispatch(entitiesActions.rename({ id: entity.id, newName })) + const action = entitiesActions.rename({ id: entity.id, newName }) + if (isLoading) { + this.updateQueue$.next(action) + return + } + + this.lastSentStoreUpdate = newName + this.store.dispatch(action) }) ) }), untilDestroyed(this) ) .subscribe() + + isLoading$ = getLoadingUpdates( + this.actions$, + [entitiesActions.rename, entitiesActions.renameSuccess, entitiesActions.renameError], + action => + this.entity$.pipe( + first(), + map(entity => entity?.id == action.id) + ) + ) + + lastSentStoreUpdate: string | null = null + + updateQueue$ = new BehaviorSubject | null>(null) + queueSubscription = this.isLoading$ + .pipe( + filter(isLoading => !isLoading), + switchMap(() => this.updateQueue$.pipe(first())), + delay(0), // move to macro queue + map(queuedAction => { + if (queuedAction === null) return + + this.lastSentStoreUpdate = queuedAction.newName + this.store.dispatch(queuedAction) + this.updateQueue$.next(null) + }), + untilDestroyed(this) + ) + .subscribe() } diff --git a/client-v2/src/app/store/entities/entities.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts index d967d699..86b3be58 100644 --- a/client-v2/src/app/store/entities/entities.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -2,6 +2,8 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store' import { HttpServerErrorResponse } from 'src/app/http/types' import { TasklistPreview, CreateTasklistDto, TaskList } from 'src/app/models/list.model' +export type HttpServerErrorResponseWithData = HttpServerErrorResponse & T + export const entitiesActions = createActionGroup({ source: 'Entities', events: { @@ -14,14 +16,14 @@ export const entitiesActions = createActionGroup({ // rename: props<{ id: string; newName: string }>(), 'rename success': props<{ id: string; newName: string }>(), - 'rename error': props(), + 'rename error': props(), 'open delete dialog': props<{ id: string }>(), 'abort delete dialog': emptyProps(), // delete: props<{ id: string }>(), 'delete success': props<{ id: string }>(), - 'delete error': props(), + 'delete error': props(), }, }) diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts index 0c86e2e3..7e195ac0 100644 --- a/client-v2/src/app/store/entities/entities.effects.ts +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -90,8 +90,8 @@ export class EntitiesEffects { return res$.pipe( map(() => entitiesActions.renameSuccess({ id, newName })), catchError(err => { - this.toast.error(getMessageFromHttpError(err)) - return of(entitiesActions.renameError(err)) + + return of(entitiesActions.renameError({ ...err, id })) }) ) }) @@ -160,7 +160,7 @@ export class EntitiesEffects { map(() => entitiesActions.deleteSuccess({ id })) ) }), - catchError(err => of(entitiesActions.deleteError(err))) + catchError(err => of(entitiesActions.deleteError({ ...err, id }))) ) }) ) diff --git a/client-v2/src/app/utils/store.helpers.ts b/client-v2/src/app/utils/store.helpers.ts index b4efa281..316f1bc8 100644 --- a/client-v2/src/app/utils/store.helpers.ts +++ b/client-v2/src/app/utils/store.helpers.ts @@ -1,6 +1,6 @@ import { Actions, ofType } from '@ngrx/effects' import { Action, ActionCreator, Creator } from '@ngrx/store' -import { map, merge, of } from 'rxjs' +import { concatMap, filter, map, merge, Observable, of, shareReplay, startWith } from 'rxjs' import { HttpServerErrorResponse } from '../http/types' export const getMessageFromHttpError = (err: HttpServerErrorResponse) => @@ -69,16 +69,33 @@ export const getErrorMapUpdates = ({ actions$, fields, resetAction, errorAction * | ---------------------|---------------| * | `/error/i` | `false` | * | `/success/i` | `false` | - * | all other cases | `true` | + * | all other cases | `true` | */ -export const getLoadingUpdates = (actions$: Actions, actionsToListenFor: AnyActionCreator[]) => - merge( - of(false), - actions$.pipe( - ofType(...actionsToListenFor), - map(({ type }) => { - if (/success|error/i.test(type)) return false - else return true - }) - ) +export const getLoadingUpdates = ( + actions$: Actions, + actionsToListenFor: T[], + filterPredicate?: (action: ReturnType) => boolean | Observable +) => + actions$.pipe( + ofType(...actionsToListenFor), + concatMap(action => { + if (!filterPredicate) return of(action) + + const predicateResult = filterPredicate(action) + + let shouldPass$: Observable + if (typeof predicateResult == 'boolean') shouldPass$ = of(predicateResult) + else shouldPass$ = predicateResult + + return shouldPass$.pipe( + filter(shouldPass => shouldPass), + map(() => action) + ) + }), + map(({ type }) => { + if (/success|error/i.test(type)) return false + else return true + }), + startWith(false), + shareReplay({ bufferSize: 1, refCount: true }) ) From cb42dd88ca86483704d94c9eb80226d682ec80f6 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sat, 31 Dec 2022 00:26:03 +0100 Subject: [PATCH 44/60] Add loading toast as opt-in back into the rename effect --- client-v2/src/app/store/entities/entities.actions.ts | 2 +- client-v2/src/app/store/entities/entities.effects.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/client-v2/src/app/store/entities/entities.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts index 86b3be58..ecd86791 100644 --- a/client-v2/src/app/store/entities/entities.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -14,7 +14,7 @@ export const entitiesActions = createActionGroup({ 'open rename dialog': props<{ id: string }>(), 'abort rename dialog': emptyProps(), // - rename: props<{ id: string; newName: string }>(), + rename: props<{ id: string; newName: string; showToast?: boolean }>(), 'rename success': props<{ id: string; newName: string }>(), 'rename error': props(), diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts index 7e195ac0..4f93bc87 100644 --- a/client-v2/src/app/store/entities/entities.effects.ts +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -73,7 +73,7 @@ export class EntitiesEffects { const newName = prompt(`Rename the ${entityType}`, entity.name)?.trim() if (!newName) return entitiesActions.abortRenameDialog() - return entitiesActions.rename({ id, newName }) + return entitiesActions.rename({ id, newName, showToast: true }) }) ) }) @@ -83,13 +83,21 @@ export class EntitiesEffects { rename = createEffect(() => { return this.actions$.pipe( ofType(entitiesActions.rename), - mergeMap(({ id, newName }) => { + mergeMap(({ id, newName, showToast }) => { const entityType = EntityType.TASKLIST // @TODO: remove hardcoded value const res$ = this.entitiesService.rename({ entityType, id, newName }) return res$.pipe( + showToast + ? this.toast.observe({ + loading: `Renaming ${entityType}...`, + success: `Renamed ${entityType}`, + error: getMessageFromHttpError, + }) + : tap(), map(() => entitiesActions.renameSuccess({ id, newName })), catchError(err => { + if (!showToast) this.toast.error(getMessageFromHttpError(err)) return of(entitiesActions.renameError({ ...err, id })) }) From afd60472531cb844b930ae0c30c56cbc02761384 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sat, 31 Dec 2022 00:48:37 +0100 Subject: [PATCH 45/60] Fix bug introduced by prev commit --- .../editable-entity-heading/editable-entity-name.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts index cd1fcb4b..e854ab40 100644 --- a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.ts @@ -46,6 +46,7 @@ export class EditableEntityNameComponent { entityName$ = this.entity$.pipe( filter(entity => entity?.name != this.lastSentStoreUpdate), + tap(() => (this.lastSentStoreUpdate = null)), map(entity => { if (entity?.name == ENTITY_NAME_DEFAULTS[EntityType.TASKLIST]) return '' // @TODO: Remove hardcoded value From 42d55b748ab636d1436bd6e221b1c93e99033bc0 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sat, 31 Dec 2022 00:50:09 +0100 Subject: [PATCH 46/60] Fix unit tests --- .../editable-entity-name.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts index 1c7a2cff..f82f163b 100644 --- a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.spec.ts @@ -1,6 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { FocusableDirective } from 'src/app/directives/focusable.directive' -import { storeMock } from 'src/app/utils/unit-test.mocks' +import { actionsMock, storeMock } from 'src/app/utils/unit-test.mocks' import { EditableEntityNameComponent } from './editable-entity-name.component' @@ -11,7 +11,7 @@ describe('EditableEntityHeadingComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [EditableEntityNameComponent, FocusableDirective], - providers: [storeMock], + providers: [storeMock, actionsMock], }).compileComponents() fixture = TestBed.createComponent(EditableEntityNameComponent) From e85d7ab10f7dcf4fd5d2eede261da136deebed33 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 03:55:58 +0100 Subject: [PATCH 47/60] Integrate description feature --- .../editable-entity-name.component.css | 4 - .../entity-view/entity-view.component.ts | 43 ++++++--- .../tasklist-view.component.html | 95 +++++++++++-------- .../tasklist-view/tasklist-view.component.ts | 56 +++++++++-- client-v2/src/app/models/list.model.ts | 4 +- .../src/app/services/entities.service.ts | 5 + .../services/entity.services/list.service.ts | 4 + .../app/store/entities/entities.actions.ts | 10 ++ .../app/store/entities/entities.effects.ts | 30 +++++- .../app/store/entities/entities.reducer.ts | 70 ++++++++++---- .../src/app/store/entities/entities.state.ts | 5 +- .../src/app/store/entities/list.effects.ts | 17 ++++ client-v2/src/css/components.css | 5 + 13 files changed, 262 insertions(+), 86 deletions(-) diff --git a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css index 9608ec36..e69de29b 100644 --- a/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css +++ b/client-v2/src/app/components/molecules/editable-entity-heading/editable-entity-name.component.css @@ -1,4 +0,0 @@ -.show-placeholder::before { - content: attr(data-placeholder); - @apply text-tinted-400; -} diff --git a/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts b/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts index 66348cca..8805615a 100644 --- a/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts +++ b/client-v2/src/app/components/organisms/entity-view/entity-view.component.ts @@ -9,6 +9,7 @@ import { ViewChild, } from '@angular/core' import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' +import { Store } from '@ngrx/store' import { BehaviorSubject, combineLatest, @@ -20,6 +21,8 @@ import { tap, } from 'rxjs' import { EntityPreviewRecursive, EntityType } from 'src/app/models/entities.model' +import { AppState } from 'src/app/store' +import { entitiesActions } from 'src/app/store/entities/entities.actions' import { EntityMenuItemsMap } from '../../../shared/entity-menu-items' import { MenuItem } from '../../molecules/drop-down/drop-down.component' import { TasklistViewComponent } from './views/tasklist-view/tasklist-view.component' @@ -28,11 +31,13 @@ const entityViewComponentMap: Record> = { [EntityType.TASKLIST]: TasklistViewComponent, } -export const ENTITY_VIEW_DATA = new InjectionToken('app.entity.view-data') -export type EntityViewData = Observable<{ - entity: EntityPreviewRecursive | null | undefined - options: MenuItem[] | null | undefined -}> +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const ENTITY_VIEW_DATA = new InjectionToken>('app.entity.view-data') +export interface EntityViewData> { + entity$: Observable + detail$: Observable + options$: Observable +} @UntilDestroy() @Component({ @@ -42,12 +47,16 @@ export type EntityViewData = Observable<{ changeDetection: ChangeDetectionStrategy.OnPush, }) export class EntityViewComponent { - constructor(private injector: Injector) {} + constructor(private injector: Injector, private store: Store) {} @Input() set entity(activeEntity: EntityPreviewRecursive | undefined | null) { this.entity$.next(activeEntity) } entity$ = new BehaviorSubject(null) + entityDetail$ = combineLatest([this.entity$, this.store.select(state => state.entities)]).pipe( + map(([entity, entitiesState]) => entity && entitiesState[EntityType.TASKLIST]?.[entity.id]), // @TODO: Remove hardcoded value EntityType.TASKLIST + shareReplay({ bufferSize: 1, refCount: true }) + ) @Input() set entityOptionsMap(menuItems: EntityMenuItemsMap | undefined | null) { this.entityOptionsMap$.next(menuItems) @@ -79,15 +88,15 @@ export class EntityViewComponent { return entityViewComponentMap[entityType] }) ) - entityViewData$: EntityViewData = combineLatest([this.entity$, this.entityOptionsMap$]).pipe( - map(([entity, optionsMap]) => { - // @TODO: Remove hardcoded value - const entityType = EntityType.TASKLIST - return { entity, options: optionsMap?.[entityType] } - }) - ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + entityViewData: EntityViewData = { + entity$: this.entity$, + detail$: this.entityDetail$, + options$: this.entityOptionsItems$, + } entityViewInjector = Injector.create({ - providers: [{ provide: ENTITY_VIEW_DATA, useValue: this.entityViewData$ as EntityViewData }], + providers: [{ provide: ENTITY_VIEW_DATA, useValue: this.entityViewData }], parent: this.injector, }) @@ -96,6 +105,12 @@ export class EntityViewComponent { .pipe( distinctUntilChanged((previous, current) => previous?.id == current?.id), tap(() => this.topElement?.nativeElement?.scrollIntoView({ behavior: 'smooth' })), // lets see how the 'smooth' behaviour feels after a while + tap(entity => { + if (!entity) return + + const entityType = EntityType.TASKLIST // @TODO: Remove hardcoded value + this.store.dispatch(entitiesActions.loadDetail({ entityType, id: entity.id })) + }), untilDestroyed(this) ) .subscribe() diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html index f727b916..fc745012 100644 --- a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.html @@ -1,4 +1,4 @@ -
+
@@ -11,17 +11,45 @@

-
-

And this is the part for the description

-

Where you can

-
    -
  • Describe the list/task in further detail
  • -
  • Take notes for yourself
  • + + +
    + + +
    + -
    +
    -
    - -
    +
    +
    diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts index 318ea9a8..bbed823c 100644 --- a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts @@ -1,12 +1,13 @@ import { ChangeDetectionStrategy, Component, ElementRef, Inject, ViewChild } from '@angular/core' import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' import { Store } from '@ngrx/store' -import { BehaviorSubject, combineLatest, of } from 'rxjs' -import { first, map, tap } from 'rxjs/operators' +import { BehaviorSubject, combineLatest, merge, of } from 'rxjs' +import { distinctUntilChanged, first, map, shareReplay, switchMap, tap } from 'rxjs/operators' import { DEFAULT_TASKLIST_NAME } from 'src/app/models/defaults' import { EntityType } from 'src/app/models/entities.model' +import { TasklistDetail } from 'src/app/models/list.model' import { AppState } from 'src/app/store' -import { entitiesActions, listActions } from 'src/app/store/entities/entities.actions' +import { listActions } from 'src/app/store/entities/entities.actions' import { EntityViewComponent, EntityViewData, ENTITY_VIEW_DATA } from '../../entity-view.component' @UntilDestroy() @@ -18,7 +19,7 @@ import { EntityViewComponent, EntityViewData, ENTITY_VIEW_DATA } from '../../ent }) export class TasklistViewComponent { constructor( - @Inject(ENTITY_VIEW_DATA) private viewData$: EntityViewData, + @Inject(ENTITY_VIEW_DATA) private viewData: EntityViewData, private store: Store, private entityView: EntityViewComponent ) {} @@ -26,8 +27,51 @@ export class TasklistViewComponent { EntityType = EntityType DEFAULT_TASKLIST_NAME = DEFAULT_TASKLIST_NAME - entity$ = this.viewData$.pipe(map(viewData => viewData.entity)) - options$ = this.viewData$.pipe(map(viewData => viewData.options)) + entity$ = this.viewData.entity$ + detail$ = this.viewData.detail$ + options$ = this.viewData.options$ + + description$ = this.detail$.pipe( + map(detail => detail?.description), + distinctUntilChanged() + ) + isDescriptionShown$ = new BehaviorSubject(false) + + descriptionChanges$ = new BehaviorSubject(null) + blurEvents$ = new BehaviorSubject(null) + + descriptionSubscription = combineLatest([this.blurEvents$, this.description$]) + .pipe( + tap(([blurEvent, description]) => { + if (blurEvent) { + if (!description) this.isDescriptionShown$.next(false) + } + this.isDescriptionShown$.next(!!description) + }), + untilDestroyed(this) + ) + .subscribe() + + descriptionDomState$ = merge( + this.descriptionChanges$, + this.description$.pipe( + tap(() => { + if (this.descriptionChanges$.value !== null) this.descriptionChanges$.next(null) + }) + ) + ).pipe(shareReplay({ bufferSize: 1, refCount: true })) + + descriptionUpdatesSubscription = this.blurEvents$ + .pipe( + switchMap(() => combineLatest([this.descriptionChanges$, this.entity$]).pipe(first())), + untilDestroyed(this) + ) + .subscribe(([newDescription, entity]) => { + // @TODO: Throttled updates should only be sent to the server and not update the store yet. + // The store should only be updated when the editor is blurred. + if (!entity || newDescription === null) return + this.store.dispatch(listActions.updateDescription({ id: entity.id, newDescription })) + }) closedTasks = 16 allTasks = 37 diff --git a/client-v2/src/app/models/list.model.ts b/client-v2/src/app/models/list.model.ts index 319b329b..b5eb6ce4 100644 --- a/client-v2/src/app/models/list.model.ts +++ b/client-v2/src/app/models/list.model.ts @@ -1,7 +1,7 @@ export interface TaskList { id: string name: string - description: string + description: string | null createdAt: string ownerId: string @@ -13,6 +13,8 @@ export interface TaskList { export type TasklistPreview = Pick +export type TasklistDetail = Pick + export interface CreateTasklistDto { name: string description?: string diff --git a/client-v2/src/app/services/entities.service.ts b/client-v2/src/app/services/entities.service.ts index f4ed03f5..525486e7 100644 --- a/client-v2/src/app/services/entities.service.ts +++ b/client-v2/src/app/services/entities.service.ts @@ -13,6 +13,8 @@ export interface EntityService { update(id: string, dto: Record): Observable> delete(id: string): Observable + // eslint-disable-next-line @typescript-eslint/no-explicit-any + loadDetail(id: string): Observable> } // eslint-disable-next-line @typescript-eslint/no-explicit-any export type EntityServiceConstructor = new (...args: any[]) => EntityService @@ -56,6 +58,9 @@ export class EntitiesService { return this.http.get('/all-lists') } + loadDetail({ entityType, id }: EntityCrudDto) { + return this.injectEntityService(entityType).loadDetail(id) + } rename({ entityType, id, newName }: EntityCrudDto<{ newName: string }>) { return this.injectEntityService(entityType).update(id, { name: newName }) } diff --git a/client-v2/src/app/services/entity.services/list.service.ts b/client-v2/src/app/services/entity.services/list.service.ts index 6a08e3c5..f98ca73d 100644 --- a/client-v2/src/app/services/entity.services/list.service.ts +++ b/client-v2/src/app/services/entity.services/list.service.ts @@ -21,4 +21,8 @@ export class ListService implements EntityService { delete(id: string) { return this.http.delete('/list/' + id) } + + loadDetail(id: string) { + return this.http.get('/list/' + id) + } } diff --git a/client-v2/src/app/store/entities/entities.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts index ecd86791..afd6d3b3 100644 --- a/client-v2/src/app/store/entities/entities.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -1,6 +1,7 @@ import { createActionGroup, emptyProps, props } from '@ngrx/store' import { HttpServerErrorResponse } from 'src/app/http/types' import { TasklistPreview, CreateTasklistDto, TaskList } from 'src/app/models/list.model' +import { EntityCrudDto } from 'src/app/services/entities.service' export type HttpServerErrorResponseWithData = HttpServerErrorResponse & T @@ -11,6 +12,11 @@ export const entitiesActions = createActionGroup({ 'load previews success': props<{ previews: TasklistPreview[] }>(), 'load previews error': props(), + 'load detail': props(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'load detail success': props }>>(), + 'load detail error': props(), + 'open rename dialog': props<{ id: string }>(), 'abort rename dialog': emptyProps(), // @@ -38,6 +44,10 @@ export const listActions = createActionGroup({ 'create task list success': props<{ createdList: TaskList }>(), 'create task list error': props(), + 'update description': props<{ id: string; newDescription: string }>(), + 'update description success': props<{ id: string; newDescription: string }>(), + 'update description error': props(), + // 'rename list dialog': props<{ id: string }>(), // 'rename list dialog abort': emptyProps(), // // diff --git a/client-v2/src/app/store/entities/entities.effects.ts b/client-v2/src/app/store/entities/entities.effects.ts index 4f93bc87..73bbf2b7 100644 --- a/client-v2/src/app/store/entities/entities.effects.ts +++ b/client-v2/src/app/store/entities/entities.effects.ts @@ -38,7 +38,7 @@ export class EntitiesEffects { }) ) - loadEntityPreviews = createEffect(() => { + loadPreviews = createEffect(() => { return this.actions$.pipe( ofType(entitiesActions.loadPreviews), mergeMap(() => { @@ -46,10 +46,30 @@ export class EntitiesEffects { return res$.pipe( map(listPreviews => entitiesActions.loadPreviewsSuccess({ previews: listPreviews })), - catchError(err => { - console.error(err) - return of(entitiesActions.loadPreviewsError(err)) - }) + catchError(err => of(entitiesActions.loadPreviewsError(err))) + ) + }) + ) + }) + + loadDetail = createEffect(() => { + return this.actions$.pipe( + ofType(entitiesActions.loadDetail), + mergeMap(dto => { + const res$ = this.store + .select(state => state.entities[dto.entityType]?.[dto.id]) + .pipe( + first(), + switchMap(entityDetail => { + if (entityDetail) return of(entityDetail) + + return this.entitiesService.loadDetail(dto) + }) + ) + + return res$.pipe( + map(entityDetail => entitiesActions.loadDetailSuccess({ ...dto, entityDetail })), + catchError(err => of(entitiesActions.loadDetailError({ ...err, id: dto.id }))) ) }) ) diff --git a/client-v2/src/app/store/entities/entities.reducer.ts b/client-v2/src/app/store/entities/entities.reducer.ts index 7043b0d6..84b75757 100644 --- a/client-v2/src/app/store/entities/entities.reducer.ts +++ b/client-v2/src/app/store/entities/entities.reducer.ts @@ -1,10 +1,15 @@ import { createReducer, on } from '@ngrx/store' +import { EntityType } from 'src/app/models/entities.model' +import { TasklistDetail } from 'src/app/models/list.model' import { entitiesActions, listActions } from './entities.actions' import { EntitiesState } from './entities.state' import { getParentByChildId, getEntityById, getEntityTree } from './utils' const initialState: EntitiesState = { entityTree: null, + [EntityType.TASKLIST]: null, + + // ...(Object.fromEntries(Object.values(EntityType).map(key => [key, null])) as Record), } export const entitiesReducer = createReducer( @@ -17,6 +22,43 @@ export const entitiesReducer = createReducer( } }), + on(entitiesActions.loadDetailSuccess, (state, { entityType, id, entityDetail }) => { + return { + ...state, + [entityType]: { + ...(state[entityType] || {}), + [id]: { + ...(state[entityType]?.[id] || {}), + ...entityDetail, + }, + }, + } as EntitiesState + }), + + on(entitiesActions.renameSuccess, (state, { id, newName }) => { + const entityTreeCopy = structuredClone(state.entityTree) + const entity = getEntityById(entityTreeCopy, id) + if (!entity) return state + + entity.name = newName + + return { + ...state, + entityTree: entityTreeCopy, + } + }), + + on(entitiesActions.deleteSuccess, (state, { id }) => { + const entityTreeCopy = structuredClone(state.entityTree) + const result = getParentByChildId(entityTreeCopy, id) + if (!result) return state + + result.subTree.splice(result.index, 1) + + return { ...state, entityTree: entityTreeCopy } + }), + + ////////////////////////////////// Tasklist //////////////////////////////////// on(listActions.createTaskListSuccess, (state, { createdList }) => { if (!createdList.parentListId) return { @@ -41,26 +83,18 @@ export const entitiesReducer = createReducer( } }), - on(entitiesActions.renameSuccess, (state, { id, newName }) => { - const entityTreeCopy = structuredClone(state.entityTree) - const entity = getEntityById(entityTreeCopy, id) - if (!entity) return state - - entity.name = newName - + on(listActions.updateDescriptionSuccess, (state, { id, newDescription }) => { + const otherTasklistDetails = state[EntityType.TASKLIST] || {} + const previousTasklistDetail = state[EntityType.TASKLIST]?.[id] || ({} as TasklistDetail) return { ...state, - entityTree: entityTreeCopy, + [EntityType.TASKLIST]: { + ...otherTasklistDetails, + [id]: { + ...previousTasklistDetail, + description: newDescription, + }, + }, } - }), - - on(entitiesActions.deleteSuccess, (state, { id }) => { - const entityTreeCopy = structuredClone(state.entityTree) - const result = getParentByChildId(entityTreeCopy, id) - if (!result) return state - - result.subTree.splice(result.index, 1) - - return { ...state, entityTree: entityTreeCopy } }) ) diff --git a/client-v2/src/app/store/entities/entities.state.ts b/client-v2/src/app/store/entities/entities.state.ts index 1bc6f554..6a2a8b25 100644 --- a/client-v2/src/app/store/entities/entities.state.ts +++ b/client-v2/src/app/store/entities/entities.state.ts @@ -1,5 +1,8 @@ -import { EntityPreviewRecursive } from 'src/app/models/entities.model' +import { EntityPreviewRecursive, EntityType } from 'src/app/models/entities.model' +import { TasklistDetail } from 'src/app/models/list.model' export interface EntitiesState { entityTree: EntityPreviewRecursive[] | null + + [EntityType.TASKLIST]: Record | null } diff --git a/client-v2/src/app/store/entities/list.effects.ts b/client-v2/src/app/store/entities/list.effects.ts index 1664a1b6..2ecbe5d8 100644 --- a/client-v2/src/app/store/entities/list.effects.ts +++ b/client-v2/src/app/store/entities/list.effects.ts @@ -44,6 +44,23 @@ export class ListEffects { ) }) + updateDescription = createEffect(() => { + return this.actions$.pipe( + ofType(listActions.updateDescription), + mergeMap(dto => { + const res$ = this.listService.update(dto.id, { description: dto.newDescription }) + + return res$.pipe( + map(() => listActions.updateDescriptionSuccess(dto)), + catchError(err => { + this.toast.error('Failed to update description') + return of(listActions.updateDescriptionError({ ...err, id: dto.id })) + }) + ) + }) + ) + }) + duplicateList = createEffect(() => { return this.actions$.pipe( ofType(listActions.duplicateList), diff --git a/client-v2/src/css/components.css b/client-v2/src/css/components.css index ac97fd51..d45f603a 100644 --- a/client-v2/src/css/components.css +++ b/client-v2/src/css/components.css @@ -42,6 +42,11 @@ @apply text-tinted-300; } + .show-placeholder::before { + content: attr(data-placeholder); + @apply text-tinted-400; + } + :is(.input, .button)[disabled] { @apply brightness-75; } From 75dc6b204f9b6afc78ec7ef16a2e32a2fb138247 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 03:57:12 +0100 Subject: [PATCH 48/60] Add error logging to actionLogger meta reducer --- client-v2/src/app/store/index.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client-v2/src/app/store/index.ts b/client-v2/src/app/store/index.ts index 352dc994..a51799b7 100644 --- a/client-v2/src/app/store/index.ts +++ b/client-v2/src/app/store/index.ts @@ -22,7 +22,18 @@ export const reducers: ActionReducerMap = { export const effects = [AppEffects, AuthEffects, AccountEffects, ListEffects, EntitiesEffects] const actionLogger: MetaReducer = reducer => (state, action) => { - console.info('%caction: %c' + action.type, 'color: hsl(130, 0%, 50%);', 'color: hsl(155, 100%, 50%);') + const isErrorAction = /error/i.test(action.type) + if (environment.production && !isErrorAction) return reducer(state, action) + + const actionColor = isErrorAction ? 'color: hsl(345, 100%, 52%);' : 'color: hsl(155, 100%, 50%);' + console.info('%caction: %c' + action.type, 'color: hsl(130, 0%, 50%);', actionColor) + + if (isErrorAction) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type, ...error } = action + console.error(error) + } + return reducer(state, action) } -export const metaReducers: MetaReducer[] = !environment.production ? [actionLogger] : [] +export const metaReducers: MetaReducer[] = !environment.production ? [actionLogger] : [actionLogger] From b011bb2278368d5dd1ab530e999e881b6ee81306 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 04:07:30 +0100 Subject: [PATCH 49/60] Address comments 1.0 (Remove dead comments) --- .../page-entity-icon/page-entity-icon.component.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts index eee8ffb9..57a46ee1 100644 --- a/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts +++ b/client-v2/src/app/components/atoms/icons/page-entity-icon/page-entity-icon.component.ts @@ -2,14 +2,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { EntityType } from 'src/app/models/entities.model' import { TaskStatus } from 'src/app/models/task.model' -/** This will come from the db */ -// export enum EntityType { -// TASKLIST = 'Tasklist', -// TASK = 'Task', -// DOCUMENT = 'Document', -// VIEW = 'View', -// } - export enum PageEntityState { LOADING = 'Loading', } @@ -20,12 +12,6 @@ export enum TaskState { } export type TaskIconKey = TaskStatus | TaskState -// const entityTypeWithoutTask = { -// [EntityType.TASKLIST]: 'Tasklist', -// [EntityType.DOCUMENT]: 'Document', -// [EntityType.VIEW]: 'View', -// } -// type EntityTypeWihoutTask = keyof typeof entityTypeWithoutTask export type PageEntityIconKey = TaskIconKey | EntityType | PageEntityState export const taskStatusIconClassMap: Record = { From d06e33ab4f7a1729771acde433ddbf8af843326e Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 04:07:51 +0100 Subject: [PATCH 50/60] Use OnPush strategy for task.component as well --- .../app/components/organisms/task/task.component.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client-v2/src/app/components/organisms/task/task.component.ts b/client-v2/src/app/components/organisms/task/task.component.ts index 815dccea..1101daca 100644 --- a/client-v2/src/app/components/organisms/task/task.component.ts +++ b/client-v2/src/app/components/organisms/task/task.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from '@angular/core' +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core' import { Task, TaskPriority, TaskStatus } from '../../../models/task.model' import { PageEntityState, TaskState } from '../../atoms/icons/page-entity-icon/page-entity-icon.component' @@ -6,13 +6,21 @@ import { PageEntityState, TaskState } from '../../atoms/icons/page-entity-icon/p selector: 'task', templateUrl: './task.component.html', styleUrls: ['./task.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class TaskComponent implements OnInit { + constructor(private changeDetectorRef: ChangeDetectorRef) {} + ngOnInit() { // dummy loading time - if (this.data.description) setTimeout(() => (this.loading = false), Math.random() * 5000) + if (this.data.description) + setTimeout(() => { + this.loading = false + this.changeDetectorRef.markForCheck() + }, Math.random() * 5000) else this.loading = false } + @Input() data!: Task TaskStatus = TaskStatus TaskPriority = TaskPriority From f6be9cde8e408a82bc45a2abcd48057a2914f28e Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 04:12:52 +0100 Subject: [PATCH 51/60] Address comments 2.0 --- .../views/tasklist-view/tasklist-view.component.ts | 4 ++-- client-v2/src/app/models/defaults.ts | 3 --- .../app/pages/home/entity-page/entity-page.component.css | 2 +- client-v2/src/app/pages/settings/settings.component.ts | 2 +- client-v2/src/app/store/entities/entities.reducer.ts | 4 ++-- client-v2/src/app/store/entities/list.effects.ts | 5 +++-- client-v2/src/app/store/entities/utils.ts | 6 +++--- 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts index bbed823c..3842bc0b 100644 --- a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.ts @@ -3,7 +3,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy' import { Store } from '@ngrx/store' import { BehaviorSubject, combineLatest, merge, of } from 'rxjs' import { distinctUntilChanged, first, map, shareReplay, switchMap, tap } from 'rxjs/operators' -import { DEFAULT_TASKLIST_NAME } from 'src/app/models/defaults' +import { ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' import { EntityType } from 'src/app/models/entities.model' import { TasklistDetail } from 'src/app/models/list.model' import { AppState } from 'src/app/store' @@ -25,7 +25,7 @@ export class TasklistViewComponent { ) {} EntityType = EntityType - DEFAULT_TASKLIST_NAME = DEFAULT_TASKLIST_NAME + DEFAULT_TASKLIST_NAME = ENTITY_NAME_DEFAULTS[EntityType.TASKLIST] entity$ = this.viewData.entity$ detail$ = this.viewData.detail$ diff --git a/client-v2/src/app/models/defaults.ts b/client-v2/src/app/models/defaults.ts index 6331000c..610f5758 100644 --- a/client-v2/src/app/models/defaults.ts +++ b/client-v2/src/app/models/defaults.ts @@ -3,6 +3,3 @@ import { EntityType } from './entities.model' export const ENTITY_NAME_DEFAULTS: Record = { [EntityType.TASKLIST]: 'Untitled tasklist', } - -// @TODO: remove placeholder -export const DEFAULT_TASKLIST_NAME = ENTITY_NAME_DEFAULTS[EntityType.TASKLIST] diff --git a/client-v2/src/app/pages/home/entity-page/entity-page.component.css b/client-v2/src/app/pages/home/entity-page/entity-page.component.css index 1a301c3d..52d04513 100644 --- a/client-v2/src/app/pages/home/entity-page/entity-page.component.css +++ b/client-v2/src/app/pages/home/entity-page/entity-page.component.css @@ -1,3 +1,3 @@ :host { - @apply block w-full; + @apply block; } diff --git a/client-v2/src/app/pages/settings/settings.component.ts b/client-v2/src/app/pages/settings/settings.component.ts index caafa1d6..676456ce 100644 --- a/client-v2/src/app/pages/settings/settings.component.ts +++ b/client-v2/src/app/pages/settings/settings.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core' -import { ActivatedRoute, Router } from '@angular/router' +import { ActivatedRoute } from '@angular/router' import { map, switchMap } from 'rxjs' interface SettingsPageItem { diff --git a/client-v2/src/app/store/entities/entities.reducer.ts b/client-v2/src/app/store/entities/entities.reducer.ts index 84b75757..fb0d8983 100644 --- a/client-v2/src/app/store/entities/entities.reducer.ts +++ b/client-v2/src/app/store/entities/entities.reducer.ts @@ -3,7 +3,7 @@ import { EntityType } from 'src/app/models/entities.model' import { TasklistDetail } from 'src/app/models/list.model' import { entitiesActions, listActions } from './entities.actions' import { EntitiesState } from './entities.state' -import { getParentByChildId, getEntityById, getEntityTree } from './utils' +import { getParentByChildId, getEntityById, buildEntityTree } from './utils' const initialState: EntitiesState = { entityTree: null, @@ -18,7 +18,7 @@ export const entitiesReducer = createReducer( on(entitiesActions.loadPreviewsSuccess, (state, { previews }) => { return { ...state, - entityTree: getEntityTree(previews), + entityTree: buildEntityTree(previews), } }), diff --git a/client-v2/src/app/store/entities/list.effects.ts b/client-v2/src/app/store/entities/list.effects.ts index 2ecbe5d8..67825be8 100644 --- a/client-v2/src/app/store/entities/list.effects.ts +++ b/client-v2/src/app/store/entities/list.effects.ts @@ -5,7 +5,8 @@ import { Actions, createEffect, ofType } from '@ngrx/effects' import { Store } from '@ngrx/store' import { catchError, map, mergeMap, of, tap } from 'rxjs' import { DialogService } from 'src/app/modal/dialog.service' -import { DEFAULT_TASKLIST_NAME } from 'src/app/models/defaults' +import { ENTITY_NAME_DEFAULTS } from 'src/app/models/defaults' +import { EntityType } from 'src/app/models/entities.model' import { ListService } from 'src/app/services/entity.services/list.service' import { getMessageFromHttpError } from 'src/app/utils/store.helpers' import { AppState } from '..' @@ -27,7 +28,7 @@ export class ListEffects { return this.actions$.pipe( ofType(listActions.createTaskList), mergeMap(dto => { - const name = dto.name || DEFAULT_TASKLIST_NAME + const name = dto.name || ENTITY_NAME_DEFAULTS[EntityType.TASKLIST] const res$ = this.listService.create({ ...dto, name }) return res$.pipe( diff --git a/client-v2/src/app/store/entities/utils.ts b/client-v2/src/app/store/entities/utils.ts index 47dcce6e..166228d8 100644 --- a/client-v2/src/app/store/entities/utils.ts +++ b/client-v2/src/app/store/entities/utils.ts @@ -1,7 +1,7 @@ import { EntityPreviewFlattend, EntityPreviewRecursive } from 'src/app/models/entities.model' import { TasklistPreview } from 'src/app/models/list.model' -export const getEntityTree = (allEntities: TasklistPreview[]) => { +export const buildEntityTree = (allEntities: TasklistPreview[]) => { const getChildren = (childIds: string[]): EntityPreviewRecursive[] => { const children = allEntities.filter(entity => childIds.includes(entity.id)) @@ -62,8 +62,8 @@ export const getParentByChildId = ( } } -export const getEntityById = (taskLists: EntityPreviewRecursive[], id: string): EntityPreviewRecursive | void => { - const res = getParentByChildId(taskLists, id) +export const getEntityById = (entityTree: EntityPreviewRecursive[], id: string): EntityPreviewRecursive | void => { + const res = getParentByChildId(entityTree, id) if (!res) return return res.subTree[res.index] From 267effac9efdf0b082cec07e7edea488d391cc8b Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 04:20:01 +0100 Subject: [PATCH 52/60] Fix unit tests --- .../tasklist-view/tasklist-view.component.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts index 7144cb22..50568479 100644 --- a/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts +++ b/client-v2/src/app/components/organisms/entity-view/views/tasklist-view/tasklist-view.component.spec.ts @@ -2,7 +2,7 @@ import { AsyncPipe } from '@angular/common' import { ComponentFixture, TestBed } from '@angular/core/testing' import { BehaviorSubject } from 'rxjs' import { storeMock } from 'src/app/utils/unit-test.mocks' -import { EntityViewComponent, ENTITY_VIEW_DATA } from '../../entity-view.component' +import { EntityViewComponent, EntityViewData, ENTITY_VIEW_DATA } from '../../entity-view.component' import { TasklistViewComponent } from './tasklist-view.component' @@ -11,10 +11,16 @@ describe('TasklistViewComponent', () => { let fixture: ComponentFixture beforeEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const entityViewDataMock: EntityViewData = { + detail$: new BehaviorSubject(null), + entity$: new BehaviorSubject(null), + options$: new BehaviorSubject(null), + } await TestBed.configureTestingModule({ declarations: [TasklistViewComponent], providers: [ - { provide: ENTITY_VIEW_DATA, useValue: new BehaviorSubject(null) }, + { provide: ENTITY_VIEW_DATA, useValue: entityViewDataMock }, storeMock, // eslint-disable-next-line @typescript-eslint/no-empty-function { provide: EntityViewComponent, useValue: { progress$: { next() {} } } }, From 21840a51c5419b6aaa1ed6e834d26a60c0cde2c8 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 14:10:06 +0100 Subject: [PATCH 53/60] Address comments 3.0 (remove dead comments) --- .../src/app/store/entities/entities.actions.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/client-v2/src/app/store/entities/entities.actions.ts b/client-v2/src/app/store/entities/entities.actions.ts index afd6d3b3..2be06a91 100644 --- a/client-v2/src/app/store/entities/entities.actions.ts +++ b/client-v2/src/app/store/entities/entities.actions.ts @@ -36,10 +36,6 @@ export const entitiesActions = createActionGroup({ export const listActions = createActionGroup({ source: 'Entity/Lists', events: { - // 'load previews': emptyProps(), - // 'load previews success': props<{ previews: TasklistPreview[] }>(), - // 'load previews error': props(), - 'create task list': props>(), 'create task list success': props<{ createdList: TaskList }>(), 'create task list error': props(), @@ -48,20 +44,6 @@ export const listActions = createActionGroup({ 'update description success': props<{ id: string; newDescription: string }>(), 'update description error': props(), - // 'rename list dialog': props<{ id: string }>(), - // 'rename list dialog abort': emptyProps(), - // // - // 'rename list': props<{ id: string; newName: string }>(), - // 'rename list success': props<{ id: string; newName: string }>(), - // 'rename list error': props(), - - // 'delete list dialog': props<{ id: string }>(), - // 'delete list dialog abort': emptyProps(), - // // - // 'delete list': props<{ id: string }>(), - // 'delete list success': props<{ id: string }>(), - // 'delete list error': props(), - 'duplicate list': props<{ id: string }>(), 'duplicate list success': props<{ id: string }>(), 'duplicate list error': props<{ id: string }>(), From 8c78de059bc722e0f07f4ffbfd064780daf2e823 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 15:04:06 +0100 Subject: [PATCH 54/60] Write unit test for store utils --- client-v2/src/app/models/entities.model.ts | 1 + .../src/app/store/entities/utils.spec.ts | 114 ++++++++++++++++++ client-v2/src/app/store/entities/utils.ts | 14 ++- 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 client-v2/src/app/store/entities/utils.spec.ts diff --git a/client-v2/src/app/models/entities.model.ts b/client-v2/src/app/models/entities.model.ts index b04515f0..f6611ac1 100644 --- a/client-v2/src/app/models/entities.model.ts +++ b/client-v2/src/app/models/entities.model.ts @@ -23,6 +23,7 @@ export enum EntityType { // } // @TODO: migrate to real Entity-interfaces above +export type EntityPreview = TasklistPreview export type EntityPreviewRecursive = Omit & { children: EntityPreviewRecursive[] } diff --git a/client-v2/src/app/store/entities/utils.spec.ts b/client-v2/src/app/store/entities/utils.spec.ts new file mode 100644 index 00000000..9af03636 --- /dev/null +++ b/client-v2/src/app/store/entities/utils.spec.ts @@ -0,0 +1,114 @@ +import { EntityPreview, EntityPreviewFlattend, EntityPreviewRecursive } from 'src/app/models/entities.model' +import { buildEntityTree, flattenEntityTree, getParentByChildId, traceEntity } from './utils' + +const entityPreviewsFixture: EntityPreview[] = [ + { id: '1', name: 'First', childLists: ['3'], parentListId: '' }, + { id: '2', name: 'Second', childLists: [], parentListId: '' }, + { id: '3', name: 'First nested', childLists: ['4'], parentListId: '1' }, + { id: '4', name: 'Deep nested', childLists: [], parentListId: '3' }, +] +const entityTreeFixture: EntityPreviewRecursive[] = [ + { + id: '1', + name: 'First', + parentListId: '', + children: [ + { + id: '3', + name: 'First nested', + parentListId: '1', + children: [{ id: '4', name: 'Deep nested', children: [], parentListId: '3' }], + }, + ], + }, + { id: '2', name: 'Second', parentListId: '', children: [] }, +] + +const entityTreeFlattenedFixture: EntityPreviewFlattend[] = [ + { + id: '1', + name: 'First', + parentListId: '', + childrenCount: 1, + path: [], + }, + { + id: '3', + name: 'First nested', + parentListId: '1', + childrenCount: 1, + path: ['1'], + }, + { + id: '4', + name: 'Deep nested', + parentListId: '3', + childrenCount: 0, + path: ['1', '3'], + }, + { + id: '2', + name: 'Second', + parentListId: '', + childrenCount: 0, + path: [], + }, +] + +// @TODO: Write proper tests with jest +describe('Store entity utils', () => { + describe('buildEntityTree()', () => { + it('should return the correct tree', () => { + const entityTree = buildEntityTree(entityPreviewsFixture) + expect(entityTree).toEqual(entityTreeFixture) + }) + }) + + describe('flattenEntityTree()', () => { + it('should return the correct flattened equivalent of the tree', () => { + const entityTreeFlattened = flattenEntityTree(entityTreeFixture) + expect(entityTreeFlattened).toEqual(entityTreeFlattenedFixture) + }) + }) + + describe('traceEntity()', () => { + it('should return the correct path to the given entity in the tree', () => { + const trace = traceEntity(entityTreeFixture, '4') + const traceFixture: EntityPreviewRecursive[] = [ + { + id: '1', + name: 'First', + parentListId: '', + children: [ + { + id: '3', + name: 'First nested', + parentListId: '1', + children: [{ id: '4', name: 'Deep nested', children: [], parentListId: '3' }], + }, + ], + }, + { + id: '3', + name: 'First nested', + parentListId: '1', + children: [{ id: '4', name: 'Deep nested', children: [], parentListId: '3' }], + }, + { id: '4', name: 'Deep nested', children: [], parentListId: '3' }, + ] + expect(trace).toEqual(traceFixture) + }) + }) + + describe('getParentByChildId()', () => { + it('should return the correct result', () => { + const result = getParentByChildId(entityTreeFixture, '4') + const resultFixture: ReturnType = { + subTree: [{ id: '4', name: 'Deep nested', children: [], parentListId: '3' }], + index: 0, + } + + expect(result).toEqual(resultFixture) + }) + }) +}) diff --git a/client-v2/src/app/store/entities/utils.ts b/client-v2/src/app/store/entities/utils.ts index 166228d8..6fb8f56f 100644 --- a/client-v2/src/app/store/entities/utils.ts +++ b/client-v2/src/app/store/entities/utils.ts @@ -1,19 +1,21 @@ -import { EntityPreviewFlattend, EntityPreviewRecursive } from 'src/app/models/entities.model' -import { TasklistPreview } from 'src/app/models/list.model' +import { EntityPreview, EntityPreviewFlattend, EntityPreviewRecursive } from 'src/app/models/entities.model' -export const buildEntityTree = (allEntities: TasklistPreview[]) => { +export const buildEntityTree = (allEntities: EntityPreview[]) => { const getChildren = (childIds: string[]): EntityPreviewRecursive[] => { const children = allEntities.filter(entity => childIds.includes(entity.id)) - return children.map(child => { - const grandChildren = getChildren(child.childLists) + return children.map(({ childLists, ...child }) => { + const grandChildren = getChildren(childLists) return { ...child, children: grandChildren } }) } const entityTree = allEntities .filter(entity => !entity.parentListId) - .map(entity => ({ ...entity, children: getChildren(entity.childLists) })) + .map(({ childLists, ...entity }) => ({ + ...entity, + children: getChildren(childLists), + })) return entityTree } From 5c46169e96a2649883340d9763b322ed4de74e96 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 15:10:00 +0100 Subject: [PATCH 55/60] Add refactoring hint comment to server --- server/src/task/list/list.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/task/list/list.controller.ts b/server/src/task/list/list.controller.ts index 9a360477..621b94d6 100644 --- a/server/src/task/list/list.controller.ts +++ b/server/src/task/list/list.controller.ts @@ -33,6 +33,7 @@ export class ListController { getRootLevelTasklists(@GetUser() user: User) { return this.listService.getRootLevelTasklists(user.id) } + // @TODO: This should be refactored into a separate entities.controller, .service, etc. @Get('all-lists') getAllTasklists(@GetUser() user: User) { return this.listService.getAllTasklists(user.id) From fb3fb4a83a9cea7346ce6391fe9729c045752fc6 Mon Sep 17 00:00:00 2001 From: Floyd Haremsa Date: Sun, 1 Jan 2023 15:54:41 +0100 Subject: [PATCH 56/60] Write component tests for breadcrumbs --- .../breadcrumbs/breadcrumbs.component.html | 7 ++- .../breadcrumbs/breadcrumbs.component.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.test.ts diff --git a/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html index 22f0c462..44de1798 100644 --- a/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html +++ b/client-v2/src/app/components/molecules/breadcrumbs/breadcrumbs.component.html @@ -1,5 +1,9 @@ -