Merged PR 1890: #5230 #5233 Remi Starten Feedback

- feat(remission-data-access,remission-list,remission-return-receipt-details): improve remission list UX and persist store state
- feat(remission-list, remission-data-access): implement resource-based receipt data fetching
- Merge branch 'develop' into feature/5230-Feedback-Remi-Starten
- feat(remission-data-access, remission-list, ui-dialog, remission-start-dialog): consolidate remission workflow and enhance dialog system
- feat(remission-list-item): extract selection logic into dedicated component
Refs: #5230 #5233
This commit is contained in:
Nino Righi
2025-07-24 21:22:02 +00:00
committed by Lorenz Hilpert
parent f4b541c7c0
commit c5182809ac
29 changed files with 1054 additions and 234 deletions

View File

@@ -3,7 +3,15 @@ import { RemissionReturnReceiptService } from './remission-return-receipt.servic
import { ReturnService } from '@generated/swagger/inventory-api';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError } from '@isa/common/data-access';
import { Return, Stock, Receipt } from '../models';
import {
Return,
Stock,
Receipt,
RemissionListType,
ReceiptReturnTuple,
ReceiptReturnSuggestionTuple,
ReturnSuggestion,
} from '../models';
import { subDays } from 'date-fns';
import { of, throwError } from 'rxjs';
import { RemissionSupplierService } from './remission-supplier.service';
@@ -1078,4 +1086,514 @@ describe('RemissionReturnReceiptService', () => {
).rejects.toThrow('Observable error');
});
});
describe('startRemission', () => {
const mockReturn: Return = { id: 123 } as Return;
const mockReceipt: Receipt = { id: 456 } as Receipt;
const mockAssignedPackage: any = {
id: 456,
packageNumber: 'PKG-789',
};
beforeEach(() => {
// Mock the internal methods that startRemission calls
jest.spyOn(service, 'createReturn').mockResolvedValue(mockReturn);
jest.spyOn(service, 'createReceipt').mockResolvedValue(mockReceipt);
jest
.spyOn(service, 'assignPackage')
.mockResolvedValue(mockAssignedPackage);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should start remission successfully with all parameters', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
});
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should start remission successfully with undefined returnGroup and receiptNumber', async () => {
// Arrange
const params = {
returnGroup: undefined,
receiptNumber: undefined,
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
});
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: undefined,
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: undefined,
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should return undefined when createReturn fails', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReturn as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toBeUndefined();
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReturn returns null', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReturn as jest.Mock).mockResolvedValue(null);
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toBeUndefined();
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReceipt fails', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReceipt as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toBeUndefined();
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should return undefined when createReceipt returns null', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
(service.createReceipt as jest.Mock).mockResolvedValue(null);
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toBeUndefined();
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when createReturn throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const createReturnError = new Error('Failed to create return');
(service.createReturn as jest.Mock).mockRejectedValue(createReturnError);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to create return',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).not.toHaveBeenCalled();
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when createReceipt throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const createReceiptError = new Error('Failed to create receipt');
(service.createReceipt as jest.Mock).mockRejectedValue(
createReceiptError,
);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to create receipt',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).not.toHaveBeenCalled();
});
it('should throw error when assignPackage throws', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
const assignPackageError = new Error('Failed to assign package');
(service.assignPackage as jest.Mock).mockRejectedValue(
assignPackageError,
);
// Act & Assert
await expect(service.startRemission(params)).rejects.toThrow(
'Failed to assign package',
);
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should handle empty string parameters', async () => {
// Arrange
const params = {
returnGroup: '',
receiptNumber: '',
packageNumber: 'PKG-789',
};
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
});
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: '',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: '',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
it('should proceed even if assignPackage fails silently', async () => {
// Arrange
const params = {
returnGroup: 'group-1',
receiptNumber: 'REC-001',
packageNumber: 'PKG-789',
};
// Mock assignPackage to resolve with undefined (but not throw)
(service.assignPackage as jest.Mock).mockResolvedValue(undefined);
// Act
const result = await service.startRemission(params);
// Assert
expect(result).toEqual({
returnId: 123,
receiptId: 456,
});
expect(service.createReturn).toHaveBeenCalledWith({
returnGroup: 'group-1',
});
expect(service.createReceipt).toHaveBeenCalledWith({
returnId: 123,
receiptNumber: 'REC-001',
});
expect(service.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
packageNumber: 'PKG-789',
});
});
});
describe('remitItem', () => {
const mockReturnTuple = {
item1: {} as Receipt,
item2: {} as Return,
} as ReceiptReturnTuple;
const mockReturnSuggestionTuple = {
item1: {} as Receipt,
item2: {} as ReturnSuggestion,
} as ReceiptReturnSuggestionTuple;
const baseAddItem = {
returnId: 1,
receiptId: 2,
quantity: 4,
inStock: 5,
};
beforeEach(() => {
jest.spyOn(service, 'addReturnItem').mockResolvedValue(mockReturnTuple);
jest
.spyOn(service, 'addReturnSuggestionItem')
.mockResolvedValue(mockReturnSuggestionTuple);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should call addReturnSuggestionItem for Abteilung type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
};
// Act
const result = await service.remitItem(params);
// Assert
expect(result).toEqual(mockReturnSuggestionTuple);
expect(service.addReturnSuggestionItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
});
expect(service.addReturnItem).not.toHaveBeenCalled();
});
it('should call addReturnItem for Pflicht type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
};
// Act
const result = await service.remitItem(params);
// Assert
expect(result).toEqual(mockReturnTuple);
expect(service.addReturnItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
});
expect(service.addReturnSuggestionItem).not.toHaveBeenCalled();
});
it('should return undefined for unknown type', async () => {
// Arrange
const params = {
itemId: 3,
addItem: baseAddItem,
type: 'Unknown' as RemissionListType,
};
// Act
const result = await service.remitItem(params);
// Assert
expect(result).toBeUndefined();
expect(service.addReturnItem).not.toHaveBeenCalled();
expect(service.addReturnSuggestionItem).not.toHaveBeenCalled();
});
it('should handle addReturnSuggestionItem throwing error', async () => {
// Arrange
const error = new Error('API Error');
(service.addReturnSuggestionItem as jest.Mock).mockRejectedValue(error);
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
};
// Act & Assert
await expect(service.remitItem(params)).rejects.toThrow('API Error');
expect(service.addReturnSuggestionItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
});
});
it('should handle addReturnItem throwing error', async () => {
// Arrange
const error = new Error('API Error');
(service.addReturnItem as jest.Mock).mockRejectedValue(error);
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
};
// Act & Assert
await expect(service.remitItem(params)).rejects.toThrow('API Error');
expect(service.addReturnItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
});
});
it('should handle addReturnSuggestionItem returning undefined', async () => {
// Arrange
(service.addReturnSuggestionItem as jest.Mock).mockResolvedValue(
undefined,
);
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Abteilung,
};
// Act
const result = await service.remitItem(params);
// Assert
expect(result).toBeUndefined();
expect(service.addReturnSuggestionItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnSuggestionId: 3,
quantity: 4,
inStock: 5,
});
});
it('should handle addReturnItem returning undefined', async () => {
// Arrange
(service.addReturnItem as jest.Mock).mockResolvedValue(undefined);
const params = {
itemId: 3,
addItem: baseAddItem,
type: RemissionListType.Pflicht,
};
// Act
const result = await service.remitItem(params);
// Assert
expect(result).toBeUndefined();
expect(service.addReturnItem).toHaveBeenCalledWith({
returnId: 1,
receiptId: 2,
returnItemId: 3,
quantity: 4,
inStock: 5,
});
});
});
});

