first commit

This commit is contained in:
Gal Podlipnik 2025-07-11 16:51:33 +02:00
commit 6166350e56
120 changed files with 23984 additions and 0 deletions

8
.vscode/mcp.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"servers": {
"nx-mcp": {
"type": "http",
"url": "http://localhost:9718/mcp"
}
}
}

45
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
"files.eol": "\n",
"editor.tabSize": 2,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
},
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.options": {
"extensions": [".js", ".ts", ".html"]
},
"eslint.validate": ["javascript", "typescript", "html"],
"material-icon-theme.folders.associations": {
"nav": "routes",
"shell": "container",
"ui": "components",
"feature": "angular",
"data-access": "api",
"quote": "core"
},
"angular.enable-strict-mode-prompt": false,
"search.exclude": {
"": true,
"**/node_modules": false
},
"search.useIgnoreFiles": false,
"nxConsole.nxWorkspacePath": "client",
"nxConsole.generateAiAgentRules": true
}

46
client/.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# compiled output
dist
tmp
out-tsc
# dependencies
node_modules
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.angular

6
client/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
/.nx/workspace-data
.angular

3
client/.prettierrc Normal file
View File

@ -0,0 +1,3 @@
{
"singleQuote": true
}

8
client/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
]
}

82
client/README.md Normal file
View File

