Subida del módulo y tema de PrestaShop

This commit is contained in:
Kaloyan
2026-04-09 18:31:51 +02:00
parent 12c253296f
commit 16b3ff9424
39262 changed files with 7418797 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Responsible for connecting to addons marketplace.
* Makes an addons connect request to the server, displays error messages if it fails.
*/
export default class AddonsConnector {
addonsConnectFormSelector: string;
$loadingSpinner: JQuery;
constructor(
addonsConnectFormSelector: string,
loadingSpinnerSelector: string,
) {
this.addonsConnectFormSelector = addonsConnectFormSelector;
this.$loadingSpinner = $(loadingSpinnerSelector);
this.initEvents();
}
/**
* Initialize events related to connection to addons.
*
* @private
*/
private initEvents(): void {
$('body').on('submit', this.addonsConnectFormSelector, (event) => {
const $form = $(event.currentTarget);
event.preventDefault();
event.stopPropagation();
this.connect(<string>$form.attr('action'), $form.serialize());
});
}
/**
* Do a POST request to connect to addons.
*
* @param {String} addonsConnectUrl
* @param {Object} formData
*
* @private
*/
private connect(addonsConnectUrl: string, formData: string): void {
$.ajax({
method: 'POST',
url: addonsConnectUrl,
dataType: 'json',
data: formData,
beforeSend: () => {
this.$loadingSpinner.show();
$('button.btn[type="submit"]', this.addonsConnectFormSelector).hide();
},
}).then(
(response) => {
if (response.success === 1) {
window.location.reload();
} else {
$.growl.error({
message: response.message,
});
this.$loadingSpinner.hide();
$(
'button.btn[type="submit"]',
this.addonsConnectFormSelector,
).fadeIn();
}
},
() => {
$.growl.error({
message: $(this.addonsConnectFormSelector).data('error-message'),
});
this.$loadingSpinner.hide();
$('button.btn[type="submit"]', this.addonsConnectFormSelector).show();
},
);
}
}

View File

@@ -0,0 +1,174 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
// @ts-ignore-next-line
import Bloodhound from 'typeahead.js';
/**
* This component is an overlay of typeahead it allows to have a single config input (since
* typeahead weirdly uses two different configs). It also provides some default rendering
* functions which are, of course, overridable.
*/
type DisplayFunction = (item: any) => string;
export interface TypeaheadJQueryDataset extends Twitter.Typeahead.Dataset<any> {
display: string | DisplayFunction;
value: string;
limit: number;
dataLimit: number;
templates: any;
}
export interface TypeaheadJQueryOptions extends Twitter.Typeahead.Options {
minLength: number,
highlight: boolean,
hint: boolean,
onSelect: (
selectedItem: any,
event: JQueryEventObject,
searchInput: JQuery
) => boolean;
onClose: (event: JQueryEventObject, searchInput: JQuery) => void;
}
export type AutoCompleteSearchConfig = {
minLength: number;
highlight: boolean;
hint: boolean;
source: Bloodhound<Record<string, any>> | (
(query: string, syncResults: (result: any[]) => void, asyncResults?: (result: any[]) => void
) => void);
onSelect: (
selectedItem: any,
event: JQueryEventObject,
searchInput: JQuery
) => boolean;
onClose: (event: JQueryEventObject, searchInput: JQuery) => void;
suggestionLimit: number;
dataLimit: number;
display: string | DisplayFunction;
value: string;
templates: any;
}
export type InputAutoCompleteSearchConfig = Partial<AutoCompleteSearchConfig> & {
source: Bloodhound<Record<string, any>> | (
(query: string, syncResults: (result: any[]) => void, asyncResults?: (result: any[]) => void
) => void); // source is mandatory option
};
export default class AutoCompleteSearch {
private $searchInput: JQuery;
private searchInputId: string;
private config: AutoCompleteSearchConfig;
constructor($searchInput: JQuery, inputConfig: Partial<InputAutoCompleteSearchConfig>) {
this.$searchInput = $searchInput;
this.searchInputId = this.$searchInput.prop('id');
// Merging object works fine on one level, but on two it erases sub elements even if not present, so
// we handle templates separately, these are the default rendering functions which can be overridden
const defaultTemplates = {
// Be careful that your rendering function must return HTML node not pure text so always include the
// content in a div at least
suggestion: (item: Record<string, string>) => {
let displaySuggestion: Record<string, string> | string = item;
if (typeof this.config.display === 'function') {
displaySuggestion = this.config.display(item);
} else if (
Object.prototype.hasOwnProperty.call(
item,
<string> this.config.display,
)
) {
displaySuggestion = item[<string> this.config.display];
}
return `<div class="px-2">${displaySuggestion}</div>`;
},
pending(query: Record<string, string>) {
return `<div class="px-2">Searching for "${query.query}"</div>`;
},
notFound(query: Record<string, string>) {
return `<div class="px-2">No results found for "${query.query}"</div>`;
},
};
// Merge default and input config
this.config = <AutoCompleteSearchConfig>{
minLength: 2,
highlight: true,
hint: false,
onSelect: (
selectedItem: any,
event: JQueryEventObject,
searchInput: JQuery,
): boolean => {
searchInput.typeahead('val', selectedItem[this.config.value]);
return true;
},
onClose(
event: Event,
searchInput: JQuery,
) {
searchInput.typeahead('val', '');
return true;
},
suggestionLimit: 30,
dataLimit: 0,
display: 'name',
value: 'id',
templates: defaultTemplates,
...inputConfig,
};
// If input has templates override me merge them with default ones
if (Object.prototype.hasOwnProperty.call(inputConfig, 'templates')) {
this.config.templates = {
...defaultTemplates,
...(<Record<string, unknown>>inputConfig.templates),
};
}
this.buildTypeahead();
}
/**
* Build the typeahead component based on provided configuration.
*/
private buildTypeahead(): void {
// Create the two config object for typeahead based on the full config
const typeaheadOptions = {
minLength: this.config.minLength,
highlight: this.config.highlight,
hint: this.config.hint,
onSelect: this.config.onSelect,
onClose: this.config.onClose,
};
const dataSetConfig = {
source: this.config.source,
display: this.config.display,
value: this.config.value,
limit: this.config.suggestionLimit,
dataLimit: this.config.dataLimit,
templates: this.config.templates,
};
/* eslint-disable */
this.$searchInput
.typeahead(<TypeaheadJQueryOptions>typeaheadOptions, <TypeaheadJQueryDataset>dataSetConfig)
.on('typeahead:select', (e: any, selectedItem: any) =>
this.config.onSelect(selectedItem, e, this.$searchInput)
)
.on('typeahead:close', (e: any) => {
this.config.onClose(e, this.$searchInput);
});
/* eslint-enable */
}
}

View File

@@ -0,0 +1,57 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import _ from 'lodash';
// @ts-ignore
import Bloodhound from 'typeahead.js';
/**
* This comes from Bloodhound it allows to create tokenizer based on multiple fields from an object.
*
* @param tokenizer
* @returns {function(*=, ...[*]=): function(*): *[]}
*/
function getObjTokenizer(tokenizer: any) {
return function setKey(...args: any) {
const tokenizerKeys = _.isArray(args[0]) ? args[0] : args;
return function tokenize(val: Array<string>) {
let tokens: Array<string> = [];
tokenizerKeys.forEach((key: number) => {
tokens = tokens.concat(tokenizer(_.toString(val[key])));
});
return tokens;
};
};
}
/**
* Split the word into multiple tokens ok different sizes, thus allowing to search into parts of the words,
* the min length of a token is two letters though (maybe it could be configurable in the future)
*
* @param {string} val
*
* @return {array}
*/
export const letters = (val: any): Array<string> => {
const tokens = Bloodhound.tokenizers.nonword(val);
tokens.forEach((token: string) => {
let i = 0;
while (i + 1 < token.length) {
tokens.push(token.substr(i, token.length));
i += 1;
}
});
return tokens;
};
export default {
letters,
obj: {
letters: getObjTokenizer(letters),
},
};

View File

@@ -0,0 +1,216 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {zxcvbn, zxcvbnOptions, ZxcvbnResult} from '@zxcvbn-ts/core';
import * as zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
import * as zxcvbnEnPackage from '@zxcvbn-ts/language-en';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {sprintf} = require('sprintf-js');
// Initialize zxcvbn-ts with language packages
const zxcvbnConfig = {
translations: zxcvbnEnPackage.translations,
graphs: zxcvbnCommonPackage.adjacencyGraphs,
dictionary: {
...zxcvbnCommonPackage.dictionary,
...zxcvbnEnPackage.dictionary,
},
};
zxcvbnOptions.setOptions(zxcvbnConfig);
const {$} = window;
export interface ChangePasswordHandlerOptions {
minLength?: number;
}
/**
* Generates a password and informs about it's strength.
* You can pass a password input to watch the password strength and display feedback messages.
* You can also generate a random password into an input.
*/
export default class ChangePasswordHandler {
minLength: number;
feedbackSelector: string;
isValid: boolean;
constructor(
passwordStrengthFeedbackContainerSelector: string,
options: ChangePasswordHandlerOptions = {},
) {
// Minimum length of the generated password.
this.minLength = <number>options.minLength || 8;
// Feedback container holds messages representing password strength.
this.feedbackSelector = passwordStrengthFeedbackContainerSelector;
this.isValid = false;
}
/**
* Watch password, which is entered in the input, strength and inform about it.
*
* @param {jQuery} $input the input to watch.
*/
watchPasswordStrength($input: JQuery): void {
const self = this;
$input.each((index, element) => {
$(element).on('keyup', function checkPasswordStrength() {
const passwordValue = <string>$(this).val();
let $feedbackContainer = $(this).parent().find(self.feedbackSelector);
if ($feedbackContainer.length === 0) {
$(this).parent().append($('#password-feedback').html());
$feedbackContainer = $(this).parent().find(self.feedbackSelector);
}
const passwordRequirementsLength = $feedbackContainer.find('.password-requirements-length');
passwordRequirementsLength.find('span').text(
sprintf(
passwordRequirementsLength.data('translation'),
$(this).data('minlength'),
$(this).data('maxlength'),
),
);
const passwordRequirementsScore = $feedbackContainer.find('.password-requirements-score');
passwordRequirementsScore.find('span').text(
sprintf(
passwordRequirementsScore.data('translation'),
$feedbackContainer.data('translations')[$(this).data('minscore')],
),
);
if (passwordValue === '') {
$feedbackContainer.toggleClass('d-none', true);
} else {
const result = zxcvbn(passwordValue);
self.displayFeedback($(this), $feedbackContainer, result);
$feedbackContainer.removeClass('d-none');
}
});
});
}
isPasswordValid(): boolean {
return this.isValid;
}
/**
* Display feedback about password's strength.
*
* @param {jQuery} $passwordInput The currenct password field
* @param {jQuery} $outputContainer a container to put feedback output into.
* @param {ZXCVBNResult} result
*
* @private
*/
private displayFeedback(
$passwordInput: JQuery,
$outputContainer: JQuery,
result: ZxcvbnResult,
): void {
const feedback = this.getPasswordStrengthFeedback(result.score);
const translations = $outputContainer.data('translations');
const popoverContent:string[] = [];
$outputContainer.find('.password-strength-text').text(translations[result.score]);
$passwordInput.popover('dispose');
if (result.feedback.warning && result.feedback.warning !== '') {
if (result.feedback.warning in translations) {
popoverContent.push(translations[result.feedback.warning]);
}
}
result.feedback.suggestions.forEach((suggestion) => {
if (suggestion in translations) {
popoverContent.push(translations[suggestion]);
}
});
$passwordInput.popover({
html: true,
placement: 'top',
content: popoverContent.join('<br/>'),
}).popover('show');
const passwordLength = (<string>$passwordInput.val()).length;
const passwordLengthValid = passwordLength >= $passwordInput.data('minlength')
&& passwordLength <= $passwordInput.data('maxlength');
$outputContainer.find('.password-requirements-length .material-icons').toggleClass(
'text-success',
passwordLengthValid,
);
const passwordScoreValid = $passwordInput.data('minscore') <= result.score;
$outputContainer.find('.password-requirements-score .material-icons').toggleClass(
'text-success',
passwordScoreValid,
);
$passwordInput
.removeClass()
.addClass(passwordScoreValid && passwordLengthValid ? 'border-success' : 'border-danger')
.addClass('form-control border');
this.isValid = passwordScoreValid && passwordLengthValid;
const percentage = (result.score * 20) + 20;
// increase and decrease progress bar
$outputContainer
.find('.progress-bar')
.width(`${percentage}%`)
.css('visibility', 'visible')
.css('background-color', feedback.color);
}
/**
* Get feedback that describes given password strength.
* Response contains text message and element class.
*
* @param {zxcvbn.ZXCVBNScore} strength
*
* @private
*/
private getPasswordStrengthFeedback(
strength: number,
): Record<string, string> {
switch (strength) {
case 0:
return {
color: '#D5343C',
};
case 1:
return {
color: '#D5343C',
};
case 2:
return {
color: '#FFA000',
};
case 3:
return {
color: '#21834D',
};
case 4:
return {
color: '#21834D',
};
default:
throw new Error('Invalid password strength indicator.');
}
}
}

View File

@@ -0,0 +1,42 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from './components-map';
const ChoiceTableMap = ComponentsMap.choiceTable;
const {$} = window;
/**
* ChoiceTable is responsible for managing common actions in choice table form type
*/
export default class ChoiceTable {
/**
* Init constructor
*/
constructor() {
$(document).on(
'change',
ChoiceTableMap.selectAll,
(e: JQueryEventObject) => {
this.handleSelectAll(e);
},
);
}
/**
* Check/uncheck all boxes in table
*
* @param {Event} event
*/
handleSelectAll(event: JQueryEventObject): void {
const $selectAllCheckboxes = $(event.target);
const isSelectAllChecked = $selectAllCheckboxes.is(':checked');
$selectAllCheckboxes
.closest('table')
.find('tbody input:checkbox')
.prop('checked', isSelectAllChecked);
}
}

View File

@@ -0,0 +1,129 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export default {
multistoreDropdown: {
searchInput: '.js-multistore-dropdown-search',
scrollbar: '.js-multistore-scrollbar',
},
multistoreHeader: {
modal: '.js-multishop-modal',
modalDialog: '.js-multishop-modal-dialog',
headerMultiShop: '.header-multishop',
headerButton: '.js-header-multishop-open-modal',
searchInput: '.js-multishop-modal-search',
jsScrollbar: '.js-multishop-scrollbar',
shopLinks: 'a.multishop-modal-shop-name',
groupShopLinks: 'a.multishop-modal-group-name',
setContextUrl: (
location: string,
urlLetter: string,
itemId: string,
): string => `${location}&setShopContext=${urlLetter}-${itemId}`,
},
shopSelector: {
container: '.shop-selector',
selectInput: '.shop-selector-input',
searchInput: '.js-shop-selector-search',
shopItem: '.shop-selector-shop-item',
selectedClass: 'selected-shop',
currentClass: 'current-shop',
shopStatus: '.shop-selector-status',
},
choiceTable: {
selectAll: '.js-choice-table-select-all',
},
multipleChoiceTable: {
selectColumn: '.js-multiple-choice-table-select-column',
selectColumnCheckbox: (columnNum: string): string => `tbody tr td:nth-child(${columnNum}) input[type=checkbox]`,
},
formSubmitButton: '.js-form-submit-btn',
moduleCard: {
moduleItemList: (techName: string): string => `div.module-item-list[data-tech-name='${techName}']`,
moduleItem: (techName: string): string => `.module-item[data-tech-name='${techName}']`,
},
confirmModal: (modalId: string): string => `#${modalId}`,
translatableField: {
toggleTab: '.translationsLocales.nav .nav-item a[data-toggle="tab"]',
nav: '.translationsLocales.nav',
select: '.translation-field',
specificLocale: (selectedLocale: string): string => `.nav-item a[data-locale="${selectedLocale}"]`,
},
entitySearchInput: {
searchInputSelector: '.entity-search-input',
entitiesContainerSelector: '.entities-list',
listContainerSelector: '.entities-list-container',
entityItemSelector: '.entity-item',
entityDeleteSelector: '.entity-item-delete',
emptyStateSelector: '.empty-entity-list',
},
form: {
selectChoice: (language: string): string => `select.translatable_choice[data-language="${language}"]`,
selectLanguage: 'select.translatable_choice_language',
},
submittableInput: {
inputSelector: '.submittable-input',
buttonSelector: '.check-button',
},
deltaQuantityInput: {
containerSelector: '.delta-quantity',
quantityInputSelector: '.delta-quantity-quantity',
deltaInputSelector: '.delta-quantity-delta',
updateQuantitySelector: '.quantity-update',
modifiedQuantityClass: 'quantity-modified',
newQuantitySelector: '.new-quantity',
initialQuantityPreviewSelector: '.initial-quantity',
},
disablingSwitch: {
disablingSelector: '.ps-disabling-switch input.ps-switch',
},
currentLength: '.js-current-length',
recommendedLengthInput: '.js-recommended-length-input',
multistoreCheckbox: '.multistore-checkbox',
formGroup: '.form-group',
formControlInvalidClass: 'is-invalid',
formControlInvalidFeedbackClass: 'invalid-feedback',
inputNotCheckbox: ':input:not(.multistore-checkbox)',
inputContainer: '.input-container',
formControlLabel: '.form-control-label',
tineMceEditor: {
selector: '.autoload_rte',
selectorClass: 'autoload_rte',
},
contextualNotification: {
close: '.contextual-notification .close',
messageBoxId: 'content-message-box',
notificationBoxId: 'contextual-notification-box',
notificationClass: 'contextual-notification',
},
ajaxConfirmation: '#ajax_confirmation',
dateRange: {
container: '.date-range',
endDate: '.date-range-end-date',
unlimitedCheckbox: '.date-range-unlimited',
},
progressModal: {
classes: {
modal: 'modal-progress',
switchToErrorButton: 'switch-to-errors-button',
progressPercent: 'progress-percent',
stopProcessing: 'stop-processing',
progressHeadline: 'progress-headline',
progressMessage: 'progress-message',
progressIcon: 'progress-icon',
errorMessage: 'progress-error-message',
errorContainer: 'progress-error-container',
switchToProgressButton: 'switch-to-progress-button',
downloadErrorLogButton: 'download-error-log',
progressBarDone: 'modal_progressbar_done',
closeModalButton: 'close-modal-button',
progressModalError: 'progress-modal-error',
progressStatusIcon: (status: string): string => `progress-${status}-icon`,
},
},
emailInput: {
inputSelector: '.email-input',
},
};

View File

@@ -0,0 +1,114 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
/**
* This class is responsible for initiating, setting and getting data related to contextual notifications,
* that is to say: should we display the notification related to this key identifier in local storage.
* It also displays the notification itself
*/
class ContextualNotification {
// all contextual notification data will be stored under this key in local storage
private localStorageKey = 'contextual_notifications';
constructor() {
$(document).on(
'click',
ComponentsMap.contextualNotification.close,
(Event) => this.disableNotification(Event),
);
}
setItem(key: any, value: boolean): void {
const notificationList = JSON.parse(this.getNotificationList());
notificationList[key] = value;
localStorage.setItem(this.localStorageKey, JSON.stringify(notificationList));
}
getItem(key: any): boolean|null {
const notificationList = JSON.parse(this.getNotificationList());
if (key in notificationList) {
return notificationList[key];
}
return null;
}
displayNotification(message: string, key: string): void {
const $element = document.createElement('div');
$element.classList.add('alert', 'alert-info', ComponentsMap.contextualNotification.notificationClass);
$element.setAttribute('data-notification-key', key);
$element.innerHTML = `${message}<button type="button" class="close" data-dismiss="alert">&times;</button>`;
const notificationBoxId = document.getElementById(ComponentsMap.contextualNotification.notificationBoxId);
if (notificationBoxId instanceof HTMLElement) {
notificationBoxId.append($element);
return;
}
const contentMessageBox = document.getElementById(ComponentsMap.contextualNotification.messageBoxId);
if (contentMessageBox instanceof HTMLElement) {
contentMessageBox.append($element);
}
}
private disableNotification(event: any): void {
const notificationKey = $(event.target).parent().attr('data-notification-key');
if (notificationKey !== '') {
this.setItem(notificationKey, false);
}
}
private getNotificationList(): string {
return localStorage.getItem(this.localStorageKey) ?? '{}';
}
}
/**
* Initializes contextual notification on the multistore header
* Example:
* initContextualNotification('checkbox');
*
* @param {string} key Key of the contextual notification
*/
export default function initContextualNotification(key: string): void {
const multistoreHeader = document.querySelector(ComponentsMap.multistoreHeader.headerMultiShop);
const dataAttr = `data-${key}-notification`;
// Only search notification message for "single shop" or "shop group" context since no notification is needed for "All shops" context
if (multistoreHeader === null
|| !(multistoreHeader instanceof HTMLElement)
|| !multistoreHeader.hasAttribute(dataAttr)
|| (multistoreHeader.dataset.shopId === undefined && multistoreHeader.dataset.groupId === undefined)) {
return;
}
// make localstorage key for this context
const contextualNotification = new ContextualNotification();
const notificationKey = multistoreHeader.dataset.shopId !== undefined
? `${key}-shop-${multistoreHeader.dataset.shopId}`
: `${key}-group-${multistoreHeader.dataset.groupId}`;
// check if key exists, if yes: display or not depending on given value
const configValue = contextualNotification.getItem(notificationKey);
const message = multistoreHeader.getAttribute(dataAttr);
if ((configValue === true || configValue === null) && message !== null) {
contextualNotification.displayNotification(message, notificationKey);
}
// if the config doesn't exist, we set it to true
if (configValue === null) {
contextualNotification.setItem(notificationKey, true);
}
}

View File

@@ -0,0 +1,78 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Toggle DNI input requirement on country selection
*
* Usage:
*
* <!-- Country select options must have need_dni attribute when needed -->
* <select name="id_country" id="id_country" states-url="path/to/states/api">
* ...
* <option value="6" need_dni="1">Spain</value>
* ...
* </select>
*
* In JS:
*
* new CountryDniRequiredToggler('#id_country', '#id_country_dni', 'label[for="id_country_dni"]');
*/
export default class CountryDniRequiredToggler {
$countryDniInput: JQuery;
$countryDniInputLabel: JQuery;
$countryInput: JQuery;
countryInputSelectedSelector: string;
countryDniInputLabelDangerSelector: string;
constructor(
countryInputSelector: string,
countryDniInput: string,
countryDniInputLabel: string,
) {
this.$countryDniInput = $(countryDniInput);
this.$countryDniInputLabel = $(countryDniInputLabel);
this.$countryInput = $(countryInputSelector);
this.countryInputSelectedSelector = `${countryInputSelector}>option:selected`;
this.countryDniInputLabelDangerSelector = `${countryDniInputLabel}>span.text-danger`;
// If field is required regardless of the country
// keep it required
if (this.$countryDniInput.attr('required')) {
return;
}
this.$countryInput.on('change', () => this.toggle());
// toggle on page load
this.toggle();
}
/**
* Toggles DNI input required
*
* @private
*/
private toggle(): void {
$(this.countryDniInputLabelDangerSelector).remove();
this.$countryDniInput.prop('required', false);
if (
parseInt(
<string>$(this.countryInputSelectedSelector).attr('need_dni'),
10,
) === 1
) {
this.$countryDniInput.prop('required', true);
this.$countryDniInputLabel.prepend(
$('<span class="text-danger">*</span>'),
);
}
}
}

View File

@@ -0,0 +1,78 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Toggle Postcode input requirement on country selection
*
* Usage:
*
* <!-- Country select options must have need_postcode attribute when needed -->
* <select name="id_country" id="id_country" states-url="path/to/states/api">
* ...
* <option value="6" need_postcode="1">Spain</value>
* ...
* </select>
*
* In JS:
*
* new CountryPostcodeRequiredToggler('#id_country', '#id_country_postcode', 'label[for="id_country_postcode"]');
*/
export default class CountryPostcodeRequiredToggler {
$countryPostcodeInput: JQuery;
$countryPostcodeInputLabel: JQuery;
$countryInput: JQuery;
countryInputSelectedSelector: string;
countryPostcodeInputLabelDangerSelector: string;
constructor(
countryInputSelector: string,
countryPostcodeInput: string,
countryPostcodeInputLabel: string,
) {
this.$countryPostcodeInput = $(countryPostcodeInput);
this.$countryPostcodeInputLabel = $(countryPostcodeInputLabel);
this.$countryInput = $(countryInputSelector);
this.countryInputSelectedSelector = `${countryInputSelector}>option:selected`;
this.countryPostcodeInputLabelDangerSelector = `${countryPostcodeInputLabel}>span.text-danger`;
// If field is required regardless of the country
// keep it required
if (this.$countryPostcodeInput.attr('required')) {
return;
}
this.$countryInput.on('change', () => this.toggle());
// toggle on page load
this.toggle();
}
/**
* Toggles Postcode input required
*
* @private
*/
private toggle(): void {
$(this.countryPostcodeInputLabelDangerSelector).remove();
this.$countryPostcodeInput.prop('required', false);
if (
parseInt(
<string>$(this.countryInputSelectedSelector).attr('need_postcode'),
10,
) === 1
) {
this.$countryPostcodeInput.prop('required', true);
this.$countryPostcodeInputLabel.prepend(
$('<span class="text-danger">*</span>'),
);
}
}
}

View File

@@ -0,0 +1,97 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Displays, fills or hides State selection block depending on selected country.
*
* Usage:
*
* <!-- Country select must have unique identifier & url for states API -->
* <select name="id_country" id="id_country" states-url="path/to/states/api">
* ...
* </select>
*
* <!-- If selected country does not have states, then this block will be hidden -->
* <div class="js-state-selection-block">
* <select name="id_state">
* ...
* </select>
* </div>
*
* In JS:
*
* new CountryStateSelectionToggler('#id_country', '#id_state', '.js-state-selection-block');
*/
export default class CountryStateSelectionToggler {
$stateSelectionBlock: JQuery;
$countryStateSelector: JQuery;
$countryInput: JQuery;
constructor(
countryInputSelector: string,
countryStateSelector: string,
stateSelectionBlockSelector: string,
) {
this.$stateSelectionBlock = $(stateSelectionBlockSelector);
this.$countryStateSelector = $(countryStateSelector);
this.$countryInput = $(countryInputSelector);
this.$countryInput.on('change', () => this.onChange());
this.onChange();
}
/**
* Change State selection
*
* @private
*/
private onChange(): void {
const countryId = this.$countryInput.val();
if (countryId === '') {
return;
}
$.get({
url: this.$countryInput.data('states-url'),
dataType: 'json',
data: {
id_country: countryId,
},
})
.then((response) => {
this.$countryStateSelector.empty();
Object.keys(response.states).forEach((value) => {
this.$countryStateSelector.append(
$('<option></option>')
.attr('value', response.states[value])
.text(value),
);
});
this.toggle();
})
.catch((response: AjaxError) => {
if (typeof response.responseJSON !== 'undefined') {
window.showErrorMessage(response.responseJSON.message);
}
});
}
toggle(): void {
// Display the field State if:
// - there is options in the select
// - (OR)
// - there is error for the field
this.$stateSelectionBlock.toggleClass(
'd-none',
this.$countryStateSelector.find('option').length === 0 && !this.$stateSelectionBlock.hasClass('has-error'),
);
}
}

View File

@@ -0,0 +1,70 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import * as EmailValidator from 'email-validator';
import ComponentsMap from '@components/components-map';
export interface EmailInputOptions extends OptionsObject {
emailInputSelector: string;
}
/**
* Component to validate email input, it displays an error message when the email is invalid,
* and it prevents the form from being submitted until the email value is valid.
*/
export default class EmailInput {
private readonly options: EmailInputOptions;
constructor(options: Partial<EmailInputOptions> | undefined = undefined) {
this.options = {
...{
emailInputSelector: ComponentsMap.emailInput.inputSelector,
},
...options,
};
this.init();
}
private init(): void {
document.querySelectorAll<HTMLInputElement>(this.options.emailInputSelector).forEach((input) => {
input.addEventListener('change', () => {
this.toggleError(input, EmailValidator.validate(input.value));
});
const inputForm = input.closest('form');
if (inputForm) {
inputForm.addEventListener('submit', (event) => {
// Prevent submit when input value is still invalid
if (!EmailValidator.validate(input.value)) {
event.stopImmediatePropagation();
event.preventDefault();
this.toggleError(input, false);
input.focus();
}
});
}
});
}
private toggleError(input: HTMLInputElement, isValid: boolean): void {
const formGroup: HTMLElement | null = input.closest(ComponentsMap.formGroup);
input.classList.toggle(ComponentsMap.formControlInvalidClass, !isValid);
if (formGroup) {
let feedbackDiv = formGroup.querySelector(`div.${ComponentsMap.formControlInvalidFeedbackClass}`);
if (!isValid) {
// Create feedback div if it doesn't exist
if (!feedbackDiv) {
feedbackDiv = document.createElement('div');
feedbackDiv.classList.add(ComponentsMap.formControlInvalidFeedbackClass);
formGroup.append(feedbackDiv);
}
feedbackDiv.textContent = input.dataset.invalidMessage ?? 'Invalid email address.';
}
}
}
};

View File

