Przeglądaj źródła

refactor(admin-ui): Extract createUpdatedTranslatable logic

Michael Bromley 7 lat temu
rodzic
commit
60aac36b47

+ 64 - 23
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -1,10 +1,12 @@
-import { Component, OnDestroy, ViewChild } from '@angular/core';
+import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
 import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { combineLatest, forkJoin, Observable, Subject } from 'rxjs';
 import { map, mergeMap, switchMap, take, takeUntil } from 'rxjs/operators';
 
 import { CustomFieldConfig } from '../../../../../../shared/shared-types';
+import { notNullOrUndefined } from '../../../../../../shared/shared-utils';
+import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
 import { normalizeString } from '../../../common/utilities/normalize-string';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
@@ -15,9 +17,10 @@ import {
     GetProductWithVariants_product_variants,
     LanguageCode,
     ProductWithVariants,
+    UpdateProductInput,
+    UpdateProductVariantInput,
 } from '../../../data/types/gql-generated-types';
 import { ModalService } from '../../../shared/providers/modal/modal.service';
-import { ProductUpdaterService } from '../../providers/product-updater/product-updater.service';
 import { ProductVariantsWizardComponent } from '../product-variants-wizard/product-variants-wizard.component';
 
 @Component({
@@ -25,7 +28,7 @@ import { ProductVariantsWizardComponent } from '../product-variants-wizard/produ
     templateUrl: './product-detail.component.html',
     styleUrls: ['./product-detail.component.scss'],
 })
