Browse Source

feat(admin-ui): Tags can be assigned to Assets in detail view

Relates to #316.
Michael Bromley 5 years ago
parent
commit
995d1b4185
25 changed files with 433 additions and 61 deletions
  1. 16 16
      packages/admin-ui/i18n-coverage.json
  2. 12 11
      packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts
  3. 104 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  4. 71 0
      packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts
  5. 33 0
      packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts
  6. 2 0
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html
  7. 16 14
      packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts
  8. 2 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.html
  9. 28 4
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts
  10. 13 2
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html
  11. 5 2
      packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts
  12. 17 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html
  13. 5 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss
  14. 59 0
      packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts
  15. 2 2
      packages/admin-ui/src/lib/core/src/shared/pipes/asset-preview.pipe.ts
  16. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  17. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/cs.json
  18. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  19. 2 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  20. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  21. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/fr.json
  22. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  23. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json
  24. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  25. 6 1
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

+ 16 - 16
packages/admin-ui/i18n-coverage.json

@@ -1,51 +1,51 @@
 {
-  "generatedOn": "2021-01-12T10:57:57.374Z",
-  "lastCommit": "b2b37a8e8ec51354855e37ed9ed6f6be03518c15",
+  "generatedOn": "2021-01-18T19:42:25.183Z",
+  "lastCommit": "cd645caa10aac99dfb3268624888835e8dcf3a2d",
   "translationStatus": {
     "cs": {
-      "tokenCount": 752,
-      "translatedCount": 751,
+      "tokenCount": 757,
+      "translatedCount": 755,
       "percentage": 100
     },
     "de": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 596,
       "percentage": 79
     },
     "en": {
-      "tokenCount": 752,
-      "translatedCount": 752,
+      "tokenCount": 757,
+      "translatedCount": 756,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 458,
       "percentage": 61
     },
     "fr": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 692,
-      "percentage": 92
+      "percentage": 91
     },
     "pl": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 551,
       "percentage": 73
     },
     "pt_BR": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 642,
       "percentage": 85
     },
     "zh_Hans": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 533,
-      "percentage": 71
+      "percentage": 70
     },
     "zh_Hant": {
-      "tokenCount": 752,
+      "tokenCount": 757,
       "translatedCount": 533,
-      "percentage": 71
+      "percentage": 70
     }
   }
 }

+ 12 - 11
packages/admin-ui/src/lib/catalog/src/components/asset-detail/asset-detail.component.ts

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/
 import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { Asset, BaseDetailComponent, LanguageCode } from '@vendure/admin-ui/core';