View File

@@ -21,6 +21,7 @@ import {
Receipt,
ReceiptReturnSuggestionTuple,
ReceiptReturnTuple,
RemissionListType,
} from '../models';
import { logger } from '@isa/core/logging';
import { RemissionSupplierService } from './remission-supplier.service';
@@ -673,4 +674,125 @@ export class RemissionReturnReceiptService {
return updatedReturnSuggestion;
}
/**
* Starts a new remission process by creating a return and receipt.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
*
* @async
* @param {Object} params - The parameters for starting the remission
* @param {string | undefined} params.returnGroup - Optional group identifier for the return
* @param {string | undefined} params.receiptNumber - Optional receipt number
* @param {string} params.packageNumber - The package number to assign
* @returns {Promise<FetchRemissionReturnParams | undefined>} The created return and receipt identifiers if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const remission = await service.startRemission({
* returnGroup: 'group1',
* receiptNumber: 'ABC-123',
* packageNumber: 'PKG-789',
* });
*/
async startRemission({
returnGroup,
receiptNumber,
packageNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
packageNumber: string;
}): Promise<FetchRemissionReturnParams | undefined> {
this.#logger.debug('Starting remission', () => ({
returnGroup,
receiptNumber,
packageNumber,
}));
// Warenbegleitschein eröffnen
const createdReturn: Return | undefined = await this.createReturn({
returnGroup,
});
if (!createdReturn) {
this.#logger.error('Failed to create return for remission');
return;
}
// Warenbegleitschein eröffnen
const createdReceipt: Receipt | undefined = await this.createReceipt({
returnId: createdReturn.id,
receiptNumber,
});
if (!createdReceipt) {
this.#logger.error('Failed to create return receipt');
return;
}
// Wannennummer zuweisen
await this.assignPackage({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
packageNumber,
});
this.#logger.info('Successfully started remission', () => ({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
}));
return {
returnId: createdReturn.id,
receiptId: createdReceipt.id,
};
}
/**
* Remits an item to the return receipt based on its type.
* Determines whether to add a return item or return suggestion item based on the remission list type.
*
* @async
* @param {Object} params - The parameters for remitting the item
* @param {number} params.itemId - The ID of the item to remit
* @param {AddReturnItem | AddReturnSuggestionItem} params.addItem - The item data to add
* @param {RemissionListType} params.type - The type of remission list (Abteilung or Pflicht)
* @returns {Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined>} The updated receipt and return tuple if successful, undefined otherwise
*/
async remitItem({
itemId,
addItem,
type,
}: {
itemId: number;
addItem:
| Omit<AddReturnItem, 'returnItemId'>
| Omit<AddReturnSuggestionItem, 'returnSuggestionId'>;
type: RemissionListType;
}): Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined> {
// ReturnSuggestion
if (type === RemissionListType.Abteilung) {
return await this.addReturnSuggestionItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
returnSuggestionId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
});
}
// ReturnItem
if (type === RemissionListType.Pflicht) {
return await this.addReturnItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
returnItemId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
});
}
return;
}
}

View File

