Browse Source

feat(admin-ui): Add shipping method test UI

Closes #133
Michael Bromley 6 years ago
parent
commit
b76eac53a4
18 changed files with 652 additions and 5 deletions
  1. 67 0
      admin-ui/src/app/common/generated-types.ts
  2. 25 1
      admin-ui/src/app/core/providers/local-storage/local-storage.service.ts
  3. 35 0
      admin-ui/src/app/data/definitions/settings-definitions.ts
  4. 24 0
      admin-ui/src/app/data/providers/settings-data.service.ts
  5. 30 1
      admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html
  6. 9 0
      admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.scss
  7. 67 3
      admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.ts
  8. 46 0
      admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.html
  9. 28 0
      admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.scss
  10. 17 0
      admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.ts
  11. 29 0
      admin-ui/src/app/settings/components/test-address-form/test-address-form.component.html
  12. 3 0
      admin-ui/src/app/settings/components/test-address-form/test-address-form.component.scss
  13. 61 0
      admin-ui/src/app/settings/components/test-address-form/test-address-form.component.ts
  14. 71 0
      admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.html
  15. 8 0
      admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.scss
  16. 117 0
      admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.ts
  17. 6 0
      admin-ui/src/app/settings/settings.module.ts
  18. 9 0
      admin-ui/src/i18n-messages/en.json

+ 67 - 0
admin-ui/src/app/common/generated-types.ts