@@ -0,0 +1,510 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import AutoCompleteSearch, {InputAutoCompleteSearchConfig} from '@components/auto-complete-search';
import ComponentsMap from '@components/components-map';
import ConfirmModal from '@components/modal';
// @ts-ignore-next-line
import Bloodhound from 'typeahead.js';
import {isUndefined} from '@components/typeguard';
const EntitySearchInputMap = ComponentsMap.entitySearchInput;
type RemoveFunction = (item: any) => void;
type SelectFunction = ($node: JQuery, item: any) => void;
type SuggestionFunction = (entity: any) => string;
export interface EntitySearchInputOptions extends OptionsObject {
prototypeTemplate: string,
prototypeIndex: string,
prototypeMapping: OptionsObject,
identifierField: string;
allowDelete: boolean,
dataLimit: number,
minLength: number,
remoteUrl: string,
filterSelected: boolean,
filteredIdentities: Array<string>,
removeModal: ModalOptions,
searchInputSelector: string,
entitiesContainerSelector: string,
listContainerSelector: string,
entityItemSelector: string,
entityDeleteSelector: string,
emptyStateSelector: string,
queryWildcard: string,
onRemovedContent: RemoveFunction | undefined,
onSelectedContent: SelectFunction | undefined,
suggestionTemplate: SuggestionFunction | undefined,
extraQueryParams?: () => Record<string, string>,
}
export interface ModalOptions extends OptionsObject {
id: string;
title: string;
message: string;
apply: string;
cancel: string;
buttonClass: string;
}
/**
* This component is used to search and select one or several entities, it uses the AutoSearchComplete
* component which displays a list of suggestion based on an API returned response. Then when
* an element is selected it is added to the selection container relying on the prototype template provided.
*
* This component is used with EntitySearchInputType forms, and is tightly linked to the content of this
* twig file src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/entity_search_input.html.twig
*
* The default content of the collection is an EntityItemType with a simple default template but you can
* either override it in a theme or create your own entity type if you need to customize the behaviour.
*/
export default class EntitySearchInput {
private readonly $entitySearchInputContainer: JQuery;
private readonly $entitySearchInput: JQuery;
private readonly $entitiesContainer: JQuery;
private $listContainer: JQuery;
private $emptyState: JQuery;
private readonly options!: EntitySearchInputOptions;
private entityRemoteSource!: Bloodhound;
private autoSearch!: AutoCompleteSearch;
constructor($entitySearchInputContainer: JQuery, options: OptionsObject) {
this.$entitySearchInputContainer = $entitySearchInputContainer;
this.options = <EntitySearchInputOptions>{};
this.buildOptions(options);
this.$entitySearchInput = $(this.options.searchInputSelector, this.$entitySearchInputContainer);
this.$entitiesContainer = $(this.options.entitiesContainerSelector, this.$entitySearchInputContainer);
this.$listContainer = $(this.options.listContainerSelector, this.$entitySearchInputContainer);
this.$emptyState = $(this.options.emptyStateSelector, this.$entitySearchInputContainer);
this.buildRemoteSource();
this.buildAutoCompleteSearch();
this.buildActions();
this.updateEmptyState();
}
/**
* Force selected values, the input is an array of object that must match the format from
* the API if you want the selected entities to be correctly displayed.
*
* @param values {Array<any>}
*/
setValues(values: any[]): void {
this.clearSelectedItems();
if (!values || values.length <= 0) {
return;
}
values.forEach((value: any) => {
this.appendSelectedItem(value);
});
}
/**
* Append the item to the selection, respecting the configured limit so if limit is already reached the item is not
* added.
*
* @param newItem
*
* @return boolean
*/
addItem(newItem: any): boolean {
return this.appendSelectedItem(newItem);
}
/**
* @param optionName
*/
getOption(optionName: string): any {
return this.options[optionName];
}
/**
* @param {string} optionName
* @param {unknown} value
*/
setOption(optionName: string, value: unknown): void {
this.options[optionName] = value;
// Apply special options to components when needed
if (optionName === 'remoteUrl' && this.entityRemoteSource) {
(<Record<string, any>> this.entityRemoteSource).remote.url = this.options.remoteUrl;
}
}
/**
* @param {Object} options
*/
private buildOptions(options: OptionsObject): void {
const inputOptions = options || {};
const defaultOptions: OptionsObject = {
suggestionField: 'name',
prototypeTemplate: undefined,
prototypeIndex: '__index__',
prototypeMapping: {
id: '__id__',
name: '__name__',
image: '__image__',
},
identifierField: 'id',
allowDelete: true,
dataLimit: 0,
minLength: 2,
remoteUrl: undefined,
filterSelected: true,
filteredIdentities: [],
removeModal: {
id: 'modal-confirm-remove-entity',
title: 'Delete item',
message: 'Are you sure you want to delete this item?',
apply: 'Delete',
cancel: 'Cancel',
buttonClass: 'btn-danger',
},
// Most of the previous config are configurable via the EntitySearchInputForm options, the following ones are only
// overridable via js config (as long as you use the default template)
searchInputSelector: EntitySearchInputMap.searchInputSelector,
entitiesContainerSelector: EntitySearchInputMap.entitiesContainerSelector,
listContainerSelector: EntitySearchInputMap.listContainerSelector,
entityItemSelector: EntitySearchInputMap.entityItemSelector,
entityDeleteSelector: EntitySearchInputMap.entityDeleteSelector,
emptyStateSelector: EntitySearchInputMap.emptyStateSelector,
queryWildcard: '__QUERY__',
// These are configurable callbacks
onRemovedContent: undefined,
onSelectedContent: undefined,
responseTransformer: (response: any) => response || [],
// Template function
suggestionTemplate: undefined,
extraQueryParams: undefined,
};
Object.keys(defaultOptions).forEach((optionName) => {
// This gets the proper value for each option, respecting the priority: input > data-attribute > default
this.initOption(optionName, inputOptions, defaultOptions[optionName]);
});
// Cast all IDs into string to avoid not matching because of different types
this.options.filteredIdentities = this.options.filteredIdentities.map(String);
}
/**
* Init the option value, the input config has the more priority. It overrides the data attribute option
* (if present), finally a default value is used (if defined).
*
* @param {string} optionName
* @param {Object} inputOptions
* @param {*|undefined} defaultOption
*/
private initOption(optionName: string, inputOptions: OptionsObject, defaultOption: any = undefined): void {
if (Object.prototype.hasOwnProperty.call(inputOptions, optionName)) {
this.options[optionName] = inputOptions[optionName];
} else if (typeof this.$entitySearchInputContainer.data(optionName) !== 'undefined') {
this.options[optionName] = this.$entitySearchInputContainer.data(optionName);
} else {
this.options[optionName] = defaultOption;
}
}
private buildActions(): void {
// Always check for click even if it is useless when allowDelete options is false, it can be changed dynamically
$(this.$entitiesContainer).on('click', this.options.entityDeleteSelector, (event) => {
if (!this.options.allowDelete) {
return;
}
const $entity = $(event.target).closest(this.options.entityItemSelector);
const modal = new (ConfirmModal as any)(
{
id: this.options.removeModal.id,
confirmTitle: this.options.removeModal.title,
confirmMessage: this.options.removeModal.message,
closeButtonLabel: this.options.removeModal.cancel,
confirmButtonLabel: this.options.removeModal.apply,
confirmButtonClass: this.options.removeModal.buttonClass,
closable: true,
},
() => {
$entity.remove();
this.updateEmptyState();
if (typeof this.options.onRemovedContent !== 'undefined') {
this.options.onRemovedContent($entity);
}
},
);
modal.show();
});
// For now adapt the display based on the allowDelete option
const $entityDelete = $(this.options.entityDeleteSelector, this.$entitiesContainer);
//'!!' converts option to bool (because if its 1 or 0, jquery toggle works differently than with true/false)
$entityDelete.toggle(!!this.options.allowDelete);
}
/**
* Build the AutoCompleteSearch component
*/
private buildAutoCompleteSearch(): void {
const autoSearchConfig: InputAutoCompleteSearchConfig = {
source: this.entityRemoteSource,
dataLimit: this.options.dataLimit,
value: this.options.identifierField,
minLength: this.options.minLength,
templates: {
suggestion: (entity: any) => this.showSuggestion(entity),
},
onSelect: (selectedItem: any) => {
// When limit is one we cannot select additional elements so we replace them instead
if (this.options.dataLimit === 1) {
return this.replaceSelectedItem(selectedItem);
}
return this.appendSelectedItem(selectedItem);
},
};
// The search feature may be disabled so the search input won't be present
if (this.$entitySearchInput.length) {
this.autoSearch = new AutoCompleteSearch(
this.$entitySearchInput,
autoSearchConfig,
);
}
}
private showSuggestion(entity: any): string {
if (!isUndefined(this.options.suggestionTemplate)) {
return this.options.suggestionTemplate(entity);
}
let entityImage = '';
if (Object.prototype.hasOwnProperty.call(entity, 'image')) {
entityImage = `<img src="${entity.image}" /> `;
}
return `<div class="search-suggestion">${entityImage}${entity[this.options.suggestionField]}</div>`;
}
/**
* Build the Bloodhound remote source which will call the API. The placeholder to
* inject the query search parameter is __QUERY__
*
* @returns {Bloodhound}
*/
private buildRemoteSource(): void {
this.entityRemoteSource = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.whitespace,
queryTokenizer: Bloodhound.tokenizers.whitespace,
identify(obj: any) {
return obj[this.options.identifierField];
},
remote: {
url: this.options.remoteUrl,
replace: (query: string, searchPhrase: string) => {
// need to replace wildcard manually, because here we are overriding the default replace function
const url = query.replace(this.options.queryWildcard, searchPhrase);
if (!isUndefined(this.options.extraQueryParams)) {
// this allows appending extra parameters to the query, such as shopId
const extraParams = this.options.extraQueryParams();
const encodedExtraParams = Object
.keys(extraParams)
.map((key: string) => `${key}=${encodeURIComponent(extraParams[key])}`)
.join('&');
return `${url}&${encodedExtraParams}`;
}
return url;
},
cache: false,
transform: (response: any) => {
if (!response) {
return [];
}
const transformedResponse = this.options.responseTransformer(response);
const selectedIds: string[] = this.getSelectedIds();
const suggestedItems: any[] = [];
transformedResponse.forEach((responseItem: any) => {
// Force casting to string to avoid inequality with number IDs because of type
const responseIdentifier: string = String(responseItem[this.options.identifierField]);
const isIdContained = this.options.filterSelected && selectedIds.includes(responseIdentifier);
const isFiltered = this.options.filteredIdentities.includes(responseIdentifier);
if (!isIdContained && !isFiltered) {
suggestedItems.push(responseItem);
}
});
return suggestedItems;
},
},
});
}
/**
* Removes selected items.
*/
private clearSelectedItems(): void {
this.$entitiesContainer.empty();
this.updateEmptyState();
}
/**
* When the component is configured to have only one selected element on each selection
* the previous selection is removed and then replaced.
*
* @param selectedItem {Object}
* @returns {boolean}
*/
private replaceSelectedItem(selectedItem: any): boolean {
this.clearSelectedItems();
this.addSelectedContentToContainer(selectedItem);
return true;
}
/**
* When the component is configured to have more than one selected item on each selection
* the item is added to the list.
*
* @param selectedItem {Object}
* @returns {boolean}
*/
private appendSelectedItem(selectedItem: any): boolean {
// If collection length is up to limit, return
const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);
if (this.options.dataLimit !== 0 && $entityItems.length >= this.options.dataLimit) {
return false;
}
this.addSelectedContentToContainer(selectedItem);
return true;
}
private updateEmptyState(): void {
const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);
this.$emptyState.toggle($entityItems.length === 0);
this.$listContainer.toggle($entityItems.length !== 0);
}
/**
* Add the selected content to the selection container, the HTML is generated based on the render that relies on the
* prototype template and mapping, and finally the rendered selection is added to the list.
*
* @param {Object} selectedItem
*/
private addSelectedContentToContainer(selectedItem: any): void {
const $entityItems = $(this.options.entityItemSelector, this.$entitiesContainer);
const newIndex = $entityItems.length ? this.getIndexFromItem($entityItems.last()) + 1 : 0;
const selectedHtml = this.renderSelected(selectedItem, newIndex);
const $selectedNode = $(selectedHtml);
const $entityDelete = $(this.options.entityDeleteSelector, $selectedNode);
$entityDelete.toggle(!!this.options.allowDelete);
this.$entitiesContainer.append($selectedNode);
if (typeof this.options.onSelectedContent !== 'undefined') {
this.options.onSelectedContent($selectedNode, selectedItem);
}
this.updateEmptyState();
}
/**
* Try and find the index of an element in the collection by parsing its inputs names which should look like:
*
* form[collection][0][name], form[collection][1][id] => we aim to extract the 1
*
* We search for the name matching the configured identifier, and extract its index. This is important because
* when you edit a collection, you can add then remove then add an element again, the indexes are not gonna follow
* so you cannot rely on just the index from element order. Which is why it is more accurate to parse the index that
* was used when the element has been rendered for the first time.
*
* If we can't find anything we use the order index as fallback though.
*
* @param {JQuery} $item
*
* @return number
*/
private getIndexFromItem($item: JQuery): number {
// By default use the position index
let index = $item.index();
// Try to find an input which names contains [1][id] (where 1 is the index, and id the identifier)
const identifierNameRegexp: string = `\\[(\\d+)\\]\\[${this.options.identifierField}\\]`;
const inputs = $item.find('input');
inputs.each((inputIndex: number, input: HTMLInputElement): void => {
const matches = input.name.match(identifierNameRegexp);
// Extract the index from the input name, if it is found and is a number use it as the index
if (matches && matches.length > 0) {
const foundIndex = parseInt(matches[1], 10);
if (!Number.isNaN(foundIndex)) {
index = foundIndex;
}
}
});
return index;
}
/**
* Render the selected element, this will be appended in the selection list (ul), prototypeTemplate is used as the
* base the we rely on prototypeMapping to replace every placeholders in the template by their mapping value in the
* provided entity.
*
* @param {Object} entity
* @param {number} index
*
* @returns {string}
*/
private renderSelected(entity: any, index: number): string {
let template = this.options.prototypeTemplate.replace(new RegExp(this.options.prototypeIndex, 'g'), String(index));
Object.keys(this.options.prototypeMapping).forEach((fieldName) => {
const fieldValue = entity[fieldName] || '';
template = template.replace(new RegExp(this.options.prototypeMapping[fieldName], 'g'), fieldValue);
});
return template;
}
/**
* Parses the selection container and extract the IDs this allows to filter the already selected items.
*
* @private
*/
private getSelectedIds(): string[] {
const selectedIds: string[] = [];
const selectedChildren = $(this.options.entityItemSelector, this.$entitiesContainer);
selectedChildren.each((index: number, selectedChild: HTMLElement) => {
const identifierNameRegexp: string = `\\[${this.options.identifierField}\\]`;
const inputs = $(selectedChild).find('input');
inputs.each((inputIndex: number, input: HTMLInputElement): void => {
if (input.name.match(identifierNameRegexp)) {
selectedIds.push(input.value);
}
});
});
return selectedIds;
}
}

View File

@@ -0,0 +1,14 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {EventEmitter as EventEmitterClass} from 'events';
/**
* We instanciate one EventEmitter (restricted via a const) so that every components
* register/dispatch on the same one and can communicate with each other.
*/
export const EventEmitter = new EventEmitterClass();
export default EventEmitter;

View File

@@ -0,0 +1,133 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
type FilterField = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const DEFAULT_CONTAINER_SELECTOR = '[data-filter-link-group]';
const DEFAULT_ITEM_SELECTOR = '[data-filter-value]';
const DEFAULT_ACTIVE_CLASS = 'is-active';
const DEFAULT_SUBMIT_BUTTON_SELECTOR = '.grid-search-button';
/**
* FilterLinkGroup turns a list of links into a form filter controller.
*
* Expected markup:
* <div
* data-filter-link-group
* data-filter-field-selector="#my-hidden-filter"
* data-filter-submit-button-selector=".grid-search-button"
* >
* <a data-filter-value="foo">Foo</a>
* </div>
*/
export default class FilterLinkGroup {
private readonly containerSelector: string;
constructor(containerSelector: string = DEFAULT_CONTAINER_SELECTOR) {
this.containerSelector = containerSelector;
this.init();
}
private init(): void {
const containers = document.querySelectorAll<HTMLElement>(this.containerSelector);
containers.forEach((container) => {
const fieldSelector = container.dataset.filterFieldSelector;
if (!fieldSelector) {
console.warn('[FilterLinkGroup] Missing "data-filter-field-selector" attribute on container.', container);
return;
}
const field = document.querySelector<FilterField>(fieldSelector);
if (!field) {
console.warn('[FilterLinkGroup] Unable to find filter field for selector:', fieldSelector);
return;
}
this.bindContainer(container, field);
});
}
private bindContainer(container: HTMLElement, field: FilterField): void {
const filterField = field;
const itemSelector = container.dataset.filterItemSelector ?? DEFAULT_ITEM_SELECTOR;
const activeClass = container.dataset.filterActiveClass ?? DEFAULT_ACTIVE_CLASS;
const submitButtonSelector = container.dataset.filterSubmitButtonSelector ?? DEFAULT_SUBMIT_BUTTON_SELECTOR;
const updateActiveState = (value: string): void => {
const items = container.querySelectorAll<HTMLElement>(itemSelector);
items.forEach((item) => {
const itemValue = item.dataset.filterValue ?? item.getAttribute('data-filter-value') ?? '';
const isActive = itemValue === value;
if (isActive) {
item.classList.add(activeClass);
item.setAttribute('aria-pressed', 'true');
} else {
item.classList.remove(activeClass);
item.setAttribute('aria-pressed', 'false');
}
});
};
const initialValue = container.dataset.filterInitialValue ?? filterField.value;
filterField.value = initialValue;
updateActiveState(initialValue);
container.addEventListener('click', (event: Event) => {
const target = (event.target as HTMLElement).closest<HTMLElement>(itemSelector);
if (!target) {
return;
}
const value = target.dataset.filterValue ?? target.getAttribute('data-filter-value');
if (value === null) {
return;
}
event.preventDefault();
if (filterField.value !== value) {
filterField.value = value;
filterField.dispatchEvent(new Event('change', {bubbles: true}));
}
updateActiveState(value);
this.submitForm(filterField, container, submitButtonSelector);
});
filterField.addEventListener('change', () => {
updateActiveState(filterField.value);
});
}
private submitForm(field: FilterField, container: HTMLElement, submitButtonSelector: string): void {
const form = field.closest('form');
if (!form) {
return;
}
if (submitButtonSelector !== 'none') {
const submitButton = form.querySelector<HTMLButtonElement>(submitButtonSelector)
?? container.querySelector<HTMLButtonElement>(submitButtonSelector);
if (submitButton && !submitButton.disabled) {
submitButton.click();
return;
}
}
form.submit();
}
}

View File

@@ -0,0 +1,92 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from './components-map';
const {$} = window;
/**
* Component which allows submitting very simple forms without having to use <form> element.
*
* Useful when performing actions on resource where URL contains all needed data.
* For example, to toggle category status via "POST /categories/2/toggle-status)"
* or delete cover image via "POST /categories/2/delete-cover-image".
*
* Usage example in template:
*
* <button class="js-form-submit-btn"
* data-form-submit-url="/my-custom-url" // (required) URL to which form will be submitted
* data-method="GET|POST|DELETE|PATCH" // (optional) specify the verb to use for the request.
* // POST is taken by default if not value is set
* data-form-csrf-token="my-generated-csrf-token" // (optional) to increase security
* data-form-confirm-message="Are you sure?" // (optional) to confirm action before submit
* type="button" // make sure its simple button
* // so we can avoid submitting actual form
* // when our button is defined inside form
* >
* Click me to submit form
* </button>
*
* In page specific JS you have to enable this feature:
*
* new FormSubmitButton();
*/
export default class FormSubmitButton {
constructor() {
$(document).on(
'click',
ComponentsMap.formSubmitButton,
(event: JQueryEventObject) => {
event.preventDefault();
const $btn = $(event.target);
if (
$btn.data('form-confirm-message')
&& window.confirm($btn.data('form-confirm-message')) === false
) {
return;
}
let method = 'POST';
let addInput = null;
if ($btn.data('method')) {
const btnMethod = $btn.data('method');
const isGetOrPostMethod = ['GET', 'POST'].includes(btnMethod);
method = isGetOrPostMethod ? btnMethod : 'POST';
if (!isGetOrPostMethod) {
addInput = $('<input>', {
type: '_hidden',
name: '_method',
value: btnMethod,
});
}
}
const $form = $('<form>', {
action: $btn.data('form-submit-url'),
method,
});
if (addInput) {
$form.append(addInput);
}
if ($btn.data('form-csrf-token')) {
$form.append(
$('<input>', {
type: '_hidden',
name: '_csrf_token',
value: $btn.data('form-csrf-token'),
}),
);
}
$form.appendTo('body').submit();
},
);
}
}

View File

@@ -0,0 +1,40 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$}: Window = window;
export default class AutocompleteWithEmail {
map: Record<string, any>;
$emailInput: JQuery;
constructor(emailInputSelector: string, map: Record<string, any> = {}) {
this.map = map;
this.$emailInput = $(emailInputSelector);
this.$emailInput.on('change', () => this.change());
}
private change(): void {
$.get({
url: this.$emailInput.data('customer-information-url'),
dataType: 'json',
data: {
email: this.$emailInput.val(),
},
})
.then((response) => {
Object.keys(this.map).forEach((key: string) => {
if (response[key] !== undefined) {
$(this.map[key]).val(response[key]);
}
});
})
.catch((response: AjaxError) => {
if (typeof response.responseJSON !== 'undefined') {
window.showErrorMessage(response.responseJSON.message);
}
});
}
}

View File

@@ -0,0 +1,278 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ChangePasswordHandler from '../change-password-handler';
import PasswordValidator from '../password-validator';
import Router from '../../components/router';
const {$} = window;
/**
* Class responsible for actions related to "change password" form type.
* Generates random passwords, validates new password and it's confirmation,
* displays error messages related to validation.
*/
export default class ChangePasswordControl {
$inputsBlock: JQuery;
showButtonSelector: string;
hideButtonSelector: string;
oldPasswordInputSelector: string;
newPasswordInputSelector: string;
confirmNewPasswordInputSelector: string;
generatedPasswordDisplaySelector: string;
generatedPasswordButton: string;
passwordStrengthFeedbackContainerSelector: string;
$newPasswordInputs: JQuery<HTMLElement>;
$copyPasswordInputs: JQuery<HTMLElement>;
$submittableInputs: JQuery<HTMLElement>;
passwordHandler: ChangePasswordHandler;
passwordValidator: PasswordValidator;
router: Router;
constructor(
inputsBlockSelector: string,
showButtonSelector: string,
hideButtonSelector: string,
oldPasswordInputSelector: string,
newPasswordInputSelector: string,
confirmNewPasswordInputSelector: string,
generatedPasswordDisplaySelector: string,
passwordStrengthFeedbackContainerSelector: string,
generatedPasswordButton: string,
) {
// Block that contains password inputs
this.$inputsBlock = $(inputsBlockSelector);
// Button that shows the password inputs block
this.showButtonSelector = showButtonSelector;
// Button that hides the password inputs block
this.hideButtonSelector = hideButtonSelector;
// Input to enter old password
this.oldPasswordInputSelector = oldPasswordInputSelector;
// Input to enter new password
this.newPasswordInputSelector = newPasswordInputSelector;
// Input to confirm the new password
this.confirmNewPasswordInputSelector = confirmNewPasswordInputSelector;
// Input that displays generated random password
this.generatedPasswordDisplaySelector = generatedPasswordDisplaySelector;
// Block that displays password strength feedback
this.passwordStrengthFeedbackContainerSelector = passwordStrengthFeedbackContainerSelector;
this.generatedPasswordButton = generatedPasswordButton;
// Main input for password generation
this.$newPasswordInputs = this.$inputsBlock.find(
this.newPasswordInputSelector,
);
// Generated password will be copied to these inputs
this.$copyPasswordInputs = this.$inputsBlock
.find(this.confirmNewPasswordInputSelector)
.add(this.generatedPasswordDisplaySelector);
// All inputs in the change password block, that are submittable with the form.
this.$submittableInputs = this.$inputsBlock
.find(this.oldPasswordInputSelector)
.add(this.newPasswordInputSelector)
.add(this.confirmNewPasswordInputSelector);
this.passwordHandler = new ChangePasswordHandler(
passwordStrengthFeedbackContainerSelector,
);
this.passwordValidator = new PasswordValidator(
this.newPasswordInputSelector,
this.confirmNewPasswordInputSelector,
);
this.hideInputsBlock();
this.initEvents();
this.router = new Router();
}
/**
* Initialize events.
*
* @private
*/
private initEvents(): void {
// Show the inputs block when show button is clicked
$(document).on('click', this.showButtonSelector, (e) => {
this.hide($(e.currentTarget));
this.showInputsBlock();
});
$(document).on('click', this.hideButtonSelector, () => {
this.hideInputsBlock();
this.show($(this.showButtonSelector));
});
$(document).on('click', this.generatedPasswordButton, () => {
const $generatedPasswordDisplay = $(this.generatedPasswordDisplaySelector);
$.get(this.router.generate('admin_employees_get_password_generated')).then((response) => {
$generatedPasswordDisplay.val(response.password);
navigator.clipboard.writeText(response.password);
});
});
// Watch and display feedback about password's strength
this.passwordHandler.watchPasswordStrength(this.$newPasswordInputs);
// Validate new password and it's confirmation when any of the inputs is changed
$(document).on(
'keyup',
`${this.newPasswordInputSelector},${this.confirmNewPasswordInputSelector}`,
() => {
this.checkPasswordValidity();
},
);
// Prevent submitting the form if new password is not valid
$(document).on(
'submit',
$(this.oldPasswordInputSelector).closest('form'),
(event) => {
// If password input is disabled - we don't need to validate it.
if ($(this.oldPasswordInputSelector).is(':disabled')) {
return;
}
if (!this.passwordValidator.isPasswordValid()) {
event.preventDefault();
}
},
);
}
/**
* Check if password is valid, show error messages if it's not.
*
* @private
*/
private checkPasswordValidity(): void {
const $firstPasswordErrorContainer = $(this.newPasswordInputSelector)
.parent()
.find('.form-text');
const $secondPasswordErrorContainer = $(
this.confirmNewPasswordInputSelector,
)
.parent()
.find('.form-text');
$firstPasswordErrorContainer
.text(this.getPasswordLengthValidationMessage())
.toggleClass(
'text-danger',
!this.passwordValidator.isPasswordLengthValid(),
);
$secondPasswordErrorContainer
.text(this.getPasswordConfirmationValidationMessage())
.toggleClass(
'text-danger',
!this.passwordValidator.isPasswordMatchingConfirmation(),
);
}
/**
* Get password confirmation validation message.
*
* @returns {String}
*
* @private
*/
private getPasswordConfirmationValidationMessage(): string {
if (!this.passwordValidator.isPasswordMatchingConfirmation()) {
return $(this.confirmNewPasswordInputSelector).data('invalid-password');
}
return '';
}
/**
* Get password length validation message.
*
* @returns {String}
*
* @private
*/
private getPasswordLengthValidationMessage(): string {
if (this.passwordValidator.isPasswordTooShort()) {
return $(this.newPasswordInputSelector).data('password-too-short');
}
if (this.passwordValidator.isPasswordTooLong()) {
return $(this.newPasswordInputSelector).data('password-too-long');
}
return '';
}
/**
* Show the password inputs block.
*
* @private
*/
private showInputsBlock(): void {
this.show(this.$inputsBlock);
this.$submittableInputs.removeAttr('disabled');
this.$submittableInputs.attr('required', 'required');
}
/**
* Hide the password inputs block.
*
* @private
*/
private hideInputsBlock(): void {
this.hide(this.$inputsBlock);
this.$submittableInputs.attr('disabled', 'disabled');
this.$submittableInputs.removeAttr('required');
this.$inputsBlock.find('input').val('');
this.$inputsBlock.find('.form-text').text('');
this.$newPasswordInputs.popover('dispose');
this.hide(this.$inputsBlock.find(this.passwordStrengthFeedbackContainerSelector));
this.$newPasswordInputs.removeClass('border-success border-danger');
}
/**
* Hide an element.
*
* @param {jQuery} $el
*
* @private
*/
private hide($el: JQuery): void {
$el.addClass('d-none');
}
/**
* Show hidden element.
*
* @param {jQuery} $el
*
* @private
*/
private show($el: JQuery): void {
$el.removeClass('d-none');
}
}

View File

@@ -0,0 +1,131 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Handles UI interactions of choice tree
*/
export default class ChoiceTree {
$container: JQuery<HTMLElement>;
/**
* @param {String} treeSelector
*/
constructor(treeSelector: string) {
this.$container = $(treeSelector);
this.$container.on('click', '.js-input-wrapper', (event) => {
const $inputWrapper = $(event.currentTarget);
this.toggleChildTree($inputWrapper);
});
this.$container.on('click', '.js-toggle-choice-tree-action', (event) => {
const $action = $(event.currentTarget);
this.toggleTree($action);
});
}
/**
* Enable automatic check/uncheck of clicked item's children.
*/
enableAutoCheckChildren(): void {
this.$container.on('change', 'input[type="checkbox"]', (event) => {
const $clickedCheckbox = $(event.currentTarget);
const $itemWithChildren = $clickedCheckbox.closest('li');
$itemWithChildren
.find('ul input[type="checkbox"]')
.prop('checked', $clickedCheckbox.is(':checked'));
});
}
/**
* Enable all inputs in the choice tree.
*/
enableAllInputs(): void {
this.$container.find('input').removeAttr('disabled');
}
/**
* Disable all inputs in the choice tree.
*/
disableAllInputs(): void {
this.$container.find('input').attr('disabled', 'disabled');
}
/**
* Collapse or expand sub-tree for single parent
*
* @param {jQuery} $inputWrapper
*
* @private
*/
toggleChildTree($inputWrapper: JQuery<HTMLElement>): void {
const $parentWrapper = $inputWrapper.closest('li');
if ($parentWrapper.hasClass('expanded')) {
$parentWrapper.removeClass('expanded').addClass('collapsed');
return;
}
if ($parentWrapper.hasClass('collapsed')) {
$parentWrapper.removeClass('collapsed').addClass('expanded');
}
}
/**
* Collapse or expand whole tree
*
* @param {jQuery} $action
*
* @private
*/
private toggleTree($action: JQuery<HTMLElement>): void {
const $parentContainer = $action.closest('.js-choice-tree-container');
const action: string = $action.data('action');
// toggle action configuration
const config: Record<string, Record<string, string>> = {
addClass: {
expand: 'expanded',
collapse: 'collapsed',
},
removeClass: {
expand: 'collapsed',
collapse: 'expanded',
},
nextAction: {
expand: 'collapse',
collapse: 'expand',
},
text: {
expand: 'collapsed-text',
collapse: 'expanded-text',
},
icon: {
expand: 'collapsed-icon',
collapse: 'expanded-icon',
},
};
$parentContainer.find('li').each((index, item) => {
const $item = $(item);
if ($item.hasClass(config.removeClass[action])) {
$item
.removeClass(config.removeClass[action])
.addClass(config.addClass[action]);
}
});
$action.data('action', config.nextAction[action]);
$action.find('.material-icons').text($action.data(config.icon[action]));
$action.find('.js-toggle-text').text($action.data(config.text[action]));
}
}

View File

@@ -0,0 +1,59 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Change symbol when the currency select is changed
*/
export default class CurrencySymbolUpdater {
currencySymbolSelect: string;
selectCurrency: HTMLSelectElement | null;
callbackChange: (symbol: string) => void;
constructor(
currencySymbolSelect: string,
callbackChange: (symbol: string) => void,
) {
this.currencySymbolSelect = currencySymbolSelect;
this.selectCurrency = document.querySelector<HTMLSelectElement>(this.currencySymbolSelect);
this.callbackChange = callbackChange;
if (!this.selectCurrency) {
console.error(`Could not find ${this.currencySymbolSelect}`);
} else {
this.init();
}
}
private init(): void {
const selectCurrency = document.querySelector<HTMLSelectElement>(this.currencySymbolSelect);
if (selectCurrency) {
this.callbackChange(this.getSymbol());
selectCurrency.addEventListener('change', () => this.callbackChange(this.getSymbol()));
}
}
public getSymbol(): string {
if (!this.selectCurrency) {
return '';
}
const defaultCurrencySymbol: string | null = this.selectCurrency.dataset.defaultCurrencySymbol ?? '';
const selectItem = this.selectCurrency.item(this.selectCurrency.selectedIndex);
if (!defaultCurrencySymbol && !selectItem) {
console.error('Could not find appropriate data attributes');
}
if (!selectItem) {
return defaultCurrencySymbol;
}
return selectItem.getAttribute('symbol') ?? defaultCurrencySymbol;
}
}

View File

@@ -0,0 +1,49 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import EntitySearchInput from '@components/entity-search-input';
import EventEmitter from '@components/event-emitter';
export default class CustomerSearchInput extends EntitySearchInput {
private readonly disablingSwitchEvent: string | undefined;
private readonly customerItemSelector: string;
constructor(
customerSearchContainer: string,
customerItemSelector: string,
shopIdCallback: () => number|null,
disablingSwitchEvent?: string|undefined,
) {
super($(customerSearchContainer), {
extraQueryParams: () => ({
shopId: shopIdCallback(),
}),
responseTransformer: (response: any) => {
if (!response || response.customers.length === 0) {
return [];
}
return Object.values(response.customers);
},
});
this.disablingSwitchEvent = disablingSwitchEvent;
this.customerItemSelector = customerItemSelector;
this.listenDisablingSwitch();
}
private listenDisablingSwitch(): void {
if (this.disablingSwitchEvent === undefined) {
return;
}
const eventEmitter = <typeof EventEmitter> window.prestashop.instance.eventEmitter;
// When customer search is disabled we also disable the selected item (if present)
eventEmitter.on(this.disablingSwitchEvent, (event: any) => {
$(this.customerItemSelector).toggleClass('disabled', event.disable);
});
}
}

View File

@@ -0,0 +1,40 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
import ChangeEvent = JQuery.ChangeEvent;
import TriggeredEvent = JQuery.TriggeredEvent;
export default class DateRange {
constructor() {
this.initListeners();
}
initListeners(): void {
$(document).on('change', ComponentsMap.dateRange.unlimitedCheckbox, (e: ChangeEvent) => {
const $dateRangeContainer = $(e.currentTarget).parents(ComponentsMap.dateRange.container);
const $endDate = $(ComponentsMap.dateRange.endDate, $dateRangeContainer);
const {checked} = e.currentTarget as HTMLInputElement;
if (checked) {
$endDate.val('');
$endDate.prop('disabled', true);
} else {
if ($endDate.val() === '') {
$endDate.val($endDate.data('defaultValue'));
}
$endDate.prop('disabled', false);
}
});
$(document).on('change dp.change', ComponentsMap.dateRange.endDate, (e: TriggeredEvent) => {
const $endDate = $(e.currentTarget);
const $dateRangeContainer = $endDate.parents(ComponentsMap.dateRange.container);
const $unlimited = $(ComponentsMap.dateRange.unlimitedCheckbox, $dateRangeContainer);
$unlimited.prop('checked', $endDate.val() === '');
});
}
}

View File

@@ -0,0 +1,98 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
import TriggeredEvent = JQuery.TriggeredEvent;
const {$} = window;
export type DeltaQuantityConfig = {
containerSelector: string;
deltaInputSelector: string;
updateQuantitySelector: string;
modifiedQuantityClass: string;
newQuantitySelector: string;
quantityInputSelector: string;
initialQuantityPreviewSelector: string;
}
export default class DeltaQuantityInput {
private config: DeltaQuantityConfig;
constructor(config: Partial<DeltaQuantityConfig> = {}) {
const componentMap = ComponentsMap.deltaQuantityInput;
this.config = {
containerSelector: componentMap.containerSelector,
deltaInputSelector: componentMap.deltaInputSelector,
updateQuantitySelector: componentMap.updateQuantitySelector,
modifiedQuantityClass: componentMap.modifiedQuantityClass,
newQuantitySelector: componentMap.newQuantitySelector,
quantityInputSelector: componentMap.quantityInputSelector,
initialQuantityPreviewSelector: componentMap.initialQuantityPreviewSelector,
...config,
};
this.init();
}
public applyNewQuantity(input: HTMLInputElement): void {
const $container: JQuery = $(input).closest(this.config.containerSelector);
if ($container.length === 0) {
console.error(`container not found by ${this.config.containerSelector}`);
return;
}
const deltaQuantity = this.getDeltaQuantity(input);
const initialQuantity = this.getInitialQuantity($container);
const newQuantity: number = initialQuantity + deltaQuantity;
$container.data('initialQuantity', newQuantity);
$container.find(this.config.initialQuantityPreviewSelector).text(newQuantity);
$container.find(this.config.newQuantitySelector).text(0);
$container.find(this.config.updateQuantitySelector).removeClass(this.config.modifiedQuantityClass);
}
private init(): void {
const deltaInputSelector: string = `${this.config.containerSelector} ${this.config.deltaInputSelector}`;
$(document).on('change keyup', deltaInputSelector, (event: TriggeredEvent) => {
this.updateDeltaQuantity(event.target);
});
}
private updateDeltaQuantity(deltaInput: HTMLElement): void {
const $container: JQuery = $(deltaInput).closest(this.config.containerSelector);
const deltaQuantity = this.getDeltaQuantity(deltaInput);
const initialQuantity = this.getInitialQuantity($container);
const updatedQuantity: number = initialQuantity + deltaQuantity;
const $newQuantity: JQuery = $container.find(this.config.newQuantitySelector);
$newQuantity.text(updatedQuantity);
$container.find(this.config.quantityInputSelector).val(updatedQuantity);
const $updateElement = $container.find(this.config.updateQuantitySelector);
$updateElement.toggleClass(this.config.modifiedQuantityClass, deltaQuantity !== 0);
}
private getDeltaQuantity(deltaInput: HTMLElement): number {
let delta: number = parseInt(Number($(deltaInput).val()).toString(), 10);
if (Number.isNaN(delta)) {
delta = 0;
}
return delta;
}
private getInitialQuantity($container: JQuery): number {
let initialQuantity = parseInt(<string> $container.data('initialQuantity'), 10);
if (Number.isNaN(initialQuantity)) {
initialQuantity = 0;
}
return initialQuantity;
}
}

View File

@@ -0,0 +1,19 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
import FormFieldToggler from '@components/form/form-field-toggler';
/**
* This components work along with the form option disabling_switch it automatizes the initialization
* of the switch disabler inputs, it is accessible easily with the other PrestaShop components via the
* initComponents method.
*/
export default class DisablingSwitch {
constructor() {
new FormFieldToggler({
disablingInputSelector: ComponentsMap.disablingSwitch.disablingSelector,
});
}
}

View File