@ -0,0 +1,82 @@
# Org
<a alt="Nx logo" href="https://nx.dev" target="_blank" rel="noreferrer"><img src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-logo.png" width="45"></a>
✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨.
[Learn more about this workspace setup and its capabilities](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed!
## Finish your remote caching setup
[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/mI3T4Te8D0)
## Run tasks
To run the dev server for your app, use:
```sh
npx nx serve org
```
To create a production bundle:
```sh
npx nx build org
```
To see all available targets to run for a project, run:
```sh
npx nx show project org
```
These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files.
[More about running tasks in the docs &raquo;](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Add new projects
While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature.
Use the plugin's generator to create new projects.
To generate a new application, use:
```sh
npx nx g @nx/angular:app demo
```
To generate a new library, use:
```sh
npx nx g @nx/angular:lib mylib
```
You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list <plugin-name>` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE.
[Learn more about Nx plugins &raquo;](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry &raquo;](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Install Nx Console
Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ.
[Install Nx Console &raquo;](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
## Useful links
Learn more:
- [Learn more about this workspace setup](https://nx.dev/getting-started/tutorials/angular-standalone-tutorial?utm_source=nx_project&amp;utm_medium=readme&amp;utm_campaign=nx_projects)
- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)
And join the Nx community:
- [Discord](https://go.nx.dev/community)
- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl)
- [Our Youtube channel](https://www.youtube.com/@nxdevtools)
- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects)

View File

@ -0,0 +1,42 @@
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
{
ignores: ['**/dist'],
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
depConstraints: [
{
sourceTag: '*',
onlyDependOnLibsWithTags: ['*'],
},
],
},
],
},
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
// Override or add rules here
rules: {},
},
];

51
client/eslint.config.mjs Normal file
View File

@ -0,0 +1,51 @@
import baseConfig from './eslint.base.config.mjs';
import nx from '@nx/eslint-plugin';
export default [
...baseConfig,
{
ignores: ['**/dist'],
},
{
files: [
'**/*.ts',
'**/*.tsx',
'**/*.cts',
'**/*.mts',
'**/*.js',
'**/*.jsx',
'**/*.cjs',
'**/*.mjs',
],
// Override or add rules here
rules: {},
},
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

6
client/jest.config.ts Normal file
View File

@ -0,0 +1,6 @@
import type { Config } from 'jest';
import { getJestProjectsAsync } from '@nx/jest';
export default async (): Promise<Config> => ({
projects: await getJestProjectsAsync(),
});

3
client/jest.preset.js Normal file
View File

@ -0,0 +1,3 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };

View File

@ -0,0 +1,7 @@
# api
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test api` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'api',
preset: '../../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../../coverage/src/app/libs/shared/api',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "api",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "src/app/libs/shared/api/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "src/app/libs/shared/api/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1,6 @@
export * from './lib/token';
export * from './lib/types';
export * from './lib/comments.service';
export * from './lib/tasks.service';
export * from './lib/users.service';

View File

@ -0,0 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { API } from './token';
import { Comment, CommentResponse } from './types';
@Injectable({
providedIn: 'root',
})
export class CommentsService {
private readonly http = inject(HttpClient);
private readonly apiEndpoints = inject(API);
getComments(): Observable<CommentResponse[]> {
return this.http.get<CommentResponse[]>(this.apiEndpoints.getComments);
}
createComment(comment: Omit<Comment, 'id'>): Observable<CommentResponse> {
return this.http.post<CommentResponse>(
this.apiEndpoints.createComment,
comment
);
}
}

View File

@ -0,0 +1,21 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { API } from './token';
import { Task, TaskResponse } from './types';
@Injectable({
providedIn: 'root',
})
export class TasksService {
private readonly http = inject(HttpClient);
private readonly apiEndpoints = inject(API);
getTasks(): Observable<TaskResponse[]> {
return this.http.get<TaskResponse[]>(this.apiEndpoints.getTasks);
}
createTask(task: Omit<Task, 'id'>): Observable<TaskResponse> {
return this.http.post<TaskResponse>(this.apiEndpoints.createTask, task);
}
}

View File

@ -0,0 +1,17 @@
import { InjectionToken } from '@angular/core';
import { APIS } from './types';
const API_BASE_URL = 'http://localhost:3000';
export const API = new InjectionToken<APIS>('API');
export const apiUrls: APIS = {
getUsers: `${API_BASE_URL}/users`,
getUserById: `${API_BASE_URL}/users`,
createUser: `${API_BASE_URL}/users`,
deleteUser: `${API_BASE_URL}/users`,
getTasks: `${API_BASE_URL}/tasks`,
createTask: `${API_BASE_URL}/tasks`,
getComments: `${API_BASE_URL}/comments`,
createComment: `${API_BASE_URL}/comments`,
};

View File

@ -0,0 +1,48 @@
export type User = {
id: number;
name: string;
email: string;
timestamp?: string;
};
export type Task = {
id: number;
title: string;
description?: string;
user_id: number;
timestamp?: string;
};
export type TaskResponse = {
tasks: Task;
users: User;
};
export type Comment = {
id: number;
content: string;
task_id: number;
user_id: number;
timestamp?: string;
};
export type CommentResponse = {
comments: Comment;
tasks: Task;
users: User;
};
export const API_KEYS = [
'getUsers',
'getUserById',
'createUser',
'deleteUser',
'getTasks',
'createTask',
'getComments',
'createComment',
] as const;
export type ApiKey = (typeof API_KEYS)[number];
export type APIS = Record<ApiKey, string>;

View File

@ -0,0 +1,29 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { API } from './token';
import { User } from './types';
@Injectable({
providedIn: 'root',
})
export class UsersService {
private readonly http = inject(HttpClient);
private readonly apiEndpoints = inject(API);
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiEndpoints.getUsers);
}
getUser(id: number): Observable<User> {
return this.http.get<User>(`${this.apiEndpoints.getUserById}/${id}`);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiEndpoints.createUser, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiEndpoints.deleteUser}/${id}`);
}
}

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,7 @@
# navbar
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test navbar` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'navbar',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/navbar',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "navbar",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/navbar/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/navbar/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1 @@
export * from './lib/navbar.component';

View File

@ -0,0 +1,53 @@
import { Component } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
@Component({
selector: 'lib-navbar',
imports: [MatToolbarModule, MatIcon],
template: `
<mat-toolbar color="primary" class="navbar">
<span class="logo">
<mat-icon>dashboard</mat-icon>
<span class="brand">TaskManager</span>
</span>
<span class="spacer"></span>
<a mat-button routerLink="/users" routerLinkActive="active-link">Users</a>
<a mat-button routerLink="/tasks" routerLinkActive="active-link">Tasks</a>
</mat-toolbar>
`,
styles: [
`
.navbar {
padding: 0 2rem;
min-height: 64px;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.3rem;
font-weight: 600;
letter-spacing: 1px;
}
.brand {
margin-left: 0.25rem;
}
.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 { }

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,7 @@
# stores
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test stores` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'stores',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/stores',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "stores",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/stores/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/stores/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1,3 @@
export * from './lib/user.store.service';
export * from './lib/tasks.store.service';
export * from './lib/comments.store.service';

View File

@ -0,0 +1,24 @@
import { inject, Injectable, signal } from '@angular/core';
import { Comment, CommentResponse, CommentsService } from '@org/shared/api';
@Injectable({
providedIn: 'root',
})
export class CommentStoreService {
private readonly service = inject(CommentsService);
comments = signal<CommentResponse[]>([]);
getComments() {
this.service.getComments().subscribe({
next: (comments) => this.comments.set(comments),
error: (error) => console.error('Failed to load comments:', error),
})
}
createComment(comment: Omit<Comment, 'id'>) {
this.service.createComment(comment).subscribe({
next: (newComment) => this.comments.update((comments) => [...comments, newComment]),
error: (error) => console.error('Failed to create comment:', error),
});
}
};

View File

@ -0,0 +1,24 @@
import { inject, Injectable, signal } from '@angular/core';
import { Task, TasksService, TaskResponse } from '@org/shared/api';
@Injectable({
providedIn: 'root',
})
export class TasksStoreService {
private readonly service = inject(TasksService);
tasks = signal<TaskResponse[]>([]);
getTasks() {
this.service.getTasks().subscribe({
next: (tasks) => this.tasks.set(tasks),
error: (error) => console.error('Failed to load tasks:', error),
})
}
createTask(task: Omit<Task, 'id'>) {
this.service.createTask(task).subscribe({
next: (newTask) => this.tasks.update((tasks) => [...tasks, newTask]),
error: (error) => console.error('Failed to create task:', error),
});
}
};

View File

@ -0,0 +1,31 @@
import { inject, Injectable, signal } from '@angular/core';
import { User, UsersService } from '@org/shared/api';
@Injectable({
providedIn: 'root',
})
export class UserStoreService {
private readonly service = inject(UsersService);
users = signal<User[]>([]);
getUsers() {
this.service.getUsers().subscribe({
next: (users) => this.users.set(users),
error: (error) => console.error('Failed to load users:', error),
});
}
createUser(user: Omit<User, 'id'>) {
this.service.createUser(user).subscribe({
next: (newUser) => this.users.update((users) => [...users, newUser]),
});
}
deleteUser(id: number) {
this.service.deleteUser(id).subscribe({
next: () =>
this.users.update((users) => users.filter((user) => user.id !== id)),
error: (error) => console.error('Failed to delete user:', error),
});
}
}

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,7 @@
# toast
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test toast` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'toast',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/toast',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "toast",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/toast/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/toast/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1 @@
export * from './lib/toast.service';

View File

@ -0,0 +1,20 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class ToastService {
private snackBar = inject(MatSnackBar);
show(message: string, type: 'success' | 'error' | 'info' = 'success', duration: number = 3000) {
const panelClass = `toast-${type}`;
this.snackBar.open(message, 'Close', {
duration,
horizontalPosition: 'end',
verticalPosition: 'top',
panelClass: [panelClass]
});
}
}

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,7 @@
# tasks
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test tasks` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'tasks',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/web/tasks',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "tasks",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/tasks/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/web/tasks/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

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

View File

@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogActions, MatDialogClose, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog';
import { TaskFormComponent } from './task-form.component';
@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>
</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;
}
`],
imports: [
MatButtonModule,
MatDialogActions,
MatDialogClose,
MatDialogTitle,
MatDialogContent,
TaskFormComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddTaskDialogComponent {
readonly dialogRef = inject(MatDialogRef<AddTaskDialogComponent>);
onTaskAdded() {
this.dialogRef.close(true);
}
}

View File

@ -0,0 +1,111 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Output, inject, input } from '@angular/core';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
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,
MatInputModule,
MatButtonModule,
MatCardModule,
],
template: `
<mat-card>
<mat-card-header>
<mat-card-title>Add New Task</mat-card-title>
</mat-card-header>
<mat-card-content>
<form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
<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"
></textarea>
</mat-form-field>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="taskForm.invalid"
>
Add Task
</button>
</form>
</mat-card-content>
</mat-card>
`,
styles: [
`
.full-width {
width: 100%;
margin-bottom: 1rem;
}
button {
margin-top: 1rem;
}
`,
],
})
export class TaskFormComponent {
userId = input<number>(0);
@Output() taskAdded = new EventEmitter<void>();
private fb = inject(FormBuilder);
private tasksService = inject(TasksService);
private toastService = inject(ToastService);
taskForm: FormGroup = this.fb.group({
title: ['', Validators.required],
description: [''],
});
onSubmit() {
if (this.taskForm.invalid) return;
const task = {
title: this.taskForm.value.title,
description: this.taskForm.value.description,
user_id: this.userId(),
};
this.tasksService.createTask(task).subscribe({
next: () => {
this.taskForm.reset();
this.toastService.show('Task created successfully!');
this.taskAdded.emit();
},
error: (error) => {
console.error('Error creating task:', error);
this.toastService.show('Failed to create task!', 'error');
},
});
}
}

View File

@ -0,0 +1,548 @@
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 {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import {
CommentResponse,
CommentsService,
TaskResponse,
TasksService,
User,
UsersService,
} from '@org/shared/api';
import { ToastService } from '@org/shared/toast';
import { catchError, finalize, forkJoin, of } from 'rxjs';
@Component({
selector: 'lib-task-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatListModule,
MatProgressSpinnerModule,
MatToolbarModule,
MatIconModule,
MatButtonModule,
MatExpansionModule,
ReactiveFormsModule,
MatFormFieldModule,
MatInputModule,
MatDividerModule,
MatBadgeModule,
MatTooltipModule,
],
template: `
<div class="page-container">
<mat-toolbar color="primary" class="page-header">
<span>Task Management</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()">
<mat-icon>add</mat-icon>
</button>
@if (loading()) {
<div class="loading-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
<p>Loading tasks...</p>
</div>
} @else if (tasks().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">assignment</mat-icon>
<h2>No Tasks Found</h2>
<p>There are currently no tasks in the system.</p>
</div>
} @else {
<div class="task-container">
@for (task of tasks(); track task.tasks.id) {
<mat-expansion-panel class="task-panel">
<mat-expansion-panel-header>
<mat-panel-title>
<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
>
<span class="task-timestamp">{{
formatDate(task.tasks.timestamp)
}}</span>
</div>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="task-details">
<p class="task-description">
{{ task.tasks.description }}
</p>
<mat-divider></mat-divider>
<div class="comments-section">
<h3>
<mat-icon>forum</mat-icon>
Comments
</h3>
@if (getTaskComments(task.tasks.id).length) {
<div class="comments-list">
@for (comment of getTaskComments(task.tasks.id); track
comment.comments.id) {
<div class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ comment.users.name }}</span>
<span class="comment-time">{{
formatDate(comment.comments.timestamp)
}}</span>
</div>
<p class="comment-content">{{ comment.comments.content }}</p>
</div>
}
</div>
} @else {
<p class="no-comments">No comments yet.</p>
}
<form
[formGroup]="commentForm"
(ngSubmit)="addComment(task.tasks.id)"
class="comment-form"
>
<h4>Add a comment</h4>
<div class="form-row">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Your comment</mat-label>
<textarea
matInput
formControlName="content"
placeholder="Share your thoughts..."
rows="2"
></textarea>
@if (commentForm.get('content')?.hasError('required') &&
commentForm.get('content')?.touched) {
<mat-error>Comment text is required</mat-error>
}
</mat-form-field>
</div>
<div class="form-actions">
<mat-form-field appearance="outline">
<mat-label>Post as</mat-label>
<select matNativeControl formControlName="userId" required>
<option value="" disabled selected>Select user</option>
@for (user of users(); track user.id) {
<option [value]="user.id">{{ user.name }}</option>
}
</select>
@if (commentForm.get('userId')?.hasError('required') &&
commentForm.get('userId')?.touched) {
<mat-error>User is required</mat-error>
}
</mat-form-field>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="commentForm.invalid || addingComment()"
>
@if (addingComment()) {
<mat-spinner
diameter="20"
class="button-spinner"
></mat-spinner>
} @else {
<mat-icon>send</mat-icon>
}
<span>{{
addingComment() ? 'Posting...' : 'Post Comment'
}}</span>
</button>
</div>
</form>
</div>
</div>
</mat-expansion-panel>
}
</div>
}
</div>
`,
styles: [
`
.fab {
position: fixed;
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;
}
.spacer {
flex: 1 1 auto;
}
.loading-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin: 4rem 0;
gap: 1rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
border-radius: 8px;
padding: 3rem;
margin: 2rem 0;
text-align: center;
}
.empty-icon {
font-size: 4rem;
height: 4rem;
width: 4rem;
color: #bdbdbd;
margin-bottom: 1rem;
}
.task-container {
margin: 1rem 0;
}
.task-panel {
margin-bottom: 1rem;
border-radius: 8px;
}
.task-title-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.comment-badge {
margin-left: 0.5rem;
}
.task-info {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.task-author {
font-weight: 500;
}
.task-timestamp {
font-size: 0.8rem;
color: #757575;
}
.task-details {
padding: 1rem 0;
}
.task-description {
font-size: 1rem;
line-height: 1.5;
margin-bottom: 1.5rem;
white-space: pre-line;
}
.comments-section {
margin-top: 1.5rem;
}
.comments-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.2rem;
margin-bottom: 1rem;
}
.comments-list {
margin-bottom: 1.5rem;
}
.comment-item {
padding: 1rem;
background-color: #f5f5f5;
border-radius: 8px;
margin-bottom: 1rem;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-author {
font-weight: 500;
}
.comment-time {
font-size: 0.8rem;
color: #757575;
}
.comment-content {
margin: 0;
white-space: pre-line;
}
.no-comments {
font-style: italic;
color: #757575;
text-align: center;
padding: 1rem;
background-color: #f5f5f5;
border-radius: 8px;
}
.comment-form {
background-color: #f9f9f9;
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.comment-form h4 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1rem;
}
.form-row {
margin-bottom: 1rem;
}
.form-actions {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.full-width {
width: 100%;
}
button {
height: 56px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.button-spinner {
margin-right: 0.5rem;
}
`,
],
})
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);
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();
}
});
}
commentForm: FormGroup = this.fb.group({
content: ['', Validators.required],
userId: ['', Validators.required],
});
ngOnInit() {
this.loadData();
}
loadData() {
this.loading.set(true);
forkJoin({
users: this.usersService.getUsers(),
tasks: this.tasksService.getTasks(),
comments: this.commentsService.getComments(),
})
.pipe(
catchError((error) => {
console.error('Error loading data:', error);
this.toastService.show(
'Failed to load data. Please try again.',
'error'
);
return of({ users: [], tasks: [], comments: [] });
}),
finalize(() => this.loading.set(false))
)
.subscribe({
next: (data) => {
this.users.set(data.users);
this.tasks.set(data.tasks);
this.comments.set(data.comments);
},
});
}
getUserName(userId: number): string {
const user = this.users().find((u) => u.id === userId);
return user ? user.name : 'Unknown User';
}
getTaskComments(taskId: number): CommentResponse[] {
return this.comments().filter(
(comment) => comment.comments.task_id === taskId
);
}
formatDate(timestamp?: string): string {
if (!timestamp) return 'Unknown date';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return (
'Today at ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
} else if (diffDays === 1) {
return (
'Yesterday at ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
} else if (diffDays < 7) {
return `${diffDays} days ago`;
} else {
return date.toLocaleDateString([], {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
}
addComment(taskId: number) {
if (this.commentForm.invalid) return;
this.addingComment.set(true);
const comment = {
content: this.commentForm.value.content,
task_id: taskId,
user_id: parseInt(this.commentForm.value.userId),
};
this.commentsService
.createComment(comment)
.pipe(finalize(() => this.addingComment.set(false)))
.subscribe({
next: (newComment) => {
this.comments.update((comments) => [...comments, newComment]);
this.commentForm.reset({ content: '', userId: '' });
this.toastService.show('Comment added successfully!');
},
error: (error) => {
console.error('Error adding comment:', error);
this.toastService.show(
'Failed to add comment! Please try again.',
'error'
);
},
});
}
}

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,7 @@
# users
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test users` to execute the unit tests.

View File

@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.base.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@ -0,0 +1,21 @@
export default {
displayName: 'users',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/web/users',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@ -0,0 +1,20 @@
{
"name": "users",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/users/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/web/users/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@ -0,0 +1,2 @@
export * from './lib/user-details.component';
export * from './lib/user-list.component';

View File

@ -0,0 +1,107 @@
import { ChangeDetectionStrategy, Component, inject } 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
@Component({
selector: 'lib-add-user-dialog',
imports: [
MatButtonModule,
MatDialogActions,
MatDialogClose,
MatDialogTitle,
MatDialogContent,
MatFormFieldModule,
MatInputModule,
MatIconModule,
ReactiveFormsModule,
],
template: `
<h2 mat-dialog-title class="dialog-title">Add New User</h2>
<mat-dialog-content class="dialog-content">
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Name</mat-label>
<input matInput formControlName="name" placeholder="Full name" />
@if (userForm.get('name')?.hasError('required') && userForm.get('name')?.touched) {
<mat-error>
Name is required
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Email</mat-label>
<input matInput formControlName="email" placeholder="Email address" />
@if (userForm.get('email')?.hasError('required')) {
<mat-error>
Email is required
</mat-error>
}
@if (userForm.get('email')?.hasError('email') && !userForm.get('email')?.hasError('required')) {
<mat-error>
Please enter a valid email address
</mat-error>
}
</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]="userForm.invalid"
>
<mat-icon>person_add</mat-icon> Add User
</button>
</mat-dialog-actions>
</form>
</mat-dialog-content>
`,
styles: [
`
.dialog-title {
font-weight: 700;
font-size: 1.3rem;
color: #1976d2;
margin-bottom: 0.5rem;
}
.dialog-content {
min-width: 340px;
padding-top: 0.5rem;
}
.full-width {
width: 100%;
margin: 1.2rem 0;
}
.dialog-actions {
margin-top: 1.2rem;
}
button[mat-raised-button] mat-icon {
margin-right: 0.3em;
}
@media (max-width: 600px) {
.dialog-content {
min-width: 90vw;
}
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddUserDialogComponent {
readonly dialogRef = inject(MatDialogRef<AddUserDialogComponent>);
private readonly fb = inject(FormBuilder);
userForm: FormGroup = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
onSubmit() {
if (this.userForm.invalid) return;
this.dialogRef.close(this.userForm.value);
}
}

View File

@ -0,0 +1,144 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatToolbarModule } from '@angular/material/toolbar';
import { ActivatedRoute, RouterModule } from '@angular/router';
import {
TaskResponse,
TasksService,
User,
UsersService,
} from '@org/shared/api';
import { TaskFormComponent } from '@org/web/tasks';
@Component({
selector: 'lib-user-details',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatButtonModule,
MatListModule,
MatIconModule,
MatProgressSpinnerModule,
MatToolbarModule,
TaskFormComponent,
],
template: `
<mat-toolbar color="primary">User Details</mat-toolbar>
@if (loading()) {
<div class="loading-container">
<mat-progress-spinner mode="indeterminate"></mat-progress-spinner>
</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="tasks-section">
<h2>User Tasks</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>
}
</mat-list>
} @else {
<p>No tasks found for this user.</p>
}
<lib-task-form
[userId]="user()?.id || 0"
(taskAdded)="loadUserTasks()"
></lib-task-form>
</div>
} @else {
<div class="error-container">
<p>User not found</p>
<button mat-button routerLink="/users">Back to Users</button>
</div>
}
`,
styles: [
`
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin: 2rem 0;
}
mat-card {
margin: 1rem 0;
}
.tasks-section {
margin-top: 2rem;
}
`,
],
})
export class UserDetailsComponent implements OnInit {
private route = inject(ActivatedRoute);
private usersService = inject(UsersService);
private tasksService = inject(TasksService);
user = signal<User | null>(null);
userTasks = signal<TaskResponse[]>([]);
loading = signal(true);
ngOnInit() {
this.route.params.subscribe((params) => {
const userId = +params['id'];
this.loadUserDetails(userId);
this.loadUserTasks(userId);
});
}
loadUserDetails(userId: number) {
this.loading.set(true);
this.usersService.getUser(userId).subscribe({
next: (userData) => {
this.user.set(userData);
this.loading.set(false);
},
error: (error) => {
console.error('Error fetching user:', error);
this.loading.set(false);
},
});
}
loadUserTasks(userId?: number) {
const id = userId || this.user()?.id;
if (!id) return;
this.tasksService.getTasks().subscribe({
next: (tasks) => {
this.userTasks.set(tasks.filter((task) => task.tasks.user_id === id));
},
error: (error) => console.error('Error fetching tasks:', error),
});
}
}

View File

@ -0,0 +1,344 @@
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 {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
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';
type UserWithCounts = User & { taskCount: number; commentCount: number };
@Component({
selector: 'lib-user-list',
standalone: true,
imports: [
CommonModule,
MatFormFieldModule,
MatInputModule,
MatListModule,
RouterModule,
ReactiveFormsModule,
MatProgressSpinnerModule,
MatIconModule,
MatToolbarModule,
MatButtonModule,
MatCardModule,
MatDividerModule,
],
template: `
<mat-toolbar color="primary">User Management</mat-toolbar>
<div class="container">
<div class="search-container">
<mat-form-field appearance="outline" class="full-width">
<mat-label>Search Users</mat-label>
<input
matInput
[formControl]="searchControl"
placeholder="type name or email"
autocomplete="off"
/>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<button mat-raised-button color="primary" (click)="openAddUserDialog()">
<mat-icon>person_add</mat-icon> Add User
</button>
</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>
} @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>
`,
styles: [
`
.container {
padding: 1rem;
}
.search-container {
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.full-width {
width: 100%;
}
.loading-container {
display: flex;
justify-content: center;
margin: 2rem 0;
}
.empty-state {
text-align: center;
padding: 2rem;
background-color: var(--mat-grey-100, #f5f5f5);
border-radius: 4px;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.user-card {
position: relative;
background: var(--mat-card-background, #fff);
border-radius: 18px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s, transform 0.2s;
will-change: box-shadow, transform;
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
padding-bottom: 0.5rem;
overflow: visible;
}
.user-card:hover {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
transform: translateY(-4px) scale(1.02);
}
.user-link {
text-decoration: none;
color: #1976d2;
font-weight: 600;
font-size: 1.15rem;
transition: color 0.2s;
}
.user-link:hover {
color: #125ea2;
text-decoration: underline;
}
.delete-btn {
position: absolute;
top: 0.7rem;
right: 0.7rem;
z-index: 2;
background: #fff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
transition: background 0.2s;
}
.delete-btn:hover {
background: #ffeaea;
}
.user-meta {
display: flex;
gap: 1.2rem;
margin-top: 0.7rem;
font-size: 0.98rem;
color: #555;
align-items: center;
}
.meta-icon {
font-size: 1.1em;
vertical-align: middle;
margin-right: 0.2em;
color: #1976d2;
}
@media (max-width: 600px) {
.container {
padding: 0.5rem;
}
.user-grid {
grid-template-columns: 1fr;
gap: 0.7rem;
}
.search-container {
flex-direction: column;
gap: 0.5rem;
}
.dialog-card {
min-width: 90vw;
}
}
.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: 1000;
animation: fadeIn 0.25s;
}
.dialog-card {
min-width: 320px;
max-width: 90vw;
border-radius: 18px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
animation: scaleIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
`,
],
})
export class UserListComponent {
private readonly userStore = inject(UserStoreService);
private readonly taskStore = inject(TasksStoreService);
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({
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)
);
}
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();
}
}
openAddUserDialog() {
const dialogRef = this.dialog.open(AddUserDialogComponent, {
width: '350px',
enterAnimationDuration: '220ms',
exitAnimationDuration: '180ms',
autoFocus: true,
restoreFocus: true,
disableClose: false,
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.userStore.createUser(result);
this.toastService.show('User created successfully');
this.applyFilter();
}
});
}
}
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);
return {
...user,
taskCount: userTasks.length,
commentCount: userComments.length,
};
})
}

View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"skipLibCheck": true,
"experimentalDecorators": true,
"importHelpers": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

64
client/nx.json Normal file
View File

@ -0,0 +1,64 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"defaultBase": "master",
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": [
"default",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.mjs",
"!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
"!{projectRoot}/tsconfig.spec.json",
"!{projectRoot}/jest.config.[jt]s",
"!{projectRoot}/src/test-setup.[jt]s",
"!{projectRoot}/test-setup.[jt]s"
],
"sharedGlobals": []
},
"nxCloudId": "6870dfc26f399d24e54b83c5",
"targetDefaults": {
"@angular/build:application": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"@nx/eslint:lint": {
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/.eslintignore",
"{workspaceRoot}/eslint.config.mjs"
]
},
"@nx/jest:jest": {
"cache": true,
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
"options": {
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"generators": {
"@nx/angular:application": {
"e2eTestRunner": "none",
"linter": "eslint",
"style": "scss",
"unitTestRunner": "jest"
},
"@nx/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest"
},
"@nx/angular:component": {
"style": "css"
}
},
"defaultProject": "org"
}

18624
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
client/package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "@org/source",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"start": "nx serve",
"build": "nx build",
"test": "nx test"
},
"private": true,
"dependencies": {
"@angular/animations": "^20.0.7",
"@angular/cdk": "^20.1.0",
"@angular/common": "~20.0.0",
"@angular/compiler": "~20.0.0",
"@angular/core": "~20.0.0",
"@angular/forms": "~20.0.0",
"@angular/material": "^20.1.0",
"@angular/platform-browser": "~20.0.0",
"@angular/platform-browser-dynamic": "~20.0.0",
"@angular/router": "~20.0.0",
"rxjs": "~7.8.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/core": "~20.0.0",
"@angular-devkit/schematics": "~20.0.0",
"@angular/build": "~20.0.0",
"@angular/cli": "~20.0.0",
"@angular/compiler-cli": "~20.0.0",
"@angular/language-service": "~20.0.0",
"@eslint/js": "^9.8.0",
"@nx/angular": "21.2.2",
"@nx/eslint": "21.2.2",
"@nx/eslint-plugin": "21.2.2",
"@nx/jest": "21.2.2",
"@nx/js": "21.2.2",
"@nx/web": "21.2.2",
"@nx/workspace": "21.2.2",
"@schematics/angular": "~20.0.0",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@types/jest": "^29.5.12",
"@types/node": "18.16.9",
"@typescript-eslint/utils": "^8.29.0",
"angular-eslint": "^20.0.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "~14.6.0",
"nx": "21.2.2",
"prettier": "^2.6.2",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"typescript": "~5.8.2",
"typescript-eslint": "^8.29.0"
},
"nx": {
"includedScripts": []
}
}

96
client/project.json Normal file
View File

@ -0,0 +1,96 @@
{
"name": "org",
"$schema": "node_modules/nx/schemas/project-schema.json",
"includedScripts": [],
"projectType": "application",
"prefix": "app",
"sourceRoot": "./src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/org",
"browser": "./src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/azure-blue.css",
"./src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "org:build:production"
},
"development": {
"buildTarget": "org:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular/build:extract-i18n",
"options": {
"buildTarget": "org:build"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["./src"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectName}"],
"options": {
"jestConfig": "jest.config.app.ts"
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "org:build",
"staticFilePath": "dist/org/browser",
"spa": true
}
}
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,25 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
importProvidersFrom,
provideBrowserGlobalErrorListeners,
provideZoneChangeDetection,
} from '@angular/core';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router';
import { API, apiUrls } from '@org/shared/api';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(appRoutes),
provideHttpClient(),
importProvidersFrom(MatSnackBarModule),
{
provide: API,
useValue: apiUrls,
},
],
};

View File

@ -0,0 +1,23 @@
import { Route } from '@angular/router';
import { TaskListComponent } from '@org/web/tasks';
import { UserDetailsComponent, UserListComponent } from '@org/web/users';
export const appRoutes: Route[] = [
{
path: 'users',
component: UserListComponent,
},
{
path: 'users/:id',
component: UserDetailsComponent,
},
{
path: 'tasks',
component: TaskListComponent,
},
{
path: '**',
redirectTo: 'users',
pathMatch: 'full',
},
];

0
client/src/app/app.scss Normal file
View File

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { NxWelcome } from './nx-welcome';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App, NxWelcome],
}).compileComponents();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Welcome org');
});
});

14
client/src/app/app.ts Normal file
View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NavbarComponent } from '@org/shared/navbar';
@Component({
imports: [RouterModule, NavbarComponent],
selector: 'app-root',
template: `
<lib-navbar />
<router-outlet />
`,
styles: [``],
})
export class App { }

15
client/src/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>org</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body class="mat-typography">
<app-root></app-root>
</body>
</html>

5
client/src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

43
client/src/styles.scss Normal file
View File

@ -0,0 +1,43 @@
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
html,
body {
height: 100%;
max-width: 100vw;
}
body {
margin: 0;
background-color: #fafafa;
color: rgba(0, 0, 0, 0.87);
transition: background-color 0.3s ease, color 0.3s ease;
}
body.dark-theme {
background-color: #303030;
color: rgba(255, 255, 255, 0.87);
}
.container {
margin-bottom: 16px;
transition: background-color 0.3s ease, color 0.3s ease;
}
.toast-success {
background-color: #4caf50;
color: white;
}
.toast-error {
background-color: #f44336;
color: white;
}
.toast-info {
background-color: #2196f3;
color: white;
}
body.dark-theme .mat-mdc-card {
--mdc-elevated-card-container-color: #424242;
color: rgba(255, 255, 255, 0.87);
}

6
client/src/test-setup.ts Normal file
View File

@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

16
client/src/theme.scss Normal file
View File

@ -0,0 +1,16 @@
@use '@angular/material' as mat;
:root {
color-scheme: light dark;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
theme-type: color-scheme,
),
typography: Roboto,
density: -3,
)
);
}

14
client/tsconfig.app.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"src/test-setup.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

20
client/tsconfig.base.json Normal file
View File

@ -0,0 +1,20 @@
{
"compileOnSave": false,
"compilerOptions": {
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"target": "es2022",
"module": "preserve",
"lib": ["es2020", "dom"],
"baseUrl": ".",
"paths": {
"@org/shared/api": ["libs/shared/api/src/index.ts"],
"@org/shared/navbar": ["libs/shared/navbar/src/index.ts"],
"@org/shared/stores": ["libs/shared/stores/src/index.ts"],
"@org/shared/toast": ["libs/shared/toast/src/index.ts"],
"@org/web/tasks": ["libs/web/tasks/src/index.ts"],
"@org/web/users": ["libs/web/users/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
}

23
client/tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"isolatedModules": true
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "./tsconfig.base.json"
}

17
client/tsconfig.spec.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

Some files were not shown because too many files have changed in this diff Show More