From 2075d6dc639a676f4bc45586abb38b48a62361a4 Mon Sep 17 00:00:00 2001 From: Andrii Baran <59182007+Anddrrew@users.noreply.github.com> Date: Tue, 20 Feb 2024 12:19:26 +0200 Subject: [PATCH 01/25] fix(core): Fix renaming of product with readonly custom field (#2684) --- .../src/data/providers/base-data.service.ts | 6 +- .../is-entity-create-or-update-mutation.ts | 51 ++++++++ .../remove-readonly-custom-fields.spec.ts | 35 ++++++ .../utils/remove-readonly-custom-fields.ts | 118 ++++-------------- 4 files changed, 113 insertions(+), 97 deletions(-) create mode 100644 packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts diff --git a/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts b/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts index c945359aa5..d46803fa41 100644 --- a/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts +++ b/packages/admin-ui/src/lib/core/src/data/providers/base-data.service.ts @@ -11,11 +11,9 @@ import { CustomFields } from '../../common/generated-types'; import { QueryResult } from '../query-result'; import { ServerConfigService } from '../server-config'; import { addCustomFields } from '../utils/add-custom-fields'; -import { - isEntityCreateOrUpdateMutation, - removeReadonlyCustomFields, -} from '../utils/remove-readonly-custom-fields'; +import { removeReadonlyCustomFields } from '../utils/remove-readonly-custom-fields'; import { transformRelationCustomFieldInputs } from '../utils/transform-relation-custom-field-inputs'; +import { isEntityCreateOrUpdateMutation } from '../utils/is-entity-create-or-update-mutation'; @Injectable() export class BaseDataService { diff --git a/packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts b/packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts new file mode 100644 index 0000000000..c8206748cd --- /dev/null +++ b/packages/admin-ui/src/lib/core/src/data/utils/is-entity-create-or-update-mutation.ts @@ -0,0 +1,51 @@ +import { DocumentNode, getOperationAST, NamedTypeNode, TypeNode } from 'graphql'; + +const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/; +const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/; + +/** + * Checks the current documentNode for an operation with a variable named "CreateInput" or "UpdateInput" + * and if a match is found, returns the name. + */ +export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): string | undefined { + const operationDef = getOperationAST(documentNode, null); + if (operationDef && operationDef.variableDefinitions) { + for (const variableDef of operationDef.variableDefinitions) { + const namedType = extractInputType(variableDef.type); + const inputTypeName = namedType.name.value; + + // special cases which don't follow the usual pattern + if (inputTypeName === 'UpdateActiveAdministratorInput') { + return 'Administrator'; + } + if (inputTypeName === 'ModifyOrderInput') { + return 'Order'; + } + if ( + inputTypeName === 'AddItemToDraftOrderInput' || + inputTypeName === 'AdjustDraftOrderLineInput' + ) { + return 'OrderLine'; + } + + const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX); + if (createMatch) { + return createMatch[1]; + } + const updateMatch = inputTypeName.match(UPDATE_ENTITY_REGEX); + if (updateMatch) { + return updateMatch[1]; + } + } + } +} + +function extractInputType(type: TypeNode): NamedTypeNode { + if (type.kind === 'NonNullType') { + return extractInputType(type.type); + } + if (type.kind === 'ListType') { + return extractInputType(type.type); + } + return type; +} diff --git a/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts b/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts index dfdcbf53ba..ac6a46500a 100644 --- a/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts +++ b/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts @@ -45,6 +45,23 @@ describe('removeReadonlyCustomFields', () => { } as any); }); + it('readonly field and customFields is undefined', () => { + const config: CustomFieldConfig[] = [{ name: 'alias', type: 'string', readonly: true, list: false }]; + + const entity = { + id: 1, + name: 'test', + customFields: undefined, + }; + + const result = removeReadonlyCustomFields(entity, config); + expect(result).toEqual({ + id: 1, + name: 'test', + customFields: undefined, + } as any); + }); + it('readonly field in translation', () => { const config: CustomFieldConfig[] = [ { name: 'alias', type: 'localeString', readonly: true, list: false }, @@ -63,6 +80,24 @@ describe('removeReadonlyCustomFields', () => { } as any); }); + it('readonly field and customFields is undefined in translation', () => { + const config: CustomFieldConfig[] = [ + { name: 'alias', type: 'localeString', readonly: true, list: false }, + ]; + const entity = { + id: 1, + name: 'test', + translations: [{ id: 1, languageCode: LanguageCode.en, customFields: undefined }], + }; + + const result = removeReadonlyCustomFields(entity, config); + expect(result).toEqual({ + id: 1, + name: 'test', + translations: [{ id: 1, languageCode: LanguageCode.en, customFields: undefined }], + } as any); + }); + it('wrapped in an input object', () => { const config: CustomFieldConfig[] = [ { name: 'weight', type: 'int', list: false }, diff --git a/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts b/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts index 7c5ecfd508..1ee102d9a1 100644 --- a/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts +++ b/packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.ts @@ -1,121 +1,53 @@ -import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone'; -import { DocumentNode, getOperationAST, NamedTypeNode, TypeNode } from 'graphql'; - import { CustomFieldConfig } from '../../common/generated-types'; -const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/; -const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/; - type InputWithOptionalCustomFields = Record & { customFields?: Record; }; -type InputWithCustomFields = Record & { - customFields: Record; -}; type EntityInput = InputWithOptionalCustomFields & { translations?: InputWithOptionalCustomFields[]; }; -/** - * Checks the current documentNode for an operation with a variable named "CreateInput" or "UpdateInput" - * and if a match is found, returns the name. - */ -export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): string | undefined { - const operationDef = getOperationAST(documentNode, null); - if (operationDef && operationDef.variableDefinitions) { - for (const variableDef of operationDef.variableDefinitions) { - const namedType = extractInputType(variableDef.type); - const inputTypeName = namedType.name.value; - - // special cases which don't follow the usual pattern - if (inputTypeName === 'UpdateActiveAdministratorInput') { - return 'Administrator'; - } - if (inputTypeName === 'ModifyOrderInput') { - return 'Order'; - } - if ( - inputTypeName === 'AddItemToDraftOrderInput' || - inputTypeName === 'AdjustDraftOrderLineInput' - ) { - return 'OrderLine'; - } +type Variable = EntityInput | EntityInput[]; - const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX); - if (createMatch) { - return createMatch[1]; - } - const updateMatch = inputTypeName.match(UPDATE_ENTITY_REGEX); - if (updateMatch) { - return updateMatch[1]; - } - } - } -} - -function extractInputType(type: TypeNode): NamedTypeNode { - if (type.kind === 'NonNullType') { - return extractInputType(type.type); - } - if (type.kind === 'ListType') { - return extractInputType(type.type); - } - return type; -} +type WrappedVariable = { + input: Variable; +}; /** * Removes any `readonly` custom fields from an entity (including its translations). * To be used before submitting the entity for a create or update request. */ export function removeReadonlyCustomFields( - variables: { input?: EntityInput | EntityInput[] } | EntityInput | EntityInput[], + variables: Variable | WrappedVariable | WrappedVariable[], customFieldConfig: CustomFieldConfig[], -): { input?: EntityInput | EntityInput[] } | EntityInput | EntityInput[] { - if (!Array.isArray(variables)) { +) { + if (Array.isArray(variables)) { + return variables.map(variable => removeReadonlyCustomFields(variable, customFieldConfig)); + } + + if ('input' in variables && variables.input) { if (Array.isArray(variables.input)) { - for (const input of variables.input) { - removeReadonly(input, customFieldConfig); - } + variables.input = variables.input.map(variable => removeReadonly(variable, customFieldConfig)); } else { - removeReadonly(variables.input, customFieldConfig); - } - } else { - for (const input of variables) { - removeReadonly(input, customFieldConfig); + variables.input = removeReadonly(variables.input, customFieldConfig); } + return variables; } + return removeReadonly(variables, customFieldConfig); } -function removeReadonly(input: InputWithOptionalCustomFields, customFieldConfig: CustomFieldConfig[]) { - for (const field of customFieldConfig) { - if (field.readonly) { - if (field.type === 'localeString') { - if (hasTranslations(input)) { - for (const translation of input.translations) { - if ( - hasCustomFields(translation) && - translation.customFields[field.name] !== undefined - ) { - delete translation.customFields[field.name]; - } - } - } - } else { - if (hasCustomFields(input) && input.customFields[field.name] !== undefined) { - delete input.customFields[field.name]; - } - } - } - } - return input; -} +function removeReadonly(input: EntityInput, customFieldConfig: CustomFieldConfig[]) { + const readonlyConfigs = customFieldConfig.filter(({ readonly }) => readonly); -function hasCustomFields(input: any): input is InputWithCustomFields { - return input != null && input.hasOwnProperty('customFields'); -} + readonlyConfigs.forEach(({ name }) => { + input.translations?.forEach(translation => { + delete translation.customFields?.[name]; + }); + + delete input.customFields?.[name]; + }); -function hasTranslations(input: any): input is { translations: InputWithOptionalCustomFields[] } { - return input != null && input.hasOwnProperty('translations'); + return input; } From 4e1f4084f50f2c787ff4fadf7c581bb0419478ba Mon Sep 17 00:00:00 2001 From: Jonas Osburg Date: Tue, 20 Feb 2024 13:18:14 +0100 Subject: [PATCH 02/25] fix(core): Handle nullable relations in EntityHydrator (#2683) Fixes #2682 --- packages/core/e2e/entity-hydrator.e2e-spec.ts | 56 ++++++++++++++++++- .../test-plugins/hydration-test-plugin.ts | 35 ++++++++++++ .../entity-hydrator.service.ts | 2 + 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/core/e2e/entity-hydrator.e2e-spec.ts b/packages/core/e2e/entity-hydrator.e2e-spec.ts index e6ff9034ab..bdea1bbb95 100644 --- a/packages/core/e2e/entity-hydrator.e2e-spec.ts +++ b/packages/core/e2e/entity-hydrator.e2e-spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { + Asset, ChannelService, EntityHydrator, mergeConfig, @@ -21,7 +22,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { initialData } from '../../../e2e-common/e2e-initial-data'; import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config'; -import { HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin'; +import { AdditionalConfig, HydrationTestPlugin } from './fixtures/test-plugins/hydration-test-plugin'; import { UpdateChannelMutation, UpdateChannelMutationVariables } from './graphql/generated-e2e-admin-types'; import { AddItemToOrderDocument, @@ -50,6 +51,14 @@ describe('Entity hydration', () => { customerCount: 2, }); await adminClient.asSuperAdmin(); + + const connection = server.app.get(TransactionalConnection).rawConnection; + const asset = await connection.getRepository(Asset).findOne({ where: {} }); + await connection.getRepository(AdditionalConfig).save( + new AdditionalConfig({ + backgroundImage: asset, + }), + ); }, TEST_SETUP_TIMEOUT_MS); afterAll(async () => { @@ -240,6 +249,45 @@ describe('Entity hydration', () => { expect(hydrateChannel.customFields.thumb.id).toBe('T_2'); }); + it('hydrates a nested custom field', async () => { + await adminClient.query(UPDATE_CHANNEL, { + input: { + id: 'T_1', + customFields: { + additionalConfigId: 'T_1', + }, + }, + }); + + const { hydrateChannelWithNestedRelation } = await adminClient.query<{ + hydrateChannelWithNestedRelation: any; + }>(GET_HYDRATED_CHANNEL_NESTED, { + id: 'T_1', + }); + + expect(hydrateChannelWithNestedRelation.customFields.additionalConfig).toBeDefined(); + }); + + // https://github.com/vendure-ecommerce/vendure/issues/2682 + it('hydrates a nested custom field where the first level is null', async () => { + await adminClient.query(UPDATE_CHANNEL, { + input: { + id: 'T_1', + customFields: { + additionalConfigId: null, + }, + }, + }); + + const { hydrateChannelWithNestedRelation } = await adminClient.query<{ + hydrateChannelWithNestedRelation: any; + }>(GET_HYDRATED_CHANNEL_NESTED, { + id: 'T_1', + }); + + expect(hydrateChannelWithNestedRelation.customFields.additionalConfig).toBeNull(); + }); + // https://github.com/vendure-ecommerce/vendure/issues/2013 describe('hydration of OrderLine ProductVariantPrices', () => { let order: Order | undefined; @@ -378,3 +426,9 @@ const GET_HYDRATED_CHANNEL = gql` hydrateChannel(id: $id) } `; + +const GET_HYDRATED_CHANNEL_NESTED = gql` + query GetHydratedChannelNested($id: ID!) { + hydrateChannelWithNestedRelation(id: $id) + } +`; diff --git a/packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts b/packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts index 7ac97dd6b1..270fbaabc5 100644 --- a/packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts +++ b/packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts @@ -4,6 +4,7 @@ import { Asset, ChannelService, Ctx, + DeepPartial, EntityHydrator, ID, LanguageCode, @@ -14,9 +15,11 @@ import { ProductVariantService, RequestContext, TransactionalConnection, + VendureEntity, VendurePlugin, } from '@vendure/core'; import gql from 'graphql-tag'; +import { Entity, ManyToOne } from 'typeorm'; @Resolver() export class TestAdminPluginResolver { @@ -125,10 +128,34 @@ export class TestAdminPluginResolver { }); return channel; } + + @Query() + async hydrateChannelWithNestedRelation(@Ctx() ctx: RequestContext, @Args() args: { id: ID }) { + const channel = await this.channelService.findOne(ctx, args.id); + await this.entityHydrator.hydrate(ctx, channel!, { + relations: [ + 'customFields.thumb', + 'customFields.additionalConfig', + 'customFields.additionalConfig.backgroundImage', + ], + }); + return channel; + } +} + +@Entity() +export class AdditionalConfig extends VendureEntity { + constructor(input?: DeepPartial) { + super(input); + } + + @ManyToOne(() => Asset, { onDelete: 'SET NULL', nullable: true }) + backgroundImage: Asset; } @VendurePlugin({ imports: [PluginCommonModule], + entities: [AdditionalConfig], adminApiExtensions: { resolvers: [TestAdminPluginResolver], schema: gql` @@ -140,11 +167,19 @@ export class TestAdminPluginResolver { hydrateOrder(id: ID!): JSON hydrateOrderReturnQuantities(id: ID!): JSON hydrateChannel(id: ID!): JSON + hydrateChannelWithNestedRelation(id: ID!): JSON } `, }, configuration: config => { config.customFields.Channel.push({ name: 'thumb', type: 'relation', entity: Asset, nullable: true }); + config.customFields.Channel.push({ + name: 'additionalConfig', + type: 'relation', + entity: AdditionalConfig, + graphQLType: 'JSON', + nullable: true, + }); return config; }, }) diff --git a/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts b/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts index 7d2497a1c7..a035bb19e1 100644 --- a/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts +++ b/packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts @@ -252,6 +252,8 @@ export class EntityHydrator { visit(item, parts.slice()); } } + } else if (target === null) { + result.push(target); } else { if (parts.length === 0) { result.push(target); From 0e9de53fffb103b03537fe748b0d61f3d4c52b64 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Thu, 22 Feb 2024 08:58:05 +0100 Subject: [PATCH 03/25] chore: Update compatibility of digital products example plugin --- .../example-plugins/digital-products/digital-products.plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts b/packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts index 8d75eb3b68..61da4d7003 100644 --- a/packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts +++ b/packages/dev-server/example-plugins/digital-products/digital-products.plugin.ts @@ -43,6 +43,6 @@ import { DigitalShippingLineAssignmentStrategy } from './config/digital-shipping config.orderOptions.process.push(digitalOrderProcess); return config; }, - compatibility: '~2.0.0', + compatibility: '^2.0.0', }) export class DigitalProductsPlugin {} From 6a4a7e5a10513b8f6f327491ffe0834390227f66 Mon Sep 17 00:00:00 2001 From: Florian Wild <362217+floze@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:06:03 +0100 Subject: [PATCH 04/25] fix(core): Export OrderByCodeAccessStrategy and DefaultOrderByCodeAccessStrategy (#2692) --- packages/core/src/config/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 084e1e0721..a0d50f571d 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -51,6 +51,7 @@ export * from './order/default-stock-allocation-strategy'; export * from './order/default-guest-checkout-strategy'; export * from './order/guest-checkout-strategy'; export * from './order/merge-orders-strategy'; +export * from './order/order-by-code-access-strategy'; export * from './order/order-code-strategy'; export * from './order/order-item-price-calculation-strategy'; export * from './order/order-merge-strategy'; From 2a3a7960ca245d2055a9ff5f84e3112d1e0e0bff Mon Sep 17 00:00:00 2001 From: Daniel Biegler Date: Mon, 26 Feb 2024 10:08:04 +0100 Subject: [PATCH 05/25] fix(admin-ui): Add missing translation of breadcrumb tooltips (#2697) --- .../core/src/components/breadcrumb/breadcrumb.component.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html b/packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html index beb80abae7..7a8a4051a2 100644 --- a/packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html +++ b/packages/admin-ui/src/lib/core/src/components/breadcrumb/breadcrumb.component.html @@ -1,6 +1,9 @@