Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Release Notes for Variant Manager

## 2.1.0 - 2026-05-13

- Add optional `status` column for product imports and exports.
- Refactor the Export Product sidebar button.

## 2.0.5 - 2026-02-19

- Fix an error that would be thrown when gc runs during a web request
Expand Down
6 changes: 6 additions & 0 deletions docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ return [
'*' => [
'title' => 'title',
'slug' => 'slug',
'status' => 'status',
],
],
'variantFieldMap' => [
Expand Down Expand Up @@ -67,8 +68,13 @@ Supported field types and formatting:
<!-- TODO -->
- title
- slug
- status
- entries

#### `status` column

Optional. Exports write `enabled` or `disabled`. Imports accept `disabled` (case-insensitive, whitespace trimmed) as disabled; any other value or empty cell imports as enabled. Remove the `'status' => 'status'` entry from `productFieldMap` to skip.

### `variantFieldMap`

The map of column names to variant properties.
Expand Down
2 changes: 2 additions & 0 deletions docs/usage/exporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ column on the bottom there is an "Export Product" button.

Click it, and Variant Manager will generate a CSV file of the current product's variant data and it will download
automatically to your computer.

The exported CSV includes columns for every entry in `productFieldMap` and `variantFieldMap`. If `productFieldMap` includes a `status` entry, the export writes `enabled` or `disabled` based on the current product status. See [Configuration](../getting-started/configuration.md) for the full column list.
4 changes: 4 additions & 0 deletions docs/usage/importing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ If you have any plain text or number fields setup in your Commerce product type
your spreadsheet as well, these columns should also be present and use the name that is configured in the
[Variant Manager configuration file](../getting-started/configuration.md) (ex. "notes", "reference", etc)

### Product status column

If `productFieldMap` includes a `status` entry, the `status` column controls whether the imported product is enabled. Write `disabled` to disable a product on import. Any other value, including an empty cell, imports the product as enabled. See [Configuration](../getting-started/configuration.md) for details.

You will then need to export your spreadsheet as a CSV file from your spreadsheet program and save it.

### For new products
Expand Down
28 changes: 10 additions & 18 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -205,33 +205,25 @@ private function registerComponents(): void

private function registerViewHooks(): void
{
Craft::$app->view->hook('cp.commerce.product.edit.details', static fn (array &$context) => Craft::$app->getView()->renderTemplate(
'variant-manager/fields/product_export',
[
'id' => 'product-export',
'namespacedId' => 'product-export',
'name' => 'product-export',
'product' => $context['product'],
]
));

Event::on(
Product::class,
Element::EVENT_DEFINE_SIDEBAR_HTML,
static function (DefineHtmlEvent $event): void {
$entry = $event->sender ?? null;
/** @var Product|null $product */
$product = $event->sender ?? null;

$view = Craft::$app->getView();
$view->registerAssetBundle(ProductExportAssetBundle::class);
$view->registerTranslations('variant-manager', [
'Export request failed with status {status}',
]);

$html = Craft::$app->getView()->renderTemplate(
$event->html .= $view->renderTemplate(
'variant-manager/fields/product_export',
[
'id' => 'product-export',
'namespacedId' => 'product-export',
'name' => 'product-export',
'product' => $entry,
'product' => $product,
]
);

$event->html .= $html;
}
);
}
Expand Down
24 changes: 24 additions & 0 deletions src/ProductExportAssetBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace fostercommerce\variantmanager;

use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;

class ProductExportAssetBundle extends AssetBundle
{
public function init(): void
{
$this->sourcePath = '@fostercommerce/variantmanager/assets';

$this->depends = [
CpAsset::class,
];

$this->js = [
'js/product-export.js',
];

parent::init();
}
}
74 changes: 74 additions & 0 deletions src/assets/js/product-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
(function () {
'use strict';

function bindExportButton(button) {
if (button.dataset.variantManagerBound === 'true') {
return;
}
button.dataset.variantManagerBound = 'true';

button.addEventListener('click', function (event) {
event.preventDefault();

var productId = button.getAttribute('data-product-id');
if (! productId) {
return;
}

var exportUrl = button.getAttribute('data-export-url');
if (! exportUrl) {
return;
}

fetch(exportUrl + '?ids=' + encodeURIComponent(productId) + '&download=true', {
headers: {
'X-CSRF-Token': Craft.csrfTokenValue,
'X-Requested-With': 'XMLHttpRequest',
},
}).then(function (response) {
if (! response.ok) {
throw new Error(
Craft.t('variant-manager', 'Export request failed with status {status}', {
status: response.status,
})
);
}

return response.blob().then(function (blob) {
var disposition = response.headers.get('content-disposition') || '';
// Capture only the filename value so surrounding quotes are not included.
// Browsers replace literal quote characters in the download filename with underscores.
var match = disposition.match(/filename="?([^";]+)"?/i);
var filename = match ? match[1] : 'export.csv';

var link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(link.href);
});
}).catch(function (error) {
if (typeof Craft !== 'undefined' && Craft.cp && typeof Craft.cp.displayError === 'function') {
Craft.cp.displayError(error.message);
} else {
console.error(error);
}
});
});
}

function init() {
var buttons = document.querySelectorAll('.js-variant-manager-export');
for (var index = 0; index < buttons.length; index++) {
bindExportButton(buttons[index]);
}
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
2 changes: 2 additions & 0 deletions src/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
'productFieldMap' => [
'*' => [
'title' => 'title',
'slug' => 'slug',
'status' => 'status',
// Add default product fields here
],
],
Expand Down
15 changes: 12 additions & 3 deletions src/services/Csv.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,17 @@ public function import(string $filename, string $csvData, ?string $productTypeHa
*/
public function export(string $productId): array|bool
{
// status(null) bypasses the default enabled-only filter so disabled products and variants are still exported.
/** @var Product|null $product */
$product = Product::find()->id($productId)->one();
$product = Product::find()->id($productId)->status(null)->one();

if (! isset($product)) {
return false;
}

return [
'filename' => "{$product->id}__{$product->slug}",
'export' => $this->exportProduct($product, Variant::find()->product($product)->all()),
'export' => $this->exportProduct($product, Variant::find()->product($product)->status(null)->all()),
];
}

Expand Down Expand Up @@ -624,7 +625,7 @@ private function resolveVariantImportMapping(TabularDataReader $tabularDataReade
private function resolveProductModel(string $title, ?string $productId, ?string $productTypeHandle): Product
{
if ($productId !== null) {
$product = Product::find()->id($productId)->one();
$product = Product::find()->id($productId)->status(null)->one();
if ($product === null) {
throw new \RuntimeException('Invalid product id');
}
Expand Down Expand Up @@ -852,6 +853,12 @@ private function applyProductFields(Product $product, array $titleRecord): void
return;
}

if ($fieldHandle === 'status') {
$normalized = is_string($value) ? strtolower(trim($value)) : '';
$product->enabled = $normalized !== 'disabled';
return;
}

$this->setFieldValue($product, $fieldHandle, $value);
});
}
Expand Down Expand Up @@ -976,6 +983,8 @@ private function normalizeProductExport(Product $product, array $mapping): array
$row[] = $product->title;
} elseif ($fieldHandle === 'slug') {
$row[] = $product->slug;
} elseif ($fieldHandle === 'status') {
$row[] = $product->enabled ? 'enabled' : 'disabled';
} else {
$value = $product->getFieldValue($fieldHandle);
$value = $this->normalizeValue($value);
Expand Down
55 changes: 19 additions & 36 deletions src/templates/fields/product_export.twig
Original file line number Diff line number Diff line change
@@ -1,37 +1,20 @@
{% set canExport = currentUser.can("variant-manager:export") %}
<div class="meta">
<div class="field">
<button
type="button"
{% if not canExport %}disabled{% endif %}
class="btn download icon {% if not canExport %}disabled{% endif %}"
data-icon="download" id="{{ id }}-btn">
{{ 'Export Product'|t }}
</button>
{% set canExport = currentUser.can('variant-manager:export') %}
<fieldset>
<legend class="h6">{{ 'Variant Manager'|t('variant-manager') }}</legend>
<div class="meta">
<div class="field">
<div class="input ltr">
<button
type="button"
{% if not canExport %}disabled{% endif %}
class="btn download icon js-variant-manager-export {% if not canExport %}disabled{% endif %}"
data-icon="download"
data-product-id="{{ product.id }}"
data-export-url="{{ cpUrl('variant-manager/export') }}"
>
{{ 'Export Product'|t('variant-manager') }}
</button>
</div>
</div>
</div>
</div>

<script type="text/javascript">
{% if canExport %}
function exportFile(event) {
fetch('/admin/variant-manager/export?ids={{ product.id }}&download=true', {
headers: {
'X-CSRF-Token': '{{ craft.app.request.csrfToken }}',
'X-Requested-With': 'XMLHttpRequest',
},
}).then((response) => {
return response.blob().then((result) => {
let link = document.createElement("a");
// Note that we slice the download name because the split includes quotations which are converted to underscores
// when the download is initiated otherwise.
link.href = window.URL.createObjectURL(result);
link.download = response.headers.get('content-disposition').split('filename=')[1].split(';')[0].slice(1, -1);
link.click();
})
});
}

document.getElementById('{{ namespacedId }}-btn').addEventListener('click', exportFile);
{% endif %}
</script>

</fieldset>
3 changes: 3 additions & 0 deletions src/translations/en/variant-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@
*/
return [
'Variant Manager plugin loaded' => 'Variant Manager plugin loaded',
'Variant Manager' => 'Variant Manager',
'Export Product' => 'Export Product',
'Export request failed with status {status}' => 'Export request failed with status {status}',
];
Loading