chore: update dependencies and add vitest configuration

- Added @analogjs/vite-plugin-angular and @analogjs/vitest-angular to devDependencies.
- Updated @nx/vite to version 20.1.4.
- Added @vitest/coverage-v8 and @vitest/ui to devDependencies.
- Added jsdom to devDependencies.
- Added vite and vitest to devDependencies.
- Updated tsconfig.base.json to include new paths for shared libraries.
- Created vitest.workspace.ts for vitest configuration.

Refs: #5135
This commit is contained in:
Lorenz Hilpert
2025-06-26 22:09:21 +02:00
parent 11cfa4039f
commit 998946157a
80 changed files with 4084 additions and 142 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs"
],
"deny": []
}
}

2
.gitignore vendored
View File

@@ -66,3 +66,5 @@ storybook-static
.github\instructions\nx.instructions.md
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*

View File

@@ -5,7 +5,7 @@
"tabWidth": 2,
"bracketSpacing": true,
"printWidth": 80,
"endOfLine": "crlf",
"endOfLine": "auto",
"arrowParens": "always",
"quoteProps": "consistent",
"overrides": [

View File

@@ -4,5 +4,6 @@
@use "../../../libs/ui/input-controls/src/input-controls.scss";
@use "../../../libs/ui/menu/src/menu.scss";
@use "../../../libs/ui/progress-bar/src/lib/progress-bar.scss";
@use "../../../libs/ui/search-bar/src/search-bar.scss";
@use "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
@use "../../../libs/ui/tooltip/src/tooltip.scss";

View File

@@ -1,2 +1,3 @@
export * from './lib/services';
export * from './lib/schemas';
export * from './lib/models';

View File

@@ -0,0 +1,6 @@
import { AvailabilityDTO } from '@generated/swagger/cat-search-api';
import { Price } from './price';
export interface Availability extends AvailabilityDTO {
price: Price;
}

View File

@@ -1,2 +1,5 @@
export * from './availability';
export * from './item';
export * from './price-value';
export * from './price';
export * from './product';

View File

@@ -1,7 +1,9 @@
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { Product } from './product';
import { Availability } from './availability';
export interface Item extends ItemDTO {
id: number;
product: Product;
catalogAvailability: Availability;
}

View File

@@ -0,0 +1,5 @@
import { PriceValueDTO } from '@generated/swagger/cat-search-api';
export interface PriceValue extends PriceValueDTO {
value: number;
}

View File

@@ -0,0 +1,6 @@
import { PriceDTO } from '@generated/swagger/cat-search-api';
import { PriceValue } from './price-value';
export interface Price extends PriceDTO {
value: PriceValue;
}

View File

@@ -1,9 +1,11 @@
import { z } from 'zod';
export const SearchByTermParamsSchema = z.object({
term: z.string().min(1, 'Search term must not be empty'),
export const SearchByTermSchema = z.object({
searchTerm: z.string().min(1, 'Search term must not be empty'),
skip: z.number().int().min(0).default(0),
take: z.number().int().min(1).max(100).default(20),
});
export type SearchByTermParams = z.infer<typeof SearchByTermParamsSchema>;
export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;

View File

@@ -4,9 +4,10 @@ import { firstValueFrom, map, Observable } from 'rxjs';
import { takeUntilAborted } from '@isa/common/data-access';
import { Item } from '../models';
import {
SearchByTermParams,
SearchByTermParamsSchema,
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
@@ -25,15 +26,15 @@ export class CatalougeSearchService {
}
async searchByTerm(
params: SearchByTermParams,
params: SearchByTermInput,
abortSignal: AbortSignal,
): Promise<Item[]> {
const { term, skip, take } = SearchByTermParamsSchema.parse(params);
): Promise<ListResponseArgs<Item>> {
const { searchTerm, skip, take } = SearchByTermSchema.parse(params);
const req$ = this.#searchService
.SearchSearch({
filter: {
qs: term,
qs: searchTerm,
},
skip,
take,
@@ -46,6 +47,6 @@ export class CatalougeSearchService {
throw new Error(res.message);
}
return res.result as Item[];
return res as ListResponseArgs<Item>;
}
}

View File

@@ -1,3 +1,4 @@
export * from './lib/errors';
export * from './lib/helpers';
export * from './lib/models';
export * from './lib/operators';

View File

@@ -1,3 +1,4 @@
export * from './data-access.error';
export * from './property-is-empty.error';
export * from './property-is-null-or-undefined.error';
export * from './response-args.error';

View File

@@ -0,0 +1,27 @@
import { ResponseArgs } from '../models';
import { DataAccessError } from './data-access.error';
function invalidPropertyErrorMessage(
invalidProperties: Record<string, string> | undefined,
): string | undefined {
if (!invalidProperties || Object.keys(invalidProperties).length === 0) {
return undefined;
}
return Object.entries(invalidProperties)
.map(([key, value]) => `${key}: ${value}`)
.join('; ');
}
export class ResponseArgsError<
T,
> extends DataAccessError<'RESPONSE_ARGS_ERROR'> {
constructor(public readonly responseArgs: Omit<ResponseArgs<T>, 'result'>) {
super(
'RESPONSE_ARGS_ERROR',
responseArgs.message ||
invalidPropertyErrorMessage(responseArgs.invalidProperties) ||
'An error occurred while processing response arguments',
);
}
}

View File

@@ -0,0 +1,13 @@
export function createEscAbortControllerHelper(): AbortController {
const escAbortController = new AbortController();
const escKeyHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
escAbortController.abort();
document.removeEventListener('keydown', escKeyHandler);
}
};
document.addEventListener('keydown', escKeyHandler);
return escAbortController;
}

View File

@@ -0,0 +1 @@
export * from './create-esc-abort-controller.helper';

View File

@@ -15,7 +15,7 @@ export interface ResponseArgs<T> {
* Map of property names to error messages for validation failures
* Keys represent property names, values contain validation error messages
*/
invalidProperties: Record<string, string>;
invalidProperties?: Record<string, string>;
/**
* Optional message providing additional information about the response

View File

@@ -14,7 +14,7 @@ import { PrintDialogComponent } from '../print-dialog/print-dialog.component';
@Injectable({ providedIn: 'root' })
export class PrintService {
#printService = inject(PrintApiService);
#printDailog = injectDialog(PrintDialogComponent, 'Drucken');
#printDailog = injectDialog(PrintDialogComponent, { title: 'Drucken' });
#platform = inject(Platform);
/**

View File

@@ -3,7 +3,7 @@ export function hash(obj: object | string): string {
return obj;
}
const str = JSON.stringify(obj, Object.keys(obj).sort());
const str = JSON.stringify(obj);
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);

View File

@@ -4,12 +4,15 @@ import { z } from 'zod';
import { OAuthService } from 'angular-oauth2-oidc';
import { hash } from './hash.utils';
export const USER_SUB = new InjectionToken<() => string>('core.storage.user-sub', {
factory: () => {
const auth = inject(OAuthService, { optional: true });
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
export const USER_SUB = new InjectionToken<() => string>(
'core.storage.user-sub',
{
factory: () => {
const auth = inject(OAuthService, { optional: true });
return () => auth?.getIdentityClaims()?.['sub'] ?? 'anonymous';
},
},
});
);
export class Storage {
private readonly userSub = inject(USER_SUB);
@@ -25,7 +28,10 @@ export class Storage {
return this.storageProvider.set(this.getKey(token), value);
}
async get<T>(token: string | object, schema?: z.ZodType<T>): Promise<T | unknown> {
async get<T>(
token: string | object,
schema?: z.ZodType<T>,
): Promise<T | unknown> {
const data = await this.storageProvider.get(this.getKey(token));
if (schema) {
return schema.parse(data);

View File

@@ -11,10 +11,9 @@ export class UncompletedTasksGuard
implements CanDeactivate<ReturnReviewComponent>
{
#returnTaskListStore = inject(ReturnTaskListStore);
#confirmationDialog = injectDialog(
ConfirmationDialogComponent,
'Aufgaben erledigen',
);
#confirmationDialog = injectDialog(ConfirmationDialogComponent, {
title: 'Aufgaben erledigen',
});
processId = injectActivatedTabId();

View File

@@ -2,5 +2,4 @@ import { PriceValueDTO } from '@generated/swagger/inventory-api';
export interface PriceValue extends PriceValueDTO {
value: number;
currency: string;
}

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const FetchReturnReasonSchema = z.object({
stockId: z.number(),
});
export type FetchReturnReason = z.infer<typeof FetchReturnReasonSchema>;
export type FetchReturnReasonParams = z.input<typeof FetchReturnReasonSchema>;

View File

@@ -1,5 +1,6 @@
export * from './fetch-product-groups.schema';
export * from './fetch-query-settings.schema';
export * from './fetch-return-reason.schema';
export * from './fetch-stock-in-stock.schema';
export * from './fetch-suppliers.schema';
export * from './fetch-query-settings.schema';
export * from './query-token.schema';
export * from './fetch-product-groups.schema';

View File

@@ -1,4 +1,5 @@
export * from './remission-stock.service';
export * from './remission-product-group.service';
export * from './remission-reason.service';
export * from './remission-search.service';
export * from './remission-stock.service';
export * from './remission-supplier.service';

View File

@@ -0,0 +1,32 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { FetchReturnReasonParams, FetchReturnReasonSchema } from '../schemas';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { KeyValueStringAndString } from '../models';
@Injectable({ providedIn: 'root' })
export class RemissionReasonService {
#returnService = inject(ReturnService);
async fetchReturnReasons(
params: FetchReturnReasonParams,
abortSignal?: AbortSignal,
): Promise<KeyValueStringAndString[]> {
const { stockId } = FetchReturnReasonSchema.parse(params);
let req$ = this.#returnService.ReturnGetReturnReasons({ stockId });
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
throw new ResponseArgsError(res);
}
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -5,13 +5,14 @@ import { Stock, StockInfo } from '../models';
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
import { ASSIGNED_STOCK_STORAGE_KEY } from '../constants';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class RemissionStockService {
#stockService = inject(StockService);
#memoryStorage = injectStorage(MemoryStorageProvider);
async fetchAssignedStock(): Promise<Stock> {
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
const cached = await this.#memoryStorage.get(ASSIGNED_STOCK_STORAGE_KEY);
if (cached) {
@@ -20,10 +21,14 @@ export class RemissionStockService {
const req$ = this.#stockService.StockCurrentStock();
if (abortSignal) {
req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch CurrentStock data');
throw new ResponseArgsError(res);
}
this.#memoryStorage.set(ASSIGNED_STOCK_STORAGE_KEY, res.result);

View File

@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectDialog } from '@isa/ui/dialog';
import { SearchItemToRemitDialogComponent } from '@isa/remission/shared/search-item-to-remit-dialog';
@Component({
selector: 'remission-feature-remission-start-card',
@@ -9,7 +11,9 @@ import { ButtonComponent } from '@isa/ui/buttons';
imports: [ButtonComponent],
})
export class RemissionStartCardComponent {
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
startRemission() {
console.log('Start');
this.searchItemToRemitDialog({ data: { searchTerm: 'Pokemon' } });
}
}

View File

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

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'remi',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'remi',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "remission-shared-search-item-to-remit-dialog",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/remission/shared/search-item-to-remit-dialog/src",
"prefix": "remi",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../../coverage/libs/remission/shared/search-item-to-remit-dialog"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/search-item-to-remit-dialog.component';

View File

@@ -0,0 +1,11 @@
import { Item } from '@isa/catalogue/data-access';
import { ListResponseArgs } from '@isa/common/data-access';
export const DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM: ListResponseArgs<Item> = {
error: false,
hits: 0,
result: [],
skip: 0,
take: 0,
invalidProperties: {},
};

View File

@@ -0,0 +1,21 @@
<div class="isa-text-body-1-bold">Menge {{ position() }}</div>
<div class="-mr-4">
<ui-dropdown [(ngModel)]="quantity" class="border-none">
<ui-dropdown-option [value]="1">1</ui-dropdown-option>
<ui-dropdown-option [value]="2">2</ui-dropdown-option>
<ui-dropdown-option [value]="3">3</ui-dropdown-option>
<ui-dropdown-option [value]="4">4</ui-dropdown-option>
<ui-dropdown-option [value]="5">5</ui-dropdown-option>
</ui-dropdown>
<ui-dropdown [(ngModel)]="reason" class="border-none">
@if (reasonResource.value(); as reasons) {
@for (reson of reasons; track reson.key) {
<ui-dropdown-option [value]="reson.value">
{{ reson.value }}
</ui-dropdown-option>
}
}
<ui-dropdown-option [value]="1">1</ui-dropdown-option>
</ui-dropdown>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-cols-[1fr,auto] items-center justify-between px-4 py-[.44rem] border border-isa-neutral-400 rounded-lg;
}

View File

@@ -0,0 +1,64 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
linkedSignal,
model,
resource,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
RemissionReasonService,
RemissionStockService,
} from '@isa/remission/data-access';
import {
DropdownButtonComponent,
DropdownOptionComponent,
} from '@isa/ui/input-controls';
@Component({
selector: 'remi-quantity-and-reason-item',
templateUrl: './quantity-and-reason-item.component.html',
styleUrls: ['./quantity-and-reason-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [DropdownButtonComponent, DropdownOptionComponent, FormsModule],
})
export class QuantityAndReasonItemComponent {
#reasonService = inject(RemissionReasonService);
#stockService = inject(RemissionStockService);
position = input.required<number>();
quantity = linkedSignal(() => 1);
reason = linkedSignal(() => {
const returnReasons = this.reasonResource.value();
if (!returnReasons || returnReasons.length === 0) {
return undefined;
}
return returnReasons[0].value;
});
assignedStockResource = resource({
loader: ({ abortSignal }) =>
this.#stockService.fetchAssignedStock(abortSignal),
});
reasonResource = resource({
params: this.assignedStockResource.value,
loader: async ({ abortSignal, params }) => {
if (!params || !params.id) {
return [];
}
return this.#reasonService.fetchReturnReasons(
{
stockId: params.id,
},
abortSignal,
);
},
});
}

View File

@@ -0,0 +1,15 @@
@if (item()) {
<remi-select-remi-quantity-and-reason></remi-select-remi-quantity-and-reason>
} @else {
<button
class="absolute top-1 right-[1.33rem]"
type="button"
uiTextButton
siez="small"
color="subtle"
(click)="close(undefined)"
>
Schließen
</button>
<remi-search-item-to-remit-list></remi-search-item-to-remit-list>
}

View File

@@ -0,0 +1,3 @@
:host {
@apply block h-full;
}

View File

@@ -0,0 +1,51 @@
import {
ChangeDetectionStrategy,
Component,
effect,
isSignal,
linkedSignal,
signal,
Signal,
} from '@angular/core';
import { DialogContentDirective } from '@isa/ui/dialog';
import { Item } from '@isa/catalogue/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionSearch } from '@isa/icons';
import { SearchItemToRemitListComponent } from './search-item-to-remit-list.component';
import { SelectRemiQuantityAndReasonComponent } from './select-remi-quantity-and-reason.component';
export type SearchItemToRemitDialogData = {
searchTerm: string | Signal<string>;
};
@Component({
selector: 'remi-search-item-to-remit-dialog',
templateUrl: './search-item-to-remit-dialog.component.html',
styleUrls: ['./search-item-to-remit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
TextButtonComponent,
SearchItemToRemitListComponent,
SelectRemiQuantityAndReasonComponent,
],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitDialogComponent extends DialogContentDirective<
SearchItemToRemitDialogData,
Item | undefined
> {
searchTerm = linkedSignal(() =>
isSignal(this.data.searchTerm)
? this.data.searchTerm()
: this.data.searchTerm,
);
item = signal<Item | undefined>(undefined);
itemEffect = effect(() => {
const item = this.item();
this.dialogRef.updateSize(item ? '36rem' : 'auto');
});
}

View File

@@ -0,0 +1,31 @@
<ui-search-bar appearance="main" class="bg-isa-neutral-100 w-full">
<input
[(ngModel)]="host.searchTerm"
type="text"
placeholder="Rechnungsnummer, E-Mail, Kundenkarte, Name..."
(keydown.enter)="triggerSearch()"
/>
<ui-search-bar-clear></ui-search-bar-clear>
<ui-icon-button
type="submit"
name="isaActionSearch"
color="brand"
(click)="triggerSearch()"
[pending]="searchResource.isLoading()"
></ui-icon-button>
</ui-search-bar>
<p
class="text-isa-neutral-600 isa-text-body-1-regular pb-4 border-b border-b-isa-neutral-300"
>
Sie können Artikel die nicht auf der Remi Liste stehen direkt zum
Warenbegleitschein hinzufügen.
</p>
<div class="overflow-y-auto">
@if (searchResource.value()?.result; as items) {
@for (item of items; track item.id) {
@defer {
<remi-search-item-to-remit [item]="item"></remi-search-item-to-remit>
}
}
}
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-rows-[auto,auto,1fr] gap-6 h-full;
}

View File

@@ -0,0 +1,124 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
resource,
signal,
} from '@angular/core';
import { isaActionSearch } from '@isa/icons';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
UiSearchBarClearComponent,
UiSearchBarComponent,
} from '@isa/ui/search-bar';
import { provideIcons } from '@ng-icons/core';
import { SearchItemToRemitComponent } from './search-item-to-remit.component';
import { FormsModule } from '@angular/forms';
import { DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM } from './constants';
import {
CatalougeSearchService,
SearchByTermInput,
Item,
} from '@isa/catalogue/data-access';
import {
ListResponseArgs,
createEscAbortControllerHelper,
} from '@isa/common/data-access';
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
@Component({
selector: 'remi-search-item-to-remit-list',
templateUrl: './search-item-to-remit-list.component.html',
styleUrls: ['./search-item-to-remit-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
UiSearchBarClearComponent,
UiSearchBarComponent,
IconButtonComponent,
FormsModule,
SearchItemToRemitComponent,
],
providers: [provideIcons({ isaActionSearch })],
})
export class SearchItemToRemitListComponent implements OnInit {
host = inject(SearchItemToRemitDialogComponent);
#catalougeSearchService = inject(CatalougeSearchService);
#memoryStorage = injectStorage(MemoryStorageProvider);
searchParams = signal<SearchByTermInput | undefined>(undefined);
#setSearchResourceCache(
params: SearchByTermInput,
data: ListResponseArgs<Item>,
): Promise<void> {
return this.#memoryStorage.set(
{
component: 'SearchItemToRemitDialogComponent',
params,
},
data,
);
}
#getSearchResourceCache(
params: SearchByTermInput,
): Promise<ListResponseArgs<Item> | undefined> {
return this.#memoryStorage.get({
component: 'SearchItemToRemitDialogComponent',
params,
}) as Promise<ListResponseArgs<Item> | undefined>;
}
triggerSearch(): void {
this.searchParams.set({
searchTerm: this.host.searchTerm(),
});
}
searchResource = resource({
params: this.searchParams,
loader: async ({ abortSignal, params }) => {
if (params === undefined) {
return DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM;
}
let result = await this.#getSearchResourceCache(params);
if (result) {
return result;
}
const escAbortController = createEscAbortControllerHelper();
try {
result = await this.#catalougeSearchService.searchByTerm(
params,
AbortSignal.any([abortSignal, escAbortController.signal]),
);
this.#setSearchResourceCache(params, result);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error';
result = {
...DEFAULT_LIST_RESPONSE_ARGS_OF_ITEM,
error: true,
message,
};
} finally {
escAbortController.abort();
}
return result;
},
});
ngOnInit(): void {
this.host.dialog.title.set('');
if (this.host.searchTerm().length) {
this.triggerSearch();
}
}
}

View File

@@ -0,0 +1,17 @@
<remi-product-info
[item]="{
product: item().product,
retailPrice: item().catalogAvailability.price,
}"
></remi-product-info>
<div class="text-right">
<button
class="-mr-5"
type="button"
uiTextButton
color="strong"
(click)="host.item.set(item())"
>
Remimenge auswählen
</button>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row border-b border-b-isa-neutral-300 pt-6 pb-4 gap-4;
}

View File

@@ -0,0 +1,23 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core';
import { Item } from '@isa/catalogue/data-access';
import { ProductInfoComponent } from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
@Component({
selector: 'remi-search-item-to-remit',
templateUrl: './search-item-to-remit.component.html',
styleUrls: ['./search-item-to-remit.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ProductInfoComponent, TextButtonComponent],
})
export class SearchItemToRemitComponent {
host = inject(SearchItemToRemitDialogComponent);
item = input.required<Item>();
}

View File

@@ -0,0 +1,22 @@
<p class="text-isa-neutral-600 isa-text-body-1-regular">
Wie viele Exemplare können remittiert werden?
</p>
<div>
<remi-quantity-and-reason-item [position]="1"></remi-quantity-and-reason-item>
</div>
<div>
<button
type="button"
class="flex items-center gap-2 -ml-5"
uiTextButton
color="strong"
>
<ng-icon name="isaActionPlus" size="1.5rem"></ng-icon>
<div>Menge hinzufügen</div>
</button>
</div>
<div></div>
<div class="grid grid-cols-2 items-center gap-2">
<button type="button" color="secondary" size="large" uiButton>Zurück</button>
<button type="button" color="primary" size="large" uiButton>Speichern</button>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-6;
}

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
} from '@angular/core';
import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component';
import { QuantityAndReasonItemComponent } from './quantity-and-reason-item.component';
import { ButtonComponent, TextButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionPlus } from '@isa/icons';
@Component({
selector: 'remi-select-remi-quantity-and-reason',
templateUrl: './select-remi-quantity-and-reason.component.html',
styleUrls: ['./select-remi-quantity-and-reason.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
QuantityAndReasonItemComponent,
TextButtonComponent,
NgIcon,
ButtonComponent,
],
providers: [provideIcons({ isaActionPlus })],
})
export class SelectRemiQuantityAndReasonComponent implements OnInit {
host = inject(SearchItemToRemitDialogComponent);
ngOnInit(): void {
this.host.dialog.title.set('Dieser Artikel steht nicht auf der Remi Liste');
}
}

View File

@@ -0,0 +1,12 @@
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

View File

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

View File

@@ -0,0 +1,28 @@
{
"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",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig({
root: __dirname,
cacheDir:
'../../../../node_modules/.vite/libs/remission/shared/search-item-to-remit-dialog',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory:
'../../../../coverage/libs/remission/shared/search-item-to-remit-dialog',
provider: 'v8',
},
},
});

View File

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

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'remi',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'remi',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "remission-shared-select-remission-quantity-and-reason-dialog",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/remission/shared/select-remission-quantity-and-reason-dialog/src",
"prefix": "remi",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../../coverage/libs/remission/shared/select-remission-quantity-and-reason-dialog"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/remission-shared-select-remission-quantity-and-reason-dialog/remission-shared-select-remission-quantity-and-reason-dialog.component';

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RemissionSharedSelectRemissionQuantityAndReasonDialogComponent } from './remission-shared-select-remission-quantity-and-reason-dialog.component';
describe('RemissionSharedSelectRemissionQuantityAndReasonDialogComponent', () => {
let component: RemissionSharedSelectRemissionQuantityAndReasonDialogComponent;
let fixture: ComponentFixture<RemissionSharedSelectRemissionQuantityAndReasonDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionSharedSelectRemissionQuantityAndReasonDialogComponent],
}).compileComponents();
fixture = TestBed.createComponent(
RemissionSharedSelectRemissionQuantityAndReasonDialogComponent,
);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'remi-remission-shared-select-remission-quantity-and-reason-dialog',
standalone: true,
imports: [CommonModule],
templateUrl:
'./remission-shared-select-remission-quantity-and-reason-dialog.component.html',
styleUrl:
'./remission-shared-select-remission-quantity-and-reason-dialog.component.css',
})
export class RemissionSharedSelectRemissionQuantityAndReasonDialogComponent {}

View File

@@ -0,0 +1,12 @@
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting,
} from '@angular/platform-browser-dynamic/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting(),
);

View File

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

View File

@@ -0,0 +1,28 @@
{
"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",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig({
root: __dirname,
cacheDir:
'../../../../node_modules/.vite/libs/remission/shared/select-remission-quantity-and-reason-dialog',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory:
'../../../../coverage/libs/remission/shared/select-remission-quantity-and-reason-dialog',
provider: 'v8',
},
},
});

View File

@@ -37,7 +37,6 @@ import { isaLoading } from '@isa/icons';
'[tabindex]': 'tabIndex()',
'[attr.aria-disabled]': 'disabled()',
'[attr.aria-label]': 'name()',
'[attr.aria-hidden]': 'pending()',
'[attr.aria-busy]': 'pending()',
'[attr.role]': 'pending() ? "progressbar" : "button"',
},

View File

@@ -1,11 +1,16 @@
.ui-dialog {
@apply bg-isa-white p-8 flex gap-8 items-start rounded-[2rem] flex-col text-isa-neutral-900;
@apply bg-isa-white p-8 grid gap-8 items-start rounded-[2rem] grid-flow-row text-isa-neutral-900 relative;
@apply max-h-[90vh] max-w-[90vw] overflow-hidden;
grid-template-rows: auto 1fr;
.ui-dialog-title {
@apply isa-text-subtitle-1-bold;
@apply flex-shrink-0;
}
.ui-dialog-content {
@apply flex flex-col gap-8;
@apply overflow-y-auto overflow-x-hidden;
@apply min-h-0;
}
}

View File

@@ -1,5 +1,6 @@
import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { Directive, inject } from '@angular/core';
import { DialogComponent } from './dialog.component';
/**
* Base directive for dialog content components
@@ -14,6 +15,8 @@ import { Directive, inject } from '@angular/core';
},
})
export abstract class DialogContentDirective<D, R> {
readonly dialog = inject(DialogComponent<D, R, DialogContentDirective<D, R>>);
/** Reference to the dialog instance */
readonly dialogRef = inject(DialogRef<R, DialogContentDirective<D, R>>);

View File

@@ -1,5 +1,5 @@
<h2 class="ui-dialog-title" data-what="title">
{{ title }}
{{ title() }}
</h2>
<ng-container *ngComponentOutlet="component"> </ng-container>

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { ComponentType } from '@angular/cdk/portal';
@@ -23,7 +28,7 @@ import { NgComponentOutlet } from '@angular/common';
})
export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
/** The title to display at the top of the dialog */
title = inject(DIALOG_TITLE);
title = signal(inject(DIALOG_TITLE));
/** The component type to instantiate as the dialog content */
readonly component = inject(DIALOG_CONTENT) as ComponentType<C>;

View File

@@ -7,13 +7,40 @@ import { DialogComponent } from './dialog.component';
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
export interface InjectDialogOptions {
/** Optional title override for the dialog */
title?: string;
/** Optional width for the dialog */
width?: string;
/** Optional height for the dialog */
height?: string;
/** Optional minWidth for the dialog */
minWidth?: string;
/** Optional maxWidth for the dialog */
maxWidth?: string;
/** Optional minHeight for the dialog */
minHeight?: string;
/** Optional maxHeight for the dialog */
maxHeight?: string;
/** Optional hasBackdrop for the dialog */
hasBackdrop?: boolean;
/** Optional disableClose for the dialog */
disableClose?: boolean;
}
/**
* Options for opening a dialog using injectDialog function
* @template D The type of data passed to the dialog
*/
export interface OpenDialogOptions<D> {
/** Optional title override for the dialog */
title?: string;
export interface OpenDialogOptions<D> extends InjectDialogOptions {
/** Data to pass to the dialog component */
data: D;
}
@@ -29,7 +56,7 @@ export interface OpenDialogOptions<D> {
*/
export function injectDialog<C extends DialogContentDirective<any, any>>(
componentType: ComponentType<C>,
title?: string,
injectOptions?: InjectDialogOptions,
) {
type D = C extends DialogContentDirective<infer D, any> ? D : never;
type R = C extends DialogContentDirective<any, infer R> ? R : never;
@@ -37,7 +64,7 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
const cdkDialog = inject(Dialog);
const injector = inject(Injector);
return (options?: OpenDialogOptions<D>) => {
return (openOptions?: OpenDialogOptions<D>) => {
const dialogInjector = Injector.create({
parent: injector,
providers: [
@@ -47,7 +74,7 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
},
{
provide: DIALOG_TITLE,
useValue: options?.title ?? title,
useValue: openOptions?.title ?? injectOptions?.title,
},
],
});
@@ -55,11 +82,18 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
const dialogRef = cdkDialog.open<R, D, DialogComponent<D, R, C>>(
DialogComponent<D, R, C>,
{
data: options?.data,
data: openOptions?.data,
injector: dialogInjector,
width: '30rem',
hasBackdrop: true,
disableClose: true,
width: openOptions?.width ?? injectOptions?.width,
height: openOptions?.height ?? injectOptions?.height,
minWidth: openOptions?.minWidth ?? injectOptions?.minWidth ?? '30rem',
maxWidth: openOptions?.maxWidth ?? injectOptions?.maxWidth,
minHeight: openOptions?.minHeight ?? injectOptions?.minHeight,
maxHeight: openOptions?.maxHeight ?? injectOptions?.maxHeight,
hasBackdrop:
openOptions?.hasBackdrop ?? injectOptions?.hasBackdrop ?? true,
disableClose:
openOptions?.disableClose ?? injectOptions?.disableClose ?? true,
},
);

View File

@@ -20,7 +20,6 @@ export type SearchbarAppearance =
@Component({
selector: 'ui-search-bar',
templateUrl: './search-bar.component.html',
styleUrl: './search-bar.component.scss',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {

View File

@@ -5,13 +5,13 @@
display: inline-flex;
}
&:has(input[type='text']:placeholder-shown) {
&:has(input[type="text"]:placeholder-shown) {
.ui-search-bar__action__close {
display: none;
}
}
input[type='text'] {
input[type="text"] {
appearance: none;
flex-grow: 1;
@@ -19,7 +19,7 @@
font-style: normal;
font-weight: 700;
line-height: 1.25rem; /* 142.857% */
@apply text-isa-neutral-900;
background-color: inherit;
&::placeholder {
@apply text-isa-neutral-500;
@@ -57,7 +57,7 @@
@apply pr-4;
}
&:has(input[type='text']:placeholder-shown) {
&:has(input[type="text"]:placeholder-shown) {
@apply pl-0;
button[prefix][uiIconButton] {
@@ -65,10 +65,10 @@
}
}
&:has(input[type='text']) {
&:has(input[type="text"]) {
@apply pl-4;
input[type='text'] {
input[type="text"] {
@apply pr-4 whitespace-nowrap overflow-hidden overflow-ellipsis;
}

View File

@@ -52,6 +52,10 @@
"cache": true,
"dependsOn": ["^build"],
"inputs": ["production", "^production"]
},
"@nx/vite:test": {
"cache": true,
"inputs": ["default", "^production"]
}
},
"defaultBase": "develop",

3074
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,8 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@analogjs/vite-plugin-angular": "~1.9.1",
"@analogjs/vitest-angular": "~1.9.1",
"@angular-devkit/build-angular": "20.0.3",
"@angular-devkit/core": "20.0.2",
"@angular-devkit/schematics": "20.0.2",
@@ -73,6 +75,7 @@
"@nx/jest": "21.2.0",
"@nx/js": "21.2.0",
"@nx/storybook": "21.2.0",
"@nx/vite": "20.1.4",
"@nx/web": "21.2.0",
"@nx/workspace": "21.2.0",
"@schematics/angular": "20.0.2",
@@ -88,6 +91,8 @@
"@types/node": "18.16.9",
"@types/uuid": "^10.0.0",
"@typescript-eslint/utils": "^8.19.0",
"@vitest/coverage-v8": "^1.0.4",
"@vitest/ui": "^1.3.1",
"angular-eslint": "^19.2.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.8.0",
@@ -99,6 +104,7 @@
"jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0",
"jest-preset-angular": "14.6.0",
"jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.13.5",
"ng-packagr": "20.0.1",
@@ -113,7 +119,9 @@
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",
"typescript": "5.8.3",
"typescript-eslint": "^8.19.0"
"typescript-eslint": "^8.19.0",
"vite": "^5.0.0",
"vitest": "^1.3.1"
},
"optionalDependencies": {
"@esbuild/linux-x64": "^0.25.5"

View File

@@ -75,11 +75,17 @@
],
"@isa/remission/helpers": ["libs/remission/helpers/src/index.ts"],
"@isa/remission/shared": ["libs/remission/shared/src/index.ts"],
"@isa/remission/shared/product": [
"libs/remission/shared/product/src/index.ts"
],
"@isa/remission/shared/product-details": [
"libs/remission/shared/product-details/src/index.ts"
],
"@isa/remission/shared/product": [
"libs/remission/shared/product/src/index.ts"
"@isa/remission/shared/search-item-to-remit-dialog": [
"libs/remission/shared/search-item-to-remit-dialog/src/index.ts"
],
"@isa/remission/shared/select-remission-quantity-and-reason-dialog": [
"libs/remission/shared/select-remission-quantity-and-reason-dialog/src/index.ts"
],
"@isa/shared/filter": ["libs/shared/filter/src/index.ts"],
"@isa/shared/product-foramt": ["libs/shared/product-format/src/index.ts"],

1
vitest.workspace.ts Normal file
View File

@@ -0,0 +1 @@
export default ['**/*/vite.config.{ts,mts}', '**/*/vitest.config.{ts,mts}'];