From b9cf5a9b62dd9d6cfa49585cbc8f1ba37b1ce164 Mon Sep 17 00:00:00 2001 From: Stephen Callender Date: Tue, 15 Sep 2026 01:45:13 -0400 Subject: [PATCH] Add the Variant Maker --- .gitignore | 1 + CHANGELOG.md | 6 +- README.md | 12 +- docs/index.md | 1 + docs/installation.md | 2 + docs/reference/configuration.md | 20 +- docs/reference/console-commands.md | 21 + docs/roadmap.md | 25 + docs/upgrade.md | 2 + docs/user-guide/variant-attributes.md | 2 +- docs/user-guide/variant-maker.md | 131 +++ src/Plugin.php | 125 ++- src/VariantMakerAssetBundle.php | 28 + src/assets/css/variant-maker.css | 166 +++ src/assets/js/variant-maker.js | 250 +++++ src/config.php | 1 + .../controllers/VariantMakerController.php | 141 +++ src/controllers/SettingsController.php | 9 + src/controllers/VariantMakerController.php | 123 +++ src/db/Table.php | 2 + src/elements/VariantAttribute.php | 53 +- src/elements/db/VariantAttributeQuery.php | 2 + src/fieldlayoutelements/VariantMakerTab.php | 45 + src/jobs/GenerateVariants.php | 62 ++ src/migrations/Install.php | 15 + ...60914_200000_add_variant_maker_columns.php | 17 + ...0915_090000_create_variant_maker_table.php | 27 + src/models/Settings.php | 17 + src/models/VariantMakerPlanRow.php | 63 ++ src/models/VariantMakerProperty.php | 15 + src/models/VariantMakerSettings.php | 247 +++++ src/records/VariantAttribute.php | 2 + src/records/VariantMaker.php | 19 + src/services/Csv.php | 48 +- src/services/VariantAttributes.php | 22 + src/services/VariantMaker.php | 977 ++++++++++++++++++ src/templates/settings/index.twig | 11 + src/templates/variant-maker/_change.twig | 10 + src/templates/variant-maker/_preview.twig | 61 ++ src/templates/variant-maker/_row.twig | 46 + src/templates/variant-maker/tab.twig | 142 +++ src/translations/en/variant-manager.php | 78 ++ 42 files changed, 3031 insertions(+), 16 deletions(-) create mode 100644 docs/roadmap.md create mode 100644 docs/user-guide/variant-maker.md create mode 100644 src/VariantMakerAssetBundle.php create mode 100644 src/assets/css/variant-maker.css create mode 100644 src/assets/js/variant-maker.js create mode 100644 src/console/controllers/VariantMakerController.php create mode 100644 src/controllers/VariantMakerController.php create mode 100644 src/fieldlayoutelements/VariantMakerTab.php create mode 100644 src/jobs/GenerateVariants.php create mode 100644 src/migrations/m260914_200000_add_variant_maker_columns.php create mode 100644 src/migrations/m260915_090000_create_variant_maker_table.php create mode 100644 src/models/VariantMakerPlanRow.php create mode 100644 src/models/VariantMakerProperty.php create mode 100644 src/models/VariantMakerSettings.php create mode 100644 src/records/VariantMaker.php create mode 100644 src/services/VariantMaker.php create mode 100644 src/templates/variant-maker/_change.twig create mode 100644 src/templates/variant-maker/_preview.twig create mode 100644 src/templates/variant-maker/_row.twig create mode 100644 src/templates/variant-maker/tab.twig diff --git a/.gitignore b/.gitignore index 82195fd..6402b12 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ composer.lock .php_cs.cache .idea .DS_Store +.twig-parse.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eaa28b..827defd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,15 @@ # Release Notes for Variant Manager -## 4.0.0 - 2026-09-14 +## 4.0.0 - 2026-09-15 > {tip} This release removes the `VariantAttributeOption` element type. See [upgrading to 4.x](./docs/upgrade.md) before updating. ### Added +- Added the Variant Maker, which builds a product's variants from the attributes and options you pick, with a preview of what generating would change. +- Added a “Variant Maker Product Types” setting, for choosing which product types offer the Variant Maker. No product type offers it by default. +- Added a “SKU Partial” and a “Price Modifier” to each attribute option, which the Variant Maker assembles into a generated variant's SKU and price. +- Added a `variant-manager/variant-maker/plan` command, for previewing a plan from the command line. - Added drag ordering to the “Variant Attributes” listing, for arranging an attribute's options in the order a storefront should render them. - Added a `resave/variant-attributes` command, for re-saving attributes and options and rewriting their search keywords. diff --git a/README.md b/README.md index 8f93bfc..100af2d 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ A Craft CMS plugin that imports and exports Craft Commerce product **variants** - 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 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. +- Adds a **Variant Attributes** field that stores option name and value pairs (Color, Size, Material) on each variant for filtering on the storefront, and lets you attach a swatch image, spec sheet or note to any value to build a color picker or size swatch in Twig. +- Generates a product's full variant matrix in the control panel from attribute options you pick (three sizes and four colors become twelve variants, each with its own SKU). - 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. @@ -35,6 +35,14 @@ Upload a CSV (or a zip of CSVs) from **Variant Manager -> Dashboard**. The CSV's See [`docs/user-guide/importing.md`](./docs/user-guide/importing.md) and [`docs/user-guide/csv-format.md`](./docs/user-guide/csv-format.md). +## Variant Maker + +Builds a product's variants from attributes and options you already have, for stores with no ERP, PIM or CSV feed. Pick the attributes to combine and set what each variant should get. The preview lists every combination and what generating would change, before anything is written. An option can carry a SKU partial and a price modifier, which the generated variant's SKU and price are assembled from. + +Off for every product type until you turn it on at **Settings -> Plugins -> Variant Manager**. + +See [`docs/user-guide/variant-maker.md`](./docs/user-guide/variant-maker.md). + ## Exporting 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. diff --git a/docs/index.md b/docs/index.md index 38355db..4963ca9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,7 @@ Coming from 2.x or 3.x? See [upgrading](./upgrade.md). - [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. +- [Variant Maker](./user-guide/variant-maker.md), building a product's variant matrix from attributes and options. - [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/): diff --git a/docs/installation.md b/docs/installation.md index d421e37..ffba0e7 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -65,6 +65,8 @@ Those values are the defaults, so the file above changes nothing. See [configura 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). +The same screen carries **Variant Maker product types**, which decides where the Variant Maker tab appears. No product type offers it by default. See [Variant Maker](./user-guide/variant-maker.md). + ## Add the Variant Attributes field The plugin ships a **Variant Attributes** field type. Add it to every Commerce product type whose variants you want to import or export by attribute. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9649f82..f0bbdc2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -12,6 +12,7 @@ return [ 'attributePrefix' => 'Attribute: ', 'inventoryPrefix' => 'Inventory', 'activityLogRetention' => '30 days', + 'variantMakerProductTypes' => [], 'productFieldMap' => [ '*' => [ 'title' => 'title', @@ -185,7 +186,24 @@ Valid values are `dropdown`, `radioButtons`, `textButtons`, `imageSwatches`, `co 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. +This setting is also editable at **Settings** -> **Plugins** -> **Variant Manager**. A value here overrides what that screen saves, and the control shows a warning saying so. + +### `variantMakerProductTypes` + +- Type: `list` +- Default: `[]` + +Handles of the Commerce product types whose products offer the [Variant Maker](../user-guide/variant-maker.md) tab. While this is empty, no product type offers it. + +```php +return [ + 'variantMakerProductTypes' => ['catalog', 'apparel'], +]; +``` + +A product type also needs a Variant Attributes field in its variant field layout, since that is where a generated variant stores its combination. Listing a product type without one leaves the tab hidden. + +This setting is also editable at **Settings** -> **Plugins** -> **Variant Manager**. A value here overrides what that screen saves, and the control shows a warning saying so. ### `defaultDisplayType` diff --git a/docs/reference/console-commands.md b/docs/reference/console-commands.md index 3973063..c1bd471 100644 --- a/docs/reference/console-commands.md +++ b/docs/reference/console-commands.md @@ -75,3 +75,24 @@ Deletes them. Anything a variant has started using since the scan is skipped. An | `--batchSize` | `500` | Variants read per batch. | See [variant attributes](../user-guide/variant-attributes.md). + +## `variant-manager/variant-maker/plan` + +Print what generating a set of combinations would do to a product's variants. + +```sh +./craft variant-manager/variant-maker/plan 4054 --select="Container Size=1 Quart,1 Gallon;CSP Color=Custom" +``` + +The first argument is the product ID. Prints one line per combination with its status, the SKU and the price, and any SKU warning under it. Saves nothing. + +The selection is read from `--select` rather than the product's saved builder. + +| Option | Default | Description | +|--------|---------|-------------| +| `--select` | `''` | Attributes and values to combine, as `Name=A,B;Other Name=C`. | +| `--mode` | `add` | One of `add`, `update` or `replace`. Only `replace` differs here, since the command includes every property on new variants only. | +| `--skuFormat` | none | SKU format. Blank uses the default variant SKU followed by each option. | +| `--basePrice` | none | Price each combination starts from. Blank uses the product's default variant price. | + +See [Variant Maker](../user-guide/variant-maker.md). diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..809cfc7 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,25 @@ +# Roadmap + +Internal working notes. Not linked from the docs index. + +## Variant Maker + +### Create attributes and options from the builder + +Today a builder row can only pick from attributes and options the registry already holds. Creating either one means leaving the product and going to **Variant Manager -> Variant Attributes** first. + +Deferred 2026-09-15: creating them elsewhere first is little enough work that it does not justify a second creation path in the builder. + +If it is picked up, the shape to match is Craft's element select, which offers a create option inside the selection modal. The row partial already renders two `forms.elementSelect` fields, so the work is a create action for `VariantAttribute` that respects the row's `attributeId` criteria, plus the field layout an attribute or option carries. + +### Editable preview table + +The preview renders what generating would do. Making its cells editable would let a merchant correct one title, SKU or price before generating, the way the CSV importer lets them edit a file. + +Deferred 2026-09-15. + +Open questions if it is picked up: + +- Edits are keyed by combination. A changed builder row invalidates some of them, so either the edits merge by combination key and survive, or they are discarded whenever the plan changes. Merging is the useful behavior and the harder one. +- Editing turns the plan into stored state. It currently derives from the settings on every request, which is what keeps the preview honest about the current builder. +- A large plan is already one HTML response with no pagination. Editable cells multiply the cost per row. diff --git a/docs/upgrade.md b/docs/upgrade.md index fc1bc53..4092f0f 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -46,6 +46,8 @@ If anything writes attribute values to variants outside the control panel, run t ./craft variant-manager/attributes/backfill ``` +4.0.0 adds the [Variant Maker](./user-guide/variant-maker.md). No product type offers it until you turn it on at **Settings -> Plugins -> Variant Manager**, so an upgrade changes nothing on its own. + ## Upgrading to 3.x Existing templates keep working. This page is the setup the update needs, plus one change to the Variant Attributes field. diff --git a/docs/user-guide/variant-attributes.md b/docs/user-guide/variant-attributes.md index 4e683fd..bc8f1fa 100644 --- a/docs/user-guide/variant-attributes.md +++ b/docs/user-guide/variant-attributes.md @@ -56,7 +56,7 @@ Values come from the variant's own stored data, so the card costs no extra queri **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. +**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 overrides what that screen saves. **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. diff --git a/docs/user-guide/variant-maker.md b/docs/user-guide/variant-maker.md new file mode 100644 index 0000000..f112e3a --- /dev/null +++ b/docs/user-guide/variant-maker.md @@ -0,0 +1,131 @@ +# Variant Maker + +Build a product's whole variant matrix in the control panel, from attributes and options you already have. + +This suits stores with no ERP, PIM or CSV feed. + +## Turning it on + +Off for every product type by default. + +Go to **Settings -> Plugins -> Variant Manager** and check the product types that should offer it. A developer can also set [`variantMakerProductTypes`](../reference/configuration.md#variantmakerproducttypes), which overrides what that screen saves. + +The product type also needs a Variant Attributes field in its variant field layout. Without one the tab stays hidden, even when the product type is checked. + +A saved product then shows a **Variant Maker** tab, last in the row. A brand new product shows the tab with a note to save first. + +## Attributes to combine + +One row per attribute. Pick the attribute, then pick as many of its options as you want. + +Every option in a row is combined with every option in every other row. Three sizes, four colors and two finishes make 24 combinations. + +The options list is limited to the attribute chosen in that row, so changing a row's attribute clears the options under it. + +A row needs both an attribute and at least one option. Leave either empty and the product will not save, with the row number named in the error. + +Row order sets the order the SKU partials assemble in, for the default SKU format below. + +## What to do with existing variants + +This is the only control over whether an existing variant is rewritten. + +| Mode | What it does | +|------|--------------| +| **Add missing only** | Creates the combinations that do not exist yet. Never touches an existing variant. | +| **Add and update existing** | Also rewrites existing variants with every included property. | +| **Replace: add, update and delete** | Does both, and deletes any variant whose combination is not in the generated set. | + +Replace deletes variants. The preview lists each one as **Delete** before you generate. + +## Variant properties + +Each property is a row in the table with two controls: + +- **Include**: whether the Variant Maker sets this property. Off means a new variant gets Commerce's default and an existing variant keeps what it has. A new variant is always titled either way, falling back to its combination. +- **Value**: a format, a number or a switch, depending on the property. + +SKU and price are always included, so their switches are on and disabled. Commerce requires both on every variant. + +| Property | Value | +|----------|-------| +| Title | Format. Hidden where the product type builds variant titles from its own format. | +| SKU | Format. | +| Price | The price each combination starts from, before its options' price modifiers. | +| Track inventory | Switch. | +| Stock | Number. Shown only while **Track inventory** is included and on. | +| Allow out of stock purchases | Switch. Shown on the same condition as Stock. | +| Available for purchase | Switch. | +| Free shipping | Switch. | +| Promotable | Switch. | + +These are not set here, so edit them on the Variants tab after generating: dimensions, weight, tax and shipping categories, minimum and maximum quantity, promotional price, and custom fields. + +Where the store has more than one inventory location, an **Inventory location** menu appears under the table. Stock is written to the location you pick. + +### Title and SKU formats + +In a format, `{Attribute Name}` becomes the option chosen under that attribute. Anything else is used as typed, so a product code goes in literally. + +```text +M110-{Container Size}-{CSP Color}-{Mortar Type} +``` + +Each attribute contributes its option's **SKU Partial** where the option has one, and the option's own value where it does not. Set SKU partials at **Variant Manager -> Variant Attributes**, or from the slideout that opens when you click an option chip. + +Leave a format blank for the default: + +- A title is the combination joined by a slash: `1 Quart / Custom / Type N`. +- A SKU is the product's default variant SKU followed by each option, joined by dashes. Spaces become dashes. + +A format you type is used exactly as written, spaces included. + +### Price modifiers + +Each option can carry a **Price Modifier**, set in the same place as its SKU partial. A combination's price is the base price plus the modifier of every option in it. Both are held to two decimal places. + +## Preview + +The table below the form updates as you change the builder. It lists every combination and what generating would do to it. + +| Status | Meaning | +|--------|---------| +| **Create** | No variant has this combination yet. | +| **Update** | A variant exists, the mode allows updates, and at least one included property would change it. A stock count on its own counts. | +| **Unchanged** | A variant exists and nothing the Variant Maker manages would change. | +| **Delete** | Replace mode only. The variant's combination is not in the generated set. | + +A column shows `old -> new` only where the run would write that value. An excluded property shows **Commerce default** on a Create row, and **Kept** on every other row. The Stock column instead shows **Unlimited** where inventory is not tracked, and **None** where it is tracked with no count set. + +The preview reads the builder as it stands on screen, not what was last saved. + +### SKU warnings + +Commerce requires every SKU to be unique across the store and no longer than 255 characters. A row that breaks either rule is flagged in red under its SKU, and **Generate variants** refuses to run while any row is flagged. + +| Warning | Cause | +|---------|-------| +| Another row builds this same SKU | Two combinations resolve to the same SKU, or one matches a variant on this product the run leaves alone. Usually a format with no `{Attribute Name}` token, or two options sharing a SKU partial. | +| A variant on another product already uses this SKU | The SKU is taken elsewhere in the store. Comparison ignores case. | +| Longer than 255 characters | The assembled SKU is over the limit Commerce enforces. | + +## Saving and generating + +Saving the product saves the builder. It does not create variants. + +The settings are stored per product, so each product keeps its own attributes, mode and properties. + +**Generate variants** runs the job in the background and returns you to the page. Watch **Variant Manager -> Dashboard** for the result. It records how many variants were created, updated and deleted, or the error if the run failed. + +Generating uses the saved settings, so save the product before pressing it. A product with unsaved changes says so instead of running. + +The whole run is one transaction. If any variant fails to save, nothing is written and the activity log records why. + +The preview does not refresh itself when the job finishes. Reload the page to see the new variants. + +## Related + +- [Variant attributes](./variant-attributes.md), where SKU partials and price modifiers are set +- [Activity log](./activity-log.md), where a generation result is recorded +- [Console commands](../reference/console-commands.md), previewing a plan from the command line +- [Configuration reference](../reference/configuration.md#variantmakerproducttypes) diff --git a/src/Plugin.php b/src/Plugin.php index 0abeda1..fd9c720 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -12,6 +12,7 @@ use craft\console\Controller as ConsoleController; use craft\console\controllers\ResaveController; use craft\elements\conditions\ElementCondition; +use craft\events\CreateFieldLayoutFormEvent; use craft\events\DefineConsoleActionsEvent; use craft\events\DefineFieldLayoutFieldsEvent; use craft\events\DefineHtmlEvent; @@ -26,6 +27,7 @@ use craft\helpers\ElementHelper; use craft\helpers\UrlHelper; use craft\models\FieldLayout; +use craft\models\FieldLayoutTab; use craft\services\Elements; use craft\services\Fields; use craft\services\Gc; @@ -40,13 +42,16 @@ use fostercommerce\variantmanager\elements\conditions\VariantAttributeConditionRule; use fostercommerce\variantmanager\elements\VariantAttribute; use fostercommerce\variantmanager\elements\VariantManagerVariant; +use fostercommerce\variantmanager\fieldlayoutelements\VariantMakerTab; use fostercommerce\variantmanager\fields\VariantAttributesField; +use fostercommerce\variantmanager\helpers\FieldHelper; 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 fostercommerce\variantmanager\services\VariantAttributes; +use fostercommerce\variantmanager\services\VariantMaker; use fostercommerce\variantmanager\utilities\AttributesUtility; use yii\base\Event; use yii\di\Instance; @@ -64,12 +69,15 @@ * @property-read Csv $csv * @property-read ActivityLogs $activityLogs * @property-read VariantAttributes $variantAttributes + * @property-read VariantMaker $variantMaker * @property-read AttributeConfigs $attributeConfigs * @property-read null|array $cpNavItem */ class Plugin extends BasePlugin { - public string $schemaVersion = '1.5.0'; + private const VARIANT_MAKER_TAB_UID = 'f05d5b7a-9a3e-4a2f-9f4e-6b1c2d3e4f50'; + + public string $schemaVersion = '1.7.0'; public bool $hasCpSettings = true; @@ -122,6 +130,12 @@ public function getCpNavItem(): ?array return $nav; } + public function getVariantMaker(): VariantMaker + { + /** @var VariantMaker */ + return $this->get('variantMaker'); + } + public function getVariantAttributes(): VariantAttributes { /** @var VariantAttributes */ @@ -168,6 +182,8 @@ private function attachEventHandlers(): void $this->registerConditionRules(); $this->registerElements(); $this->registerNativeFields(); + $this->registerVariantMakerTab(); + $this->registerVariantMakerSettings(); $this->registerUtilities(); $this->registerEvents(); } @@ -281,6 +297,7 @@ private function registerComponents(): void 'csv' => Csv::class, 'activityLogs' => ActivityLogs::class, 'variantAttributes' => VariantAttributes::class, + 'variantMaker' => VariantMaker::class, 'attributeConfigs' => AttributeConfigs::class, ]); } @@ -320,6 +337,112 @@ static function (RegisterComponentTypesEvent $registerComponentTypesEvent): void ); } + private function registerVariantMakerSettings(): void + { + Event::on( + Product::class, + Product::EVENT_AFTER_VALIDATE, + static function (Event $_event): void { + $product = $_event->sender; + + if (! $product instanceof Product) { + return; + } + + $settings = self::isProductSave() ? Plugin::getInstance()->getVariantMaker()->postedSettings() : null; + + if ($settings === null || $settings->validate()) { + return; + } + + foreach ($settings->getErrors() as $errors) { + foreach ($errors as $error) { + $product->addError('variantMaker', $error); + } + } + } + ); + + Event::on( + Elements::class, + Elements::EVENT_AFTER_SAVE_ELEMENT, + static function (ElementEvent $elementEvent): void { + $product = $elementEvent->element; + + // An autosaved draft would otherwise store the builder on every keystroke + if (! $product instanceof Product || ElementHelper::isDraftOrRevision($product)) { + return; + } + + $settings = Plugin::getInstance()->getVariantMaker()->postedSettings(); + + if ($settings !== null) { + Plugin::getInstance()->getVariantMaker()->saveSettings($product, $settings); + } + } + ); + } + + /** + * Craft validates a clone of the canonical product to create a draft, so an invalid builder would block + * every autosave rather than the save the merchant asked for. + */ + private static function isProductSave(): bool + { + $request = Craft::$app->getRequest(); + + // Skip a console request, since only a web request routes by action segments + if ($request->getIsConsoleRequest()) { + return false; + } + + return in_array(implode('/', $request->getActionSegments() ?? []), [ + 'elements/save', + 'elements/apply-draft', + ], true); + } + + private function registerVariantMakerTab(): void + { + Event::on( + FieldLayout::class, + FieldLayout::EVENT_CREATE_FORM, + static function (CreateFieldLayoutFormEvent $createFieldLayoutFormEvent): void { + $product = $createFieldLayoutFormEvent->element; + + if (! $product instanceof Product) { + return; + } + + // A revision is a snapshot, so generating variants from one has nothing to act on + if ($product->getIsRevision()) { + return; + } + + $productType = $product->getType(); + + if (! Plugin::getInstance()->getSettings()->offersVariantMaker($productType->handle)) { + return; + } + + // A product type with no attributes field gives the generated variants nowhere to store a combination + if (FieldHelper::getFirstVariantAttributesField($productType->getVariantFieldLayout()) === null) { + return; + } + + // setElements() reads the tab's layout, so it has to be configured before them + // Give the tab a stable uid, or the element editor's re-render mismaps every tab + $createFieldLayoutFormEvent->tabs[] = new FieldLayoutTab([ + 'layout' => $createFieldLayoutFormEvent->sender, + 'uid' => self::VARIANT_MAKER_TAB_UID, + 'name' => Craft::t('variant-manager', 'variantMaker.name'), + 'sortOrder' => count($createFieldLayoutFormEvent->tabs) + 1, + 'elements' => [new VariantMakerTab()], + ]); + } + ); + } + private function registerNativeFields(): void { Event::on( diff --git a/src/VariantMakerAssetBundle.php b/src/VariantMakerAssetBundle.php new file mode 100644 index 0000000..c910a53 --- /dev/null +++ b/src/VariantMakerAssetBundle.php @@ -0,0 +1,28 @@ +sourcePath = '@fostercommerce/variantmanager/assets'; + + $this->depends = [ + CpAsset::class, + ]; + + $this->css = [ + 'css/variant-maker.css', + ]; + + $this->js = [ + 'js/variant-maker.js', + ]; + + parent::init(); + } +} diff --git a/src/assets/css/variant-maker.css b/src/assets/css/variant-maker.css new file mode 100644 index 0000000..0de243d --- /dev/null +++ b/src/assets/css/variant-maker.css @@ -0,0 +1,166 @@ +.vm-rows { + border-top: 1px solid var(--hairline-color); +} + +.vm-row { + align-items: center; + border-bottom: 1px solid var(--hairline-color); + column-gap: 16px; + display: grid; + grid-template-columns: minmax(150px, 14rem) minmax(200px, 1fr) 32px; + padding: 8px 16px 8px 0; + row-gap: 2px; +} + +.vm-row--head { + border-bottom: 0; + color: var(--medium-text-color); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0 16px 6px 0; + text-transform: uppercase; +} + +.vm-row:not(.vm-row--head):hover { + background-color: var(--gray-050); +} + +/* Grid tracks grow to their content's automatic minimum, so a long chip widens the whole column */ +.vm-row__cell, +.vm-row .elementselect, +.vm-row .elements, +.vm-row .elements li { + min-width: 0; +} + +.vm-row .chip { + max-width: 100%; + overflow: hidden; +} + +.vm-row .elementselect { + align-items: center; + display: flex; + flex-flow: row wrap; + gap: var(--xs); + margin: 0; +} + +.vm-row .elementselect > * { + margin: 0; +} + +/* Collapse an empty chip list, so its row's add button lines up with the rest */ +.vm-row .elementselect > ul:empty { + display: none; +} + +/* Clear the chip list's indent, so chips align with an add button on its own */ +.vm-row .elementselect > ul { + list-style: none; + padding: 0; +} + +.vm-row .elementselect > ul > li { + margin: 0; + padding: 0; +} + +.vm-row .elementselect .btn.add, +.vm-row .chip { + margin: 0; +} + +/* Size the add button to its label, so it sits beside the chips */ +.vm-row .elementselect .btn.add { + display: inline-flex; + width: auto; +} + +/* Drop the gap above the add button, so every row's controls share one baseline */ +.vm-row .elementselect .elements:not(:empty) + .flex { + margin-block-start: 0; +} + +.vm-row__cell--delete { + text-align: right; +} + +.vm-row__cell--delete .delete { + margin: 0; + padding: 4px; + opacity: 0; + transition: opacity 0.1s ease-in-out; +} + +.vm-row:hover .vm-row__cell--delete .delete, +.vm-row .vm-row__cell--delete .delete:focus-visible { + opacity: 1; +} + +.vm-rows + .btn.add { + margin-top: 10px; +} + +.vm-properties th[scope="row"] { + width: 14rem; +} + +/* A lightswitch needs no room, so Value takes the rest of the table */ +.vm-properties td[data-vm-include] { + width: 5rem; +} + +.vm-properties td { + vertical-align: middle; +} + +.vm-properties .lightswitch { + margin: 0; +} + +.vm-status { + border-radius: 3px; + display: inline-block; + font-size: 12px; + margin-right: 8px; + padding: 2px 8px; +} + +.vm-status--create { + background: var(--green-100); + color: var(--green-800); +} + +.vm-status--update { + background: var(--blue-100); + color: var(--blue-800); +} + +.vm-status--unchanged { + background: var(--gray-100); + color: var(--gray-700); +} + +.vm-status--delete { + background: var(--red-100); + color: var(--red-800); +} + +.vm-generate { + align-items: center; + display: flex; + gap: 10px; + margin-top: 16px; +} + +.vm-sku-issues { + margin-bottom: 10px; +} + +.vm-sku-issue { + color: var(--red-600); + display: block; + font-size: 12px; +} diff --git a/src/assets/js/variant-maker.js b/src/assets/js/variant-maker.js new file mode 100644 index 0000000..beb0e4a --- /dev/null +++ b/src/assets/js/variant-maker.js @@ -0,0 +1,250 @@ +(function ($) { + Craft.VariantManager = Craft.VariantManager || {}; + + Craft.VariantManager.VariantMaker = Garnish.Base.extend({ + container: null, + rows: null, + preview: null, + productId: null, + nextRowId: 1, + pending: null, + requestId: 0, + + init: function (container) { + this.container = container; + this.rows = container.querySelector('[data-vm-rows]'); + this.preview = container.querySelector('[data-vm-preview]'); + this.productId = container.dataset.vmProductId; + this.nextRowId = this.rows.querySelectorAll('[data-vm-row]').length + 1; + + this.addListener(container, 'click', 'onClick'); + this.addListener(container, 'input', 'scheduleRefresh'); + // Craft's lightswitch is a button and fires its change through jQuery, so bind with jQuery + $(container).on('change', this.onChange.bind(this)); + + this.watchElementSaves(); + this.syncInventoryRows(); + this.refresh(); + }, + + /** + * A SKU partial or price modifier edited in a slideout changes what the plan would build. + */ + watchElementSaves: function () { + // Craft only opens the channel where the browser supports BroadcastChannel + if (!Craft.messageReceiver) { + return; + } + + Craft.messageReceiver.addEventListener('message', (event) => { + if (event.data.event === 'saveElement') { + this.scheduleRefresh(); + } + }); + }, + + onChange: function (event) { + const row = event.target.closest('[data-vm-row]'); + + if (row) { + this.syncOptionsToAttribute(row); + } + + this.syncInventoryRows(); + this.scheduleRefresh(); + }, + + onClick: function (event) { + if (event.target.closest('[data-vm-generate]')) { + this.generate(); + return; + } + + if (event.target.closest('[data-vm-add-row]')) { + this.fetchRow(this.nextRowId++); + return; + } + + const deleteButton = event.target.closest('[data-vm-row-delete]'); + + if (deleteButton) { + this.deleteRow(deleteButton.closest('[data-vm-row]')); + } + }, + + /** + * Stock and out of stock purchases only mean something while the maker is tracking inventory. + */ + syncInventoryRows: function () { + const tracking = this.isPropertyOn('inventoryTracked'); + + ['stock', 'allowOutOfStockPurchases'].forEach((propertyName) => { + this.container + .querySelector('[data-vm-property="' + propertyName + '"]') + .classList.toggle('hidden', !tracking); + }); + }, + + isPropertyOn: function (propertyName) { + const row = this.container.querySelector('[data-vm-property="' + propertyName + '"]'); + + return this.switchValue(row, '[data-vm-include]') && this.switchValue(row, '[data-vm-value]'); + }, + + /** + * Read the hidden input rather than the button's class, since that is what a save receives. + */ + switchValue: function (row, cell) { + return row.querySelector(cell + ' input').value !== ''; + }, + + elementSelect: function (id) { + return $('#' + id).data('elementSelect'); + }, + + /** + * An option only belongs under one attribute, so a changed attribute invalidates the row's selections. + */ + syncOptionsToAttribute: function (row) { + const attributeId = this.selectedId(row.dataset.vmAttributeSelectId); + + if (attributeId === row.dataset.vmAttributeId) { + return; + } + + row.dataset.vmAttributeId = attributeId; + + const optionSelect = this.elementSelect(row.dataset.vmOptionsSelectId); + optionSelect.settings.criteria = { + attributeId: attributeId === '' ? -1 : Number(attributeId), + }; + + optionSelect.$elements.each(function () { + optionSelect.removeElement($(this)); + }); + + this.destroyModal(optionSelect); + }, + + selectedId: function (selectId) { + const chip = document.getElementById(selectId).querySelector('.chip.element'); + + return chip ? chip.dataset.id : ''; + }, + + /** + * A modal is appended to the body, so removing the row it belongs to would leave it behind. + */ + destroyModal: function (elementSelect) { + if (elementSelect.modal) { + elementSelect.modal.destroy(); + elementSelect.modal = null; + } + }, + + deleteRow: function (row) { + [row.dataset.vmAttributeSelectId, row.dataset.vmOptionsSelectId].forEach((selectId) => { + this.destroyModal(this.elementSelect(selectId)); + }); + + row.remove(); + this.scheduleRefresh(); + }, + + fetchRow: function (rowId) { + Craft.sendActionRequest('POST', 'variant-manager/variant-maker/row', { + data: { + productId: this.productId, + rowId: rowId, + }, + }).then((response) => { + const wrapper = document.createElement('div'); + wrapper.innerHTML = response.data.html; + const row = wrapper.firstElementChild; + + this.rows.appendChild(row); + + // Both return promises, and the element selects do not exist until the appended scripts have run + return Promise.all([ + Craft.appendHeadHtml(response.data.headHtml), + Craft.appendBodyHtml(response.data.bodyHtml), + ]).then(() => { + Craft.initUiElements($(row)); + this.scheduleRefresh(); + }); + }).catch((error) => { + // A row that fails to build leaves a button wired to nothing, with no sign of why + Craft.cp.displayError(this.errorMessage(error)); + }); + }, + + errorMessage: function (error) { + return error.response && error.response.data ? error.response.data.message : error.message; + }, + + generate: function () { + const data = new FormData(); + data.append('productId', this.productId); + + Craft.sendActionRequest('POST', 'variant-manager/variant-maker/generate', { + data: data, + }).then((response) => { + Craft.cp.displayNotice(response.data.message); + }).catch((error) => { + Craft.cp.displayError(this.errorMessage(error)); + }); + }, + + scheduleRefresh: function () { + window.clearTimeout(this.pending); + this.pending = window.setTimeout(this.refresh.bind(this), 300); + }, + + /** + * The preview reads the builder's own inputs, so it plans from exactly what a save would store. + */ + formData: function () { + const data = new FormData(); + + data.append('productId', this.productId); + + this.container.querySelectorAll('[name^="variantMaker["]').forEach((input) => { + // A real submit skips a disabled input, and the preview has to match what a save receives + if (!input.disabled) { + data.append(input.name, input.value); + } + }); + + return data; + }, + + refresh: function () { + // A slower earlier response must not overwrite a newer one + const requestId = ++this.requestId; + + this.preview.setAttribute('aria-busy', 'true'); + + Craft.sendActionRequest('POST', 'variant-manager/variant-maker/preview', { + data: this.formData(), + }).then((response) => { + if (requestId === this.requestId) { + this.preview.innerHTML = response.data.html; + this.preview.setAttribute('aria-busy', 'false'); + } + }).catch((error) => { + if (requestId === this.requestId) { + this.preview.innerHTML = ''; + this.preview.setAttribute('aria-busy', 'false'); + Craft.cp.displayError(this.errorMessage(error)); + } + }); + }, + }); + + // jQuery ready fires immediately on an already loaded document, where a late DOMContentLoaded listener never runs + $(function () { + document.querySelectorAll('[data-vm-variant-maker]').forEach(function (container) { + new Craft.VariantManager.VariantMaker(container); + }); + }); +})(jQuery); diff --git a/src/config.php b/src/config.php index 484f809..6d54736 100644 --- a/src/config.php +++ b/src/config.php @@ -23,6 +23,7 @@ 'bulkEditableVariantFields' => [], 'availableDisplayTypes' => [], 'defaultDisplayType' => 'dropdown', + 'variantMakerProductTypes' => [], 'productFieldMap' => [ '*' => [ 'title' => 'title', diff --git a/src/console/controllers/VariantMakerController.php b/src/console/controllers/VariantMakerController.php new file mode 100644 index 0000000..abff59d --- /dev/null +++ b/src/console/controllers/VariantMakerController.php @@ -0,0 +1,141 @@ +getProducts()->getProductById($productId); + + if (! $product instanceof Product) { + $this->stderr("No product with ID {$productId}." . PHP_EOL, Console::FG_RED); + return ExitCode::DATAERR; + } + + $settings = new VariantMakerSettings([ + 'mode' => $this->mode, + 'properties' => [ + VariantMakerSettings::PROPERTY_SKU => new VariantMakerProperty([ + 'include' => true, + 'value' => $this->skuFormat, + ]), + VariantMakerSettings::PROPERTY_PRICE => new VariantMakerProperty([ + 'include' => true, + 'value' => $this->basePrice, + ]), + VariantMakerSettings::PROPERTY_TITLE => new VariantMakerProperty([ + 'include' => true, + ]), + ], + ]); + + $rows = Plugin::getInstance()->getVariantMaker()->plan($product, $this->parseSelection(), $settings); + + if ($rows === []) { + $this->stdout('Nothing to plan. Check that --select names registered attributes and values.' . PHP_EOL); + return ExitCode::OK; + } + + $countsByStatus = []; + + foreach ($rows as $row) { + $combination = implode(' / ', array_map(static fn (array $pair): string => $pair['attributeValue'], $row->pairs)); + $countsByStatus[$row->status] = ($countsByStatus[$row->status] ?? 0) + 1; + + $this->stdout(sprintf( + '%-10s %-40s %-32s %s' . PHP_EOL, + $row->status, + $combination, + self::change($row->currentSku, $row->sku, $row->status), + self::change($row->currentPrice, $row->price, $row->status), + )); + + if ($row->skuIssue !== null) { + $this->stdout(sprintf('%-10s %s' . PHP_EOL, '', $row->skuIssue), Console::FG_RED); + } + } + + $this->stdout(PHP_EOL); + + foreach ($countsByStatus as $status => $count) { + $this->stdout("{$status}: {$count}" . PHP_EOL); + } + + return ExitCode::OK; + } + + /** + * Only an update applies the proposed value, so only an update shows the arrow. + */ + private static function change(?string $current, ?string $proposed, string $status): string + { + if ($proposed === null && $current === null) { + return $status === VariantMakerPlanRow::STATUS_CREATE ? 'default' : 'kept'; + } + + if ($status !== VariantMakerPlanRow::STATUS_UPDATE || $proposed === null || $current === $proposed) { + return (string) ($current ?? $proposed); + } + + return $current === null ? $proposed : "{$current} -> {$proposed}"; + } + + /** + * @return array> + */ + private function parseSelection(): array + { + $valuesByName = []; + + foreach (explode(';', $this->select) as $group) { + if (! str_contains($group, '=')) { + continue; + } + + [$attributeName, $values] = explode('=', $group, 2); + $valuesByName[trim($attributeName)] = array_values(array_filter(array_map('trim', explode(',', $values)))); + } + + return $valuesByName; + } +} diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 16d8bb4..8cf7f11 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -3,6 +3,8 @@ namespace fostercommerce\variantmanager\controllers; use Craft; +use craft\commerce\models\ProductType; +use craft\commerce\Plugin as Commerce; use craft\web\Controller; use fostercommerce\variantmanager\elements\VariantAttribute; use fostercommerce\variantmanager\enums\DisplayType; @@ -24,6 +26,13 @@ public function actionIndex(): Response 'settings' => $settings, 'displayTypeOptions' => DisplayType::options(DisplayType::cases()), 'defaultDisplayTypeOptions' => DisplayType::options($settings->getAvailableDisplayTypes($settings->defaultDisplayType)), + 'productTypeOptions' => array_map( + static fn (ProductType $productType): array => [ + 'label' => $productType->name, + 'value' => $productType->handle, + ], + Commerce::getInstance()->getProductTypes()->getAllProductTypes(), + ), 'readOnly' => ! Craft::$app->getConfig()->getGeneral()->allowAdminChanges, ]); } diff --git a/src/controllers/VariantMakerController.php b/src/controllers/VariantMakerController.php new file mode 100644 index 0000000..47b26b3 --- /dev/null +++ b/src/controllers/VariantMakerController.php @@ -0,0 +1,123 @@ +requirePostRequest(); + $this->requireAcceptsJson(); + + $product = $this->product(); + + $variantMaker = Plugin::getInstance()->getVariantMaker(); + $settings = $variantMaker->postedSettings() ?? new VariantMakerSettings(); + + $rows = $variantMaker->plan($product, $variantMaker->selectionFromRows($settings->rows), $settings); + + return $this->asJson([ + 'html' => $this->getView()->renderTemplate('variant-manager/variant-maker/_preview', [ + 'rows' => $rows, + 'skuMaxLength' => VariantMaker::SKU_MAX_LENGTH, + 'generatesTitles' => VariantMaker::generatesTitles($product), + 'tracksInventory' => $settings->property(VariantMakerSettings::PROPERTY_INVENTORY_TRACKED)->include + && $settings->property(VariantMakerSettings::PROPERTY_INVENTORY_TRACKED)->value === true, + ]), + ]); + } + + public function actionGenerate(): Response + { + $this->requirePostRequest(); + $this->requireAcceptsJson(); + + $product = $this->product(); + + // The job reads the saved settings, so unsaved builder changes would generate the previous ones + if ($this->hasUnsavedChanges($product)) { + return $this->asFailure(Craft::t('variant-manager', 'variantMaker.saveBeforeGenerating')); + } + + $variantMaker = Plugin::getInstance()->getVariantMaker(); + $settings = $variantMaker->getSettings($product); + $rows = $variantMaker->plan($product, $variantMaker->selectionFromRows($settings->rows), $settings); + + // One SKU Commerce rejects rolls the whole run back, so the job would report a failure and write nothing + foreach ($rows as $row) { + if ($row->skuIssue !== null) { + return $this->asFailure(Craft::t('variant-manager', 'variantMaker.skuIssuesBlockGenerating')); + } + } + + Queue::push(new GenerateVariants([ + 'productId' => $product->getCanonicalId(), + 'generatedByUserId' => (int) static::currentUser()?->id, + ]), queue: Plugin::getInstance()->queue); + + return $this->asSuccess(Craft::t('variant-manager', 'variantMaker.queued')); + } + + /** + * Renders one builder row, since its element selector criteria and input names are built server side. + */ + public function actionRow(): Response + { + $this->requirePostRequest(); + $this->requireAcceptsJson(); + + $this->product(); + + $view = $this->getView(); + + // The tab renders outside any namespace, so a fetched row must not pick one up either + $html = $view->renderTemplate('variant-manager/variant-maker/_row', [ + 'rowId' => $this->request->getRequiredBodyParam('rowId'), + 'attribute' => null, + 'options' => [], + 'static' => false, + ]); + + return $this->asJson([ + 'html' => $html, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), + ]); + } + + private function hasUnsavedChanges(Product $product): bool + { + return Product::find() + ->provisionalDrafts() + ->draftOf($product->getCanonicalId()) + ->draftCreator(static::currentUser()) + ->status(null) + ->exists(); + } + + private function product(): Product + { + $productId = (int) $this->request->getRequiredBodyParam('productId'); + $product = Commerce::getInstance()->getProducts()->getProductById($productId); + + if (! $product instanceof Product) { + throw new NotFoundHttpException(Craft::t('variant-manager', 'variantMaker.productNotFound')); + } + + $this->requirePermission("commerce-editProductType:{$product->getType()->uid}"); + + return $product; + } +} diff --git a/src/db/Table.php b/src/db/Table.php index 62d6444..48255d9 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -9,4 +9,6 @@ abstract class Table public const ATTRIBUTES = '{{%variant_manager_attributes}}'; public const STRUCTURES = '{{%variant_manager_structures}}'; + + public const VARIANT_MAKER = '{{%variant_manager_variant_maker}}'; } diff --git a/src/elements/VariantAttribute.php b/src/elements/VariantAttribute.php index 87e94b5..b624837 100644 --- a/src/elements/VariantAttribute.php +++ b/src/elements/VariantAttribute.php @@ -24,6 +24,7 @@ * Variants store the name and value as strings, so deleting a row does not change a variant. * * @property-read null|VariantAttribute $parentAttribute + * @property-read list $options */ class VariantAttribute extends Element { @@ -35,8 +36,17 @@ class VariantAttribute extends Element public string $displayType = DisplayType::Dropdown->value; + public ?string $skuPartial = null; + + public ?float $priceModifier = null; + private ?VariantAttribute $parentAttribute = null; + /** + * @var list|null + */ + private ?array $options = null; + public static function displayName(): string { return Craft::t('variant-manager', 'attributes.attribute'); @@ -93,6 +103,28 @@ public function isOption(): bool return $this->attributeId !== 0; } + /** + * @return list + */ + public function getOptions(): array + { + if ($this->isOption()) { + return []; + } + + return $this->options ??= self::find() + ->attributeId($this->id) + ->all(); + } + + /** + * @param list $options + */ + public function setOptions(array $options): void + { + $this->options = $options; + } + public function getParentAttribute(): ?self { if (! $this->isOption()) { @@ -180,6 +212,8 @@ public function afterSave(bool $isNew): void $record->name = $this->name; $record->nameKey = $this->nameKey; $record->displayType = $this->displayType; + $record->skuPartial = $this->skuPartial; + $record->priceModifier = $this->priceModifier; $record->save(false); if ($isNew) { @@ -366,9 +400,10 @@ protected function defineRules(): array $rules[] = [['displayType'], 'in', 'range' => array_column(DisplayType::cases(), 'value')]; - $rules[] = [['name', 'nameKey'], + $rules[] = [['name', 'nameKey', 'skuPartial'], 'string', 'max' => 255]; + $rules[] = [['priceModifier'], 'number']; return $rules; } @@ -434,11 +469,25 @@ private function optionMetaFieldsHtml(): string ]); // Variants match on the option value string, so the value is read only - return $fields . Cp::textFieldHtml([ + $fields .= Cp::textFieldHtml([ 'label' => Craft::t('variant-manager', 'attributes.name'), 'id' => 'name', 'value' => $this->name, 'disabled' => true, ]); + + $fields .= Cp::textFieldHtml([ + 'label' => Craft::t('variant-manager', 'options.skuPartial'), + 'id' => 'skuPartial', + 'name' => 'skuPartial', + 'value' => $this->skuPartial, + ]); + + return $fields . Cp::textFieldHtml([ + 'label' => Craft::t('variant-manager', 'options.priceModifier'), + 'id' => 'priceModifier', + 'name' => 'priceModifier', + 'value' => $this->priceModifier, + ]); } } diff --git a/src/elements/db/VariantAttributeQuery.php b/src/elements/db/VariantAttributeQuery.php index ba60986..378015c 100644 --- a/src/elements/db/VariantAttributeQuery.php +++ b/src/elements/db/VariantAttributeQuery.php @@ -71,6 +71,8 @@ protected function beforePrepare(): bool 'variant_manager_attributes.name', 'variant_manager_attributes.nameKey', 'variant_manager_attributes.displayType', + 'variant_manager_attributes.skuPartial', + 'variant_manager_attributes.priceModifier', ]); if (isset($this->attributeId)) { diff --git a/src/fieldlayoutelements/VariantMakerTab.php b/src/fieldlayoutelements/VariantMakerTab.php new file mode 100644 index 0000000..4d8abfd --- /dev/null +++ b/src/fieldlayoutelements/VariantMakerTab.php @@ -0,0 +1,45 @@ +getVariantMaker(); + $postedSettings = $variantMaker->postedSettings(); + + // Only a rejected save has something to redraw, so stored settings are never shown with errors + $postedSettings?->validate(); + + $settings = $postedSettings ?? $variantMaker->getSettings($element); + + return Craft::$app->getView()->renderTemplate('variant-manager/variant-maker/tab', [ + 'product' => $element, + 'settings' => $settings, + 'savedRows' => $variantMaker->settingsRows($element, $settings), + 'inventoryLocations' => $element->getStore()->getInventoryLocationsOptions(), + 'generatesTitles' => VariantMakerService::generatesTitles($element), + 'propertyNames' => VariantMakerSettings::propertyNames(), + 'requiredProperties' => array_filter(VariantMakerSettings::propertyNames(), VariantMakerSettings::isRequired(...)), + 'static' => $static, + ]); + } + + protected function selectorLabel(): string + { + return Craft::t('variant-manager', 'variantMaker.name'); + } +} diff --git a/src/jobs/GenerateVariants.php b/src/jobs/GenerateVariants.php new file mode 100644 index 0000000..fc6258d --- /dev/null +++ b/src/jobs/GenerateVariants.php @@ -0,0 +1,62 @@ +getUsers()->getUserById($this->generatedByUserId); + $product = Commerce::getInstance()->getProducts()->getProductById($this->productId); + + if (! $product instanceof Product) { + return; + } + + $variantMaker = Plugin::getInstance()->getVariantMaker(); + + try { + $counts = $variantMaker->generate($product, $variantMaker->getSettings($product)); + + $link = Html::a(Html::encode($product->title), (string) $product->getCpEditUrl(), [ + 'class' => 'go', + ]); + + Activity::log($user, Craft::t('variant-manager', 'variantMaker.activityGenerated', [ + 'product' => $link, + 'created' => $counts['created'], + 'updated' => $counts['updated'], + 'deleted' => $counts['deleted'], + ])); + } catch (\Throwable $throwable) { + // The dashboard renders the message with |raw, and a product title is user supplied + Activity::log( + $user, + Craft::t('variant-manager', 'variantMaker.activityFailed', [ + 'product' => Html::tag('strong', Html::encode($product->title)), + 'message' => Html::encode($throwable->getMessage()), + ]), + 'error' + ); + + throw $throwable; + } + } + + protected function defaultDescription(): ?string + { + return Craft::t('variant-manager', 'jobs.generateVariants'); + } +} diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 31e113e..44d863d 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -28,6 +28,8 @@ public function safeUp(): bool 'name' => $this->string()->notNull(), 'nameKey' => $this->string()->notNull(), 'displayType' => $this->string()->notNull()->defaultValue('dropdown'), + 'skuPartial' => $this->string(), + 'priceModifier' => $this->decimal(14, 2), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), 'uid' => $this->uid(), @@ -47,6 +49,18 @@ public function safeUp(): bool $this->addForeignKey(null, Table::STRUCTURES, ['id'], CraftTable::STRUCTURES, ['id'], 'CASCADE'); + $this->createTable(Table::VARIANT_MAKER, [ + 'id' => $this->primaryKey(), + 'productId' => $this->integer()->notNull(), + 'settings' => $this->text(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->uid(), + ]); + + $this->createIndex(null, Table::VARIANT_MAKER, ['productId'], true); + $this->addForeignKey(null, Table::VARIANT_MAKER, ['productId'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); + $structure = new Structure([ 'maxLevels' => 2, ]); @@ -62,6 +76,7 @@ public function safeUp(): bool public function safeDown(): bool { + $this->dropTableIfExists(Table::VARIANT_MAKER); $this->dropTableIfExists(Table::STRUCTURES); $this->dropTableIfExists(Table::ATTRIBUTES); diff --git a/src/migrations/m260914_200000_add_variant_maker_columns.php b/src/migrations/m260914_200000_add_variant_maker_columns.php new file mode 100644 index 0000000..da4ca99 --- /dev/null +++ b/src/migrations/m260914_200000_add_variant_maker_columns.php @@ -0,0 +1,17 @@ +addColumn(Table::ATTRIBUTES, 'skuPartial', $this->string()->after('displayType')); + $this->addColumn(Table::ATTRIBUTES, 'priceModifier', $this->decimal(14, 2)->after('skuPartial')); + + return true; + } +} diff --git a/src/migrations/m260915_090000_create_variant_maker_table.php b/src/migrations/m260915_090000_create_variant_maker_table.php new file mode 100644 index 0000000..3122475 --- /dev/null +++ b/src/migrations/m260915_090000_create_variant_maker_table.php @@ -0,0 +1,27 @@ +createTable(Table::VARIANT_MAKER, [ + 'id' => $this->primaryKey(), + 'productId' => $this->integer()->notNull(), + 'settings' => $this->text(), + 'dateCreated' => $this->dateTime()->notNull(), + 'dateUpdated' => $this->dateTime()->notNull(), + 'uid' => $this->uid(), + ]); + + $this->createIndex(null, Table::VARIANT_MAKER, ['productId'], true); + $this->addForeignKey(null, Table::VARIANT_MAKER, ['productId'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); + + return true; + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 0513765..f6226df 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -64,6 +64,11 @@ class Settings extends Model */ public array $availableDisplayTypes = []; + /** + * @var list handles of the product types whose products offer the Variant Maker + */ + public array $variantMakerProductTypes = []; + public string $defaultDisplayType = DisplayType::Dropdown->value; public array $productFieldMap = [ @@ -84,6 +89,13 @@ public function setAttributes($values, $safeOnly = true): void )); } + if (isset($values['variantMakerProductTypes'])) { + $values['variantMakerProductTypes'] = array_values(array_filter( + (array) $values['variantMakerProductTypes'], + static fn (string $productTypeHandle): bool => $productTypeHandle !== '' + )); + } + parent::setAttributes($values, $safeOnly); if ($this->activityLogRetention !== false && $this->activityLogRetention !== null) { @@ -126,6 +138,11 @@ public function getDefaultDisplayType(): DisplayType return DisplayType::tryFrom($this->defaultDisplayType) ?? DisplayType::Dropdown; } + public function offersVariantMaker(string $productTypeHandle): bool + { + return in_array($productTypeHandle, $this->variantMakerProductTypes, true); + } + public function getAvailableProductTypes(): array { $productTypes = []; diff --git a/src/models/VariantMakerPlanRow.php b/src/models/VariantMakerPlanRow.php new file mode 100644 index 0000000..5203458 --- /dev/null +++ b/src/models/VariantMakerPlanRow.php @@ -0,0 +1,63 @@ + + */ + public array $pairs = []; + + /** + * @var string Normalized and sorted, so the same combination in a different order is one row + */ + public string $combinationKey = ''; + + /** + * @phpstan-var self::STATUS_* + */ + public string $status = self::STATUS_CREATE; + + public ?int $variantId = null; + + public ?string $sku = null; + + public ?string $currentSku = null; + + /** + * @var string|null Why Commerce would reject this row's SKU, since it validates the whole run as one save + */ + public ?string $skuIssue = null; + + /** + * @var string|null Null where the product type generates variant titles, since Commerce overwrites ours + */ + public ?string $title = null; + + public ?string $currentTitle = null; + + public ?string $price = null; + + public ?string $currentPrice = null; + + /** + * @var array purchasable properties this row would write, by the maker's apply rules + */ + public array $properties = []; + + public function property(string $propertyName): bool|int|null + { + return $this->properties[$propertyName] ?? null; + } +} diff --git a/src/models/VariantMakerProperty.php b/src/models/VariantMakerProperty.php new file mode 100644 index 0000000..bdb3daf --- /dev/null +++ b/src/models/VariantMakerProperty.php @@ -0,0 +1,15 @@ +}> in the order their SKU partials assemble + */ + public array $rows = []; + + /** + * @phpstan-var VariantMaker::MODE_* + */ + public string $mode = VariantMaker::MODE_ADD; + + /** + * @var array keyed by the purchasable property the maker manages + */ + public array $properties = []; + + public ?int $inventoryLocationId = null; + + /** + * @return list + */ + public static function propertyNames(): array + { + return [ + self::PROPERTY_TITLE, + self::PROPERTY_SKU, + self::PROPERTY_PRICE, + self::PROPERTY_INVENTORY_TRACKED, + self::PROPERTY_STOCK, + self::PROPERTY_ALLOW_OUT_OF_STOCK_PURCHASES, + self::PROPERTY_AVAILABLE_FOR_PURCHASE, + self::PROPERTY_FREE_SHIPPING, + self::PROPERTY_PROMOTABLE, + ]; + } + + public static function fromJson(?string $json): self + { + $stored = Json::decodeIfJson($json ?? ''); + $stored = is_array($stored) ? $stored : []; + + // Keep only the keys this model still declares, since an earlier shape stored others + $settings = new self(array_intersect_key($stored, array_flip((new self())->attributes()))); + $settings->properties = self::propertiesFromPost(array_map( + static fn (mixed $property): array => (array) $property, + $settings->properties, + )); + + return $settings; + } + + public function updatesExisting(): bool + { + return $this->mode === VariantMaker::MODE_UPDATE || $this->mode === VariantMaker::MODE_REPLACE; + } + + /** + * Stock and out of stock purchases mean nothing on a variant the maker is not tracking inventory for. + */ + public function manages(string $propertyName): bool + { + if (! $this->property($propertyName)->include) { + return false; + } + + if (! in_array($propertyName, [self::PROPERTY_STOCK, self::PROPERTY_ALLOW_OUT_OF_STOCK_PURCHASES], true)) { + return true; + } + + $tracked = $this->property(self::PROPERTY_INVENTORY_TRACKED); + + return $tracked->include && $tracked->value === true; + } + + public function property(string $propertyName): VariantMakerProperty + { + return $this->properties[$propertyName] ?? new VariantMakerProperty(); + } + + /** + * Builds settings from what the product form posted. + * + * @param array $postedSettings + */ + public static function fromPost(array $postedSettings): self + { + $rows = []; + $postedRows = $postedSettings['rows'] ?? []; + + foreach (is_array($postedRows) ? $postedRows : [] as $postedRow) { + $postedRow = (array) $postedRow; + $optionIds = array_values(array_filter(array_map('intval', (array) ($postedRow['optionIds'] ?? [])))); + + $rows[] = [ + 'attributeId' => (int) ($postedRow['attributeId'] ?? 0), + 'optionIds' => $optionIds, + ]; + } + + return new self([ + 'rows' => $rows, + 'mode' => (string) ($postedSettings['mode'] ?? VariantMaker::MODE_ADD), + 'properties' => self::propertiesFromPost((array) ($postedSettings['properties'] ?? [])), + 'inventoryLocationId' => ($postedSettings['inventoryLocationId'] ?? '') === '' ? null : (int) $postedSettings['inventoryLocationId'], + ]); + } + + /** + * Drops rows whose attribute or options are no longer registered, since the registry is what variants match on. + * + * @param list $knownIds + */ + public function forgetMissing(array $knownIds): void + { + $known = array_flip($knownIds); + $rows = []; + + foreach ($this->rows as $row) { + if (! isset($known[$row['attributeId']])) { + continue; + } + + $rows[] = [ + 'attributeId' => $row['attributeId'], + 'optionIds' => array_values(array_filter($row['optionIds'], static fn (int $optionId): bool => isset($known[$optionId]))), + ]; + } + + $this->rows = $rows; + } + + public function toJson(): string + { + return Json::encode([ + 'rows' => $this->rows, + 'mode' => $this->mode, + 'properties' => array_map( + static fn (VariantMakerProperty $property): array => [ + 'include' => $property->include, + 'value' => $property->value, + ], + $this->properties, + ), + 'inventoryLocationId' => $this->inventoryLocationId, + ]); + } + + public function validateRows(): void + { + foreach ($this->rows as $rowIndex => $row) { + $position = $rowIndex + 1; + + if ($row['attributeId'] === 0) { + $this->addError('rows', Craft::t('variant-manager', 'variantMaker.rowNumberNeedsAttribute', [ + 'row' => $position, + ])); + continue; + } + + if ($row['optionIds'] === []) { + $this->addError('rows', Craft::t('variant-manager', 'variantMaker.rowNumberNeedsOptions', [ + 'row' => $position, + ])); + } + } + } + + public static function isRequired(string $propertyName): bool + { + return in_array($propertyName, [self::PROPERTY_SKU, self::PROPERTY_PRICE], true); + } + + protected function defineRules(): array + { + $rules = parent::defineRules(); + $rules[] = [['rows'], 'validateRows']; + $rules[] = [['mode'], + 'in', + 'range' => [VariantMaker::MODE_ADD, VariantMaker::MODE_UPDATE, VariantMaker::MODE_REPLACE]]; + return $rules; + } + + /** + * A property's value is a format for the text ones, a count for stock, and a flag for the rest. + */ + private static function propertyValue(string $propertyName, mixed $postedValue): bool|int|string|null + { + if (in_array($propertyName, [self::PROPERTY_TITLE, self::PROPERTY_SKU, self::PROPERTY_PRICE], true)) { + return ($postedValue ?? '') === '' ? null : (string) $postedValue; + } + + if ($propertyName === self::PROPERTY_STOCK) { + return ($postedValue ?? '') === '' ? null : (int) $postedValue; + } + + return (bool) $postedValue; + } + + /** + * @param array $postedProperties + * @return array + */ + private static function propertiesFromPost(array $postedProperties): array + { + $properties = []; + + foreach (self::propertyNames() as $propertyName) { + $postedProperty = (array) ($postedProperties[$propertyName] ?? []); + + $properties[$propertyName] = new VariantMakerProperty([ + // A disabled switch posts nothing, and Commerce requires these two on every variant anyway + 'include' => self::isRequired($propertyName) || (bool) ($postedProperty['include'] ?? false), + 'value' => self::propertyValue($propertyName, $postedProperty['value'] ?? null), + ]); + } + + return $properties; + } +} diff --git a/src/records/VariantAttribute.php b/src/records/VariantAttribute.php index 83131c0..7d90f64 100644 --- a/src/records/VariantAttribute.php +++ b/src/records/VariantAttribute.php @@ -11,6 +11,8 @@ * @property string $name * @property string $nameKey * @property string $displayType + * @property null|string $skuPartial + * @property null|float $priceModifier */ class VariantAttribute extends ActiveRecord { diff --git a/src/records/VariantMaker.php b/src/records/VariantMaker.php new file mode 100644 index 0000000..793f52d --- /dev/null +++ b/src/records/VariantMaker.php @@ -0,0 +1,19 @@ +normalizeExistingProductImport($product, $tabularDataReader, $mapping); } + $this->saveVariants($product, $variants); + + $this->importSiteSpecificData($tabularDataReader, $mapping['variant']['sku'], $mapping['sites']); + $this->importInventoryLevels($tabularDataReader, $mapping['variant']['sku'], $mapping['inventory']); + + return $product; + } + + /** + * Saves a product's variants in the order Commerce needs, rolling a new product back if it fails to validate. + * + * @param list $variants + * @throws \Throwable + */ + public function saveVariants(Product $product, array $variants): void + { // 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 */ $error = reset($errors); - throw new \RuntimeException($error ?? 'Failed to save product'); + throw new \RuntimeException($error ?? Craft::t('variant-manager', 'import.productSaveFailed')); } $product->setVariants($variants); @@ -135,7 +151,7 @@ public function import(string $filename, string $csvData, ?string $productTypeHa $errors = $variant->getErrorSummary(false); /** @var ?string $error */ $error = reset($errors); - throw new \RuntimeException($error ?? 'Failed to save product'); + throw new \RuntimeException($error ?? Craft::t('variant-manager', 'import.variantSaveFailed')); } } @@ -152,13 +168,8 @@ public function import(string $filename, string $csvData, ?string $productTypeHa $errors = $product->getErrorSummary(false); /** @var ?string $error */ $error = reset($errors); - throw new \RuntimeException($error ?? 'Failed to save product'); + throw new \RuntimeException(($error ?? Craft::t('variant-manager', 'import.productSaveFailed')) . self::repeatedSkus($product)); } - - $this->importSiteSpecificData($tabularDataReader, $mapping['variant']['sku'], $mapping['sites']); - $this->importInventoryLevels($tabularDataReader, $mapping['variant']['sku'], $mapping['inventory']); - - return $product; } /** @@ -265,6 +276,27 @@ protected function findProductVariantSkus(array $items): Collection ->map(static fn ($variants) => $variants->map(static fn ($variant) => $variant->sku)->all()); } + /** + * Commerce reports a repeated SKU without naming it, leaving no way to tell which variants collided. + */ + private static function repeatedSkus(Product $product): string + { + $skus = []; + + foreach ($product->getVariants(true) as $variant) { + $skus[] = (string) $variant->sku; + } + + $repeated = array_keys(array_filter( + array_count_values($skus), + static fn (int $count): bool => $count > 1, + )); + + return $repeated === [] ? '' : ' ' . Craft::t('variant-manager', 'import.repeatedSkus', [ + 'skus' => implode(', ', $repeated), + ]); + } + private function importSiteSpecificData(TabularDataReader $reader, $skuColumn, array $sitesMap): void { $sites = []; diff --git a/src/services/VariantAttributes.php b/src/services/VariantAttributes.php index e869dc2..3e6b5d7 100644 --- a/src/services/VariantAttributes.php +++ b/src/services/VariantAttributes.php @@ -192,6 +192,28 @@ public function findOrphans(int $batchSize = 500): array ]; } + /** + * Every attribute with its options already loaded, so a caller rendering all of them queries twice. + * + * @return array + */ + public function getAllAttributesWithOptions(): array + { + $attributes = $this->getAllAttributes(); + + $optionsByAttributeId = []; + + foreach (VariantAttribute::find()->attributeId('not 0')->all() as $option) { + $optionsByAttributeId[$option->attributeId][] = $option; + } + + foreach ($attributes as $attribute) { + $attribute->setOptions($optionsByAttributeId[$attribute->id] ?? []); + } + + return $attributes; + } + /** * Registry rows for the given names and their values, indexed by name and then by raw value. * diff --git a/src/services/VariantMaker.php b/src/services/VariantMaker.php new file mode 100644 index 0000000..d9ac03d --- /dev/null +++ b/src/services/VariantMaker.php @@ -0,0 +1,977 @@ + the properties a plan row carries as its own field + */ + private const NAMED_VALUES = [ + VariantMakerSettings::PROPERTY_TITLE => 'title', + VariantMakerSettings::PROPERTY_SKU => 'sku', + VariantMakerSettings::PROPERTY_PRICE => 'price', + ]; + + /** + * Works out what generating the given combinations would do to a product's variants. + * + * Nothing is saved. The preview renders these rows and the commit executes them, so both read one plan. + * + * @param array> $valuesByName + * @return list + */ + public function plan(Product $product, array $valuesByName, VariantMakerSettings $settings): array + { + $registry = Plugin::getInstance()->getVariantAttributes()->getRegistry($valuesByName); + + if ($registry === []) { + return []; + } + + $valuesByName = self::registeredValues($valuesByName, $registry); + + if ($valuesByName === []) { + return []; + } + + $existingVariants = $this->existingVariantsByCombination($product); + $stockByVariantId = $settings->manages(VariantMakerSettings::PROPERTY_STOCK) + ? $this->stockByVariantId($existingVariants) + : []; + $baseSku = $this->baseSku($product); + $teller = $this->teller($product); + $mode = $settings->mode; + + $title = $settings->property(VariantMakerSettings::PROPERTY_TITLE); + $sku = $settings->property(VariantMakerSettings::PROPERTY_SKU); + $price = $settings->property(VariantMakerSettings::PROPERTY_PRICE); + $basePrice = self::amount($price->value ?? $product->getDefaultVariant()?->basePrice ?? 0); + + // Skip the title where the product type formats it, since our value would be overwritten on save + $commerceOwnsTitles = self::generatesTitles($product); + + $rows = []; + + foreach ($this->combinations($valuesByName) as $combination) { + $options = $this->combinationOptions($combination, $registry); + + $row = new VariantMakerPlanRow([ + 'pairs' => $combination, + 'combinationKey' => self::combinationKey($combination), + 'title' => $commerceOwnsTitles ? null : $this->buildTitle($combination, (string) $title->value), + 'sku' => $sku->include ? $this->buildSku($combination, $options, $baseSku, (string) $sku->value) : null, + 'price' => $price->include ? $this->buildPrice($options, $basePrice, $teller) : null, + ]); + + $variant = $existingVariants[$row->combinationKey] ?? null; + $row->status = $this->statusFor($row, $variant, $settings, $teller, $stockByVariantId); + $row->properties = self::propertiesFor($settings, $row->status); + + // A value the run would not write must not read as a change in the preview + foreach (self::NAMED_VALUES as $propertyName => $field) { + if (! self::writes($settings, $propertyName, $row->status)) { + $row->{$field} = null; + } + } + + // Price is compared by amount, so 162 and 162.00 would otherwise render as a change + if ($row->price !== null && $row->currentPrice !== null && $teller->compare($row->price, $row->currentPrice) === 0) { + $row->price = null; + } + + $rows[] = $row; + unset($existingVariants[$row->combinationKey]); + } + + $this->flagSkuIssues($rows, $product); + + if ($mode !== self::MODE_REPLACE) { + return $rows; + } + + // Replace removes every existing variant the generated combinations did not cover + foreach ($existingVariants as $combinationKey => $variant) { + $rows[] = new VariantMakerPlanRow([ + 'pairs' => $this->variantPairs($variant), + 'combinationKey' => $combinationKey, + 'status' => VariantMakerPlanRow::STATUS_DELETE, + 'variantId' => $variant->id, + 'currentSku' => $variant->sku, + 'currentTitle' => $variant->title, + 'currentPrice' => (string) $variant->basePrice, + ]); + } + + return $rows; + } + + /** + * Executes a plan, reusing the importer's save order so both paths write variants the same way. + * + * @return array{created: int, updated: int, deleted: int} + * @throws \Throwable + */ + public function generate(Product $product, VariantMakerSettings $settings): array + { + $rows = $this->plan($product, $this->selectionFromRows($settings->rows), $settings); + $counts = [ + 'created' => 0, + 'updated' => 0, + 'deleted' => 0, + ]; + + $elementsService = Craft::$app->getElements(); + $transaction = Craft::$app->getDb()->beginTransaction(); + + try { + $written = []; + $removedIds = []; + $pendingStock = []; + + foreach ($rows as $row) { + $variant = $this->variantForRow($row); + + // Another save may have removed the variant between the plan and this run + if ($variant === null) { + continue; + } + + if ($row->status === VariantMakerPlanRow::STATUS_DELETE) { + $elementsService->deleteElement($variant); + $removedIds[] = (int) $variant->id; + $counts['deleted']++; + continue; + } + + if ($row->status === VariantMakerPlanRow::STATUS_UNCHANGED) { + continue; + } + + $this->applyRow($variant, $row); + $written[] = $variant; + + if (isset($row->properties[VariantMakerSettings::PROPERTY_STOCK])) { + $pendingStock[] = [$variant, (int) $row->properties[VariantMakerSettings::PROPERTY_STOCK]]; + } + + $counts[$row->status === VariantMakerPlanRow::STATUS_CREATE ? 'created' : 'updated']++; + } + + if ($written !== [] || $removedIds !== []) { + // Commerce rebuilds the product's default variant from whatever it is given, so hand it every variant + Plugin::getInstance()->csv->saveVariants($product, $this->wholeVariantSet($product, $written, $removedIds)); + } + + // Inventory items only exist once the variant is saved + foreach ($pendingStock as [$variant, $quantity]) { + $this->setStock($variant, $quantity, $settings->inventoryLocationId); + } + + $transaction->commit(); + } catch (\Throwable $throwable) { + $transaction->rollBack(); + throw $throwable; + } + + return $counts; + } + + /** + * The builder as the product form posted it, so a failed save redraws what the merchant had rather than + * what is stored. + */ + public function postedSettings(): ?VariantMakerSettings + { + $request = Craft::$app->getRequest(); + + if ($request->getIsConsoleRequest()) { + return null; + } + + $postedSettings = $request->getBodyParam('variantMaker'); + + return is_array($postedSettings) ? VariantMakerSettings::fromPost($postedSettings) : null; + } + + public function getSettings(Product $product): VariantMakerSettings + { + $record = VariantMakerRecord::findOne([ + 'productId' => $product->getCanonicalId(), + ]); + + $settings = VariantMakerSettings::fromJson($record->settings ?? null); + $settings->forgetMissing($this->registeredIds($settings)); + + return $settings; + } + + /** + * The saved rows with their elements loaded, falling back to the attributes the product already uses. + * + * @return list}> + */ + public function settingsRows(Product $product, VariantMakerSettings $settings): array + { + if ($settings->rows === []) { + return array_map( + static fn (VariantAttribute $attribute): array => [ + 'attribute' => $attribute, + 'options' => [], + ], + $this->attributesInUse($product), + ); + } + + $elementsById = []; + + foreach (VariantAttribute::find()->id(self::idsIn($settings))->all() as $element) { + $elementsById[$element->id] = $element; + } + + $rows = []; + + foreach ($settings->rows as $row) { + $attribute = $elementsById[$row['attributeId']] ?? null; + + if ($attribute instanceof VariantAttribute) { + $rows[] = [ + 'attribute' => $attribute, + 'options' => array_values(array_filter(array_map( + static fn (int $optionId): ?VariantAttribute => $elementsById[$optionId] ?? null, + $row['optionIds'], + ))), + ]; + } + } + + return $rows; + } + + public function saveSettings(Product $product, VariantMakerSettings $settings): void + { + $productId = $product->getCanonicalId(); + + $record = VariantMakerRecord::findOne([ + 'productId' => $productId, + ]) ?? new VariantMakerRecord([ + 'productId' => $productId, + ]); + + $record->settings = $settings->toJson(); + $record->save(false); + } + + /** + * Attributes the product's variants already store, so the form opens on what this product actually uses. + * + * @return list + */ + public function attributesInUse(Product $product): array + { + $names = []; + + foreach (Variant::find()->product($product)->status(null)->all() as $variant) { + foreach ($this->variantPairs($variant) as $pair) { + $names[$pair['attributeName']] = true; + } + } + + return array_values(Plugin::getInstance()->getVariantAttributes()->getAttributesByNames(array_keys($names))); + } + + /** + * Turns the builder's rows into the name and value map the planner takes. + * + * @param list}> $settingsRows in the order their SKU partials assemble + * @return array> + */ + public function selectionFromRows(array $settingsRows): array + { + $attributeIds = array_column($settingsRows, 'attributeId'); + $optionIdsByAttributeId = array_column($settingsRows, 'optionIds', 'attributeId'); + + if ($attributeIds === []) { + return []; + } + + $rowsById = []; + + foreach (VariantAttribute::find()->id($attributeIds)->all() as $attribute) { + $rowsById[$attribute->id] = $attribute; + } + + $optionIds = array_merge(...array_values($optionIdsByAttributeId)); + + $optionsById = []; + + foreach ($optionIds === [] ? [] : VariantAttribute::find()->id($optionIds)->all() as $option) { + $optionsById[$option->id] = $option; + } + + $valuesByName = []; + + foreach ($attributeIds as $attributeId) { + $attribute = $rowsById[$attributeId] ?? null; + + if ($attribute === null) { + continue; + } + + $values = []; + + foreach ($optionIdsByAttributeId[$attributeId] ?? [] as $optionId) { + $option = $optionsById[$optionId] ?? null; + + if ($option instanceof VariantAttribute && $option->attributeId === $attributeId) { + $values[] = $option->name; + } + } + + if ($values !== []) { + $valuesByName[$attribute->name] = $values; + } + } + + return $valuesByName; + } + + /** + * Builds the combination key a variant is matched on, independent of the order its pairs are stored in. + * + * @param list $pairs + */ + public static function combinationKey(array $pairs): string + { + $normalized = []; + + foreach ($pairs as $pair) { + $normalized[] = VariantAttribute::normalizeName($pair['attributeName']) . "\0" . VariantAttribute::normalizeName($pair['attributeValue']); + } + + sort($normalized); + + return implode('|', $normalized); + } + + /** + * Whether Commerce builds variant titles itself, leaving nothing for the maker to set. + */ + public static function generatesTitles(Product $product): bool + { + $productType = $product->getType(); + + return ! $productType->hasVariantTitleField && $productType->variantTitleFormat !== ''; + } + + /** + * @param list $written + * @param list $removedIds + * @return list + */ + private function wholeVariantSet(Product $product, array $written, array $removedIds): array + { + $writtenIds = array_filter(array_map(static fn (Variant $variant): ?int => $variant->id, $written)); + $untouched = array_flip([...$writtenIds, ...$removedIds]); + + $variants = $written; + + foreach (Variant::find()->product($product)->status(null)->all() as $variant) { + if (! isset($untouched[(int) $variant->id])) { + $variants[] = $variant; + } + } + + return $variants; + } + + /** + * Whether the run would write this property to a row of the given status. + */ + private static function writes(VariantMakerSettings $settings, string $propertyName, string $status): bool + { + if ($status === VariantMakerPlanRow::STATUS_CREATE) { + // A new variant is always titled, whether or not the maker owns titles + return $propertyName === VariantMakerSettings::PROPERTY_TITLE + || $settings->manages($propertyName); + } + + return $status === VariantMakerPlanRow::STATUS_UPDATE && $settings->manages($propertyName); + } + + /** + * The property values the run would write on a row, given what the maker was told to manage. + * + * @return array + */ + private static function propertiesFor(VariantMakerSettings $settings, string $status): array + { + $properties = []; + + foreach (VariantMakerSettings::propertyNames() as $propertyName) { + // A row holds its own title, SKU and price, built from the formats these values are + if (isset(self::NAMED_VALUES[$propertyName])) { + continue; + } + + if (self::writes($settings, $propertyName, $status)) { + $properties[$propertyName] = $settings->property($propertyName)->value; + } + } + + return $properties; + } + + /** + * Write stock as an inventory update, since Commerce derives a purchasable's stock from its levels. + */ + private function setStock(Variant $variant, int $quantity, ?int $inventoryLocationId): void + { + $updates = []; + + foreach ($variant->getInventoryLevels() as $inventoryLevel) { + $inventoryLocation = $inventoryLevel->getInventoryLocation(); + + if ($inventoryLocationId !== null && $inventoryLocation->id !== $inventoryLocationId) { + continue; + } + + $updates[] = new UpdateInventoryLevel([ + 'type' => 'onHand', + 'updateAction' => InventoryUpdateQuantityType::SET, + 'inventoryItem' => $inventoryLevel->getInventoryItem(), + 'inventoryLocation' => $inventoryLocation, + 'quantity' => $quantity, + 'note' => Craft::t('variant-manager', 'variantMaker.stockNote'), + ]); + } + + if ($updates !== []) { + Commerce::getInstance()->getInventory()->executeUpdateInventoryLevels(UpdateInventoryLevelCollection::make($updates)); + } + } + + private function variantForRow(VariantMakerPlanRow $row): ?Variant + { + if ($row->variantId === null) { + return new Variant(); + } + + $variant = Variant::find()->id($row->variantId)->status(null)->one(); + + return $variant instanceof Variant ? $variant : null; + } + + private function applyRow(Variant $variant, VariantMakerPlanRow $row): void + { + if ($row->title !== null) { + $variant->title = $row->title; + } + + if ($row->sku !== null) { + $variant->sku = $row->sku; + } + + if ($row->price !== null) { + $variant->basePrice = (float) $row->price; + } + + foreach ($row->properties as $propertyName => $value) { + if ($propertyName !== VariantMakerSettings::PROPERTY_STOCK) { + $variant->{$propertyName} = $value; + } + } + + // Each product type names its own field, so the variant's layout is what says where the pairs go + $field = FieldHelper::getFirstVariantAttributesField($variant->getFieldLayout()); + + if ($field !== null) { + $variant->setFieldValue($field->handle, $row->pairs); + } + } + + /** + * A row is an update only where a property the maker owns for existing variants would actually change one. + * + * @phpstan-return VariantMakerPlanRow::STATUS_CREATE|VariantMakerPlanRow::STATUS_UPDATE|VariantMakerPlanRow::STATUS_UNCHANGED + */ + private function statusFor(VariantMakerPlanRow $row, ?Variant $variant, VariantMakerSettings $settings, Teller $teller, array $stockByVariantId): string + { + if ($variant === null) { + return VariantMakerPlanRow::STATUS_CREATE; + } + + $row->variantId = $variant->id; + $row->currentSku = $variant->sku; + $row->currentTitle = $variant->title; + $row->currentPrice = (string) $variant->basePrice; + + if (! $settings->updatesExisting()) { + return VariantMakerPlanRow::STATUS_UNCHANGED; + } + + return $this->changesVariant($row, $variant, $settings, $teller, $stockByVariantId) + ? VariantMakerPlanRow::STATUS_UPDATE + : VariantMakerPlanRow::STATUS_UNCHANGED; + } + + /** + * @param array $stockByVariantId + */ + private function changesVariant(VariantMakerPlanRow $row, Variant $variant, VariantMakerSettings $settings, Teller $teller, array $stockByVariantId): bool + { + if ($settings->manages(VariantMakerSettings::PROPERTY_TITLE) && $row->title !== $row->currentTitle) { + return true; + } + + if ($settings->manages(VariantMakerSettings::PROPERTY_SKU) && $row->sku !== $row->currentSku) { + return true; + } + + if ($settings->manages(VariantMakerSettings::PROPERTY_PRICE) && $row->price !== null && $teller->compare($row->price, $row->currentPrice) !== 0) { + return true; + } + + foreach (VariantMakerSettings::propertyNames() as $propertyName) { + if (isset(self::NAMED_VALUES[$propertyName]) || ! $settings->manages($propertyName)) { + continue; + } + + $value = $settings->property($propertyName)->value; + + $current = $propertyName === VariantMakerSettings::PROPERTY_STOCK + ? ($stockByVariantId[$variant->id] ?? 0) + : $variant->{$propertyName}; + + if ($current !== $value) { + return true; + } + } + + return false; + } + + /** + * Reads every variant's stock in one query, since Commerce derives it per purchasable from inventory levels. + * + * @param array $existingVariants + * @return array + */ + private function stockByVariantId(array $existingVariants): array + { + $variantIds = array_values(array_filter(array_map( + static fn (Variant $variant): ?int => $variant->id, + $existingVariants, + ))); + + if ($variantIds === []) { + return []; + } + + $stockByVariantId = array_fill_keys($variantIds, 0); + + $levels = Commerce::getInstance()->getInventory()->getInventoryLevelQuery() + ->andWhere([ + 'ii.purchasableId' => $variantIds, + ]) + ->all(); + + foreach ($levels as $level) { + // Commerce counts only positive availability toward stock, and sums it across locations + $available = (int) $level['availableTotal']; + + if ($available > 0) { + $stockByVariantId[(int) $level['purchasableId']] += $available; + } + } + + return $stockByVariantId; + } + + /** + * The option elements behind one combination, keyed by attribute name, skipping values with no registry row. + * + * @param list $combination + * @param array}> $registry + * @return array + */ + private function combinationOptions(array $combination, array $registry): array + { + $options = []; + + foreach ($combination as $pair) { + $option = $registry[$pair['attributeName']]['options'][$pair['attributeValue']] ?? null; + + if ($option instanceof VariantAttribute) { + $options[$pair['attributeName']] = $option; + } + } + + return $options; + } + + /** + * @param list $combination + * @param array $options + */ + private function buildSku(array $combination, array $options, string $baseSku, string $skuFormat): string + { + // An option with no SKU partial contributes its own value instead + $partials = []; + + foreach ($combination as $pair) { + $attributeName = $pair['attributeName']; + $skuPartial = $options[$attributeName]->skuPartial; + $partials[$attributeName] = ($skuPartial ?? '') === '' ? $pair['attributeValue'] : $skuPartial; + } + + if (trim($skuFormat) === '') { + $segments = array_map(self::skuSegment(...), [$baseSku, ...array_values($partials)]); + + return implode('-', array_filter($segments, static fn (string $segment): bool => $segment !== '')); + } + + $tokens = []; + + foreach ($partials as $attributeName => $partial) { + $tokens['{' . $attributeName . '}'] = $partial; + } + + return strtr($skuFormat, $tokens); + } + + /** + * Drops the values no registry row backs, since a combination is built from each value's option. + * + * @param array> $valuesByName + * @param array}> $registry + * @return array> + */ + private static function registeredValues(array $valuesByName, array $registry): array + { + $registered = []; + + foreach ($valuesByName as $attributeName => $values) { + $options = $registry[$attributeName]['options'] ?? []; + $values = array_values(array_filter($values, static fn (string $value): bool => isset($options[$value]))); + + if ($values !== []) { + $registered[$attributeName] = $values; + } + } + + return $registered; + } + + /** + * A price typed into the control panel carries the locale's separators, which Money's parser rejects. + */ + private static function amount(float|int|string $value): string + { + return (string) (Localization::normalizeNumber($value) ?: '0'); + } + + /** + * Nobody typed a SKU in the default format, so whitespace here comes from the option name. + */ + private static function skuSegment(string $value): string + { + return trim((string) preg_replace('/[\s-]+/u', '-', trim($value)), '-'); + } + + /** + * Marks the rows Commerce would reject, since one failure rolls the whole run back. + * + * @param list $rows + */ + private function flagSkuIssues(array $rows, Product $product): void + { + $rowsByLowercasedSku = []; + + foreach ($rows as $row) { + if ($row->sku !== null) { + $rowsByLowercasedSku[mb_strtolower($row->sku)][] = $row; + } + } + + $plannedIds = array_filter(array_map(static fn (VariantMakerPlanRow $row): ?int => $row->variantId, $rows)); + $keptSkus = $this->keptSkus($product, $plannedIds); + $takenElsewhere = $this->skusTakenElsewhere(array_keys($rowsByLowercasedSku), $plannedIds); + + foreach ($rowsByLowercasedSku as $lowercasedSku => $sharingRows) { + $isDuplicate = count($sharingRows) > 1 || isset($keptSkus[$lowercasedSku]); + + foreach ($sharingRows as $row) { + if ($isDuplicate) { + $row->skuIssue = Craft::t('variant-manager', 'variantMaker.skuDuplicate'); + } elseif (isset($takenElsewhere[$lowercasedSku])) { + $row->skuIssue = Craft::t('variant-manager', 'variantMaker.skuTaken'); + } elseif (mb_strlen((string) $row->sku) > self::SKU_MAX_LENGTH) { + $row->skuIssue = Craft::t('variant-manager', 'variantMaker.skuTooLong', [ + 'max' => self::SKU_MAX_LENGTH, + ]); + } + } + } + } + + /** + * The SKUs of the product's own variants no plan row covers, since Commerce rejects a repeat within one product. + * + * @param list $plannedIds + * @return array + */ + private function keptSkus(Product $product, array $plannedIds): array + { + $planned = array_flip($plannedIds); + $keptSkus = []; + + foreach (Variant::find()->product($product)->status(null)->all() as $variant) { + if (! isset($planned[(int) $variant->id])) { + $keptSkus[mb_strtolower((string) $variant->sku)] = true; + } + } + + return $keptSkus; + } + + /** + * @param list $lowercasedSkus lowercased, since Commerce compares SKUs case insensitively + * @param list $plannedIds + * @return array + */ + private function skusTakenElsewhere(array $lowercasedSkus, array $plannedIds): array + { + if ($lowercasedSkus === []) { + return []; + } + + // Read the rows Commerce validates against: no revisions, no drafts, nothing trashed + $query = (new Query()) + ->select(['[[purchasables.sku]]']) + ->from([ + 'purchasables' => CommerceTable::PURCHASABLES, + ]) + ->innerJoin([ + 'elements' => CraftTable::ELEMENTS, + ], '[[elements.id]] = [[purchasables.id]]') + ->where([ + '[[elements.revisionId]]' => null, + '[[elements.draftId]]' => null, + '[[elements.dateDeleted]]' => null, + ]) + ->andWhere([ + 'in', + new Expression('LOWER([[purchasables.sku]])'), + $lowercasedSkus, + ]); + + // A plan row rewrites its own variant, so that variant's current SKU is not taken + if ($plannedIds !== []) { + $query->andWhere([ + 'not', + [ + '[[purchasables.id]]' => $plannedIds, + ], + ]); + } + + return array_fill_keys(array_map('mb_strtolower', $query->column()), true); + } + + /** + * @param list $combination + */ + private function buildTitle(array $combination, string $titleFormat): string + { + $values = array_map(static fn (array $pair): string => $pair['attributeValue'], $combination); + + if (trim($titleFormat) === '') { + return implode(' / ', $values); + } + + $tokens = []; + + foreach ($combination as $pair) { + $tokens['{' . $pair['attributeName'] . '}'] = $pair['attributeValue']; + } + + return strtr($titleFormat, $tokens); + } + + /** + * @param array $options + */ + private function buildPrice(array $options, string $basePrice, Teller $teller): string + { + $price = $basePrice; + + foreach ($options as $option) { + if ($option->priceModifier !== null) { + $price = $teller->add($price, self::amount($option->priceModifier)); + } + } + + return $price; + } + + /** + * Every combination of the selected values, one entry per attribute in the order given. + * + * @param array> $valuesByName + * @return list> + */ + private function combinations(array $valuesByName): array + { + $combinations = [[]]; + + foreach ($valuesByName as $attributeName => $values) { + $extended = []; + + foreach ($combinations as $combination) { + foreach ($values as $attributeValue) { + $extended[] = [...$combination, [ + 'attributeName' => (string) $attributeName, + 'attributeValue' => $attributeValue, + ]]; + } + } + + $combinations = $extended; + } + + return $combinations; + } + + /** + * @return array + */ + private function existingVariantsByCombination(Product $product): array + { + $variants = []; + + foreach (Variant::find()->product($product)->status(null)->all() as $variant) { + $combinationKey = self::combinationKey($this->variantPairs($variant)); + + // Two variants can share a combination, and only the first is the one a row updates + if (! isset($variants[$combinationKey])) { + $variants[$combinationKey] = $variant; + } + } + + return $variants; + } + + /** + * @return list + */ + private function variantPairs(Variant $variant): array + { + $fieldHandle = FieldHelper::getFirstVariantAttributesField($variant->getFieldLayout())?->handle; + + if ($fieldHandle === null) { + return []; + } + + $storedAttributes = $variant->{$fieldHandle}; + + // Treat an unparseable value as no pairs, since the field stores JSON + if (! is_array($storedAttributes)) { + return []; + } + + $pairs = []; + + foreach ($storedAttributes as $pair) { + if (is_string($pair['attributeName'] ?? null) && is_string($pair['attributeValue'] ?? null)) { + $pairs[] = [ + 'attributeName' => $pair['attributeName'], + 'attributeValue' => $pair['attributeValue'], + ]; + } + } + + return $pairs; + } + + /** + * Every attribute and option ID the settings name, whether or not the row still exists. + * + * @return list + */ + private static function idsIn(VariantMakerSettings $settings): array + { + $ids = []; + + foreach ($settings->rows as $row) { + $ids[] = $row['attributeId']; + $ids = [...$ids, ...$row['optionIds']]; + } + + return $ids; + } + + /** + * @return list + */ + private function registeredIds(VariantMakerSettings $settings): array + { + $ids = self::idsIn($settings); + + if ($ids === []) { + return []; + } + + return array_map( + static fn (VariantAttribute $attribute): int => (int) $attribute->id, + VariantAttribute::find()->id($ids)->all(), + ); + } + + private function baseSku(Product $product): string + { + return $product->getDefaultVariant()?->sku ?? (string) $product->slug; + } + + private function teller(Product $product): Teller + { + $currencyIso = Commerce::getInstance()->getPaymentCurrencies()->getPrimaryPaymentCurrencyIso($product->storeId); + + return Commerce::getInstance()->getCurrencies()->getTeller($currencyIso); + } +} diff --git a/src/templates/settings/index.twig b/src/templates/settings/index.twig index 805433c..ab831e9 100644 --- a/src/templates/settings/index.twig +++ b/src/templates/settings/index.twig @@ -47,6 +47,17 @@ warning: configWarning('availableDisplayTypes'), }) }} + {{ forms.checkboxSelectField({ + label: 'settings.variantMakerProductTypes'|t('variant-manager'), + instructions: 'settings.variantMakerProductTypesIntro'|t('variant-manager'), + name: 'settings[variantMakerProductTypes]', + options: productTypeOptions, + values: settings.variantMakerProductTypes, + disabled: readOnly, + errors: settings.getErrors('variantMakerProductTypes'), + warning: configWarning('variantMakerProductTypes'), + }) }} + {{ forms.selectField({ label: 'settings.defaultDisplayType'|t('variant-manager'), instructions: 'settings.defaultDisplayTypeIntro'|t('variant-manager'), diff --git a/src/templates/variant-maker/_change.twig b/src/templates/variant-maker/_change.twig new file mode 100644 index 0000000..059821b --- /dev/null +++ b/src/templates/variant-maker/_change.twig @@ -0,0 +1,10 @@ +{# Only an update applies the proposed value, so only an update shows the arrow #} +{%- if proposed is null and current is null -%} + {{ (status == 'create' ? emptyLabel : 'variantMaker.valueKept')|t('variant-manager') }} +{%- elseif proposed is null or status != 'update' or current == proposed -%} + {{ current ?? proposed }} +{%- elseif current is null -%} + {{ proposed }} +{%- else -%} + {{ current }} → {{ proposed }} +{%- endif -%} diff --git a/src/templates/variant-maker/_preview.twig b/src/templates/variant-maker/_preview.twig new file mode 100644 index 0000000..847185f --- /dev/null +++ b/src/templates/variant-maker/_preview.twig @@ -0,0 +1,61 @@ +{% if rows is empty %} +

