@haus-tech/product-import-export-plugin 3.6.2 → 3.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +17 -34
  2. package/README.md +52 -2
  3. package/package.json +1 -3
  4. package/src/api/plugin.controller.d.ts +32 -2
  5. package/src/api/plugin.controller.js +30 -4
  6. package/src/api/plugin.controller.js.map +1 -1
  7. package/src/api/product-export.controller.d.ts +1 -0
  8. package/src/api/product-export.controller.js +17 -0
  9. package/src/api/product-export.controller.js.map +1 -1
  10. package/src/api/product-import.controller.js +1 -0
  11. package/src/api/product-import.controller.js.map +1 -1
  12. package/src/dashboard/export-configuration-panel.tsx +47 -3
  13. package/src/dashboard/import.tsx +2 -2
  14. package/src/dashboard/utils.ts +20 -3
  15. package/src/index.d.ts +1 -1
  16. package/src/product-import-export.plugin.js +24 -0
  17. package/src/product-import-export.plugin.js.map +1 -1
  18. package/src/providers/import-providers/product-importer.js +14 -4
  19. package/src/providers/import-providers/product-importer.js.map +1 -1
  20. package/src/services/product-export-queue.service.js +1 -1
  21. package/src/services/product-export-queue.service.js.map +1 -1
  22. package/src/services/product-export.service.d.ts +27 -7
  23. package/src/services/product-export.service.js +226 -48
  24. package/src/services/product-export.service.js.map +1 -1
  25. package/src/services/self-relation-custom-fields.d.ts +26 -0
  26. package/src/services/self-relation-custom-fields.js +67 -0
  27. package/src/services/self-relation-custom-fields.js.map +1 -0
  28. package/src/types.d.ts +29 -2
  29. package/src/ui/components/product-import.component.ts +1 -0
  30. package/README.mdx +0 -244
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.splitSelfRelationCustomFields = splitSelfRelationCustomFields;
4
+ exports.loadSelfRelationCustomFieldIds = loadSelfRelationCustomFieldIds;
5
+ const lodash_1 = require("lodash");
6
+ /** Owner ids per query, far below Postgres' 65 535 bind parameter limit. */
7
+ const ID_CHUNK_SIZE = 1000;
8
+ /**
9
+ * Splits an entity's relation custom fields into paths that can be loaded through
10
+ * `ProductService.findAll` / repository `relations`, and self-referencing fields (a Product field
11
+ * relating to Product, a ProductVariant field relating to ProductVariant). TypeORM joins a
12
+ * self-referencing relation under the owner table's own alias a second time, which Postgres rejects
13
+ * ("table name specified more than once"), so those must be loaded with
14
+ * {@link loadSelfRelationCustomFieldIds} instead.
15
+ */
16
+ function splitSelfRelationCustomFields(fields, owner, prefix) {
17
+ const relationFields = fields.filter((field) => field.type === 'relation');
18
+ return {
19
+ relations: relationFields
20
+ .filter((field) => field.entity !== owner)
21
+ .map((field) => `${prefix}${field.name}`),
22
+ selfRelationNames: relationFields
23
+ .filter((field) => field.entity === owner)
24
+ .map((field) => field.name),
25
+ };
26
+ }
27
+ /**
28
+ * Loads the related ids of self-referencing relation custom fields with an explicit join alias,
29
+ * one query per field. Returns a map keyed by the stringified owner id; each entry holds every
30
+ * requested field: `ID | null` for a single relation, `ID[]` (possibly empty) for a list.
31
+ */
32
+ async function loadSelfRelationCustomFieldIds(repository, alias, ids, fields) {
33
+ const byOwnerId = new Map();
34
+ if (ids.length === 0 || fields.length === 0) {
35
+ return byOwnerId;
36
+ }
37
+ for (const id of ids) {
38
+ byOwnerId.set(String(id), Object.fromEntries(fields.map((field) => [field.name, field.list ? [] : null])));
39
+ }
40
+ for (const [field, idChunk] of fields.flatMap((field) => (0, lodash_1.chunk)(ids, ID_CHUNK_SIZE).map((idChunk) => [field, idChunk]))) {
41
+ const rows = await repository
42
+ .createQueryBuilder(alias)
43
+ .select(`${alias}.id`, 'ownerId')
44
+ .addSelect('related.id', 'relatedId')
45
+ .innerJoin(`${alias}.customFields.${field.name}`, 'related')
46
+ .where(`${alias}.id IN (:...ids)`, { ids: idChunk })
47
+ // A stable order for list fields; the join table stores no position.
48
+ .orderBy('related.id', 'ASC')
49
+ .getRawMany();
50
+ for (const row of rows) {
51
+ const entry = byOwnerId.get(String(row.ownerId));
52
+ if (!entry) {
53
+ continue;
54
+ }
55
+ const current = entry[field.name];
56
+ if (Array.isArray(current)) {
57
+ // The array was created above for this call only, so appending in place is safe.
58
+ current.push(row.relatedId);
59
+ }
60
+ else {
61
+ entry[field.name] = row.relatedId;
62
+ }
63
+ }
64
+ }
65
+ return byOwnerId;
66
+ }
67
+ //# sourceMappingURL=self-relation-custom-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"self-relation-custom-fields.js","sourceRoot":"","sources":["../../../../../packages/product-import-export-plugin/src/services/self-relation-custom-fields.ts"],"names":[],"mappings":"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"}
package/src/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ID } from '@vendure/core';
2
2
  import { ParsedProduct, ParsedProductWithVariants } from './providers/import-providers/import-parser';