@@ -193,7 +193,7 @@ export class RemissionSearchService {
* const response = await service.fetchList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 20,
* take: 250,
* skip: 0,
* orderBy: 'itemName'
* });
@@ -212,7 +212,7 @@ export class RemissionSearchService {
this.#logger.info('Fetching remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: parsed.take,
take: 250,
skip: parsed.skip,
}));
@@ -223,7 +223,7 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: parsed.take,
take: 250,
skip: parsed.skip,
},
});
@@ -270,7 +270,7 @@ export class RemissionSearchService {
* const departmentResponse = await service.fetchDepartmentList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 50,
* take: 250,
* skip: 0
* });
*
@@ -289,7 +289,7 @@ export class RemissionSearchService {
this.#logger.info('Fetching department remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: parsed.take,
take: 250,
skip: parsed.skip,
}));
@@ -300,7 +300,7 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: parsed.take,
take: 250,
skip: parsed.skip,
},
});

View File

@@ -1,12 +1,25 @@
import { RemissionStore } from './remission.store';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RemissionReturnReceiptService } from '../services';
describe('RemissionStore', () => {
let store: InstanceType<typeof RemissionStore>;
beforeEach(() => {
const mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipt: jest.fn(),
};
TestBed.configureTestingModule({
providers: [RemissionStore],
imports: [HttpClientTestingModule],
providers: [
RemissionStore,
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
});
store = TestBed.inject(RemissionStore);
});

View File

@@ -2,11 +2,15 @@ import {
patchState,
signalStore,
withComputed,
withHooks,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { ReturnItem, ReturnSuggestion } from '../models';
import { computed } from '@angular/core';
import { computed, inject, resource } from '@angular/core';
import { UserStorageProvider, withStorage } from '@isa/core/storage';
import { RemissionReturnReceiptService } from '../services';
/**
* Union type representing items that can be selected for remission.
@@ -22,12 +26,6 @@ interface RemissionState {
returnId: number | undefined;
/** The unique identifier for the receipt. Can only be set once. */
receiptId: number | undefined;
/** The receipt number associated with the remission. */
receiptNumber: string | undefined;
/** The total number of items in the remission receipt. */
receiptItemsCount: number | undefined;
/** The package number associated with the remission. */
packageNumber: string | undefined;
/** Map of selected remission items indexed by their ID */
selectedItems: Record<number, RemissionItem>;
/** Map of selected quantities for each remission item indexed by their ID */
@@ -41,9 +39,6 @@ interface RemissionState {
const initialState: RemissionState = {
returnId: undefined,
receiptId: undefined,
receiptNumber: undefined,
receiptItemsCount: undefined,
packageNumber: undefined,
selectedItems: {},
selectedQuantity: {},
};
@@ -71,10 +66,65 @@ const initialState: RemissionState = {
export const RemissionStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withStorage('remission-data-access.remission-store', UserStorageProvider),
withProps(
(
store,
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Private resource for fetching the current remission receipt.
*
* This resource automatically tracks changes to returnId and receiptId from the store
* and refetches the receipt data when either value changes. The resource returns
* undefined when either ID is not set, preventing unnecessary HTTP requests.
*
* The resource uses the injected RemissionReturnReceiptService to fetch receipt data
* and supports request cancellation via AbortSignal for proper cleanup.
*
* @private
* @returns A resource instance that manages the receipt data fetching lifecycle
*
* @example
* ```typescript
* // Access the resource through computed signals
* const receipt = computed(() => store._receiptResource.value());
* const status = computed(() => store._receiptResource.status());
* const error = computed(() => store._receiptResource.error());
*
* // Manually reload the resource
* store._receiptResource.reload();
* ```
*
* @see {@link https://angular.dev/guide/signals/resource} Angular Resource API documentation
*/
_receiptResource: resource({
loader: async ({ abortSignal }) => {
const receiptId = store.receiptId();
const returnId = store.returnId();
if (!receiptId || !returnId) {
return undefined;
}
const receipt =
await remissionReturnReceiptService.fetchRemissionReturnReceipt(
{
returnId,
receiptId,
},
abortSignal,
);
return receipt;
},
}),
}),
),
withComputed((store) => ({
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
receipt: computed(() => store._receiptResource.value()),
})),
withMethods((store) => ({
/**
@@ -93,15 +143,9 @@ export const RemissionStore = signalStore(
startRemission({
returnId,
receiptId,
receiptNumber,
receiptItemsCount,
packageNumber,
}: {
returnId: number;
receiptId: number;
receiptNumber: string;
receiptItemsCount: number;
packageNumber: string;
}) {
if (store.returnId() !== undefined || store.receiptId() !== undefined) {
throw new Error(
@@ -111,10 +155,16 @@ export const RemissionStore = signalStore(
patchState(store, {
returnId,
receiptId,
receiptNumber,
receiptItemsCount,
packageNumber,
});
store._receiptResource.reload();
store.storeState();
},
/**
* Reloads the receipt resource.
* This method should be called when the receipt data needs to be refreshed.
*/
reloadReceipt() {
store._receiptResource.reload();
},
/**
@@ -231,6 +281,7 @@ export const RemissionStore = signalStore(
*/
finishRemission() {
patchState(store, initialState);
store.storeState();
},
})),
);

View File

@@ -0,0 +1,10 @@
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="item()?.product?.ean"
/>
</ui-checkbox>

View File

@@ -0,0 +1,57 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} 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({
selector: 'remi-feature-remission-list-item-select',
templateUrl: './remission-list-item-select.component.html',
styleUrl: './remission-list-item-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
})
export class RemissionListItemSelectComponent {
/**
* Store for managing selected remission quantities.
* @private
*/
#store = inject(RemissionStore);
/**
* The item to display in the list.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
item = input.required<RemissionItem>();
/**
* Computes whether the current item is selected in the remission store.
* Checks if the item's ID exists in the selected items collection.
*/
itemSelected = computed(() => {
const itemId = this.item()?.id;
return !!itemId && !!this.#store.selectedItems()?.[itemId];
});
/**
* Selects the current item in the remission store.
* Updates the selected items and quantities based on the item's ID.
*
* @param selected - Whether the item should be selected or not
*/
setSelected(selected: boolean) {
const itemId = this.item()?.id;
if (itemId && selected) {
this.#store.selectRemissionItem(itemId, this.item());
}
if (itemId && !selected) {
this.#store.removeItem(itemId);
}
}
}

View File

@@ -1,21 +1,15 @@
@let i = item();
<ui-client-row data-what="remission-list-item" [attr.data-which]="i.id">
<ui-client-row-content class="flex flex-row gap-6">
<ui-client-row-content class="flex flex-row gap-6 justify-between">
<remi-product-info
[item]="i"
[orientation]="remiProductInfoOrientation()"
></remi-product-info>
@if (mobileBreakpoint()) {
<ui-checkbox class="self-start mt-4" appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="i?.product?.ean"
/>
</ui-checkbox>
@if (displayActions() && mobileBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-start mt-4"
[item]="i"
></remi-feature-remission-list-item-select>
}
</ui-client-row-content>
<ui-item-row-data>
@@ -37,21 +31,16 @@
></remi-product-stock-info>
</ui-item-row-data>
@if (showActionButtons()) {
@if (displayActions()) {
<ui-item-row-data
class="justify-end desktop-small:justify-between col-end-last"
>
@if (!mobileBreakpoint()) {
<ui-checkbox class="self-end mt-4" appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="i?.product?.ean"
/>
</ui-checkbox>
<remi-feature-remission-list-item-select
class="self-end mt-4"
[item]="i"
>
</remi-feature-remission-list-item-select>
}
<button

View File

@@ -1,3 +1,7 @@
:host {
@apply w-full;
}
.ui-client-row {
@apply isa-desktop-l:grid-cols-4;
}

View File

@@ -1,16 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { RemissionListItemComponent } from './remission-list-item.component';
import {
ReturnItem,
ReturnSuggestion,
StockInfo,
RemissionListType,
RemissionStore,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { MockComponent } from 'ng-mocks';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { signal } from '@angular/core';
// --- Setup dynamic mocking for injectRemissionListType ---
let remissionListTypeValue: RemissionListType = RemissionListType.Pflicht;
@@ -18,6 +23,28 @@ jest.mock('../injects/inject-remission-list-type', () => ({
injectRemissionListType: () => () => remissionListTypeValue,
}));
// Mock the calculation functions to have predictable behavior
jest.mock('@isa/remission/data-access', () => ({
...jest.requireActual('@isa/remission/data-access'),
calculateStockToRemit: jest.fn(),
}));
// Mock the RemissionStore
const mockRemissionStore = {
remissionStarted: signal(true),
selectedQuantity: signal({}),
updateRemissionQuantity: jest.fn(),
};
// Mock the dialog services
const mockNumberInputDialog = jest.fn();
const mockFeedbackDialog = jest.fn();
jest.mock('@isa/ui/dialog', () => ({
injectNumberInputDialog: () => mockNumberInputDialog,
injectFeedbackDialog: () => mockFeedbackDialog,
}));
describe('RemissionListItemComponent', () => {
let component: RemissionListItemComponent;
let fixture: ComponentFixture<RemissionListItemComponent>;
@@ -31,6 +58,7 @@ describe('RemissionListItemComponent', () => {
({
id: 1,
predefinedReturnQuantity: 5,
remainingQuantityInStock: 10,
...overrides,
}) as ReturnItem;
@@ -39,6 +67,7 @@ describe('RemissionListItemComponent', () => {
): ReturnSuggestion =>
({
id: 1,
remainingQuantityInStock: 10,
returnItem: {
data: {
id: 1,
@@ -51,7 +80,8 @@ describe('RemissionListItemComponent', () => {
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo =>
({
id: 1,
quantity: 100,
inStock: 100,
removedFromStock: 0,
...overrides,
}) as StockInfo;
@@ -61,6 +91,12 @@ describe('RemissionListItemComponent', () => {
RemissionListItemComponent,
MockComponent(ProductInfoComponent),
MockComponent(ProductStockInfoComponent),
MockComponent(RemissionListItemSelectComponent),
],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: RemissionStore, useValue: mockRemissionStore },
],
}).compileComponents();
@@ -68,6 +104,17 @@ describe('RemissionListItemComponent', () => {
component = fixture.componentInstance;
});
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
mockRemissionStore.selectedQuantity.set({});
mockRemissionStore.remissionStarted.set(true);
// Reset the mocked function to return 0 by default
const { calculateStockToRemit } = require('@isa/remission/data-access');
calculateStockToRemit.mockReturnValue(0);
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
@@ -324,6 +371,7 @@ describe('RemissionListItemComponent', () => {
setRemissionListType(RemissionListType.Abteilung);
const mockSuggestion = {
id: 1,
remainingQuantityInStock: 10,
returnItem: {
data: null as any,
},
@@ -338,6 +386,7 @@ describe('RemissionListItemComponent', () => {
it('should handle item with unexpected structure', () => {
const unexpectedItem = {
id: 1,
remainingQuantityInStock: 10,
// Missing both returnItem and predefinedReturnQuantity
} as any;
fixture.componentRef.setInput('item', unexpectedItem);

View File

@@ -22,12 +22,13 @@ import {
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectTextInputDialog } from '@isa/ui/dialog';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { firstValueFrom } from 'rxjs';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
import { CheckboxComponent } from '@isa/ui/input-controls';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
/**
* Component representing a single item in the remission list.
@@ -57,6 +58,7 @@ import { CheckboxComponent } from '@isa/ui/input-controls';
ClientRowImports,
ItemRowDataImports,
CheckboxComponent,
RemissionListItemSelectComponent,
],
})
export class RemissionListItemComponent {
@@ -64,7 +66,7 @@ export class RemissionListItemComponent {
* Dialog service for prompting the user to enter a remission quantity.
* @private
*/
#dialog = injectTextInputDialog();
#dialog = injectNumberInputDialog();
/**
* Dialog service for providing feedback to the user.
@@ -121,7 +123,7 @@ export class RemissionListItemComponent {
/**
* Computes the predefined return quantity for the current item.
* - For Abteilung (suggestion), uses the nested returnItem's predefined quantity.
* - For Abteilung (suggestion), uses the item's return item data or calculates based on stock.
* - For Pflicht (item), uses the item's predefined quantity.
* - Returns 0 if not available.
*/
@@ -130,9 +132,15 @@ export class RemissionListItemComponent {
// ReturnSuggestion
if (this.remissionListType() === RemissionListType.Abteilung) {
const predefinedReturnQuantity = (item as ReturnSuggestion)?.returnItem
?.data?.predefinedReturnQuantity;
return (
(item as ReturnSuggestion)?.returnItem?.data
?.predefinedReturnQuantity ?? 0
predefinedReturnQuantity ??
calculateStockToRemit({
availableStock: this.availableStock(),
remainingQuantityInStock: this.remainingQuantityInStock(),
}) ??
0
);
}
@@ -145,11 +153,13 @@ export class RemissionListItemComponent {
});
/**
* Computes whether the change quantity button should be shown based on remission list type.
* - For Pflicht, Abteilung, checks if predefined return quantity is greater than 0.
* Computes whether the item has a predefined return quantity.
* Returns true if the predefined quantity is greater than 0.
*/
showActionButtons = computed<boolean>(() => {
return !!this.predefinedReturnQuantity() && this.#store.remissionStarted();
displayActions = computed<boolean>(() => {
return (
this.predefinedReturnQuantity() > 0 && this.#store.remissionStarted()
);
});
/**
@@ -171,15 +181,6 @@ export class RemissionListItemComponent {
() => this.#store.selectedQuantity()?.[this.item().id!],
);
/**
* Computes whether the current item is selected in the remission store.
* Checks if the item's ID exists in the selected items collection.
*/
itemSelected = computed(() => {
const itemId = this.item()?.id;
return !!itemId && !!this.#store.selectedItems()?.[itemId];
});
/**
* Computes the stock to remit based on available stock, predefined return quantity,
* and remaining quantity in stock.
@@ -206,22 +207,6 @@ export class RemissionListItemComponent {
}),
);
/**
* Selects the current item in the remission store.
* Updates the selected items and quantities based on the item's ID.
*
* @param selected - Whether the item should be selected or not
*/
setSelected(selected: boolean) {
const itemId = this.item()?.id;
if (itemId && selected) {
this.#store.selectRemissionItem(itemId, this.item());
}
if (itemId && !selected) {
this.#store.removeItem(itemId);
}
}
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user for a new quantity and updates the store if valid.
@@ -252,9 +237,9 @@ export class RemissionListItemComponent {
const result = await firstValueFrom(dialogRef.closed);
const itemId = this.item()?.id;
const quantity = Number(result?.inputValue);
const quantity = result?.inputValue;
if (itemId && quantity > 0) {
if (itemId && quantity !== undefined && quantity > 0) {
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },

View File

@@ -20,14 +20,12 @@
<div class="flex flex-col gap-4 w-full items-center justify-center">
@for (item of items(); track item.id) {
@defer (on viewport) {
<a [routerLink]="['../', 'return', item.id]" class="w-full">
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
[productGroupValue]="getProductGroupValueForItem(item)"
></remi-feature-remission-list-item>
</a>
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
[productGroupValue]="getProductGroupValueForItem(item)"
></remi-feature-remission-list-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
@@ -44,7 +42,7 @@
@if (remissionStarted()) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(click)="remitItems()"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
defaultContent="Remittieren"
@@ -57,8 +55,7 @@
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[attr.disabled]="!hasSelectedItems()"
[attr.aria-disabled]="!hasSelectedItems()"
[disabled]="!hasSelectedItems()"
>
</ui-stateful-button>
}

View File

@@ -7,7 +7,7 @@ import {
untracked,
signal,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import {
provideFilter,
withQuerySettingsFactory,
@@ -18,7 +18,6 @@ import {
import { injectRestoreScrollPosition } from '@isa/utils/scroll-position';
import { RemissionStartCardComponent } from './remission-start-card/remission-start-card.component';
import { RemissionListSelectComponent } from './remission-list-select/remission-list-select.component';
import { toSignal } from '@angular/core/rxjs-interop';
import {
createRemissionInStockResource,
createRemissionListResource,
@@ -82,7 +81,6 @@ function querySettingsFactory() {
FilterControlsPanelComponent,
RemissionListSelectComponent,
RemissionListItemComponent,
RouterLink,
IconButtonComponent,
StatefulButtonComponent,
],
@@ -101,11 +99,6 @@ export class RemissionListComponent {
searchItemToRemitDialog = injectDialog(SearchItemToRemitDialogComponent);
/**
* Signal for the current route URL segments.
*/
routeUrl = toSignal(this.route.url);
/**
* FilterService instance for managing filter state and queries.
* @private
@@ -137,11 +130,6 @@ export class RemissionListComponent {
*/
restoreScrollPosition = injectRestoreScrollPosition();
/**
* Signal containing the current route data snapshot.
*/
routeData = toSignal(this.route.data);
/**
* Signal representing the currently selected remission list type.
*/
@@ -331,9 +319,17 @@ export class RemissionListComponent {
searchTerm: this.#filterService.query()?.input['qs'] || '',
isDepartment,
},
}).closed.subscribe((result) => {
}).closed.subscribe(async (result) => {
if (result) {
this.remissionResource.reload();
if (this.remissionStarted()) {
for (const item of result) {
if (item?.id) {
this.#store.selectRemissionItem(item.id, item);
}
}
await this.remitItems();
}
this.reloadListAndReceipt();
this.searchTrigger.set('reload');
}
});
@@ -358,34 +354,21 @@ export class RemissionListComponent {
const inStock = this.getAvailableStockForItem(item);
if (returnId && receiptId) {
// ReturnSuggestion
if (
this.selectedRemissionListType() === RemissionListType.Abteilung
) {
await this.#remissionReturnReceiptService.addReturnSuggestionItem({
await this.#remissionReturnReceiptService.remitItem({
itemId: remissionItemIdNumber,
addItem: {
returnId,
receiptId,
returnSuggestionId: remissionItemIdNumber,
quantity,
inStock,
});
}
// ReturnItem
if (this.selectedRemissionListType() === RemissionListType.Pflicht) {
await this.#remissionReturnReceiptService.addReturnItem({
returnId,
receiptId,
returnItemId: remissionItemIdNumber,
quantity,
inStock,
});
}
},
type: this.selectedRemissionListType(),
});
}
}
this.remitItemsState.set('success');
this.remissionResource.reload();
this.reloadListAndReceipt();
} catch (error) {
this.#logger.error('Failed to remit items', error);
this.remitItemsError.set(
@@ -399,4 +382,13 @@ export class RemissionListComponent {
this.#store.clearSelectedItems();
this.remitItemsInProgress.set(false);
}
/**
* Reloads the remission list and receipt data.
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReceipt() {
this.remissionResource.reload();
this.#store.reloadReceipt();
}
}

View File

@@ -12,14 +12,15 @@
</div>
</div>
<button
<a
class="remi-feature-remission-return-card__navigate-cta"
data-which="navigate-to-receipt"
data-what="navigate-to-receipt"
uiButton
color="secondary"
size="large"
(click)="navigateToReceipt()"
[routerLink]="['../return-receipt', returnId(), receiptId()]"
[relativeTo]="route"
>
Zum Warenbegleitschein
</button>
</a>

View File

@@ -3,8 +3,9 @@ import {
Component,
computed,
inject,
effect,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { RemissionStore } from '@isa/remission/data-access';
import { ButtonComponent } from '@isa/ui/buttons';
@@ -13,25 +14,30 @@ import { ButtonComponent } from '@isa/ui/buttons';
templateUrl: './remission-return-card.component.html',
styleUrl: './remission-return-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ButtonComponent],
imports: [RouterLink, ButtonComponent],
})
export class RemissionReturnCardComponent {
#router = inject(Router);
#route = inject(ActivatedRoute);
route = inject(ActivatedRoute);
#remissionStore = inject(RemissionStore);
receiptNumber = computed(() => {
const receiptNumber = this.#remissionStore.receiptNumber();
return receiptNumber?.substring(6, 12);
returnId = computed(() => this.#remissionStore.returnId());
receiptId = computed(() => this.#remissionStore.receiptId());
receiptItemsCount = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.items?.length ?? 0;
});
receiptItemsCount = computed(() => this.#remissionStore.receiptItemsCount());
receiptNumber = computed(() => {
const receipt = this.#remissionStore.receipt();
return receipt?.receiptNumber?.substring(6, 12);
});
async navigateToReceipt() {
const returnId = this.#remissionStore.returnId();
const receiptId = this.#remissionStore.receiptId();
await this.#router.navigate(['../return-receipt', returnId, receiptId], {
relativeTo: this.#route,
constructor() {
effect(() => {
this.returnId();
this.receiptId();
this.#remissionStore.reloadReceipt();
});
}
}

View File

@@ -19,25 +19,17 @@ export class RemissionStartCardComponent {
async startRemission() {
const remissionStartDialogRef = this.#remissionStartDialog({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
const result = await firstValueFrom(remissionStartDialogRef.closed);
if (result) {
const {
returnId,
receiptId,
receiptNumber,
receiptItemsCount,
packageNumber,
} = result;
const { returnId, receiptId } = result;
this.#remissionStore.startRemission({
returnId,
receiptId,
receiptNumber,
receiptItemsCount,
packageNumber,
});
}
}

View File

@@ -46,6 +46,7 @@ export const createRemissionListResource = (
return resource({
params,
loader: async ({ abortSignal, params }) => {
console.log(params.queryToken);
const assignedStock = await remissionStockService.fetchAssignedStock();
if (!assignedStock || !assignedStock.id) {

View File

@@ -56,7 +56,7 @@
@if (!returnResource.isLoading() && !returnResource.value()?.completed) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(click)="completeReturn()"
(clicked)="completeReturn()"
[(state)]="completeReturnState"
defaultContent="Wanne abschließen"
defaultWidth="13rem"

View File

@@ -20,7 +20,8 @@
uiInputControl
class="isa-text-body-2-bold placeholder:isa-text-body-2-bold"
placeholder="Packstück ID scannen"
type="number"
type="text"
inputmode="numeric"
[formControl]="control"
(cleared)="control.setValue(undefined)"
(blur)="control.updateValueAndValidity()"

View File

@@ -1,9 +1,3 @@
:host {
@apply flex flex-col gap-8;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
@apply appearance-none;
-webkit-appearance: none;
}

View File

@@ -36,7 +36,7 @@ export class CreateReturnReceiptComponent {
ReturnReceiptResultType = ReturnReceiptResultType;
createReturnReceipt = output<ReturnReceiptResult>();
control = new FormControl<number | undefined>(undefined, {
control = new FormControl<string | undefined>(undefined, {
validators: [Validators.required, Validators.pattern(/^\d{18}$/)],
});
@@ -44,7 +44,7 @@ export class CreateReturnReceiptComponent {
if (!value) {
return;
}
this.control.setValue(Number(value));
this.control.setValue(value);
}
onGenerate() {

View File

@@ -10,11 +10,7 @@ import { provideIcons } from '@ng-icons/core';
import { isaActionScanner } from '@isa/icons';
import { CreateReturnReceiptComponent } from './create-return-receipt.component';
import { AssignPackageNumberComponent } from './assign-package-number.component';
import {
Receipt,
RemissionReturnReceiptService,
Return,
} from '@isa/remission/data-access';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
export enum ReturnReceiptResultType {
Close = 'close',
@@ -27,7 +23,7 @@ export type ReturnReceiptResult =
| { type: ReturnReceiptResultType.Generate }
| {
type: ReturnReceiptResultType.Input;
value: number | undefined | null;
value: string | undefined | null;
}
| undefined;
@@ -38,9 +34,6 @@ export type RemissionStartDialogData = {
export type RemissionStartDialogResult = {
returnId: number;
receiptId: number;
receiptNumber: string;
receiptItemsCount: number;
packageNumber: string;
};
@Component({
@@ -69,64 +62,46 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
onAssignPackageNumber(packageNumber: string | undefined) {
const returnReceipt = this.createReturnReceipt();
if (packageNumber && returnReceipt) {
this.startRemission({ returnReceipt, packageNumber });
if (
packageNumber &&
returnReceipt &&
returnReceipt.type !== ReturnReceiptResultType.Close
) {
let receiptNumber: string | undefined = undefined; // undefined -> Wird generiert;
if (
returnReceipt.type === ReturnReceiptResultType.Input &&
returnReceipt.value
) {
receiptNumber = returnReceipt.value;
}
this.startRemission({ receiptNumber, packageNumber });
} else {
this.onDialogClose(undefined);
}
}
async startRemission({
returnReceipt,
receiptNumber,
packageNumber,
}: {
returnReceipt: ReturnReceiptResult;
receiptNumber: string | undefined;
packageNumber: string;
}) {
this.loadRequests.set(true);
// Warenbegleitschein erstellen
const createdReturn: Return | undefined =
await this.#remissionReturnReceiptService.createReturn({
returnGroup: this.data.returnGroup,
});
if (!createdReturn || !returnReceipt) {
return this.onDialogClose(undefined);
}
let receiptNumber: string | undefined;
if (returnReceipt.type === ReturnReceiptResultType.Input) {
receiptNumber = String(returnReceipt.value);
}
if (returnReceipt.type === ReturnReceiptResultType.Generate) {
receiptNumber = undefined; // Wird generiert
}
const createdReceipt: Receipt | undefined =
await this.#remissionReturnReceiptService.createReceipt({
returnId: createdReturn.id,
receiptNumber,
});
if (!createdReceipt) {
return this.onDialogClose(undefined);
}
// Wannennummer zuweisen
await this.#remissionReturnReceiptService.assignPackage({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
const response = await this.#remissionReturnReceiptService.startRemission({
returnGroup: this.data.returnGroup,
receiptNumber,
packageNumber,
});
if (!response) {
return this.onDialogClose(undefined);
}
this.onDialogClose({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
receiptNumber: createdReceipt.receiptNumber,
receiptItemsCount: createdReceipt?.items?.length ?? 0,
packageNumber,
returnId: response.returnId,
receiptId: response.receiptId,
});
}

View File

@@ -13,9 +13,4 @@
@apply overflow-y-auto overflow-x-hidden;
@apply min-h-0;
}
&:has(ui-feedback-dialog),
&:has(remi-remission-start-dialog) {
@apply gap-0;
}
}

View File

@@ -1,7 +1,7 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { DialogComponent } from './dialog.component';
import { DialogContentDirective } from './dialog-content.directive';
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { Component } from '@angular/core';
import { DialogRef, DIALOG_DATA } from '@angular/cdk/dialog';
import { NgComponentOutlet } from '@angular/common';
@@ -12,13 +12,18 @@ import { NgComponentOutlet } from '@angular/common';
template: '<div>Mock Content</div>',
standalone: true,
})
class MockDialogContentComponent extends DialogContentDirective<unknown, unknown> {}
class MockDialogContentComponent extends DialogContentDirective<
unknown,
unknown
> {}
describe('DialogComponent', () => {
let spectator: Spectator<DialogComponent<unknown, unknown, MockDialogContentComponent>>;
let spectator: Spectator<
DialogComponent<unknown, unknown, MockDialogContentComponent>
>;
const mockTitle = 'Test Dialog Title';
const mockDialogRef = { close: jest.fn() };
const createComponent = createComponentFactory({
component: DialogComponent,
imports: [NgComponentOutlet, MockDialogContentComponent], // Use imports instead of declarations for standalone components
@@ -26,7 +31,8 @@ describe('DialogComponent', () => {
{ provide: DIALOG_TITLE, useValue: mockTitle },
{ provide: DIALOG_CONTENT, useValue: MockDialogContentComponent },
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: {} }
{ provide: DIALOG_DATA, useValue: {} },
{ provide: DIALOG_CLASS_LIST, useValue: [] },
],
});

View File

@@ -1,11 +1,12 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } from './tokens';
import { ComponentType } from '@angular/cdk/portal';
import { NgComponentOutlet } from '@angular/common';
@@ -23,7 +24,7 @@ import { NgComponentOutlet } from '@angular/common';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgComponentOutlet],
host: {
'[class]': '["ui-dialog"]',
'[class]': 'classes()',
},
})
export class DialogComponent<D, R, C extends DialogContentDirective<D, R>> {
@@ -32,4 +33,18 @@ 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>;
/** Additional CSS classes provided via injection */
private readonly classList =
inject(DIALOG_CLASS_LIST, { optional: true }) ?? [];
/**
* Computed property that combines the base dialog class with any additional classes
* This allows for dynamic styling of the dialog based on external configuration
*
* @return An array of CSS class names to apply to the dialog element
*/
classes = computed(() => {
return ['ui-dialog', ...this.classList];
});
}

View File

@@ -5,6 +5,7 @@ import {
injectFeedbackDialog,
injectMessageDialog,
injectTextInputDialog,
injectNumberInputDialog,
} from './injects';
import { MessageDialogComponent } from './message-dialog/message-dialog.component';
import { DialogComponent } from './dialog.component';
@@ -13,6 +14,7 @@ import { Component } from '@angular/core';
import { DialogContentDirective } from './dialog-content.directive';
import { TextInputDialogComponent } from './text-input-dialog/text-input-dialog.component';
import { FeedbackDialogComponent } from './feedback-dialog/feedback-dialog.component';
import { NumberInputDialogComponent } from './number-input-dialog/number-input-dialog.component';
// Test component extending DialogContentDirective for testing
@Component({ template: '' })
@@ -177,6 +179,27 @@ describe('Dialog Injects', () => {
});
});
describe('injectNumberInputDialog', () => {
it('should create a dialog injector for NumberInputDialogComponent', () => {
// Act
const openNumberInputDialog = TestBed.runInInjectionContext(() =>
injectNumberInputDialog(),
);
openNumberInputDialog({
data: {
message: 'Test message',
inputLabel: 'Enter value',
inputDefaultValue: 0,
},
});
// Assert
const callOptions = mockDialogOpen.mock.calls[0][1];
const injector = callOptions.injector;
expect(injector.get(DIALOG_CONTENT)).toBe(NumberInputDialogComponent);
});
});
describe('injectFeedbackDialog', () => {
it('should create a dialog injector for FeedbackDialogComponent', () => {
// Act

View File

@@ -4,9 +4,10 @@ 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_CONTENT, DIALOG_TITLE } from './tokens';
import { DIALOG_CLASS_LIST, DIALOG_CONTENT, DIALOG_TITLE } 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';
import {
FeedbackDialogComponent,
FeedbackDialogData,
@@ -16,6 +17,9 @@ export interface InjectDialogOptions {
/** Optional title override for the dialog */
title?: string;
/** Optional additional CSS classes to apply to the dialog */
classList?: string[];
/** Optional width for the dialog */
width?: string;
@@ -81,6 +85,10 @@ export function injectDialog<C extends DialogContentDirective<any, any>>(
provide: DIALOG_TITLE,
useValue: openOptions?.title ?? injectOptions?.title,
},
{
provide: DIALOG_CLASS_LIST,
useValue: openOptions?.classList ?? injectOptions?.classList ?? [],
},
],
});
@@ -119,6 +127,13 @@ export const injectMessageDialog = () => injectDialog(MessageDialogComponent);
export const injectTextInputDialog = () =>
injectDialog(TextInputDialogComponent);
/**
* Convenience function that returns a pre-configured NumberInputDialog injector
* @returns A function to open a number input dialog
*/
export const injectNumberInputDialog = () =>
injectDialog(NumberInputDialogComponent);
/**
* Convenience function that returns a pre-configured FeedbackDialog injector
* @returns A function to open a feedback dialog
@@ -129,5 +144,6 @@ export const injectFeedbackDialog = (
injectDialog(FeedbackDialogComponent, {
disableClose: false,
minWidth: '20rem',
classList: ['gap-0'],
...options,
});

View File

@@ -16,3 +16,11 @@ export const DIALOG_TITLE = new InjectionToken<string | undefined>(
export const DIALOG_CONTENT = new InjectionToken<ComponentType<unknown>>(
'DIALOG_CONTENT',
);
/**
* Injection token for providing additional CSS classes to the dialog
* Allows customization of dialog appearance through external styles
*/
export const DIALOG_CLASS_LIST = new InjectionToken<string[]>(
'DIALOG_CLASS_LIST',
);