Răsfoiți Sursa

Merge remote-tracking branch 'origin/master'

Kevin Mattutat 1 an în urmă
părinte
comite
1fba1813f1
19 a modificat fișierele cu 337 adăugiri și 92 ștergeri
  1. 1 1
      docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md
  2. 48 0
      license/signatures/version1/cla.json
  3. 5 2
      packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts
  4. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.scss
  5. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts
  6. 6 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  7. 0 1
      packages/admin-ui/src/lib/order/src/order.routes.ts
  8. 12 12
      packages/admin-ui/src/lib/static/i18n-messages/sv.json
  9. 9 0
      packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap
  10. 24 0
      packages/core/src/api/config/graphql-custom-fields.spec.ts
  11. 4 2
      packages/core/src/api/config/graphql-custom-fields.ts
  12. 10 12
      packages/core/src/api/schema/admin-api/order.api.graphql
  13. 2 2
      packages/core/src/common/utils.ts
  14. 5 4
      packages/payments-plugin/src/mollie/mollie.service.ts
  15. 2 1
      packages/testing/package.json
  16. 49 18
      packages/testing/src/simple-graphql-client.ts
  17. 59 11
      packages/testing/src/utils/create-upload-post-data.spec.ts
  18. 80 25
      packages/testing/src/utils/create-upload-post-data.ts
  19. 18 0
      packages/testing/vitest.config.mts

+ 1 - 1
docs/docs/reference/admin-ui-api/components/product-variant-selector-component.md

@@ -19,7 +19,7 @@ A component for selecting product variants via an autocomplete-style select inpu
 
 ```HTML
 <vdr-product-variant-selector
-  (productSelected)="selectResult($event)"></vdr-product-selector>
+  (productSelected)="selectResult($event)"></vdr-product-variant-selector>
 ```
 
 ```ts title="Signature"

+ 48 - 0
license/signatures/version1/cla.json

@@ -255,6 +255,54 @@
       "created_at": "2024-10-20T22:01:36Z",
       "repoId": 136938012,
       "pullRequestNo": 3151
+    },
+    {
+      "name": "twlite",
+      "id": 46562212,
+      "comment_id": 2441869361,
+      "created_at": "2024-10-28T15:12:26Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3171
+    },
+    {
+      "name": "alexisvigoureux",
+      "id": 6134849,
+      "comment_id": 2444508370,
+      "created_at": "2024-10-29T14:55:39Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3172
+    },
+    {
+      "name": "HausTechTeam",
+      "id": 157805863,
+      "comment_id": 2449329824,
+      "created_at": "2024-10-31T08:42:52Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3174
+    },
+    {
+      "name": "kkerti",
+      "id": 47832952,
+      "comment_id": 2458191015,
+      "created_at": "2024-11-05T21:33:05Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3187
+    },
+    {
+      "name": "shingoaoyama1",
+      "id": 17615101,
+      "comment_id": 2459213307,
+      "created_at": "2024-11-06T10:15:37Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3192
+    },
+    {
+      "name": "agoransson",
+      "id": 487002,
+      "comment_id": 2466157456,
+      "created_at": "2024-11-09T10:08:00Z",
+      "repoId": 136938012,
+      "pullRequestNo": 3205
     }
   ]
 }

+ 5 - 2
packages/admin-ui/src/lib/catalog/src/components/product-variant-quick-jump/product-variant-quick-jump.component.ts

@@ -31,14 +31,17 @@ export class ProductVariantQuickJumpComponent implements OnInit {
     @Input() productId: string;
     selectedVariantId: string | undefined;
     variants$: Observable<NonNullable<GetProductVariantsQuickJumpQuery['product']>['variants']>;
-    constructor(private dataService: DataService, private router: Router) {}
+    constructor(
+        private dataService: DataService,
+        private router: Router,
+    ) {}
 
     ngOnInit() {
         this.variants$ = this.dataService
             .query(GetProductVariantsQuickJumpDocument, {
                 id: this.productId,
             })
-            .mapSingle(data => data.product?.variants ?? []);
+            .mapStream(data => data.product?.variants ?? []);
     }
 
     searchFn = (

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/components/product-multi-selector-dialog/product-multi-selector-dialog.component.scss

@@ -32,6 +32,7 @@
     margin: 0 3px;
     font-size: 12px;
     line-height: 0.8rem;
+    color: var(--color-text-100);
 }
 
 vdr-select-toggle {
@@ -63,6 +64,7 @@ vdr-select-toggle {
         overflow-y: auto;
         .item-row {
             padding-inline-start: 3px;
+            color: var(--color-text-100);
             &:hover {
                 background-color: var(--color-component-bg-200);
             }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/product-variant-selector/product-variant-selector.component.ts

@@ -13,7 +13,7 @@ import { DataService } from '../../../data/providers/data.service';
  * @example
  * ```HTML
  * <vdr-product-variant-selector
