Merged PR 1918: feat(libs-ui-label, remission-shared-product, storybook): add UI label compon...

feat(libs-ui-label, remission-shared-product, storybook): add UI label component for remission tags

- Create new @isa/ui/label library with primary/secondary appearances
- Integrate label component into ProductInfoComponent to display remission tags (Prio 1, Prio 2, Pflicht)
- Add conditional rendering based on RemissionItemTags enum with proper appearance mapping
- Include comprehensive unit tests using Vitest and Angular Testing Utilities
- Add Storybook stories for both label component and updated product info component
- Import label styles in main tailwind.scss

Ref: #5268
This commit is contained in:
Nino Righi
2025-08-13 13:38:13 +00:00
committed by Andreas Schickinger
parent f0bd957a07
commit bbb9c5d39c
22 changed files with 492 additions and 4 deletions

View File

@@ -18,6 +18,7 @@
@import "../../../libs/ui/search-bar/src/search-bar.scss";
@import "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
@import "../../../libs/ui/tooltip/src/tooltip.scss";
@import "../../../libs/ui/label/src/label.scss";
.input-control {
@apply rounded border border-solid border-[#AEB7C1] px-4 py-[1.125rem] outline-none;

View File

@@ -50,6 +50,7 @@ const meta: Meta<ProductInfoInputs> = {
value: 19.99,
},
},
tag: 'Prio 2',
},
orientation: 'horizontal',
},
@@ -95,6 +96,7 @@ export const Default: Story = {
value: 29.99,
},
},
tag: 'Prio 2',
},
},
};

View File

@@ -0,0 +1,32 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { LabelAppearance, LabelComponent } from '@isa/ui/label';
type UiLabelInputs = {
appearance: LabelAppearance;
};
const meta: Meta<UiLabelInputs> = {
component: LabelComponent,
title: 'ui/label/Label',
argTypes: {
appearance: {
control: { type: 'select' },
options: Object.values(LabelAppearance),
description: 'Determines the label appearance',
},
},
args: {
appearance: 'primary',
},
render: (args) => ({
props: args,
template: `<ui-label ${argsToTemplate(args)}>Prio 1</ui-label>`,
}),
};
export default meta;
type Story = StoryObj<LabelComponent>;
export const Default: Story = {
args: {},
};

View File

@@ -1,7 +1,8 @@
@let product = item().product;
@let price = item().retailPrice;
@let tag = item().tag;
@let horizontal = orientation() === 'horizontal';
<div>
<div class="flex flex-col gap-2" data-which="product-image-and-remi-label">
<img
class="w-full h-auto object-contain"
sharedProductRouterLink
@@ -10,6 +11,17 @@
[alt]="product.name"
data-what="product-image"
/>
@if (tag) {
<ui-label
data-what="remission-label"
[appearance]="
tag === RemissionItemTags.Prio2
? LabelAppearance.Secondary
: LabelAppearance.Primary
"
>{{ tag }}</ui-label
>
}
</div>
<div

View File

