Browse Source

feat(admin-ui): Implement grouping Countries into Zones

Michael Bromley 7 years ago
parent
commit
15b1c071f7

+ 1 - 1
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -74,7 +74,7 @@
                                        [productVariantsFormArray]="productForm.get('variants')"
                                        [taxCategories]="taxCategories$ | async"
                                        #productVariantsList>
-                <button class="btn btn-sm btn-secondary" (click)="selectFacetValue(productVariantsList.selectedVariantIds)">
+                <button class="btn btn-sm btn-secondary" (click)="selectFacetValue(productVariantsList.selectedCountryIds)">
                     {{ 'catalog.apply-facets' | translate }}
                 </button>
             </vdr-product-variants-list>

+ 1 - 1
admin-ui/src/app/common/base-list.component.ts

@@ -5,7 +5,7 @@ import { map, takeUntil } from 'rxjs/operators';
 
 import { QueryResult } from '../data/query-result';
 
-export type ListQueryFn<R> = (...args: any[]) => QueryResult<R, any>;
+export type ListQueryFn<R> = (take: number, skip: number, ...args: any[]) => QueryResult<R, any>;
 export type MappingFn<T, R> = (result: R) => { items: T[]; totalItems: number };
 
 /**

+ 65 - 0
admin-ui/src/app/data/definitions/settings-definitions.ts

@@ -47,3 +47,68 @@ export const UPDATE_COUNTRY = gql`
     }
     ${COUNTRY_FRAGMENT}
 `;
+
+export const ZONE_FRAGMENT = gql`
+    fragment Zone on Zone {
+        id
+        name
+        members {
+            ...Country
+        }
+    }
+    ${COUNTRY_FRAGMENT}
+`;
+
+export const GET_ZONES = gql`
+    query GetZones {
+        zones {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;
+
+export const GET_ZONE = gql`
+    query GetZone($id: ID!) {
+        zone(id: $id) {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;
+
+export const CREATE_ZONE = gql`
+    mutation CreateZone($input: CreateZoneInput!) {
+        createZone(input: $input) {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;
+
+export const UPDATE_ZONE = gql`
+    mutation UpdateZone($input: UpdateZoneInput!) {
+        updateZone(input: $input) {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;
+
+export const ADD_MEMBERS_TO_ZONE = gql`
+    mutation AddMembersToZone($zoneId: ID!, $memberIds: [ID!]!) {
+        addMembersToZone(zoneId: $zoneId, memberIds: $memberIds) {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;
+
+export const REMOVE_MEMBERS_FROM_ZONE = gql`
+    mutation RemoveMembersFromZone($zoneId: ID!, $memberIds: [ID!]!) {
+        removeMembersFromZone(zoneId: $zoneId, memberIds: $memberIds) {
+            ...Zone
+        }
+    }
+    ${ZONE_FRAGMENT}
+`;

+ 6 - 0
admin-ui/src/app/data/providers/data.service.mock.ts

@@ -97,5 +97,11 @@ export class MockDataService implements DataServiceMock {
         getCountry: spyQueryResult('getCountry'),
         createCountry: spyObservable('createCountry'),
         updateCountry: spyObservable('updateCountry'),
+        getZones: spyQueryResult('getZones'),
+        getZone: spyQueryResult('getZone'),
+        createZone: spyObservable('createZone'),
+        updateZone: spyObservable('updateZone'),
+        addMembersToZone: spyObservable('addMembersToZone'),
+        removeMembersFromZone: spyObservable('removeMembersFromZone'),
     };
 }

+ 53 - 0
admin-ui/src/app/data/providers/settings-data.service.ts

@@ -1,17 +1,30 @@
 import {
+    AddMembersToZone,
     CreateCountry,
     CreateCountryInput,
+    CreateZone,
+    CreateZoneInput,
     GetCountry,
     GetCountryList,
+    GetZone,
+    GetZones,
+    RemoveMembersFromZone,
     UpdateCountry,
     UpdateCountryInput,
+    UpdateZone,
+    UpdateZoneInput,
 } from 'shared/generated-types';
 
 import {
+    ADD_MEMBERS_TO_ZONE,
     CREATE_COUNTRY,
+    CREATE_ZONE,
     GET_COUNTRY,
     GET_COUNTRY_LIST,
+    GET_ZONES,
+    REMOVE_MEMBERS_FROM_ZONE,
     UPDATE_COUNTRY,
+    UPDATE_ZONE,
 } from '../definitions/settings-definitions';
 
 import { BaseDataService } from './base-data.service';
@@ -43,4 +56,44 @@ export class SettingsDataService {
             input,
         });
     }
+
+    getZones() {
+        return this.baseDataService.query<GetZones.Query>(GET_ZONES);
+    }
+
+    getZone(id: string) {
+        return this.baseDataService.query<GetZone.Query, GetZone.Variables>(GET_ZONES, { id });
+    }
+
+    createZone(input: CreateZoneInput) {
+        return this.baseDataService.mutate<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
+            input,
+        });
+    }
+
+    updateZone(input: UpdateZoneInput) {
+        return this.baseDataService.mutate<UpdateZone.Mutation, UpdateZone.Variables>(UPDATE_ZONE, {
+            input,
+        });
+    }
+
+    addMembersToZone(zoneId: string, memberIds: string[]) {
+        return this.baseDataService.mutate<AddMembersToZone.Mutation, AddMembersToZone.Variables>(
+            ADD_MEMBERS_TO_ZONE,
+            {
+                zoneId,
+                memberIds,
+            },
+        );
+    }
+
+    removeMembersFromZone(zoneId: string, memberIds: string[]) {
+        return this.baseDataService.mutate<RemoveMembersFromZone.Mutation, RemoveMembersFromZone.Variables>(
+            REMOVE_MEMBERS_FROM_ZONE,
+            {
+                zoneId,
+                memberIds,
+            },
+        );
+    }
 }

+ 18 - 8
admin-ui/src/app/settings/components/country-list/country-list.component.html

@@ -1,4 +1,13 @@
 <vdr-action-bar>
+    <vdr-ab-left>
+        <div *ngIf="selectedCountryIds.length">
+            <button class="btn btn-sm"
+                    (click)="addCountriesToZone()">{{ 'settings.add-countries-to-zone' | translate }}</button>
+            <button class="btn btn-sm"
+                    (click)="removeCountriesFromZone()">{{ 'settings.remove-countries-from-zone' | translate }}</button>
+        </div>
+    </vdr-ab-left>
+
     <vdr-ab-right>
         <a class="btn btn-primary" [routerLink]="['./create']">
             <clr-icon shape="plus"></clr-icon>
@@ -7,21 +16,22 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<vdr-data-table [items]="items$ | async"
-                [itemsPerPage]="itemsPerPage$ | async"
-                [totalItems]="totalItems$ | async"
-                [currentPage]="currentPage$ | async"
-                (pageChange)="setPageNumber($event)"
-                (itemsPerPageChange)="setItemsPerPage($event)">
-    <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
+<vdr-data-table [items]="countriesWithZones$ | async"
+                [allSelected]="areAllSelected()"
+                [isRowSelectedFn]="isCountrySelected"
+                (rowSelectChange)="toggleSelectCountry($event)"
+                (allSelectChange)="toggleSelectAll()">
     <vdr-dt-column>{{ 'common.code' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.zone' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'common.enabled' | translate }}</vdr-dt-column>
     <vdr-dt-column></vdr-dt-column>
     <ng-template let-country="item">
-        <td class="left">{{ country.id }}</td>
         <td class="left">{{ country.code }}</td>
         <td class="left">{{ country.name }}</td>
+        <td class="left">
+            <vdr-chip *ngFor="let zone of country.zones">{{ zone.name }}</vdr-chip>
+        </td>
         <td class="left">
             <clr-icon [class.enabled]="country.enabled"
                       [attr.shape]="country.enabled ? 'check' : 'times'"></clr-icon>

+ 146 - 10
admin-ui/src/app/settings/components/country-list/country-list.component.ts

@@ -1,9 +1,17 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
-import { GetCountryList } from 'shared/generated-types';
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
+import { combineLatest, Observable, of, Subject } from 'rxjs';
+import { map, mergeMap, take, tap } from 'rxjs/operators';
+import { Country, Zone } from 'shared/generated-types';
 
-import { BaseListComponent } from '../../../common/base-list.component';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';
+import { ModalService } from '../../../shared/providers/modal/modal.service';
+import { ZoneSelectorDialogComponent } from '../zone-selector-dialog/zone-selector-dialog.component';
+
+export interface CountryWithZones extends Country.Fragment {
+    zones: Zone.Fragment[];
+}
 
 @Component({
     selector: 'vdr-country-list',
@@ -11,12 +19,140 @@ import { DataService } from '../../../data/providers/data.service';
     styleUrls: ['./country-list.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class CountryListComponent extends BaseListComponent<GetCountryList.Query, GetCountryList.Items> {
-    constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
-        super(router, route);
-        super.setQueryFn(
-            (...args: any[]) => this.dataService.settings.getCountries(...args),
-            data => data.countries,
+export class CountryListComponent implements OnInit, OnDestroy {
+    countriesWithZones$: Observable<CountryWithZones[]>;
+    zones$: Observable<Zone.Fragment[]>;
+
+    selectedCountryIds: string[] = [];
+    private countries: Country.Fragment[] = [];
+    private destroy$ = new Subject();
+
+    constructor(
+        private dataService: DataService,
+        private notificationService: NotificationService,
+        private modalService: ModalService,
+    ) {}
+
+    ngOnInit() {
+        const countries$ = this.dataService.settings.getCountries(9999, 0).stream$.pipe(
+            tap(data => (this.countries = data.countries.items)),
+            map(data => data.countries.items),
+        );
+        this.zones$ = this.dataService.settings.getZones().mapStream(data => data.zones);
+        this.countriesWithZones$ = combineLatest(countries$, this.zones$).pipe(
+            map(([countries, zones]) => {
+                return countries.map(country => ({
+                    ...country,
+                    zones: zones.filter(z => !!z.members.find(c => c.id === country.id)),
+                }));
+            }),
         );
     }
+
+    ngOnDestroy() {
+        this.destroy$.next();
+        this.destroy$.complete();
+    }
+
+    areAllSelected(): boolean {
+        return this.selectedCountryIds.length === this.countries.length;
+    }
+
+    toggleSelectAll() {
+        if (this.areAllSelected()) {
+            this.selectedCountryIds = [];
+        } else {
+            this.selectedCountryIds = this.countries.map(v => v.id);
+        }
+    }
+
+    toggleSelectCountry(country: Country.Fragment) {
+        const index = this.selectedCountryIds.indexOf(country.id);
+        if (-1 < index) {
+            this.selectedCountryIds.splice(index, 1);
+        } else {
+            this.selectedCountryIds.push(country.id);
+        }
+    }
+
+    isCountrySelected = (country: Country.Fragment): boolean => {
+        return -1 < this.selectedCountryIds.indexOf(country.id);
+    };
+
+    addCountriesToZone() {
+        this.zones$
+            .pipe(
+                take(1),
+                mergeMap(zones => {
+                    return this.modalService.fromComponent(ZoneSelectorDialogComponent, {
+                        locals: {
+                            allZones: zones,
+                            canCreateNewZone: true,
+                        },
+                    });
+                }),
+                mergeMap(selection => {
+                    if (selection && this.isZone(selection)) {
+                        return this.dataService.settings
+                            .addMembersToZone(selection.id, this.selectedCountryIds)
+                            .pipe(map(data => data.addMembersToZone));
+                    } else if (selection) {
+                        return this.dataService.settings
+                            .createZone({
+                                name: selection.name,
+                                memberIds: this.selectedCountryIds,
+                            })
+                            .pipe(map(data => data.createZone));
+                    } else {
+                        return of();
+                    }
+                }),
+            )
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_(`settings.add-countries-to-zone-success`), {
+                        countryCount: this.selectedCountryIds.length,
+                        zoneName: result.name,
+                    });
+                    this.selectedCountryIds = [];
+                }
+            });
+    }
+
+    removeCountriesFromZone() {
+        this.zones$
+            .pipe(
+                take(1),
+                mergeMap(zones => {
+                    return this.modalService.fromComponent(ZoneSelectorDialogComponent, {
+                        locals: {
+                            allZones: zones,
+                            canCreateNewZone: false,
+                        },
+                    });
+                }),
+                mergeMap(selection => {
+                    if (selection && this.isZone(selection)) {
+                        return this.dataService.settings
+                            .removeMembersFromZone(selection.id, this.selectedCountryIds)
+                            .pipe(map(data => data.removeMembersFromZone));
+                    } else {
+                        return of();
+                    }
+                }),
+            )
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_(`settings.remove-countries-from-zone-success`), {
+                        countryCount: this.selectedCountryIds.length,
+                        zoneName: result.name,
+                    });
+                    this.selectedCountryIds = [];
+                }
+            });
+    }
+
+    private isZone(input: Zone.Fragment | { name: string }): input is Zone.Fragment {
+        return input.hasOwnProperty('id');
+    }
 }

+ 22 - 0
admin-ui/src/app/settings/components/zone-selector-dialog/zone-selector-dialog.component.html

@@ -0,0 +1,22 @@
+<ng-template vdrDialogTitle>{{ 'settings.select-zone' | translate }}</ng-template>
+
+<ng-select [items]="allZones"
+           bindLabel="name"
+           bindValue="id"
+           appendTo="body"
+           [addTag]="canCreateNewZone"
+           (change)="onChange($event)">
+</ng-select>
+
+<ng-template vdrDialogButtons>
+    <button type="button"
+            class="btn"
+            (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button type="submit"
+            (click)="select()"
+            [disabled]="!selected"
+            class="btn btn-primary">
+        <span *ngIf="!!selected && !selected.id">{{ 'settings.create-zone' | translate }}</span>
+        <span *ngIf="!selected || selected.id">{{ 'settings.select-zone' | translate }}</span>
+    </button>
+</ng-template>

+ 0 - 0
admin-ui/src/app/settings/components/zone-selector-dialog/zone-selector-dialog.component.scss


+ 28 - 0
admin-ui/src/app/settings/components/zone-selector-dialog/zone-selector-dialog.component.ts

@@ -0,0 +1,28 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Zone } from 'shared/generated-types';
+
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-zone-selector-dialog',
+    templateUrl: './zone-selector-dialog.component.html',
+    styleUrls: ['./zone-selector-dialog.component.scss'],
+})
+export class ZoneSelectorDialogComponent implements Dialog<Zone.Fragment | { name: string }> {
+    allZones: Zone.Fragment[];
+    canCreateNewZone = false;
+    resolveWith: (result?: Zone.Fragment | { name: string }) => void;
+    selected: any;
+
+    onChange(e) {
+        this.selected = e;
+    }
+
+    select() {
+        this.resolveWith(this.selected);
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

+ 3 - 0
admin-ui/src/app/settings/settings.module.ts

@@ -12,6 +12,7 @@ import { RoleDetailComponent } from './components/role-detail/role-detail.compon
 import { RoleListComponent } from './components/role-list/role-list.component';
 import { TaxCategoryDetailComponent } from './components/tax-category-detail/tax-category-detail.component';
 import { TaxCategoryListComponent } from './components/tax-category-list/tax-category-list.component';
+import { ZoneSelectorDialogComponent } from './components/zone-selector-dialog/zone-selector-dialog.component';
 import { AdministratorResolver } from './providers/routing/administrator-resolver';
 import { CountryResolver } from './providers/routing/country-resolver';
 import { RoleResolver } from './providers/routing/role-resolver';
@@ -30,7 +31,9 @@ import { settingsRoutes } from './settings.routes';
         PermissionGridComponent,
         CountryListComponent,
         CountryDetailComponent,
+        ZoneSelectorDialogComponent,
     ],
+    entryComponents: [ZoneSelectorDialogComponent],
     providers: [TaxCategoryResolver, AdministratorResolver, RoleResolver, CountryResolver],
 })
 export class SettingsModule {}

+ 4 - 2
admin-ui/src/app/shared/components/data-table/data-table.component.spec.ts

@@ -4,13 +4,14 @@ import { NgxPaginationModule } from 'ngx-pagination';
 import { TestingCommonModule } from '../../../../testing/testing-common.module';
 import { ItemsPerPageControlsComponent } from '../items-per-page-controls/items-per-page-controls.component';
 import { PaginationControlsComponent } from '../pagination-controls/pagination-controls.component';
+import { SelectToggleComponent } from '../select-toggle/select-toggle.component';
 
 import { DataTableColumnComponent } from './data-table-column.component';
 import { DataTableComponent } from './data-table.component';
 
 describe('DataTableComponent', () => {
-    let component: DataTableComponent;
-    let fixture: ComponentFixture<DataTableComponent>;
+    let component: DataTableComponent<any>;
+    let fixture: ComponentFixture<DataTableComponent<any>>;
 
     beforeEach(async(() => {
         TestBed.configureTestingModule({
@@ -20,6 +21,7 @@ describe('DataTableComponent', () => {
                 DataTableColumnComponent,
                 PaginationControlsComponent,
                 ItemsPerPageControlsComponent,
+                SelectToggleComponent,
             ],
         }).compileComponents();
     }));

+ 8 - 1
admin-ui/src/i18n-messages/en.json

@@ -128,12 +128,15 @@
     "create-new-order": "Create new order"
   },
   "settings": {
+    "add-countries-to-zone": "Add countries to zone...",
+    "add-countries-to-zone-success": "Added { countryCount } {countryCount, plural, one {country} other {countries}} to zone \"{ zoneName }\"",
     "administrator": "Administrator",
     "catalog": "Catalog",
     "create": "Create",
     "create-new-country": "Create new country",
     "create-new-role": "Create new role",
     "create-new-tax-category": "Create tax category",
+    "create-zone": "Create zone",
     "customer": "Customer",
     "delete": "Delete",
     "description": "Description",
@@ -144,10 +147,14 @@
     "password": "Password",
     "permissions": "Permissions",
     "read": "Read",
+    "remove-countries-from-zone": "Remove countries from zone...",
+    "remove-countries-from-zone-success": "Removed { countryCount } {countryCount, plural, one {country} other {countries}} from zone \"{ zoneName }\"",
     "role": "Role",
     "roles": "Roles",
     "section": "Section",
+    "select-zone": "Select zone",
     "tax-rate": "Tax rate",
-    "update": "Update"
+    "update": "Update",
+    "zone": "Zone"
   }
 }