Browse Source

feat(core): Add support for Asset tags

Relates to #316.

BREAKING CHANGE: A database migration is required for the new Asset tags support.
Michael Bromley 5 years ago
parent
commit
71cf3b9016
25 changed files with 544 additions and 131 deletions
  1. 37 23
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 2 1
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 35 22
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 9 0
      packages/common/src/generated-shop-types.ts
  5. 37 23
      packages/common/src/generated-types.ts
  6. 154 2
      packages/core/e2e/asset.e2e-spec.ts
  7. 61 26
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  8. 8 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  9. 4 0
      packages/core/e2e/graphql/shared-definitions.ts
  10. 2 0
      packages/core/src/api/api-internal-modules.ts
  11. 20 0
      packages/core/src/api/resolvers/entity/asset-entity.resolver.ts
  12. 3 0
      packages/core/src/api/schema/admin-api/asset-admin.type.graphql
  13. 6 1
      packages/core/src/api/schema/admin-api/asset.api.graphql
  14. 6 0
      packages/core/src/api/schema/common/tag.type.graphql
  15. 8 0
      packages/core/src/common/types/common-types.ts
  16. 8 2
      packages/core/src/entity/asset/asset.entity.ts
  17. 2 0
      packages/core/src/entity/entities.ts
  18. 1 0
      packages/core/src/entity/index.ts
  19. 21 0
      packages/core/src/entity/tag/tag.entity.ts
  20. 2 0
      packages/core/src/service/service.module.ts
  21. 44 9
      packages/core/src/service/services/asset.service.ts
  22. 39 0
      packages/core/src/service/services/tag.service.ts
  23. 35 22
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  24. 0 0
      schema-admin.json
  25. 0 0
      schema-shop.json

+ 37 - 23
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1037,6 +1037,23 @@ export type AdministratorList = PaginatedList & {
   totalItems: Scalars['Int'];
 };
 
