Fixes and adjustments to demo

This commit is contained in:
Toni Koskinen
2026-03-27 10:37:42 +02:00
parent 51a4b84476
commit 4de890ba77
44 changed files with 1076 additions and 104 deletions

1
.gitignore vendored
View File

@@ -56,4 +56,5 @@ __screenshots__/
/prisma/libs/prisma-generated/src/lib/generated
libs/prisma-generated/src/lib/generated/
/prisma/migrations/

View File

@@ -2,6 +2,7 @@
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
"dbaeumer.vscode-eslint",
"firsttris.vscode-jest-runner"
]
}

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
displayName: 'api-e2e',
preset: '../../jest.preset.js',
globalSetup: '<rootDir>/src/support/global-setup.ts',

View File

@@ -9,6 +9,9 @@ import { AppModule } from './app/app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:4200'],
});
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;

View File

@@ -1,25 +0,0 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { Post } from '@shared/prisma-generated/src/types';
import { prisma } from '@shared/prisma-generated/src/prisma';
@Controller('posts')
export class PostsController {
@Get()
async findAll(): Promise<Post[]> {
const posts = await prisma.post.findMany();
return posts;
}
@Get(':id')
async findById(@Param('id') id: string): Promise<Post> {
const post = await prisma.post.findUnique({
where: { id },
});
if (!post) {
throw new NotFoundException(`Post with id ${id} not found`);
}
return post;
}
}

View File

@@ -0,0 +1,41 @@
import { Controller, Get, Param, Post, Body, Put, Delete, NotFoundException } from '@nestjs/common';
import { PostsService } from './posts.service';
import { Post as PostEntity } from '@shared/prisma-generated/src/types';
import { PostDto } from '@shared/shared-dto';
import { UpdatePostDto } from '@shared/shared-dto';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
async findAll(): Promise<PostEntity[]> {
const posts = this.postsService.findAll();
if (!posts) {
throw new NotFoundException("No posts found")
}
return posts
}
@Get(':id')
async findById(@Param('id') id: string): Promise<PostEntity> {
return this.postsService.findById(id);
}
@Post()
async create(@Body() data: PostDto): Promise<PostEntity> {
return this.postsService.create(data);
}
@Put(':id')
async update(@Param('id') id: string, @Body() data: UpdatePostDto): Promise<PostEntity> {
return this.postsService.update(id, data);
}
@Delete(':id')
async delete(@Param('id') id: string): Promise<PostEntity> {
return this.postsService.delete(id);
}
}

View File

@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common';
import { PostsController } from './controllers/posts/posts.controller';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
@Module({
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService],
})
export class PostsModule {}
export class PostsModule {}

View File

@@ -0,0 +1,40 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Post } from '@shared/prisma-generated/src/types';
import { prisma } from '@shared/prisma-generated/src/prisma';
import { PostDto } from '@shared/shared-dto';
import { UpdatePostDto } from '@shared/shared-dto';
@Injectable()
export class PostsService {
async findAll(): Promise<Post[]> {
const posts = await prisma.post.findMany();
return posts;
}
async findById(id: string): Promise<Post> {
const post = await prisma.post.findUnique({
where: { id },
});
if (!post) {
throw new NotFoundException(`Post with id ${id} not found`);
}
return post;
}
async create(data: PostDto): Promise<Post> {
return prisma.post.create({ data });
}
async update(id: string, data: UpdatePostDto): Promise<Post> {
return prisma.post.update({
where: { id },
data,
});
}
async delete(id: string): Promise<Post> {
return prisma.post.delete({ where: { id } });
}
}

View File

@@ -4,7 +4,8 @@ import {
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes), provideHttpClient()],
};

View File

@@ -1,2 +1 @@
<app-nx-welcome></app-nx-welcome>
<router-outlet></router-outlet>

View File

