This commit is contained in:
Gal Podlipnik 2025-07-14 19:40:13 +02:00
parent 6166350e56
commit 986d34ab12
12 changed files with 950 additions and 290 deletions

View File

@ -0,0 +1,69 @@
---
applyTo: "**"
---
You are an expert in TypeScript, Angular, and scalable web application development. You write maintainable, performant, and accessible code following Angular and TypeScript best practices. You use the features and best practices according to the latest Angular version. You write code that is easy to read, understand and maintain. You avoid unnecessary complexity and strive for simplicity in your code. You use the latest features of TypeScript and Angular to write clean, efficient and scalable code.
## TypeScript Best Practices
- Use strict type checking
- Prefer type interfaces when the type is obvious
- Avoid the `any` type; use `unknown` when type is uncertain
- Use `readonly` #<fieldName> for private class fields
- Use `const` for constants and `let` for variables that may change
- Use the following syntax for `enums`:
```typescript
export enum WeekDayEnum = <const>{
MONDAY: 'Monday',
TUESDAY: 'Tuesday',
WEDNESDAY: 'Wednesday',
THURSDAY: 'Thursday',
FRIDAY: 'Friday',
SATURDAY: 'Saturday',
SUNDAY: 'Sunday'
};
export type WeekDay = (typeof WeekDayEnum)[keyof typeof WeekDayEnum];
```
- Add all types and interfaces to a `<entity>.types.ts` file
## Angular Best Practices
- Always use standalone components over NgModules
- Don't use explicit `standalone: true` (it is implied by default)
- Use signals for state management
- Implement lazy loading for feature routes
- Use `NgOptimizedImage` for all static images
## Components
- Keep components small and focused on a single responsibility
- Use `input()` and `output()` functions instead of decorators
- Use `computed()` for derived state
- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator
- Prefer inline templates for small components
- Prefer Reactive forms instead of Template-driven ones
- Do NOT use `ngClass`, use `class` bindings instead
- Do NOT use `ngStyle`, use `style` bindings instead
## State Management
- Use signals for local component state
- Use `computed()` for derived state
- Keep state transformations pure and predictable
## Templates
- Keep templates simple and avoid complex logic
- Use Signals for your template bindings whenever possible
- Use untagged template literals in the template instead of concatenation
- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, and `*ngSwitch`
- Use the async pipe to handle Observables
## Services
- Design services around a single responsibility
- Use the `providedIn: 'root'` option for singleton services
- Use the `inject()` function instead of constructor injection

View File

