Explorar o código

Merge branch 'minor'

Michael Bromley hai 1 ano
pai
achega
bb562a688b
Modificáronse 42 ficheiros con 795 adicións e 757 borrados
  1. 1 0
      docs/docs/guides/extending-the-admin-ui/defining-routes/index.md
  2. 16 0
      docs/docs/guides/extending-the-admin-ui/page-tabs/index.md
  3. 8 4
      docs/docs/reference/admin-ui-api/react-hooks/use-lazy-query.md
  4. 6 2
      docs/docs/reference/admin-ui-api/react-hooks/use-query.md
  5. 1 1
      docs/docs/reference/admin-ui-api/routes/register-route-component.md
  6. 1 1
      docs/docs/reference/admin-ui-api/ui-devkit/compile-ui-extensions.md
  7. 20 0
      docs/docs/reference/admin-ui-api/ui-devkit/ui-extension-build-command.md
  8. 4 4
      docs/docs/reference/admin-ui-api/ui-devkit/ui-extension-compiler-options.md
  9. 2 3
      docs/docs/reference/typescript-api/events/event-types.md
  10. 232 637
      package-lock.json
  11. 4 4
      package.json
  12. 1 1
      packages/admin-ui/package.json
  13. 22 2
      packages/admin-ui/src/lib/core/src/extension/components/route.component.ts
  14. 5 1
      packages/admin-ui/src/lib/core/src/extension/register-route-component.ts
  15. 21 9
      packages/admin-ui/src/lib/react/src/react-hooks/use-query.ts
  16. 13 13
      packages/core/package.json
  17. 1 0
      packages/core/src/api/config/configure-graphql-module.ts
  18. 6 1
      packages/core/src/config/config.module.ts
  19. 2 0
      packages/core/src/config/default-config.ts
  20. 1 0
      packages/core/src/config/index.ts
  21. 53 0
      packages/core/src/config/refund/default-refund-process.ts
  22. 41 0
      packages/core/src/config/refund/refund-process.ts
  23. 9 0
      packages/core/src/config/vendure-config.ts
  24. 1 1
      packages/core/src/event-bus/events/order-line-event.ts
  25. 21 0
      packages/core/src/event-bus/events/refund-event.ts
  26. 1 0
      packages/core/src/event-bus/index.ts
  27. 2 0
      packages/core/src/service/helpers/order-modifier/order-modifier.ts
  28. 70 31
      packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts
  29. 18 16
      packages/core/src/service/helpers/refund-state-machine/refund-state.ts
  30. 41 1
      packages/core/src/service/services/order.service.ts
  31. 15 0
      packages/create/src/gather-user-responses.ts
  32. 15 0
      packages/create/src/helpers.ts
  33. 3 0
      packages/create/templates/vendure-config.hbs
  34. 31 1
      packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts
  35. 4 0
      packages/dev-server/example-plugins/ui-extensions-library/ui/routes.ts
  36. 13 3
      packages/email-plugin/src/handler/event-handler.ts
  37. 51 2
      packages/email-plugin/src/plugin.spec.ts
  38. 8 0
      packages/email-plugin/src/types.ts
  39. 1 1
      packages/testing/package.json
  40. 16 12
      packages/ui-devkit/src/compiler/compile.ts
  41. 10 2
      packages/ui-devkit/src/compiler/types.ts
  42. 4 4
      packages/ui-devkit/src/compiler/utils.ts

+ 1 - 0
docs/docs/guides/extending-the-admin-ui/defining-routes/index.md

