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,166 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div>
<transition name="fade">
<div class="modal show">
<div
class="modal-dialog modal-dialog-centered"
role="document"
>
<div
class="modal-content"
aria-labelledby="modalTitle"
aria-describedby="modalDescription"
v-click-outside="clickOutsideClose"
>
<header
class="modal-header"
>
<slot name="header">
<h5 class="modal-title">
{{ modalTitle }}
</h5>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click.prevent.stop="close"
>
<span aria-hidden="true">×</span>
</button>
</slot>
</header>
<section
class="modal-body"
>
<slot name="body" />
</section>
<footer class="modal-footer">
<slot
name="footer"
v-if="!confirmation"
>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="close"
aria-label="Close modal"
>
{{ $t(closeLabel) }}
</button>
</slot>
<slot
name="footer-confirmation"
v-if="confirmation"
>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="close"
aria-label="Close modal"
>
{{ $t(cancelLabel) }}
</button>
<button
type="button"
class="btn btn-primary"
@click.prevent.stop="confirm"
>
{{ $t(confirmLabel) }}
</button>
</slot>
</footer>
</div>
</div>
<slot name="outside" />
</div>
</transition>
<div
class="modal-backdrop show"
@click.prevent.stop="close"
/>
</div>
</template>
<script lang="ts">
import ClickOutside from '@PSVue/directives/click-outside';
import {defineComponent} from 'vue';
export default defineComponent({
name: 'Modal',
directives: {
ClickOutside,
},
props: {
closeOnClickOutside: {
type: Boolean,
required: false,
default: true,
},
confirmation: {
type: Boolean,
required: false,
default: false,
},
cancelLabel: {
type: String,
required: false,
default() {
return 'modal.cancel';
},
},
confirmLabel: {
type: String,
required: false,
default() {
return 'modal.apply';
},
},
closeLabel: {
type: String,
required: false,
default() {
return 'modal.close';
},
},
modalTitle: {
type: String,
required: false,
default() {
return '';
},
},
},
methods: {
clickOutsideClose(): void {
if (this.closeOnClickOutside) {
this.$emit('close');
}
},
close(): void {
this.$emit('close');
},
confirm(): void {
this.$emit('confirm');
},
},
});
</script>
<style lang="scss" scoped>
.modal.show {
display: block;
}
.modal-fade-enter-active, .modal-fade-leave-active {
transition: opacity .5s;
}
.modal-fade-enter, .modal-fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,165 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="pagination">
<ul class="pagination-list">
<li class="pagination-item pagination-previous">
<button
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
>
<i class="material-icons rtl-flip">chevron_left</i>
</button>
</li>
<li
:class="['pagination-item', isActive(key)]"
v-for="(page, key) of paginatedDatas"
:key="key"
>
<button @click="goToPage(key + 1)">
{{ key + 1 }}
</button>
</li>
<li class="pagination-item pagination-next">
<button
@click="goToPage(currentPage + 1)"
:disabled="currentPage === paginatedDatas.length"
>
<i class="material-icons rtl-flip">chevron_right</i>
</button>
</li>
</ul>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'Pagination',
data(): {paginatedDatas: Array<Record<string, any>>, currentPage: number} {
return {
paginatedDatas: [],
currentPage: 1,
};
},
props: {
datas: {
type: Array as () => Array<Record<string, any>>,
default: () => [],
},
paginationLength: {
type: Number,
default: 14,
},
},
methods: {
/**
* Used to switch page state of the pagination
*
* @param {int} pageNumber
*/
goToPage(pageNumber: number): void {
if (this.paginatedDatas[pageNumber - 1]) {
this.currentPage = pageNumber;
this.$emit('paginated', {
paginatedDatas: this.paginatedDatas,
currentPage: this.currentPage,
});
}
},
/**
* Split items into chunks based on paginationLength
*
* @param {array} newDatas
*/
constructDatas(newDatas: Array<Record<string, any>>): void {
this.paginatedDatas = [];
for (let i = 0; i < newDatas.length; i += this.paginationLength) {
this.paginatedDatas.push(newDatas.slice(i, i + this.paginationLength));
}
this.$emit('paginated', {
paginatedDatas: this.paginatedDatas,
currentPage: this.currentPage,
});
},
/**
* Avoid too much logics in the template
*
* @param {int} key
*/
isActive(key: number): string | null {
return this.currentPage === key + 1 ? 'active' : null;
},
},
/**
* On mount, split datas into chunks
*/
mounted() {
this.constructDatas(this.datas);
},
watch: {
/**
* On datas change, split into chunks again.
*
* @param {array} newDatas
*/
datas(newDatas: Array<Record<string, any>>): void {
this.constructDatas(newDatas);
},
},
});
</script>
<style lang="scss" type="text/scss">
@import "~@scss/config/_settings.scss";
.pagination {
&-list {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
width: 100%;
}
&-item {
list-style-type: none;
button {
font-size: var(--#{$cdk}size-16);
padding: var(--#{$cdk}size-8);
transition: 0.25s ease-out;
cursor: pointer;
color: var(--#{$cdk}primary-600);
border: 0;
background-color: inherit;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:hover:not(:disabled) {
color: var(--#{$cdk}primary-800);
}
}
&.active {
button {
color: var(--#{$cdk}primary-800);
}
}
}
&-previous,
&-next {
font-size: var(--#{$cdk}size-20);
}
}
</style>

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.
*/
export default {
modalControl: '.grouped-item-collection-modal-control',
vueAppContainerClass: 'grouped-item-collection-modal-vue-app',
searchInput: '.grouped-items-selector-component .items-search',
scrollBarContainer: '.grouped-items-selector-component .item-groups-list-overflow',
groupedItemCollection: '.grouped-item-collection',
groupedItemRow: '.grouped-item-row',
groupIdValue: '.grouped-item-id',
groupNameValue: '.grouped-item-name',
groupPreview: '.text-preview-value',
tagCollection: '.tagged-item-collection',
tagItem: '.tag-item',
tagRemoveButtons: '.remove-tag-item',
tagPreview: '.text-preview-value',
tagIdValue: '.tagged-item-value',
tagNameValue: '.tagged-item-name',
};

View File

@@ -0,0 +1,176 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="grouped-item-collection-modal">
<modal
v-if="isModalShown"
:modal-title="$t('modal.title')"
:confirmation="true"
:close-on-click-outside="false"
@close="closeModal"
>
<template #body>
<grouped-items-selector
:item-groups="itemGroups"
v-if="itemGroups"
/>
</template>
<template #footer-confirmation>
<button
type="button"
class="btn btn-outline-secondary"
@click.prevent.stop="closeModal"
:aria-label="$t('modal.close')"
>
{{ $t('modal.close') }}
</button>
<button
type="button"
class="btn btn-primary"
@click.prevent.stop="selectItems"
:disabled="!selectedItemsNb || loading"
>
<span v-if="!loading">
{{
$t('select.action', {
'selectedItemsNb': selectedItemsNb,
})
}}
</span>
<span v-if="loading">{{ $t('modal.loading') }}</span>
<span
class="spinner-border spinner-border-sm"
v-if="loading"
role="status"
aria-hidden="true"
/>
</button>
</template>
</modal>
</div>
</template>
<script lang="ts">
import GroupedItemsSelector from '@PSVue/components/grouped-item-collection/GroupedItemsSelector.vue';
import Modal from '@PSVue/components/Modal.vue';
import {defineComponent} from 'vue';
import {GroupedItemCollectionModalStates, Item, ItemGroup} from '@PSVue/components/grouped-item-collection/types';
export default defineComponent({
name: 'GroupedItemCollectionModal',
data(): GroupedItemCollectionModalStates {
return {
itemGroups: [],
isModalShown: false,
loading: false,
};
},
props: {
modalControl: {
type: HTMLElement,
required: true,
},
fetchItemGroups: {
type: Function,
required: true,
},
onItemsSelected: {
type: Function,
required: true,
},
getSelectedItems: {
type: Function,
required: true,
},
},
components: {
Modal,
GroupedItemsSelector,
},
computed: {
selectedItems(): Item[] {
const selectedItems: Item[] = [];
this.itemGroups.forEach((itemGroup: ItemGroup) => {
itemGroup.items.forEach((item: Item) => {
if (item.selected) {
selectedItems.push(item);
}
});
});
return selectedItems;
},
selectedItemsNb(): number {
return this.selectedItems.length;
},
},
mounted() {
window.prestaShopUiKit.init();
this.modalControl.addEventListener('click', () => {
this.showModal();
});
},
methods: {
/**
* Show the modal, and execute PerfectScrollBar and Typehead
*/
async showModal(): Promise<void> {
document.querySelector('body')?.classList.add('overflow-hidden');
this.isModalShown = true;
// First display, we load the items
if (!this.itemGroups.length && !this.loading) {
this.loading = true;
try {
this.itemGroups = await this.fetchItemGroups();
} catch (error) {
window.$.growl.error({message: error});
}
this.loading = false;
}
// Update selected items
const selectedItems: Item[] = this.getSelectedItems();
if (selectedItems && selectedItems.length) {
selectedItems.forEach((selectedItem: Item) => {
this.itemGroups.forEach((itemGroup: ItemGroup) => {
itemGroup.items.forEach((item: Item) => {
if (selectedItem.id === item.id) {
item.selected = true;
}
});
});
});
}
},
/**
* Handle modal closing
*/
closeModal(): void {
this.isModalShown = false;
document.querySelector('body')?.classList.remove('overflow-hidden');
},
unselectAll(): void {
this.itemGroups.forEach((itemGroup: ItemGroup) => {
itemGroup.items.forEach((item: Item) => {
item.selected = false;
});
});
},
/**
* Used when the user clicks on the Generate button of the modal
*/
async selectItems(): Promise<void> {
this.onItemsSelected(this.selectedItems);
this.unselectAll();
this.closeModal();
},
},
});
</script>

View File

@@ -0,0 +1,394 @@
<!--*
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*-->
<template>
<div class="grouped-items-selector-component">
<div class="tags-input d-flex flex-wrap">
<div class="tags-wrapper">
<span
class="tag"
:key="selectedItem.id"
v-for="selectedItem in selectedItems"
>
{{ selectedItem.groupName }}: {{ selectedItem.name }}
<i
class="material-icons"
@click.prevent.stop="unselectItem(selectedItem)"
>close</i>
</span>
</div>
<input
type="text"
:disabled="!searchableItemsNb"
:placeholder="$t('search.placeholder')"
class="form-control input items-search"
>
</div>
<div class="item-groups-list-container">
<div class="item-groups-list-overflow">
<div class="item-groups-list">
<div
class="item-group"
v-for="itemGroup of itemGroups"
:key="itemGroup.id"
>
<div class="item-group-header">
<a
class="item-group-name collapsed"
data-toggle="collapse"
:href="`#item-group-${itemGroup.id}`"
>
<label>{{ itemGroup.name }}</label>
</a>
<div class="md-checkbox item-group-checkbox">
<label>
<input
class="item-group-checkbox"
type="checkbox"
:name="`checkbox_${itemGroup.id}`"
@change.prevent.stop="toggleAllItems($event, itemGroup)"
:checked="allItemsSelected(itemGroup)"
>
<i class="md-checkbox-control" />
{{
$t('group.select-all', {
'valuesNb': itemGroup.items.length,
})
}}
</label>
</div>
</div>
<div
class="item-group-content collapse"
:id="`item-group-${itemGroup.id}`"
>
<label
v-for="item of itemGroup.items"
:class="[
'item',
getSelectedClass(item),
]"
:for="`item_${item.id}`"
:key="item.id"
>
<input
type="checkbox"
:name="`item_${item.id}`"
:id="`item_${item.id}`"
@change="toggleItemSelected(item)"
>
<div class="item-content">
<span
class="item-texture"
v-if="item.texture"
:style="`background: transparent url(${item.texture}) no-repeat; background-size: 100% auto;`"
/>
<span
class="item-color"
v-else-if="item.color"
:style="`background-color: ${item.color}`"
/>
<span class="item-name">{{ item.name }}</span>
</div>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import GroupedItemCollectionMap from '@PSVue/components/grouped-item-collection/GroupedItemCollectionMap';
import PerfectScrollbar from 'perfect-scrollbar';
// @ts-ignore
import Bloodhound from 'typeahead.js';
import AutoCompleteSearch, {AutoCompleteSearchConfig} from '@components/auto-complete-search';
import Tokenizers from '@components/bloodhound/tokenizers';
import {ItemGroup, Item, GroupedItemsSelectorStates} from '@PSVue/components/grouped-item-collection/types';
const {$} = window;
export default defineComponent({
name: 'GroupedItemsSelector',
props: {
itemGroups: {
type: Array as PropType<ItemGroup[]>,
default: () => [],
},
selectedItemGroups: {
type: Object as PropType<Record<string, ItemGroup>>,
default: () => ({}),
},
},
data(): GroupedItemsSelectorStates {
return {
dataSetConfig: {},
searchSource: {},
scrollbar: null,
searchableItems: [],
};
},
mounted() {
this.initDataSetConfig();
// Init scrollbar for the selection container (when everything is selected and open it all
// needs to fit in the modal).
this.scrollbar = new PerfectScrollbar(GroupedItemCollectionMap.scrollBarContainer);
const $searchInput = $(GroupedItemCollectionMap.searchInput);
new AutoCompleteSearch($searchInput, <Partial<AutoCompleteSearchConfig>> this.dataSetConfig);
},
computed: {
selectedItems(): Item[] {
const selectedItems: Item[] = [];
this.itemGroups.forEach((itemGroup: ItemGroup) => {
itemGroup.items.forEach((item: Item) => {
if (item.selected) {
selectedItems.push(item);
}
});
});
return selectedItems;
},
searchableItemsNb(): number {
return this.searchableItems.length;
},
},
watch: {
/**
* We use a watcher so that this list is always updated, when the itemGroups are modified,
* including when the parent updates the selected status.
*/
itemGroups() {
this.updateSearchableItems();
},
},
methods: {
/**
* Prepare the configuration for the AutoCompleteSearch component.
*/
initDataSetConfig(): void {
const letters = [
'name',
'value',
'groupName',
];
this.searchSource = new Bloodhound({
datumTokenizer: Tokenizers.obj.letters(letters),
queryTokenizer: Bloodhound.tokenizers.nonword,
local: this.searchableItems,
});
this.dataSetConfig = {
source: this.searchSource,
display: (item: Item) => `${item.groupName}: ${item.name}`,
value: 'name',
minLength: 1,
onSelect: (item: Item, e: JQueryEventObject, $searchInput: JQuery) => {
this.selectItem(item);
// This resets the search input or else previous search is cached and can be added again
$searchInput.typeahead('val', '');
return true;
},
};
},
getSelectedClass(item: Item): string {
return item.selected ? 'selected' : 'unselected';
},
toggleItemSelected(selectedItem: Item): void {
selectedItem.selected = !selectedItem.selected;
this.updateSearchableItems();
},
selectItem(selectedItem: Item): void {
selectedItem.selected = true;
this.updateSearchableItems();
},
unselectItem(item: Item): void {
item.selected = false;
this.updateSearchableItems();
},
toggleAllItems(event: any, itemGroup: ItemGroup): void {
const allSelected = event.target.checked;
itemGroup.items.forEach((item: Item) => {
item.selected = allSelected;
});
this.updateSearchableItems();
},
allItemsSelected(itemGroup: ItemGroup): boolean {
let allSelected = true;
itemGroup.items.forEach((item: Item) => {
if (!item.selected) {
allSelected = false;
}
});
return allSelected;
},
/**
* Update Bloodhound engine so that it does not include already selected items
*/
updateSearchableItems(): void {
// Create a list of searchable items, by filtering from the whole list the ones
// that have been selected already, the Item are filled with their parent group
// data which makes it more convenient to use the parent data
const searchableItems: Item[] = [];
this.itemGroups.forEach((itemGroup: ItemGroup) => {
itemGroup.items.forEach((item: Item) => {
if (this.selectedItems.includes(item)) {
return;
}
searchableItems.push(item);
});
});
this.searchableItems = searchableItems;
// Update search input source
this.searchSource.clear();
this.searchSource.add(this.searchableItems);
},
},
});
</script>
<style lang="scss" type="text/scss">
@import '~@scss/config/_settings.scss';
.grouped-items-selector-component {
@import '~@scss/components/twitter_typeahead';
.tags-input {
margin-bottom: var(--#{$cdk}size-16);
.tag {
margin-bottom: var(--#{$cdk}size-4);
}
.tags-wrapper {
max-height: var(--#{$cdk}size-208);
overflow-y: auto;
}
}
.item-groups-list-overflow {
max-height: 50vh;
.item-group {
position: relative;
margin-bottom: var(--#{$cdk}size-12);
overflow: hidden;
border: 1px solid var(--#{$cdk}primary-400);
&-header {
display: flex;
background-color: var(--#{$cdk}primary-200);
}
&-content {
border-top: 1px solid var(--#{$cdk}primary-300);
}
&-checkbox {
width: fit-content;
font-weight: 400;
position: absolute;
right: var(--#{$cdk}size-48);
top: 9px;
}
label {
margin-bottom: 0;
}
&-name {
width: 100%;
padding: var(--#{$cdk}size-8) var(--#{$cdk}size-40) var(--#{$cdk}size-8) var(--#{$cdk}size-16);
font-weight: 600;
color: var(--#{$cdk}primary-800);
&:hover {
text-decoration: none;
}
&::after {
font-family: var(--#{$cdk}font-family-material-icons);
font-size: var(--#{$cdk}size-24);
content: 'expand_more';
line-height: var(--#{$cdk}size-24);
height: var(--#{$cdk}size-24);
position: absolute;
top: var(--#{$cdk}size-8);
right: var(--#{$cdk}size-8);
}
&[aria-expanded="true"] {
&::after {
content: 'expand_less';
}
}
}
.item {
margin: var(--#{$cdk}size-4);
cursor: pointer;
border-radius: var(--#{$cdk}size-4);
&-content {
display: flex;
align-items: center;
padding: var(--#{$cdk}size-8);
}
&.unselected {
&:hover {
background-color: var(--#{$cdk}primary-200);
}
}
&.selected {
background-color: var(--#{$cdk}primary-300);
}
input {
display: none;
}
&-color {
display: block;
width: var(--#{$cdk}size-16);
height: var(--#{$cdk}size-16);
margin-right: var(--#{$cdk}size-8);
border-radius: var(--#{$cdk}size-4);
border: 1px solid var(--#{$cdk}primary-400);
}
&-texture {
display: block;
width: var(--#{$cdk}size-16);
height: var(--#{$cdk}size-16);
margin-right: var(--#{$cdk}size-8);
border-radius: var(--#{$cdk}size-4);
border: 1px solid var(--#{$cdk}primary-400);
}
}
}
.item-groups-list {
height: auto;
padding: var(--#{$cdk}size-8);
}
}
.item-groups-list-container {
position: relative;
padding-bottom: var(--#{$cdk}size-8);
}
}
</style>

View File

@@ -0,0 +1,261 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {createApp, App} from 'vue';
import {createI18n} from 'vue-i18n';
import ReplaceFormatter from '@PSVue/plugins/vue-i18n/replace-formatter';
import GroupedItemCollectionModal from '@PSVue/components/grouped-item-collection/GroupedItemCollectionModal.vue';
import GroupedItemCollectionMap from '@PSVue/components/grouped-item-collection/GroupedItemCollectionMap';
import {Item, ItemGroup} from '@PSVue/components/grouped-item-collection/types';
/**
* Initialize a GroupedItemCollection, the provided selector should be an ID targeting the root widget.
* This is the only function exported and the only endpoint you need to initialize the whole component.
*
* @param groupedItemRootSelector {String} CSS selector of the root div
* @param fetchItemGroups {Function} Asynchronous function to fetch the list of groups, the returned data must respect the ItemGroup format
* @param onItemsSelected {Function|null} Optional callback called when the items have been selected in the modal
*/
export default function initGroupedItemCollection(
groupedItemRootSelector: string,
fetchItemGroups: () => Promise<ItemGroup[]>,
onItemsSelected: ((selectedItems: Item[]) => void)|null = null,
): App {
initTagInputs(groupedItemRootSelector);
// We always update the input tags automatically, but you can specify an additional callback
// if you need to handle something more with selected items
const onSelectCallback = function (selectedItems: Item[]): void {
updateTagInputs(groupedItemRootSelector, selectedItems);
if (onItemsSelected) {
onItemsSelected(selectedItems);
}
};
// We build the getSelectedCallback based on the group selector
const getSelectedItemsCallback = function (): Item[] {
return getSelectedItemsFromInputs(groupedItemRootSelector);
};
return initVueApp(groupedItemRootSelector, fetchItemGroups, getSelectedItemsCallback, onSelectCallback);
}
/**
* @internal
* Initialize the modal VueApp that allows selecting the attributes organized by groups.
*
* @param groupedItemRootSelector {String} CSS selector of the root div
* @param fetchItemGroups {Function} Asynchronous function to fetch the list of groups, the returned data must respect the ItemGroup format
* @param getSelectedItems {Function} Callback function that provides the list of selected items, so the modal Vue app can preselect them when it opens
* @param onItemsSelected {Function|null} Optional callback called when the items have been selected in the modal * @param groupedItemRootSelector
*/
function initVueApp(
groupedItemRootSelector: string,
fetchItemGroups: () => Promise<ItemGroup[]>,
getSelectedItems: () => Item[],
onItemsSelected: (selectedItems: Item[]) => void,
): App {
const groupItemRoot = document.querySelector<HTMLElement>(groupedItemRootSelector);
const groupedItemCollection = groupItemRoot?.querySelector<HTMLElement>(GroupedItemCollectionMap.groupedItemCollection);
const modalControl = groupItemRoot?.querySelector(GroupedItemCollectionMap.modalControl);
const vueAppContainer = document.createElement('div');
vueAppContainer.classList.add(GroupedItemCollectionMap.vueAppContainerClass);
groupItemRoot?.appendChild(vueAppContainer);
const translations = JSON.parse(<string>groupedItemCollection?.dataset?.translations);
const i18n = createI18n({
locale: 'en',
formatter: new ReplaceFormatter(),
messages: {en: translations},
});
const vueApp = createApp(GroupedItemCollectionModal, {
i18n,
modalControl,
fetchItemGroups,
getSelectedItems,
onItemsSelected,
}).use(i18n);
vueApp.mount(vueAppContainer);
return vueApp;
}
/**
* @internal
* Parses the form inputs to generate a list a selected items (the most important is the item ID, the group
* details are not relevant).
*/
function getSelectedItemsFromInputs(groupedItemRootSelector: string): Item[] {
const selectedItems: Item[] = [];
const groupedItemRoot = document.querySelector<HTMLElement>(groupedItemRootSelector);
if (groupedItemRoot) {
const groupedItemCollection = groupedItemRoot.querySelectorAll<HTMLElement>(GroupedItemCollectionMap.groupedItemCollection);
groupedItemCollection.forEach((groupCollection: HTMLElement) => {
const groupIdValue = groupCollection.querySelector<HTMLInputElement>(GroupedItemCollectionMap.groupIdValue);
const groupNameValue = groupCollection.querySelector<HTMLInputElement>(GroupedItemCollectionMap.groupNameValue);
if (groupIdValue && groupNameValue) {
const tagItems = groupCollection.querySelectorAll<HTMLElement>(GroupedItemCollectionMap.tagItem);
tagItems.forEach((tagContainer: HTMLElement) => {
const tagIdValue = tagContainer.querySelector<HTMLInputElement>(GroupedItemCollectionMap.tagIdValue);
const tagNameValue = tagContainer.querySelector<HTMLInputElement>(GroupedItemCollectionMap.tagNameValue);
if (tagNameValue && tagIdValue) {
selectedItems.push({
id: parseInt(tagIdValue.value, 10),
name: tagNameValue.value,
selected: true,
groupId: parseInt(groupIdValue.value, 10),
groupName: groupNameValue.value,
color: '',
texture: '',
});
}
});
}
});
}
return selectedItems;
}
/**
* @internal
* Handles click events on the tags removal buttons. When all tags have been removed from a tags container input,
* the whole related group form group is removed automatically.
*/
function initTagInputs(groupedItemRootSelector: string): void {
const groupedItemRoot = document.querySelector<HTMLElement>(groupedItemRootSelector);
if (groupedItemRoot) {
groupedItemRoot.querySelectorAll(GroupedItemCollectionMap.tagRemoveButtons).forEach((tagRemoveButton) => {
tagRemoveButton.addEventListener('click', () => {
const tagItem = tagRemoveButton.closest<HTMLElement>(GroupedItemCollectionMap.tagItem);
if (tagItem) {
const collection = tagItem.closest<HTMLElement>(GroupedItemCollectionMap.tagCollection);
tagItem.parentNode?.removeChild(tagItem);
if (collection) {
const remainingTagItems = collection.querySelectorAll(GroupedItemCollectionMap.tagItem);
// If all tags have been removed the collection form row is removed (meaning the whole group is removed)
if (!remainingTagItems.length) {
const groupRow = collection.closest<HTMLElement>(GroupedItemCollectionMap.groupedItemRow);
if (groupRow) {
groupRow.parentNode?.removeChild(groupRow);
}
}
}
}
});
});
}
}
/**
* @internal
* Rebuilds the grouped item collection based on the selected items returned by the Vue app modal.
* The building of the element uses the Symfony collection prototype feature.
*/
function updateTagInputs(
groupedItemRootSelector: string,
selectedItems: Item[],
): void {
const groupedItemRoot = document.querySelector<HTMLElement>(groupedItemRootSelector);
if (groupedItemRoot) {
const groupedItemCollection = groupedItemRoot.querySelector<HTMLElement>(GroupedItemCollectionMap.groupedItemCollection);
if (groupedItemCollection) {
// First create all the group nodes, they are index by their group name
const groupPrototype = groupedItemCollection.dataset.prototype;
const groupPrototypeName = groupedItemCollection.dataset.prototypeName;
if (groupPrototype && groupPrototypeName) {
const groupHTMLElements: HTMLElement[] = [];
let groupHTMLElement: HTMLElement;
selectedItems.forEach((item: Item) => {
// Get the group node or create it if not existent yet
if (groupHTMLElements[item.groupId]) {
groupHTMLElement = groupHTMLElements[item.groupId];
} else {
const groupTemplate = groupPrototype.replace(new RegExp(groupPrototypeName, 'g'), item.groupId.toString());
// Trim is important here or the first child could be some text (whitespace, or \n)
const fragment = document.createRange().createContextualFragment(groupTemplate.trim());
groupHTMLElement = fragment.firstChild as HTMLElement;
// Fill the new group withs values
const groupIdValue = groupHTMLElement.querySelector<HTMLInputElement>(GroupedItemCollectionMap.groupIdValue);
const groupNameValue = groupHTMLElement.querySelector<HTMLInputElement>(GroupedItemCollectionMap.groupNameValue);
const groupSpanPreview = groupHTMLElement.querySelector<HTMLElement>(GroupedItemCollectionMap.groupPreview);
if (groupIdValue && groupNameValue && groupSpanPreview) {
groupIdValue.value = item.groupId.toString();
groupNameValue.value = item.groupName;
groupSpanPreview.innerText = item.groupName;
// Finally add the group and store it for following selected items
groupedItemRoot.appendChild(groupHTMLElement);
groupHTMLElements[item.groupId] = groupHTMLElement;
}
}
});
// Remove all existing groups and replace them with new one
while (groupedItemCollection.firstChild) {
groupedItemCollection.removeChild(groupedItemCollection.firstChild);
}
groupHTMLElements.forEach((child: HTMLElement) => {
groupedItemCollection.appendChild(child);
});
// Now add all the tags in their respective group container
const groupIndexes: number[] = [];
selectedItems.forEach((item: Item) => {
groupHTMLElement = groupHTMLElements[item.groupId];
const tagCollection = groupHTMLElement.querySelector<HTMLElement>(GroupedItemCollectionMap.tagCollection);
if (tagCollection) {
const tagPrototype = tagCollection.dataset.prototype;
const tagPrototypeName = tagCollection.dataset.prototypeName;
if (tagPrototype && tagPrototypeName) {
// The array index is built for each group starting from 0
if (typeof groupIndexes[item.groupId] === 'undefined') {
groupIndexes[item.groupId] = 0;
} else {
groupIndexes[item.groupId] += 1;
}
const groupIndex = groupIndexes[item.groupId];
const tagTemplate = tagPrototype
.replace(new RegExp(tagPrototypeName, 'g'), groupIndex.toString())
.replace(new RegExp(groupPrototypeName, 'g'), item.groupId.toString());
// Trim is important here or the first child could be some text (whitespace, or \n)
const fragment = document.createRange().createContextualFragment(tagTemplate.trim());
const tagHTMLElement = fragment.firstChild as HTMLElement;
// The tag label must be integrated in the internal preview span
const tagSpanPreview = tagHTMLElement.querySelector<HTMLElement>(GroupedItemCollectionMap.tagPreview);
const tagIdValue = tagHTMLElement.querySelector<HTMLInputElement>(GroupedItemCollectionMap.tagIdValue);
const tagNameValue = tagHTMLElement.querySelector<HTMLInputElement>(GroupedItemCollectionMap.tagNameValue);
if (tagSpanPreview && tagIdValue && tagNameValue) {
tagIdValue.value = item.id.toString();
tagNameValue.value = item.name;
tagSpanPreview.innerText = item.name;
tagCollection.appendChild(tagHTMLElement);
}
}
}
});
initTagInputs(groupedItemRootSelector);
}
}
}
}

View File

@@ -0,0 +1,37 @@
/**
* For the full copyright and license information, please view the
* docs/licenses/LICENSE.txt file that was distributed with this source code.
*/
import {AutoCompleteSearchConfig} from '@components/auto-complete-search';
import PerfectScrollbar from '@node_modules/perfect-scrollbar';
// @ts-ignore
import Bloodhound from 'typeahead.js';
interface GroupedItemCollectionModalStates {
itemGroups: Array<ItemGroup>,
isModalShown: boolean,
loading: boolean,
}
export interface GroupedItemsSelectorStates {
dataSetConfig: AutoCompleteSearchConfig | Record<string, any>;
searchSource: Bloodhound | null;
scrollbar: PerfectScrollbar | null;
searchableItems: Item[];
}
export interface ItemGroup {
id: number;
name: string;
items: Array<Item>;
}
export interface Item {
id: number;
name: string;
selected: boolean;
groupId: number;
groupName: string;
color: string|null;
texture: string|null;
}