@@ -0,0 +1,185 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {isUndefined} from '@components/typeguard';
const {$} = window;
// @TODO: typescript-eslint adds a no-shadow there, remove it when it's fixed on their side
// eslint-disable-next-line no-shadow
export enum ToggleType {
availability = 'availability',
visibility = 'visibility',
}
/**
* @param {string} disablingInputSelector - selector of input (e.g. checkbox or radio)
* which on change enables/disables or shows/hides the element selected by targetSelector.
* @param {string} matchingValue - value which should match with disablingInput value to enable/disable related element
* @param {string} targetSelector - selector of element which is toggled by the disablingInput.
* @param {boolean} disableOnMatch - once disablingInput & matchingValue values match, then
* if true - when ToggleType is "availability", then the related element is disabled. When ToggleType is "visibility", then the related element is hidden.
* if false - when ToggleType is "availability", then the related element is enabled. When ToggleType is "visibility", then the related element is visible.
* @param {ToggleType} toggleType - whether to toggle between enable/disable (availability) or show/hide (visibility)
*
* Important Note: the component can be configured on construction via the parameters object, but its behaviour
* and parameters will be overridden if a data attribute is associated to the selector node.
*/
export type FormFieldTogglerParams = {
disablingInputSelector: string,
matchingValue: string | null,
targetSelector: string | null,
switchEvent: string | null,
disableOnMatch: boolean,
toggleType: ToggleType
}
export type InputFormFieldTogglerParams = Partial<FormFieldTogglerParams> & {
disablingInputSelector: string,
};
export type SwitchEventData = {
targetSelector: string,
disable: boolean,
}
/**
* Enables/disables or shows/hides element depending on certain input value.
*/
export default class FormFieldToggler {
params: FormFieldTogglerParams;
/**
* @param {InputFormFieldTogglerParams} inputParams
*/
constructor(inputParams: InputFormFieldTogglerParams) {
this.params = {
matchingValue: '0',
disableOnMatch: true,
targetSelector: null,
switchEvent: null,
toggleType: ToggleType.availability,
...inputParams,
};
this.init();
}
private init(): void {
const disablingInputs: NodeListOf<HTMLInputElement> = document.querySelectorAll(this.params.disablingInputSelector);
disablingInputs.forEach((input: HTMLInputElement) => {
this.updateTargetState(input);
$(input).on('change', () => {
this.updateTargetState(input);
});
});
}
private updateTargetState(inputElement: HTMLInputElement): void {
const toggleValue = this.getInputValue(inputElement);
if (isUndefined(toggleValue)) {
return;
}
const matchingValue = inputElement.dataset.matchingValue ?? this.params.matchingValue;
const targetSelector = inputElement.dataset.targetSelector ?? this.params.targetSelector;
const switchEvent = inputElement.dataset.switchEvent ?? this.params.switchEvent;
let {disableOnMatch} = this.params;
if (!isUndefined(inputElement.dataset) && !isUndefined(inputElement.dataset.disableOnMatch)) {
disableOnMatch = inputElement.dataset.disableOnMatch === '1';
}
if (matchingValue === null) {
console.error('No matching value defined for inputElement', inputElement);
return;
}
if (targetSelector === null) {
console.error('No target selector defined for inputElement', inputElement);
return;
}
let disabledState;
if (toggleValue === matchingValue) {
disabledState = disableOnMatch;
} else {
disabledState = !disableOnMatch;
}
this.toggle(targetSelector, disabledState, switchEvent);
}
private getInputValue(inputElement: HTMLInputElement): string | undefined {
switch (inputElement.type) {
case 'radio': {
const checkedRadios = document.querySelectorAll<HTMLInputElement>(`[name="${inputElement.name}"]`);
let checkedValue: string | undefined;
checkedRadios.forEach((radio: HTMLInputElement) => {
if (radio.checked) {
checkedValue = radio.value;
}
});
return checkedValue;
}
case 'checkbox':
return inputElement.checked ? inputElement.value : undefined;
default:
return inputElement.value;
}
}
private toggle(
targetSelector: string,
disable: boolean,
switchEvent: string | null,
): void {
if (switchEvent) {
const {eventEmitter} = window.prestashop.instance;
if (!eventEmitter) {
console.error('Trying to use EventEmitter without having initialised the component before.');
} else {
const eventData: SwitchEventData = {
targetSelector,
disable,
};
eventEmitter.emit(switchEvent, eventData);
}
}
const elementsToToggle: NodeListOf<Element> = document.querySelectorAll(targetSelector);
if (elementsToToggle.length === 0) {
console.error(`Could not find target ${targetSelector}`);
return;
}
elementsToToggle.forEach((elementToToggle: Element) => {
const toggleByDisabling = this.params.toggleType === ToggleType.availability;
if (toggleByDisabling) {
elementToToggle.classList.toggle('disabled', disable);
elementToToggle.toggleAttribute('disabled', disable);
} else {
elementToToggle.classList.toggle('d-none', disable);
}
const formElements = elementToToggle.querySelectorAll('input, select, textarea, button, option, fieldset');
if (formElements.length === 0) {
return;
}
formElements.forEach((element: Element) => {
if (toggleByDisabling) {
element.toggleAttribute('disabled', disable);
}
});
});
}
}

View File

@@ -0,0 +1,558 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint max-classes-per-file: ["error", 2] */
import BigNumber from 'bignumber.js';
import {transform as numberCommaTransform} from '@js/app/utils/number-comma-transformer';
import {isUndefined} from '@components/typeguard';
import NameValuePair = JQuery.NameValuePair;
const {$} = window;
export interface FormUpdateEvent {
object: Record<string, any>,
modelKey: string,
value: any,
previousValue: any,
/**
* You can stop the propagation of an event propagation to avoid the other watchers to be triggered,
* this allows preventing infinite loop in case you change the value from an event (by stopping it
* and re-setting new value).
*/
stopPropagation(): void,
/**
* When true the watchers callback stop being called (for this specific event instance only).
*/
isPropagationStopped(): boolean,
}
/**
* Internal class for the event object that implements the FormUpdateEvent interface.
*/
class UpdateEvent implements FormUpdateEvent {
object: Record<string, any>;
modelKey: string;
value: any;
previousValue: any;
private propagationStopped: boolean;
constructor(
object: Record<string, any>,
modelKey: string,
value: any,
previousValue: any,
) {
this.object = object;
this.modelKey = modelKey;
this.value = value;
this.previousValue = previousValue;
this.propagationStopped = false;
}
stopPropagation(): void {
this.propagationStopped = true;
}
isPropagationStopped(): boolean {
return this.propagationStopped;
}
}
/**
* This is able to watch an HTML form and parse it as a Javascript object based on a configurable
* mapping. Each field from the model is mapped to a form input, or several, each input is watched
* to keep the model consistent.
*
* The model mapping used for this component is an object which uses the modelKey as a key (it represents
* the property path in the object, separated by a dot) and the input names as value (they follow Symfony
* convention naming using brackets). Here is an example of mapping:
*
* const modelMapping = {
* 'product.stock.quantity': 'product[stock][quantity]',
* 'product.price.priceTaxExcluded': [
* 'product[price][price_tax_excluded]',
* 'product[shortcuts][price][price_tax_excluded]',
* ],
* };
*
* As you can see for priceTaxExcluded it is possible to assign
* multiple inputs to the same modelKey, thus
* any update in one of the inputs will update the model, and all these inputs are kept in sync.
*
* With the previous configuration this component would return an object that looks like this:
*
* {
* product: {
* stock: {
* // Mapped to product[stock][quantity] input
* quantity: 200,
* },
* price: {
* // Mapped to two inputs product[price][price_tax_excluded]
* // and product[shortcuts][price][price_tax_excluded]
* priceTaxExcluded: 20.45,
* }
* }
* }
*/
export default class FormObjectMapper {
private $form: JQuery<HTMLElement>;
private fullModelMapping: Record<string, any>;
private model: Record<string, any>;
private modelMapping: Record<string, any>;
private formMapping: Record<string, any>;
private watchedProperties: Record<string, Array<(event: FormUpdateEvent) => void>>;
/**
* @param {JQuery} $form - Form element to attach the mapper to
* @param {Object} modelMapping - Structure mapping a model to form names
* @return {Object}
*/
constructor(
$form: JQuery<HTMLElement>,
modelMapping: Record<string, any>,
) {
if (!$form.length) {
console.error('Invalid empty form as input');
}
this.$form = $form;
this.fullModelMapping = modelMapping;
this.model = {};
this.modelMapping = {};
// modelMapping is a light version of the fullModelMapping,
// it only contains one input name which is considered
// as the default one (when full object is updated, only the default input is used)
this.modelMapping = {};
// formMapping is the inverse of modelMapping for each input name
// it associated the model key, it is generated for
// performance and convenience, this allows to get mapping data faster in other functions
this.formMapping = {};
// Contains callbacks identified by model keys
this.watchedProperties = {};
this.initFormMapping();
this.updateFullObject();
this.watchUpdates();
}
/**
* Returns the model mapped to the form (current live state)
*
* @returns {*|{}}
*/
getModel(): Record<string, any> {
return this.model;
}
/**
* Returns all inputs associated to a model field.
*
* @param {string} modelKey
*
* @returns {undefined|JQuery}
*/
getInputsFor(modelKey: string): JQuery<HTMLElement> | undefined {
if (
!Object.prototype.hasOwnProperty.call(this.fullModelMapping, modelKey)
) {
return undefined;
}
let inputNames = this.fullModelMapping[modelKey];
// Turn single identifier into array to limit duplicated code in the following code
if (!Array.isArray(inputNames)) {
inputNames = [inputNames];
}
// We must loop manually to keep the order in configuration,
// if we use JQuery multiple selectors the collection
// will be filled respecting the order in the DOM
const inputs: Array<HTMLElement> = [];
const domForm = this.$form.get(0);
if (!domForm) return undefined;
inputNames.forEach((inputName: string) => {
const inputsByName = domForm.querySelectorAll(`[name="${inputName}"]`);
if (inputsByName.length) {
inputsByName.forEach((input) => {
inputs.push(<HTMLElement>input);
});
}
});
return inputs.length ? $(inputs) : undefined;
}
/**
* Set a value to a field of the object based on the model key, the object itself is updated
* of course but the mapped inputs are also synced (all of them if multiple). Events are also
* triggered to indicate the object has been updated (the general and the individual field ones).
*
* @param {string} modelKey
* @param {*|{}} value
*/
set(modelKey: string, value: string | number | string[] | undefined): void {
if (
!Object.prototype.hasOwnProperty.call(this.modelMapping, modelKey)
|| value === this.getValue(modelKey)
) {
return;
}
// First update the inputs then the model, so that the event is sent at last
this.updateInputValue(modelKey, value);
this.updateObjectByKey(modelKey, value);
}
/**
* Alternative to the event listening, you can watch a specific field of the model
* and assign a callback.
* When the specified model field is updated the event is still thrown but
* additionally any callback assigned
* to this specific value is also called, the parameter is the same event.
*
* @param {string | string[]} modelKeys
* @param {function} callback
*/
watch(modelKeys: string | string[], callback: (event: FormUpdateEvent) => void): void {
const watchedKeys: string[] = Array.isArray(modelKeys) ? modelKeys : [modelKeys];
watchedKeys.forEach((modelKey: string) => {
if (
!Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey)
) {
this.watchedProperties[modelKey] = [];
}
this.watchedProperties[modelKey].push(callback);
});
}
/**
* Returns a model field by modelKey converted as a BigNumber instance, it also cleans
* any invalid comma to avoid conversion error (since some languages sue comma as a decimal
* separator).
*
* @param modelKey
*/
getBigNumber(modelKey: string): BigNumber | undefined {
const numberValue = this.getValue(modelKey);
return isUndefined(numberValue) ? undefined : new BigNumber(numberCommaTransform(numberValue));
}
/**
* Get a field from the object based on the model key,
* you can even get a sub part of the whole model,
* Get a field from the object based on the model key,
* you can even get a sub part of the whole model,
* this internal method is used by both get and set public methods.
*
* @param {string} modelKey
*
* @returns {*|{}|undefined} Returns any element from the model, undefined if not found
*/
getValue(modelKey: string): string | number | string[] | undefined {
const modelKeys = modelKey.split('.');
return $.serializeJSON.deepGet(this.model, modelKeys);
}
/**
* Serializes and updates the object based on form content and the mapping configuration, an event will be triggered
* for each field of the object.
*/
updateFullObject():void {
// Temporarily enable all inputs or they will not be serialized
const $disabledInputs: JQuery<HTMLElement> = this.$form.find(':input:disabled').removeAttr('disabled');
const serializedFormArray = this.$form.serializeArray();
// Restore initial disabled state
$disabledInputs.prop('disabled', true);
const serializedFormMap: Record<string, any> = {};
serializedFormArray.forEach((value: NameValuePair) => {
serializedFormMap[value.name] = value.value;
});
this.model = {};
Object.keys(this.modelMapping).forEach((modelKey) => {
const formMapping = this.modelMapping[modelKey];
const formValue = serializedFormMap[formMapping];
this.updateObjectByKey(modelKey, formValue);
});
}
/**
* Watches if changes happens from the form or via an event.
*/
private watchUpdates(): void {
// Only watch change event, not keyup event, this reduces the number of computing while typing and it prevents a
// bug when using the NumberFormatter component which only applies on change event So both component must trigger
// on change event only if we want them to apply their modifications appropriately The second advantage is that
// debounce is not needed anymore which prevents any bug when form is submitted before un-focusing the input
this.$form.on(
'change dp.change',
':input',
(event: JQuery.TriggeredEvent) => this.inputUpdated(event),
);
}
/**
* Triggered when a form input has been changed.
*
* @param {JQuery.TriggeredEvent} event
*/
private inputUpdated(event: JQuery.TriggeredEvent): void {
const target = <HTMLInputElement>event.currentTarget;
// All inputs changes are watched, but not all of them are part of the mapping so we ignore them
if (!Object.prototype.hasOwnProperty.call(this.formMapping, target.name)) {
return;
}
const updatedValue = this.getInputValue($(target));
const updatedModelKey = this.formMapping[target.name];
// Update the mapped input fields
this.updateInputValue(updatedModelKey, updatedValue, target.name);
// Then update model and emit event
this.updateObjectByKey(updatedModelKey, updatedValue);
}
/**
* @param {jQuery} $input
*
* @returns {*}
*/
private getInputValue($input: JQuery): string | number | string[] | boolean | undefined {
if ($input.is(':checkbox')) {
return $input.is(':checked');
}
return $input.val();
}
/**
* Update all the inputs mapped to a model key
*
* @param {string} modelKey
* @param {*|{}} value
* @param {string|undefined} sourceInputName Source of the change (no need to update it)
*/
private updateInputValue(
modelKey: string,
value: string | number | string[] | boolean | undefined,
sourceInputName?: string,
): void {
const modelInputs = this.fullModelMapping[modelKey];
// Update linked inputs (when there is more than one input associated to the model field)
if (Array.isArray(modelInputs)) {
modelInputs.forEach((inputName) => {
if (sourceInputName === inputName) {
return;
}
this.updateInputByName(inputName, value);
});
} else if (sourceInputName !== modelInputs) {
this.updateInputByName(modelInputs, value);
}
}
/**
* Update individual input based on its name
*
* @param {string} inputName
* @param {*|{}} value
*/
private updateInputByName(
inputName: string,
value: string | number | string[] | boolean | undefined,
): void {
const $input: JQuery = $(`[name="${inputName}"]`, this.$form);
if (!$input.length) {
console.error(`Input with name ${inputName} is not present in form.`);
return;
}
if (!this.hasSameValue(this.getInputValue($input), value)) {
if ($input.is(':checkbox')) {
$input.val(value ? 1 : 0);
$input.prop('checked', !!value);
} else {
$input.val(<string>value);
}
if ($input.data('toggle') === 'select2') {
// This is required for select2, because only changing the val doesn't update
// the wrapping component
$input.trigger('change');
}
this.triggerChangeEvent(inputName);
}
}
/**
* Simulate change event programmatically, this is required because when changing the value of an input via js no
* change event is triggered, so if you added a listener for this event it won't trigger and your app will not
* behave as expected.
*
* @param inputName
*/
private triggerChangeEvent(inputName: string): void {
const input: HTMLInputElement = <HTMLInputElement>document.querySelector(`[name="${inputName}"]`);
if (!input) {
return;
}
const event = document.createEvent('HTMLEvents');
event.initEvent('change', false, true);
input.dispatchEvent(event);
}
/**
* Check if both values are equal regardless of their type.
*
* @param inputValue
* @param referenceValue
* @private
*/
private hasSameValue(
inputValue: string | number | boolean | string[] | undefined,
referenceValue: string | number | boolean | string[] | undefined,
): boolean {
/*
* We need a custom checking method for equality, we don't use strict equality on purpose because it would result
* into a potential infinite loop if type doesn't match, which can easily happen when checking values with different
* type but same values in essence.
*/
if (typeof inputValue === 'boolean' || typeof referenceValue === 'boolean') {
return <boolean>inputValue === <boolean>referenceValue;
}
/*
* And we also try to see if both values have the same BigNumber value, this avoids forcing a number input value when
* it's not written exactly the same way (like pending zeros). When checking a number we use the numberCommaTransform
* as numbers can be written with comma separator depending on the language.
*/
const referenceBigNumber: BigNumber = new BigNumber(numberCommaTransform(referenceValue));
const inputBigNumber: BigNumber = new BigNumber(numberCommaTransform(inputValue));
if (inputBigNumber.isEqualTo(referenceBigNumber)) {
return true;
}
// eslint-disable-next-line eqeqeq
return referenceValue == inputValue;
}
/**
* Update a specific field of the object.
*
* @param {string} modelKey
* @param {*|{}} value
*/
private updateObjectByKey(
modelKey: string,
value: string | number | string[] | boolean | undefined,
): void {
const modelKeys = modelKey.split('.');
const previousValue = $.serializeJSON.deepGet(this.model, modelKeys);
// This check has two interests, there is no point in modifying a value
// or emit an event for a value that did not
// change, and it avoids infinite loops when the object field are co-dependent and
// need to be updated dynamically
// (ex: update price tax included when price tax excluded is updated and
// vice versa, without this check an infinite
// loop would happen)
if (previousValue === value) {
return;
}
$.serializeJSON.deepSet(this.model, modelKeys, value);
const updateEvent: FormUpdateEvent = new UpdateEvent(
this.model,
modelKey,
value,
previousValue,
);
if (
Object.prototype.hasOwnProperty.call(this.watchedProperties, modelKey)
) {
const propertyWatchers = this.watchedProperties[modelKey];
propertyWatchers.forEach(
(callback: (param: FormUpdateEvent) => void) => {
if (!updateEvent.isPropagationStopped()) {
callback(updateEvent);
}
},
);
}
}
/**
* Reverse the initial mapping Model->Form to the opposite Form->Model
* This simplifies the sync in when data updates.
*/
private initFormMapping(): void {
Object.keys(this.fullModelMapping).forEach((modelKey) => {
const formMapping = this.fullModelMapping[modelKey];
if (Array.isArray(formMapping)) {
formMapping.forEach((aliasFormMapping) => {
this.addFormMapping(aliasFormMapping, modelKey);
});
} else {
this.addFormMapping(formMapping, modelKey);
}
});
}
/**
* @param {string} formName
* @param {string} modelMapping
*/
private addFormMapping(formName: string, modelMapping: string): void {
if (Object.prototype.hasOwnProperty.call(this.formMapping, formName)) {
console.error(
`The form element ${formName} is already mapped to ${this.formMapping[formName]}`,
);
return;
}
this.formMapping[formName] = modelMapping;
this.modelMapping[modelMapping] = formName;
}
}

View File

@@ -0,0 +1,89 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Component responsible for displaying form popover errors with modified
* width which is calculated based on the
* form group width.
*/
$(() => {
// loads form popover instance
$('[data-toggle="form-popover-error"]').popover({
html: true,
content() {
return getErrorContent(<HTMLElement> this);
},
});
/**
* Recalculates popover position so it is always aligned horizontally and width is identical
* to the child elements of the form.
* @param {Object} event
*/
const repositionPopover = (event: JQueryEventObject) => {
const $element = $(event.currentTarget);
const $formGroup = $element.closest('.form-group');
const $invalidFeedbackContainer = $formGroup.find(
'.invalid-feedback-container',
);
const $errorPopover = $formGroup.find('.form-popover-error');
const localeVisibleElementWidth: number = <number>(
$invalidFeedbackContainer.width()
);
$errorPopover.css('width', localeVisibleElementWidth);
const horizontalDifference = getHorizontalDifference(
$invalidFeedbackContainer,
$errorPopover,
);
$errorPopover.css('left', `${horizontalDifference}px`);
};
/**
* gets horizontal difference which helps to align popover horizontally.
* @param {jQuery} $invalidFeedbackContainer
* @param {jQuery} $errorPopover
* @returns {number}
*/
const getHorizontalDifference = (
$invalidFeedbackContainer: JQuery,
$errorPopover: JQuery,
): number | null => {
const invalidContainerOffset = $invalidFeedbackContainer.offset();
const errorPopoverOffset = $errorPopover.offset();
if (invalidContainerOffset && errorPopoverOffset) {
const inputHorizontalPosition = invalidContainerOffset.left;
const popoverHorizontalPosition = errorPopoverOffset.left;
return inputHorizontalPosition - popoverHorizontalPosition;
}
return null;
};
/**
* Gets popover error content pre-fetched in html.
* It used unique selector to identify which one content to render.
*
* @param popoverTriggerElement
* @returns {jQuery}
*/
const getErrorContent = (popoverTriggerElement: HTMLElement) => {
const popoverTriggerId = $(popoverTriggerElement).data('id');
return $(`.js-popover-error-content[data-id="${popoverTriggerId}"]`).html();
};
// registers the event which displays the popover
$(document).on(
'shown.bs.popover',
'[data-toggle="form-popover-error"]',
(event: JQueryEventObject) => repositionPopover(event),
);
});

View File

@@ -0,0 +1,38 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Used to display errors fetched from PrestaShop API in a JSON format.
* The expected format looks like this:
* {
* errors: {
* price: 'Invalid negative value',
* name: 'Forbidden blank value',
* },
* }
*
* @param jsonResponse
*/
export function notifyFormErrors(jsonResponse: any): void {
Object.keys(jsonResponse.errors).forEach((field: string) => {
if (Object.prototype.hasOwnProperty.call(jsonResponse.errors, field)) {
let fieldErrors: string[];
if (Array.isArray(jsonResponse.errors[field])) {
fieldErrors = jsonResponse.errors[field];
} else {
fieldErrors = [jsonResponse.errors[field]];
}
fieldErrors.forEach((error: string) => {
$.growl.error({message: `${field}: ${error}`});
});
}
});
};
export default {
notifyFormErrors,
};

View File

@@ -0,0 +1,21 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export default class MultipleZoneChoice {
constructor() {
this.initZoneChoice();
}
initZoneChoice(): void {
const $multipleZoneChoice = $('.js-multiple-zone-choice');
$multipleZoneChoice.select2(
{
multiple: true,
theme: 'classic',
placeholder: $multipleZoneChoice.data('placeholder'),
},
);
}
}

View File

@@ -0,0 +1,34 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
import initContextualNotification from '@components/contextual-notification';
const {$} = window;
export default class MultistoreConfigField {
constructor() {
this.updateMultistoreFieldOnChange();
initContextualNotification('checkbox');
}
updateMultistoreFieldOnChange(): void {
$(document).on('change', ComponentsMap.multistoreCheckbox, function () {
const input = $(this)
.closest(ComponentsMap.formGroup)
.find(ComponentsMap.inputNotCheckbox);
const inputContainer = $(this)
.closest(ComponentsMap.formGroup)
.find(ComponentsMap.inputContainer);
const labelContainer = $(this)
.closest(ComponentsMap.formGroup)
.find(ComponentsMap.formControlLabel);
const isChecked = $(this).is(':checked');
inputContainer.toggleClass('disabled', !isChecked);
labelContainer.toggleClass('disabled', !isChecked);
input.prop('disabled', !isChecked);
});
}
}

View File

@@ -0,0 +1,35 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Responsible for opening another page with specified url.
* For example used in 'Save and preview' cms page create/edit actions.
*
* Usage: In selector element attr 'data-preview-url' provide page url.
* The page will be opened once provided 'open_preview' parameter in query url
*/
export default class PreviewOpener {
previewUrl: string;
constructor(previewUrlSelector: string) {
this.previewUrl = $(previewUrlSelector).data('preview-url');
this.open();
}
/**
* Opens new page of provided url
*
* @private
*/
private open(): void {
const urlParams = new URLSearchParams(window.location.search);
if (this.previewUrl && urlParams.has('open_preview')) {
window.open(this.previewUrl, '_blank');
}
}
}

View File

@@ -0,0 +1,103 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import CurrencySymbolUpdater from '@components/form/currency-symbol-updater';
const {$} = window;
/**
* Handles dynamics (shows/hides fields, changes currency symbols) of price reduction form fields
*/
export default class PriceReductionManager {
private readonly reductionTypeSelector: string;
private readonly $reductionTypeSelect: JQuery;
private readonly $taxInclusionInputs: JQuery;
private readonly currencySelect: string;
private readonly reductionValueSymbolSelector: string;
private readonly currencySymbolUpdater: CurrencySymbolUpdater;
private readonly toggleCurrencySelector: string | null;
constructor(
reductionTypeSelector: string,
taxInclusionInputs: string,
currencySelect: string,
reductionValueSymbolSelector: string,
toggleCurrencySelector: string | null = null,
) {
this.reductionTypeSelector = reductionTypeSelector;
this.$reductionTypeSelect = $(reductionTypeSelector);
this.$taxInclusionInputs = $(taxInclusionInputs);
this.currencySelect = currencySelect;
this.reductionValueSymbolSelector = reductionValueSymbolSelector;
this.toggleCurrencySelector = toggleCurrencySelector;
this.currencySymbolUpdater = new CurrencySymbolUpdater(
this.currencySelect,
((symbol: string): void => {
if (symbol === '') {
return;
}
this.updateSymbol(symbol);
}),
);
this.handle();
this.$reductionTypeSelect.on('change', () => this.handle());
}
/**
* When source value is 'percentage', target field is shown, else hidden
*/
private handle(): void {
if (this.$reductionTypeSelect.val() === 'percentage') {
this.$taxInclusionInputs.fadeOut();
if (this.toggleCurrencySelector) {
$(this.toggleCurrencySelector).fadeOut();
}
} else {
this.$taxInclusionInputs.fadeIn();
if (this.toggleCurrencySelector) {
$(this.toggleCurrencySelector).fadeIn();
}
}
this.updateSymbol(this.currencySymbolUpdater.getSymbol());
}
private updateSymbol(symbol: string): void {
const reductionTypeSelect = <HTMLSelectElement> document.querySelector(this.reductionTypeSelector);
if (reductionTypeSelect) {
for (let i = 0; i < reductionTypeSelect.options.length; i += 1) {
const reductionOption = reductionTypeSelect.options[i];
if (reductionOption.value === 'amount') {
// Update reduction type choice "amount" symbol
reductionOption.innerHTML = symbol;
}
}
const selectedReduction = <string> reductionTypeSelect.options[reductionTypeSelect.selectedIndex].value;
const reductionValueSymbols = <NodeListOf<HTMLSelectElement>> document.querySelectorAll(
this.reductionValueSymbolSelector,
);
if (reductionValueSymbols.length === 0) {
return;
}
// Update reduction value field symbol when "amount" type is selected
reductionValueSymbols.forEach((value: Element) => {
// eslint-disable-next-line no-param-reassign
value.innerHTML = selectedReduction === 'amount' ? symbol : '%';
});
}
}
}

View File

@@ -0,0 +1,49 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import EntitySearchInput, {EntitySearchInputOptions} from '@components/entity-search-input';
type Product = {
name: string,
reference: string,
image: string,
}
export default class ProductSearchInput extends EntitySearchInput {
constructor(
searchInputContainer: string,
options: OptionsObject = <EntitySearchInputOptions> {},
) {
const searchInputContainerEl = <HTMLElement> document.querySelector(searchInputContainer);
const referenceLabel = searchInputContainerEl.dataset.referenceLabel ?? '(Ref: %s)';
// eslint-disable-next-line no-param-reassign
options.suggestionTemplate = (product: Product) => {
let reference = '';
if (product.reference) {
reference = `<span class="product-reference">(${product.reference})</span>`;
}
return `<div class="search-suggestion"><img src="${product.image}" /> ${product.name}${reference}</div>`;
};
// eslint-disable-next-line no-param-reassign
options.responseTransformer = (response: any) => {
Object.keys(response).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(response, key)) {
const combination = response[key];
if (combination.reference) {
// eslint-disable-next-line no-param-reassign
response[key].reference = referenceLabel.replace('%s', combination.reference);
}
}
});
return response;
};
super($(searchInputContainer), options);
}
}

View File

@@ -0,0 +1,65 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import SubmittableInput, {SubmittableInputConfig} from '@components/form/submittable-input';
import DeltaQuantityInput, {DeltaQuantityConfig} from '@components/form/delta-quantity-input';
type SubmittableConfig = Omit<SubmittableInputConfig, 'wrapperSelector'> & {
submittableWrapperSelector: string;
}
export type SubmittableDeltaConfig = Partial<DeltaQuantityConfig> & SubmittableConfig;
export default class SubmittableDeltaQuantityInput {
private deltaQuantityComponent: DeltaQuantityInput;
private submittableInputComponent: SubmittableInput;
constructor(deltaConfig: SubmittableDeltaConfig) {
const deltaQuantityConfig: Partial<DeltaQuantityConfig> = {};
if (deltaConfig.containerSelector) {
deltaQuantityConfig.containerSelector = deltaConfig.containerSelector;
}
if (deltaConfig.deltaInputSelector) {
deltaQuantityConfig.deltaInputSelector = deltaConfig.deltaInputSelector;
}
if (deltaConfig.updateQuantitySelector) {
deltaQuantityConfig.updateQuantitySelector = deltaConfig.updateQuantitySelector;
}
if (deltaConfig.modifiedQuantityClass) {
deltaQuantityConfig.modifiedQuantityClass = deltaConfig.modifiedQuantityClass;
}
if (deltaConfig.newQuantitySelector) {
deltaQuantityConfig.newQuantitySelector = deltaConfig.newQuantitySelector;
}
if (deltaConfig.initialQuantityPreviewSelector) {
deltaQuantityConfig.initialQuantityPreviewSelector = deltaConfig.initialQuantityPreviewSelector;
}
this.deltaQuantityComponent = new DeltaQuantityInput(deltaQuantityConfig);
this.submittableInputComponent = new SubmittableInput({
wrapperSelector: deltaConfig.submittableWrapperSelector,
submitCallback: deltaConfig.submitCallback,
afterSuccess: (
input: HTMLInputElement,
response: AjaxResponse,
) => this.reset(input, response, deltaConfig.afterSuccess),
});
}
private reset(
input: HTMLInputElement,
response: AjaxResponse,
afterSuccess?: (deltaInput: HTMLInputElement, ajaxResponse: AjaxResponse) => any,
): void {
this.deltaQuantityComponent.applyNewQuantity(input);
this.submittableInputComponent.reset(input, 0);
if (afterSuccess) {
afterSuccess(input, response);
}
}
}

View File

@@ -0,0 +1,188 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {showGrowl} from '@app/utils/growl';
import ComponentsMap from '@components/components-map';
import ClickEvent = JQuery.ClickEvent;
const {$} = window;
export type SubmittableInputConfig = {
wrapperSelector: string;
submitCallback: (input: HTMLInputElement) => any;
afterSuccess?: ((input: HTMLInputElement, response: AjaxResponse) => any);
afterFailure?: ((input: HTMLInputElement, error: AjaxError) => any);
}
/**
* Activates, deactivates, shows, hides submit button inside an input
* (depending if input was changed comparing to initial value)
* After button is clicked, component fires the callback function which was provided to constructor.
*/
export default class SubmittableInput {
config: SubmittableInputConfig;
inputSelector: string;
inputsInContainerSelector: string;
buttonSelector: string;
loading: boolean;
constructor(config: SubmittableInputConfig) {
this.config = config;
this.inputSelector = ComponentsMap.submittableInput.inputSelector;
this.buttonSelector = ComponentsMap.submittableInput.buttonSelector;
this.inputsInContainerSelector = `${this.config.wrapperSelector} ${this.inputSelector}`;
this.loading = false;
this.init();
}
public reset(input: HTMLInputElement, value: string|number): void {
$(input).val(value);
$(input).data('initialValue', value);
}
private init(): void {
$(document).on('focus mouseenter', this.inputsInContainerSelector, (e) => {
this.refreshButtonState(e.currentTarget, true);
});
$(document).on('input blur mouseleave', this.inputsInContainerSelector, (e) => {
this.refreshButtonState(e.currentTarget);
});
$(document).on('click', `${this.config.wrapperSelector} ${this.buttonSelector}`, (e: ClickEvent) => {
e.stopImmediatePropagation();
this.submitInput(this.findInput(e.currentTarget));
});
$(document).on('keyup', this.inputsInContainerSelector, (e: JQueryEventObject) => {
// only on ENTER
if (e.keyCode !== 13) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();
this.submitInput(e.target as HTMLInputElement);
});
}
private submitInput(input: HTMLInputElement): void {
if (this.loading || !this.inputValueChanged(input)) {
return;
}
this.toggleLoading(input, true);
const button = this.findButton(input);
this.config.submitCallback(input)
.then((response: AjaxResponse) => {
$(input).data('initialValue', input.value);
this.toggleButtonVisibility(button, false);
this.toggleLoading(input, false);
if (response.message) {
showGrowl('success', response.message);
}
if (this.config.afterSuccess) {
this.config.afterSuccess(input, response);
}
})
.catch((error: AjaxError) => {
this.toggleError(button, true);
this.toggleButtonVisibility(button, false);
this.toggleLoading(input, false);
if (typeof error.responseJSON.errors === 'undefined') {
return;
}
const messages = error.responseJSON.errors;
Object.keys(messages).forEach((key) => {
showGrowl('error', messages[key]);
});
if (this.config.afterFailure) {
this.config.afterFailure(input, error);
}
});
}
private refreshButtonState(
input: HTMLElement,
visible: boolean | null = null,
): void {
const button = this.findButton(input);
const valueWasChanged = this.inputValueChanged(input);
this.toggleButtonActivity(button, valueWasChanged);
if (visible !== null) {
this.toggleButtonVisibility(button, visible);
} else {
this.toggleButtonVisibility(button, valueWasChanged);
}
}
private toggleButtonActivity(button: HTMLElement, active: boolean): void {
$(button).toggleClass('active', active);
}
private toggleButtonVisibility(
button: Element,
visible: boolean,
): void {
const $button = $(button);
$button.toggleClass('d-none', !visible);
}
private toggleLoading(input: HTMLInputElement, loading: boolean): void {
this.loading = loading;
const button = this.findButton(input);
// eslint-disable-next-line no-param-reassign
input.disabled = this.loading;
button.disabled = this.loading;
if (this.loading) {
$(button).html('<span class="spinner-border spinner-border-sm"></span>');
} else {
$(button).html('<i class="material-icons">check</i>');
}
}
private toggleError(button: HTMLButtonElement, error: boolean): void {
const input = this.findInput(button);
$(input).toggleClass('is-invalid', error);
}
private findButton(input: Element): HTMLButtonElement {
return <HTMLButtonElement>$(input)
.closest(this.config.wrapperSelector)
.find(this.buttonSelector)[0];
}
private findInput(button: HTMLButtonElement): HTMLInputElement {
return <HTMLInputElement>$(button)
.closest(this.config.wrapperSelector)
.find(this.inputSelector)[0];
}
private inputValueChanged(input: HTMLElement): boolean {
const initialValue = $(input).data('initial-value');
let newValue = $(input).val();
if ($(input).hasClass('is-invalid')) {
$(input).removeClass('is-invalid');
}
if (typeof initialValue === 'number') {
newValue = Number(newValue);
}
return initialValue !== newValue;
}
}

