This commit is contained in:
2025-03-31 20:17:05 +02:00
parent a03df0b268
commit d4d4c0c09d
1617 changed files with 1106381 additions and 268 deletions

View File

@@ -0,0 +1,43 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductSuppliersManager from '@pages/product/edit/product-suppliers-manager';
import ImageSelector from '@pages/product/combination/image-selector';
import ProductMap from '@pages/product/product-map';
const {$} = window;
$(() => {
window.prestashop.component.initComponents([
'TranslatableField',
'TinyMCEEditor',
'TranslatableInput',
'EventEmitter',
'TextWithLengthCounter',
]);
new ProductSuppliersManager(ProductMap.suppliers.combinationSuppliers, false);
new ImageSelector();
});

View File

@@ -0,0 +1,47 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
const {$} = window;
export default class ImageSelector {
constructor() {
this.$selectorContainer = $(ProductMap.combinations.images.selectorContainer);
this.init();
}
init() {
$(ProductMap.combinations.images.checkboxContainer, this.$selectorContainer).hide();
this.$selectorContainer.on('click', ProductMap.combinations.images.imageChoice, (event) => {
const $imageChoice = $(event.currentTarget);
const $checkbox = $(ProductMap.combinations.images.checkbox, $imageChoice);
const isChecked = $checkbox.prop('checked');
$imageChoice.toggleClass('selected', !isChecked);
$checkbox.prop('checked', !isChecked);
});
}
}

View File

@@ -0,0 +1,495 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Bloodhound from 'typeahead.js';
import _ from 'lodash';
import AutoCompleteSearch from '@components/auto-complete-search';
import Tokenizers from '@components/bloodhound/tokenizers';
import ProductMap from '@pages/product/product-map';
import ProductEventMap from '@pages/product/product-event-map';
import {getCategories} from '@pages/product/services/categories';
const {$} = window;
const ProductCategoryMap = ProductMap.categories;
export default class CategoriesManager {
/**
* @param {EventEmitter} eventEmitter
* @returns {{}}
*/
constructor(eventEmitter) {
this.eventEmitter = eventEmitter;
this.categoriesContainer = document.querySelector(
ProductCategoryMap.categoriesContainer,
);
this.categories = [];
this.typeaheadDatas = [];
this.categoryTree = this.categoriesContainer.querySelector(ProductCategoryMap.categoryTree);
this.prototypeTemplate = this.categoryTree.dataset.prototype;
this.prototypeName = this.categoryTree.dataset.prototypeName;
this.expandAllButton = this.categoriesContainer.querySelector(ProductCategoryMap.expandAllButton);
this.reduceAllButton = this.categoriesContainer.querySelector(ProductCategoryMap.reduceAllButton);
this.initCategories();
return {};
}
async initCategories() {
this.categories = await getCategories();
// This regexp is gonna be used to get id from checkbox name
let regexpString = ProductCategoryMap.checkboxName('__REGEXP__');
regexpString = _.escapeRegExp(regexpString).replace('__REGEXP__', '([0-9]+)');
this.checkboxIdRegexp = new RegExp(regexpString);
// This regexp is gonna be used to get id from radio name
regexpString = ProductCategoryMap.radioName('__REGEXP__');
regexpString = _.escapeRegExp(regexpString).replace('__REGEXP__', '([0-9]+)');
this.radioIdRegexp = new RegExp(regexpString);
this.initTypeaheadData(this.categories, '');
this.initTypeahead();
this.initTree();
this.updateCategoriesTags();
}
initTree() {
const initialElements = {};
this.categoryTree.querySelectorAll(ProductCategoryMap.treeElement).forEach((treeElement) => {
const checkboxInput = treeElement.querySelector(ProductCategoryMap.checkboxInput);
const categoryId = this.getIdFromCheckbox(checkboxInput);
initialElements[categoryId] = treeElement;
});
this.categories.forEach((category) => {
const item = this.generateCategoryTree(category, initialElements);
this.categoryTree.append(item);
});
this.expandAllButton.addEventListener('click', () => {
this.toggleAll(true);
});
this.reduceAllButton.addEventListener('click', () => {
this.toggleAll(false);
});
this.categoryTree.querySelectorAll(ProductCategoryMap.checkboxInput).forEach((checkbox) => {
checkbox.addEventListener('change', (event) => {
const checkboxInput = event.currentTarget;
const parentItem = checkboxInput.parentNode.closest(ProductCategoryMap.treeElement);
const radioInput = parentItem.querySelector(ProductCategoryMap.radioInput);
// If checkbox is associated to the default radio input it cannot be unchecked
if (radioInput.checked) {
event.preventDefault();
event.stopImmediatePropagation();
this.updateCheckbox(checkboxInput, true);
} else {
this.updateCategoriesTags();
}
});
});
this.categoryTree.querySelectorAll(ProductCategoryMap.radioInput).forEach((radioInput) => {
radioInput.addEventListener('click', () => {
this.selectedDefaultCategory(radioInput);
});
if (radioInput.checked) {
this.updateDefaultCheckbox(radioInput);
}
});
// Tree is initialized we can show it and hide loader
this.categoriesContainer
.querySelector(ProductCategoryMap.fieldset)
.classList.remove('d-none');
this.categoriesContainer
.querySelector(ProductCategoryMap.loader)
.classList.add('d-none');
}
/**
* Used to recursively create items of the category tree
*
* @param {Object} category
* @param {Object} initialElements
*/
generateCategoryTree(category, initialElements) {
const categoryNode = this.generateTreeElement(category, initialElements);
const childrenList = categoryNode.querySelector(ProductCategoryMap.childrenList);
childrenList.classList.add('d-none');
const hasChildren = category.children && category.children.length > 0;
categoryNode.classList.toggle('more', hasChildren);
if (hasChildren) {
const inputsContainer = categoryNode.querySelector(ProductCategoryMap.treeElementInputs);
inputsContainer.addEventListener('click', (event) => {
// We don't want to mess with the inputs behaviour (no toggle when checkbox or radio is clicked)
// So we only toggle when the div itself is clicked.
if (event.target !== event.currentTarget) {
return;
}
const isExpanded = !childrenList.classList.contains('d-none');
categoryNode.classList.toggle('less', !isExpanded);
categoryNode.classList.toggle('more', isExpanded);
childrenList.classList.toggle('d-none', isExpanded);
});
// Recursively build the children trees
category.children.forEach((childCategory) => {
const childTree = this.generateCategoryTree(childCategory, initialElements);
childrenList.append(childTree);
});
}
return categoryNode;
}
/**
* If the category is among the initial ones (inserted by the form on load) the existing element is used,
* if not then it is generated based on the prototype template. In both case the element is injected with the
* category name and click on radio is handled.
*
* @param {Object} category
* @param {Object} initialElements
*
* @returns {HTMLElement}
*/
generateTreeElement(category, initialElements) {
let categoryNode;
if (!Object.prototype.hasOwnProperty.call(initialElements, category.id)) {
const template = this.prototypeTemplate.replace(new RegExp(this.prototypeName, 'g'), category.id);
// Trim is important here or the first child could be some text (whitespace, or \n)
const frag = document.createRange().createContextualFragment(template.trim());
categoryNode = frag.firstChild;
} else {
categoryNode = initialElements[category.id];
}
// Add category name as a text between the checkbox and the radio
const checkboxInput = categoryNode.querySelector(ProductCategoryMap.checkboxInput);
checkboxInput.parentNode.insertBefore(
document.createTextNode(category.name),
checkboxInput,
);
return categoryNode;
}
/**
* @param {HTMLElement} radioInput
*/
selectedDefaultCategory(radioInput) {
// Uncheck all other radio inputs when one is selected
this.categoryTree.querySelectorAll(ProductCategoryMap.radioInput).forEach((radioTreeElement) => {
if (radioTreeElement !== radioInput) {
radioTreeElement.checked = false;
}
});
this.categoryTree.querySelectorAll(ProductCategoryMap.checkboxInput).forEach((checkboxTreeElement) => {
const materialCheckbox = checkboxTreeElement.parentNode.closest(ProductCategoryMap.materialCheckbox);
materialCheckbox.classList.remove('disabled');
});
this.updateDefaultCheckbox(radioInput);
}
/**
* @param {HTMLElement} radioInput
*/
updateDefaultCheckbox(radioInput) {
// If the element is selected as default it is also associated by definition
const parentItem = radioInput.parentNode.closest(ProductCategoryMap.treeElement);
const checkbox = parentItem.querySelector(ProductCategoryMap.checkboxInput);
// A default category is necessarily associated, so displayed as disabled (we do not use the disabled
// attribute because it removes the data from the form).
const materialCheckbox = checkbox.parentNode.closest(ProductCategoryMap.materialCheckbox);
materialCheckbox.classList.add('disabled');
this.updateCheckbox(checkbox, true);
this.updateCategoriesTags();
this.eventEmitter.emit(ProductEventMap.updateSubmitButtonState);
}
/**
* Expand/reduce the category tree
*
* @param {boolean} expanded Force expanding instead of toggle
*/
toggleAll(expanded) {
this.expandAllButton.style.display = expanded ? 'none' : 'block';
this.reduceAllButton.style.display = !expanded ? 'none' : 'block';
this.categoriesContainer
.querySelectorAll(ProductCategoryMap.childrenList)
.forEach((e) => {
e.classList.toggle('d-none', !expanded);
});
this.categoriesContainer
.querySelectorAll(ProductCategoryMap.everyItems)
.forEach((e) => {
e.classList.toggle('more', !expanded);
e.classList.toggle('less', expanded);
});
}
/**
* Check the selected category (matched by its ID) and toggle the tree by going up through the category's ancestors.
*
* @param {int} categoryId
*/
selectCategory(categoryId) {
const checkbox = this.categoriesContainer.querySelector(
`[name="${ProductCategoryMap.checkboxName(categoryId)}"]`,
);
if (!checkbox) {
return;
}
this.updateCheckbox(checkbox, true);
this.openCategoryParents(checkbox);
this.updateCategoriesTags();
}
/**
* @param {HTMLElement} checkbox
*/
openCategoryParents(checkbox) {
// This is the element containing the checkbox
let parentItem = checkbox.closest(ProductCategoryMap.treeElement);
if (parentItem !== null) {
// This is the first (potential) parent element
parentItem = parentItem.parentNode.closest(ProductCategoryMap.treeElement);
}
while (parentItem !== null && this.categoryTree.contains(parentItem)) {
const childrenList = parentItem.querySelector(ProductCategoryMap.childrenList);
if (childrenList.childNodes.length) {
parentItem.classList.add('less');
parentItem.classList.remove('more');
parentItem.querySelector(ProductCategoryMap.childrenList).classList.remove('d-none');
}
parentItem = parentItem.parentNode.closest(ProductCategoryMap.treeElement);
}
}
/**
* @param {int} categoryId
*/
unselectCategory(categoryId) {
const checkbox = this.categoriesContainer.querySelector(
`[name="${ProductCategoryMap.checkboxName(categoryId)}"]`,
);
if (!checkbox) {
return;
}
this.updateCheckbox(checkbox, false);
this.openCategoryParents(checkbox);
this.updateCategoriesTags();
}
/**
* Typeahead data require to have only one array level, we also build the breadcrumb as we go through the
* categories.
*/
initTypeaheadData(data, parentBreadcrumb) {
data.forEach((category) => {
category.breadcrumb = parentBreadcrumb ? `${parentBreadcrumb} > ${category.name}` : category.name;
this.typeaheadDatas.push(category);
if (category.children) {
this.initTypeaheadData(category.children, category.breadcrumb);
}
});
}
initTypeahead() {
const source = new Bloodhound({
datumTokenizer: Tokenizers.obj.letters(
'name',
'breadcrumb',
),
queryTokenizer: Bloodhound.tokenizers.nonword,
local: this.typeaheadDatas,
});
const dataSetConfig = {
source,
display: 'breadcrumb',
value: 'id',
onSelect: (selectedItem, e, $searchInput) => {
this.selectCategory(selectedItem.id);
// This resets the search input or else previous search is cached and can be added again
$searchInput.typeahead('val', '');
},
onClose: (event, $searchInput) => {
// This resets the search input or else previous search is cached and can be added again
$searchInput.typeahead('val', '');
return true;
},
};
dataSetConfig.templates = {
suggestion: (item) => `<div class="px-2">${item.breadcrumb}</div>`,
};
new AutoCompleteSearch($(ProductCategoryMap.searchInput), dataSetConfig);
}
updateCategoriesTags() {
const checkedCheckboxes = this.categoryTree.querySelectorAll(ProductCategoryMap.checkedCheckboxInputs);
const tagsContainer = this.categoriesContainer.querySelector(ProductCategoryMap.tagsContainer);
tagsContainer.innerHTML = '';
const defaultCategoryId = this.getDefaultCategoryId();
checkedCheckboxes.forEach((checkboxInput) => {
const categoryId = this.getIdFromCheckbox(checkboxInput);
const category = this.getCategoryById(categoryId);
if (!category) {
return;
}
const removeCrossTemplate = defaultCategoryId !== categoryId
? `<a class="pstaggerClosingCross" href="#" data-id="${category.id}">x</a>`
: '';
const template = `
<span class="pstaggerTag">
<span data-id="${category.id}" title="${category.breadcrumb}">${category.name}</span>
${removeCrossTemplate}
</span>
`;
// Trim is important here or the first child could be some text (whitespace, or \n)
const frag = document.createRange().createContextualFragment(template.trim());
tagsContainer.append(frag.firstChild);
});
tagsContainer.querySelectorAll('.pstaggerClosingCross').forEach((closeLink) => {
closeLink.addEventListener('click', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
const categoryId = Number(event.currentTarget.dataset.id);
if (categoryId !== defaultCategoryId) {
this.unselectCategory(categoryId);
}
});
});
tagsContainer.classList.toggle('d-block', checkedCheckboxes.length > 0);
}
/**
* @param {int} categoryId
*
* @returns {Object|null}
*/
getCategoryById(categoryId) {
return this.searchCategory(categoryId, this.categories);
}
/**
* @param {int} categoryId
* @param {array} categories
* @returns {Object|null}
*/
searchCategory(categoryId, categories) {
let searchedCategory = null;
categories.forEach((category) => {
if (categoryId === category.id) {
searchedCategory = category;
}
if (searchedCategory === null && category.children && category.children.length > 0) {
searchedCategory = this.searchCategory(categoryId, category.children);
}
});
return searchedCategory;
}
/**
* @returns {number|undefined}
*/
getDefaultCategoryId() {
const radioInput = this.categoryTree.querySelector(ProductCategoryMap.defaultRadioInput);
if (!radioInput) {
return undefined;
}
return this.getIdFromRadio(radioInput);
}
/**
* @param {HTMLElement} radioInput
*
* @returns {number}
*/
getIdFromRadio(radioInput) {
const matches = radioInput.name.match(this.radioIdRegexp);
return Number(matches[1]);
}
/**
* @param {HTMLElement} checkboxInput
*
* @returns {number}
*/
getIdFromCheckbox(checkboxInput) {
const matches = checkboxInput.name.match(this.checkboxIdRegexp);
return Number(matches[1]);
}
/**
* @param {HTMLElement} checkboxInput
* @param {boolean} checked
*/
updateCheckbox(checkboxInput, checked) {
if (checkboxInput.checked !== checked) {
checkboxInput.checked = checked;
this.eventEmitter.emit(ProductEventMap.updateSubmitButtonState);
}
}
}

View File

