Merged PR 1866: Anlage Komponenten und Directives + Unit Tests und Stories

Related work items: #5175
This commit is contained in:
Lorenz Hilpert
2025-06-18 13:58:00 +00:00
committed by Nino Righi
parent b21ebac53f
commit 4cf0ce820e
60 changed files with 2035 additions and 4884 deletions

View File

@@ -2,8 +2,11 @@ import type { StorybookConfig } from '@storybook/angular';
const config: StorybookConfig = {
stories: ['../stories/**/*.@(mdx|stories.@(js|jsx|ts|tsx))'],
addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'],
staticDirs: ['../src/assets'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-docs',
],
previewHead: (head) => `
${head}
<link href="/assets/fonts/fonts.css" rel="stylesheet" />

View File

@@ -118,7 +118,19 @@
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": ["apps/isa-app/src/ui.scss", "apps/isa-app/src/styles.scss"]
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/ui.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
@@ -133,33 +145,18 @@
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/ui.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"test-storybook": {
"executor": "nx:run-commands",
"options": {
"command": "test-storybook -c apps/isa-app/.storybook --url=http://localhost:4400"
}
},
"static-storybook": {
"executor": "@nx/web:file-server",
"dependsOn": ["build-storybook"],
"options": {
"buildTarget": "isa-app:build-storybook",
"staticFilePath": "dist/storybook/isa-app",
"spa": true
},
"configurations": {
"ci": {
"buildTarget": "isa-app:build-storybook:ci"
}
}
}
}
}

View File

@@ -1,7 +1,8 @@
@use '../../../libs/ui/buttons/src/buttons.scss';
@use '../../../libs/ui/datepicker/src/datepicker.scss';
@use '../../../libs/ui/dialog/src/dialog.scss';
@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/tooltip/src/tooltip.scss';
@use "../../../libs/ui/buttons/src/buttons.scss";
@use "../../../libs/ui/datepicker/src/datepicker.scss";
@use "../../../libs/ui/dialog/src/dialog.scss";
@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/skeleton-loader/src/skeleton-loader.scss";
@use "../../../libs/ui/tooltip/src/tooltip.scss";

View File

@@ -0,0 +1,100 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import {
ProductInfoComponent,
ProductInfoItem,
ProductInfoOrientation,
} from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { provideRouter } from '@angular/router';
type ProductInfoInputs = {
item: ProductInfoItem;
orientation: ProductInfoOrientation;
};
const meta: Meta<ProductInfoInputs> = {
component: ProductInfoComponent,
title: 'remission/shared/product/ProductInfoComponent',
decorators: [
applicationConfig({
providers: [
provideRouter([{ path: ':ean', component: ProductInfoComponent }]),
],
}),
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
item: {
product: {
contributors: 'John Doe',
ean: '9783742327529',
format: 'TB',
formatDetail: 'Taschenbuch',
name: 'Test Product',
},
retailPrice: {
value: {
currency: 'EUR',
value: 19.99,
},
},
},
orientation: 'horizontal',
},
argTypes: {
item: {
control: 'object',
description: 'The product item to display.',
},
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'The orientation of the product info display.',
table: {
defaultValue: {
summary: 'horizontal',
},
},
},
},
render: (args) => ({
props: args,
template: `<remi-product-info ${argsToTemplate(args)}></remi-product-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductInfoInputs>;
export const Default: Story = {
args: {
item: {
product: {
contributors: 'John Doe',
ean: '9783742327529',
format: 'HC',
formatDetail: 'Hardcover (gebunden)',
name: 'Sample Product',
},
retailPrice: {
value: {
currency: 'EUR',
value: 29.99,
},
},
},
},
};

View File

@@ -0,0 +1,50 @@
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductStockInfoComponent } from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
const meta: Meta<ProductStockInfoComponent> = {
component: ProductStockInfoComponent,
title: 'remission/shared/product/ProductStockInfoComponent',
decorators: [
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
stock: 100,
stockToRemit: 20,
},
argTypes: {
stock: {
control: { type: 'number' },
description: 'The current stock of the product.',
defaultValue: undefined,
},
stockToRemit: {
control: { type: 'number' },
description: 'The amount of stock to remit.',
defaultValue: undefined,
},
},
render: (args) => ({
props: args,
template: `<remi-product-stock-info ${argsToTemplate({ info: args })}></remi-product-stock-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductStockInfoComponent>;
export const Default: Story = {
args: {},
};

View File

@@ -0,0 +1,39 @@
import { Meta, argsToTemplate } from '@storybook/angular';
import { ProductFormatIconGroup } from '@isa/icons';
import { ProductFormatIconComponent } from '@isa/shared/product-foramt';
type ProductFormatInputs = {
format: string;
};
const options = Object.keys(ProductFormatIconGroup).map((key) =>
key.toUpperCase(),
);
const meta: Meta<ProductFormatInputs> = {
title: 'shared/product-format/ProductFormatIcon',
component: ProductFormatIconComponent,
argTypes: {
format: {
control: {
type: 'select',
},
options,
description: 'The product format to display the icon for.',
defaultValue: options[0],
},
},
args: {
format: options[0], // Default value for the product format
},
render: (args) => ({
props: args,
template: `<shared-product-format-icon ${argsToTemplate(args)}></shared-product-format-icon>`,
}),
};
export default meta;
type Story = typeof meta;
export const Default: Story = {};

View File

@@ -0,0 +1,48 @@
import { argsToTemplate, Meta } from '@storybook/angular';
import { ProductFormatIconGroup } from '@isa/icons';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
type ProductFormatInputs = {
format: string;
formatDetail: string;
};
const options = Object.keys(ProductFormatIconGroup).map((key) =>
key.toUpperCase(),
);
const meta: Meta<ProductFormatInputs> = {
title: 'shared/product-format/ProductFormat',
component: ProductFormatComponent,
argTypes: {
format: {
control: {
type: 'select',
},
options,
description: 'The product format to display the icon for.',
defaultValue: options[0],
},
formatDetail: {
control: {
type: 'text',
},
description: 'The detail text for the product format.',
defaultValue: 'Default Format Detail',
},
},
args: {
format: options[0], // Default value for the product format
formatDetail: 'Default Format Detail', // Default value for the format detail
},
render: (args) => ({
props: args,
template: `<shared-product-format ${argsToTemplate(args)}></shared-product-format>`,
}),
};
export default meta;
type Story = typeof meta;
export const Default: Story = {};

View File

@@ -1,5 +1,13 @@
import { argsToTemplate, type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { ProductImageDirective, provideProductImageUrl } from '@isa/shared/product-image';
import {
argsToTemplate,
type Meta,
type StoryObj,
moduleMetadata,
} from '@storybook/angular';
import {
ProductImageDirective,
provideProductImageUrl,
} from '@isa/shared/product-image';
type ProductImageInputs = {
ean: string;
@@ -13,7 +21,9 @@ const meta: Meta<ProductImageInputs> = {
decorators: [
moduleMetadata({
imports: [ProductImageDirective],
providers: [provideProductImageUrl('https://produktbilder-test.paragon-data.net')],
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
],
}),
],
argTypes: {

View File

@@ -1,5 +1,9 @@
import { type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { ClientRowComponent, ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import {
ClientRowComponent,
ClientRowImports,
ItemRowDataImports,
} from '@isa/ui/item-rows';
const meta: Meta<ClientRowComponent> = {
component: ClientRowComponent,

View File

@@ -0,0 +1,33 @@
import { type Meta, type StoryObj } from '@storybook/angular';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
type SkeletonLoaderDirectiveInputs = {
uiSkeletonLoader: boolean;
uiSkeletonLoaderWidth?: string;
uiSkeletonLoaderHeight?: string;
};
const meta: Meta<SkeletonLoaderDirectiveInputs> = {
component: SkeletonLoaderDirective,
title: 'ui/skeleton-loader/SkeletonLoaderDirective',
args: {
uiSkeletonLoader: false,
uiSkeletonLoaderWidth: '100px',
uiSkeletonLoaderHeight: '',
},
argTypes: {
uiSkeletonLoader: { control: 'boolean' },
uiSkeletonLoaderWidth: { control: 'text' },
uiSkeletonLoaderHeight: { control: 'text' },
},
render: (args) => ({
props: args,
template: `<div *uiSkeletonLoader="${args.uiSkeletonLoader}; width: '${args.uiSkeletonLoaderWidth}'; height: '${args.uiSkeletonLoaderHeight}'"> This is my content! </div>`,
}),
};
export default meta;
type Story = StoryObj<SkeletonLoaderDirectiveInputs>;
export const Default: Story = {};

View File

@@ -0,0 +1,16 @@
import { type Meta, type StoryObj } from '@storybook/angular';
import { SkeletonLoaderComponent } from '@isa/ui/skeleton-loader';
const meta: Meta<SkeletonLoaderComponent> = {
component: SkeletonLoaderComponent,
title: 'ui/skeleton-loader/SkeletonLoader',
render: () => ({
template: `<ui-skeleton-loader class="w-24"></ui-skeleton-loader>`,
}),
};
export default meta;
type Story = StoryObj<SkeletonLoaderComponent>;
export const Default: Story = {};

54
eslint.config.js Normal file
View File

@@ -0,0 +1,54 @@
const nx = require('@nx/eslint-plugin');
const eslintConfigPrettier = require('eslint-config-prettier/flat');
module.exports = [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
...nx.configs['flat/javascript'],
eslintConfigPrettier,
{
ignores: ['**/dist'],
},
// {
// files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// rules: {
// '@nx/enforce-module-boundaries': [
// 'error',
// {
// enforceBuildableLibDependency: true,
// allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?js$'],
// depConstraints: [
// {
// sourceTag: '*',
// onlyDependOnLibsWithTags: ['*'],
// },
// ],
// },
// ],
// },
// },
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {
'prettier/prettier': 'error',
},
plugins: {
prettier: require('eslint-plugin-prettier'),
},
},
{
files: ['**/*.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs}'],
},
],
},
languageOptions: {
parser: require('jsonc-eslint-parser'),
},
},
];

View File

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

View File

@@ -0,0 +1,4 @@
export * from './price-value';
export * from './price';
export * from './product';
export * from './return-item';

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { ProductDTO } from '@generated/swagger/inventory-api';
export interface Product extends ProductDTO {
name: string;
contributors: string;
ean: string;
format: string;
formatDetail: string;
}

View File

@@ -0,0 +1,8 @@
import { ReturnItemDTO } from '@generated/swagger/inventory-api';
import { Product } from './product';
import { Price } from './price';
export interface ReturnItem extends ReturnItemDTO {
product: Product;
retailPrice: Price;
}

View File

@@ -1 +0,0 @@
export const greeting = 'Hello World!';

View File

@@ -0,0 +1,2 @@
export * from './lib/product-info/product-info.component';
export * from './lib/product-stock-info/product-stock-info.component';

View File

@@ -0,0 +1,46 @@
@let product = item().product;
@let price = item().retailPrice;
@let horizontal = orientation() === 'horizontal';
<div>
<img
class="w-full h-auto object-contain"
sharedProductRouterLink
sharedProductImage
[ean]="product.ean"
[alt]="product.name"
data-what="product-image"
/>
</div>
<div
class="grid"
[class.grid-cols-[minmax(20rem,1fr),auto]]="horizontal"
[class.gap-6]="horizontal"
[class.grid-flow-row]="!horizontal"
[class.gap-2]="!horizontal"
>
<div class="grid grid-flow-row gap-2">
<div class="isa-text-body-2-bold" data-what="product-contributors">
{{ product.contributors }}
</div>
<div class="isa-text-subtitle-1-regular" data-what="product-name">
{{ product.name }}
</div>
<div class="isa-text-body-2-bold" data-what="product-price">
{{ price.value.value | currency: price.value.currencySymbol }}
</div>
</div>
<div class="flex flex-col w-full gap-2 items-start justify-end">
<shared-product-format
[format]="product.format"
[formatDetail]="product.formatDetail"
data-what="product-format"
></shared-product-format>
<div
class="isa-text-body-2-regular text-isa-neutral-600"
data-what="product-ean"
>
{{ product.ean }}
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import {
ProductInfoComponent,
ProductInfoItem,
} from './product-info.component';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { MockDirectives } from 'ng-mocks';
describe('ProductInfoComponent', () => {
let spectator: Spectator<ProductInfoComponent>;
const createComponent = createComponentFactory({
component: ProductInfoComponent,
overrideComponents: [
[
ProductInfoComponent,
{
remove: {
imports: [ProductImageDirective, ProductRouterLinkDirective],
},
add: {
imports: MockDirectives(
ProductImageDirective,
ProductRouterLinkDirective,
),
},
},
],
],
});
const mockItem: ProductInfoItem = {
product: {
ean: '1234567890123',
name: 'Test Product',
contributors: '',
format: '',
formatDetail: '',
},
retailPrice: {
value: { value: 19.99, currency: 'EUR' },
},
};
beforeEach(() => {
spectator = createComponent({ props: { item: mockItem } });
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should set data-what and data-which attributes', () => {
const host = spectator.element;
expect(host).toHaveAttribute('data-what', 'product-info');
expect(host).toHaveAttribute('data-which', 'remission-product');
});
it('should set data-ean attribute from item.product.ean', () => {
const host = spectator.element;
expect(host).toHaveAttribute('data-ean', mockItem.product.ean);
});
// Add more tests for template rendering if template is available
});

View File

@@ -0,0 +1,39 @@
import { CurrencyPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { ReturnItem } from '@isa/remission/data-access';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
export type ProductInfoItem = Pick<ReturnItem, 'product' | 'retailPrice'>;
export type ProductInfoOrientation = 'horizontal' | 'vertical';
@Component({
selector: 'remi-product-info',
templateUrl: 'product-info.component.html',
imports: [
ProductImageDirective,
ProductRouterLinkDirective,
CurrencyPipe,
ProductFormatComponent,
],
host: {
'[class]': 'classList',
'data-what': 'product-info',
'data-which': 'remission-product',
'[attr.data-ean]': 'item().product.ean',
},
})
export class ProductInfoComponent {
readonly classList: ReadonlyArray<string> = [
'grid',
'grid-cols-[3.5rem,1fr]',
'gap-6',
'text-isa-neutral-900',
];
item = input.required<ProductInfoItem>();
orientation = input<ProductInfoOrientation>('horizontal');
}

View File

@@ -0,0 +1,56 @@
<div
class="product-stock-info-row"
data-what="stock-info-row"
data-which="current-stock"
>
<div data-what="stock-label" data-which="current-stock">
Aktueller Bestand
</div>
<div
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="current-stock"
>
{{ stock() }}x
</div>
</div>
<div
class="product-stock-info-row"
data-what="stock-info-row"
data-which="remit-amount"
>
<div data-what="stock-label" data-which="remit-amount">Remi Menge</div>
<div
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="remit-amount"
>
{{ stockToRemit() }}x
</div>
</div>
<div
class="product-stock-info-row"
data-what="stock-info-row"
data-which="remaining-stock"
>
<div data-what="stock-label" data-which="remaining-stock">
Übriger Bestand
</div>
<div
class="isa-text-body-2-bold"
data-what="stock-value"
data-which="remaining-stock"
>
{{ targetStock() }}x
</div>
</div>
<div class="product-stock-info-row" data-what="stock-info-row" data-which="zob">
<div data-what="stock-label" data-which="zob">ZOB</div>
<div
class="isa-text-body-2-bold grid-flow-row"
data-what="stock-value"
data-which="zob"
>
{{ zob() }}x
</div>
</div>

View File

@@ -0,0 +1,7 @@
:host {
@apply grid grid-flow-row gap-2 text-isa-neutral-900;
}
.product-stock-info-row {
@apply flex items-center gap-2 justify-between;
}

View File

@@ -0,0 +1,67 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ProductStockInfoComponent } from './product-stock-info.component';
describe('ProductStockInfoComponent', () => {
let spectator: Spectator<ProductStockInfoComponent>;
const createComponent = createComponentFactory(ProductStockInfoComponent);
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should display the current stock', () => {
spectator.setInput('stock', 42);
spectator.detectChanges();
const value = spectator.query(
'[data-what="stock-value"][data-which="current-stock"]',
);
expect(value).toHaveText('42x');
});
it('should display the remit amount', () => {
spectator.setInput('stockToRemit', 7);
spectator.detectChanges();
const value = spectator.query(
'[data-what="stock-value"][data-which="remit-amount"]',
);
expect(value).toHaveText('7x');
});
it('should display the remaining stock (targetStock)', () => {
spectator.setInput('stock', 20);
spectator.setInput('stockToRemit', 5);
spectator.detectChanges();
const value = spectator.query(
'[data-what="stock-value"][data-which="remaining-stock"]',
);
expect(value).toHaveText('15x');
});
it('should display the zob value', () => {
spectator.component.zob.set(99);
spectator.detectChanges();
const value = spectator.query(
'[data-what="stock-value"][data-which="zob"]',
);
expect(value).toHaveText('99x');
});
it('should render all labels with correct e2e attributes', () => {
const labels = [
{ which: 'current-stock', text: 'Aktueller Bestand' },
{ which: 'remit-amount', text: 'Remi Menge' },
{ which: 'remaining-stock', text: 'Übriger Bestand' },
{ which: 'zob', text: 'ZOB' },
];
labels.forEach(({ which, text }) => {
const label = spectator.query(
`[data-what="stock-label"][data-which="${which}"]`,
);
expect(label).toHaveText(text);
});
});
});

View File

@@ -0,0 +1,27 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from '@angular/core';
@Component({
selector: 'remi-product-stock-info',
templateUrl: './product-stock-info.component.html',
styleUrls: ['./product-stock-info.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProductStockInfoComponent {
stock = input<number>(0);
stockToRemit = input<number>(0);
targetStock = computed(() => {
const stock = this.stock();
const stockToRemit = this.stockToRemit();
return stock - stockToRemit;
});
zob = signal(0);
}

View File

@@ -0,0 +1,7 @@
# shared-product-format
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test shared-product-format` 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: 'shared',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shared',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'shared-product-format',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/product-format',
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": "shared-product-format",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/product-format/src",
"prefix": "shared",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/product-format/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/product-format-icon.component';
export * from './lib/product-format.component';

View File

@@ -0,0 +1,30 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ProductFormatIconComponent } from './product-format-icon.component';
import { NgIcon } from '@ng-icons/core';
describe('ProductFormatIconComponent', () => {
let spectator: Spectator<ProductFormatIconComponent>;
const createComponent = createComponentFactory({
component: ProductFormatIconComponent,
});
beforeEach(() => {
spectator = createComponent({ props: { format: 'HC' } });
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should render ng-icon with lowercased format as name', () => {
const icon = spectator.query(NgIcon);
expect(icon).toBeTruthy();
expect(icon?.name()).toBe('hc');
});
it('should set correct data attributes on host', () => {
const host = spectator.element;
expect(host).toHaveAttribute('data-what', 'product-format-icon');
expect(host).toHaveAttribute('data-which', 'shared-product-format-icon');
});
});

View File

@@ -0,0 +1,20 @@
import { Component, computed, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { ProductFormatIconGroup } from '@isa/icons';
@Component({
selector: 'shared-product-format-icon',
template: `<ng-icon [name]="formatLowerCase()" size="1.5rem"></ng-icon>`,
imports: [NgIcon],
providers: [provideIcons(ProductFormatIconGroup)],
host: {
'[class]': '["inline-block","text-isa-neutral-900", "size-6"]',
'data-what': 'product-format-icon',
'data-which': 'shared-product-format-icon',
},
})
export class ProductFormatIconComponent {
format = input.required<string>();
formatLowerCase = computed(() => this.format().toLowerCase());
}

View File

@@ -0,0 +1,38 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ProductFormatComponent } from './product-format.component';
import { ProductFormatIconComponent } from './product-format-icon.component';
describe('ProductFormatComponent', () => {
let spectator: Spectator<ProductFormatComponent>;
const createComponent = createComponentFactory({
component: ProductFormatComponent,
imports: [ProductFormatIconComponent],
});
beforeEach(() => {
spectator = createComponent({
props: { format: 'HC', formatDetail: 'Hardcover' },
});
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
it('should render ProductFormatIconComponent with correct format', () => {
const icon = spectator.query(ProductFormatIconComponent);
expect(icon).toBeTruthy();
// Check input binding
expect(icon?.format()).toBe('HC');
});
it('should render the formatDetail text', () => {
expect(spectator.query('span')).toHaveText('Hardcover');
});
it('should set correct data attributes on host', () => {
const host = spectator.element;
expect(host).toHaveAttribute('data-what', 'product-format');
expect(host).toHaveAttribute('data-which', 'shared-product-format');
});
});

View File

@@ -0,0 +1,24 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ProductFormatIconComponent } from './product-format-icon.component';
@Component({
selector: 'shared-product-format',
template: `<shared-product-format-icon
[format]="format()"
></shared-product-format-icon>
<span class="isa-text-body-2-regular text-isa-secondary-900 truncate">
{{ formatDetail() }}
</span>`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ProductFormatIconComponent],
host: {
'[class]': '["flex", "items-center", "gap-2"]',
'data-what': 'product-format',
'data-which': 'shared-product-format',
},
})
export class ProductFormatComponent {
format = input.required<string>();
formatDetail = input.required<string>();
}

View File

@@ -0,0 +1,10 @@
// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
globalThis.ngJest = {
testEnvironmentOptions: {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
},
};
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

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,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,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@@ -0,0 +1,7 @@
# ui-skeleton-loader
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test ui-skeleton-loader` 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: 'ui',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ui',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'ui-skeleton-loader',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/ui/skeleton-loader',
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": "ui-skeleton-loader",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/skeleton-loader/src",
"prefix": "ui",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ui/skeleton-loader/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/skeleton-loader.directive';
export * from './lib/skeleton-loader.component';

View File

@@ -0,0 +1 @@
<div class="ui-skeleton-loader-bar"></div>

View File

@@ -0,0 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'ui-skeleton-loader',
templateUrl: 'skeleton-loader.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'ui-skeleton-loader',
},
})
export class SkeletonLoaderComponent {}

View File

@@ -0,0 +1,47 @@
import { Component, input } from '@angular/core';
import { Spectator, createComponentFactory } from '@ngneat/spectator/jest';
import { SkeletonLoaderDirective } from './skeleton-loader.directive';
@Component({
template: `
<div
*uiSkeletonLoader="isLoading(); width: width(); height: height()"
data-testid="content"
>
Original Content
</div>
`,
imports: [SkeletonLoaderDirective],
})
class TestHostComponent {
isLoading = input(false);
width = input<string | undefined>(undefined);
height = input<string | undefined>(undefined);
}
/**
* Unit tests for the SkeletonLoaderDirective.
* Tests conditional rendering of skeleton loader vs original content
* and verifies custom dimensions are applied correctly.
*/
describe('SkeletonLoaderDirective', () => {
let spectator: Spectator<TestHostComponent>;
const createTestHost = createComponentFactory(TestHostComponent);
beforeEach(() => {
spectator = createTestHost();
spectator.detectChanges();
});
it('should render the Original Content when isLoading is false', () => {
expect(spectator.query('[data-testid="content"]')).toHaveText(
'Original Content',
);
});
it('should render the skeleton loader when isLoading is true', () => {
spectator.setInput('isLoading', true);
expect(spectator.query('ui-skeleton-loader')).toExist();
expect(spectator.query('[data-testid="content"]')).not.toExist();
});
});

View File

@@ -0,0 +1,81 @@
import {
Directive,
ViewContainerRef,
TemplateRef,
inject,
input,
effect,
ComponentRef,
untracked,
EmbeddedViewRef,
} from '@angular/core';
import { SkeletonLoaderComponent } from './skeleton-loader.component';
/**
* Structural directive that conditionally replaces its content with a skeleton loader.
*
* @example
* <div *uiSkeletonLoader="isLoading">Content to hide while loading</div>
*
* @example
* <div *uiSkeletonLoader="isLoading; width: '100px'; height: '20px'">Content</div>
*/
@Directive({
selector: '[uiSkeletonLoader]',
})
export class SkeletonLoaderDirective {
#viewContainerRef = inject(ViewContainerRef);
#templateRef = inject(TemplateRef);
private componentRef: ComponentRef<SkeletonLoaderComponent> | null = null;
private embeddedViewRef: EmbeddedViewRef<unknown> | null = null;
uiSkeletonLoader = input<boolean>(false);
uiSkeletonLoaderWidth = input<string | undefined>(undefined);
uiSkeletonLoaderHeight = input<string | undefined>(undefined);
render = effect(() => {
const condition = this.uiSkeletonLoader();
if (condition && !this.componentRef) {
// Create the skeleton loader when condition is true and view isn't created
this.#viewContainerRef.clear();
this.componentRef = this.#viewContainerRef.createComponent(
SkeletonLoaderComponent,
);
untracked(() => {
this.applyStyles();
});
this.embeddedViewRef = null; // Clear the embedded view reference
} else if (!condition && !this.embeddedViewRef) {
// Show original content when condition is false
this.#viewContainerRef.clear();
this.embeddedViewRef = this.#viewContainerRef.createEmbeddedView(
this.#templateRef,
);
this.componentRef = null; // Clear the component reference
}
});
updateStyles = effect(() => {
this.applyStyles();
});
applyStyles() {
if (this.componentRef) {
const width = this.uiSkeletonLoaderWidth() || '100%';
const height = this.uiSkeletonLoaderHeight() || '100%';
const element: HTMLElement | undefined =
this.componentRef?.location?.nativeElement;
if (element) {
element.style.width = width;
element.style.height = height;
}
}
}
}

View File

@@ -0,0 +1,25 @@
.ui-skeleton-loader {
@apply inline-block min-h-[1rem] min-w-[2rem] overflow-hidden rounded bg-isa-neutral-300 relative;
}
.ui-skeleton-loader-bar {
@keyframes loader {
0% {
transform: translateX(-100%);
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
transform: translateX(100%);
opacity: 0.3;
}
}
@apply block absolute top-0 left-0 right-0 bottom-0;
@apply bg-gradient-to-r from-isa-neutral-300 via-isa-neutral-400 to-isa-neutral-300;
animation: loader 1s ease-in-out infinite;
}

View File

@@ -0,0 +1,10 @@
// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
globalThis.ngJest = {
testEnvironmentOptions: {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
},
};
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();

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,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,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

5529
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -78,11 +78,8 @@
"@schematics/angular": "20.0.2",
"@softarc/eslint-plugin-sheriff": "^0.18.0",
"@softarc/sheriff-core": "0.18.0",
"@storybook/angular": "9.0.9",
"@storybook/core-server": "8.6.14",
"@storybook/jest": "0.2.3",
"@storybook/test-runner": "0.23.0",
"@storybook/testing-library": "0.2.2",
"@storybook/addon-docs": "^9.0.11",
"@storybook/angular": "^9.0.5",
"@swc-node/register": "1.10.10",
"@swc/core": "1.12.1",
"@swc/helpers": "0.5.17",
@@ -104,13 +101,14 @@
"jest-preset-angular": "14.6.0",
"jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.13.5",
"ng-packagr": "~18.2.0",
"ng-packagr": "^20.0.0",
"ng-swagger-gen": "^2.3.1",
"nx": "21.2.0",
"postcss": "^8.5.3",
"postcss-url": "~10.1.3",
"prettier": "^3.5.2",
"pretty-quick": "~4.0.0",
"storybook": "^9.0.5",
"tailwindcss": "^3.4.14",
"ts-jest": "^29.1.0",
"ts-node": "10.9.1",

View File

@@ -75,10 +75,14 @@
],
"@isa/remission/helpers": ["libs/remission/helpers/src/index.ts"],
"@isa/remission/shared": ["libs/remission/shared/src/index.ts"],
"@isa/remission/shared/product-info": [
"libs/remission/shared/product-info/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/shared/filter": ["libs/shared/filter/src/index.ts"],
"@isa/shared/product-foramt": ["libs/shared/product-format/src/index.ts"],
"@isa/shared/product-image": ["libs/shared/product-image/src/index.ts"],
"@isa/shared/product-router-link": [
"libs/shared/product-router-link/src/index.ts"
@@ -95,6 +99,7 @@
"@isa/ui/menu": ["libs/ui/menu/src/index.ts"],
"@isa/ui/progress-bar": ["libs/ui/progress-bar/src/index.ts"],
"@isa/ui/search-bar": ["libs/ui/search-bar/src/index.ts"],
"@isa/ui/skeleton-loader": ["libs/ui/skeleton-loader/src/index.ts"],
"@isa/ui/toolbar": ["libs/ui/toolbar/src/index.ts"],
"@isa/ui/tooltip": ["libs/ui/tooltip/src/index.ts"],
"@isa/utils/scroll-position": ["libs/utils/scroll-position/src/index.ts"],

View File

@@ -12,7 +12,8 @@
"extends": "./tsconfig.base.json",
"ts-node": {
"compilerOptions": {
"module": "commonjs"
"module": "commonjs",
"moduleResolution": "node10"
}
}
}