diff --git a/CHANGELOG.md b/CHANGELOG.md index f0aaf30..7eaa28b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Release Notes for Variant Manager +## 4.0.0 - 2026-09-14 + +> {tip} This release removes the `VariantAttributeOption` element type. See [upgrading to 4.x](./docs/upgrade.md) before updating. + +### Added + +- 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. + +### Changed + +- Nested attribute options under their attribute in a single “Variant Attributes” listing, replacing the separate “Attribute Options” section. +- Renamed the read-only “CSV Name” and “CSV Value” labels to “System Name”, and the Title field on an attribute or option to “Display Name”. +- Labeled an attribute or option by its system name, followed by its display name in brackets where the two differ, and made both searchable. +- Moved attribute registration from the end of a CSV import to each variant save, so a failed import can now leave registry rows that the orphan prune clears. + +### Fixed + +- Fixed a bug where attribute values written outside the control panel stayed unregistered until the backfill ran. + +### Removed + +- Removed the `VariantAttributeOption` element type; an option is now a `VariantAttribute` with an `attributeId`, and its value is `name` rather than `value`. + ## 3.0.0 - 2026-09-11 > {tip} Run `./craft variant-manager/attributes/backfill` to take advantage of new features. diff --git a/README.md b/README.md index 74745c2..8f93bfc 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ composer require fostercommerce/variant-manager ./craft plugin/install variant-manager ``` -See [`docs/installation.md`](./docs/installation.md) for the full installation and configuration guide, and [`docs/upgrade.md`](./docs/upgrade.md) if you are coming from 2.x. +See [`docs/installation.md`](./docs/installation.md) for the full installation and configuration guide, and [`docs/upgrade.md`](./docs/upgrade.md) if you are coming from 2.x or 3.x. ## Importing diff --git a/docs/getting-started.md b/docs/getting-started.md index 01dd2dc..f507065 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -9,7 +9,7 @@ composer require fostercommerce/variant-manager ./craft plugin/install variant-manager ``` -In the CP you should see a **Variant Manager** nav item with four subnav entries: **Dashboard**, **Variants**, **Variant Attributes** and **Attribute Options**. +In the CP you should see a **Variant Manager** nav item with three subnav entries: **Dashboard**, **Variants** and **Variant Attributes**. ## 2. Configure @@ -58,9 +58,9 @@ Check the variants tab: ## 7. See the attributes the import registered -**Variant Manager -> Variant Attributes**. The import created `Color` and `Size`. **Variant Manager -> Attribute Options** lists `Red`, `Blue`, `Small` and `Medium`, each with the attribute it belongs to. +**Variant Manager -> Variant Attributes**. The import created `Color` and `Size`, each with its options nested under it: `Red` and `Blue` under `Color`, `Small` and `Medium` under `Size`. -Open `Red`. Its **CSV Value** is read-only; its title is not. The **Used by** count shows how many variants store `Red`. Rename the title to `Crimson` and save. No variant changed, and a template reading `option.title` now shows `Crimson`. +Open `Red`. Its **System Name** is read-only; its **Display Name** is not. The **Used by** count shows how many variants store `Red`. Rename the display name to `Crimson` and save. No variant changed, and a template reading `option.title` now shows `Crimson`. See [variant attributes](./user-guide/variant-attributes.md). @@ -88,4 +88,4 @@ For deeper reading: - [Variant Attributes field](./reference/field-type.md), how the attribute data is stored and read. - [Variant attributes](./user-guide/variant-attributes.md), attaching swatches and notes to attribute values. - [Template tags](./dev-guide/template-tags.md) and [recipes](./recipes/add-to-cart.md), using the attributes on the storefront. -- [Upgrading to 3.x](./upgrade.md), if you are coming from 2.x. +- [Upgrading](./upgrade.md), if you are coming from 2.x or 3.x. diff --git a/docs/index.md b/docs/index.md index 45649c2..38355db 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Import and export Craft Commerce product variants from CSV files. ## Upgrading -Coming from 2.x? See [upgrading to 3.x](./upgrade.md). +Coming from 2.x or 3.x? See [upgrading](./upgrade.md). ## Where to go diff --git a/docs/installation.md b/docs/installation.md index 9f8f8a7..d421e37 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -25,7 +25,7 @@ With DDEV: ddev composer require fostercommerce/variant-manager -w && ddev craft plugin/install variant-manager ``` -After install the CP navigation gets a **Variant Manager** item with **Dashboard** and **Variants**. **Variant Attributes** and **Attribute Options** appear for users with `variant-manager:manage-attributes`. +After install the CP navigation gets a **Variant Manager** item with **Dashboard** and **Variants**. **Variant Attributes** appears for users with `variant-manager:manage-attributes`. ## Configure diff --git a/docs/reference/console-commands.md b/docs/reference/console-commands.md index 550adde..3973063 100644 --- a/docs/reference/console-commands.md +++ b/docs/reference/console-commands.md @@ -38,6 +38,21 @@ Reads every variant in batches. Anything already registered is skipped, so the c The **Utilities -> Variant Attributes** utility runs the same work in the queue. +## `resave/variant-attributes` + +Re-save every attribute and option. + +```sh +./craft resave/variant-attributes --update-search-index +``` + +Craft's own resave command, with an action this plugin adds. `--update-search-index` rewrites the search keywords for each row, which is what picks up a system name stored before the row was last saved. `resave/all` includes it. + +| Option | Default | Description | +|--------|---------|-------------| +| `--update-search-index` | `false` | Rewrite each row's search keywords. | +| `--queue` | `false` | Run in the queue instead of the console. | + ## `variant-manager/attributes/orphans` List attributes and options whose name or value is no longer stored on any variant. diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md index 7ef8453..ee7148d 100644 --- a/docs/reference/permissions.md +++ b/docs/reference/permissions.md @@ -14,4 +14,4 @@ Admins bypass every check. See [user-guide/permissions](../user-guide/permissions.md) for who typically gets what. -Field layouts at **Settings -> Plugins -> Variant Manager** require an admin account. `variant-manager:manage-attributes` covers the attribute and option elements, including an attribute's display type, and Craft's own `utility:variant-manager-attributes` permission controls whether the utility is listed. +Field layouts at **Settings -> Plugins -> Variant Manager** require an admin account. `variant-manager:manage-attributes` covers attributes and their options, including an attribute's display type, and Craft's own `utility:variant-manager-attributes` permission controls whether the utility is listed. diff --git a/docs/upgrade.md b/docs/upgrade.md index 81d9578..fc1bc53 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -1,8 +1,56 @@ -# Upgrading to 3.x +# Upgrading + +Upgrade one major at a time, in order. Coming from 2.x to 4.x means working through both sections below, starting with 3.x. + +## Upgrading to 4.x + +Twig using `getAttributeOptions` or `getAttributeRegistry` keeps working. PHP that names an option element directly needs changing. + +### Attribute options are no longer their own element type + +`VariantAttributeOption` is removed. An option is a `VariantAttribute` with an `attributeId`, and its value is `name` rather than `value`. + +| 3.x | 4.x | +| --- | --- | +| `VariantAttributeOption::find()` | `VariantAttribute::find()->attributeId($attributeId)` | +| `$option->value` | `$option->name` | +| `variant_manager_attribute_options` table | `variant_manager_attributes`, with `attributeId` set | + +`variantQueryForOption()`, `variantCountForOption()` and `isOptionInUse()` take a `VariantAttribute`. + +Element IDs and UIDs are unchanged, so anything relating to an option still resolves. + +A query against the dropped table or the old element type string returns nothing instead of raising an error, so check integrations that read the database directly. + +### Options moved under their attribute + +Options are children of their attribute at **Variant Manager -> Variant Attributes**. The separate **Attribute Options** section is gone. Switch the listing to structure view to drag an attribute's options into the order a storefront should render them. + +### After updating + +Run migrations: + +```sh +./craft up +``` + +Then rewrite search keywords, so existing attributes and options are searchable by their system name: + +```sh +./craft resave/variant-attributes --update-search-index +``` + +If anything writes attribute values to variants outside the control panel, run the backfill once. On 3.x those values stayed unregistered until it ran; from 4.0.0 on, every variant save registers them. + +```sh +./craft variant-manager/attributes/backfill +``` + +## Upgrading to 3.x Existing templates keep working. This page is the setup the update needs, plus one change to the Variant Attributes field. -## Editing in the Variant Attributes field moved +### Editing in the Variant Attributes field moved The field showed an editable box per value with a **Save Attributes** button. Both are gone, along with the `variant-manager/product-variants/save-variant-attributes` action and its route. @@ -10,7 +58,7 @@ Names and values are now chips. Click one to open the attribute or option in a s Editing there is global: renaming an option changes what every product using that value shows, with no import and no change to any variant. To change the value the CSV writes, edit the CSV and reimport. -## After updating +### After updating Run migrations and project config changes: @@ -26,12 +74,12 @@ Then run the backfill once: It reads every variant and registers the names and values already stored on them. Safe to re-run. -On a site that imports CSVs this is a catch-up for data that predates 3.x. On a site that does not import, it is the only thing that populates the registry, so it is the setup step. +This is a catch-up for data that predates 3.x. On 3.x, imports and control panel saves register what they store; values written by an integration stay unregistered until the backfill runs again. -## Grant the new permission +### Grant the new permission -Existing user groups do not have `variant-manager:manage-attributes`. Grant it at **Users -> {group} -> Permissions** to anyone who should see the **Variant Attributes** and **Attribute Options** sections or run the utility. See [permissions](./reference/permissions.md). +Existing user groups do not have `variant-manager:manage-attributes`. Grant it at **Users -> {group} -> Permissions** to anyone who should see the **Variant Attributes** section or run the utility. See [permissions](./reference/permissions.md). -## Set field layouts in development +### Set field layouts in development An attribute's two field layouts are project config. Set them in your development environment and deploy them. The screen is read-only where `allowAdminChanges` is off, so they cannot be set in production directly. diff --git a/docs/user-guide/variant-attributes.md b/docs/user-guide/variant-attributes.md index e4f0c8b..4e683fd 100644 --- a/docs/user-guide/variant-attributes.md +++ b/docs/user-guide/variant-attributes.md @@ -8,11 +8,12 @@ Variants keep storing names and values as plain text, so imports and exports are | Source | When | |--------|------| +| A variant save | Any save that stores a name and value on a variant registers them, including an integration writing through Craft's API. | | CSV import | Every import registers the names and values it contains. | | **Utilities -> Variant Attributes** | Reads every variant in one pass. Run it once after installing. | | `./craft variant-manager/attributes/backfill` | The same work from the command line. | -Each new attribute and option is recorded in the [activity log](./activity-log.md). +Each new attribute and option is recorded in the [activity log](./activity-log.md). A save with no signed-in user, such as a console command or a queue job, is logged against `Unknown`. ## Names @@ -20,14 +21,18 @@ Each attribute and option has two names. | Name | Editable | Shown to | |------|----------|----------| -| **CSV Name** / **CSV Value** | No | The import. It is the exact text in your CSV. | -| Title | Yes | Shoppers, if your templates use it. | +| **System Name** | No | Whatever wrote the value. It is the exact text stored on the variant. | +| **Display Name** | Yes | Shoppers, if your templates use it. | -Rename an option's title to change what shoppers read. Every product using that value picks it up, with no import and no change to any variant. +Rename an option's display name to change what shoppers read. Every product using that value picks it up, with no import and no change to any variant. Templates read it as `option.title`. + +The control panel labels a row by its system name, and adds the display name in brackets once the two differ: `PBS200 (Pebble Stone)`. Search matches either one. ## Where to find them -**Variant Manager -> Variant Attributes** lists every attribute. **Variant Manager -> Attribute Options** lists every value, with a sidebar entry per attribute for narrowing the list. An option shows how many variants use it. +**Variant Manager -> Variant Attributes** lists every attribute with its options nested under it, the way entries in a Structure section are. An option shows how many variants use it. + +An option belongs to the attribute it sits under, so `Blue` under `Color` and `Blue` under `Paint chips` are separate rows with their own fields. Drag a row to reorder it among the rows sharing its attribute. Dragging an option onto a different attribute is refused, since that would change which value it stands for. In the Variant Attributes field on a variant, each name and value is a chip. Click one to open its row in a slideout. A name or value with no row yet shows as plain text until an import or the backfill registers it. diff --git a/src/Plugin.php b/src/Plugin.php index 84d022d..0abeda1 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -9,20 +9,27 @@ use craft\base\Plugin as BasePlugin; use craft\commerce\elements\Product; use craft\commerce\elements\Variant; +use craft\console\Controller as ConsoleController; +use craft\console\controllers\ResaveController; use craft\elements\conditions\ElementCondition; +use craft\events\DefineConsoleActionsEvent; use craft\events\DefineFieldLayoutFieldsEvent; use craft\events\DefineHtmlEvent; +use craft\events\ElementEvent; +use craft\events\MoveElementEvent; use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterConditionRulesEvent; use craft\events\RegisterElementActionsEvent; use craft\events\RegisterUrlRulesEvent; use craft\events\RegisterUserPermissionsEvent; use craft\fieldlayoutelements\TitleField; +use craft\helpers\ElementHelper; use craft\helpers\UrlHelper; use craft\models\FieldLayout; use craft\services\Elements; use craft\services\Fields; use craft\services\Gc; +use craft\services\Structures; use craft\services\UserPermissions; use craft\services\Utilities; use craft\web\twig\variables\CraftVariable; @@ -32,7 +39,6 @@ use fostercommerce\variantmanager\elements\actions\Export; use fostercommerce\variantmanager\elements\conditions\VariantAttributeConditionRule; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\elements\VariantManagerVariant; use fostercommerce\variantmanager\fields\VariantAttributesField; use fostercommerce\variantmanager\models\Settings; @@ -63,7 +69,7 @@ */ class Plugin extends BasePlugin { - public string $schemaVersion = '1.4.0'; + public string $schemaVersion = '1.5.0'; public bool $hasCpSettings = true; @@ -111,11 +117,6 @@ public function getCpNavItem(): ?array 'label' => Craft::t('variant-manager', 'attributes.attributes'), 'url' => 'variant-manager/attributes', ]; - - $nav['subnav']['attribute-options'] = [ - 'label' => Craft::t('variant-manager', 'options.options'), - 'url' => 'variant-manager/attribute-options', - ]; } return $nav; @@ -150,7 +151,9 @@ protected function createSettingsModel(): ?Model private function attachEventHandlers(): void { - if (! Craft::$app->getRequest()->getIsConsoleRequest()) { + if (Craft::$app->getRequest()->getIsConsoleRequest()) { + $this->registerResaveCommand(); + } else { if (Craft::$app->getRequest()->getIsCpRequest()) { $this->registerCpRoutes(); $this->registerActions(); @@ -169,6 +172,24 @@ private function attachEventHandlers(): void $this->registerEvents(); } + private function registerResaveCommand(): void + { + Event::on( + ResaveController::class, + ConsoleController::EVENT_DEFINE_ACTIONS, + static function (DefineConsoleActionsEvent $defineConsoleActionsEvent): void { + $defineConsoleActionsEvent->actions['variant-attributes'] = [ + 'action' => static function (): int { + /** @var ResaveController $controller */ + $controller = Craft::$app->controller; + return $controller->resaveElements(VariantAttribute::class); + }, + 'helpSummary' => 'Re-saves variant attributes and their options.', + ]; + } + ); + } + private function registerQueue(): void { $this->queue = Instance::ensure($this->queue, Queue::class); @@ -226,10 +247,6 @@ static function (RegisterUrlRulesEvent $registerUrlRulesEvent): void { 'template' => 'variant-manager/attributes/index.twig', ], 'variant-manager/attributes/' => 'elements/edit', - 'variant-manager/attribute-options' => [ - 'template' => 'variant-manager/attribute-options/index.twig', - ], - 'variant-manager/attribute-options/' => 'elements/edit', 'variant-manager/attributes//settings' => 'variant-manager/attributes/settings', ]; } @@ -299,7 +316,6 @@ private function registerElements(): void Elements::EVENT_REGISTER_ELEMENT_TYPES, static function (RegisterComponentTypesEvent $registerComponentTypesEvent): void { $registerComponentTypesEvent->types[] = VariantAttribute::class; - $registerComponentTypesEvent->types[] = VariantAttributeOption::class; } ); } @@ -314,8 +330,11 @@ static function (DefineFieldLayoutFieldsEvent $defineFieldLayoutFieldsEvent): vo $fieldLayout = $defineFieldLayoutFieldsEvent->sender; // Add a Title field, since Craft doesn't supply one for these element types - if (in_array($fieldLayout->type, [VariantAttribute::class, VariantAttributeOption::class], true)) { - $defineFieldLayoutFieldsEvent->fields[] = TitleField::class; + if ($fieldLayout->type === VariantAttribute::class) { + $defineFieldLayoutFieldsEvent->fields[] = [ + 'class' => TitleField::class, + 'label' => Craft::t('variant-manager', 'attributes.displayName'), + ]; } } ); @@ -359,6 +378,47 @@ static function (DefineHtmlEvent $event): void { private function registerEvents(): void { + Event::on( + Elements::class, + Elements::EVENT_AFTER_SAVE_ELEMENT, + static function (ElementEvent $elementEvent): void { + $element = $elementEvent->element; + + // Skip a propagated save, since it repeats the first site's values + // Skip a draft or revision, since its values may never be published + if (! $element instanceof Variant || $element->propagating || ElementHelper::isDraftOrRevision($element)) { + return; + } + + // Nothing else registers a value written outside an import or the backfill + $variantAttributes = Plugin::getInstance()->getVariantAttributes(); + $variantAttributes->ensureFromAttributePairs(array_values($variantAttributes->attributePairs([$element]))); + }, + ); + + Event::on( + Structures::class, + Structures::EVENT_BEFORE_UPDATE_ELEMENT, + static function (MoveElementEvent $moveElementEvent): void { + $element = $moveElementEvent->element; + + if (! $element instanceof VariantAttribute) { + return; + } + + $target = $moveElementEvent->getTargetElement(); + + $newAttributeId = match (true) { + ! $target instanceof VariantAttribute => 0, + in_array($moveElementEvent->action, [Structures::ACTION_PREPEND, Structures::ACTION_APPEND], true) => (int) $target->id, + default => $target->attributeId, + }; + + // The key is attributeId plus nameKey, so a move to another attribute would change it + $moveElementEvent->isValid = $newAttributeId === $element->attributeId; + }, + ); + Event::on( Gc::class, Gc::EVENT_RUN, @@ -367,7 +427,6 @@ static function (Event $_event): void { $garbageCollector = Craft::$app->getGc(); $garbageCollector->deletePartialElements(VariantAttribute::class, Table::ATTRIBUTES, 'id'); - $garbageCollector->deletePartialElements(VariantAttributeOption::class, Table::ATTRIBUTE_OPTIONS, 'id'); Plugin::getInstance()->getAttributeConfigs()->removeOrphaned(); }, diff --git a/src/console/controllers/AttributesController.php b/src/console/controllers/AttributesController.php index 8ed0efa..c89ba2b 100644 --- a/src/console/controllers/AttributesController.php +++ b/src/console/controllers/AttributesController.php @@ -61,7 +61,7 @@ public function actionOrphans(): int $orphans = $variantAttributes->findOrphans($this->batchSize); foreach ($orphans['options'] as $option) { - $this->stdout("option {$option->getVariantAttribute()?->name} / {$option->value}" . PHP_EOL); + $this->stdout("option {$option->getParentAttribute()?->name} / {$option->name}" . PHP_EOL); } foreach ($orphans['attributes'] as $attribute) { diff --git a/src/controllers/AttributesController.php b/src/controllers/AttributesController.php index d8db8ca..3726924 100644 --- a/src/controllers/AttributesController.php +++ b/src/controllers/AttributesController.php @@ -6,7 +6,6 @@ use craft\helpers\Queue; use craft\web\Controller; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\jobs\BackfillAttributes; use fostercommerce\variantmanager\jobs\PruneAttributeOrphans; use fostercommerce\variantmanager\Plugin; @@ -24,7 +23,7 @@ public function actionSettings(int $attributeId): Response { $this->requireAdmin(false); - $attribute = VariantAttribute::find()->id($attributeId)->one(); + $attribute = VariantAttribute::find()->attributeId(0)->id($attributeId)->one(); if (! $attribute instanceof VariantAttribute) { throw new NotFoundHttpException(Craft::t('variant-manager', 'attributes.notFound')); @@ -49,7 +48,7 @@ public function actionSaveSettings(): ?Response $this->requireAdmin(); $attributeId = (int) $this->request->getRequiredBodyParam('attributeId'); - $attribute = VariantAttribute::find()->id($attributeId)->one(); + $attribute = VariantAttribute::find()->attributeId(0)->id($attributeId)->one(); if (! $attribute instanceof VariantAttribute) { throw new NotFoundHttpException(Craft::t('variant-manager', 'attributes.notFound')); @@ -61,7 +60,7 @@ public function actionSaveSettings(): ?Response $fieldLayout->type = VariantAttribute::class; $optionFieldLayout = $fieldsService->assembleLayoutFromPost('option-layout'); - $optionFieldLayout->type = VariantAttributeOption::class; + $optionFieldLayout->type = VariantAttribute::class; if (! Plugin::getInstance()->getAttributeConfigs()->save($attribute->nameKey, $fieldLayout, $optionFieldLayout)) { $this->setFailFlash(Craft::t('variant-manager', 'attributes.settingsSaveFailed')); diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php index 99ad9b4..16d8bb4 100644 --- a/src/controllers/SettingsController.php +++ b/src/controllers/SettingsController.php @@ -20,7 +20,7 @@ public function actionIndex(): Response $settings = Plugin::getInstance()->getSettings(); return $this->renderTemplate('variant-manager/settings/index', [ - 'attributes' => VariantAttribute::find()->all(), + 'attributes' => VariantAttribute::find()->attributeId(0)->all(), 'settings' => $settings, 'displayTypeOptions' => DisplayType::options(DisplayType::cases()), 'defaultDisplayTypeOptions' => DisplayType::options($settings->getAvailableDisplayTypes($settings->defaultDisplayType)), diff --git a/src/db/Table.php b/src/db/Table.php index 1632de8..62d6444 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -8,5 +8,5 @@ abstract class Table public const ATTRIBUTES = '{{%variant_manager_attributes}}'; - public const ATTRIBUTE_OPTIONS = '{{%variant_manager_attribute_options}}'; + public const STRUCTURES = '{{%variant_manager_structures}}'; } diff --git a/src/elements/VariantAttribute.php b/src/elements/VariantAttribute.php index 49096a4..87e94b5 100644 --- a/src/elements/VariantAttribute.php +++ b/src/elements/VariantAttribute.php @@ -10,6 +10,7 @@ use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\models\FieldLayout; +use craft\services\Structures; use fostercommerce\variantmanager\elements\db\VariantAttributeQuery; use fostercommerce\variantmanager\enums\DisplayType; use fostercommerce\variantmanager\Plugin; @@ -18,18 +19,24 @@ use yii\base\InvalidConfigException; /** - * A registry row for one attribute name used by the Variant Attributes field. + * A registry row for one attribute name, or for one of its option values. * - * Variants store the name as a string, so deleting an attribute does not change a variant. + * Variants store the name and value as strings, so deleting a row does not change a variant. + * + * @property-read null|VariantAttribute $parentAttribute */ class VariantAttribute extends Element { + public int $attributeId = 0; + public string $name = ''; public string $nameKey = ''; public string $displayType = DisplayType::Dropdown->value; + private ?VariantAttribute $parentAttribute = null; + public static function displayName(): string { return Craft::t('variant-manager', 'attributes.attribute'); @@ -60,9 +67,14 @@ public static function hasTitles(): bool return true; } + public static function hasStructure(): bool + { + return true; + } + public static function isLocalized(): bool { - // Neither table has a siteId column, so per-site rows would be identical + // The table has no siteId column, so per-site rows would be identical return false; } @@ -76,9 +88,42 @@ public static function normalizeName(string $name): string return StringHelper::toLowerCase(trim($name)); } + public function isOption(): bool + { + return $this->attributeId !== 0; + } + + public function getParentAttribute(): ?self + { + if (! $this->isOption()) { + return null; + } + + return $this->parentAttribute ??= Plugin::getInstance()->getVariantAttributes()->getAttributeById($this->attributeId); + } + + /** + * getAttributeById() can miss an attribute created in the same request. + */ + public function setParentAttribute(self $attribute): void + { + $this->attributeId = (int) $attribute->id; + $this->parentAttribute = $attribute; + } + public function getFieldLayout(): ?FieldLayout { - return Plugin::getInstance()->getAttributeConfigs()->getFieldLayout($this->nameKey); + $attributeConfigs = Plugin::getInstance()->getAttributeConfigs(); + + if (! $this->isOption()) { + return $attributeConfigs->getFieldLayout($this->nameKey); + } + + $attribute = $this->getParentAttribute(); + + return $attribute === null + ? null + : $attributeConfigs->getOptionFieldLayout($attribute->nameKey); } public function getDisplayType(): DisplayType @@ -131,15 +176,15 @@ public function afterSave(bool $isNew): void } } + $record->attributeId = $this->attributeId; $record->name = $this->name; $record->nameKey = $this->nameKey; $record->displayType = $this->displayType; $record->save(false); if ($isNew) { - Activity::log(Craft::$app->getUser()->getIdentity(), Craft::t('variant-manager', 'attributes.activityCreated', [ - 'name' => Html::encode($this->name), - ])); + $this->placeInStructure(); + $this->logCreation(); } } @@ -152,13 +197,24 @@ public function beforeDelete(): bool return false; } - if (Plugin::getInstance()->getVariantAttributes()->isAttributeInUse($this)) { + $variantAttributes = Plugin::getInstance()->getVariantAttributes(); + + if ($this->isOption()) { + if ($variantAttributes->isOptionInUse($this)) { + $this->addError('name', Craft::t('variant-manager', 'options.deleteInUse')); + return false; + } + + return true; + } + + if ($variantAttributes->isAttributeInUse($this)) { $this->addError('name', Craft::t('variant-manager', 'attributes.deleteInUse')); return false; } // Include already-trashed options on a hard delete, since the cascade removes their rows - $options = VariantAttributeOption::find() + $options = self::find() ->attributeId($this->id) ->trashed($this->hardDelete ? null : false) ->all(); @@ -176,7 +232,7 @@ public function beforeDelete(): bool public function afterRestore(): void { - $options = VariantAttributeOption::find() + $options = self::find() ->attributeId($this->id) ->trashed(true) ->andWhere([ @@ -191,48 +247,81 @@ public function afterRestore(): void public function beforeSave(bool $isNew): bool { + $this->structureId = Plugin::getInstance()->getVariantAttributes()->getStructureId(); $this->name = trim($this->name); $this->nameKey = self::normalizeName($this->name); return parent::beforeSave($isNew); } - protected function metaFieldsHtml(bool $static): string + protected function uiLabel(): ?string { - $fields = Cp::selectFieldHtml([ - 'label' => Craft::t('variant-manager', 'attributes.displayType'), - 'id' => 'displayType', - 'name' => 'displayType', - 'options' => DisplayType::options(Plugin::getInstance()->getSettings()->getAvailableDisplayTypes($this->displayType)), - 'value' => $this->displayType, - 'disabled' => $static, - ]); + return $this->title === $this->name + ? $this->name + : "{$this->name} ({$this->title})"; + } - // Variants match on the attribute name string, so the name is read only - $fields .= Cp::textFieldHtml([ - 'label' => Craft::t('variant-manager', 'attributes.name'), - 'id' => 'name', - 'value' => $this->name, - 'disabled' => true, - ]); + protected function crumbs(): array + { + $crumbs = [ + [ + 'label' => Craft::t('variant-manager', 'plugin.name'), + 'url' => UrlHelper::cpUrl('variant-manager/dashboard'), + ], + [ + 'label' => Craft::t('variant-manager', 'attributes.attributes'), + 'url' => UrlHelper::cpUrl('variant-manager/attributes'), + ], + ]; + + $attribute = $this->getParentAttribute(); + + if ($attribute !== null) { + $crumbs[] = [ + 'html' => Cp::elementChipHtml($attribute, [ + 'class' => 'chromeless', + 'hyperlink' => true, + ]), + ]; + } - return $fields . parent::metaFieldsHtml($static); + return $crumbs; + } + + protected function metaFieldsHtml(bool $static): string + { + return ($this->isOption() ? $this->optionMetaFieldsHtml() : $this->attributeMetaFieldsHtml($static)) + . parent::metaFieldsHtml($static); } protected static function defineSources(string $context): array { + $variantAttributes = Plugin::getInstance()->getVariantAttributes(); + return [ [ 'key' => '*', 'label' => Craft::t('variant-manager', 'attributes.allAttributes'), 'criteria' => [], + 'structureId' => $variantAttributes->getStructureId(), + 'structureEditable' => Craft::$app->getRequest()->getIsConsoleRequest() || Craft::$app->getUser()->checkPermission('variant-manager:manage-attributes'), + 'defaultViewMode' => 'structure', + 'defaultSort' => ['structure', 'asc'], ], ]; } protected static function defineFieldLayouts(?string $source): array { - return Plugin::getInstance()->getAttributeConfigs()->getAllAttributeLayouts(); + $attributeConfigs = Plugin::getInstance()->getAttributeConfigs(); + + return [...$attributeConfigs->getAllAttributeLayouts(), ...$attributeConfigs->getAllOptionLayouts()]; + } + + protected static function defineSearchableAttributes(): array + { + // Name only, since the title is indexed already + return ['name']; } protected static function defineSortOptions(): array @@ -254,14 +343,15 @@ protected static function defineTableAttributes(): array protected static function defineDefaultTableAttributes(string $source): array { - return ['name', 'displayType']; + // uiLabel() puts the name in the title column, so a name column repeats it + return ['displayType']; } protected function attributeHtml(string $attribute): string { return match ($attribute) { 'name' => Html::encode($this->name), - 'displayType' => Html::encode($this->getDisplayType()->label()), + 'displayType' => $this->isOption() ? '' : Html::encode($this->getDisplayType()->label()), default => parent::attributeHtml($attribute), }; } @@ -269,6 +359,9 @@ protected function attributeHtml(string $attribute): string protected function defineRules(): array { $rules = parent::defineRules(); + $rules[] = [['attributeId'], + 'number', + 'integerOnly' => true]; $rules[] = [['name'], 'required']; $rules[] = [['displayType'], 'in', @@ -278,4 +371,74 @@ protected function defineRules(): array 'max' => 255]; return $rules; } + + private function placeInStructure(): void + { + $structuresService = Craft::$app->getStructures(); + $attribute = $this->getParentAttribute(); + + if ($attribute === null) { + $structuresService->appendToRoot($this->structureId, $this, Structures::MODE_INSERT); + return; + } + + $structuresService->append($this->structureId, $this, $attribute, Structures::MODE_INSERT); + } + + private function logCreation(): void + { + $currentUser = Craft::$app->getUser()->getIdentity(); + + if ($this->isOption()) { + Activity::log($currentUser, Craft::t('variant-manager', 'options.activityCreated', [ + 'name' => Html::encode($this->name), + 'attribute' => Html::encode((string) $this->getParentAttribute()?->name), + ])); + + return; + } + + Activity::log($currentUser, Craft::t('variant-manager', 'attributes.activityCreated', [ + 'name' => Html::encode($this->name), + ])); + } + + private function attributeMetaFieldsHtml(bool $static): string + { + $fields = Cp::selectFieldHtml([ + 'label' => Craft::t('variant-manager', 'attributes.displayType'), + 'id' => 'displayType', + 'name' => 'displayType', + 'options' => DisplayType::options(Plugin::getInstance()->getSettings()->getAvailableDisplayTypes($this->displayType)), + 'value' => $this->displayType, + 'disabled' => $static, + ]); + + // Variants match on the attribute name string, so the name is read only + return $fields . Cp::textFieldHtml([ + 'label' => Craft::t('variant-manager', 'attributes.name'), + 'id' => 'name', + 'value' => $this->name, + 'disabled' => true, + ]); + } + + private function optionMetaFieldsHtml(): string + { + $variantCount = Plugin::getInstance()->getVariantAttributes()->variantCountForOption($this); + + $fields = Cp::fieldHtml(Html::encode(Craft::t('variant-manager', 'options.variantCount', [ + 'count' => $variantCount, + ])), [ + 'label' => Craft::t('variant-manager', 'options.usedBy'), + ]); + + // Variants match on the option value string, so the value is read only + return $fields . Cp::textFieldHtml([ + 'label' => Craft::t('variant-manager', 'attributes.name'), + 'id' => 'name', + 'value' => $this->name, + 'disabled' => true, + ]); + } } diff --git a/src/elements/VariantAttributeOption.php b/src/elements/VariantAttributeOption.php deleted file mode 100644 index b0c2eb4..0000000 --- a/src/elements/VariantAttributeOption.php +++ /dev/null @@ -1,274 +0,0 @@ -getVariantAttribute(); - - return $attribute === null - ? null - : Plugin::getInstance()->getAttributeConfigs()->getOptionFieldLayout($attribute->nameKey); - } - - public function getVariantAttribute(): ?VariantAttribute - { - if ($this->attributeId === null) { - return null; - } - - return $this->variantAttribute ??= Plugin::getInstance()->getVariantAttributes()->getAttributeById($this->attributeId); - } - - public function getCpEditUrl(): ?string - { - return UrlHelper::cpUrl("variant-manager/attribute-options/{$this->id}"); - } - - public function getPostEditUrl(): ?string - { - return UrlHelper::cpUrl('variant-manager/attribute-options'); - } - - public function canView(User $user): bool - { - return $user->can('variant-manager:manage-attributes'); - } - - public function canSave(User $user): bool - { - return $user->can('variant-manager:manage-attributes'); - } - - /** - * Rows are derived from what variants store, so the prune utility removes the unused ones. - */ - public function canDelete(User $user): bool - { - return false; - } - - /** - * @throws InvalidConfigException - */ - public function afterSave(bool $isNew): void - { - if (! $this->propagating) { - if ($isNew) { - $record = new VariantAttributeOptionRecord(); - $record->id = (int) $this->id; - } else { - $record = VariantAttributeOptionRecord::findOne($this->id); - - if (! $record instanceof VariantAttributeOptionRecord) { - throw new InvalidConfigException("Invalid variant attribute option ID: {$this->id}"); - } - } - - $record->attributeId = $this->attributeId; - $record->value = $this->value; - $record->valueKey = $this->valueKey; - $record->save(false); - - if ($isNew) { - Activity::log(Craft::$app->getUser()->getIdentity(), Craft::t('variant-manager', 'options.activityCreated', [ - 'value' => Html::encode($this->value), - 'attribute' => Html::encode((string) $this->getVariantAttribute()?->name), - ])); - } - } - - parent::afterSave($isNew); - } - - public function beforeDelete(): bool - { - if (! parent::beforeDelete()) { - return false; - } - - if (Plugin::getInstance()->getVariantAttributes()->isOptionInUse($this)) { - $this->addError('value', Craft::t('variant-manager', 'options.deleteInUse')); - return false; - } - - return true; - } - - public function beforeSave(bool $isNew): bool - { - $this->value = trim($this->value); - $this->valueKey = self::normalizeValue($this->value); - - return parent::beforeSave($isNew); - } - - protected function metaFieldsHtml(bool $static): string - { - $variantCount = Plugin::getInstance()->getVariantAttributes()->variantCountForOption($this); - - $fields = Cp::fieldHtml(Html::encode(Craft::t('variant-manager', 'options.variantCount', [ - 'count' => $variantCount, - ])), [ - 'label' => Craft::t('variant-manager', 'options.usedBy'), - ]); - - // Variants match on the option value string, so the value is read only - $fields .= Cp::textFieldHtml([ - 'label' => Craft::t('variant-manager', 'options.value'), - 'id' => 'value', - 'value' => $this->value, - 'disabled' => true, - ]); - - return $fields . parent::metaFieldsHtml($static); - } - - protected static function defineSources(string $context): array - { - $sources = [ - [ - 'key' => '*', - 'label' => Craft::t('variant-manager', 'options.allOptions'), - 'criteria' => [], - ], - ]; - - foreach (Plugin::getInstance()->getVariantAttributes()->getAllAttributes() as $attribute) { - $sources[] = [ - 'key' => "attribute:{$attribute->uid}", - 'label' => $attribute->name, - 'criteria' => [ - 'attributeId' => $attribute->id, - ], - ]; - } - - return $sources; - } - - protected static function defineFieldLayouts(?string $source): array - { - return Plugin::getInstance()->getAttributeConfigs()->getAllOptionLayouts(); - } - - protected static function defineSortOptions(): array - { - return [ - 'value' => Craft::t('variant-manager', 'options.value'), - 'dateCreated' => Craft::t('app', 'Date Created'), - ]; - } - - protected static function defineTableAttributes(): array - { - return [ - 'value' => Craft::t('variant-manager', 'options.value'), - 'attribute' => Craft::t('variant-manager', 'options.attribute'), - 'dateCreated' => Craft::t('app', 'Date Created'), - ]; - } - - protected static function defineDefaultTableAttributes(string $source): array - { - return ['value', 'attribute']; - } - - protected function attributeHtml(string $attribute): string - { - return match ($attribute) { - 'value' => Html::encode($this->value), - 'attribute' => Html::encode((string) $this->getVariantAttribute()?->name), - default => parent::attributeHtml($attribute), - }; - } - - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['attributeId'], - 'number', - 'integerOnly' => true]; - $rules[] = [['value'], 'required']; - $rules[] = [['value', 'valueKey'], - 'string', - 'max' => 255]; - return $rules; - } -} diff --git a/src/elements/conditions/VariantAttributeConditionRule.php b/src/elements/conditions/VariantAttributeConditionRule.php index c811a0b..5008c58 100644 --- a/src/elements/conditions/VariantAttributeConditionRule.php +++ b/src/elements/conditions/VariantAttributeConditionRule.php @@ -13,7 +13,6 @@ use craft\elements\conditions\ElementConditionRuleInterface; use craft\elements\db\ElementQueryInterface; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\fields\VariantAttributesField; use fostercommerce\variantmanager\Plugin; @@ -24,7 +23,7 @@ class VariantAttributeConditionRule extends BaseSelectConditionRule implements E { public ?int $attributeId = null; - private VariantAttributeOption|false|null $selectedOption = null; + private VariantAttribute|false|null $selectedOption = null; /** * @var list|null @@ -107,9 +106,9 @@ protected function options(): array $options = []; - foreach (VariantAttributeOption::find()->attributeId($this->attributeId)->all() as $option) { + foreach (VariantAttribute::find()->attributeId($this->attributeId)->all() as $option) { $options[] = [ - 'label' => $option->value, + 'label' => $option->name, 'value' => (string) $option->id, ]; } @@ -147,7 +146,7 @@ private function fieldCondition(array &$params): mixed } $condition = VariantAttributesField::queryCondition($instances, [ - $attribute->name => $option->value, + $attribute->name => $option->name, ], $params); return $condition === false ? '0=1' : $condition; @@ -171,7 +170,7 @@ private function matchVariant(ElementInterface $variant): bool } foreach ($value as $pair) { - if (($pair['attributeName'] ?? null) === $attribute->name && ($pair['attributeValue'] ?? null) === $option->value) { + if (($pair['attributeName'] ?? null) === $attribute->name && ($pair['attributeValue'] ?? null) === $option->name) { return true; } } @@ -187,12 +186,12 @@ private function attribute(): ?VariantAttribute : Plugin::getInstance()->getVariantAttributes()->getAttributeById($this->attributeId); } - private function selectedOption(): ?VariantAttributeOption + private function selectedOption(): ?VariantAttribute { if ($this->selectedOption === null) { $this->selectedOption = ($this->value === '' ? null - : VariantAttributeOption::find()->id((int) $this->value)->one()) ?? false; + : VariantAttribute::find()->id((int) $this->value)->one()) ?? false; } return $this->selectedOption === false ? null : $this->selectedOption; diff --git a/src/elements/db/VariantAttributeOptionQuery.php b/src/elements/db/VariantAttributeOptionQuery.php deleted file mode 100644 index 33f5e0a..0000000 --- a/src/elements/db/VariantAttributeOptionQuery.php +++ /dev/null @@ -1,68 +0,0 @@ - - */ -class VariantAttributeOptionQuery extends ElementQuery -{ - public mixed $attributeId = null; - - public mixed $valueKey = null; - - protected array $defaultOrderBy = [ - 'variant_manager_attribute_options.value' => SORT_ASC, - ]; - - public function attributeId(mixed $value): static - { - $this->attributeId = $value; - return $this; - } - - public function valueKey(mixed $value): static - { - $this->valueKey = $value; - return $this; - } - - protected function fieldLayouts(): array - { - // These layouts are in project config, not the fieldlayouts table - return VariantAttributeOption::fieldLayouts(null); - } - - protected function beforePrepare(): bool - { - if (! parent::beforePrepare()) { - return false; - } - - $this->joinElementTable(Table::ATTRIBUTE_OPTIONS); - - $this->query->addSelect([ - 'variant_manager_attribute_options.attributeId', - 'variant_manager_attribute_options.value', - 'variant_manager_attribute_options.valueKey', - ]); - - if (isset($this->attributeId)) { - $this->subQuery->andWhere(Db::parseNumericParam('variant_manager_attribute_options.attributeId', $this->attributeId)); - } - - if (isset($this->valueKey)) { - $this->subQuery->andWhere(Db::parseParam('variant_manager_attribute_options.valueKey', $this->valueKey)); - } - - return true; - } -} diff --git a/src/elements/db/VariantAttributeQuery.php b/src/elements/db/VariantAttributeQuery.php index 3167fca..ba60986 100644 --- a/src/elements/db/VariantAttributeQuery.php +++ b/src/elements/db/VariantAttributeQuery.php @@ -6,6 +6,7 @@ use craft\helpers\Db; use fostercommerce\variantmanager\db\Table; use fostercommerce\variantmanager\elements\VariantAttribute; +use fostercommerce\variantmanager\Plugin; /** * @template TKey of array-key @@ -17,16 +18,36 @@ class VariantAttributeQuery extends ElementQuery { public mixed $nameKey = null; + public mixed $attributeId = null; + protected array $defaultOrderBy = [ 'variant_manager_attributes.name' => SORT_ASC, ]; + public function init(): void + { + if (! isset($this->withStructure)) { + $this->withStructure = true; + } + + parent::init(); + } + public function nameKey(mixed $value): static { $this->nameKey = $value; return $this; } + /** + * Narrows the query results to the options of the given attributes, or to attributes with 0. + */ + public function attributeId(mixed $value): static + { + $this->attributeId = $value; + return $this; + } + protected function fieldLayouts(): array { // These layouts are in project config, not the fieldlayouts table @@ -35,6 +56,10 @@ protected function fieldLayouts(): array protected function beforePrepare(): bool { + if (! isset($this->structureId)) { + $this->structureId = Plugin::getInstance()->getVariantAttributes()->getStructureId(); + } + if (! parent::beforePrepare()) { return false; } @@ -42,11 +67,16 @@ protected function beforePrepare(): bool $this->joinElementTable(Table::ATTRIBUTES); $this->query->addSelect([ + 'variant_manager_attributes.attributeId', 'variant_manager_attributes.name', 'variant_manager_attributes.nameKey', 'variant_manager_attributes.displayType', ]); + if (isset($this->attributeId)) { + $this->subQuery->andWhere(Db::parseNumericParam('variant_manager_attributes.attributeId', $this->attributeId)); + } + if (isset($this->nameKey)) { $this->subQuery->andWhere(Db::parseParam('variant_manager_attributes.nameKey', $this->nameKey)); } diff --git a/src/fields/VariantAttributesField.php b/src/fields/VariantAttributesField.php index 14f4923..2e36cc2 100644 --- a/src/fields/VariantAttributesField.php +++ b/src/fields/VariantAttributesField.php @@ -10,7 +10,6 @@ use craft\helpers\Json; use craft\helpers\StringHelper; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\helpers\FieldHelper; use fostercommerce\variantmanager\Plugin; use fostercommerce\variantmanager\VariantAttributesFieldAssetBundle; @@ -141,7 +140,7 @@ public static function queryCondition( /** * Pair each stored attribute with its registry elements, or null where the pair is unregistered. * - * @return list + * @return list */ private function registryRows(mixed $fieldValue): array { @@ -155,8 +154,8 @@ private function registryRows(mixed $fieldValue): array $options = []; if ($attributeIds !== []) { - foreach (VariantAttributeOption::find()->attributeId(array_values($attributeIds))->all() as $option) { - $options["{$option->attributeId}\0{$option->valueKey}"] = $option; + foreach (VariantAttribute::find()->attributeId(array_values($attributeIds))->all() as $option) { + $options["{$option->attributeId}\0{$option->nameKey}"] = $option; } } @@ -164,7 +163,7 @@ private function registryRows(mixed $fieldValue): array foreach ($fieldValue as $pair) { $attribute = $attributes[VariantAttribute::normalizeName($pair['attributeName'])] ?? null; - $optionKey = $attribute?->id . "\0" . VariantAttributeOption::normalizeValue($pair['attributeValue']); + $optionKey = $attribute?->id . "\0" . VariantAttribute::normalizeName($pair['attributeValue']); $rows[] = [ 'attributeName' => $pair['attributeName'], diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 904e03d..31e113e 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -2,8 +2,10 @@ namespace fostercommerce\variantmanager\migrations; +use Craft; use craft\db\Migration; use craft\db\Table as CraftTable; +use craft\models\Structure; use fostercommerce\variantmanager\db\Table; class Install extends Migration @@ -22,6 +24,7 @@ public function safeUp(): bool $this->createTable(Table::ATTRIBUTES, [ 'id' => $this->integer()->notNull(), + 'attributeId' => $this->integer()->notNull()->defaultValue(0), 'name' => $this->string()->notNull(), 'nameKey' => $this->string()->notNull(), 'displayType' => $this->string()->notNull()->defaultValue('dropdown'), @@ -31,30 +34,35 @@ public function safeUp(): bool 'PRIMARY KEY([[id]])', ]); - $this->createIndex(null, Table::ATTRIBUTES, ['nameKey'], true); + // The key is the attribute plus the name, so Blue under two attributes is two rows + // An attribute uses 0 rather than null, since MySQL treats null attributeIds as distinct + $this->createIndex(null, Table::ATTRIBUTES, ['attributeId', 'nameKey'], true); $this->addForeignKey(null, Table::ATTRIBUTES, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - $this->createTable(Table::ATTRIBUTE_OPTIONS, [ + $this->createTable(Table::STRUCTURES, [ 'id' => $this->integer()->notNull(), - 'attributeId' => $this->integer()->notNull(), - 'value' => $this->string()->notNull(), - 'valueKey' => $this->string()->notNull(), - 'dateCreated' => $this->dateTime()->notNull(), - 'dateUpdated' => $this->dateTime()->notNull(), 'uid' => $this->uid(), 'PRIMARY KEY([[id]])', ]); - $this->createIndex(null, Table::ATTRIBUTE_OPTIONS, ['attributeId', 'valueKey'], true); - $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['attributeId'], Table::ATTRIBUTES, ['id'], 'CASCADE'); + $this->addForeignKey(null, Table::STRUCTURES, ['id'], CraftTable::STRUCTURES, ['id'], 'CASCADE'); + + $structure = new Structure([ + 'maxLevels' => 2, + ]); + + Craft::$app->getStructures()->saveStructure($structure); + + $this->insert(Table::STRUCTURES, [ + 'id' => $structure->id, + ]); return true; } public function safeDown(): bool { - $this->dropTableIfExists(Table::ATTRIBUTE_OPTIONS); + $this->dropTableIfExists(Table::STRUCTURES); $this->dropTableIfExists(Table::ATTRIBUTES); if ($this->db->tableExists(Table::ACTIVITIES)) { diff --git a/src/migrations/m260905_153830_create_variant_attribute_tables.php b/src/migrations/m260905_153830_create_variant_attribute_tables.php index 3d6c9a3..b45b3e6 100644 --- a/src/migrations/m260905_153830_create_variant_attribute_tables.php +++ b/src/migrations/m260905_153830_create_variant_attribute_tables.php @@ -8,6 +8,8 @@ class m260905_153830_create_variant_attribute_tables extends Migration { + private const OPTIONS_TABLE = '{{%variant_manager_attribute_options}}'; + public function safeUp(): bool { $this->createTable(Table::ATTRIBUTES, [ @@ -23,7 +25,7 @@ public function safeUp(): bool $this->createIndex(null, Table::ATTRIBUTES, ['nameKey'], true); $this->addForeignKey(null, Table::ATTRIBUTES, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - $this->createTable(Table::ATTRIBUTE_OPTIONS, [ + $this->createTable(self::OPTIONS_TABLE, [ 'id' => $this->integer()->notNull(), 'attributeId' => $this->integer()->notNull(), 'value' => $this->string()->notNull(), @@ -34,9 +36,9 @@ public function safeUp(): bool 'PRIMARY KEY([[id]])', ]); - $this->createIndex(null, Table::ATTRIBUTE_OPTIONS, ['attributeId', 'valueKey'], true); - $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); - $this->addForeignKey(null, Table::ATTRIBUTE_OPTIONS, ['attributeId'], Table::ATTRIBUTES, ['id'], 'CASCADE'); + $this->createIndex(null, self::OPTIONS_TABLE, ['attributeId', 'valueKey'], true); + $this->addForeignKey(null, self::OPTIONS_TABLE, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE'); + $this->addForeignKey(null, self::OPTIONS_TABLE, ['attributeId'], Table::ATTRIBUTES, ['id'], 'CASCADE'); return true; } diff --git a/src/migrations/m260914_090000_nest_attribute_options.php b/src/migrations/m260914_090000_nest_attribute_options.php new file mode 100644 index 0000000..de72bc5 --- /dev/null +++ b/src/migrations/m260914_090000_nest_attribute_options.php @@ -0,0 +1,106 @@ +createTable(Table::STRUCTURES, [ + 'id' => $this->integer()->notNull(), + 'uid' => $this->uid(), + 'PRIMARY KEY([[id]])', + ]); + + $this->addForeignKey(null, Table::STRUCTURES, ['id'], CraftTable::STRUCTURES, ['id'], 'CASCADE'); + + $structure = new Structure([ + 'maxLevels' => 2, + ]); + + Craft::$app->getStructures()->saveStructure($structure); + + $this->insert(Table::STRUCTURES, [ + 'id' => $structure->id, + ]); + + $this->addColumn(Table::ATTRIBUTES, 'attributeId', $this->integer()->notNull()->defaultValue(0)->after('id')); + $this->dropIndexIfExists(Table::ATTRIBUTES, ['nameKey'], true); + $this->createIndex(null, Table::ATTRIBUTES, ['attributeId', 'nameKey'], true); + + $options = (new Query()) + ->from(self::OPTIONS_TABLE) + ->all(); + + foreach ($options as $option) { + $this->insert(Table::ATTRIBUTES, [ + 'id' => $option['id'], + 'attributeId' => $option['attributeId'], + 'name' => $option['value'], + 'nameKey' => $option['valueKey'], + 'displayType' => 'dropdown', + 'dateCreated' => $option['dateCreated'], + 'dateUpdated' => $option['dateUpdated'], + 'uid' => $option['uid'], + ]); + } + + if ($options !== []) { + $this->update(CraftTable::ELEMENTS, [ + 'type' => VariantAttribute::class, + ], [ + 'id' => array_column($options, 'id'), + ], updateTimestamp: false); + } + + $this->dropTableIfExists(self::OPTIONS_TABLE); + + $this->placeInStructure($structure->id); + + return true; + } + + private function placeInStructure(int $structureId): void + { + $structuresService = Craft::$app->getStructures(); + + $attributes = VariantAttribute::find() + ->attributeId(0) + ->trashed(null) + ->withStructure(false) + ->orderBy([ + 'variant_manager_attributes.name' => SORT_ASC, + ]) + ->all(); + + foreach ($attributes as $attribute) { + $structuresService->appendToRoot($structureId, $attribute); + + $attributeOptions = VariantAttribute::find() + ->attributeId($attribute->id) + ->trashed(null) + ->withStructure(false) + ->orderBy([ + 'variant_manager_attributes.name' => SORT_ASC, + ]) + ->all(); + + foreach ($attributeOptions as $option) { + $structuresService->append($structureId, $option, $attribute); + } + } + } +} diff --git a/src/records/VariantAttribute.php b/src/records/VariantAttribute.php index 2e59815..83131c0 100644 --- a/src/records/VariantAttribute.php +++ b/src/records/VariantAttribute.php @@ -7,6 +7,7 @@ /** * @property int $id + * @property int $attributeId * @property string $name * @property string $nameKey * @property string $displayType diff --git a/src/records/VariantAttributeOption.php b/src/records/VariantAttributeOption.php deleted file mode 100644 index f838508..0000000 --- a/src/records/VariantAttributeOption.php +++ /dev/null @@ -1,28 +0,0 @@ -hasOne(VariantAttribute::class, [ - 'id' => 'attributeId', - ]); - } -} diff --git a/src/services/AttributeConfigs.php b/src/services/AttributeConfigs.php index 565396e..4272fe1 100644 --- a/src/services/AttributeConfigs.php +++ b/src/services/AttributeConfigs.php @@ -7,7 +7,6 @@ use craft\helpers\StringHelper; use craft\models\FieldLayout; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use yii\base\Component; /** @@ -53,7 +52,7 @@ public function getFieldLayout(string $nameKey): FieldLayout public function getOptionFieldLayout(string $nameKey): FieldLayout { - return $this->layout($nameKey, 'optionFieldLayouts', VariantAttributeOption::class); + return $this->layout($nameKey, 'optionFieldLayouts', VariantAttribute::class); } /** @@ -69,7 +68,7 @@ public function getAllAttributeLayouts(): array */ public function getAllOptionLayouts(): array { - return $this->getAllLayouts('optionFieldLayouts', VariantAttributeOption::class); + return $this->getAllLayouts('optionFieldLayouts', VariantAttribute::class); } public function save(string $nameKey, FieldLayout $fieldLayout, FieldLayout $optionFieldLayout): bool @@ -119,7 +118,7 @@ public function removeOrphaned(): void $nameKeys = array_flip(array_map( static fn (VariantAttribute $attribute): string => $attribute->nameKey, - VariantAttribute::find()->trashed(null)->all() + VariantAttribute::find()->attributeId(0)->trashed(null)->all() )); foreach (array_keys($configs) as $nameKey) { diff --git a/src/services/Csv.php b/src/services/Csv.php index b21aa3c..53bfef5 100644 --- a/src/services/Csv.php +++ b/src/services/Csv.php @@ -158,10 +158,6 @@ public function import(string $filename, string $csvData, ?string $productTypeHa $this->importSiteSpecificData($tabularDataReader, $mapping['variant']['sku'], $mapping['sites']); $this->importInventoryLevels($tabularDataReader, $mapping['variant']['sku'], $mapping['inventory']); - // Register after the save, so a failed import creates no registry rows - $variantAttributes = Plugin::getInstance()->getVariantAttributes(); - $variantAttributes->ensureFromAttributePairs(array_values($variantAttributes->attributePairs($variants))); - return $product; } diff --git a/src/services/ProductVariants.php b/src/services/ProductVariants.php index 8041def..5158b86 100644 --- a/src/services/ProductVariants.php +++ b/src/services/ProductVariants.php @@ -5,7 +5,6 @@ use craft\base\Component; use craft\commerce\elements\Product; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\helpers\FieldHelper; use fostercommerce\variantmanager\Plugin; use yii\base\InvalidConfigException; @@ -39,7 +38,7 @@ public function getAttributeOptions(Product|int $product, array|string|null $onl * * @param Product|int $product The product to fetch variant attributes for. * @param array|string|null $only If set, limits the options returned to just the ones in the argument. - * @return array, attribute: ?VariantAttribute, options: array}> + * @return array, attribute: ?VariantAttribute, options: array}> * @throws InvalidConfigException */ public function getAttributeRegistry(Product|int $product, array|string|null $only = null): array diff --git a/src/services/VariantAttributes.php b/src/services/VariantAttributes.php index 6721b18..e869dc2 100644 --- a/src/services/VariantAttributes.php +++ b/src/services/VariantAttributes.php @@ -6,9 +6,10 @@ use craft\base\ElementInterface; use craft\commerce\elements\db\VariantQuery; use craft\commerce\elements\Variant; +use craft\db\Query; use craft\helpers\Db; +use fostercommerce\variantmanager\db\Table; use fostercommerce\variantmanager\elements\VariantAttribute; -use fostercommerce\variantmanager\elements\VariantAttributeOption; use fostercommerce\variantmanager\elements\VariantManagerVariant; use fostercommerce\variantmanager\helpers\FieldHelper; use fostercommerce\variantmanager\Plugin; @@ -27,8 +28,27 @@ class VariantAttributes extends Component */ private ?array $attributesById = null; + private ?int $structureId = null; + + /** + * @var array + */ + private array $ensuredPairKeys = []; + private string|false|null $fieldHandle = null; + public function getStructureId(): int + { + if ($this->structureId === null) { + $this->structureId = (int) (new Query()) + ->select(['id']) + ->from(Table::STRUCTURES) + ->scalar(); + } + + return $this->structureId; + } + /** * Attributes for the given names, indexed by name key. * @@ -53,7 +73,12 @@ public function getAttributesByNames(array $names, bool $includeTrashed = false) $attributes = []; - foreach (VariantAttribute::find()->nameKey(array_values($nameKeys))->trashed($includeTrashed ? null : false)->all() as $attribute) { + $attributeQuery = VariantAttribute::find() + ->attributeId(0) + ->nameKey(array_values($nameKeys)) + ->trashed($includeTrashed ? null : false); + + foreach ($attributeQuery->all() as $attribute) { $attributes[$attribute->nameKey] = $attribute; } @@ -70,7 +95,7 @@ public function getAllAttributes(): array if ($this->attributesById === null) { $this->attributesById = []; - foreach (VariantAttribute::find()->all() as $attribute) { + foreach (VariantAttribute::find()->attributeId(0)->all() as $attribute) { $this->attributesById[$attribute->id] = $attribute; } } @@ -113,9 +138,7 @@ public function attributePairs(array $variants): array continue; } - $nameKey = VariantAttribute::normalizeName($pair['attributeName']); - $valueKey = VariantAttributeOption::normalizeValue($pair['attributeValue']); - $pairs["{$nameKey}\0{$valueKey}"] = $pair; + $pairs[self::pairKey($pair['attributeName'], $pair['attributeValue'])] = $pair; } } @@ -125,13 +148,13 @@ public function attributePairs(array $variants): array /** * Get the registry rows whose name or value no longer appears on any variant. * - * @return array{attributes: list, options: list} + * @return array{attributes: list, options: list} */ public function findOrphans(int $batchSize = 500): array { // Read the registry first: a row created during the scan is not an orphan - $attributes = VariantAttribute::find()->all(); - $allOptions = VariantAttributeOption::find()->all(); + $attributes = VariantAttribute::find()->attributeId(0)->all(); + $allOptions = VariantAttribute::find()->attributeId('not 0')->all(); $storedPairs = $this->storedPairs($batchSize); @@ -156,7 +179,7 @@ public function findOrphans(int $batchSize = 500): array foreach ($allOptions as $option) { $attribute = $attributesById[$option->attributeId] ?? null; - $pairKey = ($attribute?->nameKey ?? '') . "\0" . $option->valueKey; + $pairKey = ($attribute?->nameKey ?? '') . "\0" . $option->nameKey; if (! isset($storedPairs[$pairKey])) { $orphanedOptions[] = $option; @@ -173,7 +196,7 @@ public function findOrphans(int $batchSize = 500): array * Registry rows for the given names and their values, indexed by name and then by raw value. * * @param array> $valuesByName - * @return array}> + * @return array}> */ public function getRegistry(array $valuesByName): array { @@ -188,20 +211,20 @@ public function getRegistry(array $valuesByName): array $optionsByAttributeId = []; - $valueKeys = []; + $nameKeys = []; foreach ($valuesByName as $values) { foreach ($values as $value) { - $valueKeys[VariantAttributeOption::normalizeValue($value)] = true; + $nameKeys[VariantAttribute::normalizeName($value)] = true; } } - $optionQuery = VariantAttributeOption::find() + $optionQuery = VariantAttribute::find() ->attributeId(array_values($attributeIds)) - ->valueKey(array_map(static fn (int|string $valueKey): string => Db::escapeParam((string) $valueKey), array_keys($valueKeys))); + ->nameKey(array_map(static fn (int|string $nameKey): string => Db::escapeParam((string) $nameKey), array_keys($nameKeys))); foreach ($optionQuery->all() as $option) { - $optionsByAttributeId[$option->attributeId][$option->valueKey] = $option; + $optionsByAttributeId[$option->attributeId][$option->nameKey] = $option; } $registry = []; @@ -216,9 +239,9 @@ public function getRegistry(array $valuesByName): array $options = []; foreach ($values as $value) { - $option = $optionsByAttributeId[$attribute->id][VariantAttributeOption::normalizeValue($value)] ?? null; + $option = $optionsByAttributeId[$attribute->id][VariantAttribute::normalizeName($value)] ?? null; - if ($option instanceof VariantAttributeOption) { + if ($option instanceof VariantAttribute) { $options[$value] = $option; } } @@ -237,9 +260,9 @@ public function getRegistry(array $valuesByName): array * * The match is a JSON search over all variant content, so page or count rather than call all() */ - public function variantQueryForOption(VariantAttributeOption $option): ?VariantQuery + public function variantQueryForOption(VariantAttribute $option): ?VariantQuery { - $attribute = $option->getVariantAttribute(); + $attribute = $option->getParentAttribute(); if ($attribute === null) { return null; @@ -254,7 +277,7 @@ public function variantQueryForOption(VariantAttributeOption $option): ?VariantQ return VariantManagerVariant::find() ->status(null) ->{$fieldHandle}([ - $attribute->name => $option->value, + $attribute->name => $option->name, ]); } @@ -263,7 +286,7 @@ public function variantQueryForOption(VariantAttributeOption $option): ?VariantQ * * Cached against the variant element tag, so a variant save or delete invalidates it. */ - public function variantCountForOption(VariantAttributeOption $option): int + public function variantCountForOption(VariantAttribute $option): int { return Craft::$app->getCache()->getOrSet( "variant-manager:option-usage:{$option->id}", @@ -278,14 +301,14 @@ public function variantCountForOption(VariantAttributeOption $option): int ); } - public function isOptionInUse(VariantAttributeOption $option): bool + public function isOptionInUse(VariantAttribute $option): bool { return $this->variantQueryForOption($option)?->exists() ?? false; } public function isAttributeInUse(VariantAttribute $attribute): bool { - foreach (VariantAttributeOption::find()->attributeId($attribute->id)->all() as $option) { + foreach (VariantAttribute::find()->attributeId($attribute->id)->all() as $option) { if ($this->isOptionInUse($option)) { return true; } @@ -373,68 +396,68 @@ public function ensureAttributes(array $names): array */ public function ensureOptions(VariantAttribute $attribute, array $values): void { - $valueKeys = []; + $nameKeys = []; foreach ($values as $value) { - $valueKey = VariantAttributeOption::normalizeValue($value); + $nameKey = VariantAttribute::normalizeName($value); - if ($valueKey !== '') { - $valueKeys[$valueKey] = Db::escapeParam($valueKey); + if ($nameKey !== '') { + $nameKeys[$nameKey] = Db::escapeParam($nameKey); } } - if ($valueKeys === []) { + if ($nameKeys === []) { return; } $options = []; // Filter to the given values so the query does not grow with the attribute's option count - // A trashed row keeps its unique value key, so the row is restored rather than replaced - $optionQuery = VariantAttributeOption::find() + // A trashed row keeps its unique name key, so the row is restored rather than replaced + $optionQuery = VariantAttribute::find() ->attributeId($attribute->id) - ->valueKey(array_values($valueKeys)) + ->nameKey(array_values($nameKeys)) ->trashed(null); foreach ($optionQuery->all() as $option) { - $options[$option->valueKey] = $option; + $options[$option->nameKey] = $option; } foreach ($values as $value) { - $valueKey = VariantAttributeOption::normalizeValue($value); + $nameKey = VariantAttribute::normalizeName($value); - if ($valueKey === '') { + if ($nameKey === '') { continue; } - if (isset($options[$valueKey])) { - $this->restoreIfTrashed($options[$valueKey]); + if (isset($options[$nameKey])) { + $this->restoreIfTrashed($options[$nameKey]); continue; } - $option = new VariantAttributeOption(); - $option->attributeId = $attribute->id; - $option->value = trim($value); + $option = new VariantAttribute(); + $option->setParentAttribute($attribute); + $option->name = trim($value); $option->title = trim($value); try { Craft::$app->getElements()->saveElement($option, false); } catch (IntegrityException) { - // Another process registered this value key first, so use its row - $option = VariantAttributeOption::find() + // Another process registered this name key first, so use its row + $option = VariantAttribute::find() ->attributeId($attribute->id) - ->valueKey(Db::escapeParam($valueKey)) + ->nameKey(Db::escapeParam($nameKey)) ->trashed(null) ->one(); - if (! $option instanceof VariantAttributeOption) { + if (! $option instanceof VariantAttribute) { continue; } $this->restoreIfTrashed($option); } - $options[$valueKey] = $option; + $options[$nameKey] = $option; } } @@ -448,16 +471,26 @@ public function ensureFromAttributePairs(array $pairs): void { $valuesByName = []; + // Skip pairs already registered by this process, so a large sync only queries for new pairs foreach ($pairs as $pair) { - $valuesByName[$pair['attributeName']][] = $pair['attributeValue']; + if (! isset($this->ensuredPairKeys[self::pairKey($pair['attributeName'], $pair['attributeValue'])])) { + $valuesByName[$pair['attributeName']][] = $pair['attributeValue']; + } } foreach ($valuesByName as $name => $values) { $name = (string) $name; $attribute = $this->ensureAttributes([$name])[VariantAttribute::normalizeName($name)] ?? null; - if ($attribute instanceof VariantAttribute) { - $this->ensureOptions($attribute, array_values(array_unique($values))); + if (! $attribute instanceof VariantAttribute) { + continue; + } + + $values = array_values(array_unique($values)); + $this->ensureOptions($attribute, $values); + + foreach ($values as $value) { + $this->ensuredPairKeys[self::pairKey($name, $value)] = true; } } } @@ -465,7 +498,7 @@ public function ensureFromAttributePairs(array $pairs): void /** * Deletes every orphaned option and attribute. * - * @param array{attributes: list, options: list}|null $orphans orphans already found, to skip a second scan + * @param array{attributes: list, options: list}|null $orphans orphans already found, to skip a second scan * @return array{attributes: int, options: int} * @throws Throwable */ @@ -474,7 +507,7 @@ public function pruneOrphans(int $batchSize = 500, ?array $orphans = null): arra $orphans ??= $this->findOrphans($batchSize); $elementsService = Craft::$app->getElements(); - // Options first: variant_manager_attribute_options.attributeId cascades on delete + // Options first, since deleting an attribute deletes the options under it // Hard delete, since a trashed row keeps its unique key and blocks re-registering the value foreach ($orphans['options'] as $option) { $elementsService->deleteElement($option, true); @@ -516,6 +549,11 @@ private function storedPairs(int $batchSize = 500): array return $pairs; } + private static function pairKey(string $name, string $value): string + { + return VariantAttribute::normalizeName($name) . "\0" . VariantAttribute::normalizeName($value); + } + private function restoreIfTrashed(ElementInterface $element): void { if ($element->dateDeleted !== null) { diff --git a/src/templates/attribute-options/index.twig b/src/templates/attribute-options/index.twig deleted file mode 100644 index 88c11b2..0000000 --- a/src/templates/attribute-options/index.twig +++ /dev/null @@ -1,9 +0,0 @@ -{% extends '_layouts/elementindex' %} -{% set title = 'options.options'|t('variant-manager') %} -{% set selectedSubnavItem = 'attribute-options' %} -{% set elementType = 'fostercommerce\\variantmanager\\elements\\VariantAttributeOption' %} -{% set canHaveDrafts = false %} - -{% set crumbs = [ - { label: 'Variant Manager', url: url('variant-manager/dashboard') }, -] %} diff --git a/src/translations/en/variant-manager.php b/src/translations/en/variant-manager.php index 683b1ba..7556e5f 100644 --- a/src/translations/en/variant-manager.php +++ b/src/translations/en/variant-manager.php @@ -13,6 +13,7 @@ * @since 1.0.0 */ return [ + 'plugin.name' => 'Variant Manager', 'Variant Manager plugin loaded' => 'Variant Manager plugin loaded', 'Variant Manager' => 'Variant Manager', 'Export Product' => 'Export Product', @@ -40,16 +41,17 @@ 'attributes.attributeLower' => 'variant attribute', 'attributes.attributes' => 'Variant Attributes', 'attributes.attributesLower' => 'variant attributes', - 'attributes.allAttributes' => 'All attributes', - 'attributes.name' => 'CSV Name', + 'attributes.allAttributes' => 'All attributes and options', + 'attributes.name' => 'System Name', 'attributes.noAttributes' => 'No attributes yet. They appear here once an import or the backfill utility has run.', 'attributes.notFound' => 'Attribute not found.', 'attributes.settingsIntro' => 'Each attribute is a group of options sharing one display and one set of fields, such as paint chips or shirt sizes. Choose how a storefront renders it, and add any fields its options need.', 'attributes.filterLabel' => 'Variant Attribute: {attribute}', 'attributes.activityCreated' => 'Created attribute {name}', 'attributes.deleteInUse' => 'Variants still use this attribute. Remove it from your CSV and reimport, then delete it.', - 'options.activityCreated' => 'Created option {value} under {attribute}', + 'options.activityCreated' => 'Created option {name} under {attribute}', 'options.deleteInUse' => 'Variants still use this value. Remove it from your CSV and reimport, then delete it.', + 'attributes.displayName' => 'Display Name', 'attributes.displayType' => 'Display Type', 'attributes.attributeLayout' => 'Attribute Fields', 'attributes.attributeLayoutInstructions' => 'Fields on this attribute, such as a note covering all of its options.', @@ -81,13 +83,6 @@ 'jobs.pruneAttributeOrphans' => 'Pruning orphaned variant attributes', // Variant attribute options - 'options.option' => 'Attribute Option', - 'options.optionLower' => 'attribute option', - 'options.options' => 'Attribute Options', - 'options.optionsLower' => 'attribute options', - 'options.allOptions' => 'All options', - 'options.value' => 'CSV Value', - 'options.attribute' => 'Attribute', 'options.usedBy' => 'Used by', 'options.variantCount' => '{count, plural, =0{No variants} =1{1 variant} other{# variants}}',