@@ -0,0 +1,463 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div id="combination-edit-modal">
<modal
class="combination-modal"
v-if="selectedCombinationId !== null"
@close="closeModal"
>
<template #body>
<div
class="combination-loading"
v-if="loadingCombinationForm"
>
<div class="spinner" />
</div>
<iframe
ref="iframe"
class="combination-iframe"
:src="editCombinationUrl"
@loadstart="frameLoading"
@load="onFrameLoaded"
vspace="0"
hspace="0"
scrolling="auto"
/>
</template>
<template #footer>
<button
type="button"
class="btn btn-secondary btn-close"
@click.prevent.stop="tryClose"
:aria-label="$t('modal.close')"
:disabled="submittingCombinationForm"
>
{{ $t('modal.cancel') }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="showPrevious"
:aria-label="$t('modal.previous')"
:disabled="
previousCombinationId === null || submittingCombinationForm
"
>
<i class="material-icons">keyboard_arrow_left</i>
{{ $t('modal.previous') }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="showNext"
:aria-label="$t('modal.next')"
:disabled="nextCombinationId === null || submittingCombinationForm"
>
{{ $t('modal.next') }}
<i class="material-icons">keyboard_arrow_right</i>
</button>
<button
type="button"
class="btn btn-primary"
@click.prevent.stop="submitForm"
:aria-label="$t('modal.save')"
:disabled="submittingCombinationForm || !isFormUpdated"
>
<span v-if="!submittingCombinationForm">
{{ $t('modal.save') }}
</span>
<span
class="spinner-border spinner-border-sm"
v-if="submittingCombinationForm"
role="status"
aria-hidden="true"
/>
</button>
</template>
<template #outside>
<history
:combinations-list="combinationsHistory"
@selectCombination="selectCombination"
:selected-combination="selectedCombinationId"
:empty-image-url="emptyImageUrl"
/>
</template>
</modal>
<div
class="modal-prevent-close"
@click.prevent.stop="preventClose"
>
<modal
:modal-title="$t('modal.history.confirmTitle')"
:cancel-label="$t('modal.cancel')"
:confirm-label="$t('modal.confirm')"
:close-label="$t('modal.close')"
:confirmation="true"
v-if="showConfirm"
@close="hideConfirmModal"
@confirm="confirmSelection"
>
<template #body>
<p
v-html="
$t('modal.history.confirmBody', {
'%combinationName%': selectedCombinationName,
})
"
/>
</template>
</modal>
</div>
</div>
</template>
<script>
import CombinationsService from '@pages/product/services/combinations-service';
import ProductMap from '@pages/product/product-map';
import ProductEventMap from '@pages/product/product-event-map';
import Modal from '@vue/components/Modal';
import Router from '@components/router';
import History from './History';
const {$} = window;
const CombinationEvents = ProductEventMap.combinations;
const router = new Router();
export default {
name: 'CombinationModal',
components: {Modal, History},
data() {
return {
combinationsService: null,
combinationIds: [],
selectedCombinationId: null,
selectedCombinationName: null,
previousCombinationId: null,
nextCombinationId: null,
editCombinationUrl: '',
loadingCombinationForm: false,
submittingCombinationForm: false,
combinationList: null,
hasSubmittedCombinations: false,
combinationsHistory: [],
showConfirm: false,
temporarySelection: null,
isFormUpdated: false,
isClosing: false,
};
},
props: {
productId: {
type: Number,
required: true,
},
eventEmitter: {
type: Object,
required: true,
},
emptyImageUrl: {
type: String,
required: true,
},
},
mounted() {
this.combinationList = $(ProductMap.combinations.combinationsContainer);
this.combinationsService = new CombinationsService(this.productId);
this.initCombinationIds();
this.watchEditButtons();
this.eventEmitter.on(CombinationEvents.refreshCombinationList, () => this.initCombinationIds(),
);
},
methods: {
watchEditButtons() {
this.combinationList.on(
'click',
ProductMap.combinations.editCombinationButtons,
(event) => {
event.stopImmediatePropagation();
const $row = $(event.target).closest('tr');
this.selectedCombinationId = Number(
$row.find(ProductMap.combinations.combinationIdInputsSelector).val(),
);
this.hasSubmittedCombinations = false;
},
);
},
async initCombinationIds() {
this.combinationIds = await this.combinationsService.getCombinationIds();
},
frameLoading() {
this.applyIframeStyling();
},
onFrameLoaded() {
this.loadingCombinationForm = false;
this.submittingCombinationForm = false;
const iframeBody = this.$refs.iframe.contentDocument.body;
this.applyIframeStyling();
this.selectedCombinationName = iframeBody.querySelector(
ProductMap.combinations.combinationName,
).innerHTML;
const iframeInputs = iframeBody.querySelectorAll(
ProductMap.combinations.editionFormInputs,
);
iframeInputs.forEach((input) => {
input.addEventListener('keyup', () => {
this.isFormUpdated = true;
});
input.addEventListener('change', () => {
this.isFormUpdated = true;
});
this.$refs.iframe.contentDocument.addEventListener('datepickerChange', () => {
this.isFormUpdated = true;
});
});
},
applyIframeStyling() {
this.$refs.iframe.contentDocument.body.style.overflowX = 'hidden';
},
tryClose() {
if (this.isFormUpdated) {
this.isClosing = true;
this.showConfirmModal();
} else {
this.closeModal();
}
},
closeModal() {
if (this.submittingCombinationForm) {
return;
}
// If modifications have been made refresh the combination list
if (this.hasSubmittedCombinations) {
this.eventEmitter.emit(CombinationEvents.refreshPage);
}
this.hasSubmittedCombinations = false;
// This closes the modal which is conditioned to the presence of this value
this.selectedCombinationId = null;
// Reset history on close
this.combinationsHistory = [];
},
navigateToCombination(combinationId) {
if (combinationId !== null) {
if (this.isFormUpdated) {
this.temporarySelection = combinationId;
this.showConfirmModal();
} else {
this.selectedCombinationId = combinationId;
}
}
},
showPrevious() {
this.navigateToCombination(this.previousCombinationId);
},
showNext() {
this.navigateToCombination(this.nextCombinationId);
},
selectCombination(combination) {
this.navigateToCombination(combination.id);
},
confirmSelection() {
if (this.isClosing) {
this.closeModal();
this.isClosing = false;
this.hideConfirmModal();
} else {
this.selectedCombinationId = this.temporarySelection;
this.hideConfirmModal();
}
},
submitForm() {
this.submittingCombinationForm = true;
const iframeBody = this.$refs.iframe.contentDocument.body;
const editionForm = iframeBody.querySelector(
ProductMap.combinations.editionForm,
);
editionForm.submit();
this.hasSubmittedCombinations = true;
const selectedCombination = {
id: this.selectedCombinationId,
title: iframeBody.querySelector(ProductMap.combinations.combinationName)
.innerHTML,
};
if (
(this.combinationsHistory[0]
&& this.combinationsHistory[0].id !== selectedCombination.id)
|| !this.combinationsHistory.length
) {
this.combinationsHistory = this.combinationsHistory.filter(
(combination) => combination.id !== selectedCombination.id,
);
this.combinationsHistory.unshift(selectedCombination);
}
this.isFormUpdated = false;
},
showConfirmModal() {
this.showConfirm = true;
},
hideConfirmModal() {
this.isClosing = false;
this.showConfirm = false;
},
preventClose(event) {
event.stopPropagation();
event.preventDefault();
},
},
watch: {
selectedCombinationId(combinationId) {
this.isFormUpdated = false;
if (combinationId === null) {
this.previousCombinationId = null;
this.nextCombinationId = null;
this.editCombinationUrl = null;
return;
}
this.loadingCombinationForm = true;
this.editCombinationUrl = router.generate(
'admin_products_combinations_edit_combination',
{
combinationId,
liteDisplaying: 1,
},
);
const selectedIndex = this.combinationIds.indexOf(combinationId);
if (selectedIndex === -1) {
this.previousCombinationId = null;
this.nextCombinationId = null;
} else {
this.previousCombinationId = selectedIndex === 0 ? null : this.combinationIds[selectedIndex - 1];
this.nextCombinationId = selectedIndex === this.combinationIds.length - 1
? null
: this.combinationIds[selectedIndex + 1];
}
},
},
};
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
#combination-edit-modal .combination-modal {
.modal {
display: flex;
align-items: flex-start;
justify-content: center;
}
.modal-dialog {
max-width: 990px;
width: 90%;
height: 95%;
margin: 0;
.modal-header {
display: none;
}
.modal-content {
height: 100%;
padding: 0;
margin: 0 1rem;
overflow: hidden;
.modal-body {
padding: 0.5rem;
margin: 0;
background: #eaebec;
.combination-loading {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
background: rgba(255, 255, 255, 0.8);
}
.combination-iframe {
padding: 0;
margin: 0;
border: 0;
outline: none;
vertical-align: top;
width: 100%;
height: 100%;
display: block;
.card {
margin-bottom: 0;
}
}
}
.modal-footer {
margin: 0;
padding: 0.6rem 1rem;
display: flex;
flex-direction: row;
justify-content: flex-end;
.btn-close {
margin-right: auto;
}
}
}
}
.history {
max-width: 400px;
width: 100%;
min-height: calc(100% - 3.5rem);
top: 50%;
transform: translateY(-50%);
height: 95%;
margin-right: 1rem;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div
class="card history"
@click="preventClose"
>
<div class="card-header">
{{
$t("modal.history.editedCombination", {
"%editedNb%": combinationsList.length,
})
}}
</div>
<div class="card-block">
<ul
class="history-list"
v-if="areCombinationsNotEmpty"
>
<li
:class="['history-item', isSelected(combination.id)]"
v-for="(combination, key) of paginatedDatas[currentPage - 1]"
:key="key"
@click="selectCombination(combination)"
>
{{ combination.title }}
<i class="material-icons">edit</i>
</li>
</ul>
<div
class="history-empty"
v-else
>
<img :src="emptyImageUrl">
<p class="history-empty-tip">
{{ $t("modal.history.empty") }}
</p>
</div>
</div>
<div
class="card-footer"
v-if="areCombinationsNotEmpty"
>
<pagination
:pagination-length="14"
:datas="combinationsList"
@paginated="constructDatas"
/>
</div>
</div>
</template>
<script>
import ProductEventMap from '@pages/product/product-event-map';
import Pagination from '@vue/components/Pagination';
const CombinationsEventMap = ProductEventMap.combinations;
export default {
name: 'CombinationHistory',
data() {
return {
paginatedDatas: [],
currentPage: 1,
};
},
components: {
Pagination,
},
props: {
combinationsList: {
type: Array,
default: () => [],
},
selectedCombination: {
type: Number,
required: true,
},
emptyImageUrl: {
type: String,
required: true,
},
},
computed: {
areCombinationsNotEmpty() {
return this.combinationsList.length > 0;
},
},
mounted() {
this.$parent.$on(CombinationsEventMap.selectCombination, (id) => {
this.selectedCombination = {id};
});
},
methods: {
/**
* Used to select combination in CombinationModal parent component
*
* @param {object} combination
*/
selectCombination(combination) {
this.$emit(CombinationsEventMap.selectCombination, combination);
},
/**
* This events comes from the pagination component as
*/
preventClose(event) {
event.stopPropagation();
event.preventDefault();
},
/**
* This events comes from the pagination component as
* he's the one managing cutting datas into chunks
*
* @param {array} datas
*/
constructDatas(datas) {
this.paginatedDatas = datas.paginatedDatas;
this.currentPage = datas.currentPage;
},
/**
* Used to avoid having too much logic in the markup
*/
isSelected(idCombination) {
return this.selectedCombination === idCombination
|| this.combinationsList.length === 1
? 'selected'
: null;
},
},
};
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
.history {
&-list {
padding: 0;
margin: 0;
}
.card-block {
padding: 0;
height: calc(100% - 7rem);
}
&-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 4rem);
&-tip {
color: #8a8a8a;
font-size: 1rem;
text-align: center;
max-width: 280px;
margin-top: 1.75rem;
}
}
&-item {
list-style-type: none;
padding: 0.75rem 1rem;
transition: 0.25s ease-out;
cursor: pointer;
position: relative;
i {
color: $primary;
opacity: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
right: 1rem;
font-size: 1.25rem;
transition: 0.25s ease-out;
}
&.selected {
background: #f7f7f7;
}
&:hover {
background: #f0fcfd;
color: $primary;
i {
opacity: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import ReplaceFormatter from '@vue/plugins/vue-i18n/replace-formatter';
import CombinationModal from '@pages/product/components/combination-modal/CombinationModal.vue';
Vue.use(VueI18n);
/**
* @param {string} combinationModalSelector
* @param {int} productId
* @param {Object} eventEmitter
*
* @returns {Vue|CombinedVueInstance<Vue, {eventEmitter, productId}, object, object, Record<never, any>>|null}
*/
export default function initCombinationModal(
combinationModalSelector,
productId,
eventEmitter,
) {
const container = document.querySelector(combinationModalSelector);
const {emptyImage} = container.dataset;
if (!container) {
return null;
}
const translations = JSON.parse(container.dataset.translations);
const i18n = new VueI18n({
locale: 'en',
formatter: new ReplaceFormatter(),
messages: {en: translations},
});
return new Vue({
el: combinationModalSelector,
template:
'<combination-modal :productId=productId :emptyImageUrl="emptyImage" :eventEmitter=eventEmitter />',
components: {CombinationModal},
i18n,
data: {
productId,
eventEmitter,
emptyImage,
},
});
}

View File

@@ -0,0 +1,717 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div id="product-images-container">
<div
id="product-images-dropzone"
:class="['dropzone', 'dropzone-container', { full: files.length <= 0 }]"
>
<div
:class="[
'dz-preview',
'openfilemanager',
{ 'd-none': loading || files.length <= 0 },
]"
>
<div>
<span><i class="material-icons">add_a_photo</i></span>
</div>
</div>
<div
:class="[
'dz-default',
'dz-message',
'openfilemanager',
'dz-clickable',
{ 'd-none': loading || files.length > 0 },
]"
>
<i class="material-icons">add_a_photo</i><br>
{{ $t('window.dropImages') }}<br>
<a>{{ $t('window.selectFiles') }}</a><br>
<small>
{{ $t('window.recommendedSize') }}<br>
{{ $t('window.recommendedFormats') }}
</small>
</div>
<div
class="dropzone-loading"
v-if="loading"
>
<div class="spinner" />
</div>
</div>
<dropzone-window
class="dropzone-window"
v-if="selectedFiles.length > 0"
:selected-files="selectedFiles"
:dropzone="dropzone"
@unselectAll="unselectAll"
@removeSelection="showModal"
@selectAll="selectAll"
@saveSelectedFile="saveSelectedFile"
@replacedFile="manageReplacedFile"
@openGallery="toggleGallery"
:files="files"
:locales="locales"
:selected-locale="selectedLocale"
:loading="buttonLoading"
/>
<modal
v-if="isModalShown"
:confirmation="true"
:modal-title="
$tc('modal.title', this.selectedFiles.length, {
'%filesNb%': this.selectedFiles.length,
})
"
:confirm-label="$t('modal.accept')"
:cancel-label="$t('modal.close')"
@confirm="removeSelection"
@close="hideModal"
/>
<div class="dz-template d-none">
<div class="dz-preview dz-file-preview">
<div class="dz-image">
<img data-dz-thumbnail>
</div>
<div class="dz-progress">
<span
class="dz-upload"
data-dz-uploadprogress
/>
</div>
<div class="dz-success-mark">
<span></span>
</div>
<div class="dz-error-mark">
<span></span>
</div>
<div class="dz-error-message">
<span data-dz-errormessage />
</div>
<div class="dz-hover">
<i class="material-icons drag-indicator">drag_indicator</i>
<div class="md-checkbox">
<label>
<input type="checkbox">
<i class="md-checkbox-control" />
</label>
</div>
</div>
<div class="iscover">
{{ $t('window.cover') }}
</div>
</div>
</div>
<dropzone-photo-swipe
:files="selectedFiles"
@closeGallery="toggleGallery"
v-if="selectedFiles.length > 0 && galleryOpened"
/>
</div>
</template>
<script>
import Router from '@components/router';
import {
getProductImages,
saveImageInformations,
saveImagePosition,
replaceImage,
removeProductImage,
} from '@pages/product/services/images';
import ProductMap from '@pages/product/product-map';
import ProductEventMap from '@pages/product/product-event-map';
import Modal from '@vue/components/Modal';
import DropzoneWindow from './DropzoneWindow';
import DropzonePhotoSwipe from './DropzonePhotoSwipe';
const {$} = window;
const router = new Router();
const DropzoneMap = ProductMap.dropzone;
const DropzoneEvents = ProductEventMap.dropzone;
export default {
name: 'Dropzone',
data() {
return {
dropzone: null,
configuration: {
url: router.generate('admin_products_v2_add_image'),
clickable: DropzoneMap.configuration.fileManager,
previewTemplate: null,
thumbnailWidth: 130,
thumbnailHeight: 130,
thumbnailMethod: 'crop',
},
files: [],
selectedFiles: [],
translations: [],
loading: true,
selectedLocale: null,
buttonLoading: false,
isModalShown: false,
galleryOpened: false,
};
},
props: {
productId: {
type: Number,
required: true,
},
locales: {
type: Array,
required: true,
},
formName: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
},
components: {
DropzoneWindow,
Modal,
DropzonePhotoSwipe,
},
computed: {},
mounted() {
this.watchLocaleChanges();
this.initProductImages();
},
methods: {
/**
* Watch locale changes to update the selected one
*/
watchLocaleChanges() {
this.selectedLocale = this.locales[0];
window.prestashop.instance.eventEmitter.on(
DropzoneEvents.languageSelected,
(event) => {
const {selectedLocale} = event;
this.locales.forEach((locale) => {
if (locale.iso_code === selectedLocale) {
this.selectedLocale = locale;
}
});
},
);
},
/**
* This methods is used to initialize product images we already have uploaded
*/
async initProductImages() {
try {
const images = await getProductImages(this.productId);
this.loading = false;
this.initDropZone();
images.forEach((image) => {
this.dropzone.displayExistingFile(image, image.image_url);
});
} catch (error) {
window.$.growl.error({message: error});
}
},
/**
* Method to initialize the dropzone, using the configuration's state and adding files
* we already have in database.
*/
initDropZone() {
this.configuration.previewTemplate = document.querySelector(
DropzoneMap.dzTemplate,
).innerHTML;
this.configuration.paramName = `${this.formName}[file]`;
this.configuration.method = 'POST';
this.configuration.params = {};
this.configuration.params[
`${this.formName}[product_id]`
] = this.productId;
this.configuration.params[`${this.formName}[_token]`] = this.token;
this.sortableContainer = $('#product-images-dropzone');
this.dropzone = new window.Dropzone(
DropzoneMap.dropzoneContainer,
this.configuration,
);
this.sortableContainer.sortable({
items: DropzoneMap.sortableItems,
opacity: 0.9,
containment: 'parent',
distance: 32,
tolerance: 'pointer',
cursorAt: {
left: 64,
top: 64,
},
cancel: '.disabled',
stop: (event, ui) => {
// Get new position (-1 because the open file manager is always first)
const movedPosition = ui.item.index() - 1;
this.updateImagePosition(ui.item.data('id'), movedPosition);
},
start: (event, ui) => {
this.sortableContainer.find(DropzoneMap.dzPreview).css('zIndex', 1);
ui.item.css('zIndex', 10);
},
});
this.dropzone.on(DropzoneEvents.addedFile, (file) => {
file.previewElement.dataset.id = file.image_id;
if (file.is_cover) {
file.previewElement.classList.add('is-cover');
}
file.previewElement.addEventListener('click', () => {
const input = file.previewElement.querySelector(DropzoneMap.checkbox);
input.checked = !input.checked;
if (input.checked) {
if (!this.selectedFiles.includes(file)) {
this.selectedFiles.push(file);
file.previewElement.classList.toggle('selected');
}
} else {
this.selectedFiles = this.selectedFiles.filter((e) => e !== file);
file.previewElement.classList.toggle('selected');
}
});
this.files.push(file);
});
this.dropzone.on(DropzoneEvents.error, (fileWithError, message) => {
$.growl.error({message: message.error});
this.dropzone.removeFile(fileWithError);
});
this.dropzone.on(DropzoneEvents.success, (file, response) => {
// Append the data required for a product image
file.image_id = response.image_id;
file.is_cover = response.is_cover;
file.legends = response.legends;
// Update dataset so that it can be selected later
file.previewElement.dataset.id = file.image_id;
if (file.is_cover) {
file.previewElement.classList.add('is-cover');
}
});
},
/**
* Method to select every files by checking checkboxes and add files to the files state
*/
selectAll() {
this.selectedFiles = this.files;
this.editCheckboxes(true);
},
/**
* Method to unselect every files by unchecking checkboxes and empty files state
*/
unselectAll() {
this.editCheckboxes(false);
this.selectedFiles = [];
this.removeTooltips();
},
/**
* Method to remove every selected files from the dropzone
*/
async removeSelection() {
let errorMessage = false;
let isCoverImageRemoved = false;
const nbFiles = this.selectedFiles.length;
await Promise.all(
this.selectedFiles.map(async (file) => {
try {
await removeProductImage(file.image_id);
this.dropzone.removeFile(file);
this.files = this.files.filter((e) => file !== e);
this.selectedFiles = this.selectedFiles.filter((e) => file !== e);
if (file.is_cover) {
isCoverImageRemoved = true;
}
} catch (error) {
errorMessage = error.responseJSON
? error.responseJSON.error
: error;
}
}),
);
this.removeTooltips();
if (errorMessage) {
$.growl.error({message: errorMessage});
} else {
$.growl({
message: this.$t('delete.success', {
'%filesNb%': nbFiles,
}),
});
}
if (isCoverImageRemoved) {
this.resetDropzone();
}
this.hideModal();
},
/**
* Method to manage checkboxes of files mainly used on selectAll and unselectAll
*/
editCheckboxes(checked) {
this.selectedFiles.forEach((file) => {
const input = file.previewElement.querySelector(DropzoneMap.checkbox);
input.checked = typeof checked !== 'undefined' ? checked : !input.checked;
file.previewElement.classList.toggle('selected', checked);
});
},
/**
* We sometime need to remove tooltip because Vue kick the markup of the component
*/
removeTooltips() {
$(DropzoneMap.shownTooltips).each((i, element) => {
$(element).remove();
});
},
/**
* Save selected file
*/
async saveSelectedFile(captionValue, isCover) {
if (!this.selectedFiles.length) {
return;
}
this.buttonLoading = true;
const selectedFile = this.selectedFiles[0];
selectedFile.is_cover = isCover;
selectedFile.legends = captionValue;
try {
const savedImage = await saveImageInformations(
selectedFile,
this.token,
this.formName,
);
const savedImageElement = document.querySelector(
DropzoneMap.savedImageContainer(savedImage.image_id),
);
/**
* If the image was saved as cover, we need to replace the DOM in order to display
* the correct one.
*/
if (savedImage.is_cover) {
if (!savedImageElement.classList.contains('is-cover')) {
const coverElement = document.querySelector(
DropzoneMap.coveredPreview,
);
if (coverElement) {
coverElement.classList.remove('is-cover');
}
savedImageElement.classList.add('is-cover');
this.files = this.files.map((file) => {
if (file.image_id !== savedImage.image_id && file.is_cover) {
file.is_cover = false;
}
return file;
});
}
}
$.growl({message: this.$t('window.settingsUpdated')});
this.buttonLoading = false;
} catch (error) {
$.growl.error({message: error.error});
this.buttonLoading = false;
}
},
/**
* Used to save and manage some datas from a replaced file
*/
async manageReplacedFile(event) {
const selectedFile = this.selectedFiles[0];
this.buttonLoading = true;
try {
const newImage = await replaceImage(
selectedFile,
event.target.files[0],
this.formName,
this.token,
);
const imageElement = document.querySelector(
DropzoneMap.savedImage(newImage.image_id),
);
imageElement.src = newImage.image_url;
$.growl({message: this.$t('window.imageReplaced')});
this.buttonLoading = false;
} catch (error) {
$.growl.error({message: error.responseJSON.error});
this.buttonLoading = false;
}
},
async updateImagePosition(productImageId, newPosition) {
try {
await saveImagePosition(
productImageId,
newPosition,
this.formName,
this.token,
);
} catch (error) {
this.sortableContainer.sortable('cancel');
$.growl.error({message: error.responseJSON.error});
}
},
/**
* Mainly used when we wants to reset the whole list
* to reset cover image for example on remove
*/
resetDropzone() {
this.loading = true;
this.files.forEach((file) => {
this.dropzone.removeFile(file);
});
this.dropzone.destroy();
this.dropzone = null;
this.initProductImages();
},
showModal() {
this.isModalShown = true;
},
hideModal() {
this.isModalShown = false;
},
/**
* Method used to open the photoswipe gallery
*/
toggleGallery() {
this.galleryOpened = !this.galleryOpened;
},
},
};
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
@import '~@scss/config/_bootstrap.scss';
.product-page #product-images-dropzone {
&.full {
cursor: pointer;
width: 100%;
}
.dropzone-loading {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
min-height: 10rem;
}
&.dropzone-container {
.dz-preview {
position: relative;
cursor: pointer;
.iscover {
display: none;
left: -2px;
bottom: -3px;
width: calc(100% + 4px);
padding: 9px;
}
&.is-cover {
.iscover {
display: block;
}
}
&:not(.openfilemanager) {
border: 3px solid transparent;
&:hover {
border: 3px solid $primary;
}
.dz-image {
border: 1px solid $gray-300;
width: 130px;
height: 130px;
margin: -3px;
}
}
&.openfilemanager {
border-style: dashed;
min-width: 130px;
&:hover {
border-style: solid;
}
> div {
border: none;
i {
font-size: 2.5rem;
}
}
}
img {
margin: 0;
}
&:hover {
.dz-hover {
background-color: rgba(0, 0, 0, 0.7);
.drag-indicator,
.md-checkbox {
opacity: 1;
}
}
}
&.selected {
.md-checkbox {
opacity: 1;
}
}
}
.dz-hover {
position: absolute;
top: -3px;
left: -3px;
width: calc(100% + 6px);
height: calc(100% + 6px);
background-color: rgba(0, 0, 0, 0);
transition: 0.25s ease-out;
pointer-events: none;
z-index: 11;
.drag-indicator {
position: absolute;
top: 0.5rem;
left: 0.5rem;
color: #ffffff;
opacity: 0;
transition: 0.25s ease-out;
}
.md-checkbox {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
opacity: 0;
transition: 0.25s ease-out;
.md-checkbox-control::before {
background: transparent;
}
input:checked + .md-checkbox-control::before {
background: $primary;
}
}
}
}
}
.product-page #product-images-container {
@include media-breakpoint-down(xs) {
flex-wrap: wrap;
}
#product-images-dropzone.dropzone {
display: flex;
align-items: flex-start;
justify-content: space-between;
border-radius: 4px;
flex-wrap: wrap;
@include media-breakpoint-down(xs) {
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
.dz-preview {
width: 100px;
height: 100px;
min-height: 100px;
margin: 0.5rem;
&.openfilemanager {
min-width: 100px;
}
img {
max-width: 100%;
max-height: 100%;
}
.dz-image {
width: 100px;
height: 100px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,194 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div
class="pswp"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="pswp__bg" />
<div class="pswp__scroll-wrap">
<div class="pswp__container">
<div class="pswp__item" />
<div class="pswp__item" />
<div class="pswp__item" />
</div>
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<div class="pswp__counter" />
<button
type="button"
class="pswp__button pswp__button--close"
:title="$t('window.closePhotoSwipe')"
>
<i class="material-icons">close</i>
</button>
<button
type="button"
class="pswp__button pswp__button--share"
:title="$t('window.download')"
>
<i class="material-icons">file_download</i>
</button>
<button
type="button"
class="pswp__button pswp__button--fs"
:title="$t('window.toggleFullscreen')"
>
<i class="material-icons">fullscreen</i>
</button>
<button
type="button"
class="pswp__button pswp__button--zoom"
:title="$t('window.zoomPhotoSwipe')"
>
<i class="material-icons">zoom_in</i>
</button>
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut" />
</div>
</div>
</div>
</div>
<div
class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap"
>
<div class="pswp__share-tooltip" />
</div>
<button
type="button"
class="pswp__button pswp__button--arrow--left"
:title="$t('window.previousPhotoSwipe')"
>
<i class="material-icons">arrow_back</i>
</button>
<button
type="button"
class="pswp__button pswp__button--arrow--right"
:title="$t('window.nextPhotoSwipe')"
>
<i class="material-icons">arrow_forward</i>
</button>
<div class="pswp__caption">
<div class="pswp__caption__center" />
</div>
</div>
</div>
</div>
</template>
<script>
import PhotoSwipe from 'photoswipe';
import PhotoSwipeUIDefault from 'photoswipe/dist/photoswipe-ui-default';
import ProductMap from '@pages/product/product-map';
import ProductEventMap from '@pages/product/product-event-map';
const PhotoSwipeMap = ProductMap.dropzone.photoswipe;
const PhotoSwipeEventMap = ProductEventMap.dropzone.photoswipe;
export default {
name: 'DropzonePhotoSwipe',
props: {
files: {
type: Array,
default: () => [],
},
},
mounted() {
const pswpElement = document.querySelector(PhotoSwipeMap.element);
if (pswpElement) {
const options = {
index: 0,
shareButtons: [
{
id: 'download',
label: this.$t('window.downloadImage'),
url: '{{raw_image_url}}',
download: true,
},
],
};
// This is needed to make our files compatible for photoswipe
const items = this.files.map((file) => {
file.src = file.dataURL;
file.h = file.height;
file.w = file.width;
return file;
});
const gallery = new PhotoSwipe(
pswpElement,
PhotoSwipeUIDefault,
items,
options,
);
gallery.init();
// We must tell to the rich component that the gallery have been closed
gallery.listen(PhotoSwipeEventMap.destroy, () => {
this.$emit(PhotoSwipeEventMap.closeGallery);
});
}
},
methods: {},
};
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
.product-page #product-images-container {
.pswp__button {
background: none;
color: white;
&::before {
content: none;
}
i {
pointer-events: none;
}
}
}
</style>

View File

@@ -0,0 +1,393 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div class="dropzone-window">
<div class="dropzone-window-header row">
<div class="dropzone-window-header-left">
<p
class="dropzone-window-number"
v-html="
$t('window.selectedFiles', { '%filesNb%': selectedFiles.length })
"
/>
</div>
<div class="dropzone-window-header-right">
<i
class="material-icons"
data-toggle="pstooltip"
:data-original-title="$t('window.zoom')"
@click="$emit('openGallery')"
>search</i>
<i
class="material-icons"
data-toggle="pstooltip"
:data-original-title="$t('window.replaceSelection')"
@click="openFileManager"
v-if="selectedFile"
>find_replace</i>
<i
class="material-icons"
data-toggle="pstooltip"
:data-original-title="$t('window.delete')"
@click.stop="$emit('removeSelection')"
>delete</i>
<i
class="material-icons"
data-toggle="pstooltip"
:data-original-title="$t('window.close')"
@click="$emit('unselectAll')"
>close</i>
</div>
</div>
<p
class="dropzone-window-select"
@click="$emit('selectAll')"
v-if="files.length > 0 && selectedFiles.length !== files.length"
>
{{ $t('window.selectAll') }}
</p>
<p
class="dropzone-window-unselect"
v-if="selectedFiles.length === files.length"
@click="$emit('unselectAll')"
>
{{ $t('window.unselectAll') }}
</p>
<div
class="md-checkbox dropzone-window-checkbox"
v-if="selectedFile !== null"
:data-toggle="showCoverTooltip"
:data-original-title="$t('window.cantDisableCover')"
>
<label>
<input
type="checkbox"
:disabled="isCover"
:checked="isCover"
@change.prevent.stop="coverChanged"
>
<i class="md-checkbox-control" />
{{ $t('window.useAsCover') }}
</label>
</div>
<input
type="file"
class="dropzone-window-filemanager"
@change.prevent.stop="watchFiles"
>
<div
class="dropzone-window-label"
v-if="selectedFile !== null"
>
<label
for="caption-textarea"
class="control-label"
>{{
$t('window.caption')
}}</label>
<div
class="dropdown"
v-if="locales.length > 1"
>
<button
class="btn btn-outline-secondary btn-sm dropdown-toggle js-locale-btn"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
id="product_dropzone_lang"
>
{{ selectedLocale.iso_code }}
</button>
<div
class="dropdown-menu locale-dropdown-menu"
aria-labelledby="form_invoice_prefix"
>
<span
v-for="locale in locales"
:key="locale.name"
class="dropdown-item js-locale-item"
:data-locale="locale.iso_code"
>
{{ locale.name }}
</span>
</div>
</div>
</div>
<textarea
id="caption-textarea"
name="caption-textarea"
class="form-control"
v-if="selectedFile !== null"
v-model="captionValue[selectedLocale.id_lang]"
@change.prevent.stop="prevent"
@keyup.prevent.stop="prevent"
/>
<div
class="dropzone-window-button-container"
v-if="selectedFile"
>
<button
type="button"
class="btn btn-primary save-image-settings"
@click="$emit('saveSelectedFile', captionValue, coverData)"
>
<span v-if="!loading">
{{ $t('window.saveImage') }}
</span>
<span
class="spinner-border spinner-border-sm"
v-if="loading"
role="status"
aria-hidden="true"
/>
</button>
</div>
</div>
</template>
<script>
import ProductMap from '@pages/product/product-map';
const DropzoneMap = ProductMap.dropzone;
export default {
name: 'DropzoneWindow',
props: {
selectedFiles: {
type: Array,
default: () => [],
},
files: {
type: Array,
default: () => [],
},
locales: {
type: Array,
required: true,
},
selectedLocale: {
type: Object,
default: () => {},
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
captionValue: {},
coverData: false,
};
},
watch: {
/**
* We need to watch selected files to manage multilang
* of only one file or multiple files then the value is sent
* on save.
*/
selectedFiles(value) {
if (value.length > 1) {
this.captionValue = {};
this.locales.forEach((locale) => {
this.captionValue[locale] = '';
});
} else {
this.captionValue = this.selectedFile.legends;
this.coverData = this.selectedFile.is_cover;
}
},
},
computed: {
selectedFile() {
return this.selectedFiles.length === 1 ? this.selectedFiles[0] : null;
},
isCover() {
return !!(this.selectedFile && this.selectedFile.is_cover);
},
showCoverTooltip() {
if (this.isCover) {
return 'pstooltip';
}
return false;
},
},
mounted() {
window.prestaShopUiKit.initToolTips();
// We set the intial value of the first item in order to use the computed
this.captionValue = this.selectedFile.legends;
this.coverData = this.selectedFile.is_cover;
},
updated() {
window.prestaShopUiKit.initToolTips();
},
methods: {
/**
* Watch file change and send an event to the smart component
*/
watchFiles(event) {
this.$emit('replacedFile', event);
},
/**
* Used to open the native file manager
*/
openFileManager() {
const fileInput = document.querySelector(DropzoneMap.windowFileManager);
fileInput.click();
},
/**
* Cache cover data
*/
coverChanged(event) {
this.coverData = event.target.value;
},
prevent(event) {
event.preventDefault();
event.stopPropagation();
},
},
};
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
@import '~@scss/config/_bootstrap.scss';
.product-page {
.dropzone-window {
width: 45%;
background-color: darken(#ffffff, 2%);
align-self: stretch;
padding: 1rem;
min-width: 20rem;
&-filemanager {
display: none;
}
&-label {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
label {
margin-bottom: 0;
}
.dropdown {
> button {
padding-right: 0.25rem;
}
&-item {
cursor: pointer;
}
}
}
textarea {
margin-bottom: 1rem;
}
&-button {
&-container {
display: flex;
justify-content: flex-end;
}
}
&-checkbox {
margin-bottom: 1rem;
label {
font-size: 0.875rem;
}
}
&-select,
&-unselect {
font-weight: 600;
font-size: 0.925rem;
color: $primary;
cursor: pointer;
margin-top: 0.5rem;
}
&-number {
font-size: 1rem;
span {
color: $primary;
font-weight: 600;
}
}
&-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
p {
margin-bottom: 0;
}
.material-icons {
cursor: pointer;
color: $gray-500;
transition: 0.25s ease-out;
font-size: 1.5rem;
margin: 0 0.25rem;
&:last-child {
margin-right: 0;
}
&:first-child {
margin-left: 0;
}
&:hover {
color: primary;
}
}
}
@include media-breakpoint-down(xs) {
width: 100%;
min-width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import ReplaceFormatter from '@vue/plugins/vue-i18n/replace-formatter';
import Dropzone from './Dropzone.vue';
Vue.use(VueI18n);
export default function initDropzone(imagesContainerSelector) {
const container = document.querySelector(imagesContainerSelector);
const translations = JSON.parse(container.dataset.translations);
const i18n = new VueI18n({
locale: 'en',
formatter: new ReplaceFormatter(),
messages: {en: translations},
});
const productId = Number(container.dataset.productId);
const locales = JSON.parse(container.dataset.locales);
return new Vue({
el: imagesContainerSelector,
template: '<dropzone :productId=productId :locales=locales :token=token :formName=formName />',
components: {Dropzone},
i18n,
data: {
locales,
productId,
token: container.dataset.token,
formName: container.dataset.formName,
},
});
}

View File

@@ -0,0 +1,152 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div class="combinations-filters-dropdown">
<div class="dropdown">
<button
:class="[
'btn',
'dropdown-toggle',
selectedFilters.length > 0 ? 'btn-primary' : 'btn-outline-secondary',
'btn',
]"
type="button"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
id="form_invoice_prefix"
>
{{ label }} {{ nbFiles }}
</button>
<div
class="dropdown-menu"
aria-labelledby="form_invoice_prefix"
@click="preventClose($event)"
>
<div
class="md-checkbox"
v-for="filter in children"
:key="filter.id"
>
<label class="dropdown-item">
<div class="md-checkbox-container">
<input
type="checkbox"
:checked="isChecked(filter)"
@change="toggleFilter(filter)"
>
<i class="md-checkbox-control" />
{{ filter.name }}
</div>
</label>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'FilterDropdown',
data() {
return {
selectedFilters: [],
};
},
props: {
parentId: {
type: Number,
required: true,
},
children: {
type: Array,
required: true,
},
label: {
type: String,
required: true,
},
},
mounted() {
this.$parent.$on('clearAll', this.clear);
},
computed: {
nbFiles() {
return this.selectedFilters.length > 0
? `(${this.selectedFilters.length})`
: null;
},
},
methods: {
isChecked(filter) {
return this.selectedFilters.includes(filter);
},
toggleFilter(filter) {
if (this.selectedFilters.includes(filter)) {
this.$emit('removeFilter', filter, this.parentId);
this.selectedFilters = this.selectedFilters.filter(
(item) => item.id !== filter.id,
);
} else {
this.$emit('addFilter', filter, this.parentId);
this.selectedFilters.push(filter);
}
},
preventClose(event) {
event.stopPropagation();
},
clear() {
this.selectedFilters = [];
},
},
};
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
@import "~@scss/config/_bootstrap.scss";
.combinations-filters-dropdown {
margin: 0 0.35rem;
@include media-breakpoint-down(xs) {
margin-bottom: .5rem;
}
.dropdown-item {
padding: 0.438rem 0.938rem;
padding-right: 1rem;
line-height: normal;
color: inherit;
border-bottom: 0;
.md-checkbox-container {
position: relative;
padding-left: 28px;
}
}
}
</style>

View File

@@ -0,0 +1,149 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div class="combinations-filters">
<label
class="control-label"
v-if="filters.length"
>{{ $t('filters.label') }}</label>
<div
class="combinations-filters-line"
v-if="filters.length"
>
<filter-dropdown
:key="filter.id"
v-for="filter in filters"
:children="filter.attributes"
:parent-id="filter.id"
:label="filter.name"
@addFilter="addFilter"
@removeFilter="removeFilter"
/>
<button
type="button"
v-if="selectedFiltersNumber > 0"
class="btn btn-outline-secondary combinations-filters-clear"
@click="clearAll"
>
<i class="material-icons">close</i>
{{ $tc('filters.clear', selectedFiltersNumber, { '%filtersNb%': selectedFiltersNumber }) }}
</button>
</div>
</div>
</template>
<script>
import FilterDropdown from '@pages/product/components/filters/FilterDropdown';
import ProductEventMap from '@pages/product/product-event-map';
const CombinationEvents = ProductEventMap.combinations;
export default {
name: 'Filters',
data() {
return {
selectedFilters: {},
};
},
props: {
filters: {
type: Array,
required: true,
},
eventEmitter: {
type: Object,
required: true,
},
},
components: {
FilterDropdown,
},
computed: {
selectedFiltersNumber() {
if (!this.selectedFilters) {
return 0;
}
return Object.values(this.selectedFilters).reduce((total, attributes) => total + attributes.length, 0);
},
},
mounted() {
this.eventEmitter.on(CombinationEvents.clearFilters, () => this.clearAll());
},
methods: {
/**
* This methods is used to initialize product filters
*/
addFilter(filter, parentId) {
// If absent set new field with set method so that it's reactive
if (!this.selectedFilters[parentId]) {
this.$set(this.selectedFilters, parentId, []);
}
this.selectedFilters[parentId].push(filter);
this.updateFilters();
},
removeFilter(filter, parentId) {
if (!this.selectedFilters[parentId]) {
return;
}
this.selectedFilters[parentId] = this.selectedFilters[parentId].filter(
(e) => filter.id !== e.id,
);
this.updateFilters();
},
clearAll() {
this.selectedFilters = [];
this.$emit('clearAll');
this.eventEmitter.emit(CombinationEvents.updateAttributeGroups, this.selectedFilters);
},
updateFilters() {
this.eventEmitter.emit(CombinationEvents.updateAttributeGroups, this.selectedFilters);
},
},
};
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
.combinations-filters {
.control-label {
font-weight: 600;
color: #000;
margin-botton: 1rem;
}
&-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin: 0 -0.35rem;
}
}
</style>

View File

@@ -0,0 +1,59 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Vue from 'vue';
import Filters from '@pages/product/components/filters/Filters';
import VueI18n from 'vue-i18n';
import ReplaceFormatter from '@vue/plugins/vue-i18n/replace-formatter';
Vue.use(VueI18n);
/**
* @param {string} combinationsFiltersSelector
* @param {EventEmitter} eventEmitter
* @param {array} filters
* @returns {Vue | CombinedVueInstance<Vue, {eventEmitter, filters}, object, object, Record<never, any>>}
*/
export default function initCombinationsFilters(combinationsFiltersSelector, eventEmitter, filters) {
const container = document.querySelector(combinationsFiltersSelector);
const translations = JSON.parse(container.dataset.translations);
const i18n = new VueI18n({
locale: 'en',
formatter: new ReplaceFormatter(),
messages: {en: translations},
});
return new Vue({
el: combinationsFiltersSelector,
template: '<filters :filters=filters :eventEmitter=eventEmitter />',
components: {Filters},
i18n,
data: {
filters,
eventEmitter,
},
});
}

View File

@@ -0,0 +1,399 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div class="generate-modal-content">
<div class="tags-input d-flex flex-wrap">
<div class="tags-wrapper">
<template v-for="selectedGroup in selectedAttributeGroups">
<span
class="tag"
:key="selectedAttribute.id"
v-for="selectedAttribute in selectedGroup.attributes"
>
{{ selectedGroup.name }}: {{ selectedAttribute.name }}
<i
class="material-icons"
@click.prevent.stop="
sendRemoveEvent(selectedAttribute, selectedGroup)
"
>close</i>
</span>
</template>
</div>
<input
type="text"
:placeholder="$t('search.placeholder')"
class="form-control input attributes-search"
>
</div>
<div class="product-combinations-modal-content">
<div
id="attributes-list-selector"
class="attributes-list-overflow"
>
<div class="attributes-content">
<div
class="attribute-group"
v-for="attributeGroup of attributeGroups"
:key="attributeGroup.id"
>
<div class="attribute-group-header">
<div class="md-checkbox attribute-group-checkbox">
<label>
<input
class="attribute-group-checkbox"
type="checkbox"
:name="`checkbox_${attributeGroup.id}`"
@change.prevent.stop="toggleAll(attributeGroup)"
:checked="checkboxList.includes(attributeGroup)"
>
<i class="md-checkbox-control" />
</label>
</div>
<a
class="attribute-group-name collapsed"
data-toggle="collapse"
:href="`#attribute-group-${attributeGroup.id}`"
>
{{ attributeGroup.name }}
</a>
</div>
<div
class="attribute-group-content attributes collapse"
:id="`attribute-group-${attributeGroup.id}`"
>
<label
v-for="attribute of attributeGroup.attributes"
:class="[
'attribute-item',
getSelectedClass(attribute, attributeGroup),
]"
:for="`attribute_${attribute.id}`"
:key="attribute.id"
>
<input
type="checkbox"
:name="`attribute_${attribute.id}`"
:id="`attribute_${attribute.id}`"
@change="sendChangeEvent(attribute, attributeGroup)"
>
<div class="attribute-item-content">
<span
class="attribute-item-color"
v-if="attribute.color"
:style="`background-color: ${attribute.color}`"
/>
<span class="attribute-item-name">{{ attribute.name }}</span>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import isSelected from '@pages/product/mixins/is-attribute-selected';
import ProductMap from '@pages/product/product-map';
import PerfectScrollbar from 'perfect-scrollbar';
import Bloodhound from 'typeahead.js';
import AutoCompleteSearch from '@components/auto-complete-search';
const {$} = window;
const CombinationsMap = ProductMap.combinations;
export default {
name: 'AttributesSelector',
props: {
attributeGroups: {
type: Array,
default: () => [],
},
selectedAttributeGroups: {
type: Object,
default: () => {},
},
},
mixins: [isSelected],
data() {
return {
dataSetConfig: {},
searchSource: {},
scrollbar: null,
hasGeneratedCombinations: false,
checkboxList: [],
};
},
mounted() {
this.initDataSetConfig();
this.scrollbar = new PerfectScrollbar(CombinationsMap.scrollBar);
const $searchInput = $(CombinationsMap.searchInput);
new AutoCompleteSearch($searchInput, this.dataSetConfig);
},
watch: {
selectedAttributeGroups(value) {
const attributes = Object.keys(value);
if (attributes.length <= 0) {
this.checkboxList = [];
}
},
},
methods: {
initDataSetConfig() {
const searchItems = this.getSearchableAttributes();
this.searchSource = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace(
'name',
'value',
'color',
'group_name',
),
queryTokenizer: Bloodhound.tokenizers.whitespace,
local: searchItems,
});
const dataSetConfig = {
source: this.searchSource,
display: 'name',
value: 'name',
minLength: 1,
onSelect: (attribute, e, $searchInput) => {
const attributeGroup = {
id: attribute.group_id,
name: attribute.group_name,
};
this.sendAddEvent(attribute, attributeGroup);
// This resets the search input or else previous search is cached and can be added again
$searchInput.typeahead('val', '');
},
onClose(event, $searchInput) {
$searchInput.typeahead('val', '');
return true;
},
};
dataSetConfig.templates = {
suggestion: (item) => `<div class="px-2">${item.group_name}: ${item.name}</div>`,
};
this.dataSetConfig = dataSetConfig;
},
/**
* @returns {Array}
*/
getSearchableAttributes() {
const searchableAttributes = [];
this.attributeGroups.forEach((attributeGroup) => {
attributeGroup.attributes.forEach((attribute) => {
if (
this.isSelected(
attribute,
attributeGroup,
this.selectedAttributeGroups,
)
) {
return;
}
attribute.group_name = attributeGroup.name;
attribute.group_id = attributeGroup.id;
searchableAttributes.push(attribute);
});
});
return searchableAttributes;
},
/**
* @param {Object} attribute
* @param {Object} attributeGroup
*
* @returns {string}
*/
getSelectedClass(attribute, attributeGroup) {
return this.isSelected(
attribute,
attributeGroup,
this.selectedAttributeGroups,
)
? 'selected'
: 'unselected';
},
sendRemoveEvent(selectedAttribute, selectedAttributeGroup) {
this.$emit('removeSelected', {
selectedAttribute,
selectedAttributeGroup,
});
this.updateSearchableAttributes();
this.updateCheckboxes(selectedAttributeGroup);
},
sendChangeEvent(selectedAttribute, attributeGroup) {
this.$emit('changeSelected', {selectedAttribute, attributeGroup});
this.updateSearchableAttributes();
this.updateCheckboxes(attributeGroup);
},
sendAddEvent(selectedAttribute, attributeGroup) {
this.$emit('addSelected', {selectedAttribute, attributeGroup});
this.updateSearchableAttributes();
this.updateCheckboxes(attributeGroup);
},
/**
* Update Bloodhound engine so that it does not include already selected attributes
*/
updateSearchableAttributes() {
const searchableAttributes = this.getSearchableAttributes();
this.searchSource.clear();
this.searchSource.add(searchableAttributes);
},
toggleAll(attributeGroup) {
if (this.checkboxList.includes(attributeGroup)) {
this.checkboxList = this.checkboxList.filter(
(e) => e.id !== attributeGroup.id,
);
} else {
this.checkboxList.push(attributeGroup);
}
this.$emit('toggleAll', {
attributeGroup,
select: this.checkboxList.includes(attributeGroup),
});
},
updateCheckboxes(attributeGroup) {
if (
this.selectedAttributeGroups[attributeGroup.id]
&& !this.checkboxList.includes(attributeGroup)
&& this.selectedAttributeGroups[attributeGroup.id].attributes.length
=== attributeGroup.attributes.length
) {
this.checkboxList.push(attributeGroup);
} else {
this.checkboxList = this.checkboxList.filter(
(group) => group.id !== attributeGroup.id,
);
}
},
},
};
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
#product-combinations-generate {
.modal {
.tags-input {
margin-bottom: 1rem;
.tag {
margin-bottom: 0.25rem;
}
}
#attributes-list-selector {
max-height: 50vh;
.attribute-group {
position: relative;
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid $gray-300;
border-radius: 4px;
&-header {
display: flex;
background-color: $gray-250;
}
&-content {
border-top: 1px solid $gray-300;
}
&-checkbox {
position: absolute;
top: 0.5rem;
left: 0.5rem;
}
&-name {
width: 100%;
padding: 0.4375rem 0.4375rem 0.4375rem 2.5rem;
font-weight: 600;
color: #363a41;
&:hover {
text-decoration: none;
}
}
.attribute-item {
margin: 0.25rem;
cursor: pointer;
border-radius: 3px;
&-content {
display: flex;
align-items: center;
padding: 0.5rem;
}
&.selected {
background-color: $gray-disabled;
}
input {
display: none;
}
&-color {
display: block;
width: 15px;
height: 15px;
margin-right: 0.5rem;
border-radius: 3px;
}
}
}
.attributes {
height: auto;
padding: 0.4375rem;
}
}
.product-combinations-modal-content {
position: relative;
padding-bottom: 0.5rem;
}
}
}
</style>