@@ -1,3 +1,9 @@
import { Route } from '@angular/router';
import { Home } from './features/home/home';
export const appRoutes: Route[] = [];
export const appRoutes: Route[] = [
{
path: '',
component: Home,
},
];

View File

@@ -1,20 +1,31 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { App } from './app';
import { NxWelcome } from './nx-welcome';
import { appRoutes } from './app.routes';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App, NxWelcome],
imports: [App],
providers: [provideRouter(appRoutes)],
}).compileComponents();
});
it('should render title', async () => {
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render Home page on default route', async () => {
const fixture = TestBed.createComponent(App);
const router = TestBed.inject(Router);
await router.navigateByUrl('/');
fixture.detectChanges();
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Welcome web-app',
);
expect(compiled.textContent).toContain('Posts');
});
});

View File

@@ -1,15 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NxWelcome } from './nx-welcome';
import { Post } from '@shared/prisma-generated/src/types'
@Component({
imports: [NxWelcome, RouterModule],
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
protected title = 'web-app';
protected posts: Post[] = [];
}

View File

@@ -0,0 +1,32 @@
<section class="home">
<h1>Posts</h1>
@if (isLoading) {
<p>Loading posts...</p>
} @else if (errorMessage) {
<p class="error-message">{{ errorMessage }}</p>
} @else if (posts.length === 0) {
<p class="no-posts-message">No posts found.</p>
} @else {
<div class="cards-container">
@for (post of posts; track post) {
<mat-card class="post-card" appearance="outlined">
<mat-card-header>
<mat-card-title>{{ post.title }}</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>{{ post.content || 'No content' }}</p>
<p>UserId: {{ post.userId }}</p>
</mat-card-content>
</mat-card>
}
</div>
}
<div class="button-container" data-testid="fetch-button">
<button mat-raised-button color="primary" (click)="fetchPosts()" [disabled]="isLoading">
{{ isLoading ? 'Loading...' : 'Fetch Posts' }}
</button>
</div>
</section>

View File

@@ -0,0 +1,56 @@
.button-container {
margin: 20px 0;
display: flex;
justify-content: center;
gap: 10px;
}
.cards-container {
display: flex;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-top: 20px;
justify-items: center;
width: 100%;
place-items: center;
}
.post-card {
height: 100%;
width: 100%;
max-width: 350px;
display: flex;
flex-direction: column;
text-align: center;
mat-card-header {
padding-bottom: 16px;
}
mat-card-content {
flex: 1;
overflow-y: auto;
max-height: 200px;
}
}
.error-message {
color: #d32f2f;
font-weight: 500;
margin-top: 16px;
}
.no-posts-message {
color: #666;
font-style: italic;
margin-top: 16px;
}
.home {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 20px;
}

View File

@@ -0,0 +1,34 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Home } from './home';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
describe('Home', () => {
let component: Home;
let fixture: ComponentFixture<Home>;
let debugElement: DebugElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Home],
}).compileComponents();
fixture = TestBed.createComponent(Home);
component = fixture.componentInstance;
await fixture.whenStable();
debugElement = fixture.debugElement;
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have button', () => {
const fetchButton = debugElement.query(
By.css('[data-testid="fetch-button"]')
);
expect(fetchButton).toBeDefined();
})
});

View File

