Browse Source

feat(admin-ui): Create CustomerGroup UI components & routes

Relates to #330
Michael Bromley 5 years ago
parent
commit
90b38a5432
31 changed files with 1111 additions and 37 deletions
  1. 159 1
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 8 2
      packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts
  3. 91 0
      packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts
  4. 93 1
      packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts
  5. 4 2
      packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts
  6. 1 7
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html
  7. 0 11
      packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.scss
  8. 7 0
      packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.html
  9. 13 0
      packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.scss
  10. 11 0
      packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.ts
  11. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.html
  12. 9 0
      packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss
  13. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  14. 21 0
      packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.html
  15. 0 0
      packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.scss
  16. 50 0
      packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.ts
  17. 16 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.html
  18. 0 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.scss
  19. 21 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.ts
  20. 108 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html
  21. 54 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss
  22. 231 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts
  23. 37 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.html
  24. 0 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.scss
  25. 132 0
      packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.ts
  26. 4 8
      packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.ts
  27. 8 0
      packages/admin-ui/src/lib/customer/src/customer.module.ts
  28. 15 2
      packages/admin-ui/src/lib/customer/src/customer.routes.ts
  29. 1 2
      packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.ts
  30. 14 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  31. 0 0
      schema-admin.json

