Product import
This commit is contained in:
1 parent
7442d10e68
commit
7833e1b668
21 files changed
+763
-185
No files matched your search
@@ -15,6 +15,7 @@ use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\DataHandling\DataHandler;
|
||||
use TYPO3\CMS\Core\Http\JsonResponse;
|
||||
use TYPO3\CMS\Core\Http\RedirectResponse;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessage;
|
||||
use TYPO3\CMS\Core\Messaging\FlashMessageService;
|
||||
@@ -50,28 +51,50 @@ final class ProductTextImportController
|
||||
/**
|
||||
* Never offered as import targets. `slug` changes URLs - that is redirect
|
||||
* territory, not a text import; the workbook URL is checked against the
|
||||
* record's slug informationally instead.
|
||||
* record's slug informationally instead. The flags and the shortcut pid are
|
||||
* editorial switches that will never come out of a workbook (decision
|
||||
* 2026-08-28) - a text import writing "1" into hideonwebsite would silently
|
||||
* unpublish a product. Only this XLSX flow filters them; the CSV flow keeps
|
||||
* the full field list.
|
||||
*/
|
||||
private const EXCLUDED_TARGETS = ['slug'];
|
||||
private const EXCLUDED_TARGETS = [
|
||||
'slug',
|
||||
'hideonapp',
|
||||
'hideonwebsite',
|
||||
'hideonproducts',
|
||||
'hideondatasheets',
|
||||
'shortcutpid',
|
||||
'shortcut',
|
||||
'legacy',
|
||||
'supportproduct',
|
||||
'subproduct',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-seed for the very first mapping (no row in tx_vitec_import_mapping
|
||||
* yet). Editors change everything in the dropdowns; this only saves the
|
||||
* first manual pass. Keys are product columns, values source selectors as
|
||||
* ProductXlsxReader defines them.
|
||||
*
|
||||
* Verified against the finished MGW-Diamond-H page (2026-08-28): the hero
|
||||
* one-liner lives in `teaser` (subtitle is unused there), the ~45-70-word
|
||||
* introduction in `description`, the ~80-120-word overview in
|
||||
* `description2`, and the three Key Capability Groups land as ONE HTML
|
||||
* block in `capabilities` - hence the composed `g:` selector. Why-Choose
|
||||
* cards, resources, pre-footer and hero CTA are page content elements or
|
||||
* template copy on the finished page, not record fields, and therefore
|
||||
* have no defaults here. An earlier version of this seed mapped onto
|
||||
* `subtitle` and onto columns that exist only in the DB but not in the
|
||||
* TCA (`cta`, `key1-3`, `apptext1-3`) - both corrected.
|
||||
*/
|
||||
private const DEFAULT_MAPPING = [
|
||||
'subtitle' => 'c:Hero — H1#1:body',
|
||||
'teaser' => 'c:Product Introduction#1:body',
|
||||
'description' => 'c:Product Overview — H2#1:body',
|
||||
'cta' => 'c:Pre-footer CTA — H2#1:cta',
|
||||
'seotitle' => 'x:seotitle',
|
||||
'teaser' => 'c:Hero — H1#1:body',
|
||||
'description' => 'c:Product Introduction#1:body',
|
||||
'description2' => 'c:Product Overview — H2#1:body',
|
||||
'capabilities' => 'g:Key Capability Group',
|
||||
'textrelatedproducts' => 'p:Related Products — H2#1',
|
||||
'keywords' => 'm:primaryKeyword',
|
||||
'key1' => 'c:Why Choose Card#1:title',
|
||||
'apptext1' => 'c:Why Choose Card#1:body',
|
||||
'key2' => 'c:Why Choose Card#2:title',
|
||||
'apptext2' => 'c:Why Choose Card#2:body',
|
||||
'key3' => 'c:Why Choose Card#3:title',
|
||||
'apptext3' => 'c:Why Choose Card#3:body',
|
||||
];
|
||||
|
||||
private const SORTABLE = ['title', 'slug', 'tstamp'];
|
||||
@@ -252,6 +275,16 @@ final class ProductTextImportController
|
||||
|
||||
$map = array_map('strval', (array)($body['map'] ?? []));
|
||||
$apply = array_map('strval', (array)($body['apply'] ?? []));
|
||||
// A per-row "Save" button writes exactly its own row; the checkbox
|
||||
// state of every other row is deliberately ignored then. Only one
|
||||
// submit button ever posts its value, so the two can't both be set.
|
||||
$applySingle = trim((string)($body['applySingle'] ?? ''));
|
||||
$applyRelatedSingle = trim((string)($body['applyRelatedSingle'] ?? ''));
|
||||
if ($applySingle !== '') {
|
||||
$apply = [$applySingle];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$apply = [];
|
||||
}
|
||||
$targets = $this->targetFields();
|
||||
|
||||
$data = [];
|
||||
@@ -271,6 +304,11 @@ final class ProductTextImportController
|
||||
// cannot be related to itself.
|
||||
$relatedChoices = array_map('intval', (array)($body['related'] ?? []));
|
||||
$applyRelated = array_map('intval', (array)($body['applyRelated'] ?? []));
|
||||
if ($applySingle !== '') {
|
||||
$applyRelated = [];
|
||||
} elseif ($applyRelatedSingle !== '') {
|
||||
$applyRelated = [(int)$applyRelatedSingle];
|
||||
}
|
||||
$relatedCards = $this->relatedCards($parsed);
|
||||
$relatedTodo = [];
|
||||
foreach ($applyRelated as $index) {
|
||||
@@ -352,6 +390,60 @@ final class ProductTextImportController
|
||||
return $this->renderTexts($request, $this->loadProduct($uid), $parsed, $map);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- inline field editing
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_get): the FULL raw value of one product field
|
||||
* for the click-to-edit cell - the cell itself only shows a truncated
|
||||
* preview. RTE fields return their stored HTML; editing is deliberately
|
||||
* source-level (decision 2026-08-28: no inline WYSIWYG).
|
||||
*/
|
||||
public function fieldGet(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$params = $request->getQueryParams();
|
||||
$uid = (int)($params['uid'] ?? 0);
|
||||
$field = (string)($params['field'] ?? '');
|
||||
$product = $this->loadProduct($uid);
|
||||
$targets = $this->targetFields();
|
||||
if ($product === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => (string)($product[$field] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX (vitec_product_field_save): write one product field. Same target
|
||||
* whitelist and the same DataHandler path as the form apply - RTE
|
||||
* transformations and record history included. Responds with the freshly
|
||||
* re-read value so the cell preview shows what was actually stored.
|
||||
*/
|
||||
public function fieldSave(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$body = (array)$request->getParsedBody();
|
||||
$uid = (int)($body['uid'] ?? 0);
|
||||
$field = (string)($body['field'] ?? '');
|
||||
$value = (string)($body['value'] ?? '');
|
||||
$targets = $this->targetFields();
|
||||
if ($this->loadProduct($uid) === null || !isset($targets[$field])) {
|
||||
return new JsonResponse(['success' => false, 'message' => 'Unknown product or field.']);
|
||||
}
|
||||
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
|
||||
$dataHandler->start([self::TABLE => [(string)$uid => [$field => $value]]], []);
|
||||
$dataHandler->process_datamap();
|
||||
if ($dataHandler->errorLog !== []) {
|
||||
return new JsonResponse(['success' => false, 'message' => implode(' | ', $dataHandler->errorLog)]);
|
||||
}
|
||||
$fresh = (string)($this->loadProduct($uid)[$field] ?? '');
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'value' => $fresh,
|
||||
'preview' => $this->preview($fresh),
|
||||
]);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
/**
|
||||
@@ -439,6 +531,9 @@ final class ProductTextImportController
|
||||
|
||||
$view = $this->moduleTemplateFactory->create($request);
|
||||
$this->pageRenderer->addCssFile('EXT:vitec/Resources/Public/Css/backend-import.css');
|
||||
// Click-to-edit for the "Current value" column; a module because the
|
||||
// backend CSP blocks inline handlers.
|
||||
$this->pageRenderer->loadJavaScriptModule('@evomedien/vitec/product-inline-edit.js');
|
||||
$view->assignMultiple([
|
||||
'product' => $product,
|
||||
'parsed' => $parsed,
|
||||
|
||||
Reference in new issue
Block a user