@@ -7,6 +7,7 @@ import { MockComponents, MockDirectives } from 'ng-mocks';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { LabelComponent } from '@isa/ui/label';
import { By } from '@angular/platform-browser';
describe('ProductInfoComponent', () => {
@@ -28,6 +29,7 @@ describe('ProductInfoComponent', () => {
currencySymbol: '€',
},
},
tag: 'Prio 1',
} as ProductInfoItem;
beforeEach(async () => {
@@ -40,11 +42,12 @@ describe('ProductInfoComponent', () => {
ProductFormatComponent,
ProductImageDirective,
ProductRouterLinkDirective,
LabelComponent,
],
},
add: {
imports: [
MockComponents(ProductFormatComponent),
MockComponents(ProductFormatComponent, LabelComponent),
MockDirectives(ProductImageDirective, ProductRouterLinkDirective),
],
},
@@ -154,6 +157,29 @@ describe('ProductInfoComponent', () => {
'product-format',
);
});
it('should display remission label when tag is present', () => {
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 1');
});
it('should not display remission label when tag is not present', () => {
const itemWithoutTag: ProductInfoItem = {
...mockProductItem,
tag: undefined,
};
fixture.componentRef.setInput('item', itemWithoutTag);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeFalsy();
});
});
describe('Orientation Behavior', () => {
@@ -249,6 +275,56 @@ describe('ProductInfoComponent', () => {
});
});
describe('RemissionItemTags Behavior', () => {
it('should display Prio1 tag with primary appearance', () => {
const itemWithPrio1: ProductInfoItem = {
...mockProductItem,
tag: 'Prio 1',
};
fixture.componentRef.setInput('item', itemWithPrio1);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 1');
});
it('should display Prio2 tag with secondary appearance', () => {
const itemWithPrio2: ProductInfoItem = {
...mockProductItem,
tag: 'Prio 2',
};
fixture.componentRef.setInput('item', itemWithPrio2);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Prio 2');
});
it('should display Pflicht tag with primary appearance', () => {
const itemWithPflicht: ProductInfoItem = {
...mockProductItem,
tag: 'Pflicht',
};
fixture.componentRef.setInput('item', itemWithPflicht);
fixture.detectChanges();
const labelElement = fixture.debugElement.query(
By.css('[data-what="remission-label"]'),
);
expect(labelElement).toBeTruthy();
expect(labelElement.nativeElement.textContent.trim()).toBe('Pflicht');
});
});
describe('Component Structure', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockProductItem);
@@ -272,6 +348,7 @@ describe('ProductInfoComponent', () => {
const expectedDataWhatValues = [
'product-image',
'remission-label',
'product-contributors',
'product-name',
'product-price',

View File

@@ -1,14 +1,24 @@
import { CurrencyPipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { ReturnItem } from '@isa/remission/data-access';
import { RemissionItem, 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';
import { LabelComponent, LabelAppearance } from '@isa/ui/label';
export type ProductInfoItem = Pick<ReturnItem, 'product' | 'retailPrice'>;
export type ProductInfoItem = Pick<
RemissionItem,
'product' | 'retailPrice' | 'tag'
>;
export type ProductInfoOrientation = 'horizontal' | 'vertical';
export const RemissionItemTags = {
Prio1: 'Prio 1',
Prio2: 'Prio 2',
Pflicht: 'Pflicht',
} as const;
@Component({
selector: 'remi-product-info',
templateUrl: 'product-info.component.html',
@@ -17,6 +27,7 @@ export type ProductInfoOrientation = 'horizontal' | 'vertical';
ProductRouterLinkDirective,
CurrencyPipe,
ProductFormatComponent,
LabelComponent,
],
host: {
'[class]': 'classList',
@@ -26,6 +37,8 @@ export type ProductInfoOrientation = 'horizontal' | 'vertical';
},
})
export class ProductInfoComponent {
LabelAppearance = LabelAppearance;
RemissionItemTags = RemissionItemTags;
readonly classList: ReadonlyArray<string> = [
'grid',
'grid-cols-[3.5rem,1fr]',

7
libs/ui/label/README.md Normal file
View File

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

View File

@@ -0,0 +1,20 @@
{
"name": "label",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/label/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/ui/label"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/label.component';
export * from './lib/types';

View File

@@ -0,0 +1 @@
@use "lib/label";

View File

@@ -0,0 +1,11 @@
.ui-label {
@apply flex items-center justify-center px-3 py-[0.125rem] min-w-14 rounded-[3.125rem] isa-text-caption-regular text-ellipsis whitespace-nowrap;
}
.ui-label__primary {
@apply bg-isa-neutral-700 text-isa-neutral-400;
}
.ui-label__secondary {
@apply bg-isa-neutral-300 text-isa-neutral-600;
}

View File

@@ -0,0 +1 @@
<ng-content></ng-content>

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LabelComponent } from './label.component';
import { By } from '@angular/platform-browser';
describe('LabelComponent', () => {
let component: LabelComponent;
let fixture: ComponentFixture<LabelComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LabelComponent],
}).compileComponents();
fixture = TestBed.createComponent(LabelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('Component Setup and Initialization', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default appearance as primary', () => {
expect(component.appearance()).toBe('primary');
});
it('should accept secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
fixture.detectChanges();
expect(component.appearance()).toBe('secondary');
});
it('should have correct CSS classes for primary appearance', () => {
expect(component.appearanceClass()).toBe('ui-label__primary');
});
it('should have correct CSS classes for secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
fixture.detectChanges();
expect(component.appearanceClass()).toBe('ui-label__secondary');
});
it('should set host classes correctly', () => {
const hostElement = fixture.debugElement.nativeElement;
expect(hostElement.classList.contains('ui-label')).toBe(true);
expect(hostElement.classList.contains('ui-label__primary')).toBe(true);
});
});
describe('Template Rendering', () => {
it('should display content in default primary appearance', () => {
const labelElement = fixture.debugElement.nativeElement;
expect(labelElement.classList.contains('ui-label')).toBe(true);
expect(labelElement.classList.contains('ui-label__primary')).toBe(true);
});
it('should display content in secondary appearance', () => {
fixture.componentRef.setInput('appearance', 'secondary');
fixture.detectChanges();
const labelElement = fixture.debugElement.nativeElement;
expect(labelElement.classList.contains('ui-label')).toBe(true);
expect(labelElement.classList.contains('ui-label__secondary')).toBe(true);
});
});
describe('Input Validation', () => {
it('should handle appearance input changes', () => {
fixture.componentRef.setInput('appearance', 'primary');
fixture.detectChanges();
expect(component.appearance()).toBe('primary');
expect(component.appearanceClass()).toBe('ui-label__primary');
fixture.componentRef.setInput('appearance', 'secondary');
fixture.detectChanges();
expect(component.appearance()).toBe('secondary');
expect(component.appearanceClass()).toBe('ui-label__secondary');
});
});
describe('Component Structure', () => {
it('should have proper host class binding', () => {
const hostElement = fixture.debugElement.nativeElement;
expect(hostElement.classList.contains('ui-label')).toBe(true);
expect(hostElement.classList.length).toBeGreaterThan(0);
});
it('should update classes when appearance changes', () => {
const hostElement = fixture.debugElement.nativeElement;
// Initial state
expect(hostElement.classList.contains('ui-label__primary')).toBe(true);
expect(hostElement.classList.contains('ui-label__secondary')).toBe(false);
// Change to secondary
fixture.componentRef.setInput('appearance', 'secondary');
fixture.detectChanges();
expect(hostElement.classList.contains('ui-label__primary')).toBe(false);
expect(hostElement.classList.contains('ui-label__secondary')).toBe(true);
});
});
});