View File

@@ -0,0 +1,55 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* TextWithLengthCounter handles input with length counter UI.
*
* Usage:
*
* There must be an element that wraps both
* input & counter display with ".js-text-with-length-counter" class.
* Counter display must have ".js-countable-text-display" class
* and input must have ".js-countable-text-input" class.
* Text input must have "data-max-length" attribute.
*
* <div class="js-text-with-length-counter">
* <span class="js-countable-text"></span>
* <input class="js-countable-input" data-max-length="255">
* </div>
*
* In Javascript you must enable this component:
*
* new TextWithLengthCounter();
*/
export default class TextWithLengthCounter {
wrapperSelector: string;
textSelector: string;
inputSelector: string;
constructor() {
this.wrapperSelector = '.js-text-with-length-counter';
this.textSelector = '.js-countable-text';
this.inputSelector = '.js-countable-input';
$(document).on(
'input',
`${this.wrapperSelector} ${this.inputSelector}`,
(e) => {
const $input = $(e.currentTarget);
const inputVal = <string>$input.val();
const remainingLength = $input.data('max-length') - inputVal.length;
$input
.closest(this.wrapperSelector)
.find(this.textSelector)
.text(remainingLength);
},
);
}
}

View File

@@ -0,0 +1,44 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
const {$} = window;
/**
* This component is implemented to work with TextWithRecommendedLengthType,
* but can be used as standalone component as well.
*
* Usage:
*
* Define your HTML with input and counter. Example:
*
* <input id="myInput"
* class="js-recommended-length-input"
* data-recommended-length-counter="#myInput_recommended_length_counter"
* >
*
* <div id"myInput_recommended_length_counter">
* <span class="js-current-length">0</span> of 70 characters used (recommended)
* </div>
*
* NOTE: You must use exactly the same Classes, but IDs can be different!
*
* Then enable component in JavaScript:
*
* new TextWithRecommendedLengthCounter();
*/
export default class TextWithRecommendedLengthCounter {
constructor() {
$(document).on('input', ComponentsMap.recommendedLengthInput, (event) => {
const $input = $(event.currentTarget);
const inputVal = <string>$input.val();
$($input.data('recommended-length-counter'))
.find(ComponentsMap.currentLength)
.text(inputVal.length);
});
}
}

View File

@@ -0,0 +1,54 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* This component toggles forms children based on a radio inputs.
*/
export default class ToggleChildrenChoice {
private readonly toggleChildrenChoice: string;
private readonly childrenSelect: string;
private readonly childrenRadios: string;
private readonly toggleChild: string;
private readonly selectedChild: (childName: string) => string;
constructor(options: Record<string, any> = {}) {
const opts = options || {};
this.toggleChildrenChoice = opts.toggleChildrenChoice || '.toggle-children-choice';
this.childrenSelect = opts.childrenSelect || ':scope > .form-group > .toggle-children-choice-select';
this.childrenRadios = `${this.childrenSelect} input[type="radio"]`;
this.toggleChild = opts.toggleChild || ':scope > .toggle-children-choice-container > .toggle-children-choice-child';
this.selectedChild = (childName: string) => `${this.toggleChild}[data-toggle-name=${childName}]`;
this.init();
}
private init(): void {
document.querySelectorAll<HTMLElement>(this.toggleChildrenChoice).forEach((toggleChildrenChoice: HTMLElement) => {
toggleChildrenChoice.querySelectorAll<HTMLInputElement>(this.childrenRadios).forEach((radio: HTMLInputElement) => {
radio.addEventListener('change', () => {
toggleChildrenChoice.querySelectorAll<HTMLElement>(this.toggleChild).forEach((formChild: HTMLElement) => {
formChild.classList.add('d-none');
});
// Value can be empty when a placeholder has been set
if (radio.value) {
const selectedChild = toggleChildrenChoice.querySelector<HTMLElement>(this.selectedChild(radio.value));
if (selectedChild) {
selectedChild.classList.remove('d-none');
}
}
const {eventEmitter} = window.prestashop.instance;
eventEmitter.emit('ToggleChildrenChoice:toggled', radio);
});
});
});
}
}

View File

@@ -0,0 +1,64 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
const {$} = window;
/**
* Component responsible for filtering select values by language selected.
*/
export default class TranslatableChoice {
constructor() {
// registers the event which displays the popover
$(document).on(
'change',
ComponentsMap.form.selectLanguage,
(event: JQueryEventObject) => {
this.filterSelect(event);
},
);
$('select.translatable_choice_language').trigger('change');
$('select.translatable_choice').trigger('change');
}
filterSelect(event: JQueryEventObject): void {
const $element = $(event.currentTarget);
const $formGroup = $element.closest('.form-group');
const language = $element.find('option:selected').val();
// show all the languages selects
$formGroup
.find(ComponentsMap.form.selectChoice(<string>language))
.parent()
.show();
const $selects = $formGroup.find('select.translatable_choice');
// Hide all the selects not corresponding to the language selected
$selects
.not(ComponentsMap.form.selectChoice(<string>language))
.each((index, item) => {
$(item)
.parent()
.hide();
});
// Bind choice selection to fill the hidden input
this.bindValueSelection($selects);
}
bindValueSelection($selects: JQuery): void {
$selects.each((index, element) => {
$(element).on('change', (event) => {
const $select = $(event.currentTarget);
const selectId = $select.attr('id');
const selectedValue = $select.find('option:selected').val();
$(`#${selectId}_value`).val(<string>selectedValue);
});
});
}
}

View File

@@ -0,0 +1,93 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Generates random values for inputs.
*
* Usage:
*
* There should be a button in HTML with 2 required data-* properties:
* 1. data-target-input-id - input id for which value should be generated
* 2. data-generated-value-size -
*
* Example button: <button class="js-generator-btn"
* data-target-input-id="my-input-id"
* data-generated-value-length="16"
* >
* Generate!
* </button>
*
* In JavaScript you have to enable this functionality using GeneratableInput component like so:
*
* const generateableInput = new GeneratableInput();
* generateableInput.attachOn('.js-generator-btn'); // every time our button is clicked
* // it will generate random value of 16 characters
* // for input with id of "my-input-id"
*
* Or if you use '.js-generator-btn' as default selector, you can just do:
* new GeneratableInput();
*
* You can attach as many different buttons as you like using "attachOn()" function
* as long as 2 required data-* attributes are present at each button.
*/
export default class GeneratableInput {
/**
* Constructor for GeneratableInput component
* Attach event listeners on buttons that can generate random values, by default ".js-generator-btn".
*
* @param {String} generatorButtonsSelector
*/
public constructor(generatorButtonsSelector?: string) {
this.attachOn(generatorButtonsSelector ?? '.js-generator-btn');
}
/**
* Attaches click event listeners on buttons than can generate random values
*
* @param {String} generatorButtonsSelector
*/
public attachOn(generatorButtonsSelector: string): void {
const generatorButtons = document.querySelectorAll(generatorButtonsSelector);
generatorButtons.forEach((btn: Element): void => {
btn.addEventListener('click', (event: Event): void => {
const {attributes} = <HTMLButtonElement>event.currentTarget;
const targetInputId = attributes.getNamedItem('data-target-input-id')
?.value;
const generatedValueLength = parseInt(
<string>attributes.getNamedItem('data-generated-value-length')?.value,
10,
);
const targetInput = <HTMLInputElement>(
document.querySelector(`#${targetInputId}`)
);
targetInput.value = this.generateValue(generatedValueLength);
targetInput.dispatchEvent(new CustomEvent('change', {bubbles: true}));
});
});
}
/**
* Generates random value for input
*
* @param {Number} length
*
* @returns {string}
*
* @private
*/
private generateValue(length: number): string {
const chars = '123456789ABCDEFGHIJKLMNPQRSTUVWXYZ';
let generatedValue = '';
for (let i = 1; i <= length; i += 1) {
generatedValue += chars.charAt(Math.floor(Math.random() * chars.length));
}
return generatedValue;
}
}

View File

@@ -0,0 +1,63 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class DeleteCategoriesBulkActionExtension handles submitting of row action
*/
export default class DeleteCategoriesBulkActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid.getContainer().on('click', GridMap.bulks.deleteCategories, (event) => {
event.preventDefault();
const submitUrl = $(event.currentTarget).data('categories-delete-url');
const $deleteCategoriesModal = $(
GridMap.bulks.deleteCategoriesModal(grid.getId()),
);
$deleteCategoriesModal.modal('show');
$deleteCategoriesModal.on(
'click',
GridMap.bulks.submitDeleteCategories,
() => {
const $checkboxes = grid
.getContainer()
.find(GridMap.bulks.checkedCheckbox);
const $categoriesToDeleteInputBlock = $(
GridMap.bulks.categoriesToDelete,
);
$checkboxes.each((i, element) => {
const $checkbox = $(element);
const categoryInput = $categoriesToDeleteInputBlock
.data('prototype')
.replace(/__name__/g, $checkbox.val());
const $input = $($.parseHTML(categoryInput)[0]);
$input.val(<string>$checkbox.val());
$categoriesToDeleteInputBlock.append($input);
});
const $form = $deleteCategoriesModal.find('form');
$form.attr('action', submitUrl);
$form.submit();
},
);
});
}
}

View File

@@ -0,0 +1,63 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Handles bulk delete for "Customers" grid.
*/
export default class DeleteCustomersBulkActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid.getContainer().on('click', GridMap.bulks.deleteCustomers, (event) => {
event.preventDefault();
const submitUrl = $(event.currentTarget).data('customers-delete-url');
const $modal = $(GridMap.bulks.deleteCustomerModal(grid.getId()));
$modal.modal('show');
$modal.on('click', GridMap.bulks.submitDeleteCustomers, () => {
const $selectedCustomerCheckboxes = grid
.getContainer()
.find(GridMap.bulks.checkedCheckbox);
$selectedCustomerCheckboxes.each((i, checkbox) => {
const $input = $(checkbox);
this.addCustomerToDeleteCollectionInput(<number>$input.val());
});
const $form = $modal.find('form');
$form.attr('action', submitUrl);
$form.submit();
});
});
}
/**
* Create input with customer id and add it to delete collection input
*
* @private
*/
private addCustomerToDeleteCollectionInput(customerId: number): void {
const $customersInput = $(GridMap.bulks.customersToDelete);
const customerInput = $customersInput
.data('prototype')
.replace(/__name__/g, customerId);
const $item = $($.parseHTML(customerInput)[0]);
$item.val(customerId);
$customersInput.append($item);
}
}

View File

@@ -0,0 +1,62 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class CategoryDeleteRowActionExtension handles submitting of row action
*/
export default class DeleteCategoryRowActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.rows.categoryDeleteAction, (event) => {
event.preventDefault();
const $deleteCategoriesModal = $(
GridMap.bulks.deleteCategoriesModal(grid.getId()),
);
$deleteCategoriesModal.modal('show');
$deleteCategoriesModal.on(
'click',
GridMap.bulks.submitDeleteCategories,
() => {
const $button = $(event.currentTarget);
const categoryId = $button.data('category-id');
const $categoriesToDeleteInputBlock = $(
GridMap.bulks.categoriesToDelete,
);
const categoryInput = $categoriesToDeleteInputBlock
.data('prototype')
.replace(
/__name__/g,
$categoriesToDeleteInputBlock.children().length,
);
const $item = $($.parseHTML(categoryInput)[0]);
$item.val(categoryId);
$categoriesToDeleteInputBlock.append($item);
const $form = $deleteCategoriesModal.find('form');
$form.attr('action', $button.data('category-delete-url'));
$form.submit();
},
);
});
}
}

View File

@@ -0,0 +1,67 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class DeleteCustomerRowActionExtension handles submitting of row action
*/
export default class DeleteCustomerRowActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.rows.customerDeleteAction, (event) => {
event.preventDefault();
const $deleteCustomersModal = $(
GridMap.bulks.deleteCustomerModal(grid.getId()),
);
$deleteCustomersModal.modal('show');
$deleteCustomersModal.on(
'click',
GridMap.bulks.submitDeleteCustomers,
() => {
const $button = $(event.currentTarget);
const customerId = $button.data('customer-id');
this.addCustomerInput(customerId);
const $form = $deleteCustomersModal.find('form');
$form.attr('action', $button.data('customer-delete-url'));
$form.submit();
},
);
});
}
/**
* Adds input for selected customer to delete form
*
* @param {integer} customerId
*
* @private
*/
private addCustomerInput(customerId: number): void {
const $customersToDeleteInputBlock = $(GridMap.bulks.customersToDelete);
const customerInput = $customersToDeleteInputBlock
.data('prototype')
.replace(/__name__/g, $customersToDeleteInputBlock.children().length);
const $item = $($.parseHTML(customerInput)[0]);
$item.val(customerId);
$customersToDeleteInputBlock.append($item);
}
}

View File

@@ -0,0 +1,36 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class DeleteCustomerRowActionExtension handles submitting of row action
*/
export default class DeleteImageTypeRowActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.rows.imageTypeDeleteAction, (event) => {
event.preventDefault();
const $button = $(event.currentTarget);
const $deleteImageTypeModal = $(GridMap.rows.deleteImageTypeModal(grid.getId()));
$deleteImageTypeModal.modal('show');
$deleteImageTypeModal.on('click', GridMap.rows.submitDeleteImageType, () => {
const $form = $deleteImageTypeModal.find('form');
$form.attr('action', $button.data('delete-url'));
$form.submit();
});
});
}
}

View File

@@ -0,0 +1,103 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
import {ConfirmModal} from '@components/modal';
const {$} = window;
/**
* Class SubmitRowActionExtension handles submitting of row action
*/
export default class SubmitRowActionExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid.getContainer().on('click', '.js-submit-row-action', (event) => {
event.preventDefault();
const $button = $(event.currentTarget);
const confirmMessage = $button.data('confirmMessage');
const confirmTitle = $button.data('title');
const method = $button.data('method');
if (confirmTitle) {
this.showConfirmModal(
$button,
grid,
confirmMessage,
confirmTitle,
method,
);
} else {
// eslint-disable-next-line
if (confirmMessage.length && !window.confirm(confirmMessage)) {
return;
}
this.postForm($button, method);
}
});
}
postForm($button: JQuery, method: string): void {
const isGetOrPostMethod = ['GET', 'POST'].includes(method);
const $form = $('<form>', {
action: $button.data('url'),
method: isGetOrPostMethod ? method : 'POST',
}).appendTo('body');
if (!isGetOrPostMethod) {
$form.append(
$('<input>', {
type: 'hidden',
name: '_method',
value: method,
}),
);
}
$form.submit();
}
/**
* @param {jQuery} $submitBtn
* @param {Grid} grid
* @param {string} confirmMessage
* @param {string} confirmTitle
* @param {string} method
*/
showConfirmModal(
$submitBtn: JQuery,
grid: Grid,
confirmMessage: string,
confirmTitle: string,
method: string,
): void {
const confirmButtonLabel = $submitBtn.data('confirmButtonLabel');
const closeButtonLabel = $submitBtn.data('closeButtonLabel');
const confirmButtonClass = $submitBtn.data('confirmButtonClass');
const modal = new ConfirmModal(
{
id: GridMap.confirmModal(grid.getId()),
confirmTitle,
confirmMessage,
confirmButtonLabel,
closeButtonLabel,
confirmButtonClass,
},
() => this.postForm($submitBtn, method),
);
modal.show();
}
}

View File

@@ -0,0 +1,174 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import ProgressModal from '@components/modal/progress-modal';
import ConfirmModal from '@components/modal/confirm-modal';
import GridMap from '@components/grid/grid-map';
import Router from '@components/router';
import ClickEvent = JQuery.ClickEvent;
const {$} = window;
/**
* Handles submit of grid actions
*/
export default class AjaxBulkActionExtension {
private router = new Router();
/**
* Extend grid with bulk action submitting
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.bulks.ajaxAction, (event: ClickEvent) => {
const $ajaxButton: JQuery<HTMLInputElement> = $<HTMLInputElement>(event.currentTarget);
const $checkboxes: JQuery<HTMLInputElement> = $<HTMLInputElement>(GridMap.bulks.checkedCheckbox);
const selectedIds: string[] = $checkboxes.get().map((checkbox: HTMLInputElement) => checkbox.value);
if (selectedIds.length === 0) {
return;
}
const confirmBulkAction = $ajaxButton.data('confirmBulkAction') ?? true;
if (confirmBulkAction) {
const progressionTitle = $ajaxButton.data('progressTitle');
const closeButtonLabel = $ajaxButton.data('cancelLabel') || 'Cancel';
const confirmTitle = $ajaxButton.data('confirmTitle') || 'Apply modifications';
const bulkAction = $ajaxButton.data('bulkAction') ?? 'bulk-action';
const confirmModal = new ConfirmModal({
id: GridMap.actions.ajaxBulkActionConfirmModal(grid.id, bulkAction),
modalTitle: confirmTitle,
closeButtonLabel,
confirmMessage: progressionTitle.replace('%total%', selectedIds.length),
confirmButtonLabel: confirmTitle,
confirmCallback: () => {
this.submitForm(grid, $<HTMLInputElement>(event.currentTarget), selectedIds);
},
});
confirmModal.show();
} else {
this.submitForm(grid, $<HTMLInputElement>(event.currentTarget), selectedIds);
}
});
}
private async submitForm(grid: Grid, $ajaxButton: JQuery<HTMLInputElement>, selectedIds: string[]): Promise<void> {
const bulkChunkSize = $ajaxButton.data('bulkChunkSize') ?? 10;
const reloadAfterBulk = $ajaxButton.data('reloadAfterBulk') ?? true;
const bulkAction = $ajaxButton.data('bulkAction') ?? 'bulk-action';
const progressionTitle = $ajaxButton.data('progressTitle');
const progressionMessage = $ajaxButton.data('progressMessage');
const closeLabel = $ajaxButton.data('closeLabel');
const abortProcessingLabel = $ajaxButton.data('stopProcessing');
const errorsMessage = $ajaxButton.data('errorsMessage');
const backToProcessingLabel = $ajaxButton.data('backToProcessing');
const downloadErrorLogLabel = $ajaxButton.data('downloadErrorLog');
const viewErrorLogLabel = $ajaxButton.data('viewErrorLog');
const viewErrorTitle = $ajaxButton.data('viewErrorTitle');
const abortController = new AbortController();
const modal = new ProgressModal({
id: GridMap.actions.ajaxBulkActionProgressModal(grid.id, bulkAction),
abortCallback: () => {
stopProcess = true;
abortController.abort();
},
closeCallback: () => {
if (reloadAfterBulk) {
$<HTMLInputElement>(GridMap.bulks.checkedCheckbox).filter(':checked').prop('checked', false);
window.location.reload();
}
},
progressionTitle,
progressionMessage,
closeLabel,
abortProcessingLabel,
errorsMessage,
backToProcessingLabel,
downloadErrorLogLabel,
viewErrorLogLabel,
viewErrorTitle,
total: selectedIds.length,
});
modal.show();
let stopProcess = false;
let doneCount = 0;
while (selectedIds.length) {
const chunkIds: string[] = selectedIds.splice(0, bulkChunkSize);
if (stopProcess) {
break;
}
let data: Record<string, any>;
try {
// eslint-disable-next-line no-await-in-loop
const response = await this.callAjaxAction($ajaxButton, chunkIds, abortController.signal);
// eslint-disable-next-line no-await-in-loop
data = await response.json();
} catch (e: any) {
data = {error: `Something went wrong with IDs ${chunkIds.join(', ')}: ${e.message ?? ''}`};
}
doneCount += chunkIds.length;
modal.updateProgress(doneCount);
if (!data.success) {
if (data.errors && Array.isArray(data.errors)) {
data.errors.forEach((error:string) => {
modal.addError(error);
});
} else {
modal.addError(data.errors ?? data.error ?? data.message);
}
}
}
modal.completeProgress();
}
private callAjaxAction($ajaxButton: JQuery<HTMLInputElement>, chunkIds: string[], abortSignal: AbortSignal): Promise<Response> {
const requestParamName: string = $ajaxButton.data('requestParamName') ?? 'bulk_ids';
const routeParams: Record<string, any> = $ajaxButton.data('routeParams') ?? {};
const routeMethod: string = $ajaxButton.data('routeMethod') ?? 'POST';
const formData: FormData = new FormData();
chunkIds.forEach((chunkId: string, index: number) => {
formData.append(`${requestParamName}[${index}]`, chunkId);
});
let requestMethod: string;
// For PATCH and DELETE request we use a POST request but we use the _method for Symfony to handle it
switch (routeMethod.toUpperCase()) {
case 'PATCH':
case 'DELETE':
requestMethod = 'POST';
break;
default:
requestMethod = routeMethod;
break;
}
return fetch(this.router.generate($ajaxButton.data('ajax-url'), routeParams), {
method: requestMethod,
body: formData,
headers: {
_method: routeMethod,
},
signal: abortSignal,
});
}
}

View File

@@ -0,0 +1,115 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class BulkActionSelectCheckboxExtension
*/
export default class BulkActionCheckboxExtension {
/**
* Extend grid with bulk action checkboxes handling functionality
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
this.handleBulkActionCheckboxStatus(grid);
this.handleBulkActionCheckboxSelect(grid);
this.handleBulkActionSelectAllCheckbox(grid);
}
/**
* Disable/Enable "Select all" button in the grid
*
* @param {Grid} grid
*
* @private
*/
private handleBulkActionCheckboxStatus(grid: Grid) {
const gridBulkActionSelectAll = grid.getContainer().find(GridMap.bulks.actionSelectAll);
gridBulkActionSelectAll.prop(
'disabled',
grid.getContainer().find(GridMap.bulks.bulkActionCheckbox).length === 0,
);
}
/**
* Handles "Select all" button in the grid
*
* @param {Grid} grid
*
* @private
*/
private handleBulkActionSelectAllCheckbox(grid: Grid) {
grid.getContainer().on('change', GridMap.bulks.actionSelectAll, (e) => {
const $checkbox = $(e.currentTarget);
const isChecked = $checkbox.is(':checked');
if (isChecked) {
this.enableBulkActionsBtn(grid);
} else {
this.disableBulkActionsBtn(grid);
}
grid
.getContainer()
.find(GridMap.bulks.bulkActionCheckbox)
.prop('checked', isChecked);
});
}
/**
* Handles each bulk action checkbox select in the grid
*
* @param {Grid} grid
*
* @private
*/
private handleBulkActionCheckboxSelect(grid: Grid) {
grid.getContainer().on('change', GridMap.bulks.bulkActionCheckbox, () => {
const checkedRowsCount = grid
.getContainer()
.find(GridMap.bulks.checkedCheckbox).length;
if (checkedRowsCount > 0) {
this.enableBulkActionsBtn(grid);
} else {
this.disableBulkActionsBtn(grid);
}
});
}
/**
* Enable bulk actions button
*
* @param {Grid} grid
*
* @private
*/
private enableBulkActionsBtn(grid: Grid): void {
grid
.getContainer()
.find(GridMap.bulks.bulkActionBtn)
.prop('disabled', false);
}
/**
* Disable bulk actions button
*
* @param {Grid} grid
*
* @private
*/
private disableBulkActionsBtn(grid: Grid): void {
grid
.getContainer()
.find(GridMap.bulks.bulkActionBtn)
.prop('disabled', true);
}
}

View File

@@ -0,0 +1,70 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
import Router from '../../router';
const {$} = window;
/**
* Class BulkOpenTabsExtension
*/
export default class BulkOpenTabsExtension {
router: Router;
constructor() {
this.router = new Router();
}
/**
* Extend grid with bulk action open tabs
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.bulks.openTabsBtn, (event: JQueryEventObject) => {
this.openTabs(event, grid);
});
}
/**
* Handle bulk action opening tabs
*
* @param {Event} event
* @param {Grid} grid
*
* @private
*/
openTabs(event: JQueryEventObject, grid: Grid): void {
const $submitBtn = $(event.currentTarget);
const route = $submitBtn.data('route');
const routeParamName = $submitBtn.data('routeParamName');
const tabsBlockedMessage = $submitBtn.data('tabsBlockedMessage');
const $checkboxes = grid.getContainer().find(GridMap.bulks.checkedCheckbox);
let allTabsOpened = true;
$checkboxes.each((i, element) => {
const $checkbox = $(element);
const routeParams = {};
// @ts-ignore
routeParams[routeParamName] = $checkbox.val();
const handle = window.open(this.router.generate(route, routeParams));
if (handle) {
handle.blur();
window.focus();
} else {
allTabsOpened = false;
}
if (!allTabsOpened) {
alert(tabsBlockedMessage);
}
});
}
}

View File

@@ -0,0 +1,91 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* This extension enables submit functionality of the choice fields in grid.
*
* Usage of the extension:
*
* const myGrid = new Grid('myGrid');
* myGrid.addExtension(new ChoiceExtension());
*
*/
export default class ChoiceExtension {
lockArray: Array<string>;
constructor() {
this.lockArray = [];
}
extend(grid: Grid): void {
const $choiceOptionsContainer = grid
.getContainer()
.find(GridMap.bulks.choiceOptions);
$choiceOptionsContainer.find(GridMap.dropdownItem).on('click', (e) => {
e.preventDefault();
const $button = $(e.currentTarget);
const $parent = $button.closest(GridMap.bulks.choiceOptions);
const url = $parent.data('url');
this.submitForm(url, $button);
});
}
/**
* Submits the form.
* @param {string} url
* @param {jQuery} $button
* @private
*/
private submitForm(url: string, $button: JQuery) {
const selectedStatusId = $button.data('value');
if (this.isLocked(url)) {
return;
}
const $form = $('<form>', {
action: url,
method: 'POST',
}).append(
$('<input>', {
name: 'value',
value: selectedStatusId,
type: 'hidden',
}),
);
$form.appendTo('body');
$form.submit();
this.lock(url);
}
/**
* Checks if current url is being used at the moment.
*
* @param url
* @return {boolean}
*
* @private
*/
private isLocked(url: string): boolean {
return this.lockArray.includes(url);
}
/**
* Locks the current url so it cant be used twice to execute same request
* @param url
* @private
*/
private lock(url: string): void {
this.lockArray.push(url);
}
}

View File

@@ -0,0 +1,52 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class ReloadListExtension extends grid with "Column toggling" feature
*/
export default class ColumnTogglingExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
const $table = grid.getContainer().find(GridMap.table);
$table.find(GridMap.togglableRow).on('click', (e) => {
e.preventDefault();
this.toggleValue($(e.delegateTarget));
});
}
/**
* @param {jQuery} row
* @private
*/
private toggleValue(row: JQuery) {
const toggleUrl = row.data('toggleUrl');
this.submitAsForm(toggleUrl);
}
/**
* Submits request url as form
*
* @param {string} toggleUrl
* @private
*/
private submitAsForm(toggleUrl: string) {
const $form = $('<form>', {
action: toggleUrl,
method: 'POST',
}).appendTo('body');
$form.submit();
}
}

View File

@@ -0,0 +1,169 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import 'tablednd/dist/jquery.tablednd.min';
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class CategoryPositionExtension extends Grid with reorderable category positions
*/
export default class CategoryPositionExtension {
grid: Grid;
originalPositions: string;
constructor(grid: Grid) {
this.grid = grid;
this.originalPositions = '';
}
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
this.grid = grid;
this.addIdsToGridTableRows();
grid
.getContainer()
.find(GridMap.gridTable)
.tableDnD({
dragHandle: GridMap.dragHandler,
onDragClass: 'dragging-row',
onDragStart: () => {
this.originalPositions = decodeURIComponent($.tableDnD.serialize());
},
onDrop: (table: HTMLElement, row: HTMLElement) => this.handleCategoryPositionChange(row),
});
}
/**
* When position is changed handle update
*
* @param {HTMLElement} row
*
* @private
*/
handleCategoryPositionChange(row: HTMLElement): void {
const positions = decodeURIComponent($.tableDnD.serialize());
const way = this.originalPositions.indexOf(row.id) < positions.indexOf(row.id)
? 1
: 0;
const $categoryPositionContainer = $(row).find(
GridMap.position(this.grid.getId()),
);
const categoryId = $categoryPositionContainer.data('id');
const categoryParentId = $categoryPositionContainer.data('id-parent');
const positionUpdateUrl = $categoryPositionContainer.data(
'position-update-url',
);
let params = positions.replace(
new RegExp(GridMap.specificGridTable(this.grid.getId()), 'g'),
'positions',
);
const queryParams = {
id_category_parent: categoryParentId,
id_category_to_move: categoryId,
way,
found_first: 0,
};
if (positions.indexOf('_0&') !== -1) {
queryParams.found_first = 1;
}
params += `&${$.param(queryParams)}`;
this.updateCategoryPosition(positionUpdateUrl, params);
}
/**
* Add ID's to Grid table rows to make tableDnD.onDrop() function work.
*
* @private
*/
addIdsToGridTableRows(): void {
this.grid
.getContainer()
.find(GridMap.gridTable)
.find(GridMap.gridPosition(this.grid.getId()))
.each((index, positionWrapper) => {
const $positionWrapper = $(positionWrapper);
const categoryId = $positionWrapper.data('id');
const categoryParentId = $positionWrapper.data('id-parent');
const position = $positionWrapper.data('position');
const id = `tr_${categoryParentId}_${categoryId}_${position}`;
$positionWrapper.closest('tr').attr('id', id);
});
}
/**
* Update categories listing with new positions
*
* @private
*/
updateCategoryIdsAndPositions(): void {
this.grid
.getContainer()
.find(GridMap.gridTable)
.find(GridMap.gridPosition(this.grid.getId()))
.each((index, positionWrapper) => {
const $positionWrapper = $(positionWrapper);
const $row = $positionWrapper.closest('tr');
const offset = $positionWrapper.data('pagination-offset');
const newPosition = offset > 0 ? index + offset : index;
const oldId = $row.attr('id');
if (oldId) {
$row.attr('id', oldId.replace(/_[0-9]$/g, `_${newPosition}`));
}
$positionWrapper.find(GridMap.selectPosition).text(newPosition + 1);
$positionWrapper.data('position', newPosition);
});
}
/**
* Process categories positions update
*
* @param {String} url
* @param {String} params
*
* @private
*/
updateCategoryPosition(url: string, params: string): void {
$.post({
url,
headers: {
'cache-control': 'no-cache',
},
data: params,
dataType: 'json',
}).then((response) => {
if (response.success) {
window.showSuccessMessage(response.message);
} else {
window.showErrorMessage(response.message);
}
this.updateCategoryIdsAndPositions();
});
}
}

View File

@@ -0,0 +1,98 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class AsyncToggleColumnExtension submits toggle action using AJAX
*/
export default class AsyncToggleColumnExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.find(GridMap.gridTable)
.on('click', GridMap.togglableRow, (event) => {
const $button = $(event.currentTarget);
if (!$button.hasClass('ps-switch')) {
event.preventDefault();
}
const $newStateInput = $button.find('input:checked');
const newState = Boolean($newStateInput.val());
$.post({
url: $button.data('toggle-url'),
})
.then((response) => {
if (response.status) {
window.showSuccessMessage(response.message);
this.toggleButtonDisplay($button);
return;
}
this.showErrorMessage(response.message, $newStateInput.prop('name'), !newState);
})
.catch((error: AjaxError) => {
const response = error.responseJSON;
this.showErrorMessage(response.message, $newStateInput.prop('name'), !newState);
});
});
}
private showErrorMessage(message: string, switchName: string, initialState: boolean): void {
// We need to toggle back the switch state
this.toggleSwitch(switchName, initialState);
window.showErrorMessage(message);
}
private toggleSwitch(switchName: string, checked: boolean): void {
const $switchOn = $(`[name="${switchName}"][value="1"]`);
const $switchOff = $(`[name="${switchName}"][value="0"]`);
if ($switchOn.is(':checked') !== checked) {
$switchOn.prop('checked', checked);
}
if ($switchOff.is(':checked') === checked) {
$switchOff.prop('checked', !checked);
}
}
/**
* Toggle button display from enabled to disabled and other way around
*
* @param {jQuery} $button
*
* @private
*/
private toggleButtonDisplay($button: JQuery): void {
const isActive = $button.hasClass('grid-toggler-icon-valid');
const classToAdd = isActive
? 'grid-toggler-icon-not-valid'
: 'grid-toggler-icon-valid';
const classToRemove = isActive
? 'grid-toggler-icon-valid'
: 'grid-toggler-icon-not-valid';
const icon = isActive ? 'clear' : 'check';
$button.removeClass(classToRemove);
$button.addClass(classToAdd);
if ($button.hasClass('material-icons')) {
$button.text(icon);
}
}
}