@@ -482,6 +482,7 @@ export default [
     registerRouteComponent({
         component: TestComponent,
         title: 'Test',
+        locationId: 'my-location-id'
         // highlight-next-line
         breadcrumb: 'Test',
     }),

+ 16 - 0
docs/docs/guides/extending-the-admin-ui/page-tabs/index.md

@@ -23,6 +23,22 @@ export default [
 
 ![./ui-extensions-tabs.webp](./ui-extensions-tabs.webp)
 
+If you want to add page tabs to a custom admin page, specify the `locationId` property:
+
+```ts title="src/plugins/my-plugin/ui/routes.ts"
+import { registerRouteComponent } from '@vendure/admin-ui/core';
+import { TestComponent } from './components/test/test.component';
+
+export default [
+    registerRouteComponent({
+        component: TestComponent,
+        title: 'Test',
+        // highlight-next-line
+        locationId: 'my-location-id'
+    }),
+];
+```
+
 :::note
 Currently it is only possible to define new tabs using Angular components.
 :::

+ 8 - 4
docs/docs/reference/admin-ui-api/react-hooks/use-lazy-query.md

@@ -11,9 +11,9 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## useLazyQuery
 
-<GenerationInfo sourceFile="packages/admin-ui/src/lib/react/src/react-hooks/use-query.ts" sourceLine="113" packageName="@vendure/admin-ui" since="2.2.0" />
+<GenerationInfo sourceFile="packages/admin-ui/src/lib/react/src/react-hooks/use-query.ts" sourceLine="121" packageName="@vendure/admin-ui" since="2.2.0" />
 
-A React hook which allows you to execute a GraphQL query.
+A React hook which allows you to execute a GraphQL query lazily.
 
 *Example*
 
@@ -37,7 +37,7 @@ type ProductResponse = {
 }
 
 export const MyComponent = () => {
-    const [getProduct, { data, loading, error }] = useLazyQuery<ProductResponse>(GET_PRODUCT);
+    const [getProduct, { data, loading, error }] = useLazyQuery<ProductResponse>(GET_PRODUCT, { refetchOnChannelChange: true });
 
    const handleClick = () => {
         getProduct({
@@ -64,7 +64,7 @@ export const MyComponent = () => {
 ```
 
 ```ts title="Signature"
-function useLazyQuery<T, V extends Record<string, any> = Record<string, any>>(query: DocumentNode | TypedDocumentNode<T, V>): void
+function useLazyQuery<T, V extends Record<string, any> = Record<string, any>>(query: DocumentNode | TypedDocumentNode<T, V>, options: {refetchOnChannelChange: boolean } = {refetchOnChannelChange: false}): void
 ```
 Parameters
 
@@ -72,3 +72,7 @@ Parameters
 
 <MemberInfo kind="parameter" type={`DocumentNode | TypedDocumentNode&#60;T, V&#62;`} />
 
+### options
+
+<MemberInfo kind="parameter" type={`{refetchOnChannelChange: boolean }`} />
+

+ 6 - 2
docs/docs/reference/admin-ui-api/react-hooks/use-query.md

@@ -31,7 +31,7 @@ const GET_PRODUCT = gql`
    }`;
 
 export const MyComponent = () => {
-    const { data, loading, error } = useQuery(GET_PRODUCT, { id: '1' });
+    const { data, loading, error } = useQuery(GET_PRODUCT, { id: '1' }, { refetchOnChannelChange: true });
 
     if (loading) return <div>Loading...</div>;
     if (error) return <div>Error! { error }</div>;
@@ -45,7 +45,7 @@ export const MyComponent = () => {
 ```
 
 ```ts title="Signature"
-function useQuery<T, V extends Record<string, any> = Record<string, any>>(query: DocumentNode | TypedDocumentNode<T, V>, variables?: V): void
+function useQuery<T, V extends Record<string, any> = Record<string, any>>(query: DocumentNode | TypedDocumentNode<T, V>, variables?: V, options: { refetchOnChannelChange: boolean } = { refetchOnChannelChange: false }): void
 ```
 Parameters
 
@@ -57,3 +57,7 @@ Parameters
 
 <MemberInfo kind="parameter" type={`V`} />
 
+### options
+
+<MemberInfo kind="parameter" type={`{ refetchOnChannelChange: boolean }`} />
+

+ 1 - 1
docs/docs/reference/admin-ui-api/routes/register-route-component.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## registerRouteComponent
 
-<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/extension/register-route-component.ts" sourceLine="77" packageName="@vendure/admin-ui" />
+<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/extension/register-route-component.ts" sourceLine="79" packageName="@vendure/admin-ui" />
 
 Registers an Angular standalone component to be rendered in a route.
 

+ 1 - 1
docs/docs/reference/admin-ui-api/ui-devkit/compile-ui-extensions.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## compileUiExtensions
 
-<GenerationInfo sourceFile="packages/ui-devkit/src/compiler/compile.ts" sourceLine="35" packageName="@vendure/ui-devkit" />
+<GenerationInfo sourceFile="packages/ui-devkit/src/compiler/compile.ts" sourceLine="36" packageName="@vendure/ui-devkit" />
 
 Compiles the Admin UI app with the specified extensions.
 

+ 20 - 0
docs/docs/reference/admin-ui-api/ui-devkit/ui-extension-build-command.md

@@ -0,0 +1,20 @@
+---
+title: "UiExtensionBuildCommand"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## UiExtensionBuildCommand
+
+<GenerationInfo sourceFile="packages/ui-devkit/src/compiler/types.ts" sourceLine="356" packageName="@vendure/ui-devkit" />
+
+The package manager to use when invoking the Angular CLI to build UI extensions.
+
+```ts title="Signature"
+type UiExtensionBuildCommand = 'npm' | 'yarn' | 'pnpm'
+```

+ 4 - 4
docs/docs/reference/admin-ui-api/ui-devkit/ui-extension-compiler-options.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## UiExtensionCompilerOptions
 
-<GenerationInfo sourceFile="packages/ui-devkit/src/compiler/types.ts" sourceLine="356" packageName="@vendure/ui-devkit" />
+<GenerationInfo sourceFile="packages/ui-devkit/src/compiler/types.ts" sourceLine="364" packageName="@vendure/ui-devkit" />
 
 Options to configure how the Admin UI should be compiled.
 
@@ -23,7 +23,7 @@ interface UiExtensionCompilerOptions {
     devMode?: boolean;
     baseHref?: string;
     watchPort?: number;
-    command?: 'yarn' | 'npm';
+    command?: UiExtensionBuildCommand;
     additionalProcessArguments?: UiExtensionCompilerProcessArgument[];
 }
 ```
@@ -102,11 +102,11 @@ In watch mode, allows the port of the dev server to be specified. Defaults to th
 of `4200`.
 ### command
 
-<MemberInfo kind="property" type={`'yarn' | 'npm'`}  since="1.5.0"  />
+<MemberInfo kind="property" type={`<a href='/reference/admin-ui-api/ui-devkit/ui-extension-build-command#uiextensionbuildcommand'>UiExtensionBuildCommand</a>`}  since="1.5.0"  />
 
 Internally, the Angular CLI will be invoked as an npm script. By default, the compiler will use Yarn
 to run the script if it is detected, otherwise it will use npm. This setting allows you to explicitly
-set which command to use, rather than relying on the default behavior.
+set which command to use, including pnpm, rather than relying on the default behavior.
 ### additionalProcessArguments
 
 <MemberInfo kind="property" type={`<a href='/reference/admin-ui-api/ui-devkit/ui-extension-compiler-process-argument#uiextensioncompilerprocessargument'>UiExtensionCompilerProcessArgument</a>[]`} default="undefined"  since="1.5.0"  />

+ 2 - 3
docs/docs/reference/typescript-api/events/event-types.md

@@ -811,12 +811,11 @@ class OrderEvent extends VendureEvent {
 
 <GenerationInfo sourceFile="packages/core/src/event-bus/events/order-line-event.ts" sourceLine="13" packageName="@vendure/core" />
 
-This event is fired whenever an <a href='/reference/typescript-api/entities/order-line#orderline'>OrderLine</a> is added, updated
-or deleted.
+This event is fired whenever an <a href='/reference/typescript-api/entities/order-line#orderline'>OrderLine</a> is added, updated, deleted or cancelled.
 
 ```ts title="Signature"
 class OrderLineEvent extends VendureEvent {
-    constructor(ctx: RequestContext, order: Order, orderLine: OrderLine, type: 'created' | 'updated' | 'deleted')
+    constructor(ctx: RequestContext, order: Order, orderLine: OrderLine, type: 'created' | 'updated' | 'deleted' | 'cancelled')
 }
 ```
 * Extends: <code><a href='/reference/typescript-api/events/vendure-event#vendureevent'>VendureEvent</a></code>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 232 - 637
package-lock.json


+ 4 - 4
package.json

@@ -34,9 +34,9 @@
     "@graphql-codegen/cli": "5.0.2",
     "@graphql-codegen/fragment-matcher": "5.0.2",
     "@graphql-codegen/typed-document-node": "^5.0.6",
-    "@graphql-codegen/typescript": "4.0.6",
-    "@graphql-codegen/typescript-operations": "4.2.0",
-    "@graphql-tools/schema": "^10.0.3",
+    "@graphql-codegen/typescript": "4.0.9",
+    "@graphql-codegen/typescript-operations": "4.2.3",
+    "@graphql-tools/schema": "^10.0.4",
     "@swc/core": "^1.4.6",
     "@types/klaw-sync": "^6.0.5",
     "@types/node": "^20.11.19",
@@ -44,7 +44,7 @@
     "conventional-changelog-core": "^7.0.0",
     "cross-env": "^7.0.3",
     "find": "^0.3.0",
-    "graphql": "16.8.1",
+    "graphql": "~16.9.0",
     "husky": "^4.3.0",
     "klaw-sync": "^6.0.0",
     "lerna": "^8.1.2",

+ 1 - 1
packages/admin-ui/package.json

@@ -56,7 +56,7 @@
         "chartist": "^1.3.0",
         "codejar": "^4.2.0",
         "dayjs": "^1.11.10",
-        "graphql": "16.8.1",
+        "graphql": "~16.9.0",
         "just-extend": "^6.2.0",
         "messageformat": "2.3.0",
         "ngx-pagination": "^6.0.3",

+ 22 - 2
packages/admin-ui/src/lib/core/src/extension/components/route.component.ts

@@ -1,4 +1,4 @@
-import { Component, inject, InjectionToken } from '@angular/core';
+import { Component, inject, InjectionToken, Input } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { combineLatest, Observable, of, switchMap } from 'rxjs';
@@ -7,6 +7,9 @@ import { BreadcrumbValue } from '../../providers/breadcrumb/breadcrumb.service';
 import { SharedModule } from '../../shared/shared.module';
 import { PageMetadataService } from '../providers/page-metadata.service';
 import { AngularRouteComponentOptions } from '../types';
+import { HeaderTab } from '../../shared/components/page-header-tabs/page-header-tabs.component';
+import { PageService } from '../../providers/page/page.service';
+import { PageLocationId } from '../../common/component-registry-types';
 
 export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<AngularRouteComponentOptions>(
     'ROUTE_COMPONENT_OPTIONS',
@@ -17,6 +20,8 @@ export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<AngularRouteComponentO
     template: `
         <vdr-page-header>
             <vdr-page-title *ngIf="title$ | async as title" [title]="title"></vdr-page-title>
+            <vdr-page-header-description *ngIf="description">{{ description }}</vdr-page-header-description>
+            <vdr-page-header-tabs *ngIf="headerTabs.length > 1" [tabs]="headerTabs"></vdr-page-header-tabs>
         </vdr-page-header>
         <vdr-page-body><ng-content /></vdr-page-body>
     `,
@@ -26,8 +31,14 @@ export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<AngularRouteComponentO
 })
 export class RouteComponent {
     protected title$: Observable<string | undefined>;
+    @Input() protected locationId: PageLocationId;
+    @Input() protected description: string;
+    headerTabs: HeaderTab[] = [];
 
-    constructor(private route: ActivatedRoute) {
+    constructor(
+        private route: ActivatedRoute,
+        private pageService: PageService,
+    ) {
         const breadcrumbLabel$ = this.route.data.pipe(
             switchMap(data => {
                 if (data.breadcrumb instanceof Observable) {
@@ -53,5 +64,14 @@ export class RouteComponent {
         this.title$ = combineLatest([inject(ROUTE_COMPONENT_OPTIONS).title$, breadcrumbLabel$]).pipe(
             map(([title, breadcrumbLabel]) => title ?? breadcrumbLabel),
         );
+
+        this.locationId = this.route.snapshot.data.locationId;
+        this.description = this.route.snapshot.data.description;
+        this.headerTabs = this.pageService.getPageTabs(this.locationId).map(tab => ({
+            id: tab.tab,
+            label: tab.tab,
+            icon: tab.tabIcon,
+            route: tab.route ? [tab.route] : ['./'],
+        }));
     }
 }

+ 5 - 1
packages/admin-ui/src/lib/core/src/extension/register-route-component.ts

@@ -25,6 +25,8 @@ export type RegisterRouteComponentOptions<
 > = {
     component: Type<Component> | Component;
     title?: string;
+    locationId?: string;
+    description?: string;
     breadcrumb?: BreadcrumbValue;
     path?: string;
     query?: T;
@@ -81,7 +83,7 @@ export function registerRouteComponent<
     Field extends keyof ResultOf<T>,
     R extends Field,
 >(options: RegisterRouteComponentOptions<Component, Entity, T, Field, R>) {
-    const { query, entityKey, variables, getBreadcrumbs } = options;
+    const { query, entityKey, variables, getBreadcrumbs, locationId, description } = options;
 
     const breadcrumbSubject$ = new BehaviorSubject<BreadcrumbValue>(options.breadcrumb ?? '');
     const titleSubject$ = new BehaviorSubject<string | undefined>(options.title);
@@ -129,6 +131,8 @@ export function registerRouteComponent<
         ...(options.routeConfig ?? {}),
         resolve: { ...(resolveFn ? { detail: resolveFn } : {}), ...(options.routeConfig?.resolve ?? {}) },
         data: {
+            locationId,
+            description,
             breadcrumb: breadcrumbSubject$,
             ...(options.routeConfig?.data ?? {}),
             ...(getBreadcrumbs && query && entityKey

+ 21 - 9
packages/admin-ui/src/lib/react/src/react-hooks/use-query.ts

@@ -25,7 +25,7 @@ import { HostedComponentContext } from '../directives/react-component-host.direc
  *    }`;
  *
  * export const MyComponent = () => {
- *     const { data, loading, error } = useQuery(GET_PRODUCT, { id: '1' });
+ *     const { data, loading, error } = useQuery(GET_PRODUCT, { id: '1' }, { refetchOnChannelChange: true });
  *
  *     if (loading) return <div>Loading...</div>;
  *     if (error) return <div>Error! { error }</div>;
@@ -43,10 +43,16 @@ import { HostedComponentContext } from '../directives/react-component-host.direc
 export function useQuery<T, V extends Record<string, any> = Record<string, any>>(
     query: DocumentNode | TypedDocumentNode<T, V>,
     variables?: V,
+    options: { refetchOnChannelChange: boolean } = { refetchOnChannelChange: false },
 ) {
-    const { data, loading, error, runQuery } = useDataService<T, V>(
-        (dataService, vars) => dataService.query(query, vars).stream$,
-    );
+    const { refetchOnChannelChange } = options;
+    const { data, loading, error, runQuery } = useDataService<T, V>((dataService, vars) => {
+        let queryFn = dataService.query(query, vars);
+        if (refetchOnChannelChange) {
+            queryFn = queryFn.refetchOnChannelChange();
+        }
+        return queryFn.stream$;
+    });
     useEffect(() => {
         const subscription = runQuery(variables).subscribe();
         return () => subscription.unsubscribe();
@@ -58,7 +64,7 @@ export function useQuery<T, V extends Record<string, any> = Record<string, any>>
 
 /**
  * @description
- * A React hook which allows you to execute a GraphQL query.
+ * A React hook which allows you to execute a GraphQL query lazily.
  *
  * @example
  * ```ts
@@ -81,7 +87,7 @@ export function useQuery<T, V extends Record<string, any> = Record<string, any>>
  * }
  *
  * export const MyComponent = () => {
- *     const [getProduct, { data, loading, error }] = useLazyQuery<ProductResponse>(GET_PRODUCT);
+ *     const [getProduct, { data, loading, error }] = useLazyQuery<ProductResponse>(GET_PRODUCT, { refetchOnChannelChange: true });
  *
  *    const handleClick = () => {
  *         getProduct({
@@ -112,10 +118,16 @@ export function useQuery<T, V extends Record<string, any> = Record<string, any>>
  */
 export function useLazyQuery<T, V extends Record<string, any> = Record<string, any>>(
     query: DocumentNode | TypedDocumentNode<T, V>,
+    options: { refetchOnChannelChange: boolean } = { refetchOnChannelChange: false },
 ) {
-    const { data, loading, error, runQuery } = useDataService<T, V>(
-        (dataService, vars) => dataService.query(query, vars).stream$,
-    );
+    const { refetchOnChannelChange } = options;
+    const { data, loading, error, runQuery } = useDataService<T, V>((dataService, vars) => {
+        let queryFn = dataService.query(query, vars);
+        if (refetchOnChannelChange) {
+            queryFn = queryFn.refetchOnChannelChange();
+        }
+        return queryFn.stream$;
+    });
     const rest = { data, loading, error };
     const execute = (variables?: V) => firstValueFrom(runQuery(variables));
     return [execute, rest] as [typeof execute, typeof rest];

+ 13 - 13
packages/core/package.json

@@ -40,16 +40,16 @@
         "cli/**/*"
     ],
     "dependencies": {
-        "@apollo/server": "^4.10.1",
-        "@graphql-tools/stitch": "^9.0.5",
-        "@nestjs/apollo": "^12.1.0",
-        "@nestjs/common": "10.3.3",
-        "@nestjs/core": "10.3.3",
-        "@nestjs/graphql": "12.1.1",
-        "@nestjs/platform-express": "10.3.3",
-        "@nestjs/terminus": "10.2.3",
-        "@nestjs/testing": "10.3.3",
-        "@nestjs/typeorm": "10.0.2",
+        "@apollo/server": "^4.10.4",
+        "@graphql-tools/stitch": "^9.2.10",
+        "@nestjs/apollo": "~12.2.0",
+        "@nestjs/common": "~10.3.10",
+        "@nestjs/core": "~10.3.10",
+        "@nestjs/graphql": "~12.2.0",
+        "@nestjs/platform-express": "~10.3.10",
+        "@nestjs/terminus": "~10.2.3",
+        "@nestjs/testing": "~10.3.10",
+        "@nestjs/typeorm": "~10.0.2",
         "@types/fs-extra": "^9.0.1",
         "@vendure/common": "^2.2.7",
         "bcrypt": "^5.1.1",
@@ -58,13 +58,13 @@
         "csv-parse": "^5.5.5",
         "express": "^4.18.3",
         "fs-extra": "^11.2.0",
-        "graphql": "16.8.1",
+        "graphql": "~16.9.0",
         "graphql-fields": "^2.0.3",
         "graphql-scalars": "^1.22.5",
         "graphql-tag": "^2.12.6",
         "graphql-upload": "^16.0.2",
         "http-proxy-middleware": "^2.0.6",
-        "i18next": "^23.10.1",
+        "i18next": "^23.12.1",
         "i18next-fs-backend": "^2.3.1",
         "i18next-http-middleware": "^3.5.0",
         "i18next-icu": "^2.3.0",
@@ -75,7 +75,7 @@
         "nanoid": "^3.3.7",
         "picocolors": "^1.0.0",
         "progress": "^2.0.3",
-        "reflect-metadata": "^0.2.1",
+        "reflect-metadata": "^0.2.2",
         "rxjs": "^7.8.1",
         "semver": "^7.6.0",
         "typeorm": "0.3.20"

+ 1 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -100,6 +100,7 @@ async function createGraphQLOptions(
         path: '/' + options.apiPath,
         typeDefs: printSchema(builtSchema),
         include: [options.resolverModule],
+        inheritResolversFromInterfaces: true,
         fieldResolverEnhancers: ['guards'],
         resolvers,
         // We no longer rely on the upload facility bundled with Apollo Server, and instead

+ 6 - 1
packages/core/src/config/config.module.ts

@@ -14,7 +14,10 @@ import { ConfigService } from './config.service';
     exports: [ConfigService],
 })
 export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdown {
-    constructor(private configService: ConfigService, private moduleRef: ModuleRef) {}
+    constructor(
+        private configService: ConfigService,
+        private moduleRef: ModuleRef,
+    ) {}
 
     async onApplicationBootstrap() {
         await this.initInjectableStrategies();
@@ -106,6 +109,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
         const { entityIdStrategy } = this.configService.entityOptions;
         const { healthChecks, errorHandlers } = this.configService.systemOptions;
         const { assetImportStrategy } = this.configService.importExportOptions;
+        const { refundProcess: refundProcess } = this.configService.paymentOptions;
         return [
             ...adminAuthenticationStrategy,
             ...shopAuthenticationStrategy,
@@ -145,6 +149,7 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             stockLocationStrategy,
             productVariantPriceSelectionStrategy,
             guestCheckoutStrategy,
+            ...refundProcess,
         ];
     }
 

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -43,6 +43,7 @@ import { DefaultOrderCodeStrategy } from './order/order-code-strategy';
 import { UseGuestStrategy } from './order/use-guest-strategy';
 import { defaultPaymentProcess } from './payment/default-payment-process';
 import { defaultPromotionActions, defaultPromotionConditions } from './promotion';
+import { defaultRefundProcess } from './refund/default-refund-process';
 import { InMemorySessionCacheStrategy } from './session-cache/in-memory-session-cache-strategy';
 import { defaultShippingCalculator } from './shipping-method/default-shipping-calculator';
 import { defaultShippingEligibilityChecker } from './shipping-method/default-shipping-eligibility-checker';
@@ -170,6 +171,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         paymentMethodHandlers: [],
         customPaymentProcess: [],
         process: [defaultPaymentProcess],
+        refundProcess: [defaultRefundProcess],
     },
     taxOptions: {
         taxZoneStrategy: new DefaultTaxZoneStrategy(),

+ 1 - 0
packages/core/src/config/index.ts

@@ -89,3 +89,4 @@ export * from './tax/default-tax-zone-strategy';
 export * from './tax/tax-line-calculation-strategy';
 export * from './tax/tax-zone-strategy';
 export * from './vendure-config';
+export * from './refund/default-refund-process';

+ 53 - 0
packages/core/src/config/refund/default-refund-process.ts

@@ -0,0 +1,53 @@
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
+
+import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
+
+import { RefundProcess } from './refund-process';
+
+let configService: import('../config.service').ConfigService;
+let historyService: import('../../service/index').HistoryService;
+
+/**
+ * @description
+ * The default {@link RefundProcess}.
+ *
+ * @docsCategory refund
+ */
+export const defaultRefundProcess: RefundProcess<RefundState> = {
+    transitions: {
+        Pending: {
+            to: ['Settled', 'Failed'],
+        },
+        Settled: {
+            to: [],
+        },
+        Failed: {
+            to: [],
+        },
+    },
+    async init(injector) {
+        const ConfigService = await import('../config.service.js').then(m => m.ConfigService);
+        const HistoryService = await import('../../service/index.js').then(m => m.HistoryService);
+        configService = injector.get(ConfigService);
+        historyService = injector.get(HistoryService);
+    },
+    onTransitionStart: async (fromState, toState, data) => {
+        return true;
+    },
+    onTransitionEnd: async (fromState, toState, data) => {
+        if (!historyService) {
+            throw new Error('HistoryService has not been initialized');
+        }
+        await historyService.createHistoryEntryForOrder({
+            ctx: data.ctx,
+            orderId: data.order.id,
+            type: HistoryEntryType.ORDER_REFUND_TRANSITION,
+            data: {
+                refundId: data.refund.id,
+                from: fromState,
+                to: toState,
+                reason: data.refund.reason,
+            },
+        });
+    },
+};

+ 41 - 0
packages/core/src/config/refund/refund-process.ts

@@ -0,0 +1,41 @@
+import {
+    OnTransitionEndFn,
+    OnTransitionErrorFn,
+    OnTransitionStartFn,
+    Transitions,
+} from '../../common/finite-state-machine/types';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import {
+    CustomRefundStates,
+    RefundState,
+    RefundTransitionData,
+} from '../../service/helpers/refund-state-machine/refund-state';
+
+/**
+ * @description
+ * A RefundProcess is used to define the way the refund process works as in: what states a Refund can be
+ * in, and how it may transition from one state to another. Using the `onTransitionStart()` hook, a
+ * RefundProcess can perform checks before allowing a state transition to occur, and the `onTransitionEnd()`
+ * hook allows logic to be executed after a state change.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @docsCategory refund
+ */
+export interface RefundProcess<State extends keyof CustomRefundStates | string> extends InjectableStrategy {
+    transitions?: Transitions<State, State | RefundState> & Partial<Transitions<RefundState | State>>;
+    onTransitionStart?: OnTransitionStartFn<State | RefundState, RefundTransitionData>;
+    onTransitionEnd?: OnTransitionEndFn<State | RefundState, RefundTransitionData>;
+    onTransitionError?: OnTransitionErrorFn<State | RefundState>;
+}
+
+/**
+ * @description
+ * Used to define extensions to or modifications of the default refund process.
+ *
+ * For detailed description of the interface members, see the {@link StateMachineConfig} docs.
+ *
+ * @deprecated Use RefundProcess
+ */
+export interface CustomRefundProcess<State extends keyof CustomRefundStates | string>
+    extends RefundProcess<State> {}

+ 9 - 0
packages/core/src/config/vendure-config.ts

@@ -48,6 +48,7 @@ import { PaymentMethodHandler } from './payment/payment-method-handler';
 import { PaymentProcess } from './payment/payment-process';
 import { PromotionAction } from './promotion/promotion-action';
 import { PromotionCondition } from './promotion/promotion-condition';
+import { RefundProcess } from './refund/refund-process';
 import { SessionCacheStrategy } from './session-cache/session-cache-strategy';
 import { ShippingCalculator } from './shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from './shipping-method/shipping-eligibility-checker';
@@ -848,6 +849,14 @@ export interface PaymentOptions {
      * @since 2.0.0
      */
     process?: Array<PaymentProcess<any>>;
+    /**
+     * @description
+     * Allows the definition of custom states and transition logic for the refund process state machine.
+     * Takes an array of objects implementing the {@link RefundProcess} interface.
+     *
+     * @default defaultRefundProcess
+     */
+    refundProcess?: Array<RefundProcess<any>>;
 }
 
 /**

+ 1 - 1
packages/core/src/event-bus/events/order-line-event.ts

@@ -15,7 +15,7 @@ export class OrderLineEvent extends VendureEvent {
         public ctx: RequestContext,
         public order: Order,
         public orderLine: OrderLine,
-        public type: 'created' | 'updated' | 'deleted',
+        public type: 'created' | 'updated' | 'deleted' | 'cancelled',
     ) {
         super();
     }

+ 21 - 0
packages/core/src/event-bus/events/refund-event.ts

@@ -0,0 +1,21 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Order, Refund } from '../../entity';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever a {@link Refund} is created
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ */
+export class RefundEvent extends VendureEvent {
+    constructor(
+        public ctx: RequestContext,
+        public order: Order,
+        public refund: Refund,
+        public type: 'created',
+    ) {
+        super();
+    }
+}

+ 1 - 0
packages/core/src/event-bus/index.ts

@@ -47,6 +47,7 @@ export * from './events/product-variant-channel-event';
 export * from './events/product-variant-event';
 export * from './events/product-variant-price-event';
 export * from './events/promotion-event';
+export * from './events/refund-event';
 export * from './events/refund-state-transition-event';
 export * from './events/role-change-event';
 export * from './events/role-event';

+ 2 - 0
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -341,6 +341,8 @@ export class OrderModifier {
                 await this.connection.getRepository(ctx, OrderLine).update(line.orderLineId, {
                     quantity: orderLine.quantity - line.quantity,
                 });
+
+                await this.eventBus.publish(new OrderLineEvent(ctx, order, orderLine, 'cancelled'));
             }
         }
 

+ 70 - 31
packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts

@@ -1,46 +1,31 @@
 import { Injectable } from '@nestjs/common';
-import { HistoryEntryType } from '@vendure/common/lib/generated-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { IllegalOperationError } from '../../../common/error/errors';
 import { FSM } from '../../../common/finite-state-machine/finite-state-machine';
-import { StateMachineConfig } from '../../../common/finite-state-machine/types';
+import { mergeTransitionDefinitions } from '../../../common/finite-state-machine/merge-transition-definitions';
+import { StateMachineConfig, Transitions } from '../../../common/finite-state-machine/types';
+import { validateTransitionDefinition } from '../../../common/finite-state-machine/validate-transition-definition';
+import { awaitPromiseOrObservable } from '../../../common/utils';
 import { ConfigService } from '../../../config/config.service';
+import { Logger } from '../../../config/logger/vendure-logger';
 import { Order } from '../../../entity/order/order.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
-import { HistoryService } from '../../services/history.service';
 
-import { RefundState, refundStateTransitions, RefundTransitionData } from './refund-state';
+import { RefundState, RefundTransitionData } from './refund-state';
 
 @Injectable()
 export class RefundStateMachine {
-    private readonly config: StateMachineConfig<RefundState, RefundTransitionData> = {
-        transitions: refundStateTransitions,
-        onTransitionStart: async (fromState, toState, data) => {
-            return true;
-        },
-        onTransitionEnd: async (fromState, toState, data) => {
-            await this.historyService.createHistoryEntryForOrder({
-                ctx: data.ctx,
-                orderId: data.order.id,
-                type: HistoryEntryType.ORDER_REFUND_TRANSITION,
-                data: {
-                    refundId: data.refund.id,
-                    from: fromState,
-                    to: toState,
-                    reason: data.refund.reason,
-                },
-            });
-        },
-        onError: (fromState, toState, message) => {
-            throw new IllegalOperationError(message || 'error.cannot-transition-refund-from-to', {
-                fromState,
-                toState,
-            });
-        },
-    };
-
-    constructor(private configService: ConfigService, private historyService: HistoryService) {}
+    private readonly config: StateMachineConfig<RefundState, RefundTransitionData>;
+    private readonly initialState: RefundState = 'Pending';
+
+    constructor(private configService: ConfigService) {
+        this.config = this.initConfig();
+    }
+
+    getInitialState(): RefundState {
+        return this.initialState;
+    }
 
     getNextStates(refund: Refund): readonly RefundState[] {
         const fsm = new FSM(this.config, refund.state);
@@ -53,4 +38,58 @@ export class RefundStateMachine {
         refund.state = state;
         return result;
     }
+
+    private initConfig(): StateMachineConfig<RefundState, RefundTransitionData> {
+        const processes = [...(this.configService.paymentOptions.refundProcess ?? [])];
+        const allTransitions = processes.reduce(
+            (transitions, process) =>
+                mergeTransitionDefinitions(transitions, process.transitions as Transitions<any>),
+            {} as Transitions<RefundState>,
+        );
+
+        const validationResult = validateTransitionDefinition(allTransitions, this.initialState);
+        if (!validationResult.valid && validationResult.error) {
+            Logger.error(`The refund process has an invalid configuration:`);
+            throw new Error(validationResult.error);
+        }
+        if (validationResult.valid && validationResult.error) {
+            Logger.warn(`Refund process: ${validationResult.error}`);
+        }
+
+        return {
+            transitions: allTransitions,
+            onTransitionStart: async (fromState, toState, data) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionStart === 'function') {
+                        const result = await awaitPromiseOrObservable(
+                            process.onTransitionStart(fromState, toState, data),
+                        );
+                        if (result === false || typeof result === 'string') {
+                            return result;
+                        }
+                    }
+                }
+            },
+            onTransitionEnd: async (fromState, toState, data) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionEnd === 'function') {
+                        await awaitPromiseOrObservable(process.onTransitionEnd(fromState, toState, data));
+                    }
+                }
+            },
+            onError: async (fromState, toState, message) => {
+                for (const process of processes) {
+                    if (typeof process.onTransitionError === 'function') {
+                        await awaitPromiseOrObservable(
+                            process.onTransitionError(fromState, toState, message),
+                        );
+                    }
+                }
+                throw new IllegalOperationError(message || 'error.cannot-transition-refund-from-to', {
+                    fromState,
+                    toState,
+                });
+            },
+        };
+    }
 }

+ 18 - 16
packages/core/src/service/helpers/refund-state-machine/refund-state.ts

@@ -1,28 +1,30 @@
 import { RequestContext } from '../../../api/common/request-context';
-import { Transitions } from '../../../common/finite-state-machine/types';
 import { Order } from '../../../entity/order/order.entity';
-import { Payment } from '../../../entity/payment/payment.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
 
 /**
  * @description
- * These are the default states of the refund process.
+ * An interface to extend standard {@link RefundState}.
  *
- * @docsCategory payment
+ * @deprecated use RefundStates
+ */
+export interface CustomRefundStates {}
+
+/**
+ * @description
+ * An interface to extend standard {@link RefundState}.
+ *
+ * @docsCategory refund
  */
-export type RefundState = 'Pending' | 'Settled' | 'Failed';
+export interface RefundStates {}
 
-export const refundStateTransitions: Transitions<RefundState> = {
-    Pending: {
-        to: ['Settled', 'Failed'],
-    },
-    Settled: {
-        to: [],
-    },
-    Failed: {
-        to: [],
-    },
-};
+/**
+ * @description
+ * These are the default states of the refund process.
+ *
+ * @docsCategory refund
+ */
+export type RefundState = 'Pending' | 'Settled' | 'Failed' | keyof CustomRefundStates | keyof RefundStates;
 
 /**
  * @description

+ 41 - 1
packages/core/src/service/services/order.service.ts

@@ -53,6 +53,7 @@ import {
     CancelPaymentError,
     EmptyOrderLineSelectionError,
     FulfillmentStateTransitionError,
+    RefundStateTransitionError,
     InsufficientStockOnHandError,
     ItemsAlreadyFulfilledError,
     ManualPaymentStateError,
@@ -98,6 +99,7 @@ import { CouponCodeEvent } from '../../event-bus/events/coupon-code-event';
 import { OrderEvent } from '../../event-bus/events/order-event';
 import { OrderLineEvent } from '../../event-bus/events/order-line-event';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
+import { RefundEvent } from '../../event-bus/events/refund-event';
 import { RefundStateTransitionEvent } from '../../event-bus/events/refund-state-transition-event';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { FulfillmentState } from '../helpers/fulfillment-state-machine/fulfillment-state';
@@ -108,6 +110,7 @@ import { OrderModifier } from '../helpers/order-modifier/order-modifier';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { PaymentState } from '../helpers/payment-state-machine/payment-state';
+import { RefundState } from '../helpers/refund-state-machine/refund-state';
 import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import { TranslatorService } from '../helpers/translator/translator.service';
@@ -987,6 +990,38 @@ export class OrderService {
         return result.fulfillment;
     }
 
+    /**
+     * @description
+     * Transitions a Refund to the given state
+     */
+    async transitionRefundToState(
+        ctx: RequestContext,
+        refundId: ID,
+        state: RefundState,
+        transactionId?: string,
+    ): Promise<Refund | RefundStateTransitionError> {
+        const refund = await this.connection.getEntityOrThrow(ctx, Refund, refundId, {
+            relations: ['payment', 'payment.order'],
+        });
+        if (transactionId && refund.transactionId !== transactionId) {
+            refund.transactionId = transactionId;
+        }
+        const fromState = refund.state;
+        const toState = state;
+        const { finalize } = await this.refundStateMachine.transition(
+            ctx,
+            refund.payment.order,
+            refund,
+            toState,
+        );
+        await this.connection.getRepository(ctx, Refund).save(refund);
+        await finalize();
+        await this.eventBus.publish(
+            new RefundStateTransitionEvent(fromState, toState, ctx, refund, refund.payment.order),
+        );
+        return refund;
+    }
+
     /**
      * @description
      * Allows the Order to be modified, which allows several aspects of the Order to be changed:
@@ -1419,7 +1454,12 @@ export class OrderService {
             return new RefundOrderStateError({ orderState: order.state });
         }
 
-        return await this.paymentService.createRefund(ctx, input, order, payment);
+        const createdRefund = await this.paymentService.createRefund(ctx, input, order, payment);
+
+        if (createdRefund instanceof Refund) {
+            await this.eventBus.publish(new RefundEvent(ctx, order, createdRefund, 'created'));
+        }
+        return createdRefund;
     }
 
     /**

+ 15 - 0
packages/create/src/gather-user-responses.ts

@@ -14,6 +14,7 @@ interface PromptAnswers {
     dbSchema?: string | symbol;
     dbUserName: string | symbol;
     dbPassword: string | symbol;
+    dbSSL?: boolean | symbol;
     superadminIdentifier: string | symbol;
     superadminPassword: string | symbol;
     populateProducts: boolean | symbol;
@@ -72,6 +73,19 @@ export async function gatherUserResponses(
               })
             : '';
     checkCancel(dbSchema);
+    const dbSSL =
+        dbType === 'postgres'
+            ? await select({
+                  message:
+                      'Use SSL to connect to the database? (only enable if you database provider supports SSL)',
+                  options: [
+                      { label: 'no', value: false },
+                      { label: 'yes', value: true },
+                  ],
+                  initialValue: false,
+              })
+            : false;
+    checkCancel(dbSSL);
     const dbUserName = hasConnection
         ? await text({
               message: "What's the database user name?",
@@ -113,6 +127,7 @@ export async function gatherUserResponses(
         dbSchema,
         dbUserName,
         dbPassword,
+        dbSSL,
         superadminIdentifier,
         superadminPassword,
         populateProducts,

+ 15 - 0
packages/create/src/helpers.ts

@@ -352,6 +352,7 @@ async function checkPostgresDbExists(options: any, root: string): Promise<true>
         port: options.port,
         database: options.database,
         schema: options.schema,
+        ssl: options.ssl,
     };
     const client = new Client(connectionOptions);
 
@@ -371,6 +372,8 @@ async function checkPostgresDbExists(options: any, root: string): Promise<true>
             throwDatabaseDoesNotExist(options.database);
         } else if (e.message === 'NO_SCHEMA') {
             throwDatabaseSchemaDoesNotExist(options.database, options.schema);
+        } else if (e.code === '28000') {
+            throwSSLConnectionError(e, options.ssl);
         }
         throwConnectionError(e);
         await client.end();
@@ -389,6 +392,18 @@ function throwConnectionError(err: any) {
     );
 }
 
+function throwSSLConnectionError(err: any, sslEnabled?: any) {
+    throw new Error(
+        'Could not connect to the database. ' +
+            (sslEnabled === undefined
+                ? 'Is your server requiring an SSL connection?'
+                : 'Are you sure your server supports SSL?') +
+            `Please check the connection settings in your Vendure config.\n[${
+                (err.message || err.toString()) as string
+            }]`,
+    );
+}
+
 function throwDatabaseDoesNotExist(name: string) {
     throw new Error(`Database "${name}" does not exist. Please create the database and then try again.`);
 }

+ 3 - 0
packages/create/templates/vendure-config.hbs

@@ -52,6 +52,9 @@ export const config: VendureConfig = {
         {{#if dbSchema}}
         schema: process.env.DB_SCHEMA,
         {{/if}}
+        {{#if dbSSL}}
+        ssl: true,
+        {{/if}}
         {{#if isSQLjs}}
         location: path.join(__dirname, 'vendure.sqlite'),
         autoSave: true,

+ 31 - 1
packages/dev-server/example-plugins/ui-extensions-library/ui/providers.ts

@@ -1,4 +1,5 @@
-import { addNavMenuSection } from '@vendure/admin-ui/core';
+import { PageLocationId, addNavMenuSection, registerPageTab } from '@vendure/admin-ui/core';
+import { AngularUiComponent } from './angular-components/angular-ui/angular-ui.component';
 
 export default [
     addNavMenuSection({
@@ -17,4 +18,33 @@ export default [
             },
         ],
     }),
+    //Testing page tabs on custom angular components
+    registerPageTab({
+        location: 'angular-ui' as PageLocationId,
+        tab: 'Example Tab 1',
+        route: '/extensions/ui-library/angular-ui',
+        tabIcon: 'star',
+        component: AngularUiComponent,
+    }),
+    registerPageTab({
+        location: 'angular-ui' as PageLocationId,
+        tab: 'Example Tab 2',
+        route: '/extensions/ui-library/angular-ui2',
+        tabIcon: 'star',
+        component: AngularUiComponent,
+    }),
+    registerPageTab({
+        location: 'react-ui' as PageLocationId,
+        tab: 'Example Tab 1',
+        route: '/extensions/ui-library/angular-ui',
+        tabIcon: 'star',
+        component: AngularUiComponent,
+    }),
+    registerPageTab({
+        location: 'react-ui' as PageLocationId,
+        tab: 'Example Tab 2',
+        route: '/extensions/ui-library/angular-ui2',
+        tabIcon: 'star',
+        component: AngularUiComponent,
+    }),
 ];

+ 4 - 0
packages/dev-server/example-plugins/ui-extensions-library/ui/routes.ts

@@ -9,11 +9,15 @@ export default [
         path: 'react-ui',
         component: ReactUi,
         title: 'React UI',
+        description: "Test description",
+        locationId: "react-ui"
     }),
     registerRouteComponent({
         path: 'angular-ui',
         component: AngularUiComponent,
         title: 'Angular UI',
+        locationId: "angular-ui",
+        description: "Test description 2",
         routeConfig: {
             canDeactivate: [CanDeactivateDetailGuard],
         },

+ 13 - 3
packages/email-plugin/src/handler/event-handler.ts

@@ -14,6 +14,7 @@ import {
     LoadDataFn,
     SetAttachmentsFn,
     SetOptionalAddressFieldsFn,
+    SetSubjectFn,
     SetTemplateVarsFn,
 } from '../types';
 
@@ -135,6 +136,7 @@ import {
 export class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
     private setRecipientFn: (event: Event) => string;
     private setLanguageCodeFn: (event: Event) => LanguageCode | undefined;
+    private setSubjectFn?: SetSubjectFn<Event>;
     private setTemplateVarsFn: SetTemplateVarsFn<Event>;
     private setAttachmentsFn?: SetAttachmentsFn<Event>;
     private setOptionalAddressFieldsFn?: SetOptionalAddressFieldsFn<Event>;
@@ -214,8 +216,12 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
      * Sets the default subject of the email. The subject string may use Handlebars variables defined by the
      * setTemplateVars() method.
      */
-    setSubject(defaultSubject: string): EmailEventHandler<T, Event> {
-        this.defaultSubject = defaultSubject;
+    setSubject(defaultSubject: string | SetSubjectFn<Event>): EmailEventHandler<T, Event> {
+        if (typeof defaultSubject === 'string') {
+            this.defaultSubject = defaultSubject;
+        } else {
+            this.setSubjectFn = defaultSubject;
+        }
         return this;
     }
 
@@ -370,7 +376,11 @@ export class EmailEventHandler<T extends string = string, Event extends EventWit
         const { ctx } = event;
         const languageCode = this.setLanguageCodeFn?.(event) || ctx.languageCode;
         const configuration = this.getBestConfiguration(ctx.channel.code, languageCode);
-        const subject = configuration ? configuration.subject : this.defaultSubject;
+        const subject = configuration
+            ? configuration.subject
+            : this.setSubjectFn
+              ? await this.setSubjectFn(event, ctx, injector)
+              : this.defaultSubject;
         if (subject == null) {
             throw new Error(
                 `No subject field has been defined. ` +

+ 51 - 2
packages/email-plugin/src/plugin.spec.ts

@@ -29,7 +29,9 @@ import { EmailSender } from './sender/email-sender';
 import { EmailEventHandler } from './handler/event-handler';
 import { EmailEventListener } from './event-listener';
 import { EmailPlugin } from './plugin';
-import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
+import { EmailDetails, EmailPluginOptions, EmailTransportOptions, LoadTemplateInput } from './types';
+import { TemplateLoader } from './template-loader/template-loader';
+import fs from 'fs-extra';
 
 describe('EmailPlugin', () => {
     let eventBus: EventBus;
@@ -945,6 +947,53 @@ describe('EmailPlugin', () => {
             expect(transport.type).toBe('testing');
         });
     });
+
+    describe('Dynamic subject handling', () => {
+        it('With string', async () => {
+            const ctx = RequestContext.deserialize({
+                _channel: { code: DEFAULT_CHANNEL_CODE },
+                _languageCode: LanguageCode.en,
+            } as any);
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject('Hello')
+                .setTemplateVars(event => ({ subjectVar: 'foo' }));
+
+            await initPluginWithHandlers([handler]);
+
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+            expect(onSend.mock.calls[0][0].subject).toBe('Hello');
+            expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
+            expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
+        });
+        it('With callback function', async () => {
+            const ctx = RequestContext.deserialize({
+                _channel: { code: DEFAULT_CHANNEL_CODE },
+                _languageCode: LanguageCode.en,
+            } as any);
+            const handler = new EmailEventListener('test')
+                .on(MockEvent)
+                .setFrom('"test from" <noreply@test.com>')
+                .setRecipient(() => 'test@test.com')
+                .setSubject(async (_e, _ctx, _i) => {
+                    const service = _i.get(MockService)
+                    const mockData = await service.someAsyncMethod()
+                    return `Hello from ${mockData} and {{ subjectVar }}`;
+                })
+                .setTemplateVars(event => ({ subjectVar: 'foo' }));
+
+            await initPluginWithHandlers([handler]);
+
+            eventBus.publish(new MockEvent(ctx, true));
+            await pause();
+            expect(onSend.mock.calls[0][0].subject).toBe('Hello from loaded data and foo');
+            expect(onSend.mock.calls[0][0].recipient).toBe('test@test.com');
+            expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
+        });
+    })
 });
 
 class FakeCustomSender implements EmailSender {
@@ -966,4 +1015,4 @@ class MockService {
     someAsyncMethod() {
         return Promise.resolve('loaded data');
     }
-}
+}

+ 8 - 0
packages/email-plugin/src/types.ts

@@ -425,6 +425,14 @@ export type SetTemplateVarsFn<Event> = (
  */
 export type SetAttachmentsFn<Event> = (event: Event) => EmailAttachment[] | Promise<EmailAttachment[]>;
 
+/**
+ * @description
+ * A function used to define the subject to be sent with the email.
+ * @docsCategory core plugins/EmailPlugin
+ * @docsPage Email Plugin Types
+ */
+export type SetSubjectFn<Event> = (event: Event, ctx: RequestContext, injector: Injector) => string | Promise<string>;
+
 /**
  * @description
  * Optional address-related fields for sending the email.

+ 1 - 1
packages/testing/package.json

@@ -40,7 +40,7 @@
         "@vendure/common": "^2.2.7",
         "faker": "^4.1.0",
         "form-data": "^4.0.0",
-        "graphql": "16.8.1",
+        "graphql": "~16.9.0",
         "graphql-tag": "^2.12.6",
         "node-fetch": "^2.7.0",
         "sql.js": "1.8.0"

+ 16 - 12
packages/ui-devkit/src/compiler/compile.ts

@@ -11,19 +11,20 @@ import { copyGlobalStyleFile, setBaseHref, setupScaffold } from './scaffold';
 import { getAllTranslationFiles, mergeExtensionTranslations } from './translations';
 import {
     StaticAssetDefinition,
+    UiExtensionBuildCommand,
     UiExtensionCompilerOptions,
     UiExtensionCompilerProcessArgument,
 } from './types';
 import {
     copyStaticAsset,
     copyUiDevkit,
+    determinePackageManager,
     getStaticAssetPath,
     isAdminUiExtension,
     isGlobalStylesExtension,
     isStaticAssetExtension,
     isTranslationExtension,
     normalizeExtensions,
-    shouldUseYarn,
 } from './utils';
 
 /**
@@ -35,18 +36,21 @@ import {
 export function compileUiExtensions(
     options: UiExtensionCompilerOptions,
 ): AdminUiAppConfig | AdminUiAppDevModeConfig {
-    const { devMode, watchPort, command } = options;
-    const usingYarn = command && command === 'npm' ? false : shouldUseYarn();
+    const { devMode, watchPort } = options;
+    const command: UiExtensionBuildCommand =
+        options.command && ['npm', 'pnpm'].includes(options.command)
+            ? options.command
+            : determinePackageManager();
     if (devMode) {
         return runWatchMode({
             watchPort: watchPort || 4200,
-            usingYarn,
             ...options,
+            command,
         });
     } else {
         return runCompileMode({
-            usingYarn,
             ...options,
+            command,
         });
     }
 }
@@ -55,10 +59,10 @@ function runCompileMode({
     outputPath,
     baseHref,
     extensions,
-    usingYarn,
+    command,
     additionalProcessArguments,
     ngCompilerPath,
-}: UiExtensionCompilerOptions & { usingYarn: boolean }): AdminUiAppConfig {
+}: UiExtensionCompilerOptions & { command: UiExtensionBuildCommand }): AdminUiAppConfig {
     const distPath = path.join(outputPath, 'dist');
 
     const compile = () =>
@@ -66,13 +70,13 @@ function runCompileMode({
             await setupScaffold(outputPath, extensions);
             await setBaseHref(outputPath, baseHref || DEFAULT_BASE_HREF);
 
-            let cmd = usingYarn ? 'yarn' : 'npm';
+            let cmd: UiExtensionBuildCommand | 'node' = command;
             let commandArgs = ['run', 'build'];
             if (ngCompilerPath) {
                 cmd = 'node';
                 commandArgs = [ngCompilerPath, 'build', '--configuration production'];
             } else {
-                if (!usingYarn) {
+                if (cmd === 'npm') {
                     // npm requires `--` before any command line args being passed to a script
                     commandArgs.splice(2, 0, '--');
                 }
@@ -109,10 +113,10 @@ function runWatchMode({
     baseHref,
     watchPort,
     extensions,
-    usingYarn,
+    command,
     additionalProcessArguments,
     ngCompilerPath,
-}: UiExtensionCompilerOptions & { usingYarn: boolean }): AdminUiAppDevModeConfig {
+}: UiExtensionCompilerOptions & { command: UiExtensionBuildCommand }): AdminUiAppDevModeConfig {
     const devkitPath = require.resolve('@vendure/ui-devkit');
     let buildProcess: ChildProcess;
     let watcher: FSWatcher | undefined;
@@ -128,7 +132,7 @@ function runWatchMode({
             const globalStylesExtensions = extensions.filter(isGlobalStylesExtension);
             const staticAssetExtensions = extensions.filter(isStaticAssetExtension);
             const allTranslationFiles = getAllTranslationFiles(extensions.filter(isTranslationExtension));
-            let cmd = usingYarn ? 'yarn' : 'npm';
+            let cmd: UiExtensionBuildCommand | 'node' = command;
             let commandArgs = ['run', 'start'];
             if (ngCompilerPath) {
                 cmd = 'node';

+ 10 - 2
packages/ui-devkit/src/compiler/types.ts

@@ -347,6 +347,14 @@ export interface AdminUiExtensionLazyModule {
  */
 export type UiExtensionCompilerProcessArgument = string | [string, any];
 
+/**
+ * @description
+ * The package manager to use when invoking the Angular CLI to build UI extensions.
+ *
+ * @docsCategory UiDevkit
+ */
+export type UiExtensionBuildCommand = 'npm' | 'yarn' | 'pnpm';
+
 /**
  * @description
  * Options to configure how the Admin UI should be compiled.
@@ -435,11 +443,11 @@ export interface UiExtensionCompilerOptions {
      * @description
      * Internally, the Angular CLI will be invoked as an npm script. By default, the compiler will use Yarn
      * to run the script if it is detected, otherwise it will use npm. This setting allows you to explicitly
-     * set which command to use, rather than relying on the default behavior.
+     * set which command to use, including pnpm, rather than relying on the default behavior.
      *
      * @since 1.5.0
      */
-    command?: 'yarn' | 'npm';
+    command?: UiExtensionBuildCommand;
 
     /**
      * @description

+ 4 - 4
packages/ui-devkit/src/compiler/utils.ts

@@ -23,14 +23,14 @@ export const logger = {
 };
 
 /**
- * Checks for the global yarn binary and returns true if found.
+ * Checks for the global yarn binary to determine whether to use yarn or npm.
  */
-export function shouldUseYarn(): boolean {
+export function determinePackageManager(): 'yarn' | 'npm' {
     try {
         execSync('yarnpkg --version', { stdio: 'ignore' });
-        return true;
+        return 'yarn';
     } catch (e: any) {
-        return false;
+        return 'npm';
     }
 }
 

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio