mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-31 09:37:15 +01:00
Merged PR 1919: feat(remission): add impediment management and UI enhancements for remission...
feat(remission): add impediment management and UI enhancements for remission list Implement comprehensive impediment handling for return items and suggestions with enhanced user interface components and improved data access layer. Key additions: - Add impediment update schema and validation for return items - Implement RemissionReturnReceiptService with full CRUD operations - Create RemissionListItemComponent with actions and selection capabilities - Add ProductInfoComponent with responsive layout and labeling - Enhance UI Dialog system with improved injection patterns and testing - Add comprehensive test coverage for all new components and services - Implement proper data attributes for E2E testing support Technical improvements: - Follow SOLID principles with clear separation of concerns - Use OnPush change detection strategy for optimal performance - Implement proper TypeScript typing with Zod schema validation - Add comprehensive JSDoc documentation for all public APIs - Use modern Angular signals and computed properties for state management Refs: #5275, #5038
This commit is contained in:
committed by
Andreas Schickinger
parent
0740273dbc
commit
514715589b
@@ -1,22 +1,29 @@
|
||||
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
|
||||
import { LabelAppearance, LabelComponent } from '@isa/ui/label';
|
||||
import { Labeltype, LabelPriority, LabelComponent } from '@isa/ui/label';
|
||||
|
||||
type UiLabelInputs = {
|
||||
appearance: LabelAppearance;
|
||||
type: Labeltype;
|
||||
priority: LabelPriority;
|
||||
};
|
||||
|
||||
const meta: Meta<UiLabelInputs> = {
|
||||
component: LabelComponent,
|
||||
title: 'ui/label/Label',
|
||||
argTypes: {
|
||||
appearance: {
|
||||
type: {
|
||||
control: { type: 'select' },
|
||||
options: Object.values(LabelAppearance),
|
||||
description: 'Determines the label appearance',
|
||||
options: Object.values(Labeltype),
|
||||
description: 'Determines the label type',
|
||||
},
|
||||
priority: {
|
||||
control: { type: 'select' },
|
||||
options: Object.values(LabelPriority),
|
||||
description: 'Determines the label priority',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
appearance: 'primary',
|
||||
type: 'tag',
|
||||
priority: 'high',
|
||||
},
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './fetch-stock-in-stock.schema';
|
||||
export * from './query-token.schema';
|
||||
export * from './fetch-required-capacity.schema';
|
||||
export * from './fetch-return.schema';
|
||||
export * from './update-item-impediment.schema';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UpdateItemImpedimentSchema = z.object({
|
||||
itemId: z.number(),
|
||||
comment: z.string(),
|
||||
});
|
||||
|
||||
export type UpdateItemImpediment = z.infer<typeof UpdateItemImpedimentSchema>;
|
||||
@@ -42,6 +42,8 @@ describe('RemissionReturnReceiptService', () => {
|
||||
ReturnAddReturnSuggestion: jest.Mock;
|
||||
ReturnCancelReturn: jest.Mock;
|
||||
ReturnCancelReturnReceipt: jest.Mock;
|
||||
ReturnReturnItemImpediment: jest.Mock;
|
||||
ReturnReturnSuggestionImpediment: jest.Mock;
|
||||
};
|
||||
let mockRemissionStockService: {
|
||||
fetchAssignedStock: jest.Mock;
|
||||
@@ -105,6 +107,8 @@ describe('RemissionReturnReceiptService', () => {
|
||||
ReturnAddReturnSuggestion: jest.fn(),
|
||||
ReturnCancelReturn: jest.fn(),
|
||||
ReturnCancelReturnReceipt: jest.fn(),
|
||||
ReturnReturnItemImpediment: jest.fn(),
|
||||
ReturnReturnSuggestionImpediment: jest.fn(),
|
||||
};
|
||||
|
||||
mockRemissionStockService = {
|
||||
@@ -708,6 +712,84 @@ describe('RemissionReturnReceiptService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReturnItemImpediment', () => {
|
||||
const mockReturnItem: ReturnItem = {
|
||||
id: 1001,
|
||||
quantity: 5,
|
||||
item: { id: 123, name: 'Test Item' },
|
||||
comment: 'Updated impediment comment',
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReturnService.ReturnReturnItemImpediment = jest.fn();
|
||||
});
|
||||
|
||||
it('should update return item impediment successfully', async () => {
|
||||
// Arrange
|
||||
mockReturnService.ReturnReturnItemImpediment.mockReturnValue(
|
||||
of({ result: mockReturnItem, error: null }),
|
||||
);
|
||||
|
||||
const params = {
|
||||
itemId: 1001,
|
||||
comment: 'Updated impediment comment',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await service.updateReturnItemImpediment(params);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockReturnItem);
|
||||
expect(mockReturnService.ReturnReturnItemImpediment).toHaveBeenCalledWith(
|
||||
{
|
||||
itemId: 1001,
|
||||
data: {
|
||||
comment: 'Updated impediment comment',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReturnSuggestionImpediment', () => {
|
||||
const mockReturnSuggestion: ReturnSuggestion = {
|
||||
id: 2001,
|
||||
quantity: 3,
|
||||
item: { id: 456, name: 'Test Suggestion Item' },
|
||||
comment: 'Updated suggestion impediment comment',
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockReturnService.ReturnReturnSuggestionImpediment = jest.fn();
|
||||
});
|
||||
|
||||
it('should update return suggestion impediment successfully', async () => {
|
||||
// Arrange
|
||||
mockReturnService.ReturnReturnSuggestionImpediment.mockReturnValue(
|
||||
of({ result: mockReturnSuggestion, error: null }),
|
||||
);
|
||||
|
||||
const params = {
|
||||
itemId: 2001,
|
||||
comment: 'Updated suggestion impediment comment',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = await service.updateReturnSuggestionImpediment(params);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockReturnSuggestion);
|
||||
expect(
|
||||
mockReturnService.ReturnReturnSuggestionImpediment,
|
||||
).toHaveBeenCalledWith({
|
||||
itemId: 2001,
|
||||
data: {
|
||||
comment: 'Updated suggestion impediment comment',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeReturnReceipt', () => {
|
||||
const mockCompletedReceipt: Receipt = {
|
||||
id: 101,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
FetchRemissionReturnReceiptsSchema,
|
||||
FetchReturnParams,
|
||||
FetchReturnSchema,
|
||||
UpdateItemImpediment,
|
||||
UpdateItemImpedimentSchema,
|
||||
} from '../schemas';
|
||||
import {
|
||||
CreateRemission,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
ReceiptReturnTuple,
|
||||
RemissionListType,
|
||||
ReturnItem,
|
||||
ReturnSuggestion,
|
||||
} from '../models';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { RemissionSupplierService } from './remission-supplier.service';
|
||||
@@ -538,6 +541,54 @@ export class RemissionReturnReceiptService {
|
||||
return res?.result as ReturnItem;
|
||||
}
|
||||
|
||||
async updateReturnItemImpediment(params: UpdateItemImpediment) {
|
||||
this.#logger.debug('Update return item impediment', () => ({ params }));
|
||||
|
||||
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
|
||||
|
||||
const res = await firstValueFrom(
|
||||
this.#returnService.ReturnReturnItemImpediment({
|
||||
itemId,
|
||||
data: {
|
||||
comment,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (res?.error) {
|
||||
this.#logger.error(
|
||||
'Failed to update return item impediment',
|
||||
new Error(res.message || 'Unknown error'),
|
||||
);
|
||||
throw new ResponseArgsError(res);
|
||||
}
|
||||
|
||||
return res?.result as ReturnItem;
|
||||
}
|
||||
|
||||
async updateReturnSuggestionImpediment(params: UpdateItemImpediment) {
|
||||
this.#logger.debug('Update return suggestion impediment', () => ({
|
||||
params,
|
||||
}));
|
||||
const { itemId, comment } = UpdateItemImpedimentSchema.parse(params);
|
||||
const res = await firstValueFrom(
|
||||
this.#returnService.ReturnReturnSuggestionImpediment({
|
||||
itemId,
|
||||
data: {
|
||||
comment,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (res?.error) {
|
||||
this.#logger.error(
|
||||
'Failed to update return suggestion impediment',
|
||||
new Error(res.message || 'Unknown error'),
|
||||
);
|
||||
throw new ResponseArgsError(res);
|
||||
}
|
||||
return res?.result as ReturnSuggestion;
|
||||
}
|
||||
|
||||
async completeReturnReceipt({
|
||||
returnId,
|
||||
receiptId,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
uiTextButton
|
||||
color="strong"
|
||||
(click)="deleteItemFromList()"
|
||||
[disabled]="deleteRemissionListItemInProgress()"
|
||||
[pending]="deleteRemissionListItemInProgress()"
|
||||
[disabled]="inProgress()"
|
||||
[pending]="inProgress()"
|
||||
data-what="button"
|
||||
data-which="remove-remission-item"
|
||||
>
|
||||
@@ -21,7 +21,7 @@
|
||||
uiTextButton
|
||||
color="strong"
|
||||
(click)="openRemissionQuantityDialog()"
|
||||
[disabled]="deleteRemissionListItemInProgress()"
|
||||
[disabled]="inProgress()"
|
||||
data-what="button"
|
||||
data-which="change-remission-quantity"
|
||||
>
|
||||
|
||||
@@ -11,12 +11,14 @@ import { logger } from '@isa/core/logging';
|
||||
import {
|
||||
RemissionItem,
|
||||
RemissionItemSource,
|
||||
RemissionListType,
|
||||
RemissionReturnReceiptService,
|
||||
RemissionStore,
|
||||
} from '@isa/remission/data-access';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
|
||||
@Component({
|
||||
selector: 'remi-feature-remission-list-item-actions',
|
||||
@@ -52,6 +54,12 @@ export class RemissionListItemActionsComponent {
|
||||
*/
|
||||
#store = inject(RemissionStore);
|
||||
|
||||
/**
|
||||
* Signal indicating whether remission has started.
|
||||
* Used to determine if the item can be selected or not.
|
||||
*/
|
||||
remissionListType = injectRemissionListType();
|
||||
|
||||
/**
|
||||
* Service for handling remission return receipts.
|
||||
* @private
|
||||
@@ -75,9 +83,8 @@ export class RemissionListItemActionsComponent {
|
||||
* ModelSignal indicating whether remission items are currently being processed.
|
||||
* Used to prevent multiple submissions or actions.
|
||||
* @default false
|
||||
*
|
||||
*/
|
||||
deleteRemissionListItemInProgress = model<boolean>();
|
||||
inProgress = model<boolean>();
|
||||
|
||||
/**
|
||||
* Signal indicating whether remission has started.
|
||||
@@ -109,13 +116,14 @@ export class RemissionListItemActionsComponent {
|
||||
|
||||
/**
|
||||
* Opens a dialog to change the remission quantity for the current item.
|
||||
* Prompts the user to enter a new quantity and validates the input.
|
||||
* If the input is valid, updates the remission quantity in the store.
|
||||
* Displays a feedback dialog upon successful update.
|
||||
* Prompts the user to enter a new quantity and updates the store with the new value
|
||||
* if valid.
|
||||
* If the item is not found, it updates the impediment with a comment.
|
||||
*/
|
||||
async openRemissionQuantityDialog(): Promise<void> {
|
||||
const dialogRef = this.#dialog({
|
||||
title: 'Remi-Menge ändern',
|
||||
displayClose: true,
|
||||
data: {
|
||||
message: 'Wie viele Exemplare können remittiert werden?',
|
||||
subMessage: this.selectedQuantityDiffersFromStockToRemit()
|
||||
@@ -125,6 +133,7 @@ export class RemissionListItemActionsComponent {
|
||||
? `${this.stockToRemit()}x`
|
||||
: undefined,
|
||||
inputLabel: 'Remi-Menge',
|
||||
closeText: 'Produkt nicht gefunden',
|
||||
inputValidation: [
|
||||
{
|
||||
errorKey: 'required',
|
||||
@@ -141,29 +150,60 @@ export class RemissionListItemActionsComponent {
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
// Dialog Close
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = this.item()?.id;
|
||||
const quantity = result?.inputValue;
|
||||
|
||||
if (itemId && quantity !== undefined && quantity > 0) {
|
||||
// Speichern CTA
|
||||
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Remi-Menge wurde geändert' },
|
||||
});
|
||||
} else if (itemId) {
|
||||
// Produkt nicht gefunden CTA
|
||||
try {
|
||||
this.inProgress.set(true);
|
||||
|
||||
if (this.remissionListType() === RemissionListType.Pflicht) {
|
||||
await this.#remissionReturnReceiptService.updateReturnItemImpediment({
|
||||
itemId,
|
||||
comment: 'Produkt nicht gefunden',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.remissionListType() === RemissionListType.Abteilung) {
|
||||
await this.#remissionReturnReceiptService.updateReturnSuggestionImpediment(
|
||||
{
|
||||
itemId,
|
||||
comment: 'Produkt nicht gefunden',
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.#logger.error('Failed to update impediment', error);
|
||||
}
|
||||
|
||||
this.inProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current item from the remission list.
|
||||
* Only proceeds if the item has an ID and deletion is not already in progress.
|
||||
* Sets the deleteRemissionListItemInProgress signal to true during deletion.
|
||||
* Logs an error if the deletion fails.
|
||||
* Only proceeds if the item has an ID and no other deletion is in progress.
|
||||
* Calls the service to delete the item and handles any errors.
|
||||
*/
|
||||
async deleteItemFromList() {
|
||||
const itemId = this.item()?.id;
|
||||
if (!itemId || this.deleteRemissionListItemInProgress()) {
|
||||
if (!itemId || this.inProgress()) {
|
||||
return;
|
||||
}
|
||||
this.deleteRemissionListItemInProgress.set(true);
|
||||
this.inProgress.set(true);
|
||||
|
||||
try {
|
||||
await this.#remissionReturnReceiptService.deleteReturnItem({ itemId });
|
||||
@@ -171,6 +211,6 @@ export class RemissionListItemActionsComponent {
|
||||
this.#logger.error('Failed to delete return item', error);
|
||||
}
|
||||
|
||||
this.deleteRemissionListItemInProgress.set(false);
|
||||
this.inProgress.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RemissionItem, RemissionStore } from '@isa/remission/data-access';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { CheckboxComponent } from '@isa/ui/input-controls';
|
||||
|
||||
@Component({
|
||||
@@ -15,7 +14,7 @@ import { CheckboxComponent } from '@isa/ui/input-controls';
|
||||
templateUrl: './remission-list-item-select.component.html',
|
||||
styleUrl: './remission-list-item-select.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
|
||||
imports: [FormsModule, CheckboxComponent],
|
||||
})
|
||||
export class RemissionListItemSelectComponent {
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
></remi-product-stock-info>
|
||||
</ui-item-row-data>
|
||||
|
||||
@if (displayImpediment()) {
|
||||
<ui-item-row-data
|
||||
class="w-fit"
|
||||
[class.row-start-second]="desktopBreakpoint()"
|
||||
>
|
||||
<ui-label [type]="Labeltype.Notice">{{ impediment() }}</ui-label>
|
||||
</ui-item-row-data>
|
||||
}
|
||||
|
||||
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
|
||||
@if (desktopBreakpoint()) {
|
||||
<remi-feature-remission-list-item-select
|
||||
@@ -49,9 +58,7 @@
|
||||
[selectedQuantityDiffersFromStockToRemit]="
|
||||
selectedQuantityDiffersFromStockToRemit()
|
||||
"
|
||||
(deleteRemissionListItemInProgressChange)="
|
||||
deleteRemissionListItemInProgress.set($event)
|
||||
"
|
||||
(inProgressChange)="inProgress.set($event)"
|
||||
></remi-feature-remission-list-item-actions>
|
||||
</ui-item-row-data>
|
||||
</ui-client-row>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
@apply isa-desktop:col-span-2 desktop-large:col-span-1;
|
||||
}
|
||||
|
||||
.row-start-second {
|
||||
grid-row-start: 2;
|
||||
}
|
||||
|
||||
.col-end-last {
|
||||
grid-column-end: -1;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { MockComponent } from 'ng-mocks';
|
||||
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
|
||||
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
|
||||
import { LabelComponent } from '@isa/ui/label';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
// --- Setup dynamic mocking for injectRemissionListType ---
|
||||
@@ -25,6 +26,15 @@ jest.mock('../injects/inject-remission-list-type', () => ({
|
||||
injectRemissionListType: () => () => remissionListTypeValue,
|
||||
}));
|
||||
|
||||
// Mock the breakpoint function
|
||||
jest.mock('@isa/ui/layout', () => ({
|
||||
breakpoint: jest.fn(() => jest.fn(() => true)), // Default to desktop
|
||||
Breakpoint: {
|
||||
DekstopL: 'DekstopL',
|
||||
DekstopXL: 'DekstopXL',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the calculation functions to have predictable behavior
|
||||
jest.mock('@isa/remission/data-access', () => ({
|
||||
...jest.requireActual('@isa/remission/data-access'),
|
||||
@@ -85,6 +95,7 @@ describe('RemissionListItemComponent', () => {
|
||||
MockComponent(ProductShelfMetaInfoComponent),
|
||||
MockComponent(RemissionListItemSelectComponent),
|
||||
MockComponent(RemissionListItemActionsComponent),
|
||||
MockComponent(LabelComponent),
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
@@ -150,23 +161,72 @@ describe('RemissionListItemComponent', () => {
|
||||
expect(component.productGroupValue()).toBe(testValue);
|
||||
});
|
||||
|
||||
it('should have deleteRemissionListItemInProgress model with undefined default', () => {
|
||||
it('should have stockFetching input with false default', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
expect(component.deleteRemissionListItemInProgress()).toBeUndefined();
|
||||
expect(component.stockFetching()).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept deleteRemissionListItemInProgress model value', () => {
|
||||
it('should accept stockFetching input value', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.componentRef.setInput('deleteRemissionListItemInProgress', true);
|
||||
fixture.componentRef.setInput('stockFetching', true);
|
||||
fixture.detectChanges();
|
||||
expect(component.deleteRemissionListItemInProgress()).toBe(true);
|
||||
expect(component.stockFetching()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have inProgress model with undefined default', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
expect(component.inProgress()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept inProgress model value', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.componentRef.setInput('inProgress', true);
|
||||
fixture.detectChanges();
|
||||
expect(component.inProgress()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
describe('desktopBreakpoint', () => {
|
||||
it('should be defined and accessible', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.desktopBreakpoint).toBeDefined();
|
||||
expect(typeof component.desktopBreakpoint()).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remissionListType', () => {
|
||||
it('should return injected remission list type', () => {
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
|
||||
});
|
||||
|
||||
it('should update when remission list type changes', () => {
|
||||
setRemissionListType(RemissionListType.Pflicht);
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.remissionListType()).toBe(RemissionListType.Pflicht);
|
||||
|
||||
setRemissionListType(RemissionListType.Abteilung);
|
||||
expect(component.remissionListType()).toBe(RemissionListType.Abteilung);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availableStock', () => {
|
||||
it('should calculate available stock correctly', () => {
|
||||
const {
|
||||
@@ -489,6 +549,135 @@ describe('RemissionListItemComponent', () => {
|
||||
const orientation = component.remiProductInfoOrientation();
|
||||
expect(['horizontal', 'vertical']).toContain(orientation);
|
||||
});
|
||||
|
||||
it('should depend on desktop breakpoint', () => {
|
||||
fixture.componentRef.setInput('item', createMockReturnItem());
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
// The function should compute based on the breakpoint
|
||||
const orientation = component.remiProductInfoOrientation();
|
||||
expect(typeof orientation).toBe('string');
|
||||
expect(['horizontal', 'vertical']).toContain(orientation);
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayImpediment', () => {
|
||||
it('should return truthy when item has impediment', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: {
|
||||
comment: 'Test impediment',
|
||||
attempts: 2,
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayImpediment()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return truthy when item is descendant of enabled impediment', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
descendantOf: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayImpediment()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return falsy when item has no impediment and is not descendant of enabled impediment', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: undefined,
|
||||
descendantOf: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayImpediment()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should return falsy when item has no impediment and no descendantOf property', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: undefined,
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayImpediment()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('impediment', () => {
|
||||
it('should return impediment comment when available', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: {
|
||||
comment: 'Custom impediment message',
|
||||
attempts: 3,
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.impediment()).toBe('Custom impediment message (3)');
|
||||
});
|
||||
|
||||
it('should return default "Restmenge" when no comment provided', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: {
|
||||
attempts: 2,
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.impediment()).toBe('Restmenge (2)');
|
||||
});
|
||||
|
||||
it('should return only comment when no attempts provided', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: {
|
||||
comment: 'Custom message',
|
||||
},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.impediment()).toBe('Custom message');
|
||||
});
|
||||
|
||||
it('should return default "Restmenge" when impediment is empty object', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: {},
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.impediment()).toBe('Restmenge');
|
||||
});
|
||||
|
||||
it('should return "Restmenge" when impediment is undefined', () => {
|
||||
const mockItem = createMockReturnItem({
|
||||
impediment: undefined,
|
||||
});
|
||||
fixture.componentRef.setInput('item', mockItem);
|
||||
fixture.componentRef.setInput('stock', createMockStockInfo());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.impediment()).toBe('Restmenge');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ import {
|
||||
ProductShelfMetaInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
} from '@isa/remission/shared/product';
|
||||
import { TextButtonComponent } from '@isa/ui/buttons';
|
||||
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
|
||||
import { Breakpoint, breakpoint } from '@isa/ui/layout';
|
||||
import { injectRemissionListType } from '../injects/inject-remission-list-type';
|
||||
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
|
||||
import { RemissionListItemActionsComponent } from './remission-list-item-actions.component';
|
||||
import { LabelComponent, Labeltype } from '@isa/ui/label';
|
||||
|
||||
/**
|
||||
* Component representing a single item in the remission list.
|
||||
@@ -52,14 +52,20 @@ import { RemissionListItemActionsComponent } from './remission-list-item-actions
|
||||
ProductInfoComponent,
|
||||
ProductStockInfoComponent,
|
||||
ProductShelfMetaInfoComponent,
|
||||
TextButtonComponent,
|
||||
ClientRowImports,
|
||||
ItemRowDataImports,
|
||||
RemissionListItemSelectComponent,
|
||||
RemissionListItemActionsComponent,
|
||||
LabelComponent,
|
||||
],
|
||||
})
|
||||
export class RemissionListItemComponent {
|
||||
/**
|
||||
* Type of label to display for the item.
|
||||
* Defaults to 'tag', can be changed to 'notice' or other types as needed.
|
||||
*/
|
||||
Labeltype = Labeltype;
|
||||
|
||||
/**
|
||||
* Store for managing selected remission quantities.
|
||||
* @private
|
||||
@@ -100,9 +106,8 @@ export class RemissionListItemComponent {
|
||||
* ModelSignal indicating whether remission items are currently being processed.
|
||||
* Used to prevent multiple submissions or actions.
|
||||
* @default false
|
||||
*
|
||||
*/
|
||||
deleteRemissionListItemInProgress = model<boolean>();
|
||||
inProgress = model<boolean>();
|
||||
|
||||
/**
|
||||
* Optional product group value for display or filtering.
|
||||
@@ -193,4 +198,25 @@ export class RemissionListItemComponent {
|
||||
remainingQuantityInStock: this.remainingQuantityInStock(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes whether to display the impediment for the item.
|
||||
* Displays if the item is a descendant of an enabled impediment or if it has its own impediment.
|
||||
*/
|
||||
displayImpediment = computed(
|
||||
() =>
|
||||
(this.item() as ReturnItem)?.descendantOf?.enabled ||
|
||||
this.item()?.impediment,
|
||||
);
|
||||
|
||||
/**
|
||||
* Computes the impediment comment and attempts for display.
|
||||
* If no impediment comment is provided, defaults to 'Restmenge'.
|
||||
* Appends the number of attempts if available.
|
||||
*/
|
||||
impediment = computed(() => {
|
||||
const comment = this.item()?.impediment?.comment ?? 'Restmenge';
|
||||
const attempts = this.item()?.impediment?.attempts;
|
||||
return `${comment}${attempts ? ` (${attempts})` : ''}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,9 +31,7 @@
|
||||
[stock]="getStockForItem(item)"
|
||||
[stockFetching]="inStockFetching()"
|
||||
[productGroupValue]="getProductGroupValueForItem(item)"
|
||||
(deleteRemissionListItemInProgressChange)="
|
||||
onDeleteRemissionListItem($event)
|
||||
"
|
||||
(inProgressChange)="onListItemActionInProgress($event)"
|
||||
></remi-feature-remission-list-item>
|
||||
} @placeholder {
|
||||
<div class="h-[7.75rem] w-full flex items-center justify-center">
|
||||
@@ -64,7 +62,7 @@
|
||||
size="large"
|
||||
color="brand"
|
||||
[pending]="remitItemsInProgress()"
|
||||
[disabled]="!hasSelectedItems() || deleteRemissionListItemInProgress()"
|
||||
[disabled]="!hasSelectedItems() || listItemActionInProgress()"
|
||||
>
|
||||
</ui-stateful-button>
|
||||
}
|
||||
|
||||
@@ -150,12 +150,10 @@ export class RemissionListComponent {
|
||||
});
|
||||
|
||||
/**
|
||||
* Signal indicating whether remission items are currently being processed.
|
||||
* Used to prevent multiple submissions or actions.
|
||||
* @default false
|
||||
*
|
||||
* Signal indicating whether a remission list item deletion is in progress.
|
||||
* Used to disable actions while deletion is happening.
|
||||
*/
|
||||
deleteRemissionListItemInProgress = signal(false);
|
||||
listItemActionInProgress = signal(false);
|
||||
|
||||
/**
|
||||
* Resource signal for fetching the remission list based on current filters.
|
||||
@@ -330,8 +328,8 @@ export class RemissionListComponent {
|
||||
*
|
||||
* @param inProgress - Whether the deletion is currently in progress
|
||||
*/
|
||||
onDeleteRemissionListItem(inProgress: boolean) {
|
||||
this.deleteRemissionListItemInProgress.set(inProgress);
|
||||
onListItemActionInProgress(inProgress: boolean) {
|
||||
this.listItemActionInProgress.set(inProgress);
|
||||
if (!inProgress) {
|
||||
this.reloadListAndReturnData();
|
||||
}
|
||||
|
||||
@@ -149,10 +149,20 @@ const sortResponseResult = (
|
||||
resopnse: ListResponseArgs<RemissionItem>,
|
||||
): void => {
|
||||
resopnse.result.sort((a, b) => {
|
||||
const aHasImpediment = !!a.impediment;
|
||||
const bHasImpediment = !!b.impediment;
|
||||
const aIsManuallyAdded = a.source === 'manually-added';
|
||||
const bIsManuallyAdded = b.source === 'manually-added';
|
||||
|
||||
// First priority: manually-added items come first
|
||||
// First priority: move all items with impediment to the end of the list
|
||||
if (!aHasImpediment && bHasImpediment) {
|
||||
return -1;
|
||||
}
|
||||
if (aHasImpediment && !bHasImpediment) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Second priority: manually-added items come first
|
||||
if (aIsManuallyAdded && !bIsManuallyAdded) {
|
||||
return -1;
|
||||
}
|
||||
@@ -160,7 +170,7 @@ const sortResponseResult = (
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Second priority: sort by created date (latest first)
|
||||
// Third priority: sort by created date (latest first)
|
||||
if (a.created && b.created) {
|
||||
const dateA = parseISO(a.created);
|
||||
const dateB = parseISO(b.created);
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
[alt]="product.name"
|
||||
data-what="product-image"
|
||||
/>
|
||||
|
||||
@if (tag) {
|
||||
<ui-label
|
||||
data-what="remission-label"
|
||||
[appearance]="
|
||||
tag === RemissionItemTags.Prio2
|
||||
? LabelAppearance.Secondary
|
||||
: LabelAppearance.Primary
|
||||
[type]="Labeltype.Tag"
|
||||
[priority]="
|
||||
tag === RemissionItemTags.Prio2 ? LabelPriority.Low : LabelPriority.High
|
||||
"
|
||||
>{{ tag }}</ui-label
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { LabelComponent, LabelPriority, Labeltype } from '@isa/ui/label';
|
||||
|
||||
export type ProductInfoItem = Pick<
|
||||
RemissionItem,
|
||||
@@ -37,7 +37,8 @@ export const RemissionItemTags = {
|
||||
},
|
||||
})
|
||||
export class ProductInfoComponent {
|
||||
LabelAppearance = LabelAppearance;
|
||||
Labeltype = Labeltype;
|
||||
LabelPriority = LabelPriority;
|
||||
RemissionItemTags = RemissionItemTags;
|
||||
readonly classList: ReadonlyArray<string> = [
|
||||
'grid',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.ui-dialog {
|
||||
@apply bg-isa-white p-8 grid gap-8 items-start rounded-[2rem] grid-flow-row text-isa-neutral-900 relative;
|
||||
@apply bg-isa-white p-8 grid gap-4 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;
|
||||
|
||||
@@ -13,4 +13,8 @@
|
||||
@apply overflow-y-auto overflow-x-hidden;
|
||||
@apply min-h-0;
|
||||
}
|
||||
|
||||
.ui-dialog-close-cta {
|
||||
@apply flex justify-end items-center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ import { DialogComponent } from './dialog.component';
|
||||
@Component({
|
||||
selector: 'ui-test-dialog-content',
|
||||
template: '<div>Test dialog content</div>',
|
||||
standalone: true,
|
||||
})
|
||||
class TestDialogContentComponent extends DialogContentDirective<unknown, unknown> {}
|
||||
class TestDialogContentComponent extends DialogContentDirective<
|
||||
unknown,
|
||||
unknown
|
||||
> {}
|
||||
|
||||
describe('DialogContentDirective', () => {
|
||||
let spectator: Spectator<TestDialogContentComponent>;
|
||||
const mockDialogRef = { close: jest.fn() };
|
||||
const mockDialog = { close: jest.fn() };
|
||||
const mockData = { message: 'Test message' };
|
||||
|
||||
const createComponent = createComponentFactory({
|
||||
@@ -21,7 +26,7 @@ describe('DialogContentDirective', () => {
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{ provide: DIALOG_DATA, useValue: mockData },
|
||||
{ provide: DialogComponent, useValue: {} }
|
||||
{ provide: DialogComponent, useValue: mockDialog },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -40,12 +45,33 @@ describe('DialogContentDirective', () => {
|
||||
|
||||
it('should call dialogRef.close with provided result when close method is called', () => {
|
||||
const result = { success: true };
|
||||
|
||||
|
||||
// Act
|
||||
spectator.component.close(result);
|
||||
|
||||
|
||||
// Assert
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith(result);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have access to DialogComponent instance', () => {
|
||||
expect(spectator.component.dialog).toBe(mockDialog);
|
||||
});
|
||||
|
||||
it('should have access to DialogRef instance', () => {
|
||||
expect(spectator.component.dialogRef).toBe(mockDialogRef);
|
||||
});
|
||||
|
||||
it('should apply ui-dialog-content class to host element', () => {
|
||||
expect(spectator.element).toHaveClass('ui-dialog-content');
|
||||
});
|
||||
|
||||
it('should call dialogRef.close without parameters when close is called without result', () => {
|
||||
// Act
|
||||
spectator.component.close(undefined as any);
|
||||
|
||||
// Assert
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith(undefined);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
@if (displayCloseButton()) {
|
||||
<button class="ui-dialog-close-cta" type="button" (click)="close()">
|
||||
<ng-icon size="1.75rem" name="isaActionClose"></ng-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (title()) {
|
||||
<h2 class="ui-dialog-title" data-what="title">
|
||||
{{ title() }}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('DialogComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spectator = createComponent();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create the component', () => {
|
||||
@@ -56,4 +57,13 @@ describe('DialogComponent', () => {
|
||||
it('should apply the ui-dialog class to the host element', () => {
|
||||
expect(spectator.element).toHaveClass('ui-dialog');
|
||||
});
|
||||
|
||||
it('should call dialogRef.close when close method is called', () => {
|
||||
// Act
|
||||
spectator.component.close();
|
||||
|
||||
// Assert
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith();
|
||||
expect(mockDialogRef.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,17 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { DialogContentDirective } from './dialog-content.directive';
|
||||
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
|
||||
import {
|
||||
DIALOG_CLASS_LIST,
|
||||
DIALOG_CONTENT,
|
||||
DIALOG_TITLE,
|
||||
DISPLAY_DIALOG_CLOSE,
|
||||
} from './tokens';
|
||||
import { ComponentType } from '@angular/cdk/portal';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { isaActionClose } from '@isa/icons';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
|
||||
/**
|
||||
* Base dialog component that serves as a container for dialog content
|
||||
@@ -22,10 +30,11 @@ import { NgComponentOutlet } from '@angular/common';
|
||||
selector: 'ui-dialog',
|
||||
templateUrl: './dialog.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgComponentOutlet],
|
||||
imports: [NgComponentOutlet, NgIcon],
|
||||
host: {
|
||||
'[class]': 'classes()',
|
||||
},
|
||||
providers: [provideIcons({ isaActionClose })],
|
||||
})
|
||||
export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
|
||||
/** The title to display at the top of the dialog */
|
||||
@@ -34,6 +43,19 @@ export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
|
||||
/** The component type to instantiate as the dialog content */
|
||||
readonly component = inject(DIALOG_CONTENT) as ComponentType<C>;
|
||||
|
||||
/** Reference to the dialog instance for closing the dialog */
|
||||
private readonly dialogRef = inject(DialogRef<R, DialogComponent<D, R, C>>);
|
||||
|
||||
/**
|
||||
* Signal that determines whether the close button should be displayed
|
||||
* This is controlled by an injection token, allowing for flexible configuration
|
||||
*/
|
||||
readonly displayCloseButton = signal(
|
||||
inject(DISPLAY_DIALOG_CLOSE, {
|
||||
optional: true,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Additional CSS classes provided via injection */
|
||||
private readonly classList =
|
||||
inject(DIALOG_CLASS_LIST, { optional: true }) ?? [];
|
||||
@@ -47,4 +69,12 @@ export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
|
||||
classes = computed(() => {
|
||||
return ['ui-dialog', ...this.classList];
|
||||
});
|
||||
|
||||
/**
|
||||
* Closes the dialog without returning any result
|
||||
* This is typically called when the user clicks the close button (X)
|
||||
*/
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from './injects';
|
||||
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
|
||||
import { DialogComponent } from './dialog.component';
|
||||
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
|
||||
import { DIALOG_CONTENT, DIALOG_TITLE, DISPLAY_DIALOG_CLOSE } from './tokens';
|
||||
import { Component } from '@angular/core';
|
||||
import { DialogContentDirective } from './dialog-content.directive';
|
||||
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
|
||||
@@ -143,6 +143,57 @@ describe('Dialog Injects', () => {
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DIALOG_CONTENT)).toBe(componentType);
|
||||
});
|
||||
|
||||
it('should set displayClose to false by default', () => {
|
||||
// Arrange
|
||||
const componentType = TestDialogContentComponent;
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType),
|
||||
);
|
||||
openDialog({ data });
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should use provided displayClose option from inject options', () => {
|
||||
// Arrange
|
||||
const componentType = TestDialogContentComponent;
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { displayClose: true }),
|
||||
);
|
||||
openDialog({ data });
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should use provided displayClose option from open options and override inject options', () => {
|
||||
// Arrange
|
||||
const componentType = TestDialogContentComponent;
|
||||
const data = { message: 'Test Message' };
|
||||
|
||||
// Act
|
||||
const openDialog = TestBed.runInInjectionContext(() =>
|
||||
injectDialog(componentType, { displayClose: true }),
|
||||
);
|
||||
openDialog({ data, displayClose: false });
|
||||
|
||||
// Assert
|
||||
const callOptions = mockDialogOpen.mock.calls[0][1];
|
||||
const injector = callOptions.injector;
|
||||
expect(injector.get(DISPLAY_DIALOG_CLOSE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectMessageDialog', () => {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { ComponentType } from '@angular/cdk/portal';
|
||||
import { inject, Injector } from '@angular/core';
|
||||
import { DialogContentDirective } from './dialog-content.directive';
|
||||
import { DialogComponent } from './dialog.component';
|
||||
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
|
||||
import {
|
||||
DIALOG_CLASS_LIST,
|
||||
DIALOG_CONTENT,
|
||||
DIALOG_TITLE,
|
||||
DISPLAY_DIALOG_CLOSE,
|
||||
} from './tokens';
|
||||
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
|
||||
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
|
||||
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
|
||||
@@ -21,6 +26,9 @@ export interface InjectDialogOptions {
|
||||
/** Optional title override for the dialog */
|
||||
title?: string;
|
||||
|
||||
/** Optional flag to control whether the close button is displayed */
|
||||
displayClose?: boolean;
|
||||
|
||||
/** Optional additional CSS classes to apply to the dialog */
|
||||
classList?: string[];
|
||||
|
||||
@@ -89,6 +97,11 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
|
||||
provide: DIALOG_TITLE,
|
||||
useValue: openOptions?.title ?? injectOptions?.title,
|
||||
},
|
||||
{
|
||||
provide: DISPLAY_DIALOG_CLOSE,
|
||||
useValue:
|
||||
openOptions?.displayClose ?? injectOptions?.displayClose ?? false, // #5275 Default false -> Bei true müssten ALLE eingesetzten Dialoge überprüft werden, da bei close ein undefined emitted wird und mit diesem Wert evtl. bereits Logik verbunden ist
|
||||
},
|
||||
{
|
||||
provide: DIALOG_CLASS_LIST,
|
||||
useValue: openOptions?.classList ?? injectOptions?.classList ?? [],
|
||||
|
||||
@@ -9,6 +9,15 @@ export const DIALOG_TITLE = new InjectionToken<string | undefined>(
|
||||
'DIALOG_TITLE',
|
||||
);
|
||||
|
||||
/**
|
||||
* Injection token for controlling the display of the close button in the dialog
|
||||
* If set to true, a close button will be displayed; if false, it will not be shown
|
||||
* If undefined, the default behavior is used (usually true)
|
||||
*/
|
||||
export const DISPLAY_DIALOG_CLOSE = new InjectionToken<boolean | undefined>(
|
||||
'DISPLAY_DIALOG_CLOSE',
|
||||
);
|
||||
|
||||
/**
|
||||
* Injection token for providing the dialog content component
|
||||
* Used internally by the dialog system to instantiate the correct content component
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
.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;
|
||||
@apply flex items-center justify-center text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.ui-label__primary {
|
||||
.ui-label__tag {
|
||||
@apply px-3 py-[0.125rem] min-w-14 rounded-[3.125rem] isa-text-caption-regular;
|
||||
}
|
||||
|
||||
.ui-label__tag-priority-high {
|
||||
@apply bg-isa-neutral-700 text-isa-neutral-400;
|
||||
}
|
||||
|
||||
.ui-label__secondary {
|
||||
.ui-label__tag-priority-low {
|
||||
@apply bg-isa-neutral-300 text-isa-neutral-600;
|
||||
}
|
||||
|
||||
.ui-label__notice {
|
||||
@apply p-2 min-w-48 rounded-lg isa-text-body-2-bold text-isa-neutral-900;
|
||||
}
|
||||
|
||||
.ui-label__notice-priority-high {
|
||||
@apply bg-isa-secondary-100;
|
||||
}
|
||||
|
||||
.ui-label__notice-priority-medium {
|
||||
@apply bg-isa-neutral-100;
|
||||
}
|
||||
|
||||
.ui-label__notice-priority-low {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { LabelPriority, Labeltype } from './types';
|
||||
|
||||
describe('LabelComponent', () => {
|
||||
let component: LabelComponent;
|
||||
@@ -22,61 +22,127 @@ describe('LabelComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default appearance as primary', () => {
|
||||
expect(component.appearance()).toBe('primary');
|
||||
it('should have default type as tag', () => {
|
||||
expect(component.type()).toBe(Labeltype.Tag);
|
||||
});
|
||||
|
||||
it('should accept secondary appearance', () => {
|
||||
fixture.componentRef.setInput('appearance', 'secondary');
|
||||
it('should have default priority as high', () => {
|
||||
expect(component.priority()).toBe(LabelPriority.High);
|
||||
});
|
||||
|
||||
it('should accept notice type', () => {
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.detectChanges();
|
||||
expect(component.appearance()).toBe('secondary');
|
||||
expect(component.type()).toBe(Labeltype.Notice);
|
||||
});
|
||||
|
||||
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');
|
||||
it('should accept different priority levels', () => {
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Medium);
|
||||
fixture.detectChanges();
|
||||
expect(component.appearanceClass()).toBe('ui-label__secondary');
|
||||
expect(component.priority()).toBe(LabelPriority.Medium);
|
||||
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Low);
|
||||
fixture.detectChanges();
|
||||
expect(component.priority()).toBe(LabelPriority.Low);
|
||||
});
|
||||
|
||||
it('should have correct CSS classes for default type and priority', () => {
|
||||
expect(component.typeClass()).toBe('ui-label__tag');
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
|
||||
});
|
||||
|
||||
it('should have correct CSS classes for notice type', () => {
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.detectChanges();
|
||||
expect(component.typeClass()).toBe('ui-label__notice');
|
||||
expect(component.priorityClass()).toBe('ui-label__notice-priority-high');
|
||||
});
|
||||
|
||||
it('should have correct CSS classes for different priorities', () => {
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Medium);
|
||||
fixture.detectChanges();
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-medium');
|
||||
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Low);
|
||||
fixture.detectChanges();
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-low');
|
||||
});
|
||||
|
||||
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);
|
||||
expect(hostElement.classList.contains('ui-label__tag')).toBe(true);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Template Rendering', () => {
|
||||
it('should display content in default primary appearance', () => {
|
||||
it('should display content with default type and priority classes', () => {
|
||||
const labelElement = fixture.debugElement.nativeElement;
|
||||
expect(labelElement.classList.contains('ui-label')).toBe(true);
|
||||
expect(labelElement.classList.contains('ui-label__primary')).toBe(true);
|
||||
expect(labelElement.classList.contains('ui-label__tag')).toBe(true);
|
||||
expect(
|
||||
labelElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should display content in secondary appearance', () => {
|
||||
fixture.componentRef.setInput('appearance', 'secondary');
|
||||
it('should display content with notice type', () => {
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.detectChanges();
|
||||
|
||||
const labelElement = fixture.debugElement.nativeElement;
|
||||
expect(labelElement.classList.contains('ui-label')).toBe(true);
|
||||
expect(labelElement.classList.contains('ui-label__secondary')).toBe(true);
|
||||
expect(labelElement.classList.contains('ui-label__notice')).toBe(true);
|
||||
expect(
|
||||
labelElement.classList.contains('ui-label__notice-priority-high'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should display content with different priority levels', () => {
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Low);
|
||||
fixture.detectChanges();
|
||||
|
||||
const labelElement = fixture.debugElement.nativeElement;
|
||||
expect(labelElement.classList.contains('ui-label')).toBe(true);
|
||||
expect(labelElement.classList.contains('ui-label__tag')).toBe(true);
|
||||
expect(
|
||||
labelElement.classList.contains('ui-label__tag-priority-low'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('should handle appearance input changes', () => {
|
||||
fixture.componentRef.setInput('appearance', 'primary');
|
||||
it('should handle type input changes', () => {
|
||||
fixture.componentRef.setInput('type', Labeltype.Tag);
|
||||
fixture.detectChanges();
|
||||
expect(component.appearance()).toBe('primary');
|
||||
expect(component.appearanceClass()).toBe('ui-label__primary');
|
||||
expect(component.type()).toBe(Labeltype.Tag);
|
||||
expect(component.typeClass()).toBe('ui-label__tag');
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
|
||||
|
||||
fixture.componentRef.setInput('appearance', 'secondary');
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.detectChanges();
|
||||
expect(component.appearance()).toBe('secondary');
|
||||
expect(component.appearanceClass()).toBe('ui-label__secondary');
|
||||
expect(component.type()).toBe(Labeltype.Notice);
|
||||
expect(component.typeClass()).toBe('ui-label__notice');
|
||||
expect(component.priorityClass()).toBe('ui-label__notice-priority-high');
|
||||
});
|
||||
|
||||
it('should handle priority input changes', () => {
|
||||
fixture.componentRef.setInput('priority', LabelPriority.High);
|
||||
fixture.detectChanges();
|
||||
expect(component.priority()).toBe(LabelPriority.High);
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-high');
|
||||
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Medium);
|
||||
fixture.detectChanges();
|
||||
expect(component.priority()).toBe(LabelPriority.Medium);
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-medium');
|
||||
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Low);
|
||||
fixture.detectChanges();
|
||||
expect(component.priority()).toBe(LabelPriority.Low);
|
||||
expect(component.priorityClass()).toBe('ui-label__tag-priority-low');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,19 +153,69 @@ describe('LabelComponent', () => {
|
||||
expect(hostElement.classList.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should update classes when appearance changes', () => {
|
||||
it('should update classes when type 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);
|
||||
expect(hostElement.classList.contains('ui-label__tag')).toBe(true);
|
||||
expect(hostElement.classList.contains('ui-label__notice')).toBe(false);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(true);
|
||||
|
||||
// Change to secondary
|
||||
fixture.componentRef.setInput('appearance', 'secondary');
|
||||
// Change to notice
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hostElement.classList.contains('ui-label__primary')).toBe(false);
|
||||
expect(hostElement.classList.contains('ui-label__secondary')).toBe(true);
|
||||
expect(hostElement.classList.contains('ui-label__tag')).toBe(false);
|
||||
expect(hostElement.classList.contains('ui-label__notice')).toBe(true);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__notice-priority-high'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should update classes when priority changes', () => {
|
||||
const hostElement = fixture.debugElement.nativeElement;
|
||||
|
||||
// Initial state
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-medium'),
|
||||
).toBe(false);
|
||||
|
||||
// Change to medium priority
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Medium);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-medium'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain both type and priority classes simultaneously', () => {
|
||||
const hostElement = fixture.debugElement.nativeElement;
|
||||
|
||||
fixture.componentRef.setInput('type', Labeltype.Notice);
|
||||
fixture.componentRef.setInput('priority', LabelPriority.Low);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(hostElement.classList.contains('ui-label')).toBe(true);
|
||||
expect(hostElement.classList.contains('ui-label__notice')).toBe(true);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__notice-priority-low'),
|
||||
).toBe(true);
|
||||
expect(hostElement.classList.contains('ui-label__tag')).toBe(false);
|
||||
expect(
|
||||
hostElement.classList.contains('ui-label__tag-priority-high'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,16 +6,11 @@ import {
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LabelAppearance } from './types';
|
||||
import { LabelPriority, Labeltype } 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>
|
||||
* ```
|
||||
* A component that displays a label with a specific type and priority.
|
||||
* The label can be used to indicate tags or notices with different priorities.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ui-label',
|
||||
@@ -24,13 +19,21 @@ import { LabelAppearance } from './types';
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[class]': '["ui-label", appearanceClass()]',
|
||||
'[class]': '["ui-label", typeClass(), priorityClass()]',
|
||||
},
|
||||
})
|
||||
export class LabelComponent {
|
||||
/** The appearance of the label. */
|
||||
appearance = input<LabelAppearance>('primary');
|
||||
/** The type of the label. */
|
||||
type = input<Labeltype>(Labeltype.Tag);
|
||||
|
||||
/** A computed CSS class based on the current appearance. */
|
||||
appearanceClass = computed(() => `ui-label__${this.appearance()}`);
|
||||
/** A computed CSS class based on the current type. */
|
||||
typeClass = computed(() => `ui-label__${this.type()}`);
|
||||
|
||||
/** The priority of the label. */
|
||||
priority = input<LabelPriority>(LabelPriority.High);
|
||||
|
||||
/** A computed CSS class based on the current priority and typeClass. */
|
||||
priorityClass = computed(
|
||||
() => `${this.typeClass()}-priority-${this.priority()}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export const LabelAppearance = {
|
||||
Primary: 'primary',
|
||||
Secondary: 'secondary',
|
||||
export const Labeltype = {
|
||||
Tag: 'tag',
|
||||
Notice: 'notice',
|
||||
} as const;
|
||||
|
||||
export type LabelAppearance =
|
||||
(typeof LabelAppearance)[keyof typeof LabelAppearance];
|
||||
export type Labeltype = (typeof Labeltype)[keyof typeof Labeltype];
|
||||
|
||||
export const LabelPriority = {
|
||||
High: 'high',
|
||||
Medium: 'medium',
|
||||
Low: 'low',
|
||||
} as const;
|
||||
|
||||
export type LabelPriority = (typeof LabelPriority)[keyof typeof LabelPriority];
|
||||
|
||||
Reference in New Issue
Block a user