diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3acfcbf..f0aaf30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
# Release Notes for Variant Manager
+## 3.0.0 - 2026-09-11
+
+> {tip} Run `./craft variant-manager/attributes/backfill` to take advantage of new features.
+
+### Added
+
+- Added Variant Attribute and Attribute Option elements for the names and values your variants store, listed under “Variant Attributes” and “Attribute Options”.
+- Added a display type per attribute, set on the attribute itself, and a field layout for the attribute and one for its options, set at “Settings” -> “Plugins” -> “Variant Manager” and stored in project config.
+- Added element chips to the Variant Attributes field, which open an attribute or option in a slideout.
+- Added a “Variant Attribute: {name}” filter for each attribute, on variant and product listings.
+- Added a “Variant Attributes” utility, plus `variant-manager/attributes/backfill` and `variant-manager/attributes/orphans` commands, for populating and pruning the registry.
+- Added the `variant-manager:manage-attributes` permission.
+- Added `craft.variantManager.getAttributeRegistry()`, which pairs a product's attribute names and values with their elements.
+- Added support for showing a variant's attribute values in its card and as an element index column.
+- Added a “Bulk edit field” action to the Variants index, configured with `bulkEditableVariantFields`.
+- Added `defaultVariantTableAttributes`, for extra default columns on the Variants index.
+- Added an “Available Display Types” setting, at “Settings” -> “Plugins” -> “Variant Manager”, which narrows the display types an attribute can be set to. It can also be set with `availableDisplayTypes` in `config/variant-manager.php`.
+- Added a “Default Display Type” setting, on the same screen, which is the display type a newly registered attribute is given. It can also be set with `defaultDisplayType`.
+
+### Changed
+
+- Attributes and options can no longer be deleted from the control panel. Use “Prune orphans”, which removes only the rows no variant uses.
+- The `variant-manager:manage` permission now also gates bulk editing variants.
+- A Money column written with thousands separators, such as `1,234.56`, now fails the import instead of being read as a smaller amount.
+- `productFieldMap` and `variantFieldMap` now default to the `title`, `slug` and `status` product columns and the standard variant columns, so `config/variant-manager.php` is only needed to change them.
+
+### Fixed
+
+- Fixed a bug where import and export produced no variant columns without a `config/variant-manager.php` file.
+- Fixed a bug where an empty `variantFieldMap` entry exported a CSV with no variant columns.
+- Fixed the `src/config.php` template mapping `'price' => 'basePrice'`, which named the price column `price[default]` where the rest of the documentation says `basePrice[default]`.
+- Fixed a CSV column for a standard variant field the map does not list, such as `enabled` or `isDefault`, failing the import with an unknown field error.
+- Fixed a CSV with no `sku` column raising a PHP error instead of reporting the missing column.
+- Fixed a renamed product column, such as `'Product Name' => 'title'`, being ignored on import and written twice on export.
+- Fixed a bug where the “Export Product” button reported only the HTTP status of a failed export.
+- Fixed a bug where importing a Money field could store a cent less than the CSV held, such as `19.99` becoming `19.98`, including on a straight export and reimport.
+- Fixed a bug where importing a Money field in a zero-decimal currency, such as JPY, stored an amount 100 times too large.
+- Fixed an error that occurred when a Money column held a value that was not a number.
+- Fixed a bug where “Upload Product” failed on sites with a `cpTrigger` other than `admin`.
+
+### Removed
+
+- Removed inline editing of attribute values on a variant, along with the `variant-manager/product-variants/save-variant-attributes` action and its route. A value now changes for every variant using it, edited from the option's slideout, or for one variant through a CSV import.
+
## 2.1.0 - 2026-05-13
- Add optional `status` column for product imports and exports.
diff --git a/README.md b/README.md
index 6e82c14..74745c2 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,14 @@
A Craft CMS plugin that imports and exports Craft Commerce product **variants** from CSV files.
-## What it does
+## Overview
- Imports a CSV to create or update a Craft Commerce product and its variants.
- Bulk-imports many products at once from a zip of CSVs, each file becoming its own product.
-- Exports a product to CSV from the product edit page, or many products at once from the Variants element index.
+- Exports a product to CSV from the product edit page, or many products at once from the Commerce products index.
- Adds a **Variant Attributes** field that stores option name and value pairs (Color, Size, Material) on each variant for filtering on the storefront.
+- Lets you build a color picker or size swatch in Twig, from swatch images, spec sheets or notes you attach to any attribute value.
+- Sets one field on many variants at once from the Variants index.
- Logs each import and export, with configurable retention, in a dashboard activity feed.
## Requirements
@@ -25,7 +27,7 @@ composer require fostercommerce/variant-manager
./craft plugin/install variant-manager
```
-See [`docs/installation.md`](./docs/installation.md) for the full installation and configuration guide.
+See [`docs/installation.md`](./docs/installation.md) for the full installation and configuration guide, and [`docs/upgrade.md`](./docs/upgrade.md) if you are coming from 2.x.
## Importing
@@ -35,26 +37,37 @@ See [`docs/user-guide/importing.md`](./docs/user-guide/importing.md) and [`docs/
## Exporting
-Two ways to export: from a single product's edit page (sidebar **Export Product** button), or from the **Variants** element index using the **Export Variant Data** action on a multi-select. A single product downloads as one CSV; multiple products download as a zip. Exported CSVs are shaped so they can be reimported without edits to the column headers.
+Two ways to export: the sidebar **Export Product** button on a product's edit page, or the **Export Variant Data** action on a multi-select at **Commerce -> Products**. A single product downloads as one CSV; multiple products download as a zip. Exported CSVs are shaped so they can be reimported without edits to the column headers.
See [`docs/user-guide/exporting.md`](./docs/user-guide/exporting.md).
-## Variant Attributes field
+## Variant attributes
-The plugin ships a **Variant Attributes** field type that you add to each product type's variant field layout. The field stores the option-name and option-value pairs from your CSV (Color: Red, Size: Small) as JSON on the variant, and exposes them to Twig for variant selectors and faceted filtering.
+The plugin ships a **Variant Attributes** field type that you add to each product type's variant field layout. It stores the name and value pairs from your CSV (Color: Red, Size: Small) as JSON on the variant. Twig reads them for variant selectors and faceted filtering.
-See [`docs/reference/field-type.md`](./docs/reference/field-type.md) for storage and Twig usage.
+Every name and value your variants use also gets its own element. Attach a swatch image, a spec sheet or a note to it. Pick how a storefront renders it: dropdown, radio buttons, text buttons, image swatches, color swatches or lightswitch. Imports and exports are unchanged.
+
+See [`docs/user-guide/variant-attributes.md`](./docs/user-guide/variant-attributes.md) and [`docs/reference/field-type.md`](./docs/reference/field-type.md).
## Permissions
In addition to `accessPlugin-variant-manager`:
- `variant-manager:import`, upload CSVs and create or update products and variants.
-- `variant-manager:export`, export products from the product edit page or the variants index.
-- `variant-manager:manage`, clear the activity log and manage plugin data.
+- `variant-manager:export`, export products from the product edit page or the Commerce products index.
+- `variant-manager:manage`, clear the activity log and bulk edit variant fields.
+- `variant-manager:manage-attributes`, view and edit variant attributes and their options.
See [`docs/reference/permissions.md`](./docs/reference/permissions.md).
## License
Proprietary.
+
+## Documentation
+
+See [`docs/index.md`](./docs/index.md).
+
+## Credits
+
+Brought to you by [Foster Commerce](https://fostercommerce.com).
diff --git a/docs/dev-guide/custom-queue.md b/docs/dev-guide/custom-queue.md
index 2bfcb58..6033439 100644
--- a/docs/dev-guide/custom-queue.md
+++ b/docs/dev-guide/custom-queue.md
@@ -1,6 +1,6 @@
# Custom queue
-How to keep Variant Manager's import jobs from delaying other Craft queue work. Audience: developers running production sites with high queue volume.
+How to keep Variant Manager's import jobs from delaying other Craft queue work.
Bulk imports can generate thousands of import jobs. By default they go on Craft's main queue, which means other Craft work (search index rebuilds, image transforms, emails) can sit behind a long import batch.
@@ -64,11 +64,3 @@ Then run the worker for the custom queue separately:
```
See Craft's [custom queues guide](https://craftcms.com/docs/5.x/system/queue.html#custom-queues) for more on how Yii's queue components are wired up.
-
-## Verifying it works
-
-1. Upload a small CSV from **Variant Manager -> Dashboard**. The upload modal returns "File ... has been queued for processing" as usual.
-2. With the main queue worker stopped, check the dashboard activity log; the new row stays in its pending state because the main queue does not own the job.
-3. Start the custom queue worker: `./craft queue/run --queue=priorityQueue`.
-4. Refresh the dashboard. The activity log row flips to the green-dot success state once the worker drains the import.
-5. For the priority approach, push two jobs back to back (an import and a search index rebuild) and confirm the search rebuild runs first.
diff --git a/docs/dev-guide/template-tags.md b/docs/dev-guide/template-tags.md
index 2d716f3..87e527c 100644
--- a/docs/dev-guide/template-tags.md
+++ b/docs/dev-guide/template-tags.md
@@ -2,6 +2,18 @@
Twig helpers Variant Manager exposes on the `craft` variable for use in storefront templates.
+## Reading the field on a variant
+
+The field's value is accessible by its handle:
+
+```twig
+{% for attribute in variant.variantAttributes ?? [] %}
+
+{% endfor %}
+```
+
+Substitute the handle you gave the field for `variantAttributes`.
+
## `craft.variantManager.getAttributeOptions(product, only?)`
Returns the distinct attribute names and the unique values used across a product's variants. Useful for building variant pickers and faceted filters.
@@ -13,18 +25,15 @@ Parameters:
Returns an array of associative arrays, each with:
-- `name`: the attribute name.
+- `name`: the attribute name, as stored on the variant.
- `values`: a deduplicated array of every value used by any variant for that attribute.
-### Example output
+The result is plain strings, so it is safe to pass to `json_encode` for a JavaScript picker:
```twig
-{% set attributeOptions = craft.variantManager.getAttributeOptions(product.id) %}
-{{ attributeOptions | json_encode }}
+{{ craft.variantManager.getAttributeOptions(product.id)|json_encode }}
```
-Renders something like:
-
```json
[
{ "name": "Color", "values": ["Red", "Blue"] },
@@ -32,24 +41,42 @@ Renders something like:
]
```
-### Example: build a radio picker for every attribute
+## `craft.variantManager.getAttributeRegistry(product, only?)`
+
+Returns the same names and values, each paired with the [registry](../user-guide/variant-attributes.md) elements that carry its metadata. Use this to render swatches, display names, or any field a developer added.
+
+Takes the same parameters. Each entry adds:
+
+- `attribute`: the attribute element, or `null` if the name is not registered yet. Carries the display type and the attribute's own fields.
+- `options`: the option elements, keyed by the raw value. A value that is not registered is absent from this array.
+
+These are elements, so read them in Twig rather than encoding them. `json_encode` inlines every public property of every element.
+
+### Example: render by display type
```twig
-{% set product = craft.products().id(30).one() %}
+{% for attributeOptions in craft.variantManager.getAttributeRegistry(product) %}
+ {% set attribute = attributeOptions.attribute %}
-{% for attribute in craft.variantManager.getAttributeOptions(product) %}
{% endfor %}
```
+A name or value that has not been registered yet has no element, so fall back to the raw name and value.
+
+`attribute.displayType` is one of `dropdown`, `radioButtons`, `textButtons`, `imageSwatches`, `colorSwatches` or `lightswitch`. Branch on it to pick how you render the options.
+
### Example: limit to specific attributes
```twig
diff --git a/docs/dev-guide/twig-queries.md b/docs/dev-guide/twig-queries.md
index 66b53fb..c4bc4e0 100644
--- a/docs/dev-guide/twig-queries.md
+++ b/docs/dev-guide/twig-queries.md
@@ -1,6 +1,6 @@
# Querying variants
-How to filter Commerce variants by their Variant Attributes field. Audience: developers building storefront templates or PHP code that queries variants.
+How to filter Commerce variants by their Variant Attributes field.
Substitute the handle you gave your Variant Attributes field (`variantAttributes`, `myVariantAttributes`, anything you chose) for `variantAttributes` in the examples below.
diff --git a/docs/examples/classic-tee-minimum.csv b/docs/examples/classic-tee-minimum.csv
index cc841db..2c73578 100644
--- a/docs/examples/classic-tee-minimum.csv
+++ b/docs/examples/classic-tee-minimum.csv
@@ -1,4 +1,4 @@
-title,sku,basePrice,Attribute: Color,Attribute: Size
+title,sku,basePrice[default],Attribute: Color,Attribute: Size
Classic Tee,,,,
,TEE-RED-S,19.99,Red,Small
,TEE-RED-M,19.99,Red,Medium
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 80978bd..01dd2dc 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -9,53 +9,15 @@ composer require fostercommerce/variant-manager
./craft plugin/install variant-manager
```
-In the CP you should see a **Variant Manager** nav item with two subnav entries: **Dashboard** and **Variants**.
+In the CP you should see a **Variant Manager** nav item with four subnav entries: **Dashboard**, **Variants**, **Variant Attributes** and **Attribute Options**.
## 2. Configure
-Create `config/variant-manager.php`:
-
-```php
- '',
- 'attributePrefix' => 'Attribute: ',
- 'inventoryPrefix' => 'Inventory',
- 'activityLogRetention' => '30 days',
- 'productFieldMap' => [
- '*' => [
- 'title' => 'title',
- 'slug' => 'slug',
- 'status' => 'status',
- ],
- ],
- 'variantFieldMap' => [
- '*' => [
- 'title' => 'title',
- 'sku' => 'sku',
- 'inventoryTracked' => 'inventoryTracked',
- 'price' => 'basePrice',
- 'height' => 'height',
- 'width' => 'width',
- 'length' => 'length',
- 'weight' => 'weight',
- ],
- ],
-];
-```
-
-All keys have defaults; you can skip the file entirely and revisit it once you know which fields you want to import.
+The default `productFieldMap` and `variantFieldMap` already cover every column this walkthrough uses, so there is nothing to add. Create `config/variant-manager.php` when you want to map a custom field or rename a column. See the [configuration reference](./reference/configuration.md) for every key.
## 3. Add the Variant Attributes field
-The plugin needs a place to store option name and value pairs (Color: Red, Size: Small) on each variant.
-
-1. **Settings -> Fields -> New field**.
-2. **Field Type**: **Variant Attributes**. Name it **Variant Attributes**, handle `variantAttributes`. No further settings needed.
-3. **Commerce -> Settings -> Product Types -> {your product type} -> Variant Fields**. Drag the new field onto the layout. Save.
-
-You only need one Variant Attributes field per product type's variant layout. Extras are ignored.
+The plugin needs a place to store option name and value pairs (Color: Red, Size: Small) on each variant. Create a **Variant Attributes** field with the handle `variantAttributes` and add it to your product type's variant field layout. See [installation](./installation.md#add-the-variant-attributes-field) for the steps.
## 4. Build your first CSV
@@ -94,7 +56,15 @@ Check the variants tab:
- Each has its price set to 19.99 and inventory tracking on.
- Open one variant. Scroll to the Variant Attributes field; you should see Color and Size with the value for that variant.
-## 7. Round-trip: export, edit, reimport
+## 7. See the attributes the import registered
+
+**Variant Manager -> Variant Attributes**. The import created `Color` and `Size`. **Variant Manager -> Attribute Options** lists `Red`, `Blue`, `Small` and `Medium`, each with the attribute it belongs to.
+
+Open `Red`. Its **CSV Value** is read-only; its title is not. The **Used by** count shows how many variants store `Red`. Rename the title to `Crimson` and save. No variant changed, and a template reading `option.title` now shows `Crimson`.
+
+See [variant attributes](./user-guide/variant-attributes.md).
+
+## 8. Round-trip: export, edit, reimport
This is the workflow you will use to bulk-edit existing products.
@@ -103,17 +73,19 @@ This is the workflow you will use to bulk-edit existing products.
3. Change something. Bump the price on DEMO-RED-S to 21.99.
4. Save the file. **Do not rename it.**
5. Back to **Variant Manager -> Dashboard -> Upload Product** and pick the same file.
-6. The modal recognises the existing product and asks "Are you sure you want to edit an existing product named \"Demo Shirt\"?" with a **Refresh variants** radio group. Leave it on **Update and remove extra variants** (the default) and click **Edit Product**.
+6. The modal recognizes the existing product and asks "Are you sure you want to edit an existing product named \"Demo Shirt\"?" with a **Refresh variants** radio group. Leave it on **Update and remove extra variants** (the default) and click **Edit Product**.
7. Activity log shows another green-dot row. Reopen the product; DEMO-RED-S is now 21.99.
-## 8. Where to go next
+## 9. Where to go next
For deeper reading:
-- [CSV format](./user-guide/csv-format.md), every column the import recognises, with formatting rules.
+- [CSV format](./user-guide/csv-format.md), every column the import recognizes, with formatting rules.
- [Importing](./user-guide/importing.md), the upload flow in detail, including the choice between updating and replacing variants.
- [Exporting](./user-guide/exporting.md), single-product and bulk export.
- [Bulk import](./user-guide/bulk-import.md), uploading a zip of CSVs.
- [Troubleshooting](./user-guide/troubleshooting.md), when imports fail.
- [Variant Attributes field](./reference/field-type.md), how the attribute data is stored and read.
+- [Variant attributes](./user-guide/variant-attributes.md), attaching swatches and notes to attribute values.
- [Template tags](./dev-guide/template-tags.md) and [recipes](./recipes/add-to-cart.md), using the attributes on the storefront.
+- [Upgrading to 3.x](./upgrade.md), if you are coming from 2.x.
diff --git a/docs/index.md b/docs/index.md
index 3a5e11b..45649c2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -2,21 +2,26 @@
Import and export Craft Commerce product variants from CSV files.
+## Upgrading
+
+Coming from 2.x? See [upgrading to 3.x](./upgrade.md).
+
## Where to go
**First time here?** Start with [Getting started](./getting-started.md), a walkthrough from install to your first successful CSV import.
**Building or editing CSVs for a Craft Commerce store?** See the [user guide](./user-guide/):
-- [CSV format](./user-guide/csv-format.md), every column the import recognises, with a working example.
+- [CSV format](./user-guide/csv-format.md), every column the import recognizes, with a working example.
- [Importing](./user-guide/importing.md), uploading single CSVs and zip batches.
- [Exporting](./user-guide/exporting.md), getting CSVs out of Commerce to edit.
+- [Variant attributes](./user-guide/variant-attributes.md), attaching swatches, notes and other fields to attribute values.
- [Troubleshooting](./user-guide/troubleshooting.md), when an import does not behave the way you expected.
**Building on top of the plugin?** See the [developer guide](./dev-guide/), [recipes](./recipes/), and [reference](./reference/):
- [Template tags and queries](./dev-guide/template-tags.md), reading and filtering Variant Attributes in Twig.
-- [Recipes](./recipes/add-to-cart.md), full storefront examples for variant pickers and faceted filtering.
+- [Console commands](./reference/console-commands.md), backfilling and pruning the attribute registry.
- [Custom queue](./dev-guide/custom-queue.md), running imports on a dedicated queue.
- [Configuration reference](./reference/configuration.md), every config key with defaults.
diff --git a/docs/installation.md b/docs/installation.md
index 25111ae..9f8f8a7 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -1,6 +1,6 @@
# Installation
-A Craft CMS plugin that manages Craft Commerce product variants from CSV files.
+A Craft CMS plugin that imports and exports Craft Commerce product **variants** from CSV files.
## Requirements
@@ -22,14 +22,14 @@ composer require fostercommerce/variant-manager
With DDEV:
```sh
-ddev composer require fostercommerce/variant-manager -w && ddev exec php craft plugin/install variant-manager
+ddev composer require fostercommerce/variant-manager -w && ddev craft plugin/install variant-manager
```
-After install you will see a **Variant Manager** item in the CP navigation with two subnav entries: **Dashboard** and **Variants**.
+After install the CP navigation gets a **Variant Manager** item with **Dashboard** and **Variants**. **Variant Attributes** and **Attribute Options** appear for users with `variant-manager:manage-attributes`.
## Configure
-Settings live in a config file, not the Control Panel. Create `config/variant-manager.php`:
+Import and export settings live in a config file. The plugin runs without one. Create `config/variant-manager.php` to change any of the defaults:
```php
'title',
'sku' => 'sku',
'inventoryTracked' => 'inventoryTracked',
- 'price' => 'basePrice',
+ 'basePrice' => 'basePrice',
'height' => 'height',
'width' => 'width',
'length' => 'length',
@@ -61,7 +61,9 @@ return [
];
```
-Every key has a default; the plugin runs without the file. See [configuration reference](./reference/configuration.md) for what each key controls.
+Those values are the defaults, so the file above changes nothing. See [configuration reference](./reference/configuration.md) for what each key controls.
+
+Attribute display types and field layouts are set in the CP instead, at **Settings -> Plugins -> Variant Manager**, and stored in project config. See [variant attributes](./user-guide/variant-attributes.md).
## Add the Variant Attributes field
@@ -77,15 +79,9 @@ See [Variant Attributes field reference](./reference/field-type.md) for how the
## Permissions
-Grant the plugin's permissions on user groups in **Users -> {group} -> Permissions** or on individual users:
-
-- `variant-manager:import`, upload CSVs and create or update products and variants.
-- `variant-manager:export`, export products from the product edit page or the variants index.
-- `variant-manager:manage`, clear the activity log and manage plugin data.
-
-`accessPlugin-variant-manager` is required to see the plugin's CP section at all.
+Grant the plugin's permissions on user groups at **Users -> {group} -> Permissions** or on individual users. `accessPlugin-variant-manager` is required to see the plugin's CP section at all.
-See [permissions reference](./reference/permissions.md).
+See [permissions reference](./reference/permissions.md) for the full list.
## Console commands
@@ -93,4 +89,16 @@ See [permissions reference](./reference/permissions.md).
./craft variant-manager/activities/clear
```
-Deletes activity log entries older than `activityLogRetention`. Pass `--all` to wipe every entry regardless of age. Craft's garbage collection runs the same expiry pass automatically. See [console commands](./reference/console-commands.md).
+Deletes activity log entries older than `activityLogRetention`. Pass `1` to wipe every entry regardless of age. Craft's garbage collection runs the same expiry pass automatically. See [console commands](./reference/console-commands.md).
+
+```sh
+./craft variant-manager/attributes/backfill
+```
+
+Registers an attribute and option for every name and value already stored on a variant. Run this once after installing on a store that already has variant data.
+
+```sh
+./craft variant-manager/attributes/orphans
+```
+
+Lists attributes and options no longer used by any variant. Pass `--prune` to delete them.
diff --git a/docs/recipes/field-maps-for-many-product-types.md b/docs/recipes/field-maps-for-many-product-types.md
index 2e310c0..b97ac95 100644
--- a/docs/recipes/field-maps-for-many-product-types.md
+++ b/docs/recipes/field-maps-for-many-product-types.md
@@ -1,6 +1,6 @@
# Recipe: field maps for a catalog with many product types
-How to organise `productFieldMap` and `variantFieldMap` on a store with a dozen product types that each have their own custom fields on top of a shared core. Audience: developers maintaining `config/variant-manager.php` on a multi-product-type Commerce site.
+How to organize `productFieldMap` and `variantFieldMap` on a store with a dozen product types that each have their own custom fields on top of a shared core.
## The gotcha to know first
@@ -10,7 +10,7 @@ This means: as soon as you add one custom field for one product type, the entry
## The pattern
-Define shared field groups as PHP arrays at the top of the file, then spread them into each per-product-type entry. The file stays DRY, and every entry is explicit about its full field set so the no-merge behaviour cannot surprise you.
+Define shared field groups as PHP arrays at the top of the file, then spread them into each per-product-type entry. The file stays DRY, and every entry is explicit about its full field set so the no-merge behavior cannot surprise you.
```php
'title',
'sku' => 'sku',
'inventoryTracked' => 'inventoryTracked',
- 'price' => 'basePrice',
+ 'basePrice' => 'basePrice',
'height' => 'height',
'width' => 'width',
'length' => 'length',
@@ -48,7 +48,7 @@ The placeholder written into the Variant Attributes field when a row's attribute
- Type: `string`
- Default: `'Attribute: '`
-The prefix used to recognise attribute columns in a CSV. A column whose header starts with this string is mapped to a Variant Attributes entry whose name is the rest of the header. Default behaviour: `Attribute: Color` becomes attribute `Color`.
+The prefix used to recognize attribute columns in a CSV. A column whose header starts with this string is mapped to a Variant Attributes entry whose name is the rest of the header. Default behavior: `Attribute: Color` becomes attribute `Color`.
Changing this is a breaking change for any existing CSVs. Keep it consistent across your store.
@@ -57,7 +57,7 @@ Changing this is a breaking change for any existing CSVs. Keep it consistent acr
- Type: `string`
- Default: `'Inventory'`
-The prefix used to recognise inventory columns. The full column pattern is `{prefix}[locationHandle]: totalName`, so with the default prefix a column is `Inventory[main]: available`.
+The prefix used to recognize inventory columns. The full column pattern is `{prefix}[locationHandle]: totalName`, so with the default prefix a column is `Inventory[main]: available`.
### `activityLogRetention`
@@ -75,7 +75,7 @@ Expiry runs during Craft's garbage collection and via the `variant-manager/activ
### `productFieldMap`
- Type: `array`
-- Default: `['*' => ['title' => 'title', 'slug' => 'slug', 'status' => 'status']]`
+- Default: `['*' => ['title' => 'title', 'slug' => 'slug', 'status' => 'status']]`. Set the key to replace that map, not to add to it.
Maps CSV column headers (left) to product properties or field handles (right). Keys at the top level are product type handles, with `'*'` matching any product type not otherwise listed.
@@ -111,7 +111,9 @@ Example per-product-type map:
### `variantFieldMap`
- Type: `array`
-- Default: see above
+- Default: `['*' => ['title' => 'title', 'sku' => 'sku', 'inventoryTracked' => 'inventoryTracked', 'basePrice' => 'basePrice', 'height' => 'height', 'width' => 'width', 'length' => 'length', 'weight' => 'weight']]`. Set the key to replace that map, not to add to it.
+
+An empty map for a product type fails the import and the export with "No variant fields are mapped".
Same shape as `productFieldMap`, but maps to variant properties or field handles. The `'*'` catch-all applies to product types not otherwise listed.
@@ -123,7 +125,7 @@ Per-site variant properties (use the column suffix `[siteHandle]`):
- `basePrice`, `inventoryTracked`, `availableForPurchase`, `freeShipping`, `promotable`, `minQty`, `maxQty`.
-If `availableForPurchase` or `promotable` are not mapped, the import defaults them to `true` on save. This matches the standard Commerce variant behaviour.
+If `availableForPurchase` or `promotable` are not mapped, the import defaults them to `true` on save. This matches the standard Commerce variant behavior.
The Variant Attributes field handle does not need to be in this map. The plugin discovers it from the product type's variant field layout.
@@ -134,7 +136,7 @@ $defaults = [
'title' => 'title',
'sku' => 'sku',
'inventoryTracked' => 'inventoryTracked',
- 'price' => 'basePrice',
+ 'basePrice' => 'basePrice',
'height' => 'height',
'width' => 'width',
'length' => 'length',
@@ -152,6 +154,48 @@ return [
];
```
+### `defaultVariantTableAttributes`
+
+- Type: `list`
+- Default: `[]`
+
+Extra columns shown by default on **Variant Manager -> Variants**. Each entry is a variant field handle or table attribute, appended to the plugin's own defaults.
+
+### `bulkEditableVariantFields`
+
+- Type: `list`
+- Default: `[]`
+
+Variant field handles the **Bulk edit field** action can set. `inventoryTracked` is accepted alongside custom field handles. While this is empty, the action does not appear on the Variants index. Bulk editing also requires the `variant-manager:manage` permission.
+
+### `availableDisplayTypes`
+
+- Type: `list`
+- Default: `[]`
+
+Display types offered in the **Display Type** menu on an attribute. While this is empty, or while it holds `'*'`, every type is offered. Use it to hide the ones your templates do not render:
+
+```php
+return [
+ 'availableDisplayTypes' => ['dropdown', 'textButtons', 'imageSwatches'],
+];
+```
+
+Valid values are `dropdown`, `radioButtons`, `textButtons`, `imageSwatches`, `colorSwatches` and `lightswitch`. An unrecognized value is skipped.
+
+An attribute already set to a type this list omits keeps it, and the menu still shows it, so nothing is rewritten on the next save. Change that attribute and the omitted type is gone from its menu.
+
+This setting is also editable at **Settings** -> **Plugins** -> **Variant Manager**. Setting it here disables that control, since a config file overrides what the control panel saves.
+
+### `defaultDisplayType`
+
+- Type: `string`
+- Default: `'dropdown'`
+
+Display type given to an attribute the first time an import or `variant-manager/attributes/backfill` registers it. Attributes that already exist keep the type they have.
+
+Takes the same values as `availableDisplayTypes`. An unrecognized value falls back to `dropdown`. This setting is also editable at **Settings** -> **Plugins** -> **Variant Manager**, where the menu offers only the types `availableDisplayTypes` allows.
+
## Supported field types
When `productFieldMap` or `variantFieldMap` maps a column to a custom field, the import knows how to write the following types:
@@ -160,8 +204,9 @@ When `productFieldMap` or `variantFieldMap` maps a column to a custom field, the
|------------|-----------------|
| Plain Text | Raw text. |
| Number | Raw number. |
+| Date | Any date string PHP can parse (`2026-03-15`, `2026-03-15 14:30`). Exported in ATOM format. |
| Lightswitch | `1` for on, anything else for off. |
-| Money | Decimal value (`15.00`). The plugin multiplies by 100 and creates a Money object in the field's currency. |
+| Money | Decimal value (`15.00`), parsed in the field's currency. Thousands separators fail the import. |
| Entries | Comma-separated `sectionHandle:slug` (`articles:summer-launch,faqs:returns`). |
| Assets | Comma-separated `volumeHandle:path/to/file.jpg`. Numeric asset IDs are also accepted. |
| Other relation fields | Comma-separated slugs. |
diff --git a/docs/reference/console-commands.md b/docs/reference/console-commands.md
index a60b40c..550adde 100644
--- a/docs/reference/console-commands.md
+++ b/docs/reference/console-commands.md
@@ -10,14 +10,53 @@ Delete activity log entries.
./craft variant-manager/activities/clear
```
-Deletes entries older than the `activityLogRetention` setting (default: `30 days`). Same behaviour as Craft's garbage collection running on the plugin.
+Deletes entries older than the `activityLogRetention` setting (default: `30 days`).
```sh
-./craft variant-manager/activities/clear --all
+./craft variant-manager/activities/clear 1
```
-Wipes every activity log entry regardless of age.
+Wipes every activity log entry regardless of age. The `1` is a positional argument, not an option.
Cleared entries are gone permanently; there is no trash to restore from.
See [activity log](../user-guide/activity-log.md) for the dashboard equivalent.
+
+## `variant-manager/attributes/backfill`
+
+Register an attribute and option for every name and value already stored on a variant.
+
+```sh
+./craft variant-manager/attributes/backfill
+```
+
+Reads every variant in batches. Anything already registered is skipped, so the command is safe to re-run. New attributes and options appear in the activity log.
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--batchSize` | `500` | Variants read per batch. |
+
+The **Utilities -> Variant Attributes** utility runs the same work in the queue.
+
+## `variant-manager/attributes/orphans`
+
+List attributes and options whose name or value is no longer stored on any variant.
+
+```sh
+./craft variant-manager/attributes/orphans
+```
+
+Reads every variant, then compares against the registry. Prints one line per orphan and deletes nothing.
+
+```sh
+./craft variant-manager/attributes/orphans --prune
+```
+
+Deletes them. Anything a variant has started using since the scan is skipped. An attribute takes its display type and field layouts with it, unless `allowAdminChanges` is off, where the rows are deleted but their project config stays. This is permanent, and any custom field values on the deleted rows are lost.
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--prune` | `false` | Delete the orphans instead of only listing them. |
+| `--batchSize` | `500` | Variants read per batch. |
+
+See [variant attributes](../user-guide/variant-attributes.md).
diff --git a/docs/reference/field-type.md b/docs/reference/field-type.md
index eba478e..0d68d06 100644
--- a/docs/reference/field-type.md
+++ b/docs/reference/field-type.md
@@ -1,17 +1,6 @@
# Variant Attributes field
-The custom field type that Variant Manager uses to store option name and value pairs on each variant. Audience: developers and admins setting up product types.
-
-## Adding the field
-
-1. **Settings -> Fields -> New field**.
-2. Set **Field Type** to **Variant Attributes**. The field has no settings of its own.
-3. **Commerce -> Settings -> Product Types -> {type} -> Variant Fields** and add the field to the variant field layout.
-
-
-
-
-Only one Variant Attributes field per variant field layout is read by the plugin. Additional copies are ignored on import and export.
+The custom field type that Variant Manager uses to store option name and value pairs on each variant.
## What it stores
@@ -24,40 +13,18 @@ The field's value is an array of associative arrays, each one an attribute name
]
```
-It is persisted as JSON in the element content row (`Schema::TYPE_JSON`). The field's `dbType` is JSON in both MySQL and PostgreSQL.
-
-## Editing in the CP
-
-
-
-The field's input UI on a variant edit page shows one row per attribute with the name and the value. The **attribute name** is read-only; only the **attribute value** can be edited from the CP. Names are deliberately locked to keep the import contract stable; renaming an attribute is done by reimporting the CSV with the new column header.
-
-Variants whose attributes were not set by Variant Manager (for example variants created in the CP before the field existed) show the field empty. Fill it in by reimporting a CSV with the right `Attribute:` columns.
-
-## Reading in Twig
-
-The field's value is accessible by its handle:
-
-```twig
-{% for attribute in variant.variantAttributes ?? [] %}
-
-{% endfor %}
-```
-
-Substitute the handle you gave the field for `variantAttributes`.
+It is persisted as JSON in the element content row (`Schema::TYPE_JSON`).
## Filtering in queries
Use the field handle as an element-query parameter on `craft.variants()` (Twig) or `Variant::find()` (PHP). The filter accepts a string, an associative array, or a list of either.
-See [querying variants](../dev-guide/twig-queries.md) for the supported filter shapes and SQL behaviour.
-
-## Listing all options for a product
+See [querying variants](../dev-guide/twig-queries.md) for the supported filter shapes and SQL behavior.
-The plugin exposes a Twig helper that returns the distinct attribute names and the set of values used across a product's variants:
+In the control panel, each registered attribute is its own filter under **Add a filter**, on both variant and product listings. On a product listing the filter is applied through Commerce's `hasVariant` param, so a product matches when one of its variants does.
-```twig
-{% set options = craft.variantManager.getAttributeOptions(product) %}
-```
+## Related
-See [template tags](../dev-guide/template-tags.md).
+- [Installation](../installation.md), adding the field to a variant field layout.
+- [Template tags](../dev-guide/template-tags.md), reading the field and the registry in Twig.
+- [Variant attributes](../user-guide/variant-attributes.md), display types, custom fields, and variant cards.
diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md
index 6f2285f..7ef8453 100644
--- a/docs/reference/permissions.md
+++ b/docs/reference/permissions.md
@@ -3,12 +3,15 @@
| Handle | Description |
|--------|-------------|
| `accessPlugin-variant-manager` | Standard Craft permission. Required to see the **Variant Manager** CP section. |
-| `variant-manager:import` | Upload CSVs from the dashboard. Allows creating new products and editing existing ones. Carries a CP warning because the default import behaviour deletes variants not listed in the CSV. |
-| `variant-manager:export` | Export products from the product edit page sidebar and from the **Export Variant Data** element action on the Variants index. |
-| `variant-manager:manage` | Clear the activity log from the dashboard. |
+| `variant-manager:import` | Upload CSVs from the dashboard. Allows creating new products and editing existing ones. Carries a CP warning because the default import behavior deletes variants not listed in the CSV. |
+| `variant-manager:export` | Export products from the product edit page sidebar and from the **Export Variant Data** action at **Commerce -> Products**. |
+| `variant-manager:manage` | Clear the activity log from the dashboard, and run the **Bulk edit field** action on the Variants index. |
+| `variant-manager:manage-attributes` | View and edit variant attributes and their options, and run the **Variant Attributes** utility. |
Set permissions at **Users -> {group} -> Permissions** or **Users -> {user} -> Permissions**.
Admins bypass every check.
See [user-guide/permissions](../user-guide/permissions.md) for who typically gets what.
+
+Field layouts at **Settings -> Plugins -> Variant Manager** require an admin account. `variant-manager:manage-attributes` covers the attribute and option elements, including an attribute's display type, and Craft's own `utility:variant-manager-attributes` permission controls whether the utility is listed.
diff --git a/docs/upgrade.md b/docs/upgrade.md
new file mode 100644
index 0000000..81d9578
--- /dev/null
+++ b/docs/upgrade.md
@@ -0,0 +1,37 @@
+# Upgrading to 3.x
+
+Existing templates keep working. This page is the setup the update needs, plus one change to the Variant Attributes field.
+
+## Editing in the Variant Attributes field moved
+
+The field showed an editable box per value with a **Save Attributes** button. Both are gone, along with the `variant-manager/product-variants/save-variant-attributes` action and its route.
+
+Names and values are now chips. Click one to open the attribute or option in a slideout and edit it there, including any custom fields you have added.
+
+Editing there is global: renaming an option changes what every product using that value shows, with no import and no change to any variant. To change the value the CSV writes, edit the CSV and reimport.
+
+## After updating
+
+Run migrations and project config changes:
+
+```sh
+./craft up
+```
+
+Then run the backfill once:
+
+```sh
+./craft variant-manager/attributes/backfill
+```
+
+It reads every variant and registers the names and values already stored on them. Safe to re-run.
+
+On a site that imports CSVs this is a catch-up for data that predates 3.x. On a site that does not import, it is the only thing that populates the registry, so it is the setup step.
+
+## Grant the new permission
+
+Existing user groups do not have `variant-manager:manage-attributes`. Grant it at **Users -> {group} -> Permissions** to anyone who should see the **Variant Attributes** and **Attribute Options** sections or run the utility. See [permissions](./reference/permissions.md).
+
+## Set field layouts in development
+
+An attribute's two field layouts are project config. Set them in your development environment and deploy them. The screen is read-only where `allowAdminChanges` is off, so they cannot be set in production directly.
diff --git a/docs/user-guide/activity-log.md b/docs/user-guide/activity-log.md
index 4d36270..4acad23 100644
--- a/docs/user-guide/activity-log.md
+++ b/docs/user-guide/activity-log.md
@@ -1,6 +1,6 @@
# Activity log
-Where to find a record of past imports, and how to keep it from growing forever. Audience: anyone running imports.
+Where to find a record of past imports, and how to keep it from growing forever.
## What gets logged
@@ -8,6 +8,7 @@ Every import attempt writes one row to the activity log, success or failure:
- **Successful imports** (green status dot): "Imported new product {title} into {product type}" or "Imported existing product {title} into {product type}". The product title is a link to the product edit page.
- **Failed imports** (red status dot): "Failed to import {filename}: {error message}". The error message is the same one the import threw, useful for diagnosing what went wrong.
+- **New attributes and options**: "Created attribute Color" and "Created option Red under Color", written when an import or the backfill registers a name or value for the first time. See [variant attributes](./variant-attributes.md).
Each row records the user who triggered the import and the date.
@@ -48,7 +49,7 @@ Two ways:
./craft variant-manager/activities/clear
# Wipe every row regardless of age.
- ./craft variant-manager/activities/clear --all
+ ./craft variant-manager/activities/clear 1
```
Either way, cleared rows are gone permanently; there is no trash to restore from.
diff --git a/docs/user-guide/bulk-import.md b/docs/user-guide/bulk-import.md
index 06226b8..3ad3b11 100644
--- a/docs/user-guide/bulk-import.md
+++ b/docs/user-guide/bulk-import.md
@@ -1,6 +1,6 @@
# Bulk import
-Upload many products at once by zipping their CSVs together. Audience: anyone doing a catalog-scale import.
+Upload many products at once by zipping their CSVs together.
## When to use it
@@ -30,7 +30,7 @@ The plugin ignores:
2. Put them all in a single folder.
3. Zip the folder, or select all the CSVs and create a zip from them.
-A zip with subfolders works, but folder paths are ignored; only the CSV's own filename is used to find or create the product. Avoid relying on folder structure to organise products.
+A zip with subfolders works, but folder paths are ignored; only the CSV's own filename is used to find or create the product. Avoid relying on folder structure to organize products.
## Uploading
diff --git a/docs/user-guide/csv-format.md b/docs/user-guide/csv-format.md
index a956773..5a7aa18 100644
--- a/docs/user-guide/csv-format.md
+++ b/docs/user-guide/csv-format.md
@@ -1,6 +1,6 @@
# CSV format
-How to shape a CSV file so Variant Manager imports it cleanly the first time. Audience: anyone preparing product data in a spreadsheet for upload.
+How to shape a CSV file so Variant Manager imports it cleanly the first time.
## The filename matters
@@ -11,7 +11,7 @@ The CSV's filename is how Variant Manager decides whether to **create a new prod
- `Heritage Mug.csv` creates a product titled `Heritage Mug`.
- **Existing product**: name the file with the product's exact title in Commerce.
- `Classic Tee.csv` updates the existing `Classic Tee` product.
- - Capitalisation, spaces, and punctuation must all match. `classic tee.csv` will **not** find `Classic Tee`; it will create a new product called `classic tee` instead.
+ - Capitalization, spaces, and punctuation must all match. `classic tee.csv` will **not** find `Classic Tee`; it will create a new product called `classic tee` instead.
- **Exporting then re-uploading**: exported files are named `{id}__{slug}.csv` (for example `42__classic-tee.csv`). Leave this filename alone. The number before `__` ties the upload back to the same product even if its title has been edited in the meantime.
- **Hidden files**: filenames beginning with `.` are ignored, and zip uploads skip files in `__MACOSX/` folders that macOS adds automatically. Both are safe to leave in your zip.
@@ -28,7 +28,7 @@ Variant Manager expects:
A minimum file looks like this:
```csv
-title,sku,basePrice,Attribute: Color,Attribute: Size
+title,sku,basePrice[default],Attribute: Color,Attribute: Size
Classic Tee,,,,
,TEE-RED-S,19.99,Red,Small
,TEE-RED-M,19.99,Red,Medium
@@ -36,7 +36,7 @@ Classic Tee,,,,
,TEE-BLUE-M,19.99,Blue,Medium
```
-The first cell of row 2 (`Classic Tee`) is the product title. From row 3 onwards every row is one variant; leave the product title column empty on variant rows.
+The first cell of row 2 (`Classic Tee`) is the product title. From row 3 onwards every row is one variant; leave the product title column empty on variant rows. `basePrice[default]` is per-site; replace `default` with your site's handle if it is different.
Download: [`classic-tee-minimum.csv`](../examples/classic-tee-minimum.csv). Rename it to `Classic Tee.csv` (or whatever title you want the product to have) before uploading.
@@ -132,7 +132,9 @@ Empty cells become the `emptyAttributeValue` configured in the plugin config (de
### Other custom fields
-Plain text and number fields you have added to your variant or product field layouts can be included by adding their handles to `variantFieldMap` or `productFieldMap`. The plugin also recognises:
+Plain text and number fields you have added to your variant or product field layouts can be included by adding their handles to `variantFieldMap` or `productFieldMap`. The plugin also recognizes:
+
+- **Date fields**: any date string PHP can parse (`2026-03-15`).
- **Money fields**: decimal value (`15.00`).
- **Lightswitch fields**: `1` for on, anything else for off.
@@ -145,13 +147,13 @@ Plain text and number fields you have added to your variant or product field lay
These are the imports that fail or behave strangely:
- **Smart quotes in column headers**: typing column headers in a word processor turns `"Attribute: Color"` into `"Attribute: Color"` with curly quotes. Header matching is exact. Stick to a spreadsheet editor or a plain-text editor.
-- **A BOM at the start of the file**: some spreadsheet apps add a byte-order mark, which makes the first column header unrecognisable. Save as "CSV (comma-delimited)" or "CSV UTF-8" without BOM.
+- **A BOM at the start of the file**: some spreadsheet apps add a byte-order mark, which makes the first column header unrecognizable. Save as "CSV (comma-delimited)" or "CSV UTF-8" without BOM.
- **Semicolon as the separator**: spreadsheet apps in some regions default to `;`. Variant Manager only reads commas. Re-export with comma as the delimiter.
- **Inventory column on an untracked variant**: the cell is ignored. Set `inventoryTracked[default]` to `1` first.
- **Wrong attribute prefix**: `Option: Color` does nothing if `attributePrefix` is `Attribute: `. Match the config.
- **Mixed prefixes**: every attribute column must use the same prefix you have in config. You cannot mix `Attribute: ` and `Option: ` in one file.
- **SKU collisions**: an SKU on a different product blocks the whole import with an error. SKUs must be unique across the entire store.
- **Duplicate SKUs inside the file**: the same SKU on two rows in the same CSV also blocks the import.
-- **Product title differs from filename**: if `title` in row 2 says `Classic Tee` but the filename is `Heritage Mug.csv`, the file creates a product named `Heritage Mug` and writes `Classic Tee` over its title once saved. Pick one. The filename wins for the create-vs-update decision; the cell wins for the final title.
+- **Product title differs from filename**: `Heritage Mug.csv` with `Classic Tee` in row 2 creates a product named `Heritage Mug`, then renames it to `Classic Tee` on save. The filename decides create-vs-update; the cell decides the final title. Match them.
See [troubleshooting](./troubleshooting.md) for what to do when an import goes wrong.
diff --git a/docs/user-guide/exporting.md b/docs/user-guide/exporting.md
index e476b68..2f0f285 100644
--- a/docs/user-guide/exporting.md
+++ b/docs/user-guide/exporting.md
@@ -1,11 +1,11 @@
# Exporting
-Get a CSV out of Craft Commerce so you can edit it in a spreadsheet and reimport. Audience: anyone maintaining product data.
+Get a CSV out of Craft Commerce so you can edit it in a spreadsheet and reimport.
## Two ways to export
- **One product at a time**: from the product edit page. Useful when you want to edit a single product's variants.
-- **Many products at once**: from the **Variants** element index, using a multi-select action. Useful for catalog-wide updates.
+- **Many products at once**: from **Commerce -> Products**, using a multi-select action. Useful for catalog-wide updates.
## Exporting one product
@@ -19,18 +19,16 @@ That filename is important: when you reupload it, Variant Manager uses the `{id}
## Exporting many products
-1. **Variant Manager -> Variants**, which is the plugin's variants element index.
-2. Filter or search to narrow down the variants you want.
-3. Select the variants you want to export. Use the checkbox in the table header to select everything visible.
+1. **Commerce -> Products**.
+2. Filter or search to narrow the list. **Add a filter** offers one entry per attribute, such as **Variant Attribute: Size**.
+3. Select the products you want to export. Use the checkbox in the table header to select everything visible.
4. Open the actions menu and choose **Export Variant Data**.
-Variant Manager finds the products that own those variants and exports one CSV per product:
+Variant Manager exports one CSV per selected product:
- **One product**: a single CSV downloads, named the same way as the single-product export.
- **Multiple products**: a zip downloads, named `products_{YmdHis}.zip` (for example `products_20260513142301.zip`). Each CSV inside is named `{id}__{slug}.csv`.
-You can also trigger this action from the standard **Commerce -> Products -> Variants** element index, since the plugin registers itself on the same element type.
-
## What is in the exported CSV
Exported CSVs are shaped so they can be reimported as-is after edits to cell values.
@@ -65,7 +63,7 @@ Steps:
When you reupload an existing product:
-- The modal will recognise it as an existing product and ask whether to **Update and remove extra variants** (default) or **Replace all variants**.
+- The modal recognizes it as an existing product and asks whether to **Update and remove extra variants** (default) or **Replace all variants**.
- Choose **Update and remove extra variants** for the round-trip workflow. Any variant whose SKU is in the CSV gets updated; any variant whose SKU is missing gets deleted.
See [importing](./importing.md#existing-product-update-options) for the difference between the two refresh options.
diff --git a/docs/user-guide/importing.md b/docs/user-guide/importing.md
index 42eae7c..de09dc3 100644
--- a/docs/user-guide/importing.md
+++ b/docs/user-guide/importing.md
@@ -1,6 +1,6 @@
# Importing
-How to upload a CSV to create or update a product in Craft Commerce. Audience: anyone preparing product data in a spreadsheet.
+How to upload a CSV to create or update a product in Craft Commerce.
If you have not built the CSV yet, start with [CSV format](./csv-format.md).
@@ -9,7 +9,7 @@ If you have not built the CSV yet, start with [CSV format](./csv-format.md).
The filename decides what happens when you upload it.
- **Creating a new product**: name the file with the product's exact title plus `.csv`. `Classic Tee.csv` creates a product titled `Classic Tee`.
-- **Updating an existing product**: name the file with the existing product's exact title plus `.csv`. Capitalisation, spacing, and punctuation all have to match what is in Commerce.
+- **Updating an existing product**: name the file with the existing product's exact title plus `.csv`. Capitalization, spacing, and punctuation all have to match what is in Commerce.
- **Reimporting an export**: leave the filename Variant Manager generated. Exports are named `{id}__{slug}.csv` (for example `42__classic-tee.csv`); the number before `__` ties the upload back to the same product regardless of any title edits since export.
If you are unsure, export the product first and edit that file rather than building a filename by hand.
@@ -88,6 +88,9 @@ The upload responds with "File {your-file}.csv has been queued for processing" a
Watch the dashboard's activity log for the result:
- A green status dot means the import succeeded. The message links to the new or updated product.
+
+
+
- A red status dot means the import failed. The message contains the failure reason.
If your queue is not running automatically, run `./craft queue/run` from the project directory.
@@ -107,5 +110,3 @@ If anything is wrong, the safest fix is to edit the source CSV and re-upload it;
Failed imports show their error in the activity log. The two most common reasons are a misnamed CSV and a SKU that already exists on a different product. See [troubleshooting](./troubleshooting.md).
**Failed queue jobs**: delete them. Retrying a failed import job will fail with the same error because the data in the job is the data that failed. Fix the CSV and re-upload it instead. See [cleaning up failed import jobs](./troubleshooting.md#cleaning-up-failed-import-jobs).
-
-
diff --git a/docs/user-guide/permissions.md b/docs/user-guide/permissions.md
index a6254f2..0f94b51 100644
--- a/docs/user-guide/permissions.md
+++ b/docs/user-guide/permissions.md
@@ -4,23 +4,17 @@ Which user groups and users can do what in Variant Manager. Set permissions at *

-## Permission summary
-
-| Permission | What it allows |
-|------------|---------------|
-| `accessPlugin-variant-manager` | See the **Variant Manager** CP nav item and read the dashboard. Required for any of the others to be useful. |
-| `variant-manager:import` | Upload CSVs and create or update products and variants from the dashboard. |
-| `variant-manager:export` | Export products from the product edit page sidebar or the Variants element index action. |
-| `variant-manager:manage` | Clear the activity log from the dashboard. |
+See [permissions reference](../reference/permissions.md) for the full list.
## Choosing what to grant
- **Product team**: grant `accessPlugin-variant-manager`, `variant-manager:import`, and `variant-manager:export`. They get the full import-edit-export round trip without touching plugin internals.
- **Support or read-only roles**: grant `accessPlugin-variant-manager` alone. They can see imports happen but cannot upload or export.
-- **Admins or operations leads**: grant all three permissions, including `variant-manager:manage`, so they can clear logs.
+- **Merchandisers**: add `variant-manager:manage-attributes` on top of the product team set, so they can edit attribute and option titles, display types and custom fields without running imports. Field layouts stay admin-only.
+- **Admins or operations leads**: grant everything, including `variant-manager:manage` for clearing logs and `variant-manager:manage-attributes` for editing attributes and running the utility.
Site admins bypass every permission check; they always have full access.
## Warnings
-`variant-manager:import` carries a warning at the permission edit screen ("Imports can potentially overwrite existing variants"). The default existing-product import deletes any variant whose SKU is not in the CSV, so grant import access only to people who understand that behaviour. See [importing](./importing.md#existing-product-update-options) for the refresh-variants choices and what they do.
+`variant-manager:import` carries a warning at the permission edit screen ("Imports can potentially overwrite existing variants"). The default existing-product import deletes any variant whose SKU is not in the CSV, so grant import access only to people who understand that behavior. See [importing](./importing.md#existing-product-update-options) for the refresh-variants choices and what they do.
diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md
index 7a9acea..02a741f 100644
--- a/docs/user-guide/troubleshooting.md
+++ b/docs/user-guide/troubleshooting.md
@@ -1,6 +1,6 @@
# Troubleshooting
-Common problems when importing, by what you see. Audience: anyone uploading CSVs.
+Common problems when importing, by what you see.
If nothing here matches, the dashboard's activity log (**Variant Manager -> Dashboard**) records the failure message from every failed import. Filter to **Errored activities** and read the message.
@@ -53,6 +53,18 @@ Row 2 of the CSV is empty (or the first column on row 2 is empty). The plugin re
Fix: put the product title in the first cell of row 2.
+## "The CSV has no “sku” column"
+
+Every variant row is matched by SKU, so the column is required on both new and existing product imports.
+
+Fix: add a `sku` column, or rename the column you are using to `sku`. See [CSV format](./csv-format.md#variant-columns).
+
+## "No variant fields are mapped"
+
+`variantFieldMap` in `config/variant-manager.php` has an empty entry for this product type, or an empty `'*'` entry. Import and export both need at least one column.
+
+Fix: remove the entry to fall back on the defaults, or list the columns you want. See [`variantFieldMap`](../reference/configuration.md#variantfieldmap).
+
## "Invalid product type handle" or the product type dropdown was wrong
For a new product the upload modal asks which product type to create the product under. If the chosen handle does not exist in Commerce the import fails.
@@ -61,7 +73,7 @@ Fix: re-upload and pick the correct **Product Type** in the modal.
## Variants were updated, but extras I expected to keep got deleted
-The default import behaviour for an existing product is **Update and remove extra variants**, meaning any variant in Commerce that is not listed in the CSV by SKU is deleted. This suits full catalog updates, not partial edits.
+The default import behavior for an existing product is **Update and remove extra variants**, meaning any variant in Commerce that is not listed in the CSV by SKU is deleted. This suits full catalog updates, not partial edits.
Fix: if you want to add or update only some variants, your CSV must list every variant you want to keep. Export the product first, edit the export, and reupload. The exported file contains every variant.
@@ -72,8 +84,8 @@ If you actually wanted to wipe everything and start fresh, see "Replace all vari
Three causes, in order of likelihood:
1. The variant's `inventoryTracked[siteHandle]` is not `1`. Untracked variants ignore inventory columns.
-2. The column header pattern is wrong. It must be `Inventory[locationHandle]: totalName` where `locationHandle` matches the handle in **Commerce -> Settings -> Inventory Locations** and `totalName` is one of `available`, `committed`, `reserved`, `damaged`, `safety`, `qualityControl`. The space after the colon matters.
-3. The variant has no inventory levels for the given location (for example a fresh import where the variant was just created with `inventoryTracked` off, then turned on later). Save the product once in the CP to materialise the inventory levels, then reimport.
+2. The column header pattern is wrong. It must be `Inventory[locationHandle]: totalName`. `locationHandle` is the handle from **Commerce -> Settings -> Inventory Locations**. `totalName` is one of `available`, `committed`, `reserved`, `damaged`, `safety`, `qualityControl`. The space after the colon matters.
+3. The variant has no inventory levels for the given location (for example a fresh import where the variant was just created with `inventoryTracked` off, then turned on later). Save the product once in the CP to materialize the inventory levels, then reimport.
## Variant attributes are missing or wrong on the imported variants
@@ -83,6 +95,12 @@ Likely causes:
2. The product type's variant field layout does not include the Variant Attributes field. Add it under **Commerce -> Settings -> Product Types -> {type} -> Variant Fields**.
3. Two Variant Attributes fields exist on the same variant field layout. Only the first one is used; remove the duplicates.
+## The Variant Attributes list is empty
+
+Attributes and options are created by CSV imports. A store with existing variant data has none until you backfill.
+
+Fix: run **Utilities -> Variant Attributes -> Start backfill**, or `./craft variant-manager/attributes/backfill`. See [variant attributes](./variant-attributes.md).
+
## "$value items must be associative arrays or strings" from a Twig template
The template is calling `.variantAttributes(...)` with a value that is not a string or an associative array. See [querying variants](../dev-guide/twig-queries.md) for the supported filter shapes.
@@ -94,9 +112,14 @@ The user does not have the matching permission.
- Upload requires `variant-manager:import`.
- Export requires `variant-manager:export`.
- Clearing the activity log requires `variant-manager:manage`.
+- Viewing or editing variant attributes requires `variant-manager:manage-attributes`.
Set permissions at **Users -> {group} -> Permissions** or on an individual user.
+## "That field cannot be bulk edited."
+
+The field handle is not listed in `bulkEditableVariantFields`. See [configuration reference](../reference/configuration.md#bulkeditablevariantfields).
+
## Cleaning up failed import jobs
Failed imports usually fail because the CSV was wrong (bad filename, duplicate SKU, smart quotes, wrong attribute prefix). Retrying the job will fail again with the same error.
diff --git a/docs/user-guide/variant-attributes.md b/docs/user-guide/variant-attributes.md
new file mode 100644
index 0000000..e4f0c8b
--- /dev/null
+++ b/docs/user-guide/variant-attributes.md
@@ -0,0 +1,81 @@
+# Variant attributes
+
+Every attribute name and value your variants use gets a row you can attach fields to. `Color` becomes an attribute; `Red` and `Blue` become its options.
+
+Variants keep storing names and values as plain text, so imports and exports are unchanged. The registry sits alongside them and holds anything extra you want to show a shopper.
+
+## Where they come from
+
+| Source | When |
+|--------|------|
+| CSV import | Every import registers the names and values it contains. |
+| **Utilities -> Variant Attributes** | Reads every variant in one pass. Run it once after installing. |
+| `./craft variant-manager/attributes/backfill` | The same work from the command line. |
+
+Each new attribute and option is recorded in the [activity log](./activity-log.md).
+
+## Names
+
+Each attribute and option has two names.
+
+| Name | Editable | Shown to |
+|------|----------|----------|
+| **CSV Name** / **CSV Value** | No | The import. It is the exact text in your CSV. |
+| Title | Yes | Shoppers, if your templates use it. |
+
+Rename an option's title to change what shoppers read. Every product using that value picks it up, with no import and no change to any variant.
+
+## Where to find them
+
+**Variant Manager -> Variant Attributes** lists every attribute. **Variant Manager -> Attribute Options** lists every value, with a sidebar entry per attribute for narrowing the list. An option shows how many variants use it.
+
+In the Variant Attributes field on a variant, each name and value is a chip. Click one to open its row in a slideout. A name or value with no row yet shows as plain text until an import or the backfill registers it.
+
+Each attribute is its own filter. **Add a filter** offers **Variant Attribute: Size**, **Variant Attribute: Color** and so on, each listing that attribute's values. Add more than one to combine them.
+
+The filters appear on variant listings, including the Variants index and a product's **Variants** tab. On product listings, a product matches when one of its variants does.
+
+## Showing values on a variant card
+
+A variant's values can also be shown on its card, and as a column on element indexes.
+
+Go to **Commerce -> Settings -> Product Types -> {product type} -> Variant Fields** and add the field under **Card Attributes**. The card then shows the values, comma separated:
+
+```text
+Youth XS, Blue
+```
+
+Values come from the variant's own stored data, so the card costs no extra queries.
+
+## Display type and fields
+
+**Display Type** is set on the attribute itself, at **Variant Manager -> Variant Attributes**. It tells your storefront how to render the options: dropdown, radio buttons, text buttons, image swatches, color swatches or lightswitch. It does not change anything in the control panel. Dropdown is the default.
+
+**Available Display Types**, at **Settings** -> **Plugins** -> **Variant Manager**, narrows that menu to the types your templates render. It applies to every attribute. A developer can also set it in [`availableDisplayTypes`](../reference/configuration.md#availabledisplaytypes), which disables the control panel field.
+
+**Default Display Type**, on the same screen, is what a new attribute is given when an import first registers it. Attributes that already exist keep the type they have.
+
+The field layouts are set separately, at **Settings -> Plugins -> Variant Manager**, then pick an attribute. There are two:
+
+- **Attribute Fields** apply to the attribute itself, for something covering all of its options.
+- **Option Fields** apply to each option, for a swatch image, a spec sheet, or a note.
+
+Add whatever the storefront needs.
+
+These settings are stored in project config, so they are made in your development environment and deployed. The screen is read-only where `allowAdminChanges` is off.
+
+## Removing rows
+
+Attributes and options are created from what your variants store, so the control panel does not offer a delete. Removing one is the job of the prune, which only ever removes rows nothing uses.
+
+An attribute or option whose name or value is no longer on any variant is an orphan. Nothing removes them automatically.
+
+`./craft variant-manager/attributes/orphans` lists them. **Prune orphans**, in the **Utilities -> Variant Attributes** utility, deletes them, as does the same command with `--prune`.
+
+Deleting is permanent. See [console commands](../reference/console-commands.md).
+
+## Related
+
+- [Console commands](../reference/console-commands.md)
+- [Template tags](../dev-guide/template-tags.md), reading attributes and options in Twig
+- [Permissions](../reference/permissions.md)
diff --git a/phpstan.neon b/phpstan.neon
index 234e483..0c57b80 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -4,4 +4,6 @@ includes:
parameters:
paths:
- src
+ scanFiles:
+ - stubs/CustomFieldBehavior.php
level: 5
diff --git a/resources/img/header.png b/resources/img/header.png
index a982710..4670b7c 100644
Binary files a/resources/img/header.png and b/resources/img/header.png differ
diff --git a/src/Plugin.php b/src/Plugin.php
index 2baaacd..84d022d 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -3,33 +3,46 @@
namespace fostercommerce\variantmanager;
use Craft;
+use craft\base\conditions\BaseCondition;
use craft\base\Element;
use craft\base\Model;
use craft\base\Plugin as BasePlugin;
use craft\commerce\elements\Product;
+use craft\commerce\elements\Variant;
+use craft\elements\conditions\ElementCondition;
+use craft\events\DefineFieldLayoutFieldsEvent;
use craft\events\DefineHtmlEvent;
use craft\events\RegisterComponentTypesEvent;
+use craft\events\RegisterConditionRulesEvent;
use craft\events\RegisterElementActionsEvent;
use craft\events\RegisterUrlRulesEvent;
use craft\events\RegisterUserPermissionsEvent;
+use craft\fieldlayoutelements\TitleField;
+use craft\helpers\UrlHelper;
+use craft\models\FieldLayout;
+use craft\services\Elements;
use craft\services\Fields;
use craft\services\Gc;
use craft\services\UserPermissions;
+use craft\services\Utilities;
use craft\web\twig\variables\CraftVariable;
use craft\web\UrlManager;
+use fostercommerce\variantmanager\db\Table;
use fostercommerce\variantmanager\elements\actions\BulkEditField;
use fostercommerce\variantmanager\elements\actions\Export;
+use fostercommerce\variantmanager\elements\conditions\VariantAttributeConditionRule;
+use fostercommerce\variantmanager\elements\VariantAttribute;
+use fostercommerce\variantmanager\elements\VariantAttributeOption;
use fostercommerce\variantmanager\elements\VariantManagerVariant;
use fostercommerce\variantmanager\fields\VariantAttributesField;
use fostercommerce\variantmanager\models\Settings;
use fostercommerce\variantmanager\services\ActivityLogs;
+use fostercommerce\variantmanager\services\AttributeConfigs;
use fostercommerce\variantmanager\services\Csv;
use fostercommerce\variantmanager\services\ProductVariants;
-use Twig\Error\LoaderError;
-use Twig\Error\RuntimeError;
-use Twig\Error\SyntaxError;
+use fostercommerce\variantmanager\services\VariantAttributes;
+use fostercommerce\variantmanager\utilities\AttributesUtility;
use yii\base\Event;
-use yii\base\Exception;
use yii\di\Instance;
use yii\queue\Queue;
@@ -44,13 +57,17 @@
* @property-read ProductVariants $productVariants
* @property-read Csv $csv
* @property-read ActivityLogs $activityLogs
+ * @property-read VariantAttributes $variantAttributes
+ * @property-read AttributeConfigs $attributeConfigs
* @property-read null|array $cpNavItem
*/
class Plugin extends BasePlugin
{
- public string $schemaVersion = '1.0.0';
+ public string $schemaVersion = '1.4.0';
- public bool $hasCpSettings = false;
+ public bool $hasCpSettings = true;
+
+ public bool $hasReadOnlyCpSettings = true;
public bool $hasCpSection = true;
@@ -68,6 +85,7 @@ public function init(): void
Craft::$app->onInit(function (): void {
$this->registerComponents();
+ $this->getAttributeConfigs()->registerOverriddenFieldHandles();
$this->registerQueue();
$this->attachEventHandlers();
});
@@ -88,26 +106,46 @@ public function getCpNavItem(): ?array
'url' => 'variant-manager/variants',
];
+ if (Craft::$app->getUser()->checkPermission('variant-manager:manage-attributes')) {
+ $nav['subnav']['attributes'] = [
+ 'label' => Craft::t('variant-manager', 'attributes.attributes'),
+ 'url' => 'variant-manager/attributes',
+ ];
+
+ $nav['subnav']['attribute-options'] = [
+ 'label' => Craft::t('variant-manager', 'options.options'),
+ 'url' => 'variant-manager/attribute-options',
+ ];
+ }
+
return $nav;
}
- protected function createSettingsModel(): ?Model
+ public function getVariantAttributes(): VariantAttributes
{
- return new Settings();
+ /** @var VariantAttributes */
+ return $this->get('variantAttributes');
}
- /**
- * @throws SyntaxError
- * @throws RuntimeError
- * @throws Exception
- * @throws LoaderError
- */
- protected function settingsHtml(): ?string
+ public function getAttributeConfigs(): AttributeConfigs
{
- return Craft::$app->getView()->renderTemplate('variantmanager/_settings', [
- 'plugin' => $this,
- 'settings' => $this->getSettings(),
- ]);
+ /** @var AttributeConfigs */
+ return $this->get('attributeConfigs');
+ }
+
+ public function getSettingsResponse(): mixed
+ {
+ return Craft::$app->getResponse()->redirect(UrlHelper::cpUrl('variant-manager/settings'));
+ }
+
+ public function getReadOnlySettingsResponse(): mixed
+ {
+ return $this->getSettingsResponse();
+ }
+
+ protected function createSettingsModel(): ?Model
+ {
+ return new Settings();
}
private function attachEventHandlers(): void
@@ -124,6 +162,10 @@ private function attachEventHandlers(): void
$this->registerViewHooks();
}
+ $this->registerConditionRules();
+ $this->registerElements();
+ $this->registerNativeFields();
+ $this->registerUtilities();
$this->registerEvents();
}
@@ -176,10 +218,19 @@ static function (RegisterUrlRulesEvent $registerUrlRulesEvent): void {
'variant-manager/dashboard' => 'variant-manager/dashboard',
'variant-manager/product-exists' => 'variant-manager/product-variants/product-exists',
'variant-manager/export' => 'variant-manager/product-variants/export',
- 'variant-manager/save-variant-attributes/' => 'variant-manager/product-variants/save-variant-attributes',
'variant-manager/variants' => [
'template' => 'variant-manager/variants/index.twig',
],
+ 'variant-manager/settings' => 'variant-manager/settings/index',
+ 'variant-manager/attributes' => [
+ 'template' => 'variant-manager/attributes/index.twig',
+ ],
+ 'variant-manager/attributes/' => 'elements/edit',
+ 'variant-manager/attribute-options' => [
+ 'template' => 'variant-manager/attribute-options/index.twig',
+ ],
+ 'variant-manager/attribute-options/' => 'elements/edit',
+ 'variant-manager/attributes//settings' => 'variant-manager/attributes/settings',
];
}
);
@@ -212,9 +263,75 @@ private function registerComponents(): void
'productVariants' => ProductVariants::class,
'csv' => Csv::class,
'activityLogs' => ActivityLogs::class,
+ 'variantAttributes' => VariantAttributes::class,
+ 'attributeConfigs' => AttributeConfigs::class,
]);
}
+ private function registerConditionRules(): void
+ {
+ Event::on(
+ ElementCondition::class,
+ BaseCondition::EVENT_REGISTER_CONDITION_RULES,
+ static function (RegisterConditionRulesEvent $registerConditionRulesEvent): void {
+ /** @var ElementCondition $condition */
+ $condition = $registerConditionRulesEvent->sender;
+ $elementType = $condition->elementType;
+
+ if ($elementType === null || (! is_a($elementType, Variant::class, true) && ! is_a($elementType, Product::class, true))) {
+ return;
+ }
+
+ foreach (Plugin::getInstance()->getVariantAttributes()->getAllAttributes() as $attribute) {
+ $registerConditionRulesEvent->conditionRules[] = [
+ 'class' => VariantAttributeConditionRule::class,
+ 'attributeId' => $attribute->id,
+ ];
+ }
+ }
+ );
+ }
+
+ private function registerElements(): void
+ {
+ Event::on(
+ Elements::class,
+ Elements::EVENT_REGISTER_ELEMENT_TYPES,
+ static function (RegisterComponentTypesEvent $registerComponentTypesEvent): void {
+ $registerComponentTypesEvent->types[] = VariantAttribute::class;
+ $registerComponentTypesEvent->types[] = VariantAttributeOption::class;
+ }
+ );
+ }
+
+ private function registerNativeFields(): void
+ {
+ Event::on(
+ FieldLayout::class,
+ FieldLayout::EVENT_DEFINE_NATIVE_FIELDS,
+ static function (DefineFieldLayoutFieldsEvent $defineFieldLayoutFieldsEvent): void {
+ /** @var FieldLayout $fieldLayout */
+ $fieldLayout = $defineFieldLayoutFieldsEvent->sender;
+
+ // Add a Title field, since Craft doesn't supply one for these element types
+ if (in_array($fieldLayout->type, [VariantAttribute::class, VariantAttributeOption::class], true)) {
+ $defineFieldLayoutFieldsEvent->fields[] = TitleField::class;
+ }
+ }
+ );
+ }
+
+ private function registerUtilities(): void
+ {
+ Event::on(
+ Utilities::class,
+ Utilities::EVENT_REGISTER_UTILITIES,
+ static function (RegisterComponentTypesEvent $registerComponentTypesEvent): void {
+ $registerComponentTypesEvent->types[] = AttributesUtility::class;
+ }
+ );
+ }
+
private function registerViewHooks(): void
{
Event::on(
@@ -245,8 +362,14 @@ private function registerEvents(): void
Event::on(
Gc::class,
Gc::EVENT_RUN,
- function (Event $_event): void {
- $this->activityLogs->gc();
+ static function (Event $_event): void {
+ Plugin::getInstance()->activityLogs->gc();
+
+ $garbageCollector = Craft::$app->getGc();
+ $garbageCollector->deletePartialElements(VariantAttribute::class, Table::ATTRIBUTES, 'id');
+ $garbageCollector->deletePartialElements(VariantAttributeOption::class, Table::ATTRIBUTE_OPTIONS, 'id');
+
+ Plugin::getInstance()->getAttributeConfigs()->removeOrphaned();
},
);
}
@@ -267,6 +390,9 @@ private function registerPermissions(): void
'variant-manager:manage' => [
'label' => Craft::t('variant-manager', 'Manage'),
],
+ 'variant-manager:manage-attributes' => [
+ 'label' => Craft::t('variant-manager', 'permissions.manageAttributes'),
+ ],
],
];
});
diff --git a/src/VariantAttributesFieldAssetBundle.php b/src/VariantAttributesFieldAssetBundle.php
new file mode 100644
index 0000000..22b5766
--- /dev/null
+++ b/src/VariantAttributesFieldAssetBundle.php
@@ -0,0 +1,24 @@
+sourcePath = '@fostercommerce/variantmanager/assets';
+
+ $this->depends = [
+ CpAsset::class,
+ ];
+
+ $this->css = [
+ 'css/variant-attributes-field.css',
+ ];
+
+ parent::init();
+ }
+}
diff --git a/src/assets/css/variant-attributes-field.css b/src/assets/css/variant-attributes-field.css
new file mode 100644
index 0000000..5bc16a0
--- /dev/null
+++ b/src/assets/css/variant-attributes-field.css
@@ -0,0 +1,12 @@
+.variant-attributes-warning {
+ background-color: #b44d12;
+ border-radius: 0.5rem;
+ color: #fff;
+ display: inline-block;
+ padding: 0.725rem;
+}
+
+.variant-attributes-container .table.data {
+ margin-top: 1rem;
+ width: 100%;
+}
diff --git a/src/assets/js/product-export.js b/src/assets/js/product-export.js
index 23b0542..f745093 100644
--- a/src/assets/js/product-export.js
+++ b/src/assets/js/product-export.js
@@ -14,16 +14,23 @@
fetch(exportUrl + '?' + params.toString(), {
headers: {
+ // Craft only renders an error as JSON when the request accepts it
+ 'Accept': 'application/json, text/csv',
'X-CSRF-Token': Craft.csrfTokenValue,
'X-Requested-With': 'XMLHttpRequest',
},
}).then(function (response) {
if (! response.ok) {
- throw new Error(
- Craft.t('variant-manager', 'Export request failed with status {status}', {
- status: response.status,
- })
- );
+ return response.json().catch(function () {
+ return {};
+ }).then(function (body) {
+ throw new Error(
+ body.message ||
+ Craft.t('variant-manager', 'Export request failed with status {status}', {
+ status: response.status,
+ })
+ );
+ });
}
return response.blob().then(function (blob) {
diff --git a/src/config.php b/src/config.php
index e15a006..484f809 100644
--- a/src/config.php
+++ b/src/config.php
@@ -21,6 +21,8 @@
'activityLogRetention' => '1 week',
'defaultVariantTableAttributes' => [],
'bulkEditableVariantFields' => [],
+ 'availableDisplayTypes' => [],
+ 'defaultDisplayType' => 'dropdown',
'productFieldMap' => [
'*' => [
'title' => 'title',
@@ -34,7 +36,7 @@
'title' => 'title',
'sku' => 'sku',
'inventoryTracked' => 'inventoryTracked',
- 'price' => 'basePrice',
+ 'basePrice' => 'basePrice',
'height' => 'height',
'width' => 'width',
'length' => 'length',
diff --git a/src/console/controllers/AttributesController.php b/src/console/controllers/AttributesController.php
new file mode 100644
index 0000000..8ed0efa
--- /dev/null
+++ b/src/console/controllers/AttributesController.php
@@ -0,0 +1,89 @@
+getVariantAttributes();
+ $variantCount = 0;
+
+ foreach (Variant::find()->status(null)->batch($this->batchSize) as $variants) {
+ $variantCount += count($variants);
+ $variantAttributes->ensureFromAttributePairs(array_values($variantAttributes->attributePairs($variants)));
+
+ $this->stdout('.');
+ }
+
+ $this->stdout(PHP_EOL);
+ $this->stdout("Read {$variantCount} variants. New attributes and options are listed in the activity log." . PHP_EOL, Console::FG_GREEN);
+
+ return ExitCode::OK;
+ }
+
+ /**
+ * Lists every attribute and option whose name or value is no longer stored on a variant.
+ */
+ public function actionOrphans(): int
+ {
+ $variantAttributes = Plugin::getInstance()->getVariantAttributes();
+ $orphans = $variantAttributes->findOrphans($this->batchSize);
+
+ foreach ($orphans['options'] as $option) {
+ $this->stdout("option {$option->getVariantAttribute()?->name} / {$option->value}" . PHP_EOL);
+ }
+
+ foreach ($orphans['attributes'] as $attribute) {
+ $this->stdout("attribute {$attribute->name}" . PHP_EOL);
+ }
+
+ $optionCount = count($orphans['options']);
+ $attributeCount = count($orphans['attributes']);
+
+ if ($optionCount === 0 && $attributeCount === 0) {
+ $this->stdout('No orphans.' . PHP_EOL, Console::FG_GREEN);
+ return ExitCode::OK;
+ }
+
+ if (! $this->prune) {
+ $this->stdout("{$optionCount} options and {$attributeCount} attributes are orphaned. Re-run with --prune to delete them." . PHP_EOL, Console::FG_YELLOW);
+ return ExitCode::OK;
+ }
+
+ $deleted = $variantAttributes->pruneOrphans($this->batchSize, $orphans);
+ $this->stdout("Deleted {$deleted['options']} options and {$deleted['attributes']} attributes." . PHP_EOL, Console::FG_GREEN);
+
+ return ExitCode::OK;
+ }
+}
diff --git a/src/controllers/AttributesController.php b/src/controllers/AttributesController.php
new file mode 100644
index 0000000..d8db8ca
--- /dev/null
+++ b/src/controllers/AttributesController.php
@@ -0,0 +1,100 @@
+requireAdmin(false);
+
+ $attribute = VariantAttribute::find()->id($attributeId)->one();
+
+ if (! $attribute instanceof VariantAttribute) {
+ throw new NotFoundHttpException(Craft::t('variant-manager', 'attributes.notFound'));
+ }
+
+ $attributeConfigs = Plugin::getInstance()->getAttributeConfigs();
+
+ return $this->renderTemplate('variant-manager/attributes/_settings', [
+ 'attribute' => $attribute,
+ 'attributeFieldLayout' => $attributeConfigs->getFieldLayout($attribute->nameKey),
+ 'optionFieldLayout' => $attributeConfigs->getOptionFieldLayout($attribute->nameKey),
+ 'readOnly' => ! Craft::$app->getConfig()->getGeneral()->allowAdminChanges,
+ ]);
+ }
+
+ /**
+ * @throws NotFoundHttpException
+ */
+ public function actionSaveSettings(): ?Response
+ {
+ $this->requirePostRequest();
+ $this->requireAdmin();
+
+ $attributeId = (int) $this->request->getRequiredBodyParam('attributeId');
+ $attribute = VariantAttribute::find()->id($attributeId)->one();
+
+ if (! $attribute instanceof VariantAttribute) {
+ throw new NotFoundHttpException(Craft::t('variant-manager', 'attributes.notFound'));
+ }
+
+ $fieldsService = Craft::$app->getFields();
+
+ $fieldLayout = $fieldsService->assembleLayoutFromPost();
+ $fieldLayout->type = VariantAttribute::class;
+
+ $optionFieldLayout = $fieldsService->assembleLayoutFromPost('option-layout');
+ $optionFieldLayout->type = VariantAttributeOption::class;
+
+ if (! Plugin::getInstance()->getAttributeConfigs()->save($attribute->nameKey, $fieldLayout, $optionFieldLayout)) {
+ $this->setFailFlash(Craft::t('variant-manager', 'attributes.settingsSaveFailed'));
+ return null;
+ }
+
+ $this->setSuccessFlash(Craft::t('variant-manager', 'attributes.settingsSaved'));
+
+ return $this->redirectToPostedUrl();
+ }
+
+ public function actionBackfill(): Response
+ {
+ $this->requirePostRequest();
+ $this->requirePermission('variant-manager:manage-attributes');
+
+ Queue::push(new BackfillAttributes(), queue: Plugin::getInstance()->queue);
+
+ $this->setSuccessFlash(Craft::t('variant-manager', 'attributes.backfillQueued'));
+
+ return $this->redirectToPostedUrl();
+ }
+
+ public function actionPruneOrphans(): Response
+ {
+ $this->requirePostRequest();
+ $this->requirePermission('variant-manager:manage-attributes');
+
+ // Reading every variant takes minutes on a large catalog, well past the queue's default TTR
+ Queue::push(new PruneAttributeOrphans(), ttr: 3600, queue: Plugin::getInstance()->queue);
+
+ $this->setSuccessFlash(Craft::t('variant-manager', 'attributes.pruneQueued'));
+
+ return $this->redirectToPostedUrl();
+ }
+}
diff --git a/src/controllers/ProductVariantsController.php b/src/controllers/ProductVariantsController.php
index 7136df1..dd26e67 100644
--- a/src/controllers/ProductVariantsController.php
+++ b/src/controllers/ProductVariantsController.php
@@ -2,19 +2,16 @@
namespace fostercommerce\variantmanager\controllers;
-use Craft;
use craft\commerce\elements\Product;
-use craft\commerce\elements\Variant;
use craft\commerce\Plugin as CommercePlugin;
use craft\helpers\Db;
use craft\helpers\FileHelper;
use craft\helpers\Queue;
use craft\web\Controller;
use craft\web\UploadedFile;
-use fostercommerce\variantmanager\helpers\FieldHelper;
+use fostercommerce\variantmanager\errors\FieldMapException;
use fostercommerce\variantmanager\jobs\Import as ImportJob;
use fostercommerce\variantmanager\Plugin;
-use yii\base\InvalidConfigException;
use yii\web\BadRequestHttpException;
use yii\web\ForbiddenHttpException;
use yii\web\NotFoundHttpException;
@@ -27,7 +24,6 @@ class ProductVariantsController extends Controller
'product-exists' => self::ALLOW_ANONYMOUS_NEVER,
'upload' => self::ALLOW_ANONYMOUS_NEVER,
'export' => self::ALLOW_ANONYMOUS_NEVER,
- 'save-variant-attributes' => self::ALLOW_ANONYMOUS_NEVER,
];
/**
@@ -98,9 +94,7 @@ public function actionUpload(): void
$baseName = $pathinfo['filename'];
$extension = $pathinfo['extension'] ?? null;
if (! str_starts_with($baseName, '.') && $extension === 'csv') {
- // Only extract csv files from the zip.
- // Don't extract any hidden files. This helps catch OSX specific files such as DS_Store, etc. It
- // also prevents extracting files from the __MACOSX dir.
+ // Skip dotfiles and __MACOSX entries so only real CSVs are extracted
$filenames[] = $filename;
}
}
@@ -136,7 +130,7 @@ public function actionUpload(): void
/**
* @throws \JsonException
* @throws NotFoundHttpException
- * @throws InvalidConfigException
+ * @throws ServerErrorHttpException
* @throws BadRequestHttpException
*/
public function actionExport(): void
@@ -154,7 +148,12 @@ public function actionExport(): void
$csvService = Plugin::getInstance()->csv;
$results = [];
foreach (explode('|', (string) $ids) as $id) {
- $result = $csvService->export($id);
+ try {
+ $result = $csvService->export($id);
+ } catch (FieldMapException $fieldMapException) {
+ // Craft renders the message only for a UserException, and this one names the setting to fix
+ throw new ServerErrorHttpException($fieldMapException->getMessage(), 0, $fieldMapException);
+ }
if ($result === false) {
throw new NotFoundHttpException("Product with ID {$id} not found");
@@ -165,7 +164,6 @@ public function actionExport(): void
if ($download) {
if (count($results) === 1) {
- // If there is just a single product, then download that file
$result = $results[0];
$filename = "{$result['filename']}.csv";
$result = $result['export'];
@@ -177,7 +175,6 @@ public function actionExport(): void
'mimeType' => 'text/csv',
]);
} else {
- // If there are multiple products then download a zip file of the content
$zipPath = tempnam(sys_get_temp_dir(), 'export_');
$zipArchive = new \ZipArchive();
if ($zipArchive->open($zipPath, \ZipArchive::CREATE) !== true) {
@@ -205,50 +202,4 @@ public function actionExport(): void
$this->response->data = array_map(static fn ($r) => $r['export'], $results);
}
}
-
- public function actionSaveVariantAttributes(int $variantId): Response
- {
- $this->requirePostRequest();
-
- $this->requirePermission('variant-manager:import');
-
- $variant = Variant::find()->id($variantId)->one();
- if (! $variant) {
- return $this->asFailure('Variant not found');
- }
-
- $variantAttributesField = FieldHelper::getFirstVariantAttributesField($variant->fieldLayout);
- if (! $variantAttributesField) {
- return $this->asFailure('Variant attributes field not found');
- }
-
- $handle = $variantAttributesField->handle;
-
- $variantAttributes = collect($variant->{$handle})
- ->flatMap(static fn ($attribute) => [
- $attribute['attributeName'] => $attribute['attributeValue'],
- ]);
- $allowedKeys = $variantAttributes->keys()->all();
-
- // We need to make sure that the attributes we are updating are only the ones that already existed.
- $updatedAttributes = collect($this->request->getRequiredBodyParam('attributes'))
- ->flatMap(static fn ($attribute) => [
- $attribute['attributeName'] => $attribute['attributeValue'],
- ])
- ->only($allowedKeys);
-
- $variantAttributes = $variantAttributes
- ->merge($updatedAttributes)
- ->map(static fn ($value, $key) => [
- 'attributeName' => $key,
- 'attributeValue' => $value,
- ])
- ->values();
-
- $variant->{$handle} = $variantAttributes->toArray();
-
- Craft::$app->elements->saveElement($variant, runValidation: false, updateSearchIndex: true);
-
- return $this->asSuccess('Variant attributes updated');
- }
}
diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php
new file mode 100644
index 0000000..99ad9b4
--- /dev/null
+++ b/src/controllers/SettingsController.php
@@ -0,0 +1,53 @@
+requireAdmin(false);
+
+ $settings = Plugin::getInstance()->getSettings();
+
+ return $this->renderTemplate('variant-manager/settings/index', [
+ 'attributes' => VariantAttribute::find()->all(),
+ 'settings' => $settings,
+ 'displayTypeOptions' => DisplayType::options(DisplayType::cases()),
+ 'defaultDisplayTypeOptions' => DisplayType::options($settings->getAvailableDisplayTypes($settings->defaultDisplayType)),
+ 'readOnly' => ! Craft::$app->getConfig()->getGeneral()->allowAdminChanges,
+ ]);
+ }
+
+ public function actionSave(): ?Response
+ {
+ $this->requirePostRequest();
+ $this->requireAdmin();
+
+ $settings = $this->request->getBodyParam('settings', []);
+ $plugin = Plugin::getInstance();
+
+ if (! Craft::$app->getPlugins()->savePluginSettings($plugin, $settings)) {
+ $this->setFailFlash(Craft::t('variant-manager', 'settings.saveFailed'));
+
+ Craft::$app->getUrlManager()->setRouteParams([
+ 'settings' => $plugin->getSettings(),
+ ]);
+
+ return null;
+ }
+
+ $this->setSuccessFlash(Craft::t('variant-manager', 'settings.saved'));
+
+ return $this->redirectToPostedUrl();
+ }
+}
diff --git a/src/db/Table.php b/src/db/Table.php
new file mode 100644
index 0000000..1632de8
--- /dev/null
+++ b/src/db/Table.php
@@ -0,0 +1,12 @@
+value;
+
+ public static function displayName(): string
+ {
+ return Craft::t('variant-manager', 'attributes.attribute');
+ }
+
+ public static function lowerDisplayName(): string
+ {
+ return Craft::t('variant-manager', 'attributes.attributeLower');
+ }
+
+ public static function pluralDisplayName(): string
+ {
+ return Craft::t('variant-manager', 'attributes.attributes');
+ }
+
+ public static function pluralLowerDisplayName(): string
+ {
+ return Craft::t('variant-manager', 'attributes.attributesLower');
+ }
+
+ public static function refHandle(): ?string
+ {
+ return 'variantattribute';
+ }
+
+ public static function hasTitles(): bool
+ {
+ return true;
+ }
+
+ public static function isLocalized(): bool
+ {
+ // Neither table has a siteId column, so per-site rows would be identical
+ return false;
+ }
+
+ public static function find(): VariantAttributeQuery
+ {
+ return new VariantAttributeQuery(static::class);
+ }
+
+ public static function normalizeName(string $name): string
+ {
+ return StringHelper::toLowerCase(trim($name));
+ }
+
+ public function getFieldLayout(): ?FieldLayout
+ {
+ return Plugin::getInstance()->getAttributeConfigs()->getFieldLayout($this->nameKey);
+ }
+
+ public function getDisplayType(): DisplayType
+ {
+ return DisplayType::tryFrom($this->displayType) ?? DisplayType::Dropdown;
+ }
+
+ public function getCpEditUrl(): ?string
+ {
+ return UrlHelper::cpUrl("variant-manager/attributes/{$this->id}");
+ }
+
+ public function getPostEditUrl(): ?string
+ {
+ return UrlHelper::cpUrl('variant-manager/attributes');
+ }
+
+ public function canView(User $user): bool
+ {
+ return $user->can('variant-manager:manage-attributes');
+ }
+
+ public function canSave(User $user): bool
+ {
+ return $user->can('variant-manager:manage-attributes');
+ }
+
+ /**
+ * Rows are derived from what variants store, so the prune utility removes the unused ones.
+ */
+ public function canDelete(User $user): bool
+ {
+ return false;
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function afterSave(bool $isNew): void
+ {
+ if (! $this->propagating) {
+ if ($isNew) {
+ $record = new VariantAttributeRecord();
+ $record->id = (int) $this->id;
+ } else {
+ $record = VariantAttributeRecord::findOne($this->id);
+
+ if (! $record instanceof VariantAttributeRecord) {
+ throw new InvalidConfigException("Invalid variant attribute ID: {$this->id}");
+ }
+ }
+
+ $record->name = $this->name;
+ $record->nameKey = $this->nameKey;
+ $record->displayType = $this->displayType;
+ $record->save(false);
+
+ if ($isNew) {
+ Activity::log(Craft::$app->getUser()->getIdentity(), Craft::t('variant-manager', 'attributes.activityCreated', [
+ 'name' => Html::encode($this->name),
+ ]));
+ }
+ }
+
+ parent::afterSave($isNew);
+ }
+
+ public function beforeDelete(): bool
+ {
+ if (! parent::beforeDelete()) {
+ return false;
+ }
+
+ if (Plugin::getInstance()->getVariantAttributes()->isAttributeInUse($this)) {
+ $this->addError('name', Craft::t('variant-manager', 'attributes.deleteInUse'));
+ return false;
+ }
+
+ // Include already-trashed options on a hard delete, since the cascade removes their rows
+ $options = VariantAttributeOption::find()
+ ->attributeId($this->id)
+ ->trashed($this->hardDelete ? null : false)
+ ->all();
+
+ $elementsService = Craft::$app->getElements();
+
+ foreach ($options as $option) {
+ // Flag the option, since afterRestore() only restores options flagged here
+ $option->deletedWithOwner = true;
+ $elementsService->deleteElement($option, $this->hardDelete);
+ }
+
+ return true;
+ }
+
+ public function afterRestore(): void
+ {
+ $options = VariantAttributeOption::find()
+ ->attributeId($this->id)
+ ->trashed(true)
+ ->andWhere([
+ 'elements.deletedWithOwner' => true,
+ ])
+ ->all();
+
+ Craft::$app->getElements()->restoreElements($options);
+
+ parent::afterRestore();
+ }
+
+ public function beforeSave(bool $isNew): bool
+ {
+ $this->name = trim($this->name);
+ $this->nameKey = self::normalizeName($this->name);
+
+ return parent::beforeSave($isNew);
+ }
+
+ protected function metaFieldsHtml(bool $static): string
+ {
+ $fields = Cp::selectFieldHtml([
+ 'label' => Craft::t('variant-manager', 'attributes.displayType'),
+ 'id' => 'displayType',
+ 'name' => 'displayType',
+ 'options' => DisplayType::options(Plugin::getInstance()->getSettings()->getAvailableDisplayTypes($this->displayType)),
+ 'value' => $this->displayType,
+ 'disabled' => $static,
+ ]);
+
+ // Variants match on the attribute name string, so the name is read only
+ $fields .= Cp::textFieldHtml([
+ 'label' => Craft::t('variant-manager', 'attributes.name'),
+ 'id' => 'name',
+ 'value' => $this->name,
+ 'disabled' => true,
+ ]);
+
+ return $fields . parent::metaFieldsHtml($static);
+ }
+
+ protected static function defineSources(string $context): array
+ {
+ return [
+ [
+ 'key' => '*',
+ 'label' => Craft::t('variant-manager', 'attributes.allAttributes'),
+ 'criteria' => [],
+ ],
+ ];
+ }
+
+ protected static function defineFieldLayouts(?string $source): array
+ {
+ return Plugin::getInstance()->getAttributeConfigs()->getAllAttributeLayouts();
+ }
+
+ protected static function defineSortOptions(): array
+ {
+ return [
+ 'name' => Craft::t('variant-manager', 'attributes.name'),
+ 'dateCreated' => Craft::t('app', 'Date Created'),
+ ];
+ }
+
+ protected static function defineTableAttributes(): array
+ {
+ return [
+ 'name' => Craft::t('variant-manager', 'attributes.name'),
+ 'displayType' => Craft::t('variant-manager', 'attributes.displayType'),
+ 'dateCreated' => Craft::t('app', 'Date Created'),
+ ];
+ }
+
+ protected static function defineDefaultTableAttributes(string $source): array
+ {
+ return ['name', 'displayType'];
+ }
+
+ protected function attributeHtml(string $attribute): string
+ {
+ return match ($attribute) {
+ 'name' => Html::encode($this->name),
+ 'displayType' => Html::encode($this->getDisplayType()->label()),
+ default => parent::attributeHtml($attribute),
+ };
+ }
+
+ protected function defineRules(): array
+ {
+ $rules = parent::defineRules();
+ $rules[] = [['name'], 'required'];
+ $rules[] = [['displayType'],
+ 'in',
+ 'range' => array_column(DisplayType::cases(), 'value')];
+ $rules[] = [['name', 'nameKey'],
+ 'string',
+ 'max' => 255];
+ return $rules;
+ }
+}
diff --git a/src/elements/VariantAttributeOption.php b/src/elements/VariantAttributeOption.php
new file mode 100644
index 0000000..b0c2eb4
--- /dev/null
+++ b/src/elements/VariantAttributeOption.php
@@ -0,0 +1,274 @@
+getVariantAttribute();
+
+ return $attribute === null
+ ? null
+ : Plugin::getInstance()->getAttributeConfigs()->getOptionFieldLayout($attribute->nameKey);
+ }
+
+ public function getVariantAttribute(): ?VariantAttribute
+ {
+ if ($this->attributeId === null) {
+ return null;
+ }
+
+ return $this->variantAttribute ??= Plugin::getInstance()->getVariantAttributes()->getAttributeById($this->attributeId);
+ }
+
+ public function getCpEditUrl(): ?string
+ {
+ return UrlHelper::cpUrl("variant-manager/attribute-options/{$this->id}");
+ }
+
+ public function getPostEditUrl(): ?string
+ {
+ return UrlHelper::cpUrl('variant-manager/attribute-options');
+ }
+
+ public function canView(User $user): bool
+ {
+ return $user->can('variant-manager:manage-attributes');
+ }
+
+ public function canSave(User $user): bool
+ {
+ return $user->can('variant-manager:manage-attributes');
+ }
+
+ /**
+ * Rows are derived from what variants store, so the prune utility removes the unused ones.
+ */
+ public function canDelete(User $user): bool
+ {
+ return false;
+ }
+
+ /**
+ * @throws InvalidConfigException
+ */
+ public function afterSave(bool $isNew): void
+ {
+ if (! $this->propagating) {
+ if ($isNew) {
+ $record = new VariantAttributeOptionRecord();
+ $record->id = (int) $this->id;
+ } else {
+ $record = VariantAttributeOptionRecord::findOne($this->id);
+
+ if (! $record instanceof VariantAttributeOptionRecord) {
+ throw new InvalidConfigException("Invalid variant attribute option ID: {$this->id}");
+ }
+ }
+
+ $record->attributeId = $this->attributeId;
+ $record->value = $this->value;
+ $record->valueKey = $this->valueKey;
+ $record->save(false);
+
+ if ($isNew) {
+ Activity::log(Craft::$app->getUser()->getIdentity(), Craft::t('variant-manager', 'options.activityCreated', [
+ 'value' => Html::encode($this->value),
+ 'attribute' => Html::encode((string) $this->getVariantAttribute()?->name),
+ ]));
+ }
+ }
+
+ parent::afterSave($isNew);
+ }
+
+ public function beforeDelete(): bool
+ {
+ if (! parent::beforeDelete()) {
+ return false;
+ }
+
+ if (Plugin::getInstance()->getVariantAttributes()->isOptionInUse($this)) {
+ $this->addError('value', Craft::t('variant-manager', 'options.deleteInUse'));
+ return false;
+ }
+
+ return true;
+ }
+
+ public function beforeSave(bool $isNew): bool
+ {
+ $this->value = trim($this->value);
+ $this->valueKey = self::normalizeValue($this->value);
+
+ return parent::beforeSave($isNew);
+ }
+
+ protected function metaFieldsHtml(bool $static): string
+ {
+ $variantCount = Plugin::getInstance()->getVariantAttributes()->variantCountForOption($this);
+
+ $fields = Cp::fieldHtml(Html::encode(Craft::t('variant-manager', 'options.variantCount', [
+ 'count' => $variantCount,
+ ])), [
+ 'label' => Craft::t('variant-manager', 'options.usedBy'),
+ ]);
+
+ // Variants match on the option value string, so the value is read only
+ $fields .= Cp::textFieldHtml([
+ 'label' => Craft::t('variant-manager', 'options.value'),
+ 'id' => 'value',
+ 'value' => $this->value,
+ 'disabled' => true,
+ ]);
+
+ return $fields . parent::metaFieldsHtml($static);
+ }
+
+ protected static function defineSources(string $context): array
+ {
+ $sources = [
+ [
+ 'key' => '*',
+ 'label' => Craft::t('variant-manager', 'options.allOptions'),
+ 'criteria' => [],
+ ],
+ ];
+
+ foreach (Plugin::getInstance()->getVariantAttributes()->getAllAttributes() as $attribute) {
+ $sources[] = [
+ 'key' => "attribute:{$attribute->uid}",
+ 'label' => $attribute->name,
+ 'criteria' => [
+ 'attributeId' => $attribute->id,
+ ],
+ ];
+ }
+
+ return $sources;
+ }
+
+ protected static function defineFieldLayouts(?string $source): array
+ {
+ return Plugin::getInstance()->getAttributeConfigs()->getAllOptionLayouts();
+ }
+
+ protected static function defineSortOptions(): array
+ {
+ return [
+ 'value' => Craft::t('variant-manager', 'options.value'),
+ 'dateCreated' => Craft::t('app', 'Date Created'),
+ ];
+ }
+
+ protected static function defineTableAttributes(): array
+ {
+ return [
+ 'value' => Craft::t('variant-manager', 'options.value'),
+ 'attribute' => Craft::t('variant-manager', 'options.attribute'),
+ 'dateCreated' => Craft::t('app', 'Date Created'),
+ ];
+ }
+
+ protected static function defineDefaultTableAttributes(string $source): array
+ {
+ return ['value', 'attribute'];
+ }
+
+ protected function attributeHtml(string $attribute): string
+ {
+ return match ($attribute) {
+ 'value' => Html::encode($this->value),
+ 'attribute' => Html::encode((string) $this->getVariantAttribute()?->name),
+ default => parent::attributeHtml($attribute),
+ };
+ }
+
+ protected function defineRules(): array
+ {
+ $rules = parent::defineRules();
+ $rules[] = [['attributeId'],
+ 'number',
+ 'integerOnly' => true];
+ $rules[] = [['value'], 'required'];
+ $rules[] = [['value', 'valueKey'],
+ 'string',
+ 'max' => 255];
+ return $rules;
+ }
+}
diff --git a/src/elements/VariantManagerVariant.php b/src/elements/VariantManagerVariant.php
index d60bb43..0b4fb0d 100755
--- a/src/elements/VariantManagerVariant.php
+++ b/src/elements/VariantManagerVariant.php
@@ -34,7 +34,7 @@ protected static function defineSortOptions(): array
{
return [
...parent::defineSortOptions(),
- // Keep translations from Commerce for now
+ // Sort options are Commerce attributes, so their labels translate in Commerce
'price' => Craft::t('commerce', 'Price'),
'promotionalPrice' => Craft::t('commerce', 'Promotional Price'),
'stock' => Craft::t('commerce', 'Stock'),
diff --git a/src/elements/actions/BulkEditField.php b/src/elements/actions/BulkEditField.php
index 5d02ff5..a412b64 100644
--- a/src/elements/actions/BulkEditField.php
+++ b/src/elements/actions/BulkEditField.php
@@ -14,9 +14,6 @@
class BulkEditField extends ElementAction
{
- /**
- * The handle of the custom field or native attribute to set on every selected variant.
- */
public ?string $fieldHandle = null;
public function getTriggerLabel(): string
@@ -61,7 +58,7 @@ public function getTriggerHtml(): ?string
$type = Json::encode(static::class);
$js = <<
- // popups and input focus; the date picker's calendar also renders outside the menu, so an outside
- // click would close it. Stop those mousedowns from reaching the menu so the inputs stay usable.
+ // Keep mousedowns inside the menu, since Garnish's CustomSelect preventDefaults them
document.addEventListener('mousedown', function(event) {
- // The lightswitch toggles on mousedown, so let its event reach the toggle.
+ // Let the lightswitch see its own mousedown, since it toggles on the mouseup that follows
if (event.target.closest('.lightswitch')) {
return;
}
@@ -99,8 +94,7 @@ public function getTriggerHtml(): ?string
const fieldHandle = document.getElementById('vm-bulk-edit-field').value;
const container = document.querySelector('[data-vm-bulk-edit-value-for="' + fieldHandle + '"]');
- // A lightswitch keeps its value in a hidden input, which the selector below skips, so read its
- // on/off state from the toggle element instead.
+ // Read the on class, since the lightswitch's value sits in a hidden input
const lightswitch = container.querySelector('.lightswitch');
let value;
if (lightswitch) {
@@ -146,9 +140,8 @@ public function performAction(ElementQueryInterface $query): bool
// Read raw, not as an action property: a Date value arrives as an array, which won't fit ?string.
$value = Craft::$app->getRequest()->getBodyParam('value');
- // The index menu submits only the date field's visible input, dropping its hidden timezone, so a
- // bare string parses in the wrong zone. Reattach zone + locale. Other composite fields (Money,
- // Time) likewise submit only their first input and are not reassembled here.
+ // Our trigger JS sends only the visible input, so rebuild the array a Date field expects
+ // Money and Time have the same gap and are not rebuilt here
if ($field instanceof Date) {
$value = [
'date' => $value,
@@ -187,8 +180,7 @@ public function performAction(ElementQueryInterface $query): bool
}
/**
- * Field layouts/elements are stored under the base Variant class, and a layout may override a
- * field's handle, so resolve against the layout's fields.
+ * Search every Variant field layout, since a layout can rename the handle.
*/
private function resolveField(string $handle): ?FieldInterface
{
diff --git a/src/elements/conditions/VariantAttributeConditionRule.php b/src/elements/conditions/VariantAttributeConditionRule.php
new file mode 100644
index 0000000..c811a0b
--- /dev/null
+++ b/src/elements/conditions/VariantAttributeConditionRule.php
@@ -0,0 +1,222 @@
+|null
+ */
+ private ?array $fieldInstances = null;
+
+ public function getLabel(): string
+ {
+ return Craft::t('variant-manager', 'attributes.filterLabel', [
+ 'attribute' => $this->attribute()?->name,
+ ]);
+ }
+
+ /**
+ * Make the param unique per attribute, so Size and Color rules can both be added.
+ */
+ public function getExclusiveQueryParams(): array
+ {
+ return ["variantAttribute:{$this->attributeId}"];
+ }
+
+ public function getConfig(): array
+ {
+ return parent::getConfig() + [
+ 'attributeId' => $this->attributeId,
+ ];
+ }
+
+ /**
+ * @param ProductQuery|VariantQuery $query
+ */
+ public function modifyQuery(ElementQueryInterface $query): void
+ {
+ $params = [];
+ $condition = $this->fieldCondition($params);
+
+ if ($condition === null) {
+ return;
+ }
+
+ if ($query instanceof ProductQuery) {
+ // Filter products through their variants, since the field is only on variant layouts
+ $query->hasVariant($this->variantQuery($query)->andWhere($condition, $params));
+ return;
+ }
+
+ $query->andWhere($condition, $params);
+ }
+
+ public function matchElement(ElementInterface $element): bool
+ {
+ if ($element instanceof Product) {
+ foreach ($element->getVariants() as $variant) {
+ if ($this->matchVariant($variant)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return $this->matchVariant($element);
+ }
+
+ protected function defineRules(): array
+ {
+ $rules = parent::defineRules();
+ // Validate attributeId, or building the rule from config drops it
+ $rules[] = [['attributeId'],
+ 'number',
+ 'integerOnly' => true];
+ return $rules;
+ }
+
+ protected function options(): array
+ {
+ if ($this->attributeId === null) {
+ return [];
+ }
+
+ $options = [];
+
+ foreach (VariantAttributeOption::find()->attributeId($this->attributeId)->all() as $option) {
+ $options[] = [
+ 'label' => $option->value,
+ 'value' => (string) $option->id,
+ ];
+ }
+
+ return $options;
+ }
+
+ /**
+ * Reuse an existing hasVariant filter, given as a query or as criteria.
+ */
+ private function variantQuery(ProductQuery $query): VariantQuery
+ {
+ if ($query->hasVariant instanceof VariantQuery) {
+ return $query->hasVariant;
+ }
+
+ if (is_array($query->hasVariant)) {
+ return Craft::configure(Variant::find(), ProductQueryHelper::cleanseQueryCriteria($query->hasVariant));
+ }
+
+ return Variant::find();
+ }
+
+ /**
+ * @param array $params
+ */
+ private function fieldCondition(array &$params): mixed
+ {
+ $attribute = $this->attribute();
+ $option = $this->selectedOption();
+ $instances = $this->fieldInstances();
+
+ if ($attribute === null || $option === null || $instances === []) {
+ return null;
+ }
+
+ $condition = VariantAttributesField::queryCondition($instances, [
+ $attribute->name => $option->value,
+ ], $params);
+
+ return $condition === false ? '0=1' : $condition;
+ }
+
+ private function matchVariant(ElementInterface $variant): bool
+ {
+ $attribute = $this->attribute();
+ $option = $this->selectedOption();
+
+ if ($attribute === null || $option === null) {
+ return false;
+ }
+
+ foreach ($this->fieldInstances() as $field) {
+ $value = $variant->getFieldValue($field->handle);
+
+ // An unparseable JSON field value is the raw string
+ if (! is_array($value)) {
+ continue;
+ }
+
+ foreach ($value as $pair) {
+ if (($pair['attributeName'] ?? null) === $attribute->name && ($pair['attributeValue'] ?? null) === $option->value) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private function attribute(): ?VariantAttribute
+ {
+ return $this->attributeId === null
+ ? null
+ : Plugin::getInstance()->getVariantAttributes()->getAttributeById($this->attributeId);
+ }
+
+ private function selectedOption(): ?VariantAttributeOption
+ {
+ if ($this->selectedOption === null) {
+ $this->selectedOption = ($this->value === ''
+ ? null
+ : VariantAttributeOption::find()->id((int) $this->value)->one()) ?? false;
+ }
+
+ return $this->selectedOption === false ? null : $this->selectedOption;
+ }
+
+ /**
+ * Collect the field instances from variant layouts.
+ *
+ * @return list
+ */
+ private function fieldInstances(): array
+ {
+ if ($this->fieldInstances === null) {
+ $this->fieldInstances = [];
+
+ foreach (Craft::$app->getFields()->getLayoutsByType(Variant::class) as $fieldLayout) {
+ foreach ($fieldLayout->getCustomFields() as $field) {
+ if ($field instanceof VariantAttributesField) {
+ $this->fieldInstances[] = $field;
+ }
+ }
+ }
+ }
+
+ return $this->fieldInstances;
+ }
+}
diff --git a/src/elements/db/VariantAttributeOptionQuery.php b/src/elements/db/VariantAttributeOptionQuery.php
new file mode 100644
index 0000000..33f5e0a
--- /dev/null
+++ b/src/elements/db/VariantAttributeOptionQuery.php
@@ -0,0 +1,68 @@
+
+ */
+class VariantAttributeOptionQuery extends ElementQuery
+{
+ public mixed $attributeId = null;
+
+ public mixed $valueKey = null;
+
+ protected array $defaultOrderBy = [
+ 'variant_manager_attribute_options.value' => SORT_ASC,
+ ];
+
+ public function attributeId(mixed $value): static
+ {
+ $this->attributeId = $value;
+ return $this;
+ }
+
+ public function valueKey(mixed $value): static
+ {
+ $this->valueKey = $value;
+ return $this;
+ }
+
+ protected function fieldLayouts(): array
+ {
+ // These layouts are in project config, not the fieldlayouts table
+ return VariantAttributeOption::fieldLayouts(null);
+ }
+
+ protected function beforePrepare(): bool
+ {
+ if (! parent::beforePrepare()) {
+ return false;
+ }
+
+ $this->joinElementTable(Table::ATTRIBUTE_OPTIONS);
+
+ $this->query->addSelect([
+ 'variant_manager_attribute_options.attributeId',
+ 'variant_manager_attribute_options.value',
+ 'variant_manager_attribute_options.valueKey',
+ ]);
+
+ if (isset($this->attributeId)) {
+ $this->subQuery->andWhere(Db::parseNumericParam('variant_manager_attribute_options.attributeId', $this->attributeId));
+ }
+
+ if (isset($this->valueKey)) {
+ $this->subQuery->andWhere(Db::parseParam('variant_manager_attribute_options.valueKey', $this->valueKey));
+ }
+
+ return true;
+ }
+}
diff --git a/src/elements/db/VariantAttributeQuery.php b/src/elements/db/VariantAttributeQuery.php
new file mode 100644
index 0000000..3167fca
--- /dev/null
+++ b/src/elements/db/VariantAttributeQuery.php
@@ -0,0 +1,56 @@
+
+ */
+class VariantAttributeQuery extends ElementQuery
+{
+ public mixed $nameKey = null;
+
+ protected array $defaultOrderBy = [
+ 'variant_manager_attributes.name' => SORT_ASC,
+ ];
+
+ public function nameKey(mixed $value): static
+ {
+ $this->nameKey = $value;
+ return $this;
+ }
+
+ protected function fieldLayouts(): array
+ {
+ // These layouts are in project config, not the fieldlayouts table
+ return VariantAttribute::fieldLayouts(null);
+ }
+
+ protected function beforePrepare(): bool
+ {
+ if (! parent::beforePrepare()) {
+ return false;
+ }
+
+ $this->joinElementTable(Table::ATTRIBUTES);
+
+ $this->query->addSelect([
+ 'variant_manager_attributes.name',
+ 'variant_manager_attributes.nameKey',
+ 'variant_manager_attributes.displayType',
+ ]);
+
+ if (isset($this->nameKey)) {
+ $this->subQuery->andWhere(Db::parseParam('variant_manager_attributes.nameKey', $this->nameKey));
+ }
+
+ return true;
+ }
+}
diff --git a/src/elements/variants/VariantCondition.php b/src/elements/variants/VariantCondition.php
index c7d748e..9c87fa7 100644
--- a/src/elements/variants/VariantCondition.php
+++ b/src/elements/variants/VariantCondition.php
@@ -13,7 +13,9 @@ class VariantCondition extends ElementCondition
{
public ?string $elementType = VariantManagerVariant::class;
- // Resolve field layouts against base Variant so custom-field filtering works.
+ /**
+ * Resolve against the base Variant class, since layouts are stored under it.
+ */
public function getFieldLayouts(): array
{
return Craft::$app->getFields()->getLayoutsByType(CommerceVariant::class);
diff --git a/src/enums/DisplayType.php b/src/enums/DisplayType.php
new file mode 100644
index 0000000..3dfe4b6
--- /dev/null
+++ b/src/enums/DisplayType.php
@@ -0,0 +1,37 @@
+value}");
+ }
+
+ /**
+ * @param list $displayTypes
+ * @return array
+ */
+ public static function options(array $displayTypes): array
+ {
+ return array_map(static fn (self $displayType): array => [
+ 'label' => $displayType->label(),
+ 'value' => $displayType->value,
+ ], $displayTypes);
+ }
+}
diff --git a/src/errors/FieldMapException.php b/src/errors/FieldMapException.php
new file mode 100644
index 0000000..fe49ff6
--- /dev/null
+++ b/src/errors/FieldMapException.php
@@ -0,0 +1,18 @@
+handle;
- $id = Html::id($name);
- $namespacedId = Craft::$app->view->namespaceInputId($id);
- $uniquePrefix = explode('-', $namespacedId)[0];
+ $view = Craft::$app->getView();
+ $view->registerAssetBundle(VariantAttributesFieldAssetBundle::class);
+ $namespacedId = $view->namespaceInputId(Html::id($this->handle));
- return Craft::$app->getView()->renderTemplate('variant-manager/fields/variant_attributes', [
- 'id' => $id,
+ return $view->renderTemplate('variant-manager/fields/variant_attributes', [
'namespacedId' => $namespacedId,
- 'name' => $name,
- 'attributes' => $value,
+ 'rows' => $this->registryRows($value),
'multipleFieldsExist' => ! FieldHelper::isFirstVariantAttributesField($this, $element),
- 'variant' => $element,
- 'uniquePrefix' => $uniquePrefix,
]);
}
@@ -91,8 +101,7 @@ public static function queryCondition(
];
if (is_array($value)) {
if (! array_is_list($value)) {
- // If the value is an associative array, then we need to filter out variants that don't have the combination
- // of key/value pairs in their field.
+ // Match variants storing every name/value pair in the filter
$instance->generateAssociativeFilter($contentColumn, $value, $whereParts);
} else {
$whereParts = [
@@ -111,7 +120,7 @@ public static function queryCondition(
}
}
} elseif (is_string($value)) {
- // If the value is a string, then we filter out variants that don't have that value in their fields attributeValue property.
+ // Match variants storing this value under any attribute name
$instance->generateStringFilter($contentColumn, $value, $whereParts);
} else {
throw new \RuntimeException('$value must be either an array or a string');
@@ -129,6 +138,45 @@ public static function queryCondition(
return $qb->buildCondition(implode(' OR ', $conditions), $params);
}
+ /**
+ * Pair each stored attribute with its registry elements, or null where the pair is unregistered.
+ *
+ * @return list
+ */
+ private function registryRows(mixed $fieldValue): array
+ {
+ // An unparseable JSON field value is the raw string
+ if (! is_array($fieldValue)) {
+ return [];
+ }
+
+ $attributes = Plugin::getInstance()->getVariantAttributes()->getAttributesByNames(array_column($fieldValue, 'attributeName'));
+ $attributeIds = array_map(static fn (VariantAttribute $attribute): int => (int) $attribute->id, $attributes);
+ $options = [];
+
+ if ($attributeIds !== []) {
+ foreach (VariantAttributeOption::find()->attributeId(array_values($attributeIds))->all() as $option) {
+ $options["{$option->attributeId}\0{$option->valueKey}"] = $option;
+ }
+ }
+
+ $rows = [];
+
+ foreach ($fieldValue as $pair) {
+ $attribute = $attributes[VariantAttribute::normalizeName($pair['attributeName'])] ?? null;
+ $optionKey = $attribute?->id . "\0" . VariantAttributeOption::normalizeValue($pair['attributeValue']);
+
+ $rows[] = [
+ 'attributeName' => $pair['attributeName'],
+ 'attributeValue' => $pair['attributeValue'],
+ 'attribute' => $attribute,
+ 'option' => $options[$optionKey] ?? null,
+ ];
+ }
+
+ return $rows;
+ }
+
private function generateAssociativeFilter(string $contentColumn, array $filter, array &$whereParts): void
{
if (
@@ -156,10 +204,14 @@ private function generateAssociativeFilter(string $contentColumn, array $filter,
$whereParts['params'][$keyParam] = $key;
$whereParts['params'][$valueParam] = $value;
} else {
- $whereParts['conditions'][] = << {$valueParam}
-EOQ;
- $whereParts['params'][$valueParam] = "[{\"attributeName\": \"{$key}\", \"attributeValue\": \"{$value}\"}]";
+ $fieldParam = ":af{$paramKey}";
+ // Content is keyed by field uid, so containment is checked against that key's array
+ $whereParts['conditions'][] = "{$contentColumn} -> {$fieldParam} @> {$valueParam}::jsonb";
+ $whereParts['params'][$fieldParam] = $fieldUid;
+ $whereParts['params'][$valueParam] = Json::encode([[
+ 'attributeName' => $key,
+ 'attributeValue' => $value,
+ ]]);
}
}
}
@@ -171,16 +223,19 @@ private function generateStringFilter(string $contentColumn, string $value, arra
$valueParam = ":av{$paramKey}";
if (Craft::$app->getDb()->getIsMysql()) {
- // This query checks that the path returned by json_search on each side is the same path.
+ // Match any attributeValue in the field's JSON array
$whereParts['conditions'][] = <<>"$.\"{$fieldUid}\"[*].attributeValue", 'one', {$valueParam}) is not null
EOQ;
$whereParts['params'][$valueParam] = $value;
} else {
- $whereParts['conditions'][] = << {$valueParam}
-EOQ;
- $whereParts['params'][$valueParam] = "[{\"attributeValue\": \"{$value}\"}]";
+ $fieldParam = ":af{$paramKey}";
+ // Content is keyed by field uid, so containment is checked against that key's array
+ $whereParts['conditions'][] = "{$contentColumn} -> {$fieldParam} @> {$valueParam}::jsonb";
+ $whereParts['params'][$fieldParam] = $fieldUid;
+ $whereParts['params'][$valueParam] = Json::encode([[
+ 'attributeValue' => $value,
+ ]]);
}
}
}
diff --git a/src/helpers/FieldHelper.php b/src/helpers/FieldHelper.php
index 6af5a92..11c0972 100644
--- a/src/helpers/FieldHelper.php
+++ b/src/helpers/FieldHelper.php
@@ -3,15 +3,17 @@
namespace fostercommerce\variantmanager\helpers;
use craft\base\ElementInterface;
-use craft\base\Field;
use craft\models\FieldLayout;
use fostercommerce\variantmanager\fields\VariantAttributesField;
-class FieldHelper extends Field
+class FieldHelper
{
- public static function getFirstVariantAttributesField(?FieldLayout $fieldLayout = null): ?VariantAttributesField
+ public static function getFirstVariantAttributesField(?FieldLayout $fieldLayout): ?VariantAttributesField
{
- // Using getElementsByType(VariantAttributesField::class) gave inconsistent results. Maybe I was doing something wrong.
+ if ($fieldLayout === null) {
+ return null;
+ }
+
foreach ($fieldLayout->getCustomFields() as $field) {
if ($field::class === VariantAttributesField::class) {
return $field;
@@ -21,9 +23,13 @@ public static function getFirstVariantAttributesField(?FieldLayout $fieldLayout
return null;
}
- public static function isFirstVariantAttributesField(VariantAttributesField $variantAttributesField, ?ElementInterface $element = null): bool
+ public static function isFirstVariantAttributesField(VariantAttributesField $variantAttributesField, ?ElementInterface $element): bool
{
- // Using getElementsByType(VariantAttributesField::class) gave inconsistent results. Maybe I was doing something wrong.
+ // No element means nothing to rank, as when the input renders as a field preview
+ if ($element === null) {
+ return true;
+ }
+
$customFieldIndex = -1;
foreach ($element->getFieldLayout()->getCustomFields() as $customField) {
if ($customField::class === VariantAttributesField::class) {
@@ -35,7 +41,6 @@ public static function isFirstVariantAttributesField(VariantAttributesField $var
}
}
- // Shouldn't reach here ever. But if we do, then it's going to be false.
return false;
}
}
diff --git a/src/jobs/BackfillAttributes.php b/src/jobs/BackfillAttributes.php
new file mode 100644
index 0000000..560549a
--- /dev/null
+++ b/src/jobs/BackfillAttributes.php
@@ -0,0 +1,61 @@
+
+ */
+ private array $pairs = [];
+
+ protected function loadData(): Batchable
+ {
+ $query = Variant::find()
+ ->status(null)
+ ->offset(null)
+ ->limit(null)
+ ->orderBy([
+ 'elements.id' => SORT_ASC,
+ ]);
+
+ return new QueryBatcher($query);
+ }
+
+ protected function processItem(mixed $variant): void
+ {
+ $this->pairs = [
+ ...$this->pairs,
+ ...Plugin::getInstance()->getVariantAttributes()->attributePairs([$variant]),
+ ];
+ }
+
+ /**
+ * Register the batch's distinct pairs.
+ *
+ * Registering per variant would run three element queries for each one.
+ */
+ protected function afterBatch(): void
+ {
+ if ($this->pairs !== []) {
+ Plugin::getInstance()->getVariantAttributes()->ensureFromAttributePairs(array_values($this->pairs));
+ $this->pairs = [];
+ }
+
+ parent::afterBatch();
+ }
+
+ protected function defaultDescription(): ?string
+ {
+ return Craft::t('variant-manager', 'jobs.backfillAttributes');
+ }
+}
diff --git a/src/jobs/Import.php b/src/jobs/Import.php
index 1bf9122..c6dae4d 100644
--- a/src/jobs/Import.php
+++ b/src/jobs/Import.php
@@ -3,6 +3,7 @@
namespace fostercommerce\variantmanager\jobs;
use craft\errors\ElementNotFoundException;
+use craft\helpers\Html;
use craft\queue\BaseJob;
use craft\web\UploadedFile;
use fostercommerce\variantmanager\Plugin;
@@ -59,22 +60,19 @@ public function execute($queue): void
try {
$product = Plugin::getInstance()->csv->import($this->filename, $this->csvData, $this->productTypeHandle, $this->refreshVariants);
- // Do this after save so that we can get the correct edit URL from a new product
- if ($product->isNewForSite) {
- Activity::log(
- $user,
- "Imported new product getCpEditUrl()}\">{$product->title} into {$product->type->name}",
- );
- } else {
- Activity::log(
- $user,
- "Imported existing product getCpEditUrl()}\">{$product->title} into {$product->type->name}",
- );
- }
+ // getCpEditUrl() needs the saved product's ID
+ $link = Html::a(Html::encode($product->title), (string) $product->getCpEditUrl(), [
+ 'class' => 'go',
+ ]);
+ $productTypeName = Html::encode($product->type->name);
+ $verb = $product->isNewForSite ? 'new' : 'existing';
+
+ Activity::log($user, "Imported {$verb} product {$link} into {$productTypeName}");
} catch (\Throwable $throwable) {
+ // The dashboard renders the message with |raw, and a CSV filename becomes a product title
Activity::log(
$user,
- "Failed to import {$this->filename}: {$throwable->getMessage()}",
+ 'Failed to import ' . Html::tag('strong', Html::encode($this->filename)) . ': ' . Html::encode($throwable->getMessage()),
'error'
);
throw $throwable;
diff --git a/src/jobs/PruneAttributeOrphans.php b/src/jobs/PruneAttributeOrphans.php
new file mode 100644
index 0000000..3f20988
--- /dev/null
+++ b/src/jobs/PruneAttributeOrphans.php
@@ -0,0 +1,20 @@
+getVariantAttributes()->pruneOrphans();
+ }
+
+ protected function defaultDescription(): ?string
+ {
+ return Craft::t('variant-manager', 'jobs.pruneAttributeOrphans');
+ }
+}
diff --git a/src/migrations/Install.php b/src/migrations/Install.php
index a8772a8..904e03d 100644
--- a/src/migrations/Install.php
+++ b/src/migrations/Install.php
@@ -3,13 +3,14 @@
namespace fostercommerce\variantmanager\migrations;
use craft\db\Migration;
-use fostercommerce\variantmanager\records\Activity;
+use craft\db\Table as CraftTable;
+use fostercommerce\variantmanager\db\Table;
class Install extends Migration
{
public function safeUp(): bool
{
- $this->createTable(Activity::TABLE_NAME, [
+ $this->createTable(Table::ACTIVITIES, [
'id' => $this->primaryKey(),
'message' => $this->text()->notNull(),
'type' => $this->string()->notNull(),
@@ -17,16 +18,48 @@ public function safeUp(): bool
'username' => $this->string()->notNull(),
'dateCreated' => $this->dateTime()->notNull(),
]);
- $this->createIndex(null, Activity::TABLE_NAME, ['dateCreated'], false);
+ $this->createIndex(null, Table::ACTIVITIES, ['dateCreated'], false);
+
+ $this->createTable(Table::ATTRIBUTES, [
+ 'id' => $this->integer()->notNull(),
+ 'name' => $this->string()->notNull(),
+ 'nameKey' => $this->string()->notNull(),
+ 'displayType' => $this->string()->notNull()->defaultValue('dropdown'),
+ 'dateCreated' => $this->dateTime()->notNull(),
+ 'dateUpdated' => $this->dateTime()->notNull(),
+ 'uid' => $this->uid(),
+ 'PRIMARY KEY([[id]])',
+ ]);
+
+ $this->createIndex(null, Table::ATTRIBUTES, ['nameKey'], true);
+ $this->addForeignKey(null, Table::ATTRIBUTES, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE');
+
+ $this->createTable(Table::ATTRIBUTE_OPTIONS, [
+ 'id' => $this->integer()->notNull(),
+ 'attributeId' => $this->integer()->notNull(),
+ 'value' => $this->string()->notNull(),
+ 'valueKey' => $this->string()->notNull(),
+ 'dateCreated' => $this->dateTime()->notNull(),
+ 'dateUpdated' => $this->dateTime()->notNull(),
+ 'uid' => $this->uid(),
+ 'PRIMARY KEY([[id]])',
+ ]);
+
+ $this->createIndex(null, Table::ATTRIBUTE_OPTIONS, ['attributeId', 'valueKey'], true);
+ $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE');
+ $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['attributeId'], Table::ATTRIBUTES, ['id'], 'CASCADE');
return true;
}
public function safeDown(): bool
{
- if ($this->db->tableExists(Activity::TABLE_NAME)) {
- $this->dropIndexIfExists(Activity::TABLE_NAME, ['dateCreated'], false);
- $this->dropTable(Activity::TABLE_NAME);
+ $this->dropTableIfExists(Table::ATTRIBUTE_OPTIONS);
+ $this->dropTableIfExists(Table::ATTRIBUTES);
+
+ if ($this->db->tableExists(Table::ACTIVITIES)) {
+ $this->dropIndexIfExists(Table::ACTIVITIES, ['dateCreated'], false);
+ $this->dropTable(Table::ACTIVITIES);
}
return true;
diff --git a/src/migrations/m240222_084504_add_activity_error_col.php b/src/migrations/m240222_084504_add_activity_error_col.php
index 87598f5..bbb7138 100644
--- a/src/migrations/m240222_084504_add_activity_error_col.php
+++ b/src/migrations/m240222_084504_add_activity_error_col.php
@@ -12,7 +12,6 @@ class m240222_084504_add_activity_error_col extends Migration
{
public function safeUp(): bool
{
- // Place migration code here...
$this->addColumn(Activity::TABLE_NAME, 'type', $this->string()->after('message'));
$this->update(Activity::TABLE_NAME, [
'type' => 'success',
diff --git a/src/migrations/m260905_153830_create_variant_attribute_tables.php b/src/migrations/m260905_153830_create_variant_attribute_tables.php
new file mode 100644
index 0000000..3d6c9a3
--- /dev/null
+++ b/src/migrations/m260905_153830_create_variant_attribute_tables.php
@@ -0,0 +1,43 @@
+createTable(Table::ATTRIBUTES, [
+ 'id' => $this->integer()->notNull(),
+ 'name' => $this->string()->notNull(),
+ 'nameKey' => $this->string()->notNull(),
+ 'dateCreated' => $this->dateTime()->notNull(),
+ 'dateUpdated' => $this->dateTime()->notNull(),
+ 'uid' => $this->uid(),
+ 'PRIMARY KEY([[id]])',
+ ]);
+
+ $this->createIndex(null, Table::ATTRIBUTES, ['nameKey'], true);
+ $this->addForeignKey(null, Table::ATTRIBUTES, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE');
+
+ $this->createTable(Table::ATTRIBUTE_OPTIONS, [
+ 'id' => $this->integer()->notNull(),
+ 'attributeId' => $this->integer()->notNull(),
+ 'value' => $this->string()->notNull(),
+ 'valueKey' => $this->string()->notNull(),
+ 'dateCreated' => $this->dateTime()->notNull(),
+ 'dateUpdated' => $this->dateTime()->notNull(),
+ 'uid' => $this->uid(),
+ 'PRIMARY KEY([[id]])',
+ ]);
+
+ $this->createIndex(null, Table::ATTRIBUTE_OPTIONS, ['attributeId', 'valueKey'], true);
+ $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE');
+ $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['attributeId'], Table::ATTRIBUTES, ['id'], 'CASCADE');
+
+ return true;
+ }
+}
diff --git a/src/migrations/m260908_110000_add_display_type_col.php b/src/migrations/m260908_110000_add_display_type_col.php
new file mode 100644
index 0000000..8a84c11
--- /dev/null
+++ b/src/migrations/m260908_110000_add_display_type_col.php
@@ -0,0 +1,42 @@
+addColumn(
+ Table::ATTRIBUTES,
+ 'displayType',
+ $this->string()->notNull()->defaultValue('dropdown')->after('nameKey')
+ );
+
+ // Display types were project config before this release
+ $configs = Craft::$app->getProjectConfig()->get(AttributeConfigs::CONFIG_PATH);
+
+ if (! is_array($configs)) {
+ return true;
+ }
+
+ foreach ($configs as $nameKey => $config) {
+ $displayType = $config['displayType'] ?? null;
+
+ if (is_string($displayType)) {
+ Db::update(Table::ATTRIBUTES, [
+ 'displayType' => $displayType,
+ ], [
+ 'nameKey' => $nameKey,
+ ]);
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/models/Settings.php b/src/models/Settings.php
index 4129094..0513765 100644
--- a/src/models/Settings.php
+++ b/src/models/Settings.php
@@ -6,6 +6,7 @@
use craft\base\Model;
use craft\commerce\models\ProductType;
use craft\commerce\Plugin as CommercePlugin;
+use fostercommerce\variantmanager\enums\DisplayType;
/**
* Variant Manager settings
@@ -16,28 +17,33 @@ class Settings extends Model
{
public const DEFAULT_CLEAR_ACTIVITY_LOGS_AFTER = '30 days';
- /**
- * The value to use for empty attribute values.
- */
+ public const DEFAULT_PRODUCT_FIELD_MAP = [
+ 'title' => 'title',
+ 'slug' => 'slug',
+ 'status' => 'status',
+ ];
+
+ public const DEFAULT_VARIANT_FIELD_MAP = [
+ 'title' => 'title',
+ 'sku' => 'sku',
+ 'inventoryTracked' => 'inventoryTracked',
+ 'basePrice' => 'basePrice',
+ 'height' => 'height',
+ 'width' => 'width',
+ 'length' => 'length',
+ 'weight' => 'weight',
+ ];
+
public string $emptyAttributeValue = '';
- /**
- * The prefix to use for attribute fields.
- */
public string $attributePrefix = 'Attribute: ';
- /**
- * The prefix to use for inventory fields.
- */
public string $inventoryPrefix = 'Inventory';
/**
- * If set, how long to keep individual activity logs for.
+ * How long to keep activity logs: an int is days, a string is a relative time like '1 week'.
*
- * For integer values, it will be number of days.
- * For string values, it refers to a relative time string like '1 hour', '1 day', '1 week', '1 month', '1 year'.
- *
- * Note that activity logs are only cleared during Craft's garbage collection or when the `clear-activity-logs` console command is run.
+ * Logs are cleared during garbage collection, or by `./craft variant-manager/activities/clear`.
*
* @see https://www.php.net/manual/en/datetime.formats.php#datetime.formats.relative
*/
@@ -53,16 +59,31 @@ class Settings extends Model
*/
public array $bulkEditableVariantFields = [];
+ /**
+ * @var list
+ */
+ public array $availableDisplayTypes = [];
+
+ public string $defaultDisplayType = DisplayType::Dropdown->value;
+
public array $productFieldMap = [
- '*' => [],
+ '*' => self::DEFAULT_PRODUCT_FIELD_MAP,
];
public array $variantFieldMap = [
- '*' => [],
+ '*' => self::DEFAULT_VARIANT_FIELD_MAP,
];
public function setAttributes($values, $safeOnly = true): void
{
+ // The “All” checkbox posts '*' on its own, and an unchecked group posts ''
+ if (isset($values['availableDisplayTypes'])) {
+ $values['availableDisplayTypes'] = array_values(array_filter(
+ (array) $values['availableDisplayTypes'],
+ static fn (string $displayType): bool => $displayType !== ''
+ ));
+ }
+
parent::setAttributes($values, $safeOnly);
if ($this->activityLogRetention !== false && $this->activityLogRetention !== null) {
@@ -71,27 +92,38 @@ public function setAttributes($values, $safeOnly = true): void
}
}
- // Make sure that the catch-all type always exists
- if ($this->variantFieldMap === []) {
- $this->variantFieldMap = [
- '*' => [],
- ];
- }
-
+ // getProductTypeMapping() reads the catch-all key without a guard
if (! array_key_exists('*', $this->variantFieldMap)) {
- $this->variantFieldMap['*'] = [];
+ $this->variantFieldMap['*'] = self::DEFAULT_VARIANT_FIELD_MAP;
}
- // Make sure that the catch-all type always exists for product field map
- if ($this->productFieldMap === []) {
- $this->productFieldMap = [
- '*' => [],
- ];
+ // getProductFieldMapping() reads the catch-all key without a guard
+ if (! array_key_exists('*', $this->productFieldMap)) {
+ $this->productFieldMap['*'] = self::DEFAULT_PRODUCT_FIELD_MAP;
}
+ }
- if (! array_key_exists('*', $this->productFieldMap)) {
- $this->productFieldMap['*'] = [];
+ /**
+ * @return list
+ */
+ public function getAvailableDisplayTypes(?string $currentDisplayType = null): array
+ {
+ $displayTypes = $this->availableDisplayTypes === [] || in_array('*', $this->availableDisplayTypes, true)
+ ? DisplayType::cases()
+ : array_values(array_filter(array_map(DisplayType::tryFrom(...), $this->availableDisplayTypes)));
+
+ // Keep a stored type the config no longer lists, or the select posts a different one on the next save
+ $currentDisplayType = $currentDisplayType === null ? null : DisplayType::tryFrom($currentDisplayType);
+ if ($currentDisplayType instanceof DisplayType && ! in_array($currentDisplayType, $displayTypes, true)) {
+ $displayTypes[] = $currentDisplayType;
}
+
+ return $displayTypes;
+ }
+
+ public function getDefaultDisplayType(): DisplayType
+ {
+ return DisplayType::tryFrom($this->defaultDisplayType) ?? DisplayType::Dropdown;
}
public function getAvailableProductTypes(): array
diff --git a/src/records/Activity.php b/src/records/Activity.php
index 78029e2..74f37b3 100644
--- a/src/records/Activity.php
+++ b/src/records/Activity.php
@@ -5,6 +5,7 @@
use craft\db\ActiveRecord;
use craft\elements\User;
use craft\helpers\Db;
+use fostercommerce\variantmanager\db\Table;
/**
* @property int $id
@@ -14,7 +15,7 @@
*/
class Activity extends ActiveRecord
{
- final public const TABLE_NAME = '{{%variant_manager_activities}}';
+ final public const TABLE_NAME = Table::ACTIVITIES;
public static function tableName(): string
{
diff --git a/src/records/VariantAttribute.php b/src/records/VariantAttribute.php
new file mode 100644
index 0000000..2e59815
--- /dev/null
+++ b/src/records/VariantAttribute.php
@@ -0,0 +1,20 @@
+hasOne(VariantAttribute::class, [
+ 'id' => 'attributeId',
+ ]);
+ }
+}
diff --git a/src/services/AttributeConfigs.php b/src/services/AttributeConfigs.php
new file mode 100644
index 0000000..565396e
--- /dev/null
+++ b/src/services/AttributeConfigs.php
@@ -0,0 +1,195 @@
+getProjectConfig()->get(self::CONFIG_PATH);
+
+ if (! is_array($configs)) {
+ return;
+ }
+
+ foreach ($configs as $config) {
+ foreach (['fieldLayouts', 'optionFieldLayouts'] as $layoutKey) {
+ foreach ($config[$layoutKey] ?? [] as $layout) {
+ foreach ($layout['tabs'] ?? [] as $tab) {
+ foreach ($tab['elements'] ?? [] as $element) {
+ if (isset($element['handle'])) {
+ CustomFieldBehavior::$fieldHandles[$element['handle']] = true;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ public function getFieldLayout(string $nameKey): FieldLayout
+ {
+ return $this->layout($nameKey, 'fieldLayouts', VariantAttribute::class);
+ }
+
+ public function getOptionFieldLayout(string $nameKey): FieldLayout
+ {
+ return $this->layout($nameKey, 'optionFieldLayouts', VariantAttributeOption::class);
+ }
+
+ /**
+ * @return list
+ */
+ public function getAllAttributeLayouts(): array
+ {
+ return $this->getAllLayouts('fieldLayouts', VariantAttribute::class);
+ }
+
+ /**
+ * @return list
+ */
+ public function getAllOptionLayouts(): array
+ {
+ return $this->getAllLayouts('optionFieldLayouts', VariantAttributeOption::class);
+ }
+
+ public function save(string $nameKey, FieldLayout $fieldLayout, FieldLayout $optionFieldLayout): bool
+ {
+ if (! $fieldLayout->validate() || ! $optionFieldLayout->validate()) {
+ return false;
+ }
+
+ // Reuse the stored uid, since an assembled layout has none and a fresh one churns config
+ $fieldLayout->uid = $this->getFieldLayout($nameKey)->uid ?? StringHelper::UUID();
+ $optionFieldLayout->uid = $this->getOptionFieldLayout($nameKey)->uid ?? StringHelper::UUID();
+
+ Craft::$app->getProjectConfig()->set(
+ self::CONFIG_PATH . '.' . $nameKey,
+ [
+ 'fieldLayouts' => [
+ $fieldLayout->uid => $fieldLayout->getConfig() ?? [],
+ ],
+ 'optionFieldLayouts' => [
+ $optionFieldLayout->uid => $optionFieldLayout->getConfig() ?? [],
+ ],
+ ],
+ "Save the “{$nameKey}” variant attribute settings"
+ );
+
+ return true;
+ }
+
+ /**
+ * Remove config for name keys with no attribute row.
+ *
+ * Sweep here, since a hard delete from garbage collection fires no element hook.
+ */
+ public function removeOrphaned(): void
+ {
+ $projectConfig = Craft::$app->getProjectConfig();
+
+ if ($projectConfig->readOnly) {
+ return;
+ }
+
+ $configs = $projectConfig->get(self::CONFIG_PATH);
+
+ if (! is_array($configs)) {
+ return;
+ }
+
+ $nameKeys = array_flip(array_map(
+ static fn (VariantAttribute $attribute): string => $attribute->nameKey,
+ VariantAttribute::find()->trashed(null)->all()
+ ));
+
+ foreach (array_keys($configs) as $nameKey) {
+ if (! isset($nameKeys[$nameKey])) {
+ $this->remove((string) $nameKey);
+ }
+ }
+ }
+
+ public function remove(string $nameKey): void
+ {
+ Craft::$app->getProjectConfig()->remove(
+ self::CONFIG_PATH . '.' . $nameKey,
+ "Delete the “{$nameKey}” variant attribute settings"
+ );
+ }
+
+ /**
+ * Get every configured layout.
+ *
+ * These layouts exist only in project config, not the fieldlayouts table.
+ *
+ * @param class-string $elementType
+ * @return list
+ */
+ private function getAllLayouts(string $layoutKey, string $elementType): array
+ {
+ $configs = Craft::$app->getProjectConfig()->get(self::CONFIG_PATH);
+
+ if (! is_array($configs)) {
+ return [];
+ }
+
+ $layouts = [];
+
+ foreach (array_keys($configs) as $nameKey) {
+ $layouts[] = $this->layout((string) $nameKey, $layoutKey, $elementType);
+ }
+
+ return $layouts;
+ }
+
+ /**
+ * @return array
+ */
+ private function config(string $nameKey): array
+ {
+ $config = Craft::$app->getProjectConfig()->get(self::CONFIG_PATH . '.' . $nameKey);
+
+ return is_array($config) ? $config : [];
+ }
+
+ /**
+ * @param class-string $elementType
+ */
+ private function layout(string $nameKey, string $layoutKey, string $elementType): FieldLayout
+ {
+ $layouts = $this->config($nameKey)[$layoutKey] ?? [];
+
+ if (! is_array($layouts) || $layouts === []) {
+ return new FieldLayout([
+ 'type' => $elementType,
+ ]);
+ }
+
+ $config = reset($layouts);
+ $fieldLayout = FieldLayout::createFromConfig(is_array($config) ? $config : []);
+ $fieldLayout->uid = (string) key($layouts);
+ $fieldLayout->type = $elementType;
+
+ return $fieldLayout;
+ }
+}
diff --git a/src/services/Csv.php b/src/services/Csv.php
index fba2071..b21aa3c 100644
--- a/src/services/Csv.php
+++ b/src/services/Csv.php
@@ -29,6 +29,7 @@
use craft\helpers\Typecast;
use craft\models\Site;
use DateTimeInterface;
+use fostercommerce\variantmanager\errors\FieldMapException;
use fostercommerce\variantmanager\helpers\FieldHelper;
use fostercommerce\variantmanager\Plugin;
use Illuminate\Support\Collection;
@@ -43,6 +44,7 @@
use Money\Currency;
use Money\Formatter\DecimalMoneyFormatter;
use Money\Money;
+use Money\Parser\DecimalMoneyParser;
use yii\base\Exception;
use yii\base\InvalidConfigException;
@@ -115,7 +117,7 @@ public function import(string $filename, string $csvData, ?string $productTypeHa
$variants = $this->normalizeExistingProductImport($product, $tabularDataReader, $mapping);
}
- // If this is a new product, we need to save it first so that the variants can be assigned to the product
+ // Save a new product first, since variants need its ID
if ($product->isNewForSite && ! Craft::$app->elements->saveElement($product, false, true, true)) {
$errors = $product->getErrorSummary(false);
/** @var ?string $error */
@@ -123,12 +125,10 @@ public function import(string $filename, string $csvData, ?string $productTypeHa
throw new \RuntimeException($error ?? 'Failed to save product');
}
- // Now we can set the variants
$product->setVariants($variants);
- // And then apply the live scenario to the product and it's variants
$product->setScenario(Element::SCENARIO_LIVE);
- // Save after product has been saved so that titles can be generated correctly if necessary.
+ // Variant titles are generated from the owner, so save each one after the product
foreach ($variants as $variant) {
$variant->setOwner($product);
if (! Craft::$app->elements->saveElement($variant, false, true, true)) {
@@ -139,12 +139,10 @@ public function import(string $filename, string $csvData, ?string $productTypeHa
}
}
- // runValidation needs to be `true` so that updateTitle and updateSku are run against Variants.
- // See: https://github.com/craftcms/commerce/pull/3297
- // It also seems to include some logic that allows a products URL to be generated.
+ // Validate, so an invalid product fails the import instead of saving half-formed
if (! Craft::$app->elements->saveElement($product, true, true, true)) {
if ($product->isNewForSite) {
- // Make sure if we're importing a new product and it fails that we delete it.
+ // Roll back a new product, or a failed import leaves an empty one behind
foreach ($variants as $variant) {
Craft::$app->elements->deleteElement($variant, true);
}
@@ -160,16 +158,21 @@ public function import(string $filename, string $csvData, ?string $productTypeHa
$this->importSiteSpecificData($tabularDataReader, $mapping['variant']['sku'], $mapping['sites']);
$this->importInventoryLevels($tabularDataReader, $mapping['variant']['sku'], $mapping['inventory']);
+ // Register after the save, so a failed import creates no registry rows
+ $variantAttributes = Plugin::getInstance()->getVariantAttributes();
+ $variantAttributes->ensureFromAttributePairs(array_values($variantAttributes->attributePairs($variants)));
+
return $product;
}
/**
* @throws CannotInsertRecord
* @throws CsvException
+ * @throws FieldMapException
*/
public function export(string $productId): array|bool
{
- // status(null) bypasses the default enabled-only filter so disabled products and variants are still exported.
+ // Export disabled products and variants too
/** @var Product|null $product */
$product = Product::find()->id($productId)->status(null)->one();
@@ -186,6 +189,7 @@ public function export(string $productId): array|bool
/**
* @throws CannotInsertRecord
* @throws CsvException
+ * @throws FieldMapException
*/
public function exportProduct(Product $product, array $variants): string
{
@@ -246,8 +250,7 @@ public function exportProduct(Product $product, array $variants): string
if (count($row) < count($header)) {
$row = array_merge($row, array_fill(count($row), count($header) - count($row), ''));
}
- // We need to make sure that the variant columns that share a name with product columns are not duplicated.
- // This line of code removes it by creating an associative array first using the header values as keys and then converting it back to an indexed array.
+ // Collapse columns sharing a header, so a variant column never duplicates a product one
$row = array_values(array_combine($header, $row));
$writer->insertOne($row);
}
@@ -401,8 +404,12 @@ private function importInventoryLevels(TabularDataReader $reader, $skuColumn, ar
*/
private function validateSkus(Product $product, array $mapping, TabularDataReader $tabularDataReader): void
{
+ $skuColumn = $mapping['variant']['sku'] ?? null;
+ if ($skuColumn === null) {
+ throw new \RuntimeException(Craft::t('variant-manager', 'import.missingSkuColumn'));
+ }
+
// Exit early if there are duplicate SKUs
- $skuColumn = $mapping['variant']['sku'];
$skus = iterator_to_array($tabularDataReader->fetchColumnByOffset($skuColumn));
$countedSkus = array_count_values($skus);
@@ -414,12 +421,10 @@ private function validateSkus(Product $product, array $mapping, TabularDataReade
/** @var Collection $foundSkus */
$foundSkus = $this->findProductVariantSkus($skus);
- // If the product is a new product and the SKU exists already, return an error.
if ($product->isNewForSite && ! $foundSkus->isEmpty()) {
throw new \RuntimeException('One or more SKUs already exist: ' . implode(', ', $foundSkus->flatten()->values()->all()));
}
- // If the SKU already exists for a different product return an error.
$foundSkus = $foundSkus->filter(static fn ($_value, $key) => $key !== $product->id);
if (! $foundSkus->isEmpty()) {
throw new \RuntimeException('One or more SKUs already exist on different products: ' . implode(', ', $foundSkus->flatten()->values()->all()));
@@ -487,10 +492,8 @@ private function normalizeExistingProductImport(Product $product, TabularDataRea
$newVariants[] = $this->normalizeVariantImport($record, $mapping, $variant);
}
-
$removedVariants = $existingVariants->diff($newVariants);
foreach ($removedVariants as $variant) {
- // Remove variants that weren't in the import.
Craft::$app->elements->deleteElement($variant);
}
@@ -554,12 +557,19 @@ private function normalizeVariantImport(array $variant, array $mapping, int $var
return $variantElement;
}
+ /**
+ * @throws FieldMapException
+ */
private function resolveVariantImportMapping(TabularDataReader $tabularDataReader, string $productTypeHandle): array
{
$settings = Plugin::getInstance()->getSettings();
$attributePrefix = $settings->attributePrefix;
$inventoryPrefix = $settings->inventoryPrefix;
$productTypeMap = $settings->getProductTypeMapping($productTypeHandle);
+ if ($productTypeMap === []) {
+ throw new FieldMapException(Craft::t('variant-manager', 'settings.emptyVariantFieldMap'));
+ }
+
$productType = CommercePlugin::getInstance()->productTypes->getProductTypeByHandle($productTypeHandle);
$crossSiteProductTypeMap = array_filter(
@@ -599,7 +609,8 @@ private function resolveVariantImportMapping(TabularDataReader $tabularDataReade
$matchedVariantFieldMap = array_filter($variantSiteMap, static fn ($mapping): bool => str_starts_with($heading, (string) $mapping), ARRAY_FILTER_USE_KEY);
if ($matchedCrossSiteFieldMap !== []) {
- $variantMap[$productTypeMap[$heading]] = $i;
+ // A standard field the map omits matches on its own name, and has no entry to look up
+ $variantMap[$productTypeMap[$heading] ?? $heading] = $i;
} elseif ($matchedVariantFieldMap !== []) {
$key = array_key_first($matchedVariantFieldMap);
$value = $matchedVariantFieldMap[$key];
@@ -705,7 +716,7 @@ private function normalizeVariantExport(Variant $variant, array $mapping, array
// Add variant fields
foreach ($mapping['variant'] as [$fieldHandle, $header]) {
if ($fieldHandle === 'stock' && $variant->inventoryTracked) {
- // If inventory tracking is enabled, we don't want to set the stock field, because the inventory will manage stock levels.
+ // Leave stock empty when inventory is tracked, since the levels export separately
$row[] = '';
continue;
}
@@ -768,6 +779,7 @@ private function normalizeVariantExport(Variant $variant, array $mapping, array
/**
* @param Site[] $sites
* @throws InvalidConfigException
+ * @throws FieldMapException
*/
private function resolveVariantExportMapping(Product $product, array $sites): array
{
@@ -776,6 +788,9 @@ private function resolveVariantExportMapping(Product $product, array $sites): ar
$inventoryPrefix = $settings->inventoryPrefix;
$productTypeMapping = $settings->getProductTypeMapping($product->type->handle);
+ if ($productTypeMapping === []) {
+ throw new FieldMapException(Craft::t('variant-manager', 'settings.emptyVariantFieldMap'));
+ }
$variantMap = [];
$commerceVariantFieldMap = array_combine(self::STANDARD_PER_SITE_VARIANT_FIELDS, self::STANDARD_PER_SITE_VARIANT_FIELDS);
@@ -794,10 +809,9 @@ private function resolveVariantExportMapping(Product $product, array $sites): ar
$inventoryMap = [];
$mappedSites = [];
if ($product->variants !== []) {
- // Get a variant that has tracked inventory so that we can get the inventory levels
+ // Prefer a tracked variant, since only it has inventory levels
$variant = Variant::find()->product($product)->inventoryTracked()->one();
if ($variant === null) {
- // Otherwise get any variant.
$variant = Variant::find()->product($product)->one();
}
@@ -852,10 +866,13 @@ private function applyProductFields(Product $product, array $titleRecord): void
}
$settings = Plugin::getInstance()->getSettings();
- $productTypeMapping = array_values($settings->getProductFieldMapping($product->type->handle));
+ $productFieldMapping = $settings->getProductFieldMapping($product->type->handle);
collect($titleRecord)
- ->only($productTypeMapping)
+ ->only(array_keys($productFieldMapping))
+ ->mapWithKeys(static fn (mixed $value, string $heading) => [
+ $productFieldMapping[$heading] => $value,
+ ])
->filter(static fn ($value, $fieldHandle) => $fieldHandle !== 'title')
->each(function (mixed $value, string $fieldHandle) use ($product) {
if ($fieldHandle === 'slug') {
@@ -883,7 +900,7 @@ private function setFieldValue(Element $element, string $fieldHandle, mixed $val
: array_map(static fn ($source) => str_replace('section:', '', $source), $field->sources);
$sectionHandles = array_map(static fn ($uid) => Craft::$app->entries->getSectionByUid($uid)?->handle, $sectionUids);
- // We have to assume that the value is an array of slugs
+ // The CSV identifies entries as sectionHandle:slug pairs
$slugs = collect(explode(',', $value))->map(static fn ($slug) => explode(':', $slug))->all();
$entries = [];
foreach ($slugs as $slug) {
@@ -894,15 +911,12 @@ private function setFieldValue(Element $element, string $fieldHandle, mixed $val
continue;
}
-
if ($sectionUids !== [] && ! in_array($sectionHandle, $sectionHandles, true)) {
- // If the field defines sections, and the section is not in the list of allowed sections, skip.
continue;
}
$entry = Entry::find()->slug($slug)->section($sectionHandle)->one();
if ($entry === null) {
- // If the entry is not found, skip.
continue;
}
@@ -920,9 +934,9 @@ private function setFieldValue(Element $element, string $fieldHandle, mixed $val
return;
}
- // Money takes values like 15.00 and turns it into 0.15, so we need to give it the value in cents.
- $value = (int) ($value * 100);
- $element->setFieldValue($fieldHandle, new Money($value, new Currency($field->currency)));
+ // Parse the decimal string: a float multiply loses cents and assumes two subunits
+ $moneyParser = new DecimalMoneyParser(new ISOCurrencies());
+ $element->setFieldValue($fieldHandle, $moneyParser->parse((string) $value, new Currency($field->currency)));
} elseif ($field instanceof DateField) {
if (is_string($value)) {
$value = trim($value);
@@ -1031,7 +1045,7 @@ private function resolveProductExportMapping(Product $product): array
$productMap[$i] = [$fieldHandle, $heading];
}
- $titleMap = collect($productMap)->filter(static fn ($mapping) => $mapping[1] === 'title')->first();
+ $titleMap = collect($productMap)->filter(static fn ($mapping) => $mapping[0] === 'title')->first();
if ($titleMap === null) {
$productMap = array_merge([['title', 'title']], $productMap);
}
diff --git a/src/services/ProductVariants.php b/src/services/ProductVariants.php
index 43111fd..8041def 100644
--- a/src/services/ProductVariants.php
+++ b/src/services/ProductVariants.php
@@ -4,17 +4,68 @@
use craft\base\Component;
use craft\commerce\elements\Product;
+use fostercommerce\variantmanager\elements\VariantAttribute;
+use fostercommerce\variantmanager\elements\VariantAttributeOption;
use fostercommerce\variantmanager\helpers\FieldHelper;
+use fostercommerce\variantmanager\Plugin;
use yii\base\InvalidConfigException;
class ProductVariants extends Component
{
/**
+ * Get each attribute name and the values a product's variants use.
+ *
* @param Product|int $product The product to fetch variant attributes for.
* @param array|string|null $only If set, limits the options returned to just the ones in the argument.
+ * @return array}>
* @throws InvalidConfigException
*/
public function getAttributeOptions(Product|int $product, array|string|null $only = null): array
+ {
+ $attributeOptions = [];
+
+ foreach ($this->valuesByName($product, $only) as $name => $values) {
+ $attributeOptions[] = [
+ 'name' => $name,
+ 'values' => $values,
+ ];
+ }
+
+ return $attributeOptions;
+ }
+
+ /**
+ * Get each attribute name and its values, with the matching registry attribute and options.
+ *
+ * @param Product|int $product The product to fetch variant attributes for.
+ * @param array|string|null $only If set, limits the options returned to just the ones in the argument.
+ * @return array, attribute: ?VariantAttribute, options: array}>
+ * @throws InvalidConfigException
+ */
+ public function getAttributeRegistry(Product|int $product, array|string|null $only = null): array
+ {
+ $valuesByName = $this->valuesByName($product, $only);
+ $registry = Plugin::getInstance()->getVariantAttributes()->getRegistry($valuesByName);
+
+ $attributeOptions = [];
+
+ foreach ($valuesByName as $name => $values) {
+ $attributeOptions[] = [
+ 'name' => $name,
+ 'values' => $values,
+ 'attribute' => $registry[$name]['attribute'] ?? null,
+ 'options' => $registry[$name]['options'] ?? [],
+ ];
+ }
+
+ return $attributeOptions;
+ }
+
+ /**
+ * @return array>
+ * @throws InvalidConfigException
+ */
+ private function valuesByName(Product|int $product, array|string|null $only): array
{
if (is_int($product)) {
$product = Product::find()->id($product)->one();
@@ -34,10 +85,10 @@ public function getAttributeOptions(Product|int $product, array|string|null $onl
// Turn the attributes into associative arrays
$variants[] = array_reduce(
$variant->{$fieldHandle} ?? [],
- static function (array $carry, array $item) use ($only): array {
- $key = $item['attributeName'];
+ static function (array $carry, array $pair) use ($only): array {
+ $key = $pair['attributeName'];
if ($only === null || $only === [] || in_array($key, $only, true)) {
- $carry[$key] = $item['attributeValue'];
+ $carry[$key] = $pair['attributeValue'];
}
return $carry;
@@ -48,19 +99,12 @@ static function (array $carry, array $item) use ($only): array {
$merged = array_merge_recursive(...$variants);
- return array_map(static function ($name, $values): array {
- // Turn the value into an array if it isn't already one.
- if (! is_array($values)) {
- $values = [$values];
- }
-
- // Otherwise make sure the items are unique
- $values = array_values(array_unique($values));
+ $valuesByName = [];
+ foreach ($merged as $name => $values) {
+ // Wrap a lone value, since array_merge_recursive only nests on a repeated name
+ $valuesByName[$name] = array_values(array_unique(is_array($values) ? $values : [$values]));
+ }
- return [
- 'name' => $name,
- 'values' => $values,
- ];
- }, array_keys($merged), array_values($merged));
+ return $valuesByName;
}
}
diff --git a/src/services/VariantAttributes.php b/src/services/VariantAttributes.php
new file mode 100644
index 0000000..6721b18
--- /dev/null
+++ b/src/services/VariantAttributes.php
@@ -0,0 +1,525 @@
+|null
+ */
+ private ?array $attributesById = null;
+
+ private string|false|null $fieldHandle = null;
+
+ /**
+ * Attributes for the given names, indexed by name key.
+ *
+ * @param list $names
+ * @return array
+ */
+ public function getAttributesByNames(array $names, bool $includeTrashed = false): array
+ {
+ $nameKeys = [];
+
+ foreach ($names as $name) {
+ $nameKey = VariantAttribute::normalizeName($name);
+
+ if ($nameKey !== '') {
+ $nameKeys[$nameKey] = Db::escapeParam($nameKey);
+ }
+ }
+
+ if ($nameKeys === []) {
+ return [];
+ }
+
+ $attributes = [];
+
+ foreach (VariantAttribute::find()->nameKey(array_values($nameKeys))->trashed($includeTrashed ? null : false)->all() as $attribute) {
+ $attributes[$attribute->nameKey] = $attribute;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Get every attribute, indexed by ID.
+ *
+ * @return array
+ */
+ public function getAllAttributes(): array
+ {
+ if ($this->attributesById === null) {
+ $this->attributesById = [];
+
+ foreach (VariantAttribute::find()->all() as $attribute) {
+ $this->attributesById[$attribute->id] = $attribute;
+ }
+ }
+
+ return $this->attributesById;
+ }
+
+ public function getAttributeById(int $id): ?VariantAttribute
+ {
+ return $this->getAllAttributes()[$id] ?? null;
+ }
+
+ /**
+ * Get the distinct attribute name and value pairs stored on the given variants.
+ *
+ * @param Variant[] $variants
+ * @return array
+ */
+ public function attributePairs(array $variants): array
+ {
+ $pairs = [];
+
+ foreach ($variants as $variant) {
+ $fieldHandle = FieldHelper::getFirstVariantAttributesField($variant->getFieldLayout())?->handle;
+
+ if ($fieldHandle === null) {
+ continue;
+ }
+
+ $storedAttributes = $variant->{$fieldHandle};
+
+ // An unparseable JSON field value is the raw string
+ if (! is_array($storedAttributes)) {
+ continue;
+ }
+
+ foreach ($storedAttributes as $pair) {
+ // One malformed row would otherwise fail the whole import or backfill batch
+ if (! is_string($pair['attributeName'] ?? null) || ! is_string($pair['attributeValue'] ?? null)) {
+ continue;
+ }
+
+ $nameKey = VariantAttribute::normalizeName($pair['attributeName']);
+ $valueKey = VariantAttributeOption::normalizeValue($pair['attributeValue']);
+ $pairs["{$nameKey}\0{$valueKey}"] = $pair;
+ }
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Get the registry rows whose name or value no longer appears on any variant.
+ *
+ * @return array{attributes: list, options: list}
+ */
+ public function findOrphans(int $batchSize = 500): array
+ {
+ // Read the registry first: a row created during the scan is not an orphan
+ $attributes = VariantAttribute::find()->all();
+ $allOptions = VariantAttributeOption::find()->all();
+
+ $storedPairs = $this->storedPairs($batchSize);
+
+ $nameKeys = [];
+
+ foreach ($storedPairs as $pair) {
+ $nameKeys[VariantAttribute::normalizeName($pair['attributeName'])] = true;
+ }
+
+ $attributesById = [];
+ $orphanedAttributes = [];
+
+ foreach ($attributes as $attribute) {
+ $attributesById[$attribute->id] = $attribute;
+
+ if (! isset($nameKeys[$attribute->nameKey])) {
+ $orphanedAttributes[] = $attribute;
+ }
+ }
+
+ $orphanedOptions = [];
+
+ foreach ($allOptions as $option) {
+ $attribute = $attributesById[$option->attributeId] ?? null;
+ $pairKey = ($attribute?->nameKey ?? '') . "\0" . $option->valueKey;
+
+ if (! isset($storedPairs[$pairKey])) {
+ $orphanedOptions[] = $option;
+ }
+ }
+
+ return [
+ 'attributes' => $orphanedAttributes,
+ 'options' => $orphanedOptions,
+ ];
+ }
+
+ /**
+ * Registry rows for the given names and their values, indexed by name and then by raw value.
+ *
+ * @param array> $valuesByName
+ * @return array}>
+ */
+ public function getRegistry(array $valuesByName): array
+ {
+ $names = array_map(static fn (int|string $name): string => (string) $name, array_keys($valuesByName));
+ $attributes = $this->getAttributesByNames($names);
+
+ if ($attributes === []) {
+ return [];
+ }
+
+ $attributeIds = array_map(static fn (VariantAttribute $attribute): int => (int) $attribute->id, $attributes);
+
+ $optionsByAttributeId = [];
+
+ $valueKeys = [];
+
+ foreach ($valuesByName as $values) {
+ foreach ($values as $value) {
+ $valueKeys[VariantAttributeOption::normalizeValue($value)] = true;
+ }
+ }
+
+ $optionQuery = VariantAttributeOption::find()
+ ->attributeId(array_values($attributeIds))
+ ->valueKey(array_map(static fn (int|string $valueKey): string => Db::escapeParam((string) $valueKey), array_keys($valueKeys)));
+
+ foreach ($optionQuery->all() as $option) {
+ $optionsByAttributeId[$option->attributeId][$option->valueKey] = $option;
+ }
+
+ $registry = [];
+
+ foreach ($valuesByName as $name => $values) {
+ $attribute = $attributes[VariantAttribute::normalizeName((string) $name)] ?? null;
+
+ if (! $attribute instanceof VariantAttribute) {
+ continue;
+ }
+
+ $options = [];
+
+ foreach ($values as $value) {
+ $option = $optionsByAttributeId[$attribute->id][VariantAttributeOption::normalizeValue($value)] ?? null;
+
+ if ($option instanceof VariantAttributeOption) {
+ $options[$value] = $option;
+ }
+ }
+
+ $registry[(string) $name] = [
+ 'attribute' => $attribute,
+ 'options' => $options,
+ ];
+ }
+
+ return $registry;
+ }
+
+ /**
+ * Get a query for the variants storing the option's attribute name and value.
+ *
+ * The match is a JSON search over all variant content, so page or count rather than call all()
+ */
+ public function variantQueryForOption(VariantAttributeOption $option): ?VariantQuery
+ {
+ $attribute = $option->getVariantAttribute();
+
+ if ($attribute === null) {
+ return null;
+ }
+
+ $fieldHandle = $this->getVariantAttributesFieldHandle();
+
+ if ($fieldHandle === null) {
+ return null;
+ }
+
+ return VariantManagerVariant::find()
+ ->status(null)
+ ->{$fieldHandle}([
+ $attribute->name => $option->value,
+ ]);
+ }
+
+ /**
+ * Get how many variants store the option's attribute name and value.
+ *
+ * Cached against the variant element tag, so a variant save or delete invalidates it.
+ */
+ public function variantCountForOption(VariantAttributeOption $option): int
+ {
+ return Craft::$app->getCache()->getOrSet(
+ "variant-manager:option-usage:{$option->id}",
+ fn (): int => $this->variantQueryForOption($option)?->count() ?? 0,
+ null,
+ new TagDependency([
+ 'tags' => [
+ sprintf('element::%s::*', Variant::class),
+ sprintf('element::%s::*', VariantManagerVariant::class),
+ ],
+ ])
+ );
+ }
+
+ public function isOptionInUse(VariantAttributeOption $option): bool
+ {
+ return $this->variantQueryForOption($option)?->exists() ?? false;
+ }
+
+ public function isAttributeInUse(VariantAttribute $attribute): bool
+ {
+ foreach (VariantAttributeOption::find()->attributeId($attribute->id)->all() as $option) {
+ if ($this->isOptionInUse($option)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getVariantAttributesFieldHandle(): ?string
+ {
+ if ($this->fieldHandle === null) {
+ $this->fieldHandle = false;
+
+ foreach (Craft::$app->getFields()->getLayoutsByType(Variant::class) as $fieldLayout) {
+ $field = FieldHelper::getFirstVariantAttributesField($fieldLayout);
+
+ if ($field !== null) {
+ $this->fieldHandle = $field->handle;
+ break;
+ }
+ }
+ }
+
+ return $this->fieldHandle === false ? null : $this->fieldHandle;
+ }
+
+ /**
+ * Creates a registry row for each name that has none yet.
+ *
+ * @param list $names
+ * @return array
+ * @throws Throwable
+ */
+ public function ensureAttributes(array $names): array
+ {
+ // A trashed row keeps its unique name key, so the row is restored rather than replaced
+ $attributes = $this->getAttributesByNames($names, true);
+
+ foreach ($names as $name) {
+ $name = trim($name);
+ $nameKey = VariantAttribute::normalizeName($name);
+
+ if ($nameKey === '') {
+ continue;
+ }
+
+ if (isset($attributes[$nameKey])) {
+ $this->restoreIfTrashed($attributes[$nameKey]);
+ continue;
+ }
+
+ $attribute = new VariantAttribute();
+ $attribute->name = $name;
+ $attribute->title = $name;
+ $attribute->displayType = Plugin::getInstance()->getSettings()->getDefaultDisplayType()->value;
+
+ try {
+ Craft::$app->getElements()->saveElement($attribute, false);
+ } catch (IntegrityException) {
+ // Another process registered this name key first, so use its row
+ $attribute = $this->getAttributesByNames([$name], true)[$nameKey] ?? null;
+
+ if (! $attribute instanceof VariantAttribute) {
+ continue;
+ }
+
+ $this->restoreIfTrashed($attribute);
+ }
+
+ $attributes[$nameKey] = $attribute;
+
+ if ($this->attributesById !== null) {
+ $this->attributesById[$attribute->id] = $attribute;
+ }
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Creates an option row for each of an attribute's values that has none yet.
+ *
+ * @param list $values
+ * @throws Throwable
+ */
+ public function ensureOptions(VariantAttribute $attribute, array $values): void
+ {
+ $valueKeys = [];
+
+ foreach ($values as $value) {
+ $valueKey = VariantAttributeOption::normalizeValue($value);
+
+ if ($valueKey !== '') {
+ $valueKeys[$valueKey] = Db::escapeParam($valueKey);
+ }
+ }
+
+ if ($valueKeys === []) {
+ return;
+ }
+
+ $options = [];
+
+ // Filter to the given values so the query does not grow with the attribute's option count
+ // A trashed row keeps its unique value key, so the row is restored rather than replaced
+ $optionQuery = VariantAttributeOption::find()
+ ->attributeId($attribute->id)
+ ->valueKey(array_values($valueKeys))
+ ->trashed(null);
+
+ foreach ($optionQuery->all() as $option) {
+ $options[$option->valueKey] = $option;
+ }
+
+ foreach ($values as $value) {
+ $valueKey = VariantAttributeOption::normalizeValue($value);
+
+ if ($valueKey === '') {
+ continue;
+ }
+
+ if (isset($options[$valueKey])) {
+ $this->restoreIfTrashed($options[$valueKey]);
+ continue;
+ }
+
+ $option = new VariantAttributeOption();
+ $option->attributeId = $attribute->id;
+ $option->value = trim($value);
+ $option->title = trim($value);
+
+ try {
+ Craft::$app->getElements()->saveElement($option, false);
+ } catch (IntegrityException) {
+ // Another process registered this value key first, so use its row
+ $option = VariantAttributeOption::find()
+ ->attributeId($attribute->id)
+ ->valueKey(Db::escapeParam($valueKey))
+ ->trashed(null)
+ ->one();
+
+ if (! $option instanceof VariantAttributeOption) {
+ continue;
+ }
+
+ $this->restoreIfTrashed($option);
+ }
+
+ $options[$valueKey] = $option;
+ }
+ }
+
+ /**
+ * Registers every attribute name and option value in the given name/value pairs.
+ *
+ * @param array $pairs
+ * @throws Throwable
+ */
+ public function ensureFromAttributePairs(array $pairs): void
+ {
+ $valuesByName = [];
+
+ foreach ($pairs as $pair) {
+ $valuesByName[$pair['attributeName']][] = $pair['attributeValue'];
+ }
+
+ foreach ($valuesByName as $name => $values) {
+ $name = (string) $name;
+ $attribute = $this->ensureAttributes([$name])[VariantAttribute::normalizeName($name)] ?? null;
+
+ if ($attribute instanceof VariantAttribute) {
+ $this->ensureOptions($attribute, array_values(array_unique($values)));
+ }
+ }
+ }
+
+ /**
+ * Deletes every orphaned option and attribute.
+ *
+ * @param array{attributes: list, options: list}|null $orphans orphans already found, to skip a second scan
+ * @return array{attributes: int, options: int}
+ * @throws Throwable
+ */
+ public function pruneOrphans(int $batchSize = 500, ?array $orphans = null): array
+ {
+ $orphans ??= $this->findOrphans($batchSize);
+ $elementsService = Craft::$app->getElements();
+
+ // Options first: variant_manager_attribute_options.attributeId cascades on delete
+ // Hard delete, since a trashed row keeps its unique key and blocks re-registering the value
+ foreach ($orphans['options'] as $option) {
+ $elementsService->deleteElement($option, true);
+ }
+
+ $attributeConfigs = Plugin::getInstance()->getAttributeConfigs();
+ $projectConfig = Craft::$app->getProjectConfig();
+
+ foreach ($orphans['attributes'] as $attribute) {
+ if (! $elementsService->deleteElement($attribute, true)) {
+ continue;
+ }
+
+ // Remove the config here, after the delete commits, rather than from afterDelete()
+ if (! $projectConfig->readOnly) {
+ $attributeConfigs->remove($attribute->nameKey);
+ }
+ }
+
+ return [
+ 'attributes' => count($orphans['attributes']),
+ 'options' => count($orphans['options']),
+ ];
+ }
+
+ /**
+ * Get every attribute name and value pair stored on any variant.
+ *
+ * @return array
+ */
+ private function storedPairs(int $batchSize = 500): array
+ {
+ $pairs = [];
+
+ foreach (Variant::find()->status(null)->batch($batchSize) as $variants) {
+ $pairs = [...$pairs, ...$this->attributePairs($variants)];
+ }
+
+ return $pairs;
+ }
+
+ private function restoreIfTrashed(ElementInterface $element): void
+ {
+ if ($element->dateDeleted !== null) {
+ Craft::$app->getElements()->restoreElement($element);
+ }
+ }
+}
diff --git a/src/templates/_settings.twig b/src/templates/_settings.twig
deleted file mode 100644
index a6b71f0..0000000
--- a/src/templates/_settings.twig
+++ /dev/null
@@ -1,6 +0,0 @@
-{# @var plugin \fostercommerce\variantmanager\VariantManagert #}
-{# @var settings \fostercommerce\variantmanager\models\Settings #}
-
-{% import '_includes/forms.twig' as forms %}
-
-{# ... #}
diff --git a/src/templates/_utilities/attributes.twig b/src/templates/_utilities/attributes.twig
new file mode 100644
index 0000000..eb05213
--- /dev/null
+++ b/src/templates/_utilities/attributes.twig
@@ -0,0 +1,27 @@
+
+ {% endif %}
+{% endblock %}
diff --git a/src/translations/en/variant-manager.php b/src/translations/en/variant-manager.php
index 7d2a560..683b1ba 100644
--- a/src/translations/en/variant-manager.php
+++ b/src/translations/en/variant-manager.php
@@ -1,10 +1,9 @@
'Could not update one or more variants.',
'That field cannot be bulk edited.' => 'That field cannot be bulk edited.',
'You do not have permission to bulk edit variants.' => 'You do not have permission to bulk edit variants.',
+
+ // Importing
+ 'import.missingSkuColumn' => 'The CSV has no “sku” column.',
+
+ // Variant Attributes field
+ 'field.nameHeading' => 'Name',
+ 'field.valueHeading' => 'Value',
+ 'field.multipleFieldsWarning' => 'Multiple Variant Attributes fields have been applied.',
+ 'field.multipleFieldsIgnored' => 'This field will be ignored.',
+
+ // Variant attributes
+ 'attributes.attribute' => 'Variant Attribute',
+ 'attributes.attributeLower' => 'variant attribute',
+ 'attributes.attributes' => 'Variant Attributes',
+ 'attributes.attributesLower' => 'variant attributes',
+ 'attributes.allAttributes' => 'All attributes',
+ 'attributes.name' => 'CSV Name',
+ 'attributes.noAttributes' => 'No attributes yet. They appear here once an import or the backfill utility has run.',
+ 'attributes.notFound' => 'Attribute not found.',
+ 'attributes.settingsIntro' => 'Each attribute is a group of options sharing one display and one set of fields, such as paint chips or shirt sizes. Choose how a storefront renders it, and add any fields its options need.',
+ 'attributes.filterLabel' => 'Variant Attribute: {attribute}',
+ 'attributes.activityCreated' => 'Created attribute {name}',
+ 'attributes.deleteInUse' => 'Variants still use this attribute. Remove it from your CSV and reimport, then delete it.',
+ 'options.activityCreated' => 'Created option {value} under {attribute}',
+ 'options.deleteInUse' => 'Variants still use this value. Remove it from your CSV and reimport, then delete it.',
+ 'attributes.displayType' => 'Display Type',
+ 'attributes.attributeLayout' => 'Attribute Fields',
+ 'attributes.attributeLayoutInstructions' => 'Fields on this attribute, such as a note covering all of its options.',
+ 'attributes.optionLayout' => 'Option Fields',
+ 'attributes.optionLayoutInstructions' => 'Fields on each of this attribute’s options, such as a spec sheet.',
+ 'settings.availableDisplayTypes' => 'Available Display Types',
+ 'settings.availableDisplayTypesIntro' => 'Display types an attribute can be set to.',
+ 'settings.defaultDisplayType' => 'Default Display Type',
+ 'settings.defaultDisplayTypeIntro' => 'Display type given to an attribute the first time an import or the backfill registers it.',
+ 'settings.emptyVariantFieldMap' => 'No variant fields are mapped. Set “variantFieldMap” in config/variant-manager.php.',
+ 'settings.saved' => 'Settings saved.',
+ 'settings.saveFailed' => 'Couldn’t save settings.',
+ 'settings.overriddenByConfig' => 'This is being overridden by the {setting} setting in config/variant-manager.php.',
+ 'attributes.settingsSaved' => 'Attribute settings saved.',
+ 'attributes.settingsSaveFailed' => 'Couldn’t save attribute settings.',
+ 'attributes.utilityTitle' => 'Variant Attributes',
+ 'attributes.backfillTitle' => 'Backfill',
+ 'attributes.backfillIntro' => 'Reads every variant and registers any attribute name or option value that has no row yet. Runs in the queue and can be run again at any time.',
+ 'attributes.backfillStart' => 'Start backfill',
+ 'attributes.backfillQueued' => 'Variant attribute backfill queued.',
+ 'attributes.pruneTitle' => 'Prune orphans',
+ 'attributes.pruneIntro' => 'Deletes any attribute or option whose name or value is no longer stored on a variant, along with its fields. Deleting an attribute also removes its display type and field layouts.',
+ 'attributes.pruneStart' => 'Prune orphans',
+ 'attributes.pruneConfirm' => 'Delete every attribute and option no longer stored on a variant, along with their fields? This can’t be undone.',
+ 'attributes.pruneQueued' => 'Variant attribute orphan prune queued.',
+
+ // Jobs
+ 'jobs.backfillAttributes' => 'Backfilling variant attributes',
+ 'jobs.pruneAttributeOrphans' => 'Pruning orphaned variant attributes',
+
+ // Variant attribute options
+ 'options.option' => 'Attribute Option',
+ 'options.optionLower' => 'attribute option',
+ 'options.options' => 'Attribute Options',
+ 'options.optionsLower' => 'attribute options',
+ 'options.allOptions' => 'All options',
+ 'options.value' => 'CSV Value',
+ 'options.attribute' => 'Attribute',
+ 'options.usedBy' => 'Used by',
+ 'options.variantCount' => '{count, plural, =0{No variants} =1{1 variant} other{# variants}}',
+
+ // Display types
+ 'displayTypes.dropdown' => 'Dropdown',
+ 'displayTypes.radioButtons' => 'Radio buttons',
+ 'displayTypes.textButtons' => 'Text buttons',
+ 'displayTypes.imageSwatches' => 'Image swatches',
+ 'displayTypes.colorSwatches' => 'Color swatches',
+ 'displayTypes.lightswitch' => 'Lightswitch',
+
+ // Permissions
+ 'permissions.manageAttributes' => 'Manage variant attributes',
];
diff --git a/src/utilities/AttributesUtility.php b/src/utilities/AttributesUtility.php
new file mode 100644
index 0000000..9f6c681
--- /dev/null
+++ b/src/utilities/AttributesUtility.php
@@ -0,0 +1,29 @@
+getView()->renderTemplate('variant-manager/_utilities/attributes');
+ }
+}
diff --git a/stubs/CustomFieldBehavior.php b/stubs/CustomFieldBehavior.php
new file mode 100644
index 0000000..311c5f0
--- /dev/null
+++ b/stubs/CustomFieldBehavior.php
@@ -0,0 +1,16 @@
+
+ */
+ public static $fieldHandles = [];
+}