View File

@@ -0,0 +1,108 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class ExportToSqlManagerExtension extends grid with exporting query to SQL Manager
*/
export default class ExportToSqlManagerExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getHeaderContainer()
.on('click', GridMap.actions.showQuery, () => this.onShowSqlQueryClick(grid));
grid
.getHeaderContainer()
.on('click', GridMap.actions.exportQuery, () => this.onExportSqlManagerClick(grid));
}
/**
* Invoked when clicking on the "show sql query" toolbar button
*
* @param {Grid} grid
*
* @private
*/
onShowSqlQueryClick(grid: Grid): void {
const $sqlManagerForm = $(GridMap.actions.showModalForm(grid.getId()));
this.fillExportForm($sqlManagerForm, grid);
const $modal = $(GridMap.actions.showModalGrid(grid.getId()));
$modal.modal('show');
$modal.on('click', GridMap.sqlSubmit, () => $sqlManagerForm.submit());
}
/**
* Invoked when clicking on the "export to the sql query" toolbar button
*
* @param {Grid} grid
*
* @private
*/
private onExportSqlManagerClick(grid: Grid): void {
const $sqlManagerForm = $(GridMap.actions.showModalForm(grid.getId()));
this.fillExportForm($sqlManagerForm, grid);
$sqlManagerForm.submit();
}
/**
* Fill export form with SQL and it's name
*
* @param {jQuery} $sqlManagerForm
* @param {Grid} grid
*
* @private
*/
private fillExportForm($sqlManagerForm: JQuery, grid: Grid) {
const query = grid
.getContainer()
.find(GridMap.gridTable)
.data('query');
$sqlManagerForm.find('textarea[name="sql"]').val(query);
$sqlManagerForm
.find('input[name="name"]')
.val(this.getNameFromBreadcrumb());
}
/**
* Get export name from page's breadcrumb
*
* @return {String}
*
* @private
*/
private getNameFromBreadcrumb(): string {
const $breadcrumbs = $(GridMap.headerToolbar).find(GridMap.breadcrumbItem);
let name = '';
$breadcrumbs.each((i, item) => {
const $breadcrumb = $(item);
const breadcrumbTitle = $breadcrumb.find('a').length > 0
? $breadcrumb.find('a').text()
: $breadcrumb.text();
if (name.length > 0) {
name = name.concat(' > ');
}
name = name.concat(breadcrumbTitle);
});
return name;
}
}

View File

@@ -0,0 +1,29 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import resetSearch from '@app/utils/reset_search';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class FiltersResetExtension extends grid with filters resetting
*/
export default class FiltersResetExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid.getContainer().on('click', GridMap.resetSearch, (event) => {
resetSearch(
$(event.currentTarget).data('url'),
$(event.currentTarget).data('redirect'),
);
});
}
}

View File

@@ -0,0 +1,27 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
/**
* Responsible for grid filters search and reset button availability when filter inputs changes.
*/
export default class FiltersSubmitButtonEnablerExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
const $filtersRow = grid.getContainer().find(GridMap.columnFilters);
$filtersRow.find(GridMap.gridSearchButton).prop('disabled', true);
$filtersRow.find(GridMap.inputAndSelect).on('input dp.change', () => {
$filtersRow.find(GridMap.gridSearchButton).prop('disabled', false);
$filtersRow.find(GridMap.gridResetButton).prop('hidden', false);
});
}
}

View File

@@ -0,0 +1,100 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
import {isUndefined} from '@components/typeguard';
const {$} = window;
type OnClickCallbackFunction = (button: HTMLElement) => void;
/**
* Class LinkRowActionExtension handles link row actions
*/
export default class LinkRowActionExtension {
private readonly onClick?: OnClickCallbackFunction | undefined;
constructor(onClick:OnClickCallbackFunction | undefined = undefined) {
this.onClick = onClick;
}
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
this.initRowLinks(grid);
this.initConfirmableActions(grid);
}
/**
* Extend grid
*
* @param {Grid} grid
*/
initConfirmableActions(grid: Grid): void {
grid.getContainer().on('click', GridMap.rows.linkRowAction, (event) => {
const confirmMessage = $(event.currentTarget).data('confirm-message');
if (confirmMessage.length && !window.confirm(confirmMessage)) {
event.preventDefault();
}
});
}
/**
* Add a click event on rows that matches the first link action (if present)
*
* @param {Grid} grid
*/
initRowLinks(grid: Grid): void {
const onClickCallback = this.onClick;
$('tr', grid.getContainer()).each(function initEachRow() {
const $parentRow = $(this);
$(GridMap.rows.linkRowActionClickableFirst, $parentRow).each(
function propagateFirstLinkAction() {
const $rowAction = $(this);
const $parentCell = $rowAction.closest('td');
const clickableCells = $(GridMap.rows.clickableTd, $parentRow).not(
$parentCell,
);
let isDragging = false;
clickableCells.addClass('cursor-pointer').on('mousedown', () => {
$(window).on('mousemove', () => {
isDragging = true;
$(window).off('mousemove');
});
});
clickableCells.on('mouseup', () => {
const wasDragging = isDragging;
isDragging = false;
$(window).off('mousemove');
if (!wasDragging) {
const confirmMessage = $rowAction.data('confirm-message');
if (
!confirmMessage.length
|| (window.confirm(confirmMessage) && $rowAction.attr('href'))
) {
if (!isUndefined(onClickCallback) && !isUndefined($rowAction.get(0))) {
onClickCallback($rowAction.get(0) as HTMLElement);
} else {
document.location.href = <string>$rowAction.attr('href');
}
}
}
});
},
);
});
}
}

View File

@@ -0,0 +1,66 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Allows submitting form inside modals.
* Form must be inside modal, see example structure below:
*
* <div class="modal" id="uniqueModalId">
* <form data-bulk-inputs-id="bulkInputs">
* <div class="d-none">
* <div id="bulkInputs" data-prototype="<input type="hidden" name="__name__"/>"></div>
* </div>
* </form>
* </div>
*
* Note that "data-prototype" is required to add checked items to the form. "__name__"
* will be replaced with value of bulk checkbox.
*/
export default class ModalFormSubmitExtension {
extend(grid: Grid): void {
grid
.getContainer()
.on(
'click',
GridMap.bulks.modalFormSubmitBtn,
(event: JQueryEventObject) => {
const modalId = $(event.target).data('modal-id');
const $modal = $(`#${modalId}`);
$modal.modal('show');
$modal.find(GridMap.actions.submitModalFormBtn).on('click', () => {
const $form = $modal.find('form');
const $bulkInputsBlock = $form.find(
GridMap.actions.bulkInputsBlock($form.data('bulk-inputs-id')),
);
const $checkboxes = grid
.getContainer()
.find(GridMap.bulks.checkedCheckbox);
$checkboxes.each((i, element) => {
const $checkbox = $(element);
const input = $bulkInputsBlock
.data('prototype')
.replace(/__name__/g, $checkbox.val());
const $input = $($.parseHTML(input)[0]);
$input.val(<string>$checkbox.val());
$form.append($input);
});
$form.submit();
});
},
);
}
}

View File

@@ -0,0 +1,307 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
import {isUndefined} from '@components/typeguard';
import 'tablednd/dist/jquery.tablednd.min';
const {$} = window;
interface RowDatas {
rowMarker: string;
offset: number;
}
interface DNDPositions {
rowId: string;
oldPosition: number;
newPosition: number;
}
/**
* Class PositionExtension extends Grid with reorderable positions
*/
export default class PositionExtension {
grid: Grid;
constructor(grid: Grid) {
this.grid = grid;
}
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
this.grid = grid;
this.addIdsToGridTableRows();
grid
.getContainer()
.find(GridMap.gridTable)
.tableDnD({
onDragClass: GridMap.onDragClass,
dragHandle: GridMap.dragHandler,
onDrop: (table: HTMLElement, row: HTMLElement) => this.handlePositionChange(row),
});
grid
.getContainer()
.find('.js-drag-handle')
.on(
'mouseenter',
function () {
$(this)
.closest('tr')
.addClass('hover');
},
).on(
'mouseleave',
function () {
$(this)
.closest('tr')
.removeClass('hover');
},
);
this.setReorderButtonLabel();
this.getReorderButton().on('click', (event) => this.oncClickReorderButton(event));
}
/**
* When position is changed handle update
*
* @param {HTMLElement} row
*
* @private
*/
private handlePositionChange(row: HTMLElement): void {
const $rowPositionContainer = $(row).find(
GridMap.gridPositionFirst(this.grid.getId()),
);
const updateUrl = $rowPositionContainer.data('update-url');
const method = $rowPositionContainer.data('update-method');
const positions = this.getRowsPositions();
const params = {positions};
this.updatePosition(updateUrl, params, method);
}
/**
* Returns the current table positions
* @returns {Array}
* @private
*/
private getRowsPositions(): Array<DNDPositions> {
const tableData = JSON.parse($.tableDnD.jsonize());
const rowsData = tableData[`${this.grid.getId()}_grid_table`];
const completeRowsData = [];
let trData;
// retrieve dragAndDropOffset offset to have all needed data
// for positions mapping evolution over time
for (let i = 0; i < rowsData.length; i += 1) {
trData = this.grid.getContainer().find(`#${rowsData[i]}`);
completeRowsData.push({
rowMarker: rowsData[i],
offset: trData.data('dragAndDropOffset'),
});
}
return this.computeMappingBetweenOldAndNewPositions(completeRowsData);
}
/**
* Add ID's to Grid table rows to make tableDnD.onDrop() function work.
*
* @private
*/
private addIdsToGridTableRows(): void {
let counter = 0;
this.grid
.getContainer()
.find(GridMap.gridTablePosition(this.grid.getId()))
.each((index, positionWrapper) => {
const $positionWrapper = $(positionWrapper);
const rowId = $positionWrapper.data('id');
const position = $positionWrapper.data('position');
const id = `row_${rowId}_${position}`;
$positionWrapper.closest('tr').attr('id', id);
$positionWrapper.closest('td').addClass(GridMap.dragHandlerClass);
$positionWrapper.closest('tr').data('dragAndDropOffset', counter);
counter += 1;
});
}
/**
* Process rows positions update
*
* @param {String} url
* @param {Object} params
* @param {String} method
*
* @private
*/
private updatePosition(
url: string,
params: Record<string, Array<DNDPositions>>,
method: string,
): void {
const isGetOrPostMethod = ['GET', 'POST'].includes(method);
const $form = $('<form>', {
action: url,
method: isGetOrPostMethod ? method : 'POST',
}).appendTo('body');
const positionsNb = params.positions.length;
let position;
for (let i = 0; i < positionsNb; i += 1) {
position = params.positions[i];
$form.append(
$('<input>', {
type: 'hidden',
name: `positions[${i}][rowId]`,
value: position.rowId,
}),
$('<input>', {
type: 'hidden',
name: `positions[${i}][oldPosition]`,
value: position.oldPosition,
}),
$('<input>', {
type: 'hidden',
name: `positions[${i}][newPosition]`,
value: position.newPosition,
}),
);
}
// This _method param is used by Symfony to simulate DELETE and PUT methods
if (!isGetOrPostMethod) {
$form.append(
$('<input>', {
type: 'hidden',
name: '_method',
value: method,
}),
);
}
$form.submit();
}
/**
* Rows have been reordered. This function
* finds, for each row ID: the old position, the new position
*
* @returns {Array}
* @private
*/
private computeMappingBetweenOldAndNewPositions(
rowsData: Array<RowDatas>,
): Array<DNDPositions> {
const regex = /^row_(?<rowId>\d+)_(?<oldPosition>\d+)$/;
const mapping: Array<DNDPositions> = [];
// First loop is to create the mapping objects with old positions
for (let i = 0; i < rowsData.length; i += 1) {
const regexResult = regex.exec(rowsData[i].rowMarker);
if (regexResult
&& !isUndefined(regexResult.groups)
&& !isUndefined(regexResult.groups.rowId)
&& !isUndefined(regexResult.groups.oldPosition)) {
const oldPosition: number = parseInt(regexResult?.groups?.oldPosition, 10);
mapping[i] = {
rowId: regexResult.groups.rowId,
oldPosition,
newPosition: oldPosition,
};
}
// Second loop, now that all positions are defined for all rows we can switch the position when needed
for (let j = 0; j < rowsData.length; j += 1) {
if (!isUndefined(rowsData[j])
&& !isUndefined(rowsData[j].offset)
&& !isUndefined(mapping[rowsData[j].offset])
&& !isUndefined(mapping[j])) {
// This row will have as a new position the old position of the current one
mapping[rowsData[j].offset].newPosition = mapping[j].oldPosition;
}
}
}
return mapping;
}
/**
* Check if position reorder is active
*
* @private
*/
private isPositionsReorderActive(): boolean {
return this.grid.getContainer()
.find('.ps-sortable-column[data-sort-col-name="position"]')
.first()
.data('sort-is-current');
}
/**
* Get reorder button
*
* @private
*/
private getReorderButton(): JQuery<HTMLElement> {
return this.grid
.getContainer()
.find('.js-btn-reorder-positions')
.first();
}
/**
* Set reorder button label in function of sortable column state.
*
* @private
*/
private setReorderButtonLabel(): void {
const rearrangeButton = this.getReorderButton();
if (this.isPositionsReorderActive()) {
rearrangeButton.hide();
} else {
rearrangeButton.data('label-reorder');
}
}
/**
* Onclick reorder button
*
* @param event
* @private
*/
private oncClickReorderButton(event: JQuery.Event): void {
event.preventDefault();
// If positions are actually being reordered...
if (this.isPositionsReorderActive()) {
// we need to reset filters and order by of the grid
this.grid.getContainer()
.find('.ps-sortable-column')
.first()
.click();
} else {
// Else, we need to set the position column as the current sort ordering
this.grid.getContainer()
.find('.ps-sortable-column[data-sort-col-name="position"]')
.first()
.click();
}
}
}

View File

@@ -0,0 +1,230 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Extends grid with preview functionality.
*/
export default class PreviewExtension {
locks: Array<unknown>;
expandSelector: string;
previewOpenClass: string;
collapseSelector: string;
previewToggleSelector: string;
previewCustomization: (previewTemplate: JQuery) => void;
$gridContainer: JQuery;
constructor(previewCustomization: (previewTemplate: JQuery) => void, grid: Grid) {
this.locks = [];
this.expandSelector = GridMap.expand;
this.collapseSelector = GridMap.collapse;
this.previewOpenClass = 'preview-open';
this.previewToggleSelector = GridMap.previewToggle;
this.previewCustomization = previewCustomization;
this.$gridContainer = $(grid.getContainer);
}
/**
* Extends provided grid with preview functionality
*
* @param grid
*/
extend(grid: Grid): void {
this.$gridContainer = $(grid.getContainer);
this.$gridContainer.find('tbody tr').on('mouseover mouseleave', (event: JQueryEventObject) => this.handleIconHovering(event));
this.$gridContainer.find(this.previewToggleSelector).on('click', (event: JQueryEventObject) => this.togglePreview(event));
}
/**
* Shows/hides preview toggling icons
*
* @param event
* @private
*/
private handleIconHovering(event: JQueryEventObject) {
const $previewToggle = $(event.currentTarget).find(this.previewToggleSelector);
if (event.type === 'mouseover' && !$(event.currentTarget).hasClass(this.previewOpenClass)) {
this.showExpandIcon($previewToggle);
} else {
this.hideExpandIcon($previewToggle);
}
}
/**
* Shows/hides preview
*
* @param event
* @private
*/
togglePreview(event: JQueryEventObject): void {
const $previewToggle = $(event.currentTarget);
const $columnRow = $previewToggle.closest('tr');
if ($columnRow.hasClass(this.previewOpenClass)) {
$columnRow.next(GridMap.previewRow).remove();
$columnRow.removeClass(this.previewOpenClass);
this.showExpandIcon($columnRow);
this.hideCollapseIcon($columnRow);
return;
}
this.closeOpenedPreviews();
const dataUrl = $(event.currentTarget).data('preview-data-url');
if (this.isLocked(dataUrl)) {
return;
}
// Prevents loading preview multiple times.
// Uses "dataUrl" as lock key.
this.lock(dataUrl);
$.ajax({
url: dataUrl,
method: 'GET',
dataType: 'json',
complete: () => {
this.unlock(dataUrl);
},
})
.then((response) => {
this.renderPreviewContent($columnRow, response.preview);
})
.catch((e: AjaxError) => {
window.showErrorMessage(e.responseJSON.message);
});
}
/**
* Renders preview content
*
* @param $columnRow
* @param content
*
* @private
*/
private renderPreviewContent($columnRow: JQuery<Element>, content: string) {
const rowColumnCount = $columnRow.find('td').length;
const $previewTemplate = $(`
<tr class="preview-row">
<td colspan="${rowColumnCount}">${content}</td>
</tr>
`);
$columnRow.addClass(this.previewOpenClass);
this.showCollapseIcon($columnRow);
this.hideExpandIcon($columnRow);
if (typeof this.previewCustomization === 'function') {
this.previewCustomization($previewTemplate);
}
$columnRow.after($previewTemplate);
}
/**
* Shows preview expanding icon
*
* @param parent
* @private
*/
private showExpandIcon(parent: JQuery<Element>): void {
parent.find(this.expandSelector).removeClass('d-none');
}
/**
* Hides preview expanding icon
*
* @param parent
* @private
*/
private hideExpandIcon(parent: JQuery<Element>): void {
parent.find(this.expandSelector).addClass('d-none');
}
/**
* Shows preview collapsing icon
*
* @param parent
* @private
*/
private showCollapseIcon(parent: JQuery<Element>): void {
parent.find(this.collapseSelector).removeClass('d-none');
}
/**
* Hides preview collapsing icon
*
* @param parent
* @private
*/
private hideCollapseIcon(parent: JQuery<Element>): void {
parent.find(this.collapseSelector).addClass('d-none');
}
isLocked(key: number): boolean {
return this.locks.indexOf(key) !== -1;
}
lock(key: number): void {
if (this.isLocked(key)) {
return;
}
this.locks.push(key);
}
unlock(key: number): void {
const index = this.locks.indexOf(key);
if (index === -1) {
return;
}
this.locks.splice(index, 1);
}
/**
* Close all previews that are open.
*
* @private
*/
private closeOpenedPreviews(): void {
const $rows = this.$gridContainer.find(GridMap.gridTbody).find(GridMap.trNotPreviewRow);
$.each($rows, (i, row) => {
const $row = $(row);
if (!$row.hasClass(this.previewOpenClass)) {
return;
}
const $previewRow = $row.next();
if (!$previewRow.hasClass('preview-row')) {
return;
}
$previewRow.remove();
$row.removeClass(this.previewOpenClass);
this.hideCollapseIcon($row);
});
}
}

View File

@@ -0,0 +1,25 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
/**
* Class ReloadListExtension extends grid with "List reload" action
*/
export default class ReloadListExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getHeaderContainer()
.on('click', GridMap.commonRefreshListAction, () => {
window.location.reload();
});
}
}

View File

@@ -0,0 +1,24 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import TableSorting from '@app/utils/table-sorting';
import GridMap from '@components/grid/grid-map';
/**
* Class ReloadListExtension extends grid with "List reload" action
*/
export default class SortingExtension {
/**
* Extend grid
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
const $sortableTable = grid.getContainer().find(GridMap.table);
new TableSorting($sortableTable).attach();
}
}

View File

@@ -0,0 +1,95 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import ConfirmModal from '@components/modal';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Handles submit of grid actions
*/
export default class SubmitBulkActionExtension {
/**
* Extend grid with bulk action submitting
*
* @param {Grid} grid
*/
extend(grid: Grid): void {
grid
.getContainer()
.on('click', GridMap.bulks.submitAction, (event: JQueryEventObject) => {
this.submit(event, grid);
});
}
/**
* Handle bulk action submitting
*
* @param {Event} event
* @param {Grid} grid
*
* @private
*/
private submit(event: JQueryEventObject, grid: Grid): void {
const $submitBtn = $(event.currentTarget);
const confirmMessage = $submitBtn.data('confirm-message');
const confirmTitle = $submitBtn.data('confirmTitle');
if (confirmMessage !== undefined && confirmMessage.length > 0) {
if (confirmTitle !== undefined) {
this.showConfirmModal($submitBtn, grid, confirmMessage, confirmTitle);
} else if (window.confirm(confirmMessage)) {
this.postForm($submitBtn, grid);
}
} else {
this.postForm($submitBtn, grid);
}
}
/**
* @param {jQuery} $submitBtn
* @param {Grid} grid
* @param {string} confirmMessage
* @param {string} confirmTitle
*/
private showConfirmModal(
$submitBtn: JQuery<Element>,
grid: Grid,
confirmMessage: string,
confirmTitle: string,
): void {
const confirmButtonLabel = $submitBtn.data('confirmButtonLabel');
const closeButtonLabel = $submitBtn.data('closeButtonLabel');
const confirmButtonClass = $submitBtn.data('confirmButtonClass');
const modal = new ConfirmModal(
{
id: GridMap.confirmModal(grid.getId()),
confirmTitle,
confirmMessage,
confirmButtonLabel,
closeButtonLabel,
confirmButtonClass,
},
() => this.postForm($submitBtn, grid),
);
modal.show();
}
/**
* @param {jQuery} $submitBtn
* @param {Grid} grid
*/
private postForm($submitBtn: JQuery<Element>, grid: Grid): void {
const $form = $(GridMap.filterForm(grid.getId()));
$form.attr('action', $submitBtn.data('form-url'));
$form.attr('method', $submitBtn.data('form-method'));
$form.submit();
}
}

View File

@@ -0,0 +1,56 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Grid} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$} = window;
/**
* Class SubmitGridActionExtension handles grid action submits
*/
export default class SubmitGridActionExtension {
extend(grid: Grid): void {
grid
.getHeaderContainer()
.on(
'click',
GridMap.bulks.gridSubmitAction,
(event: JQueryEventObject) => {
this.handleSubmit(event, grid);
},
);
}
/**
* Handle grid action submit.
* It uses grid form to submit actions.
*
* @param {Event} event
* @param {Grid} grid
*
* @private
*/
private handleSubmit(event: JQueryEventObject, grid: Grid): void {
const $submitBtn = $(event.currentTarget);
const confirmMessage = $submitBtn.data('confirm-message');
if (
typeof confirmMessage !== 'undefined'
&& confirmMessage.length > 0
&& !window.confirm(confirmMessage)
) {
return;
}
const $form = $(GridMap.filterForm(grid.getId()));
$form.attr('action', $submitBtn.data('url'));
$form.attr('method', $submitBtn.data('method'));
$form
.find(GridMap.actions.tokenInput(grid.getId()))
.val($submitBtn.data('csrf'));
$form.submit();
}
}

View File

@@ -0,0 +1,84 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export default {
bulks: {
deleteCategories: '.js-delete-categories-bulk-action',
deleteCategoriesModal: (id: string): string => `#${id}_grid_delete_categories_modal`,
checkedCheckbox: '.js-bulk-action-checkbox:checked',
deleteCustomers: '.js-delete-customers-bulk-action',
deleteCustomerModal: (id: string): string => `#${id}_grid_delete_customers_modal`,
submitDeleteCategories: '.js-submit-delete-categories',
submitDeleteCustomers: '.js-submit-delete-customers',
categoriesToDelete: '#delete_categories_categories_to_delete',
customersToDelete: '#delete_customers_customers_to_delete',
actionSelectAll: '.js-bulk-action-select-all',
bulkActionCheckbox: '.js-bulk-action-checkbox',
bulkActionBtn: '.js-bulk-actions-btn',
openTabsBtn: '.js-bulk-action-btn.open_tabs',
tableChoiceOptions: 'table.table .js-choice-options',
choiceOptions: '.js-choice-options',
modalFormSubmitBtn: '.js-bulk-modal-form-submit-btn',
submitAction: '.js-bulk-action-submit-btn',
ajaxAction: '.js-bulk-action-ajax-btn',
gridSubmitAction: '.js-grid-action-submit-btn',
},
rows: {
categoryDeleteAction: '.js-delete-category-row-action',
customerDeleteAction: '.js-delete-customer-row-action',
linkRowAction: '.js-link-row-action',
linkRowActionClickableFirst:
'.js-link-row-action[data-clickable-row=1]:first',
clickableTd: 'td.clickable',
imageTypeDeleteAction: '.js-delete-image-type-row-action',
deleteImageTypeModal: (id: string): string => `#${id}_grid_delete_image_type_modal`,
submitDeleteImageType: '.js-submit-delete-image-type',
},
actions: {
showQuery: '.js-common_show_query-grid-action',
exportQuery: '.js-common_export_sql_manager-grid-action',
showModalForm: (id: string): string => `#${id}_common_show_query_modal_form`,
showModalGrid: (id: string): string => `#${id}_grid_common_show_query_modal`,
modalFormSubmitBtn: '.js-bulk-modal-form-submit-btn',
submitModalFormBtn: '.js-submit-modal-form-btn',
bulkInputsBlock: (id: string): string => `#${id}`,
tokenInput: (id: string): string => `input[name="${id}[_token]"]`,
ajaxBulkActionConfirmModal: (id: string, bulkAction: string): string => `${id}-ajax-${bulkAction}-confirm-modal`,
ajaxBulkActionProgressModal: (id: string, bulkAction: string): string => `${id}-ajax-${bulkAction}-progress-modal`,
},
position: (id: string): string => `.js-${id}-position:first`,
confirmModal: (id: string): string => `${id}-grid-confirm-modal`,
gridTable: '.js-grid-table',
dragHandler: '.js-drag-handle',
dragHandlerClass: 'js-drag-handle',
specificGridTable: (id: string): string => `${id}_grid_table`,
grid: (id: string): string => `#${id}_grid`,
gridPanel: '.js-grid-panel',
gridHeader: '.js-grid-header',
gridPosition: (id: string): string => `.js-${id}-position`,
gridTablePosition: (id: string): string => `.js-grid-table .js-${id}-position`,
gridPositionFirst: (id: string): string => `.js-${id}-position:first`,
selectPosition: 'js-position',
togglableRow: '.ps-togglable-row',
dropdownItem: '.js-dropdown-item',
table: 'table.table',
headerToolbar: '.header-toolbar',
breadcrumbItem: '.breadcrumb-item',
resetSearch: '.js-reset-search',
expand: '.js-expand',
collapse: '.js-collapse',
columnFilters: '.column-filters',
gridSearchButton: '.grid-search-button',
gridResetButton: '.grid-reset-button',
inputAndSelect: 'input:not(.js-bulk-action-select-all), select',
previewToggle: '.preview-toggle',
previewRow: '.preview-row',
gridTbody: '.grid-table tbody',
trNotPreviewRow: 'tr:not(.preview-row)',
commonRefreshListAction: '.js-common_refresh_list-grid-action',
filterForm: (id: string): string => `#${id}_filter_form`,
onDragClass: 'position-row-while-drag',
sqlSubmit: '.btn-sql-submit',
};

View File

@@ -0,0 +1,64 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {GridExtension} from '@PSTypes/grid';
import GridMap from '@components/grid/grid-map';
const {$}: Window = window;
/**
* Class is responsible for handling Grid events
*/
export default class Grid {
id: string;
$container: JQuery;
/**
* Grid id
*
* @param {string} id
*/
constructor(id: string) {
this.id = id;
this.$container = $(GridMap.grid(this.id));
}
/**
* Get grid id
*
* @returns {string}
*/
getId(): string {
return this.id;
}
/**
* Get grid container
*
* @returns {jQuery}
*/
getContainer(): JQuery {
return this.$container;
}
/**
* Get grid header container
*
* @returns {jQuery}
*/
getHeaderContainer(): JQuery {
return this.$container.closest(GridMap.gridPanel).find(GridMap.gridHeader);
}
/**
* Extend grid with external extensions
*
* @param {object} extension
*/
addExtension(extension: GridExtension): void {
extension.extend(this);
}
}

View File

@@ -0,0 +1,60 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
$(() => {
const searchComponent = document.querySelector('.component-search');
let windowWidth = window.innerWidth;
let eventsAdded = false;
const searchInput = searchComponent?.querySelector('.js-form-search');
const cancelButton = searchComponent?.querySelector('.component-search-cancel');
const quickAccess = searchComponent?.querySelector('.component-search-quickaccess');
const background = searchComponent?.querySelector('.component-search-background');
const closeQuickaccess = () => {
searchComponent?.classList.remove('active');
quickAccess?.classList.add('d-none');
cancelButton?.classList.add('d-none');
background?.classList.add('d-none');
};
const openQuickaccess = () => {
if (windowWidth <= 768) {
searchComponent?.classList.add('active');
quickAccess?.classList.remove('d-none');
cancelButton?.classList.remove('d-none');
background?.classList.remove('d-none');
}
};
const addQuickaccessEvent = () => {
if (searchComponent) {
searchInput?.addEventListener('focus', openQuickaccess);
cancelButton?.addEventListener('click', closeQuickaccess);
background?.addEventListener('click', closeQuickaccess);
eventsAdded = true;
}
};
window.addEventListener('resize', (e: Record<string, any>) => {
windowWidth = e.target.outerWidth;
if (windowWidth > 768) {
closeQuickaccess();
return;
}
if (eventsAdded) {
return;
}
addQuickaccessEvent();
});
addQuickaccessEvent();
});

View File

@@ -0,0 +1,17 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
/**
* Takes link from clicked item and redirects to it.
*/
export default class LinkableItem {
constructor() {
$(document).on('click', '.js-linkable-item', (event: JQueryEventObject) => {
window.location = $(event.currentTarget).data('linkable-href');
});
}
}

View File

@@ -0,0 +1,17 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {Modal} from '@components/modal/modal';
import {ConfirmModal} from '@components/modal/confirm-modal';
import {IframeModal} from '@components/modal/iframe-modal';
import {FormIframeModal} from '@components/modal/form-iframe-modal';
export {
Modal,
ConfirmModal,
IframeModal,
FormIframeModal,
};
export default ConfirmModal;

View File

@@ -0,0 +1,144 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint max-classes-per-file: ["error", 2] */
import {
ModalContainerType, ModalContainer, ModalType, ModalParams, Modal,
} from '@components/modal/modal';
import {isUndefined} from '@components/typeguard';
export interface ConfirmModalContainerType extends ModalContainerType {
message: HTMLElement;
footer: HTMLElement;
closeButton: HTMLElement;
confirmButton: HTMLButtonElement;
}
export interface ConfirmModalType extends ModalType {
modal: ConfirmModalContainerType;
}
export type ConfirmModalParams = ModalParams & {
confirmTitle?: string;
confirmMessage: string;
closeButtonLabel: string;
confirmButtonLabel: string;
confirmButtonClass: string;
confirmCallback: (event: Event) => void,
customButtons: Array<HTMLButtonElement | HTMLAnchorElement>;
}
export type InputConfirmModalParams = Partial<ConfirmModalParams>;
/**
* This class is used to build the modal DOM elements, it is not usable as is because it doesn't even have a show
* method and the elements are created but not added to the DOM. It just creates a basic DOM structure of a
* Bootstrap modal, thus keeping the logic class of the modal separated.
*
* This container is built on the basic ModalContainer and adds some confirm/cancel buttons along with a message
* in the body, it is mostly used as a Rich confirm dialog box.
*/
export class ConfirmModalContainer extends ModalContainer implements ConfirmModalContainerType {
footer!: HTMLElement;
closeButton!: HTMLElement;
confirmButton!: HTMLButtonElement;
/* This constructor is important to force the input type but ESLint is not happy about it*/
/* eslint-disable no-useless-constructor */
constructor(params: ConfirmModalParams) {
super(params);
}
protected buildModalContainer(params: ConfirmModalParams): void {
super.buildModalContainer(params);
// Modal message element
this.message.classList.add('confirm-message');
this.message.innerHTML = params.confirmMessage;
// Modal footer element
this.footer = document.createElement('div');
this.footer.classList.add('modal-footer');
// Modal close button element
this.closeButton = document.createElement('button');
this.closeButton.setAttribute('type', 'button');
this.closeButton.classList.add('btn', 'btn-outline-secondary', 'btn-lg');
this.closeButton.dataset.dismiss = 'modal';
this.closeButton.innerHTML = params.closeButtonLabel;
// Modal confirm button element
this.confirmButton = document.createElement('button');
this.confirmButton.setAttribute('type', 'button');
this.confirmButton.classList.add(
'btn',
params.confirmButtonClass,
'btn-lg',
'btn-confirm-submit',
);
this.confirmButton.dataset.dismiss = 'modal';
this.confirmButton.innerHTML = params.confirmButtonLabel;
// Appending element to the modal
this.footer.append(this.closeButton, ...params.customButtons, this.confirmButton);
this.content.append(this.footer);
}
}
/**
* ConfirmModal component
*
* @param {InputConfirmModalParams} params
* @param {Function} confirmCallback @deprecated You should rely on the confirmCallback param
* @param {Function} cancelCallback @deprecated You should rely on the closeCallback param
*/
export class ConfirmModal extends Modal implements ConfirmModalType {
modal!: ConfirmModalContainerType;
constructor(
inputParams: InputConfirmModalParams,
confirmCallback?: (event: Event) => void,
cancelCallback?: () => void,
) {
let confirmModalCallback: (event: Event) => void;
if (!isUndefined(inputParams.confirmCallback)) {
confirmModalCallback = inputParams.confirmCallback;
} else if (!isUndefined(confirmCallback)) {
confirmModalCallback = confirmCallback;
} else {
// We kept the parameters for backward compatibility, this forces us to keep the param confirmCallback as optional
// but when we remove deprecation it will become mandatory, a confirm callback should always be specified
confirmModalCallback = (): void => {
console.error('No confirm callback provided for ConfirmModal component.');
};
}
const params: ConfirmModalParams = {
id: 'confirm-modal',
confirmMessage: 'Are you sure?',
closeButtonLabel: 'Close',
confirmButtonLabel: 'Accept',
confirmButtonClass: 'btn-primary',
customButtons: [],
closable: false,
modalTitle: inputParams.confirmTitle,
dialogStyle: {},
confirmCallback: confirmModalCallback,
closeCallback: inputParams.closeCallback ?? cancelCallback,
...inputParams,
};
super(params);
}
protected initContainer(params: ConfirmModalParams): void {
this.modal = new ConfirmModalContainer(params);
this.modal.confirmButton.addEventListener('click', params.confirmCallback);
super.initContainer(params);
}
}
export default ConfirmModal;