View File

@@ -0,0 +1,322 @@
<!--**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*-->
<template>
<div id="product-combinations-generate">
<modal
v-if="isModalShown"
:modal-title="$t('modal.title')"
:confirmation="true"
@close="closeModal"
>
<template #body>
<attributes-selector
:attribute-groups="attributeGroups"
:selected-attribute-groups="selectedAttributeGroups"
@changeSelected="changeSelected"
@removeSelected="removeSelected"
@addSelected="addSelected"
@toggleAll="toggleAll"
v-if="attributeGroups"
/>
</template>
<template #footer-confirmation>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="closeModal"
:aria-label="$t('modal.close')"
>
{{ $t('modal.close') }}
</button>
<button
type="button"
class="btn btn-primary"
@click.prevent.stop="generateCombinations"
:disabled="!generatedCombinationsNb || loading"
>
<span v-if="!loading">
{{
$tc('generator.action', generatedCombinationsNb, {
'%combinationsNb%': generatedCombinationsNb,
})
}}
</span>
<span
class="spinner-border spinner-border-sm"
v-if="loading"
role="status"
aria-hidden="true"
/>
</button>
</template>
</modal>
</div>
</template>
<script>
import CombinationsService from '@pages/product/services/combinations-service';
import AttributesSelector from '@pages/product/components/generator/AttributesSelector';
import isSelected from '@pages/product/mixins/is-attribute-selected';
import {getAllAttributeGroups} from '@pages/product/services/attribute-groups';
import Modal from '@vue/components/Modal';
import ProductEventMap from '@pages/product/product-event-map';
const {$} = window;
const CombinationEvents = ProductEventMap.combinations;
export default {
name: 'CombinationGenerator',
data() {
return {
attributeGroups: [],
selectedAttributeGroups: {},
combinationsService: new CombinationsService(this.productId),
isModalShown: false,
preLoading: true,
loading: false,
scrollbar: null,
hasGeneratedCombinations: false,
};
},
props: {
productId: {
type: Number,
required: true,
},
eventEmitter: {
type: Object,
required: true,
},
},
mixins: [isSelected],
components: {
Modal,
AttributesSelector,
},
computed: {
generatedCombinationsNb() {
const groupIds = Object.keys(this.selectedAttributeGroups);
let combinationsNumber = 0;
groupIds.forEach((attributeGroupId) => {
const {attributes} = this.selectedAttributeGroups[attributeGroupId];
if (!attributes.length) {
return;
}
// Only start counting when at least one attribute is selected
if (combinationsNumber === 0) {
combinationsNumber = 1;
}
combinationsNumber *= this.selectedAttributeGroups[attributeGroupId]
.attributes.length;
});
return combinationsNumber;
},
},
mounted() {
this.initAttributeGroups();
this.eventEmitter.on(CombinationEvents.openCombinationsGenerator, () => this.showModal());
},
methods: {
/**
* This methods is used to initialize combinations definitions
*/
async initAttributeGroups() {
try {
this.attributeGroups = await getAllAttributeGroups();
window.prestaShopUiKit.init();
this.preLoading = false;
this.eventEmitter.emit(CombinationEvents.combinationGeneratorReady);
} catch (error) {
window.$.growl.error({message: error});
}
},
/**
* Show the modal, and execute PerfectScrollBar and Typehead
*/
showModal() {
if (this.preLoading) {
return;
}
document.querySelector('body').classList.add('overflow-hidden');
this.hasGeneratedCombinations = false;
this.selectedAttributeGroups = {};
this.isModalShown = true;
},
/**
* Handle modal closing
*/
closeModal() {
this.isModalShown = false;
document.querySelector('body').classList.remove('overflow-hidden');
if (this.hasGeneratedCombinations) {
this.eventEmitter.emit(CombinationEvents.refreshCombinationList);
}
},
/**
* Used when the user clicks on the Generate button of the modal
*/
async generateCombinations() {
this.loading = true;
const data = {
attributes: {},
};
Object.keys(this.selectedAttributeGroups).forEach((attributeGroupId) => {
data.attributes[attributeGroupId] = [];
this.selectedAttributeGroups[attributeGroupId].attributes.forEach(
(attribute) => {
data.attributes[attributeGroupId].push(attribute.id);
},
);
});
try {
const response = await this.combinationsService.generateCombinations(
data,
);
$.growl({
message: this.$t('generator.success', {
'%combinationsNb%': response.combination_ids.length,
}),
});
this.selectedAttributeGroups = {};
this.hasGeneratedCombinations = true;
} catch (error) {
if (error.responseJSON && error.responseJSON.error) {
$.growl.error({message: error.responseJSON.error});
} else {
$.growl.error({message: error});
}
}
this.loading = false;
},
/**
* Remove the attribute if it's selected or add it
*
* @param {Object} selectedAttribute
* @param {{id: int, name: string}} attributeGroup
*/
changeSelected({selectedAttribute, attributeGroup}) {
if (
!this.isSelected(
selectedAttribute,
attributeGroup,
this.selectedAttributeGroups,
)
) {
this.addSelected({selectedAttribute, attributeGroup});
} else {
this.removeSelected({
selectedAttribute,
selectedAttributeGroup: attributeGroup,
});
}
},
/**
* @param {Object} selectedAttribute
* @param {{id: int, name: string}} attributeGroup
*/
addSelected({selectedAttribute, attributeGroup}) {
// Extra check to avoid adding same attribute twice which would cause a duplicate key error
if (
this.isSelected(
selectedAttribute,
attributeGroup,
this.selectedAttributeGroups,
)
) {
return;
}
// Add copy of attribute group in selected groups
if (!this.selectedAttributeGroups[attributeGroup.id]) {
const newAttributeGroup = {
[attributeGroup.id]: {
id: attributeGroup.id,
name: attributeGroup.name,
attributes: [],
},
};
// This is needed to correctly handle observation
this.selectedAttributeGroups = {
...this.selectedAttributeGroups,
...newAttributeGroup,
};
}
this.selectedAttributeGroups[attributeGroup.id].attributes.push(
selectedAttribute,
);
},
/**
* @param {Object} selectedAttribute
* @param {Object} selectedAttributeGroup
*/
removeSelected({selectedAttribute, selectedAttributeGroup}) {
if (
!Object.prototype.hasOwnProperty.call(
this.selectedAttributeGroups,
selectedAttributeGroup.id,
)
) {
return;
}
const group = this.selectedAttributeGroups[selectedAttributeGroup.id];
group.attributes = group.attributes.filter(
(attribute) => attribute.id !== selectedAttribute.id,
);
},
/**
* Remove the attribute if it's selected or add it
*
* @param {Object} selectedAttribute
* @param {{id: int, name: string}} attributeGroup
*/
toggleAll({attributeGroup, select}) {
if (select) {
attributeGroup.attributes.forEach((attribute) => {
this.addSelected({selectedAttribute: attribute, attributeGroup});
});
} else {
attributeGroup.attributes.forEach((attribute) => {
this.removeSelected({
selectedAttribute: attribute,
selectedAttributeGroup: attributeGroup,
});
});
}
},
},
};
</script>