- *   (productSelected)="selectResult($event)"></vdr-product-selector>
+ *   (productSelected)="selectResult($event)"></vdr-product-variant-selector>
  * ```
  *
  * @docsCategory components

+ 6 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -187,6 +187,12 @@ export class DynamicFormInputComponent
         if (this.listItems) {
             for (const item of this.listItems) {
                 if (item.componentRef) {
+                    const { value } = item.control;
+                    const { type } = item.componentRef.instance.config || {};
+                    // fix a bug where the list item of string turns into number which lead to unexpected behavior
+                    if (typeof value === 'number' && type === 'string') {
+                        item.control.setValue(item.control.value.toString(), { emitEvent: false });
+                    }
                     this.updateBindings(changes, item.componentRef);
                 }
             }

+ 0 - 1
packages/admin-ui/src/lib/order/src/order.routes.ts

@@ -7,7 +7,6 @@ export const createRoutes = (pageService: PageService): Route[] => [
     {
         path: '',
         component: PageComponent,
-        pathMatch: 'full',
         data: {
             locationId: 'order-list',
             breadcrumb: _('breadcrumb.orders'),

+ 12 - 12
packages/admin-ui/src/lib/static/i18n-messages/sv.json

@@ -28,7 +28,7 @@
     "administrators": "Administratörer",
     "assets": "Filer",
     "channels": "Kanaler",
-    "collections": "Kategorier",
+    "collections": "Samlingar",
     "countries": "Länder",
     "customer-groups": "Kundgrupper",
     "customers": "Kunder",
@@ -76,9 +76,9 @@
     "calculated-price-tooltip": "Det finns en anpassad prisberäkning konfigurerad som ändrar priset ovan:",
     "cannot-create-variants-without-options": "Produktvarianter kan inte läggas till förrän en alternativgrupp med minst två produktalternativ har definierats",
     "channel-price-preview": "Förhandsgranskning av kanalpris",
-    "collection": "Kategorier",
-    "collection-contents": "Innehåll i kategorin",
-    "collections": "Kategorier",
+    "collection": "Samling",
+    "collection-contents": "Innehåll i samlingen",
+    "collections": "Samlingar",
     "confirm-bulk-delete-products": "Vill du radera {count} produkter?",
     "confirm-cancel": "Avbryt?",
     "confirm-delete-assets": "Radera {count} {count, plural, one {fil} other {filer}}?",
@@ -91,7 +91,7 @@
     "confirm-deletion-of-unused-variants-body": "Följande produktvarianter har blivit överflödiga på grund av tillägg av nya alternativ. De kommer att raderas vid skapandet av nya produktvarianter.",
     "confirm-deletion-of-unused-variants-title": "Radera överflödiga produktvarianter?",
     "create-draft-order": "Lägg till utkast till order",
-    "create-new-collection": "Lägg till ny kategori",
+    "create-new-collection": "Lägg till ny samling",
     "create-new-facet": "Lägg till ny etikett",
     "create-new-product": "Lägg till ny produkt",
     "create-new-stock-location": "Lägg till lagerplats",
@@ -117,8 +117,8 @@
     "live-preview-contents": "Förhandsgranska innehåll",
     "manage-variants": "Hantera varianter",
     "move-collection-to": "Flytta till { name }",
-    "move-collections": "Flytta kategorier",
-    "move-collections-success": "Flyttade {count, plural, one {1 kategori} other {{count} kategorier}}",
+    "move-collections": "Flytta samlingar",
+    "move-collections-success": "Flyttade {count, plural, one {1 samling} other {{count} samlingar}}",
     "move-down": "Flytta nedåt",
     "move-to": "Flytta till",
     "move-up": "Flytta uppåt",
@@ -163,7 +163,7 @@
     "remove-option": "Ta bort alternativ",
     "remove-product-from-channel": "Ta bort produkt från kanal",
     "remove-product-variant-from-channel": "Ta bort produktvariant från kanal",
-    "reorder-collection": "Ordna om kategorier",
+    "reorder-collection": "Ordna om samlingar",
     "root-collection": "Rotkatalog",
     "run-pending-search-index-updates": "Sökindex: kör {count, plural, one {1 avvaktande uppdatering} other {{count} avvaktande uppdateringar}}",
     "running-search-index-updates": "Kör {count, plural, one {1 uppdatering} other {{count} uppdateringar}} till sökindex",
@@ -282,10 +282,10 @@
     "notify-assign-to-channel-success-with-count": "Lyckades tilldela {count, plural, one {1 objekt} other {{count} objekt}} till { channelCode }",
     "notify-bulk-update-success": "Uppdaterade { count } { entity }",
     "notify-create-error": "Ett fel uppstod, kunde inte lägga till { entity }",
-    "notify-create-success": "La till nytt { entity }",
+    "notify-create-success": "Ny { entity } tillagd",
     "notify-delete-error": "Ett fel uppstod, kunde inte radera { entity }",
     "notify-delete-error-with-count": "Kunde inte radera {count, plural, one {1 objekt} other {{count} objekt}}",
-    "notify-delete-success": "Raderade { entity }",
+    "notify-delete-success": "{ entity } borttagen",
     "notify-delete-success-with-count": "Lyckades radera {count, plural, one {1 objekt} other {{count} objekt}}",
     "notify-duplicate-error": "Kunde inte duplicera { name } på grund av ett fel: { error }",
     "notify-duplicate-error-excess": "Ytterligare { count } {count, plural, one {objekt} other {objekt}} kunde inte dupliceras på grund av fel",
@@ -513,10 +513,10 @@
   },
   "nav": {
     "administrators": "Administratörer",
-    "assets": "ar",
+    "assets": "Filer",
     "catalog": "Katalog",
     "channels": "Kanaler",
-    "collections": "Kategorier",
+    "collections": "Samlingar",
     "countries": "Länder",
     "customer-groups": "Kundgrupper",
     "customers": "Kunder",

+ 9 - 0
packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -250,6 +250,15 @@ scalar JSON
 scalar DateTime"
 `;
 