View File

@@ -0,0 +1,119 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import IframeModal, {
IframeModalParams,
IframeModalType, InputIframeModalParams,
} from '@components/modal/iframe-modal';
export type FormIframeModalType = IframeModalType
export type FormIframeCallbackFunction = (
form: HTMLFormElement,
formData: FormData,
dataAttributes: DOMStringMap | null,
event: Event,
) => void;
export type FormIframeConfirmCallback = (
form: HTMLFormElement,
iframe: HTMLIFrameElement,
event: Event
) => void;
export type FormIframeModalParams = Omit<IframeModalParams, 'iframeUrl' | 'onLoaded' | 'confirmCallback'> & {
formUrl: string;
formSelector: string;
cancelButtonSelector: string;
modalTitle?: string;
onFormLoaded?: FormIframeCallbackFunction,
formConfirmCallback?: FormIframeConfirmCallback,
}
export type InputFormIframeModalParams = Partial<FormIframeModalParams> & {
formUrl: string; // formUrl is mandatory in params
};
/**
* This modal opens an url containing a form inside a modal and watches for the submit (via iframe loading)
* On each load it is able to return data from the form via the onFormLoaded callback
*/
export class FormIframeModal extends IframeModal implements FormIframeModalType {
constructor(
params: InputFormIframeModalParams,
) {
const iframeParams: InputIframeModalParams = {
iframeUrl: params.formUrl,
onLoaded: (iframe: HTMLIFrameElement, event: Event) => {
this.onIframeLoaded(
iframe,
event,
params.onFormLoaded,
params.cancelButtonSelector ?? '.cancel-btn',
params.formSelector ?? 'form',
);
},
confirmCallback: (iframe: HTMLIFrameElement, event: Event) => {
this.onConfirmCallback(iframe, event, params.formConfirmCallback, params.formSelector ?? 'form');
},
...params,
};
super(iframeParams);
}
private onIframeLoaded(
iframe: HTMLIFrameElement,
event: Event,
onFormLoaded: FormIframeCallbackFunction | undefined,
cancelButtonSelector: string,
formSelector: string,
): void {
if (!onFormLoaded) {
return;
}
const iframeForm: HTMLFormElement | null = this.getForm(iframe, formSelector);
if (!iframeForm) {
return;
}
// Close modal when cancel button is clicked
const cancelButtons = iframeForm.querySelectorAll(cancelButtonSelector);
cancelButtons.forEach((cancelButton) => {
cancelButton.addEventListener('click', () => {
this.hide();
});
});
onFormLoaded(iframeForm, new FormData(iframeForm), iframeForm.dataset ?? null, event);
}
private onConfirmCallback(
iframe: HTMLIFrameElement,
event: Event,
formConfirmCallback: FormIframeConfirmCallback | undefined,
formSelector: string,
): void {
if (!formConfirmCallback) {
return;
}
const iframeForm: HTMLFormElement | null = this.getForm(iframe, formSelector);
if (!iframeForm) {
return;
}
formConfirmCallback(iframeForm, iframe, event);
}
private getForm(iframe: HTMLIFrameElement, formSelector: string): HTMLFormElement | null {
if (!iframe.contentWindow) {
return null;
}
return iframe.contentWindow.document.querySelector<HTMLFormElement>(formSelector);
}
}

View File

@@ -0,0 +1,30 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import IframeEvent from '@components/modal/iframe-event';
/**
* Client to integrate in a iframe in order to communicate with the parent window via some events.
* The parent window needs to register to the IframeEvent:
*
* window.addEventListener(IframeEvent.parentWindowEvent, (event: IframeEvent) => {
* if (event.name === 'iframeAction') {
* doAction(event.parameters);
* }
* });
*/
export default class IframeClient {
private iframeWindow: Window;
private parentWindow: Window;
constructor() {
this.iframeWindow = window;
this.parentWindow = this.iframeWindow.parent;
}
dispatchEvent(eventName: string, parameters: any = {}): void {
this.parentWindow.dispatchEvent(new IframeEvent(eventName, parameters));
}
}

View File

@@ -0,0 +1,26 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
export default class IframeEvent extends Event {
static readonly parentWindowEvent: string = 'IframeClientEvent';
private readonly eventName: string;
private readonly eventParameters: any;
constructor(eventName: string, parameters: any = {}) {
super(IframeEvent.parentWindowEvent);
this.eventName = eventName;
this.eventParameters = parameters;
}
get name(): string {
return this.eventName;
}
get parameters(): any {
return this.eventParameters;
}
}

View File