@@ -0,0 +1,39 @@
import { ChangeDetectorRef, Component, inject } from '@angular/core';
import { PostDto } from '@shared/shared-dto'
import { PostService } from '../../services/PostService/PostService/post-service';
import { timeout } from 'rxjs';
import { MatButtonModule } from '@angular/material/button'
import {MatCardModule} from '@angular/material/card';
@Component({
selector: 'app-home',
imports: [MatButtonModule, MatCardModule],
templateUrl: './home.html',
styleUrl: './home.scss',
})
export class Home {
posts: PostDto[] = [];
isLoading = false;
errorMessage = '';
postService = inject(PostService);
cdr = inject(ChangeDetectorRef);
fetchPosts(): void {
this.isLoading = true;
this.errorMessage = '';
this.postService.getPosts().pipe(timeout(8000)).subscribe({
next: (posts) => {
this.posts = posts;
this.isLoading = false;
this.cdr.detectChanges();
},
error: () => {
this.errorMessage = 'Failed to load posts.';
this.isLoading = false;
this.cdr.detectChanges();
},
});
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { PostService } from './post-service';
describe('PostService', () => {
let service: PostService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(PostService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs';
import { PostDto } from '@shared/shared-dto';
@Injectable({
providedIn: 'root',
})
export class PostService {
private httpClient = inject(HttpClient);
private baseUrl = 'http://localhost:3000/api';
getPosts(): Observable<PostDto[]> {
return this.httpClient.get<PostDto[]>(`${this.baseUrl}/posts`);
}
}

View File

@@ -6,7 +6,11 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>
<app-root></app-root>
</body>

View File

@@ -1 +1,41 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme((
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
));
}
body {
display: flex;
align-items: center;
justify-content: center;
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
/* You can add global styles to this file, and also import other style files */

View File

@@ -20,7 +20,7 @@ services:
build:
context: .
dockerfile: ./apps/api/Dockerfile.dev
command: sh -c "pnpm install && pnpm prisma generate && pnpm prisma migrate deploy && pnpm nx serve api"
command: sh -c "pnpm install && pnpm prisma generate && pnpm prisma migrate dev && pnpm prisma db seed && pnpm nx serve api"
ports:
- "3000:3000"
environment:

6
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
jest.preset.js Normal file
View File

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

11
libs/shared-dto/README.md Normal file
View File

@@ -0,0 +1,11 @@
# shared-dto
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build shared-dto` to build the library.
## Running unit tests
Run `nx test shared-dto` to execute the unit tests via [Jest](https://jestjs.io).

View File

@@ -0,0 +1,19 @@
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
},
],
},
languageOptions: {
parser: await import('jsonc-eslint-parser'),
},
},
];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: 'shared-dto',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }]
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/libs/shared-dto',
};

View File

@@ -0,0 +1,9 @@
{
"name": "shared-dto",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared-dto/src",
"projectType": "library",
"tags": [],
"targets": {
}
}

View File

@@ -0,0 +1 @@
export * from './lib/shared-dto';

View File

@@ -0,0 +1,7 @@
import { sharedDto } from './shared-dto';
describe('sharedDto', () => {
it('should work', () => {
expect(sharedDto()).toEqual('shared-dto');
});
});

View File

@@ -0,0 +1,9 @@
import { Post } from '@shared/prisma-generated/src/types';
export function sharedDto(): string {
return 'shared-dto';
}
export type PostDto = Omit<Post, 'id' | 'createdAt' | 'updatedAt'>;
export type UpdatePostDto = Partial<PostDto>;

View File

@@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

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

View File

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

21
nx.json
View File

@@ -6,7 +6,12 @@
"production": [
"default",
"!{projectRoot}/.eslintrc.json",
"!{projectRoot}/eslint.config.mjs"
"!{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": []
},
@@ -28,6 +33,11 @@
"@angular/build:unit-test": {
"cache": true,
"inputs": ["default", "^production"]
},
"@nx/js:tsc": {
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
}
},
"generators": {
@@ -36,6 +46,9 @@
"linter": "eslint",
"style": "scss",
"unitTestRunner": "vitest-angular"
},
"@nx/angular:component": {
"style": "scss"
}
},
"neverConnectToCloud": true,
@@ -56,6 +69,12 @@
"options": {
"targetName": "eslint:lint"
}
},
{
"plugin": "@nx/jest/plugin",
"options": {
"targetName": "test"
}
}
]
}

View File

@@ -5,10 +5,12 @@
"scripts": {},
"private": true,
"dependencies": {
"@angular/cdk": "^21.2.4",
"@angular/common": "~21.2.0",
"@angular/compiler": "~21.2.0",
"@angular/core": "~21.2.0",
"@angular/forms": "~21.2.0",
"@angular/material": "^21.2.4",
"@angular/platform-browser": "~21.2.0",
"@angular/router": "~21.2.0",
"@nestjs/common": "^11.0.0",
@@ -17,7 +19,6 @@
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"axios": "^1.6.0",
"pg": "^8.20.0",
"reflect-metadata": "^0.1.13",
"rxjs": "~7.8.0"
},
@@ -34,6 +35,7 @@
"@nx/angular": "22.6.1",
"@nx/eslint": "22.6.1",
"@nx/eslint-plugin": "22.6.1",
"@nx/jest": "22.6.1",
"@nx/js": "22.6.1",
"@nx/nest": "^22.6.1",
"@nx/node": "22.6.1",
@@ -44,17 +46,25 @@
"@swc-node/register": "~1.11.1",
"@swc/core": "~1.15.5",
"@swc/helpers": "~0.5.18",
"@types/jest": "^30.0.0",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "^8.40.0",
"angular-eslint": "^21.2.0",
"dotenv": "^17.3.1",
"eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0",
"jest": "^30.0.2",
"jest-environment-node": "^30.0.2",
"jest-util": "^30.0.2",
"jsdom": "^27.1.0",
"jsonc-eslint-parser": "^2.1.0",
"nx": "22.6.1",
"prettier": "~3.6.2",
"prisma": "^7.5.0",
"ts-jest": "^29.4.0",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"tsx": "^4.21.0",
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vitest": "^4.0.8",

517
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seedData.ts"
},
datasource: {
url: process.env["DATABASE_URL"],

View File

@@ -1,15 +0,0 @@
-- CreateEnum
CREATE TYPE "PostStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateTable
CREATE TABLE "posts" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"status" "PostStatus" NOT NULL DEFAULT 'DRAFT',
"authorName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "posts_pkey" PRIMARY KEY ("id")
);

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -19,13 +19,20 @@ enum PostStatus {
// Simple table for blog posts
model Post {
id String @id @default(cuid())
id String @id @unique @default(cuid())
title String
content String?
status PostStatus @default(DRAFT)
authorName String
published Boolean
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
userId String
@@map("posts")
}
model User {
id String @id @unique @default(cuid())
email String @unique
name String
posts Post[]
}

View File

@@ -1,5 +0,0 @@
INSERT INTO "posts" ("id","title","content","status","authorName","createdAt","updatedAt")
VALUES
('post_1','Hello Prisma','First seeded post','DRAFT','Alice',NOW(),NOW()),
('post_2','Published post','Seeded and published','PUBLISHED','Bob',NOW(),NOW())
ON CONFLICT ("id") DO NOTHING;

56
prisma/seedData.ts Normal file
View File

@@ -0,0 +1,56 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../libs/prisma-generated/src/client";
const connectionString = `${process.env.DATABASE_URL}`;
const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });
async function main() {
const alice = await prisma.user.upsert({
where: { email: "alice@prisma.io" },
update: {},
create: {
email: "alice@prisma.io",
name: "Alice",
posts: {
create: {
title: "Check out Prisma with Next.js",
content: "https://www.prisma.io/nextjs",
published: true,
},
},
},
});
const bob = await prisma.user.upsert({
where: { email: "bob@prisma.io" },
update: {},
create: {
email: "bob@prisma.io",
name: "Bob",
posts: {
create: [
{
title: "Check out the code in gitea",
content: "https://gitea.tonssikas.ovh",
published: true,
},
{
title: "A third example card",
content: "https://google.com",
published: true,
},
],
},
},
});
console.log({ alice, bob });
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -27,7 +27,8 @@
"@shared/prisma-generated/src/prisma": [
"libs/prisma-generated/src/prisma.ts"
],
},
"@shared/shared-dto": ["libs/shared-dto/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]
}