+ 159 - 1
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1839,6 +1839,8 @@ export type Mutation = {
   deleteCustomer: DeletionResponse;
   /** Update an existing Address */
   deleteCustomerAddress: Scalars['Boolean'];
+  /** Delete a CustomerGroup */
+  deleteCustomerGroup: DeletionResponse;
   /** Delete an existing Facet */
   deleteFacet: DeletionResponse;
   /** Delete one or more FacetValues */
@@ -2102,6 +2104,11 @@ export type MutationDeleteCustomerAddressArgs = {
 };
 
 
+export type MutationDeleteCustomerGroupArgs = {
+  id: Scalars['ID'];
+};
+
+
 export type MutationDeleteFacetArgs = {
   id: Scalars['ID'];
   force?: Maybe<Scalars['Boolean']>;
@@ -4245,7 +4252,7 @@ export type GetCustomerListQuery = (
     & Pick<CustomerList, 'totalItems'>
     & { items: Array<(
       { __typename?: 'Customer' }
-      & Pick<Customer, 'id' | 'title' | 'firstName' | 'lastName' | 'emailAddress'>
+      & Pick<Customer, 'id' | 'createdAt' | 'updatedAt' | 'title' | 'firstName' | 'lastName' | 'emailAddress'>
       & { user?: Maybe<(
         { __typename?: 'User' }
         & Pick<User, 'id' | 'verified'>
@@ -4330,6 +4337,112 @@ export type UpdateCustomerAddressMutation = (
   ) }
 );
 
+export type CreateCustomerGroupMutationVariables = {
+  input: CreateCustomerGroupInput;
+};
+
+
+export type CreateCustomerGroupMutation = (
+  { __typename?: 'Mutation' }
+  & { createCustomerGroup: (
+    { __typename?: 'CustomerGroup' }
+    & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+  ) }
+);
+
+export type UpdateCustomerGroupMutationVariables = {
+  input: UpdateCustomerGroupInput;
+};
+
+
+export type UpdateCustomerGroupMutation = (
+  { __typename?: 'Mutation' }
+  & { updateCustomerGroup: (
+    { __typename?: 'CustomerGroup' }
+    & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+  ) }
+);
+
+export type DeleteCustomerGroupMutationVariables = {
+  id: Scalars['ID'];
+};
+
+
+export type DeleteCustomerGroupMutation = (
+  { __typename?: 'Mutation' }
+  & { deleteCustomerGroup: (
+    { __typename?: 'DeletionResponse' }
+    & Pick<DeletionResponse, 'result' | 'message'>
+  ) }
+);
+
+export type GetCustomerGroupsQueryVariables = {
+  options?: Maybe<CustomerGroupListOptions>;
+};
+
+
+export type GetCustomerGroupsQuery = (
+  { __typename?: 'Query' }
+  & { customerGroups: (
+    { __typename?: 'CustomerGroupList' }
+    & Pick<CustomerGroupList, 'totalItems'>
+    & { items: Array<(
+      { __typename?: 'CustomerGroup' }
+      & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+    )> }
+  ) }
+);
+
+export type GetCustomerGroupWithCustomersQueryVariables = {
+  id: Scalars['ID'];
+  options?: Maybe<CustomerListOptions>;
+};
+
+
+export type GetCustomerGroupWithCustomersQuery = (
+  { __typename?: 'Query' }
+  & { customerGroup?: Maybe<(
+    { __typename?: 'CustomerGroup' }
+    & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+    & { customers: (
+      { __typename?: 'CustomerList' }
+      & Pick<CustomerList, 'totalItems'>
+      & { items: Array<(
+        { __typename?: 'Customer' }
+        & Pick<Customer, 'id' | 'createdAt' | 'updatedAt' | 'emailAddress' | 'firstName' | 'lastName'>
+      )> }
+    ) }
+  )> }
+);
+
+export type AddCustomersToGroupMutationVariables = {
+  groupId: Scalars['ID'];
+  customerIds: Array<Scalars['ID']>;
+};
+
+
+export type AddCustomersToGroupMutation = (
+  { __typename?: 'Mutation' }
+  & { addCustomersToGroup: (
+    { __typename?: 'CustomerGroup' }
+    & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+  ) }
+);
+
+export type RemoveCustomersFromGroupMutationVariables = {
+  groupId: Scalars['ID'];
+  customerIds: Array<Scalars['ID']>;
+};
+
+
+export type RemoveCustomersFromGroupMutation = (
+  { __typename?: 'Mutation' }
+  & { removeCustomersFromGroup: (
+    { __typename?: 'CustomerGroup' }
+    & Pick<CustomerGroup, 'id' | 'createdAt' | 'updatedAt' | 'name'>
+  ) }
+);
+
 export type FacetValueFragment = (
   { __typename?: 'FacetValue' }
   & Pick<FacetValue, 'id' | 'createdAt' | 'updatedAt' | 'languageCode' | 'code' | 'name'>
@@ -6701,6 +6814,51 @@ export namespace UpdateCustomerAddress {
   export type UpdateCustomerAddress = AddressFragment;
 }
 
+export namespace CreateCustomerGroup {
+  export type Variables = CreateCustomerGroupMutationVariables;
+  export type Mutation = CreateCustomerGroupMutation;
+  export type CreateCustomerGroup = CreateCustomerGroupMutation['createCustomerGroup'];
+}
+
+export namespace UpdateCustomerGroup {
+  export type Variables = UpdateCustomerGroupMutationVariables;
+  export type Mutation = UpdateCustomerGroupMutation;
+  export type UpdateCustomerGroup = UpdateCustomerGroupMutation['updateCustomerGroup'];
+}
+
+export namespace DeleteCustomerGroup {
+  export type Variables = DeleteCustomerGroupMutationVariables;
+  export type Mutation = DeleteCustomerGroupMutation;
+  export type DeleteCustomerGroup = DeleteCustomerGroupMutation['deleteCustomerGroup'];
+}
+
+export namespace GetCustomerGroups {
+  export type Variables = GetCustomerGroupsQueryVariables;
+  export type Query = GetCustomerGroupsQuery;
+  export type CustomerGroups = GetCustomerGroupsQuery['customerGroups'];
+  export type Items = (NonNullable<GetCustomerGroupsQuery['customerGroups']['items'][0]>);
+}
+
+export namespace GetCustomerGroupWithCustomers {
+  export type Variables = GetCustomerGroupWithCustomersQueryVariables;
+  export type Query = GetCustomerGroupWithCustomersQuery;
+  export type CustomerGroup = (NonNullable<GetCustomerGroupWithCustomersQuery['customerGroup']>);
+  export type Customers = (NonNullable<GetCustomerGroupWithCustomersQuery['customerGroup']>)['customers'];
+  export type Items = (NonNullable<(NonNullable<GetCustomerGroupWithCustomersQuery['customerGroup']>)['customers']['items'][0]>);
+}
+
+export namespace AddCustomersToGroup {
+  export type Variables = AddCustomersToGroupMutationVariables;
+  export type Mutation = AddCustomersToGroupMutation;
+  export type AddCustomersToGroup = AddCustomersToGroupMutation['addCustomersToGroup'];
+}
+
+export namespace RemoveCustomersFromGroup {
+  export type Variables = RemoveCustomersFromGroupMutationVariables;
+  export type Mutation = RemoveCustomersFromGroupMutation;
+  export type RemoveCustomersFromGroup = RemoveCustomersFromGroupMutation['removeCustomersFromGroup'];
+}
+
 export namespace FacetValue {
   export type Fragment = FacetValueFragment;
   export type Translations = (NonNullable<FacetValueFragment['translations'][0]>);

+ 8 - 2
packages/admin-ui/src/lib/core/src/components/main-nav/main-nav.component.ts

@@ -79,6 +79,12 @@ export class MainNavComponent implements OnInit {
                         routerLink: ['/customer', 'customers'],
                         icon: 'user',
                     },
+                    {
+                        id: 'customer-groups',
+                        label: _('nav.customer-groups'),
+                        routerLink: ['/customer', 'groups'],
+                        icon: 'users',
+                    },
                 ],
             },
             {
@@ -180,7 +186,7 @@ export class MainNavComponent implements OnInit {
                         statusBadge: this.jobQueueService.activeJobs$.pipe(
                             startWith([]),
                             map(
-                                jobs =>
+                                (jobs) =>
                                     ({
                                         type: jobs.length === 0 ? 'none' : 'info',
                                         propagateToSection: jobs.length > 0,
@@ -194,7 +200,7 @@ export class MainNavComponent implements OnInit {
                         routerLink: ['/system', 'system-status'],
                         icon: 'rack-server',
                         statusBadge: this.healthCheckService.status$.pipe(
-                            map(status => ({
+                            map((status) => ({
                                 type: status === 'ok' ? 'success' : 'error',
                                 propagateToSection: status === 'error',
                             })),

+ 91 - 0
packages/admin-ui/src/lib/core/src/data/definitions/customer-definitions.ts

@@ -51,6 +51,8 @@ export const GET_CUSTOMER_LIST = gql`
         customers(options: $options) {
             items {
                 id
+                createdAt
+                updatedAt
                 title
                 firstName
                 lastName
@@ -120,3 +122,92 @@ export const UPDATE_CUSTOMER_ADDRESS = gql`
     }
     ${ADDRESS_FRAGMENT}
 `;
+
+export const CREATE_CUSTOMER_GROUP = gql`
+    mutation CreateCustomerGroup($input: CreateCustomerGroupInput!) {
+        createCustomerGroup(input: $input) {
+            id
+            createdAt
+            updatedAt
+            name
+        }
+    }
+`;
+
+export const UPDATE_CUSTOMER_GROUP = gql`
+    mutation UpdateCustomerGroup($input: UpdateCustomerGroupInput!) {
+        updateCustomerGroup(input: $input) {
+            id
+            createdAt
+            updatedAt
+            name
+        }
+    }
+`;
+
+export const DELETE_CUSTOMER_GROUP = gql`
+    mutation DeleteCustomerGroup($id: ID!) {
+        deleteCustomerGroup(id: $id) {
+            result
+            message
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUPS = gql`
+    query GetCustomerGroups($options: CustomerGroupListOptions) {
+        customerGroups(options: $options) {
+            items {
+                id
+                createdAt
+                updatedAt
+                name
+            }
+            totalItems
+        }
+    }
+`;
+
+export const GET_CUSTOMER_GROUP_WITH_CUSTOMERS = gql`
+    query GetCustomerGroupWithCustomers($id: ID!, $options: CustomerListOptions) {
+        customerGroup(id: $id) {
+            id
+            createdAt
+            updatedAt
+            name
+            customers(options: $options) {
+                items {
+                    id
+                    createdAt
+                    updatedAt
+                    emailAddress
+                    firstName
+                    lastName
+                }
+                totalItems
+            }
+        }
+    }
+`;
+
+export const ADD_CUSTOMERS_TO_GROUP = gql`
+    mutation AddCustomersToGroup($groupId: ID!, $customerIds: [ID!]!) {
+        addCustomersToGroup(customerGroupId: $groupId, customerIds: $customerIds) {
+            id
+            createdAt
+            updatedAt
+            name
+        }
+    }
+`;
+
+export const REMOVE_CUSTOMERS_FROM_GROUP = gql`
+    mutation RemoveCustomersFromGroup($groupId: ID!, $customerIds: [ID!]!) {
+        removeCustomersFromGroup(customerGroupId: $groupId, customerIds: $customerIds) {
+            id
+            createdAt
+            updatedAt
+            name
+        }
+    }
+`;

+ 93 - 1
packages/admin-ui/src/lib/core/src/data/providers/customer-data.service.ts

@@ -1,23 +1,41 @@
 import {
+    AddCustomersToGroup,
     CreateAddressInput,
     CreateCustomer,
     CreateCustomerAddress,
+    CreateCustomerGroup,
+    CreateCustomerGroupInput,
     CreateCustomerInput,
+    CustomerGroupListOptions,
+    CustomerListOptions,
+    DeleteCustomerGroup,
     GetCustomer,
+    GetCustomerGroups,
+    GetCustomerGroupWithCustomers,
     GetCustomerList,
     OrderListOptions,
+    RemoveCustomersFromGroup,
     UpdateAddressInput,
     UpdateCustomer,
     UpdateCustomerAddress,
+    UpdateCustomerGroup,
+    UpdateCustomerGroupInput,
     UpdateCustomerInput,
 } from '../../common/generated-types';
 import {
+    ADD_CUSTOMERS_TO_GROUP,
     CREATE_CUSTOMER,
     CREATE_CUSTOMER_ADDRESS,
+    CREATE_CUSTOMER_GROUP,
+    DELETE_CUSTOMER_GROUP,
     GET_CUSTOMER,
+    GET_CUSTOMER_GROUP_WITH_CUSTOMERS,
+    GET_CUSTOMER_GROUPS,
     GET_CUSTOMER_LIST,
+    REMOVE_CUSTOMERS_FROM_GROUP,
     UPDATE_CUSTOMER,
     UPDATE_CUSTOMER_ADDRESS,
+    UPDATE_CUSTOMER_GROUP,
 } from '../definitions/customer-definitions';
 
 import { BaseDataService } from './base-data.service';
@@ -25,13 +43,23 @@ import { BaseDataService } from './base-data.service';
 export class CustomerDataService {
     constructor(private baseDataService: BaseDataService) {}
 
-    getCustomerList(take: number = 10, skip: number = 0) {
+    getCustomerList(take: number = 10, skip: number = 0, filterTerm?: string) {
+        const filter = filterTerm
+            ? {
+                  filter: {
+                      emailAddress: {
+                          contains: filterTerm,
+                      },
+                  },
+              }
+            : {};
         return this.baseDataService.query<GetCustomerList.Query, GetCustomerList.Variables>(
             GET_CUSTOMER_LIST,
             {
                 options: {
                     take,
                     skip,
+                    ...filter,
                 },
             },
         );
@@ -81,4 +109,68 @@ export class CustomerDataService {
             },
         );
     }
+
+    createCustomerGroup(input: CreateCustomerGroupInput) {
+        return this.baseDataService.mutate<CreateCustomerGroup.Mutation, CreateCustomerGroup.Variables>(
+            CREATE_CUSTOMER_GROUP,
+            {
+                input,
+            },
+        );
+    }
+
+    updateCustomerGroup(input: UpdateCustomerGroupInput) {
+        return this.baseDataService.mutate<UpdateCustomerGroup.Mutation, UpdateCustomerGroup.Variables>(
+            UPDATE_CUSTOMER_GROUP,
+            {
+                input,
+            },
+        );
+    }
+
+    deleteCustomerGroup(id: string) {
+        return this.baseDataService.mutate<DeleteCustomerGroup.Mutation, DeleteCustomerGroup.Variables>(
+            DELETE_CUSTOMER_GROUP,
+            { id },
+        );
+    }
+
+    getCustomerGroupList(options?: CustomerGroupListOptions) {
+        return this.baseDataService.query<GetCustomerGroups.Query, GetCustomerGroups.Variables>(
+            GET_CUSTOMER_GROUPS,
+            {
+                options,
+            },
+        );
+    }
+
+    getCustomerGroupWithCustomers(id: string, options: CustomerListOptions) {
+        return this.baseDataService.query<
+            GetCustomerGroupWithCustomers.Query,
+            GetCustomerGroupWithCustomers.Variables
+        >(GET_CUSTOMER_GROUP_WITH_CUSTOMERS, {
+            id,
+            options,
+        });
+    }
+
+    addCustomersToGroup(groupId: string, customerIds: string[]) {
+        return this.baseDataService.mutate<AddCustomersToGroup.Mutation, AddCustomersToGroup.Variables>(
+            ADD_CUSTOMERS_TO_GROUP,
+            {
+                groupId,
+                customerIds,
+            },
+        );
+    }
+
+    removeCustomersFromGroup(groupId: string, customerIds: string[]) {
+        return this.baseDataService.mutate<
+            RemoveCustomersFromGroup.Mutation,
+            RemoveCustomersFromGroup.Variables
+        >(REMOVE_CUSTOMERS_FROM_GROUP, {
+            groupId,
+            customerIds,
+        });
+    }
 }

+ 4 - 2
packages/admin-ui/src/lib/core/src/providers/modal/modal.service.ts

@@ -41,6 +41,8 @@ export interface DialogConfig<T> {
 export interface ModalOptions<T> {
     /** Sets the width of the dialog */
     size?: 'sm' | 'md' | 'lg' | 'xl';
+    /** Sets the vertical alignment of the dialog */
+    verticalAlign?: 'top' | 'center' | 'bottom';
     /**
      * When true, the "x" icon is shown
      * and clicking it or the mask will close the dialog
@@ -111,13 +113,13 @@ export class ModalService {
         const modalFactory = this.componentFactoryResolver.resolveComponentFactory(ModalDialogComponent);
 
         return from(this.overlayHostService.getHostView()).pipe(
-            mergeMap(hostView => {
+            mergeMap((hostView) => {
                 const modalComponentRef = hostView.createComponent(modalFactory);
                 const modalInstance: ModalDialogComponent<any> = modalComponentRef.instance;
                 modalInstance.childComponentType = component;
                 modalInstance.options = options;
 
-                return new Observable<R>(subscriber => {
+                return new Observable<R>((subscriber) => {
                     modalInstance.closeModal = (result: R) => {
                         modalComponentRef.destroy();
                         subscriber.next(result);

+ 1 - 7
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.html

@@ -60,11 +60,5 @@
     </div>
 </ng-container>
 <ng-template #emptyPlaceholder>
-    <div class="empty-state">
-        <clr-icon shape="bubble-exclamation" size="64"></clr-icon>
-        <div class="empty-label">
-            <ng-container *ngIf="emptyStateLabel; else defaultEmptyLabel">{{ emptyStateLabel }}</ng-container>
-            <ng-template #defaultEmptyLabel>{{ 'common.no-results' | translate }}</ng-template>
-        </div>
-    </div>
+    <vdr-empty-placeholder [emptyStateLabel]="emptyStateLabel"></vdr-empty-placeholder>
 </ng-template>

+ 0 - 11
packages/admin-ui/src/lib/core/src/shared/components/data-table/data-table.component.scss

@@ -22,14 +22,3 @@ thead th {
     justify-content: space-between;
     margin-top: 6px;
 }
-
-.empty-state {
-    text-align: center;
-    padding: 60px;
-    color: $color-grey-400;
-
-    .empty-label {
-        margin-top: 12px;
-        font-size: 22px;
-    }
-}

+ 7 - 0
packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.html

@@ -0,0 +1,7 @@
+<div class="empty-state">
+    <clr-icon shape="bubble-exclamation" size="64"></clr-icon>
+    <div class="empty-label">
+        <ng-container *ngIf="emptyStateLabel; else defaultEmptyLabel">{{ emptyStateLabel }}</ng-container>
+        <ng-template #defaultEmptyLabel>{{ 'common.no-results' | translate }}</ng-template>
+    </div>
+</div>

+ 13 - 0
packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.scss

@@ -0,0 +1,13 @@
+@import "variables";
+
+.empty-state {
+    text-align: center;
+    padding: 60px;
+    color: $color-grey-400;
+    width: 100%;
+
+    .empty-label {
+        margin-top: 12px;
+        font-size: 22px;
+    }
+}

+ 11 - 0
packages/admin-ui/src/lib/core/src/shared/components/empty-placeholder/empty-placeholder.component.ts

@@ -0,0 +1,11 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+@Component({
+    selector: 'vdr-empty-placeholder',
+    templateUrl: './empty-placeholder.component.html',
+    styleUrls: ['./empty-placeholder.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class EmptyPlaceholderComponent {
+    @Input() emptyStateLabel: string;
+}

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.html

@@ -3,6 +3,7 @@
     (clrModalOpenChange)="modalOpenChange($event)"
     [clrModalClosable]="options?.closable"
     [clrModalSize]="options?.size"
+    [ngClass]="'modal-valign-' + (options?.verticalAlign || 'center')"
 >
     <h3 class="modal-title"><ng-container *ngTemplateOutlet="(titleTemplateRef$ | async)"></ng-container></h3>
     <div class="modal-body">

+ 9 - 0
packages/admin-ui/src/lib/core/src/shared/components/modal-dialog/modal-dialog.component.scss

@@ -1,3 +1,12 @@
+::ng-deep clr-modal {
+    &.modal-valign-top .modal {
+        justify-content: flex-start;
+    }
+    &.modal-valign-bottom .modal {
+        justify-content: flex-end;
+    }
+}
+
 .modal-body {
     display: flex;
     flex-direction: column;

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -41,6 +41,7 @@ import { DropdownItemDirective } from './components/dropdown/dropdown-item.direc
 import { DropdownMenuComponent } from './components/dropdown/dropdown-menu.component';
 import { DropdownTriggerDirective } from './components/dropdown/dropdown-trigger.directive';
 import { DropdownComponent } from './components/dropdown/dropdown.component';
+import { EmptyPlaceholderComponent } from './components/empty-placeholder/empty-placeholder.component';
 import { EntityInfoComponent } from './components/entity-info/entity-info.component';
 import { ExtensionHostComponent } from './components/extension-host/extension-host.component';
 import { FacetValueChipComponent } from './components/facet-value-chip/facet-value-chip.component';
@@ -164,6 +165,7 @@ const DECLARATIONS = [
     ExternalImageDialogComponent,
     TimeAgoPipe,
     DurationPipe,
+    EmptyPlaceholderComponent,
 ];
 
 @NgModule({

+ 21 - 0
packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.html

@@ -0,0 +1,21 @@
+<ng-template vdrDialogTitle>
+    {{ 'customer.add-customers-to-group-with-name' | translate: {groupName: group.name} }}
+</ng-template>
+
+<vdr-customer-group-member-list
+    [members]="customers$ | async"
+    [totalItems]="customersTotal$ | async"
+    [route]="route"
+    [selectedMemberIds]="selectedCustomerIds"
+    (fetchParamsChange)="fetchGroupMembers$.next($event)"
+    (selectionChange)="selectedCustomerIds = $event"
+>
+
+</vdr-customer-group-member-list>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit" (click)="add()" [disabled]="!selectedCustomerIds.length" class="btn btn-primary">
+        {{ 'customer.add-customers-to-group-with-count' | translate: {count: selectedCustomerIds.length} }}
+    </button>
+</ng-template>

+ 0 - 0
packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.scss


+ 50 - 0
packages/admin-ui/src/lib/customer/src/components/add-customer-to-group-dialog/add-customer-to-group-dialog.component.ts

@@ -0,0 +1,50 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { DataService, Dialog, GetCustomerGroups, GetCustomerList } from '@vendure/admin-ui/core';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
+
+import { CustomerGroupMemberFetchParams } from '../customer-group-member-list/customer-group-member-list.component';
+
+@Component({
+    selector: 'vdr-add-customer-to-group-dialog',
+    templateUrl: './add-customer-to-group-dialog.component.html',
+    styleUrls: ['./add-customer-to-group-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AddCustomerToGroupDialogComponent implements Dialog<string[]>, OnInit {
+    resolveWith: (result?: string[]) => void;
+    group: GetCustomerGroups.Items;
+    route: ActivatedRoute;
+    selectedCustomerIds: string[] = [];
+    customers$: Observable<GetCustomerList.Items[]>;
+    customersTotal$: Observable<number>;
+    fetchGroupMembers$ = new BehaviorSubject<CustomerGroupMemberFetchParams>({
+        skip: 0,
+        take: 10,
+        filterTerm: '',
+    });
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit() {
+        const customerResult$ = this.fetchGroupMembers$.pipe(
+            switchMap(({ skip, take, filterTerm }) => {
+                return this.dataService.customer
+                    .getCustomerList(take, skip, filterTerm)
+                    .mapStream((res) => res.customers);
+            }),
+        );
+
+        this.customers$ = customerResult$.pipe(map((res) => res.items));
+        this.customersTotal$ = customerResult$.pipe(map((res) => res.totalItems));
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+
+    add() {
+        this.resolveWith(this.selectedCustomerIds);
+    }
+}

+ 16 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.html

@@ -0,0 +1,16 @@
+<ng-template vdrDialogTitle>
+    <span *ngIf="group.id">{{ 'customer.update-customer-group' | translate }}</span>
+    <span *ngIf="!group.id">{{ 'customer.create-customer-group' | translate }}</span>
+</ng-template>
+
+<vdr-form-field [label]="'common.name' | translate" for="name">
+    <input id="name" type="text" [(ngModel)]="group.name" [readonly]="!('UpdateCustomer' | hasPermission)" />
+</vdr-form-field>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit" (click)="save()" [disabled]="!group.name" class="btn btn-primary">
+        <span *ngIf="group.id">{{ 'customer.update-customer-group' | translate }}</span>
+        <span *ngIf="!group.id">{{ 'customer.create-customer-group' | translate }}</span>
+    </button>
+</ng-template>

+ 0 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.scss


+ 21 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-detail-dialog/customer-group-detail-dialog.component.ts

@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Dialog } from '@vendure/admin-ui/core';
+
+@Component({
+    selector: 'vdr-customer-group-detail-dialog',
+    templateUrl: './customer-group-detail-dialog.component.html',
+    styleUrls: ['./customer-group-detail-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomerGroupDetailDialogComponent implements Dialog<string> {
+    group: { id?: string; name: string };
+    resolveWith: (result?: string) => void;
+
+    cancel() {
+        this.resolveWith();
+    }
+
+    save() {
+        this.resolveWith(this.group.name);
+    }
+}

+ 108 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.html

@@ -0,0 +1,108 @@
+<vdr-action-bar>
+    <vdr-ab-left> </vdr-ab-left>
+    <vdr-ab-right>
+        <vdr-action-bar-items locationId="customer-group-list"></vdr-action-bar-items>
+        <button class="btn btn-primary" *vdrIfPermissions="'CreateCustomer'" (click)="create()">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'customer.create-new-customer-group' | translate }}
+        </button>
+    </vdr-ab-right>
+</vdr-action-bar>
+<div class="group-wrapper">
+    <table class="table group-list" *ngIf="!(listIsEmpty$ | async); else emptyPlaceholder">
+        <tbody>
+            <tr *ngFor="let group of groups$ | async" [class.active]="group.id === (activeGroup$ | async)?.id">
+                <td class="left align-middle"><vdr-entity-info [entity]="group"></vdr-entity-info></td>
+                <td class="left align-middle"><vdr-chip [colorFrom]="group.id">{{ group.name }}</vdr-chip></td>
+                <td class="text-right align-middle">
+                    <a
+                        class="btn btn-link btn-sm"
+                        [routerLink]="['./', { contents: group.id }]"
+                        queryParamsHandling="preserve"
+                    >
+                        <clr-icon shape="view-list"></clr-icon>
+                        {{ 'customer.view-group-members' | translate }}
+                    </a>
+                </td>
+                <td class="align-middle">
+                    <button class="btn btn-link btn-sm" (click)="update(group)">
+                        <clr-icon shape="edit"></clr-icon>
+                        {{ 'common.edit' | translate }}
+                    </button>
+                </td>
+                <td class="align-middle">
+                    <vdr-dropdown>
+                        <button type="button" class="btn btn-link btn-sm" vdrDropdownTrigger>
+                            {{ 'common.actions' | translate }}
+                            <clr-icon shape="caret down"></clr-icon>
+                        </button>
+                        <vdr-dropdown-menu vdrPosition="bottom-right">
+                            <button
+                                class="button"
+                                vdrDropdownItem
+                                (click)="delete(group.id)"
+                                [disabled]="!('DeleteSettings' | hasPermission)"
+                            >
+                                <clr-icon shape="trash" class="is-danger"></clr-icon>
+                                {{ 'common.delete' | translate }}
+                            </button>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+    <ng-template #emptyPlaceholder>
+        <vdr-empty-placeholder></vdr-empty-placeholder>
+    </ng-template>
+    <div class="group-members" [class.expanded]="activeGroup$ | async">
+        <ng-container *ngIf="activeGroup$ | async as activeGroup">
+            <div class="flex">
+                <div class="header-title-row">
+                    {{ activeGroup.name }} ({{ membersTotal$ | async }})
+                </div>
+                <div class="flex-spacer"></div>
+                <button type="button" class="close-button" (click)="closeMembers()">
+                    <clr-icon shape="close"></clr-icon>
+                </button>
+            </div>
+            <div class="controls">
+                <vdr-dropdown>
+                    <button
+                        type="button"
+                        class="btn btn-secondary btn-sm"
+                        vdrDropdownTrigger
+                        [disabled]="selectedCustomerIds.length === 0"
+                    >
+                        {{ 'common.with-selected' | translate }}
+                        <clr-icon shape="caret down"></clr-icon>
+                    </button>
+                    <vdr-dropdown-menu vdrPosition="bottom-right">
+                        <button
+                            type="button"
+                            class="delete-button"
+                            (click)="removeFromGroup(activeGroup, selectedCustomerIds)"
+                            vdrDropdownItem
+                            [disabled]="!('DeleteSettings' | hasPermission)"
+                        >
+                            <clr-icon shape="trash" class="is-danger"></clr-icon>
+                            {{ 'customer.remove-from-group' | translate }}
+                        </button>
+                    </vdr-dropdown-menu>
+                </vdr-dropdown>
+                <button class="btn btn-secondary btn-sm" (click)="addToGroup(activeGroup)">
+                    {{ 'customer.add-customers-to-group' | translate: { groupName: activeGroup.name } }}
+                </button>
+            </div>
+            <vdr-customer-group-member-list
+                [members]="members$ | async"
+                [route]="route"
+                [totalItems]="membersTotal$ | async"
+                [selectedMemberIds]="selectedCustomerIds"
+                (selectionChange)="selectedCustomerIds = $event"
+                (fetchParamsChange)="fetchGroupMembers$.next($event)"
+            ></vdr-customer-group-member-list>
+        </ng-container>
+    </div>
+</div>
+

+ 54 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.scss

@@ -0,0 +1,54 @@
+@import 'variables';
+
+.group-wrapper {
+    display: flex;
+    height: calc(100% - 50px);
+
+    .group-list {
+        flex: 1;
+        overflow: auto;
+        margin-top: 0;
+
+        tr.active {
+            background-color: $color-grey-200;
+        }
+    }
+}
+.group-members {
+    height: 100%;
+    width: 0;
+    opacity: 0;
+    visibility: hidden;
+    overflow: auto;
+    transition: width 0.3s, opacity 0.2s 0.3s, visibility 0s 0.3s;
+
+    &.expanded {
+        width: 40vw;
+        visibility: visible;
+        opacity: 1;
+        padding-left: 12px;
+    }
+
+    .close-button {
+        margin: 0;
+        background: none;
+        border: none;
+        cursor: pointer;
+    }
+
+    ::ng-deep table.table {
+        margin-top: 0;
+        th {
+            top: 0;
+        }
+    }
+
+    .controls {
+        display: flex;
+        justify-content: space-between;
+    }
+}
+
+vdr-empty-placeholder {
+    flex: 1;
+}

+ 231 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-list/customer-group-list.component.ts

@@ -0,0 +1,231 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
+import {
+    DataService,
+    DeletionResult,
+    GetCustomerGroups,
+    GetCustomerGroupWithCustomers,
+    GetZones,
+    ModalService,
+    NotificationService,
+} from '@vendure/admin-ui/core';
+import { BehaviorSubject, combineLatest, EMPTY, Observable, of } from 'rxjs';
+import { distinctUntilChanged, map, mapTo, switchMap, tap } from 'rxjs/operators';
+
+import { AddCustomerToGroupDialogComponent } from '../add-customer-to-group-dialog/add-customer-to-group-dialog.component';
+import { CustomerGroupDetailDialogComponent } from '../customer-group-detail-dialog/customer-group-detail-dialog.component';
+import { CustomerGroupMemberFetchParams } from '../customer-group-member-list/customer-group-member-list.component';
+
+@Component({
+    selector: 'vdr-customer-group-list',
+    templateUrl: './customer-group-list.component.html',
+    styleUrls: ['./customer-group-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomerGroupListComponent implements OnInit {
+    activeGroup$: Observable<GetCustomerGroups.Items | undefined>;
+    groups$: Observable<GetCustomerGroups.Items[]>;
+    listIsEmpty$: Observable<boolean>;
+    members$: Observable<GetCustomerGroupWithCustomers.Items[]>;
+    membersTotal$: Observable<number>;
+    selectedCustomerIds: string[] = [];
+    fetchGroupMembers$ = new BehaviorSubject<CustomerGroupMemberFetchParams>({
+        skip: 0,
+        take: 0,
+        filterTerm: '',
+    });
+    private refreshActiveGroupMembers$ = new BehaviorSubject<void>(undefined);
+
+    constructor(
+        private dataService: DataService,
+        private notificationService: NotificationService,
+        private modalService: ModalService,
+        public route: ActivatedRoute,
+        private router: Router,
+    ) {}
+
+    ngOnInit(): void {
+        this.groups$ = this.dataService.customer
+            .getCustomerGroupList()
+            .mapStream((data) => data.customerGroups.items);
+        const activeGroupId$ = this.route.paramMap.pipe(
+            map((pm) => pm.get('contents')),
+            distinctUntilChanged(),
+            tap(() => (this.selectedCustomerIds = [])),
+        );
+        this.listIsEmpty$ = this.groups$.pipe(map((groups) => groups.length === 0));
+        this.activeGroup$ = combineLatest(this.groups$, activeGroupId$).pipe(
+            map(([groups, activeGroupId]) => {
+                if (activeGroupId) {
+                    return groups.find((g) => g.id === activeGroupId);
+                }
+            }),
+        );
+        const membersResult$ = combineLatest(
+            this.activeGroup$,
+            this.fetchGroupMembers$,
+            this.refreshActiveGroupMembers$,
+        ).pipe(
+            switchMap(([activeGroup, { skip, take, filterTerm }]) => {
+                if (activeGroup) {
+                    return this.dataService.customer
+                        .getCustomerGroupWithCustomers(activeGroup.id, {
+                            skip,
+                            take,
+                            filter: {
+                                emailAddress: {
+                                    contains: filterTerm,
+                                },
+                            },
+                        })
+                        .mapStream((res) => res.customerGroup?.customers);
+                } else {
+                    return of(undefined);
+                }
+            }),
+        );
+
+        this.members$ = membersResult$.pipe(map((res) => res?.items ?? []));
+        this.membersTotal$ = membersResult$.pipe(map((res) => res?.totalItems ?? 0));
+    }
+
+    create() {
+        this.modalService
+            .fromComponent(CustomerGroupDetailDialogComponent, { locals: { group: { name: '' } } })
+            .pipe(
+                switchMap((name) =>
+                    name ? this.dataService.customer.createCustomerGroup({ name, customerIds: [] }) : EMPTY,
+                ),
+                // refresh list
+                switchMap(() => this.dataService.customer.getCustomerGroupList().single$),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-create-success'), {
+                        entity: 'CustomerGroup',
+                    });
+                },
+                (err) => {
+                    this.notificationService.error(_('common.notify-create-error'), {
+                        entity: 'CustomerGroup',
+                    });
+                },
+            );
+    }
+
+    delete(groupId: string) {
+        this.modalService
+            .dialog({
+                title: _('customer.confirm-delete-customer-group'),
+                buttons: [
+                    { type: 'secondary', label: _('common.cancel') },
+                    { type: 'danger', label: _('common.delete'), returnValue: true },
+                ],
+            })
+            .pipe(
+                switchMap((response) =>
+                    response ? this.dataService.customer.deleteCustomerGroup(groupId) : EMPTY,
+                ),
+
+                switchMap((result) => {
+                    if (result.deleteCustomerGroup.result === DeletionResult.DELETED) {
+                        // refresh list
+                        return this.dataService.customer
+                            .getCustomerGroupList()
+                            .mapSingle(() => ({ errorMessage: false }));
+                    } else {
+                        return of({ errorMessage: result.deleteCustomerGroup.message });
+                    }
+                }),
+            )
+            .subscribe(
+                (result) => {
+                    if (typeof result.errorMessage === 'string') {
+                        this.notificationService.error(result.errorMessage);
+                    } else {
+                        this.notificationService.success(_('common.notify-delete-success'), {
+                            entity: 'CustomerGroup',
+                        });
+                    }
+                },
+                (err) => {
+                    this.notificationService.error(_('common.notify-delete-error'), {
+                        entity: 'CustomerGroup',
+                    });
+                },
+            );
+    }
+
+    update(group: GetCustomerGroups.Items) {
+        this.modalService
+            .fromComponent(CustomerGroupDetailDialogComponent, { locals: { group } })
+            .pipe(
+                switchMap((name) =>
+                    name ? this.dataService.customer.updateCustomerGroup({ id: group.id, name }) : EMPTY,
+                ),
+            )
+            .subscribe(
+                () => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'CustomerGroup',
+                    });
+                },
+                (err) => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'CustomerGroup',
+                    });
+                },
+            );
+    }
+
+    closeMembers() {
+        const params = { ...this.route.snapshot.params };
+        delete params.contents;
+        this.router.navigate(['./', params], { relativeTo: this.route, queryParamsHandling: 'preserve' });
+    }
+
+    addToGroup(group: GetCustomerGroupWithCustomers.CustomerGroup) {
+        this.modalService
+            .fromComponent(AddCustomerToGroupDialogComponent, {
+                locals: {
+                    group,
+                    route: this.route,
+                },
+                size: 'md',
+                verticalAlign: 'top',
+            })
+            .pipe(
+                switchMap((customerIds) =>
+                    customerIds
+                        ? this.dataService.customer
+                              .addCustomersToGroup(group.id, customerIds)
+                              .pipe(mapTo(customerIds))
+                        : EMPTY,
+                ),
+            )
+            .subscribe({
+                next: (result) => {
+                    this.notificationService.success(_(`customer.add-customers-to-group-success`), {
+                        customerCount: result.length,
+                        groupName: group.name,
+                    });
+                    this.refreshActiveGroupMembers$.next();
+                    this.selectedCustomerIds = [];
+                },
+            });
+    }
+
+    removeFromGroup(group: GetZones.Zones, customerIds: string[]) {
+        this.dataService.customer.removeCustomersFromGroup(group.id, customerIds).subscribe({
+            complete: () => {
+                this.notificationService.success(_(`customer.remove-customers-from-group-success`), {
+                    customerCount: customerIds.length,
+                    groupName: group.name,
+                });
+                this.refreshActiveGroupMembers$.next();
+                this.selectedCustomerIds = [];
+            },
+        });
+    }
+}

+ 37 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.html

@@ -0,0 +1,37 @@
+<input
+    type="text"
+    name="searchTerm"
+    [formControl]="filterTermControl"
+    [placeholder]="'customer.search-customers-by-email' | translate"
+    class="clr-input search-input"
+/>
+
+<vdr-data-table
+    [items]="members"
+    [itemsPerPage]="membersItemsPerPage$ | async"
+    [totalItems]="totalItems"
+    [currentPage]="membersCurrentPage$ | async"
+    (pageChange)="setContentsPageNumber($event)"
+    (itemsPerPageChange)="setContentsItemsPerPage($event)"
+    [allSelected]="areAllSelected()"
+    [isRowSelectedFn]="('UpdateCustomer' | hasPermission) && isMemberSelected"
+    (rowSelectChange)="toggleSelectMember($event)"
+    (allSelectChange)="toggleSelectAll()"
+>
+    <vdr-dt-column [expand]="true">{{ 'customer.name' | translate }}</vdr-dt-column>
+    <vdr-dt-column [expand]="true">{{ 'customer.email-address' | translate }}</vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <ng-template let-customer="item">
+        <td class="left align-middle">
+            {{ customer.title }} {{ customer.firstName }} {{ customer.lastName }}
+        </td>
+        <td class="left align-middle">{{ customer.emailAddress }}</td>
+        <td class="right align-middle">
+            <vdr-table-row-action
+                iconShape="edit"
+                [label]="'common.edit' | translate"
+                [linkTo]="['/customer', 'customers', customer.id]"
+            ></vdr-table-row-action>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 0 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.scss


+ 132 - 0
packages/admin-ui/src/lib/customer/src/components/customer-group-member-list/customer-group-member-list.component.ts

@@ -0,0 +1,132 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    Input,
+    OnDestroy,
+    OnInit,
+    Output,
+} from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Customer, DataService, GetCustomerGroupWithCustomers } from '@vendure/admin-ui/core';
+import { ZoneMember } from '@vendure/admin-ui/settings';
+import { BehaviorSubject, combineLatest, Observable, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, map, startWith, takeUntil, tap } from 'rxjs/operators';
+
+export interface CustomerGroupMemberFetchParams {
+    skip: number;
+    take: number;
+    filterTerm: string;
+}
+
+@Component({
+    selector: 'vdr-customer-group-member-list',
+    templateUrl: './customer-group-member-list.component.html',
+    styleUrls: ['./customer-group-member-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CustomerGroupMemberListComponent implements OnInit, OnDestroy {
+    @Input() members: Array<
+        Pick<Customer, 'id' | 'createdAt' | 'updatedAt' | 'title' | 'firstName' | 'lastName' | 'emailAddress'>
+    >;
+    @Input() totalItems: number;
+    @Input() route: ActivatedRoute;
+    @Input() selectedMemberIds: string[] = [];
+    @Output() selectionChange = new EventEmitter<string[]>();
+    @Output() fetchParamsChange = new EventEmitter<CustomerGroupMemberFetchParams>();
+
+    membersItemsPerPage$: Observable<number>;
+    membersCurrentPage$: Observable<number>;
+    filterTermControl = new FormControl('');
+    private refresh$ = new BehaviorSubject<boolean>(true);
+    private destroy$ = new Subject<void>();
+
+    constructor(private router: Router, private dataService: DataService) {}
+
+    ngOnInit() {
+        this.membersCurrentPage$ = this.route.paramMap.pipe(
+            map((qpm) => qpm.get('membersPage')),
+            map((page) => (!page ? 1 : +page)),
+            startWith(1),
+            distinctUntilChanged(),
+        );
+
+        this.membersItemsPerPage$ = this.route.paramMap.pipe(
+            map((qpm) => qpm.get('membersPerPage')),
+            map((perPage) => (!perPage ? 10 : +perPage)),
+            startWith(10),
+            distinctUntilChanged(),
+        );
+
+        const filterTerm$ = this.filterTermControl.valueChanges.pipe(
+            debounceTime(250),
+            tap(() => this.setContentsPageNumber(1)),
+            startWith(''),
+        );
+
+        combineLatest(this.membersCurrentPage$, this.membersItemsPerPage$, filterTerm$, this.refresh$)
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(([currentPage, itemsPerPage, filterTerm]) => {
+                const take = itemsPerPage;
+                const skip = (currentPage - 1) * itemsPerPage;
+                this.fetchParamsChange.emit({
+                    filterTerm,
+                    skip,
+                    take,
+                });
+            });
+    }
+
+    ngOnDestroy() {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    setContentsPageNumber(page: number) {
+        this.setParam('membersPage', page);
+    }
+
+    setContentsItemsPerPage(perPage: number) {
+        this.setParam('membersPerPage', perPage);
+    }
+
+    refresh() {
+        this.refresh$.next(true);
+    }
+
+    private setParam(key: string, value: any) {
+        this.router.navigate(['./', { ...this.route.snapshot.params, [key]: value }], {
+            relativeTo: this.route,
+            queryParamsHandling: 'merge',
+        });
+    }
+
+    areAllSelected(): boolean {
+        if (this.members) {
+            return this.selectedMemberIds.length === this.members.length;
+        } else {
+            return false;
+        }
+    }
+
+    toggleSelectAll() {
+        if (this.areAllSelected()) {
+            this.selectionChange.emit([]);
+        } else {
+            this.selectionChange.emit(this.members.map((v) => v.id));
+        }
+    }
+
+    toggleSelectMember(member: ZoneMember) {
+        if (this.selectedMemberIds.includes(member.id)) {
+            this.selectionChange.emit(this.selectedMemberIds.filter((id) => id !== member.id));
+        } else {
+            this.selectionChange.emit([...this.selectedMemberIds, member.id]);
+        }
+    }
+
+    isMemberSelected = (member: ZoneMember): boolean => {
+        return -1 < this.selectedMemberIds.indexOf(member.id);
+    };
+}

+ 4 - 8
packages/admin-ui/src/lib/customer/src/components/customer-list/customer-list.component.ts

@@ -1,12 +1,11 @@
 import { Component, OnInit } from '@angular/core';
 import { FormControl } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { SortOrder } from '@vendure/common/lib/generated-shop-types';
-import { debounceTime, takeUntil } from 'rxjs/operators';
-
 import { BaseListComponent } from '@vendure/admin-ui/core';
 import { GetCustomerList } from '@vendure/admin-ui/core';
 import { DataService } from '@vendure/admin-ui/core';
+import { SortOrder } from '@vendure/common/lib/generated-shop-types';
+import { debounceTime, takeUntil } from 'rxjs/operators';
 
 @Component({
     selector: 'vdr-customer-list',
@@ -20,7 +19,7 @@ export class CustomerListComponent extends BaseListComponent<GetCustomerList.Que
         super(router, route);
         super.setQueryFn(
             (...args: any[]) => this.dataService.customer.getCustomerList(...args),
-            data => data.customers,
+            (data) => data.customers,
             (skip, take) => ({
                 options: {
                     skip,
@@ -41,10 +40,7 @@ export class CustomerListComponent extends BaseListComponent<GetCustomerList.Que
     ngOnInit() {
         super.ngOnInit();
         this.searchTerm.valueChanges
-            .pipe(
-                debounceTime(250),
-                takeUntil(this.destroy$),
-            )
+            .pipe(debounceTime(250), takeUntil(this.destroy$))
             .subscribe(() => this.refresh());
     }
 }

+ 8 - 0
packages/admin-ui/src/lib/customer/src/customer.module.ts

@@ -2,8 +2,12 @@ import { NgModule } from '@angular/core';
 import { RouterModule } from '@angular/router';
 import { SharedModule } from '@vendure/admin-ui/core';
 
+import { AddCustomerToGroupDialogComponent } from './components/add-customer-to-group-dialog/add-customer-to-group-dialog.component';
 import { AddressCardComponent } from './components/address-card/address-card.component';
 import { CustomerDetailComponent } from './components/customer-detail/customer-detail.component';
+import { CustomerGroupDetailDialogComponent } from './components/customer-group-detail-dialog/customer-group-detail-dialog.component';
+import { CustomerGroupListComponent } from './components/customer-group-list/customer-group-list.component';
+import { CustomerGroupMemberListComponent } from './components/customer-group-member-list/customer-group-member-list.component';
 import { CustomerListComponent } from './components/customer-list/customer-list.component';
 import { CustomerStatusLabelComponent } from './components/customer-status-label/customer-status-label.component';
 import { customerRoutes } from './customer.routes';
@@ -15,6 +19,10 @@ import { customerRoutes } from './customer.routes';
         CustomerDetailComponent,
         CustomerStatusLabelComponent,
         AddressCardComponent,
+        CustomerGroupListComponent,
+        CustomerGroupDetailDialogComponent,
+        AddCustomerToGroupDialogComponent,
+        CustomerGroupMemberListComponent,
     ],
     exports: [AddressCardComponent],
 })

+ 15 - 2
packages/admin-ui/src/lib/customer/src/customer.routes.ts

@@ -1,8 +1,14 @@
 import { Route } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { CanDeactivateDetailGuard, createResolveData, Customer, detailBreadcrumb } from '@vendure/admin-ui/core';
+import {
+    CanDeactivateDetailGuard,
+    createResolveData,
+    Customer,
+    detailBreadcrumb,
+} from '@vendure/admin-ui/core';
 
 import { CustomerDetailComponent } from './components/customer-detail/customer-detail.component';
+import { CustomerGroupListComponent } from './components/customer-group-list/customer-group-list.component';
 import { CustomerListComponent } from './components/customer-list/customer-list.component';
 import { CustomerResolver } from './providers/routing/customer-resolver';
 
@@ -24,6 +30,13 @@ export const customerRoutes: Route[] = [
             breadcrumb: customerBreadcrumb,
         },
     },
+    {
+        path: 'groups',
+        component: CustomerGroupListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.customer-groups'),
+        },
+    },
 ];
 
 export function customerBreadcrumb(data: any, params: any) {
@@ -31,7 +44,7 @@ export function customerBreadcrumb(data: any, params: any) {
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.customers',
-        getName: customer => `${customer.firstName} ${customer.lastName}`,
+        getName: (customer) => `${customer.firstName} ${customer.lastName}`,
         route: 'customers',
     });
 }

+ 1 - 2
packages/admin-ui/src/lib/settings/src/components/zone-detail-dialog/zone-detail-dialog.component.ts

@@ -1,5 +1,5 @@
 import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { CreateZoneInput, DataService, Dialog, UpdateZoneInput } from '@vendure/admin-ui/core';
+import { Dialog } from '@vendure/admin-ui/core';
 
 @Component({
     selector: 'vdr-zone-detail-dialog',
@@ -10,7 +10,6 @@ import { CreateZoneInput, DataService, Dialog, UpdateZoneInput } from '@vendure/
 export class ZoneDetailDialogComponent implements Dialog<string> {
     zone: { id?: string; name: string };
     resolveWith: (result?: string) => void;
-    constructor(private dataService: DataService) {}
 
     cancel() {
         this.resolveWith();

+ 14 - 1
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -30,6 +30,7 @@
     "channels": "Channels",
     "collections": "Collections",
     "countries": "Countries",
+    "customer-groups": "Customer groups",
     "customers": "Customers",
     "dashboard": "Dashboard",
     "facets": "Facets",
@@ -195,11 +196,18 @@
     "with-selected": "With selected..."
   },
   "customer": {
+    "add-customers-to-group": "Add customers to group",
+    "add-customers-to-group-success": "Added {customerCount, plural, one {1 customer} other {{customerCount} customers}} to \"{ groupName }\"",
+    "add-customers-to-group-with-count": "Add {count, plural, one {1 customer} other {{count} customers}}",
+    "add-customers-to-group-with-name": "Add customers to \"{ groupName }\"",
     "addresses": "Addresses",
     "city": "City",
+    "confirm-delete-customer-group": "Delete customer group?",
     "country": "Country",
+    "create-customer-group": "Create customer group",
     "create-new-address": "Create new address",
     "create-new-customer": "Create new customer",
+    "create-new-customer-group": "Create new customer group",
     "customer-type": "Customer type",
     "default-billing-address": "Default billing",
     "default-shipping-address": "Default shipping",
@@ -217,13 +225,17 @@
     "postal-code": "Postal code",
     "province": "Province",
     "registered": "Registered",
+    "remove-customers-from-group-success": "Removed {customerCount, plural, one {1 customer} other {{customerCount} customers}} from \"{ groupName }\"",
+    "remove-from-group": "Remove from this group",
     "search-customers-by-email": "Search by email address",
     "set-as-default-billing-address": "Set as default billing",
     "set-as-default-shipping-address": "Set as default shipping",
     "street-line-1": "Street line 1",
     "street-line-2": "Street line 2",
     "title": "Title",
-    "verified": "Verified"
+    "update-customer-group": "Update customer group",
+    "verified": "Verified",
+    "view-group-members": "View group members"
   },
   "datetime": {
     "ago-days": "{count, plural, one {1 day} other {{count} days}} ago",
@@ -477,6 +489,7 @@
     "channels": "Channels",
     "collections": "Collections",
     "countries": "Countries",
+    "customer-groups": "Customer groups",
     "customers": "Customers",
     "facets": "Facets",
     "global-settings": "Global settings",

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


Some files were not shown because too many files changed in this diff