diff --git a/admin-dev/themes/new-theme/js/app/utils/init-components.ts b/admin-dev/themes/new-theme/js/app/utils/init-components.ts index 04fe8cc2ab6f5..e0717c105999d 100644 --- a/admin-dev/themes/new-theme/js/app/utils/init-components.ts +++ b/admin-dev/themes/new-theme/js/app/utils/init-components.ts @@ -41,7 +41,6 @@ import Grid from '@components/grid/grid'; import ModifyAllShopsCheckbox from '@components/modify-all-shops-checkbox'; import MultipleChoiceTable from '@js/components/multiple-choice-table'; import MultistoreConfigField from '@js/components/form/multistore-config-field'; -import CarrierRanges from '@js/components/form/carrier-ranges'; import PreviewOpener from '@components/form/preview-opener'; import Router from '@components/router'; import ShopSelector from '@components/shop-selector/shop-selector'; @@ -166,7 +165,6 @@ const initPrestashopComponents = (): void => { TranslatableInput, EntitySearchInput, EmailInput, - CarrierRanges, MultipleZoneChoice, }; }; diff --git a/admin-dev/themes/new-theme/js/components/components-map.ts b/admin-dev/themes/new-theme/js/components/components-map.ts index 36753a0f72808..aa8d65471d9f1 100644 --- a/admin-dev/themes/new-theme/js/components/components-map.ts +++ b/admin-dev/themes/new-theme/js/components/components-map.ts @@ -146,7 +146,4 @@ export default { emailInput: { inputSelector: '.email-input', }, - carrierRanges: { - addRangeButton: '.js-add-carrier-ranges-btn', - }, }; diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-event-map.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-event-map.ts index fca007504fe8d..b052058bfdd86 100644 --- a/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-event-map.ts +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-event-map.ts @@ -24,4 +24,6 @@ */ export default { openRangeSelectionModal: 'openRangeSelectionModal', + shippingMethodChange: 'carrierShippingMethodChange', + rangesUpdated: 'carrierRangesUpdated', }; diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-manager.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-manager.ts new file mode 100644 index 0000000000000..ccb715803b7cc --- /dev/null +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-manager.ts @@ -0,0 +1,259 @@ +/** +* Copyright since 2007 PrestaShop SA and Contributors +* PrestaShop is an International Registered Trademark & Property of PrestaShop SA +* +* NOTICE OF LICENSE +* +* This source file is subject to the Open Software License (OSL 3.0) +* that is bundled with this package in the file LICENSE.md. +* It is also available through the world-wide-web at this URL: +* https://opensource.org/licenses/OSL-3.0 +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* DISCLAIMER +* +* Do not edit or add to this file if you wish to upgrade PrestaShop to newer +* versions in the future. If you wish to customize PrestaShop for your +* needs please refer to https://devdocs.prestashop.com/ for more information. +* +* @author PrestaShop SA and Contributors +* @copyright Since 2007 PrestaShop SA and Contributors +* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) +*/ + +import {EventEmitter} from 'events'; +import CarrierFormMap from '@pages/carrier/form/carrier-form-map'; +import CarrierFormEventMap from '@pages/carrier/form/carrier-form-event-map'; +import ConfirmModal from '@js/components/modal/confirm-modal'; +import {Range} from '@pages/carrier/form/types'; + +const {$} = window; + +/** + * This component is used in carrier form page to manage the behavior of the form: + * - Selections of zones, ranges and ranges prices + * - Update form when the carrier shipping method change + */ +export default class CarrierFormManager { + eventEmitter: EventEmitter; + + currentShippingSymbol: string; + + $zonesInput: JQuery; + + $rangesInput: JQuery; + + $shippingMethodInput: JQuery; + + $freeShippingInput: JQuery; + + /** + * @param {EventEmitter} eventEmitter + */ + constructor(eventEmitter: EventEmitter) { + this.eventEmitter = eventEmitter; + this.currentShippingSymbol = ''; + + // Initialize dom elements + this.$zonesInput = $(CarrierFormMap.zonesInput); + this.$rangesInput = $(CarrierFormMap.rangesInput); + this.$shippingMethodInput = $(CarrierFormMap.shippingMethodInput); + this.$freeShippingInput = $(CarrierFormMap.freeShippingInput); + + // Initialize form + this.initForm(); + + // Initialize listeners + this.initListeners(); + } + + private initForm() { + // First toggle shipping related controls + this.refreshFreeShipping(); + // Then, we need to refresh the shipping method symbol + this.refreshCurrentShippingSymbol(); + } + + private initListeners() { + this.$zonesInput.on('change', () => this.onChangeZones()); + this.$freeShippingInput.on('change', () => this.refreshFreeShipping()); + this.$shippingMethodInput.on('change', () => this.refreshCurrentShippingSymbol()); + $(CarrierFormMap.zonesContainer).on('click', CarrierFormMap.deleteZoneButton, (e:Event) => this.onDeleteZone(e)); + this.eventEmitter.on(CarrierFormEventMap.rangesUpdated, (ranges: Range[]) => this.onChangeRanges(ranges)); + } + + private refreshFreeShipping(): void { + const isFreeShipping = $(`${CarrierFormMap.freeShippingInput}:checked`).val() === '1'; + CarrierFormMap.shippingControls.forEach((inputId: string) => { + const $inputGroup = $(inputId).closest('.form-group'); + $inputGroup.toggleClass('d-none', isFreeShipping); + }); + } + + private refreshCurrentShippingSymbol() { + // First, we need to get the units of the selected shipping method + const shippingMethodUnits = $(CarrierFormMap.shippingMethodRow).data('units'); + const shippingMethodValue = this.$shippingMethodInput.filter(':checked').first().val() || -1; + this.currentShippingSymbol = shippingMethodUnits[shippingMethodValue] || '?'; + + // Then, we need to emit an event to update this symbol to other components + this.eventEmitter.emit(CarrierFormEventMap.shippingMethodChange, this.currentShippingSymbol); + + // Finally, we need to update the ranges names with the new symbol + $(CarrierFormMap.rangeRow).each((_, rangeRow: HTMLElement) => { + const $rangeRow = $(rangeRow); + const $rangeName = $rangeRow.find(CarrierFormMap.rangeNamePreview); + const $rangeNameHidden = $rangeRow.find(CarrierFormMap.rangeNameInput); + const from = $rangeRow.find(CarrierFormMap.rangeFromInput).val(); + const to = $rangeRow.find(CarrierFormMap.rangeToInput).val(); + const rangeName = `${from}${this.currentShippingSymbol} - ${to}${this.currentShippingSymbol}`; + $rangeName.text(rangeName); + $rangeNameHidden.val(rangeName); + }); + } + + private onChangeZones() { + // First, we retrieve the zones actually displayed and selected + const $zonesContainer = $(CarrierFormMap.zonesContainer); + const $zonesRows = $(CarrierFormMap.zoneRow); + const zones = this.$zonesInput.val() ?? []; + + // First, we need to delete the zones that are not selected and already displayed + // (and we keep the zones that are already displayed) + const zonesAlreadyDisplayed = []; + $zonesRows.each((_, zoneRow: HTMLElement) => { + const $zoneRow = $(zoneRow); + const zoneId = $zoneRow.find(CarrierFormMap.zoneIdInput).val()?.toString(); + + if (zoneId !== undefined) { + if (!zones.includes(zoneId)) { + $zoneRow.remove(); + } else { + zonesAlreadyDisplayed.push(zoneId); + } + } + }); + + // Then, we need to add the zones that are selected but not displayed + const zonePrototype = $zonesContainer.data('prototype'); + zones.forEach((zoneId: string) => { + if (!zonesAlreadyDisplayed.includes(zoneId)) { + // We create new zone row by duplicating the prototype and replacing the zone index + const prototype = zonePrototype.replace(/__zone__/g, $(CarrierFormMap.zoneRow).length); + + // We need to update the zone id and the zone name + const $prototype = $(prototype); + $prototype.find(CarrierFormMap.zoneIdInput).val(zoneId); + $prototype.find(CarrierFormMap.zoneNamePreview).text(this.$zonesInput.find(CarrierFormMap.zoneIdOption(zoneId)).text()); + + // We append the new zone row into the zones container + $zonesContainer.append($prototype); + + // Next, we need to prepare the ranges for this zone + const $rangeContainer = $prototype.find(CarrierFormMap.rangesContainer); + const $rangeContainerBody = $prototype.find(CarrierFormMap.rangesContainerBody); + const rangePrototype = $rangeContainer.data('prototype'); + // @ts-ignore + const ranges = JSON.parse(this.$rangesInput.val() || '[]'); + + // For each range selected, we need to create a new range row with the range prototype + ranges.forEach((range: Range, index) => { + // Then, we append the new range row into the range container + const $rPrototype = this.prepareRangePrototype(rangePrototype, index, range); + $rangeContainerBody.append($rPrototype); + }); + } + }); + } + + private onDeleteZone(e: Event) { + e.preventDefault(); + + // We need to get the zone id to delete + const $currentTarget = $(e.currentTarget as HTMLElement); + const $currentZoneRow = $currentTarget.parents(CarrierFormMap.zoneRow); + const idZoneToDelete = $currentZoneRow.children(CarrierFormMap.zoneIdInput).val(); + + // We need to display a confirmation modal before deleting the zone + const modal = new ConfirmModal( + { + id: 'modal-confirm-submit-feature-flag', + confirmButtonClass: 'btn-danger', + confirmTitle: $currentTarget.data('modal-title'), + confirmMessage: '', + confirmButtonLabel: $currentTarget.data('modal-confirm'), + closeButtonLabel: $currentTarget.data('modal-cancel'), + }, + () => { + // If, the user confirms the deletion, we need to remove the zone + // First, we need to remove this zone from the zones + let zones = this.$zonesInput.val() || []; + zones = zones.filter((zoneId: string) => zoneId !== idZoneToDelete); + + // And update the zones selected values and trigger the zones selector change event + this.$zonesInput.val(zones); + this.$zonesInput.change(); + }, + ); + modal.show(); + } + + private onChangeRanges(ranges: Range[]) { + // We retrieve all ranges containers in the page + const $rangesContainerBodies = $(CarrierFormMap.rangesContainerBody); + + // For each range container, we need to update the ranges + $rangesContainerBodies.each((_, zoneRangesContainer: HTMLElement) => { + // First, we need to save all values for this range. + const $zoneRangesContainerBody = $(zoneRangesContainer); + const pricesRanges = $(zoneRangesContainer).find(CarrierFormMap.rangeRow).map((__, rangeRow: HTMLElement) => { + const $rangeRow = $(rangeRow); + const from = parseFloat($rangeRow.find(CarrierFormMap.rangeFromInput).val()?.toString() || '0'); + const to = parseFloat($rangeRow.find(CarrierFormMap.rangeToInput).val()?.toString() || '0'); + const price = $rangeRow.find(CarrierFormMap.rangePriceInput).val() || ''; + + return {from, to, price}; + }); + + // Then, we reset the ranges container + $zoneRangesContainerBody.html(''); + + // and, we need to add all the ranges selected + const rangePrototype = $zoneRangesContainerBody.closest(CarrierFormMap.rangesContainer).data('prototype'); + ranges.forEach((range: Range, index) => { + // First, we need to prepare the range prototype + const $rPrototype = this.prepareRangePrototype(rangePrototype, index, range); + + // Then, we need to search the previous price if exist (oldFrom = newFrom OR oldTo = newTo) + let price = ''; + + for (let i = 0; i < pricesRanges.length; i += 1) { + if (pricesRanges[i].from === range.from || pricesRanges[i].to === range.to) { + price = pricesRanges[i].price.toString(); + break; + } + } + + // We set the previous value for this range if it exists + // @ts-ignore + $rPrototype.find(CarrierFormMap.rangePriceInput).val(price); + // Then, we append the new range row into the range container + $zoneRangesContainerBody.append($rPrototype); + }); + }); + } + + private prepareRangePrototype(rangePrototype: string, index: number, range: Range): JQuery { + // We prepare the range prototype by replacing the range index, and setting the range values + const $rPrototype = $(rangePrototype.replace(/__range__/g, index.toString())); + $rPrototype.find(CarrierFormMap.rangeFromInput).val(range.from || '0'); + $rPrototype.find(CarrierFormMap.rangeToInput).val(range.to || '0'); + $rPrototype.find(CarrierFormMap.rangeNamePreview) + .text(`${range.from}${this.currentShippingSymbol} - ${range.to}${this.currentShippingSymbol}`); + + // We return the prototype well formed + return $rPrototype; + } +} diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-map.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-map.ts new file mode 100644 index 0000000000000..5c1e03a481273 --- /dev/null +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-form-map.ts @@ -0,0 +1,56 @@ +/** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Open Software License (OSL 3.0) + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/OSL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * DISCLAIMER + * + * Do not edit or add to this file if you wish to upgrade PrestaShop to newer + * versions in the future. If you wish to customize PrestaShop for your + * needs please refer to https://devdocs.prestashop.com/ for more information. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +export default { + freeShippingInput: 'input[name="carrier[shipping_settings][is_free]"]', + zonesInput: '#carrier_shipping_settings_ranges_costs_control_zones', + zoneIdOption: (zoneId: number|string): string => `option[value="${zoneId}"]`, + rangesInput: '#carrier_shipping_settings_ranges_costs_control_ranges_data', + rangesSelectionAppId: '#carrier_shipping_settings_ranges-app', + addRangeButton: '.js-add-carrier-ranges-btn', + shippingMethodRow: '#carrier_shipping_settings_shipping_method', + shippingMethodInput: 'input[name="carrier[shipping_settings][shipping_method]"]', + deleteZoneButton: '.js-carrier-delete-zone', + zonesContainer: '#carrier_shipping_settings_ranges_costs', + rangesContainer: '.js-carrier-range-container', + rangesContainerBody: '.js-carrier-range-container-body', + zoneRow: '.js-carrier-zone-row', + zoneIdInput: 'input[name$="[zoneId]"]', + rangeNamePreview: '.js-carrier-range-name .text-preview-value', + rangeNameInput: '.js-carrier-range-name input[type="hidden"]', + rangeRow: '.js-carrier-range-row', + zoneNamePreview: '.card-title .text-preview-value', + rangeFromInput: 'input[name$="[from]"]', + rangeToInput: 'input[name$="[to]"]', + rangePriceInput: 'input[name$="[price]"]', + shippingControls: [ + '#carrier_shipping_settings_id_tax_rule_group', + '#carrier_shipping_settings_has_additional_handling_fee', + '#carrier_shipping_settings_shipping_method', + '#carrier_shipping_settings_range_behavior', + '#carrier_shipping_settings_ranges_costs_control', + '#carrier_shipping_settings_ranges_costs', + ], +}; diff --git a/admin-dev/themes/new-theme/js/components/form/carrier-ranges.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-range-modal.ts similarity index 74% rename from admin-dev/themes/new-theme/js/components/form/carrier-ranges.ts rename to admin-dev/themes/new-theme/js/pages/carrier/form/carrier-range-modal.ts index 0c5534b7a74d7..6b2117a30cdbd 100644 --- a/admin-dev/themes/new-theme/js/components/form/carrier-ranges.ts +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/carrier-range-modal.ts @@ -23,7 +23,7 @@ * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ -import ComponentsMap from '@components/components-map'; +import CarrierFormMap from '@pages/carrier/form/carrier-form-map'; import {createApp} from 'vue'; import {createI18n} from 'vue-i18n'; import CarrierRangesModal from '@pages/carrier/form/components/CarrierRangesModal.vue'; @@ -34,15 +34,15 @@ import CarrierFormEventMap from '@pages/carrier/form/carrier-form-event-map'; export default class CarrierRanges { private readonly eventEmitter: typeof EventEmitter; - constructor() { - this.eventEmitter = window.prestashop.instance.eventEmitter; + constructor(eventEmitter: typeof EventEmitter) { + this.eventEmitter = eventEmitter; this.initRangesSelectionModal(); } initRangesSelectionModal(): void { // Create the modal container - const $showModal = $(ComponentsMap.carrierRanges.addRangeButton); - const $modalContainer = $('
'); + const $showModal = $(CarrierFormMap.addRangeButton); + const $modalContainer = $(`
`); $showModal.after($modalContainer); // Retreive translations from the button @@ -59,13 +59,20 @@ export default class CarrierRanges { }).use(i18n); // Mount the Vue app to the modal container - vueApp.mount('#carrier-ranges-modal-selection'); + vueApp.mount(CarrierFormMap.rangesSelectionAppId); - // Open the modal when the button "Add range" is clicked + // Open the modal with data when the button "Add range" is clicked $showModal.click((e: JQuery.ClickEvent) => { e.preventDefault(); e.stopImmediatePropagation(); - this.eventEmitter.emit(CarrierFormEventMap.openRangeSelectionModal); + const data = $(CarrierFormMap.rangesInput).val() || '[]'; + this.eventEmitter.emit(CarrierFormEventMap.openRangeSelectionModal, JSON.parse(data.toString())); + }); + + // Listen the modal to apply the ranges selected to the data + this.eventEmitter.on(CarrierFormEventMap.rangesUpdated, (ranges: Array) => { + const $data = $(CarrierFormMap.rangesInput); + $data.val(JSON.stringify(ranges)); }); } } diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/components/CarrierRangesModal.vue b/admin-dev/themes/new-theme/js/pages/carrier/form/components/CarrierRangesModal.vue index 973bd87f06587..923f2f72613e4 100644 --- a/admin-dev/themes/new-theme/js/pages/carrier/form/components/CarrierRangesModal.vue +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/components/CarrierRangesModal.vue @@ -39,14 +39,6 @@ - + + @@ -147,11 +146,7 @@ import Modal from '@PSVue/components/Modal.vue'; import {defineComponent} from 'vue'; import CarrierFormEventMap from '@pages/carrier/form/carrier-form-event-map'; - - interface Range { - min: number|null, - max: number|null, - } + import {Range} from '@pages/carrier/form/types'; interface CarrierRangesModalStates { isModalShown: boolean, // define if the modal is shown @@ -161,6 +156,7 @@ refreshKey: number, // force the refresh of the table by incrementing this key errors: boolean, // define if there are errors in the ranges overlappingAlert: boolean, // define if there are overlapping ranges (and display an alert) + symbol: string, // define the current symbol used in function of the shipping method } export default defineComponent({ @@ -175,6 +171,7 @@ refreshKey: 0, errors: false, overlappingAlert: false, + symbol: '', }; }, props: { @@ -185,7 +182,12 @@ }, mounted() { // If we need to open this modal - this.eventEmitter.on(CarrierFormEventMap.openRangeSelectionModal, () => this.openModal()); + this.eventEmitter.on(CarrierFormEventMap.openRangeSelectionModal, (ranges: Range[]) => { + this.ranges = ranges ?? []; + this.openModal(); + }); + // If we need to change the shipping method symbol + this.eventEmitter.on(CarrierFormEventMap.shippingMethodChange, (symbol: string) => { this.symbol = symbol; }); }, methods: { openModal() { @@ -195,11 +197,11 @@ // We save the ranges to be able to cancel the changes this.savedRanges.splice(0, this.savedRanges.length); - this.ranges.forEach((range) => this.savedRanges.push({min: range.min, max: range.max})); + this.ranges.forEach((range) => this.savedRanges.push({from: range.from, to: range.to})); // We add an empty range if there is none if (this.ranges.length === 0) { - this.ranges.push({min: null, max: null}); + this.ranges.push({from: null, to: null}); } // We reset the errors @@ -215,21 +217,21 @@ cancelChanges() { // We cancel the changes and close the modal this.ranges.splice(0, this.ranges.length); - this.savedRanges.forEach((range) => this.ranges.push({min: range.min, max: range.max})); + this.savedRanges.forEach((range) => this.ranges.push({from: range.from, to: range.to})); // We remove empty ranges - this.ranges = this.ranges.filter((range) => range.min !== null || range.max !== null); + this.ranges = this.ranges.filter((range) => range.from !== null || range.to !== null); // Then, we close the modal this.closeModal(); }, applyChanges() { // We remove empty ranges - this.ranges = this.ranges.filter((range) => range.min !== null || range.max !== null); + this.ranges = this.ranges.filter((range) => range.from !== null || range.to !== null); // We validate the changes this.validateChanges(); if (!this.errors) { // We emit the new ranges - this.eventEmitter.emit('updateRanges', this.ranges); + this.eventEmitter.emit(CarrierFormEventMap.rangesUpdated, this.ranges); // We close the modal this.closeModal(); } @@ -245,18 +247,33 @@ }); // We sort the ranges by min values - this.ranges.sort((a, b) => (a.min || 0) - (b.min || 0)); + this.ranges.sort((a, b) => (a.from || 0) - (b.from || 0)); // We check ranges let saveMax: null|number = null; this.ranges.forEach((range, index) => { + // Check if all fields are filled + if (range.from === null) { + table.querySelectorAll(`tr[data-row="${index}"] input.form-from`) + .forEach((input) => { + input.classList.add('is-invalid'); + }); + this.errors = true; + } + if (range.to === null) { + table.querySelectorAll(`tr[data-row="${index}"] input.form-to`) + .forEach((input) => { + input.classList.add('is-invalid'); + }); + this.errors = true; + } // Check overlapping - if (saveMax !== null && range.min !== null && range.min < saveMax) { - table.querySelectorAll(`tr[data-row="${index - 1}"] input.form-max`) + if (saveMax !== null && range.from !== null && range.from < saveMax) { + table.querySelectorAll(`tr[data-row="${index - 1}"] input.form-to`) .forEach((input) => { input.classList.add('is-invalid'); }); - table.querySelectorAll(`tr[data-row="${index}"] input.form-min`) + table.querySelectorAll(`tr[data-row="${index}"] input.form-from`) .forEach((input) => { input.classList.add('is-invalid'); }); @@ -264,25 +281,25 @@ this.overlappingAlert = true; } - // Check min < max for each range - if (range.max !== null && range.min !== null && range.max <= range.min) { - table.querySelectorAll(`tr[data-row="${index}"] input.form-max`) + // Check from < to for each range + if (range.to !== null && range.from !== null && range.to <= range.from) { + table.querySelectorAll(`tr[data-row="${index}"] input.form-to`) .forEach((input) => { input.classList.add('is-invalid'); }); this.errors = true; } - saveMax = range.max; + saveMax = range.to; }); }, addRange(index: undefined|number) { // Add new range at the index specified, at the bottom if not specified - // (with "min" already set to the previous "max") + // (with "from" already set to the previous "to") if (index === undefined) { - this.ranges.push({min: this.ranges[this.ranges.length - 1]?.max, max: null}); + this.ranges.push({from: this.ranges[this.ranges.length - 1]?.to, to: null}); } else { - this.ranges.splice(index + 1, 0, {min: this.ranges[index]?.max, max: null}); + this.ranges.splice(index + 1, 0, {from: this.ranges[index]?.to, to: null}); } }, deleteRange(rangeIndex: number) { @@ -290,7 +307,7 @@ this.ranges.splice(rangeIndex, 1); // We add an empty range if there is none if (this.ranges.length === 0) { - this.ranges.push({min: null, max: null}); + this.ranges.push({from: null, to: null}); } }, dragStart(rangeIndex: number) { diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/index.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/index.ts index 603a54ddbe541..f76aee93f3f66 100644 --- a/admin-dev/themes/new-theme/js/pages/carrier/form/index.ts +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/index.ts @@ -23,11 +23,20 @@ * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) */ +import CarrierFormManager from '@pages/carrier/form/carrier-form-manager'; +import CarrierRanges from '@pages/carrier/form/carrier-range-modal'; + $(() => { + // Initialize components window.prestashop.component.initComponents([ 'TranslatableInput', 'EventEmitter', - 'CarrierRanges', 'MultipleZoneChoice', ]); + + // Initialize the ranges selection modal + new CarrierRanges(window.prestashop.instance.eventEmitter); + + // Initialize the carrier form manager + new CarrierFormManager(window.prestashop.instance.eventEmitter); }); diff --git a/admin-dev/themes/new-theme/js/pages/carrier/form/types.d.ts b/admin-dev/themes/new-theme/js/pages/carrier/form/types.d.ts new file mode 100644 index 0000000000000..f99fd7814dd4e --- /dev/null +++ b/admin-dev/themes/new-theme/js/pages/carrier/form/types.d.ts @@ -0,0 +1,29 @@ +/** +* Copyright since 2007 PrestaShop SA and Contributors +* PrestaShop is an International Registered Trademark & Property of PrestaShop SA +* +* NOTICE OF LICENSE +* +* This source file is subject to the Open Software License (OSL 3.0) +* that is bundled with this package in the file LICENSE.md. +* It is also available through the world-wide-web at this URL: +* https://opensource.org/licenses/OSL-3.0 +* If you did not receive a copy of the license and are unable to +* obtain it through the world-wide-web, please send an email +* to license@prestashop.com so we can send you a copy immediately. +* +* DISCLAIMER +* +* Do not edit or add to this file if you wish to upgrade PrestaShop to newer +* versions in the future. If you wish to customize PrestaShop for your +* needs please refer to https://devdocs.prestashop.com/ for more information. +* +* @author PrestaShop SA and Contributors +* @copyright Since 2007 PrestaShop SA and Contributors +* @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) +*/ + +export interface Range { + from: number|null, + to: number|null, +} diff --git a/admin-dev/themes/new-theme/scss/components/_form.scss b/admin-dev/themes/new-theme/scss/components/_form.scss index 5f9114ee0c7e4..b34ee2d21accc 100644 --- a/admin-dev/themes/new-theme/scss/components/_form.scss +++ b/admin-dev/themes/new-theme/scss/components/_form.scss @@ -262,3 +262,12 @@ } } } + +.small.form-external-link, +.form-external-link { + font-weight: 400; + + .btn { + font-weight: 400; + } +} diff --git a/admin-dev/themes/new-theme/scss/pages/_improve.scss b/admin-dev/themes/new-theme/scss/pages/_improve.scss index d8a28ec9e716c..2c914b12e66ba 100644 --- a/admin-dev/themes/new-theme/scss/pages/_improve.scss +++ b/admin-dev/themes/new-theme/scss/pages/_improve.scss @@ -4,26 +4,24 @@ } } -#carrier_shipping_location_and_costs { +#carrier_shipping_settings { /* stylelint-disable order/properties-order */ .select2-container--bootstrap { height: 2.188rem; padding: 0.4375rem 0.375rem; padding-left: 15px; cursor: default; - border: 1px solid #bbcdd2; - /* stylelint-disable-next-line property-disallowed-list */ - border-radius: 4px; + border: 1px solid var(--#{$cdk}primary-400); } .select2-container--classic.select2-container--open .select2-dropdown, .select2-container--classic.select2-container--open .select2-selection--multiple { - border-color: #7cd5e7; + border-color: var(--#{$cdk}primary-400); } .select2-container--classic .select2-selection--multiple .select2-selection__choice { margin: 5px; - background-color: #e5eff1; + background-color: var(--#{$cdk}primary-100); } .select2-container .select2-selection--multiple { @@ -36,13 +34,13 @@ right: 6px; display: inline-block; width: auto; - font-family: "Material Icons", Arial, Verdana, Tahoma, sans-serif; + font-family: var(--#{$cdk}font-family-material-icons); font-size: 1.5rem; font-style: normal; font-weight: 400; font-feature-settings: "liga"; line-height: 1; - color: #6c868e; + color: var(--#{$cdk}primary-600); text-transform: none; letter-spacing: normal; word-wrap: normal; @@ -83,4 +81,40 @@ width: 19px; margin-right: 0; } + + .carrier-ranges-control { + display: flex; + flex-direction: row; + gap: 0.6rem; + + .multiple_zone_choice-widget { + width: 100%; + } + + .carrier-ranges-edit-row { + .btn-label { + width: max-content; + } + } + } + + #carrier_shipping_settings_ranges_costs { + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + gap: 0.6rem; + + .js-carrier-range-name { + padding-right: 0.5rem; + } + + .js-carrier-delete-zone { + padding: 0 var(--#{$cdk}size-8); + color: var(--#{$cdk}red-500); + + .material-icons { + font-size: var(--#{$cdk}size-24); + } + } + } } diff --git a/src/Core/Domain/Carrier/QueryResult/CarrierRangesCollection.php b/src/Core/Domain/Carrier/QueryResult/CarrierRangesCollection.php index 2217db1e6e3e7..3c40603b568b5 100644 --- a/src/Core/Domain/Carrier/QueryResult/CarrierRangesCollection.php +++ b/src/Core/Domain/Carrier/QueryResult/CarrierRangesCollection.php @@ -76,4 +76,17 @@ public function getZones(): array { return $this->zones; } + + /** + * @return int[] + */ + public function getZonesIds(): array + { + return array_map( + function (CarrierRangeZone $zone) { + return $zone->getZoneId(); + }, + $this->zones + ); + } } diff --git a/src/Core/Form/IdentifiableObject/DataHandler/CarrierFormDataHandler.php b/src/Core/Form/IdentifiableObject/DataHandler/CarrierFormDataHandler.php index a5d22908d4466..bd9c1b546b80c 100644 --- a/src/Core/Form/IdentifiableObject/DataHandler/CarrierFormDataHandler.php +++ b/src/Core/Form/IdentifiableObject/DataHandler/CarrierFormDataHandler.php @@ -29,7 +29,9 @@ use PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface; use PrestaShop\PrestaShop\Core\Domain\Carrier\Command\AddCarrierCommand; use PrestaShop\PrestaShop\Core\Domain\Carrier\Command\EditCarrierCommand; +use PrestaShop\PrestaShop\Core\Domain\Carrier\Command\SetCarrierRangesCommand; use PrestaShop\PrestaShop\Core\Domain\Carrier\ValueObject\CarrierId; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use Symfony\Component\HttpFoundation\File\UploadedFile; class CarrierFormDataHandler implements FormDataHandlerInterface @@ -70,11 +72,15 @@ public function create(array $data) $logoPath, )); + // Then, we need to add ranges for this carrier + $carrierId = $this->setCarrierRange($carrierId, $data); + return $carrierId->getValue(); } public function update($id, array $data) { + // First, we need to update the general settings of the carrier $command = new EditCarrierCommand($id); $command ->setName($data['general_settings']['name']) @@ -82,6 +88,10 @@ public function update($id, array $data) ->setGrade($data['general_settings']['grade']) ->setActive((bool) $data['general_settings']['active']) ->setTrackingUrl($data['general_settings']['tracking_url'] ?? '') + ->setAdditionalHandlingFee((bool) $data['shipping_settings']['has_additional_handling_fee']) + ->setIsFree((bool) $data['shipping_settings']['is_free']) + ->setShippingMethod($data['shipping_settings']['shipping_method']) + ->setRangeBehavior($data['shipping_settings']['range_behavior']) ->setMaxWidth($data['size_weight_settings']['max_width'] ?? null) ->setMaxHeight($data['size_weight_settings']['max_height'] ?? null) ->setMaxDepth($data['size_weight_settings']['max_depth'] ?? null) @@ -94,9 +104,53 @@ public function update($id, array $data) $command->setLogoPathName($logo->getPathname()); } + /** @var CarrierId $carrierId */ + $carrierId = $this->commandBus->handle($command); + + // Then, we need to update the shipping ranges of the carrier + $carrierId = $this->setCarrierRange($carrierId, $data); + + return $carrierId->getValue(); + } + + /** + * Function aim to format ranges data from the form, to be used in the command of Seting carrier ranges. + */ + private function formatFormRangesData(array $data): array + { + $ranges = []; + $data = $data['shipping_settings']['ranges_costs'] ?? []; + + foreach ($data as $zone) { + foreach ($zone['ranges'] as $range) { + $ranges[] = [ + 'id_zone' => $zone['zoneId'], + 'range_from' => $range['from'], + 'range_to' => $range['to'], + 'range_price' => $range['price'], + ]; + } + } + + return $ranges; + } + + /** + * Save the carrier ranges. + */ + private function setCarrierRange(CarrierId $carrierId, array $data): CarrierId + { + // We format the ranges data from the form, and create the command object. + $rangesData = $this->formatFormRangesData($data); + $rangesCommand = new SetCarrierRangesCommand( + $carrierId->getValue(), + $rangesData, + ShopConstraint::allShops() + ); + // Then, we handle the command to save the ranges. /** @var CarrierId $newCarrierId */ - $newCarrierId = $this->commandBus->handle($command); + $newCarrierId = $this->commandBus->handle($rangesCommand); - return $newCarrierId->getValue(); + return $newCarrierId; } } diff --git a/src/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProvider.php b/src/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProvider.php index f9583553a8717..d83a21fe87cd3 100644 --- a/src/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProvider.php +++ b/src/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProvider.php @@ -26,10 +26,16 @@ namespace PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataProvider; +use PrestaShop\PrestaShop\Adapter\Form\ChoiceProvider\ZoneByIdChoiceProvider; use PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface; +use PrestaShop\PrestaShop\Core\ConfigurationInterface; use PrestaShop\PrestaShop\Core\Context\ShopContext; +use PrestaShop\PrestaShop\Core\Currency\CurrencyDataProviderInterface; use PrestaShop\PrestaShop\Core\Domain\Carrier\Query\GetCarrierForEditing; +use PrestaShop\PrestaShop\Core\Domain\Carrier\Query\GetCarrierRanges; +use PrestaShop\PrestaShop\Core\Domain\Carrier\QueryResult\CarrierRangesCollection; use PrestaShop\PrestaShop\Core\Domain\Carrier\QueryResult\EditableCarrier; +use PrestaShop\PrestaShop\Core\Domain\Carrier\ValueObject\ShippingMethod; use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; class CarrierFormDataProvider implements FormDataProviderInterface @@ -37,6 +43,9 @@ class CarrierFormDataProvider implements FormDataProviderInterface public function __construct( private readonly CommandBusInterface $queryBus, private readonly ShopContext $shopContext, + private readonly CurrencyDataProviderInterface $currencyDataProvider, + private readonly ConfigurationInterface $configuration, + private readonly ZoneByIdChoiceProvider $zonesChoiceProvider ) { } @@ -44,6 +53,7 @@ public function getData($id) { /** @var EditableCarrier $carrier */ $carrier = $this->queryBus->handle(new GetCarrierForEditing((int) $id, ShopConstraint::allShops())); + $carrierRanges = $this->queryBus->handle(new GetCarrierRanges((int) $id, ShopConstraint::allShops())); return [ 'general_settings' => [ @@ -62,6 +72,11 @@ public function getData($id) 'shipping_method' => $carrier->getShippingMethod(), 'id_tax_rule_group' => $carrier->getIdTaxRuleGroup(), 'range_behavior' => $carrier->getRangeBehavior(), + 'ranges_costs_control' => [ + 'zones' => $carrierRanges->getZonesIds(), + 'ranges' => $this->formatRangesData($carrierRanges), + ], + 'ranges_costs' => $this->formatRangesCostsData($carrier, $carrierRanges), ], 'size_weight_settings' => [ 'max_width' => $carrier->getMaxWidth(), @@ -81,4 +96,83 @@ public function getDefaultData() ], ]; } + + /** + * Function to format ranges data. + * + * @param CarrierRangesCollection $carrierRangesCollection + * + * @return array + */ + private function formatRangesData(CarrierRangesCollection $carrierRangesCollection): array + { + $ranges = []; + + // For each zones, we need to get all ranges + foreach ($carrierRangesCollection->getZones() as $zone) { + foreach ($zone->getRanges() as $range) { + $ranges[] = [ + 'from' => (float) (string) $range->getFrom(), + 'to' => (float) (string) $range->getTo(), + ]; + } + } + + // Then, we remove duplicates and sort ranges by from value. + $ranges = array_unique($ranges, SORT_REGULAR); + $from_values = array_column($ranges, 'from'); + array_multisort($from_values, SORT_ASC, $ranges); + + return ['data' => json_encode($ranges)]; + } + + /** + * Function to format ranges costs data. + * + * @param CarrierRangesCollection $carrierRangesCollection + * + * @return array + */ + private function formatRangesCostsData(EditableCarrier $carrier, CarrierRangesCollection $carrierRangesCollection): array + { + $ranges = []; + + // We retrieve zones to get the correct zone name + $zones = $this->zonesChoiceProvider->getChoices([]); + $zones = array_flip($zones); + + // We choose the right symbol for the range in function of the ShippingMethod + switch ($carrier->getShippingMethod()) { + default: + $rangeSymbol = ''; + break; + case ShippingMethod::BY_PRICE: + $rangeSymbol = $this->currencyDataProvider->getDefaultCurrencySymbol(); + break; + case ShippingMethod::BY_WEIGHT: + $rangeSymbol = $this->configuration->get('PS_WEIGHT_UNIT'); + break; + } + + // For each zones, we need to get all ranges + foreach ($carrierRangesCollection->getZones() as $zone) { + $zoneRanges = []; + foreach ($zone->getRanges() as $range) { + $zoneRanges[] = [ + 'range' => $range->getFrom()->__toString() . $rangeSymbol . ' - ' . $range->getTo()->__toString() . $rangeSymbol, + 'from' => $range->getFrom(), + 'to' => $range->getTo(), + 'price' => $range->getPrice()->__toString(), + ]; + } + + $ranges[] = [ + 'zoneId' => $zone->getZoneId(), + 'zoneName' => $zones[$zone->getZoneId()] ?? $zone->getZoneId(), + 'ranges' => $zoneRanges, + ]; + } + + return $ranges; + } } diff --git a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/CarrierType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/CarrierType.php index 059aefa0be28f..ca89d931baa1b 100644 --- a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/CarrierType.php +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/CarrierType.php @@ -52,7 +52,7 @@ public function buildForm(FormBuilderInterface $builder, array $options) ->add('general_settings', GeneralSettings::class, [ 'label' => $this->trans('General settings', 'Admin.Shipping.Feature'), ]) - ->add('shipping_location_and_costs', ShippingLocationsAndCostsType::class, [ + ->add('shipping_settings', ShippingLocationsAndCostsType::class, [ 'label' => $this->trans('Shipping locations and costs', 'Admin.Shipping.Feature'), ]) ->add('size_weight_settings', SizeWeightSettings::class, [ diff --git a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/ShippingLocationsAndCostsType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/ShippingLocationsAndCostsType.php index 3ae0a74beb757..906a8f4cadb30 100644 --- a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/ShippingLocationsAndCostsType.php +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/ShippingLocationsAndCostsType.php @@ -26,75 +26,95 @@ namespace PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier; -use PrestaShopBundle\Form\Admin\Type\MultipleZoneChoiceType; +use PrestaShop\PrestaShop\Core\ConfigurationInterface; +use PrestaShop\PrestaShop\Core\Currency\CurrencyDataProviderInterface; +use PrestaShop\PrestaShop\Core\Domain\Carrier\ValueObject\OutOfRangeBehavior; +use PrestaShop\PrestaShop\Core\Domain\Carrier\ValueObject\ShippingMethod; +use PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type\CarrierRangesControlType; +use PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type\CostsZoneType; use PrestaShopBundle\Form\Admin\Type\SwitchType; use PrestaShopBundle\Form\Admin\Type\TaxGroupChoiceType; use PrestaShopBundle\Form\Admin\Type\TranslatorAwareType; +use PrestaShopBundle\Translation\TranslatorInterface; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Routing\RouterInterface; class ShippingLocationsAndCostsType extends TranslatorAwareType { + public function __construct( + TranslatorInterface $translator, + array $locales, + private readonly RouterInterface $router, + private readonly ConfigurationInterface $configuration, + private readonly CurrencyDataProviderInterface $currencyDataProvider + ) { + parent::__construct($translator, $locales); + } + public function buildForm(FormBuilderInterface $builder, array $options) { parent::buildForm($builder, $options); $builder - ->add('zones', MultipleZoneChoiceType::class, [ - 'label' => $this->trans('Zones', 'Admin.Shipping.Feature'), - 'required' => false, - 'multiple' => true, - 'label_help_box' => $this->trans('Zones that the carrier can handle', 'Admin.Shipping.Help'), - 'external_link' => [ - 'text' => $this->trans('[1]Manage Zones[/1]', 'Admin.Shipping.Feature'), - 'position' => 'prepend', - 'href' => '', - ], - 'attr' => [ - 'data-placeholder' => $this->trans('Zones', 'Admin.Shipping.Feature'), - 'class' => 'select2 js-multiple-zone-choice', - ], - ]) ->add('is_free', SwitchType::class, [ 'label' => $this->trans('Free Shipping', 'Admin.Shipping.Feature'), - 'external_link' => [ - 'text' => $this->trans('[1]Manage Free Shipping[/1]', 'Admin.Shipping.Feature'), - 'position' => 'prepend', - 'href' => '', - ], ]) - ->add('tax', TaxGroupChoiceType::class, [ + ->add('id_tax_rule_group', TaxGroupChoiceType::class, [ 'label' => $this->trans('Tax', 'Admin.Shipping.Feature'), 'external_link' => [ - 'text' => $this->trans('[1]Manage Tax[/1]', 'Admin.Shipping.Feature'), - 'position' => 'prepend', - 'href' => '', + 'text' => $this->trans('[1]Manage taxes[/1]', 'Admin.Shipping.Feature'), + 'position' => 'below', + 'href' => $this->router->generate('admin_taxes_index'), + 'attr' => [ + 'target' => '_blank', + ], ], ]) ->add('has_additional_handling_fee', SwitchType::class, [ 'label' => $this->trans('Handling costs', 'Admin.Shipping.Feature'), 'label_help_box' => $this->trans('Does the carrier have additional fees', 'Admin.Shipping.Help'), 'external_link' => [ - 'text' => $this->trans('[1]Manage Free Shipping[/1]', 'Admin.Shipping.Feature'), - 'position' => 'prepend', - 'href' => '', + 'text' => $this->trans('[1]Manage handling costs[/1]', 'Admin.Shipping.Feature'), + 'position' => 'below', + 'href' => $this->router->generate('admin_shipping_preferences'), + 'attr' => [ + 'target' => '_blank', + ], ], ]) ->add('shipping_method', ChoiceType::class, [ 'label' => $this->trans('Shipping costs', 'Admin.Shipping.Feature'), 'choices' => [ - $this->trans("are based on the order's total price", 'Admin.Shipping.Feature') => 0, - $this->trans("are based on the order's total weight", 'Admin.Shipping.Feature') => 1, + $this->trans("Based on the order's total price", 'Admin.Shipping.Feature') => ShippingMethod::BY_PRICE, + $this->trans("Based on the order's total weight", 'Admin.Shipping.Feature') => ShippingMethod::BY_WEIGHT, ], + 'default_empty_data' => ShippingMethod::BY_PRICE, 'expanded' => true, 'multiple' => false, + 'attr' => [ + 'data-units' => json_encode([ + ShippingMethod::BY_PRICE => $this->currencyDataProvider->getDefaultCurrencySymbol(), + ShippingMethod::BY_WEIGHT => $this->configuration->get('PS_WEIGHT_UNIT'), + ]), + ], ]) ->add('range_behavior', ChoiceType::class, [ 'label' => $this->trans('Out of range behavior', 'Admin.Shipping.Feature'), 'choices' => [ - $this->trans('Apply the cost of the highest defined range', 'Admin.Shipping.Feature') => 0, - $this->trans('Disable carrier', 'Admin.Shipping.Feature') => 1, + $this->trans('Apply the cost of the highest defined range', 'Admin.Shipping.Feature') => OutOfRangeBehavior::USE_HIGHEST_RANGE, + $this->trans('Disable carrier', 'Admin.Shipping.Feature') => OutOfRangeBehavior::DISABLED, ], + 'default_empty_data' => OutOfRangeBehavior::USE_HIGHEST_RANGE, + ]) + ->add('ranges_costs_control', CarrierRangesControlType::class) + ->add('ranges_costs', CollectionType::class, [ + 'prototype_name' => '__zone__', + 'entry_type' => CostsZoneType::class, + 'label' => null, + 'allow_add' => true, + 'allow_delete' => true, ]) ; } diff --git a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesControlType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesControlType.php new file mode 100644 index 0000000000000..d25f4f70ea2e6 --- /dev/null +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesControlType.php @@ -0,0 +1,86 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +namespace PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type; + +use PrestaShopBundle\Form\Admin\Type\MultipleZoneChoiceType; +use PrestaShopBundle\Form\Admin\Type\TranslatorAwareType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +class CarrierRangesControlType extends TranslatorAwareType +{ + public function __construct( + TranslatorInterface $translator, + array $locales, + private readonly RouterInterface $router, + ) { + parent::__construct($translator, $locales); + } + + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('zones', MultipleZoneChoiceType::class, [ + 'label' => false, + 'required' => false, + 'multiple' => true, + 'external_link' => [ + 'text' => $this->trans('[1]Manage locations[/1]', 'Admin.Shipping.Feature'), + 'position' => 'below', + 'href' => $this->router->generate('admin_zones_index'), + 'attr' => [ + 'target' => '_blank', + ], + ], + 'attr' => [ + 'data-placeholder' => $this->trans('Zones', 'Admin.Shipping.Feature'), + 'class' => 'select2 js-multiple-zone-choice', + ], + ]) + ->add('ranges', CarrierRangesType::class, [ + 'label' => false, + 'row_attr' => [ + 'class' => 'carrier-ranges-edit-row', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + $resolver->setDefaults([ + 'label' => $this->trans('Zones', 'Admin.Shipping.Feature'), + 'label_help_box' => $this->trans('Zones that the carrier can handle', 'Admin.Shipping.Help'), + 'attr' => [ + 'class' => 'carrier-ranges-control', + ], + ]); + } +} diff --git a/src/PrestaShopBundle/Form/Admin/Type/CarrierRangesType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesType.php similarity index 84% rename from src/PrestaShopBundle/Form/Admin/Type/CarrierRangesType.php rename to src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesType.php index 83126cbfa2ad5..70815adfaa721 100644 --- a/src/PrestaShopBundle/Form/Admin/Type/CarrierRangesType.php +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CarrierRangesType.php @@ -26,9 +26,11 @@ declare(strict_types=1); -namespace PrestaShopBundle\Form\Admin\Type; +namespace PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type; -use PrestaShopBundle\Translation\TranslatorInterface; +use PrestaShopBundle\Form\Admin\Type\IconButtonType; +use PrestaShopBundle\Form\Admin\Type\TranslatorAwareType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -43,31 +45,25 @@ */ class CarrierRangesType extends TranslatorAwareType { - public function __construct( - TranslatorInterface $translator, - array $locales, - ) { - parent::__construct($translator, $locales); - } - /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder + ->add('data', HiddenType::class) ->add('show_modal', IconButtonType::class, [ 'label' => ' ' . $options['button_label'], 'icon' => 'add_box', 'attr' => [ - 'class' => 'js-add-carrier-ranges-btn btn btn-outline-primary', + 'class' => 'js-add-carrier-ranges-btn btn btn-outline-secondary', 'data-translations' => json_encode([ 'modal.title' => $this->trans('Ranges', 'Admin.Shipping.Feature'), 'modal.addRange' => $this->trans('Add range', 'Admin.Shipping.Feature'), 'modal.apply' => $this->trans('Apply', 'Admin.Actions'), 'modal.cancel' => $this->trans('Cancel', 'Admin.Actions'), - 'modal.col.min' => $this->trans('Minimum', 'Admin.Shipping.Feature'), - 'modal.col.max' => $this->trans('Maximum', 'Admin.Shipping.Feature'), + 'modal.col.from' => $this->trans('Minimum', 'Admin.Shipping.Feature'), + 'modal.col.to' => $this->trans('Maximum', 'Admin.Shipping.Feature'), 'modal.col.action' => $this->trans('Action', 'Admin.Shipping.Feature'), 'modal.overlappingAlert' => $this->trans('Make sure there are no overlapping ranges. Remember, the minimum is part of the range, but the maximum isn\'t. So, the upper limit of a range is the lower limit of the next range.', 'Admin.Shipping.Feature'), ]), @@ -83,7 +79,7 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'label' => $this->trans('Ranges', 'Admin.Shipping.Feature'), - 'button_label' => $this->trans('Add range', 'Admin.Shipping.Feature'), + 'button_label' => $this->trans('Edit ranges', 'Admin.Shipping.Feature'), ]); } diff --git a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsRangeType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsRangeType.php new file mode 100644 index 0000000000000..7f162528736c0 --- /dev/null +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsRangeType.php @@ -0,0 +1,76 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type; + +use PrestaShopBundle\Form\Admin\Type\MoneyWithSuffixType; +use PrestaShopBundle\Form\Admin\Type\TextPreviewType; +use PrestaShopBundle\Form\Admin\Type\TranslatorAwareType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CostsRangeType extends TranslatorAwareType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('range', TextPreviewType::class, [ + 'label' => $this->trans('Range', 'Admin.Shipping.Feature'), + ]) + ->add('from', HiddenType::class) + ->add('to', HiddenType::class) + ->add('price', MoneyWithSuffixType::class, [ + 'label' => $this->trans('Price (VAT excl.)', 'Admin.Shipping.Feature'), + 'empty_data' => 0.0, + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'label' => false, + 'form_theme' => '@PrestaShop/Admin/Improve/Shipping/Carriers/FormTheme/costs-range.html.twig', + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return 'carrier_ranges_costs_zone_range'; + } +} diff --git a/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsZoneType.php b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsZoneType.php new file mode 100644 index 0000000000000..e59563fb49183 --- /dev/null +++ b/src/PrestaShopBundle/Form/Admin/Improve/Shipping/Carrier/Type/CostsZoneType.php @@ -0,0 +1,90 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShopBundle\Form\Admin\Improve\Shipping\Carrier\Type; + +use PrestaShopBundle\Form\Admin\Type\IconButtonType; +use PrestaShopBundle\Form\Admin\Type\TextPreviewType; +use PrestaShopBundle\Form\Admin\Type\TranslatorAwareType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class CostsZoneType extends TranslatorAwareType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('zoneId', HiddenType::class) + ->add('deleteZone', IconButtonType::class, [ + 'label' => false, + 'icon' => 'delete', + 'attr' => [ + 'class' => 'js-carrier-delete-zone', + 'data-modal-title' => $this->trans('Delete Zone?', 'Admin.Shipping.Feature'), + 'data-modal-confirm' => $this->trans('Delete', 'Admin.Actions'), + 'data-modal-cancel' => $this->trans('Cancel', 'Admin.Actions'), + ], + ]) + ->add('zoneName', TextPreviewType::class, [ + 'label' => false, + ]) + ->add('ranges', CollectionType::class, [ + 'prototype_name' => '__range__', + 'entry_type' => CostsRangeType::class, + 'label' => false, + 'required' => false, + 'allow_add' => true, + 'block_prefix' => 'carrier_ranges_costs_zone_ranges_collection', + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'label' => false, + 'form_theme' => '@PrestaShop/Admin/Improve/Shipping/Carriers/FormTheme/costs-range.html.twig', + ]); + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return 'carrier_ranges_costs_zone'; + } +} diff --git a/src/PrestaShopBundle/Form/Extension/ExternalLinkExtension.php b/src/PrestaShopBundle/Form/Extension/ExternalLinkExtension.php index 1656ba94721bc..c50d38425e65f 100644 --- a/src/PrestaShopBundle/Form/Extension/ExternalLinkExtension.php +++ b/src/PrestaShopBundle/Form/Extension/ExternalLinkExtension.php @@ -108,7 +108,7 @@ private function getExternalLinkResolver(): OptionsResolver ->setAllowedTypes('align', 'string') ->setAllowedTypes('position', 'string') ->setAllowedTypes('attr', ['null', 'array']) - ->setAllowedValues('position', ['append', 'prepend']) + ->setAllowedValues('position', ['append', 'prepend', 'below']) ->setAllowedTypes('open_in_new_tab', 'bool') ; diff --git a/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/FormTheme/costs-range.html.twig b/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/FormTheme/costs-range.html.twig new file mode 100644 index 0000000000000..442701b851d81 --- /dev/null +++ b/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/FormTheme/costs-range.html.twig @@ -0,0 +1,75 @@ +{#** + * Copyright since 2007 PrestaShop SA and Contributors + * PrestaShop is an International Registered Trademark & Property of PrestaShop SA + * + * NOTICE OF LICENSE + * + * This source file is subject to the Open Software License (OSL 3.0) + * that is bundled with this package in the file LICENSE.md. + * It is also available through the world-wide-web at this URL: + * https://opensource.org/licenses/OSL-3.0 + * If you did not receive a copy of the license and are unable to + * obtain it through the world-wide-web, please send an email + * to license@prestashop.com so we can send you a copy immediately. + * + * DISCLAIMER + * + * Do not edit or add to this file if you wish to upgrade PrestaShop to newer + * versions in the future. If you wish to customize PrestaShop for your + * needs please refer to https://devdocs.prestashop.com/ for more information. + * + * @author PrestaShop SA and Contributors + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + *#} + +{% block carrier_ranges_costs_zone_row %} +
+ {{ form_widget(form.zoneId) }} +
+
+

{{ form_widget(form.zoneName) }}

+ {{ form_widget(form.deleteZone) }} +
+
+
+ {{ form_widget(form.ranges) }} + {{ form_rest(form) }} +
+
+
+
+{% endblock %} + +{% block carrier_ranges_costs_zone_ranges_collection_widget %} + {% if prototype is defined and not prototype.rendered %} + {%- set attr = attr|merge({'data-prototype': form_row(prototype), 'class': ('js-carrier-range-container ' ~ form.vars.attr.class|default(''))|trim }) -%} + {% endif %} + {%- if form is rootform -%} + {{ form_errors(form) }} + {%- endif -%} + + + + + + + + + {{- block('form_rows') -}} + + {{- form_rest(form) -}} +
{{ form.vars.prototype.range.vars.label }}{{ form.vars.prototype.price.vars.label }}
+{% endblock %} + +{% block carrier_ranges_costs_zone_range_row %} + + + {{ form_widget(form.range) }} + + + {{ form_widget(form.price) }} + + {{ form_rest(form) }} + +{% endblock %} diff --git a/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/form.html.twig b/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/form.html.twig index 3dc9ab6ccd9b3..cc6e6510322c2 100644 --- a/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/form.html.twig +++ b/src/PrestaShopBundle/Resources/views/Admin/Improve/Shipping/Carriers/form.html.twig @@ -25,8 +25,6 @@ {% extends '@PrestaShop/Admin/layout.html.twig' %} {% trans_default_domain 'Admin.Shipping.Feature' %} -{% form_theme carrierForm '@PrestaShop/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig' %} - {% block content %} {% block carrier_form_block %} {{ form_start(carrierForm) }} diff --git a/src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig b/src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig index ae321573e8d16..83c2fe41e8935 100644 --- a/src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig +++ b/src/PrestaShopBundle/Resources/views/Admin/TwigTemplateForm/prestashop_ui_kit.html.twig @@ -114,6 +114,9 @@ {{ form_widget(form) }} {{ form_errors(form, {'attr': {'fieldError': true}}) }} {{- block('form_append_alert') -}} + {% if position == 'below' %} + {{- block('form_external_link') -}} + {% endif %} {% if attribute(form.parent, form.vars.name) is defined and attribute(form.parent, form.vars.name).vars.multistore_dropdown != false %} {{ attribute(form.parent, form.vars.name).vars.multistore_dropdown | raw }} diff --git a/tests/Unit/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProviderTest.php b/tests/Unit/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProviderTest.php index aea589d64ebaa..bb7bc30b76d3f 100644 --- a/tests/Unit/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProviderTest.php +++ b/tests/Unit/Core/Form/IdentifiableObject/DataProvider/CarrierFormDataProviderTest.php @@ -27,9 +27,14 @@ namespace Core\Form\IdentifiableObject\DataProvider; use PHPUnit\Framework\TestCase; +use PrestaShop\PrestaShop\Adapter\Form\ChoiceProvider\ZoneByIdChoiceProvider; use PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface; +use PrestaShop\PrestaShop\Core\ConfigurationInterface; use PrestaShop\PrestaShop\Core\Context\ShopContext; +use PrestaShop\PrestaShop\Core\Currency\CurrencyDataProviderInterface; use PrestaShop\PrestaShop\Core\Domain\Carrier\Query\GetCarrierForEditing; +use PrestaShop\PrestaShop\Core\Domain\Carrier\Query\GetCarrierRanges; +use PrestaShop\PrestaShop\Core\Domain\Carrier\QueryResult\CarrierRangesCollection; use PrestaShop\PrestaShop\Core\Domain\Carrier\QueryResult\EditableCarrier; use PrestaShop\PrestaShop\Core\Domain\Carrier\ValueObject\OutOfRangeBehavior; use PrestaShop\PrestaShop\Core\Form\IdentifiableObject\DataProvider\CarrierFormDataProvider; @@ -38,37 +43,80 @@ class CarrierFormDataProviderTest extends TestCase { public function testGetData(): void { + // Create a mock for CommandBusInterface $queryBus = $this->createMock(CommandBusInterface::class); $queryBus ->method('handle') - ->with($this->isInstanceOf(GetCarrierForEditing::class)) - ->willReturn(new EditableCarrier( - 42, - 'Carrier name', - 5, - 'http://track.to', - 1, - true, - [ - 1 => 'English delay', - 2 => 'French delay', - ], - 1234, - 1123, - 3421, - 1657, - [1, 2, 3], - false, - true, - 1, - 1, - OutOfRangeBehavior::USE_HIGHEST_RANGE, - [1, 3], - '/img/c/45.jkg', - )) + ->withConsecutive( + [$this->isInstanceOf(GetCarrierForEditing::class)], + [$this->isInstanceOf(GetCarrierRanges::class)] + ) + ->willReturnOnConsecutiveCalls( + new EditableCarrier( + 42, + 'Carrier name', + 5, + 'http://track.to', + 1, + true, + [ + 1 => 'English delay', + 2 => 'French delay', + ], + 1234, + 1123, + 3421, + 1657, + [1, 2, 3], + false, + true, + 1, + 1, + OutOfRangeBehavior::USE_HIGHEST_RANGE, + [1, 3], + '/img/c/45.jkg', + ), + new CarrierRangesCollection([ + ['id_zone' => 1, 'range_from' => 0, 'range_to' => 10, 'range_price' => '10.00'], + ['id_zone' => 1, 'range_from' => 10, 'range_to' => 20, 'range_price' => '11.00'], + ['id_zone' => 1, 'range_from' => 20, 'range_to' => 25, 'range_price' => '12.00'], + ['id_zone' => 2, 'range_from' => 0, 'range_to' => 10, 'range_price' => '20.00'], + ['id_zone' => 2, 'range_from' => 10, 'range_to' => 20, 'range_price' => '21.00'], + ['id_zone' => 2, 'range_from' => 20, 'range_to' => 25, 'range_price' => '22.00'], + ]) + ) ; - $formDataProvider = new CarrierFormDataProvider($queryBus, $this->createMock(ShopContext::class)); + // Create a mock for CurrencyDataProviderInterface + $currencyDataProvider = $this->createMock(CurrencyDataProviderInterface::class); + $currencyDataProvider + ->method('getDefaultCurrencySymbol') + ->willReturn('€'); + + // Create a mock for ConfigurationInterface + $configuration = $this->createMock(ConfigurationInterface::class); + $configuration + ->method('get') + ->with('PS_WEIGHT_UNIT') + ->willReturn('kg'); + + // Create a mock for ZoneByIdChoiceProvider + $zonesChoiceProvider = $this->createMock(ZoneByIdChoiceProvider::class); + $zonesChoiceProvider + ->method('getChoices') + ->willReturn([ + 'Zone A' => 1, + 'Zone B' => 2, + 'Zone C' => 3, + ]); + + $formDataProvider = new CarrierFormDataProvider( + $queryBus, + $this->createMock(ShopContext::class), + $currencyDataProvider, + $configuration, + $zonesChoiceProvider + ); $formData = $formDataProvider->getData(42); $this->assertEquals([ 'general_settings' => [ @@ -90,6 +138,28 @@ public function testGetData(): void 'shipping_method' => 1, 'id_tax_rule_group' => 1, 'range_behavior' => OutOfRangeBehavior::USE_HIGHEST_RANGE, + 'ranges_costs_control' => [ + 'zones' => [1, 2], + 'ranges' => [ + 'data' => json_encode([ + ['from' => 0, 'to' => 10], + ['from' => 10, 'to' => 20], + ['from' => 20, 'to' => 25], + ]), + ], + ], + 'ranges_costs' => [ + ['zoneId' => 1, 'zoneName' => 'Zone A', 'ranges' => [ + ['range' => '0kg - 10kg', 'from' => '0', 'to' => '10', 'price' => '10'], + ['range' => '10kg - 20kg', 'from' => '10', 'to' => '20', 'price' => '11'], + ['range' => '20kg - 25kg', 'from' => '20', 'to' => '25', 'price' => '12'], + ]], + ['zoneId' => 2, 'zoneName' => 'Zone B', 'ranges' => [ + ['range' => '0kg - 10kg', 'from' => '0', 'to' => '10', 'price' => '20'], + ['range' => '10kg - 20kg', 'from' => '10', 'to' => '20', 'price' => '21'], + ['range' => '20kg - 25kg', 'from' => '20', 'to' => '25', 'price' => '22'], + ]], + ], ], 'size_weight_settings' => [ 'max_width' => 1234, @@ -104,7 +174,13 @@ public function testGetDefaultData(): void { $shopContext = $this->createMock(ShopContext::class); $shopContext->method('getAssociatedShopIds')->willReturn([2, 4]); - $formDataProvider = new CarrierFormDataProvider($this->createMock(CommandBusInterface::class), $shopContext); + $formDataProvider = new CarrierFormDataProvider( + $this->createMock(CommandBusInterface::class), + $shopContext, + $this->createMock(CurrencyDataProviderInterface::class), + $this->createMock(ConfigurationInterface::class), + $this->createMock(ZoneByIdChoiceProvider::class) + ); $this->assertEquals([ 'general_settings' => [ 'grade' => 0,