+import { Asset, BaseDetailComponent, GetAsset, LanguageCode } from '@vendure/admin-ui/core';
 import { DataService, NotificationService, ServerConfigService } from '@vendure/admin-ui/core';
 
 @Component({
@@ -11,7 +11,7 @@ import { DataService, NotificationService, ServerConfigService } from '@vendure/
     styleUrls: ['./asset-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> implements OnInit, OnDestroy {
+export class AssetDetailComponent extends BaseDetailComponent<GetAsset.Asset> implements OnInit, OnDestroy {
     detailForm = new FormGroup({});
 
     constructor(
@@ -28,6 +28,7 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
     ngOnInit() {
         this.detailForm = new FormGroup({
             name: new FormControl(''),
+            tags: new FormControl([]),
         });
         this.init();
     }
@@ -36,11 +37,10 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
         this.destroy();
     }
 
-    onAssetChange(event: { id: string; name: string }) {
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.setValue(event.name);
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.markAsDirty();
+    onAssetChange(event: { id: string; name: string; tags: string[] }) {
+        this.detailForm.get('name')?.setValue(event.name);
+        this.detailForm.get('tags')?.setValue(event.tags);
+        this.detailForm.markAsDirty();
     }
 
     save() {
@@ -48,12 +48,13 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
             .updateAsset({
                 id: this.id,
                 name: this.detailForm.value.name,
+                tags: this.detailForm.value.tags,
             })
             .subscribe(
                 () => {
                     this.notificationService.success(_('common.notify-update-success'), { entity: 'Asset' });
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Asset',
                     });
@@ -61,8 +62,8 @@ export class AssetDetailComponent extends BaseDetailComponent<Asset.Fragment> im
             );
     }
 
-    protected setFormValues(entity: Asset.Fragment, languageCode: LanguageCode): void {
-        // tslint:disable-next-line:no-non-null-assertion
-        this.detailForm.get('name')!.setValue(entity.name);
+    protected setFormValues(entity: GetAsset.Asset, languageCode: LanguageCode): void {
+        this.detailForm.get('name')?.setValue(entity.name);
+        this.detailForm.get('tags')?.setValue(entity.tags);
     }
 }

+ 104 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -5977,6 +5977,11 @@ export type AssetFragment = (
   )> }
 );
 
+export type TagFragment = (
+  { __typename?: 'Tag' }
+  & Pick<Tag, 'id' | 'value'>
+);
+
 export type ProductOptionGroupFragment = (
   { __typename?: 'ProductOptionGroup' }
   & Pick<ProductOptionGroup, 'id' | 'code' | 'languageCode' | 'name'>
@@ -6249,6 +6254,10 @@ export type GetAssetListQuery = { assets: (
     & Pick<AssetList, 'totalItems'>
     & { items: Array<(
       { __typename?: 'Asset' }
+      & { tags: Array<(
+        { __typename?: 'Tag' }
+        & TagFragment
+      )> }
       & AssetFragment
     )> }
   ) };
@@ -6260,6 +6269,10 @@ export type GetAssetQueryVariables = Exact<{
 
 export type GetAssetQuery = { asset?: Maybe<(
     { __typename?: 'Asset' }
+    & { tags: Array<(
+      { __typename?: 'Tag' }
+      & TagFragment
+    )> }
     & AssetFragment
   )> };
 
@@ -6488,6 +6501,60 @@ export type GetProductVariantQuery = { productVariant?: Maybe<(
     ) }
   )> };
 
+export type GetTagListQueryVariables = Exact<{
+  options?: Maybe<TagListOptions>;
+}>;
+
+
+export type GetTagListQuery = { tags: (
+    { __typename?: 'TagList' }
+    & Pick<TagList, 'totalItems'>
+    & { items: Array<(
+      { __typename?: 'Tag' }
+      & TagFragment
+    )> }
+  ) };
+
+export type GetTagQueryVariables = Exact<{
+  id: Scalars['ID'];
+}>;
+
+
+export type GetTagQuery = { tag: (
+    { __typename?: 'Tag' }
+    & TagFragment
+  ) };
+
+export type CreateTagMutationVariables = Exact<{
+  input: CreateTagInput;
+}>;
+
+
+export type CreateTagMutation = { createTag: (
+    { __typename?: 'Tag' }
+    & TagFragment
+  ) };
+
+export type UpdateTagMutationVariables = Exact<{
+  input: UpdateTagInput;
+}>;
+
+
+export type UpdateTagMutation = { updateTag: (
+    { __typename?: 'Tag' }
+    & TagFragment
+  ) };
+
+export type DeleteTagMutationVariables = Exact<{
+  id: Scalars['ID'];
+}>;
+
+
+export type DeleteTagMutation = { deleteTag: (
+    { __typename?: 'DeletionResponse' }
+    & Pick<DeletionResponse, 'message' | 'result'>
+  ) };
+
 export type PromotionFragment = (
   { __typename?: 'Promotion' }
   & Pick<Promotion, 'id' | 'createdAt' | 'updatedAt' | 'name' | 'enabled' | 'couponCode' | 'perCustomerUsageLimit' | 'startsAt' | 'endsAt'>
@@ -8420,6 +8487,10 @@ export namespace Asset {
   export type FocalPoint = (NonNullable<AssetFragment['focalPoint']>);
 }
 
+export namespace Tag {
+  export type Fragment = TagFragment;
+}
+
 export namespace ProductOptionGroup {
   export type Fragment = ProductOptionGroupFragment;
   export type Translations = NonNullable<(NonNullable<ProductOptionGroupFragment['translations']>)[number]>;
@@ -8553,12 +8624,14 @@ export namespace GetAssetList {
   export type Query = GetAssetListQuery;
   export type Assets = (NonNullable<GetAssetListQuery['assets']>);
   export type Items = NonNullable<(NonNullable<(NonNullable<GetAssetListQuery['assets']>)['items']>)[number]>;
+  export type Tags = NonNullable<(NonNullable<NonNullable<(NonNullable<(NonNullable<GetAssetListQuery['assets']>)['items']>)[number]>['tags']>)[number]>;
 }
 
 export namespace GetAsset {
   export type Variables = GetAssetQueryVariables;
   export type Query = GetAssetQuery;
   export type Asset = (NonNullable<GetAssetQuery['asset']>);
+  export type Tags = NonNullable<(NonNullable<(NonNullable<GetAssetQuery['asset']>)['tags']>)[number]>;
 }
 
 export namespace CreateAssets {
@@ -8666,6 +8739,37 @@ export namespace GetProductVariant {
   export type FocalPoint = (NonNullable<(NonNullable<(NonNullable<(NonNullable<GetProductVariantQuery['productVariant']>)['product']>)['featuredAsset']>)['focalPoint']>);
 }
 
+export namespace GetTagList {
+  export type Variables = GetTagListQueryVariables;
+  export type Query = GetTagListQuery;
+  export type Tags = (NonNullable<GetTagListQuery['tags']>);
+  export type Items = NonNullable<(NonNullable<(NonNullable<GetTagListQuery['tags']>)['items']>)[number]>;
+}
+
+export namespace GetTag {
+  export type Variables = GetTagQueryVariables;
+  export type Query = GetTagQuery;
+  export type Tag = (NonNullable<GetTagQuery['tag']>);
+}
+
+export namespace CreateTag {
+  export type Variables = CreateTagMutationVariables;
+  export type Mutation = CreateTagMutation;
+  export type CreateTag = (NonNullable<CreateTagMutation['createTag']>);
+}
+
+export namespace UpdateTag {
+  export type Variables = UpdateTagMutationVariables;
+  export type Mutation = UpdateTagMutation;
+  export type UpdateTag = (NonNullable<UpdateTagMutation['updateTag']>);
+}
+
+export namespace DeleteTag {
+  export type Variables = DeleteTagMutationVariables;
+  export type Mutation = DeleteTagMutation;
+  export type DeleteTag = (NonNullable<DeleteTagMutation['deleteTag']>);
+}
+
 export namespace Promotion {
   export type Fragment = PromotionFragment;
   export type Conditions = NonNullable<(NonNullable<PromotionFragment['conditions']>)[number]>;

+ 71 - 0
packages/admin-ui/src/lib/core/src/data/definitions/product-definitions.ts

@@ -22,6 +22,13 @@ export const ASSET_FRAGMENT = gql`
     }
 `;
 
+export const TAG_FRAGMENT = gql`
+    fragment Tag on Tag {
+        id
+        value
+    }
+`;
+
 export const PRODUCT_OPTION_GROUP_FRAGMENT = gql`
     fragment ProductOptionGroup on ProductOptionGroup {
         id
@@ -363,41 +370,57 @@ export const GET_ASSET_LIST = gql`
         assets(options: $options) {
             items {
                 ...Asset
+                tags {
+                    ...Tag
+                }
             }
             totalItems
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const GET_ASSET = gql`
     query GetAsset($id: ID!) {
         asset(id: $id) {
             ...Asset
+            tags {
+                ...Tag
+            }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const CREATE_ASSETS = gql`
     mutation CreateAssets($input: [CreateAssetInput!]!) {
         createAssets(input: $input) {
             ...Asset
+            tags {
+                ...Tag
+            }
             ... on ErrorResult {
                 message
             }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const UPDATE_ASSET = gql`
     mutation UpdateAsset($input: UpdateAssetInput!) {
         updateAsset(input: $input) {
             ...Asset
+            tags {
+                ...Tag
+            }
         }
     }
     ${ASSET_FRAGMENT}
+    ${TAG_FRAGMENT}
 `;
 
 export const DELETE_ASSETS = gql`
@@ -613,3 +636,51 @@ export const GET_PRODUCT_VARIANT = gql`
         }
     }
 `;
+
+export const GET_TAG_LIST = gql`
+    query GetTagList($options: TagListOptions) {
+        tags(options: $options) {
+            items {
+                ...Tag
+            }
+            totalItems
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const GET_TAG = gql`
+    query GetTag($id: ID!) {
+        tag(id: $id) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const CREATE_TAG = gql`
+    mutation CreateTag($input: CreateTagInput!) {
+        createTag(input: $input) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const UPDATE_TAG = gql`
+    mutation UpdateTag($input: UpdateTagInput!) {
+        updateTag(input: $input) {
+            ...Tag
+        }
+    }
+    ${TAG_FRAGMENT}
+`;
+
+export const DELETE_TAG = gql`
+    mutation DeleteTag($id: ID!) {
+        deleteTag(id: $id) {
+            message
+            result
+        }
+    }
+`;

+ 33 - 0
packages/admin-ui/src/lib/core/src/data/providers/product-data.service.ts

@@ -15,9 +15,12 @@ import {
     CreateProductOptionInput,
     CreateProductVariantInput,
     CreateProductVariants,
+    CreateTag,
+    CreateTagInput,
     DeleteAssets,
     DeleteProduct,
     DeleteProductVariant,
+    DeleteTag,
     GetAsset,
     GetAssetList,
     GetProductList,
@@ -26,6 +29,8 @@ import {
     GetProductVariant,
     GetProductVariantOptions,
     GetProductWithVariants,
+    GetTag,
+    GetTagList,
     ProductListOptions,
     ProductSelectorSearch,
     Reindex,
@@ -36,6 +41,7 @@ import {
     RemoveVariantsFromChannel,
     SearchProducts,
     SortOrder,
+    TagListOptions,
     UpdateAsset,
     UpdateAssetInput,
     UpdateProduct,
@@ -44,6 +50,8 @@ import {
     UpdateProductOptionInput,
     UpdateProductVariantInput,
     UpdateProductVariants,
+    UpdateTag,
+    UpdateTagInput,
 } from '../../common/generated-types';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
@@ -54,9 +62,11 @@ import {
     CREATE_PRODUCT,
     CREATE_PRODUCT_OPTION_GROUP,
     CREATE_PRODUCT_VARIANTS,
+    CREATE_TAG,
     DELETE_ASSETS,
     DELETE_PRODUCT,
     DELETE_PRODUCT_VARIANT,
+    DELETE_TAG,
     GET_ASSET,
     GET_ASSET_LIST,
     GET_PRODUCT_LIST,
@@ -65,6 +75,8 @@ import {
     GET_PRODUCT_VARIANT,
     GET_PRODUCT_VARIANT_OPTIONS,
     GET_PRODUCT_WITH_VARIANTS,
+    GET_TAG,
+    GET_TAG_LIST,
     PRODUCT_SELECTOR_SEARCH,
     REMOVE_OPTION_GROUP_FROM_PRODUCT,
     REMOVE_PRODUCTS_FROM_CHANNEL,
@@ -74,6 +86,7 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_OPTION,
     UPDATE_PRODUCT_VARIANTS,
+    UPDATE_TAG,
 } from '../definitions/product-definitions';
 import { REINDEX } from '../definitions/settings-definitions';
 
@@ -354,4 +367,24 @@ export class ProductDataService {
             input,
         });
     }
+
+    getTag(id: string) {
+        return this.baseDataService.query<GetTag.Query, GetTag.Variables>(GET_TAG, { id });
+    }
+
+    getTagList(options?: TagListOptions) {
+        return this.baseDataService.query<GetTagList.Query, GetTagList.Variables>(GET_TAG_LIST, { options });
+    }
+
+    createTag(input: CreateTagInput) {
+        return this.baseDataService.mutate<CreateTag.Mutation, CreateTag.Variables>(CREATE_TAG, { input });
+    }
+
+    updateTag(input: UpdateTagInput) {
+        return this.baseDataService.mutate<UpdateTag.Mutation, UpdateTag.Variables>(UPDATE_TAG, { input });
+    }
+
+    deleteTag(id: string) {
+        return this.baseDataService.mutate<DeleteTag.Mutation, DeleteTag.Variables>(DELETE_TAG, { id });
+    }
 }

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html

@@ -35,7 +35,9 @@
         <div class="card-block details" *ngIf="selection.length >= 1">
             <div class="name">{{ lastSelected().name }}</div>
             <div>{{ 'asset.original-asset-size' | translate }}: {{ lastSelected().fileSize | filesize }}</div>
+
             <ng-container *ngIf="selection.length === 1">
+                <vdr-chip *ngFor="let tag of lastSelected().tags" [colorFrom]="tag.value"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag.value }}</vdr-chip>
                 <div>
                     <button (click)="previewAsset(lastSelected())" class="btn btn-link">
                         <clr-icon shape="eye"></clr-icon> {{ 'asset.preview' | translate }}

+ 16 - 14
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts

@@ -1,9 +1,11 @@
 import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
 
-import { Asset } from '../../../common/generated-types';
+import { Asset, GetAssetList } from '../../../common/generated-types';
 import { ModalService } from '../../../providers/modal/modal.service';
 import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-preview-dialog.component';
 
+export type AssetLike = GetAssetList.Items;
+
 @Component({
     selector: 'vdr-asset-gallery',
     templateUrl: './asset-gallery.component.html',
@@ -11,16 +13,16 @@ import { AssetPreviewDialogComponent } from '../asset-preview-dialog/asset-previ
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetGalleryComponent implements OnChanges {
-    @Input() assets: Asset[];
+    @Input() assets: AssetLike[];
     /**
      * If true, allows multiple assets to be selected by ctrl+clicking.
      */
     @Input() multiSelect = false;
     @Input() canDelete = false;
-    @Output() selectionChange = new EventEmitter<Asset[]>();
-    @Output() deleteAssets = new EventEmitter<Asset[]>();
+    @Output() selectionChange = new EventEmitter<AssetLike[]>();
+    @Output() deleteAssets = new EventEmitter<AssetLike[]>();
 
-    selection: Asset[] = [];
+    selection: AssetLike[] = [];
 
     constructor(private modalService: ModalService) {}
 
@@ -28,7 +30,7 @@ export class AssetGalleryComponent implements OnChanges {
         if (this.assets) {
             for (const asset of this.selection) {
                 // Update and selected assets with any changes
-                const match = this.assets.find((a) => a.id === asset.id);
+                const match = this.assets.find(a => a.id === asset.id);
                 if (match) {
                     Object.assign(asset, match);
                 }
@@ -37,15 +39,15 @@ export class AssetGalleryComponent implements OnChanges {
     }
 
     toggleSelection(event: MouseEvent, asset: Asset) {
-        const index = this.selection.findIndex((a) => a.id === asset.id);
+        const index = this.selection.findIndex(a => a.id === asset.id);
         if (this.multiSelect && event.shiftKey && 1 <= this.selection.length) {
             const lastSelection = this.selection[this.selection.length - 1];
-            const lastSelectionIndex = this.assets.findIndex((a) => a.id === lastSelection.id);
-            const currentIndex = this.assets.findIndex((a) => a.id === asset.id);
+            const lastSelectionIndex = this.assets.findIndex(a => a.id === lastSelection.id);
+            const currentIndex = this.assets.findIndex(a => a.id === asset.id);
             const start = currentIndex < lastSelectionIndex ? currentIndex : lastSelectionIndex;
             const end = currentIndex > lastSelectionIndex ? currentIndex + 1 : lastSelectionIndex;
             this.selection.push(
-                ...this.assets.slice(start, end).filter((a) => !this.selection.find((s) => s.id === a.id)),
+                ...this.assets.slice(start, end).filter(a => !this.selection.find(s => s.id === a.id)),
             );
         } else if (index === -1) {
             if (this.multiSelect && (event.ctrlKey || event.shiftKey)) {
@@ -65,15 +67,15 @@ export class AssetGalleryComponent implements OnChanges {
         this.selectionChange.emit(this.selection);
     }
 
-    isSelected(asset: Asset): boolean {
-        return !!this.selection.find((a) => a.id === asset.id);
+    isSelected(asset: AssetLike): boolean {
+        return !!this.selection.find(a => a.id === asset.id);
     }
 
-    lastSelected(): Asset {
+    lastSelected(): AssetLike {
         return this.selection[this.selection.length - 1];
     }
 
-    previewAsset(asset: Asset) {
+    previewAsset(asset: AssetLike) {
         this.modalService
             .fromComponent(AssetPreviewDialogComponent, {
                 size: 'xl',

+ 2 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.html

@@ -5,7 +5,8 @@
 </ng-template>
 
 <vdr-asset-preview
-    [asset]="asset"
+    *ngIf="assetWithTags$ | async as assetWithTags"
+    [asset]="assetWithTags"
     (assetChange)="assetChanges = $event"
     (editClick)="resolveWith()"
 ></vdr-asset-preview>

+ 28 - 4
packages/admin-ui/src/lib/core/src/shared/components/asset-preview-dialog/asset-preview-dialog.component.ts

@@ -1,7 +1,12 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { mergeMap } from 'rxjs/operators';
 
+import { AssetFragment, GetAsset, GetAssetList, UpdateAssetInput } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
 import { Dialog } from '../../../providers/modal/modal.service';
-import { Asset, UpdateAssetInput } from '../../../common/generated-types';
+
+type AssetLike = GetAssetList.Items | AssetFragment;
 
 @Component({
     selector: 'vdr-asset-preview-dialog',
@@ -9,8 +14,27 @@ import { Asset, UpdateAssetInput } from '../../../common/generated-types';
     styleUrls: ['./asset-preview-dialog.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class AssetPreviewDialogComponent implements Dialog<void> {
-    asset: Asset;
+export class AssetPreviewDialogComponent implements Dialog<void>, OnInit {
+    constructor(private dataService: DataService) {}
+    asset: AssetLike;
     assetChanges?: UpdateAssetInput;
     resolveWith: (result?: void) => void;
+    assetWithTags$: Observable<GetAsset.Asset>;
+
+    ngOnInit() {
+        this.assetWithTags$ = of(this.asset).pipe(
+            mergeMap(asset => {
+                if (this.hasTags(asset)) {
+                    return of(asset);
+                } else {
+                    // tslint:disable-next-line:no-non-null-assertion
+                    return this.dataService.product.getAsset(asset.id).mapSingle(data => data.asset!);
+                }
+            }),
+        );
+    }
+
+    private hasTags(asset: AssetLike): asset is GetAssetList.Items {
+        return asset.hasOwnProperty('tags');
+    }
 }

+ 13 - 2
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.html

@@ -8,7 +8,12 @@
             [editable]="settingFocalPoint"
             (focalPointChange)="onFocalPointChange($event)"
         >
-            <img class="asset-image" [src]="asset | assetPreview:size" #imageElement (load)="onImageLoad()" />
+            <img
+                class="asset-image"
+                [src]="asset | assetPreview: size"
+                #imageElement
+                (load)="onImageLoad()"
+            />
         </vdr-focal-point-control>
         <div class="focal-point-info" *ngIf="settingFocalPoint">
             <button class="icon-button" (click)="setFocalPointCancel()">
@@ -36,7 +41,7 @@
 
         <vdr-labeled-data [label]="'common.name' | translate" *ngIf="!editable">
             <span class="elide">
-            {{ asset.name }}
+                {{ asset.name }}
             </span>
         </vdr-labeled-data>
 
@@ -78,6 +83,12 @@
                 {{ 'asset.unset-focal-point' | translate }}
             </button>
         </vdr-labeled-data>
+        <vdr-labeled-data [label]="'common.tags' | translate">
+            <vdr-tag-selector *ngIf="editable" formControlName="tags"></vdr-tag-selector>
+            <div *ngIf="!editable">
+                <vdr-chip *ngFor="let tag of asset.tags" [colorFrom]="tag.value"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag.value }}</vdr-chip>
+            </div>
+        </vdr-labeled-data>
     </form>
     <div class="flex-spacer"></div>
     <div class="preview-select">

+ 5 - 2
packages/admin-ui/src/lib/core/src/shared/components/asset-preview/asset-preview.component.ts

@@ -15,12 +15,13 @@ import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import { fromEvent, Subscription } from 'rxjs';
 import { debounceTime } from 'rxjs/operators';
 
+import { GetAsset, GetAssetList, UpdateAssetInput } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
 import { NotificationService } from '../../../providers/notification/notification.service';
-import { Asset, UpdateAssetInput } from '../../../common/generated-types';
 import { Point } from '../focal-point-control/focal-point-control.component';
 
 export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | '';
+type AssetLike = GetAssetList.Items | GetAsset.Asset;
 
 @Component({
     selector: 'vdr-asset-preview',
@@ -29,7 +30,7 @@ export type PreviewPreset = 'tiny' | 'thumb' | 'small' | 'medium' | 'large' | ''
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class AssetPreviewComponent implements OnInit, OnDestroy {
-    @Input() asset: Asset;
+    @Input() asset: AssetLike;
     @Input() editable = false;
     @Output() assetChange = new EventEmitter<Omit<UpdateAssetInput, 'focalPoint'>>();
     @Output() editClick = new EventEmitter();
@@ -66,11 +67,13 @@ export class AssetPreviewComponent implements OnInit, OnDestroy {
         const { focalPoint } = this.asset;
         this.form = this.formBuilder.group({
             name: [this.asset.name],
+            tags: [this.asset.tags?.map(t => t.value)],
         });
         this.subscription = this.form.valueChanges.subscribe(value => {
             this.assetChange.emit({
                 id: this.asset.id,
                 name: value.name,
+                tags: value.tags,
             });
         });
 

+ 17 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.html

@@ -0,0 +1,17 @@
+<ng-select
+    [addTag]="addTagFn"
+    [multiple]="true"
+    [ngModel]="_value"
+    [clearable]="true"
+    [searchable]="true"
+    [disabled]="disabled"
+    (change)="valueChanged($event)"
+>
+    <ng-template ng-label-tmp let-tag="item" let-clear="clear">
+        <span aria-hidden="true" class="ng-value-icon left" (click)="clear(tag)"> × </span>
+        <vdr-chip [colorFrom]="tag"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
+    </ng-template>
+    <ng-option *ngFor="let tag of allTags$ | async" [value]="tag">
+        <vdr-chip [colorFrom]="tag"><clr-icon shape="tag" class="mr2"></clr-icon> {{ tag }}</vdr-chip>
+    </ng-option>
+</ng-select>

+ 5 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.scss

@@ -0,0 +1,5 @@
+:host {
+    display: block;
+    margin-top: 12px;
+    position: relative;
+}

+ 59 - 0
packages/admin-ui/src/lib/core/src/shared/components/tag-selector/tag-selector.component.ts

@@ -0,0 +1,59 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { DataService, TagFragment } from '@vendure/admin-ui/core';
+import { Observable } from 'rxjs';
+
+@Component({
+    selector: 'vdr-tag-selector',
+    templateUrl: './tag-selector.component.html',
+    styleUrls: ['./tag-selector.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [
+        {
+            provide: NG_VALUE_ACCESSOR,
+            useExisting: TagSelectorComponent,
+            multi: true,
+        },
+    ],
+})
+export class TagSelectorComponent implements OnInit, ControlValueAccessor {
+    allTags$: Observable<string[]>;
+    onChange: (val: any) => void;
+    onTouch: () => void;
+    _value: string[];
+    disabled: boolean;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit(): void {
+        this.allTags$ = this.dataService.product
+            .getTagList()
+            .mapStream(data => data.tags.items.map(i => i.value));
+    }
+
+    addTagFn(val: string) {
+        return val;
+    }
+
+    registerOnChange(fn: any): void {
+        this.onChange = fn;
+    }
+
+    registerOnTouched(fn: any): void {
+        this.onTouch = fn;
+    }
+
+    setDisabledState(isDisabled: boolean): void {
+        this.disabled = isDisabled;
+    }
+
+    writeValue(obj: unknown): void {
+        if (Array.isArray(obj)) {
+            this._value = obj;
+        }
+    }
+
+    valueChanged(event: string[]) {
+        this.onChange(event);
+    }
+}

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/pipes/asset-preview.pipe.ts

@@ -1,12 +1,12 @@
 import { Pipe, PipeTransform } from '@angular/core';
 
-import { Asset } from '../../common/generated-types';
+import { AssetFragment } from '../../common/generated-types';
 
 @Pipe({
     name: 'assetPreview',
 })
 export class AssetPreviewPipe implements PipeTransform {
-    transform(asset: Asset, preset: string | number = 'thumb'): string {
+    transform(asset: AssetFragment, preset: string | number = 'thumb'): string {
         if (!asset.preview || typeof asset.preview !== 'string') {
             throw new Error(`Expected an Asset, got ${JSON.stringify(asset)}`);
         }

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

@@ -72,6 +72,7 @@ import { RichTextEditorComponent } from './components/rich-text-editor/rich-text
 import { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 import { SimpleDialogComponent } from './components/simple-dialog/simple-dialog.component';
 import { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
+import { TagSelectorComponent } from './components/tag-selector/tag-selector.component';
 import { TimelineEntryComponent } from './components/timeline-entry/timeline-entry.component';
 import { TitleInputComponent } from './components/title-input/title-input.component';
 import { DisabledDirective } from './directives/disabled.directive';
@@ -197,6 +198,7 @@ const DECLARATIONS = [
     AddressFormComponent,
     LocaleDatePipe,
     LocaleCurrencyPipe,
+    TagSelectorComponent,
 ];
 
 const DYNAMIC_FORM_INPUTS = [

+ 2 - 1
packages/admin-ui/src/lib/static/i18n-messages/cs.json

@@ -221,6 +221,7 @@
     "select": "Vybrat...",
     "select-display-language": "Vyberte jazyk",
     "select-today": "Vybrat dnešní datum",
+    "tags": "",
     "theme": "Motiv",
     "there-are-unsaved-changes": "Provedené změny nebyly uloženy. Přechod na jinou stránku způsobí ztrátu těchto změn.",
     "toggle-all": "Přepnout vše",
@@ -789,4 +790,4 @@
     "job-result": "Výsledek úlohy",
     "job-state": "Stav úlohy"
   }
-}
+}

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -221,6 +221,7 @@
     "select": "Auswählen...",
     "select-display-language": "Anzeigesprache wählen",
     "select-today": "Heute auswählen",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "Es gibt ungespeicherte Änderungen. Wenn Sie wechseln, gehen diese Änderungen verloren.",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "{count, plural, one {Vor einem Tag} other {Vor {count} Tagen}}",

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

@@ -221,6 +221,7 @@
     "select": "Select...",
     "select-display-language": "Select display language",
     "select-today": "Select today",
+    "tags": "Tags",
     "theme": "Theme",
     "there-are-unsaved-changes": "There are unsaved changes. Navigating away will cause these changes to be lost.",
     "toggle-all": "Toggle all",
@@ -789,4 +790,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -221,6 +221,7 @@
     "select": "Seleccionar...",
     "select-display-language": "Seleccionar idioma de interfaz",
     "select-today": "Hoy",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "Hay cambios sin guardar. Si sale de este sitio sus cambios se perderán.",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/fr.json

@@ -221,6 +221,7 @@
     "select": "Selectionner...",
     "select-display-language": "Choisir la langue d'affichage",
     "select-today": "Choisir aujourd'hui",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "Il y a des changements non enregistrés. Naviguer ailleurs fera perdre ces changements.",
     "toggle-all": "Cocher/décocher Tout",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "Il y a {count, plural, one {1 jour} other {{count} jours}}",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -221,6 +221,7 @@
     "select": "Wybrano...",
     "select-display-language": "Wybierz język",
     "select-today": "Wybierz dzisiaj",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "Są nie zapisane zmiany. Nawigacja do innej lokalizacji spowoduje utrate zmian.",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "{count, plural, one {1 dzień} other {{count} dni}} temu",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/pt_BR.json

@@ -221,6 +221,7 @@
     "select": "Selecione...",
     "select-display-language": "Selecionar idioma de exibição",
     "select-today": "Selecione hoje",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "Há alterações não salvas. Navegar para outra página fará com que essas alterações sejam perdidas.",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "{count, plural, one {1 day} other {{count} days}} atrás",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -221,6 +221,7 @@
     "select": "选择...",
     "select-display-language": "选择显示语言",
     "select-today": "选择今天",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "修改尚未被保存,现在离开会导致您的修改会被删除",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "",

+ 6 - 1
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -221,6 +221,7 @@
     "select": "選擇...",
     "select-display-language": "選擇顯示語言",
     "select-today": "選擇今天",
+    "tags": "",
     "theme": "",
     "there-are-unsaved-changes": "變更尚未被儲存,離開會失去所有變更",
     "toggle-all": "",
@@ -302,10 +303,14 @@
     "latest-orders": "",
     "orders-summary": "",
     "remove-widget": "",
+    "thisMonth": "",
+    "thisWeek": "",
+    "today": "",
     "total-order-value": "",
     "total-orders": "",
     "widget-resize": "",
-    "widget-width": ""
+    "widget-width": "",
+    "yesterday": ""
   },
   "datetime": {
     "ago-days": "",