Procházet zdrojové kódy

feat: Initial implementation of Assets & file uploads

Relates to #22. The basic mechanism is there, but more work needs to be done on:

1. figuring out how to properly modularize the asset server
2. transforming asset identifiers into absolute urls at the API layer
3. handling other file types (video & default previews)
4. providing url-based image manipulation for the default asset server
Michael Bromley před 7 roky
rodič
revize
0e5a8e4d54
51 změnil soubory, kde provedl 1276 přidání a 37 odebrání
  1. 1 0
      .gitignore
  2. 1 1
      admin-ui/package.json
  3. 1 1
      admin-ui/src/app/administrator/components/role-detail/role-detail.component.ts
  4. 2 0
      admin-ui/src/app/catalog/catalog.module.ts
  5. 8 0
      admin-ui/src/app/catalog/catalog.routes.ts
  6. 25 0
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.html
  7. 0 0
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.scss
  8. 30 0
      admin-ui/src/app/catalog/components/asset-list/asset-list.component.ts
  9. 1 1
      admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.ts
  10. 1 1
      admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.ts
  11. 1 1
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  12. 1 0
      admin-ui/src/app/catalog/providers/routing/product-resolver.ts
  13. 6 0
      admin-ui/src/app/core/components/main-nav/main-nav.component.html
  14. 5 8
      admin-ui/src/app/data/data.module.ts
  15. 39 0
      admin-ui/src/app/data/definitions/product-definitions.ts
  16. 2 1
      admin-ui/src/app/data/omit-typename-link.ts
  17. 2 0
      admin-ui/src/app/data/providers/data.service.mock.ts
  18. 22 0
      admin-ui/src/app/data/providers/product-data.service.ts
  19. 24 13
      admin-ui/yarn.lock
  20. 6 0
      docs/diagrams/full-class-diagram.puml
  21. 0 0
      schema.json
  22. 3 0
      server/e2e/__snapshots__/product.e2e-spec.ts.snap
  23. 3 0
      server/package.json
  24. 2 0
      server/src/api/api.module.ts
  25. 37 0
      server/src/api/asset/asset.api.graphql
  26. 40 0
      server/src/api/asset/asset.resolver.ts
  27. 5 0
      server/src/api/graphql-config.service.ts
  28. 13 0
      server/src/bootstrap.ts
  29. 5 0
      server/src/common/types/asset-type.graphql
  30. 1 0
      server/src/common/types/common-types.graphql
  31. 13 0
      server/src/config/asset-preview-strategy/asset-preview-strategy.ts
  32. 23 0
      server/src/config/asset-preview-strategy/default-asset-preview-strategy.ts
  33. 37 0
      server/src/config/asset-storage-strategy/asset-storage-strategy.ts
  34. 60 0
      server/src/config/asset-storage-strategy/local-asset-storage-strategy.ts
  35. 17 2
      server/src/config/config.service.ts
  36. 5 0
      server/src/config/entity-id-strategy/entity-id-strategy.ts
  37. 23 0
      server/src/config/vendure-config.ts
  38. 29 0
      server/src/entity/asset/asset.entity.ts
  39. 13 0
      server/src/entity/asset/asset.graphql
  40. 2 0
      server/src/entity/entities.ts
  41. 10 1
      server/src/entity/product/product.entity.ts
  42. 1 0
      server/src/entity/product/product.graphql
  43. 68 0
      server/src/service/asset.service.ts
  44. 1 1
      server/src/service/product.service.ts
  45. 2 0
      server/src/service/service.module.ts
  46. 413 3
      server/yarn.lock
  47. 154 0
      shared/generated-types.ts
  48. 0 0
      shared/normalize-string.spec.ts
  49. 0 0
      shared/normalize-string.ts
  50. 96 0
      shared/omit.spec.ts
  51. 22 3
      shared/omit.ts

+ 1 - 0
.gitignore