View File

@@ -0,0 +1,52 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import ReplaceFormatter from '@vue/plugins/vue-i18n/replace-formatter';
import CombinationGenerator from '@pages/product/components/generator/CombinationGenerator.vue';
Vue.use(VueI18n);
export default function initCombinationGenerator(combinationGeneratorSelector, eventEmitter, productId) {
const container = document.querySelector(combinationGeneratorSelector);
const translations = JSON.parse(container.dataset.translations);
const i18n = new VueI18n({
locale: 'en',
formatter: new ReplaceFormatter(),
messages: {en: translations},
});
return new Vue({
el: combinationGeneratorSelector,
template: '<combination-generator :productId=productId :eventEmitter=eventEmitter />',
components: {CombinationGenerator},
i18n,
data: {
productId,
eventEmitter,
},
});
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
const {$} = window;
export default function () {
const $defaultArrowWidth = 35;
const $arrow = $(ProductMap.jsArrow);
const $tabs = $(ProductMap.jsTabs);
const $navTabs = $(ProductMap.jsNavTabs);
let $positions;
let $moveTo = 0;
let $tabWidth = 0;
let $navWidth = $defaultArrowWidth;
let $widthWithTabs = 0;
$navTabs.find('li').each((index, item) => {
$navWidth += $(item).width();
});
$widthWithTabs = $navWidth + $defaultArrowWidth * 2;
$navTabs.width($widthWithTabs);
$navTabs.find(ProductMap.toggleTab).on('click', (e) => {
if (!$(e.target).hasClass('active')) {
$(ProductMap.formContentTab).removeClass('active');
}
});
$arrow.on('click', (e) => {
if ($arrow.is(':visible')) {
$tabWidth = $tabs.width();
$positions = $navTabs.position();
$moveTo = '-=0';
if ($(e.currentTarget).hasClass('right-arrow')) {
if ($tabWidth - $positions.left < $navWidth) {
$moveTo = `-=${$tabWidth}`;
}
} else if ($positions.left < $defaultArrowWidth) {
$moveTo = `+=${$tabWidth}`;
}
$navTabs.animate(
{
left: $moveTo,
},
400,
'easeOutQuad',
() => {
$(ProductMap.leftArrow).toggleClass('visible', $(e.currentTarget).hasClass('right-arrow'));
$(ProductMap.rightArrow).toggleClass('visible', !$(e.currentTarget).hasClass('right-arrow'));
},
);
}
});
}

View File

@@ -0,0 +1,124 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
const {$} = window;
/**
* Renders the list of combinations in product edit page
*/
export default class CombinationsGridRenderer {
/**
* @returns {{render: (function(*=): void)}}
*/
constructor() {
this.$combinationsTable = $(ProductMap.combinations.combinationsTable);
this.$combinationsTableBody = $(ProductMap.combinations.combinationsTableBody);
this.$loadingSpinner = $(ProductMap.combinations.loadingSpinner);
this.prototypeTemplate = this.$combinationsTable.data('prototype');
this.prototypeName = this.$combinationsTable.data('prototypeName');
return {
render: (data) => this.render(data),
toggleLoading: (loading) => this.toggleLoading(loading),
};
}
/**
* @param {Object} data expected structure: {combinations: [{Object}, {Object}...], total: {Number}}
*/
render(data) {
this.renderCombinations(data.combinations);
}
/**
* @param {Boolean} loading
*/
toggleLoading(loading) {
this.$loadingSpinner.toggle(loading);
}
/**
* @param {Array} combinations
*
* @private
*/
renderCombinations(combinations) {
this.$combinationsTableBody.empty();
let rowIndex = 0;
combinations.forEach((combination) => {
const $row = $(this.getPrototypeRow(rowIndex));
// fill inputs
const $combinationCheckbox = $(ProductMap.combinations.tableRow.combinationCheckbox(rowIndex), $row);
const $combinationIdInput = $(ProductMap.combinations.tableRow.combinationIdInput(rowIndex), $row);
const $combinationNameInput = $(ProductMap.combinations.tableRow.combinationNameInput(rowIndex), $row);
const $quantityInput = $(ProductMap.combinations.tableRow.quantityInput(rowIndex), $row);
const $impactOnPriceInput = $(ProductMap.combinations.tableRow.impactOnPriceInput(rowIndex), $row);
const $referenceInput = $(ProductMap.combinations.tableRow.referenceInput(rowIndex), $row);
// @todo final price should be calculated based on price impact and product price,
// so it doesnt need to be in api response
const $finalPriceInput = $(ProductMap.combinations.tableRow.finalPriceTeInput(rowIndex), $row);
$combinationIdInput.val(combination.id);
$combinationNameInput.val(combination.name);
// This adds the ID in the checkbox label
$combinationCheckbox.closest('label').append(combination.id);
// This adds a text after the cell children (do not use text which replaces everything)
$combinationNameInput.closest('td').append(combination.name);
$finalPriceInput.closest('td').append(combination.finalPriceTe);
$referenceInput.val(combination.reference);
$referenceInput.data('initial-value', combination.reference);
$quantityInput.val(combination.quantity);
$quantityInput.data('initial-value', combination.quantity);
$impactOnPriceInput.val(combination.impactOnPrice);
$impactOnPriceInput.data('initial-value', combination.impactOnPrice);
$(ProductMap.combinations.tableRow.editButton(rowIndex), $row).data('id', combination.id);
$(ProductMap.combinations.tableRow.deleteButton(rowIndex), $row).data('id', combination.id);
$(ProductMap.combinations.tableRow.combinationImg, $row)
.attr('src', combination.imageUrl)
.attr('alt', combination.name);
if (combination.isDefault) {
$(ProductMap.combinations.tableRow.isDefaultInput(rowIndex), $row).prop('checked', true);
}
this.$combinationsTableBody.append($row);
rowIndex += 1;
});
}
/**
* @param {Number} rowIndex
*
* @returns {String}
*
* @private
*/
getPrototypeRow(rowIndex) {
return this.prototypeTemplate.replace(new RegExp(this.prototypeName, 'g'), rowIndex);
}
}

View File

@@ -0,0 +1,437 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
import CombinationsGridRenderer from '@pages/product/edit/combinations-grid-renderer';
import CombinationsService from '@pages/product/services/combinations-service';
import DynamicPaginator from '@components/pagination/dynamic-paginator';
import SubmittableInput from '@components/form/submittable-input';
import ProductEventMap from '@pages/product/product-event-map';
import initCombinationModal from '@pages/product/components/combination-modal';
import initFilters from '@pages/product/components/filters';
import ConfirmModal from '@components/modal';
import initCombinationGenerator from '@pages/product/components/generator';
import {getProductAttributeGroups} from '@pages/product/services/attribute-groups';
const {$} = window;
const CombinationEvents = ProductEventMap.combinations;
const CombinationsMap = ProductMap.combinations;
export default class CombinationsManager {
/**
* @param {int} productId
* @returns {{}}
*/
constructor(productId) {
this.productId = productId;
this.eventEmitter = window.prestashop.instance.eventEmitter;
this.$productForm = $(ProductMap.productForm);
this.$combinationsContainer = $(
ProductMap.combinations.combinationsContainer,
);
this.combinationIdInputsSelector = ProductMap.combinations.combinationIdInputsSelector;
this.$externalCombinationTab = $(
ProductMap.combinations.externalCombinationTab,
);
this.$preloader = $(ProductMap.combinations.preloader);
this.$paginatedList = $(CombinationsMap.combinationsPaginatedList);
this.$emptyState = $(CombinationsMap.emptyState);
this.paginator = null;
this.combinationsRenderer = null;
this.filtersApp = null;
this.combinationModalApp = null;
this.combinationGeneratorApp = null;
this.initialized = false;
this.combinationsService = new CombinationsService(this.productId);
this.productAttributeGroups = [];
this.init();
return {};
}
/**
* @private
*/
init() {
// Paginate to first page when tab is shown
this.$productForm
.find(CombinationsMap.navigationTab)
.on('shown.bs.tab', () => this.showCombinationTab());
this.$productForm
.find(CombinationsMap.navigationTab)
.on('hidden.bs.tab', () => this.hideCombinationTab());
// Finally watch events related to combination listing
this.watchEvents();
}
/**
* @private
*/
showCombinationTab() {
this.$externalCombinationTab.removeClass('d-none');
this.firstInit();
}
/**
* @private
*/
hideCombinationTab() {
this.$externalCombinationTab.addClass('d-none');
}
/**
* @private
*/
firstInit() {
if (this.initialized) {
return;
}
this.initialized = true;
this.combinationGeneratorApp = initCombinationGenerator(
CombinationsMap.combinationsGeneratorContainer,
this.eventEmitter,
this.productId,
);
this.combinationModalApp = initCombinationModal(
CombinationsMap.editModal,
this.productId,
this.eventEmitter,
);
this.filtersApp = initFilters(
CombinationsMap.combinationsFiltersContainer,
this.eventEmitter,
this.productAttributeGroups,
);
this.initPaginatedList();
this.refreshCombinationList(true);
}
/**
* @param {boolean} firstTime
* @returns {Promise<void>}
*
* @private
*/
async refreshCombinationList(firstTime) {
// Preloader is only shown on first load
this.$preloader.toggleClass('d-none', !firstTime);
this.$paginatedList.toggleClass('d-none', firstTime);
this.$emptyState.addClass('d-none');
// When attributes are refreshed we show first page
this.paginator.paginate(1);
// Wait for product attributes to adapt rendering depending on their number
this.productAttributeGroups = await getProductAttributeGroups(
this.productId,
);
this.filtersApp.filters = this.productAttributeGroups;
this.eventEmitter.emit(CombinationEvents.clearFilters);
this.$preloader.addClass('d-none');
const hasCombinations = this.productAttributeGroups && this.productAttributeGroups.length;
this.$paginatedList.toggleClass('d-none', !hasCombinations);
if (!hasCombinations) {
// Empty list
this.combinationsRenderer.render({combinations: []});
this.$emptyState.removeClass('d-none');
}
}
/**
* @private
*/
refreshPage() {
this.paginator.paginate(this.paginator.getCurrentPage());
}
/**
* @private
*/
initPaginatedList() {
this.combinationsRenderer = new CombinationsGridRenderer();
this.paginator = new DynamicPaginator(
CombinationsMap.paginationContainer,
this.combinationsService,
this.combinationsRenderer,
);
this.initSubmittableInputs();
this.$combinationsContainer.on(
'change',
CombinationsMap.isDefaultInputsSelector,
async (e) => {
if (!e.currentTarget.checked) {
return;
}
await this.updateDefaultCombination(e.currentTarget);
},
);
this.$combinationsContainer.on(
'click',
CombinationsMap.removeCombinationSelector,
async (e) => {
await this.removeCombination(e.currentTarget);
},
);
this.initSortingColumns();
this.paginator.paginate(1);
}
/**
* @private
*/
watchEvents() {
/* eslint-disable */
this.eventEmitter.on(CombinationEvents.refreshCombinationList, () =>
this.refreshCombinationList(false)
);
this.eventEmitter.on(CombinationEvents.refreshPage, () =>
this.refreshPage()
);
/* eslint-disable */
this.eventEmitter.on(
CombinationEvents.updateAttributeGroups,
attributeGroups => {
const currentFilters = this.combinationsService.getFilters();
currentFilters.attributes = {};
Object.keys(attributeGroups).forEach(attributeGroupId => {
currentFilters.attributes[attributeGroupId] = [];
const attributes = attributeGroups[attributeGroupId];
attributes.forEach(attribute => {
currentFilters.attributes[attributeGroupId].push(attribute.id);
});
});
this.combinationsService.setFilters(currentFilters);
this.paginator.paginate(1);
}
);
this.eventEmitter.on(CombinationEvents.combinationGeneratorReady, () => {
const $generateButtons = $(
ProductMap.combinations.generateCombinationsButton
);
$generateButtons.prop('disabled', false);
$('body').on(
'click',
ProductMap.combinations.generateCombinationsButton,
event => {
// Stop event or it will be caught by click-outside directive and automatically close the modal
event.stopImmediatePropagation();
this.eventEmitter.emit(CombinationEvents.openCombinationsGenerator);
}
);
});
}
/**
* @private
*/
initSubmittableInputs() {
const combinationToken = this.getCombinationToken();
const { quantityKey } = CombinationsMap.combinationItemForm;
const { impactOnPriceKey } = CombinationsMap.combinationItemForm;
const { referenceKey } = CombinationsMap.combinationItemForm;
const { tokenKey } = CombinationsMap.combinationItemForm;
/* eslint-disable */
new SubmittableInput(CombinationsMap.quantityInputWrapper, input =>
this.combinationsService.updateListedCombination(
this.findCombinationId(input),
{
[quantityKey]: input.value,
[tokenKey]: combinationToken
}
)
);
new SubmittableInput(CombinationsMap.impactOnPriceInputWrapper, input =>
this.combinationsService.updateListedCombination(
this.findCombinationId(input),
{
[impactOnPriceKey]: input.value,
[tokenKey]: combinationToken
}
)
);
new SubmittableInput(CombinationsMap.referenceInputWrapper, input =>
this.combinationsService.updateListedCombination(
this.findCombinationId(input),
{
[referenceKey]: input.value,
[tokenKey]: combinationToken
}
)
);
/* eslint-enable */
}
/**
* @private
*/
initSortingColumns() {
this.$combinationsContainer.on(
'click',
CombinationsMap.sortableColumns,
(event) => {
const $sortableColumn = $(event.currentTarget);
const columnName = $sortableColumn.data('sortColName');
if (!columnName) {
return;
}
let direction = $sortableColumn.data('sortDirection');
if (!direction || direction === 'desc') {
direction = 'asc';
} else {
direction = 'desc';
}
// Reset all columns, we need to force the attributes for CSS matching
$(
CombinationsMap.sortableColumns,
this.$combinationsContainer,
).removeData('sortIsCurrent');
$(
CombinationsMap.sortableColumns,
this.$combinationsContainer,
).removeData('sortDirection');
$(
CombinationsMap.sortableColumns,
this.$combinationsContainer,
).removeAttr('data-sort-is-current');
$(
CombinationsMap.sortableColumns,
this.$combinationsContainer,
).removeAttr('data-sort-direction');
// Set correct data in current column, we need to force the attributes for CSS matching
$sortableColumn.data('sortIsCurrent', 'true');
$sortableColumn.data('sortDirection', direction);
$sortableColumn.attr('data-sort-is-current', 'true');
$sortableColumn.attr('data-sort-direction', direction);
// Finally update list
this.combinationsService.setOrderBy(columnName, direction);
this.paginator.paginate(1);
},
);
}
/**
* @param {HTMLElement} button
*
* @private
*/
async removeCombination(button) {
try {
const $deleteButton = $(button);
const modal = new ConfirmModal(
{
id: 'modal-confirm-delete-combination',
confirmTitle: $deleteButton.data('modal-title'),
confirmMessage: $deleteButton.data('modal-message'),
confirmButtonLabel: $deleteButton.data('modal-apply'),
closeButtonLabel: $deleteButton.data('modal-cancel'),
confirmButtonClass: 'btn-danger',
closable: true,
},
async () => {
const response = await this.combinationsService.removeCombination(
this.findCombinationId(button),
);
$.growl({message: response.message});
this.eventEmitter.emit(CombinationEvents.refreshCombinationList);
},
);
modal.show();
} catch (error) {
const errorMessage = error.responseJSON
? error.responseJSON.error
: error;
$.growl.error({message: errorMessage});
}
}
/**
* @param {HTMLElement} checkedInput
*
* @private
*/
async updateDefaultCombination(checkedInput) {
const checkedInputs = this.$combinationsContainer.find(
`${CombinationsMap.isDefaultInputsSelector}:checked`,
);
const checkedDefaultId = this.findCombinationId(checkedInput);
await this.combinationsService.updateListedCombination(checkedDefaultId, {
'combination_item[is_default]': checkedInput.value,
'combination_item[_token]': this.getCombinationToken(),
});
$.each(checkedInputs, (index, input) => {
if (this.findCombinationId(input) !== checkedDefaultId) {
$(input).prop('checked', false);
}
});
}
/**
* @returns {String}
*/
getCombinationToken() {
return $(CombinationsMap.combinationsContainer).data('combinationToken');
}
/**
* @param {HTMLElement} input of the same table row
*
* @returns {Number}
*
* @private
*/
findCombinationId(input) {
return $(input)
.closest('tr')
.find(this.combinationIdInputsSelector)
.val();
}
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
import ProductEventMap from '@pages/product/product-event-map';
import ConfirmModal from '@components/modal';
const {$} = window;
export default class CustomizationsManager {
constructor() {
this.$customizationsContainer = $(ProductMap.customizations.customizationsContainer);
this.$customizationFieldsList = $(ProductMap.customizations.customizationFieldsList);
this.eventEmitter = window.prestashop.instance.eventEmitter;
this.prototypeTemplate = this.$customizationFieldsList.data('prototype');
this.prototypeName = this.$customizationFieldsList.data('prototypeName');
this.init();
}
init() {
this.$customizationsContainer.on('click', ProductMap.customizations.addCustomizationBtn, () => {
this.addCustomizationField();
});
this.$customizationsContainer.on('click', ProductMap.customizations.removeCustomizationBtn, (e) => {
this.removeCustomizationField(e);
});
}
addCustomizationField() {
const index = this.getIndex();
const newItem = this.prototypeTemplate.replace(new RegExp(this.prototypeName, 'g'), this.getIndex());
this.$customizationFieldsList.append(newItem);
window.prestaShopUiKit.initToolTips();
const {translatableInput} = window.prestashop.instance;
translatableInput.refreshFormInputs(this.$customizationsContainer.closest('form'));
this.eventEmitter.emit(ProductEventMap.customizations.rowAdded, {index});
}
removeCustomizationField(event) {
const $deleteButton = $(event.currentTarget);
const modal = new ConfirmModal(
{
id: 'modal-confirm-delete-customization',
confirmTitle: $deleteButton.data('modal-title'),
confirmMessage: $deleteButton.data('modal-message'),
confirmButtonLabel: $deleteButton.data('modal-apply'),
closeButtonLabel: $deleteButton.data('modal-cancel'),
confirmButtonClass: 'btn-danger',
closable: true,
},
() => {
$deleteButton
.closest(ProductMap.customizations.customizationFieldRow)
.remove();
this.eventEmitter.emit(ProductEventMap.customizations.rowRemoved);
},
);
modal.show();
}
getIndex() {
return this.$customizationFieldsList.find(ProductMap.customizations.customizationFieldRow).length;
}
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
import Router from '@components/router';
import ConfirmModal from '@components/modal';
import ProductEventMap from '@pages/product/product-event-map';
const {$} = window;
export default class FeatureValuesManager {
/**
* @param eventEmitter {EventEmitter}
*/
constructor(eventEmitter) {
this.router = new Router();
this.eventEmitter = eventEmitter;
this.$collectionContainer = $(ProductMap.featureValues.collectionContainer);
this.$collectionRowsContainer = $(ProductMap.featureValues.collectionRowsContainer);
this.watchFeatureSelectors();
this.watchCustomInputs();
this.watchDeleteButtons();
this.watchAddButton();
}
watchAddButton() {
$(ProductMap.featureValues.addFeatureValue).on('click', () => {
const prototype = this.$collectionContainer.data('prototype');
const prototypeName = this.$collectionContainer.data('prototypeName');
const newIndex = $(ProductMap.featureValues.collectionRow, this.$collectionContainer).length;
const $newRow = $(prototype.replace(new RegExp(prototypeName, 'g'), newIndex));
this.$collectionRowsContainer.append($newRow);
$('select[data-toggle="select2"]', $newRow).select2();
});
}
watchDeleteButtons() {
$(this.$collectionContainer).on('click', ProductMap.featureValues.deleteFeatureValue, (event) => {
const $deleteButton = $(event.currentTarget);
const $collectionRow = $deleteButton.closest(ProductMap.featureValues.collectionRow);
const modal = new ConfirmModal(
{
id: 'modal-confirm-delete-feature-value',
confirmTitle: $deleteButton.data('modal-title'),
confirmMessage: $deleteButton.data('modal-message'),
confirmButtonLabel: $deleteButton.data('modal-apply'),
closeButtonLabel: $deleteButton.data('modal-cancel'),
confirmButtonClass: 'btn-danger',
closable: true,
},
() => {
$collectionRow.remove();
this.eventEmitter.emit(ProductEventMap.updateSubmitButtonState);
},
);
modal.show();
});
}
watchCustomInputs() {
$(this.$collectionContainer).on('keyup change', ProductMap.featureValues.customValueInput, (event) => {
const $changedInput = $(event.target);
const $collectionRow = $changedInput.closest(ProductMap.featureValues.collectionRow);
// Check if any custom inputs has a value
let hasCustomValue = false;
$(ProductMap.featureValues.customValueInput, $collectionRow).each((index, input) => {
const $input = $(input);
if ($input.val() !== '') {
hasCustomValue = true;
}
});
const $featureValueSelector = $(ProductMap.featureValues.featureValueSelect, $collectionRow).first();
$featureValueSelector.prop('disabled', hasCustomValue);
if (hasCustomValue) {
$featureValueSelector.val('');
}
});
}
watchFeatureSelectors() {
$(this.$collectionContainer).on('change', ProductMap.featureValues.featureSelect, (event) => {
const $selector = $(event.target);
const idFeature = $selector.val();
const $collectionRow = $selector.closest(ProductMap.featureValues.collectionRow);
const $featureValueSelector = $(ProductMap.featureValues.featureValueSelect, $collectionRow).first();
const $customValueInputs = $(ProductMap.featureValues.customValueInput, $collectionRow);
const $customFeatureIdInput = $(ProductMap.featureValues.customFeatureIdInput, $collectionRow);
// Reset values
$customValueInputs.val('');
$featureValueSelector.val('');
$customFeatureIdInput.val('');
$.get(this.router.generate('admin_feature_get_feature_values', {idFeature}))
.then((featureValuesData) => {
$featureValueSelector.prop('disabled', featureValuesData.length === 0);
$featureValueSelector.empty();
$.each(featureValuesData, (index, featureValue) => {
// The placeholder shouldn't be posted.
if (featureValue.id === '0') {
featureValue.id = '';
}
$featureValueSelector
.append($('<option></option>')
.attr('value', featureValue.id)
.text(featureValue.value));
});
});
});
}
}

View File

@@ -0,0 +1,107 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import NavbarHandler from '@components/navbar-handler';
import ProductMap from '@pages/product/product-map';
import CategoriesManager from '@pages/product/components/categories';
import CombinationsManager from '@pages/product/edit/combinations-manager';
import CustomizationsManager from '@pages/product/edit/customizations-manager';
import FeatureValuesManager from '@pages/product/edit/feature-values-manager';
import ProductFooterManager from '@pages/product/edit/product-footer-manager';
import ProductFormModel from '@pages/product/edit/product-form-model';
import ProductModulesManager from '@pages/product/edit/product-modules-manager';
import ProductPartialUpdater from '@pages/product/edit/product-partial-updater';
import ProductSEOManager from '@pages/product/edit/product-seo-manager';
import ProductSuppliersManager from '@pages/product/edit/product-suppliers-manager';
import ProductTypeManager from '@pages/product/edit/product-type-manager';
import VirtualProductManager from '@pages/product/edit/virtual-product-manager';
import initDropzone from '@pages/product/components/dropzone';
import initTabs from '@pages/product/components/nav-tabs';
const {$} = window;
$(() => {
window.prestashop.component.initComponents([
'TranslatableField',
'TinyMCEEditor',
'TranslatableInput',
'EventEmitter',
'TextWithLengthCounter',
]);
const $productForm = $(ProductMap.productForm);
const productId = parseInt($productForm.data('productId'), 10);
const productType = $productForm.data('productType');
// Responsive navigation tabs
initTabs();
const {eventEmitter} = window.prestashop.instance;
// Init product model along with input watching and syncing
const productFormModel = new ProductFormModel($productForm, eventEmitter);
if (productId && productType === ProductMap.productType.COMBINATIONS) {
// Combinations manager must be initialized BEFORE nav handler, or it won't trigger the pagination if the tab is
// selected on load, it is only initialized when productId exists though (edition mode)
new CombinationsManager(productId);
}
new NavbarHandler(ProductMap.navigationBar);
new ProductSEOManager();
// Product type has strong impact on the page rendering so when it is modified it must be submitted right away
new ProductTypeManager($(ProductMap.productTypeSelector), $productForm);
new CategoriesManager(eventEmitter);
new ProductFooterManager();
new ProductModulesManager();
const $productFormSubmitButton = $(ProductMap.productFormSubmitButton);
new ProductPartialUpdater(
eventEmitter,
$productForm,
$productFormSubmitButton,
).watch();
// Form has no productId data means that we are in creation mode
if (!productId) {
return;
}
// From here we init component specific to edition
initDropzone(ProductMap.dropzoneImagesContainer);
new FeatureValuesManager(eventEmitter);
new CustomizationsManager();
if (productType !== ProductMap.productType.COMBINATIONS) {
new ProductSuppliersManager(ProductMap.suppliers.productSuppliers, true);
}
if (productType === ProductMap.productType.VIRTUAL) {
new VirtualProductManager(productFormModel);
}
});

View File

@@ -0,0 +1,54 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ConfirmModal from '@components/modal';
import ProductMap from '@pages/product/product-map';
export default class ProductFooterManager {
constructor() {
this.$deleteProductButton = $(ProductMap.footer.deleteProductButton);
this.$deleteProductButton.click(() => this.deleteProduct());
}
deleteProduct() {
const modal = new ConfirmModal(
{
id: 'modal-confirm-delete-product',
confirmTitle: this.$deleteProductButton.data('modal-title'),
confirmMessage: this.$deleteProductButton.data('modal-message'),
confirmButtonLabel: this.$deleteProductButton.data('modal-apply'),
closeButtonLabel: this.$deleteProductButton.data('modal-cancel'),
confirmButtonClass: 'btn-danger',
closable: true,
},
() => {
const removeUrl = this.$deleteProductButton.data('removeUrl');
$(ProductMap.productFormSubmitButton).prop('disabled', true);
window.location = removeUrl;
},
);
modal.show();
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
export default {
'product.stock.quantity': [
'product[stock][quantities][quantity]',
'product[shortcuts][stock][quantity]',
],
'product.price.priceTaxExcluded': [
'product[pricing][retail_price][price_tax_excluded]',
'product[shortcuts][retail_price][price_tax_excluded]',
],
'product.price.priceTaxIncluded': [
'product[pricing][retail_price][price_tax_included]',
'product[shortcuts][retail_price][price_tax_included]',
],
'product.price.taxRulesGroupId': [
'product[pricing][tax_rules_group_id]',
'product[shortcuts][retail_price][tax_rules_group_id]',
],
'product.stock.hasVirtualProductFile': 'product[stock][virtual_product_file][has_file]',
};

View File

@@ -0,0 +1,135 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import BigNumber from 'bignumber.js';
import ObjectFormMapper from '@components/form/form-object-mapper';
import ProductFormMapping from '@pages/product/edit/product-form-mapping';
import ProductEventMap from '@pages/product/product-event-map';
export default class ProductFormModel {
constructor($form, eventEmitter) {
this.eventEmitter = eventEmitter;
// Init form mapper
this.mapper = new ObjectFormMapper(
$form,
ProductFormMapping,
eventEmitter,
{
modelUpdated: ProductEventMap.productModelUpdated,
updateModel: ProductEventMap.updatedProductModel,
modelFieldUpdated: ProductEventMap.updatedProductField,
},
);
// For now we get precision only in the component, but maybe it would deserve a more global configuration
// BigNumber.set({DECIMAL_PLACES: someConfig}) But where can we define/inject this global config?
const $priceTaxExcludedInput = this.mapper.getInputsFor('product.price.priceTaxExcluded');
this.precision = $priceTaxExcludedInput.data('displayPricePrecision');
// Listens to event for product modification (registered after the model is constructed, because events are
// triggered during the initial parsing but don't need them at first).
this.eventEmitter.on(ProductEventMap.updatedProductField, (event) => this.productFieldUpdated(event));
return {
getProduct: () => this.getProduct(),
watch: (productModelKey, callback) => this.watchProductModel(productModelKey, callback),
};
}
/**
* @returns {Object}
*/
getProduct() {
return this.mapper.getModel().product;
}
/**
* @param {string} productModelKey
* @param {function} callback
*/
watchProductModel(productModelKey, callback) {
this.mapper.watch(`product.${productModelKey}`, callback);
}
/**
* Handles modifications that have happened in the product
*
* @param {Object} event
* @private
*/
productFieldUpdated(event) {
this.updateProductPrices(event);
}
/**
* Specific handler for modifications related to the product price
*
* @param {Object} event
* @private
*/
updateProductPrices(event) {
const pricesFields = [
'product.price.priceTaxIncluded',
'product.price.priceTaxExcluded',
'product.price.taxRulesGroupId',
];
if (!pricesFields.includes(event.modelKey)) {
return;
}
const $taxRulesGroupIdInput = this.mapper.getInputsFor('product.price.taxRulesGroupId');
const $selectedTaxOption = $(':selected', $taxRulesGroupIdInput);
let taxRate;
try {
taxRate = new BigNumber($selectedTaxOption.data('taxRate'));
} catch (error) {
taxRate = BigNumber.NaN;
}
if (taxRate.isNaN()) {
taxRate = new BigNumber(0);
}
const taxRatio = taxRate.dividedBy(100).plus(1);
switch (event.modelKey) {
case 'product.price.priceTaxIncluded': {
const priceTaxIncluded = new BigNumber(this.getProduct().price.priceTaxIncluded);
this.mapper.set(
'product.price.priceTaxExcluded',
priceTaxIncluded.dividedBy(taxRatio).toFixed(this.precision),
);
break;
}
default: {
const priceTaxExcluded = new BigNumber(this.getProduct().price.priceTaxExcluded);
this.mapper.set('product.price.priceTaxIncluded', priceTaxExcluded.times(taxRatio).toFixed(this.precision));
break;
}
}
}
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
export default class ProductModulesManager {
constructor() {
this.$previewContainer = $(ProductMap.modules.previewContainer);
this.$selectorContainer = $(ProductMap.modules.selectorContainer);
this.$contentContainer = $(ProductMap.modules.contentContainer);
this.$moduleSelector = $(ProductMap.modules.moduleSelector);
this.$selectorPreviews = $(ProductMap.modules.selectorPreviews);
this.$moduleContents = $(ProductMap.modules.moduleContents);
this.init();
return {};
}
/**
* @private
*/
init() {
this.$previewContainer.removeClass('d-none');
this.$selectorContainer.addClass('d-none');
this.$contentContainer.addClass('d-none');
this.$selectorPreviews.addClass('d-none');
this.$moduleContents.addClass('d-none');
this.$previewContainer.on('click', ProductMap.modules.previewButton, (event) => {
const $button = $(event.target);
this.selectModule($button.data('target'));
});
this.$moduleSelector.on('change', () => this.showSelectedModule());
}
/**
* @param {string} moduleId
*
* @private
*/
selectModule(moduleId) {
this.$previewContainer.addClass('d-none');
this.$selectorContainer.removeClass('d-none');
this.$contentContainer.removeClass('d-none');
this.$moduleSelector.val(moduleId);
// trigger change because this is a select2 component, and module is switched when change even triggers
this.$moduleSelector.trigger('change');
}
/**
* @private
*/
showSelectedModule() {
this.$selectorPreviews.addClass('d-none');
this.$moduleContents.addClass('d-none');
const moduleId = this.$moduleSelector.val();
$(ProductMap.modules.selectorPreview(moduleId)).removeClass('d-none');
$(ProductMap.modules.moduleContent(moduleId)).removeClass('d-none');
}
}

View File

@@ -0,0 +1,262 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import _ from 'lodash';
import ProductEventMap from '@pages/product/product-event-map';
const {$} = window;
/**
* When product is edited we want to send only partial updates
* so this class compares the initial data from the form computes
* the diff when form is submitted And dynamically build another
* form to submit only updated data (along with required fields
* token and such).
*
* It also disabled the submit button as long as no data has been
* modified by the user.
*/
export default class ProductPartialUpdater {
/**
* @param eventEmitter {EventEmitter}
* @param $productForm {jQuery}
* @param $productFormSubmitButton {jQuery}
*/
constructor(eventEmitter, $productForm, $productFormSubmitButton) {
this.eventEmitter = eventEmitter;
this.$productForm = $productForm;
this.$productFormSubmitButton = $productFormSubmitButton;
}
/**
* This the public method you need to use to start this component
* ex: new ProductPartialUpdater($productForm, $productFormSubmitButton).watch();
*/
watch() {
// Avoid submitting form when pressing Enter
this.$productForm.keypress((e) => e.which !== 13);
this.$productFormSubmitButton.prop('disabled', true);
this.initialData = this.getFormDataAsObject();
this.$productForm.submit(() => this.updatePartialForm());
// 'dp.change' event allows tracking datepicker input changes
this.$productForm.on('keyup change dp.change', ':input', () => this.updateSubmitButtonState());
this.eventEmitter.on(ProductEventMap.updateSubmitButtonState, () => this.updateSubmitButtonState());
this.watchCustomizations();
this.initFormattedTextarea();
}
/**
* Watch events specifically related to customizations subform
*/
watchCustomizations() {
this.eventEmitter.on(ProductEventMap.customizations.rowAdded, () => this.updateSubmitButtonState());
this.eventEmitter.on(ProductEventMap.customizations.rowRemoved, () => this.updateSubmitButtonState());
}
/**
* Rich editors apply a layer over initial textarea fields therefore they need to be watched differently.
*/
initFormattedTextarea() {
this.eventEmitter.on('tinymceEditorSetup', (event) => {
event.editor.on('change', () => this.updateSubmitButtonState());
});
}
/**
* This methods handles the form submit
*
* @returns {boolean}
*
* @private
*/
updatePartialForm() {
const updatedData = this.getUpdatedFormData();
if (updatedData !== null) {
let formMethod = this.$productForm.prop('method');
if (Object.prototype.hasOwnProperty.call(updatedData, '_method')) {
// eslint-disable-next-line dot-notation
formMethod = updatedData['_method'];
}
if (formMethod !== 'PATCH') {
// Returning true will continue submitting form as usual
return true;
}
// On patch method we extract changed values and submit only them
this.submitUpdatedData(updatedData);
} else {
// @todo: This is temporary we should probably use a nice modal instead, that said since the submit button is
// disabled when no data has been modified it should never happen
alert('no fields updated');
}
return false;
}
/**
* Dynamically build a form with provided updated data and submit this "shadow" form
*
* @param updatedData {Object} Contains an object with all form fields to update indexed by query parameters name
*/
submitUpdatedData(updatedData) {
this.$productFormSubmitButton.prop('disabled', true);
const $updatedForm = this.createShadowForm(updatedData);
$updatedForm.appendTo('body');
$updatedForm.submit();
}
/**
* @param updatedData
*
* @returns {Object} Form clone (Jquery object)
*/
createShadowForm(updatedData) {
const $updatedForm = this.$productForm.clone();
$updatedForm.empty();
$updatedForm.prop('class', '');
Object.keys(updatedData).forEach((fieldName) => {
if (Array.isArray(updatedData[fieldName])) {
updatedData[fieldName].forEach((value) => {
this.appendInputToForm($updatedForm, fieldName, value);
});
} else {
this.appendInputToForm($updatedForm, fieldName, updatedData[fieldName]);
}
});
return $updatedForm;
}
/**
* Adapt the submit button state, as long as no data has been updated the button is disabled
*/
updateSubmitButtonState() {
const updatedData = this.getUpdatedFormData();
this.$productFormSubmitButton.prop('disabled', updatedData === null);
}
/**
* Returns the updated data, only fields which are different from the initial page load
* are returned (token and method are added since they are required for a valid request).
*
* If no fields have been modified this method returns null.
*
* @returns {{}|null}
*/
getUpdatedFormData() {
const currentData = this.getFormDataAsObject();
// Loop through current form data and remove the one that did not change
// This way only updated AND new values remain
Object.keys(this.initialData).forEach((fieldName) => {
const fieldValue = this.initialData[fieldName];
// Field is absent in the new data (it was not in the initial) we force it to empty string (not null
// or it will be ignored)
if (!Object.prototype.hasOwnProperty.call(currentData, fieldName)) {
currentData[fieldName] = '';
} else if (_.isEqual(currentData[fieldName], fieldValue)) {
delete currentData[fieldName];
}
});
// No need to loop through the field contained in currentData and not in the initial
// they are new values so are, by fact, updated values
if (Object.keys(currentData).length === 0) {
return null;
}
// Some parameters are always needed
const permanentParameters = [
// We need the form CSRF token
'product[_token]',
// If method is not POST or GET a hidden type input is used to simulate it (like PATCH)
'_method',
];
permanentParameters.forEach((permanentParameter) => {
if (Object.prototype.hasOwnProperty.call(this.initialData, permanentParameter)) {
currentData[permanentParameter] = this.initialData[permanentParameter];
}
});
return currentData;
}
/**
* Returns the serialized form data as an Object indexed by field name
*
* @returns {{}}
*/
getFormDataAsObject() {
const formArray = this.$productForm.serializeArray();
const serializedForm = {};
formArray.forEach((formField) => {
let {value} = formField;
// Input names can be identical when expressing array of values for same field (like multiselect checkboxes)
// so we need to put these input values into single array indexed by that field name
if (formField.name.endsWith('[]')) {
let multiField = [];
if (Object.prototype.hasOwnProperty.call(serializedForm, formField.name)) {
multiField = serializedForm[formField.name];
}
multiField.push(formField.value);
value = multiField;
}
serializedForm[formField.name] = value;
});
// File inputs must be handled manually
$('input[type="file"]', this.$productForm).each((inputIndex, fileInput) => {
$.each($(fileInput)[0].files, (fileIndex, file) => {
serializedForm[fileInput.name] = file;
});
});
return serializedForm;
}
/**
* @param $form
* @param name
* @param value
*
* @private
*/
appendInputToForm($form, name, value) {
$('<input>').attr({
name,
type: 'hidden',
value,
}).appendTo($form);
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Serp from '@app/utils/serp';
import RedirectOptionManager from '@pages/product/edit/redirect-option-manager';
import ProductMap from '@pages/product/product-map';
const {$} = window;
export default class ProductSEOManager {
constructor() {
this.$previewButton = $(ProductMap.footer.previewUrlButton);
this.init();
return {};
}
/**
* @private
*/
init() {
// Init the product/category search field for redirection target
const $redirectTypeInput = $(ProductMap.seo.redirectOption.typeInput);
const $redirectTargetInput = $(ProductMap.seo.redirectOption.targetInput);
new RedirectOptionManager($redirectTypeInput, $redirectTargetInput);
// Init Serp component to preview Search engine display
const {translatableInput, translatableField} = window.prestashop.instance;
let previewUrl = this.$previewButton.data('seoUrl');
if (!previewUrl) {
previewUrl = '';
}
new Serp(
{
container: ProductMap.seo.container,
defaultTitle: ProductMap.seo.defaultTitle,
watchedTitle: ProductMap.seo.watchedTitle,
defaultDescription: ProductMap.seo.defaultDescription,
watchedDescription: ProductMap.seo.watchedDescription,
watchedMetaUrl: ProductMap.seo.watchedMetaUrl,
multiLanguageInput: `${translatableInput.localeInputSelector}:not(.d-none)`,
multiLanguageField: `${translatableField.translationFieldSelector}.active`,
},
previewUrl,
);
}
}

View File

@@ -0,0 +1,265 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import SuppliersMap from '@pages/product/suppliers-map';
const {$} = window;
export default class ProductSuppliersManager {
/**
*
* @param {string} suppliersFormId
* @param {boolean} forceUpdateDefault
*
* @returns {{}}
*/
constructor(suppliersFormId, forceUpdateDefault) {
this.forceUpdateDefault = forceUpdateDefault;
this.suppliersMap = SuppliersMap(suppliersFormId);
this.$productSuppliersCollection = $(this.suppliersMap.productSuppliersCollection);
this.$supplierIdsGroup = $(this.suppliersMap.supplierIdsInput).closest('.form-group');
this.$defaultSupplierGroup = $(this.suppliersMap.defaultSupplierInput).closest('.form-group');
this.$productsTable = $(this.suppliersMap.productSuppliersTable);
this.$productsTableBody = $(this.suppliersMap.productsSuppliersTableBody);
this.suppliers = [];
this.prototypeTemplate = this.$productSuppliersCollection.data('prototype');
this.prototypeName = this.$productSuppliersCollection.data('prototypeName');
this.defaultDataForSupplier = this.getDefaultDataForSupplier();
this.init();
return {};
}
init() {
this.memorizeCurrentSuppliers();
this.toggleTableVisibility();
this.refreshDefaultSupplierBlock();
this.$initialDefault = this.$defaultSupplierGroup.find('input:checked').first();
if (this.$initialDefault.length) {
this.$initialDefault
.closest(this.suppliersMap.checkboxContainer)
.addClass(this.suppliersMap.defaultSupplierClass);
}
this.$productsTable.on('change', 'input', () => {
this.memorizeCurrentSuppliers();
});
this.$supplierIdsGroup.on('change', 'input', (e) => {
const input = e.currentTarget;
if (input.checked) {
this.addSupplier({
supplierId: input.value,
supplierName: input.dataset.label,
});
} else {
this.removeSupplier(input.value);
}
this.renderSuppliers();
this.toggleTableVisibility();
this.refreshDefaultSupplierBlock();
});
}
toggleTableVisibility() {
if (this.getSelectedSuppliers().length === 0) {
this.hideTable();
return;
}
this.showTable();
}
/**
* @param {Object} supplier
*/
addSupplier(supplier) {
if (typeof this.suppliers[supplier.supplierId] === 'undefined') {
const newSupplier = Object.create(this.defaultDataForSupplier);
newSupplier.supplierId = supplier.supplierId;
newSupplier.supplierName = supplier.supplierName;
this.suppliers[supplier.supplierId] = newSupplier;
} else {
this.suppliers[supplier.supplierId].removed = false;
}
}
/**
* @param {int} supplierId
*/
removeSupplier(supplierId) {
this.suppliers[supplierId].removed = true;
}
renderSuppliers() {
this.$productsTableBody.empty();
// Loop through select suppliers so that we use the same order as in the select list
this.getSelectedSuppliers().forEach((selectedSupplier) => {
const supplier = this.suppliers[selectedSupplier.supplierId];
if (supplier.removed) {
return;
}
const productSupplierRow = this.prototypeTemplate.replace(
new RegExp(this.prototypeName, 'g'),
supplier.supplierId,
);
this.$productsTableBody.append(productSupplierRow);
// Fill inputs
const rowMap = this.suppliersMap.productSupplierRow;
$(rowMap.supplierIdInput(supplier.supplierId)).val(supplier.supplierId);
$(rowMap.supplierNamePreview(supplier.supplierId)).html(supplier.supplierName);
$(rowMap.supplierNameInput(supplier.supplierId)).val(supplier.supplierName);
$(rowMap.productSupplierIdInput(supplier.supplierId)).val(supplier.productSupplierId);
$(rowMap.referenceInput(supplier.supplierId)).val(supplier.reference);
$(rowMap.priceInput(supplier.supplierId)).val(supplier.price);
$(rowMap.currencyIdInput(supplier.supplierId)).val(supplier.currencyId);
});
}
getSelectedSuppliers() {
const selectedSuppliers = [];
this.$supplierIdsGroup.find('input:checked').each((index, input) => {
selectedSuppliers.push({
supplierName: input.dataset.label,
supplierId: input.value,
});
});
return selectedSuppliers;
}
refreshDefaultSupplierBlock() {
const suppliers = this.getSelectedSuppliers();
if (suppliers.length === 0) {
if (this.forceUpdateDefault) {
this.$defaultSupplierGroup.find('input').prop('checked', false);
}
this.hideDefaultSuppliers();
return;
}
this.showDefaultSuppliers();
const selectedSupplierIds = suppliers.map((supplier) => supplier.supplierId);
this.$defaultSupplierGroup.find('input').each((key, input) => {
const isValid = selectedSupplierIds.includes(input.value);
if (this.forceUpdateDefault && !isValid) {
input.checked = false;
}
input.disabled = !isValid;
});
if (this.$defaultSupplierGroup.find('input:checked').length === 0 && this.forceUpdateDefault) {
this.checkFirstAvailableDefaultSupplier(selectedSupplierIds);
}
}
hideDefaultSuppliers() {
this.$defaultSupplierGroup.addClass('d-none');
}
showDefaultSuppliers() {
this.$defaultSupplierGroup.removeClass('d-none');
}
/**
* @param {int[]} selectedSupplierIds
*/
checkFirstAvailableDefaultSupplier(selectedSupplierIds) {
const firstSupplierId = selectedSupplierIds[0];
this.$defaultSupplierGroup.find(`input[value="${firstSupplierId}"]`).prop('checked', true);
}
showTable() {
this.$productsTable.removeClass('d-none');
}
hideTable() {
this.$productsTable.addClass('d-none');
}
/**
* Memorize suppliers to be able to re-render them later.
* Flag `removed` allows identifying whether supplier was removed from list or should be rendered
*/
memorizeCurrentSuppliers() {
this.getSelectedSuppliers().forEach((supplier) => {
this.suppliers[supplier.supplierId] = {
supplierId: supplier.supplierId,
productSupplierId: $(this.suppliersMap.productSupplierRow.productSupplierIdInput(supplier.supplierId)).val(),
supplierName: $(this.suppliersMap.productSupplierRow.supplierNameInput(supplier.supplierId)).val(),
reference: $(this.suppliersMap.productSupplierRow.referenceInput(supplier.supplierId)).val(),
price: $(this.suppliersMap.productSupplierRow.priceInput(supplier.supplierId)).val(),
currencyId: $(this.suppliersMap.productSupplierRow.currencyIdInput(supplier.supplierId)).val(),
removed: false,
};
});
}
/**
* Create a "shadow" prototype just to parse default values set inside the input fields,
* this allow to build an object with default values set in the FormType
*
* @returns {{reference, removed: boolean, price, currencyId, productSupplierId}}
*/
getDefaultDataForSupplier() {
const rowPrototype = new DOMParser().parseFromString(
this.prototypeTemplate,
'text/html',
);
return {
removed: false,
productSupplierId: this.getDataFromRow(this.suppliersMap.productSupplierRow.productSupplierIdInput, rowPrototype),
reference: this.getDataFromRow(this.suppliersMap.productSupplierRow.referenceInput, rowPrototype),
price: this.getDataFromRow(this.suppliersMap.productSupplierRow.priceInput, rowPrototype),
currencyId: this.getDataFromRow(this.suppliersMap.productSupplierRow.currencyIdInput, rowPrototype),
};
}
/**
* @param selectorGenerator {function}
* @param rowPrototype {Document}
*
* @returns {*}
*/
getDataFromRow(selectorGenerator, rowPrototype) {
return rowPrototype.querySelector(selectorGenerator(this.prototypeName)).value;
}
}

View File

@@ -0,0 +1,100 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ConfirmModal from '@components/modal';
import ProductMap from '@pages/product/product-map';
/**
* This component watches for modification of the product type, when it happens it displays a modal warning
* for the user with a warning about what is going to be deleted if he validates this change. If modification
* is confirmed the form is submitted right away to validate the change and update the page.
*/
export default class ProductTypeManager {
/**
* @param {jQuery} $typeSelector Select element to choose the product type
* @param {jQuery} $productForm Product form that needs to be submitted
*/
constructor($typeSelector, $productForm) {
this.$typeSelector = $typeSelector;
this.$productForm = $productForm;
this.productId = parseInt($productForm.data('productId'), 10);
this.initialType = $typeSelector.val();
this.$typeSelector.on('change', (event) => this.confirmTypeSubmit(event));
return {};
}
/**
* @private
*/
confirmTypeSubmit() {
let confirmMessage = this.$typeSelector.data('confirm-message');
let confirmWarning = '';
// If no productId we are in creation page so no need for extra warning
if (this.productId) {
switch (this.initialType) {
case ProductMap.productType.COMBINATIONS:
confirmWarning = this.$typeSelector.data('combinations-warning');
break;
case ProductMap.productType.PACK:
confirmWarning = this.$typeSelector.data('pack-warning');
break;
case ProductMap.productType.VIRTUAL:
confirmWarning = this.$typeSelector.data('virtual-warning');
break;
case ProductMap.productType.STANDARD:
default:
confirmWarning = '';
break;
}
}
if (confirmWarning) {
confirmWarning = `<div class="alert alert-warning">${confirmWarning}</div>`;
}
confirmMessage = `<div class="alert alert-info">${confirmMessage}</div>`;
const modal = new ConfirmModal(
{
id: 'modal-confirm-product-type',
confirmTitle: this.$typeSelector.data('modal-title'),
confirmMessage: `${confirmMessage} ${confirmWarning}`,
confirmButtonLabel: this.$typeSelector.data('modal-apply'),
closeButtonLabel: this.$typeSelector.data('modal-cancel'),
closable: false,
},
() => {
$(ProductMap.productFormSubmitButton).prop('disabled', true);
this.$productForm.submit();
},
() => {
this.$typeSelector.val(this.initialType);
},
);
modal.show();
}
}

View File

@@ -0,0 +1,105 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import EntitySearchInput from '@components/entity-search-input';
const {$} = window;
/**
* This component is used in product page to selected where the redirection points to when the
* product is out of stock. It is composed on two inputs:
* - a selection of the redirection type
* - a rich component to select a product or a category
*
* When the type is changed the component automatically updates the labels, remote search urls
* and values of the target.
*/
export default class RedirectOptionManager {
constructor($redirectTypeInput, $redirectTargetInput) {
this.$redirectTypeInput = $redirectTypeInput;
this.$redirectTargetInput = $redirectTargetInput;
this.$redirectTargetRow = this.$redirectTargetInput.closest('.form-group');
this.$redirectTargetLabel = $('.form-control-label', this.$redirectTargetRow).first();
this.$redirectTargetHint = $('.typeahead-hint', this.$redirectTargetRow);
this.buildAutoCompleteSearchInput();
this.watchRedirectType();
}
/**
* Watch the selected redirection type and adapt the inputs accordingly.
*/
watchRedirectType() {
this.lastSelectedType = this.$redirectTypeInput.val();
this.$redirectTypeInput.change(() => {
const redirectType = this.$redirectTypeInput.val();
switch (redirectType) {
case '301-category':
case '302-category':
this.entitySearchInput.setRemoteUrl(this.$redirectTargetInput.data('categorySearchUrl'));
this.$redirectTargetInput.prop('placeholder', this.$redirectTargetInput.data('categoryPlaceholder'));
this.$redirectTargetLabel.html(this.$redirectTargetInput.data('categoryLabel'));
// If previous type was not a category we reset the selected value
if (this.lastSelectedType !== '301-category' && this.lastSelectedType !== '302-category') {
this.entitySearchInput.setValue(null);
}
this.$redirectTargetHint.html(this.$redirectTargetInput.data('categoryHelp'));
this.showTarget();
break;
case '301-product':
case '302-product':
this.entitySearchInput.setRemoteUrl(this.$redirectTargetInput.data('productSearchUrl'));
this.$redirectTargetInput.prop('placeholder', this.$redirectTargetInput.data('productPlaceholder'));
this.$redirectTargetLabel.html(this.$redirectTargetInput.data('productLabel'));
// If previous type was not a category we reset the selected value
if (this.lastSelectedType !== '301-product' && this.lastSelectedType !== '302-product') {
this.entitySearchInput.setValue(null);
}
this.$redirectTargetHint.html(this.$redirectTargetInput.data('productHelp'));
this.showTarget();
break;
case '404':
default:
this.entitySearchInput.setValue(null);
this.hideTarget();
break;
}
this.lastSelectedType = this.$redirectTypeInput.val();
});
}
buildAutoCompleteSearchInput() {
this.entitySearchInput = new EntitySearchInput(this.$redirectTargetInput);
}
showTarget() {
this.$redirectTargetRow.removeClass('d-none');
}
hideTarget() {
this.$redirectTargetRow.addClass('d-none');
}
}

View File

@@ -0,0 +1,75 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import ProductMap from '@pages/product/product-map';
const {$} = window;
export default class VirtualProductManager {
constructor(productFormModel) {
this.productFormModel = productFormModel;
this.$virtualProductContainer = $(ProductMap.virtualProduct.container);
this.$fileContentContainer = $(ProductMap.virtualProduct.fileContentContainer);
this.init();
return {};
}
/**
* @private
*/
init() {
this.productFormModel.watch('stock.hasVirtualProductFile', () => this.toggleContentVisibility());
this.toggleContentVisibility();
}
toggleContentVisibility() {
const hasVirtualFile = Number(this.productFormModel.getProduct().stock.hasVirtualProductFile) === 1;
const hasErrors = this.$virtualProductContainer
.find(ProductMap.invalidField)
.length !== 0;
if (hasVirtualFile || hasErrors) {
this.showContent();
} else {
this.hideContent();
}
}
/**
* @private
*/
hideContent() {
this.$fileContentContainer.addClass('d-none');
}
/**
* @private
*/
showContent() {
this.$fileContentContainer.removeClass('d-none');
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
export default {
methods: {
/**
* The selected attribute is provided as a parameter instead od using this reference because it helps the
* observer work better whe this.selectedAttributeGroups is explicitly used as an argument.
*
* @param {Object} attribute
* @param {Object} attributeGroup
* @param {Object} attributeGroups
*
* @returns {boolean}
*/
isSelected(attribute, attributeGroup, attributeGroups) {
if (!Object.prototype.hasOwnProperty.call(attributeGroups, attributeGroup.id)) {
return false;
}
return attributeGroups[attributeGroup.id].attributes.includes(attribute);
},
},
};

View File

@@ -0,0 +1,54 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
export default {
productModelUpdated: 'productModelUpdated',
updatedProductModel: 'updatedProductModel',
updatedProductField: 'updatedProductField',
updateSubmitButtonState: 'updateSubmitButtonState',
customizations: {
rowRemoved: 'customizationRowRemoved',
rowAdded: 'customizationRowAdded',
},
dropzone: {
addedFile: 'addedfile',
error: 'error',
success: 'success',
languageSelected: 'languageSelected',
photoswipe: {
destroy: 'destroy',
closeGallery: 'closeGallery',
},
},
combinations: {
refreshPage: 'refreshPage',
refreshCombinationList: 'refreshCombinationList',
updateAttributeGroups: 'updateAttributeGroups',
combinationGeneratorReady: 'combinationGeneratorReady',
openCombinationsGenerator: 'openCombinationsGenerator',
clearFilters: 'clearFilters',
selectCombination: 'selectCombination',
},
};

View File

@@ -0,0 +1,197 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
const combinationListId = '#combination_list';
export default {
productForm: 'form[name=product]',
productTypeSelector: '#product_header_type',
productType: {
STANDARD: 'standard',
PACK: 'pack',
VIRTUAL: 'virtual',
COMBINATIONS: 'combinations',
},
invalidField: '.is-invalid',
productFormSubmitButton: '.product-form-save-button',
navigationBar: '#form-nav',
dropzoneImagesContainer: '.product-image-dropzone',
featureValues: {
collectionContainer: '.feature-values-collection',
collectionRowsContainer: '.feature-values-collection > .col-sm',
collectionRow: 'div.row.product-feature',
featureSelect: 'select.feature-selector',
featureValueSelect: 'select.feature-value-selector',
customValueInput: '.custom-values input',
customFeatureIdInput: 'input.custom-value-id',
deleteFeatureValue: 'button.delete-feature-value',
addFeatureValue: '.feature-value-add-button',
},
customizations: {
customizationsContainer: '.product-customizations-collection',
customizationFieldsList: '.product-customizations-collection ul',
addCustomizationBtn: '.add-customization-btn',
removeCustomizationBtn: '.remove-customization-btn',
customizationFieldRow: '.customization-field-row',
},
combinations: {
navigationTab: '#combinations-tab-nav',
externalCombinationTab: '#external-combination-tab',
preloader: '#combinations-preloader',
emptyState: '#combinations-empty-state',
combinationsPaginatedList: '#combinations-paginated-list',
combinationsContainer: `${combinationListId}`,
combinationsFiltersContainer: '#combinations_filters',
combinationsGeneratorContainer: '#product_combinations_generator',
combinationsTable: `${combinationListId} table`,
combinationsTableBody: `${combinationListId} table tbody`,
combinationIdInputsSelector: '.combination-id-input',
isDefaultInputsSelector: '.combination-is-default-input',
removeCombinationSelector: '.remove-combination-item',
combinationName: 'form .card-header span',
paginationContainer: '#combinations-pagination',
loadingSpinner: '#productCombinationsLoading',
quantityInputWrapper: '.combination-quantity',
impactOnPriceInputWrapper: '.combination-impact-on-price',
referenceInputWrapper: '.combination-reference',
sortableColumns: '.ps-sortable-column',
combinationItemForm: {
quantityKey: 'combination_item[quantity][value]',
impactOnPriceKey: 'combination_item[impact_on_price][value]',
referenceKey: 'combination_item[reference][value]',
tokenKey: 'combination_item[_token]',
},
editionForm: 'form[name="combination_form"]',
editionFormInputs:
// eslint-disable-next-line
'form[name="combination_form"] input, form[name="combination_form"] textarea, form[name="combination_form"] select',
editCombinationButtons: '.edit-combination-item',
tableRow: {
combinationImg: '.combination-image',
combinationCheckbox: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_is_selected`,
combinationIdInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_combination_id`,
combinationNameInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_name`,
referenceInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_reference_value`,
impactOnPriceInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_impact_on_price_value`,
finalPriceTeInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_final_price_te`,
quantityInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_quantity_value`,
isDefaultInput: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_is_default`,
editButton: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_edit`,
deleteButton: (rowIndex) => `${combinationListId}_combinations_${rowIndex}_delete`,
},
editModal: '#combination-edit-modal',
images: {
selectorContainer: '.combination-images-selector',
imageChoice: '.combination-image-choice',
checkboxContainer: '.form-check',
checkbox: 'input[type=checkbox]',
},
scrollBar: '.attributes-list-overflow',
searchInput: '#product-combinations-generate .attributes-search',
generateCombinationsButton: '.generate-combinations-button',
},
virtualProduct: {
container: '.virtual-product-file-container',
fileContentContainer: '.virtual-product-file-content',
},
dropzone: {
configuration: {
fileManager: '.openfilemanager',
},
photoswipe: {
element: '.pswp',
},
dzTemplate: '.dz-template',
dzPreview: '.dz-preview',
sortableContainer: '#product-images-dropzone',
sortableItems: 'div.dz-preview:not(.disabled)',
dropzoneContainer: '.dropzone-container',
checkbox: '.md-checkbox input',
shownTooltips: '.tooltip.show',
savedImageContainer: (imageId) => `.dz-preview[data-id="${imageId}"]`,
savedImage: (imageId) => `.dz-preview[data-id="${imageId}"] img`,
coveredPreview: '.dz-preview.is-cover',
windowFileManager: '.dropzone-window-filemanager',
},
suppliers: {
productSuppliers: '#product_options_suppliers',
combinationSuppliers: '#combination_form_suppliers',
},
seo: {
container: '#product_seo_serp',
defaultTitle: '.serp-default-title:input',
watchedTitle: '.serp-watched-title:input',
defaultDescription: '.serp-default-description',
watchedDescription: '.serp-watched-description',
watchedMetaUrl: '.serp-watched-url:input',
redirectOption: {
typeInput: '#product_seo_redirect_option_type',
targetInput: '#product_seo_redirect_option_target',
},
},
jsTabs: '.js-tabs',
jsArrow: '.js-arrow',
jsNavTabs: '.js-nav-tabs',
toggleTab: '[data-toggle="tab"]',
formContentTab: '#form_content > .form-contenttab',
leftArrow: '.left-arrow',
rightArrow: '.right-arrow',
footer: {
previewUrlButton: '.preview-url-button',
deleteProductButton: '.delete-product-button',
},
categories: {
categoriesContainer: '.js-categories-container',
categoryTree: '.js-categories-tree',
treeElement: '.category-tree-element',
treeElementInputs: '.category-tree-inputs',
checkboxInput: '[type=checkbox]',
checkedCheckboxInputs: '[type=checkbox]:checked',
checkboxName: (categoryId) => `product[categories][product_categories][${categoryId}][is_associated]`,
materialCheckbox: '.md-checkbox',
radioInput: '[type=radio]',
defaultRadioInput: '[type=radio]:checked',
radioName: (categoryId) => `product[categories][product_categories][${categoryId}][is_default]`,
tagsContainer: '#categories-tags-container',
searchInput: '#ps-select-product-category',
fieldset: '.tree-fieldset',
loader: '.categories-tree-loader',
childrenList: '.children-list',
everyItems: '.less, .more',
expandAllButton: '#categories-tree-expand',
reduceAllButton: '#categories-tree-reduce',
},
modules: {
previewContainer: '.module-render-container.all-modules',
previewButton: '.modules-list-button',
selectorContainer: '.module-selection',
moduleSelector: '.modules-list-select',
selectorPreviews: '.module-selection .module-render-container',
selectorPreview: (moduleId) => `.module-selection .module-render-container.${moduleId}`,
contentContainer: '.module-contents',
moduleContents: '.module-contents .module-render-container',
moduleContent: (moduleId) => `.module-contents .module-render-container.${moduleId}`,
},
};

View File

@@ -0,0 +1,40 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Router from '@components/router';
const router = new Router();
const {$} = window;
export const getProductAttributeGroups = async (productId) => $.get(router.generate('admin_products_attribute_groups', {
productId,
}));
export const getAllAttributeGroups = async () => $.get(router.generate('admin_all_attribute_groups'));
export default {
getProductAttributeGroups,
getAllAttributeGroups,
};

View File

@@ -0,0 +1,35 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Router from '@components/router';
const router = new Router();
const {$} = window;
export const getCategories = async () => $.get(router.generate('admin_categories_get_categories_tree'));
export default {
getCategories,
};

View File

@@ -0,0 +1,145 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Router from '@components/router';
const {$} = window;
export default class CombinationsService {
/**
* @param {Number} productId
*/
constructor(productId) {
this.productId = productId;
this.router = new Router();
this.filters = {};
this.orderBy = null;
this.orderWay = null;
}
/**
* @param {Number} offset
* @param {Number} limit
*
* @returns {Promise}
*/
fetch(offset, limit) {
const filterId = `product_combinations_${this.productId}`;
const requestParams = {};
// Required for route generation
requestParams.productId = this.productId;
// These are the query parameters
requestParams[filterId] = {};
requestParams[filterId].offset = offset;
requestParams[filterId].limit = limit;
requestParams[filterId].filters = this.filters;
if (this.orderBy !== null) {
requestParams[filterId].orderBy = this.orderBy;
}
if (this.orderWay !== null) {
requestParams[filterId].sortOrder = this.orderWay;
}
return $.get(this.router.generate('admin_products_combinations', requestParams));
}
/**
* @param {int} combinationId
*
* @returns {Promise}
*/
removeCombination(combinationId) {
return $.ajax({
url: this.router.generate('admin_products_combinations_remove_combination', {
combinationId,
}),
type: 'DELETE',
});
}
/**
* @param {Number} combinationId
* @param {Object} data
*
* @returns {Promise}
*/
updateListedCombination(combinationId, data) {
return $.ajax({
url: this.router.generate('admin_products_combinations_update_combination_from_listing', {
combinationId,
}),
data,
type: 'PATCH',
});
}
/**
* @param {Object} data Attributes indexed by attributeGroupId { 1: [23, 34], 3: [45, 52]}
*/
generateCombinations(data) {
return $.ajax({
url: this.router.generate('admin_products_combinations_generate', {
productId: this.productId,
}),
data,
method: 'POST',
});
}
/**
* @returns {Promise}
*/
getCombinationIds() {
return $.get(
this.router.generate('admin_products_combinations_ids', {
productId: this.productId,
}),
);
}
/**
* @param {string} orderBy
* @param {string} orderWay
*/
setOrderBy(orderBy, orderWay) {
this.orderBy = orderBy;
this.orderWay = orderWay.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
}
/**
* @returns {Object}
*/
getFilters() {
return this.filters;
}
/**
* @param {Object} filters
*/
setFilters(filters) {
this.filters = filters;
}
}

View File

@@ -0,0 +1,104 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
import Router from '@components/router';
const router = new Router();
const {$} = window;
export const getProductImages = async (productId) => {
const imagesUrl = router.generate('admin_products_v2_get_images', {
productId,
});
return $.get(imagesUrl);
};
export const saveImageInformations = async (selectedFile, token, formName) => {
const saveUrl = router.generate('admin_products_v2_update_image', {
productImageId: selectedFile.image_id,
});
const data = {};
data[`${formName}[is_cover]`] = selectedFile.is_cover ? 1 : 0;
Object.keys(selectedFile.legends).forEach((langId) => {
data[`${formName}[legend][${langId}]`] = selectedFile.legends[langId];
});
data[`${formName}[_token]`] = token;
return $.ajax(saveUrl, {
method: 'PATCH',
data,
});
};
export const replaceImage = async (selectedFile, newFile, formName, token) => {
const replaceUrl = router.generate('admin_products_v2_update_image', {
productImageId: selectedFile.image_id,
});
const formData = new FormData();
formData.append(`${formName}[file]`, newFile);
formData.append(`${formName}[_token]`, token);
formData.append('_method', 'PATCH');
return $.ajax(replaceUrl, {
method: 'POST',
data: formData,
processData: false,
contentType: false,
});
};
export const saveImagePosition = async (productImageId, newPosition, formName, token) => {
const sortUrl = router.generate('admin_products_v2_update_image', {
productImageId,
});
const data = {};
data[`${formName}[position]`] = newPosition;
data[`${formName}[_token]`] = token;
return $.ajax(sortUrl, {
method: 'PATCH',
data,
});
};
export const removeProductImage = async (productImageId) => {
const deleteUrl = router.generate('admin_products_v2_delete_image', {
productImageId,
});
return $.post(deleteUrl);
};
export default {
getProductImages,
saveImageInformations,
replaceImage,
saveImagePosition,
removeProductImage,
};

View File

@@ -0,0 +1,48 @@
/**
* Copyright since 2007 PrestaShop SA and Contributors
* PrestaShop is an International Registered Trademark & Property of PrestaShop SA
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0)
* that is bundled with this package in the file LICENSE.md.
* It is also available through the world-wide-web at this URL:
* https://opensource.org/licenses/OSL-3.0
* If you did not receive a copy of the license and are unable to
* obtain it through the world-wide-web, please send an email
* to license@prestashop.com so we can send you a copy immediately.
*
* DISCLAIMER
*
* Do not edit or add to this file if you wish to upgrade PrestaShop to newer
* versions in the future. If you wish to customize PrestaShop for your
* needs please refer to https://devdocs.prestashop.com/ for more information.
*
* @author PrestaShop SA and Contributors <contact@prestashop.com>
* @copyright Since 2007 PrestaShop SA and Contributors
* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
export default (suppliersFormId) => {
const productSuppliersId = `${suppliersFormId}_product_suppliers`;
const productSupplierInputId = (supplierIndex, inputName) => `${productSuppliersId}_${supplierIndex}_${inputName}`;
return {
productSuppliersCollection: `${productSuppliersId}`,
supplierIdsInput: `${suppliersFormId}_supplier_ids`,
defaultSupplierInput: `${suppliersFormId}_default_supplier_id`,
productSuppliersTable: `${productSuppliersId} table`,
productsSuppliersTableBody: `${productSuppliersId} table tbody`,
defaultSupplierClass: 'default-supplier',
productSupplierRow: {
supplierIdInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'supplier_id'),
supplierNameInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'supplier_name'),
productSupplierIdInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'product_supplier_id'),
referenceInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'reference'),
priceInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'price_tax_excluded'),
currencyIdInput: (supplierIndex) => productSupplierInputId(supplierIndex, 'currency_id'),
supplierNamePreview: (supplierIndex) => `#product_supplier_row_${supplierIndex} .supplier_name .preview`,
},
checkboxContainer: '.form-check',
};
};