-export class ProductDetailComponent implements OnDestroy {
+export class ProductDetailComponent implements OnInit, OnDestroy {
     product$: Observable<ProductWithVariants>;
     variants$: Observable<GetProductWithVariants_product_variants[]>;
     availableLanguages$: Observable<LanguageCode[]>;
@@ -44,8 +47,9 @@ export class ProductDetailComponent implements OnDestroy {
         private formBuilder: FormBuilder,
         private modalService: ModalService,
         private notificationService: NotificationService,
-        private productUpdaterService: ProductUpdaterService,
-    ) {
+    ) {}
+
+    ngOnInit() {
         this.customFields = getServerConfig().customFields.Product || [];
         this.customVariantFields = getServerConfig().customFields.ProductVariant || [];
         this.product$ = this.route.data.pipe(switchMap(data => data.product));
@@ -110,10 +114,9 @@ export class ProductDetailComponent implements OnDestroy {
             .pipe(
                 take(1),
                 mergeMap(([product, languageCode]) => {
-                    const newProduct = this.productUpdaterService.getUpdatedProduct(
+                    const newProduct = this.getUpdatedProduct(
                         product,
-                        productGroup.value,
-                        this.customFields,
+                        productGroup as FormGroup,
                         languageCode,
                     );
                     return this.dataService.product.createProduct(newProduct);
@@ -140,29 +143,20 @@ export class ProductDetailComponent implements OnDestroy {
                     const updateOperations: Array<Observable<any>> = [];
 
                     if (productGroup && productGroup.dirty) {
-                        const newProduct = this.productUpdaterService.getUpdatedProduct(
+                        const newProduct = this.getUpdatedProduct(
                             product,
-                            productGroup.value,
-                            this.customFields,
+                            productGroup as FormGroup,
                             languageCode,
                         );
                         if (newProduct) {
                             updateOperations.push(this.dataService.product.updateProduct(newProduct));
                         }
                     }
-                    const variantsArray = this.productForm.get('variants') as FormArray;
+                    const variantsArray = this.productForm.get('variants');
                     if (variantsArray && variantsArray.dirty) {
-                        const dirtyVariants = product.variants.filter((v, i) => {
-                            const formRow = variantsArray.get(i.toString());
-                            return formRow && formRow.dirty;
-                        });
-                        const dirtyVariantValues = variantsArray.controls
-                            .filter(c => c.dirty)
-                            .map(c => c.value);
-                        const newVariants = this.productUpdaterService.getUpdatedProductVariants(
-                            dirtyVariants,
-                            dirtyVariantValues,
-                            this.customVariantFields,
+                        const newVariants = this.getUpdatedProductVariants(
+                            product,
+                            variantsArray as FormArray,
                             languageCode,
                         );
                         updateOperations.push(this.dataService.product.updateProductVariants(newVariants));
@@ -256,6 +250,53 @@ export class ProductDetailComponent implements OnDestroy {
         }
     }
 
+    /**
+     * Given a product and the value of the productForm, this method creates an updated copy of the product which
+     * can then be persisted to the API.
+     */
+    private getUpdatedProduct(
+        product: ProductWithVariants,
+        productFormGroup: FormGroup,
+        languageCode: LanguageCode,
+    ): UpdateProductInput {
+        return createUpdatedTranslatable(product, productFormGroup.value, this.customFields, languageCode, {
+            languageCode,
+            name: product.name || '',
+            slug: product.slug || '',
+            description: product.description || '',
+        });
+    }
+
+    /**
+     * Given an array of product variants and the values from the productForm, this method creates an new array
+     * which can be persisted to the API.
+     */
+    private getUpdatedProductVariants(
+        product: ProductWithVariants,
+        variantsFormArray: FormArray,
+        languageCode: LanguageCode,
+    ): UpdateProductVariantInput[] {
+        const dirtyVariants = product.variants.filter((v, i) => {
+            const formRow = variantsFormArray.get(i.toString());
+            return formRow && formRow.dirty;
+        });
+        const dirtyVariantValues = variantsFormArray.controls.filter(c => c.dirty).map(c => c.value);
+
+        if (dirtyVariants.length !== dirtyVariantValues.length) {
+            throw new Error(_(`error.product-variant-form-values-do-not-match`));
+        }
+        return dirtyVariants
+            .map((variant, i) => {
+                return createUpdatedTranslatable(
+                    variant,
+                    dirtyVariantValues[i],
+                    this.customVariantFields,
+                    languageCode,
+                );
+            })
+            .filter(notNullOrUndefined);
+    }
+
     private setQueryParam(key: string, value: any) {
         this.router.navigate(['./'], {
             queryParams: { [key]: value },

+ 137 - 0
admin-ui/src/app/common/utilities/create-updated-translatable.spec.ts

@@ -0,0 +1,137 @@
+import { CustomFieldConfig } from '../../../../../shared/shared-types';
+import { LanguageCode, ProductWithVariants } from '../../data/types/gql-generated-types';
+
+import { createUpdatedTranslatable } from './create-updated-translatable';
+
+// tslint:disable:no-non-null-assertion
+describe('createUpdatedTranslatable()', () => {
+    let product: any;
+
+    beforeEach(() => {
+        product = {
+            id: '1',
+            languageCode: LanguageCode.en,
+            name: 'Old Name EN',
+            image: 'old-image.jpg',
+            translations: [
+                { languageCode: LanguageCode.en, name: 'Old Name EN' },
+                { languageCode: LanguageCode.de, name: 'Old Name DE' },
+            ],
+        } as Partial<ProductWithVariants>;
+    });
+
+    it('returns a clone', () => {
+        const formValue = {};
+        const result = createUpdatedTranslatable(product, formValue, [], LanguageCode.en);
+
+        expect(result).not.toBe(product);
+    });
+
+    it('creates new translation if the specified translation does not exist', () => {
+        const formValue = {
+            name: 'New Name AA',
+        };
+        const result = createUpdatedTranslatable(product, formValue, [], LanguageCode.aa, {
+            languageCode: LanguageCode.aa,
+            name: product.name || '',
+        });
+
+        expect(result.translations[2]).toEqual({
+            languageCode: LanguageCode.aa,
+            name: 'New Name AA',
+        });
+    });
+
+    it('updates the non-translatable properties', () => {
+        const formValue = {
+            image: 'new-image.jpg',
+        };
+
+        const result = createUpdatedTranslatable(product, formValue, [], LanguageCode.en);
+
+        if (!result) {
+            fail('Expected result to be truthy');
+            return;
+        }
+
+        expect(result.image).toBe('new-image.jpg');
+    });
+
+    it('updates only the specified translation', () => {
+        const formValue = {
+            name: 'New Name EN',
+        };
+
+        const result = createUpdatedTranslatable(product, formValue, [], LanguageCode.en);
+
+        if (!result) {
+            fail('Expected result to be truthy');
+            return;
+        }
+
+        expect(result.translations).not.toBe(product.translations);
+        expect(result.translations[0]!.name).toBe('New Name EN');
+        expect(result.translations[1]!.name).toBe('Old Name DE');
+    });
+
+    it('updates custom fields correctly', () => {
+        const customFieldConfig: CustomFieldConfig[] = [
+            { name: 'available', type: 'boolean' },
+            { name: 'shortName', type: 'localeString' },
+        ];
+        product.customFields = {
+            available: true,
+            shortName: 'foo',
+        };
+        product.translations[0].customFields = { shortName: 'foo' };
+
+        const formValue = {
+            customFields: {
+                available: false,
+                shortName: 'bar',
+            },
+        };
+
+        const result = createUpdatedTranslatable(product, formValue, customFieldConfig, LanguageCode.en);
+
+        if (!result) {
+            fail('Expected result to be truthy');
+            return;
+        }
+
+        expect((result as any).customFields).toEqual({
+            available: false,
+        });
+        expect((result.translations[0] as any).customFields).toEqual({
+            shortName: 'bar',
+        });
+    });
+
+    it('updates custom fields when none initially exists', () => {
+        const customFieldConfig: CustomFieldConfig[] = [
+            { name: 'available', type: 'boolean' },
+            { name: 'shortName', type: 'localeString' },
+        ];
+
+        const formValue = {
+            customFields: {
+                available: false,
+                shortName: 'bar',
+            },
+        };
+
+        const result = createUpdatedTranslatable(product, formValue, customFieldConfig, LanguageCode.en);
+
+        if (!result) {
+            fail('Expected result to be truthy');
+            return;
+        }
+
+        expect((result as any).customFields).toEqual({
+            available: false,
+        });
+        expect((result.translations[0] as any).customFields).toEqual({
+            shortName: 'bar',
+        });
+    });
+});

+ 63 - 0
admin-ui/src/app/common/utilities/create-updated-translatable.ts

@@ -0,0 +1,63 @@
+import {
+    CustomFieldConfig,
+    CustomFieldsObject,
+    MayHaveCustomFields,
+} from '../../../../../shared/shared-types';
+import { LanguageCode } from '../../data/types/gql-generated-types';
+
+/**
+ * When updating an entity which has translations, the value from the form will pertain to the current
+ * languageCode. This function ensures that the "translations" array is correctly set based on the
+ * existing languages and the updated values in the specified language.
+ */
+export function createUpdatedTranslatable<T extends { translations: any[] } & MayHaveCustomFields>(
+    translatable: T,
+    updatedFields: { [key: string]: any },
+    customFieldConfig: CustomFieldConfig[],
+    languageCode: LanguageCode,
+    defaultTranslation?: Partial<T['translations'][number]>,
+): T {
+    const currentTranslation =
+        translatable.translations.find(t => t.languageCode === languageCode) || defaultTranslation;
+    const index = translatable.translations.indexOf(currentTranslation);
+    const newTranslation = patchObject(currentTranslation, updatedFields);
+    const customFields = translatable.customFields;
+    const newCustomFields: CustomFieldsObject = {};
+    const newTranslatedCustomFields: CustomFieldsObject = {};
+    if (customFieldConfig && updatedFields.hasOwnProperty('customFields')) {
+        for (const field of customFieldConfig) {
+            const value = updatedFields.customFields[field.name];
+            if (field.type === 'localeString') {
+                newTranslatedCustomFields[field.name] = value;
+            } else {
+                newCustomFields[field.name] = value;
+            }
+        }
+        newTranslation.customFields = newTranslatedCustomFields;
+    }
+    const newTranslatable = {
+        ...(patchObject(translatable, updatedFields) as any),
+        ...{ translations: translatable.translations.slice() },
+        customFields: newCustomFields,
+    };
+    if (index !== -1) {
+        newTranslatable.translations.splice(index, 1, newTranslation);
+    } else {
+        newTranslatable.translations.push(newTranslation);
+    }
+    return newTranslatable;
+}
+
+/**
+ * Returns a shallow clone of `obj` with any properties contained in `patch` overwriting
+ * those of `obj`.
+ */
+function patchObject<T extends { [key: string]: any }>(obj: T, patch: { [key: string]: any }): T {
+    const clone = Object.assign({}, obj);
+    Object.keys(clone).forEach(key => {
+        if (patch.hasOwnProperty(key)) {
+            clone[key] = patch[key];
+        }
+    });
+    return clone;
+}