{{ 'variantMaker.previewEmpty'|t('variant-manager') }}

+{% else %} +

+ {% for status in ['create', 'update', 'unchanged', 'delete'] %} + {% set count = rows|filter(row => row.status == status)|length %} + {% if count %} + {{ ('variantMaker.status.' ~ status)|t('variant-manager') }}: {{ count }} + {% endif %} + {% endfor %} +

+ + {% set skuIssueCount = rows|filter(row => row.skuIssue is not null)|length %} + {% if skuIssueCount %} +

{{ 'variantMaker.skuIssues'|t('variant-manager', { count: skuIssueCount, max: skuMaxLength }) }}

+ {% endif %} + + + + + + + {% if not generatesTitles %} + + {% endif %} + + + + + + + {% for row in rows %} + + + + {% if not generatesTitles %} + + {% endif %} + + + + + {% endfor %} + +
{{ 'variantMaker.status'|t('variant-manager') }}{{ 'variantMaker.combination'|t('variant-manager') }}{{ 'variantMaker.title'|t('variant-manager') }}{{ 'variantMaker.sku'|t('variant-manager') }}{{ 'variantMaker.price'|t('variant-manager') }}{{ 'variantMaker.stock'|t('variant-manager') }}
{{ ('variantMaker.status.' ~ row.status)|t('variant-manager') }} + {%- for pair in row.pairs -%} + {{ pair.attributeValue }}{% if not loop.last %} / {% endif %} + {%- endfor -%} + {{ include('variant-manager/variant-maker/_change', { current: row.currentTitle, proposed: row.title, status: row.status, emptyLabel: 'variantMaker.valueDefault' }) }} + {{- include('variant-manager/variant-maker/_change', { current: row.currentSku, proposed: row.sku, status: row.status, emptyLabel: 'variantMaker.valueDefault' }) -}} + {% if row.skuIssue %} + {{ row.skuIssue }} + {% endif %} + {{ include('variant-manager/variant-maker/_change', { current: row.currentPrice, proposed: row.price, status: row.status, emptyLabel: 'variantMaker.valueDefault' }) }} + {%- if not tracksInventory -%} + {{ 'variantMaker.valueUnlimited'|t('variant-manager') }} + {%- else -%} + {{ include('variant-manager/variant-maker/_change', { current: null, proposed: row.property('stock'), status: row.status, emptyLabel: 'variantMaker.valueNone' }) }} + {%- endif -%} +
+{% endif %} diff --git a/src/templates/variant-maker/_row.twig b/src/templates/variant-maker/_row.twig new file mode 100644 index 0000000..ede2527 --- /dev/null +++ b/src/templates/variant-maker/_row.twig @@ -0,0 +1,46 @@ +{% import '_includes/forms.twig' as forms %} + +
+ +
+ {{ forms.elementSelect({ + id: 'vm-row-' ~ rowId ~ '-attribute', + name: 'variantMaker[rows][' ~ rowId ~ '][attributeId]', + elementType: 'fostercommerce\\variantmanager\\elements\\VariantAttribute', + criteria: { attributeId: 0 }, + selectionLabel: 'variantMaker.addAttribute'|t('variant-manager'), + elements: attribute ? [attribute] : [], + single: true, + searchCriteria: {}, + disabled: static ?? false, + }) }} +
+ +
+ {{ forms.elementSelect({ + id: 'vm-row-' ~ rowId ~ '-options', + name: 'variantMaker[rows][' ~ rowId ~ '][optionIds]', + elementType: 'fostercommerce\\variantmanager\\elements\\VariantAttribute', + criteria: { attributeId: attribute.id ?? -1 }, + selectionLabel: 'variantMaker.addOption'|t('variant-manager'), + elements: options ?? [], + sortable: true, + viewMode: 'list-inline', + searchCriteria: {}, + disabled: static ?? false, + }) }} +
+ +
+ {% if not (static ?? false) %} + + {% endif %} +
+ +
diff --git a/src/templates/variant-maker/tab.twig b/src/templates/variant-maker/tab.twig new file mode 100644 index 0000000..7fb22a7 --- /dev/null +++ b/src/templates/variant-maker/tab.twig @@ -0,0 +1,142 @@ +{% import '_includes/forms.twig' as forms %} + +{% if not product.id %} + {{ forms.field({ + label: 'variantMaker.name'|t('variant-manager'), + }, '

' ~ 'variantMaker.saveFirst'|t('variant-manager') ~ '

') }} +{% else %} + {% if not static %} + {% do view.registerAssetBundle('fostercommerce\\variantmanager\\VariantMakerAssetBundle') %} + {% endif %} + +
+ {% set rowBuilder %} +
+ {{ 'attributes.attribute'|t('variant-manager') }} + {{ 'variantMaker.options'|t('variant-manager') }} + +
+ +
+ {% for row in savedRows %} + {% include 'variant-manager/variant-maker/_row' with { + rowId: loop.index, + attribute: row.attribute, + options: row.options, + static: static, + } only %} + {% endfor %} +
+ + {% if not static %} + + {% endif %} + {% endset %} + + {{ forms.field({ + label: 'variantMaker.rows'|t('variant-manager'), + instructions: 'variantMaker.rowsInstructions'|t('variant-manager'), + }, rowBuilder) }} + + {{ forms.selectField({ + label: 'variantMaker.mode'|t('variant-manager'), + instructions: 'variantMaker.modeInstructions'|t('variant-manager'), + name: 'variantMaker[mode]', + value: settings.mode, + options: [ + { label: 'variantMaker.mode.add'|t('variant-manager'), value: 'add' }, + { label: 'variantMaker.mode.update'|t('variant-manager'), value: 'update' }, + { label: 'variantMaker.mode.replace'|t('variant-manager'), value: 'replace' }, + ], + }) }} + + {% set propertyTable %} + + + + + + + + + + {% for propertyName in propertyNames|filter(propertyName => not (propertyName == 'title' and generatesTitles)) %} + {% set property = settings.property(propertyName) %} + {% set required = propertyName in requiredProperties %} + {% set tracked = settings.property('inventoryTracked') %} + + + + + + {% endfor %} + +
{{ 'variantMaker.property'|t('variant-manager') }}{{ 'variantMaker.include'|t('variant-manager') }}{{ 'variantMaker.value'|t('variant-manager') }}
{{ ('variantMaker.property.' ~ propertyName)|t('variant-manager') }} + {{ forms.lightswitch({ + name: 'variantMaker[properties][' ~ propertyName ~ '][include]', + on: property.include, + disabled: required, + title: required ? 'variantMaker.propertyRequired'|t('variant-manager') : null, + labelledBy: 'vm-property-' ~ propertyName, + }) }} + + {% if propertyName in ['title', 'sku', 'price'] %} + {{ forms.text({ + name: 'variantMaker[properties][' ~ propertyName ~ '][value]', + value: property.value, + placeholder: ('variantMaker.placeholder.' ~ propertyName)|t('variant-manager'), + labelledBy: 'vm-property-' ~ propertyName, + }) }} + {% elseif propertyName == 'stock' %} + {{ forms.text({ + type: 'number', + min: 0, + size: 6, + name: 'variantMaker[properties][' ~ propertyName ~ '][value]', + value: property.value, + labelledBy: 'vm-property-' ~ propertyName, + }) }} + {% else %} + {{ forms.lightswitch({ + name: 'variantMaker[properties][' ~ propertyName ~ '][value]', + on: property.value, + labelledBy: 'vm-property-' ~ propertyName, + }) }} + {% endif %} +
+ + {% if inventoryLocations|length > 1 %} + {{ forms.selectField({ + label: 'variantMaker.inventoryLocation'|t('variant-manager'), + name: 'variantMaker[inventoryLocationId]', + value: settings.inventoryLocationId, + options: inventoryLocations, + }) }} + {% endif %} + {% endset %} + + {{ forms.field({ + label: 'variantMaker.properties'|t('variant-manager'), + instructions: 'variantMaker.propertiesInstructions'|t('variant-manager'), + }, propertyTable + ~ '

' ~ 'variantMaker.formatTokens'|t('variant-manager') ~ '

' + ~ (generatesTitles ? '

' ~ 'variantMaker.titlesGenerated'|t('variant-manager') ~ '

' : '') + ~ '

' ~ 'variantMaker.propertiesExcluded'|t('variant-manager') ~ '

') }} + +
+ +

{{ 'variantMaker.preview'|t('variant-manager') }}

+
+ + {% if not static %} +
+ + {{ 'variantMaker.generateInstructions'|t('variant-manager', { + activity: '' ~ 'variantMaker.results'|t('variant-manager') ~ '', + })|raw }} +
+ {% endif %} +
+{% endif %} diff --git a/src/translations/en/variant-manager.php b/src/translations/en/variant-manager.php index 7556e5f..a21666c 100644 --- a/src/translations/en/variant-manager.php +++ b/src/translations/en/variant-manager.php @@ -28,6 +28,9 @@ 'You do not have permission to bulk edit variants.' => 'You do not have permission to bulk edit variants.', // Importing + 'import.productSaveFailed' => 'Couldn’t save the product.', + 'import.variantSaveFailed' => 'Couldn’t save the variant.', + 'import.repeatedSkus' => 'Repeated: {skus}', 'import.missingSkuColumn' => 'The CSV has no “sku” column.', // Variant Attributes field @@ -84,8 +87,83 @@ // Variant attribute options 'options.usedBy' => 'Used by', + 'options.skuPartial' => 'SKU Partial', + 'options.priceModifier' => 'Price Modifier', 'options.variantCount' => '{count, plural, =0{No variants} =1{1 variant} other{# variants}}', + // Variant maker + 'settings.variantMakerProductTypes' => 'Variant Maker Product Types', + 'settings.variantMakerProductTypesIntro' => 'Product types whose products offer the Variant Maker tab. A product type also needs a Variant Attributes field in its variant field layout. None are selected by default.', + 'variantMaker.name' => 'Variant Maker', + 'variantMaker.saveFirst' => 'Save the product before creating variants.', + 'variantMaker.options' => 'Options', + 'variantMaker.productNotFound' => 'Product not found.', + 'variantMaker.rows' => 'Attributes to combine', + 'variantMaker.rowsInstructions' => 'One row per attribute. Every option in a row is combined with every option in every other row.', + 'variantMaker.addRow' => 'Add attribute', + 'variantMaker.rowNumberNeedsAttribute' => 'Variant Maker row {row} has no attribute.', + 'variantMaker.rowNumberNeedsOptions' => 'Variant Maker row {row} has no options.', + 'variantMaker.removeRow' => 'Remove', + 'variantMaker.addAttribute' => 'Add an attribute', + 'variantMaker.addOption' => 'Add an option', + 'variantMaker.mode' => 'What to do with existing variants', + 'variantMaker.modeInstructions' => 'Add only creates the combinations that are missing, and never touches an existing variant. Update also rewrites existing variants with every included property. Replace does that and deletes any variant not in the generated set.', + 'variantMaker.mode.add' => 'Add missing only', + 'variantMaker.mode.update' => 'Add and update existing', + 'variantMaker.mode.replace' => 'Replace: add, update and delete', + 'variantMaker.titlesGenerated' => 'This product type builds variant titles from its own format, so the Variant Maker does not set them.', + 'variantMaker.title' => 'Title', + 'variantMaker.properties' => 'Variant properties', + 'variantMaker.propertiesInstructions' => 'Include a property to have the Variant Maker set it. Whether an existing variant is rewritten is decided above, by what to do with existing variants. SKU and price are always set, since Commerce requires both. A new variant is always titled, falling back to its combination. Anything else left out keeps whatever Commerce sets.', + 'variantMaker.formatTokens' => 'In a title or SKU format, {Attribute Name} becomes the option chosen under that attribute. Type anything else, such as a product code, as-is. Left blank, a title is the options joined by a slash and a SKU is the default variant SKU followed by each option, with spaces turned into dashes.', + 'variantMaker.propertiesExcluded' => 'Not set here: dimensions, weight, tax and shipping categories, minimum and maximum quantity, promotional price, and any custom fields.', + 'variantMaker.property' => 'Property', + 'variantMaker.include' => 'Include', + 'variantMaker.value' => 'Value', + 'variantMaker.property.title' => 'Title', + 'variantMaker.property.sku' => 'SKU', + 'variantMaker.property.price' => 'Price', + 'variantMaker.placeholder.title' => '{Size} / {Color}', + 'variantMaker.placeholder.sku' => 'PRODUCT-{Size}-{Color}', + 'variantMaker.placeholder.price' => 'Default variant price', + 'variantMaker.property.inventoryTracked' => 'Track inventory', + 'variantMaker.property.stock' => 'Stock', + 'variantMaker.property.allowOutOfStockPurchases' => 'Allow out of stock purchases', + 'variantMaker.property.availableForPurchase' => 'Available for purchase', + 'variantMaker.property.freeShipping' => 'Free shipping', + 'variantMaker.property.promotable' => 'Promotable', + 'variantMaker.stock' => 'Stock', + 'variantMaker.inventoryLocation' => 'Inventory location', + 'variantMaker.preview' => 'Preview', + 'variantMaker.generate' => 'Generate variants', + 'variantMaker.generateInstructions' => 'Runs in the background. Watch for the {activity}.', + 'variantMaker.results' => 'results', + 'variantMaker.queued' => 'Generating variants. Watch for the results.', + 'variantMaker.saveBeforeGenerating' => 'Save the product first; generating uses the saved settings.', + 'variantMaker.activityGenerated' => 'Generated variants for {product}: {created} created, {updated} updated, {deleted} deleted', + 'variantMaker.activityFailed' => 'Failed to generate variants for {product}: {message}', + 'jobs.generateVariants' => 'Generating variants', + 'variantMaker.previewEmpty' => 'Check at least one option under each attribute you want combined.', + 'variantMaker.valueDefault' => 'Commerce default', + 'variantMaker.valueUnlimited' => 'Unlimited', + 'variantMaker.valueNone' => 'None', + 'variantMaker.propertyRequired' => 'Commerce requires this on every variant, so the Variant Maker always sets it.', + 'variantMaker.valueKept' => 'Kept', + 'variantMaker.status' => 'Status', + 'variantMaker.combination' => 'Combination', + 'variantMaker.sku' => 'SKU', + 'variantMaker.price' => 'Price', + 'variantMaker.status.create' => 'Create', + 'variantMaker.status.update' => 'Update', + 'variantMaker.status.unchanged' => 'Unchanged', + 'variantMaker.status.delete' => 'Delete', + 'variantMaker.skuDuplicate' => 'Another row builds this same SKU.', + 'variantMaker.skuTaken' => 'A variant on another product already uses this SKU.', + 'variantMaker.skuTooLong' => 'Longer than {max} characters.', + 'variantMaker.skuIssues' => 'Commerce requires every SKU to be unique and no longer than {max} characters. {count, plural, =1{One row breaks that rule} other{# rows break that rule}}, so generating is blocked until the SKU format is fixed.', + 'variantMaker.stockNote' => 'Quantity set by the Variant Maker', + 'variantMaker.skuIssuesBlockGenerating' => 'Some SKUs would collide or run too long. Fix the SKU format before generating.', + // Display types 'displayTypes.dropdown' => 'Dropdown', 'displayTypes.radioButtons' => 'Radio buttons',