@@ -2666,6 +2666,7 @@ export type Query = {
   shippingMethod?: Maybe<ShippingMethod>,
   shippingEligibilityCheckers: Array<ConfigurableOperation>,
   shippingCalculators: Array<ConfigurableOperation>,
+  testShippingMethod: TestShippingMethodResult,
   taxCategories: Array<TaxCategory>,
   taxCategory?: Maybe<TaxCategory>,
   taxRates: TaxRateList,
@@ -2834,6 +2835,11 @@ export type QueryShippingMethodArgs = {
 };
 
 
+export type QueryTestShippingMethodArgs = {
+  input: TestShippingMethodInput
+};
+
+
 export type QueryTaxCategoryArgs = {
   id: Scalars['ID']
 };
@@ -3050,6 +3056,12 @@ export type ShippingMethodSortParameter = {
   description?: Maybe<SortOrder>,
 };
 
+export type ShippingPrice = {
+  __typename?: 'ShippingPrice',
+  price: Scalars['Int'],
+  priceWithTax: Scalars['Int'],
+};
+
 /** The price value where the result has a single price */
 export type SinglePrice = {
   __typename?: 'SinglePrice',
@@ -3173,6 +3185,24 @@ export type TaxRateSortParameter = {
   value?: Maybe<SortOrder>,
 };
 
+export type TestShippingMethodInput = {
+  checker: ConfigurableOperationInput,
+  calculator: ConfigurableOperationInput,
+  shippingAddress: CreateAddressInput,
+  lines: Array<TestShippingMethodOrderLineInput>,
+};
+
+export type TestShippingMethodOrderLineInput = {
+  productVariantId: Scalars['ID'],
+  quantity: Scalars['Int'],
+};
+
+export type TestShippingMethodResult = {
+  __typename?: 'TestShippingMethodResult',
+  eligible: Scalars['Boolean'],
+  price?: Maybe<ShippingPrice>,
+};
+
 export type UiState = {
   __typename?: 'UiState',
   language: LanguageCode,
@@ -4178,6 +4208,21 @@ export type ReindexMutationVariables = {};
 
 export type ReindexMutation = ({ __typename?: 'Mutation' } & { reindex: ({ __typename?: 'JobInfo' } & JobInfoFragment) });
 
+export type SearchForTestOrderQueryVariables = {
+  term: Scalars['String'],
+  take: Scalars['Int']
+};
+
+
+export type SearchForTestOrderQuery = ({ __typename?: 'Query' } & { search: ({ __typename?: 'SearchResponse' } & { items: Array<({ __typename?: 'SearchResult' } & Pick<SearchResult, 'productVariantId' | 'productVariantName' | 'productPreview' | 'sku'> & { price: ({ __typename?: 'SinglePrice' } & Pick<SinglePrice, 'value'>), priceWithTax: ({ __typename?: 'SinglePrice' } & Pick<SinglePrice, 'value'>) })> }) });
+
+export type TestShippingMethodQueryVariables = {
+  input: TestShippingMethodInput
+};
+
+
+export type TestShippingMethodQuery = ({ __typename?: 'Query' } & { testShippingMethod: ({ __typename?: 'TestShippingMethodResult' } & Pick<TestShippingMethodResult, 'eligible'> & { price: Maybe<({ __typename?: 'ShippingPrice' } & Pick<ShippingPrice, 'price' | 'priceWithTax'>)> }) });
+
 export type ShippingMethodFragment = ({ __typename?: 'ShippingMethod' } & Pick<ShippingMethod, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'description'> & { checker: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment), calculator: ({ __typename?: 'ConfigurableOperation' } & ConfigurableOperationFragment) });
 
 export type GetShippingMethodListQueryVariables = {
@@ -4212,6 +4257,10 @@ export type UpdateShippingMethodMutationVariables = {
 
 
 export type UpdateShippingMethodMutation = ({ __typename?: 'Mutation' } & { updateShippingMethod: ({ __typename?: 'ShippingMethod' } & ShippingMethodFragment) });
+type DiscriminateUnion<T, U> = T extends U ? T : never;
+
+type RequireField<T, TNames extends string> = T & { [P in TNames]: (T & { [name: string]: never })[P] };
+
 export namespace Administrator {
   export type Fragment = AdministratorFragment;
   export type User = AdministratorFragment['user'];
@@ -5109,6 +5158,24 @@ export namespace Reindex {
   export type Reindex = JobInfoFragment;
 }
 
+export namespace SearchForTestOrder {
+  export type Variables = SearchForTestOrderQueryVariables;
+  export type Query = SearchForTestOrderQuery;
+  export type Search = SearchForTestOrderQuery['search'];
+  export type Items = (NonNullable<SearchForTestOrderQuery['search']['items'][0]>);
+  export type Price = (NonNullable<SearchForTestOrderQuery['search']['items'][0]>)['price'];
+  export type SinglePriceInlineFragment = (DiscriminateUnion<RequireField<(NonNullable<SearchForTestOrderQuery['search']['items'][0]>)['price'], '__typename'>, { __typename: 'SinglePrice' }>);
+  export type PriceWithTax = (NonNullable<SearchForTestOrderQuery['search']['items'][0]>)['priceWithTax'];
+  export type _SinglePriceInlineFragment = (DiscriminateUnion<RequireField<(NonNullable<SearchForTestOrderQuery['search']['items'][0]>)['priceWithTax'], '__typename'>, { __typename: 'SinglePrice' }>);
+}
+
+export namespace TestShippingMethod {
+  export type Variables = TestShippingMethodQueryVariables;
+  export type Query = TestShippingMethodQuery;
+  export type TestShippingMethod = TestShippingMethodQuery['testShippingMethod'];
+  export type Price = (NonNullable<TestShippingMethodQuery['testShippingMethod']['price']>);
+}
+
 export namespace ShippingMethod {
   export type Fragment = ShippingMethodFragment;
   export type Checker = ConfigurableOperationFragment;

+ 25 - 1
admin-ui/src/app/core/providers/local-storage/local-storage.service.ts

@@ -1,6 +1,8 @@
+import { Location } from '@angular/common';
 import { Injectable } from '@angular/core';
 
-export type LocalStorageKey = 'refreshToken' | 'authToken' | 'activeChannelToken';
+export type LocalStorageKey = 'activeChannelToken';
+export type LocalStorageLocationBasedKey = 'shippingTestOrder' | 'shippingTestAddress';
 const PREFIX = 'vnd_';
 
 /**
@@ -8,6 +10,7 @@ const PREFIX = 'vnd_';
  */
 @Injectable()
 export class LocalStorageService {
+    constructor(private location: Location) {}
     /**
      * Set a key-value pair in the browser's LocalStorage
      */
@@ -16,6 +19,14 @@ export class LocalStorageService {
         localStorage.setItem(keyName, JSON.stringify(value));
     }
 
+    /**
+     * Set a key-value pair specific to the current location (url)
+     */
+    public setForCurrentLocation(key: LocalStorageLocationBasedKey, value: any) {
+        const compositeKey = this.getLocationBasedKey(key);
+        this.set(compositeKey as any, value);
+    }
+
     /**
      * Set a key-value pair in the browser's SessionStorage
      */
@@ -40,12 +51,25 @@ export class LocalStorageService {
         return result;
     }
 
+    /**
+     * Get the value of the given key for the current location (url)
+     */
+    public getForCurrentLocation(key: LocalStorageLocationBasedKey): any {
+        const compositeKey = this.getLocationBasedKey(key);
+        return this.get(compositeKey as any);
+    }
+
     public remove(key: LocalStorageKey): void {
         const keyName = this.keyName(key);
         sessionStorage.removeItem(keyName);
         localStorage.removeItem(keyName);
     }
 
+    private getLocationBasedKey(key: string) {
+        const path = this.location.path();
+        return key + path;
+    }
+
     private keyName(key: LocalStorageKey): string {
         return PREFIX + key;
     }

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

@@ -566,3 +566,38 @@ export const REINDEX = gql`
     }
     ${JOB_INFO_FRAGMENT}
 `;
+
+export const SEARCH_FOR_TEST_ORDER = gql`
+    query SearchForTestOrder($term: String!, $take: Int!) {
+        search(input: { groupByProduct: false, term: $term, take: $take }) {
+            items {
+                productVariantId
+                productVariantName
+                productPreview
+                price {
+                    ... on SinglePrice {
+                        value
+                    }
+                }
+                priceWithTax {
+                    ... on SinglePrice {
+                        value
+                    }
+                }
+                sku
+            }
+        }
+    }
+`;
+
+export const TEST_SHIPPING_METHOD = gql`
+    query TestShippingMethod($input: TestShippingMethodInput!) {
+        testShippingMethod(input: $input) {
+            eligible
+            price {
+                price
+                priceWithTax
+            }
+        }
+    }
+`;

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

@@ -33,6 +33,9 @@ import {
     GetZones,
     JobState,
     RemoveMembersFromZone,
+    SearchForTestOrder,
+    TestShippingMethod,
+    TestShippingMethodInput,
     UpdateChannel,
     UpdateChannelInput,
     UpdateCountry,
@@ -73,6 +76,8 @@ import {
     GET_TAX_RATE_LIST,
     GET_ZONES,
     REMOVE_MEMBERS_FROM_ZONE,
+    SEARCH_FOR_TEST_ORDER,
+    TEST_SHIPPING_METHOD,
     UPDATE_CHANNEL,
     UPDATE_COUNTRY,
     UPDATE_GLOBAL_SETTINGS,
@@ -309,4 +314,23 @@ export class SettingsDataService {
             input: { state: JobState.RUNNING },
         });
     }
+
+    searchForTestOrder(term: string, take: number) {
+        return this.baseDataService.query<SearchForTestOrder.Query, SearchForTestOrder.Variables>(
+            SEARCH_FOR_TEST_ORDER,
+            {
+                take,
+                term,
+            },
+        );
+    }
+
+    testShippingMethod(input: TestShippingMethodInput) {
+        return this.baseDataService.query<TestShippingMethod.Query, TestShippingMethod.Variables>(
+            TEST_SHIPPING_METHOD,
+            {
+                input,
+            },
+        );
+    }
 }

+ 30 - 1
admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.html

@@ -4,7 +4,7 @@
     <vdr-ab-right>
         <button
             class="btn btn-primary"
-            *ngIf="(isNew$ | async); else updateButton"
+            *ngIf="isNew$ | async; else updateButton"
             (click)="create()"
             [disabled]="detailForm.pristine || detailForm.invalid"
         >
@@ -89,3 +89,32 @@
         </div>
     </div>
 </form>
+<div class="testing-tool">
+    <clr-accordion>
+        <clr-accordion-panel>
+            <clr-accordion-title>{{ 'settings.test-shipping-method' | translate }}</clr-accordion-title>
+            <clr-accordion-content *clrIfExpanded>
+                <div class="clr-row">
+                    <div class="clr-col">
+                        <!--<label class="clr-control-label">{{ 'settings.test-order' | translate }}</label>-->
+                        <vdr-test-order-builder
+                            (orderLinesChange)="setTestOrderLines($event)"
+                        ></vdr-test-order-builder>
+                    </div>
+                    <div class="clr-col">
+                        <vdr-test-address-form
+                            (addressChange)="setTestAddress($event)"
+                        ></vdr-test-address-form>
+                        <vdr-shipping-method-test-result
+                            [currencyCode]="(activeChannel$ | async)?.currencyCode"
+                            [okToRun]="allTestDataPresent() && testDataUpdated && detailForm.valid"
+                            [testDataUpdated]="testDataUpdated"
+                            [testResult]="testResult$ | async"
+                            (runTest)="runTest()"
+                        ></vdr-shipping-method-test-result>
+                    </div>
+                </div>
+            </clr-accordion-content>
+        </clr-accordion-panel>
+    </clr-accordion>
+</div>

+ 9 - 0
admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.scss

@@ -0,0 +1,9 @@
+@import "variables";
+
+.testing-tool {
+    margin-top: 48px;
+    h4 {
+        margin-bottom: 12px;
+    }
+    margin-bottom: 128px;
+}

+ 67 - 3
admin-ui/src/app/settings/components/shipping-method-detail/shipping-method-detail.component.ts

@@ -1,8 +1,8 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { Observable } from 'rxjs';
-import { mergeMap, take } from 'rxjs/operators';
+import { merge, Observable, of, Subject } from 'rxjs';
+import { mergeMap, switchMap, take, takeUntil } from 'rxjs/operators';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
 import {
@@ -11,12 +11,16 @@ import {
     CreateShippingMethodInput,
     GetActiveChannel,
     ShippingMethod,
+    TestShippingMethodInput,
+    TestShippingMethodResult,
     UpdateShippingMethodInput,
 } from '../../../common/generated-types';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';
 import { ServerConfigService } from '../../../data/server-config';
+import { TestAddress } from '../test-address-form/test-address-form.component';
+import { TestOrderLine } from '../test-order-builder/test-order-builder.component';
 
 @Component({
     selector: 'vdr-shipping-method-detail',
@@ -32,6 +36,11 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
     selectedChecker?: ConfigurableOperation;
     selectedCalculator?: ConfigurableOperation;
     activeChannel$: Observable<GetActiveChannel.ActiveChannel>;
+    testAddress: TestAddress;
+    testOrderLines: TestOrderLine[];
+    testDataUpdated = false;
+    testResult$: Observable<TestShippingMethodResult | undefined>;
+    private fetchTestResult$ = new Subject<[TestAddress, TestOrderLine[]]>();
 
     constructor(
         router: Router,
@@ -62,6 +71,36 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
         this.activeChannel$ = this.dataService.settings
             .getActiveChannel()
             .mapStream(data => data.activeChannel);
+
+        this.testResult$ = this.fetchTestResult$.pipe(
+            switchMap(([address, lines]) => {
+                if (!this.selectedChecker || !this.selectedCalculator) {
+                    return of(undefined);
+                }
+                const formValue = this.detailForm.value;
+                const input: TestShippingMethodInput = {
+                    shippingAddress: { ...address, streetLine1: 'test' },
+                    lines: lines.map(l => ({ productVariantId: l.id, quantity: l.quantity })),
+                    checker: this.toAdjustmentOperationInput(this.selectedChecker, formValue.checker),
+                    calculator: this.toAdjustmentOperationInput(
+                        this.selectedCalculator,
+                        formValue.calculator,
+                    ),
+                };
+                return this.dataService.settings
+                    .testShippingMethod(input)
+                    .mapSingle(result => result.testShippingMethod);
+            }),
+        );
+
+        // tslint:disable:no-non-null-assertion
+        merge(
+            this.detailForm.get(['checker'])!.valueChanges,
+            this.detailForm.get(['calculator'])!.valueChanges,
+        )
+            .pipe(takeUntil(this.destroy$))
+            .subscribe(() => (this.testDataUpdated = true));
+        // tslint:enable:no-non-null-assertion
     }
 
     ngOnDestroy(): void {
@@ -141,6 +180,31 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
             );
     }
 
+    setTestOrderLines(event: TestOrderLine[]) {
+        this.testOrderLines = event;
+        this.testDataUpdated = true;
+    }
+
+    setTestAddress(event: TestAddress) {
+        this.testAddress = event;
+        this.testDataUpdated = true;
+    }
+
+    allTestDataPresent(): boolean {
+        return !!(
+            this.testAddress &&
+            this.testOrderLines &&
+            this.testOrderLines.length &&
+            this.selectedChecker &&
+            this.selectedCalculator
+        );
+    }
+
+    runTest() {
+        this.fetchTestResult$.next([this.testAddress, this.testOrderLines]);
+        this.testDataUpdated = false;
+    }
+
     /**
      * Maps an array of conditions or actions to the input format expected by the GraphQL API.
      */
@@ -150,7 +214,7 @@ export class ShippingMethodDetailComponent extends BaseDetailComponent<ShippingM
     ): ConfigurableOperationInput {
         return {
             code: operation.code,
-            arguments: Object.values(formValueOperations.args || {}).map((value, j) => ({
+            arguments: Object.values<any>(formValueOperations.args || {}).map((value, j) => ({
                 name: operation.args[j].name,
                 value: value.hasOwnProperty('value') ? (value as any).value : value.toString(),
                 type: operation.args[j].type,

+ 46 - 0
admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.html

@@ -0,0 +1,46 @@
+<div
+    class="test-result card"
+    [ngClass]="{
+        success: testResult?.eligible === true,
+        error: testResult?.eligible === false,
+        unknown: !testResult
+    }"
+>
+    <div class="card-header">
+        {{ 'settings.test-result' | translate }}
+    </div>
+    <div class="card-block">
+        <div class="result-details" [class.stale]="testDataUpdated">
+            <vdr-labeled-data [label]="'settings.elibigle' | translate">
+                <div class="eligible-icon">
+                    <clr-icon
+                        shape="success-standard"
+                        class="is-solid success"
+                        *ngIf="testResult?.eligible"
+                    ></clr-icon>
+                    <clr-icon
+                        shape="ban"
+                        class="is-solid error"
+                        *ngIf="testResult?.eligible === false"
+                    ></clr-icon>
+                    <clr-icon shape="unknown-status" *ngIf="!testResult"></clr-icon>
+                </div>
+                {{ testResult?.eligible }}
+            </vdr-labeled-data>
+            <vdr-labeled-data [label]="'common.price' | translate" *ngIf="testResult?.price?.price as price">
+                {{ price / 100 | currency: currencyCode }}
+            </vdr-labeled-data>
+            <vdr-labeled-data
+                [label]="'common.price-with-tax' | translate"
+                *ngIf="testResult?.price?.priceWithTax as priceWithTax"
+            >
+                {{ priceWithTax / 100 | currency: currencyCode }}
+            </vdr-labeled-data>
+        </div>
+    </div>
+    <div class="card-footer">
+        <button class="btn btn-secondary" (click)="runTest.emit()" [disabled]="!okToRun">
+            {{ 'settings.test-shipping-method' | translate }}
+        </button>
+    </div>
+</div>

+ 28 - 0
admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.scss

@@ -0,0 +1,28 @@
+@import "variables";
+.test-result {
+    &.success .card-block {
+        background-color: $color-success-100;
+    }
+    &.error .card-block {
+        background-color: $color-error-100;
+    }
+    &.unknown .card-block {
+        background-color: $color-grey-100;
+    }
+}
+.result-details {
+    transition: opacity 0.2s;
+    &.stale {
+        opacity: 0.5;
+    }
+}
+
+.eligible-icon {
+    display: inline-block;
+    .success {
+        color: $color-success-500;
+    }
+    .error {
+        color: $color-error-500;
+    }
+}

+ 17 - 0
admin-ui/src/app/settings/components/shipping-method-test-result/shipping-method-test-result.component.ts

@@ -0,0 +1,17 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+
+import { CurrencyCode, TestShippingMethodResult } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-shipping-method-test-result',
+    templateUrl: './shipping-method-test-result.component.html',
+    styleUrls: ['./shipping-method-test-result.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ShippingMethodTestResultComponent {
+    @Input() testResult: TestShippingMethodResult;
+    @Input() okToRun = false;
+    @Input() testDataUpdated = false;
+    @Input() currencyCode: CurrencyCode;
+    @Output() runTest = new EventEmitter<void>();
+}

+ 29 - 0
admin-ui/src/app/settings/components/test-address-form/test-address-form.component.html

@@ -0,0 +1,29 @@
+<div class="card">
+    <div class="card-header">
+        {{ 'settings.test-address' | translate }}
+    </div>
+    <div class="card-block">
+        <form [formGroup]="form">
+            <clr-input-container>
+                <label>{{ 'customer.city' | translate }}</label>
+                <input formControlName="city" type="text" clrInput />
+            </clr-input-container>
+            <clr-input-container>
+                <label>{{ 'customer.province' | translate }}</label>
+                <input formControlName="province" type="text" clrInput />
+            </clr-input-container>
+            <clr-input-container>
+                <label>{{ 'customer.postal-code' | translate }}</label>
+                <input formControlName="postalCode" type="text" clrInput />
+            </clr-input-container>
+            <clr-input-container>
+                <label>{{ 'customer.country' | translate }}</label>
+                <select name="countryCode" formControlName="countryCode" clrInput clrSelect>
+                    <option *ngFor="let country of availableCountries$ | async" [value]="country.code">
+                        {{ country.name }}
+                    </option>
+                </select>
+            </clr-input-container>
+        </form>
+    </div>
+</div>

+ 3 - 0
admin-ui/src/app/settings/components/test-address-form/test-address-form.component.scss

@@ -0,0 +1,3 @@
+clr-input-container {
+    margin-bottom: 12px;
+}

+ 61 - 0
admin-ui/src/app/settings/components/test-address-form/test-address-form.component.ts

@@ -0,0 +1,61 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
+import { FormBuilder, FormGroup } from '@angular/forms';
+import { Observable, Subscription } from 'rxjs';
+
+import { GetAvailableCountries } from '../../../common/generated-types';
+import { LocalStorageService } from '../../../core/providers/local-storage/local-storage.service';
+import { DataService } from '../../../data/providers/data.service';
+
+export interface TestAddress {
+    city: string;
+    province: string;
+    postalCode: string;
+    countryCode: string;
+}
+
+@Component({
+    selector: 'vdr-test-address-form',
+    templateUrl: './test-address-form.component.html',
+    styleUrls: ['./test-address-form.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TestAddressFormComponent implements OnInit, OnDestroy {
+    @Output() addressChange = new EventEmitter<TestAddress>();
+    availableCountries$: Observable<GetAvailableCountries.Items[]>;
+    form: FormGroup;
+    private subscription: Subscription;
+
+    constructor(
+        private formBuilder: FormBuilder,
+        private dataService: DataService,
+        private localStorageService: LocalStorageService,
+    ) {}
+
+    ngOnInit() {
+        this.availableCountries$ = this.dataService.settings
+            .getAvailableCountries()
+            .mapSingle(result => result.countries.items);
+        const storedValue = this.localStorageService.getForCurrentLocation('shippingTestAddress');
+        const initialValue: TestAddress = storedValue
+            ? storedValue
+            : {
+                  city: '',
+                  countryCode: '',
+                  postalCode: '',
+                  province: '',
+              };
+        this.addressChange.emit(initialValue);
+
+        this.form = this.formBuilder.group(initialValue);
+        this.subscription = this.form.valueChanges.subscribe(value => {
+            this.localStorageService.setForCurrentLocation('shippingTestAddress', value);
+            this.addressChange.emit(value);
+        });
+    }
+
+    ngOnDestroy() {
+        if (this.subscription) {
+            this.subscription.unsubscribe();
+        }
+    }
+}

+ 71 - 0
admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.html

@@ -0,0 +1,71 @@
+<div class="card">
+    <div class="card-header">
+        {{ 'settings.test-order' | translate }}
+    </div>
+    <table class="order-lines table" *ngIf="lines.length; else emptyPlaceholder">
+        <thead>
+            <tr>
+                <th></th>
+                <th>{{ 'order.product-name' | translate }}</th>
+                <th>{{ 'order.product-sku' | translate }}</th>
+                <th>{{ 'order.unit-price' | translate }}</th>
+                <th>{{ 'order.quantity' | translate }}</th>
+                <th>{{ 'order.total' | translate }}</th>
+            </tr>
+        </thead>
+        <tr *ngFor="let line of lines" class="order-line">
+            <td class="align-middle thumb">
+                <img [src]="line.preview + '?preset=tiny'" />
+            </td>
+            <td class="align-middle name">{{ line.name }}</td>
+            <td class="align-middle sku">{{ line.sku }}</td>
+            <td class="align-middle unit-price">
+                {{ line.unitPriceWithTax / 100 | currency: currencyCode }}
+            </td>
+            <td class="align-middle quantity">
+                <input
+                    [(ngModel)]="line.quantity"
+                    (change)="orderLinesChange.emit(lines)"
+                    type="number"
+                    max="9999"
+                    min="1"
+                />
+                <button class="icon-button" (click)="removeLine(line)">
+                    <clr-icon shape="trash"></clr-icon>
+                </button>
+            </td>
+            <td class="align-middle total">
+                {{ (line.unitPriceWithTax * line.quantity) / 100 | currency: currencyCode }}
+            </td>
+        </tr>
+        <tr class="sub-total">
+            <td class="left">{{ 'order.sub-total' | translate }}</td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td></td>
+            <td>{{ subTotal / 100 | currency: currencyCode }}</td>
+        </tr>
+    </table>
+
+    <ng-template #emptyPlaceholder>
+        <div class="card-block empty-placeholder">
+            <div class="empty-text">{{ 'settings.add-products-to-test-order' | translate }}</div>
+            <clr-icon shape="arrow" dir="down" size="96"></clr-icon>
+        </div>
+    </ng-template>
+    <div class="card-block">
+        <ng-select
+            #autoComplete
+            [items]="searchResults$ | async"
+            bindLabel="productVariantName"
+            [addTag]="false"
+            [multiple]="false"
+            [hideSelected]="true"
+            [loading]="searchLoading"
+            [typeahead]="searchInput$"
+            [placeholder]="'settings.search-by-product-name-or-sku' | translate"
+            (change)="selectResult($event)"
+        ></ng-select>
+    </div>
+</div>

+ 8 - 0
admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.scss

@@ -0,0 +1,8 @@
+@import "variables";
+.empty-placeholder {
+    color: $color-grey-400;
+    text-align: center;
+}
+.empty-text {
+    font-size: 22px;
+}

+ 117 - 0
admin-ui/src/app/settings/components/test-order-builder/test-order-builder.component.ts

@@ -0,0 +1,117 @@
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    Input,
+    OnInit,
+    Output,
+    ViewChild,
+} from '@angular/core';
+import { NgSelectComponent } from '@ng-select/ng-select';
+import { concat, merge, Observable, of, Subject } from 'rxjs';
+import { debounceTime, distinctUntilChanged, mapTo, switchMap, tap } from 'rxjs/operators';
+
+import { CurrencyCode, SearchForTestOrder } from '../../../common/generated-types';
+import { LocalStorageService } from '../../../core/providers/local-storage/local-storage.service';
+import { DataService } from '../../../data/providers/data.service';
+
+export interface TestOrderLine {
+    id: string;
+    name: string;
+    preview: string;
+    sku: string;
+    unitPriceWithTax: number;
+    quantity: number;
+}
+
+@Component({
+    selector: 'vdr-test-order-builder',
+    templateUrl: './test-order-builder.component.html',
+    styleUrls: ['./test-order-builder.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TestOrderBuilderComponent implements OnInit {
+    @Output() orderLinesChange = new EventEmitter<TestOrderLine[]>();
+
+    lines: TestOrderLine[] = [];
+    currencyCode: CurrencyCode;
+    searchInput$ = new Subject<string>();
+    resultSelected$ = new Subject<void>();
+    searchLoading = false;
+    searchResults$: Observable<SearchForTestOrder.Items[]>;
+    @ViewChild('autoComplete', { static: true })
+    private ngSelect: NgSelectComponent;
+
+    get subTotal(): number {
+        return this.lines.reduce((sum, l) => sum + l.unitPriceWithTax * l.quantity, 0);
+    }
+    constructor(private dataService: DataService, private localStorageService: LocalStorageService) {}
+
+    ngOnInit() {
+        this.lines = this.loadFromLocalStorage();
+        if (this.lines) {
+            this.orderLinesChange.emit(this.lines);
+        }
+        this.initSearchResults();
+        this.dataService.settings.getActiveChannel('cache-first').single$.subscribe(result => {
+            this.currencyCode = result.activeChannel.currencyCode;
+        });
+    }
+
+    selectResult(result: SearchForTestOrder.Items) {
+        if (result) {
+            this.resultSelected$.next();
+            this.ngSelect.clearModel();
+            this.addToLines(result);
+        }
+    }
+
+    removeLine(line: TestOrderLine) {
+        this.lines = this.lines.filter(l => l.id !== line.id);
+        this.persistToLocalStorage();
+        this.orderLinesChange.emit(this.lines);
+    }
+
+    private addToLines(result: SearchForTestOrder.Items) {
+        if (!this.lines.find(l => l.id === result.productVariantId)) {
+            this.lines.push({
+                id: result.productVariantId,
+                name: result.productVariantName,
+                preview: result.productPreview,
+                quantity: 1,
+                sku: result.sku,
+                unitPriceWithTax: result.priceWithTax.value,
+            });
+            this.persistToLocalStorage();
+            this.orderLinesChange.emit(this.lines);
+        }
+    }
+
+    private initSearchResults() {
+        const searchItems$ = this.searchInput$.pipe(
+            debounceTime(200),
+            distinctUntilChanged(),
+            tap(() => (this.searchLoading = true)),
+            switchMap(term => {
+                if (!term) {
+                    return of([]);
+                }
+                return this.dataService.settings
+                    .searchForTestOrder(term, 10)
+                    .mapSingle(result => result.search.items);
+            }),
+            tap(() => (this.searchLoading = false)),
+        );
+
+        const clear$ = this.resultSelected$.pipe(mapTo([]));
+        this.searchResults$ = concat(of([]), merge(searchItems$, clear$));
+    }
+
+    private persistToLocalStorage() {
+        this.localStorageService.setForCurrentLocation('shippingTestOrder', this.lines);
+    }
+
+    private loadFromLocalStorage(): TestOrderLine[] {
+        return this.localStorageService.getForCurrentLocation('shippingTestOrder') || [];
+    }
+}

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

@@ -17,10 +17,13 @@ import { RoleDetailComponent } from './components/role-detail/role-detail.compon
 import { RoleListComponent } from './components/role-list/role-list.component';
 import { ShippingMethodDetailComponent } from './components/shipping-method-detail/shipping-method-detail.component';
 import { ShippingMethodListComponent } from './components/shipping-method-list/shipping-method-list.component';
+import { ShippingMethodTestResultComponent } from './components/shipping-method-test-result/shipping-method-test-result.component';
 import { TaxCategoryDetailComponent } from './components/tax-category-detail/tax-category-detail.component';
 import { TaxCategoryListComponent } from './components/tax-category-list/tax-category-list.component';
 import { TaxRateDetailComponent } from './components/tax-rate-detail/tax-rate-detail.component';
 import { TaxRateListComponent } from './components/tax-rate-list/tax-rate-list.component';
+import { TestAddressFormComponent } from './components/test-address-form/test-address-form.component';
+import { TestOrderBuilderComponent } from './components/test-order-builder/test-order-builder.component';
 import { ZoneSelectorDialogComponent } from './components/zone-selector-dialog/zone-selector-dialog.component';
 import { AdministratorResolver } from './providers/routing/administrator-resolver';
 import { ChannelResolver } from './providers/routing/channel-resolver';
@@ -55,6 +58,9 @@ import { settingsRoutes } from './settings.routes';
         PaymentMethodListComponent,
         PaymentMethodDetailComponent,
         GlobalSettingsComponent,
+        TestOrderBuilderComponent,
+        TestAddressFormComponent,
+        ShippingMethodTestResultComponent,
     ],
     entryComponents: [ZoneSelectorDialogComponent],
     providers: [

+ 9 - 0
admin-ui/src/i18n-messages/en.json

@@ -131,6 +131,8 @@
     "notify-update-success": "Updated { entity }",
     "open": "Open",
     "password": "Password",
+    "price": "Price",
+    "price-with-tax": "Price with tax",
     "remember-me": "Remember me",
     "remove": "Remove",
     "results-count": "{ count } {count, plural, one {result} other {results}}",
@@ -477,6 +479,7 @@
   "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 }\"",
+    "add-products-to-test-order": "Add products to the test order",
     "administrator": "Administrator",
     "catalog": "Catalog",
     "channel-token": "Channel token",
@@ -493,6 +496,7 @@
     "default-shipping-zone": "Default shipping zone",
     "default-tax-zone": "Default tax zone",
     "delete": "Delete",
+    "elibigle": "Eligible",
     "email-address": "Email address",
     "first-name": "First name",
     "last-name": "Last name",
@@ -506,6 +510,7 @@
     "remove-countries-from-zone": "Remove countries from zone...",
     "remove-countries-from-zone-success": "Removed { countryCount } {countryCount, plural, one {country} other {countries}} from zone \"{ zoneName }\"",
     "roles": "Roles",
+    "search-by-product-name-or-sku": "Search by product name or SKU",
     "section": "Section",
     "select-zone": "Select zone",
     "settings": "Settings",
@@ -513,6 +518,10 @@
     "shipping-eligibility-checker": "Shipping eligibility checker",
     "tax-category": "Tax category",
     "tax-rate": "Tax rate",
+    "test-address": "Test address",
+    "test-order": "Test order",
+    "test-result": "Test result",
+    "test-shipping-method": "Test shipping method",
     "track-inventory-default": "Track inventory by default",
     "update": "Update",
     "zone": "Zone"