Forráskód Böngészése

refactor: Allow patch updates and clean up update APIs

A number of changes to the API:

1. Entities can now be updated with partial inputs
2. Languages are not deleted if that languageCode is omitted from the update input
3. Remove "image" from Product and ProductVariant (replaced by Assets)
Michael Bromley 7 éve
szülő
commit
ff8478567b
36 módosított fájl, 202 hozzáadás és 204 törlés
  1. 4 3
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  2. 0 1
      admin-ui/src/app/catalog/providers/routing/product-resolver.ts
  3. 2 2
      admin-ui/src/app/common/utilities/create-updated-translatable.spec.ts
  4. 0 2
      admin-ui/src/app/data/definitions/product-definitions.ts
  5. 3 3
      admin-ui/src/app/data/providers/product-data.service.ts
  6. 0 0
      schema.json
  7. 0 3
      server/e2e/__snapshots__/product.e2e-spec.ts.snap
  8. 15 0
      server/e2e/administrator.e2e-spec.ts
  9. 18 6
      server/e2e/product.e2e-spec.ts
  10. 17 0
      server/e2e/role.e2e-spec.ts
  11. 19 11
      server/mock-data/mock-data.service.ts
  12. 2 2
      server/mock-data/populate.ts
  13. 2 2
      server/src/common/types/locale-types.ts
  14. 4 4
      server/src/entity/administrator/administrator.graphql
  15. 3 3
      server/src/entity/facet-value/facet-value.graphql
  16. 3 3
      server/src/entity/facet/facet.graphql
  17. 3 3
      server/src/entity/product-option-group/product-option-group.graphql
  18. 1 1
      server/src/entity/product-option/product-option.graphql
  19. 0 1
      server/src/entity/product-variant/create-product-variant.dto.ts
  20. 0 2
      server/src/entity/product-variant/product-variant.entity.ts
  21. 4 7
      server/src/entity/product-variant/product-variant.graphql
  22. 0 3
      server/src/entity/product/product.entity.ts
  23. 8 9
      server/src/entity/product/product.graphql
  24. 3 3
      server/src/entity/role/role.graphql
  25. 8 8
      server/src/service/administrator.service.ts
  26. 6 4
      server/src/service/helpers/create-translatable.ts
  27. 0 2
      server/src/service/helpers/parse-sort-params.spec.ts
  28. 17 0
      server/src/service/helpers/patch-entity.ts
  29. 0 11
      server/src/service/helpers/translation-updater.spec.ts
  30. 24 28
      server/src/service/helpers/translation-updater.ts
  31. 7 1
      server/src/service/helpers/update-translatable.ts
  32. 0 1
      server/src/service/product-variant.service.ts
  33. 0 25
      server/src/service/product.service.spec.ts
  34. 0 6
      server/src/service/product.service.ts
  35. 3 4
      server/src/service/role.service.ts
  36. 26 40
      shared/generated-types.ts

+ 4 - 3
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { combineLatest, EMPTY, forkJoin, Observable } from 'rxjs';
 import { map, mergeMap, take } from 'rxjs/operators';
 import {
+    CreateProductInput,
     LanguageCode,
     ProductWithVariants,
     ProductWithVariants_variants,
@@ -127,7 +128,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                         product,
                         productGroup as FormGroup,
                         languageCode,
-                    );
+                    ) as CreateProductInput;
                     return this.dataService.product.createProduct(newProduct);
                 }),
             )
@@ -161,7 +162,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                             product,
                             productGroup as FormGroup,
                             languageCode,
-                        );
+                        ) as UpdateProductInput;
                         if (newProduct) {
                             updateOperations.push(this.dataService.product.updateProduct(newProduct));
                         }
@@ -253,7 +254,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         product: ProductWithVariants,
         productFormGroup: FormGroup,
         languageCode: LanguageCode,