+exports[`addGraphQLCustomFields() > uses JSON scalar in UpdateActiveAdministratorInput if only internal custom fields defined on Administrator 1`] = `
+"scalar JSON
+
+input UpdateActiveAdministratorInput {
+  placeholder: String
+  customFields: JSON
+}"
+`;
+
 exports[`addOrderLineCustomFieldsInput() > Modifies the schema when the addItemToOrder & adjustOrderLine mutation is present 1`] = `
 "type Mutation {
   addItemToOrder(id: ID!, quantity: Int!, customFields: OrderLineCustomFieldsInput = null): Boolean

+ 24 - 0
packages/core/src/api/config/graphql-custom-fields.spec.ts

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
 import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
 
 import {
+    addActiveAdministratorCustomFields,
     addGraphQLCustomFields,
     addOrderLineCustomFieldsInput,
     addRegisterCustomerCustomFieldsInput,
@@ -23,6 +24,29 @@ describe('addGraphQLCustomFields()', () => {
         expect(printSchema(result)).toMatchSnapshot();
     });
 
+    // regression test for
+    // https://github.com/vendure-ecommerce/vendure/issues/3158
+    it('uses JSON scalar in UpdateActiveAdministratorInput if only internal custom fields defined on Administrator', () => {
+        // custom field that is internal but not readonly - should not cause
+        // `addActiveAdministratorCustomFields` to assume that
+        // `UpdateAdministratorCustomFieldsInput` exists
+        const customFieldConfig: Required<Pick<CustomFields, 'Administrator'>> = {
+            Administrator: [{ name: 'testField', type: 'string', internal: true }],
+        };
+        // `addActiveAdministratorCustomFields` should add customFields to
+        // UpdateActiveAdministratorInput as a JSON scalar. need to provide
+        // those types for that to work
+        const input = `
+            scalar JSON
+
+            input UpdateActiveAdministratorInput {
+                placeholder: String
+            }
+        `;
+        const schema = addActiveAdministratorCustomFields(input, customFieldConfig.Administrator);
+        expect(printSchema(schema)).toMatchSnapshot();
+    });
+
     it('extends a type', () => {
         const input = `
             type Product {

+ 4 - 2
packages/core/src/api/config/graphql-custom-fields.ts

@@ -256,7 +256,7 @@ export function addServerConfigCustomFields(
     const customFieldTypeDefs = `
             """
             This type is deprecated in v2.2 in favor of the EntityCustomFields type,
-            which allows custom fields to be defined on user-supplies entities.
+            which allows custom fields to be defined on user-supplied entities.
             """
             type CustomFields {
                 ${Object.keys(customFieldConfig).reduce(
@@ -288,7 +288,9 @@ export function addActiveAdministratorCustomFields(
     administratorCustomFields: CustomFieldConfig[],
 ) {
     const schema = typeof typeDefsOrSchema === 'string' ? buildSchema(typeDefsOrSchema) : typeDefsOrSchema;
-    const writableCustomFields = administratorCustomFields?.filter(field => field.readonly !== true);
+    const writableCustomFields = administratorCustomFields?.filter(
+        field => field.readonly !== true && field.internal !== true,
+    );
     const extension = `
         extend input UpdateActiveAdministratorInput {
             customFields: ${

+ 10 - 12
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -111,14 +111,12 @@ input CancelOrderInput {
 }
 
 input RefundOrderInput {
-    lines: [OrderLineInput!]!
-    shipping: Money!
-    adjustment: Money!
+    lines: [OrderLineInput!] @deprecated(reason: "Use the `amount` field instead")
+    shipping: Money @deprecated(reason: "Use the `amount` field instead")
+    adjustment: Money @deprecated(reason: "Use the `amount` field instead")
     """
-    If an amount is specified, this value will be used to create a Refund rather than calculating the
-    amount automatically. This was added in v2.2 and will be the preferred way to specify the refund
-    amount in the future. The `lines`, `shipping` and `adjustment` fields will likely be removed in a future
-    version.
+    The amount to be refunded to this particular payment. This was introduced in v2.2.0 as the preferred way to specify the refund amount.
+    Can be as much as the total amount of the payment minus the sum of all previous refunds.
     """
     amount: Money
     paymentId: ID!
@@ -410,13 +408,13 @@ type ManualPaymentStateError implements ErrorResult {
 
 union TransitionOrderToStateResult = Order | OrderStateTransitionError
 union SettlePaymentResult =
-      Payment
+    | Payment
     | SettlePaymentError
     | PaymentStateTransitionError
     | OrderStateTransitionError
 union CancelPaymentResult = Payment | CancelPaymentError | PaymentStateTransitionError
 union AddFulfillmentToOrderResult =
-      Fulfillment
+    | Fulfillment
     | EmptyOrderLineSelectionError
     | ItemsAlreadyFulfilledError
     | InsufficientStockOnHandError
@@ -424,14 +422,14 @@ union AddFulfillmentToOrderResult =
     | FulfillmentStateTransitionError
     | CreateFulfillmentError
 union CancelOrderResult =
-      Order
+    | Order
     | EmptyOrderLineSelectionError
     | QuantityTooGreatError
     | MultipleOrderError
     | CancelActiveOrderError
     | OrderStateTransitionError
 union RefundOrderResult =
-      Refund
+    | Refund
     | QuantityTooGreatError
     | NothingToRefundError
     | OrderStateTransitionError
@@ -445,7 +443,7 @@ union SettleRefundResult = Refund | RefundStateTransitionError
 union TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError
 union TransitionPaymentToStateResult = Payment | PaymentStateTransitionError
 union ModifyOrderResult =
-      Order
+    | Order
     | NoChangesSpecifiedError
     | OrderModificationStateError
     | PaymentMethodMissingError

+ 2 - 2
packages/core/src/common/utils.ts

@@ -35,8 +35,8 @@ export function assertFound<T>(promise: Promise<T | undefined | null>): Promise<
  * Compare ID values for equality, taking into account the fact that they may not be of matching types
  * (string or number).
  */
-export function idsAreEqual(id1?: ID, id2?: ID): boolean {
-    if (id1 === undefined || id2 === undefined) {
+export function idsAreEqual(id1?: ID | null, id2?: ID | null): boolean {
+    if (id1 == null || id2 == null) {
         return false;
     }
     return id1.toString() === id2.toString();

+ 5 - 4
packages/payments-plugin/src/mollie/mollie.service.ts

@@ -264,7 +264,12 @@ export class MollieService {
                 `Unable to find order ${mollieOrder.orderNumber}, unable to process Mollie order ${mollieOrder.id}`,
             );
         }
+        if (mollieOrder.status === OrderStatus.expired) {
+            // Expired is fine, a customer can retry the payment later
+            return;
+        }
         if (order.orderPlacedAt) {
+            // Verify if the Vendure order isn't already paid for, and log if so
             const paymentWithSameTransactionId = order.payments.find(
                 p => p.transactionId === mollieOrder.id && p.state === 'Settled',
             );
@@ -293,10 +298,6 @@ export class MollieService {
             return;
         }
         const amount = amountToCents(mollieOrder.amount);
-        if (mollieOrder.status === OrderStatus.expired) {
-            // Expired is fine, a customer can retry the payment later
-            return;
-        }
         if (mollieOrder.status === OrderStatus.paid) {
             // Paid is only used by 1-step payments without Authorized state. This will settle immediately
             await this.addPayment(ctx, order, amount, mollieOrder, paymentMethod.code, 'Settled');

+ 2 - 1
packages/testing/package.json

@@ -30,7 +30,8 @@
         "build": "tsc -p ./tsconfig.build.json",
         "watch": "tsc -p ./tsconfig.build.json -w",
         "lint": "eslint --fix .",
-        "ci": "npm run build"
+        "ci": "npm run build",
+        "test": "vitest --config vitest.config.mts --run"
     },
     "bugs": {
         "url": "https://github.com/vendure-ecommerce/vendure/issues"

+ 49 - 18
packages/testing/src/simple-graphql-client.ts

@@ -44,7 +44,10 @@ export class SimpleGraphQLClient {
         'Apollo-Require-Preflight': 'true',
     };
 
-    constructor(private vendureConfig: Required<VendureConfig>, private apiUrl: string = '') {}
+    constructor(
+        private vendureConfig: Required<VendureConfig>,
+        private apiUrl: string = '',
+    ) {}
 
     /**
      * @description
@@ -136,15 +139,13 @@ export class SimpleGraphQLClient {
     async asUserWithCredentials(username: string, password: string) {
         // first log out as the current user
         if (this.authToken) {
-            await this.query(
-                gql`
-                    mutation {
-                        logout {
-                            success
-                        }
+            await this.query(gql`
+                mutation {
+                    logout {
+                        success
                     }
-                `,
-            );
+                }
+            `);
         }
         const result = await this.query(LOGIN, { username, password });
         if (result.login.channels?.length === 1) {
@@ -170,15 +171,13 @@ export class SimpleGraphQLClient {
      * Logs out so that the client is then treated as an anonymous user.
      */
     async asAnonymousUser() {
-        await this.query(
-            gql`
-                mutation {
-                    logout {
-                        success
-                    }
+        await this.query(gql`
+            mutation {
+                logout {
+                    success
                 }
-            `,
-        );
+            }
+        `);
     }
 
     private async makeGraphQlRequest(
@@ -214,7 +213,36 @@ export class SimpleGraphQLClient {
      * Perform a file upload mutation.
      *
      * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+     *
      * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
+     *
+     * @param mutation - GraphQL document for a mutation that has input files
+     * with the Upload type.
+     * @param filePaths - Array of paths to files, in the same order that the
+     * corresponding Upload fields appear in the variables for the mutation.
+     * @param mapVariables - Function that must return the variables for the
+     * mutation, with `null` as the value for each `Upload` field.
+     *
+     * @example
+     * // Testing a custom mutation:
+     * const result = await client.fileUploadMutation({
+     *   mutation: gql`
+     *     mutation AddSellerImages($input: AddSellerImagesInput!) {
+     *       addSellerImages(input: $input) {
+     *         id
+     *         name
+     *       }
+     *     }
+     *   `,
+     *   filePaths: ['./images/profile-picture.jpg', './images/logo.png'],
+     *   mapVariables: () => ({
+     *     name: "George's Pans",
+     *     profilePicture: null,  // corresponds to filePaths[0]
+     *     branding: {
+     *       logo: null  // corresponds to filePaths[1]
+     *     }
+     *   })
+     * });
      */
     async fileUploadMutation(options: {
         mutation: DocumentNode;
@@ -256,7 +284,10 @@ export class SimpleGraphQLClient {
 }
 
 export class ClientError extends Error {
-    constructor(public response: any, public request: any) {
+    constructor(
+        public response: any,
+        public request: any,
+    ) {
         super(ClientError.extractMessage(response));
     }
     private static extractMessage(response: any): string {

+ 59 - 11
packages/testing/src/utils/create-upload-post-data.spec.ts

@@ -1,4 +1,5 @@
 import gql from 'graphql-tag';
+import { describe, it, assert } from 'vitest';
 
 import { createUploadPostData } from './create-upload-post-data';
 
@@ -8,8 +9,16 @@ describe('createUploadPostData()', () => {
             gql`
                 mutation CreateAssets($input: [CreateAssetInput!]!) {
                     createAssets(input: $input) {
-                        id
-                        name
+                        ... on Asset {
+                            id
+                            name
+                        }
+                        ... on MimeTypeError {
+                            errorCode
+                            message
+                            fileName
+                            mimeType
+                        }
                     }
                 }
             `,
@@ -19,15 +28,18 @@ describe('createUploadPostData()', () => {
             }),
         );
 
-        expect(result.operations.operationName).toBe('CreateAssets');
-        expect(result.operations.variables).toEqual({
+        assert.equal(result.operations.operationName, 'CreateAssets');
+        assert.deepEqual(result.operations.variables, {
             input: [{ file: null }, { file: null }],
         });
-        expect(result.map).toEqual({
+        assert.deepEqual(result.map, {
             0: 'variables.input.0.file',
             1: 'variables.input.1.file',
         });
-        expect(result.filePaths).toEqual([{ name: '0', file: 'a.jpg' }, { name: '1', file: 'b.jpg' }]);
+        assert.deepEqual(result.filePaths, [
+            { name: '0', file: 'a.jpg' },
+            { name: '1', file: 'b.jpg' },
+        ]);
     });
 
     it('creates correct output for importProducts mutation', () => {
@@ -36,7 +48,7 @@ describe('createUploadPostData()', () => {
                 mutation ImportProducts($input: Upload!) {
                     importProducts(csvFile: $input) {
                         errors
-                        importedCount
+                        imported
                     }
                 }
             `,
@@ -44,11 +56,47 @@ describe('createUploadPostData()', () => {
             () => ({ csvFile: null }),
         );
 
-        expect(result.operations.operationName).toBe('ImportProducts');
-        expect(result.operations.variables).toEqual({ csvFile: null });
-        expect(result.map).toEqual({
+        assert.equal(result.operations.operationName, 'ImportProducts');
+        assert.deepEqual(result.operations.variables, { csvFile: null });
+        assert.deepEqual(result.map, {
             0: 'variables.csvFile',
         });
-        expect(result.filePaths).toEqual([{ name: '0', file: 'data.csv' }]);
+        assert.deepEqual(result.filePaths, [{ name: '0', file: 'data.csv' }]);
+    });
+
+    it('creates correct output for a mutation with nested Upload and non-Upload fields', () => {
+        // this is not meant to be a real mutation; it's just an example of one
+        // that could exist
+        const result = createUploadPostData(
+            gql`
+                mutation ComplexUpload($input: ComplexTypeIncludingUpload!) {
+                    complexUpload(input: $input) {
+                        results
+                        errors
+                    }
+                }
+            `,
+            // the two file paths that are specified must appear in the same
+            // order as the `null` variables that stand in for the Upload fields
+            ['logo.png', 'profilePicture.jpg'],
+            () => ({ name: 'George', sellerLogo: null, someOtherThing: { profilePicture: null } }),
+        );
+
+        assert.equal(result.operations.operationName, 'ComplexUpload');
+        assert.deepEqual(result.operations.variables, {
+            name: 'George',
+            sellerLogo: null,
+            someOtherThing: { profilePicture: null },
+        });
+        // `result.map` should map `result.filePaths` onto the Upload fields
+        // implied by `variables`
+        assert.deepEqual(result.map, {
+            0: 'variables.sellerLogo',
+            1: 'variables.someOtherThing.profilePicture',
+        });
+        assert.deepEqual(result.filePaths, [
+            { name: '0', file: 'logo.png' },
+            { name: '1', file: 'profilePicture.jpg' },
+        ]);
     });
 });

+ 80 - 25
packages/testing/src/utils/create-upload-post-data.ts

@@ -1,27 +1,72 @@
 import { DocumentNode, Kind, OperationDefinitionNode, print } from 'graphql';
 
-export interface FilePlaceholder {
-    file: null;
-}
 export interface UploadPostData<V = any> {
+    /**
+     * Data from a GraphQL document that takes the Upload type as input
+     */
     operations: {
         operationName: string;
         variables: V;
         query: string;
     };
 
+    /**
+     * A map from index values to variable paths. Maps files in the `filePaths`
+     * array to fields with the Upload type in the GraphQL mutation input.
+     *
+     * If this was the GraphQL mutation input type:
+     * ```graphql
+     * input ImageReceivingInput {
+     *   bannerImage: Upload!
+     *   logo: Upload!
+     * }
+     * ```
+     *
+     * And this was the GraphQL mutation:
+     * ```graphql
+     * addSellerImages(input: ImageReceivingInput!): Seller
+     * ```
+     *
+     * Then this would be the value for `map`:
+     * ```js
+     * {
+     *   0: 'variables.input.bannerImage',
+     *   1: 'variables.input.logo'
+     * }
+     * ```
+     */
     map: {
         [index: number]: string;
     };
+
+    /**
+     * Array of file paths. Mapped to a GraphQL mutation input variable by
+     * `map`.
+     */
     filePaths: Array<{
+        /**
+         * Index of the file path as a string.
+         */
         name: string;
+        /**
+         * The actual file path
+         */
         file: string;
     }>;
 }
 
 /**
- * Creates a data structure which can be used to mae a curl request to upload files to a mutation using
- * the Upload type.
+ * Creates a data structure which can be used to make a POST request to upload
+ * files to a mutation using the Upload type.
+ *
+ * @param mutation - The GraphQL document for a mutation that takes an Upload
+ * type as an input
+ * @param filePaths - Either a single path or an array of paths to the files
+ * that should be uploaded
+ * @param mapVariables - A function that will receive `filePaths` and return an
+ * object containing the input variables for the mutation, where every field
+ * with the Upload type has the value `null`.
+ * @returns an UploadPostData object.
  */
 export function createUploadPostData<P extends string[] | string, V>(
     mutation: DocumentNode,
@@ -40,9 +85,7 @@ export function createUploadPostData<P extends string[] | string, V>(
             variables,
             query: print(mutation),
         },
-        map: filePathsArray.reduce((output, filePath, i) => {
-            return { ...output, [i.toString()]: objectPath(variables, i).join('.') };
-        }, {} as Record<number, string>),
+        map: objectPath(variables).reduce((acc, path, i) => ({ ...acc, [i.toString()]: path }), {}),
         filePaths: filePathsArray.map((filePath, i) => ({
             name: i.toString(),
             file: filePath,
@@ -51,23 +94,35 @@ export function createUploadPostData<P extends string[] | string, V>(
     return postData;
 }
 
-function objectPath(variables: any, i: number): Array<string | number> {
-    const path: Array<string | number> = ['variables'];
-    let current = variables;
-    while (current !== null) {
-        const props = Object.getOwnPropertyNames(current);
-        if (props) {
-            const firstProp = props[0];
-            const val = current[firstProp];
-            if (Array.isArray(val)) {
-                path.push(firstProp);
-                path.push(i);
-                current = val[0];
-            } else {
-                path.push(firstProp);
-                current = val;
+/**
+ * This function visits each property in the `variables` object, including
+ * nested ones, and returns the path of each null value, in order.
+ *
+ * @example
+ * // variables:
+ * {
+ *   input: {
+ *     name: "George's Pots and Pans",
+ *     logo: null,
+ *     user: {
+ *       profilePicture: null
+ *     }
+ *   }
+ * }
+ * // return value:
+ * ['variables.input.logo', 'variables.input.user.profilePicture']
+ */
+function objectPath(variables: any): string[] {
+    const pathsToNulls: string[] = [];
+    const checkValue = (pathSoFar: string, value: any) => {
+        if (value === null) {
+            pathsToNulls.push(pathSoFar);
+        } else if (typeof value === 'object') {
+            for (const key of Object.getOwnPropertyNames(value)) {
+                checkValue(`${pathSoFar}.${key}`, value[key]);
             }
         }
-    }
-    return path;
+    };
+    checkValue('variables', variables);
+    return pathsToNulls;
 }

+ 18 - 0
packages/testing/vitest.config.mts

@@ -0,0 +1,18 @@
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+    plugins: [
+        // SWC required to support decorators used in test plugins
+        // See https://github.com/vitest-dev/vitest/issues/708#issuecomment-1118628479
+        // Vite plugin
+        swc.vite({
+            jsc: {
+                transform: {
+                    // See https://github.com/vendure-ecommerce/vendure/issues/2099
+                    useDefineForClassFields: false,
+                },
+            },
+        }),
+    ],
+});