update
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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'));
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user