3
- import type { Injector } from '@vendure/core';
3
+ import type { Injector, Product, ProductVariant, RequestContext } from '@vendure/core';
4
4
  import { ExportStorageStrategy } from './services/export-storage/export-storage-strategy';
5
5
  import { ImportJobStorageStrategy } from './services/import-storage/import-job-storage-strategy';
6
6
  /**
@@ -68,8 +68,35 @@ export interface PluginInitOptions {
68
68
  * Preferred configuration: provide a factory which can use Vendure's DI via the Injector.
69
69
  */
70
70
  storageStrategyFactory?: (injector: Injector) => ExportStorageStrategy | Promise<ExportStorageStrategy>;
71
+ /**
72
+ * @description
73
+ * Optional. If omitted, the default local-disk strategy is used.
74
+ */
75
+ /**
76
+ * @description
77
+ * Optional. Additional CSV columns appended after the built-in export fields.
78
+ * Each column's `name` must not contain `:` and must not collide with a
79
+ * built-in export field name; names must also be unique. Validated at
80
+ * plugin init time.
81
+ */
82
+ customExportColumns?: CustomExportColumn[];
71
83
  };
72
84
  }
85
+ /**
86
+ * @description
87
+ * A custom column added to the product export CSV. Only included in the output
88
+ * when its `name` is present in the caller-selected export fields.
89
+ *
90
+ * @category Options
91
+ */
92
+ export interface CustomExportColumn {
93
+ /** CSV column name. No ':' and no collision with built-in export fields. */
94
+ name: string;
95
+ /** Awaited once per export job before the first resolve. Use to reset caches. */
96
+ onExportStart?: () => void | Promise<void>;
97
+ /** Called per variant row; return value is written to the cell ('' for null/undefined). */
98
+ resolve: (ctx: RequestContext, injector: Injector, product: Product, variant: ProductVariant) => Promise<unknown> | unknown;
99
+ }
73
100
  export type ParsedProductWithId = ParsedProductWithVariants & {
74
101
  product: ParsedProduct & {
75
102
  productId: number | null;
@@ -81,7 +108,7 @@ export type JsonAsset = {
81
108
  name?: string;
82
109
  };
83
110
  type ExportFields = Array<ProductFields | VariantFields>;
84
- type ProductFields = 'name' | 'slug' | 'description' | 'assets' | 'facets' | 'optionGroups';
111
+ type ProductFields = 'id' | 'name' | 'slug' | 'description' | 'assets' | 'facets' | 'optionGroups';
85
112
  type VariantFields = 'sku' | 'optionValues' | 'price' | 'taxCategory' | 'stockOnHand' | 'trackInventory' | 'variantAssets' | 'variantFacets' | 'enabled';
86
113
  export type UpdatingStrategy = 'replace' | 'merge';
87
114
  export {};
@@ -60,6 +60,7 @@ export class ProductImportComponent implements OnInit {
60
60
  method: 'GET',
61
61
  headers: {
62
62
  'Content-Type': 'application/json',
63
+ ...this.getRequestHeaders(),
63
64
  },
64
65
  })
65
66
  .then((res) => res.json())
package/README.mdx DELETED
@@ -1,244 +0,0 @@
1
- ---
2
- name: product-import-export-plugin
3
- title: Product Import & Export
4
- description: Vendure plugin for importing and exporting product data via CSV.
5
- version: 3.6.0-next.2
6
- tags:
7
- - vendure
8
- - plugin
9
- - import
10
- - export
11
- - products
12
- - csv
13
- - bulk
14
- ---
15
-
16
- # Product Import Export Plugin
17
-
18
- A Vendure plugin for importing and exporting product data via CSV. Supports bulk product management and data migration between environments.
19
-
20
- ## Features
21
-
22
- - **Import** – Bulk upload products from CSV with validation, custom fields, assets, facets, and variants
23
- - **Export** – Export products to CSV with selected fields; export all products or a selection
24
- - **Storage strategies** – Store exported files in S3 or locally on disk (with automatic fallback)
25
- - **UI** – Admin UI and Dashboard integration for import and export
26
-
27
- ![Dashboard UI](assets/Screenshot-dashboard-ui.png)
28
-
29
- ## Compatibility
30
-
31
- Vendure **^3.6.0**
32
-
33
- ## Getting started
34
-
35
- ```bash npm2yarn
36
- npm install @haus-tech/product-import-export-plugin
37
- ```
38
-
39
- ## Configuration
40
-
41
- Add the plugin to your Vendure configuration.
42
-
43
- ```typescript
44
- export const config = {
45
- plugins: [
46
- ProductImportExportPlugin.init({
47
- importOptions: {}, // Configure import options
48
- exportOptions: {
49
- storageStrategy: 's3', // 's3' | 'disk'
50
- s3Options: {
51
- bucket: 'your-bucket',
52
- region: 'your-region',
53
- credentials: {
54
- accessKeyId: '...',
55
- secretAccessKey: '...',
56
- },
57
- },
58
- },
59
- }),
60
- ],
61
- }
62
- ```
63
-
64
- ## Configuration Options
65
-
66
- ### Import options
67
-
68
- | Option | Type | Default | Description |
69
- | ------------------------ | -------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
70
- | `updateProductSlug` | `boolean` | `true` | Update product slug from the `name` field in the CSV |
71
- | `restoreSoftDeleted` | `boolean` | `true` | Restore soft-deleted products/variants found by id or SKU during import. If `false`, new products will be created instead. |
72
- | `storageStrategy` | `ImportJobStorageStrategy` | local disk | Strategy instance for queued import file storage and retrieval. |
73
- | `storageStrategyFactory` | `(injector: Injector) => ImportJobStorageStrategy` | `undefined` | Factory for DI-aware strategy creation (recommended for S3/remote storage). |
74
- | `importJobStorage` | `'local' \| 's3'` | `'local'` | Deprecated compatibility flag. Prefer `storageStrategy` / `storageStrategyFactory`. |
75
-
76
- Available import storage strategies:
77
-
78
- - `LocalImportJobStorageStrategy` (default)
79
- - `S3ImportJobStorageStrategy`
80
-
81
- ### Export options
82
-
83
- | Option | Type | Default | Description |
84
- | ----------------------- | --------------------- | ----------------------- | ------------------------------------------------------------ |
85
- | `defaultFileName` | `string` | `'products_export.csv'` | Default filename for exports |
86
- | `exportAssetsAsOptions` | `('url' \| 'json')[]` | `['url', 'json']` | Available formats for asset export |
87
- | `defaultExportAssetsAs` | `'url' \| 'json'` | `'url'` | Default asset export format |
88
- | `defaultExportFields` | `string[]` | See above | Fields that will be pre-selected in the export UI |
89
- | `requiredExportFields` | `string[]` | `['name', 'sku']` | Fields that must always be included |
90
- | `storageStrategy` | `'s3' \| 'disk'` | `'disk'` | Where exported files are stored |
91
- | `s3Options` | `object` | `undefined` | S3 configuration (required when `storageStrategy` is `'s3'`) |
92
-
93
- ### Storage strategy
94
-
95
- The plugin supports configurable storage for exported files:
96
-
97
- - **S3** – Stores export files in an S3-compatible bucket
98
- - **Disk (default)** – Saves files locally on the server
99
-
100
- ## CSV Format for Import
101
-
102
- ### Required columns
103
-
104
- - `name` – Product name (use `name:en`, `name:sv` etc. for multiple languages)
105
- - `sku` – Variant SKU (unique per variant)
106
-
107
- ### Optional columns
108
-
109
- | Column | Format | Example |
110
- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
111
- | `name`, `slug`, `description` | Text, or `name:en`, `name:sv` for translations | `My Product` |
112
- | `assets` | Pipe-separated URLs, or JSON `[{"url":"...","name":"..."}]` | `https://example.com/1.jpg\|https://example.com/2.jpg` |
113
- | `facets` | `facet:value` pairs, pipe-separated | `Brand:Acme\|Category:Electronics` |
114
- | `optionGroups` | Pipe-separated group names. Use `name:code` to share a group across products (see [Sharing option groups](#sharing-option-groups)) | `Size\|Color` or `size:shoe-size` |
115
- | `optionValues` | Pipe-separated values (order matches option groups) | `Large\|Blue` |
116
- | `price` | Number (in smallest currency unit) | `9999` |
117
- | `taxCategory` | Tax category name | `standard` |
118
- | `stockOnHand` | Number | `100` |
119
- | `trackInventory` | `true` or `false` | `true` |
120
- | `variantAssets` | Same format as `assets` | `https://example.com/variant.jpg` |
121
- | `variantFacets` | Same format as `facets` | `Material:Cotton` |
122
- | `enabled` | `true` or `false` | `true` |
123
- | Custom fields | Column name = custom field name; value depends on field type | Add columns for any Product or ProductVariant custom fields defined in your Vendure config |
124
-
125
- #### Import behavior rules
126
-
127
- - If a column is omitted from the CSV header, that field is left unchanged on existing products/variants.
128
- - If a column exists in the header but the cell is empty, that field is explicitly cleared.
129
- - For products with more than one variant, `optionGroups` and `optionValues` are required.
130
-
131
- #### Sharing option groups
132
-
133
- By default, each product gets its own option groups. To **share an option group** across
134
- multiple products (e.g. a common "Size" group for all shoes), use the `name:code` syntax in the
135
- `optionGroups` column: `size:shoe-size`. All products that reference the same code will share
136
- a single option group. Products using a plain name (e.g. just `size`) will each get their own
137
- independent option group.
138
-
139
- ```csv
140
- ...optionGroups , optionValues , ...
141
- # Each shoe product shares a single "shoe-size" option group:
142
- "size:shoe-size" , Small , ...
143
- "size:shoe-size" , Medium , ...
144
- # This product gets its own independent "size" group:
145
- "size" , 4GB , ...
146
- ```
147
-
148
- The first product to reference a given code defines the full set of option values for that
149
- shared group; subsequent products may use any subset of those values. Codes are language-
150
- agnostic — only the `name` portion before the colon is translated per language column
151
- (`optionGroups:en`, `optionGroups:sv`, etc.).
152
-
153
- #### Multi-language support
154
-
155
- Use language-specific columns by suffixing the field name with the language code. The language codes must match those configured in your Vendure instance (e.g. `en`, `sv`).
156
-
157
- | Column pattern | Example columns | Use case |
158
- | ------------------------ | ---------------------- | ------------------------------------------------ |
159
- | `name:en`, `name:sv` | Product name per lang | Translated product names |
160
- | `slug:en`, `slug:sv` | Slug per lang | Translated URLs |
161
- | `description:en` | Description per lang | Translated descriptions |
162
- | `facets:en`, `facets:sv` | Facets per lang | Translated facet names and values |
163
- | `optionGroups:en` | Option groups per lang | Translated option group names (e.g. Size, Color) |
164
- | `optionValues:en` | Option values per lang | Translated option values (e.g. Large, Blue) |
165
-
166
- Example CSV with English and Swedish:
167
-
168
- ```csv
169
- name:en,name:sv,sku,facets:en,facets:sv
170
- "Red Shirt","Röd tröja",SHIRT-001,"Color:Red|Size:Large","Färg:Röd|Storlek:Stor"
171
- ```
172
-
173
- #### Multiple values (pipe separator)
174
-
175
- Fields that can have multiple values use the pipe character `|` as a separator:
176
-
177
- - **Facets**: `Brand:Acme|Category:Electronics`
178
- - **Option groups**: `Size|Color`
179
- - **Option values**: `Large|Blue` (order matches option groups)
180
- - **Assets**: `https://example.com/1.jpg|https://example.com/2.jpg`
181
-
182
- ## Usage
183
-
184
- :::note
185
- The Admin UI offers basic import and export. Some features such as the export-all option and the list of exported files are only available in the Dashboard. New features will be added to the Dashboard only.
186
- :::
187
-
188
- ### Importing products
189
-
190
- 1. Create a CSV with the columns above.
191
- 2. Go to the Admin UI or Dashboard and open the Product Import section.
192
- 3. Upload the CSV and choose options (update slugs, restore soft-deleted, main language).
193
- 4. Select the update strategy: **merge** (keep existing facets/assets) or **replace** (overwrite).
194
- 5. Start the import.
195
-
196
- ### Exporting products
197
-
198
- 1. In the Admin UI or Dashboard, select products to export (or use bulk export).
199
- 2. Configure export fields (assets, facets, custom fields, etc.).
200
- - For products with more than one variant, `optionGroups` and `optionValues` must be included.
201
- - `productId` is not available as a selectable export field.
202
- - If `customFields` are not selected, no custom-field columns are written to the CSV.
203
- 3. Choose asset format: URL or JSON.
204
- 4. Start the export (it is added to a job queue and runs in the background).
205
- 5. Download the CSV when the export is complete. Your downloaded files will be listed in the export view.
206
-
207
- ![Export settings](assets/Screenshot-export-settings.png)
208
-
209
- ## Optional: Email notification on export complete
210
-
211
- If you use `@vendure/email-plugin`, you can add an email notification when an export finishes from the `/email` subpath.
212
-
213
- The plugin now provides a default fallback template for `product-export-complete/body.hbs`.
214
- If you define your own template in your app's template directory, your template takes precedence.
215
- Note: templates for `@vendure/email-plugin` should be valid MJML.
216
-
217
- ```typescript
218
- import path from 'path'
219
- import {
220
- DefaultEmailPlugin,
221
- FileBasedTemplateLoader,
222
- defaultEmailHandlers,
223
- } from '@vendure/email-plugin'
224
- import {
225
- withProductExportedHandler,
226
- withProductExportedTemplateFallback,
227
- } from '@haus-tech/product-import-export-plugin/email'
228
-
229
- export const config = {
230
- plugins: [
231
- DefaultEmailPlugin.init({
232
- handlers: withProductExportedHandler(defaultEmailHandlers),
233
- templateLoader: withProductExportedTemplateFallback(
234
- new FileBasedTemplateLoader(path.join(__dirname, '../static/email/templates')),
235
- ),
236
- }),
237
- ],
238
- }
239
- ```
240
-
241
- ## Resources
242
-
243
- - [Vendure Plugin Documentation](https://docs.vendure.io/guides/developer-guide/plugins/)
244
- - [Vendure Import/Export Guide](https://docs.vendure.io/guides/developer-guide/importing-data/)