@@ -388,4 +388,5 @@ docker-compose.yml
 .env
 dist
 server/e2e/__data__/*
+server/assets
 !server/e2e/__data__/.gitkeep

+ 1 - 1
admin-ui/package.json

@@ -29,12 +29,12 @@
     "@ngx-translate/http-loader": "^3.0.1",
     "@webcomponents/custom-elements": "1.0.0",
     "apollo-angular": "^1.2.0",
-    "apollo-angular-link-http": "^1.2.0",
     "apollo-cache-inmemory": "^1.2.7",
     "apollo-client": "^2.3.8",
     "apollo-link": "^1.2.2",
     "apollo-link-context": "^1.0.8",
     "apollo-link-state": "^0.4.1",
+    "apollo-upload-client": "^8.1.0",
     "core-js": "^2.5.4",
     "graphql": "^0.13.2",
     "graphql-tag": "^2.9.2",

+ 1 - 1
admin-ui/src/app/administrator/components/role-detail/role-detail.component.ts

@@ -4,9 +4,9 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { Observable } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 import { CreateRoleInput, LanguageCode, Permission, Role, UpdateRoleInput } from 'shared/generated-types';
+import { normalizeString } from 'shared/normalize-string';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
-import { normalizeString } from '../../../common/utilities/normalize-string';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';

+ 2 - 0
admin-ui/src/app/catalog/catalog.module.ts

@@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
 
 import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
+import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { CreateOptionGroupDialogComponent } from './components/create-option-group-dialog/create-option-group-dialog.component';
 import { CreateOptionGroupFormComponent } from './components/create-option-group-form/create-option-group-form.component';
 import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
@@ -37,6 +38,7 @@ import { ProductResolver } from './providers/routing/product-resolver';
         ProductVariantsListComponent,
         FacetValueSelectorComponent,
         ApplyFacetDialogComponent,
+        AssetListComponent,
     ],
     entryComponents: [
         CreateOptionGroupDialogComponent,

+ 8 - 0
admin-ui/src/app/catalog/catalog.routes.ts

@@ -5,6 +5,7 @@ import { createResolveData } from '../common/base-entity-resolver';
 import { detailBreadcrumb } from '../common/detail-breadcrumb';
 import { _ } from '../core/providers/i18n/mark-for-extraction';
 
+import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { FacetDetailComponent } from './components/facet-detail/facet-detail.component';
 import { FacetListComponent } from './components/facet-list/facet-list.component';
 import { ProductDetailComponent } from './components/product-detail/product-detail.component';
@@ -43,6 +44,13 @@ export const catalogRoutes: Route[] = [
             breadcrumb: facetBreadcrumb,
         },
     },
+    {
+        path: 'assets',
+        component: AssetListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.assets'),
+        },
+    },
 ];
 
 export function productBreadcrumb(data: any, params: any) {

+ 25 - 0
admin-ui/src/app/catalog/components/asset-list/asset-list.component.html

@@ -0,0 +1,25 @@
+<vdr-action-bar>
+    <vdr-ab-right>
+        <!--<a class="btn btn-primary" [routerLink]="['./create']">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'catalog.create-new-asset' | translate }}
+        </a>-->
+        <input type="file" (change)="fileSelected($event)">
+    </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-dt-column>{{ 'catalog.name' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.preview' | translate }}</vdr-dt-column>
+    <ng-template let-asset="item">
+        <td class="left">{{ asset.id }}</td>
+        <td class="left">{{ asset.code }}</td>
+        <td class="left"><img [src]="asset.preview"></td>
+    </ng-template>
+</vdr-data-table>

+ 0 - 0
admin-ui/src/app/catalog/components/asset-list/asset-list.component.scss


+ 30 - 0
admin-ui/src/app/catalog/components/asset-list/asset-list.component.ts

@@ -0,0 +1,30 @@
+import { Component } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { GetAssetList, GetAssetList_assets_items } from 'shared/generated-types';
+
+import { BaseListComponent } from '../../../common/base-list.component';
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-asset-list',
+    templateUrl: './asset-list.component.html',
+    styleUrls: ['./asset-list.component.scss'],
+})
+export class AssetListComponent extends BaseListComponent<GetAssetList, GetAssetList_assets_items> {
+    constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
+        super(router, route);
+        super.setQueryFn(
+            (...args: any[]) => this.dataService.product.getAssetList(...args),
+            data => data.assets,
+        );
+    }
+
+    fileSelected(event: Event) {
+        const files = (event.target as HTMLInputElement).files;
+        if (files && files.length === 1) {
+            this.dataService.product.createAsset(files[0]).subscribe(res => {
+                // empty
+            });
+        }
+    }
+}

+ 1 - 1
admin-ui/src/app/catalog/components/create-option-group-form/create-option-group-form.component.ts

@@ -6,9 +6,9 @@ import {
     CreateProductOptionGroupInput,
     CreateProductOptionInput,
 } from 'shared/generated-types';
+import { normalizeString } from 'shared/normalize-string';
 
 import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
-import { normalizeString } from '../../../common/utilities/normalize-string';
 import { DataService } from '../../../data/providers/data.service';
 
 @Component({

+ 1 - 1
admin-ui/src/app/catalog/components/facet-detail/facet-detail.component.ts

@@ -10,12 +10,12 @@ import {
     LanguageCode,
     UpdateFacetValueInput,
 } from 'shared/generated-types';
+import { normalizeString } from 'shared/normalize-string';
 import { CustomFieldConfig } from 'shared/shared-types';
 import { notNullOrUndefined } from 'shared/shared-utils';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
 import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
-import { normalizeString } from '../../../common/utilities/normalize-string';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';

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

@@ -10,12 +10,12 @@ import {
     UpdateProductInput,
     UpdateProductVariantInput,
 } from 'shared/generated-types';
+import { normalizeString } from 'shared/normalize-string';
 import { CustomFieldConfig } from 'shared/shared-types';
 import { notNullOrUndefined } from 'shared/shared-utils';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
 import { createUpdatedTranslatable } from '../../../common/utilities/create-updated-translatable';
-import { normalizeString } from '../../../common/utilities/normalize-string';
 import { _ } from '../../../core/providers/i18n/mark-for-extraction';
 import { NotificationService } from '../../../core/providers/notification/notification.service';
 import { DataService } from '../../../data/providers/data.service';

+ 1 - 0
admin-ui/src/app/catalog/providers/routing/product-resolver.ts

@@ -20,6 +20,7 @@ export class ProductResolver extends BaseEntityResolver<ProductWithVariants> {
                 name: '',
                 slug: '',
                 image: '',
+                assets: [],
                 description: '',
                 translations: [],
                 optionGroups: [],

+ 6 - 0
admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -17,6 +17,12 @@
                     <clr-icon shape="tag" size="20"></clr-icon>{{ 'nav.facets' | translate }}
                 </a>
                 </li>
+                <li><a class="nav-link"
+                       [routerLink]="['/catalog', 'assets']"
+                       routerLinkActive="active">
+                    <clr-icon shape="tag" size="20"></clr-icon>{{ 'nav.assets' | translate }}
+                </a>
+                </li>
                 <li>
                     <a class="nav-link">
                         <clr-icon shape="folder-open" size="20"></clr-icon>{{ 'nav.categories' | translate }}

+ 5 - 8
admin-ui/src/app/data/data.module.ts

@@ -1,12 +1,12 @@
 import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
 import { APP_INITIALIZER, NgModule } from '@angular/core';
 import { Apollo, APOLLO_OPTIONS, ApolloModule } from 'apollo-angular';
-import { HttpLink, HttpLinkModule } from 'apollo-angular-link-http';
 import { InMemoryCache } from 'apollo-cache-inmemory';
 import { ApolloClientOptions } from 'apollo-client';
 import { ApolloLink } from 'apollo-link';
 import { setContext } from 'apollo-link-context';
 import { withClientState } from 'apollo-link-state';
+import { createUploadLink } from 'apollo-upload-client';
 import { API_PATH } from 'shared/shared-constants';
 
 import { environment } from '../../environments/environment';
@@ -34,10 +34,7 @@ const stateLink = withClientState({
     defaults: clientDefaults,
 });
 
-export function createApollo(
-    httpLink: HttpLink,
-    localStorageService: LocalStorageService,
-): ApolloClientOptions<any> {
+export function createApollo(localStorageService: LocalStorageService): ApolloClientOptions<any> {
     return {
         link: ApolloLink.from([
             stateLink,
@@ -57,7 +54,7 @@ export function createApollo(
                     };
                 }
             }),
-            httpLink.create({ uri: `${API_URL}/${API_PATH}` }),
+            createUploadLink({ uri: `${API_URL}/${API_PATH}` }),
         ]),
         cache: apolloCache,
     };
@@ -68,7 +65,7 @@ export function createApollo(
  * state via the apollo-link-state package.
  */
 @NgModule({
-    imports: [ApolloModule, HttpLinkModule, HttpClientModule],
+    imports: [ApolloModule, HttpClientModule],
     exports: [],
     declarations: [],
     providers: [
@@ -77,7 +74,7 @@ export function createApollo(
         {
             provide: APOLLO_OPTIONS,
             useFactory: createApollo,
-            deps: [HttpLink, LocalStorageService],
+            deps: [LocalStorageService],
         },
         { provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true },
         {

+ 39 - 0
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -35,6 +35,12 @@ export const PRODUCT_WITH_VARIANTS_FRAGMENT = gql`
         slug
         image
         description
+        assets {
+            description
+            name
+            preview
+            type
+        }
         translations {
             languageCode
             name
@@ -75,6 +81,18 @@ export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
     }
 `;
 
+export const ASSET_FRAGMENT = gql`
+    fragment Asset on Asset {
+        id
+        name
+        mimetype
+        type
+        name
+        preview
+        source
+    }
+`;
+
 export const UPDATE_PRODUCT = gql`
     mutation UpdateProduct($input: UpdateProductInput!) {
         updateProduct(input: $input) {
@@ -208,3 +226,24 @@ export const GET_PRODUCT_OPTION_GROUPS = gql`
         }
     }
 `;
+
+export const GET_ASSET_LIST = gql`
+    query GetAssetList($options: AssetListOptions) {
+        assets(options: $options) {
+            items {
+                ...Asset
+            }
+            totalItems
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;
+
+export const CREATE_ASSET = gql`
+    mutation CreateAsset($input: CreateAssetInput!) {
+        createAsset(input: $input) {
+            ...Asset
+        }
+    }
+    ${ASSET_FRAGMENT}
+`;

+ 2 - 1
admin-ui/src/app/data/omit-typename-link.ts

@@ -1,4 +1,5 @@
 import { ApolloLink } from 'apollo-link';
+import { omit } from 'shared/omit';
 
 /**
  * The "__typename" property added by Apollo Client causes errors when posting the entity
@@ -11,7 +12,7 @@ export class OmitTypenameLink extends ApolloLink {
     constructor() {
         super((operation, forward) => {
             if (operation.variables) {
-                operation.variables = JSON.parse(JSON.stringify(operation.variables), this.omitTypename);
+                operation.variables = omit(operation.variables, ['__typename'], true);
             }
 
             return forward ? forward(operation) : null;

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

@@ -62,6 +62,8 @@ export class MockDataService implements DataServiceMock {
         getProductOptionGroups: spyQueryResult('getProductOptionGroups'),
         generateProductVariants: spyObservable('generateProductVariants'),
         applyFacetValuesToProductVariants: spyObservable('applyFacetValuesToProductVariants'),
+        getAssetList: spyQueryResult('getAssetList'),
+        createAsset: spyObservable('createAsset'),
     };
     auth = {
         checkLoggedIn: spyObservable('checkLoggedIn'),

+ 22 - 0
admin-ui/src/app/data/providers/product-data.service.ts

@@ -4,6 +4,9 @@ import {
     AddOptionGroupToProductVariables,
     ApplyFacetValuesToProductVariants,
     ApplyFacetValuesToProductVariantsVariables,
+    CreateAsset,
+    CreateAssetInput,
+    CreateAssetVariables,
     CreateProduct,
     CreateProductInput,
     CreateProductOptionGroup,
@@ -12,6 +15,8 @@ import {
     CreateProductVariables,
     GenerateProductVariants,
     GenerateProductVariantsVariables,
+    GetAssetList,
+    GetAssetListVariables,
     GetProductList,
     GetProductListVariables,
     GetProductOptionGroups,
@@ -34,9 +39,11 @@ import { addCustomFields } from '../add-custom-fields';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
     APPLY_FACET_VALUE_TO_PRODUCT_VARIANTS,
+    CREATE_ASSET,
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
     GENERATE_PRODUCT_VARIANTS,
+    GET_ASSET_LIST,
     GET_PRODUCT_LIST,
     GET_PRODUCT_OPTION_GROUPS,
     GET_PRODUCT_WITH_VARIANTS,
@@ -166,4 +173,19 @@ export class ProductDataService {
             productVariantIds,
         });
     }
+
+    getAssetList(take: number = 10, skip: number = 0): QueryResult<GetAssetList, GetAssetListVariables> {
+        return this.baseDataService.query<GetAssetList, GetAssetListVariables>(GET_ASSET_LIST, {
+            options: {
+                skip,
+                take,
+            },
+        });
+    }
+
+    createAsset(file: File): Observable<CreateAsset> {
+        return this.baseDataService.mutate<CreateAsset, CreateAssetVariables>(CREATE_ASSET, {
+            input: { file },
+        });
+    }
 }

+ 24 - 13
admin-ui/yarn.lock

@@ -205,6 +205,12 @@
     source-map "^0.5.0"
     trim-right "^1.0.1"
 
+"@babel/runtime@^7.0.0-beta.38", "@babel/runtime@^7.0.0-beta.51":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c"
+  dependencies:
+    regenerator-runtime "^0.12.0"
+
 "@babel/types@7.0.0-beta.38":
   version "7.0.0-beta.38"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0-beta.38.tgz#2ce2443f7dc6ad535a67db4940cbd34e64035a6f"
@@ -780,19 +786,6 @@ anymatch@^2.0.0:
     micromatch "^3.1.4"
     normalize-path "^2.1.1"
 
-apollo-angular-link-http-common@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/apollo-angular-link-http-common/-/apollo-angular-link-http-common-1.2.0.tgz#76be0d02478b266dc99d44b54483418e356bd410"
-  dependencies:
-    tslib "^1.9.0"
-
-apollo-angular-link-http@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/apollo-angular-link-http/-/apollo-angular-link-http-1.2.0.tgz#5eb243e45561032022b5bb9cb2953fc3afaa5418"
-  dependencies:
-    apollo-angular-link-http-common "~1.2.0"
-    tslib "^1.9.0"
-
 apollo-angular@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/apollo-angular/-/apollo-angular-1.2.0.tgz#0eebb785e1365f473e437c8a738c71d68a07d0f2"
@@ -922,6 +915,14 @@ apollo-link@^1.0.0, apollo-link@^1.2.2:
     apollo-utilities "^1.0.0"
     zen-observable-ts "^0.8.9"
 
+apollo-upload-client@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/apollo-upload-client/-/apollo-upload-client-8.1.0.tgz#db99eed6af926dbd54cb0bbde30345672c97fc5f"
+  dependencies:
+    "@babel/runtime" "^7.0.0-beta.51"
+    apollo-link-http-common "^0.2.4"
+    extract-files "^3.1.0"
+
 apollo-utilities@^1.0.0, apollo-utilities@^1.0.1, apollo-utilities@^1.0.19, apollo-utilities@^1.0.8:
   version "1.0.19"
   resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.19.tgz#f2d253bb8aa1395b62ded2f7749884233b5838e2"
@@ -2888,6 +2889,12 @@ extglob@^2.0.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+extract-files@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/extract-files/-/extract-files-3.1.0.tgz#b70424c9d4a1a4208efe22069388f428e4ae00f1"
+  dependencies:
+    "@babel/runtime" "^7.0.0-beta.38"
+
 extract-stack@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/extract-stack/-/extract-stack-1.0.0.tgz#b97acaf9441eea2332529624b732fc5a1c8165fa"
@@ -6161,6 +6168,10 @@ regenerator-runtime@^0.11.0:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
 
+regenerator-runtime@^0.12.0:
+  version "0.12.1"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
+
 regex-cache@^0.4.2:
   version "0.4.4"
   resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"

+ 6 - 0
docs/diagrams/full-class-diagram.puml

@@ -38,6 +38,7 @@ class Address {
 }
 class Product {
     variants: ProductVariant[]
+    assets: Asset[]
 }
 class ProductOptionGroup
 class ProductOption {
@@ -45,6 +46,7 @@ class ProductOption {
 }
 class ProductVariant {
     sku: string
+    assets: Asset[]
     price: ProductVariantPrice[]
 }
 class ProductVariantPrice {
@@ -79,6 +81,8 @@ class FacetValue {
 }
 class Category {
 }
+class Asset {
+}
 
 Customer --  User
 Administrator -- User
@@ -87,10 +91,12 @@ Role o-- "1..*" Permission
 Role -- Channel
 Customer *-- "0..*" Address
 Product *-- "1..*" ProductVariant
+Product *-- "0..*" Asset
 ProductOptionGroup *-- "1..*" ProductOption
 Product o-- "0..*" ProductOptionGroup
 ProductVariant o-- "0..*" ProductOption
 ProductVariant o-- "0..*" FacetValue
+ProductVariant o-- "0..*" Asset
 Facet *-- "1..*" FacetValue
 Category o-- "1..*" FacetValue
 Customer *-- "0..*" Order

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
schema.json


+ 3 - 0
server/e2e/__snapshots__/product.e2e-spec.ts.snap

@@ -2,6 +2,7 @@
 
 exports[`Product resolver product mutation createProduct creates a new Product 1`] = `
 Object {
+  "assets": Array [],
   "description": "A baked potato",
   "id": "21",
   "image": "baked-potato",
@@ -29,6 +30,7 @@ Object {
 
 exports[`Product resolver product mutation updateProduct updates a Product 1`] = `
 Object {
+  "assets": Array [],
   "description": "A blob of mashed potato",
   "id": "21",
   "image": "mashed-potato",
@@ -96,6 +98,7 @@ Array [
 
 exports[`Product resolver product query returns expected properties 1`] = `
 Object {
+  "assets": Array [],
   "description": "en Sed dignissimos debitis incidunt accusantium sed libero.",
   "id": "2",
   "image": "http://lorempixel.com/640/480",

+ 3 - 0
server/package.json

@@ -29,6 +29,7 @@
     "apollo-server-express": "^2.0.4",
     "bcrypt": "^3.0.0",
     "body-parser": "^1.18.3",
+    "fs-extra": "^7.0.0",
     "graphql": "^14.0.0",
     "graphql-iso-date": "^3.5.0",
     "graphql-tag": "^2.9.2",
@@ -38,6 +39,7 @@
     "i18next-express-middleware": "^1.3.2",
     "i18next-icu": "^0.4.0",
     "i18next-node-fs-backend": "^2.0.0",
+    "jimp": "^0.4.0",
     "jsonwebtoken": "^8.2.2",
     "mysql": "^2.16.0",
     "passport": "^0.4.0",
@@ -51,6 +53,7 @@
     "@types/bcrypt": "^2.0.0",
     "@types/express": "^4.0.39",
     "@types/faker": "^4.1.3",
+    "@types/fs-extra": "^5.0.4",
     "@types/i18next": "^8.4.3",
     "@types/i18next-express-middleware": "^0.0.33",
     "@types/jest": "^23.3.1",

+ 2 - 0
server/src/api/api.module.ts

@@ -7,6 +7,7 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 
 import { AdministratorResolver } from './administrator/administrator.resolver';
+import { AssetResolver } from './asset/asset.resolver';
 import { AuthGuard } from './auth-guard';
 import { AuthResolver } from './auth/auth.resolver';
 import { ChannelResolver } from './channel/channel.resolver';
@@ -24,6 +25,7 @@ import { RolesGuard } from './roles-guard';
 const exportedProviders = [
     AdministratorResolver,
     AuthResolver,
+    AssetResolver,
     ChannelResolver,
     ConfigResolver,
     FacetResolver,

+ 37 - 0
server/src/api/asset/asset.api.graphql

@@ -0,0 +1,37 @@
+type Query {
+    assets(options: AssetListOptions): AssetList!
+    asset(id: ID!): Asset
+}
+
+type Mutation {
+    "Create a new Asset"
+    createAsset(input: CreateAssetInput!): Asset!
+}
+
+type AssetList implements PaginatedList {
+    items: [Asset!]!
+    totalItems: Int!
+}
+
+input AssetListOptions {
+    take: Int
+    skip: Int
+    sort: AssetSortParameter
+    filter: AssetFilterParameter
+}
+
+input AssetSortParameter {
+    id: SortOrder
+    createdAt: SortOrder
+    updatedAt: SortOrder
+    name: SortOrder
+    description: SortOrder
+}
+
+input AssetFilterParameter {
+    name: StringOperators
+    description: StringOperators
+    type: StringOperators
+    createdAt: DateOperators
+    updatedAt: DateOperators
+}

+ 40 - 0
server/src/api/asset/asset.resolver.ts

@@ -0,0 +1,40 @@
+import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { CreateAssetVariables, Permission } from 'shared/generated-types';
+import { PaginatedList } from 'shared/shared-types';
+
+import { Asset } from '../../entity/asset/asset.entity';
+import { AssetService } from '../../service/asset.service';
+import { RequestContextPipe } from '../common/request-context.pipe';
+import { Allow } from '../roles-guard';
+
+@Resolver('Auth')
+export class AssetResolver {
+    constructor(private assetService: AssetService) {}
+
+    /**
+     * Returns a list of Assets
+     */
+    @Query()
+    @Allow(Permission.ReadCatalog)
+    async asset(@Args() args: any): Promise<Asset | undefined> {
+        return this.assetService.findOne(args.id);
+    }
+
+    /**
+     * Returns a list of Assets
+     */
+    @Query()
+    @Allow(Permission.ReadCatalog)
+    async assets(@Args() args: any): Promise<PaginatedList<Asset>> {
+        return this.assetService.findAll(args.options);
+    }
+
+    /**
+     * Create a new Asset
+     */
+    @Mutation()
+    @Allow(Permission.CreateCatalog)
+    async createAsset(@Args() args: CreateAssetVariables): Promise<Asset> {
+        return this.assetService.create(args.input);
+    }
+}

+ 5 - 0
server/src/api/graphql-config.service.ts

@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { GqlModuleOptions, GqlOptionsFactory } from '@nestjs/graphql';
+import { GraphQLUpload } from 'apollo-server-core';
 import * as fs from 'fs';
 import * as glob from 'glob';
 import { GraphQLDateTime } from 'graphql-iso-date';
@@ -36,6 +37,10 @@ export class GraphqlConfigService implements GqlOptionsFactory {
                 DateTime: GraphQLDateTime,
                 Node: dummyResolveType,
                 PaginatedList: dummyResolveType,
+                Upload: GraphQLUpload,
+            },
+            uploads: {
+                maxFileSize: this.configService.uploadMaxFileSize,
             },
             playground: true,
             debug: true,

+ 13 - 0
server/src/bootstrap.ts

@@ -1,8 +1,10 @@
 import { INestApplication } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
+import * as path from 'path';
 import { Type } from 'shared/shared-types';
 import { EntitySubscriberInterface } from 'typeorm';
 
+import { LocalAssetStorageStrategy } from './config/asset-storage-strategy/local-asset-storage-strategy';
 import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 import { VendureEntity } from './entity/base/base.entity';
 import { registerCustomEntityFields } from './entity/custom-entity-fields';
@@ -20,6 +22,17 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     // tslint:disable-next-line:whitespace
     const appModule = await import('./app.module');
     const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
+
+    // If the LocalAssetStorageStrategy is being used, set up the server to serve
+    // the asset files from the configured directory (defaults to /assets/)
+    const assetStore = config.assetStorageStrategy;
+    if (assetStore instanceof LocalAssetStorageStrategy) {
+        const uploadPath = assetStore.setAbsoluteUploadPath(path.join(__dirname, '..'));
+        app.useStaticAssets(uploadPath, {
+            prefix: `/${assetStore.uploadDir}`,
+        });
+    }
+
     return app.listen(config.port);
 }
 

+ 5 - 0
server/src/common/types/asset-type.graphql

@@ -0,0 +1,5 @@
+enum AssetType {
+    IMAGE,
+    VIDEO,
+    BINARY
+}

+ 1 - 0
server/src/common/types/common-types.graphql

@@ -1,6 +1,7 @@
 # Third-party custom scalars
 scalar JSON
 scalar DateTime
+scalar Upload
 
 interface PaginatedList {
     items: [Node!]!

+ 13 - 0
server/src/config/asset-preview-strategy/asset-preview-strategy.ts

@@ -0,0 +1,13 @@
+import { Stream } from 'stream';
+
+/**
+ * The AssetPreviewStrategy determines how preview images for assets are created. For image
+ * assets, this would usually typically involve resizing to sensible dimensions. Other file types
+ * could be previewed in a variety of ways, e.g.:
+ * - waveform images generated for audio files
+ * - preview images generated for pdf documents
+ * - watermarks added to preview images
+ */
+export interface AssetPreviewStrategy {
+    generatePreviewImage(mimetype: string, data: Buffer): Promise<Buffer>;
+}

+ 23 - 0
server/src/config/asset-preview-strategy/default-asset-preview-strategy.ts

@@ -0,0 +1,23 @@
+import Jimp = require('jimp');
+import { Stream } from 'stream';
+
+import { AssetPreviewStrategy } from './asset-preview-strategy';
+
+export class DefaultAssetPreviewStrategy implements AssetPreviewStrategy {
+    constructor(
+        private config: {
+            maxHeight: number;
+            maxWidth: number;
+        },
+    ) {}
+
+    async generatePreviewImage(mimetype: string, data: Buffer): Promise<Buffer> {
+        const image = await Jimp.read(data);
+        const { maxWidth, maxHeight } = this.config;
+        if (maxWidth < image.getWidth() || maxHeight < image.getHeight()) {
+            image.scaleToFit(maxWidth, maxHeight);
+            return image.getBufferAsync(image.getMIME());
+        }
+        return data;
+    }
+}

+ 37 - 0
server/src/config/asset-storage-strategy/asset-storage-strategy.ts

@@ -0,0 +1,37 @@
+import { Stream } from 'stream';
+
+/**
+ * The AssetPersistenceStrategy determines how Asset files are physically stored
+ * and retrieved.
+ */
+export interface AssetStorageStrategy {
+    /**
+     * Writes a buffer to the store and returns a unique identifier for that
+     * file such as a file path or a URL.
+     */
+    writeFileFromBuffer(fileName: string, data: Buffer): Promise<string>;
+
+    /**
+     * Writes a readable stream to the store and returns a unique identifier for that
+     * file such as a file path or a URL.
+     */
+    writeFileFromStream(fileName: string, data: Stream): Promise<string>;
+
+    /**
+     * Reads a file based on an identifier which was generated by the writeFile
+     * method, and returns the file in binary form.
+     */
+    readFileToBuffer(identifier: string): Promise<Buffer>;
+
+    /**
+     * Reads a file based on an identifier which was generated by the writeFile
+     * method, and returns the file in binary form.
+     */
+    readFileToStream(identifier: string): Promise<Stream>;
+
+    /**
+     * Convert an identifier as generated by the writeFile... methods into an absolute
+     * url (if it is not already in that form)
+     */
+    toAbsoluteUrl(identifier: string): string;
+}

+ 60 - 0
server/src/config/asset-storage-strategy/local-asset-storage-strategy.ts

@@ -0,0 +1,60 @@
+import * as fs from 'fs-extra';
+import * as path from 'path';
+import { Stream } from 'stream';
+
+import { AssetStorageStrategy } from './asset-storage-strategy';
+
+/**
+ * A persistence strategy which saves files to the local file system.
+ */
+export class LocalAssetStorageStrategy implements AssetStorageStrategy {
+    private uploadPath: string;
+
+    constructor(public uploadDir: string = 'assets') {}
+
+    setAbsoluteUploadPath(rootDir: string): string {
+        this.uploadPath = path.join(rootDir, this.uploadDir);
+        if (!fs.existsSync(this.uploadPath)) {
+            fs.mkdirSync(this.uploadPath);
+        }
+        return this.uploadPath;
+    }
+
+    writeFileFromStream(fileName: string, data: Stream): Promise<string> {
+        const filePath = path.join(this.uploadPath, fileName);
+        const writeStream = fs.createWriteStream(filePath, 'binary');
+        return new Promise<string>((resolve, reject) => {
+            data.pipe(writeStream);
+            writeStream.on('close', () => resolve(this.filePathToIdentifier(filePath)));
+            writeStream.on('error', reject);
+        });
+    }
+
+    async writeFileFromBuffer(fileName: string, data: Buffer): Promise<string> {
+        const filePath = path.join(this.uploadPath, fileName);
+        await fs.writeFile(filePath, data, 'binary');
+        return this.filePathToIdentifier(filePath);
+    }
+
+    readFileToBuffer(identifier: string): Promise<Buffer> {
+        return fs.readFile(this.identifierToFilePath(identifier));
+    }
+
+    readFileToStream(identifier: string): Promise<Stream> {
+        const readStream = fs.createReadStream(this.identifierToFilePath(identifier), 'binary');
+        return Promise.resolve(readStream);
+    }
+
+    toAbsoluteUrl(identifier: string): string {
+        // TODO: implement this as part of an interceptor.
+        return `http://localhost:3000/${identifier}`;
+    }
+
+    private filePathToIdentifier(filePath: string): string {
+        return `${this.uploadDir}/${path.basename(filePath)}`;
+    }
+
+    private identifierToFilePath(identifier: string): string {
+        return path.join(this.uploadPath, path.basename(identifier));
+    }
+}

+ 17 - 2
server/src/config/config.service.ts

@@ -5,8 +5,11 @@ import { CustomFields } from 'shared/shared-types';
 import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
-import { EntityIdStrategy } from '../config/entity-id-strategy/entity-id-strategy';
-import { getConfig, VendureConfig } from '../config/vendure-config';
+
+import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
+import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
+import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
+import { getConfig, VendureConfig } from './vendure-config';
 
 @Injectable()
 export class ConfigService implements VendureConfig {
@@ -42,10 +45,22 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.entityIdStrategy;
     }
 
+    get assetStorageStrategy(): AssetStorageStrategy {
+        return this.activeConfig.assetStorageStrategy;
+    }
+
+    get assetPreviewStrategy(): AssetPreviewStrategy {
+        return this.activeConfig.assetPreviewStrategy;
+    }
+
     get dbConnectionOptions(): ConnectionOptions {
         return this.activeConfig.dbConnectionOptions;
     }
 
+    get uploadMaxFileSize(): number {
+        return this.activeConfig.uploadMaxFileSize;
+    }
+
     get customFields(): CustomFields {
         return this.activeConfig.customFields;
     }

+ 5 - 0
server/src/config/entity-id-strategy/entity-id-strategy.ts

@@ -2,6 +2,11 @@ import { ID } from 'shared/shared-types';
 
 export type PrimaryKeyType = 'increment' | 'uuid';
 
+/**
+ * The EntityIdStrategy determines how entity IDs are generated and stored in the
+ * database, as well as how they are transformed when being passed from the API to the
+ * service layer.
+ */
 export interface EntityIdStrategy<T extends ID = ID> {
     readonly primaryKeyType: PrimaryKeyType;
     encodeId: (primaryKey: T) => string;

+ 23 - 0
server/src/config/vendure-config.ts

@@ -6,6 +6,10 @@ import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 
+import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
+import { DefaultAssetPreviewStrategy } from './asset-preview-strategy/default-asset-preview-strategy';
+import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
+import { LocalAssetStorageStrategy } from './asset-storage-strategy/local-asset-storage-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
@@ -54,6 +58,17 @@ export interface VendureConfig {
      * strategy.
      */
     entityIdStrategy?: EntityIdStrategy<any>;
+    /**
+     * Defines the strategy used for storing uploaded binary files. By default files are
+     * persisted to the local file system.
+     */
+    assetStorageStrategy?: AssetStorageStrategy;
+    /**
+     * Defines the strategy used for creating preview images of uploaded assets. The default
+     * strategy resizes images based on maximum dimensions and outputs a sensible default
+     * preview image for other file types.
+     */
+    assetPreviewStrategy?: AssetPreviewStrategy;
     /**
      * The connection options used by TypeORM to connect to the database.
      */
@@ -62,6 +77,10 @@ export interface VendureConfig {
      * Defines custom fields which can be used to extend the built-in entities.
      */
     customFields?: CustomFields;
+    /**
+     * The max file size in bytes for uploaded assets.
+     */
+    uploadMaxFileSize: number;
 }
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
@@ -73,9 +92,13 @@ const defaultConfig: ReadOnlyRequired<VendureConfig> = {
     jwtSecret: 'secret',
     apiPath: API_PATH,
     entityIdStrategy: new AutoIncrementIdStrategy(),
+    // tslint:disable-next-line:no-non-null-assertion
+    assetStorageStrategy: new LocalAssetStorageStrategy('assets'),
+    assetPreviewStrategy: new DefaultAssetPreviewStrategy({ maxHeight: 50, maxWidth: 50 }),
     dbConnectionOptions: {
         type: 'mysql',
     },
+    uploadMaxFileSize: 20971520,
     customFields: {
         Address: [],
         Customer: [],

+ 29 - 0
server/src/entity/asset/asset.entity.ts

@@ -0,0 +1,29 @@
+import { AssetType } from 'shared/generated-types';
+import { DeepPartial } from 'shared/shared-types';
+import { HasCustomFields } from 'shared/shared-types';
+import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm';
+
+import { Address } from '../address/address.entity';
+import { VendureEntity } from '../base/base.entity';
+import { CustomCustomerFields } from '../custom-entity-fields';
+import { User } from '../user/user.entity';
+
+@Entity()
+export class Asset extends VendureEntity {
+    constructor(input?: DeepPartial<Asset>) {
+        super(input);
+    }
+
+    @Column() name: string;
+
+    @Column({ nullable: true })
+    description: string;
+
+    @Column('varchar') type: AssetType;
+
+    @Column() mimetype: string;
+
+    @Column() source: string;
+
+    @Column() preview: string;
+}

+ 13 - 0
server/src/entity/asset/asset.graphql

@@ -0,0 +1,13 @@
+type Asset implements Node {
+    id: ID!
+    name: String!
+    description: String
+    type: AssetType!
+    mimetype: String!
+    source: String!
+    preview: String!
+}
+
+input CreateAssetInput {
+    file: Upload!
+}

+ 2 - 0
server/src/entity/entities.ts

@@ -1,5 +1,6 @@
 import { Address } from './address/address.entity';
 import { Administrator } from './administrator/administrator.entity';
+import { Asset } from './asset/asset.entity';
 import { Channel } from './channel/channel.entity';
 import { Customer } from './customer/customer.entity';
 import { FacetValueTranslation } from './facet-value/facet-value-translation.entity';
@@ -24,6 +25,7 @@ import { User } from './user/user.entity';
 export const coreEntitiesMap = {
     Address,
     Administrator,
+    Asset,
     Channel,
     Customer,
     Facet,

+ 10 - 1
server/src/entity/product/product.entity.ts

@@ -1,8 +1,9 @@
 import { DeepPartial, HasCustomFields } from 'shared/shared-types';
-import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { ChannelAware } from '../../common/types/common-types';
 import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
+import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { CustomProductFields } from '../custom-entity-fields';
@@ -22,8 +23,16 @@ export class Product extends VendureEntity implements Translatable, HasCustomFie
 
     description: LocaleString;
 
+    // TODO: remove once Assets have been implemented
     @Column() image: string;
 
+    @ManyToOne(type => Asset)
+    defaultAsset: Asset;
+
+    @ManyToMany(type => Asset)
+    @JoinTable()
+    assets: Asset[];
+
     @OneToMany(type => ProductTranslation, translation => translation.base, { eager: true })
     translations: Array<Translation<Product>>;
 

+ 1 - 0
server/src/entity/product/product.graphql

@@ -7,6 +7,7 @@ type Product implements Node {
     slug: String!
     description: String!
     image: String!
+    assets: [Asset!]!
     variants: [ProductVariant!]!
     optionGroups: [ProductOptionGroup!]!
     translations: [ProductTranslation!]!

+ 68 - 0
server/src/service/asset.service.ts

@@ -0,0 +1,68 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import * as path from 'path';
+import { AssetType, CreateAssetInput } from 'shared/generated-types';
+import { normalizeString } from 'shared/normalize-string';
+import { ID, PaginatedList } from 'shared/shared-types';
+import { Connection } from 'typeorm';
+
+import { ListQueryOptions } from '../common/types/common-types';
+import { ConfigService } from '../config/config.service';
+import { Asset } from '../entity/asset/asset.entity';
+
+import { buildListQuery } from './helpers/build-list-query';
+
+@Injectable()
+export class AssetService {
+    constructor(@InjectConnection() private connection: Connection, private configService: ConfigService) {}
+
+    findOne(id: ID): Promise<Asset | undefined> {
+        return this.connection.getRepository(Asset).findOne(id);
+    }
+
+    findAll(options?: ListQueryOptions<Asset>): Promise<PaginatedList<Asset>> {
+        return buildListQuery(this.connection, Asset, options)
+            .getManyAndCount()
+            .then(([items, totalItems]) => ({
+                items,
+                totalItems,
+            }));
+    }
+
+    async create(input: CreateAssetInput): Promise<Asset> {
+        const { stream, filename, mimetype, encoding } = await input.file;
+        const { assetPreviewStrategy, assetStorageStrategy } = this.configService;
+        const normalizedFileName = this.normalizeFileName(filename);
+
+        const sourceFile = await assetStorageStrategy.writeFileFromStream(normalizedFileName, stream);
+        const image = await assetStorageStrategy.readFileToBuffer(sourceFile);
+        const preview = await assetPreviewStrategy.generatePreviewImage(mimetype, image);
+        const previewFile = await assetStorageStrategy.writeFileFromBuffer(
+            this.addSuffix(normalizedFileName, '__preview'),
+            preview,
+        );
+
+        const asset = new Asset({
+            type: AssetType.IMAGE,
+            name: filename,
+            mimetype,
+            source: sourceFile,
+            preview: previewFile,
+        });
+        return this.connection.manager.save(asset);
+    }
+
+    private normalizeFileName(fileName: string): string {
+        const normalized = normalizeString(fileName, '-');
+        const randomPart = Math.random()
+            .toString(8)
+            .substr(2, 8);
+        return this.addSuffix(normalized, `-${randomPart}`);
+    }
+
+    private addSuffix(fileName: string, suffix: string): string {
+        const ext = path.extname(fileName);
+        const baseName = path.basename(fileName, ext);
+        return `${baseName}${suffix}${ext}`;
+    }
+}

+ 1 - 1
server/src/service/product.service.ts

@@ -55,7 +55,7 @@ export class ProductService {
     }
 
     async findOne(ctx: RequestContext, productId: ID): Promise<Translated<Product> | undefined> {
-        const relations = ['variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
+        const relations = ['assets', 'variants', 'optionGroups', 'variants.options', 'variants.facetValues'];
         const product = await this.connection.manager.findOne(Product, productId, { relations });
         if (!product) {
             return;

+ 2 - 0
server/src/service/service.module.ts

@@ -5,6 +5,7 @@ import { ConfigModule } from '../config/config.module';
 import { getConfig } from '../config/vendure-config';
 
 import { AdministratorService } from './administrator.service';
+import { AssetService } from './asset.service';
 import { AuthService } from './auth.service';
 import { ChannelService } from './channel.service';
 import { CustomerService } from './customer.service';
@@ -20,6 +21,7 @@ import { RoleService } from './role.service';
 
 const exportedProviders = [
     AdministratorService,
+    AssetService,
     AuthService,
     ChannelService,
     CustomerService,

+ 413 - 3
server/yarn.lock

@@ -28,6 +28,13 @@
     esutils "^2.0.2"
     js-tokens "^3.0.0"
 
+"@babel/polyfill@^7.0.0":
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.0.0.tgz#c8ff65c9ec3be6a1ba10113ebd40e8750fb90bff"
+  dependencies:
+    core-js "^2.5.7"
+    regenerator-runtime "^0.11.1"
+
 "@babel/runtime-corejs2@^7.0.0-rc.1":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.0.0.tgz#786711ee099c2c2af7875638866c1259eff30a8c"
@@ -42,6 +49,231 @@
     is-absolute "^1.0.0"
     is-negated-glob "^1.0.0"
 
+"@jimp/bmp@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/bmp/-/bmp-0.4.0.tgz#9eae4b61fb82a34a529cd1f02423f5efe93a8951"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    bmp-js "^0.1.0"
+    core-js "^2.5.7"
+
+"@jimp/core@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/core/-/core-0.4.0.tgz#db6b063fd9f6d0f292d9eecffc3a9e07dbc8b7e7"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    any-base "^1.1.0"
+    buffer "^5.2.0"
+    core-js "^2.5.7"
+    exif-parser "^0.1.12"
+    file-type "^9.0.0"
+    load-bmfont "^1.3.1"
+    mkdirp "0.5.1"
+    phin "^2.9.1"
+    pixelmatch "^4.0.2"
+    tinycolor2 "^1.4.1"
+
+"@jimp/custom@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/custom/-/custom-0.4.0.tgz#c0b801aea1413c2b827ba299523cbc2d0932197d"
+  dependencies:
+    "@jimp/core" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/gif@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/gif/-/gif-0.4.0.tgz#69ed6022587f72eab4d22ba1388bdf43649c64e5"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+    omggif "^1.0.9"
+
+"@jimp/jpeg@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/jpeg/-/jpeg-0.4.0.tgz#66bab17638c834e21d21ca2c4f41e1f9e6f5ae1d"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+    jpeg-js "^0.3.4"
+
+"@jimp/plugin-blit@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-blit/-/plugin-blit-0.4.0.tgz#a4ac8709a640faea25d41ec1f54e0635f6e59adf"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-blur@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-blur/-/plugin-blur-0.4.0.tgz#a869910080a2a43ddd7f4c191b4dca93ed36feba"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-color@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-color/-/plugin-color-0.4.0.tgz#91904918942c0cc038dd62c088936ae9488b51fb"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+    tinycolor2 "^1.4.1"
+
+"@jimp/plugin-contain@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-contain/-/plugin-contain-0.4.0.tgz#f1db0d955e021cbb065bc459ccfb867db3c7ed19"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-cover@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-cover/-/plugin-cover-0.4.0.tgz#80db9bd7616351cc22d9b8dc34cb2825662dc6df"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-crop@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-crop/-/plugin-crop-0.4.0.tgz#fe636da06d35d0d95e5bcc47fcc3af3a9baa185a"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-displace@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-displace/-/plugin-displace-0.4.0.tgz#f6a07cc786f034930987477db8b2aa1eacfb4f3d"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-dither@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-dither/-/plugin-dither-0.4.0.tgz#92114cb4e0bd7a8cac8e2eb4e33e81e3c196be16"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-flip@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-flip/-/plugin-flip-0.4.0.tgz#314cb6e89f8b778843d7c50d4114576ca81587a2"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-gaussian@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.4.0.tgz#5b31cb253367342811d91bb7db2c7fd31fb37198"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-invert@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-invert/-/plugin-invert-0.4.0.tgz#b490b6c758394cfd73555837968db2d94e41afa8"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-mask@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-mask/-/plugin-mask-0.4.0.tgz#22323b24ce6dadaadfe8c07958d349a5eac27f46"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-normalize@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-normalize/-/plugin-normalize-0.4.0.tgz#84370b3dd65181b95c25d5e0388aa9dd9abffff7"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-print@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-print/-/plugin-print-0.4.0.tgz#a1c2bd77e425a9a676c20cb6c202cd42d4a1ec64"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+    load-bmfont "^1.4.0"
+
+"@jimp/plugin-resize@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-resize/-/plugin-resize-0.4.0.tgz#755fc8404928ae2f7598786b25b79caa17402496"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-rotate@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-rotate/-/plugin-rotate-0.4.0.tgz#27faa1aef76767464d15f5501a549b604f8c4ccb"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugin-scale@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugin-scale/-/plugin-scale-0.4.0.tgz#0bbaf2ff305a43669d1bace61396657438ada818"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+
+"@jimp/plugins@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/plugins/-/plugins-0.4.0.tgz#a0c155b8b800fd8841ccadb73d701c84b2969d2f"
+  dependencies:
+    "@jimp/plugin-blit" "^0.4.0"
+    "@jimp/plugin-blur" "^0.4.0"
+    "@jimp/plugin-color" "^0.4.0"
+    "@jimp/plugin-contain" "^0.4.0"
+    "@jimp/plugin-cover" "^0.4.0"
+    "@jimp/plugin-crop" "^0.4.0"
+    "@jimp/plugin-displace" "^0.4.0"
+    "@jimp/plugin-dither" "^0.4.0"
+    "@jimp/plugin-flip" "^0.4.0"
+    "@jimp/plugin-gaussian" "^0.4.0"
+    "@jimp/plugin-invert" "^0.4.0"
+    "@jimp/plugin-mask" "^0.4.0"
+    "@jimp/plugin-normalize" "^0.4.0"
+    "@jimp/plugin-print" "^0.4.0"
+    "@jimp/plugin-resize" "^0.4.0"
+    "@jimp/plugin-rotate" "^0.4.0"
+    "@jimp/plugin-scale" "^0.4.0"
+    core-js "^2.5.7"
+    timm "^1.6.1"
+
+"@jimp/png@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/png/-/png-0.4.0.tgz#11cd89fb842d76ba6b6aa97466118baf52aceb62"
+  dependencies:
+    "@jimp/utils" "^0.4.0"
+    core-js "^2.5.7"
+    pngjs "^3.3.3"
+
+"@jimp/tiff@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/tiff/-/tiff-0.4.0.tgz#aa1f34f608437ca7378c4cf71a55932ef6f37fc4"
+  dependencies:
+    core-js "^2.5.7"
+    utif "^2.0.1"
+
+"@jimp/types@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/types/-/types-0.4.0.tgz#246bd4727fd81474ff51fc87a1bbf0ab6add9a74"
+  dependencies:
+    "@jimp/bmp" "^0.4.0"
+    "@jimp/gif" "^0.4.0"
+    "@jimp/jpeg" "^0.4.0"
+    "@jimp/png" "^0.4.0"
+    "@jimp/tiff" "^0.4.0"
+    core-js "^2.5.7"
+    timm "^1.6.1"
+
+"@jimp/utils@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@jimp/utils/-/utils-0.4.0.tgz#e54d04d883231c993d3d27f33701dd09d32fc3b9"
+  dependencies:
+    core-js "^2.5.7"
+
 "@mrmlnc/readdir-enhanced@^2.2.1":
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@@ -207,6 +439,12 @@
   version "4.1.3"
   resolved "https://registry.yarnpkg.com/@types/faker/-/faker-4.1.3.tgz#544398268b37248300dc428316daa6a7521bbd19"
 
+"@types/fs-extra@^5.0.4":
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-5.0.4.tgz#b971134d162cc0497d221adde3dbb67502225599"
+  dependencies:
+    "@types/node" "*"
+
 "@types/graphql@0.12.6":
   version "0.12.6"
   resolved "https://registry.yarnpkg.com/@types/graphql/-/graphql-0.12.6.tgz#3d619198585fcabe5f4e1adfb5cf5f3388c66c13"
@@ -364,6 +602,10 @@ ansi-wrap@0.1.0, ansi-wrap@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf"
 
+any-base@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe"
+
 any-promise@^1.0.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
@@ -899,6 +1141,10 @@ binary-extensions@^1.0.0:
   version "1.11.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
 
+bmp-js@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.1.0.tgz#e05a63f796a6c1ff25f4771ec7adadc148c07233"
+
 body-parser@1.18.2:
   version "1.18.2"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.2.tgz#87678a19d84b47d859b83199bd59bce222b10454"
@@ -991,6 +1237,10 @@ buffer-equal-constant-time@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
 
+buffer-equal@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-0.0.1.tgz#91bc74b11ea405bc916bc6aa908faafa5b4aac4b"
+
 buffer-equal@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe"
@@ -1006,6 +1256,13 @@ buffer@^5.1.0:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
 
+buffer@^5.2.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6"
+  dependencies:
+    base64-js "^1.0.2"
+    ieee754 "^1.1.4"
+
 builtin-modules@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
@@ -1549,6 +1806,10 @@ dir-glob@^2.0.0:
     arrify "^1.0.1"
     path-type "^3.0.0"
 
+dom-walk@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
+
 domexception@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
@@ -1755,6 +2016,10 @@ execa@^0.7.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
+exif-parser@^0.1.12:
+  version "0.1.12"
+  resolved "https://registry.yarnpkg.com/exif-parser/-/exif-parser-0.1.12.tgz#58a9d2d72c02c1f6f02a0ef4a9166272b7760922"
+
 exit@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@@ -1930,6 +2195,10 @@ figures@^2.0.0:
   dependencies:
     escape-string-regexp "^1.0.5"
 
+file-type@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18"
+
 filename-regex@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
@@ -2021,6 +2290,12 @@ follow-redirects@^1.2.5:
   dependencies:
     debug "^3.1.0"
 
+for-each@^0.3.2:
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
+  dependencies:
+    is-callable "^1.1.3"
+
 for-in@^1.0.1, for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -2079,6 +2354,14 @@ fs-extra@6.0.1, fs-extra@^6.0.1:
     jsonfile "^4.0.0"
     universalify "^0.1.0"
 
+fs-extra@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.0.tgz#8cc3f47ce07ef7b3593a11b9fb245f7e34c041d6"
+  dependencies:
+    graceful-fs "^4.1.2"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
+
 fs-minipass@^1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d"
@@ -2221,6 +2504,13 @@ global-prefix@^1.0.1:
     is-windows "^1.0.1"
     which "^1.2.14"
 
+global@~4.3.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
+  dependencies:
+    min-document "^2.19.0"
+    process "~0.5.1"
+
 globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
@@ -2727,6 +3017,10 @@ is-fullwidth-code-point@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
 
+is-function@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5"
+
 is-generator-fn@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-1.0.0.tgz#969d49e1bb3329f6bb7f09089be26578b2ddd46a"
@@ -3246,6 +3540,20 @@ jest@^23.5.0:
     import-local "^1.0.0"
     jest-cli "^23.5.0"
 
+jimp@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/jimp/-/jimp-0.4.0.tgz#19c9bb2d104e468a86f81962a99363f6f7b3be47"
+  dependencies:
+    "@babel/polyfill" "^7.0.0"
+    "@jimp/custom" "^0.4.0"
+    "@jimp/plugins" "^0.4.0"
+    "@jimp/types" "^0.4.0"
+    core-js "^2.5.7"
+
+jpeg-js@^0.3.4:
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.3.4.tgz#dc2ba501ee3d58b7bb893c5d1fab47294917e7e7"
+
 js-tokens@^3.0.0, js-tokens@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
@@ -3473,6 +3781,19 @@ liftoff@^2.5.0:
     rechoir "^0.6.2"
     resolve "^1.1.7"
 
+load-bmfont@^1.3.1, load-bmfont@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.0.tgz#75f17070b14a8c785fe7f5bee2e6fd4f98093b6b"
+  dependencies:
+    buffer-equal "0.0.1"
+    mime "^1.3.4"
+    parse-bmfont-ascii "^1.0.3"
+    parse-bmfont-binary "^1.0.5"
+    parse-bmfont-xml "^1.1.4"
+    phin "^2.9.1"
+    xhr "^2.0.1"
+    xtend "^4.0.0"
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -3709,10 +4030,20 @@ mime@1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
 
+mime@^1.3.4:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+
 mimic-fn@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
 
+min-document@^2.19.0:
+  version "2.19.0"
+  resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
+  dependencies:
+    dom-walk "^0.1.0"
+
 minimalistic-assert@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@@ -3755,7 +4086,7 @@ mixin-deep@^1.2.0:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1:
+mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
   dependencies:
@@ -4076,6 +4407,10 @@ object.reduce@^1.0.0:
     for-own "^1.0.0"
     make-iterator "^1.0.0"
 
+omggif@^1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.9.tgz#dcb7024dacd50c52b4d303f04802c91c057c765f"
+
 on-finished@^2.3.0, on-finished@~2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -4174,10 +4509,29 @@ package-json@^4.0.0:
     registry-url "^3.0.3"
     semver "^5.1.0"
 
+pako@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
+
 parent-require@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/parent-require/-/parent-require-1.0.0.tgz#746a167638083a860b0eef6732cb27ed46c32977"
 
+parse-bmfont-ascii@^1.0.3:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz#11ac3c3ff58f7c2020ab22769079108d4dfa0285"
+
+parse-bmfont-binary@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz#d038b476d3e9dd9db1e11a0b0e53a22792b69006"
+
+parse-bmfont-xml@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.4.tgz#015319797e3e12f9e739c4d513872cd2fa35f389"
+  dependencies:
+    xml-parse-from-string "^1.0.0"
+    xml2js "^0.4.5"
+
 parse-filepath@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.2.tgz#a632127f53aaf3d15876f5872f3ffac763d6c891"
@@ -4195,6 +4549,13 @@ parse-glob@^3.0.4:
     is-extglob "^1.0.0"
     is-glob "^2.0.0"
 
+parse-headers@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.1.tgz#6ae83a7aa25a9d9b700acc28698cd1f1ed7e9536"
+  dependencies:
+    for-each "^0.3.2"
+    trim "0.0.1"
+
 parse-json@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
@@ -4317,6 +4678,10 @@ performance-now@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
 
+phin@^2.9.1:
+  version "2.9.2"
+  resolved "https://registry.yarnpkg.com/phin/-/phin-2.9.2.tgz#0a82d5b6dd75552b665f371f8060689c1af7336e"
+
 pify@^2.0.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -4335,6 +4700,12 @@ pinkie@^2.0.0:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
 
+pixelmatch@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-4.0.2.tgz#8f47dcec5011b477b67db03c243bc1f3085e8854"
+  dependencies:
+    pngjs "^3.0.0"
+
 pkg-dir@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
@@ -4345,6 +4716,10 @@ pn@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
 
+pngjs@^3.0.0, pngjs@^3.3.3:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.3.3.tgz#85173703bde3edac8998757b96e5821d0966a21b"
+
 posix-character-classes@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
@@ -4384,6 +4759,10 @@ process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
 
+process@~0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
+
 prompts@^0.1.9:
   version "0.1.11"
   resolved "https://registry.yarnpkg.com/prompts/-/prompts-0.1.11.tgz#fdfac72f61d2887f4eaf2e65e748a9d9ef87206f"
@@ -4567,7 +4946,7 @@ reflect-metadata@^0.1.12:
   version "0.1.12"
   resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.12.tgz#311bf0c6b63cd782f228a81abe146a2bfa9c56f2"
 
-regenerator-runtime@^0.11.0:
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
 
@@ -5264,6 +5643,14 @@ timers-ext@0.1, timers-ext@^0.1.2:
     es5-ext "~0.10.14"
     next-tick "1"
 
+timm@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/timm/-/timm-1.6.1.tgz#5f8aafc932248c76caf2c6af60542a32d3c30701"
+
+tinycolor2@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"
+
 tmpl@1.0.x:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
@@ -5336,6 +5723,10 @@ trim-right@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
 
+trim@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd"
+
 ts-jest@^23.1.4:
   version "23.1.4"
   resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-23.1.4.tgz#66ac1d8d3fbf8f9a98432b11aa377aa850664b2b"
@@ -5544,6 +5935,12 @@ use@^3.1.0:
   dependencies:
     kind-of "^6.0.2"
 
+utif@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/utif/-/utif-2.0.1.tgz#9e1582d9bbd20011a6588548ed3266298e711759"
+  dependencies:
+    pako "^1.0.5"
+
 util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -5760,11 +6157,24 @@ xdg-basedir@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
 
+xhr@^2.0.1:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.5.0.tgz#bed8d1676d5ca36108667692b74b316c496e49dd"
+  dependencies:
+    global "~4.3.0"
+    is-function "^1.0.1"
+    parse-headers "^2.0.0"
+    xtend "^4.0.0"
+
 xml-name-validator@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
 
-xml2js@^0.4.17:
+xml-parse-from-string@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz#a9029e929d3dbcded169f3c6e28238d95a5d5a28"
+
+xml2js@^0.4.17, xml2js@^0.4.5:
   version "0.4.19"
   resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
   dependencies:

+ 154 - 0
shared/generated-types.ts

@@ -803,6 +803,14 @@ export interface GetUiState {
 // GraphQL mutation operation: UpdateProduct
 // ====================================================
 
+export interface UpdateProduct_updateProduct_assets {
+  __typename: "Asset";
+  description: string | null;
+  name: string;
+  preview: string;
+  type: AssetType;
+}
+
 export interface UpdateProduct_updateProduct_translations {
   __typename: "ProductTranslation";
   languageCode: LanguageCode;
@@ -862,6 +870,7 @@ export interface UpdateProduct_updateProduct {
   slug: string;
   image: string;
   description: string;
+  assets: UpdateProduct_updateProduct_assets[];
   translations: UpdateProduct_updateProduct_translations[];
   optionGroups: UpdateProduct_updateProduct_optionGroups[];
   variants: UpdateProduct_updateProduct_variants[];
@@ -885,6 +894,14 @@ export interface UpdateProductVariables {
 // GraphQL mutation operation: CreateProduct
 // ====================================================
 
+export interface CreateProduct_createProduct_assets {
+  __typename: "Asset";
+  description: string | null;
+  name: string;
+  preview: string;
+  type: AssetType;
+}
+
 export interface CreateProduct_createProduct_translations {
   __typename: "ProductTranslation";
   languageCode: LanguageCode;
@@ -944,6 +961,7 @@ export interface CreateProduct_createProduct {
   slug: string;
   image: string;
   description: string;
+  assets: CreateProduct_createProduct_assets[];
   translations: CreateProduct_createProduct_translations[];
   optionGroups: CreateProduct_createProduct_optionGroups[];
   variants: CreateProduct_createProduct_variants[];
@@ -967,6 +985,14 @@ export interface CreateProductVariables {
 // GraphQL mutation operation: GenerateProductVariants
 // ====================================================
 
+export interface GenerateProductVariants_generateVariantsForProduct_assets {
+  __typename: "Asset";
+  description: string | null;
+  name: string;
+  preview: string;
+  type: AssetType;
+}
+
 export interface GenerateProductVariants_generateVariantsForProduct_translations {
   __typename: "ProductTranslation";
   languageCode: LanguageCode;
@@ -1026,6 +1052,7 @@ export interface GenerateProductVariants_generateVariantsForProduct {
   slug: string;
   image: string;
   description: string;
+  assets: GenerateProductVariants_generateVariantsForProduct_assets[];
   translations: GenerateProductVariants_generateVariantsForProduct_translations[];
   optionGroups: GenerateProductVariants_generateVariantsForProduct_optionGroups[];
   variants: GenerateProductVariants_generateVariantsForProduct_variants[];
@@ -1281,6 +1308,14 @@ export interface ApplyFacetValuesToProductVariantsVariables {
 // GraphQL query operation: GetProductWithVariants
 // ====================================================
 
+export interface GetProductWithVariants_product_assets {
+  __typename: "Asset";
+  description: string | null;
+  name: string;
+  preview: string;
+  type: AssetType;
+}
+
 export interface GetProductWithVariants_product_translations {
   __typename: "ProductTranslation";
   languageCode: LanguageCode;
@@ -1340,6 +1375,7 @@ export interface GetProductWithVariants_product {
   slug: string;
   image: string;
   description: string;
+  assets: GetProductWithVariants_product_assets[];
   translations: GetProductWithVariants_product_translations[];
   optionGroups: GetProductWithVariants_product_optionGroups[];
   variants: GetProductWithVariants_product_variants[];
@@ -1421,6 +1457,65 @@ export interface GetProductOptionGroupsVariables {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL query operation: GetAssetList
+// ====================================================
+
+export interface GetAssetList_assets_items {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  mimetype: string;
+  type: AssetType;
+  preview: string;
+  source: string;
+}
+
+export interface GetAssetList_assets {
+  __typename: "AssetList";
+  items: GetAssetList_assets_items[];
+  totalItems: number;
+}
+
+export interface GetAssetList {
+  assets: GetAssetList_assets;
+}
+
+export interface GetAssetListVariables {
+  options?: AssetListOptions | null;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
+// ====================================================
+// GraphQL mutation operation: CreateAsset
+// ====================================================
+
+export interface CreateAsset_createAsset {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  mimetype: string;
+  type: AssetType;
+  preview: string;
+  source: string;
+}
+
+export interface CreateAsset {
+  /**
+   * Create a new Asset
+   */
+  createAsset: CreateAsset_createAsset;
+}
+
+export interface CreateAssetVariables {
+  input: CreateAssetInput;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 // ====================================================
 // GraphQL fragment: Administrator
 // ====================================================
@@ -1599,6 +1694,14 @@ export interface ProductVariant {
 // GraphQL fragment: ProductWithVariants
 // ====================================================
 
+export interface ProductWithVariants_assets {
+  __typename: "Asset";
+  description: string | null;
+  name: string;
+  preview: string;
+  type: AssetType;
+}
+
 export interface ProductWithVariants_translations {
   __typename: "ProductTranslation";
   languageCode: LanguageCode;
@@ -1658,6 +1761,7 @@ export interface ProductWithVariants {
   slug: string;
   image: string;
   description: string;
+  assets: ProductWithVariants_assets[];
   translations: ProductWithVariants_translations[];
   optionGroups: ProductWithVariants_optionGroups[];
   variants: ProductWithVariants_variants[];
@@ -1702,10 +1806,33 @@ export interface ProductOptionGroup {
 /* tslint:disable */
 // This file was automatically generated and should not be edited.
 
+// ====================================================
+// GraphQL fragment: Asset
+// ====================================================
+
+export interface Asset {
+  __typename: "Asset";
+  id: string;
+  name: string;
+  mimetype: string;
+  type: AssetType;
+  preview: string;
+  source: string;
+}
+
+/* tslint:disable */
+// This file was automatically generated and should not be edited.
+
 //==============================================================
 // START Enums and Input Objects
 //==============================================================
 
+export enum AssetType {
+  BINARY = "BINARY",
+  IMAGE = "IMAGE",
+  VIDEO = "VIDEO",
+}
+
 /**
  * ISO 639-1 language code
  */
@@ -1949,6 +2076,29 @@ export interface AdministratorSortParameter {
   emailAddress?: SortOrder | null;
 }
 
+export interface AssetFilterParameter {
+  name?: StringOperators | null;
+  description?: StringOperators | null;
+  type?: StringOperators | null;
+  createdAt?: DateOperators | null;
+  updatedAt?: DateOperators | null;
+}
+
+export interface AssetListOptions {
+  take?: number | null;
+  skip?: number | null;
+  sort?: AssetSortParameter | null;
+  filter?: AssetFilterParameter | null;
+}
+
+export interface AssetSortParameter {
+  id?: SortOrder | null;
+  createdAt?: SortOrder | null;
+  updatedAt?: SortOrder | null;
+  name?: SortOrder | null;
+  description?: SortOrder | null;
+}
+
 export interface BooleanOperators {
   eq?: boolean | null;
 }
@@ -1961,6 +2111,10 @@ export interface CreateAdministratorInput {
   roleIds: string[];
 }
 
+export interface CreateAssetInput {
+  file: any;
+}
+
 export interface CreateFacetCustomFieldsInput {
   searchable?: boolean | null;
 }

+ 0 - 0
admin-ui/src/app/common/utilities/normalize-string.spec.ts → shared/normalize-string.spec.ts


+ 0 - 0
admin-ui/src/app/common/utilities/normalize-string.ts → shared/normalize-string.ts


+ 96 - 0
shared/omit.spec.ts

@@ -1,5 +1,7 @@
 import { omit } from './omit';
 
+declare const File: any;
+
 describe('omit()', () => {
 
     it('returns a new object', () => {
@@ -28,4 +30,98 @@ describe('omit()', () => {
             },
         });
     });
+
+    describe('recursive', () => {
+
+        it('returns a new object', () => {
+            const obj = { foo: 1, bar: 2 };
+            expect(omit(obj, ['bar'], true)).not.toBe(obj);
+        });
+
+        it('works with 1-level-deep objects', () => {
+            const input = {
+                foo: 1,
+                bar: 2,
+                baz: 3,
+            };
+            const expected = { foo: 1, baz: 3 };
+
+            expect(omit(input, ['bar'], true)).toEqual(expected);
+        });
+
+        it('works with 2-level-deep objects', () => {
+            const input = {
+                foo: 1,
+                bar: {
+                    bad: true,
+                    good: true,
+                },
+                baz: {
+                    bad: true,
+                },
+            };
+            const expected = {
+                foo: 1,
+                bar: {
+                    good: true,
+                },
+                baz: {},
+            };
+
+            expect(omit(input, ['bad'], true)).toEqual(expected);
+        });
+
+        it('works with array of objects', () => {
+            const input = {
+                foo: 1,
+                bar: [
+                    {
+                        bad: true,
+                        good: true,
+                    },
+                    { bad: true },
+                ],
+            };
+            const expected = {
+                foo: 1,
+                bar: [
+                    { good: true },
+                    {},
+                ],
+            };
+
+            expect(omit(input, ['bad'], true)).toEqual(expected);
+        });
+
+        it('works top-level array', () => {
+            const input = [
+                { foo: 1 },
+                { bad: true },
+                { bar: 2 },
+            ];
+            const expected = [
+                { foo: 1 },
+                {},
+                { bar: 2 },
+            ];
+
+            expect(omit(input, ['bad'], true)).toEqual(expected);
+        });
+
+        it('preserves File objects', () => {
+            const file = new File([], 'foo');
+            const input = [
+                { foo: 1 },
+                { bad: true },
+                { bar: file },
+            ];
+            const expected = [
+                { foo: 1 },
+                {},
+                { bar: file },
+            ];
+
+            expect(omit(input, ['bad'], true)).toEqual(expected);
+        });
+    });
 });

+ 22 - 3
shared/omit.ts

@@ -1,13 +1,32 @@
 export type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
 
+declare const File: any;
+
 /**
  * Type-safe omit function - returns a new object which omits the specified keys.
  */
-export function omit<T extends object, K extends keyof T>(obj: T, keysToOmit: K[]): Omit<T, K> {
+export function omit<T extends object, K extends keyof T>(obj: T, keysToOmit: K[]): Omit<T, K>;
+export function omit<T extends object | any[], K extends keyof T>(obj: T, keysToOmit: string[], recursive: boolean): T;
+export function omit<T extends any, K extends keyof T>(obj: T, keysToOmit: string[], recursive: boolean = false): T {
+    if ((recursive && !isObject(obj)) || obj instanceof File) {
+        return obj;
+    }
+
+    if (recursive && Array.isArray(obj)) {
+        return obj.map((item: any) => omit(item, keysToOmit, true));
+    }
+
     return Object.keys(obj).reduce((output: any, key) => {
-        if (keysToOmit.includes(key as K)) {
+        if (keysToOmit.includes(key)) {
             return output;
         }
-        return { ...output, [key]: (obj as any)[key] };
+        if (recursive) {
+            return {...output, [key]: omit((obj as any)[key], keysToOmit, true)};
+        }
+        return {...output, [key]: (obj as any)[key]};
     }, {} as Omit<T, K>);
 }
+
+function isObject(input: any): input is object {
+    return typeof input === 'object' && input !== null;
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů