Merged PR 1974: feat(crm): introduce PrimaryCustomerCardResource and format-name utility

feat(crm): introduce PrimaryCustomerCardResource and format-name utility

Replace SelectedCustomerBonusCardsResource with a new PrimaryCustomerCardResource
that automatically loads and exposes the primary customer card as a signal.
This simplifies customer card access across the application by providing a
centralized, root-level injectable resource with automatic tab synchronization.

Create new @isa/utils/format-name library to consolidate customer name formatting
logic previously duplicated across components. The utility formats names with
configurable first name, last name, and organization name fields.

Key changes:
- Add PrimaryCustomerCardResource as providedIn root service with automatic
  customer selection tracking via effect
- Remove SelectedCustomerBonusCardsResource and its manual provisioning
- Extract formatName function to dedicated utility library with Vitest setup
- Update all reward-related components to use new resource pattern
- Migrate OMS components to use centralized format-name utility
- Add comprehensive unit tests for formatName function

BREAKING CHANGE: SelectedCustomerBonusCardsResource has been removed

Ref: #5389
This commit is contained in:
Nino Righi
2025-10-21 13:11:03 +00:00
committed by Lorenz Hilpert
parent f549c59bc8
commit 0b76552211
38 changed files with 592 additions and 208 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,125 @@
import { formatName } from './format-name.component';
describe('formatName', () => {
it('should format full name with first and last name', () => {
// Arrange
const input = {
firstName: 'John',
lastName: 'Doe',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Doe John');
});
it('should format name with organisation and full name', () => {
// Arrange
const input = {
firstName: 'John',
lastName: 'Doe',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - Doe John');
});
it('should format with only organisation name', () => {
// Arrange
const input = {
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp');
});
it('should format with only last name', () => {
// Arrange
const input = {
lastName: 'Doe',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Doe');
});
it('should format with only first name', () => {
// Arrange
const input = {
firstName: 'John',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('John');
});
it('should return empty string when all inputs are undefined', () => {
// Arrange
const input = {};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('');
});
it('should return empty string when all inputs are empty strings', () => {
// Arrange
const input = {
firstName: '',
lastName: '',
organisationName: '',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('');
});
it('should format with organisation and only last name', () => {
// Arrange
const input = {
lastName: 'Doe',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - Doe');
});
it('should format with organisation and only first name', () => {
// Arrange
const input = {
firstName: 'John',
organisationName: 'Acme Corp',
};
// Act
const result = formatName(input);
// Assert
expect(result).toBe('Acme Corp - John');
});
});

View File

@@ -0,0 +1,45 @@
/**
* Formats a name by combining first name, last name, and organisation name.
*
* The function follows these formatting rules:
* - Names are formatted as "LastName FirstName"
* - Organisation name is separated from the personal name with " - "
* - Empty or undefined values are filtered out
* - If all values are empty/undefined, returns an empty string
*
* @param params - The name components to format
* @param params.firstName - The person's first name (optional)
* @param params.lastName - The person's last name (optional)
* @param params.organisationName - The organisation name (optional)
* @returns The formatted name string
*
* @example
* formatName({ firstName: 'John', lastName: 'Doe' })
* // Returns: "Doe John"
*
* @example
* formatName({ firstName: 'John', lastName: 'Doe', organisationName: 'Acme Corp' })
* // Returns: "Acme Corp - Doe John"
*
* @example
* formatName({ organisationName: 'Acme Corp' })
* // Returns: "Acme Corp"
*/
export const formatName = ({
firstName,
lastName,
organisationName,
}: {
firstName?: string;
lastName?: string;
organisationName?: string;
}): string => {
const nameCombined = [lastName, firstName].filter((f) => !!f);
const organisation = [organisationName].filter((f) => !!f);
return (
[organisation.join(), nameCombined.join(' ')]
.filter((f) => !!f)
.join(' - ') ?? ''
);
};

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

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

View File

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