+export type Asset = Node & {
+  __typename?: 'Asset';
+  tags: Array<Tag>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  name: Scalars['String'];
+  type: AssetType;
+  fileSize: Scalars['Int'];
+  mimeType: Scalars['String'];
+  width: Scalars['Int'];
+  height: Scalars['Int'];
+  source: Scalars['String'];
+  preview: Scalars['String'];
+  focalPoint?: Maybe<Coordinate>;
+};
+
 export type MimeTypeError = ErrorResult & {
   __typename?: 'MimeTypeError';
   errorCode: ErrorCode;
@@ -1047,8 +1064,18 @@ export type MimeTypeError = ErrorResult & {
 
 export type CreateAssetResult = Asset | MimeTypeError;
 
+export type AssetListOptions = {
+  skip?: Maybe<Scalars['Int']>;
+  take?: Maybe<Scalars['Int']>;
+  sort?: Maybe<AssetSortParameter>;
+  filter?: Maybe<AssetFilterParameter>;
+  tags?: Maybe<Array<Scalars['String']>>;
+  tagsOperator?: Maybe<LogicalOperator>;
+};
+
 export type CreateAssetInput = {
   file: Scalars['Upload'];
+  tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type CoordinateInput = {
@@ -1060,6 +1087,7 @@ export type UpdateAssetInput = {
   id: Scalars['ID'];
   name?: Maybe<Scalars['String']>;
   focalPoint?: Maybe<CoordinateInput>;
+  tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
@@ -2366,22 +2394,6 @@ export type Address = Node & {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Asset = Node & {
-  __typename?: 'Asset';
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  name: Scalars['String'];
-  type: AssetType;
-  fileSize: Scalars['Int'];
-  mimeType: Scalars['String'];
-  width: Scalars['Int'];
-  height: Scalars['Int'];
-  source: Scalars['String'];
-  preview: Scalars['String'];
-  focalPoint?: Maybe<Coordinate>;
-};
-
 export type Coordinate = {
   __typename?: 'Coordinate';
   x: Scalars['Float'];
@@ -3728,6 +3740,7 @@ export type OrderAddress = {
   country?: Maybe<Scalars['String']>;
   countryCode?: Maybe<Scalars['String']>;
   phoneNumber?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -4086,6 +4099,14 @@ export type ShippingMethodList = PaginatedList & {
   totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+  __typename?: 'Tag';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
   __typename?: 'TaxCategory';
   id: Scalars['ID'];
@@ -4150,13 +4171,6 @@ export type AdministratorListOptions = {
   filter?: Maybe<AdministratorFilterParameter>;
 };
 
-export type AssetListOptions = {
-  skip?: Maybe<Scalars['Int']>;
-  take?: Maybe<Scalars['Int']>;
-  sort?: Maybe<AssetSortParameter>;
-  filter?: Maybe<AssetFilterParameter>;
-};
-
 export type CollectionListOptions = {
   skip?: Maybe<Scalars['Int']>;
   take?: Maybe<Scalars['Int']>;

+ 2 - 1
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -89,6 +89,7 @@ const result: PossibleTypesResultData = {
         ],
         Node: [
             'Administrator',
+            'Asset',
             'Collection',
             'Customer',
             'Facet',
@@ -107,7 +108,6 @@ const result: PossibleTypesResultData = {
             'Return',
             'Release',
             'Address',
-            'Asset',
             'Channel',
             'Country',
             'CustomerGroup',
@@ -122,6 +122,7 @@ const result: PossibleTypesResultData = {
             'Promotion',
             'Role',
             'ShippingMethod',
+            'Tag',
             'TaxCategory',
             'TaxRate',
             'User',

+ 35 - 22
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -861,6 +861,22 @@ export type AdministratorList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Asset = Node & {
+    tags: Array<Tag>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    name: Scalars['String'];
+    type: AssetType;
+    fileSize: Scalars['Int'];
+    mimeType: Scalars['String'];
+    width: Scalars['Int'];
+    height: Scalars['Int'];
+    source: Scalars['String'];
+    preview: Scalars['String'];
+    focalPoint?: Maybe<Coordinate>;
+};
+
 export type MimeTypeError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];
@@ -870,8 +886,18 @@ export type MimeTypeError = ErrorResult & {
 
 export type CreateAssetResult = Asset | MimeTypeError;
 
+export type AssetListOptions = {
+    skip?: Maybe<Scalars['Int']>;
+    take?: Maybe<Scalars['Int']>;
+    sort?: Maybe<AssetSortParameter>;
+    filter?: Maybe<AssetFilterParameter>;
+    tags?: Maybe<Array<Scalars['String']>>;
+    tagsOperator?: Maybe<LogicalOperator>;
+};
+
 export type CreateAssetInput = {
     file: Scalars['Upload'];
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type CoordinateInput = {
@@ -883,6 +909,7 @@ export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
     focalPoint?: Maybe<CoordinateInput>;
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
@@ -2168,21 +2195,6 @@ export type Address = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Asset = Node & {
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    name: Scalars['String'];
-    type: AssetType;
-    fileSize: Scalars['Int'];
-    mimeType: Scalars['String'];
-    width: Scalars['Int'];
-    height: Scalars['Int'];
-    source: Scalars['String'];
-    preview: Scalars['String'];
-    focalPoint?: Maybe<Coordinate>;
-};
-
 export type Coordinate = {
     x: Scalars['Float'];
     y: Scalars['Float'];
@@ -3486,6 +3498,7 @@ export type OrderAddress = {
     country?: Maybe<Scalars['String']>;
     countryCode?: Maybe<Scalars['String']>;
     phoneNumber?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -3816,6 +3829,13 @@ export type ShippingMethodList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -3874,13 +3894,6 @@ export type AdministratorListOptions = {
     filter?: Maybe<AdministratorFilterParameter>;
 };
 
-export type AssetListOptions = {
-    skip?: Maybe<Scalars['Int']>;
-    take?: Maybe<Scalars['Int']>;
-    sort?: Maybe<AssetSortParameter>;
-    filter?: Maybe<AssetFilterParameter>;
-};
-
 export type CollectionListOptions = {
     skip?: Maybe<Scalars['Int']>;
     take?: Maybe<Scalars['Int']>;

+ 9 - 0
packages/common/src/generated-shop-types.ts

@@ -1799,6 +1799,7 @@ export type OrderAddress = {
     country?: Maybe<Scalars['String']>;
     countryCode?: Maybe<Scalars['String']>;
     phoneNumber?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -2239,6 +2240,14 @@ export type ShippingMethodList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+    __typename?: 'Tag';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
     __typename?: 'TaxCategory';
     id: Scalars['ID'];

+ 37 - 23
packages/common/src/generated-types.ts

@@ -1000,6 +1000,23 @@ export type AdministratorList = PaginatedList & {
   totalItems: Scalars['Int'];
 };
 
+export type Asset = Node & {
+  __typename?: 'Asset';
+  tags: Array<Tag>;
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  name: Scalars['String'];
+  type: AssetType;
+  fileSize: Scalars['Int'];
+  mimeType: Scalars['String'];
+  width: Scalars['Int'];
+  height: Scalars['Int'];
+  source: Scalars['String'];
+  preview: Scalars['String'];
+  focalPoint?: Maybe<Coordinate>;
+};
+
 export type MimeTypeError = ErrorResult & {
   __typename?: 'MimeTypeError';
   errorCode: ErrorCode;
@@ -1010,8 +1027,18 @@ export type MimeTypeError = ErrorResult & {
 
 export type CreateAssetResult = Asset | MimeTypeError;
 
+export type AssetListOptions = {
+  skip?: Maybe<Scalars['Int']>;
+  take?: Maybe<Scalars['Int']>;
+  sort?: Maybe<AssetSortParameter>;
+  filter?: Maybe<AssetFilterParameter>;
+  tags?: Maybe<Array<Scalars['String']>>;
+  tagsOperator?: Maybe<LogicalOperator>;
+};
+
 export type CreateAssetInput = {
   file: Scalars['Upload'];
+  tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type CoordinateInput = {
@@ -1023,6 +1050,7 @@ export type UpdateAssetInput = {
   id: Scalars['ID'];
   name?: Maybe<Scalars['String']>;
   focalPoint?: Maybe<CoordinateInput>;
+  tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
@@ -2329,22 +2357,6 @@ export type Address = Node & {
   customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Asset = Node & {
-  __typename?: 'Asset';
-  id: Scalars['ID'];
-  createdAt: Scalars['DateTime'];
-  updatedAt: Scalars['DateTime'];
-  name: Scalars['String'];
-  type: AssetType;
-  fileSize: Scalars['Int'];
-  mimeType: Scalars['String'];
-  width: Scalars['Int'];
-  height: Scalars['Int'];
-  source: Scalars['String'];
-  preview: Scalars['String'];
-  focalPoint?: Maybe<Coordinate>;
-};
-
 export type Coordinate = {
   __typename?: 'Coordinate';
   x: Scalars['Float'];
@@ -3690,6 +3702,7 @@ export type OrderAddress = {
   country?: Maybe<Scalars['String']>;
   countryCode?: Maybe<Scalars['String']>;
   phoneNumber?: Maybe<Scalars['String']>;
+  customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -4048,6 +4061,14 @@ export type ShippingMethodList = PaginatedList & {
   totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+  __typename?: 'Tag';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
   __typename?: 'TaxCategory';
   id: Scalars['ID'];
@@ -4112,13 +4133,6 @@ export type AdministratorListOptions = {
   filter?: Maybe<AdministratorFilterParameter>;
 };
 
-export type AssetListOptions = {
-  skip?: Maybe<Scalars['Int']>;
-  take?: Maybe<Scalars['Int']>;
-  sort?: Maybe<AssetSortParameter>;
-  filter?: Maybe<AssetFilterParameter>;
-};
-
 export type CollectionListOptions = {
   skip?: Maybe<Scalars['Int']>;
   take?: Maybe<Scalars['Int']>;

+ 154 - 2
packages/core/e2e/asset.e2e-spec.ts

@@ -1,12 +1,13 @@
 /* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
+import { pick } from '@vendure/common/lib/pick';
 import { mergeConfig } from '@vendure/core';
-import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { ASSET_FRAGMENT } from './graphql/fragments';
 import {
@@ -18,6 +19,7 @@ import {
     GetAssetFragmentFirst,
     GetAssetList,
     GetProductWithVariants,
+    LogicalOperator,
     SortOrder,
     UpdateAsset,
 } from './graphql/generated-e2e-admin-types';
@@ -164,6 +166,7 @@ describe('Asset resolver', () => {
                     name: 'pps1.jpg',
                     preview: 'test-url/test-assets/pps1__preview.jpg',
                     source: 'test-url/test-assets/pps1.jpg',
+                    tags: [],
                     type: 'IMAGE',
                 },
                 {
@@ -173,6 +176,7 @@ describe('Asset resolver', () => {
                     name: 'pps2.jpg',
                     preview: 'test-url/test-assets/pps2__preview.jpg',
                     source: 'test-url/test-assets/pps2.jpg',
+                    tags: [],
                     type: 'IMAGE',
                 },
             ]);
@@ -200,6 +204,7 @@ describe('Asset resolver', () => {
                     name: 'dummy.pdf',
                     preview: 'test-url/test-assets/dummy__preview.pdf.png',
                     source: 'test-url/test-assets/dummy.pdf',
+                    tags: [],
                     type: 'BINARY',
                 },
             ]);
@@ -222,6 +227,118 @@ describe('Asset resolver', () => {
                 fileName: 'dummy.txt',
             });
         });
+
+        it('create with new tags', async () => {
+            const filesToUpload = [path.join(__dirname, 'fixtures/assets/pps1.jpg')];
+            const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                mutation: CREATE_ASSETS,
+                filePaths: filesToUpload,
+                mapVariables: filePaths => ({
+                    input: filePaths.map(p => ({ file: null, tags: ['foo', 'bar'] })),
+                }),
+            });
+            const results = createAssets.filter(isAsset);
+
+            expect(results.map(a => pick(a, ['id', 'name', 'tags']))).toEqual([
+                {
+                    id: 'T_8',
+                    name: 'pps1.jpg',
+                    tags: [
+                        { id: 'T_1', value: 'foo' },
+                        { id: 'T_2', value: 'bar' },
+                    ],
+                },
+            ]);
+        });
+
+        it('create with existing tags', async () => {
+            const filesToUpload = [path.join(__dirname, 'fixtures/assets/pps1.jpg')];
+            const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                mutation: CREATE_ASSETS,
+                filePaths: filesToUpload,
+                mapVariables: filePaths => ({
+                    input: filePaths.map(p => ({ file: null, tags: ['foo', 'bar'] })),
+                }),
+            });
+            const results = createAssets.filter(isAsset);
+
+            expect(results.map(a => pick(a, ['id', 'name', 'tags']))).toEqual([
+                {
+                    id: 'T_9',
+                    name: 'pps1.jpg',
+                    tags: [
+                        { id: 'T_1', value: 'foo' },
+                        { id: 'T_2', value: 'bar' },
+                    ],
+                },
+            ]);
+        });
+
+        it('create with new and existing tags', async () => {
+            const filesToUpload = [path.join(__dirname, 'fixtures/assets/pps1.jpg')];
+            const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                mutation: CREATE_ASSETS,
+                filePaths: filesToUpload,
+                mapVariables: filePaths => ({
+                    input: filePaths.map(p => ({ file: null, tags: ['quux', 'bar'] })),
+                }),
+            });
+            const results = createAssets.filter(isAsset);
+
+            expect(results.map(a => pick(a, ['id', 'name', 'tags']))).toEqual([
+                {
+                    id: 'T_10',
+                    name: 'pps1.jpg',
+                    tags: [
+                        { id: 'T_3', value: 'quux' },
+                        { id: 'T_2', value: 'bar' },
+                    ],
+                },
+            ]);
+        });
+    });
+
+    describe('filter by tags', () => {
+        it('and', async () => {
+            const { assets } = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
+                GET_ASSET_LIST,
+                {
+                    options: {
+                        tags: ['foo', 'bar'],
+                        tagsOperator: LogicalOperator.AND,
+                    },
+                },
+            );
+
+            expect(assets.items.map(i => i.id).sort()).toEqual(['T_8', 'T_9']);
+        });
+
+        it('or', async () => {
+            const { assets } = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
+                GET_ASSET_LIST,
+                {
+                    options: {
+                        tags: ['foo', 'bar'],
+                        tagsOperator: LogicalOperator.OR,
+                    },
+                },
+            );
+
+            expect(assets.items.map(i => i.id).sort()).toEqual(['T_10', 'T_8', 'T_9']);
+        });
+
+        it('empty array', async () => {
+            const { assets } = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
+                GET_ASSET_LIST,
+                {
+                    options: {
+                        tags: [],
+                    },
+                },
+            );
+
+            expect(assets.totalItems).toBe(10);
+        });
     });
 
     describe('updateAsset', () => {
@@ -272,6 +389,37 @@ describe('Asset resolver', () => {
 
             expect(updateAsset.focalPoint).toEqual(null);
         });
+
+        it('update tags', async () => {
+            const { updateAsset } = await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(
+                UPDATE_ASSET,
+                {
+                    input: {
+                        id: firstAssetId,
+                        tags: ['foo', 'quux'],
+                    },
+                },
+            );
+
+            expect(updateAsset.tags).toEqual([
+                { id: 'T_1', value: 'foo' },
+                { id: 'T_3', value: 'quux' },
+            ]);
+        });
+
+        it('remove tags', async () => {
+            const { updateAsset } = await adminClient.query<UpdateAsset.Mutation, UpdateAsset.Variables>(
+                UPDATE_ASSET,
+                {
+                    input: {
+                        id: firstAssetId,
+                        tags: [],
+                    },
+                },
+            );
+
+            expect(updateAsset.tags).toEqual([]);
+        });
     });
 
     describe('deleteAsset', () => {
@@ -390,6 +538,10 @@ export const CREATE_ASSETS = gql`
                     x
                     y
                 }
+                tags {
+                    id
+                    value
+                }
             }
             ... on MimeTypeError {
                 message

+ 61 - 26
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -861,6 +861,22 @@ export type AdministratorList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Asset = Node & {
+    tags: Array<Tag>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    name: Scalars['String'];
+    type: AssetType;
+    fileSize: Scalars['Int'];
+    mimeType: Scalars['String'];
+    width: Scalars['Int'];
+    height: Scalars['Int'];
+    source: Scalars['String'];
+    preview: Scalars['String'];
+    focalPoint?: Maybe<Coordinate>;
+};
+
 export type MimeTypeError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];
@@ -870,8 +886,18 @@ export type MimeTypeError = ErrorResult & {
 
 export type CreateAssetResult = Asset | MimeTypeError;
 
+export type AssetListOptions = {
+    skip?: Maybe<Scalars['Int']>;
+    take?: Maybe<Scalars['Int']>;
+    sort?: Maybe<AssetSortParameter>;
+    filter?: Maybe<AssetFilterParameter>;
+    tags?: Maybe<Array<Scalars['String']>>;
+    tagsOperator?: Maybe<LogicalOperator>;
+};
+
 export type CreateAssetInput = {
     file: Scalars['Upload'];
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type CoordinateInput = {
@@ -883,6 +909,7 @@ export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
     focalPoint?: Maybe<CoordinateInput>;
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
@@ -2168,21 +2195,6 @@ export type Address = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Asset = Node & {
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    name: Scalars['String'];
-    type: AssetType;
-    fileSize: Scalars['Int'];
-    mimeType: Scalars['String'];
-    width: Scalars['Int'];
-    height: Scalars['Int'];
-    source: Scalars['String'];
-    preview: Scalars['String'];
-    focalPoint?: Maybe<Coordinate>;
-};
-
 export type Coordinate = {
     x: Scalars['Float'];
     y: Scalars['Float'];
@@ -3486,6 +3498,7 @@ export type OrderAddress = {
     country?: Maybe<Scalars['String']>;
     countryCode?: Maybe<Scalars['String']>;
     phoneNumber?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -3816,6 +3829,13 @@ export type ShippingMethodList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -3874,13 +3894,6 @@ export type AdministratorListOptions = {
     filter?: Maybe<AdministratorFilterParameter>;
 };
 
-export type AssetListOptions = {
-    skip?: Maybe<Scalars['Int']>;
-    take?: Maybe<Scalars['Int']>;
-    sort?: Maybe<AssetSortParameter>;
-    filter?: Maybe<AssetFilterParameter>;
-};
-
 export type CollectionListOptions = {
     skip?: Maybe<Scalars['Int']>;
     take?: Maybe<Scalars['Int']>;
@@ -4415,7 +4428,10 @@ export type CreateAssetsMutationVariables = Exact<{
 
 export type CreateAssetsMutation = {
     createAssets: Array<
-        | ({ focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>> } & AssetFragment)
+        | ({
+              focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>>;
+              tags: Array<Pick<Tag, 'id' | 'value'>>;
+          } & AssetFragment)
         | Pick<MimeTypeError, 'message' | 'fileName' | 'mimeType'>
     >;
 };
@@ -5348,7 +5364,10 @@ export type UpdateAssetMutationVariables = Exact<{
 }>;
 
 export type UpdateAssetMutation = {
-    updateAsset: { focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>> } & AssetFragment;
+    updateAsset: {
+        tags: Array<Pick<Tag, 'id' | 'value'>>;
+        focalPoint?: Maybe<Pick<Coordinate, 'x' | 'y'>>;
+    } & AssetFragment;
 };
 
 export type DeleteAssetMutationVariables = Exact<{
@@ -6303,6 +6322,14 @@ export namespace CreateAssets {
             { __typename?: 'Asset' }
         >['focalPoint']
     >;
+    export type Tags = NonNullable<
+        NonNullable<
+            DiscriminateUnion<
+                NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
+                { __typename?: 'Asset' }
+            >['tags']
+        >[number]
+    >;
     export type MimeTypeErrorInlineFragment = DiscriminateUnion<
         NonNullable<NonNullable<CreateAssetsMutation['createAssets']>[number]>,
         { __typename?: 'MimeTypeError' }
@@ -7353,12 +7380,20 @@ export namespace UpdateAsset {
     export type UpdateAsset = NonNullable<UpdateAssetMutation['updateAsset']>;
     export type AssetInlineFragment = { __typename: 'Asset' } & Pick<
         NonNullable<UpdateAssetMutation['updateAsset']>,
-        'focalPoint'
+        'tags' | 'focalPoint'
+    >;
+    export type Tags = NonNullable<
+        NonNullable<
+            ({ __typename: 'Asset' } & Pick<
+                NonNullable<UpdateAssetMutation['updateAsset']>,
+                'tags' | 'focalPoint'
+            >)['tags']
+        >[number]
     >;
     export type FocalPoint = NonNullable<
         ({ __typename: 'Asset' } & Pick<
             NonNullable<UpdateAssetMutation['updateAsset']>,
-            'focalPoint'
+            'tags' | 'focalPoint'
         >)['focalPoint']
     >;
 }

+ 8 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1746,6 +1746,7 @@ export type OrderAddress = {
     country?: Maybe<Scalars['String']>;
     countryCode?: Maybe<Scalars['String']>;
     phoneNumber?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -2154,6 +2155,13 @@ export type ShippingMethodList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];

+ 4 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -374,6 +374,10 @@ export const UPDATE_ASSET = gql`
         updateAsset(input: $input) {
             ...Asset
             ... on Asset {
+                tags {
+                    id
+                    value
+                }
                 focalPoint {
                     x
                     y

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -32,6 +32,7 @@ import { TaxCategoryResolver } from './resolvers/admin/tax-category.resolver';
 import { TaxRateResolver } from './resolvers/admin/tax-rate.resolver';
 import { ZoneResolver } from './resolvers/admin/zone.resolver';
 import { AdministratorEntityResolver } from './resolvers/entity/administrator-entity.resolver';
+import { AssetEntityResolver } from './resolvers/entity/asset-entity.resolver';
 import { CollectionEntityResolver } from './resolvers/entity/collection-entity.resolver';
 import {
     CustomerAdminEntityResolver,
@@ -124,6 +125,7 @@ export const entityResolvers = [
 
 export const adminEntityResolvers = [
     AdministratorEntityResolver,
+    AssetEntityResolver,
     CustomerAdminEntityResolver,
     OrderAdminEntityResolver,
     PaymentMethodEntityResolver,

+ 20 - 0
packages/core/src/api/resolvers/entity/asset-entity.resolver.ts

@@ -0,0 +1,20 @@
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
+
+import { Asset } from '../../../entity/asset/asset.entity';
+import { Tag } from '../../../entity/tag/tag.entity';
+import { TagService } from '../../../service/services/tag.service';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Asset')
+export class AssetEntityResolver {
+    constructor(private tagService: TagService) {}
+
+    @ResolveField()
+    async tags(@Ctx() ctx: RequestContext, @Parent() asset: Asset): Promise<Tag[]> {
+        if (asset.tags) {
+            return asset.tags;
+        }
+        return this.tagService.getTagsForEntity(ctx, Asset, asset.id);
+    }
+}

+ 3 - 0
packages/core/src/api/schema/admin-api/asset-admin.type.graphql

@@ -0,0 +1,3 @@
+type Asset {
+    tags: [Tag!]!
+}

+ 6 - 1
packages/core/src/api/schema/admin-api/asset.api.graphql

@@ -26,10 +26,14 @@ type MimeTypeError implements ErrorResult {
 union CreateAssetResult = Asset | MimeTypeError
 
 # generated by generateListOptions function
-input AssetListOptions
+input AssetListOptions {
+    tags: [String!]
+    tagsOperator: LogicalOperator
+}
 
 input CreateAssetInput {
     file: Upload!
+    tags: [String!]
 }
 
 input CoordinateInput {
@@ -41,4 +45,5 @@ input UpdateAssetInput {
     id: ID!
     name: String
     focalPoint: CoordinateInput
+    tags: [String!]
 }

+ 6 - 0
packages/core/src/api/schema/common/tag.type.graphql

@@ -0,0 +1,6 @@
+type Tag implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    value: String!
+}

+ 8 - 0
packages/core/src/common/types/common-types.ts

@@ -1,5 +1,6 @@
 import { VendureEntity } from '../../entity/base/base.entity';
 import { Channel } from '../../entity/channel/channel.entity';
+import { Tag } from '../../entity/tag/tag.entity';
 
 import { LocaleString } from './locale-types';
 
@@ -24,6 +25,13 @@ export interface Orderable {
     position: number;
 }
 
+/**
+ * Entities which can have Tags applied to them.
+ */
+export interface Taggable {
+    tags: Tag[];
+}
+
 /**
  * Creates a type based on T, but with all properties non-optional
  * and readonly.

+ 8 - 2
packages/core/src/entity/asset/asset.entity.ts

@@ -1,10 +1,12 @@
 import { AssetType } from '@vendure/common/lib/generated-types';
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm';
+import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne } from 'typeorm';
 
+import { Taggable } from '../../common/types/common-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
 import { CustomCustomerFields } from '../custom-entity-fields';
+import { Tag } from '../tag/tag.entity';
 import { User } from '../user/user.entity';
 
 /**
@@ -15,7 +17,7 @@ import { User } from '../user/user.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Asset extends VendureEntity {
+export class Asset extends VendureEntity implements Taggable {
     constructor(input?: DeepPartial<Asset>) {
         super(input);
     }
@@ -38,4 +40,8 @@ export class Asset extends VendureEntity {
 
     @Column('simple-json', { nullable: true })
     focalPoint?: { x: number; y: number };
+
+    @ManyToMany(type => Tag)
+    @JoinTable()
+    tags: Tag[];
 }

+ 2 - 0
packages/core/src/entity/entities.ts

@@ -54,6 +54,7 @@ import { Sale } from './stock-movement/sale.entity';
 import { StockAdjustment } from './stock-movement/stock-adjustment.entity';
 import { StockMovement } from './stock-movement/stock-movement.entity';
 import { Surcharge } from './surcharge/surcharge.entity';
+import { Tag } from './tag/tag.entity';
 import { TaxCategory } from './tax-category/tax-category.entity';
 import { TaxRate } from './tax-rate/tax-rate.entity';
 import { User } from './user/user.entity';
@@ -119,6 +120,7 @@ export const coreEntitiesMap = {
     StockAdjustment,
     StockMovement,
     Surcharge,
+    Tag,
     TaxCategory,
     TaxRate,
     User,

+ 1 - 0
packages/core/src/entity/index.ts

@@ -42,6 +42,7 @@ export * from './session/anonymous-session.entity';
 export * from './session/authenticated-session.entity';
 export * from './surcharge/surcharge.entity';
 export * from './shipping-method/shipping-method.entity';
+export * from './tag/tag.entity';
 export * from './tax-category/tax-category.entity';
 export * from './tax-rate/tax-rate.entity';
 export * from './user/user.entity';

+ 21 - 0
packages/core/src/entity/tag/tag.entity.ts

@@ -0,0 +1,21 @@
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Column, Entity } from 'typeorm';
+
+import { VendureEntity } from '../base/base.entity';
+
+/**
+ * @description
+ * A tag is an arbitrary label which can be applied to certain entities.
+ * It is used to help organize and filter those entities.
+ *
+ * @docsCategory entities
+ */
+@Entity()
+export class Tag extends VendureEntity {
+    constructor(input?: DeepPartial<Tag>) {
+        super(input);
+    }
+
+    @Column()
+    value: string;
+}

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -53,6 +53,7 @@ import { SearchService } from './services/search.service';
 import { SessionService } from './services/session.service';
 import { ShippingMethodService } from './services/shipping-method.service';
 import { StockMovementService } from './services/stock-movement.service';
+import { TagService } from './services/tag.service';
 import { TaxCategoryService } from './services/tax-category.service';
 import { TaxRateService } from './services/tax-rate.service';
 import { UserService } from './services/user.service';
@@ -86,6 +87,7 @@ const services = [
     SessionService,
     ShippingMethodService,
     StockMovementService,
+    TagService,
     TaxCategoryService,
     TaxRateService,
     UserService,

+ 44 - 9
packages/core/src/service/services/asset.service.ts

@@ -1,18 +1,22 @@
 import { Injectable } from '@nestjs/common';
 import {
+    AssetListOptions,
     AssetType,
     CreateAssetInput,
     CreateAssetResult,
     DeletionResponse,
     DeletionResult,
+    LogicalOperator,
     UpdateAssetInput,
 } from '@vendure/common/lib/generated-types';
+import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList, Type } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { ReadStream } from 'fs-extra';
 import mime from 'mime-types';
 import path from 'path';
 import { Stream } from 'stream';
+import { Brackets } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
 import { isGraphQlErrorResult } from '../../common/error/error-result';
@@ -33,6 +37,8 @@ import { AssetEvent } from '../../event-bus/events/asset-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { patchEntity } from '../helpers/utils/patch-entity';
 import { TransactionalConnection } from '../transaction/transactional-connection';
+
+import { TagService } from './tag.service';
 // tslint:disable-next-line:no-var-requires
 const sizeOf = require('image-size');
 
@@ -55,6 +61,7 @@ export class AssetService {
         private configService: ConfigService,
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
+        private tagService: TagService,
     ) {
         this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
             .map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
@@ -69,14 +76,34 @@ export class AssetService {
         return this.connection.getRepository(ctx, Asset).findOne(id);
     }
 
-    findAll(ctx: RequestContext, options?: ListQueryOptions<Asset>): Promise<PaginatedList<Asset>> {
-        return this.listQueryBuilder
-            .build(Asset, options, { ctx })
-            .getManyAndCount()
-            .then(([items, totalItems]) => ({
-                items,
-                totalItems,
-            }));
+    findAll(ctx: RequestContext, options?: AssetListOptions): Promise<PaginatedList<Asset>> {
+        const qb = this.listQueryBuilder.build(Asset, options, {
+            ctx,
+            relations: options?.tags ? ['tags'] : [],
+        });
+        const tags = options?.tags;
+        if (tags && tags.length) {
+            const operator = options?.tagsOperator ?? LogicalOperator.AND;
+            const subquery = qb.connection
+                .createQueryBuilder()
+                .select('asset.id')
+                .from(Asset, 'asset')
+                .leftJoin('asset.tags', 'tags')
+                .where(`tags.value IN (:...tags)`);
+
+            if (operator === LogicalOperator.AND) {
+                subquery.groupBy('asset.id').having('COUNT(asset.id) = :tagCount');
+            }
+
+            qb.andWhere(`asset.id IN (${subquery.getQuery()})`).setParameters({
+                tags,
+                tagCount: tags.length,
+            });
+        }
+        return qb.getManyAndCount().then(([items, totalItems]) => ({
+            items,
+            totalItems,
+        }));
     }
 
     async getFeaturedAsset<T extends Omit<EntityWithAssets, 'assets'>>(
@@ -162,6 +189,11 @@ export class AssetService {
         if (isGraphQlErrorResult(result)) {
             return result;
         }
+        if (input.tags) {
+            const tags = await this.tagService.valuesToTags(ctx, input.tags);
+            result.tags = tags;
+            await this.connection.getRepository(ctx, Asset).save(result);
+        }
         this.eventBus.publish(new AssetEvent(ctx, result, 'created'));
         return result;
     }
@@ -173,7 +205,10 @@ export class AssetService {
             input.focalPoint.x = to3dp(input.focalPoint.x);
             input.focalPoint.y = to3dp(input.focalPoint.y);
         }
-        patchEntity(asset, input);
+        patchEntity(asset, omit(input, ['tags']));
+        if (input.tags) {
+            asset.tags = await this.tagService.valuesToTags(ctx, input.tags);
+        }
         const updatedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
         this.eventBus.publish(new AssetEvent(ctx, updatedAsset, 'updated'));
         return updatedAsset;

+ 39 - 0
packages/core/src/service/services/tag.service.ts

@@ -0,0 +1,39 @@
+import { Injectable } from '@nestjs/common';
+import { ID, Type } from '@vendure/common/lib/shared-types';
+import { unique } from '@vendure/common/lib/unique';
+
+import { RequestContext } from '../../api/common/request-context';
+import { Taggable } from '../../common/types/common-types';
+import { VendureEntity } from '../../entity/base/base.entity';
+import { Tag } from '../../entity/tag/tag.entity';
+import { TransactionalConnection } from '../transaction/transactional-connection';
+
+@Injectable()
+export class TagService {
+    constructor(private connection: TransactionalConnection) {}
+
+    async valuesToTags(ctx: RequestContext, values: string[]): Promise<Tag[]> {
+        const tags: Tag[] = [];
+        for (const value of unique(values)) {
+            tags.push(await this.tagValueToTag(ctx, value));
+        }
+        return tags;
+    }
+
+    getTagsForEntity(ctx: RequestContext, entity: Type<VendureEntity & Taggable>, id: ID): Promise<Tag[]> {
+        return this.connection
+            .getRepository(ctx, entity)
+            .createQueryBuilder()
+            .relation(entity, 'tags')
+            .of(id)
+            .loadMany();
+    }
+
+    private async tagValueToTag(ctx: RequestContext, value: string): Promise<Tag> {
+        const existing = await this.connection.getRepository(ctx, Tag).findOne({ where: { value } });
+        if (existing) {
+            return existing;
+        }
+        return await this.connection.getRepository(ctx, Tag).save(new Tag({ value }));
+    }
+}

+ 35 - 22
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -861,6 +861,22 @@ export type AdministratorList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Asset = Node & {
+    tags: Array<Tag>;
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    name: Scalars['String'];
+    type: AssetType;
+    fileSize: Scalars['Int'];
+    mimeType: Scalars['String'];
+    width: Scalars['Int'];
+    height: Scalars['Int'];
+    source: Scalars['String'];
+    preview: Scalars['String'];
+    focalPoint?: Maybe<Coordinate>;
+};
+
 export type MimeTypeError = ErrorResult & {
     errorCode: ErrorCode;
     message: Scalars['String'];
@@ -870,8 +886,18 @@ export type MimeTypeError = ErrorResult & {
 
 export type CreateAssetResult = Asset | MimeTypeError;
 
+export type AssetListOptions = {
+    skip?: Maybe<Scalars['Int']>;
+    take?: Maybe<Scalars['Int']>;
+    sort?: Maybe<AssetSortParameter>;
+    filter?: Maybe<AssetFilterParameter>;
+    tags?: Maybe<Array<Scalars['String']>>;
+    tagsOperator?: Maybe<LogicalOperator>;
+};
+
 export type CreateAssetInput = {
     file: Scalars['Upload'];
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type CoordinateInput = {
@@ -883,6 +909,7 @@ export type UpdateAssetInput = {
     id: Scalars['ID'];
     name?: Maybe<Scalars['String']>;
     focalPoint?: Maybe<CoordinateInput>;
+    tags?: Maybe<Array<Scalars['String']>>;
 };
 
 export type NativeAuthenticationResult = CurrentUser | InvalidCredentialsError | NativeAuthStrategyError;
@@ -2168,21 +2195,6 @@ export type Address = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
-export type Asset = Node & {
-    id: Scalars['ID'];
-    createdAt: Scalars['DateTime'];
-    updatedAt: Scalars['DateTime'];
-    name: Scalars['String'];
-    type: AssetType;
-    fileSize: Scalars['Int'];
-    mimeType: Scalars['String'];
-    width: Scalars['Int'];
-    height: Scalars['Int'];
-    source: Scalars['String'];
-    preview: Scalars['String'];
-    focalPoint?: Maybe<Coordinate>;
-};
-
 export type Coordinate = {
     x: Scalars['Float'];
     y: Scalars['Float'];
@@ -3486,6 +3498,7 @@ export type OrderAddress = {
     country?: Maybe<Scalars['String']>;
     countryCode?: Maybe<Scalars['String']>;
     phoneNumber?: Maybe<Scalars['String']>;
+    customFields?: Maybe<Scalars['JSON']>;
 };
 
 export type OrderList = PaginatedList & {
@@ -3816,6 +3829,13 @@ export type ShippingMethodList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
+export type Tag = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    value: Scalars['String'];
+};
+
 export type TaxCategory = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
@@ -3874,13 +3894,6 @@ export type AdministratorListOptions = {
     filter?: Maybe<AdministratorFilterParameter>;
 };
 
-export type AssetListOptions = {
-    skip?: Maybe<Scalars['Int']>;
-    take?: Maybe<Scalars['Int']>;
-    sort?: Maybe<AssetSortParameter>;
-    filter?: Maybe<AssetFilterParameter>;
-};
-
 export type CollectionListOptions = {
     skip?: Maybe<Scalars['Int']>;
     take?: Maybe<Scalars['Int']>;

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


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


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