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,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'));
},
);
}
});
}