-
Notifications
You must be signed in to change notification settings - Fork 638
/
slug-element.ts
243 lines (222 loc) · 9.71 KB
/
slug-element.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
import { AjaxResponse } from '@typo3/core/ajax/ajax-response';
import AjaxRequest from '@typo3/core/ajax/ajax-request';
import DocumentService from '@typo3/core/document-service';
import DebounceEvent from '@typo3/core/event/debounce-event';
import RegularEvent from '@typo3/core/event/regular-event';
interface FieldOptions {
pageId: number;
recordId: number;
tableName: string;
fieldName: string;
config: { [key: string]: any };
listenerFieldNames: Record<string, string>;
language: number;
originalValue: string;
signature: string;
command: string;
parentPageId: number;
includeUidInValues: boolean;
}
interface Response {
hasConflicts: boolean;
manual: string;
proposal: ProposalModes;
}
enum Selectors {
toggleButton = '.t3js-form-field-slug-toggle',
recreateButton = '.t3js-form-field-slug-recreate',
inputField = '.t3js-form-field-slug-input',
readOnlyField = '.t3js-form-field-slug-readonly',
hiddenField = '.t3js-form-field-slug-hidden',
}
enum ProposalModes {
AUTO = 'auto',
RECREATE = 'recreate',
MANUAL = 'manual',
}
/**
* Module: @typo3/backend/form-engine/element/slug-element
* Logic for a TCA type "slug"
*
* For new records, changes on the other fields of the record (typically the record title) are listened
* on as well and the response is put in as "placeholder" into the input field.
*
* For new and existing records, the toggle switch will allow editors to modify the slug
* - for new records, we only need to see if that is already in use or not (uniqueInSite), if it is taken, show a message.
* - for existing records, we also check for conflicts, and check if we have subpages, or if we want to add a redirect (todo)
*/
class SlugElement {
private readonly options: FieldOptions = null;
private fullElement: HTMLElement = null;
private manuallyChanged: boolean = false;
private readOnlyField: HTMLInputElement = null;
private inputField: HTMLInputElement = null;
private hiddenField: HTMLInputElement = null;
private request: AjaxRequest = null;
private readonly fieldsToListenOn: Record<string, string> = {};
constructor(selector: string, options: FieldOptions) {
this.options = options;
this.fieldsToListenOn = this.options.listenerFieldNames || {};
DocumentService.ready().then((document: Document): void => {
this.fullElement = document.querySelector(selector);
this.inputField = this.fullElement.querySelector(Selectors.inputField);
this.readOnlyField = this.fullElement.querySelector(Selectors.readOnlyField);
this.hiddenField = this.fullElement.querySelector(Selectors.hiddenField);
this.registerEvents();
});
}
private registerEvents(): void {
const fieldsToListenOnList = Object.values(this.getAvailableFieldsForProposalGeneration()).map((field: HTMLElement) => `[id="${field.id}"]`);
const recreateButton: HTMLButtonElement = this.fullElement.querySelector(Selectors.recreateButton);
// Listen on 'listenerFieldNames' for new pages. This is typically the 'title' field
// of a page to create slugs from the title when title is set / changed.
if (fieldsToListenOnList.length > 0) {
if (this.options.command === 'new') {
new DebounceEvent('input', (): void => {
if (!this.manuallyChanged) {
this.sendSlugProposal(ProposalModes.AUTO);
}
}).delegateTo(document, fieldsToListenOnList.join(','));
}
}
// Clicking the recreate button makes new slug proposal created from 'title' field or any defined postModifiers
if (fieldsToListenOnList.length > 0 || this.hasPostModifiersDefined()) {
new RegularEvent('click', (e: Event): void => {
e.preventDefault();
if (this.readOnlyField.classList.contains('hidden')) {
// Switch to readonly version - similar to 'new' page where field is
// written on the fly with title change
this.readOnlyField.classList.toggle('hidden', false);
this.inputField.classList.toggle('hidden', true);
}
this.sendSlugProposal(ProposalModes.RECREATE);
}).bindTo(recreateButton);
} else {
recreateButton.classList.add('disabled');
recreateButton.disabled = true;
}
// Scenario for new pages: Usually, slug is created from the page title. However, if user toggles the
// input field and feeds an own slug, and then changes title again, the slug should stay. manuallyChanged
// is used to track this.
new DebounceEvent('input', (): void => {
this.manuallyChanged = true;
this.sendSlugProposal(ProposalModes.MANUAL);
}).bindTo(this.inputField);
// Clicking the toggle button toggles the read only field and the input field.
// Also set the value of either the read only or the input field to the hidden field
// and update the value of the read only field after manual change of the input field.
const toggleButton = this.fullElement.querySelector(Selectors.toggleButton);
new RegularEvent('click', (e: Event): void => {
e.preventDefault();
const showReadOnlyField = this.readOnlyField.classList.contains('hidden');
this.readOnlyField.classList.toggle('hidden', !showReadOnlyField);
this.inputField.classList.toggle('hidden', showReadOnlyField);
if (!showReadOnlyField) {
this.hiddenField.value = this.inputField.value;
return;
}
if (this.inputField.value !== this.readOnlyField.value) {
this.readOnlyField.value = this.inputField.value;
} else {
this.manuallyChanged = false;
this.fullElement.querySelector('.t3js-form-proposal-accepted').classList.add('hidden');
this.fullElement.querySelector('.t3js-form-proposal-different').classList.add('hidden');
}
this.hiddenField.value = this.readOnlyField.value;
}).bindTo(toggleButton);
}
/**
* @param {ProposalModes} mode
*/
private sendSlugProposal(mode: ProposalModes): void {
const input: Record<string, string> = {};
if (mode === ProposalModes.AUTO || mode === ProposalModes.RECREATE) {
Object.entries(this.getAvailableFieldsForProposalGeneration()).forEach((entry: [fieldName: string, field: HTMLInputElement|HTMLSelectElement]) => {
input[entry[0]] = entry[1].value;
});
if (this.options.includeUidInValues === true) {
input.uid = this.options.recordId.toString();
}
} else {
input.manual = this.inputField.value;
}
if (this.request instanceof AjaxRequest) {
this.request.abort();
}
this.request = (new AjaxRequest(TYPO3.settings.ajaxUrls.record_slug_suggest));
this.request.post({
values: input,
mode: mode,
tableName: this.options.tableName,
pageId: this.options.pageId,
parentPageId: this.options.parentPageId,
recordId: this.options.recordId,
language: this.options.language,
fieldName: this.options.fieldName,
command: this.options.command,
signature: this.options.signature,
}).then(async (response: AjaxResponse): Promise<void> => {
const data: Response = await response.resolve();
const visualProposal = '/' + data.proposal.replace(/^\//, '');
const acceptedProposalField: HTMLElement = this.fullElement.querySelector('.t3js-form-proposal-accepted');
const differentProposalField: HTMLElement = this.fullElement.querySelector('.t3js-form-proposal-different');
acceptedProposalField.classList.toggle('hidden', data.hasConflicts);
differentProposalField.classList.toggle('hidden', !data.hasConflicts);
(data.hasConflicts ? differentProposalField : acceptedProposalField).querySelector('span').innerText = visualProposal;
const isChanged = this.hiddenField.value !== data.proposal;
if (isChanged) {
this.fullElement.querySelector('input[data-formengine-input-name]')
.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
}
if (mode === ProposalModes.AUTO || mode === ProposalModes.RECREATE) {
this.readOnlyField.value = data.proposal;
this.hiddenField.value = data.proposal;
this.inputField.value = data.proposal;
} else {
this.hiddenField.value = data.proposal;
}
}).finally((): void => {
this.request = null;
});
}
/**
* Gets a list of all available fields that can be used for slug generation
*
* @return Record<string, string>
*/
private getAvailableFieldsForProposalGeneration(): { [key: string]: HTMLElement } {
const availableFields: { [key: string]: HTMLElement } = {};
for (const [fieldName, selector] of Object.entries(this.fieldsToListenOn)) {
let field = document.querySelector('[data-formengine-input-name="' + selector + '"]') as HTMLElement;
if (field === null) {
// Also check for fields, which do not point to a hidden input field (e.g. "input type=hidden" or select fields)
field = document.querySelector('[name="' + selector + '"]') as HTMLElement;
}
if (field !== null) {
availableFields[fieldName] = field;
}
}
return availableFields;
}
/**
* Check whether the slug element has post modifiers defined for slug generation
*
* @return boolean
*/
private hasPostModifiersDefined(): boolean {
return Array.isArray(this.options.config.generatorOptions.postModifiers) && this.options.config.generatorOptions.postModifiers.length > 0;
}
}
export default SlugElement;