@@ -0,0 +1,342 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint max-classes-per-file: ["error", 2] */
import ResizeObserver from 'resize-observer-polyfill';
import {
ModalContainerType, ModalContainer, ModalType, ModalParams, Modal,
} from '@components/modal/modal';
import IframeEvent from '@components/modal/iframe-event';
import {isUndefined} from '@components/typeguard';
export interface IframeModalContainerType extends ModalContainerType {
iframe: HTMLIFrameElement;
loader: HTMLElement;
spinner: HTMLElement;
closeButton?: HTMLElement;
confirmButton?: HTMLButtonElement;
}
export interface IframeModalType extends ModalType {
modal: IframeModalContainerType;
render: (content: string, hideIframe?: boolean) => void;
}
export type IframeCallbackFunction = (iframe:HTMLIFrameElement, event: Event) => void;
export type IframeEventCallbackFunction = (event: IframeEvent) => void;
export type IframeModalParams = ModalParams & {
// Callback method executed each time the iframe loads an url
onLoaded?: IframeCallbackFunction,
// Callback method executed each time the iframe is about to unload its content
onUnload?: IframeCallbackFunction,
// The iframe can launch IframeEvent to communicate with its parent via this callback
onIframeEvent?: IframeEventCallbackFunction,
// Initial url of the iframe
iframeUrl: string;
// When true the iframe height is computed based on its content
autoSize: boolean;
// By default the body of the iframe is used as a reference of its content's size but this option can customize it
autoSizeContainer: string;
// Optional, when set a close button is added in the modal's footer
closeButtonLabel?: string;
// Optional, when set a confirm button is added in the modal's footer
confirmButtonLabel?: string;
// Callback when the confirm button is clicked
confirmCallback?: (iframe: HTMLIFrameElement, event: Event) => void;
// By default the iframe closes when confirm button is clicked, this options overrides this behaviour
closeOnConfirm: boolean;
// When the iframe is refreshed auto scroll up the body container (true by default)
autoScrollUp: boolean;
}
export type InputIframeModalParams = Partial<IframeModalParams> & {
iframeUrl: string; // iframeUrl is mandatory in input
};
/**
* This class is used to build the modal DOM elements, it is not usable as is because it doesn't even have a show
* method and the elements are created but not added to the DOM. It just creates a basic DOM structure of a
* Bootstrap modal, thus keeping the logic class of the modal separated.
*
* This container is built on the basic ModalContainer and adds an iframe to load external content along with a
* loader div on top of it.
*
* @param {InputIframeModalParams} inputParams
*/
export class IframeModalContainer extends ModalContainer implements IframeModalContainerType {
iframe!: HTMLIFrameElement;
loader!: HTMLElement;
spinner!: HTMLElement;
footer?: HTMLElement;
closeButton?: HTMLElement;
confirmButton?: HTMLButtonElement;
/* This constructor is important to force the input type but ESLint is not happy about it*/
/* eslint-disable no-useless-constructor */
constructor(params: IframeModalParams) {
super(params);
}
protected buildModalContainer(params: IframeModalParams): void {
super.buildModalContainer(params);
this.container.classList.add('modal-iframe');
// Message is hidden by default
this.message.classList.add('d-none');
this.iframe = document.createElement('iframe');
this.iframe.frameBorder = '0';
this.iframe.scrolling = 'no';
this.iframe.width = '100%';
this.iframe.setAttribute('name', `${params.id}-iframe`);
if (!params.autoSize) {
this.iframe.height = '100%';
}
this.loader = document.createElement('div');
this.loader.classList.add('modal-iframe-loader');
this.spinner = document.createElement('div');
this.spinner.classList.add('spinner');
this.loader.appendChild(this.spinner);
this.body.append(this.loader, this.iframe);
// Modal footer element
if (!isUndefined(params.closeButtonLabel) || !isUndefined(params.confirmButtonLabel)) {
this.footer = document.createElement('div');
this.footer.classList.add('modal-footer');
// Modal close button element
if (!isUndefined(params.closeButtonLabel)) {
this.closeButton = document.createElement('button');
this.closeButton.setAttribute('type', 'button');
this.closeButton.classList.add('btn', 'btn-outline-secondary', 'btn-lg');
this.closeButton.dataset.dismiss = 'modal';
this.closeButton.innerText = params.closeButtonLabel;
this.footer.append(this.closeButton);
}
// Modal confirm button element
if (!isUndefined(params.confirmButtonLabel)) {
this.confirmButton = document.createElement('button');
this.confirmButton.setAttribute('type', 'button');
this.confirmButton.classList.add('btn', 'btn-primary', 'btn-lg', 'btn-confirm-submit');
if (params.closeOnConfirm) {
this.confirmButton.dataset.dismiss = 'modal';
}
this.confirmButton.innerHTML = params.confirmButtonLabel;
this.footer.append(this.confirmButton);
}
// Appending element to the modal
this.content.append(this.footer);
}
}
}
/**
* This modal opens an url inside a modal, it then can handle two specific callbacks
* - onLoaded: called when the iframe has juste been refreshed
* - onUnload: called when the iframe is about to refresh (so it is unloaded)
*/
export class IframeModal extends Modal implements IframeModalType {
modal!: IframeModalContainerType;
protected autoSize!: boolean;
protected autoSizeContainer!: string;
protected resizeObserver?: ResizeObserver | null;
constructor(
inputParams: InputIframeModalParams,
) {
const params: IframeModalParams = {
id: 'iframe-modal',
closable: false,
autoSize: true,
autoSizeContainer: 'body',
closeOnConfirm: true,
autoScrollUp: true,
...inputParams,
};
super(params);
}
protected initContainer(params: IframeModalParams): void {
// Construct the container
this.modal = new IframeModalContainer(params);
super.initContainer(params);
this.autoSize = params.autoSize;
this.autoSizeContainer = params.autoSizeContainer;
this.modal.iframe.addEventListener('load', (loadedEvent: Event) => {
// Scroll the body container back to the top after iframe loaded
this.modal.body.scroll(0, 0);
this.hideLoading();
if (params.onLoaded) {
params.onLoaded(this.modal.iframe, loadedEvent);
}
if (this.modal.iframe.contentWindow) {
this.modal.iframe.contentWindow.addEventListener('beforeunload', (unloadEvent: BeforeUnloadEvent) => {
if (params.onUnload) {
params.onUnload(this.modal.iframe, unloadEvent);
}
this.showLoading();
});
// Auto resize the iframe container
this.initAutoResize();
}
});
this.$modal.on('shown.bs.modal', () => {
this.modal.iframe.src = params.iframeUrl;
});
window.addEventListener(IframeEvent.parentWindowEvent, ((event: IframeEvent) => {
if (params.onIframeEvent) {
params.onIframeEvent(event);
}
}) as EventListener);
if (this.modal.confirmButton && params.confirmCallback) {
this.modal.confirmButton.addEventListener('click', (event) => {
if (params.confirmCallback) {
params.confirmCallback(this.modal.iframe, event);
}
});
}
}
render(content: string, hideIframe: boolean = true, useInnerText: boolean = false): this {
if (useInnerText) {
this.modal.message.innerText = content;
} else {
this.modal.message.innerHTML = content;
}
this.modal.message.classList.remove('d-none');
if (hideIframe) {
this.hideIframe();
}
this.autoResize();
this.hideLoading();
return this;
}
showLoading(): this {
const bodyHeight = this.getOuterHeight(this.modal.body);
const bodyWidth = this.getOuterWidth(this.modal.body);
this.modal.loader.style.height = `${bodyHeight}px`;
this.modal.loader.style.width = `${bodyWidth}px`;
this.modal.loader.classList.remove('d-none');
this.modal.iframe.classList.remove('invisible');
this.modal.iframe.classList.add('invisible');
return this;
}
hideLoading(): this {
this.modal.iframe.classList.remove('invisible');
this.modal.iframe.classList.add('visible');
this.modal.loader.classList.add('d-none');
return this;
}
hide(): this {
super.hide();
this.cleanResizeObserver();
return this;
}
hideIframe(): void {
this.modal.iframe.classList.add('d-none');
}
private getResizableContainer(): HTMLElement | null {
if (this.autoSize && this.modal.iframe.contentWindow) {
return this.modal.iframe.contentWindow.document.querySelector(this.autoSizeContainer);
}
return null;
}
private initAutoResize(): void {
const iframeContainer: HTMLElement | null = this.getResizableContainer();
if (iframeContainer) {
this.cleanResizeObserver();
this.resizeObserver = new ResizeObserver(() => {
this.autoResize();
});
this.resizeObserver.observe(iframeContainer);
}
this.autoResize();
}
private cleanResizeObserver(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
private autoResize(): void {
const iframeContainer: HTMLElement | null = this.getResizableContainer();
if (iframeContainer) {
const iframeScrollHeight = iframeContainer.scrollHeight;
const contentHeight = this.getOuterHeight(this.modal.message)
+ iframeScrollHeight;
// Avoid applying height of 0 (on first load for example)
if (contentHeight) {
// We force the iframe to its real height and it's the container that handles the overflow with scrollbars
this.modal.iframe.style.height = `${contentHeight}px`;
}
}
}
private getOuterHeight(element: HTMLElement): number {
// If the element height is 0 it is likely empty or hidden, then no need to compute the margin
if (!element.offsetHeight) {
return 0;
}
let height = element.offsetHeight;
const style: CSSStyleDeclaration = getComputedStyle(element);
height += parseInt(style.marginTop, 10) + parseInt(style.marginBottom, 10);
return height;
}
private getOuterWidth(element: HTMLElement): number {
// If the element height is 0 it is likely empty or hidden, then no need to compute the margin
if (!element.offsetWidth) {
return 0;
}
let width = element.offsetWidth;
const style: CSSStyleDeclaration = getComputedStyle(element);
width += parseInt(style.marginLeft, 10) + parseInt(style.marginRight, 10);
return width;
}
}
export default IframeModal;

View File

@@ -0,0 +1,227 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint max-classes-per-file: ["error", 2] */
export interface ModalContainerType {
container: HTMLElement;
dialog: HTMLElement;
content: HTMLElement;
body: HTMLElement;
message: HTMLElement;
header: HTMLElement;
title?: HTMLElement;
closeIcon?: HTMLButtonElement;
}
export interface ModalCoreType {
show: () => void;
hide: () => void;
}
export interface ModalType extends ModalCoreType {
modal: ModalContainerType;
render: (content: string) => void;
}
export type CssProps = Record<string, string>;
export type ModalParams = {
id: string;
closable?: boolean;
modalTitle?: string
dialogStyle?: CssProps;
closeCallback?: () => void;
}
export type InputModalParams = Partial<ModalParams>;
/**
* This class is used to build the modal DOM elements, it is not usable as is because it doesn't even have a show
* method and the elements are created but not added to the DOM. It just creates a basic DOM structure of a
* Bootstrap modal, thus keeping the logic class of the modal separated.
*
* This is the most basic modal container (only the modal and dialog box, with a close icon
* and an optional title). No footer and no content is handled.
*
* @param {ModalParams} params
*/
export class ModalContainer implements ModalContainerType {
container!: HTMLElement;
dialog!: HTMLElement;
content!: HTMLElement;
message!: HTMLElement;
header!: HTMLElement;
title?: HTMLElement;
closeIcon?: HTMLButtonElement;
body!: HTMLElement;
constructor(inputParams: InputModalParams) {
const params: ModalParams = {
id: 'confirm-modal',
closable: false,
...inputParams,
};
this.buildModalContainer(params);
}
protected buildModalContainer(params: ModalParams): void {
// Main modal element
this.container = document.createElement('div');
this.container.classList.add('modal', 'fade');
this.container.id = params.id;
// Modal dialog element
this.dialog = document.createElement('div');
this.dialog.classList.add('modal-dialog');
if (params.dialogStyle) {
Object.keys(params.dialogStyle).forEach((key: string) => {
// @ts-ignore
this.dialog.style[key] = params.dialogStyle[key];
});
}
// Modal content element
this.content = document.createElement('div');
this.content.classList.add('modal-content');
// Modal message element
this.message = document.createElement('p');
this.message.classList.add('modal-message');
// Modal header element
this.header = document.createElement('div');
this.header.classList.add('modal-header');
// Modal title element
if (params.modalTitle) {
this.title = document.createElement('h4');
this.title.classList.add('modal-title');
this.title.innerHTML = params.modalTitle;
}
// Modal close button icon
this.closeIcon = document.createElement('button');
this.closeIcon.classList.add('close');
this.closeIcon.setAttribute('type', 'button');
this.closeIcon.dataset.dismiss = 'modal';
this.closeIcon.innerHTML = '×';
// Modal body element
this.body = document.createElement('div');
this.body.classList.add('modal-body', 'text-left', 'font-weight-normal');
// Constructing the modal
if (this.title) {
this.header.appendChild(this.title);
}
this.header.appendChild(this.closeIcon);
this.content.append(this.header, this.body);
this.body.appendChild(this.message);
this.dialog.appendChild(this.content);
this.container.appendChild(this.dialog);
}
}
/**
* Modal component
*
* @param {InputModalParams} params
* @param {Function} closeCallback
*/
export class Modal implements ModalType {
modal!: ModalContainerType;
protected $modal!: JQuery;
constructor(
inputParams: InputModalParams,
) {
const params: ModalParams = {
id: 'confirm-modal',
closable: false,
dialogStyle: {},
...inputParams,
};
this.initContainer(params);
}
protected initContainer(params: ModalParams): void {
// Construct the modal, check if it already exists This allows child classes to use their custom container
if (!this.modal) {
this.modal = new ModalContainer(params);
}
// jQuery modal object
this.$modal = $(this.modal.container);
const {id, closable} = params;
this.$modal.modal({
backdrop: closable ? true : 'static',
keyboard: closable !== undefined ? closable : true,
});
this.$modal.modal('hide');
this.$modal.on('hidden.bs.modal', () => {
const modal = document.querySelector(`#${id}`);
if (modal) {
modal.remove();
}
if (params.closeCallback) {
params.closeCallback();
}
});
document.body.appendChild(this.modal.container);
}
setTitle(modalTitle: string): this {
if (!this.modal.title) {
this.modal.title = document.createElement('h4');
this.modal.title.classList.add('modal-title');
if (this.modal.closeIcon) {
this.modal.header.insertBefore(this.modal.title, this.modal.closeIcon);
} else {
this.modal.header.appendChild(this.modal.title);
}
}
this.modal.title.innerHTML = modalTitle;
return this;
}
render(content: string): this {
this.modal.message.innerHTML = content;
return this;
}
show(): this {
this.$modal.modal('show');
return this;
}
hide(): this {
this.$modal.modal('hide');
// Sometimes modal animation is still in progress and hiding fails, so we attach event listener for that case.
this.$modal.on('shown.bs.modal', () => {
this.$modal.modal('hide');
this.$modal.off('shown.bs.modal');
});
return this;
}
}
export default Modal;

View File

@@ -0,0 +1,573 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/* eslint max-classes-per-file: ["error", 4] */
import ComponentsMap from '@components/components-map';
import {
ModalParams, Modal, ModalCoreType, ModalContainerType, ModalContainer,
} from '@components/modal/modal';
export interface ProgressModalContainerType extends ModalContainerType {
progressView: ProgressView;
errorView: ErrorView;
switchView(view: string): void;
}
export interface ProgressModalType extends ModalCoreType {
modal: ProgressModalContainerType;
}
export type ProgressModalParams = Omit<ModalParams, 'modalTitle'> & {
/**
* Progression view title (optional) ex: 'Completing %total% actions'
*/
progressionTitle?: string;
/**
* Progression message in progress view Default: 'Processing %done% / %total% elements.'
*/
progressionMessage: string;
/**
* Close button label Default: 'Close'
*/
closeLabel: string;
/**
* Abort process button label Default: 'Stop processing'
*/
abortProcessingLabel: string;
/**
* Errors message in error view, Default: '%error_count% errors occurred. You can download the logs for future reference.'
*/
errorsMessage: string;
/**
* Back to processing button label Default: 'Back to processing'
*/
backToProcessingLabel: string;
/**
* Download error log label Default: 'Download error log'
*/
downloadErrorLogLabel: string;
/**
* View error log label Default: 'View %error_count% error logs'
*/
viewErrorLogLabel: string;
/**
* Error view title Default: 'Error log'
*/
viewErrorTitle: string;
/**
* Total number of elements to process (mandatory)
*/
total: number;
/**
* Additional buttons to display in the progress view footer (optional)
*/
customButtons: Array<HTMLButtonElement | HTMLAnchorElement>;
/**
* Abort callback triggered when the user stops/abort the bulk progress (optional)
*/
abortCallback?: () => void;
}
export type InputProgressModalParams = Partial<ProgressModalParams> & {
/**
* Total must be provided in input params, the other field are either optional or have default values.
*/
total: number;
};
/**
* Common interface for all views
*/
export type ViewContainerType = {
content: HTMLElement;
body: HTMLElement;
message: HTMLElement;
header: HTMLElement;
title?: HTMLElement;
};
const ProgressModalMap = ComponentsMap.progressModal;
export class ProgressModalContainer extends ModalContainer implements ProgressModalContainerType {
static readonly PROGRESS_VIEW: string = 'progress_view';
static readonly ERROR_VIEW: string = 'error_view';
progressView!: ProgressView;
errorView!: ErrorView;
protected currentView!: string;
/* This constructor is important to force the input type but ESLint is not happy about it*/
/* eslint-disable no-useless-constructor */
constructor(params: ProgressModalParams) {
super(params);
}
/**
* This container is a bit different it
*/
protected buildModalContainer(params: ProgressModalParams): void {
this.container = document.createElement('div');
this.container.classList.add('modal', 'fade', ProgressModalMap.classes.modal);
this.container.id = params.id;
// Modal dialog element
this.dialog = document.createElement('div');
this.dialog.classList.add('modal-dialog');
if (params.dialogStyle) {
Object.keys(params.dialogStyle).forEach((key: string) => {
// @ts-ignore
this.dialog.style[key] = params.dialogStyle[key];
});
}
this.progressView = new ProgressView(params);
this.errorView = new ErrorView(params);
this.container.appendChild(this.dialog);
this.toggleView(this.progressView);
this.currentView = ProgressModalContainer.PROGRESS_VIEW;
}
switchView(view: string): void {
if (this.currentView === view) {
return;
}
if (view === ProgressModalContainer.PROGRESS_VIEW) {
this.toggleView(this.progressView);
} else if (view === ProgressModalContainer.ERROR_VIEW) {
this.toggleView(this.errorView);
} else {
console.error(`Unknown view ${view}`);
return;
}
this.currentView = view;
}
protected toggleView(viewContainer: ViewContainerType): void {
if (this.dialog.contains(this.progressView.content)) {
this.dialog.removeChild(this.progressView.content);
}
if (this.dialog.contains(this.errorView.content)) {
this.dialog.removeChild(this.errorView.content);
}
// Update references to modal usual elements
this.content = viewContainer.content;
this.message = viewContainer.message;
this.header = viewContainer.header;
this.title = viewContainer.title;
this.body = viewContainer.body;
this.dialog.appendChild(viewContainer.content);
}
}
export class ProgressView implements ViewContainerType {
footer: HTMLElement;
content: HTMLElement;
message: HTMLElement;
header: HTMLElement;
title?: HTMLElement;
body: HTMLElement;
abortProcessingButton: HTMLElement;
closeModalButton: HTMLElement;
switchToErrorButton: HTMLElement;
progressDone!: HTMLElement;
lastError: HTMLElement;
progressMessage: HTMLElement;
progressPercent: HTMLElement;
progressIcon: HTMLElement;
constructor(params: ProgressModalParams) {
// Modal content element
this.content = document.createElement('div');
this.content.classList.add('modal-content');
// Modal message element
this.message = document.createElement('p');
this.message.classList.add('modal-message');
// Modal header element
this.header = document.createElement('div');
this.header.classList.add('modal-header');
if (params.progressionTitle) {
this.title = document.createElement('h4');
this.title.classList.add('modal-title');
this.title.innerHTML = params.progressionTitle.replace('%total%', String(params.total));
this.header.append(this.title);
}
this.switchToErrorButton = document.createElement('div');
this.switchToErrorButton.classList.add(
ProgressModalMap.classes.switchToErrorButton,
'alert',
'alert-warning',
'd-none',
);
this.switchToErrorButton.innerHTML = params.viewErrorLogLabel.replace('%error_count%', '0');
this.header.append(this.switchToErrorButton);
// Modal body element
this.body = document.createElement('div');
this.body.classList.add('modal-body', 'text-left', 'font-weight-normal');
// Progress headline with icon and progression message embedded in a parent div
const progressHeadline = document.createElement('div');
progressHeadline.classList.add(ProgressModalMap.classes.progressHeadline);
this.progressMessage = document.createElement('div');
this.progressMessage.classList.add(ProgressModalMap.classes.progressMessage);
this.progressMessage.innerHTML = params.progressionMessage
.replace('%done%', '0')
.replace('%total%', String(params.total));
this.progressIcon = document.createElement('span');
this.progressIcon.classList.add(ProgressModalMap.classes.progressIcon);
const spinner = document.createElement('div');
spinner.classList.add('spinner');
this.progressIcon.appendChild(spinner);
this.progressPercent = document.createElement('span');
this.progressPercent.classList.add(ProgressModalMap.classes.progressPercent);
this.progressPercent.innerHTML = '0%';
progressHeadline.append(this.progressIcon);
progressHeadline.append(this.progressMessage);
progressHeadline.append(this.progressPercent);
this.body.append(progressHeadline);
// Then add progress bar
this.body.append(this.buildProgressBar());
this.lastError = document.createElement('div');
this.lastError.classList.add('alert', 'alert-warning', 'd-print-none', 'd-none');
this.body.append(this.lastError);
// Modal footer element
this.footer = document.createElement('div');
this.footer.classList.add('modal-footer');
this.abortProcessingButton = document.createElement('button');
this.abortProcessingButton.setAttribute('type', 'button');
this.abortProcessingButton.classList.add('btn', 'btn-secondary', 'btn-lg', ProgressModalMap.classes.stopProcessing);
this.abortProcessingButton.innerHTML = params.abortProcessingLabel;
this.closeModalButton = document.createElement('button');
this.closeModalButton.setAttribute('type', 'button');
this.closeModalButton.classList.add('btn', 'btn-primary', 'btn-lg', ProgressModalMap.classes.closeModalButton, 'd-none');
this.closeModalButton.innerHTML = params.closeLabel;
this.closeModalButton.dataset.dismiss = 'modal';
// Appending element to the modal
this.footer.append(this.abortProcessingButton, this.closeModalButton, ...params.customButtons);
this.content.append(this.header, this.body, this.footer);
}
protected buildProgressBar(): HTMLElement {
const progressBar = document.createElement('div');
progressBar.setAttribute('style', 'display: block; width: 100%');
progressBar.classList.add(
'progress',
'active',
);
this.progressDone = document.createElement('div');
this.progressDone.classList.add(
'progress-bar',
'progress-bar-success',
);
this.progressDone.setAttribute('style', 'width: 0%');
this.progressDone.setAttribute('role', 'progressbar');
this.progressDone.setAttribute('aria-valuemax', '100');
this.progressDone.id = ProgressModalMap.classes.progressBarDone;
progressBar.append(this.progressDone);
return progressBar;
}
}
export class ErrorView implements ViewContainerType {
footer: HTMLElement;
content: HTMLElement;
message: HTMLElement;
header: HTMLElement;
title?: HTMLElement;
body: HTMLElement;
errorMessage: HTMLElement;
switchToProgressButton: HTMLElement;
downloadErrorsButton: HTMLElement;
errorContainer: HTMLElement;
constructor(params: ProgressModalParams) {
this.content = document.createElement('div');
this.content.classList.add('modal-content');
// Modal message element
this.message = document.createElement('p');
this.message.classList.add('modal-message');
// Modal header element
this.header = document.createElement('div');
this.header.classList.add('modal-header');
// Modal title element
this.title = document.createElement('h4');
this.title.classList.add('modal-title');
this.title.innerHTML = params.viewErrorTitle;
this.header.appendChild(this.title);
this.body = document.createElement('div');
this.body.classList.add('modal-body', 'text-left', 'font-weight-normal');
this.errorMessage = document.createElement('div');
this.errorMessage.classList.add(ProgressModalMap.classes.errorMessage);
this.errorMessage.innerHTML = params.errorsMessage.replace('%error_count%', '0');
this.body.append(this.errorMessage);
this.errorContainer = document.createElement('div');
this.errorContainer.classList.add(
ProgressModalMap.classes.errorContainer,
'd-print-none',
);
this.body.append(this.errorContainer);
this.footer = document.createElement('div');
this.footer.classList.add('modal-footer');
this.switchToProgressButton = document.createElement('div');
this.switchToProgressButton.classList.add(
ProgressModalMap.classes.switchToProgressButton,
'btn',
'btn-secondary',
);
this.switchToProgressButton.innerHTML = params.backToProcessingLabel;
this.downloadErrorsButton = document.createElement('div');
this.downloadErrorsButton.classList.add(
ProgressModalMap.classes.downloadErrorLogButton,
'btn',
'btn-secondary',
);
const downloadIcon = ProgressModal.getProgressIcon('download');
this.downloadErrorsButton.innerHTML = `${downloadIcon.outerHTML} ${params.downloadErrorLogLabel}`;
this.footer.append(this.switchToProgressButton);
this.footer.append(this.downloadErrorsButton);
this.content.append(this.header, this.body, this.footer);
}
}
/**
* ConfirmModal component
*
* @param {InputConfirmModalParams} params
*/
export class ProgressModal extends Modal implements ProgressModalType {
modal!: ProgressModalContainerType;
protected doneCount: number;
protected total: number;
protected errors: Array<string>;
protected progressStopped: boolean;
protected params: ProgressModalParams;
constructor(inputParams: InputProgressModalParams) {
const params: ProgressModalParams = {
id: 'progress-modal',
customButtons: [],
closable: false,
dialogStyle: {},
progressionMessage: 'Processing %done% / %total% elements.',
closeLabel: 'Close',
abortProcessingLabel: 'Stop processing',
errorsMessage: '%error_count% errors occurred. You can download the logs for future reference.',
backToProcessingLabel: 'Back to processing',
downloadErrorLogLabel: 'Download error log',
viewErrorLogLabel: 'View %error_count% error logs',
viewErrorTitle: 'Error log',
...inputParams,
};
super(params);
this.doneCount = 0;
this.total = params.total;
this.errors = [];
this.progressStopped = false;
this.params = params;
}
protected initContainer(params: ProgressModalParams): void {
this.modal = new ProgressModalContainer(params);
super.initContainer(params);
this.initListeners(params);
}
public updateProgress(doneCount: number): void {
this.doneCount = doneCount;
const percentDone = (this.doneCount * 100) / this.total;
this.modal.progressView.progressDone.style.width = `${String(percentDone)}%`;
// This attribute is used in CSS rules for low values
this.modal.progressView.progressDone.setAttribute('aria-valuenow', percentDone.toFixed());
this.modal.progressView.progressMessage.innerHTML = this.params.progressionMessage
.replace('%done%', String(this.doneCount))
.replace('%total%', String(this.params.total));
this.modal.progressView.progressPercent.innerHTML = `${String(percentDone.toFixed())}%`;
}
public addError(error: string): void {
this.errors.push(error);
const errorContent = document.createElement('p');
errorContent.classList.add(ProgressModalMap.classes.progressModalError);
errorContent.append(this.getWarningIcon());
errorContent.append(error);
this.modal.errorView.errorContainer.append(errorContent);
this.modal.progressView.switchToErrorButton.innerHTML = this.params.viewErrorLogLabel.replace(
'%error_count%',
this.errors.length.toString(),
);
this.modal.errorView.errorMessage.innerHTML = this.params.errorsMessage.replace(
'%error_count%',
this.errors.length.toFixed(),
);
this.modal.progressView.lastError.classList.remove('d-none');
this.modal.progressView.lastError.innerHTML = error;
this.modal.progressView.switchToErrorButton.classList.remove('d-none');
}
public completeProgress(): void {
this.stopProgress(this.errors.length > 0 ? this.getWarningIcon() : this.getCompleteIcon());
}
public interruptProgress(): void {
this.stopProgress(this.getStopIcon());
}
protected stopProgress(progressIcon: HTMLElement): void {
if (this.progressStopped) {
return;
}
this.replaceStopProcessButton();
this.modal.progressView.progressIcon.innerHTML = progressIcon.outerHTML;
this.progressStopped = true;
}
protected initListeners(params: ProgressModalParams): void {
this.modal.errorView.downloadErrorsButton.addEventListener('click', () => {
let csvContent = 'data:text/csv;charset=utf-8,';
this.errors.forEach((error) => {
csvContent += `${error}\r\n`;
});
const link = document.createElement('a');
link.href = encodeURI(csvContent);
link.download = 'errors.csv';
link.click();
});
this.modal.errorView.switchToProgressButton.addEventListener('click', () => {
this.modal.switchView(ProgressModalContainer.PROGRESS_VIEW);
});
this.modal.progressView.switchToErrorButton.addEventListener('click', () => {
this.modal.switchView(ProgressModalContainer.ERROR_VIEW);
});
this.modal.progressView.abortProcessingButton.addEventListener('click', () => {
this.interruptProgress();
if (params.abortCallback) {
params.abortCallback();
}
});
this.modal.progressView.closeModalButton.addEventListener('click', () => {
if (params.closeCallback) {
params.closeCallback();
}
});
}
protected replaceStopProcessButton(): void {
this.modal.progressView.abortProcessingButton.classList.add('d-none');
this.modal.progressView.closeModalButton.classList.remove('d-none');
}
protected getWarningIcon(): HTMLElement {
return ProgressModal.getProgressIcon('warning');
}
protected getCompleteIcon(): HTMLElement {
return ProgressModal.getProgressIcon('complete');
}
protected getStopIcon(): HTMLElement {
return ProgressModal.getProgressIcon('stop');
}
public static getProgressIcon(progressStatus: string): HTMLElement {
let iconContent: string;
switch (progressStatus) {
case 'complete':
iconContent = 'check';
break;
case 'stop':
iconContent = 'close';
break;
case 'download':
iconContent = 'file_download';
break;
default:
iconContent = progressStatus;
break;
}
const progressIcon = document.createElement('span');
progressIcon.classList.add(
'material-icons',
ProgressModalMap.classes.progressStatusIcon(progressStatus),
);
progressIcon.innerHTML = iconContent;
return progressIcon;
}
}
export default ProgressModal;

View File

@@ -0,0 +1,15 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Encapsulates selectors for multi shop modify all component
*/
export default {
modifyAllWidgets: '.modify-all-shops',
widgetCheckbox: '[type=checkbox]',
fieldFocusedClass: 'multi-shop-field-focused',
focusedClass: 'multi-shop-focused',
updatedClass: 'multi-shop-updated',
};

View File

@@ -0,0 +1,123 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {EventEmitter} from '@components/event-emitter';
import ComponentsMap from '@components/components-map';
import MultiShopModifyAllMap from './component-map';
const {$} = window;
/**
* Enables multi shop modify all checkboxes on the page. The checkboxes are hidden by default,
* they appear on input focus and stay visible when the value changed.
*/
export default class ModifyAllShopsCheckbox {
private modifyAllNamePrefix: string;
private richTextAreas: Record<string, Element>;
/**
* @param {string} modifyAllNamePrefix
*/
constructor(modifyAllNamePrefix = '') {
if (!modifyAllNamePrefix) {
this.modifyAllNamePrefix = 'modify_all_shops_';
} else {
this.modifyAllNamePrefix = modifyAllNamePrefix;
}
this.richTextAreas = {};
this.init();
}
init(): void {
const widgets = document.querySelectorAll(MultiShopModifyAllMap.modifyAllWidgets);
widgets.forEach((widget: Element) => {
const widgetCheckbox: HTMLInputElement = <HTMLInputElement>widget.querySelector(MultiShopModifyAllMap.widgetCheckbox);
if (widgetCheckbox) {
// If checkbox is already checked on page load (after submit with errors for example) it is considered updated and visible
if (widgetCheckbox.checked) {
widget.classList.add(MultiShopModifyAllMap.updatedClass);
}
const multiShopFieldId: string = widgetCheckbox.id.replace(this.modifyAllNamePrefix, '');
const multiShopField: HTMLInputElement = <HTMLInputElement>document.getElementById(multiShopFieldId);
if (multiShopField) {
const $multiShopField = $(multiShopField);
// Toggle element when field (or its children inputs) is focused/unfocused except when it is dropdown toggling element
$multiShopField.on('focus', () => {
widget.classList.add(MultiShopModifyAllMap.fieldFocusedClass);
});
$multiShopField.on('focus', ':input', (event) => {
const {currentTarget} = <any> event;
// ignore focus event if target is dropdown (so that the checkbox doesn't mess with the dropdown selection)
if (currentTarget instanceof HTMLElement && currentTarget.dataset.toggle !== 'dropdown') {
widget.classList.add(MultiShopModifyAllMap.fieldFocusedClass);
}
});
// Search tiny mce editors and store them, we need to wait for the component to be initialized to listen to its events
$(ComponentsMap.tineMceEditor.selector, $multiShopField).each((index, textarea) => {
this.richTextAreas[textarea.id] = widget;
});
$multiShopField.on('blur', () => {
widget.classList.remove(MultiShopModifyAllMap.fieldFocusedClass);
});
$multiShopField.on('blur', ':input', () => {
widget.classList.remove(MultiShopModifyAllMap.fieldFocusedClass);
});
// When the checkbox is hovered keep it visible (it will be hidden when field is unfocused otherwise)
widget.addEventListener('mouseenter', () => {
widget.classList.add(MultiShopModifyAllMap.focusedClass);
});
widget.addEventListener('mouseleave', () => {
widget.classList.remove(MultiShopModifyAllMap.focusedClass);
});
// Once the field (or the checkbox) has changed the checkbox is permanently visible
multiShopField.addEventListener('change', () => {
widget.classList.add(MultiShopModifyAllMap.updatedClass);
});
// We check the event via JQuery as well because some components use internal JQuery event instead of native
// ones (like select2) And it allows to check all children changes easily
$multiShopField.on('change dp.change', () => {
widget.classList.add(MultiShopModifyAllMap.updatedClass);
});
// Check for checkbox change also, in case it is modified programmatically
widgetCheckbox.addEventListener('change', () => {
widget.classList.add(MultiShopModifyAllMap.updatedClass);
});
}
}
});
if (Object.keys(this.richTextAreas).length > 0) {
// We wait for editor setup event to register events on tinymce editors
EventEmitter.on('tinymceEditorSetup', (event) => {
const {editor} = event;
if (Object.prototype.hasOwnProperty.call(this.richTextAreas, editor.id)) {
const widget: Element = this.richTextAreas[editor.id];
editor.on('Focus', () => {
widget.classList.add(MultiShopModifyAllMap.fieldFocusedClass);
});
editor.on('Blur', () => {
widget.classList.remove(MultiShopModifyAllMap.fieldFocusedClass);
});
editor.on('Change', () => {
widget.classList.add(MultiShopModifyAllMap.updatedClass);
});
}
});
}
}
}

View File

@@ -0,0 +1,464 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {EventEmitter} from 'events';
import ConfirmModal from '@components/modal';
import ComponentsMap from './components-map';
const ModuleCardMap = ComponentsMap.moduleCard;
const {$} = window;
/**
* Class is responsible for handling Module Card behavior
*/
export default class ModuleCard {
moduleActionMenuLinkSelector: string;
moduleActionMenuInstallLinkSelector: string;
moduleActionMenuEnableLinkSelector: string;
moduleActionMenuUninstallLinkSelector: string;
moduleActionMenuDisableLinkSelector: string;
moduleActionMenuResetLinkSelector: string;
moduleActionMenuUpdateLinkSelector: string;
moduleActionMenuDeleteLinkSelector: string;
moduleItemListSelector: string;
moduleItemActionsSelector: string;
moduleActionModalDisableLinkSelector: string;
moduleActionModalResetLinkSelector: string;
moduleActionModalUninstallLinkSelector: string;
forceDeletionOption: string;
private pendingRequest: boolean = false;
private eventEmitter: EventEmitter;
constructor() {
/* Selectors for module action links (uninstall, reset, etc...) to add a confirm popin */
this.moduleActionMenuLinkSelector = 'button.module_action_menu_';
this.moduleActionMenuInstallLinkSelector = 'button.module_action_menu_install';
this.moduleActionMenuEnableLinkSelector = 'button.module_action_menu_enable';
this.moduleActionMenuUninstallLinkSelector = 'button.module_action_menu_uninstall';
this.moduleActionMenuDisableLinkSelector = 'button.module_action_menu_disable';
this.moduleActionMenuResetLinkSelector = 'button.module_action_menu_reset';
this.moduleActionMenuUpdateLinkSelector = 'button.module_action_menu_upgrade';
this.moduleActionMenuDeleteLinkSelector = 'button.module_action_menu_delete';
this.moduleItemListSelector = '.module-item-list';
this.moduleItemActionsSelector = '.module-actions';
/* Selectors only for modal buttons */
this.moduleActionModalDisableLinkSelector = 'a.module_action_modal_disable';
this.moduleActionModalResetLinkSelector = 'a.module_action_modal_reset';
this.moduleActionModalUninstallLinkSelector = 'a.module_action_modal_uninstall';
this.forceDeletionOption = '#force_deletion';
this.eventEmitter = window.prestashop.component.EventEmitter;
this.initActionButtons();
}
initActionButtons(): void {
const self = this;
$(document).on('click', this.forceDeletionOption, function () {
const btn = $(
self.moduleActionModalUninstallLinkSelector,
$(ModuleCardMap.moduleItemList(<string>$(this).attr('data-tech-name'))),
);
if ($(this).prop('checked') === true) {
btn.attr('data-deletion', 'true');
} else {
btn.removeAttr('data-deletion');
}
});
$(document).on(
'click',
this.moduleActionMenuInstallLinkSelector,
function () {
return (
self.dispatchPreEvent('install', this)
&& self.confirmAction('install', this)
&& self.requestToController('install', $(this))
);
},
);
$(document).on(
'click',
this.moduleActionMenuEnableLinkSelector,
function () {
return (
self.dispatchPreEvent('enable', this)
&& self.confirmAction('enable', this)
&& self.requestToController('enable', $(this))
);
},
);
$(document).on(
'click',
this.moduleActionMenuUninstallLinkSelector,
function () {
return (
self.dispatchPreEvent('uninstall', this)
&& self.confirmAction('uninstall', this)
&& self.requestToController('uninstall', $(this))
);
},
);
$(document).on(
'click',
this.moduleActionMenuDeleteLinkSelector,
function () {
return (
self.dispatchPreEvent('delete', this)
&& self.confirmAction('delete', this)
&& self.requestToController('delete', $(this))
);
},
);
$(document).on(
'click',
this.moduleActionMenuDisableLinkSelector,
function () {
return (
self.dispatchPreEvent('disable', this)
&& self.confirmAction('disable', this)
&& self.requestToController('disable', $(this))
);
},
);
$(document).on('click', this.moduleActionMenuResetLinkSelector, function () {
return (
self.dispatchPreEvent('reset', this)
&& self.confirmAction('reset', this)
&& self.requestToController('reset', $(this))
);
});
$(document).on('click', this.moduleActionMenuUpdateLinkSelector, function (
event,
) {
event.preventDefault();
const modal = $(`#${$(this).data('confirm_modal')}`);
const isMaintenanceMode = window.isShopMaintenance;
if (modal.length !== 1) {
// Modal body element
const maintenanceLink = document.createElement('a');
maintenanceLink.classList.add('btn', 'btn-primary', 'btn-lg');
maintenanceLink.setAttribute('href', window.moduleURLs.maintenancePage);
maintenanceLink.innerHTML = window.moduleTranslations.moduleModalUpdateMaintenance;
const updateConfirmModal = new ConfirmModal(
{
id: 'confirm-module-update-modal',
confirmTitle:
window.moduleTranslations.singleModuleModalUpdateTitle,
closeButtonLabel: window.moduleTranslations.moduleModalUpdateCancel,
confirmButtonLabel: isMaintenanceMode
? window.moduleTranslations.moduleModalUpdateUpgrade
: window.moduleTranslations.upgradeAnywayButtonText,
confirmButtonClass: isMaintenanceMode
? 'btn-primary'
: 'btn-secondary',
confirmMessage: isMaintenanceMode
? ''
: window.moduleTranslations.moduleModalUpdateConfirmMessage,
closable: true,
customButtons: isMaintenanceMode ? [] : [maintenanceLink],
},
() => self.dispatchPreEvent('update', this)
&& self.confirmAction('update', this)
&& self.upgradeWithUploadFallback(this),
);
updateConfirmModal.show();
} else {
return self.dispatchPreEvent('update', this)
&& self.confirmAction('update', this)
&& self.upgradeWithUploadFallback(this);
}
return false;
});
$(document).on(
'click',
this.moduleActionModalDisableLinkSelector,
function () {
return self.requestToController(
'disable',
$(
self.moduleActionMenuDisableLinkSelector,
$(
ModuleCardMap.moduleItemList(
<string>$(this).attr('data-tech-name'),
),
),
),
);
},
);
$(document).on(
'click',
this.moduleActionModalResetLinkSelector,
function () {
return self.requestToController(
'reset',
$(
self.moduleActionMenuResetLinkSelector,
$(
ModuleCardMap.moduleItemList(
<string>$(this).attr('data-tech-name'),
),
),
),
);
},
);
$(document).on(
'click',
this.moduleActionModalUninstallLinkSelector,
(e) => {
$(e.target)
.parents('.modal')
.on('hidden.bs.modal', () => self.requestToController(
'uninstall',
$(
self.moduleActionMenuUninstallLinkSelector,
$(
ModuleCardMap.moduleItemList(
<string>$(e.target).attr('data-tech-name'),
),
),
),
$(e.target).attr('data-deletion'),
),
);
},
);
}
confirmAction(action: string, element: string): boolean {
const modal = $(
ComponentsMap.confirmModal($(element).data('confirm_modal')),
);
if (modal.length !== 1) {
return true;
}
modal.first().modal('show');
return false; // do not allow a.href to reload the page. The confirm modal dialog will do it async if needed.
}
dispatchPreEvent(action: string, element: string): boolean {
const event = jQuery.Event('module_card_action_event');
$(element).trigger(event, [action]);
if (
event.isPropagationStopped() !== false
|| event.isImmediatePropagationStopped() !== false
) {
return false; // if all handlers have not been called, then stop propagation of the click event.
}
// @ts-ignore-next-line
return event.result !== false; // explicit false must be set from handlers to stop propagation of the click event.
}
hasPendingRequest(): boolean {
return this.pendingRequest;
}
requestToController(
action: string,
element: JQuery,
forceDeletion: string | boolean = false,
callback: (response?: any) => boolean = () => true,
): boolean {
if (this.pendingRequest) {
$.growl.warning({
message: window.translate_javascripts['An action is already in progress. Please wait for it to finish.'],
});
return false;
}
this.pendingRequest = true;
let jqElementObj = element.closest(this.moduleItemActionsSelector);
const form = element.closest('form');
const spinnerObj = $(
'<button class="btn-primary-reverse onclick unbind spinner "></button>',
);
// Use custom upload_url for 'upgrade' if available, otherwise use the default URL.
let url = `//${window.location.host}${form.attr('action')}`;
if (action === 'upload' && form.data('upload-url')) {
url = form.data('upload-url');
}
const actionParams = form.serializeArray();
let refreshNeeded = false;
if (forceDeletion === 'true' || forceDeletion === true) {
actionParams.push({name: 'actionParams[deletion]', value: 'true'});
}
$.ajax({
url,
dataType: 'json',
method: 'POST',
data: actionParams,
beforeSend() {
jqElementObj.hide();
jqElementObj.after(spinnerObj);
},
})
.done((result) => {
if (result === undefined) {
$.growl.error({
message: 'No answer received from server',
fixed: true,
});
return;
}
if (typeof result.status !== 'undefined' && result.status === false) {
$.growl.error({message: result.msg, fixed: true});
return;
}
const moduleTechName = Object.keys(result)[0];
if (result[moduleTechName].status === false) {
$.growl.error({message: result[moduleTechName].msg, fixed: true});
return;
}
if (result[moduleTechName].refresh_needed === true) {
refreshNeeded = true;
return;
}
const alteredSelector = this.moduleItemListSelector.replace('.', '');
let mainElement = null;
if (action === 'delete' && !result[moduleTechName].has_download_url) {
mainElement = jqElementObj.closest(`.${alteredSelector}`);
this.eventEmitter.emit('Module Delete', mainElement);
} else if (action === 'uninstall') {
mainElement = jqElementObj.closest(`.${alteredSelector}`);
mainElement.attr('data-installed', '0');
mainElement.attr('data-active', '0');
if ((forceDeletion === 'true' || forceDeletion === true) && !result[moduleTechName].has_download_url) {
this.eventEmitter.emit('Module Delete', mainElement);
} else {
this.eventEmitter.emit('Module Uninstalled', mainElement);
}
} else if (action === 'disable') {
mainElement = jqElementObj.closest(`.${alteredSelector}`);
mainElement.addClass(`${alteredSelector}-isNotActive`);
mainElement.attr('data-active', '0');
this.eventEmitter.emit('Module Disabled', mainElement);
} else if (action === 'enable') {
mainElement = jqElementObj.closest(`.${alteredSelector}`);
mainElement.removeClass(`${alteredSelector}-isNotActive`);
mainElement.attr('data-active', '1');
this.eventEmitter.emit('Module Enabled', mainElement);
} else if (action === 'install') {
mainElement = jqElementObj.closest(`.${alteredSelector}`);
mainElement.attr('data-installed', '1');
mainElement.attr('data-active', '1');
mainElement.removeClass(`${alteredSelector}-isNotActive`);
this.eventEmitter.emit('Module Installed', mainElement);
} else if (action === 'update' || action === 'upgrade') { // because the action is update on ModuleManager button and upgrade on bulk actions
mainElement = jqElementObj.closest(`.${alteredSelector}`);
this.eventEmitter.emit('Module Upgraded', mainElement);
}
if (action !== 'upload') {
$.growl({
message: result[moduleTechName].msg,
duration: 6000,
});
// Since we replace the DOM content
// we need to update the jquery object reference to target the new content,
// and we need to hide the new content which is not hidden by default
jqElementObj = $(result[moduleTechName].action_menu_html).replaceAll(jqElementObj);
jqElementObj.hide();
}
})
.fail(() => {
const moduleItem = jqElementObj.closest('module-item-list');
const techName = moduleItem.data('techName');
$.growl.error({
message: `Could not perform action ${action} for module ${techName}`,
fixed: true,
});
})
.always((response) => {
if (refreshNeeded) {
document.location.reload();
return;
}
jqElementObj.fadeIn();
spinnerObj.remove();
this.pendingRequest = false;
if (callback) {
callback(Object.values(response)[0]);
}
});
return false;
}
upgradeWithUploadFallback(element: string, callback = () => true): boolean {
const form = $(element).closest('form');
// If the form contains a data-upload-url attribute, we use two step workflow.
if (form.data('upload-url')) {
try {
return this.requestToController('upload', $(element), false, (response): boolean => {
if (response.status === true) {
return this.requestToController('upgrade', $(element), false, callback);
}
return false;
});
} catch (error) {
console.error('Error making request', error);
return false;
}
} else {
return this.requestToController('upgrade', $(element), false, callback);
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Encapsulates selectors for multi store restriction component
*/
export default {
multiStoreRestrictionCheckbox: '.js-multi-store-restriction-checkbox',
multiStoreRestrictionSwitch: '.js-multi-store-restriction-switch',
sourceField: (targetValue: string): string => `[data-shop-restriction-source="${targetValue}"]`,
};

View File

@@ -0,0 +1,88 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import multiStoreRestrictionFieldMap from './multi-store-restriction-field-map';
const {$} = window;
/**
* Enables multi store functionality for the page. It includes switch functionality and checkboxes
*/
export default class MultiStoreRestrictionField {
constructor() {
$(document).on(
'change',
multiStoreRestrictionFieldMap.multiStoreRestrictionCheckbox,
(e: JQueryEventObject) => this.multiStoreRestrictionCheckboxFieldChangeEvent(e),
);
$(document).on(
'change',
multiStoreRestrictionFieldMap.multiStoreRestrictionSwitch,
(e: JQueryEventObject) => this.multiStoreRestrictionSwitchFieldChangeEvent(e),
);
}
/**
* Toggles the checkbox field and enables or disables its related field.
*
* @param {Event} e
* @private
*/
private multiStoreRestrictionCheckboxFieldChangeEvent(
e: JQueryEventObject,
): void {
const $currentItem = <JQuery<HTMLElement>>$(e.currentTarget);
this.toggleSourceFieldByTargetElement(
$currentItem,
!$currentItem.is(':checked'),
);
}
/**
* Mass updates multi-store checkbox fields - it enables or disabled the switch and after that
* it calls the function
* which handles the toggle update related form field by its current state.
* @param {Event} e
* @private
*/
private multiStoreRestrictionSwitchFieldChangeEvent(
e: JQueryEventObject,
): void {
const $currentItem = $(e.currentTarget);
const isSelected = parseInt(<string>$currentItem.val(), 10) === 1;
const targetFormName = $currentItem.data('targetFormName');
$(`form[name="${targetFormName}"]`)
.find(multiStoreRestrictionFieldMap.multiStoreRestrictionCheckbox)
.each((index, el) => {
const $el = $(el);
$el.prop('checked', isSelected);
this.toggleSourceFieldByTargetElement($el, !isSelected);
});
}
/**
* Changes related form fields state to disabled or enabled.
* It also toggles class disabled since for some fields
* this class is used instead of the native disabled attribute.
*
* @param {jquery} $targetElement
* @param {boolean} isDisabled
* @private
*/
private toggleSourceFieldByTargetElement(
$targetElement: JQuery,
isDisabled: boolean,
): void {
const targetValue = $targetElement.data('shopRestrictionTarget');
const $sourceFieldSelector = $(
multiStoreRestrictionFieldMap.sourceField(targetValue),
);
$sourceFieldSelector.prop('disabled', isDisabled);
$sourceFieldSelector.toggleClass('disabled', isDisabled);
}
}

View File

@@ -0,0 +1,46 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
const {$} = window;
/**
* MultipleChoiceTable is responsible for managing common actions in multiple choice table form type
*/
export default class MultipleChoiceTable {
/**
* Init constructor
*/
constructor() {
$(document).on(
'click',
ComponentsMap.multipleChoiceTable.selectColumn,
(e: JQueryEventObject) => this.handleSelectColumn(e),
);
}
/**
* Check/uncheck all boxes in column
*
* @param {Event} event
*/
handleSelectColumn(event: JQueryEventObject): void {
event.preventDefault();
const $selectColumnBtn = $(event.target);
const checked = $selectColumnBtn.data('column-checked');
$selectColumnBtn.data('column-checked', !checked);
const $table = $selectColumnBtn.closest('table');
$table
.find(
ComponentsMap.multipleChoiceTable.selectColumnCheckbox(
$selectColumnBtn.data('column-num'),
),
)
.prop('checked', !checked);
}
}

View File

@@ -0,0 +1,59 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
// @ts-ignore-next-line
import Bloodhound from 'typeahead.js';
import Router from '@components/router';
import AutoCompleteSearch from '@components/auto-complete-search';
import PerfectScrollbar from 'perfect-scrollbar';
import ComponentsMap from '@components/components-map';
import 'perfect-scrollbar/css/perfect-scrollbar.css';
const {$} = window;
const initMultistoreDropdown = () => {
const MultistoreDropdownMap = ComponentsMap.multistoreDropdown;
const $searchInput = $(MultistoreDropdownMap.searchInput);
const router = new Router();
const route = router.generate('admin_shops_search', {
searchTerm: '__QUERY__',
});
if ($(MultistoreDropdownMap.scrollbar).length > 0) {
new PerfectScrollbar(MultistoreDropdownMap.scrollbar);
}
const source = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace,
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: route,
wildcard: '__QUERY__',
},
});
const dataSetConfig = {
display: 'name',
value: 'id',
source,
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
onSelect(selectedItem: any, event: Event) {
const contextUrlLetter = typeof selectedItem.groupName !== 'undefined' ? 's' : 'g';
window.location.href = ComponentsMap.multistoreHeader.setContextUrl(
window.location.href,
contextUrlLetter,
selectedItem.id,
);
return true;
},
};
new AutoCompleteSearch($searchInput, dataSetConfig);
};
$(() => {
initMultistoreDropdown();
});

View File

@@ -0,0 +1,104 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
// @ts-ignore-next-line
import Bloodhound from 'typeahead.js';
import Router from '@components/router';
import AutoCompleteSearch from '@components/auto-complete-search';
import PerfectScrollbar from 'perfect-scrollbar';
import ComponentsMap from '@components/components-map';
import initContextualNotification from '@components/contextual-notification';
import 'perfect-scrollbar/css/perfect-scrollbar.css';
const {$} = window;
const initMultistoreHeader = () => {
const MultistoreHeaderMap = ComponentsMap.multistoreHeader;
const headerButton = document.querySelector(MultistoreHeaderMap.headerButton);
const modalMultishop = document.querySelector(MultistoreHeaderMap.modal);
const modalMultishopDialog = document.querySelector(MultistoreHeaderMap.modalDialog);
const $searchInput = $(MultistoreHeaderMap.searchInput);
const router = new Router();
const route = router.generate('admin_shops_search', {
searchTerm: '__QUERY__',
});
new PerfectScrollbar(MultistoreHeaderMap.jsScrollbar);
const source = new Bloodhound({
datumTokenizer: Bloodhound.tokenizers.obj.whitespace,
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: route,
wildcard: '__QUERY__',
},
});
const dataSetConfig = {
source,
onSelect(selectedItem: any) {
const contextUrlLetter = typeof selectedItem.groupName !== 'undefined' ? 's' : 'g';
const setContextUrl = MultistoreHeaderMap.setContextUrl(
window.location.href,
contextUrlLetter,
selectedItem.id,
);
window.location.href = setContextUrl;
return true;
},
};
new AutoCompleteSearch($searchInput, dataSetConfig);
function toggleModal(): void {
if (!headerButton || !modalMultishop) {
return;
}
modalMultishop.classList.toggle('multishop-modal-hidden');
headerButton.classList.toggle('active');
}
if (headerButton && modalMultishop && modalMultishopDialog) {
headerButton.addEventListener('click', () => {
toggleModal();
});
modalMultishop.addEventListener('click', (e: Event) => {
if (e.target instanceof Node && !modalMultishopDialog.contains(e.target)) {
toggleModal();
}
}, false);
}
/**
* Header multishop links don't handle anchors which might be useful for tab navigation for example
* so we synchronize them via javascript
*/
function updateLinksAnchor(): void {
function updateLinkAnchor(shopLink: HTMLLinkElement) {
if (!shopLink.hasAttribute('href')) {
return;
}
const updatedLink = shopLink.href.replace(/#(.*)$/, '') + window.location.hash;
shopLink.setAttribute('href', updatedLink);
}
const shopLinks: NodeListOf<HTMLLinkElement> = document.querySelectorAll(MultistoreHeaderMap.shopLinks);
shopLinks.forEach(updateLinkAnchor);
const groupShopLinks: NodeListOf<HTMLLinkElement> = document.querySelectorAll(MultistoreHeaderMap.groupShopLinks);
groupShopLinks.forEach(updateLinkAnchor);
}
updateLinksAnchor();
window.addEventListener('hashchange', updateLinksAnchor);
};
$(() => {
initMultistoreHeader();
initContextualNotification('header-color');
});

View File

@@ -0,0 +1,69 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import NavbarHandler from './navbar-handler';
type NavbarFormErrorHandlerType = {
form: HTMLElement;
navbarHandler: NavbarHandler;
}
/**
* This component is used as a wrapper for the NavbahHandler component. It allows handling
* tab redirection to the tab that contains some HTML form errors.
* Use this component only if you are using a form with NavbarHandler.
*/
export default class NavbarFormErrorHandler {
private readonly form: HTMLElement;
private readonly navbarHandler: NavbarHandler;
constructor(options: NavbarFormErrorHandlerType) {
this.navbarHandler = options.navbarHandler;
this.form = options.form;
this.initListener();
}
private findAllFormFields(): NodeListOf<HTMLElement> {
return this.form.querySelectorAll('input, select, textarea');
}
private initListener(): void {
let isFirstInvalidField = false;
this.findAllFormFields().forEach((field) => {
field.addEventListener('invalid', () => {
if (isFirstInvalidField) {
return;
}
isFirstInvalidField = true;
const tab = field.closest('[role="tabpanel"]');
if (!tab || typeof tab === null) {
throw new Error('NavbarFormErrorHandler: Cannot find the tab that contains some form fields in error.');
}
if (!('id' in tab)) {
throw new Error('NavbarFormErrorHandler: Id missing from the tab.');
}
this.navbarHandler.switchToTarget(`#${tab.id}`);
field.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
// Set a timeout to reset the flag after the current event loop
setTimeout(() => {
isFirstInvalidField = false;
}, 0);
});
});
}
}

View File

@@ -0,0 +1,101 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* This component watches a navigation bar and is able to link it alternative links, and automatic switch
* on page load.
*
* You can add button with class tab-link when they are clicked the tab target is fetched
* from the button's data property targetTab (so data-target-tab), it then search for a tab
* which targets matches in the navbar and simulates a click on it.
*
* Alternatively it also checks on page load if a hash matches a tab and activates it if one is found,
* and of course the hash is kept in sync when the navbar or alternative links are used.
*/
export default class NavbarHandler {
tabPrefix?: string;
$navigationContainer: JQuery;
constructor($navigationContainer: JQuery, tabPrefix: string = 'tab-') {
// We use a tab prefix for hastag so that on reload the page doesn't auto scroll to the anchored element
this.tabPrefix = tabPrefix;
this.$navigationContainer = $navigationContainer;
this.watchNavbar();
this.watchTabLinks();
this.switchOnPageLoad();
}
public getHashTarget(): string {
const {hash} = document.location;
return hash.replace(`#${this.tabPrefix}`, '#');
}
public switchToTarget(target: string): void {
if (!target) {
return;
}
const matchingTabs = $(`[href="${target}"]`, this.$navigationContainer);
if (matchingTabs.length <= 0) {
return;
}
const tabLink = matchingTabs.first();
this.switchToTab(tabLink);
}
private switchToTab(tab: JQuery): void {
tab.click();
this.updateBrowserHash(<string>tab.attr('href'));
}
private updateBrowserHash(target: string): void {
// Better use this rather than pushState because the hashchange event can be listened
window.location.hash = target.replace('#', `#${this.tabPrefix}`);
}
private watchNavbar(): void {
this.$navigationContainer.on(
'shown.bs.tab',
(event: JQueryEventObject) => {
// @ts-ignore-next-line
if (event.target.hash) {
// @ts-ignore-next-line
this.updateBrowserHash(event.target.hash);
}
},
);
}
private watchTabLinks(): void {
$('.tab-link').on('click', (event) => {
event.preventDefault();
const target = $(event.target).attr('href');
if (!target) {
return;
}
this.switchToTarget(`${target}`);
});
}
private switchOnPageLoad(): void {
const errorTabs = $('.has-error', this.$navigationContainer);
if (errorTabs.length) {
const errorTab = $('a[role="tab"]', errorTabs.first()).first();
this.switchToTab(errorTab);
} else {
const {hash} = document.location;
const target = hash.replace(`#${this.tabPrefix}`, '#');
this.switchToTarget(target);
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Toggle a class on $mainMenu after the end of an event (transition, animation...)
* @param {jQuery element} $navBar - The navbar item which get a css transition property.
* @param {jQuery element} $mainMenu - The menu inside the $navBar element.
* @param {string} endTransitionEvent - The name of the event.
* @param {jQuery element} $body - The body of the page.
* @method showNavBarContent - Toggle the class based on event and if body got a class.
* @method toggle - Add the listener if there is no transition launched yet.
* @return {Object} The object with methods wich permit to toggle on specific event.
*/
export const MAX_MOBILE_WIDTH = 1023;
const windowWidth = <number>$(window).width();
interface NavbarTransitionType {
$body: JQuery;
transitionFired: boolean;
$navBar: HTMLElement;
$mainMenu: JQuery;
endTransitionEvent: string;
showNavBarContent: (event: Event) => void;
toggle: () => void;
}
export function NavbarTransitionHandler(
this: NavbarTransitionType,
$navBar: JQuery,
$mainMenu: JQuery,
endTransitionEvent: string,
$body: JQuery,
): void {
this.$body = $body;
this.transitionFired = false;
this.$navBar = $navBar.get(0)!;
this.$mainMenu = $mainMenu;
this.endTransitionEvent = endTransitionEvent;
this.showNavBarContent = (event) => {
// @ts-ignore-next-line
if (event.propertyName !== 'width') {
return;
}
this.$navBar.removeEventListener(
this.endTransitionEvent,
this.showNavBarContent,
);
const isSidebarClosed = this.$body.hasClass('page-sidebar-closed');
if (windowWidth > MAX_MOBILE_WIDTH) {
this.$mainMenu.toggleClass('sidebar-closed', isSidebarClosed);
}
this.transitionFired = false;
};
this.toggle = () => {
if (!this.transitionFired) {
this.$navBar.addEventListener(
this.endTransitionEvent,
this.showNavBarContent.bind(this),
);
} else {
this.$navBar.removeEventListener(
this.endTransitionEvent,
this.showNavBarContent,
);
}
this.transitionFired = !this.transitionFired;
};
}

View File

@@ -0,0 +1,13 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const onReady = (callback: () => void): void => {
if (document.readyState !== 'loading') {
callback();
} else {
document.addEventListener('DOMContentLoaded', callback);
}
};
export default onReady;

View File

@@ -0,0 +1,359 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import PaginationServiceType from '@PSTypes/services';
import RendererType from '@PSTypes/renderers';
const {$} = window;
/**
* Related html template src/PrestaShopBundle/Resources/views/Admin/Common/javascript_pagination.html.twig
*
* Usage
*```
* $paginator new DynamicPaginator(
* '#foo-container',
* FooDataService,
* FooRenderer
* );
* this.eventEmitter.on('fooEventThatShouldTriggerPagination', () => $paginator.paginate(1));
*```
* You can also provide the starting page to initiate it automatically on page load:
*```
* $paginator new DynamicPaginator(
* '#foo-container',
* FooDataService,
* FooRenderer,
* 1
* );
*```
* There is also a possibility to provide custom selectorsMap as 5th argument. See this.setSelectorsMap().
*
* Pagination service must have a method fetch(offset, limit) which returns data.{any resources name} & data.total
* e.g.
* ```
* class FooDataService {
* fetch(offset, limit) {
* return $.get(this.router.generate('admin_products_combinations', {
* productId: this.productId,
* page,
* limit,
* }));
* }
* }
*```
* * In this case the action of route `admin_products_combinations` returns following json:
* ```
* {
* total: 100,
* combinations: [{combinationId: 1, name: foo...}, {combinationId: 2, name: bar...}]
* }
*```
*
* The renderer must have a method render(data) which accepts the data from PaginationService
* and renders it depending on needs
*/
export default class DynamicPaginator {
private $paginationContainer: JQuery;
private paginationService: PaginationServiceType;
private renderer: RendererType;
private currentPage: number;
private selectorsMap: Record<string, string>;
private pagesCount: number;
private total: number;
private totalInPage: number;
/**
* @param {String} containerSelector
* @param {Object} paginationService
* @param {Object} renderer
* @param {Number|null} startingPage If provided it will load the provided page data on page load
* @param {Object|null} selectorsMap If provided it will override css selectors used for all the actions.
*/
constructor(
containerSelector: string,
paginationService: PaginationServiceType,
renderer: RendererType,
startingPage = 0,
selectorsMap = {},
) {
this.$paginationContainer = $(containerSelector);
this.paginationService = paginationService;
this.renderer = renderer;
this.selectorsMap = {};
this.setSelectorsMap(selectorsMap);
this.pagesCount = 0;
this.total = 0;
this.totalInPage = 0;
this.init();
this.currentPage = startingPage;
if (startingPage > 0) {
this.paginate(startingPage);
}
}
/**
* @param {Number} page
*/
async paginate(page: number): Promise<void> {
this.currentPage = page > 0 ? page : 1;
this.renderer.setLoading(true);
const limit = this.getLimit();
const data: FetchResponse = await this.paginationService.fetch(
this.calculateOffset(page, limit),
limit,
);
$(this.selectorsMap.jumpToPageInput).val(page);
this.countPages(<number>data.total);
this.refreshButtonsData(page);
this.refreshInfoLabel(page, <number>data.total);
this.total = data.total;
this.setTotalInPage(page, limit, data.total);
this.toggleTargetAvailability(this.selectorsMap.firstPageItem, page > 1);
this.toggleTargetAvailability(this.selectorsMap.previousPageItem, page > 1);
this.toggleTargetAvailability(
this.selectorsMap.nextPageItem,
page < this.pagesCount,
);
this.toggleTargetAvailability(
this.selectorsMap.lastPageItem,
page < this.pagesCount,
);
this.toggleTargetAvailability(
this.selectorsMap.jumpToPageInput,
this.getPagesCount() > 1,
);
this.renderer.render(data);
this.$paginationContainer.toggleClass('d-none', this.getTotal() <= this.getMinLimit());
this.renderer.setLoading(false);
window.prestaShopUiKit.initToolTips();
}
getCurrentPage(): number {
return this.currentPage;
}
getPagesCount(): number {
return this.pagesCount;
}
getTotal(): number {
return this.total;
}
getTotalInPage(): number {
return this.totalInPage;
}
/**
* Initiates the pagination component
*
* @private
*/
private init(): void {
this.$paginationContainer.on('click', this.selectorsMap.pageLink, (e) => {
this.paginate(Number($(e.currentTarget).data('page')));
});
this.$paginationContainer
.find(this.selectorsMap.jumpToPageInput)
.on('keypress', (e) => {
if (e.which === 13) {
e.preventDefault();
const input = <HTMLInputElement>e.currentTarget;
const page = this.getValidPageNumber(Number(input.value));
this.paginate(page);
}
});
this.$paginationContainer.on(
'change',
this.selectorsMap.limitSelect,
() => {
this.paginate(1);
},
);
}
/**
* @param page
* @param limit
*
* @returns {Number}
*
* @private
*/
private calculateOffset(page: number, limit: number): number {
return (page - 1) * limit;
}
/**
* @param {Number} page
*
* @private
*/
private refreshButtonsData(page: number): void {
this.$paginationContainer
.find(this.selectorsMap.nextPageBtn)
.data('page', page + 1);
this.$paginationContainer
.find(this.selectorsMap.previousPageBtn)
.data('page', page - 1);
this.$paginationContainer
.find(this.selectorsMap.lastPageBtn)
.data('page', this.pagesCount);
}
/**
* @param {Number} page
* @param {Number} total
*
* @private
*/
private refreshInfoLabel(page: number, total: number): void {
const infoLabel = this.$paginationContainer.find(
this.selectorsMap.paginationInfoLabel,
);
const limit = this.getLimit();
const modifiedInfoText = infoLabel
.data('pagination-info')
.replace(/%from%/g, this.calculateFrom(page, limit))
.replace(/%to%/g, this.calculateTo(page, limit, total))
.replace(/%total%/g, total)
.replace(/%current_page%/g, page)
.replace(/%page_count%/g, this.pagesCount);
infoLabel.text(modifiedInfoText);
}
/**
* @param {String} targetSelector
* @param {Boolean} enable
*
* @private
*/
private toggleTargetAvailability(
targetSelector: string,
enable: boolean,
): void {
const target = this.$paginationContainer.find(targetSelector);
target.toggleClass('disabled', !enable);
target.prop('disabled', !enable);
}
/**
* @param {Number} total
*
* @private
*/
private countPages(total: number): void {
this.pagesCount = Math.ceil(total / this.getLimit());
const lastPageItem = this.$paginationContainer.find(
this.selectorsMap.lastPageBtn,
);
lastPageItem.data('page', this.pagesCount);
lastPageItem.text(this.pagesCount);
}
/**
* @returns {Number}
*
* @private
*/
private getLimit(): number {
return <number>(
this.$paginationContainer.find(this.selectorsMap.limitSelect).val()
);
}
/**
* @returns {Number}
*
* @private
*/
private getMinLimit(): number {
const limitSelections = this.$paginationContainer.find(`${this.selectorsMap.limitSelect} option`).get();
const limitValues = limitSelections.map((option:HTMLElement) => {
if (!(option instanceof HTMLOptionElement)) {
console.error('Only <option> elements are expected in <select> for list limit choices');
return 0;
}
return Number(option.value);
});
return Math.min(...limitValues);
}
/**
* @param page
*
* @returns {Number}
*
* @private
*/
private getValidPageNumber(page: number): number {
if (page > this.pagesCount) {
return this.pagesCount;
}
if (page < 1) {
return 1;
}
return page;
}
/**
* @param {Object} selectorsMap
*
* @private
*/
private setSelectorsMap(selectorsMap: Record<string, string>): void {
this.selectorsMap = {
jumpToPageInput: 'input[name="paginator-jump-page"]',
firstPageBtn: 'button.page-link.first',
firstPageItem: 'li.page-item.first',
nextPageBtn: 'button.page-link.next',
nextPageItem: 'li.page-item.next',
previousPageBtn: 'button.page-link.previous',
previousPageItem: 'li.page-item.previous',
lastPageItem: 'li.page-item.last',
lastPageBtn: 'button.page-link.last',
pageLink: 'button.page-link',
limitSelect: '#paginator-limit',
paginationInfoLabel: '#pagination-info',
//override with custom selectors if any provided
...selectorsMap,
};
}
private calculateFrom(page: number, limit: number): number {
// increment by 1 because offset, starts from 0, but lowest "from" can be 1
return page === 1 ? 1 : Math.round((page - 1) * limit + 1);
}
private calculateTo(page: number, limit:number, total: number) {
return page === this.pagesCount ? total : Math.round(page * limit);
}
private setTotalInPage(page: number, limit: number, total: number): void {
// increment by 1 to include the first "from" result to total count
this.totalInPage = this.calculateTo(page, limit, total) - this.calculateFrom(page, limit) + 1;
}
}

View File

@@ -0,0 +1,109 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Class responsible for checking password's validity.
* Can validate entered password's length against min/max values.
* If password confirmation input is provided, can validate if entered password is matching confirmation.
*/
export default class PasswordValidator {
newPasswordInput: HTMLInputElement | null;
confirmPasswordInput: HTMLInputElement | null;
minPasswordLength: number;
maxPasswordLength: number;
/**
* @param {String} passwordInputSelector selector of the password input.
* @param {String|null} confirmPasswordInputSelector (optional) selector for the password confirmation input.
* @param {Object} options allows overriding default options.
*/
constructor(
passwordInputSelector: string,
confirmPasswordInputSelector: string,
options: any = {},
) {
this.newPasswordInput = document.querySelector(passwordInputSelector);
this.confirmPasswordInput = document.querySelector(
confirmPasswordInputSelector,
);
// Minimum allowed length for entered password
this.minPasswordLength = options.minPasswordLength || 8;
// Maximum allowed length for entered password
this.maxPasswordLength = options.maxPasswordLength || 255;
}
/**
* Check if the password is valid.
*
* @returns {boolean}
*/
isPasswordValid(): boolean {
if (this.confirmPasswordInput && !this.isPasswordMatchingConfirmation()) {
return false;
}
return this.isPasswordLengthValid();
}
/**
* Check if password's length is valid.
*
* @returns {boolean}
*/
isPasswordLengthValid(): boolean {
return !this.isPasswordTooShort() && !this.isPasswordTooLong();
}
/**
* Check if password is matching it's confirmation.
*
* @returns {boolean}
*/
isPasswordMatchingConfirmation(): boolean {
if (!this.confirmPasswordInput) {
throw new Error(
'Confirm password input is not provided for the password validator.',
);
}
if (this.confirmPasswordInput.value === '' || !this.newPasswordInput) {
return true;
}
return this.newPasswordInput.value === this.confirmPasswordInput.value;
}
/**
* Check if password is too short.
*
* @returns {boolean}
*/
isPasswordTooShort(): boolean {
if (this.newPasswordInput?.value) {
return this.newPasswordInput.value.length < this.minPasswordLength;
}
return false;
}
/**
* Check if password is too long.
*
* @returns {boolean}
*/
isPasswordTooLong(): boolean {
if (this.newPasswordInput?.value) {
return this.newPasswordInput.value.length > this.maxPasswordLength;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import Routing from 'fos-routing';
import routes from '@js/fos_js_routes.json';
const {$} = window;
/* eslint-disable */
/**
* Wraps FOSJsRoutingbundle with exposed routes.
* To expose route add option `expose: true` in .yml routing config
*
* e.g.
*
* `my_route
* path: /my-path
* options:
* expose: true
* And run `bin/console fos:js-routing:dump --format=json --target=admin-dev/themes/new-theme/js/fos_js_routes.json`
*/
/* eslint-enable */
export default class Router {
constructor() {
if (window.prestashop && window.prestashop.customRoutes) {
Object.assign(routes.routes, window.prestashop.customRoutes);
}
Routing.setData(routes);
Routing.setBaseUrl(
$(document)
.find('body')
.data('base-url'),
);
}
/**
* Decorated "generate" method, with predefined security token in params
*
* @param route
* @param params
*
* @returns {String}
*/
generate(route: string, params: Record<string, unknown> = {}): string {
const tokenizedParams = Object.assign(params, {
_token: $(document)
.find('body')
.data('token'),
});
return Routing.generate(route, tokenizedParams);
}
}

View File

@@ -0,0 +1,112 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
/**
* Component that handle shop selector, basically a select input customized for better UI.
* The layout is found in the shop_selector_widget from src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/multishop.html.twig
*
* The component is configurable, it can be multiple or not:
* - in single mode the only selected shop is highlighted
* - in multiple mode you can select several shops, their initial state is also known which allows to update a label to indicate their state Add/Removed
*
* In both cases on interaction the related input triggers a change event so that other components can watch it.
*/
export default class ShopSelector {
constructor() {
document.querySelectorAll<HTMLElement>(ComponentsMap.shopSelector.container).forEach((container: HTMLElement) => {
const isMultiple = container.dataset.multiple;
if (isMultiple) {
const shopSelectorInput = container.querySelector<HTMLSelectElement>(ComponentsMap.shopSelector.selectInput);
if (shopSelectorInput) {
const initialShops: string[] = [];
Array.from(shopSelectorInput.selectedOptions).forEach((option: HTMLOptionElement) => {
initialShops.push(option.value);
});
shopSelectorInput.dataset.initialShops = initialShops.join(',');
}
}
});
document.querySelectorAll<HTMLElement>(ComponentsMap.shopSelector.shopItem).forEach((shopItem: HTMLElement) => {
shopItem.addEventListener('click', (event: MouseEvent) => {
const clickedShop: HTMLElement = event.currentTarget as HTMLElement;
const container = clickedShop.closest<HTMLElement>(ComponentsMap.shopSelector.container);
if (container) {
const isMultiple = container.dataset.multiple;
const shopSelectorInput = container.querySelector<HTMLSelectElement>(ComponentsMap.shopSelector.selectInput);
if (!shopSelectorInput) {
console.error(`Could not find selector ${ComponentsMap.shopSelector.selectInput}`);
return;
}
if (isMultiple) {
this.selectMultipleShops(container, shopSelectorInput);
} else {
this.selectSingleShop(clickedShop, shopSelectorInput);
}
}
});
});
}
private selectSingleShop(selectedShop: HTMLElement, shopSelectorInput: HTMLSelectElement): void {
document.querySelectorAll<HTMLElement>(ComponentsMap.shopSelector.shopItem).forEach((shopItem: HTMLElement) => {
shopItem.classList.remove(ComponentsMap.shopSelector.selectedClass);
});
selectedShop.classList.add(ComponentsMap.shopSelector.selectedClass);
// eslint-disable-next-line no-param-reassign
shopSelectorInput.value = selectedShop.dataset.shopId ?? '';
shopSelectorInput.dispatchEvent(new Event('change'));
}
private selectMultipleShops(container: HTMLElement, shopSelectorInput: HTMLSelectElement): void {
const selectedShops: number[] = [];
const shopData: string = shopSelectorInput.dataset.initialShops ?? '';
const initialShops: number[] = shopData.split(',').map((shopId: string) => parseInt(shopId, 10));
container.querySelectorAll<HTMLElement>(ComponentsMap.shopSelector.shopItem).forEach((shopItem: HTMLElement) => {
const shopId: number = parseInt(shopItem.dataset.shopId ?? '', 10);
if (Number.isNaN(shopId)) {
return;
}
if (shopItem.classList.contains(ComponentsMap.shopSelector.currentClass)) {
selectedShops.push(shopId);
return;
}
const shopStatus = shopItem.querySelector<HTMLElement>(ComponentsMap.shopSelector.shopStatus);
const checkbox = shopItem.querySelector('input');
const initiallySelected: boolean = initialShops.includes(shopId);
if (checkbox?.checked) {
selectedShops.push(shopId);
shopItem.classList.toggle(ComponentsMap.shopSelector.selectedClass, !initiallySelected);
if (shopStatus) {
shopStatus.innerHTML = initiallySelected ? '' : shopStatus?.dataset.addedLabel ?? '';
}
} else {
shopItem.classList.toggle(ComponentsMap.shopSelector.selectedClass, initiallySelected);
if (shopStatus) {
shopStatus.innerHTML = initiallySelected ? shopStatus?.dataset.removedLabel ?? '' : '';
}
}
});
// Finally apply/update the selected choices
Array.from(shopSelectorInput.options).forEach((option: HTMLOptionElement) => {
// eslint-disable-next-line no-param-reassign
option.selected = selectedShops.includes(parseInt(option.value, 10));
});
shopSelectorInput.dispatchEvent(new Event('change'));
}
}

View File

@@ -0,0 +1,36 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {ShowcaseCard} from '@PSTypes/showcase';
const {$} = window;
/**
* Class ShowcaseCardCloseExtension is responsible for providing helper block closing behavior
*/
export default class ShowcaseCardCloseExtension {
/**
* Extend helper block.
*
* @param {ShowcaseCard} helperBlock
*/
extend(helperBlock: ShowcaseCard): void {
const container = helperBlock.getContainer();
container.on('click', '.js-remove-helper-block', (evt: JQuery.ClickEvent) => {
container.remove();
const $btn = $(evt.target);
const url = $btn.data('closeUrl');
const cardName = $btn.data('cardName');
if (url) {
// notify the card was closed
$.post(url, {
close: 1,
name: cardName,
});
}
});
}
}

View File

@@ -0,0 +1,44 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {ShowcaseExtension} from '@PSTypes/showcase';
const {$} = window;
/**
* Class ShowcaseCard is responsible for handling events related with showcase card.
*/
export default class ShowcaseCard {
id: string;
$container: JQuery;
/**
* Showcase card id.
*
* @param {string} id
*/
constructor(id: string) {
this.id = id;
this.$container = $(`#${this.id}`);
}
/**
* Get showcase card container.
*
* @returns {jQuery}
*/
getContainer(): JQuery {
return this.$container;
}
/**
* Extend showcase card with external extensions.
*
* @param {object} extension
*/
addExtension(extension: ShowcaseExtension): void {
extension.extend(this);
}
}

View File

@@ -0,0 +1,110 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
interface TaggableFieldParams {
tokenFieldSelector: string;
options: TaggableFieldOptions;
}
interface TaggableFieldOptions {
/**
* Tokens (or tags). Can be:
* - a string with comma-separated values ("one,two,three")
* - an array of strings (["one","two","three"])
* - an array of objects ([{ value: "one", label: "Einz" }, { value: "two", label: "Zwei" }])
* @default []
*/
tokens?: string | string[],
/**
* Maximum number of tokens allowed. 0 = unlimited
* @default 0
*/
limit?: number,
/**
* Minimum length required for token value.
* @default 0
*/
minLength?: number,
/**
* Minimum input field width. In pixels.
* @default 60
*/
minWidth?: number,
/**
* jQuery UI Autocomplete options
* @default {}
*/
autocomplete?: any,
/**
* Whether to show autocomplete suggestions menu on focus or not. Works only for jQuery UI Autocomplete,
* as Typeahead has no support for this kind of behavior.
* @default false
*/
showAutocompleteOnFocus?: boolean,
/**
* Arguments for Twitter Typeahead. The first argument should be an options hash (or null if you want to use the
* defaults). The second argument should be a dataset. You can add multiple datasets:
* typeahead: [options, dataset1, dataset2]
* @default {}
*/
typeahead?: any,
/**
* Whether to turn input into tokens when tokenfield loses focus or not.
* @default false
*/
createTokensOnBlur?: boolean,
/**
* A character or an array of characters that will trigger token creation on keypress event. Defaults to ',' (comma).
* Note - this does not affect Enter or Tab keys, as they are handled in the keydown event. The first delimiter will
* be used as a separator when getting the list of tokens or copy-pasting tokens.
* @default ','
*/
delimiter?: string | string[],
/**
* Whether to insert spaces after each token when getting a comma-separated list of tokens. This affects both value
* returned by getTokensList() and the value of the original input field.
* @default true
*/
beautify?: boolean,
/**
* HTML type attribute for the token input. This is useful for specifying an HTML5 input type like 'email', 'url' or
* 'tel' which allows mobile browsers to show a specialized virtual keyboard optimized for different types of input.
* This only sets the type of the visible token input but does not touch the original input field. So you may set
* the original input to have type="text" but set this inputType option to 'email' if you only want to take advantage
* of the email style keyboard on mobile, but don't want to enable HTML5 native email validation on the original
* hidden input.
* @default 'text'
*/
inputType?: string,
/**
* Limit the number of characters allowed by token.
* @default 0
*/
maxCharacters?: number;
}
/**
* class TaggableField is responsible for providing functionality from bootstrap-tokenfield plugin.
* It allows to have taggable fields which are split in separate blocks once you click enter. Values originally saved
* in comma split strings.
*/
export default class TaggableField {
/**
* @param {string} tokenFieldSelector - a selector which is used within jQuery object.
* @param {object} options - extends basic tokenField behavior with additional options such as minLength, delimiter,
* allow to add token on focus out action. See bootstrap-tokenfield docs for more information.
*/
constructor({tokenFieldSelector, options = {}}: TaggableFieldParams) {
$(tokenFieldSelector).tokenfield(options);
const maxCharacters: number = options.maxCharacters || 0;
if (maxCharacters > 0) {
const $inputFields = $(tokenFieldSelector).siblings('.token-input');
$inputFields.prop('maxlength', maxCharacters);
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
const {$} = window;
interface TextToLinkParams {
sourceElementSelector: string;
destinationElementSelector: string;
options?: Record<string, string>;
}
/**
* Component which allows to copy regular text to url friendly text
*
* Usage example in template:
*
* <input name="source-input"
* class="js-link-rewrite-copier-source"> // The original text will be taken from this element
* <input name="destination-input"
* class="js-link-rewrite-copier-destination"> // Modified text will be added to this input
*
* in javascript:
*
* textToLinkRewriteCopier({
* sourceElementSelector: '.js-link-rewrite-copier-source'
* destinationElementSelector: '.js-link-rewrite-copier-destination',
* });
*
* If the source-input has value "test name" the link rewrite value will be "test-name".
* If the source-input has value "test name #$" link rewrite will be "test-name-" since #$
* are un allowed characters in url.
*
* You can also pass additional options to change the event name, or encoding format:
*
* textToLinkRewriteCopier({
* sourceElementSelector: '.js-link-rewrite-copier-source'
* destinationElementSelector: '.js-link-rewrite-copier-destination',
* options: {
* eventName: 'change', // default is 'input'
* }
* });
*
*/
const textToLinkRewriteCopier = ({
sourceElementSelector,
destinationElementSelector,
options = {eventName: 'input'},
}: TextToLinkParams): void => {
$(document).on(options.eventName, `${sourceElementSelector}`, (event) => {
if (!$(event.currentTarget).closest('form').data('id')) {
$(destinationElementSelector).val(
window.str2url($(event.currentTarget).val(), 'UTF-8'),
);
}
});
};
export default textToLinkRewriteCopier;

View File

@@ -0,0 +1,272 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import ComponentsMap from '@components/components-map';
import {EventEmitter} from './event-emitter';
const {$} = window;
/**
* This class init TinyMCE instances in the back-office. It is wildly inspired by
* the scripts from js/admin And it actually loads TinyMCE from the js/tiny_mce
* folder along with its modules. One improvement could be to install TinyMCE via
* npm and fully integrate in the back-office theme.
*/
class TinyMCEEditor {
constructor(options) {
const opts = options || {};
this.tinyMCELoaded = false;
if (typeof opts.baseAdminUrl === 'undefined') {
if (typeof window.baseAdminDir !== 'undefined') {
opts.baseAdminUrl = window.baseAdminDir;
} else {
const pathParts = window.location.pathname.split('/');
pathParts.every((pathPart) => {
if (pathPart !== '') {
opts.baseAdminUrl = `/${pathPart}/`;
return false;
}
return true;
});
}
}
if (typeof opts.langIsRtl === 'undefined') {
opts.langIsRtl = typeof window.lang_is_rtl !== 'undefined' ? window.lang_is_rtl === '1' : false;
}
this.setupTinyMCE(opts);
}
/**
* Initial setup which checks if the tinyMCE library is already loaded.
*
* @param config
*/
setupTinyMCE(config) {
if (typeof tinyMCE === 'undefined') {
this.loadAndInitTinyMCE(config);
} else {
this.initTinyMCE(config);
}
}
/**
* Prepare the config and init all TinyMCE editors
*
* @param config
*/
initTinyMCE(config) {
const cfg = {
selector: '.rte',
plugins:
/* eslint-disable-next-line max-len */
'align colorpicker link image filemanager table media placeholder lists advlist code table autoresize hr',
browser_spellcheck: true,
toolbar1:
/* eslint-disable-next-line max-len */
'code,colorpicker,bold,italic,underline,strikethrough,blockquote,link,align,bullist,numlist,table,image,media,formatselect,hr',
toolbar2: '',
language: window.iso_user,
external_filemanager_path: `${config.baseAdminUrl}filemanager/`,
filemanager_title: 'File manager',
external_plugins: {
filemanager: `${config.baseAdminUrl}filemanager/plugin.min.js`,
},
content_style: config.langIsRtl ? 'body {direction:rtl;}' : '',
skin: 'prestashop',
mobile: {
theme: 'mobile',
plugins: ['lists', 'align', 'link', 'table', 'placeholder', 'advlist', 'code', 'hr'],
toolbar:
/* eslint-disable-next-line max-len */
'undo code colorpicker bold italic underline strikethrough blockquote link align bullist numlist table formatselect styleselect hr',
},
menubar: false,
statusbar: false,
relative_urls: false,
convert_urls: false,
entity_encoding: 'raw',
extended_valid_elements: 'em[class|name|id],@[role|data-*|aria-*]',
valid_children: '+*[*]',
valid_elements: '*[*]',
rel_list: [{title: 'nofollow', value: 'nofollow'}],
editor_selector: ComponentsMap.tineMceEditor.selectorClass,
init_instance_callback: () => {
this.changeToMaterial();
},
setup: (editor) => {
this.setupEditor(editor);
},
...config,
};
if (typeof window.defaultTinyMceConfig !== 'undefined') {
Object.assign(cfg, window.defaultTinyMceConfig);
}
if (typeof cfg.editor_selector !== 'undefined') {
cfg.selector = `.${cfg.editor_selector}`;
}
EventEmitter.emit('initTinyMCE', {
config: cfg,
});
// Change icons in popups
$('body').on('click', '.mce-btn, .mce-open, .mce-menu-item', () => {
this.changeToMaterial();
});
window.tinyMCE.init(cfg);
this.watchTabChanges(cfg);
}
/**
* Setup TinyMCE editor once it has been initialized
*
* @param editor
*/
setupEditor(editor) {
editor.on('loadContent', (event) => {
this.handleCounterTiny(event.target.id);
});
editor.on('change', (event) => {
window.tinyMCE.triggerSave();
this.handleCounterTiny(event.target.id);
});
editor.on('blur', () => {
window.tinyMCE.triggerSave();
});
EventEmitter.emit('tinymceEditorSetup', {
editor,
});
}
/**
* When the editor is inside a tab it can cause a bug on tab switching.
* So we check if the editor is contained in a navigation and refresh the editor when its
* parent tab is shown.
*
* @param config
*/
watchTabChanges(config) {
$(config.selector).each((index, textarea) => {
const translatedField = $(textarea).closest('.translation-field');
const tabContainer = $(textarea).closest('.translations.tabbable');
if (translatedField.length && tabContainer.length) {
const textareaLocale = translatedField.data('locale');
const textareaLinkSelector = `.nav-item a[data-locale="${textareaLocale}"]`;
$(textareaLinkSelector, tabContainer).on('shown.bs.tab', () => {
const form = $(textarea).closest('form');
const editor = window.tinyMCE.get(textarea.id);
if (editor) {
// Reset content to force refresh of editor
editor.setContent(editor.getContent());
}
EventEmitter.emit('languageSelected', {
selectedLocale: textareaLocale,
form,
});
});
}
});
EventEmitter.on('languageSelected', (data) => {
const textareaLinkSelector = `.nav-item a[data-locale="${data.selectedLocale}"]`;
$(textareaLinkSelector).click();
});
}
/**
* Loads the TinyMCE javascript library and then init the editors
*
* @param config
*/
loadAndInitTinyMCE(config) {
if (this.tinyMCELoaded) {
return;
}
this.tinyMCELoaded = true;
const pathArray = config.baseAdminUrl.split('/');
pathArray.splice(pathArray.length - 2, 2);
const finalPath = pathArray.join('/');
window.tinyMCEPreInit = {};
window.tinyMCEPreInit.base = `${finalPath}/js/tiny_mce`;
window.tinyMCEPreInit.suffix = '.min';
$.getScript(`${finalPath}/js/tiny_mce/tinymce.min.js`, () => {
this.setupTinyMCE(config);
});
}
/**
* Replace initial TinyMCE icons with material icons
*/
changeToMaterial() {
const materialIconAssoc = {
'mce-i-code': '<i class="material-icons">code</i>',
'mce-i-none': '<i class="material-icons">format_color_text</i>',
'mce-i-bold': '<i class="material-icons">format_bold</i>',
'mce-i-italic': '<i class="material-icons">format_italic</i>',
'mce-i-underline': '<i class="material-icons">format_underlined</i>',
'mce-i-strikethrough': '<i class="material-icons">format_strikethrough</i>',
'mce-i-blockquote': '<i class="material-icons">format_quote</i>',
'mce-i-link': '<i class="material-icons">link</i>',
'mce-i-alignleft': '<i class="material-icons">format_align_left</i>',
'mce-i-aligncenter': '<i class="material-icons">format_align_center</i>',
'mce-i-alignright': '<i class="material-icons">format_align_right</i>',
'mce-i-alignjustify': '<i class="material-icons">format_align_justify</i>',
'mce-i-bullist': '<i class="material-icons">format_list_bulleted</i>',
'mce-i-numlist': '<i class="material-icons">format_list_numbered</i>',
'mce-i-image': '<i class="material-icons">image</i>',
'mce-i-table': '<i class="material-icons">grid_on</i>',
'mce-i-media': '<i class="material-icons">video_library</i>',
'mce-i-browse': '<i class="material-icons">attachment</i>',
'mce-i-checkbox': '<i class="mce-ico mce-i-checkbox"></i>',
};
$.each(materialIconAssoc, (index, value) => {
$(`.${index}`).replaceWith(value);
});
}
/**
* Updates the characters counter. This counter is used for front but if you don't want to encounter Validation
* problems you should be in sync with the TinyMceMaxLengthValidator PHP class. Both codes must behave the same
* way.
*
* @param id
*/
handleCounterTiny(id) {
const textarea = $(`#${id}`);
const counter = textarea.attr('counter');
const counterType = textarea.attr('counter_type');
const editor = window.tinyMCE.get(id);
const max = editor.getBody() ? editor.getBody().textContent.length : 0;
textarea
.parent()
.find('span.currentLength')
.text(max);
if (counterType !== 'recommended' && max > counter) {
textarea
.parent()
.find('span.maxLength')
.addClass('text-danger');
} else {
textarea
.parent()
.find('span.maxLength')
.removeClass('text-danger');
}
}
}
export default TinyMCEEditor;

View File

@@ -0,0 +1,94 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {EventEmitter} from '@components/event-emitter';
import ComponentsMap from '@components/components-map';
const {$} = window;
/**
* This class is used to automatically toggle translated fields (displayed with tabs
* using the TranslateType Symfony form type).
* Also compatible with TranslatableInput changes.
*/
class TranslatableField {
localeButtonSelector: string;
localeNavigationSelector: string;
translationFieldSelector: string;
selectedLocale: string;
constructor(options: Record<string, any>) {
const opts = options || {};
this.localeButtonSelector = opts.localeButtonSelector || ComponentsMap.translatableField.toggleTab;
this.localeNavigationSelector = opts.localeNavigationSelector || ComponentsMap.translatableField.nav;
this.translationFieldSelector = opts.translationFieldSelector || ComponentsMap.translatableField.select;
this.selectedLocale = $(
'.nav-item a.active',
$(this.localeNavigationSelector),
).data('locale');
$('body').on(
'shown.bs.tab',
this.localeButtonSelector,
this.toggleLanguage.bind(this),
);
EventEmitter.on('languageSelected', this.toggleFields.bind(this));
}
/**
* @param form
*
* @private
*/
private refreshInputs(form: JQuery<Element>) {
EventEmitter.emit('languageSelected', {
selectedLocale: this.selectedLocale,
form,
});
}
/**
* Dispatch event on language selection to update inputs and other components which depend on the locale.
*
* @param event
*
* @private
*/
toggleLanguage(event: JQueryEventObject): void {
const localeLink = $(event.target);
const form = localeLink.closest('form');
this.selectedLocale = localeLink.data('locale');
this.refreshInputs(form);
}
/**
* Toggle all transtation fields to the selected locale
*
* @param event
*
* @private
*/
toggleFields(event: Record<string, string>): void {
this.selectedLocale = event.selectedLocale;
$(this.localeNavigationSelector).each((index, navigation) => {
const selectedLink = $('.nav-item a.active', navigation);
const selectedLocale = selectedLink.data('locale');
if (this.selectedLocale !== selectedLocale) {
$(
ComponentsMap.translatableField.specificLocale(this.selectedLocale),
navigation,
).tab('show');
}
});
}
}
export default TranslatableField;

View File

@@ -0,0 +1,115 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {EventEmitter} from './event-emitter';
const {$} = window;
/**
* This class is used to automatically toggle translated inputs (displayed with one
* input and a language selector using the TranslatableType Symfony form type).
* Also compatible with TranslatableField changes.
*/
class TranslatableInput {
localeItemSelector: string;
localeButtonSelector: string;
localeInputSelector: string;
selectedLocale: string;
constructor(options: Record<string, any> = {}) {
const opts = options || {};
this.localeItemSelector = opts.localeItemSelector || '.js-locale-item';
this.localeButtonSelector = opts.localeButtonSelector || '.js-locale-btn';
this.localeInputSelector = opts.localeInputSelector || '.js-locale-input';
this.selectedLocale = $(this.localeItemSelector).data('locale');
$('body').on(
'click',
this.localeItemSelector,
this.toggleLanguage.bind(this),
);
EventEmitter.on('languageSelected', this.toggleInputs.bind(this));
}
/**
* @param {jQuery} form
*
* @private
*/
refreshInputs(form: JQuery<Element>): void {
if (!this.selectedLocale) {
return;
}
EventEmitter.emit('languageSelected', {
selectedLocale: this.selectedLocale,
form,
});
}
/**
* Dispatch event on language selection to update inputs and other components which depend on the locale.
*
* @param event
*
* @private
*/
toggleLanguage(event: JQueryEventObject): void {
const localeItem = $(event.target);
const form = localeItem.closest('form');
this.selectedLocale = localeItem.data('locale');
this.refreshInputs(form);
}
/**
* Toggle all translatable inputs in form in which locale was changed
*
* @param {Event} event
*
* @private
*/
toggleInputs(event: Record<string, any>): void {
const {form} = event;
this.selectedLocale = event.selectedLocale;
const localeButton = form.find(this.localeButtonSelector);
const changeLanguageUrl = localeButton.data('change-language-url');
localeButton.text(this.selectedLocale.toUpperCase());
form.find(this.localeInputSelector).addClass('d-none');
form
.find(`${this.localeInputSelector}.js-locale-${this.selectedLocale}`)
.removeClass('d-none');
if (changeLanguageUrl) {
this.saveSelectedLanguage(changeLanguageUrl, this.selectedLocale);
}
}
/**
* Save language choice for employee forms.
*
* @param {String} changeLanguageUrl
* @param {String} selectedLocale
*
* @private
*/
private saveSelectedLanguage(
changeLanguageUrl: string,
selectedLocale: string,
): void {
$.post({
url: changeLanguageUrl,
data: {
language_iso_code: selectedLocale,
},
});
}
}
export default TranslatableInput;

View File

@@ -0,0 +1,22 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
/**
* Assert that value is undefined
*
* @param value
*/
export function isUndefined(value: any): value is undefined {
return typeof value === 'undefined';
}
/**
* Assert that input exist is an HTMLInputElement and if so returns its checked status
*
* @param input
*/
export function isChecked(input: any): boolean {
return input instanceof HTMLInputElement && input.checked;
}