@ -9,7 +9,7 @@ export type Task = {
id: number;
title: string;
description?: string;
user_id: number;
userId: number;
timestamp?: string;
};
@ -21,8 +21,8 @@ export type TaskResponse = {
export type Comment = {
id: number;
content: string;
task_id: number;
user_id: number;
taskId: number;
userId: number;
timestamp?: string;
};

View File

@ -1,10 +1,13 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
@Component({
selector: 'lib-navbar',
imports: [MatToolbarModule, MatIcon],
imports: [MatToolbarModule, MatIcon, MatButtonModule, RouterModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-toolbar color="primary" class="navbar">
<span class="logo">
@ -36,18 +39,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
.spacer {
flex: 1 1 auto;
}
a[mat-button] {
font-weight: 500;
font-size: 1rem;
margin-left: 1rem;
transition: background 0.2s, color 0.2s;
border-radius: 4px;
}
a[mat-button]:hover, a[mat-button].active-link {
background: rgba(255,255,255,0.15);
color: #fff;
}
`,
],
})
export class NavbarComponent { }
export class NavbarComponent {}

View File

@ -1,2 +1,3 @@
export * from './lib/task-list.component';
export * from './lib/task-form.component';
export * from './lib/add-task-dialog.component';

View File

@ -1,39 +1,195 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
import { TaskFormComponent } from './task-form.component';
import {
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TasksService, User, UsersService } from '@org/shared/api';
import { ToastService } from '@org/shared/toast';
@Component({
selector: 'lib-add-task-dialog',
standalone: true,
template: `
<h2 mat-dialog-title>Add New Task</h2>
<mat-dialog-content>
<lib-task-form (taskAdded)="onTaskAdded()"></lib-task-form>
<h2 mat-dialog-title class="dialog-title">Add New Task</h2>
<mat-dialog-content class="dialog-content">
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Assigned to</mat-label>
<mat-select formControlName="userId" required>
@for (user of users(); track user.id) {
<mat-option [value]="user.id">{{ user.name }}</mat-option>
}
</mat-select>
@if (taskForm.get('userId')?.hasError('required') &&
taskForm.get('userId')?.touched) {
<mat-error>Please select a user</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Title</mat-label>
<input matInput formControlName="title" placeholder="Task title" />
@if (taskForm.get('title')?.hasError('required') &&
taskForm.get('title')?.touched) {
<mat-error>Title is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea
matInput
formControlName="description"
placeholder="Task description"
rows="3"
></textarea>
</mat-form-field>
<mat-dialog-actions align="end" class="dialog-actions">
<button mat-stroked-button mat-dialog-close type="button">
Cancel
</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="taskForm.invalid"
>
<mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span>
</button>
</mat-dialog-actions>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
</mat-dialog-actions>
`,
styles: [`
mat-dialog-content {
min-width: 320px;
}
`],
styles: [
`
.dialog-title {
font-weight: 700;
font-size: 1.3rem;
color: #1976d2;
margin-bottom: 0.5rem;
}
.dialog-content {
min-width: 400px;
padding-top: 0.5rem;
}
.full-width {
width: 100%;
margin: 1.2rem 0;
}
.dialog-actions {
margin-top: 1.2rem;
}
button[mat-raised-button] {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-height: 44px;
padding: 0.75rem 1rem;
white-space: normal;
line-height: 1.3;
}
button[mat-raised-button] mat-icon {
margin: 0;
flex-shrink: 0;
margin-top: 0.1rem;
}
button[mat-raised-button] .button-text {
flex: 1;
text-align: left;
}
@media (max-width: 600px) {
.dialog-content {
min-width: 90vw;
}
}
`,
],
imports: [
MatButtonModule,
MatDialogActions,
MatDialogClose,
MatDialogTitle,
MatDialogContent,
TaskFormComponent,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatSelectModule,
ReactiveFormsModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddTaskDialogComponent {
readonly dialogRef = inject(MatDialogRef<AddTaskDialogComponent>);
private readonly fb = inject(FormBuilder);
private readonly tasksService = inject(TasksService);
private readonly usersService = inject(UsersService);
private readonly toastService = inject(ToastService);
onTaskAdded() {
this.dialogRef.close(true);
readonly users = signal<User[]>([]);
taskForm: FormGroup = this.fb.group({
userId: ['', Validators.required],
title: ['', Validators.required],
description: [''],
});
constructor() {
this.loadUsers();
}
private loadUsers() {
this.usersService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
},
error: (error) => {
console.error('Error loading users:', error);
this.toastService.show('Failed to load users!', 'error');
},
});
}
onSubmit() {
if (this.taskForm.invalid) return;
const task = {
title: this.taskForm.value.title,
description: this.taskForm.value.description,
userId: parseInt(this.taskForm.value.userId),
};
this.tasksService.createTask(task).subscribe({
next: () => {
this.taskForm.reset();
this.toastService.show('Task created successfully!');
this.dialogRef.close(true);
},
error: (error) => {
console.error('Error creating task:', error);
this.toastService.show('Failed to create task!', 'error');
},
});
}
}

View File

@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Output, inject, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
@ -9,25 +15,27 @@ import {
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TasksService } from '@org/shared/api';
import { ToastService } from '@org/shared/toast';
@Component({
selector: 'lib-task-form',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatButtonModule,
MatCardModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-card>
<mat-card class="task-form-card">
<mat-card-header>
<mat-card-title>Add New Task</mat-card-title>
<mat-card-title class="form-title">Add New Task</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
@ -46,41 +54,79 @@ import { ToastService } from '@org/shared/toast';
matInput
formControlName="description"
placeholder="Task description"
rows="3"
></textarea>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="taskForm.invalid"
>
Add Task
</button>
<div class="form-actions">
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="taskForm.invalid"
>
<mat-icon>add_task</mat-icon>
<span class="button-text">Add Task</span>
</button>
</div>
</form>
</mat-card-content>
</mat-card>
`,
styles: [
`
.full-width {
width: 100%;
margin-bottom: 1rem;
.task-form-card {
margin: 2rem 0;
max-width: 600px;
}
button {
margin-top: 1rem;
.form-title {
font-weight: 700;
color: #1976d2;
margin-bottom: 0.5rem;
}
.full-width {
width: 100%;
margin: 1.2rem 0;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.2rem;
}
button[mat-raised-button] {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-height: 44px;
padding: 0.75rem 1rem;
white-space: normal;
line-height: 1.3;
}
button[mat-raised-button] mat-icon {
margin: 0;
flex-shrink: 0;
margin-top: 0.1rem;
}
button[mat-raised-button] .button-text {
flex: 1;
text-align: left;
}
`,
],
})
export class TaskFormComponent {
userId = input<number>(0);
@Output() taskAdded = new EventEmitter<void>();
readonly userId = input<number>(0);
readonly taskAdded = output<void>();
private fb = inject(FormBuilder);
private tasksService = inject(TasksService);
private toastService = inject(ToastService);
private readonly fb = inject(FormBuilder);
private readonly tasksService = inject(TasksService);
private readonly toastService = inject(ToastService);
taskForm: FormGroup = this.fb.group({
title: ['', Validators.required],
@ -93,7 +139,7 @@ export class TaskFormComponent {
const task = {
title: this.taskForm.value.title,
description: this.taskForm.value.description,
user_id: this.userId(),
userId: this.userId(),
};
this.tasksService.createTask(task).subscribe({

View File

@ -1,7 +1,10 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AddTaskDialogComponent } from './add-task-dialog.component';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
@ -11,6 +14,7 @@ import {
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -31,10 +35,10 @@ import {
} from '@org/shared/api';
import { ToastService } from '@org/shared/toast';
import { catchError, finalize, forkJoin, of } from 'rxjs';
import { AddTaskDialogComponent } from './add-task-dialog.component';
@Component({
selector: 'lib-task-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
@ -52,17 +56,18 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
MatBadgeModule,
MatTooltipModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page-container">
<mat-toolbar color="primary" class="page-header">
<span>Task Management</span>
<span>Tasks</span>
<span class="spacer"></span>
<button mat-icon-button matTooltip="Refresh data" (click)="loadData()">
<mat-icon>refresh</mat-icon>
</button>
</mat-toolbar>
<button class="fab" mat-fab color="accent" (click)="openAddTaskDialog()">
<button class="fab" matFab color="accent" (click)="openAddTaskDialog()">
<mat-icon>add</mat-icon>
</button>
@ -86,22 +91,24 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
<div class="task-title-container">
<mat-icon>assignment</mat-icon>
<span>{{ task.tasks.title }}</span>
@if (getTaskComments(task.tasks.id).length) {
<mat-icon
class="comment-badge"
matBadge="{{ getTaskComments(task.tasks.id).length }}"
matBadgeColor="accent"
>
comment
</mat-icon>
}
</div>
</mat-panel-title>
<mat-panel-description>
<div class="task-info">
<span class="task-author"
>By {{ getUserName(task.tasks.user_id) }}</span
>
<div class="task-info-row">
<span class="task-author"
>By {{ getUserName(task.tasks.userId) }}</span
>
@if (getTaskComments(task.tasks.id).length) {
<mat-icon
class="comment-badge"
matBadge="{{ getTaskComments(task.tasks.id).length }}"
matBadgeColor="accent"
>
comment
</mat-icon>
}
</div>
<span class="task-timestamp">{{
formatDate(task.tasks.timestamp)
}}</span>
@ -213,29 +220,20 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
bottom: 2rem;
right: 2rem;
z-index: 1100;
}
.dialog-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.dialog-card {
min-width: 320px;
max-width: 90vw;
}
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
margin-bottom: 1rem;
border-radius: 4px;
margin-bottom: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.spacer {
@ -256,11 +254,12 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 8px;
padding: 3rem;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 12px;
padding: 4rem 2rem;
margin: 2rem 0;
text-align: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.empty-icon {
@ -271,33 +270,86 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
margin-bottom: 1rem;
}
.empty-state h2 {
color: #424242;
margin-bottom: 0.5rem;
}
.empty-state p {
color: #757575;
}
.task-container {
margin: 1rem 0;
}
.task-panel {
margin-bottom: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
min-height: 80px;
}
.task-panel:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.task-panel .mat-expansion-panel-header {
height: auto;
min-height: 80px;
padding: 1rem 1.5rem;
}
.task-title-container {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
flex: 1;
min-width: 0;
margin-right: 1rem;
}
.comment-badge {
margin-left: 0.5rem;
.task-title-container mat-icon {
color: #1976d2;
flex-shrink: 0;
}
.task-title-container span {
font-weight: 500;
font-size: 1.1rem;
flex: 1;
min-width: 0;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.task-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
flex-shrink: 0;
min-width: 180px;
text-align: right;
}
.task-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.comment-badge {
flex-shrink: 0;
}
.task-author {
font-weight: 500;
color: #424242;
}
.task-timestamp {
@ -306,18 +358,23 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
}
.task-details {
padding: 1rem 0;
padding: 1.5rem 0;
}
.task-description {
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
line-height: 1.6;
margin-bottom: 2rem;
white-space: pre-line;
color: #424242;
background-color: #f8f9fa;
padding: 1rem;
border-radius: 8px;
border-left: 4px solid #1976d2;
}
.comments-section {
margin-top: 1.5rem;
margin-top: 2rem;
}
.comments-section h3 {
@ -325,28 +382,42 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
margin-bottom: 1rem;
margin-bottom: 1.5rem;
color: #424242;
}
.comments-section h3 mat-icon {
color: #1976d2;
}
.comments-list {
margin-bottom: 1.5rem;
margin-bottom: 2rem;
}
.comment-item {
padding: 1rem;
background-color: #f5f5f5;
border-radius: 8px;
padding: 1.25rem;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
margin-bottom: 1rem;
border: 1px solid #e0e0e0;
transition: transform 0.2s ease;
}
.comment-item:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.comment-author {
font-weight: 500;
font-weight: 600;
color: #1976d2;
}
.comment-time {
@ -357,38 +428,56 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
.comment-content {
margin: 0;
white-space: pre-line;
color: #424242;
line-height: 1.5;
}
.no-comments {
font-style: italic;
color: #757575;
text-align: center;
padding: 1rem;
background-color: #f5f5f5;
border-radius: 8px;
padding: 2rem;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
border-radius: 12px;
border: 2px dashed #e0e0e0;
}
.comment-form {
background-color: #f9f9f9;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.comment-form h4 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
margin-bottom: 1.5rem;
font-size: 1.1rem;
color: #424242;
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-form h4::before {
content: '💬';
}
.form-row {
margin-bottom: 1rem;
margin-bottom: 1.5rem;
}
.form-actions {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.form-actions mat-form-field {
min-width: 200px;
}
.full-width {
@ -400,51 +489,54 @@ import { catchError, finalize, forkJoin, of } from 'rxjs';
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 8px;
font-weight: 500;
}
.button-spinner {
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.page-container {
padding: 0 0.5rem 5rem 0.5rem;
}
.task-info {
align-items: flex-start;
}
.form-actions {
flex-direction: column;
}
.form-actions mat-form-field {
width: 100%;
}
}
`,
],
})
export class TaskListComponent implements OnInit {
private tasksService = inject(TasksService);
private usersService = inject(UsersService);
private commentsService = inject(CommentsService);
private fb = inject(FormBuilder);
private toastService = inject(ToastService);
tasks = signal<TaskResponse[]>([]);
users = signal<User[]>([]);
comments = signal<CommentResponse[]>([]);
loading = signal(true);
addingComment = signal(false);
export class TaskListComponent {
private readonly tasksService = inject(TasksService);
private readonly usersService = inject(UsersService);
private readonly commentsService = inject(CommentsService);
private readonly fb = inject(FormBuilder);
private readonly toastService = inject(ToastService);
private readonly dialog = inject(MatDialog);
openAddTaskDialog() {
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
width: '400px',
enterAnimationDuration: '220ms',
exitAnimationDuration: '180ms',
autoFocus: true,
restoreFocus: true,
disableClose: false,
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.loadData();
}
});
}
readonly tasks = signal<TaskResponse[]>([]);
readonly users = signal<User[]>([]);
readonly comments = signal<CommentResponse[]>([]);
readonly loading = signal(true);
readonly addingComment = signal(false);
commentForm: FormGroup = this.fb.group({
readonly commentForm: FormGroup = this.fb.group({
content: ['', Validators.required],
userId: ['', Validators.required],
});
ngOnInit() {
constructor() {
this.loadData();
}
@ -476,6 +568,22 @@ export class TaskListComponent implements OnInit {
});
}
openAddTaskDialog() {
const dialogRef = this.dialog.open(AddTaskDialogComponent, {
width: '400px',
enterAnimationDuration: '220ms',
exitAnimationDuration: '180ms',
autoFocus: true,
restoreFocus: true,
disableClose: false,
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.loadData();
}
});
}
getUserName(userId: number): string {
const user = this.users().find((u) => u.id === userId);
return user ? user.name : 'Unknown User';
@ -483,7 +591,7 @@ export class TaskListComponent implements OnInit {
getTaskComments(taskId: number): CommentResponse[] {
return this.comments().filter(
(comment) => comment.comments.task_id === taskId
(comment) => comment.comments.taskId === taskId
);
}
@ -523,8 +631,8 @@ export class TaskListComponent implements OnInit {
const comment = {
content: this.commentForm.value.content,
task_id: taskId,
user_id: parseInt(this.commentForm.value.userId),
taskId: taskId,
userId: parseInt(this.commentForm.value.userId),
};
this.commentsService

View File

@ -1,5 +1,11 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@ -17,7 +23,6 @@ import { TaskFormComponent } from '@org/web/tasks';
@Component({
selector: 'lib-user-details',
standalone: true,
imports: [
CommonModule,
RouterModule,
@ -29,57 +34,120 @@ import { TaskFormComponent } from '@org/web/tasks';
MatToolbarModule,
TaskFormComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-toolbar color="primary">User Details</mat-toolbar>
<mat-toolbar color="primary">
<span>User Details</span>
</mat-toolbar>
@if (loading()) {
<div class="loading-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
<mat-progress-spinner
mode="indeterminate"
diameter="40"
></mat-progress-spinner>
<p>Loading user details...</p>
</div>
} @else if (user()) {
<mat-card>
<mat-card-header>
<mat-card-title>{{ user()?.name }}</mat-card-title>
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-actions>
<button mat-button routerLink="/users">Back to Users</button>
</mat-card-actions>
</mat-card>
<div class="user-container">
<mat-card>
<mat-card-header>
<mat-icon aria-hidden="true">account_circle</mat-icon>
<div>
<mat-card-title>{{ user()?.name }}</mat-card-title>
<mat-card-subtitle>{{ user()?.email }}</mat-card-subtitle>
</div>
</mat-card-header>
<mat-card-content>
<p><strong>User ID:</strong> {{ user()?.id }}</p>
<p><strong>Tasks:</strong> {{ userTasks().length }}</p>
</mat-card-content>
<mat-card-actions>
<button
mat-raised-button
color="primary"
routerLink="/users"
class="back-button"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span>
</button>
</mat-card-actions>
</mat-card>
<div class="tasks-section">
<h2>User Tasks</h2>
<div class="tasks-section">
<h2>
<mat-icon aria-hidden="true">assignment</mat-icon>
<span>User Tasks</span>
</h2>
@if (userTasks().length) {
<mat-list>
@for (task of userTasks(); track task.tasks.id) {
<mat-list-item>
<mat-icon matListItemIcon>assignment</mat-icon>
<h3 matListItemTitle>{{ task.tasks.title }}</h3>
<p matListItemLine>
{{ task.tasks.description || 'No description' }}
</p>
</mat-list-item>
@if (userTasks().length) {
<mat-list>
@for (task of userTasks(); track task.tasks.id) {
<mat-list-item>
<mat-icon
matListItemIcon
[style.color]="'#3f51b5'"
aria-hidden="true"
>task_alt</mat-icon
>
<div matListItemTitle>
<h3 class="task-title">{{ task.tasks.title }}</h3>
</div>
<div matListItemLine class="task-description">
{{ task.tasks.description || 'No description' }}
</div>
</mat-list-item>
}
</mat-list>
} @else {
<div class="no-tasks">
<mat-icon aria-hidden="true">assignment_late</mat-icon>
<p>No tasks found for this user.</p>
</div>
}
</mat-list>
} @else {
<p>No tasks found for this user.</p>
}
<lib-task-form
[userId]="user()?.id || 0"
(taskAdded)="loadUserTasks()"
></lib-task-form>
<lib-task-form
[userId]="user()?.id || 0"
(taskAdded)="loadUserTasks()"
></lib-task-form>
</div>
</div>
} @else {
<div class="error-container">
<mat-icon
style="font-size: 3rem; height: 3rem; width: 3rem; color: #f44336;"
aria-hidden="true"
>error_outline</mat-icon
>
<p>User not found</p>
<button mat-button routerLink="/users">Back to Users</button>
<button
mat-raised-button
color="primary"
routerLink="/users"
class="back-button"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
<span>Back to Users</span>
</button>
</div>
}
`,
styles: [
`
:host {
display: block;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
@media (min-width: 576px) {
:host {
padding: 0 1.5rem;
}
}
.loading-container,
.error-container {
display: flex;
@ -89,30 +157,237 @@ import { TaskFormComponent } from '@org/web/tasks';
margin: 2rem 0;
}
@media (min-width: 576px) {
.loading-container,
.error-container {
margin: 3rem 0;
}
}
.error-container {
text-align: center;
}
.error-container p {
color: #f44336;
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
mat-toolbar {
margin-bottom: 1.5rem;
border-radius: 4px;
}
@media (min-width: 576px) {
mat-toolbar {
margin-bottom: 2rem;
border-radius: 8px;
}
}
.user-container {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.user-container {
gap: 2rem;
grid-template-columns: 1fr 2fr;
}
}
mat-card {
margin: 1rem 0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 1rem;
height: fit-content;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
mat-card-header {
margin-bottom: 1.25rem;
flex-direction: column;
align-items: flex-start;
}
@media (min-width: 576px) {
mat-card-header {
margin-bottom: 1.5rem;
flex-direction: row;
align-items: center;
}
}
mat-card-title {
font-size: 1.25rem;
font-weight: 500;
margin-bottom: 0.25rem;
word-break: break-word;
}
@media (min-width: 576px) {
mat-card-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
}
mat-card-subtitle {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
word-break: break-word;
}
@media (min-width: 576px) {
mat-card-subtitle {
font-size: 1rem;
}
}
.tasks-section {
margin-top: 2rem;
background-color: #f9f9f9;
border-radius: 8px;
padding: 1rem;
}
@media (min-width: 576px) {
.tasks-section {
padding: 1.5rem;
}
}
.tasks-section h2 {
font-size: 1.25rem;
margin-top: 0;
margin-bottom: 1rem;
color: #333;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (min-width: 576px) {
.tasks-section h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
}
}
mat-list {
border-radius: 4px;
overflow: hidden;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
mat-list-item {
margin-bottom: 0.5rem;
border-left: 4px solid #3f51b5;
transition: transform 0.2s, box-shadow 0.2s;
height: auto !important;
min-height: 48px;
padding: 8px 0;
}
mat-list-item:hover {
transform: translateX(2px);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
[matListItemTitle] {
white-space: normal;
word-break: break-word;
}
[matListItemLine] {
white-space: normal;
word-break: break-word;
overflow: visible;
text-overflow: unset;
display: block;
max-height: none;
line-height: 1.5;
margin-top: 4px;
}
.task-title {
margin: 0;
font-weight: 500;
font-size: 1rem;
}
.task-description {
white-space: pre-line;
color: rgba(0, 0, 0, 0.6);
padding-top: 4px;
}
.no-tasks {
text-align: center;
padding: 1.5rem;
color: rgba(0, 0, 0, 0.5);
background-color: white;
border-radius: 8px;
}
@media (min-width: 576px) {
.no-tasks {
padding: 2rem;
}
}
lib-task-form {
display: block;
margin-top: 1.5rem;
padding: 1rem;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
@media (min-width: 576px) {
lib-task-form {
margin-top: 2rem;
padding: 1.5rem;
}
}
.back-button {
margin-top: 1rem;
width: 100%;
}
@media (min-width: 576px) {
.back-button {
width: auto;
}
}
`,
],
})
export class UserDetailsComponent implements OnInit {
private route = inject(ActivatedRoute);
private usersService = inject(UsersService);
private tasksService = inject(TasksService);
export class UserDetailsComponent {
private readonly route = inject(ActivatedRoute);
private readonly usersService = inject(UsersService);
private readonly tasksService = inject(TasksService);
user = signal<User | null>(null);
userTasks = signal<TaskResponse[]>([]);
loading = signal(true);
readonly user = signal<User | null>(null);
readonly userTasks = signal<TaskResponse[]>([]);
readonly loading = signal(true);
ngOnInit() {
this.route.params.subscribe((params) => {
const userId = +params['id'];
this.loadUserDetails(userId);
this.loadUserTasks(userId);
constructor() {
effect(() => {
this.route.params.subscribe((params) => {
const userId = +params['id'];
this.loadUserDetails(userId);
this.loadUserTasks(userId);
});
});
}
@ -136,7 +411,7 @@ export class UserDetailsComponent implements OnInit {
this.tasksService.getTasks().subscribe({
next: (tasks) => {
this.userTasks.set(tasks.filter((task) => task.tasks.user_id === id));
this.userTasks.set(tasks.filter((task) => task.tasks.userId === id));
},
error: (error) => console.error('Error fetching tasks:', error),
});

View File

@ -1,7 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, effect, inject, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AddUserDialogComponent } from './add-user-dialog.component';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import {
FormBuilder,
FormControl,
@ -11,6 +16,7 @@ import {
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
@ -19,15 +25,19 @@ import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { CommentStoreService, TasksStoreService, UserStoreService } from '@org/shared/stores';
import { ToastService } from '@org/shared/toast';
import { CommentResponse, TaskResponse, User } from '@org/shared/api';
import {
CommentStoreService,
TasksStoreService,
UserStoreService,
} from '@org/shared/stores';
import { ToastService } from '@org/shared/toast';
import { AddUserDialogComponent } from './add-user-dialog.component';
type UserWithCounts = User & { taskCount: number; commentCount: number };
@Component({
selector: 'lib-user-list',
standalone: true,
imports: [
CommonModule,
MatFormFieldModule,
@ -42,8 +52,8 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
MatCardModule,
MatDividerModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<mat-toolbar color="primary">User Management</mat-toolbar>
<div class="container">
<div class="search-container">
<mat-form-field appearance="outline" class="full-width">
@ -62,50 +72,48 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
</div>
@if (isLoading()) {
<div class="loading-container">
<mat-progress-spinner mode="indeterminate" />
</div>
} @else if (filteredUsers.length === 0) {
<div class="empty-state">
<p>No users found. Try adjusting your search or add a new user.</p>
</div>
<div class="loading-container">
<mat-progress-spinner mode="indeterminate" />
</div>
} @else if (filteredUsers().length === 0) {
<div class="empty-state">
<p>No users found. Try adjusting your search or add a new user.</p>
</div>
} @else {
<div class="user-grid">
@for (user of filteredUsers; track user.id) {
<mat-card class="user-card">
<button
mat-icon-button
color="warn"
class="delete-btn"
(click)="deleteUser(user.id)"
>
<mat-icon>delete</mat-icon>
</button>
<mat-card-header>
<mat-card-title>
<a
[routerLink]="['/users', user.id]"
class="user-link"
>{{ user.name }}</a
>
</mat-card-title>
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="user-meta">
<span>
<mat-icon class="meta-icon">assignment</mat-icon>
{{ user.taskCount }} Tasks
</span>
<span>
<mat-icon class="meta-icon">comment</mat-icon>
{{ user.commentCount }} Comments
</span>
</div>
</mat-card-content>
</mat-card>
}
</div>
<div class="user-grid">
@for (user of filteredUsers(); track user.id) {
<mat-card class="user-card">
<button
mat-icon-button
color="warn"
class="delete-btn"
(click)="deleteUser(user.id)"
>
<mat-icon>delete</mat-icon>
</button>
<mat-card-header>
<mat-card-title>
<a [routerLink]="['/users', user.id]" class="user-link">{{
user.name
}}</a>
</mat-card-title>
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div class="user-meta">
<span>
<mat-icon class="meta-icon">assignment</mat-icon>
{{ user.taskCount }} Tasks
</span>
<span>
<mat-icon class="meta-icon">comment</mat-icon>
{{ user.commentCount }} Comments
</span>
</div>
</mat-card-content>
</mat-card>
}
</div>
}
</div>
`,
@ -118,7 +126,7 @@ type UserWithCounts = User & { taskCount: number; commentCount: number };
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: center;
align-items: baseline;
}
.full-width {
width: 100%;
@ -266,49 +274,50 @@ export class UserListComponent {
private readonly commentsStore = inject(CommentStoreService);
private readonly fb = inject(FormBuilder);
private readonly toastService = inject(ToastService);
protected searchControl = new FormControl('');
protected isLoading = signal(true);
filteredUsers: UserWithCounts[] = [];
private allUsersWithCounts: (User & { taskCount: number; commentCount: number })[] = [];
constructor() {
this.userStore.getUsers();
effect(() => {
const users = this.userStore.users();
const tasks = this.taskStore.tasks?.() ?? [];
const comments = this.commentsStore.comments?.() ?? [];
if (Array.isArray(users)) {
this.allUsersWithCounts = addCountsToUsers(users, tasks, comments);
this.isLoading.set(false);
this.applyFilter();
}
});
this.searchControl.valueChanges.subscribe(() => this.applyFilter());
}
private readonly dialog = inject(MatDialog);
userForm: FormGroup = this.fb.group({
readonly searchControl = new FormControl('');
readonly isLoading = signal(true);
private readonly allUsersWithCounts = signal<UserWithCounts[]>([]);
readonly filteredUsers = computed(() => {
const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
return this.allUsersWithCounts().filter(
(u) =>
u.name?.toLocaleLowerCase().includes(term) ||
u.email?.toLowerCase().includes(term)
);
});
readonly userForm: FormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
applyFilter() {
const term = this.searchControl.value?.toLocaleLowerCase() ?? '';
this.filteredUsers = this.allUsersWithCounts
.filter(
(u) =>
u.name?.toLocaleLowerCase().includes(term) ||
u.email?.toLowerCase().includes(term)
);
constructor() {
this.userStore.getUsers();
this.taskStore.getTasks();
this.commentsStore.getComments();
effect(() => {
const users = this.userStore.users();
const tasks = this.taskStore.tasks();
const comments = this.commentsStore.comments();
if (Array.isArray(users)) {
this.allUsersWithCounts.set(addCountsToUsers(users, tasks, comments));
this.isLoading.set(false);
}
});
effect(() => {
this.searchControl.valueChanges.subscribe();
});
}
deleteUser(id: number) {
if (confirm('Are you sure you want to delete this user?')) {
this.userStore.deleteUser(id);
this.toastService.show('User deleted successfully');
this.applyFilter();
}
}
@ -325,20 +334,24 @@ export class UserListComponent {
if (result) {
this.userStore.createUser(result);
this.toastService.show('User created successfully');
this.applyFilter();
}
});
}
}
function addCountsToUsers(users: User[], tasks: TaskResponse[], comments: CommentResponse[]) {
function addCountsToUsers(
users: User[],
tasks: TaskResponse[],
comments: CommentResponse[]
) {
return users.map((user) => {
const userTasks = tasks.filter((t) => t.tasks.user_id === user.id);
const userComments = comments.filter((c) => c.comments.user_id === user.id);
const userTasks = tasks.filter((t) => t.tasks.userId === user.id);
const userComments = comments.filter((c) => c.comments.userId === user.id);
return {
...user,
taskCount: userTasks.length,
commentCount: userComments.length,
};
})
});
}

View File

@ -8,7 +8,8 @@ export const getTasks = async (c: Context) => {
const tasks = await db
.select()
.from(tasksTable)
.leftJoin(usersTable, eq(tasksTable.userId, usersTable.id));
.leftJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.orderBy(tasksTable.timestamp);
if (tasks.length === 0) return c.json({ error: "No tasks found" }, 404);

Binary file not shown.

View File

@ -3,7 +3,6 @@ import "dotenv/config";
import { db } from "./index";
import { commentsTable, tasksTable, usersTable } from "./schema";
// Configuration
const NUM_USERS = 10;
const TASKS_PER_USER_MIN = 1;
const TASKS_PER_USER_MAX = 5;