-    ): UpdateProductInput {
+    ): UpdateProductInput | CreateProductInput {
         return createUpdatedTranslatable(product, productFormGroup.value, this.customFields, languageCode, {
             languageCode,
             name: product.name || '',

+ 0 - 1
admin-ui/src/app/catalog/providers/routing/product-resolver.ts

@@ -19,7 +19,6 @@ export class ProductResolver extends BaseEntityResolver<ProductWithVariants> {
                 languageCode: getDefaultLanguage(),
                 name: '',
                 slug: '',
-                image: '',
                 featuredAsset: null,
                 assets: [],
                 description: '',

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

@@ -1,6 +1,6 @@
 import { LanguageCode, ProductWithVariants } from 'shared/generated-types';
 
-import { CustomFieldConfig } from 'shared/shared-types';
+import { CustomFieldConfig, DeepPartial } from 'shared/shared-types';
 
 import { createUpdatedTranslatable } from './create-updated-translatable';
 
@@ -18,7 +18,7 @@ describe('createUpdatedTranslatable()', () => {
                 { languageCode: LanguageCode.en, name: 'Old Name EN' },
                 { languageCode: LanguageCode.de, name: 'Old Name DE' },
             ],
-        } as Partial<ProductWithVariants>;
+        } as DeepPartial<ProductWithVariants>;
     });
 
     it('returns a clone', () => {

+ 0 - 2
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -20,7 +20,6 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
         name
         price
         sku
-        image
         options {
             id
             code
@@ -46,7 +45,6 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         languageCode
         name
         slug
-        image
         description
         featuredAsset {
             ...Asset

+ 3 - 3
admin-ui/src/app/data/providers/product-data.service.ts

@@ -80,7 +80,7 @@ export class ProductDataService {
 
     createProduct(product: CreateProductInput): Observable<CreateProduct> {
         const input: CreateProductVariables = {
-            input: pick(product, ['image', 'translations', 'optionGroupCodes', 'customFields']),
+            input: pick(product, ['translations', 'customFields']),
         };
         return this.baseDataService.mutate<CreateProduct, CreateProductVariables>(
             addCustomFields(CREATE_PRODUCT),
@@ -90,7 +90,7 @@ export class ProductDataService {
 
     updateProduct(product: UpdateProductInput): Observable<UpdateProduct> {
         const input: UpdateProductVariables = {
-            input: pick(product, ['id', 'image', 'translations', 'customFields']),
+            input: pick(product, ['id', 'translations', 'customFields']),
         };
         return this.baseDataService.mutate<UpdateProduct, UpdateProductVariables>(
             addCustomFields(UPDATE_PRODUCT),
@@ -111,7 +111,7 @@ export class ProductDataService {
 
     updateProductVariants(variants: UpdateProductVariantInput[]): Observable<UpdateProductVariants> {
         const input: UpdateProductVariantsVariables = {
-            input: variants.map(pick(['id', 'translations', 'sku', 'image', 'price'])),
+            input: variants.map(pick(['id', 'translations', 'sku', 'price'])),
         };
         return this.baseDataService.mutate<UpdateProductVariants, UpdateProductVariantsVariables>(
             UPDATE_PRODUCT_VARIANTS,

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
schema.json


+ 0 - 3
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -6,7 +6,6 @@ Object {
   "description": "A baked potato",
   "featuredAsset": null,
   "id": "21",
-  "image": "baked-potato",
   "languageCode": "en",
   "name": "en Baked Potato",
   "optionGroups": Array [],
@@ -35,7 +34,6 @@ Object {
   "description": "A blob of mashed potato",
   "featuredAsset": null,
   "id": "21",
-  "image": "mashed-potato",
   "languageCode": "en",
   "name": "en Mashed Potato",
   "optionGroups": Array [],
@@ -104,7 +102,6 @@ Object {
   "description": "en Sed dignissimos debitis incidunt accusantium sed libero.",
   "featuredAsset": null,
   "id": "2",
-  "image": "http://lorempixel.com/640/480",
   "languageCode": "en",
   "name": "en Practical Plastic Chicken",
   "optionGroups": Array [

+ 15 - 0
server/e2e/administrator.e2e-spec.ts

@@ -85,6 +85,21 @@ describe('Administrator resolver', () => {
         expect(result.updateAdministrator).toMatchSnapshot();
     });
 
+    it('updateAdministrator works with partial input', async () => {
+        const result = await client.query<UpdateAdministrator, UpdateAdministratorVariables>(
+            UPDATE_ADMINISTRATOR,
+            {
+                input: {
+                    id: createdAdmin.id,
+                    emailAddress: 'newest-email',
+                },
+            },
+        );
+        expect(result.updateAdministrator.emailAddress).toBe('newest-email');
+        expect(result.updateAdministrator.firstName).toBe('new first');
+        expect(result.updateAdministrator.lastName).toBe('new last');
+    });
+
     it('updateAdministrator throws with invalid roleId', async () => {
         try {
             const result = await client.query<UpdateAdministrator, UpdateAdministratorVariables>(

+ 18 - 6
server/e2e/product.e2e-spec.ts

@@ -146,7 +146,6 @@ describe('Product resolver', () => {
         it('createProduct creates a new Product', async () => {
             const result = await client.query<CreateProduct, CreateProductVariables>(CREATE_PRODUCT, {
                 input: {
-                    image: 'baked-potato',
                     translations: [
                         {
                             languageCode: LanguageCode.en,
@@ -171,7 +170,6 @@ describe('Product resolver', () => {
             const result = await client.query<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, {
                 input: {
                     id: newProduct.id,
-                    image: 'mashed-potato',
                     translations: [
                         {
                             languageCode: LanguageCode.en,
@@ -191,12 +189,29 @@ describe('Product resolver', () => {
             expect(result.updateProduct).toMatchSnapshot();
         });
 
+        it('updateProduct accepts partial input', async () => {
+            const result = await client.query<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, {
+                input: {
+                    id: newProduct.id,
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'en Very Mashed Potato',
+                        },
+                    ],
+                },
+            });
+            expect(result.updateProduct.translations.length).toBe(2);
+            expect(result.updateProduct.translations[0].name).toBe('en Very Mashed Potato');
+            expect(result.updateProduct.translations[0].description).toBe('A blob of mashed potato');
+            expect(result.updateProduct.translations[1].name).toBe('de Mashed Potato');
+        });
+
         it('updateProduct errors with an invalid productId', async () => {
             try {
                 await client.query<UpdateProduct, UpdateProductVariables>(UPDATE_PRODUCT, {
                     input: {
                         id: '999',
-                        image: 'mashed-potato',
                         translations: [
                             {
                                 languageCode: LanguageCode.en,
@@ -339,7 +354,6 @@ describe('Product resolver', () => {
                                 id: firstVariant.id,
                                 translations: firstVariant.translations,
                                 sku: 'ABC',
-                                image: 'new-image',
                                 price: 432,
                             },
                         ],
@@ -351,7 +365,6 @@ describe('Product resolver', () => {
                     return;
                 }
                 expect(updatedVariant.sku).toBe('ABC');
-                expect(updatedVariant.image).toBe('new-image');
                 expect(updatedVariant.price).toBe(432);
             });
 
@@ -365,7 +378,6 @@ describe('Product resolver', () => {
                                     id: '999',
                                     translations: variants[0].translations,
                                     sku: 'ABC',
-                                    image: 'new-image',
                                     price: 432,
                                 },
                             ],

+ 17 - 0
server/e2e/role.e2e-spec.ts

@@ -80,6 +80,23 @@ describe('Role resolver', () => {
         expect(omit(result.updateRole, ['channels'])).toMatchSnapshot();
     });
 
+    it('updateRole works with partial input', async () => {
+        const result = await client.query<UpdateRole, UpdateRoleVariables>(UPDATE_ROLE, {
+            input: {
+                id: createdRole.id,
+                code: 'test-modified-again',
+            },
+        });
+
+        expect(result.updateRole.code).toBe('test-modified-again');
+        expect(result.updateRole.description).toBe('test role modified');
+        expect(result.updateRole.permissions).toEqual([
+            Permission.ReadCustomer,
+            Permission.UpdateCustomer,
+            Permission.DeleteCustomer,
+        ]);
+    });
+
     it('updateRole is not allowed for SuperAdmin role', async () => {
         const superAdminRole = defaultRoles.find(r => r.code === SUPER_ADMIN_ROLE_CODE);
         if (!superAdminRole) {

+ 19 - 11
server/mock-data/mock-data.service.ts

@@ -1,6 +1,8 @@
 import * as faker from 'faker/locale/en_GB';
 import gql from 'graphql-tag';
 import {
+    AddOptionGroupToProduct,
+    AddOptionGroupToProductVariables,
     CreateFacet,
     CreateFacetValueWithFacetInput,
     CreateFacetVariables,
@@ -18,13 +20,13 @@ import {
 
 import { CREATE_FACET } from '../../admin-ui/src/app/data/definitions/facet-definitions';
 import {
+    ADD_OPTION_GROUP_TO_PRODUCT,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
     GENERATE_PRODUCT_VARIANTS,
     UPDATE_PRODUCT_VARIANTS,
 } from '../../admin-ui/src/app/data/definitions/product-definitions';
 import { CreateAddressDto } from '../src/entity/address/address.dto';
-import { CreateAdministratorDto } from '../src/entity/administrator/administrator.dto';
 import { Channel } from '../src/entity/channel/channel.entity';
 import { CreateCustomerDto } from '../src/entity/customer/customer.dto';
 import { Customer } from '../src/entity/customer/customer.entity';
@@ -61,8 +63,8 @@ export class MockDataService {
         return channels;
     }
 
-    async populateOptions(): Promise<any> {
-        await this.client
+    async populateOptions(): Promise<string> {
+        return this.client
             .query<CreateProductOptionGroup, CreateProductOptionGroupVariables>(CREATE_PRODUCT_OPTION_GROUP, {
                 input: {
                     code: 'size',
@@ -88,10 +90,10 @@ export class MockDataService {
                     ],
                 },
             })
-            .then(
-                data => this.log('Created option group:', data.createProductOptionGroup.name),
-                err => this.log(err),
-            );
+            .then(data => {
+                this.log('Created option group:', data.createProductOptionGroup.name);
+                return data.createProductOptionGroup.id;
+            });
     }
 
     async populateCustomers(count: number = 5): Promise<any> {
@@ -155,7 +157,7 @@ export class MockDataService {
         }
     }
 
-    async populateProducts(count: number = 5): Promise<any> {
+    async populateProducts(count: number = 5, optionGroupId: string): Promise<any> {
         for (let i = 0; i < count; i++) {
             const query = CREATE_PRODUCT;
 
@@ -164,10 +166,8 @@ export class MockDataService {
             const description = faker.lorem.sentence();
             const languageCodes = [LanguageCode.en, LanguageCode.de];
 
-            const variables = {
+            const variables: CreateProductVariables = {
                 input: {
-                    image: faker.image.imageUrl(),
-                    optionGroupCodes: ['size'],
                     translations: languageCodes.map(code =>
                         this.makeProductTranslation(code, name, slug, description),
                     ),
@@ -183,7 +183,15 @@ export class MockDataService {
                     },
                     err => this.log(err),
                 );
+
             if (product) {
+                await this.client.query<AddOptionGroupToProduct, AddOptionGroupToProductVariables>(
+                    ADD_OPTION_GROUP_TO_PRODUCT,
+                    {
+                        productId: product.createProduct.id,
+                        optionGroupId,
+                    },
+                );
                 const prodWithVariants = await this.makeProductVariant(product.createProduct.id);
                 const variants = prodWithVariants.generateVariantsForProduct.variants;
                 for (const variant of variants) {

+ 2 - 2
server/mock-data/populate.ts

@@ -39,8 +39,8 @@ export async function populate(
     if (options.channels) {
         channels = await mockDataService.populateChannels(options.channels);
     }
-    await mockDataService.populateOptions();
-    await mockDataService.populateProducts(options.productCount);
+    const optionGroupId = await mockDataService.populateOptions();
+    await mockDataService.populateProducts(options.productCount, optionGroupId);
     await mockDataService.populateCustomers(options.customerCount);
     await mockDataService.populateFacets();
     return app;

+ 2 - 2
server/src/common/types/locale-types.ts

@@ -41,7 +41,7 @@ export type Translation<T> =
 /**
  * This is the type of a translation object when provided as input to a create or update operation.
  */
-export type TranslationInput<T> = { [K in TranslatableKeys<T>]: string } & {
+export type TranslationInput<T> = { [K in TranslatableKeys<T>]?: string | null } & {
     id?: ID | null;
     languageCode: LanguageCode;
 };
@@ -51,7 +51,7 @@ export type TranslationInput<T> = { [K in TranslatableKeys<T>]: string } & {
  * properties.
  */
 export interface TranslatedInput<T> {
-    translations: Array<TranslationInput<T>>;
+    translations?: Array<TranslationInput<T>> | null;
 }
 
 // prettier-ignore

+ 4 - 4
server/src/entity/administrator/administrator.graphql

@@ -18,9 +18,9 @@ input CreateAdministratorInput {
 
 input UpdateAdministratorInput {
     id: ID!
-    firstName: String!
-    lastName: String!
-    emailAddress: String!
+    firstName: String
+    lastName: String
+    emailAddress: String
     password: String
-    roleIds: [ID!]!
+    roleIds: [ID!]
 }

+ 3 - 3
server/src/entity/facet-value/facet-value.graphql

@@ -19,7 +19,7 @@ type FacetValueTranslation {
 input FacetValueTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
+    name: String
 }
 
 input CreateFacetValueWithFacetInput {
@@ -35,6 +35,6 @@ input CreateFacetValueInput {
 
 input UpdateFacetValueInput {
     id: ID!
-    code: String!
-    translations: [FacetValueTranslationInput!]!
+    code: String
+    translations: [FacetValueTranslationInput!]
 }

+ 3 - 3
server/src/entity/facet/facet.graphql

@@ -20,7 +20,7 @@ type FacetTranslation {
 input FacetTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
+    name: String
 }
 
 input CreateFacetInput {
@@ -31,6 +31,6 @@ input CreateFacetInput {
 
 input UpdateFacetInput {
     id: ID!
-    code: String!
-    translations: [FacetTranslationInput!]!
+    code: String
+    translations: [FacetTranslationInput!]
 }

+ 3 - 3
server/src/entity/product-option-group/product-option-group.graphql

@@ -20,7 +20,7 @@ type ProductOptionGroupTranslation {
 input ProductOptionGroupTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
+    name: String
 }
 
 input CreateProductOptionGroupInput {
@@ -31,6 +31,6 @@ input CreateProductOptionGroupInput {
 
 input UpdateProductOptionGroupInput {
     id: ID!
-    code: String!
-    translations: [ProductOptionGroupTranslationInput!]!
+    code: String
+    translations: [ProductOptionGroupTranslationInput!]
 }

+ 1 - 1
server/src/entity/product-option/product-option.graphql

@@ -19,7 +19,7 @@ type ProductOptionTranslation {
 input ProductOptionTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
+    name: String
 }
 
 input CreateProductOptionInput {

+ 0 - 1
server/src/entity/product-variant/create-product-variant.dto.ts

@@ -7,6 +7,5 @@ import { ProductVariant } from './product-variant.entity';
 export interface CreateProductVariantDto extends TranslatedInput<ProductVariant> {
     sku: string;
     price: number;
-    image?: string;
     optionCodes?: string[];
 }

+ 0 - 2
server/src/entity/product-variant/product-variant.entity.ts

@@ -21,8 +21,6 @@ export class ProductVariant extends VendureEntity implements Translatable, HasCu
 
     @Column() sku: string;
 
-    @Column() image: string;
-
     @Column({
         name: 'lastPriceValue',
         comment: 'Not used - actual price is stored in product_variant_price table',

+ 4 - 7
server/src/entity/product-variant/product-variant.graphql

@@ -5,7 +5,6 @@ type ProductVariant implements Node {
     languageCode: LanguageCode!
     sku: String!
     name: String!
-    image: String
     price: Int!
     options: [ProductOption!]!
     facetValues: [FacetValue!]!
@@ -23,21 +22,19 @@ type ProductVariantTranslation {
 input ProductVariantTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
+    name: String
 }
 
 input CreateProductVariantInput {
     translations: [ProductVariantTranslationInput!]!
     sku: String!
-    image: String
     price: Int!
     optionCodes: [String!]
 }
 
 input UpdateProductVariantInput {
     id: ID!
-    translations: [ProductVariantTranslationInput!]!
-    sku: String!
-    image: String
-    price: Int!
+    translations: [ProductVariantTranslationInput!]
+    sku: String
+    price: Int
 }

+ 0 - 3
server/src/entity/product/product.entity.ts

@@ -23,9 +23,6 @@ export class Product extends VendureEntity implements Translatable, HasCustomFie
 
     description: LocaleString;
 
-    // TODO: remove once Assets have been implemented
-    @Column() image: string;
-
     @ManyToOne(type => Asset)
     featuredAsset: Asset;
 

+ 8 - 9
server/src/entity/product/product.graphql

@@ -6,7 +6,6 @@ type Product implements Node {
     name: String!
     slug: String!
     description: String!
-    image: String!
     featuredAsset: Asset
     assets: [Asset!]!
     variants: [ProductVariant!]!
@@ -27,20 +26,20 @@ type ProductTranslation {
 input ProductTranslationInput {
     id: ID
     languageCode: LanguageCode!
-    name: String!
-    slug: String!
-    description: String!
+    name: String
+    slug: String
+    description: String
 }
 
 input CreateProductInput {
-    image: String
+    featuredAssetId: ID
+    assetIds: [ID!]
     translations: [ProductTranslationInput!]!
-    optionGroupCodes: [String!]
 }
 
 input UpdateProductInput {
     id: ID!
-    image: String
-    translations: [ProductTranslationInput!]!
-    optionGroupCodes: [String!]
+    featuredAssetId: ID
+    assetIds: [ID!]
+    translations: [ProductTranslationInput!]
 }

+ 3 - 3
server/src/entity/role/role.graphql

@@ -15,7 +15,7 @@ input CreateRoleInput {
 
 input UpdateRoleInput {
     id: ID!
-    code: String!
-    description: String!
-    permissions: [Permission!]!
+    code: String
+    description: String
+    permissions: [Permission!]
 }

+ 8 - 8
server/src/service/administrator.service.ts

@@ -11,6 +11,7 @@ import { User } from '../entity/user/user.entity';
 import { I18nError } from '../i18n/i18n-error';
 
 import { buildListQuery } from './helpers/build-list-query';
+import { patchEntity } from './helpers/patch-entity';
 import { PasswordService } from './password.service';
 import { RoleService } from './role.service';
 
@@ -66,19 +67,18 @@ export class AdministratorService {
                 id: input.id,
             });
         }
-        administrator.emailAddress = input.emailAddress;
-        administrator.firstName = input.firstName;
-        administrator.lastName = input.lastName;
+        let updatedAdministrator = patchEntity(administrator, input);
         await this.connection.manager.save(administrator);
 
         if (input.password) {
             administrator.user.passwordHash = await this.passwordService.hash(input.password);
         }
-        administrator.user.roles = [];
-        let updatedAdministrator = administrator;
-        await this.connection.manager.save(administrator.user);
-        for (const roleId of input.roleIds) {
-            updatedAdministrator = await this.assignRole(administrator.id, roleId);
+        if (input.roleIds) {
+            administrator.user.roles = [];
+            await this.connection.manager.save(administrator.user);
+            for (const roleId of input.roleIds) {
+                updatedAdministrator = await this.assignRole(administrator.id, roleId);
+            }
         }
         return updatedAdministrator;
     }

+ 6 - 4
server/src/service/helpers/create-translatable.ts

@@ -20,10 +20,12 @@ export function createTranslatable<T extends Translatable>(
         const entity = new entityType(dto);
         const translations: Array<Translation<T>> = [];
 
-        for (const input of dto.translations) {
-            const translation = new translationType(input);
-            translations.push(translation);
-            await connection.manager.save(translation);
+        if (dto.translations) {
+            for (const input of dto.translations) {
+                const translation = new translationType(input);
+                translations.push(translation);
+                await connection.manager.save(translation);
+            }
         }
 
         entity.translations = translations;

+ 0 - 2
server/src/service/helpers/parse-sort-params.spec.ts

@@ -43,14 +43,12 @@ describe('parseSortParams()', () => {
         const sortParams: SortParameter<Product> = {
             id: 'ASC',
             createdAt: 'DESC',
-            image: 'ASC',
         };
 
         const result = parseSortParams(connection as any, Product, sortParams);
         expect(result).toEqual({
             'product.id': 'ASC',
             'product.createdAt': 'DESC',
-            'product.image': 'ASC',
         });
     });
 

+ 17 - 0
server/src/service/helpers/patch-entity.ts

@@ -0,0 +1,17 @@
+import { VendureEntity } from '../../entity/base/base.entity';
+
+export type InputPatch<T> = { [K in keyof T]?: T[K] | null };
+
+/**
+ * Updates only the specified properties from an Input object as long as the value is not
+ * null or undefined.
+ */
+export function patchEntity<T extends VendureEntity, I extends InputPatch<T>>(entity: T, input: I): T {
+    for (const key of Object.keys(entity)) {
+        const value = input[key];
+        if (value != null && key !== 'id') {
+            entity[key] = value;
+        }
+    }
+    return entity;
+}

+ 0 - 11
server/src/service/helpers/translation-updater.spec.ts

@@ -77,16 +77,6 @@ describe('TranslationUpdater', () => {
             expect(diff.toAdd).toEqual(updated);
         });
 
-        it('correctly marks translations for removal', async () => {
-            const updated = [];
-
-            const diff = new TranslationUpdater(ProductTranslation as any, entityManager).diff(
-                existing,
-                updated,
-            );
-            expect(diff.toRemove).toEqual(existing);
-        });
-
         it('correctly marks languages for update, addition and deletion', async () => {
             const updated: Array<TranslationInput<Product>> = [
                 {
@@ -108,7 +98,6 @@ describe('TranslationUpdater', () => {
             );
             expect(diff.toUpdate).toEqual([existing[0]]);
             expect(diff.toAdd).toEqual([updated[1]]);
-            expect(diff.toRemove).toEqual([existing[1]]);
         });
     });
 });

+ 24 - 28
server/src/service/helpers/translation-updater.ts

@@ -1,11 +1,10 @@
 import { DeepPartial } from 'shared/shared-types';
 import { EntityManager } from 'typeorm';
 
+import { Translatable, Translation, TranslationInput } from '../../common/types/locale-types';
 import { foundIn, not } from '../../common/utils';
 import { I18nError } from '../../i18n/i18n-error';
 
-import { Translatable, Translation, TranslationInput } from '../../common/types/locale-types';
-
 export interface TranslationContructor<T> {
     new (input?: DeepPartial<TranslationInput<T>> | DeepPartial<Translation<T>>): Translation<T>;
 }
@@ -13,7 +12,6 @@ export interface TranslationContructor<T> {
 export interface TranslationDiff<T> {
     toUpdate: Array<Translation<T>>;
     toAdd: Array<Translation<T>>;
-    toRemove: Array<Translation<T>>;
 }
 
 /**
@@ -28,30 +26,37 @@ export class TranslationUpdater<Entity extends Translatable> {
      */
     diff(
         existing: Array<Translation<Entity>>,
-        updated: Array<TranslationInput<Entity>>,
+        updated?: Array<TranslationInput<Entity>> | null,
     ): TranslationDiff<Entity> {
-        const translationEntities = this.translationInputsToEntities(updated, existing);
+        if (updated) {
+            const translationEntities = this.translationInputsToEntities(updated, existing);
 
-        const toDelete = existing.filter(not(foundIn(translationEntities, 'languageCode')));
-        const toAdd = translationEntities.filter(not(foundIn(existing, 'languageCode')));
-        const toUpdate = translationEntities.filter(foundIn(existing, 'languageCode'));
+            // TODO: deletion should be made more explicit that simple omission
+            // from the update array. This would lead to accidental deletion.
+            // const toDelete = existing.filter(not(foundIn(translationEntities, 'languageCode')));
+            const toDelete = [];
+            const toAdd = translationEntities.filter(not(foundIn(existing, 'languageCode')));
+            const toUpdate = translationEntities.filter(foundIn(existing, 'languageCode'));
 
-        return { toUpdate, toAdd, toRemove: toDelete };
+            return { toUpdate, toAdd };
+        } else {
+            return {
+                toUpdate: [],
+                toAdd: [],
+            };
+        }
     }
 
-    async applyDiff(entity: Entity, { toUpdate, toAdd, toRemove }: TranslationDiff<Entity>): Promise<Entity> {
-        entity.translations = [];
-
+    async applyDiff(entity: Entity, { toUpdate, toAdd }: TranslationDiff<Entity>): Promise<Entity> {
         if (toUpdate.length) {
             for (const translation of toUpdate) {
-                await this.manager
-                    .createQueryBuilder()
-                    .update(this.translationCtor)
-                    .set(translation)
-                    .where('id = :id', { id: translation.id })
-                    .execute();
+                // any cast below is required due to TS issue: https://github.com/Microsoft/TypeScript/issues/21592
+                const updated = await this.manager
+                    .getRepository(this.translationCtor)
+                    .save(translation as any);
+                const index = entity.translations.findIndex(t => t.languageCode === updated.languageCode);
+                entity.translations.splice(index, 1, updated);
             }
-            entity.translations = entity.translations.concat(toUpdate);
         }
 
         if (toAdd.length) {
@@ -67,19 +72,10 @@ export class TranslationUpdater<Entity extends Translatable> {
                     const id = (entity as any).id || 'undefined';
                     throw new I18nError('error.entity-with-id-not-found', { entityName, id });
                 }
-
                 entity.translations.push(newTranslation);
             }
         }
 
-        if (toRemove.length) {
-            const toDeleteEntities = toRemove.map(translation => {
-                translation.base = entity;
-                return translation;
-            });
-            await this.manager.getRepository(this.translationCtor).remove(toDeleteEntities);
-        }
-
         return entity;
     }
 

+ 7 - 1
server/src/service/helpers/update-translatable.ts

@@ -1,8 +1,10 @@
+import { omit } from 'shared/omit';
 import { ID, Type } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { Translatable, TranslatedInput, Translation } from '../../common/types/locale-types';
 
+import { patchEntity } from './patch-entity';
 import { TranslationUpdaterService } from './translation-updater.service';
 
 /**
@@ -26,7 +28,11 @@ export function updateTranslatable<T extends Translatable>(
         const translationUpdater = translationUpdaterService.create(translationType);
         const diff = translationUpdater.diff(existingTranslations, dto.translations);
 
-        const entity = await translationUpdater.applyDiff(new entityType(dto), diff);
+        const entity = await translationUpdater.applyDiff(
+            new entityType({ ...dto, translations: existingTranslations }),
+            diff,
+        );
+        const updatedEntity = patchEntity(entity as any, omit(dto, ['translations']));
         return connection.manager.save(entity);
     };
 }

+ 0 - 1
server/src/service/product-variant.service.ts

@@ -89,7 +89,6 @@ export class ProductVariantService {
             const variant = await this.create(ctx, product, {
                 sku: defaultSku || 'sku-not-set',
                 price: defaultPrice || 0,
-                image: '',
                 optionCodes: options.map(o => o.code),
                 translations: [
                     {

+ 0 - 25
server/src/service/product.service.spec.ts

@@ -86,30 +86,6 @@ describe('ProductService', () => {
             expect(savedTranslation2 instanceof ProductTranslation).toBe(true);
             expect(savedProduct.translations).toEqual([savedTranslation1, savedTranslation2]);
         });
-
-        it('adds OptionGroups to the product when specified', async () => {
-            const mockOptionGroups = [
-                { code: 'optionGroup1' },
-                { code: 'optionGroup2' },
-                { code: 'optionGroup3' },
-            ];
-            connection.registerMockRepository(ProductOptionGroup).find.mockReturnValue(mockOptionGroups);
-
-            await productService.create(new RequestContext(), {
-                translations: [
-                    {
-                        languageCode: LanguageCode.en,
-                        name: 'Test en',
-                        slug: 'test-en',
-                        description: 'Test description en',
-                    },
-                ],
-                optionGroupCodes: ['optionGroup2'],
-            });
-
-            const savedProduct = connection.manager.save.mock.calls[1][0];
-            expect(savedProduct.optionGroups).toEqual([mockOptionGroups[1]]);
-        });
     });
 
     describe('update()', () => {
@@ -123,7 +99,6 @@ describe('ProductService', () => {
 
             const dto: UpdateProductInput = {
                 id: '1',
-                image: 'some-image',
                 translations: [],
             };
             await productService.update(new RequestContext(), dto);

+ 0 - 6
server/src/service/product.service.ts

@@ -86,12 +86,6 @@ export class ProductService {
     async create(ctx: RequestContext, createProductDto: CreateProductInput): Promise<Translated<Product>> {
         const save = createTranslatable(Product, ProductTranslation, async p => {
             this.channelService.assignToChannels(p, ctx);
-            const { optionGroupCodes } = createProductDto;
-            if (optionGroupCodes && optionGroupCodes.length) {
-                const optionGroups = await this.connection.getRepository(ProductOptionGroup).find();
-                const selectedOptionGroups = optionGroups.filter(og => optionGroupCodes.includes(og.code));
-                p.optionGroups = selectedOptionGroups;
-            }
         });
         const product = await save(this.connection, createProductDto);
         return assertFound(this.findOne(ctx, product.id));

+ 3 - 4
server/src/service/role.service.ts

@@ -17,6 +17,7 @@ import { I18nError } from '../i18n/i18n-error';
 import { ChannelService } from './channel.service';
 import { buildListQuery } from './helpers/build-list-query';
 import { ActiveConnection } from './helpers/connection.decorator';
+import { patchEntity } from './helpers/patch-entity';
 
 @Injectable()
 export class RoleService {
@@ -74,10 +75,8 @@ export class RoleService {
         if (role.code === SUPER_ADMIN_ROLE_CODE || role.code === CUSTOMER_ROLE_CODE) {
             throw new I18nError(`error.cannot-modify-role`, { roleCode: role.code });
         }
-        role.code = input.code;
-        role.description = input.description;
-        role.permissions = input.permissions;
-        await this.connection.manager.save(role);
+        const updatedRole = patchEntity(role, input);
+        await this.connection.manager.save(updatedRole);
         return assertFound(this.findOne(role.id));
     }
 

+ 26 - 40
shared/generated-types.ts

@@ -870,7 +870,6 @@ export interface UpdateProduct_updateProduct_variants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: UpdateProduct_updateProduct_variants_options[];
   facetValues: UpdateProduct_updateProduct_variants_facetValues[];
   translations: UpdateProduct_updateProduct_variants_translations[];
@@ -882,7 +881,6 @@ export interface UpdateProduct_updateProduct {
   languageCode: LanguageCode;
   name: string;
   slug: string;
-  image: string;
   description: string;
   featuredAsset: UpdateProduct_updateProduct_featuredAsset | null;
   assets: UpdateProduct_updateProduct_assets[];
@@ -976,7 +974,6 @@ export interface CreateProduct_createProduct_variants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: CreateProduct_createProduct_variants_options[];
   facetValues: CreateProduct_createProduct_variants_facetValues[];
   translations: CreateProduct_createProduct_variants_translations[];
@@ -988,7 +985,6 @@ export interface CreateProduct_createProduct {
   languageCode: LanguageCode;
   name: string;
   slug: string;
-  image: string;
   description: string;
   featuredAsset: CreateProduct_createProduct_featuredAsset | null;
   assets: CreateProduct_createProduct_assets[];
@@ -1082,7 +1078,6 @@ export interface GenerateProductVariants_generateVariantsForProduct_variants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: GenerateProductVariants_generateVariantsForProduct_variants_options[];
   facetValues: GenerateProductVariants_generateVariantsForProduct_variants_facetValues[];
   translations: GenerateProductVariants_generateVariantsForProduct_variants_translations[];
@@ -1094,7 +1089,6 @@ export interface GenerateProductVariants_generateVariantsForProduct {
   languageCode: LanguageCode;
   name: string;
   slug: string;
-  image: string;
   description: string;
   featuredAsset: GenerateProductVariants_generateVariantsForProduct_featuredAsset | null;
   assets: GenerateProductVariants_generateVariantsForProduct_assets[];
@@ -1152,7 +1146,6 @@ export interface UpdateProductVariants_updateProductVariants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: UpdateProductVariants_updateProductVariants_options[];
   facetValues: UpdateProductVariants_updateProductVariants_facetValues[];
   translations: UpdateProductVariants_updateProductVariants_translations[];
@@ -1328,7 +1321,6 @@ export interface ApplyFacetValuesToProductVariants_applyFacetValuesToProductVari
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_options[];
   facetValues: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_facetValues[];
   translations: ApplyFacetValuesToProductVariants_applyFacetValuesToProductVariants_translations[];
@@ -1420,7 +1412,6 @@ export interface GetProductWithVariants_product_variants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: GetProductWithVariants_product_variants_options[];
   facetValues: GetProductWithVariants_product_variants_facetValues[];
   translations: GetProductWithVariants_product_variants_translations[];
@@ -1432,7 +1423,6 @@ export interface GetProductWithVariants_product {
   languageCode: LanguageCode;
   name: string;
   slug: string;
-  image: string;
   description: string;
   featuredAsset: GetProductWithVariants_product_featuredAsset | null;
   assets: GetProductWithVariants_product_assets[];
@@ -1761,7 +1751,6 @@ export interface ProductVariant {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: ProductVariant_options[];
   facetValues: ProductVariant_facetValues[];
   translations: ProductVariant_translations[];
@@ -1841,7 +1830,6 @@ export interface ProductWithVariants_variants {
   name: string;
   price: number;
   sku: string;
-  image: string | null;
   options: ProductWithVariants_variants_options[];
   facetValues: ProductWithVariants_variants_facetValues[];
   translations: ProductWithVariants_variants_translations[];
@@ -1853,7 +1841,6 @@ export interface ProductWithVariants {
   languageCode: LanguageCode;
   name: string;
   slug: string;
-  image: string;
   description: string;
   featuredAsset: ProductWithVariants_featuredAsset | null;
   assets: ProductWithVariants_assets[];
@@ -2227,9 +2214,9 @@ export interface CreateProductCustomFieldsInput {
 }
 
 export interface CreateProductInput {
-  image?: string | null;
+  featuredAssetId?: string | null;
+  assetIds?: string[] | null;
   translations: ProductTranslationInput[];
-  optionGroupCodes?: string[] | null;
   customFields?: CreateProductCustomFieldsInput | null;
 }
 
@@ -2291,14 +2278,14 @@ export interface FacetSortParameter {
 export interface FacetTranslationInput {
   id?: string | null;
   languageCode: LanguageCode;
-  name: string;
+  name?: string | null;
   customFields?: any | null;
 }
 
 export interface FacetValueTranslationInput {
   id?: string | null;
   languageCode: LanguageCode;
-  name: string;
+  name?: string | null;
   customFields?: any | null;
 }
 
@@ -2323,7 +2310,7 @@ export interface ProductListOptions {
 export interface ProductOptionGroupTranslationInput {
   id?: string | null;
   languageCode: LanguageCode;
-  name: string;
+  name?: string | null;
   customFields?: any | null;
 }
 
@@ -2347,16 +2334,16 @@ export interface ProductTranslationCustomFieldsInput {
 export interface ProductTranslationInput {
   id?: string | null;
   languageCode: LanguageCode;
-  name: string;
-  slug: string;
-  description: string;
+  name?: string | null;
+  slug?: string | null;
+  description?: string | null;
   customFields?: ProductTranslationCustomFieldsInput | null;
 }
 
 export interface ProductVariantTranslationInput {
   id?: string | null;
   languageCode: LanguageCode;
-  name: string;
+  name?: string | null;
   customFields?: any | null;
 }
 
@@ -2389,11 +2376,11 @@ export interface StringOperators {
 
 export interface UpdateAdministratorInput {
   id: string;
-  firstName: string;
-  lastName: string;
-  emailAddress: string;
+  firstName?: string | null;
+  lastName?: string | null;
+  emailAddress?: string | null;
   password?: string | null;
-  roleIds: string[];
+  roleIds?: string[] | null;
 }
 
 export interface UpdateFacetCustomFieldsInput {
@@ -2402,8 +2389,8 @@ export interface UpdateFacetCustomFieldsInput {
 
 export interface UpdateFacetInput {
   id: string;
-  code: string;
-  translations: FacetTranslationInput[];
+  code?: string | null;
+  translations?: FacetTranslationInput[] | null;
   customFields?: UpdateFacetCustomFieldsInput | null;
 }
 
@@ -2414,8 +2401,8 @@ export interface UpdateFacetValueCustomFieldsInput {
 
 export interface UpdateFacetValueInput {
   id: string;
-  code: string;
-  translations: FacetValueTranslationInput[];
+  code?: string | null;
+  translations?: FacetValueTranslationInput[] | null;
   customFields?: UpdateFacetValueCustomFieldsInput | null;
 }
 
@@ -2426,26 +2413,25 @@ export interface UpdateProductCustomFieldsInput {
 
 export interface UpdateProductInput {
   id: string;
-  image?: string | null;
-  translations: ProductTranslationInput[];
-  optionGroupCodes?: string[] | null;
+  featuredAssetId?: string | null;
+  assetIds?: string[] | null;
+  translations?: ProductTranslationInput[] | null;
   customFields?: UpdateProductCustomFieldsInput | null;
 }
 
 export interface UpdateProductVariantInput {
   id: string;
-  translations: ProductVariantTranslationInput[];
-  sku: string;
-  image?: string | null;
-  price: number;
+  translations?: ProductVariantTranslationInput[] | null;
+  sku?: string | null;
+  price?: number | null;
   customFields?: any | null;
 }
 
 export interface UpdateRoleInput {
   id: string;
-  code: string;
-  description: string;
-  permissions: Permission[];
+  code?: string | null;
+  description?: string | null;
+  permissions?: Permission[] | null;
 }
 
 //==============================================================

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott