From 651912e868e9e078f01834baedd5e8cd885e12e4 Mon Sep 17 00:00:00 2001 From: Peter Ashwood Date: Wed, 19 Aug 2026 21:26:14 +0000 Subject: [PATCH 1/4] feat(product)!: add custom attributes feature BREAKING CHANGE: `DaffProductReducersState` has two new required properties: `customAttributes` and `customAttributesOperation` --- .../state/src/reducers/product-reducers.ts | 2 + .../composite-product-item-option.factory.ts | 6 +- .../composite-product-item.factory.spec.ts | 7 +- .../composite-product-item.factory.ts | 2 +- .../factories/composite-product.factory.ts | 9 +- .../factories/configurable-product.factory.ts | 24 ++-- .../src/custom-attribute.service.spec.ts | 83 +++++++++++++ .../magento/src/custom-attribute.service.ts | 38 ++++++ .../src/custom-attributes/attribute.type.ts | 30 +++++ .../src/custom-attributes/error.type.ts | 11 ++ .../src/custom-attributes/public_api.ts | 14 +++ .../src/custom-attributes/value.type.ts | 13 +++ .../src/models/attributes-list.type.ts | 9 ++ .../src/models/custom-attributes.type.ts | 9 ++ .../magento/src/models/magento-product.ts | 2 + .../driver/magento/src/models/public_api.ts | 2 + .../magento/src/product-driver.module.ts | 9 +- libs/product/driver/magento/src/provider.ts | 9 +- libs/product/driver/magento/src/public_api.ts | 2 + .../magento/src/queries/attributes-list.ts | 23 ++++ .../queries/attributes-list/validator.spec.ts | 34 ++++++ .../src/queries/attributes-list/validator.ts | 21 ++++ .../queries/fragments/custom-attributes.ts | 26 +++++ .../src/queries/fragments/product-page.ts | 3 + .../src/queries/fragments/public_api.ts | 1 + .../driver/magento/src/queries/public_api.ts | 2 + .../custom-attribute-transformers.spec.ts | 95 +++++++++++++++ .../custom-attribute-transformers.ts | 49 ++++++++ .../magento/src/transforms/public_api.ts | 1 + .../simple-product-transformers.spec.ts | 109 +++++++++++++----- .../transforms/simple-product-transformers.ts | 24 +++- .../src/factories/core/product.factory.ts | 4 + .../shopify-daff-product-transform.ts | 1 + .../custom-attribute-service.interface.ts | 25 ++++ libs/product/driver/src/public_api.ts | 5 + .../drivers/custom-attribute.service.spec.ts | 34 ++++++ .../src/drivers/custom-attribute.service.ts | 25 ++++ .../src/drivers/product-driver.module.ts | 7 +- libs/product/driver/testing/src/public_api.ts | 1 + .../product/src/custom-attribute/kind.enum.ts | 10 ++ .../src/custom-attribute/public_api.ts | 12 ++ .../src/custom-attribute/selection.type.ts | 31 +++++ libs/product/src/custom-attribute/type.ts | 44 +++++++ libs/product/src/models/product.ts | 6 + libs/product/src/public_api.ts | 1 + .../state/src/custom-attributes/actions.ts | 46 ++++++++ .../src/custom-attributes/effects.spec.ts | 99 ++++++++++++++++ .../state/src/custom-attributes/effects.ts | 51 ++++++++ .../src/custom-attributes/facade.interface.ts | 14 +++ .../src/custom-attributes/facade.spec.ts | 74 ++++++++++++ .../state/src/custom-attributes/facade.ts | 58 ++++++++++ .../src/custom-attributes/provider.spec.ts | 13 +++ .../state/src/custom-attributes/provider.ts | 18 +++ .../state/src/custom-attributes/public_api.ts | 7 ++ .../reducers/entities.reducer.spec.ts | 49 ++++++++ .../reducers/entities.reducer.ts | 36 ++++++ .../reducers/operation.reducer.spec.ts | 102 ++++++++++++++++ .../reducers/operation.reducer.ts | 35 ++++++ .../custom-attributes/reducers/public_api.ts | 5 + .../src/custom-attributes/selectors.spec.ts | 94 +++++++++++++++ .../state/src/custom-attributes/selectors.ts | 51 ++++++++ .../product/state/src/product-state.module.ts | 4 + libs/product/state/src/public_api.ts | 1 + .../injection-tokens/reducers.token.spec.ts | 10 +- .../product-reducers-state.interface.ts | 8 +- .../state/src/reducers/product-reducers.ts | 4 + .../custom-attribute-option.factory.spec.ts | 33 ++++++ .../custom-attribute-option.factory.ts | 25 ++++ .../custom-attribute-value.factory.spec.ts | 33 ++++++ .../custom-attribute-value.factory.ts | 77 +++++++++++++ .../custom-attribute.factory.spec.ts | 34 ++++++ .../src/factories/custom-attribute.factory.ts | 82 +++++++++++++ .../src/factories/default-product.factory.ts | 8 +- .../src/factories/extension.factory.spec.ts | 11 +- .../src/factories/kind.factory.spec.ts | 9 +- .../testing/src/factories/kind.factory.ts | 8 +- .../testing/src/factories/public_api.ts | 19 ++- .../src/factories/related-product.factory.ts | 9 +- .../src/factories/reviewed-product.factory.ts | 6 +- .../state/src/reducers/product/reducers.ts | 2 + .../src/factories/upsell-product.factory.ts | 9 +- 81 files changed, 1937 insertions(+), 82 deletions(-) create mode 100644 libs/product/driver/magento/src/custom-attribute.service.spec.ts create mode 100644 libs/product/driver/magento/src/custom-attribute.service.ts create mode 100644 libs/product/driver/magento/src/custom-attributes/attribute.type.ts create mode 100644 libs/product/driver/magento/src/custom-attributes/error.type.ts create mode 100644 libs/product/driver/magento/src/custom-attributes/public_api.ts create mode 100644 libs/product/driver/magento/src/custom-attributes/value.type.ts create mode 100644 libs/product/driver/magento/src/models/attributes-list.type.ts create mode 100644 libs/product/driver/magento/src/models/custom-attributes.type.ts create mode 100644 libs/product/driver/magento/src/queries/attributes-list.ts create mode 100644 libs/product/driver/magento/src/queries/attributes-list/validator.spec.ts create mode 100644 libs/product/driver/magento/src/queries/attributes-list/validator.ts create mode 100644 libs/product/driver/magento/src/queries/fragments/custom-attributes.ts create mode 100644 libs/product/driver/magento/src/transforms/custom-attribute-transformers.spec.ts create mode 100644 libs/product/driver/magento/src/transforms/custom-attribute-transformers.ts create mode 100644 libs/product/driver/src/interfaces/custom-attribute-service.interface.ts create mode 100644 libs/product/driver/testing/src/drivers/custom-attribute.service.spec.ts create mode 100644 libs/product/driver/testing/src/drivers/custom-attribute.service.ts create mode 100644 libs/product/src/custom-attribute/kind.enum.ts create mode 100644 libs/product/src/custom-attribute/public_api.ts create mode 100644 libs/product/src/custom-attribute/selection.type.ts create mode 100644 libs/product/src/custom-attribute/type.ts create mode 100644 libs/product/state/src/custom-attributes/actions.ts create mode 100644 libs/product/state/src/custom-attributes/effects.spec.ts create mode 100644 libs/product/state/src/custom-attributes/effects.ts create mode 100644 libs/product/state/src/custom-attributes/facade.interface.ts create mode 100644 libs/product/state/src/custom-attributes/facade.spec.ts create mode 100644 libs/product/state/src/custom-attributes/facade.ts create mode 100644 libs/product/state/src/custom-attributes/provider.spec.ts create mode 100644 libs/product/state/src/custom-attributes/provider.ts create mode 100644 libs/product/state/src/custom-attributes/public_api.ts create mode 100644 libs/product/state/src/custom-attributes/reducers/entities.reducer.spec.ts create mode 100644 libs/product/state/src/custom-attributes/reducers/entities.reducer.ts create mode 100644 libs/product/state/src/custom-attributes/reducers/operation.reducer.spec.ts create mode 100644 libs/product/state/src/custom-attributes/reducers/operation.reducer.ts create mode 100644 libs/product/state/src/custom-attributes/reducers/public_api.ts create mode 100644 libs/product/state/src/custom-attributes/selectors.spec.ts create mode 100644 libs/product/state/src/custom-attributes/selectors.ts create mode 100644 libs/product/testing/src/factories/custom-attribute-option.factory.spec.ts create mode 100644 libs/product/testing/src/factories/custom-attribute-option.factory.ts create mode 100644 libs/product/testing/src/factories/custom-attribute-value.factory.spec.ts create mode 100644 libs/product/testing/src/factories/custom-attribute-value.factory.ts create mode 100644 libs/product/testing/src/factories/custom-attribute.factory.spec.ts create mode 100644 libs/product/testing/src/factories/custom-attribute.factory.ts diff --git a/libs/category/state/src/reducers/product-reducers.ts b/libs/category/state/src/reducers/product-reducers.ts index 1418d6f2c4..76340e22c1 100644 --- a/libs/category/state/src/reducers/product-reducers.ts +++ b/libs/category/state/src/reducers/product-reducers.ts @@ -9,4 +9,6 @@ export const daffCategoryProductReducers: ActionReducerMap{ +export class DaffCompositeProductItemOptionFactory extends DaffModelFactory{ constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockCompositeProductItemOption, imageFactory); + super(MockCompositeProductItemOption, imageFactory, customAttributeFactory); } } diff --git a/libs/product-composite/testing/src/factories/composite-product-item.factory.spec.ts b/libs/product-composite/testing/src/factories/composite-product-item.factory.spec.ts index 794007d6fe..847bbb190c 100644 --- a/libs/product-composite/testing/src/factories/composite-product-item.factory.spec.ts +++ b/libs/product-composite/testing/src/factories/composite-product-item.factory.spec.ts @@ -5,14 +5,9 @@ import { DaffCompositeProductItem } from '@daffodil/product-composite'; import { DaffCompositeProductItemFactory } from './composite-product-item.factory'; describe('@daffodil/product-composite/testing | DaffCompositeProductItemFactory', () => { - - let compositeProductFactory; + let compositeProductFactory: DaffCompositeProductItemFactory; beforeEach(() => { - TestBed.configureTestingModule({ - providers: [DaffCompositeProductItemFactory], - }); - compositeProductFactory = TestBed.inject(DaffCompositeProductItemFactory); }); diff --git a/libs/product-composite/testing/src/factories/composite-product-item.factory.ts b/libs/product-composite/testing/src/factories/composite-product-item.factory.ts index 0c2c62ffda..fc658e90f1 100644 --- a/libs/product-composite/testing/src/factories/composite-product-item.factory.ts +++ b/libs/product-composite/testing/src/factories/composite-product-item.factory.ts @@ -38,7 +38,7 @@ export class MockCompositeProductItem implements DaffCompositeProductItem { @Injectable({ providedIn: 'root', }) -export class DaffCompositeProductItemFactory extends DaffModelFactory{ +export class DaffCompositeProductItemFactory extends DaffModelFactory{ constructor( optionFactory: DaffCompositeProductItemOptionFactory, ) { diff --git a/libs/product-composite/testing/src/factories/composite-product.factory.ts b/libs/product-composite/testing/src/factories/composite-product.factory.ts index 1a0cea06d3..7931013552 100644 --- a/libs/product-composite/testing/src/factories/composite-product.factory.ts +++ b/libs/product-composite/testing/src/factories/composite-product.factory.ts @@ -4,6 +4,7 @@ import { DaffModelFactory } from '@daffodil/core/testing'; import { DaffProductTypeEnum } from '@daffodil/product'; import { DaffProductImageFactory, + DaffProductCustomAttributeValueFactory, MockProduct, } from '@daffodil/product/testing'; import { DaffCompositeProduct } from '@daffodil/product-composite'; @@ -20,8 +21,9 @@ export class MockCompositeProduct extends MockProduct implements DaffCompositePr constructor( protected itemFactory: DaffCompositeProductItemFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(imageFactory); + super(imageFactory, customAttributeFactory); } } @@ -31,11 +33,12 @@ export class MockCompositeProduct extends MockProduct implements DaffCompositePr @Injectable({ providedIn: 'root', }) -export class DaffCompositeProductFactory extends DaffModelFactory{ +export class DaffCompositeProductFactory extends DaffModelFactory{ constructor( itemFactory: DaffCompositeProductItemFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockCompositeProduct, itemFactory, imageFactory); + super(MockCompositeProduct, itemFactory, imageFactory, customAttributeFactory); } } diff --git a/libs/product-configurable/testing/src/factories/configurable-product.factory.ts b/libs/product-configurable/testing/src/factories/configurable-product.factory.ts index 58a045a027..64a5ff2d2c 100644 --- a/libs/product-configurable/testing/src/factories/configurable-product.factory.ts +++ b/libs/product-configurable/testing/src/factories/configurable-product.factory.ts @@ -5,6 +5,7 @@ import { DaffModelFactory } from '@daffodil/core/testing'; import { DaffProductTypeEnum } from '@daffodil/product'; import { DaffProductImageFactory, + DaffProductCustomAttributeValueFactory, MockProduct, } from '@daffodil/product/testing'; import { DaffConfigurableProduct } from '@daffodil/product-configurable'; @@ -32,7 +33,7 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur label: 'Blue', swatch: { value: '#0000FF', - thumbnail: null, + thumbnail: undefined, }, }, { @@ -40,7 +41,7 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur label: 'Yellow', swatch: { value: '#FFFF00', - thumbnail: null, + thumbnail: undefined, }, }, { @@ -48,7 +49,7 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur label: 'Red', swatch: { value: '#FF0000', - thumbnail: null, + thumbnail: undefined, }, }, ], @@ -61,17 +62,17 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur { value: '0', label: 'Small', - swatch: null, + swatch: undefined, }, { value: '1', label: 'Medium', - swatch: null, + swatch: undefined, }, { value: '2', label: 'Large', - swatch: null, + swatch: undefined, }, ], }, @@ -83,17 +84,17 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur { value: '0', label: 'Cotton', - swatch: null, + swatch: undefined, }, { value: '1', label: 'Polyester', - swatch: null, + swatch: undefined, }, { value: '2', label: 'Spandex', - swatch: null, + swatch: undefined, }, ], }, @@ -248,10 +249,11 @@ export class MockConfigurableProduct extends MockProduct implements DaffConfigur @Injectable({ providedIn: 'root', }) -export class DaffConfigurableProductFactory extends DaffModelFactory{ +export class DaffConfigurableProductFactory extends DaffModelFactory{ constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockConfigurableProduct, imageFactory); + super(MockConfigurableProduct, imageFactory, customAttributeFactory); } } diff --git a/libs/product/driver/magento/src/custom-attribute.service.spec.ts b/libs/product/driver/magento/src/custom-attribute.service.spec.ts new file mode 100644 index 0000000000..e9924da6c4 --- /dev/null +++ b/libs/product/driver/magento/src/custom-attribute.service.spec.ts @@ -0,0 +1,83 @@ +import { TestBed } from '@angular/core/testing'; +import { InMemoryCache } from '@apollo/client'; +import { addTypenameToDocument } from '@apollo/client/utilities'; +import { + ApolloTestingModule, + ApolloTestingController, + APOLLO_TESTING_CACHE, +} from 'apollo-angular/testing'; + +import { MAGENTO_POSSIBLE_TYPES } from '@daffodil/driver/magento'; +import { DaffProductCustomAttributeKind } from '@daffodil/product'; +import { + getAttributesList, + MagentoAttribute, + MagentoAttributeFrontendInputEnum, +} from '@daffodil/product/driver/magento'; + +import { DaffMagentoProductCustomAttributeService } from './custom-attribute.service'; + +describe('@daffodil/product/driver/magento | DaffMagentoProductCustomAttributeService', () => { + let service: DaffMagentoProductCustomAttributeService; + let controller: ApolloTestingController; + let stubMagentoAttribute: MagentoAttribute; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + ApolloTestingModule, + ], + providers: [ + DaffMagentoProductCustomAttributeService, + { + provide: APOLLO_TESTING_CACHE, + useValue: new InMemoryCache({ + possibleTypes: MAGENTO_POSSIBLE_TYPES.possibleTypes, + }), + }, + ], + }); + + service = TestBed.inject(DaffMagentoProductCustomAttributeService); + controller = TestBed.inject(ApolloTestingController); + + stubMagentoAttribute = { + code: 'brand', + label: 'Brand', + frontend_input: MagentoAttributeFrontendInputEnum.TEXT, + options: [], + }; + }); + + afterEach(() => { + controller.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('list | listing the custom attributes', () => { + it('should return a list of DaffProductCustomAttributes', done => { + service.list().subscribe(result => { + expect(result).toEqual([{ + id: stubMagentoAttribute.code, + kind: DaffProductCustomAttributeKind.SCALAR, + label: stubMagentoAttribute.label, + }]); + done(); + }); + + const op = controller.expectOne(addTypenameToDocument(getAttributesList())); + + op.flush({ + data: { + attributesList: { + items: [stubMagentoAttribute], + errors: [], + }, + }, + }); + }); + }); +}); diff --git a/libs/product/driver/magento/src/custom-attribute.service.ts b/libs/product/driver/magento/src/custom-attribute.service.ts new file mode 100644 index 0000000000..6975847811 --- /dev/null +++ b/libs/product/driver/magento/src/custom-attribute.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { Apollo } from 'apollo-angular'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeServiceInterface } from '@daffodil/product/driver'; + +import { MagentoAttributesList } from './models/public_api'; +import { + getAttributesList, + magentoAttributesListValidator, +} from './queries/public_api'; +import { DaffMagentoCustomAttributeTransformer } from './transforms/public_api'; + +/** + * A service for making magento apollo queries for the list of {@link DaffProductCustomAttribute}s. + * + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffMagentoProductCustomAttributeService implements DaffProductCustomAttributeServiceInterface { + constructor( + private apollo: Apollo, + private customAttributeTransformer: DaffMagentoCustomAttributeTransformer, + ) {} + + list(): Observable { + return this.apollo.query<{ attributesList: MagentoAttributesList }>({ + query: getAttributesList(), + }).pipe( + map(magentoAttributesListValidator), + map(result => this.customAttributeTransformer.transformManyMagentoAttributes(result.data.attributesList.items)), + ); + } +} diff --git a/libs/product/driver/magento/src/custom-attributes/attribute.type.ts b/libs/product/driver/magento/src/custom-attributes/attribute.type.ts new file mode 100644 index 0000000000..91391b84e4 --- /dev/null +++ b/libs/product/driver/magento/src/custom-attributes/attribute.type.ts @@ -0,0 +1,30 @@ +export enum MagentoAttributeFrontendInputEnum { + BOOLEAN = 'BOOLEAN', + DATE = 'DATE', + DATETIME = 'DATETIME', + FILE = 'FILE', + GALLERY = 'GALLERY', + HIDDEN = 'HIDDEN', + IMAGE = 'IMAGE', + MEDIA_IMAGE = 'MEDIA_IMAGE', + MULTILINE = 'MULTILINE', + MULTISELECT = 'MULTISELECT', + PRICE = 'PRICE', + SELECT = 'SELECT', + TEXT = 'TEXT', + TEXTAREA = 'TEXTAREA', + WEIGHT = 'WEIGHT', + UNDEFINED = 'UNDEFINED', +} + +export interface MagentoAttributeOption { + value: string; + label: string; +} + +export interface MagentoAttribute { + code: string; + label: string; + frontend_input: MagentoAttributeFrontendInputEnum; + options: Array; +} diff --git a/libs/product/driver/magento/src/custom-attributes/error.type.ts b/libs/product/driver/magento/src/custom-attributes/error.type.ts new file mode 100644 index 0000000000..d890e2c364 --- /dev/null +++ b/libs/product/driver/magento/src/custom-attributes/error.type.ts @@ -0,0 +1,11 @@ +export enum MagentoAttributeMetadataErrorType { + ENTITY_NOT_FOUND = 'ENTITY_NOT_FOUND', + ATTRIBUTE_NOT_FOUND = 'ATTRIBUTE_NOT_FOUND', + FILTER_NOT_FOUND = 'FILTER_NOT_FOUND', + UNDEFINED = 'UNDEFINED' +} + +export interface MagentoAttributeMetadataError { + type: MagentoAttributeMetadataErrorType; + message: string; +} diff --git a/libs/product/driver/magento/src/custom-attributes/public_api.ts b/libs/product/driver/magento/src/custom-attributes/public_api.ts new file mode 100644 index 0000000000..3961ce7bd1 --- /dev/null +++ b/libs/product/driver/magento/src/custom-attributes/public_api.ts @@ -0,0 +1,14 @@ +export { + MagentoAttributeSelectedOptions, + MagentoAttributeValue, + MagentoAttributeValueText, +} from './value.type'; +export { + MagentoAttributeMetadataError, + MagentoAttributeMetadataErrorType, +} from './error.type'; +export { + MagentoAttribute, + MagentoAttributeOption, + MagentoAttributeFrontendInputEnum, +} from './attribute.type'; diff --git a/libs/product/driver/magento/src/custom-attributes/value.type.ts b/libs/product/driver/magento/src/custom-attributes/value.type.ts new file mode 100644 index 0000000000..a7b0b826bc --- /dev/null +++ b/libs/product/driver/magento/src/custom-attributes/value.type.ts @@ -0,0 +1,13 @@ +export interface MagentoAttributeValueText { + __typename: 'AttributeValue'; + code: string; + value: string; +} + +export interface MagentoAttributeSelectedOptions { + __typename: 'AttributeSelectedOptions'; + code: string; + selected_options: Array<{label: string; value: string}>; +} + +export type MagentoAttributeValue = MagentoAttributeValueText | MagentoAttributeSelectedOptions; diff --git a/libs/product/driver/magento/src/models/attributes-list.type.ts b/libs/product/driver/magento/src/models/attributes-list.type.ts new file mode 100644 index 0000000000..68987b8801 --- /dev/null +++ b/libs/product/driver/magento/src/models/attributes-list.type.ts @@ -0,0 +1,9 @@ +import { + MagentoAttribute, + MagentoAttributeMetadataError, +} from '../custom-attributes/public_api'; + +export interface MagentoAttributesList { + items: Array; + errors: Array; +} diff --git a/libs/product/driver/magento/src/models/custom-attributes.type.ts b/libs/product/driver/magento/src/models/custom-attributes.type.ts new file mode 100644 index 0000000000..fbb4e2ac0f --- /dev/null +++ b/libs/product/driver/magento/src/models/custom-attributes.type.ts @@ -0,0 +1,9 @@ +import { + MagentoAttributeMetadataError, + MagentoAttributeValue, +} from '../custom-attributes/public_api'; + +export interface MagentoCustomAttributes { + items: Array; + errors: Array; +} diff --git a/libs/product/driver/magento/src/models/magento-product.ts b/libs/product/driver/magento/src/models/magento-product.ts index 70ce40fafe..fdb10247ca 100644 --- a/libs/product/driver/magento/src/models/magento-product.ts +++ b/libs/product/driver/magento/src/models/magento-product.ts @@ -1,3 +1,4 @@ +import { MagentoCustomAttributes } from './custom-attributes.type'; import { MagentoProductPreview } from './product-preview.interface'; export enum MagentoProductTypeEnum { @@ -26,4 +27,5 @@ export interface MagentoProduct extends MagentoProductPreview { description?: { html: string; }; + custom_attributesV2?: MagentoCustomAttributes; } diff --git a/libs/product/driver/magento/src/models/public_api.ts b/libs/product/driver/magento/src/models/public_api.ts index 49f048c945..2106b25106 100644 --- a/libs/product/driver/magento/src/models/public_api.ts +++ b/libs/product/driver/magento/src/models/public_api.ts @@ -19,3 +19,5 @@ export { MagentoSortDirectionEnum, MagentoSortFieldAction, } from './sort'; +export { MagentoCustomAttributes } from './custom-attributes.type'; +export { MagentoAttributesList } from './attributes-list.type'; diff --git a/libs/product/driver/magento/src/product-driver.module.ts b/libs/product/driver/magento/src/product-driver.module.ts index 9000e0e25c..0504d6b41c 100644 --- a/libs/product/driver/magento/src/product-driver.module.ts +++ b/libs/product/driver/magento/src/product-driver.module.ts @@ -5,14 +5,19 @@ import { } from '@angular/core'; import { provideManyDaffMagentoCacheableOperations } from '@daffodil/driver/magento'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; +import { + provideDaffProductDriver, + provideDaffProductCustomAttributeDriver, +} from '@daffodil/product/driver'; import { MAGENTO_PRODUCT_CONFIG_DEFAULT } from './config/public_api'; +import { DaffMagentoProductCustomAttributeService } from './custom-attribute.service'; import { DaffProductMagentoDriverConfig, provideMagentoProductConfig, } from './interfaces/public_api'; import { DaffMagentoProductService } from './product.service'; +import { DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME } from './queries/attributes-list'; import { DAFF_MAGENTO_GET_ALL_PRODUCTS_QUERY_NAME } from './queries/get-all-products'; import { DAFF_MAGENTO_GET_A_PRODUCT_QUERY_NAME } from './queries/get-product'; import { DAFF_MAGENTO_GET_A_PRODUCT_BY_URL_QUERY_NAME } from './queries/get-product-by-url/query'; @@ -32,10 +37,12 @@ export class DaffProductMagentoDriverModule { ngModule: DaffProductMagentoDriverModule, providers: [ provideDaffProductDriver(DaffMagentoProductService), + provideDaffProductCustomAttributeDriver(DaffMagentoProductCustomAttributeService), provideManyDaffMagentoCacheableOperations( DAFF_MAGENTO_GET_ALL_PRODUCTS_QUERY_NAME, DAFF_MAGENTO_GET_A_PRODUCT_QUERY_NAME, DAFF_MAGENTO_GET_A_PRODUCT_BY_URL_QUERY_NAME, + DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME, ), provideMagentoProductConfig(config), ], diff --git a/libs/product/driver/magento/src/provider.ts b/libs/product/driver/magento/src/provider.ts index 9467f932f7..9d8830a7c6 100644 --- a/libs/product/driver/magento/src/provider.ts +++ b/libs/product/driver/magento/src/provider.ts @@ -1,12 +1,17 @@ import { Provider } from '@angular/core'; import { provideManyDaffMagentoCacheableOperations } from '@daffodil/driver/magento'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; +import { + provideDaffProductDriver, + provideDaffProductCustomAttributeDriver, +} from '@daffodil/product/driver'; import { DAFF_MAGENTO_GET_A_PRODUCT_BY_URL_QUERY_NAME, DAFF_MAGENTO_GET_A_PRODUCT_QUERY_NAME, DAFF_MAGENTO_GET_ALL_PRODUCTS_QUERY_NAME, + DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME, + DaffMagentoProductCustomAttributeService, DaffMagentoProductService, DaffProductMagentoDriverConfig, MAGENTO_PRODUCT_CONFIG_DEFAULT, @@ -15,10 +20,12 @@ import { export const provideDaffProductMagentoDriver = (config: DaffProductMagentoDriverConfig = MAGENTO_PRODUCT_CONFIG_DEFAULT): Provider[] => [ provideDaffProductDriver(DaffMagentoProductService), + provideDaffProductCustomAttributeDriver(DaffMagentoProductCustomAttributeService), provideManyDaffMagentoCacheableOperations( DAFF_MAGENTO_GET_ALL_PRODUCTS_QUERY_NAME, DAFF_MAGENTO_GET_A_PRODUCT_QUERY_NAME, DAFF_MAGENTO_GET_A_PRODUCT_BY_URL_QUERY_NAME, + DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME, ), provideMagentoProductConfig(config), ]; diff --git a/libs/product/driver/magento/src/public_api.ts b/libs/product/driver/magento/src/public_api.ts index 92c939a21d..bc5a4e1c0b 100644 --- a/libs/product/driver/magento/src/public_api.ts +++ b/libs/product/driver/magento/src/public_api.ts @@ -1,7 +1,9 @@ export { DaffMagentoProductService } from './product.service'; +export { DaffMagentoProductCustomAttributeService } from './custom-attribute.service'; export { DaffProductMagentoDriverModule } from './product-driver.module'; export { provideDaffProductMagentoDriver } from './provider'; +export * from './custom-attributes/public_api'; export * from './queries/public_api'; export * from './transforms/public_api'; export * from './interfaces/public_api'; diff --git a/libs/product/driver/magento/src/queries/attributes-list.ts b/libs/product/driver/magento/src/queries/attributes-list.ts new file mode 100644 index 0000000000..1cd1d0dfc3 --- /dev/null +++ b/libs/product/driver/magento/src/queries/attributes-list.ts @@ -0,0 +1,23 @@ +import { gql } from 'apollo-angular'; + +export const DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME = 'MagentoGetAttributesList'; + +export const getAttributesList = () => gql` + query ${DAFF_MAGENTO_GET_ATTRIBUTES_LIST_QUERY_NAME} { + attributesList(entityType: CATALOG_PRODUCT, filters: {is_visible_on_front: true}) { + items { + code + label + frontend_input + options { + value + label + } + } + errors { + type + message + } + } + } +`; diff --git a/libs/product/driver/magento/src/queries/attributes-list/validator.spec.ts b/libs/product/driver/magento/src/queries/attributes-list/validator.spec.ts new file mode 100644 index 0000000000..c16569b59d --- /dev/null +++ b/libs/product/driver/magento/src/queries/attributes-list/validator.spec.ts @@ -0,0 +1,34 @@ +import { DaffProductInvalidAPIResponseError } from '@daffodil/product/driver'; + +import { magentoAttributesListValidator } from './validator'; + +describe('@daffodil/product/driver/magento | magentoAttributesListValidator', () => { + describe('when the response contains attributesList items', () => { + it('should return the response', () => { + const response = { + data: { + attributesList: { + items: [], + errors: [], + }, + }, + }; + + expect(magentoAttributesListValidator(response)).toEqual(response); + }); + }); + + describe('when the response does not contain attributesList items', () => { + it('should throw a DaffProductInvalidAPIResponseError', () => { + const response = { + data: { + attributesList: null, + }, + }; + + expect(() => magentoAttributesListValidator(response)).toThrow( + new DaffProductInvalidAPIResponseError('The platform did not respond with custom attributes.'), + ); + }); + }); +}); diff --git a/libs/product/driver/magento/src/queries/attributes-list/validator.ts b/libs/product/driver/magento/src/queries/attributes-list/validator.ts new file mode 100644 index 0000000000..02f313e096 --- /dev/null +++ b/libs/product/driver/magento/src/queries/attributes-list/validator.ts @@ -0,0 +1,21 @@ +import { GraphQlApolloValidator } from '@daffodil/core/graphql'; +import { DaffProductInvalidAPIResponseError } from '@daffodil/product/driver'; + +import { MagentoAttributesList } from '../../models/public_api'; + +interface Shape { + data: { attributesList: { items: true } }; +} +type ValidatorFn = GraphQlApolloValidator<{ attributesList: MagentoAttributesList }, Shape>; + +const isValid = ( + response: Parameters[0], +): response is ReturnType => !!response.data?.attributesList?.items; + +export const magentoAttributesListValidator: ValidatorFn = (response) => { + if (isValid(response)) { + return response; + } + + throw new DaffProductInvalidAPIResponseError('The platform did not respond with custom attributes.'); +}; diff --git a/libs/product/driver/magento/src/queries/fragments/custom-attributes.ts b/libs/product/driver/magento/src/queries/fragments/custom-attributes.ts new file mode 100644 index 0000000000..cbd9c0b28c --- /dev/null +++ b/libs/product/driver/magento/src/queries/fragments/custom-attributes.ts @@ -0,0 +1,26 @@ +import { gql } from 'apollo-angular'; + +export const magentoProductCustomAttributesFragment = gql` + fragment magentoProductCustomAttributes on ProductInterface { + custom_attributesV2(filters: {is_visible_on_front: true}) { + items { + __typename + code + ... on AttributeValue { + value + } + ... on AttributeSelectedOptions { + selected_options { + __typename + label + value + } + } + } + errors { + type + message + } + } + } +`; diff --git a/libs/product/driver/magento/src/queries/fragments/product-page.ts b/libs/product/driver/magento/src/queries/fragments/product-page.ts index 344c92f5d4..ac9220600a 100644 --- a/libs/product/driver/magento/src/queries/fragments/product-page.ts +++ b/libs/product/driver/magento/src/queries/fragments/product-page.ts @@ -1,10 +1,12 @@ import { gql } from 'apollo-angular'; +import { magentoProductCustomAttributesFragment } from './custom-attributes'; import { magentoProductFragment } from './product'; export const magentoProductPageFragment = gql` fragment magentoProductPage on ProductInterface { ...product + ...magentoProductCustomAttributes __typename media_gallery_entries { label @@ -18,4 +20,5 @@ export const magentoProductPageFragment = gql` } } ${magentoProductFragment} + ${magentoProductCustomAttributesFragment} `; diff --git a/libs/product/driver/magento/src/queries/fragments/public_api.ts b/libs/product/driver/magento/src/queries/fragments/public_api.ts index 0317e95724..e3739ec4b8 100644 --- a/libs/product/driver/magento/src/queries/fragments/public_api.ts +++ b/libs/product/driver/magento/src/queries/fragments/public_api.ts @@ -1,3 +1,4 @@ +export { magentoProductCustomAttributesFragment } from './custom-attributes'; export { magentoProductFragment } from './product'; export { magentoProductPreviewFragment } from './product-preview'; export { magentoProductPageFragment } from './product-page'; diff --git a/libs/product/driver/magento/src/queries/public_api.ts b/libs/product/driver/magento/src/queries/public_api.ts index 03af013f79..800b3b813a 100644 --- a/libs/product/driver/magento/src/queries/public_api.ts +++ b/libs/product/driver/magento/src/queries/public_api.ts @@ -2,5 +2,7 @@ export * from './get-product'; export * from './get-product-by-url/public_api'; export * from './get-all-products'; export * from './get-filter-types'; +export * from './attributes-list'; export * from './fragments/public_api'; export { magentoProductGetAllValidator } from './get-all-products/validator'; +export { magentoAttributesListValidator } from './attributes-list/validator'; diff --git a/libs/product/driver/magento/src/transforms/custom-attribute-transformers.spec.ts b/libs/product/driver/magento/src/transforms/custom-attribute-transformers.spec.ts new file mode 100644 index 0000000000..355ac79e3a --- /dev/null +++ b/libs/product/driver/magento/src/transforms/custom-attribute-transformers.spec.ts @@ -0,0 +1,95 @@ +import { TestBed } from '@angular/core/testing'; + +import { DaffProductCustomAttributeKind } from '@daffodil/product'; + +import { DaffMagentoCustomAttributeTransformer } from './custom-attribute-transformers'; +import { + MagentoAttribute, + MagentoAttributeFrontendInputEnum, +} from '../custom-attributes/public_api'; + +describe('@daffodil/product/driver/magento | DaffMagentoCustomAttributeTransformer', () => { + let service: DaffMagentoCustomAttributeTransformer; + let stubMagentoAttribute: MagentoAttribute; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DaffMagentoCustomAttributeTransformer); + + stubMagentoAttribute = { + code: 'brand', + label: 'Brand', + frontend_input: MagentoAttributeFrontendInputEnum.TEXT, + options: [], + }; + }); + + describe('transformMagentoAttribute', () => { + it('should set the id from the code', () => { + expect(service.transformMagentoAttribute(stubMagentoAttribute).id).toEqual(stubMagentoAttribute.code); + }); + + it('should set the label', () => { + expect(service.transformMagentoAttribute(stubMagentoAttribute).label).toEqual(stubMagentoAttribute.label); + }); + + describe('when the frontend_input is SELECT', () => { + beforeEach(() => { + stubMagentoAttribute = { + ...stubMagentoAttribute, + frontend_input: MagentoAttributeFrontendInputEnum.SELECT, + options: [{ value: '1', label: 'Option 1' }], + }; + }); + + it('should set the kind to SELECT', () => { + expect(service.transformMagentoAttribute(stubMagentoAttribute).kind).toEqual(DaffProductCustomAttributeKind.SELECT); + }); + + it('should transform the options', () => { + const result = service.transformMagentoAttribute(stubMagentoAttribute); + + expect(result['options']).toEqual([{ id: '1', label: 'Option 1' }]); + }); + }); + + describe('when the frontend_input is MULTISELECT', () => { + beforeEach(() => { + stubMagentoAttribute = { + ...stubMagentoAttribute, + frontend_input: MagentoAttributeFrontendInputEnum.MULTISELECT, + options: [{ value: '1', label: 'Option 1' }], + }; + }); + + it('should set the kind to SELECT', () => { + expect(service.transformMagentoAttribute(stubMagentoAttribute).kind).toEqual(DaffProductCustomAttributeKind.SELECT); + }); + + it('should transform the options', () => { + const result = service.transformMagentoAttribute(stubMagentoAttribute); + + expect(result['options']).toEqual([{ id: '1', label: 'Option 1' }]); + }); + }); + + describe('when the frontend_input is any other type', () => { + beforeEach(() => { + stubMagentoAttribute = { + ...stubMagentoAttribute, + frontend_input: MagentoAttributeFrontendInputEnum.TEXT, + }; + }); + + it('should set the kind to SCALAR', () => { + expect(service.transformMagentoAttribute(stubMagentoAttribute).kind).toEqual(DaffProductCustomAttributeKind.SCALAR); + }); + }); + }); + + describe('transformManyMagentoAttributes', () => { + it('should transform many attributes', () => { + expect(service.transformManyMagentoAttributes([stubMagentoAttribute, stubMagentoAttribute]).length).toEqual(2); + }); + }); +}); diff --git a/libs/product/driver/magento/src/transforms/custom-attribute-transformers.ts b/libs/product/driver/magento/src/transforms/custom-attribute-transformers.ts new file mode 100644 index 0000000000..af59e8654c --- /dev/null +++ b/libs/product/driver/magento/src/transforms/custom-attribute-transformers.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@angular/core'; + +import { + DaffProductCustomAttribute, + DaffProductCustomAttributeKind, +} from '@daffodil/product'; + +import { + MagentoAttribute, + MagentoAttributeFrontendInputEnum, +} from '../custom-attributes/public_api'; + +/** + * Transforms a magento MagentoAttribute from the attributesList query into a DaffProductCustomAttribute. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffMagentoCustomAttributeTransformer { + transformMagentoAttribute(attribute: MagentoAttribute): DaffProductCustomAttribute { + switch (attribute.frontend_input) { + case MagentoAttributeFrontendInputEnum.SELECT: + case MagentoAttributeFrontendInputEnum.MULTISELECT: + return { + id: attribute.code, + kind: DaffProductCustomAttributeKind.SELECT, + label: attribute.label, + options: attribute.options.map(option => ({ + id: option.value, + label: option.label, + })), + }; + + default: + return { + id: attribute.code, + kind: DaffProductCustomAttributeKind.SCALAR, + label: attribute.label, + }; + } + } + + /** + * Transforms many magento MagentoAttributes from the attributesList query into DaffProductCustomAttributes. + */ + transformManyMagentoAttributes(attributes: MagentoAttribute[]): DaffProductCustomAttribute[] { + return attributes.map(attribute => this.transformMagentoAttribute(attribute)); + } +} diff --git a/libs/product/driver/magento/src/transforms/public_api.ts b/libs/product/driver/magento/src/transforms/public_api.ts index 17d9af3f6e..442fbac91b 100644 --- a/libs/product/driver/magento/src/transforms/public_api.ts +++ b/libs/product/driver/magento/src/transforms/public_api.ts @@ -5,3 +5,4 @@ export * from './simple-product-transformers'; export * from './aggregate/public_api'; export * from './applied-sort-option'; export * from './collection-metadata'; +export * from './custom-attribute-transformers'; diff --git a/libs/product/driver/magento/src/transforms/simple-product-transformers.spec.ts b/libs/product/driver/magento/src/transforms/simple-product-transformers.spec.ts index 4b333f0e97..5552bf3c32 100644 --- a/libs/product/driver/magento/src/transforms/simple-product-transformers.spec.ts +++ b/libs/product/driver/magento/src/transforms/simple-product-transformers.spec.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { DaffProduct, + DaffProductCustomAttributeKind, DaffProductTypeEnum, } from '@daffodil/product'; import { MagentoProduct } from '@daffodil/product/driver/magento'; @@ -12,7 +13,6 @@ import { DaffMagentoSimpleProductTransformers } from './simple-product-transform describe('DaffMagentoSimpleProductTransformerService', () => { let stubMagentoProduct: MagentoProduct; const mediaUrl = 'media url'; - let expectedDaffProduct: DaffProduct; let service: DaffMagentoSimpleProductTransformers; let productFactory: MagentoProductFactory; @@ -20,44 +20,101 @@ describe('DaffMagentoSimpleProductTransformerService', () => { productFactory = TestBed.inject(MagentoProductFactory); service = TestBed.inject(DaffMagentoSimpleProductTransformers); - stubMagentoProduct = productFactory.create(); + stubMagentoProduct = productFactory.create({ + custom_attributesV2: { + items: [{ + __typename: 'AttributeValue', + code: 'code', + value: 'value', + }], + errors: [], + }, + }); + }); + + describe('transformMagentoSimpleProduct', () => { + let result: DaffProduct; + + beforeEach(() => { + result = service.transformMagentoSimpleProduct(stubMagentoProduct, mediaUrl); + }); + + it('should set the type', () => { + expect(result.type).toEqual(DaffProductTypeEnum.Simple); + }); + + it('should set the id', () => { + expect(result.id).toEqual(stubMagentoProduct.sku); + }); + + it('should set the url', () => { + expect(result.url).toEqual(`/${stubMagentoProduct.url_key}${stubMagentoProduct.url_suffix}`); + }); + + it('should set the canonicalUrl', () => { + expect(result.canonicalUrl).toEqual(stubMagentoProduct.canonical_url); + }); + + it('should set the name', () => { + expect(result.name).toEqual(stubMagentoProduct.name); + }); + + it('should set the price', () => { + expect(result.price).toEqual(stubMagentoProduct.price_range.maximum_price.regular_price.value); + }); - expectedDaffProduct = { - type: DaffProductTypeEnum.Simple, - id: stubMagentoProduct.sku, - url: `/${stubMagentoProduct.url_key}${stubMagentoProduct.url_suffix}`, - canonicalUrl: stubMagentoProduct.canonical_url, - name: stubMagentoProduct.name, - price: stubMagentoProduct.price_range.maximum_price.regular_price.value, - discount: { + it('should set the discount', () => { + expect(result.discount).toEqual({ amount: stubMagentoProduct.price_range.maximum_price.discount.amount_off, percent: stubMagentoProduct.price_range.maximum_price.discount.percent_off, - }, - images: [], - thumbnail: { + }); + }); + + it('should set the images', () => { + expect(result.images).toEqual([]); + }); + + it('should set the thumbnail', () => { + expect(result.thumbnail).toEqual({ url: stubMagentoProduct.image.url, label: stubMagentoProduct.image.label, id: null, - }, - description: stubMagentoProduct.description.html, - short_description: stubMagentoProduct.short_description.html, - meta_title: stubMagentoProduct.meta_title, - meta_description: stubMagentoProduct.meta_description, - in_stock: true, - }; - }); + }); + }); - describe('transformMagentoSimpleProduct', () => { + it('should set the description', () => { + expect(result.description).toEqual(stubMagentoProduct.description?.html); + }); + + it('should set the short_description', () => { + expect(result.short_description).toEqual(stubMagentoProduct.short_description?.html); + }); + + it('should set the meta_title', () => { + expect(result.meta_title).toEqual(stubMagentoProduct.meta_title); + }); + + it('should set the meta_description', () => { + expect(result.meta_description).toEqual(stubMagentoProduct.meta_description); + }); + + it('should set in_stock', () => { + expect(result.in_stock).toEqual(true); + }); - it('should transform a MagentoProduct to a DaffProduct', () => { - expect(service.transformMagentoSimpleProduct(stubMagentoProduct, mediaUrl)).toEqual(expectedDaffProduct); + it('should set the customAttributes', () => { + expect(result.customAttributes).toEqual([{ + id: 'code', + kind: DaffProductCustomAttributeKind.SCALAR, + value: 'value', + }]); }); }); describe('when some of the fields are missing', () => { beforeEach(() => { - stubMagentoProduct.media_gallery_entries = null; - stubMagentoProduct.meta_description = null; + stubMagentoProduct.media_gallery_entries = undefined; + stubMagentoProduct.meta_description = undefined; }); it('should not set those fields on the result', () => { diff --git a/libs/product/driver/magento/src/transforms/simple-product-transformers.ts b/libs/product/driver/magento/src/transforms/simple-product-transformers.ts index d16350e29f..d99dae7660 100644 --- a/libs/product/driver/magento/src/transforms/simple-product-transformers.ts +++ b/libs/product/driver/magento/src/transforms/simple-product-transformers.ts @@ -1,15 +1,16 @@ import { - Inject, + inject, Injectable, } from '@angular/core'; import { DaffProduct, + DaffProductCustomAttributeKind, + DaffProductCustomAttributeValue, DaffProductImage, } from '@daffodil/product'; import { DAFF_PRODUCT_MAGENTO_PRODUCT_PREVIEW_TRANSFORM } from '../injection-tokens/transforms/product-preview/preview.token'; -import { DaffMagentoProductTransform } from '../interfaces/product-preview-transform.type'; import { MagentoProduct } from '../models/magento-product'; /** @@ -21,10 +22,22 @@ import { MagentoProduct } from '../models/magento-product'; providedIn: 'root', }) export class DaffMagentoSimpleProductTransformers { + protected readonly productPreviewTransform = inject(DAFF_PRODUCT_MAGENTO_PRODUCT_PREVIEW_TRANSFORM); - constructor( - @Inject(DAFF_PRODUCT_MAGENTO_PRODUCT_PREVIEW_TRANSFORM) private productPreviewTransform: DaffMagentoProductTransform, - ) {} + protected transformCustomAttributes(product: MagentoProduct): Array { + return product.custom_attributesV2?.items.flatMap((attr) => + attr.__typename === 'AttributeSelectedOptions' + ? { + id: attr.code, + kind: DaffProductCustomAttributeKind.SELECT, + values: attr.selected_options.map((o) => o.value), + } + : { + id: attr.code, + kind: DaffProductCustomAttributeKind.SCALAR, + value: attr.value, + }) || []; + } transformMagentoSimpleProduct(product: MagentoProduct, mediaUrl: string): DaffProduct { return { @@ -35,6 +48,7 @@ export class DaffMagentoSimpleProductTransformers { ...product.meta_description && { meta_description: product.meta_description }, ...product.canonical_url && { canonicalUrl: product.canonical_url }, ...product.media_gallery_entries && { images: transformMediaGalleryEntries(product, mediaUrl) }, + customAttributes: this.transformCustomAttributes(product), }; } } diff --git a/libs/product/driver/magento/testing/src/factories/core/product.factory.ts b/libs/product/driver/magento/testing/src/factories/core/product.factory.ts index 19ad1ddb2d..52ffce7044 100644 --- a/libs/product/driver/magento/testing/src/factories/core/product.factory.ts +++ b/libs/product/driver/magento/testing/src/factories/core/product.factory.ts @@ -25,6 +25,10 @@ export class MockMagentoCoreProduct extends MockMagentoProductPreview implements html: faker.lorem.words(3), }; media_gallery_entries = []; + custom_attributesV2 = { + items: [], + errors: [], + }; } /** diff --git a/libs/product/driver/shopify/src/transforms/shopify-daff-product-transform.ts b/libs/product/driver/shopify/src/transforms/shopify-daff-product-transform.ts index 755ef58dee..e3878394a8 100644 --- a/libs/product/driver/shopify/src/transforms/shopify-daff-product-transform.ts +++ b/libs/product/driver/shopify/src/transforms/shopify-daff-product-transform.ts @@ -31,4 +31,5 @@ export const daffShopifyProductTransformer = (node: ShopifyProductNode): DaffPro price: node.priceRange.maxVariantPrice.amount, in_stock: node.availableForSale, description: node.description, + customAttributes: [], }); diff --git a/libs/product/driver/src/interfaces/custom-attribute-service.interface.ts b/libs/product/driver/src/interfaces/custom-attribute-service.interface.ts new file mode 100644 index 0000000000..c9b17dfc18 --- /dev/null +++ b/libs/product/driver/src/interfaces/custom-attribute-service.interface.ts @@ -0,0 +1,25 @@ +import { Observable } from 'rxjs'; + +import { createSingletonInjectionToken } from '@daffodil/core'; +import { DaffProductCustomAttribute } from '@daffodil/product'; + +export const { + /** + * Injection token that serves as a placeholder for any service that implements the DaffProductCustomAttributeServiceInterface. + */ + token: DaffProductCustomAttributeDriver, + /** + * Provider function for {@link DaffProductCustomAttributeDriver}. + */ + provider: provideDaffProductCustomAttributeDriver, +} = createSingletonInjectionToken('DaffProductCustomAttributeDriver'); + +/** + * An interface for any product custom attribute service drivers. + */ +export interface DaffProductCustomAttributeServiceInterface { + /** + * Get the list of all custom attribute definitions. + */ + list(): Observable; +} diff --git a/libs/product/driver/src/public_api.ts b/libs/product/driver/src/public_api.ts index e4b137f320..226563d4cc 100644 --- a/libs/product/driver/src/public_api.ts +++ b/libs/product/driver/src/public_api.ts @@ -3,6 +3,11 @@ export { DaffProductDriver, provideDaffProductDriver, } from './interfaces/product-service.interface'; +export { + DaffProductCustomAttributeServiceInterface, + DaffProductCustomAttributeDriver, + provideDaffProductCustomAttributeDriver, +} from './interfaces/custom-attribute-service.interface'; export * from './models/public_api'; export * from './errors/public_api'; diff --git a/libs/product/driver/testing/src/drivers/custom-attribute.service.spec.ts b/libs/product/driver/testing/src/drivers/custom-attribute.service.spec.ts new file mode 100644 index 0000000000..30cd51e716 --- /dev/null +++ b/libs/product/driver/testing/src/drivers/custom-attribute.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { DaffTestingProductCustomAttributeService } from './custom-attribute.service'; + +describe('@daffodil/product/driver/testing | DaffTestingProductCustomAttributeService', () => { + let service: DaffTestingProductCustomAttributeService; + let customAttributeFactory: DaffProductCustomAttributeFactory; + let stubCustomAttributes: DaffProductCustomAttribute[]; + + beforeEach(() => { + TestBed.configureTestingModule({}); + + service = TestBed.inject(DaffTestingProductCustomAttributeService); + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + + stubCustomAttributes = customAttributeFactory.createMany(5); + spyOn(customAttributeFactory, 'createMany').and.returnValue(stubCustomAttributes); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('list', () => { + it('should return a list of custom attributes', () => { + service.list().subscribe(result => { + expect(result).toEqual(stubCustomAttributes); + }); + }); + }); +}); diff --git a/libs/product/driver/testing/src/drivers/custom-attribute.service.ts b/libs/product/driver/testing/src/drivers/custom-attribute.service.ts new file mode 100644 index 0000000000..f526acff1e --- /dev/null +++ b/libs/product/driver/testing/src/drivers/custom-attribute.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeServiceInterface } from '@daffodil/product/driver'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +/** + * The product custom attribute testing driver to mock the backend custom attribute service. + * + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffTestingProductCustomAttributeService implements DaffProductCustomAttributeServiceInterface { + constructor(private customAttributeFactory: DaffProductCustomAttributeFactory) {} + + list(): Observable { + return of(this.customAttributeFactory.createMany(5)); + } +} diff --git a/libs/product/driver/testing/src/drivers/product-driver.module.ts b/libs/product/driver/testing/src/drivers/product-driver.module.ts index d6f8724915..3668586245 100644 --- a/libs/product/driver/testing/src/drivers/product-driver.module.ts +++ b/libs/product/driver/testing/src/drivers/product-driver.module.ts @@ -4,9 +4,13 @@ import { ModuleWithProviders, } from '@angular/core'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; +import { + provideDaffProductDriver, + provideDaffProductCustomAttributeDriver, +} from '@daffodil/product/driver'; import { DaffProductTestingModule } from '@daffodil/product/testing'; +import { DaffTestingProductCustomAttributeService } from './custom-attribute.service'; import { DaffTestingProductService } from './product.service'; /** @@ -24,6 +28,7 @@ export class DaffProductTestingDriverModule { ngModule: DaffProductTestingDriverModule, providers: [ provideDaffProductDriver(DaffTestingProductService), + provideDaffProductCustomAttributeDriver(DaffTestingProductCustomAttributeService), ], }; } diff --git a/libs/product/driver/testing/src/public_api.ts b/libs/product/driver/testing/src/public_api.ts index dd20837d6b..bc27a6839e 100644 --- a/libs/product/driver/testing/src/public_api.ts +++ b/libs/product/driver/testing/src/public_api.ts @@ -1,2 +1,3 @@ export { DaffTestingProductService } from './drivers/product.service'; +export { DaffTestingProductCustomAttributeService } from './drivers/custom-attribute.service'; export { DaffProductTestingDriverModule } from './drivers/product-driver.module'; diff --git a/libs/product/src/custom-attribute/kind.enum.ts b/libs/product/src/custom-attribute/kind.enum.ts new file mode 100644 index 0000000000..44a8d97b8d --- /dev/null +++ b/libs/product/src/custom-attribute/kind.enum.ts @@ -0,0 +1,10 @@ +export enum DaffProductCustomAttributeKind { + /** + * A custom attribute that has a scalar value, i.e a number or text. + */ + SCALAR = 'scalar', + /** + * A custom attribute whose value is selected from a list of predefined options. + */ + SELECT = 'select' +} diff --git a/libs/product/src/custom-attribute/public_api.ts b/libs/product/src/custom-attribute/public_api.ts new file mode 100644 index 0000000000..37abf03549 --- /dev/null +++ b/libs/product/src/custom-attribute/public_api.ts @@ -0,0 +1,12 @@ +export { + DaffProductCustomAttribute, + DaffProductCustomAttributeOption, + DaffProductCustomAttributeScalar, + DaffProductCustomAttributeSelect, +} from './type'; +export { + DaffProductCustomAttributeValue, + DaffProductCustomAttributeValueScalar, + DaffProductCustomAttributeValueSelect, +} from './selection.type'; +export { DaffProductCustomAttributeKind } from './kind.enum'; diff --git a/libs/product/src/custom-attribute/selection.type.ts b/libs/product/src/custom-attribute/selection.type.ts new file mode 100644 index 0000000000..5dc5a6a8dc --- /dev/null +++ b/libs/product/src/custom-attribute/selection.type.ts @@ -0,0 +1,31 @@ +import { DaffIdentifiable } from '@daffodil/core'; + +import { DaffProductCustomAttributeKind } from './kind.enum'; +import { DaffProductCustomAttributeOption } from './type'; + +export interface DaffProductCustomAttributeValueBase extends DaffIdentifiable { + /** + * The type of the custom attribute. + */ + kind: DaffProductCustomAttributeKind; +} + +export interface DaffProductCustomAttributeValueScalar extends DaffProductCustomAttributeValueBase { + kind: DaffProductCustomAttributeKind.SCALAR; + + /** + * The value of the custom attribute, coerced to a string. + */ + value: string; +} + +export interface DaffProductCustomAttributeValueSelect extends DaffProductCustomAttributeValueBase { + kind: DaffProductCustomAttributeKind.SELECT; + + /** + * A list of selections. + */ + values: Array; +} + +export type DaffProductCustomAttributeValue = DaffProductCustomAttributeValueScalar | DaffProductCustomAttributeValueSelect; diff --git a/libs/product/src/custom-attribute/type.ts b/libs/product/src/custom-attribute/type.ts new file mode 100644 index 0000000000..269132d989 --- /dev/null +++ b/libs/product/src/custom-attribute/type.ts @@ -0,0 +1,44 @@ +import { DaffIdentifiable } from '@daffodil/core'; + +import { DaffProductCustomAttributeKind } from './kind.enum'; + +/** + * Represents a selection of a discrete list of values as a value for a custom attribute. + */ +export interface DaffProductCustomAttributeOption extends DaffIdentifiable { + /** + * The human readable label of the attribute. + */ + label: string; +} + +/** + * Represents a custom attribute that can be added to an ecommerce model. + * Custom attributes are used to add rich data such as a product brand. + */ +export interface DaffProductCustomAttributeBase extends DaffIdentifiable { + /** + * The type of the custom attribute. + */ + kind: DaffProductCustomAttributeKind; + + /** + * The human-readable label of the attribute. + */ + label: string; +} + +export interface DaffProductCustomAttributeScalar extends DaffProductCustomAttributeBase { + kind: DaffProductCustomAttributeKind.SCALAR; +} + +export interface DaffProductCustomAttributeSelect extends DaffProductCustomAttributeBase { + kind: DaffProductCustomAttributeKind.SELECT; + + /** + * A list of selectable options. + */ + options: Array; +} + +export type DaffProductCustomAttribute = DaffProductCustomAttributeScalar | DaffProductCustomAttributeSelect; diff --git a/libs/product/src/models/product.ts b/libs/product/src/models/product.ts index 003e8e77de..17d4a69894 100644 --- a/libs/product/src/models/product.ts +++ b/libs/product/src/models/product.ts @@ -5,6 +5,7 @@ import { } from '@daffodil/core'; import { DaffProductImage } from './product-image'; +import { DaffProductCustomAttributeValue } from '../custom-attribute/public_api'; /** * The possible product types. @@ -82,6 +83,11 @@ export interface DaffProduct * A description of the product for usage in search engine results. */ meta_description?: string; + + /** + * A list of custom attribute values on the product. + */ + customAttributes?: Array; } /** diff --git a/libs/product/src/public_api.ts b/libs/product/src/public_api.ts index eb95749690..d47012d428 100644 --- a/libs/product/src/public_api.ts +++ b/libs/product/src/public_api.ts @@ -1,2 +1,3 @@ +export * from './custom-attribute/public_api'; export * from './models/public_api'; export * from './helpers/public_api'; diff --git a/libs/product/state/src/custom-attributes/actions.ts b/libs/product/state/src/custom-attributes/actions.ts new file mode 100644 index 0000000000..7a40fdec0b --- /dev/null +++ b/libs/product/state/src/custom-attributes/actions.ts @@ -0,0 +1,46 @@ +import { Action } from '@ngrx/store'; + +import { DaffStateError } from '@daffodil/core/state'; +import { DaffProductCustomAttribute } from '@daffodil/product'; + +/** + * The product custom attributes action types enum. + */ +export enum DaffProductCustomAttributesActionTypes { + List = '[@daffodil/product] Product Custom Attributes List Action', + ListSuccess = '[@daffodil/product] Product Custom Attributes List Success Action', + ListFailure = '[@daffodil/product] Product Custom Attributes List Failure Action', +} + +/** + * Lists the available product custom attributes. + */ +export class DaffProductCustomAttributesList implements Action { + readonly type = DaffProductCustomAttributesActionTypes.List; +} + +/** + * Indicates a successful listing of product custom attributes. + */ +export class DaffProductCustomAttributesListSuccess implements Action { + readonly type = DaffProductCustomAttributesActionTypes.ListSuccess; + + constructor(public payload: DaffProductCustomAttribute[]) {} +} + +/** + * A failed product custom attributes list with the error message. + */ +export class DaffProductCustomAttributesListFailure implements Action { + readonly type = DaffProductCustomAttributesActionTypes.ListFailure; + + constructor(public payload: DaffStateError) {} +} + +/** + * A union of the product custom attributes action types. + */ +export type DaffProductCustomAttributesActions = + | DaffProductCustomAttributesList + | DaffProductCustomAttributesListSuccess + | DaffProductCustomAttributesListFailure; diff --git a/libs/product/state/src/custom-attributes/effects.spec.ts b/libs/product/state/src/custom-attributes/effects.spec.ts new file mode 100644 index 0000000000..38717506de --- /dev/null +++ b/libs/product/state/src/custom-attributes/effects.spec.ts @@ -0,0 +1,99 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { + hot, + cold, +} from 'jasmine-marbles'; +import { + Observable, + of, +} from 'rxjs'; + +import { daffTransformErrorToStateError } from '@daffodil/core/state'; +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { + DaffProductCustomAttributeDriver, + DaffProductCustomAttributeServiceInterface, + DaffProductInvalidAPIResponseError, +} from '@daffodil/product/driver'; +import { DaffProductTestingDriverModule } from '@daffodil/product/driver/testing'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { + DaffProductCustomAttributesList, + DaffProductCustomAttributesListSuccess, + DaffProductCustomAttributesListFailure, +} from './actions'; +import { DaffProductCustomAttributesEffects } from './effects'; + +describe('@daffodil/product/state | DaffProductCustomAttributesEffects', () => { + let actions$: Observable; + let effects: DaffProductCustomAttributesEffects; + + let customAttributeFactory: DaffProductCustomAttributeFactory; + let mockCustomAttributes: DaffProductCustomAttribute[]; + + let daffDriver: DaffProductCustomAttributeServiceInterface; + let driverListSpy: jasmine.Spy; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + DaffProductTestingDriverModule.forRoot(), + ], + providers: [ + DaffProductCustomAttributesEffects, + provideMockActions(() => actions$), + ], + }); + + effects = TestBed.inject(DaffProductCustomAttributesEffects); + daffDriver = TestBed.inject(DaffProductCustomAttributeDriver); + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + + mockCustomAttributes = customAttributeFactory.createMany(2); + + driverListSpy = spyOn(daffDriver, 'list'); + }); + + it('should be created', () => { + expect(effects).toBeTruthy(); + }); + + describe('when DaffList is triggered', () => { + let expected; + let listAction: DaffProductCustomAttributesList; + + beforeEach(() => { + listAction = new DaffProductCustomAttributesList(); + }); + + describe('and the call to the driver is successful', () => { + beforeEach(() => { + driverListSpy.and.returnValue(of(mockCustomAttributes)); + const listSuccessAction = new DaffProductCustomAttributesListSuccess(mockCustomAttributes); + actions$ = hot('--a', { a: listAction }); + expected = cold('--b', { b: listSuccessAction }); + }); + + it('should dispatch a DaffProductCustomAttributesListSuccess action', () => { + expect(effects.list$).toBeObservable(expected); + }); + }); + + describe('and the call to the driver fails', () => { + beforeEach(() => { + const error = new DaffProductInvalidAPIResponseError('Failed to list product custom attributes'); + const response = cold('#', {}, error); + driverListSpy.and.returnValue(response); + const listFailureAction = new DaffProductCustomAttributesListFailure(daffTransformErrorToStateError(error)); + actions$ = hot('--a', { a: listAction }); + expected = cold('--b', { b: listFailureAction }); + }); + + it('should dispatch a DaffProductCustomAttributesListFailure action', () => { + expect(effects.list$).toBeObservable(expected); + }); + }); + }); +}); diff --git a/libs/product/state/src/custom-attributes/effects.ts b/libs/product/state/src/custom-attributes/effects.ts new file mode 100644 index 0000000000..7e80664713 --- /dev/null +++ b/libs/product/state/src/custom-attributes/effects.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, +} from '@angular/core'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { of } from 'rxjs'; +import { + catchError, + map, + switchMap, +} from 'rxjs/operators'; + +import { DaffError } from '@daffodil/core'; +import { ErrorTransformer } from '@daffodil/core/state'; +import { + DaffProductCustomAttributeDriver, + DaffProductCustomAttributeServiceInterface, +} from '@daffodil/product/driver'; + +import { + DaffProductCustomAttributesActionTypes, + DaffProductCustomAttributesListFailure, + DaffProductCustomAttributesListSuccess, +} from './actions'; +import { DAFF_PRODUCT_ERROR_MATCHER } from '../injection-tokens/public_api'; + +@Injectable() +export class DaffProductCustomAttributesEffects { + constructor( + private actions$: Actions, + @Inject(DaffProductCustomAttributeDriver) private driver: DaffProductCustomAttributeServiceInterface, + @Inject(DAFF_PRODUCT_ERROR_MATCHER) private errorMatcher: ErrorTransformer, + ) {} + + /** + * An effect for listing the product custom attributes. + */ + list$ = createEffect(() => this.actions$.pipe( + ofType(DaffProductCustomAttributesActionTypes.List), + switchMap(() => + this.driver.list().pipe( + map(resp => new DaffProductCustomAttributesListSuccess(resp)), + catchError((error: DaffError) => of(new DaffProductCustomAttributesListFailure(this.errorMatcher(error)))), + ), + ), + )); +} diff --git a/libs/product/state/src/custom-attributes/facade.interface.ts b/libs/product/state/src/custom-attributes/facade.interface.ts new file mode 100644 index 0000000000..fc0de2ddc3 --- /dev/null +++ b/libs/product/state/src/custom-attributes/facade.interface.ts @@ -0,0 +1,14 @@ +import { Observable } from 'rxjs'; + +import { DaffOperationStateFacadeInterface } from '@daffodil/core/state'; +import { DaffProductCustomAttribute } from '@daffodil/product'; + +/** + * Exposes the product custom attributes state selectors. + */ +export interface DaffProductCustomAttributesFacadeInterface extends DaffOperationStateFacadeInterface { + /** + * A list of all product custom attributes. + */ + customAttributes$: Observable; +} diff --git a/libs/product/state/src/custom-attributes/facade.spec.ts b/libs/product/state/src/custom-attributes/facade.spec.ts new file mode 100644 index 0000000000..a09b8ea6b5 --- /dev/null +++ b/libs/product/state/src/custom-attributes/facade.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { + combineReducers, + Store, + StoreModule, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { DaffProductCustomAttributesListSuccess } from './actions'; +import { DaffProductCustomAttributesFacade } from './facade'; +import { + daffProductReducers, + DaffProductStateRootSlice, + DAFF_PRODUCT_STORE_FEATURE_KEY, +} from '../reducers/public_api'; + +describe('@daffodil/product/state | DaffProductCustomAttributesFacade', () => { + let store: Store; + let facade: DaffProductCustomAttributesFacade; + let customAttributeFactory: DaffProductCustomAttributeFactory; + + let mockCustomAttribute: DaffProductCustomAttribute; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_PRODUCT_STORE_FEATURE_KEY]: combineReducers(daffProductReducers), + }), + ], + providers: [ + DaffProductCustomAttributesFacade, + ], + }); + + store = TestBed.inject(Store); + facade = TestBed.inject(DaffProductCustomAttributesFacade); + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + + mockCustomAttribute = customAttributeFactory.create(); + + store.dispatch(new DaffProductCustomAttributesListSuccess([mockCustomAttribute])); + }); + + it('should be created', () => { + expect(facade).toBeTruthy(); + }); + + it('should be able to dispatch an action to the store', () => { + spyOn(store, 'dispatch'); + const action = { type: 'SOME_TYPE' }; + + facade.dispatch(action); + expect(store.dispatch).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + describe('customAttributes$', () => { + it('should contain the loaded custom attribute', () => { + const expected = cold('a', { a: jasmine.arrayContaining([jasmine.objectContaining(mockCustomAttribute)]) }); + expect(facade.customAttributes$).toBeObservable(expected); + }); + }); + + describe('entities$', () => { + it('should contain the loaded custom attribute keyed by id', () => { + const expected = cold('a', { a: jasmine.objectContaining({ [mockCustomAttribute.id]: jasmine.objectContaining(mockCustomAttribute) }) }); + expect(facade.entities$).toBeObservable(expected); + }); + }); +}); diff --git a/libs/product/state/src/custom-attributes/facade.ts b/libs/product/state/src/custom-attributes/facade.ts new file mode 100644 index 0000000000..8ec370038b --- /dev/null +++ b/libs/product/state/src/custom-attributes/facade.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; +import { Dictionary } from '@ngrx/entity'; +import { + Action, + select, + Store, +} from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { DaffOperationState } from '@daffodil/core/state'; +import { DaffProductCustomAttribute } from '@daffodil/product'; + +import { DaffProductCustomAttributesFacadeInterface } from './facade.interface'; +import { getDaffProductCustomAttributesSelectors } from './selectors'; +import { DaffProductStateRootSlice } from '../reducers/public_api'; + +/** + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributesFacade implements DaffProductCustomAttributesFacadeInterface { + entities$: Observable>; + customAttributes$: Observable; + loadingState$: Observable; + loading$: Observable; + resolving$: Observable; + mutating$: Observable; + errors$: Observable; + hasErrors$: Observable; + + constructor(private store: Store) { + const { + selectProductCustomAttributes, + selectProductCustomAttributeEntities, + selectLoadingState, + selectLoading, + selectResolving, + selectMutating, + selectErrors, + selectHasErrors, + } = getDaffProductCustomAttributesSelectors(); + + this.customAttributes$ = this.store.pipe(select(selectProductCustomAttributes)); + this.entities$ = this.store.pipe(select(selectProductCustomAttributeEntities)); + this.loadingState$ = this.store.pipe(select(selectLoadingState)); + this.loading$ = this.store.pipe(select(selectLoading)); + this.resolving$ = this.store.pipe(select(selectResolving)); + this.mutating$ = this.store.pipe(select(selectMutating)); + this.errors$ = this.store.pipe(select(selectErrors)); + this.hasErrors$ = this.store.pipe(select(selectHasErrors)); + } + + dispatch(action: Action) { + this.store.dispatch(action); + } +} diff --git a/libs/product/state/src/custom-attributes/provider.spec.ts b/libs/product/state/src/custom-attributes/provider.spec.ts new file mode 100644 index 0000000000..ac3e38393d --- /dev/null +++ b/libs/product/state/src/custom-attributes/provider.spec.ts @@ -0,0 +1,13 @@ +import { provideDaffProductCustomAttributesState } from './provider'; + +describe('@daffodil/product/state | provideDaffProductCustomAttributesState', () => { + it('should not throw when registering providers', () => { + expect(() => { + provideDaffProductCustomAttributesState(); + }).not.toThrow(); + }); + + it('should return providers', () => { + expect(provideDaffProductCustomAttributesState()).toBeTruthy(); + }); +}); diff --git a/libs/product/state/src/custom-attributes/provider.ts b/libs/product/state/src/custom-attributes/provider.ts new file mode 100644 index 0000000000..1867c0669e --- /dev/null +++ b/libs/product/state/src/custom-attributes/provider.ts @@ -0,0 +1,18 @@ +import { + importProvidersFrom, + makeEnvironmentProviders, +} from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; + +import { DaffProductCustomAttributesEffects } from './effects'; + +/** + * Provides the effects for the product custom attributes feature state. + * The reducers for this feature are part of the base product state, + * see {@link DaffProductReducersState}. + */ +export const provideDaffProductCustomAttributesState = ()=> makeEnvironmentProviders([ + importProvidersFrom( + EffectsModule.forFeature(DaffProductCustomAttributesEffects), + ), +]); diff --git a/libs/product/state/src/custom-attributes/public_api.ts b/libs/product/state/src/custom-attributes/public_api.ts new file mode 100644 index 0000000000..6bce856c37 --- /dev/null +++ b/libs/product/state/src/custom-attributes/public_api.ts @@ -0,0 +1,7 @@ +export * from './actions'; +export * from './reducers/public_api'; +export * from './selectors'; +export * from './effects'; +export * from './facade.interface'; +export * from './facade'; +export { provideDaffProductCustomAttributesState } from './provider'; diff --git a/libs/product/state/src/custom-attributes/reducers/entities.reducer.spec.ts b/libs/product/state/src/custom-attributes/reducers/entities.reducer.spec.ts new file mode 100644 index 0000000000..5f8a90e212 --- /dev/null +++ b/libs/product/state/src/custom-attributes/reducers/entities.reducer.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { + daffProductCustomAttributesEntitiesAdapter, + daffProductCustomAttributesEntitiesReducer, +} from './entities.reducer'; +import { DaffProductCustomAttributesListSuccess } from '../actions'; + +describe('@daffodil/product/state | daffProductCustomAttributesEntitiesReducer', () => { + let customAttributeFactory: DaffProductCustomAttributeFactory; + const initialState = daffProductCustomAttributesEntitiesAdapter().getInitialState(); + + beforeEach(() => { + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + }); + + describe('when an unknown action is triggered', () => { + it('should return the current state', () => { + const action = {}; + + const result = daffProductCustomAttributesEntitiesReducer(initialState, action); + + expect(result).toEqual(initialState); + }); + }); + + describe('when ListSuccess is triggered', () => { + let customAttributes: DaffProductCustomAttribute[]; + let result; + + beforeEach(() => { + customAttributes = customAttributeFactory.createMany(2); + const listSuccess = new DaffProductCustomAttributesListSuccess(customAttributes); + + result = daffProductCustomAttributesEntitiesReducer(initialState, listSuccess); + }); + + it('sets expected number of custom attributes on state', () => { + expect(result.ids.length).toEqual(customAttributes.length); + }); + + it('sets expected custom attribute on state', () => { + expect(result.entities[customAttributes[0].id]).toEqual(customAttributes[0]); + }); + }); +}); diff --git a/libs/product/state/src/custom-attributes/reducers/entities.reducer.ts b/libs/product/state/src/custom-attributes/reducers/entities.reducer.ts new file mode 100644 index 0000000000..0b1b43906c --- /dev/null +++ b/libs/product/state/src/custom-attributes/reducers/entities.reducer.ts @@ -0,0 +1,36 @@ +import { + createEntityAdapter, + EntityAdapter, + EntityState, +} from '@ngrx/entity'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; + +import { + DaffProductCustomAttributesActions, + DaffProductCustomAttributesActionTypes, +} from '../actions'; + +/** + * Product Custom Attributes Adapter for changing/overwriting entity state. + */ +export const daffProductCustomAttributesEntitiesAdapter = (() => { + let cache: EntityAdapter; + return (): EntityAdapter => + cache = cache || createEntityAdapter(); +})(); + +/** + * Reducer function that catches actions and changes/overwrites product custom attributes entity state. + */ +export function daffProductCustomAttributesEntitiesReducer( + state = daffProductCustomAttributesEntitiesAdapter().getInitialState(), + action: DaffProductCustomAttributesActions, +): EntityState { + switch (action.type) { + case DaffProductCustomAttributesActionTypes.ListSuccess: + return daffProductCustomAttributesEntitiesAdapter().upsertMany(action.payload, state); + default: + return state; + } +} diff --git a/libs/product/state/src/custom-attributes/reducers/operation.reducer.spec.ts b/libs/product/state/src/custom-attributes/reducers/operation.reducer.spec.ts new file mode 100644 index 0000000000..d489a2849b --- /dev/null +++ b/libs/product/state/src/custom-attributes/reducers/operation.reducer.spec.ts @@ -0,0 +1,102 @@ +import { + DaffState, + DaffStateError, + daffOperationInitialState as initialState, + DaffOperationState, +} from '@daffodil/core/state'; + +import { daffProductCustomAttributesOperationReducer as reducer } from './operation.reducer'; +import { + DaffProductCustomAttributesList, + DaffProductCustomAttributesListSuccess, + DaffProductCustomAttributesListFailure, +} from '../actions'; + +describe('@daffodil/product/state | daffProductCustomAttributesOperationReducer', () => { + describe('when an unknown action is triggered', () => { + it('should return the current state', () => { + const action = {}; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); + + describe('when List is triggered', () => { + let result: DaffOperationState; + + beforeEach(() => { + const listAction = new DaffProductCustomAttributesList(); + + result = reducer(initialState, listAction); + }); + + it('sets loading state to resolving', () => { + expect(result.daffState).toEqual(DaffState.Resolving); + }); + }); + + describe('when ListSuccess is triggered', () => { + let mockError: DaffStateError; + let result: DaffOperationState; + let state: DaffOperationState; + + beforeEach(() => { + mockError = { + code: 'error code', + message: 'error message', + }; + state = { + ...initialState, + daffState: DaffState.Resolving, + daffErrors: [mockError], + }; + + const listSuccess = new DaffProductCustomAttributesListSuccess([]); + + result = reducer(state, listSuccess); + }); + + it('sets loading to stable', () => { + expect(result.daffState).toEqual(DaffState.Stable); + }); + + it('should reset errors', () => { + expect(result.daffErrors).toEqual([]); + }); + }); + + describe('when ListFailure is triggered', () => { + let result: DaffOperationState; + let state: DaffOperationState; + let mockError: DaffStateError; + + beforeEach(() => { + mockError = { + code: 'error code', + message: 'error message', + }; + state = { + ...initialState, + daffState: DaffState.Resolving, + daffErrors: [ + { code: 'firstErrorCode', message: 'firstErrorMessage' }, + ], + }; + + const listFailureAction = new DaffProductCustomAttributesListFailure(mockError); + + result = reducer(state, listFailureAction); + }); + + it('stores the errors in state', () => { + expect(result.daffErrors).toContain(mockError); + expect(result.daffErrors.length).toEqual(1); + }); + + it('sets loading to error', () => { + expect(result.daffState).toEqual(DaffState.Error); + }); + }); +}); diff --git a/libs/product/state/src/custom-attributes/reducers/operation.reducer.ts b/libs/product/state/src/custom-attributes/reducers/operation.reducer.ts new file mode 100644 index 0000000000..ee123ee57c --- /dev/null +++ b/libs/product/state/src/custom-attributes/reducers/operation.reducer.ts @@ -0,0 +1,35 @@ +import { + DaffOperationState, + daffCompleteOperation, + daffOperationFailed, + daffOperationInitialState, + daffStartResolution, +} from '@daffodil/core/state'; + +import { + DaffProductCustomAttributesActions, + DaffProductCustomAttributesActionTypes, + DaffProductCustomAttributesListFailure, +} from '../actions'; + +/** + * The reducer for the product custom attributes operation state, see {@link DaffOperationState}. + */ +export function daffProductCustomAttributesOperationReducer( + state = daffOperationInitialState, + action: DaffProductCustomAttributesActions, +): DaffOperationState { + switch (action.type) { + case DaffProductCustomAttributesActionTypes.List: + return daffStartResolution(state); + + case DaffProductCustomAttributesActionTypes.ListSuccess: + return daffCompleteOperation(state); + + case DaffProductCustomAttributesActionTypes.ListFailure: + return daffOperationFailed([(action).payload], state); + + default: + return state; + } +} diff --git a/libs/product/state/src/custom-attributes/reducers/public_api.ts b/libs/product/state/src/custom-attributes/reducers/public_api.ts new file mode 100644 index 0000000000..e60f39f2ac --- /dev/null +++ b/libs/product/state/src/custom-attributes/reducers/public_api.ts @@ -0,0 +1,5 @@ +export { + daffProductCustomAttributesEntitiesAdapter, + daffProductCustomAttributesEntitiesReducer, +} from './entities.reducer'; +export { daffProductCustomAttributesOperationReducer } from './operation.reducer'; diff --git a/libs/product/state/src/custom-attributes/selectors.spec.ts b/libs/product/state/src/custom-attributes/selectors.spec.ts new file mode 100644 index 0000000000..07dfec5dee --- /dev/null +++ b/libs/product/state/src/custom-attributes/selectors.spec.ts @@ -0,0 +1,94 @@ +import { TestBed } from '@angular/core/testing'; +import { + Store, + select, + StoreModule, + combineReducers, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { DaffProductCustomAttributesListSuccess } from './actions'; +import { getDaffProductCustomAttributesSelectors } from './selectors'; +import { + daffProductReducers, + DaffProductStateRootSlice, + DAFF_PRODUCT_STORE_FEATURE_KEY, +} from '../reducers/public_api'; + +describe('@daffodil/product/state | getDaffProductCustomAttributesSelectors', () => { + let store: Store; + let customAttributeFactory: DaffProductCustomAttributeFactory; + + let mockCustomAttribute: DaffProductCustomAttribute; + + const { + selectProductCustomAttributes, + selectProductCustomAttributeEntities, + } = getDaffProductCustomAttributesSelectors(); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_PRODUCT_STORE_FEATURE_KEY]: combineReducers(daffProductReducers), + }), + ], + }); + + store = TestBed.inject(Store); + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + + mockCustomAttribute = customAttributeFactory.create(); + }); + + describe('selectProductCustomAttributes', () => { + describe('before the custom attributes are loaded', () => { + it('should return an empty array', () => { + const selector = store.pipe(select(selectProductCustomAttributes)); + const expected = cold('a', { a: []}); + + expect(selector).toBeObservable(expected); + }); + }); + + describe('after the custom attributes are loaded', () => { + beforeEach(() => { + store.dispatch(new DaffProductCustomAttributesListSuccess([mockCustomAttribute])); + }); + + it('should select the custom attributes', () => { + const selector = store.pipe(select(selectProductCustomAttributes)); + const expected = cold('a', { a: [jasmine.objectContaining(mockCustomAttribute)]}); + + expect(selector).toBeObservable(expected); + }); + }); + }); + + describe('selectProductCustomAttributeEntities', () => { + describe('before the custom attributes are loaded', () => { + it('should return an empty dictionary', () => { + const selector = store.pipe(select(selectProductCustomAttributeEntities)); + const expected = cold('a', { a: {}}); + + expect(selector).toBeObservable(expected); + }); + }); + + describe('after the custom attributes are loaded', () => { + beforeEach(() => { + store.dispatch(new DaffProductCustomAttributesListSuccess([mockCustomAttribute])); + }); + + it('should select the custom attribute entities', () => { + const selector = store.pipe(select(selectProductCustomAttributeEntities)); + const expected = cold('a', { a: jasmine.objectContaining({ [mockCustomAttribute.id]: jasmine.objectContaining(mockCustomAttribute) }) }); + + expect(selector).toBeObservable(expected); + }); + }); + }); +}); diff --git a/libs/product/state/src/custom-attributes/selectors.ts b/libs/product/state/src/custom-attributes/selectors.ts new file mode 100644 index 0000000000..f0a55ca4aa --- /dev/null +++ b/libs/product/state/src/custom-attributes/selectors.ts @@ -0,0 +1,51 @@ +import { Dictionary } from '@ngrx/entity'; +import { + createSelector, + defaultMemoize, + MemoizedSelector, +} from '@ngrx/store'; + +import { + DaffOperationStateSelectors, + daffOperationStateSelectorFactory, +} from '@daffodil/core/state'; +import { DaffProductCustomAttribute } from '@daffodil/product'; + +import { daffProductCustomAttributesEntitiesAdapter } from './reducers/public_api'; +import { DaffProductStateRootSlice } from '../reducers/public_api'; +import { getDaffProductFeatureSelector } from '../selectors/public_api'; + +/** + * Selectors for the product custom attributes feature state. + */ +export interface DaffProductCustomAttributesSelectors extends DaffOperationStateSelectors { + /** + * Selects all of the product custom attributes. + */ + selectProductCustomAttributeEntities: MemoizedSelector>; + selectProductCustomAttributes: MemoizedSelector; +} + +const daffProductCustomAttributesCreateSelectors = (): DaffProductCustomAttributesSelectors => { + const { selectProductState } = getDaffProductFeatureSelector(); + const selectProductCustomAttributesEntitiesState = createSelector( + selectProductState, + state => state.customAttributes, + ); + const selectProductCustomAttributesOperationState = createSelector( + selectProductState, + state => state.customAttributesOperation, + ); + const { selectAll, selectEntities } = daffProductCustomAttributesEntitiesAdapter().getSelectors(selectProductCustomAttributesEntitiesState); + + return { + ...daffOperationStateSelectorFactory(selectProductCustomAttributesOperationState), + selectProductCustomAttributes: selectAll, + selectProductCustomAttributeEntities: selectEntities, + }; +}; + +/** + * Creates a group of memoized selectors for the product custom attributes feature state. + */ +export const getDaffProductCustomAttributesSelectors: () => DaffProductCustomAttributesSelectors = defaultMemoize(daffProductCustomAttributesCreateSelectors).memoized; diff --git a/libs/product/state/src/product-state.module.ts b/libs/product/state/src/product-state.module.ts index 31de3ff9a0..0bfaed26c1 100644 --- a/libs/product/state/src/product-state.module.ts +++ b/libs/product/state/src/product-state.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; +import { provideDaffProductCustomAttributesState } from './custom-attributes/public_api'; import { DaffProductGridEffects } from './effects/product-grid.effects'; import { DaffProductPageEffects } from './effects/product-page.effects'; import { DaffProductEffects } from './effects/product.effects'; @@ -21,5 +22,8 @@ import { DAFF_PRODUCT_STORE_FEATURE_KEY } from './reducers/public_api'; DaffProductPageEffects, ]), ], + providers: [ + provideDaffProductCustomAttributesState(), + ], }) export class DaffProductStateModule { } diff --git a/libs/product/state/src/public_api.ts b/libs/product/state/src/public_api.ts index 32f729858c..91befa0ec8 100644 --- a/libs/product/state/src/public_api.ts +++ b/libs/product/state/src/public_api.ts @@ -4,5 +4,6 @@ export * from './facades/public_api'; export * from './reducers/public_api'; export * from './selectors/public_api'; export * from './injection-tokens/public_api'; +export * from './custom-attributes/public_api'; export { DaffProductStateModule } from './product-state.module'; diff --git a/libs/product/state/src/reducers/injection-tokens/reducers.token.spec.ts b/libs/product/state/src/reducers/injection-tokens/reducers.token.spec.ts index 5404dac8b3..c07f232d5f 100644 --- a/libs/product/state/src/reducers/injection-tokens/reducers.token.spec.ts +++ b/libs/product/state/src/reducers/injection-tokens/reducers.token.spec.ts @@ -4,7 +4,10 @@ import { combineReducers, } from '@ngrx/store'; -import { daffIdentityReducer } from '@daffodil/core/state'; +import { + daffIdentityReducer, + daffOperationInitialState, +} from '@daffodil/core/state'; import { DaffProduct } from '@daffodil/product'; import { daffProductEntitiesAdapter, @@ -15,6 +18,7 @@ import { import { DaffProductFactory } from '@daffodil/product/testing'; import { DAFF_PRODUCT_REDUCERS } from './reducers.token'; +import { daffProductCustomAttributesEntitiesAdapter } from '../../custom-attributes/reducers/public_api'; import { daffProductReducerInitialState } from '../product/product.reducer'; describe('@daffodil/product/state | daffProductProvideExtraReducers', () => { @@ -30,6 +34,8 @@ describe('@daffodil/product/state | daffProductProvideExtraReducers', () => { product: daffProductReducerInitialState, products: daffProductEntitiesAdapter().getInitialState(), productGrid: null, + customAttributes: daffProductCustomAttributesEntitiesAdapter().getInitialState(), + customAttributesOperation: daffOperationInitialState, }; extraReducer = combineReducers({ product: (state, action) => ({ @@ -38,6 +44,8 @@ describe('@daffodil/product/state | daffProductProvideExtraReducers', () => { }), products: daffIdentityReducer, productGrid: daffIdentityReducer, + customAttributes: daffIdentityReducer, + customAttributesOperation: daffIdentityReducer, }); TestBed.configureTestingModule({ diff --git a/libs/product/state/src/reducers/product-reducers-state.interface.ts b/libs/product/state/src/reducers/product-reducers-state.interface.ts index 2312783e7c..6c84c9888b 100644 --- a/libs/product/state/src/reducers/product-reducers-state.interface.ts +++ b/libs/product/state/src/reducers/product-reducers-state.interface.ts @@ -1,6 +1,10 @@ import { EntityState } from '@ngrx/entity'; -import { DaffProduct } from '@daffodil/product'; +import { DaffOperationState } from '@daffodil/core/state'; +import { + DaffProduct, + DaffProductCustomAttribute, +} from '@daffodil/product'; import { DaffProductReducerState } from './product/product-reducer-state.interface'; import { DaffProductGridReducerState } from './product-grid/product-grid-reducer-state.interface'; @@ -13,6 +17,8 @@ export interface DaffProductReducersState { products: EntityState; productGrid: DaffProductGridReducerState; product: DaffProductReducerState; + customAttributes: EntityState; + customAttributesOperation: DaffOperationState; } export interface DaffProductStateRootSlice { diff --git a/libs/product/state/src/reducers/product-reducers.ts b/libs/product/state/src/reducers/product-reducers.ts index 1453f2dd2b..70a090dc04 100644 --- a/libs/product/state/src/reducers/product-reducers.ts +++ b/libs/product/state/src/reducers/product-reducers.ts @@ -4,6 +4,8 @@ import { daffProductReducer } from './product/product.reducer'; import { daffProductEntitiesReducer } from './product-entities/product-entities.reducer'; import { daffProductGridReducer } from './product-grid/product-grid.reducer'; import { DaffProductReducersState } from './product-reducers-state.interface'; +import { daffProductCustomAttributesEntitiesReducer } from '../custom-attributes/reducers/entities.reducer'; +import { daffProductCustomAttributesOperationReducer } from '../custom-attributes/reducers/operation.reducer'; /** * Returns state values from all product related reducers. @@ -12,4 +14,6 @@ export const daffProductReducers: ActionReducerMap = { products: daffProductEntitiesReducer, productGrid: daffProductGridReducer, product: daffProductReducer, + customAttributes: daffProductCustomAttributesEntitiesReducer, + customAttributesOperation: daffProductCustomAttributesOperationReducer, }; diff --git a/libs/product/testing/src/factories/custom-attribute-option.factory.spec.ts b/libs/product/testing/src/factories/custom-attribute-option.factory.spec.ts new file mode 100644 index 0000000000..48ac07666f --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute-option.factory.spec.ts @@ -0,0 +1,33 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { DaffProductCustomAttributeOption } from '@daffodil/product'; + +import { DaffProductCustomAttributeOptionFactory } from './custom-attribute-option.factory'; + +describe('@daffodil/product | DaffProductCustomAttributeOptionFactory', () => { + let factory: DaffProductCustomAttributeOptionFactory; + + beforeEach(waitForAsync(() => { + factory = TestBed.inject(DaffProductCustomAttributeOptionFactory); + })); + + it('should be created', () => { + expect(factory).toBeTruthy(); + }); + + describe('create', () => { + let result: DaffProductCustomAttributeOption; + + beforeEach(() => { + result = factory.create(); + }); + + it('should set required fields', () => { + expect(result.id).toBeDefined(); + expect(result.label).toBeDefined(); + }); + }); +}); diff --git a/libs/product/testing/src/factories/custom-attribute-option.factory.ts b/libs/product/testing/src/factories/custom-attribute-option.factory.ts new file mode 100644 index 0000000000..a9d3b844e5 --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute-option.factory.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { faker } from '@faker-js/faker/locale/en_US'; + +import { DaffModelFactory } from '@daffodil/core/testing'; +import { DaffProductCustomAttributeOption } from '@daffodil/product'; + +/** + * Mocked DaffProductCustomAttributeOption object. + */ +export class MockDaffProductCustomAttributeOption implements DaffProductCustomAttributeOption { + id = faker.string.uuid(); + label = faker.word.noun(); +} + +/** + * Factory for creating DaffProductCustomAttributeOptions. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeOptionFactory extends DaffModelFactory { + constructor() { + super(MockDaffProductCustomAttributeOption); + } +} diff --git a/libs/product/testing/src/factories/custom-attribute-value.factory.spec.ts b/libs/product/testing/src/factories/custom-attribute-value.factory.spec.ts new file mode 100644 index 0000000000..260ad635cb --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute-value.factory.spec.ts @@ -0,0 +1,33 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { DaffProductCustomAttributeValue } from '@daffodil/product'; + +import { DaffProductCustomAttributeValueFactory } from './custom-attribute-value.factory'; + +describe('@daffodil/product | DaffProductCustomAttributeValueFactory', () => { + let factory: DaffProductCustomAttributeValueFactory; + + beforeEach(waitForAsync(() => { + factory = TestBed.inject(DaffProductCustomAttributeValueFactory); + })); + + it('should be created', () => { + expect(factory).toBeTruthy(); + }); + + describe('create', () => { + let result: DaffProductCustomAttributeValue; + + beforeEach(() => { + result = factory.create(); + }); + + it('should set required fields', () => { + expect(result.id).toBeDefined(); + expect(result.kind).toBeDefined(); + }); + }); +}); diff --git a/libs/product/testing/src/factories/custom-attribute-value.factory.ts b/libs/product/testing/src/factories/custom-attribute-value.factory.ts new file mode 100644 index 0000000000..4cc63e427a --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute-value.factory.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import { faker } from '@faker-js/faker/locale/en_US'; + +import { sample } from '@daffodil/core'; +import { DaffModelFactory } from '@daffodil/core/testing'; +import { + DaffProductCustomAttributeKind, + DaffProductCustomAttributeValue, + DaffProductCustomAttributeValueScalar, + DaffProductCustomAttributeValueSelect, +} from '@daffodil/product'; + +/** + * Mocked DaffProductCustomAttributeValueScalar object. + */ +export class MockDaffProductCustomAttributeValueScalar implements DaffProductCustomAttributeValueScalar { + id = faker.string.uuid(); + kind: DaffProductCustomAttributeKind.SCALAR = DaffProductCustomAttributeKind.SCALAR; + value = faker.word.noun(); +} + +/** + * Mocked DaffProductCustomAttributeValueSelect object. + */ +export class MockDaffProductCustomAttributeValueSelect implements DaffProductCustomAttributeValueSelect { + id = faker.string.uuid(); + kind: DaffProductCustomAttributeKind.SELECT = DaffProductCustomAttributeKind.SELECT; + values = [faker.string.uuid(), faker.string.uuid()]; +} + +/** + * Factory for creating DaffProductCustomAttributeValueScalars. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeValueScalarFactory extends DaffModelFactory { + constructor() { + super(MockDaffProductCustomAttributeValueScalar); + } +} + +/** + * Factory for creating DaffProductCustomAttributeValueSelects. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeValueSelectFactory extends DaffModelFactory { + constructor() { + super(MockDaffProductCustomAttributeValueSelect); + } +} + +/** + * Factory for creating DaffProductCustomAttributeValues. + * This will create a random custom attribute value kind, either scalar or select. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeValueFactory extends DaffModelFactory { + constructor( + private scalarFactory: DaffProductCustomAttributeValueScalarFactory, + private selectFactory: DaffProductCustomAttributeValueSelectFactory, + ) { + super(null); + } + + private get _randomFactory(): DaffModelFactory { + return sample([this.scalarFactory, this.selectFactory]); + } + + create(partial: Partial = {}): DaffProductCustomAttributeValue { + return this._randomFactory.create(partial); + } +} diff --git a/libs/product/testing/src/factories/custom-attribute.factory.spec.ts b/libs/product/testing/src/factories/custom-attribute.factory.spec.ts new file mode 100644 index 0000000000..f066aadfbc --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute.factory.spec.ts @@ -0,0 +1,34 @@ +import { + TestBed, + waitForAsync, +} from '@angular/core/testing'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; + +import { DaffProductCustomAttributeFactory } from './custom-attribute.factory'; + +describe('@daffodil/product | DaffProductCustomAttributeFactory', () => { + let factory: DaffProductCustomAttributeFactory; + + beforeEach(waitForAsync(() => { + factory = TestBed.inject(DaffProductCustomAttributeFactory); + })); + + it('should be created', () => { + expect(factory).toBeTruthy(); + }); + + describe('create', () => { + let result: DaffProductCustomAttribute; + + beforeEach(() => { + result = factory.create(); + }); + + it('should set required fields', () => { + expect(result.id).toBeDefined(); + expect(result.kind).toBeDefined(); + expect(result.label).toBeDefined(); + }); + }); +}); diff --git a/libs/product/testing/src/factories/custom-attribute.factory.ts b/libs/product/testing/src/factories/custom-attribute.factory.ts new file mode 100644 index 0000000000..48359d107c --- /dev/null +++ b/libs/product/testing/src/factories/custom-attribute.factory.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@angular/core'; +import { faker } from '@faker-js/faker/locale/en_US'; + +import { sample } from '@daffodil/core'; +import { DaffModelFactory } from '@daffodil/core/testing'; +import { + DaffProductCustomAttribute, + DaffProductCustomAttributeKind, + DaffProductCustomAttributeScalar, + DaffProductCustomAttributeSelect, +} from '@daffodil/product'; + +import { DaffProductCustomAttributeOptionFactory } from './custom-attribute-option.factory'; + +/** + * Mocked DaffProductCustomAttributeScalar object. + */ +export class MockDaffProductCustomAttributeScalar implements DaffProductCustomAttributeScalar { + id = faker.string.uuid(); + kind: DaffProductCustomAttributeKind.SCALAR = DaffProductCustomAttributeKind.SCALAR; + label = faker.word.noun(); +} + +/** + * Mocked DaffProductCustomAttributeSelect object. + */ +export class MockDaffProductCustomAttributeSelect implements DaffProductCustomAttributeSelect { + id = faker.string.uuid(); + kind: DaffProductCustomAttributeKind.SELECT = DaffProductCustomAttributeKind.SELECT; + label = faker.word.noun(); + options = this.optionFactory.createMany(faker.number.int({ min: 2, max: 5 })); + + constructor(private optionFactory: DaffProductCustomAttributeOptionFactory) {} +} + +/** + * Factory for creating DaffProductCustomAttributeScalars. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeScalarFactory extends DaffModelFactory { + constructor() { + super(MockDaffProductCustomAttributeScalar); + } +} + +/** + * Factory for creating DaffProductCustomAttributeSelects. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeSelectFactory extends DaffModelFactory { + constructor(optionFactory: DaffProductCustomAttributeOptionFactory) { + super(MockDaffProductCustomAttributeSelect, optionFactory); + } +} + +/** + * Factory for creating DaffProductCustomAttributes. + * This will create a random custom attribute kind, either scalar or select. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffProductCustomAttributeFactory extends DaffModelFactory { + constructor( + private scalarFactory: DaffProductCustomAttributeScalarFactory, + private selectFactory: DaffProductCustomAttributeSelectFactory, + ) { + super(null); + } + + private get _randomFactory(): DaffModelFactory { + return sample([this.scalarFactory, this.selectFactory]); + } + + create(partial: Partial = {}): DaffProductCustomAttribute { + return this._randomFactory.create(partial); + } +} diff --git a/libs/product/testing/src/factories/default-product.factory.ts b/libs/product/testing/src/factories/default-product.factory.ts index f1a5d70081..75f4368cfc 100644 --- a/libs/product/testing/src/factories/default-product.factory.ts +++ b/libs/product/testing/src/factories/default-product.factory.ts @@ -7,6 +7,7 @@ import { DaffProductTypeEnum, } from '@daffodil/product'; +import { DaffProductCustomAttributeValueFactory } from './custom-attribute-value.factory'; import { DaffProductImageFactory } from './product-image.factory'; /** @@ -34,9 +35,11 @@ export class MockProduct implements DaffProduct { short_description = faker.commerce.productDescription(); meta_title = faker.commerce.productName(); meta_description = faker.commerce.productDescription(); + customAttributes = this.customAttributeFactory.createMany(faker.number.int({ min: 3, max: 5 })); constructor( protected imageFactory: DaffProductImageFactory, + protected customAttributeFactory: DaffProductCustomAttributeValueFactory, ) {} } @@ -46,10 +49,11 @@ export class MockProduct implements DaffProduct { @Injectable({ providedIn: 'root', }) -export class DaffDefaultProductFactory extends DaffModelFactory { +export class DaffDefaultProductFactory extends DaffModelFactory { constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockProduct, imageFactory); + super(MockProduct, imageFactory, customAttributeFactory); } } diff --git a/libs/product/testing/src/factories/extension.factory.spec.ts b/libs/product/testing/src/factories/extension.factory.spec.ts index ee197f6c5b..c106c29689 100644 --- a/libs/product/testing/src/factories/extension.factory.spec.ts +++ b/libs/product/testing/src/factories/extension.factory.spec.ts @@ -11,6 +11,7 @@ import { MockProduct, DaffProductKindFactory, DaffProductImageFactory, + DaffProductCustomAttributeValueFactory, } from '@daffodil/product/testing'; import { DaffProductExtensionFactory } from './extension.factory'; @@ -22,11 +23,12 @@ class TestMockProduct extends MockProduct { @Injectable({ providedIn: 'root', }) -class TestProductFactory extends DaffModelFactory { +class TestProductFactory extends DaffModelFactory { constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(TestMockProduct, imageFactory); + super(TestMockProduct, imageFactory, customAttributeFactory); } } @@ -38,11 +40,12 @@ class TestMockProductKind extends MockProduct { @Injectable({ providedIn: 'root', }) -class TestProductKindFactory extends DaffModelFactory { +class TestProductKindFactory extends DaffModelFactory { constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(TestMockProductKind, imageFactory); + super(TestMockProductKind, imageFactory, customAttributeFactory); } } diff --git a/libs/product/testing/src/factories/kind.factory.spec.ts b/libs/product/testing/src/factories/kind.factory.spec.ts index 13b79c6972..0d61f9489f 100644 --- a/libs/product/testing/src/factories/kind.factory.spec.ts +++ b/libs/product/testing/src/factories/kind.factory.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { DaffModelFactory } from '@daffodil/core/testing'; import { DaffProduct } from '@daffodil/product'; import { + DaffProductCustomAttributeValueFactory, DaffProductImageFactory, MockProduct, provideDaffProductExtraFactoryTypes, @@ -18,17 +19,17 @@ class TestMockProduct extends MockProduct { @Injectable({ providedIn: 'root', }) -class TestProductFactory extends DaffModelFactory { +class TestProductFactory extends DaffModelFactory { constructor( imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(TestMockProduct, imageFactory); + super(TestMockProduct, imageFactory, customAttributeFactory); } } describe('@daffodil/product/testing | DaffProductKindFactory', () => { - - let productFactory; + let productFactory: DaffProductKindFactory; beforeEach(() => { TestBed.configureTestingModule({ diff --git a/libs/product/testing/src/factories/kind.factory.ts b/libs/product/testing/src/factories/kind.factory.ts index 4df5e22990..20fabe4e21 100644 --- a/libs/product/testing/src/factories/kind.factory.ts +++ b/libs/product/testing/src/factories/kind.factory.ts @@ -1,6 +1,6 @@ import { Injectable, - Inject, + inject, } from '@angular/core'; import { sample } from '@daffodil/core'; @@ -19,9 +19,9 @@ import { DAFF_PRODUCT_TYPE_FACTORIES } from '../injection-tokens/public_api'; providedIn: 'root', }) export class DaffProductKindFactory extends DaffModelFactory { - constructor( - @Inject(DAFF_PRODUCT_TYPE_FACTORIES) private productTypeFactories: DaffModelFactory[], - ) { + private readonly productTypeFactories = inject(DAFF_PRODUCT_TYPE_FACTORIES); + + constructor() { super(MockProduct); } diff --git a/libs/product/testing/src/factories/public_api.ts b/libs/product/testing/src/factories/public_api.ts index 20f6abc884..d9aecf0adb 100644 --- a/libs/product/testing/src/factories/public_api.ts +++ b/libs/product/testing/src/factories/public_api.ts @@ -3,5 +3,22 @@ export * from './extension.factory'; export * from './kind.factory'; export * from './default-product.factory'; export * from './collection.factory'; - +export { + DaffProductCustomAttributeFactory, + DaffProductCustomAttributeScalarFactory, + DaffProductCustomAttributeSelectFactory, + MockDaffProductCustomAttributeScalar, + MockDaffProductCustomAttributeSelect, +} from './custom-attribute.factory'; +export { + DaffProductCustomAttributeOptionFactory, + MockDaffProductCustomAttributeOption, +} from './custom-attribute-option.factory'; +export { + DaffProductCustomAttributeValueFactory, + DaffProductCustomAttributeValueScalarFactory, + DaffProductCustomAttributeValueSelectFactory, + MockDaffProductCustomAttributeValueScalar, + MockDaffProductCustomAttributeValueSelect, +} from './custom-attribute-value.factory'; export { DaffDefaultProductFactory as DaffProductFactory } from './default-product.factory'; diff --git a/libs/related-products/testing/src/factories/related-product.factory.ts b/libs/related-products/testing/src/factories/related-product.factory.ts index 82ad4ff3c6..90fc079832 100644 --- a/libs/related-products/testing/src/factories/related-product.factory.ts +++ b/libs/related-products/testing/src/factories/related-product.factory.ts @@ -6,6 +6,7 @@ import { MockProduct, DaffProductKindFactory, DaffProductImageFactory, + DaffProductCustomAttributeValueFactory, } from '@daffodil/product/testing'; import { DaffRelatedProduct } from '@daffodil/related-products'; @@ -18,8 +19,9 @@ export class MockRelatedProduct extends MockProduct implements DaffRelatedProduc constructor( productFactory: DaffProductKindFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(imageFactory); + super(imageFactory, customAttributeFactory); this.related = productFactory.createMany(3); } @@ -31,11 +33,12 @@ export class MockRelatedProduct extends MockProduct implements DaffRelatedProduc @Injectable({ providedIn: 'root', }) -export class DaffRelatedProductFactory extends DaffModelFactory{ +export class DaffRelatedProductFactory extends DaffModelFactory{ constructor( productKindFactory: DaffProductKindFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockRelatedProduct, productKindFactory, imageFactory); + super(MockRelatedProduct, productKindFactory, imageFactory, customAttributeFactory); } } diff --git a/libs/reviews/testing/src/factories/reviewed-product.factory.ts b/libs/reviews/testing/src/factories/reviewed-product.factory.ts index f905d22fa7..ccc20dad5d 100644 --- a/libs/reviews/testing/src/factories/reviewed-product.factory.ts +++ b/libs/reviews/testing/src/factories/reviewed-product.factory.ts @@ -5,6 +5,7 @@ import { DaffModelFactory } from '@daffodil/core/testing'; import { MockProduct, DaffProductKindFactory, + DaffProductCustomAttributeValueFactory, DaffProductImageFactory, } from '@daffodil/product/testing'; import { DaffReviewedProduct } from '@daffodil/reviews'; @@ -24,12 +25,13 @@ export class MockReviewedProduct extends MockProduct implements DaffReviewedProd @Injectable({ providedIn: 'root', }) -export class DaffReviewedProductFactory extends DaffModelFactory{ +export class DaffReviewedProductFactory extends DaffModelFactory{ constructor( private productKindFactory: DaffProductKindFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockReviewedProduct, imageFactory); + super(MockReviewedProduct, imageFactory, customAttributeFactory); } create(partial: Partial = {}): DaffReviewedProduct { diff --git a/libs/search-product/state/src/reducers/product/reducers.ts b/libs/search-product/state/src/reducers/product/reducers.ts index a2172a050e..79b6bb21b5 100644 --- a/libs/search-product/state/src/reducers/product/reducers.ts +++ b/libs/search-product/state/src/reducers/product/reducers.ts @@ -9,4 +9,6 @@ export const daffSearchProductProductReducers: ActionReducerMap{ +export class DaffUpsellProductFactory extends DaffModelFactory{ constructor( productKindFactory: DaffProductKindFactory, imageFactory: DaffProductImageFactory, + customAttributeFactory: DaffProductCustomAttributeValueFactory, ) { - super(MockUpsellProduct, productKindFactory, imageFactory); + super(MockUpsellProduct, productKindFactory, imageFactory, customAttributeFactory); } } From 067c33449cc7c95dc0d3e179931604f17ea468cb Mon Sep 17 00:00:00 2001 From: Peter Ashwood Date: Wed, 19 Aug 2026 22:42:02 +0000 Subject: [PATCH 2/4] add in memory driver --- .../custom-attribute-transfer-state-key.ts | 5 + .../backend/custom-attribute.service.spec.ts | 140 ++++++++++++++++++ .../src/backend/custom-attribute.service.ts | 78 ++++++++++ .../in-memory/src/backend/product.service.ts | 2 +- .../in-memory/src/collection-name.const.ts | 1 + .../driver/custom-attribute.service.spec.ts | 63 ++++++++ .../src/driver/custom-attribute.service.ts | 30 ++++ .../src/driver/product-driver.module.ts | 16 +- .../driver/in-memory/src/driver/provider.ts | 20 ++- .../driver/in-memory/src/public_api.ts | 2 + 10 files changed, 337 insertions(+), 20 deletions(-) create mode 100644 libs/product/driver/in-memory/src/backend/custom-attribute-transfer-state-key.ts create mode 100644 libs/product/driver/in-memory/src/backend/custom-attribute.service.spec.ts create mode 100644 libs/product/driver/in-memory/src/backend/custom-attribute.service.ts create mode 100644 libs/product/driver/in-memory/src/driver/custom-attribute.service.spec.ts create mode 100644 libs/product/driver/in-memory/src/driver/custom-attribute.service.ts diff --git a/libs/product/driver/in-memory/src/backend/custom-attribute-transfer-state-key.ts b/libs/product/driver/in-memory/src/backend/custom-attribute-transfer-state-key.ts new file mode 100644 index 0000000000..eaf614e43c --- /dev/null +++ b/libs/product/driver/in-memory/src/backend/custom-attribute-transfer-state-key.ts @@ -0,0 +1,5 @@ +import { makeStateKey } from '@angular/core'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; + +export const CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY = makeStateKey('DAFF_PRODUCT_CUSTOM_ATTRIBUTES_INMEMORY_DATA'); diff --git a/libs/product/driver/in-memory/src/backend/custom-attribute.service.spec.ts b/libs/product/driver/in-memory/src/backend/custom-attribute.service.spec.ts new file mode 100644 index 0000000000..e5b7526bec --- /dev/null +++ b/libs/product/driver/in-memory/src/backend/custom-attribute.service.spec.ts @@ -0,0 +1,140 @@ +import { + PLATFORM_ID, + TransferState, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + DaffProductCustomAttribute, + DaffProductCustomAttributeKind, +} from '@daffodil/product'; + +import { CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY } from './custom-attribute-transfer-state-key'; +import { DaffInMemoryBackendProductCustomAttributeService } from './custom-attribute.service'; + +describe('Driver | InMemory | Product | DaffInMemoryBackendProductCustomAttributeService', () => { + let customAttributeTestingService: DaffInMemoryBackendProductCustomAttributeService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DaffInMemoryBackendProductCustomAttributeService, + ], + }); + + customAttributeTestingService = TestBed.inject(DaffInMemoryBackendProductCustomAttributeService); + }); + + it('should be created', () => { + expect(customAttributeTestingService).toBeTruthy(); + }); + + describe('transferring state from the server to the browser', () => { + describe('on browser platform', () => { + let transferState: TransferState; + let mockCustomAttributes: DaffProductCustomAttribute[]; + + beforeEach(() => { + mockCustomAttributes = [ + { id: '1', kind: DaffProductCustomAttributeKind.SCALAR, label: 'Attribute 1' }, + { id: '2', kind: DaffProductCustomAttributeKind.SCALAR, label: 'Attribute 2' }, + ]; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + DaffInMemoryBackendProductCustomAttributeService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + transferState = TestBed.inject(TransferState); + transferState.set(CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY, mockCustomAttributes); + + customAttributeTestingService = TestBed.inject(DaffInMemoryBackendProductCustomAttributeService); + }); + + it('should load custom attributes from transfer state', () => { + expect(customAttributeTestingService.customAttributes).toEqual(mockCustomAttributes); + }); + + it('should not create new custom attributes', () => { + expect(customAttributeTestingService.customAttributes.length).toEqual(2); + }); + }); + + describe('on browser platform without transfer state', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + DaffInMemoryBackendProductCustomAttributeService, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + + customAttributeTestingService = TestBed.inject(DaffInMemoryBackendProductCustomAttributeService); + }); + + it('should create default custom attributes from factory', () => { + expect(customAttributeTestingService.customAttributes.length).toEqual(10); + }); + }); + + describe('on server platform', () => { + let transferState: TransferState; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + DaffInMemoryBackendProductCustomAttributeService, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + transferState = TestBed.inject(TransferState); + customAttributeTestingService = TestBed.inject(DaffInMemoryBackendProductCustomAttributeService); + }); + + it('should set custom attributes in transfer state', () => { + const transferStateData = transferState.get(CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY, null); + expect(transferStateData).toBeTruthy(); + expect(Array.isArray(transferStateData)).toBe(true); + }); + }); + }); + + describe('createDb', () => { + let result: { customAttributes: DaffProductCustomAttribute[] }; + + beforeEach(() => { + result = customAttributeTestingService.createDb(); + }); + + it('should return an object with an array of custom attributes', () => { + expect(Array.isArray(result.customAttributes)).toEqual(true); + expect(result.customAttributes.length).toEqual(10); + }); + }); + + describe('get', () => { + let reqInfoStub: any; + let result: any; + + beforeEach(() => { + reqInfoStub = { + utils: { + createResponse$: (func: () => any) => func(), + }, + }; + + result = customAttributeTestingService.get(reqInfoStub); + }); + + it('should return the full list of custom attributes', () => { + expect(result.body).toEqual(customAttributeTestingService.customAttributes); + expect(result.status).toEqual(200); + }); + }); +}); diff --git a/libs/product/driver/in-memory/src/backend/custom-attribute.service.ts b/libs/product/driver/in-memory/src/backend/custom-attribute.service.ts new file mode 100644 index 0000000000..bef6bd15c7 --- /dev/null +++ b/libs/product/driver/in-memory/src/backend/custom-attribute.service.ts @@ -0,0 +1,78 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + inject, + Injectable, + PLATFORM_ID, + TransferState, +} from '@angular/core'; +import { + InMemoryDbService, + RequestInfo, + STATUS, +} from 'angular-in-memory-web-api'; + +import { DaffInMemorySingleRouteableBackend } from '@daffodil/driver/in-memory'; +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY } from './custom-attribute-transfer-state-key'; +import { DAFF_PRODUCT_CUSTOM_ATTRIBUTE_IN_MEMORY_COLLECTION_NAME } from '../collection-name.const'; + +/** + * An in-memory service that stubs out the backend service for listing product custom attributes. + * + * @Param customAttributeFactory: DaffProductCustomAttributeFactory instance + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffInMemoryBackendProductCustomAttributeService implements InMemoryDbService, DaffInMemorySingleRouteableBackend { + readonly collectionName = DAFF_PRODUCT_CUSTOM_ATTRIBUTE_IN_MEMORY_COLLECTION_NAME; + + protected _customAttributes: DaffProductCustomAttribute[] = []; + + /** + * The collection of custom attributes in the backend. + */ + get customAttributes(): DaffProductCustomAttribute[] { + return this._customAttributes; + } + + constructor() { + const transferState = inject(TransferState); + const platform = inject(PLATFORM_ID); + const customAttributeFactory = inject(DaffProductCustomAttributeFactory); + + if (isPlatformBrowser(platform)) { + this._customAttributes = transferState.get(CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY, customAttributeFactory.createMany(10)); + } else { + this._customAttributes = customAttributeFactory.createMany(10); + transferState.set(CUSTOM_ATTRIBUTE_TRANSFER_STATE_KEY, this._customAttributes); + } + } + + /** + * Creates a fake database of custom attributes for the product custom attribute inmemory backend service. + * + * @docs-private + * @returns A fake database of an array of custom attributes + */ + createDb(): any { + return { + customAttributes: this._customAttributes, + }; + } + + /** + * Responds to GET requests with the full list of custom attributes. + * + * @param reqInfo request object + * @returns An http response object + */ + get(reqInfo: RequestInfo) { + return reqInfo.utils.createResponse$(() => ({ + body: this._customAttributes, + status: STATUS.OK, + })); + } +} diff --git a/libs/product/driver/in-memory/src/backend/product.service.ts b/libs/product/driver/in-memory/src/backend/product.service.ts index 579fde208d..8dcc6fd501 100644 --- a/libs/product/driver/in-memory/src/backend/product.service.ts +++ b/libs/product/driver/in-memory/src/backend/product.service.ts @@ -86,7 +86,7 @@ export class DaffInMemoryBackendProductService implements InMemoryDbService, Daf * @returns An http response object */ get(reqInfo: RequestInfo) { - if(reqInfo.id?.includes('.html')) { + if (reqInfo.id?.includes('.html')) { const product = this._products.find((p) => daffUriTruncateLeadingSlash(p.url) === reqInfo.id); if(!product) { diff --git a/libs/product/driver/in-memory/src/collection-name.const.ts b/libs/product/driver/in-memory/src/collection-name.const.ts index 4482bc390e..8d1c383e74 100644 --- a/libs/product/driver/in-memory/src/collection-name.const.ts +++ b/libs/product/driver/in-memory/src/collection-name.const.ts @@ -1 +1,2 @@ export const DAFF_PRODUCT_IN_MEMORY_COLLECTION_NAME = 'products'; +export const DAFF_PRODUCT_CUSTOM_ATTRIBUTE_IN_MEMORY_COLLECTION_NAME = 'customAttributes'; diff --git a/libs/product/driver/in-memory/src/driver/custom-attribute.service.spec.ts b/libs/product/driver/in-memory/src/driver/custom-attribute.service.spec.ts new file mode 100644 index 0000000000..43802924e9 --- /dev/null +++ b/libs/product/driver/in-memory/src/driver/custom-attribute.service.spec.ts @@ -0,0 +1,63 @@ +import { + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { InMemoryBackendConfig } from 'angular-in-memory-web-api'; + +import { DaffProductCustomAttributeFactory } from '@daffodil/product/testing'; + +import { DaffInMemoryProductCustomAttributeService } from './custom-attribute.service'; + +describe('@daffodil/product/driver/in-memory | ProductCustomAttributeService', () => { + let customAttributeService: DaffInMemoryProductCustomAttributeService; + let httpMock: HttpTestingController; + let customAttributeFactory: DaffProductCustomAttributeFactory; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DaffInMemoryProductCustomAttributeService, + { + provide: InMemoryBackendConfig, + useValue: { + apiBase: 'api', + }, + }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }); + + httpMock = TestBed.inject(HttpTestingController); + customAttributeService = TestBed.inject(DaffInMemoryProductCustomAttributeService); + customAttributeFactory = TestBed.inject(DaffProductCustomAttributeFactory); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(customAttributeService).toBeTruthy(); + }); + + describe('list | getting a list of custom attributes', () => { + it('should send a get request', () => { + const mockCustomAttributes = customAttributeFactory.createMany(3); + + customAttributeService.list().subscribe(customAttributes => { + expect(customAttributes).toEqual(mockCustomAttributes); + }); + + const req = httpMock.expectOne(`${customAttributeService['url']}/`); + expect(req.request.method).toBe('GET'); + + req.flush(mockCustomAttributes); + }); + }); +}); diff --git a/libs/product/driver/in-memory/src/driver/custom-attribute.service.ts b/libs/product/driver/in-memory/src/driver/custom-attribute.service.ts new file mode 100644 index 0000000000..5d1413a090 --- /dev/null +++ b/libs/product/driver/in-memory/src/driver/custom-attribute.service.ts @@ -0,0 +1,30 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { InMemoryBackendConfig } from 'angular-in-memory-web-api'; +import { Observable } from 'rxjs'; + +import { DaffInMemoryDriverBase } from '@daffodil/driver/in-memory'; +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeServiceInterface } from '@daffodil/product/driver'; + +import { DAFF_PRODUCT_CUSTOM_ATTRIBUTE_IN_MEMORY_COLLECTION_NAME } from '../collection-name.const'; + +/** + * The product custom attribute inmemory driver to mock the custom attribute backend service. + * + * @inheritdoc + * @Param HttpClient + */ +@Injectable() +export class DaffInMemoryProductCustomAttributeService extends DaffInMemoryDriverBase implements DaffProductCustomAttributeServiceInterface { + constructor( + private http: HttpClient, + config: InMemoryBackendConfig, + ) { + super(config, DAFF_PRODUCT_CUSTOM_ATTRIBUTE_IN_MEMORY_COLLECTION_NAME); + } + + list(): Observable { + return this.http.get(`${this.url}/`); + } +} diff --git a/libs/product/driver/in-memory/src/driver/product-driver.module.ts b/libs/product/driver/in-memory/src/driver/product-driver.module.ts index 9ee94fc31b..e0f859b770 100644 --- a/libs/product/driver/in-memory/src/driver/product-driver.module.ts +++ b/libs/product/driver/in-memory/src/driver/product-driver.module.ts @@ -4,16 +4,9 @@ import { ModuleWithProviders, } from '@angular/core'; -import { provideDaffInMemoryBackends } from '@daffodil/driver/in-memory'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; -import { - DaffDefaultProductFactory, - DaffProductTestingModule, - provideDaffProductExtraFactoryTypes, -} from '@daffodil/product/testing'; +import { DaffProductTestingModule } from '@daffodil/product/testing'; -import { DaffInMemoryProductService } from './product.service'; -import { DaffInMemoryBackendProductService } from '../backend/product.service'; +import { provideDaffProductInMemoryDriver } from './provider'; /** * Module for providing the DaffInMemoryProductService driver to your application. @@ -29,10 +22,7 @@ export class DaffProductInMemoryDriverModule { return { ngModule: DaffProductInMemoryDriverModule, providers: [ - DaffInMemoryProductService, - provideDaffProductDriver(DaffInMemoryProductService), - provideDaffProductExtraFactoryTypes(DaffDefaultProductFactory), - provideDaffInMemoryBackends(DaffInMemoryBackendProductService), + provideDaffProductInMemoryDriver(), ], }; } diff --git a/libs/product/driver/in-memory/src/driver/provider.ts b/libs/product/driver/in-memory/src/driver/provider.ts index c358c8ee07..eb728a92ff 100644 --- a/libs/product/driver/in-memory/src/driver/provider.ts +++ b/libs/product/driver/in-memory/src/driver/provider.ts @@ -7,17 +7,20 @@ import { provideDaffInMemoryBackends, provideDaffInMemoryRoutableObjects, } from '@daffodil/driver/in-memory'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; +import { + provideDaffProductDriver, + provideDaffProductCustomAttributeDriver, +} from '@daffodil/product/driver'; import { DaffDefaultProductFactory, provideDaffProductExtraFactoryTypes, } from '@daffodil/product/testing'; -import { - DaffInMemoryBackendProductService, - DaffInMemoryProductService, -} from '../public_api'; +import { DaffInMemoryProductCustomAttributeService } from './custom-attribute.service'; +import { DaffInMemoryProductService } from './product.service'; import { daffProductInMemoryRoutableObjects } from './routable-objects.token'; +import { DaffInMemoryBackendProductCustomAttributeService } from '../backend/custom-attribute.service'; +import { DaffInMemoryBackendProductService } from '../backend/product.service'; /** * Provides the Daffodil product in-memory driver and its dependencies. @@ -26,6 +29,11 @@ export const provideDaffProductInMemoryDriver = (): Array Date: Thu, 20 Aug 2026 18:01:32 +0000 Subject: [PATCH 3/4] noop shopify driver --- .../src/custom-attribute.service.spec.ts | 29 +++++++++++++++++++ .../shopify/src/custom-attribute.service.ts | 24 +++++++++++++++ libs/product/driver/shopify/src/provider.ts | 8 ++++- libs/product/driver/shopify/src/public_api.ts | 1 + 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 libs/product/driver/shopify/src/custom-attribute.service.spec.ts create mode 100644 libs/product/driver/shopify/src/custom-attribute.service.ts diff --git a/libs/product/driver/shopify/src/custom-attribute.service.spec.ts b/libs/product/driver/shopify/src/custom-attribute.service.spec.ts new file mode 100644 index 0000000000..1613d7cdd2 --- /dev/null +++ b/libs/product/driver/shopify/src/custom-attribute.service.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; + +import { DaffShopifyProductCustomAttributeService } from './custom-attribute.service'; + +describe('Driver | Shopify | Product | ProductCustomAttributeService', () => { + let service: DaffShopifyProductCustomAttributeService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DaffShopifyProductCustomAttributeService, + ], + }); + + service = TestBed.inject(DaffShopifyProductCustomAttributeService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('list', () => { + it('should return an empty list, as Shopify has no concept of a catalog-wide list of custom attribute definitions', () => { + service.list().subscribe(customAttributes => { + expect(customAttributes).toEqual([]); + }); + }); + }); +}); diff --git a/libs/product/driver/shopify/src/custom-attribute.service.ts b/libs/product/driver/shopify/src/custom-attribute.service.ts new file mode 100644 index 0000000000..26926e9076 --- /dev/null +++ b/libs/product/driver/shopify/src/custom-attribute.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { + Observable, + of, +} from 'rxjs'; + +import { DaffProductCustomAttribute } from '@daffodil/product'; +import { DaffProductCustomAttributeServiceInterface } from '@daffodil/product/driver'; + +/** + * Shopify's Storefront API has no concept of a catalog-wide list of custom attribute + * definitions - metafields can only be queried by explicit identifier against a specific + * product, and defining/discovering those identifiers is only possible via the Admin API. + * + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffShopifyProductCustomAttributeService implements DaffProductCustomAttributeServiceInterface { + list(): Observable { + return of([]); + } +} diff --git a/libs/product/driver/shopify/src/provider.ts b/libs/product/driver/shopify/src/provider.ts index 0aaf5f83f2..3eb5dcc407 100644 --- a/libs/product/driver/shopify/src/provider.ts +++ b/libs/product/driver/shopify/src/provider.ts @@ -1,7 +1,11 @@ import { Provider } from '@angular/core'; -import { provideDaffProductDriver } from '@daffodil/product/driver'; +import { + provideDaffProductDriver, + provideDaffProductCustomAttributeDriver, +} from '@daffodil/product/driver'; +import { DaffShopifyProductCustomAttributeService } from './custom-attribute.service'; import { DaffShopifyProductService } from './product.service'; /** @@ -12,4 +16,6 @@ import { DaffShopifyProductService } from './product.service'; export const provideDaffProductShopifyDriver = (): Provider[] => [ DaffShopifyProductService, provideDaffProductDriver(DaffShopifyProductService), + DaffShopifyProductCustomAttributeService, + provideDaffProductCustomAttributeDriver(DaffShopifyProductCustomAttributeService), ]; diff --git a/libs/product/driver/shopify/src/public_api.ts b/libs/product/driver/shopify/src/public_api.ts index 8ed6b6d52a..7a0e6625c6 100644 --- a/libs/product/driver/shopify/src/public_api.ts +++ b/libs/product/driver/shopify/src/public_api.ts @@ -1,4 +1,5 @@ export { DaffShopifyProductService } from './product.service'; +export { DaffShopifyProductCustomAttributeService } from './custom-attribute.service'; export { provideDaffProductShopifyDriver } from './provider'; export * from './queries/public_api'; From 296bf592900c4b81b56ad2b937ff2fdbd12c2bac Mon Sep 17 00:00:00 2001 From: Peter Ashwood Date: Thu, 20 Aug 2026 18:10:30 +0000 Subject: [PATCH 4/4] fix provider --- libs/product/driver/in-memory/src/driver/provider.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/product/driver/in-memory/src/driver/provider.ts b/libs/product/driver/in-memory/src/driver/provider.ts index eb728a92ff..2cbebce55f 100644 --- a/libs/product/driver/in-memory/src/driver/provider.ts +++ b/libs/product/driver/in-memory/src/driver/provider.ts @@ -4,6 +4,7 @@ import { } from '@angular/core'; import { + DaffInMemoryBackendInterface, provideDaffInMemoryBackends, provideDaffInMemoryRoutableObjects, } from '@daffodil/driver/in-memory'; @@ -29,7 +30,7 @@ export const provideDaffProductInMemoryDriver = (): Array( DaffInMemoryBackendProductService, DaffInMemoryBackendProductCustomAttributeService, ),