View File

@@ -0,0 +1,36 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
ViewEncapsulation,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { LabelAppearance } from './types';
/**
* A simple label component that can be used to display text with different appearances.
* It supports primary and secondary appearances.
* Example usage:
* ```html
* <ui-label appearance="primary">Primary Label</ui-label>
* <ui-label appearance="secondary">Secondary Label</ui-label>
* ```
*/
@Component({
selector: 'ui-label',
imports: [CommonModule],
templateUrl: './label.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class]': '["ui-label", appearanceClass()]',
},
})
export class LabelComponent {
/** The appearance of the label. */
appearance = input<LabelAppearance>('primary');
/** A computed CSS class based on the current appearance. */
appearanceClass = computed(() => `ui-label__${this.appearance()}`);
}

View File

@@ -0,0 +1,7 @@
export const LabelAppearance = {
Primary: 'primary',
Secondary: 'secondary',
} as const;
export type LabelAppearance =
(typeof LabelAppearance)[keyof typeof LabelAppearance];

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

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

View File

@@ -0,0 +1,27 @@
{
"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"
],
"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,27 @@
/// <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/ui/label',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/ui/label',
provider: 'v8' as const,
},
},
}));

View File

@@ -107,6 +107,7 @@
"@isa/ui/expandable": ["libs/ui/expandable/src/index.ts"],
"@isa/ui/input-controls": ["libs/ui/input-controls/src/index.ts"],
"@isa/ui/item-rows": ["libs/ui/item-rows/src/index.ts"],
"@isa/ui/label": ["libs/ui/label/src/index.ts"],
"@isa/ui/layout": ["libs/ui/layout/src/index.ts"],
"@isa/ui/menu": ["libs/ui/menu/src/index.ts"],
"@isa/ui/progress-bar": ["libs/ui/progress-bar/src/index.ts"],