Skip to content

Commit 2dd04e5

Browse files
authored
Merge pull request PrestaShop#41407 from nicosomb/migrate-hook-module
Migrate the Hook a module feature
2 parents d04bb4f + 8f42a38 commit 2dd04e5

30 files changed

Lines changed: 1927 additions & 43 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* For the full copyright and license information, please view the
3+
* docs/licenses/LICENSE.txt file that was distributed with this source code.
4+
*/
5+
6+
/**
7+
* Handles bidirectional synchronisation between the "exceptions" text field
8+
* (comma-separated filenames) and the adjacent multi-select listing all
9+
* available front-controllers / module controllers, mirroring the legacy
10+
* AdminModulesPositionsController::displayModuleExceptionList behaviour.
11+
*/
12+
export default class ExceptionListHandler {
13+
private readonly textField: HTMLInputElement | null = null;
14+
15+
private readonly listField: HTMLSelectElement | null = null;
16+
17+
private readonly customGroup: HTMLOptGroupElement | null = null;
18+
19+
constructor() {
20+
const form = document.querySelector<HTMLFormElement>('[data-hook-url]');
21+
22+
if (!form) {
23+
return;
24+
}
25+
26+
this.textField = form.querySelector<HTMLInputElement>('input[name$="[exceptions]"]');
27+
this.listField = form.querySelector<HTMLSelectElement>('[data-exception-list="true"]');
28+
29+
if (!this.textField || !this.listField) {
30+
return;
31+
}
32+
33+
this.customGroup = this.listField.querySelector<HTMLOptGroupElement>('[data-exception-group="custom"]');
34+
35+
this.syncTextToList();
36+
this.textField.addEventListener('change', () => this.syncTextToList());
37+
this.listField.addEventListener('change', () => this.syncListToText());
38+
// Force a final sync at submit time — guarantees the text field carries the current
39+
// multi-select state even if the user clicks Save without ever firing a `change` event
40+
// on the list (e.g. browsers that defer change on <select multiple> until blur).
41+
form.addEventListener('submit', () => this.syncListToText());
42+
}
43+
44+
private parseTextValues(): string[] {
45+
if (!this.textField) {
46+
return [];
47+
}
48+
49+
return this.textField.value
50+
.split(',')
51+
.map((value) => value.trim())
52+
.filter((value) => value !== '');
53+
}
54+
55+
private knownValues(): Set<string> {
56+
if (!this.listField) {
57+
return new Set();
58+
}
59+
60+
const values = new Set<string>();
61+
this.listField
62+
.querySelectorAll<HTMLOptionElement>('optgroup:not([data-exception-group="custom"]) > option')
63+
.forEach((option) => values.add(option.value));
64+
65+
return values;
66+
}
67+
68+
private syncTextToList(): void {
69+
if (!this.listField) {
70+
return;
71+
}
72+
73+
const values = this.parseTextValues();
74+
const known = this.knownValues();
75+
76+
if (this.customGroup) {
77+
this.customGroup.innerHTML = '';
78+
values
79+
.filter((value) => !known.has(value))
80+
.forEach((value) => {
81+
const option = document.createElement('option');
82+
option.value = value;
83+
option.text = value;
84+
this.customGroup!.appendChild(option);
85+
});
86+
}
87+
88+
const selected = new Set(values);
89+
this.listField.querySelectorAll<HTMLOptionElement>('option').forEach((option) => {
90+
// eslint-disable-next-line no-param-reassign
91+
option.selected = selected.has(option.value);
92+
});
93+
}
94+
95+
private syncListToText(): void {
96+
if (!this.textField || !this.listField) {
97+
return;
98+
}
99+
100+
const selected: string[] = [];
101+
this.listField.querySelectorAll<HTMLOptionElement>('option:checked').forEach((option) => {
102+
if (option.value) {
103+
selected.push(option.value);
104+
}
105+
});
106+
107+
this.textField.value = selected.join(', ');
108+
}
109+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* For the full copyright and license information, please view the
3+
* docs/licenses/LICENSE.txt file that was distributed with this source code.
4+
*/
5+
6+
const {$} = window;
7+
8+
interface HookableInfo {
9+
id: number;
10+
name: string;
11+
title: string;
12+
registered: boolean;
13+
}
14+
15+
/**
16+
* Handles dynamic hook selector on the "Hook a module" form.
17+
* When the module select changes, fetches possible hooks via AJAX and
18+
* repopulates the hook dropdown.
19+
*/
20+
export default class HookModuleHandler {
21+
private readonly moduleSelector: HTMLSelectElement | null | undefined;
22+
23+
private readonly hookSelector: HTMLSelectElement | null | undefined;
24+
25+
private readonly hookUrl: string | null | undefined;
26+
27+
private readonly availableLabel: string | undefined;
28+
29+
private readonly registeredLabel: string | undefined;
30+
31+
// Shared select2 config so both selectors match the rest of the BO
32+
// (bootstrap4 theme) and always expose the search box, even for short lists.
33+
private readonly select2Options = {
34+
theme: 'bootstrap4',
35+
minimumResultsForSearch: 0,
36+
width: '100%',
37+
};
38+
39+
constructor() {
40+
const form = document.querySelector<HTMLFormElement>('[data-hook-url]');
41+
42+
if (!form) {
43+
return;
44+
}
45+
46+
this.hookUrl = form.dataset.hookUrl ?? null;
47+
this.availableLabel = form.dataset.labelAvailable ?? 'Available hooks';
48+
this.registeredLabel = form.dataset.labelRegistered ?? 'Already registered hooks';
49+
this.moduleSelector = form.querySelector<HTMLSelectElement>('[data-module-selector="true"]');
50+
this.hookSelector = form.querySelector<HTMLSelectElement>('[data-hook-selector="true"]');
51+
52+
if (!this.moduleSelector || !this.hookSelector || !this.hookUrl) {
53+
return;
54+
}
55+
56+
// Enhance both selects with select2 (search helper). The hook select keeps
57+
// its initial disabled state until a module is chosen.
58+
$(this.moduleSelector).select2(this.select2Options);
59+
this.refreshHookSelect2();
60+
61+
// select2 emits a jQuery "change" event, which a native addEventListener
62+
// does not reliably catch — bind through jQuery instead.
63+
$(this.moduleSelector).on('change', () => this.onModuleChange());
64+
}
65+
66+
private async onModuleChange(): Promise<void> {
67+
if (!this.moduleSelector || !this.hookSelector || !this.hookUrl) {
68+
return;
69+
}
70+
71+
const moduleId = Number(this.moduleSelector.value);
72+
73+
if (!moduleId) {
74+
this.clearHookSelector();
75+
76+
return;
77+
}
78+
79+
try {
80+
const response = await fetch(this.hookUrl, {
81+
method: 'POST',
82+
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
83+
body: new URLSearchParams({module_id: String(moduleId)}),
84+
});
85+
86+
const json = await response.json();
87+
88+
if (json.hasError || !Array.isArray(json.hooks)) {
89+
this.clearHookSelector();
90+
91+
return;
92+
}
93+
94+
this.populateHookSelector(json.hooks as HookableInfo[]);
95+
} catch {
96+
this.clearHookSelector();
97+
}
98+
}
99+
100+
private populateHookSelector(hooks: HookableInfo[]): void {
101+
if (!this.hookSelector) {
102+
return;
103+
}
104+
105+
const currentValue = this.hookSelector.value;
106+
this.clearHookSelector();
107+
108+
const available = hooks.filter((hook) => !hook.registered);
109+
const registered = hooks.filter((hook) => hook.registered);
110+
111+
const buildOption = (hook: HookableInfo): HTMLOptionElement => {
112+
const option = document.createElement('option');
113+
option.value = String(hook.id);
114+
option.text = hook.title ? `${hook.name} (${hook.title})` : hook.name;
115+
116+
if (String(hook.id) === currentValue) {
117+
option.selected = true;
118+
}
119+
120+
return option;
121+
};
122+
123+
if (available.length > 0) {
124+
const availableGroup = document.createElement('optgroup');
125+
126+
if (this.availableLabel != null) {
127+
availableGroup.label = this.availableLabel;
128+
}
129+
available.forEach((hook) => availableGroup.appendChild(buildOption(hook)));
130+
this.hookSelector.appendChild(availableGroup);
131+
}
132+
133+
if (registered.length > 0) {
134+
const registeredGroup = document.createElement('optgroup');
135+
136+
if (this.registeredLabel != null) {
137+
registeredGroup.label = this.registeredLabel;
138+
}
139+
registeredGroup.disabled = true;
140+
registered.forEach((hook) => registeredGroup.appendChild(buildOption(hook)));
141+
this.hookSelector.appendChild(registeredGroup);
142+
}
143+
144+
// Re-enable the selector now that choices are available.
145+
this.setHookSelectorEnabled(true);
146+
}
147+
148+
private clearHookSelector(): void {
149+
if (!this.hookSelector) {
150+
return;
151+
}
152+
153+
// Keep only the placeholder option (the first direct <option> child if any)
154+
const placeholder = this.hookSelector.querySelector<HTMLOptionElement>(':scope > option');
155+
this.hookSelector.innerHTML = '';
156+
if (placeholder) {
157+
this.hookSelector.appendChild(placeholder);
158+
}
159+
160+
// Without choices the selector is unusable — disable it.
161+
this.setHookSelectorEnabled(false);
162+
}
163+
164+
/**
165+
* Toggles the disabled state of the hook selector.
166+
* The Symfony form theme renders the field disabled by adding a `disabled`
167+
* CSS class on both the wrapping `.input-container` and the label. Toggling
168+
* the `disabled` property on the <select> alone leaves those classes behind,
169+
* which keeps the field visually greyed out — so they must be synced too.
170+
*/
171+
private setHookSelectorEnabled(enabled: boolean): void {
172+
if (!this.hookSelector) {
173+
return;
174+
}
175+
176+
this.hookSelector.disabled = !enabled;
177+
178+
const container = this.hookSelector.closest<HTMLElement>('.input-container');
179+
container?.classList.toggle('disabled', !enabled);
180+
181+
const formGroup = this.hookSelector.closest<HTMLElement>('.form-group');
182+
formGroup
183+
?.querySelector<HTMLElement>('.form-control-label')
184+
?.classList.toggle('disabled', !enabled);
185+
186+
// The options and the disabled state were changed programmatically: rebuild
187+
// select2 so the widget reflects the new choices and enabled/disabled state.
188+
this.refreshHookSelect2();
189+
}
190+
191+
/**
192+
* (Re)initializes select2 on the hook selector. select2 caches the original
193+
* options at init time, so after repopulating or toggling the field we must
194+
* destroy and recreate it for the widget to pick up the changes.
195+
*/
196+
private refreshHookSelect2(): void {
197+
if (!this.hookSelector) {
198+
return;
199+
}
200+
201+
const $hookSelector = $(this.hookSelector);
202+
203+
if ($hookSelector.hasClass('select2-hidden-accessible')) {
204+
$hookSelector.select2('destroy');
205+
}
206+
$hookSelector.select2(this.select2Options);
207+
}
208+
}

admin-dev/themes/new-theme/js/pages/improve/design_positions/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55

66
import PositionsListHandler from './positions-list-handler';
77
import HookStatusHandler from './hook-status-handler';
8+
import HookModuleHandler from './hook-module-handler';
9+
import ExceptionListHandler from './exception-list-handler';
810

911
const {$} = window;
1012

1113
$(() => {
1214
new PositionsListHandler();
1315
new HookStatusHandler();
16+
new HookModuleHandler();
17+
new ExceptionListHandler();
1418
});

admin-dev/themes/new-theme/js/pages/improve/design_positions/positions-list-handler.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,14 @@ class PositionsListHandler {
237237
const $moduleId = <string>self.$showModules.val();
238238
const $regex = new RegExp(`(${$hookName})`, 'gi');
239239

240-
// Update "Transplant module" button
241-
const transplantModuleHref = new URL(this.$transplantModuleButton.prop('href'));
242-
transplantModuleHref.searchParams.set('show_modules', $moduleId);
243-
this.$transplantModuleButton.attr('href', transplantModuleHref.toString());
240+
// Update "Transplant module" button (only present on the positions list page)
241+
const transplantModuleRawHref = this.$transplantModuleButton.prop('href');
242+
243+
if (transplantModuleRawHref) {
244+
const transplantModuleHref = new URL(transplantModuleRawHref);
245+
transplantModuleHref.searchParams.set('show_modules', $moduleId);
246+
this.$transplantModuleButton.attr('href', transplantModuleHref.toString());
247+
}
244248

245249
const isVisible: boolean = $hookName === '' && $moduleId === 'all';
246250

install-dev/data/xml/feature_flag.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@
3030
<feature_flag id="improved_b2b" name="improved_b2b" type="env,dotenv,db" label_wording="Improved B2B" label_domain="Admin.Advparameters.Feature" description_wording="Enable / Disable the improved B2B mode. To use the feature activate the B2B mode in General Settings." description_domain="Admin.Advparameters.Help" state="0" stability="beta" />
3131
<feature_flag id="new_pricing" name="new_pricing" type="env,query,dotenv,db" label_wording="New pricing" label_domain="Admin.Advparameters.Feature" description_wording="Enable / Disable the new pricing system. This feature introduces an improved pricing engine." description_domain="Admin.Advparameters.Help" state="0" stability="beta" />
3232
<feature_flag id="email_body_translation" name="email_body_translation" type="env,dotenv,db" label_wording="Email body translations" label_domain="Admin.Advparameters.Feature" description_wording="Enable / Disable the migrated email body translations page." description_domain="Admin.Advparameters.Help" state="0" stability="beta" />
33+
<feature_flag id="hook_module_v2" name="hook_module_v2" type="env,dotenv,db" label_wording="Hook a module" label_domain="Admin.Design.Feature" description_wording="Enable / Disable the migrated Hook a module form page." description_domain="Admin.Design.Help" state="0" stability="beta" />
3334
</entities>
3435
</entity_feature_flag>

0 commit comments

Comments
 (0)