typo3-typescript-sources 14.3.6 → 14.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.last-synced-tag +1 -1
- package/README-TYPO3.md +1 -1
- package/README.md +1 -1
- package/backend/element/contextual-record-edit-trigger.ts +9 -1
- package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
- package/backend/form-engine/element/select-tree.ts +16 -2
- package/backend/form-engine/field-control/add-record.ts +27 -1
- package/backend/form-engine.ts +80 -1
- package/backend/localization/localization-wizard.ts +3 -0
- package/backend/localization/steps/handler-selection-step.ts +12 -17
- package/backend/localization/steps/mode-step.ts +9 -14
- package/backend/localization/steps/source-language-step.ts +8 -13
- package/backend/localization/steps/target-language-step.ts +12 -17
- package/backend/modal.ts +124 -1
- package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
- package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
- package/backend/wizard/steps/finisher-step.ts +2 -2
- package/backend/wizard/wizard.ts +11 -4
- package/core/ajax/ajax-request.ts +18 -0
- package/core/tests/ajax/ajax-request-test.ts +1 -1
- package/core/tests/utility/text-normalizer-test.ts +138 -0
- package/core/utility/text-normalizer.ts +98 -0
- package/form/backend/form-editor/view-model.ts +1 -1
- package/form/backend/form-wizard/steps/settings-step.ts +6 -3
- package/package.json +2 -2
- package/workspaces/backend.ts +7 -2
- package/workspaces/renderable/comment-view.ts +4 -2
- package/workspaces/renderable/record-table.ts +41 -4
- package/workspaces/workspaces.ts +65 -12
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the TYPO3 CMS project.
|
|
3
|
+
*
|
|
4
|
+
* It is free software; you can redistribute it and/or modify it under
|
|
5
|
+
* the terms of the GNU General Public License, either version 2
|
|
6
|
+
* of the License, or any later version.
|
|
7
|
+
*
|
|
8
|
+
* For the full copyright and license information, please read the
|
|
9
|
+
* LICENSE.txt file that was distributed with this source code.
|
|
10
|
+
*
|
|
11
|
+
* The TYPO3 project - inspiring people to share!
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import SelectBoxFilter from '@typo3/backend/form-engine/element/extra/select-box-filter.js';
|
|
15
|
+
import { expect } from '@open-wc/testing';
|
|
16
|
+
import type { } from 'mocha';
|
|
17
|
+
|
|
18
|
+
describe('@typo3/backend/form-engine/element/extra/select-box-filter', () => {
|
|
19
|
+
let root: HTMLElement;
|
|
20
|
+
let filterField: HTMLInputElement | HTMLSelectElement;
|
|
21
|
+
|
|
22
|
+
const optionLabels: string[] = ['Crème brûlée', 'Müller', 'Sirup (Vanille)', 'Cremeschnitte', 'Tiramisu'];
|
|
23
|
+
|
|
24
|
+
const options = (labels: string[]): string => labels.map((label: string): string => `<option>${label}</option>`).join('');
|
|
25
|
+
|
|
26
|
+
const mount = (selectContent: string, filterMarkup: string = '<input type="text" class="t3js-formengine-multiselect-filter-textfield">'): void => {
|
|
27
|
+
root = document.createElement('div');
|
|
28
|
+
root.innerHTML = `
|
|
29
|
+
<div class="form-wizards-wrap">
|
|
30
|
+
${filterMarkup}
|
|
31
|
+
<select multiple>${selectContent}</select>
|
|
32
|
+
</div>`;
|
|
33
|
+
document.body.appendChild(root);
|
|
34
|
+
new SelectBoxFilter(root.querySelector('select[multiple]'));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
afterEach((): void => {
|
|
38
|
+
root.remove();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const visibleOptions = (): string[] => Array
|
|
42
|
+
.from(root.querySelectorAll('select[multiple] option'))
|
|
43
|
+
.filter((option: HTMLOptionElement): boolean => !option.hidden)
|
|
44
|
+
.map((option: HTMLOptionElement): string => option.textContent);
|
|
45
|
+
|
|
46
|
+
const filterBy = (value: string): void => {
|
|
47
|
+
filterField.value = value;
|
|
48
|
+
filterField.dispatchEvent(new Event('input', { bubbles: true }));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('text field', () => {
|
|
52
|
+
beforeEach((): void => {
|
|
53
|
+
mount(options(optionLabels));
|
|
54
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('shows all options for an empty filter', (): void => {
|
|
58
|
+
filterBy('');
|
|
59
|
+
expect(visibleOptions()).to.eql(optionLabels);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('matches case insensitively', (): void => {
|
|
63
|
+
filterBy('MÜLLER');
|
|
64
|
+
expect(visibleOptions()).to.eql(['Müller']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('matches a plain substring anywhere in the label', (): void => {
|
|
68
|
+
filterBy('schnitte');
|
|
69
|
+
expect(visibleOptions()).to.eql(['Cremeschnitte']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('matches labels with diacritics when typing without them', (): void => {
|
|
73
|
+
filterBy('creme');
|
|
74
|
+
expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('matches labels without diacritics when typing with them', (): void => {
|
|
78
|
+
filterBy('crèm');
|
|
79
|
+
expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('treats regular expression meta characters as literal text', (): void => {
|
|
83
|
+
filterBy('(Vanille)');
|
|
84
|
+
expect(visibleOptions()).to.eql(['Sirup (Vanille)']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('hides everything when nothing matches', (): void => {
|
|
88
|
+
filterBy('.*');
|
|
89
|
+
expect(visibleOptions()).to.eql([]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('Latin letters that do not decompose', () => {
|
|
94
|
+
const labels: string[] = ['Straße', 'Müller', 'Ærø', 'Łódź'];
|
|
95
|
+
|
|
96
|
+
beforeEach((): void => {
|
|
97
|
+
mount(options(labels));
|
|
98
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('finds the sharp s when it is typed as a double s', (): void => {
|
|
102
|
+
filterBy('strasse');
|
|
103
|
+
expect(visibleOptions()).to.eql(['Straße']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('finds the sharp s when it is typed as written', (): void => {
|
|
107
|
+
filterBy('straße');
|
|
108
|
+
expect(visibleOptions()).to.eql(['Straße']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('finds the slashed o and the ligature typed as ASCII', (): void => {
|
|
112
|
+
filterBy('aero');
|
|
113
|
+
expect(visibleOptions()).to.eql(['Ærø']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('finds the stroked l typed as a plain l', (): void => {
|
|
117
|
+
filterBy('lodz');
|
|
118
|
+
expect(visibleOptions()).to.eql(['Łódź']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('does not resolve an umlaut to its transcription', (): void => {
|
|
122
|
+
filterBy('mueller');
|
|
123
|
+
expect(visibleOptions()).to.eql([]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('characters the editor cannot see in the label', () => {
|
|
128
|
+
const nbsp = 'Cr\u00e8me\u00a0br\u00fbl\u00e9e';
|
|
129
|
+
const softHyphen = 'Do\u00adnau';
|
|
130
|
+
const zeroWidth = 'Ti\u200bramisu';
|
|
131
|
+
const labels: string[] = [nbsp, softHyphen, zeroWidth];
|
|
132
|
+
|
|
133
|
+
beforeEach((): void => {
|
|
134
|
+
mount(options(labels));
|
|
135
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('matches across a no-break space when a plain space is typed', (): void => {
|
|
139
|
+
filterBy('creme brulee');
|
|
140
|
+
expect(visibleOptions()).to.eql([nbsp]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('matches across a soft hyphen', (): void => {
|
|
144
|
+
filterBy('donau');
|
|
145
|
+
expect(visibleOptions()).to.eql([softHyphen]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('matches across a zero width space', (): void => {
|
|
149
|
+
filterBy('tiramisu');
|
|
150
|
+
expect(visibleOptions()).to.eql([zeroWidth]);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('scripts whose combining marks carry meaning', () => {
|
|
155
|
+
// Thai vowel signs are nonspacing marks, but they distinguish syllables rather
|
|
156
|
+
// than accent them. Removing them would fold these three labels into one and
|
|
157
|
+
// the filter would stop narrowing anything for Thai.
|
|
158
|
+
const labels: string[] = ['กิน', 'กีฬา', 'กุญแจ'];
|
|
159
|
+
|
|
160
|
+
beforeEach((): void => {
|
|
161
|
+
mount(options(labels));
|
|
162
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('keeps Thai vowel signs, so the filter still narrows', (): void => {
|
|
166
|
+
filterBy('กิ');
|
|
167
|
+
expect(visibleOptions()).to.eql(['กิน']);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('predefined filter items', () => {
|
|
172
|
+
beforeEach((): void => {
|
|
173
|
+
mount(
|
|
174
|
+
options(optionLabels),
|
|
175
|
+
`<select class="t3js-formengine-multiselect-filter-dropdown">
|
|
176
|
+
<option value=""></option>
|
|
177
|
+
<option value="creme">creme</option>
|
|
178
|
+
</select>`
|
|
179
|
+
);
|
|
180
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-dropdown');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('applies a predefined filter value on change', (): void => {
|
|
184
|
+
filterField.value = 'creme';
|
|
185
|
+
filterField.dispatchEvent(new Event('change', { bubbles: true }));
|
|
186
|
+
expect(visibleOptions()).to.eql(['Crème brûlée', 'Cremeschnitte']);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('option groups', () => {
|
|
191
|
+
beforeEach((): void => {
|
|
192
|
+
mount(`
|
|
193
|
+
<optgroup label="Desserts">${options(['Crème brûlée', 'Tiramisu'])}</optgroup>
|
|
194
|
+
<optgroup label="Namen">${options(['Müller'])}</optgroup>`);
|
|
195
|
+
filterField = root.querySelector('.t3js-formengine-multiselect-filter-textfield');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const visibleGroups = (): string[] => Array
|
|
199
|
+
.from(root.querySelectorAll('optgroup'))
|
|
200
|
+
.filter((group: HTMLOptGroupElement): boolean => !group.hidden)
|
|
201
|
+
.map((group: HTMLOptGroupElement): string => group.label);
|
|
202
|
+
|
|
203
|
+
it('hides a group once all of its options are filtered away', (): void => {
|
|
204
|
+
filterBy('creme');
|
|
205
|
+
expect(visibleOptions()).to.eql(['Crème brûlée']);
|
|
206
|
+
expect(visibleGroups()).to.eql(['Desserts']);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('shows every group again for an empty filter', (): void => {
|
|
210
|
+
filterBy('creme');
|
|
211
|
+
filterBy('');
|
|
212
|
+
expect(visibleGroups()).to.eql(['Desserts', 'Namen']);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the TYPO3 CMS project.
|
|
3
|
+
*
|
|
4
|
+
* It is free software; you can redistribute it and/or modify it under
|
|
5
|
+
* the terms of the GNU General Public License, either version 2
|
|
6
|
+
* of the License, or any later version.
|
|
7
|
+
*
|
|
8
|
+
* For the full copyright and license information, please read the
|
|
9
|
+
* LICENSE.txt file that was distributed with this source code.
|
|
10
|
+
*
|
|
11
|
+
* The TYPO3 project - inspiring people to share!
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Imported for its side effect, the module registers the custom element used below
|
|
15
|
+
import '@typo3/backend/form-engine/element/select-tree.js';
|
|
16
|
+
import type { SelectTree } from '@typo3/backend/form-engine/element/select-tree.js';
|
|
17
|
+
import type { TreeNodeInterface } from '@typo3/backend/tree/tree-node.js';
|
|
18
|
+
import { expect } from '@open-wc/testing';
|
|
19
|
+
import type { } from 'mocha';
|
|
20
|
+
|
|
21
|
+
describe('@typo3/backend/form-engine/element/select-tree', () => {
|
|
22
|
+
let tree: SelectTree;
|
|
23
|
+
|
|
24
|
+
const createNode = (identifier: string, name: string, parents: string[]): TreeNodeInterface => {
|
|
25
|
+
return <TreeNodeInterface><unknown>{
|
|
26
|
+
identifier,
|
|
27
|
+
name,
|
|
28
|
+
parents,
|
|
29
|
+
depth: parents.length,
|
|
30
|
+
__parents: parents,
|
|
31
|
+
__treeIdentifier: identifier,
|
|
32
|
+
__processed: true,
|
|
33
|
+
__hidden: false,
|
|
34
|
+
__expanded: false,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
beforeEach((): void => {
|
|
39
|
+
tree = <SelectTree>document.createElement('typo3-backend-form-selecttree');
|
|
40
|
+
tree.nodes = [
|
|
41
|
+
createNode('root', 'Root', []),
|
|
42
|
+
createNode('vanilla', 'Vanille', ['root']),
|
|
43
|
+
createNode('syrup', 'Sirup (Vanille)', ['root']),
|
|
44
|
+
createNode('cocoa', 'Kakao', ['root', 'syrup']),
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const visibleNodes = (): string[] => tree.nodes
|
|
49
|
+
.filter((node: TreeNodeInterface): boolean => node.identifier !== 'root' && !node.__hidden)
|
|
50
|
+
.map((node: TreeNodeInterface): string => node.name);
|
|
51
|
+
|
|
52
|
+
it('matches case insensitively', (): void => {
|
|
53
|
+
tree.filter('VANILLE');
|
|
54
|
+
expect(visibleNodes()).to.eql(['Vanille', 'Sirup (Vanille)', 'Kakao']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('treats regular expression meta characters as literal text', (): void => {
|
|
58
|
+
tree.filter('(Vanille)');
|
|
59
|
+
expect(visibleNodes()).to.eql(['Sirup (Vanille)', 'Kakao']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('does not throw on an unbalanced meta character', (): void => {
|
|
63
|
+
expect((): void => tree.filter('(')).to.not.throw();
|
|
64
|
+
expect(visibleNodes()).to.eql(['Sirup (Vanille)', 'Kakao']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('shows all nodes for an empty search term', (): void => {
|
|
68
|
+
tree.filter('');
|
|
69
|
+
expect(visibleNodes()).to.eql(['Vanille', 'Sirup (Vanille)', 'Kakao']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('hides everything when nothing matches', (): void => {
|
|
73
|
+
tree.filter('Erdbeere');
|
|
74
|
+
expect(visibleNodes()).to.eql([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('folding of the search term and the node names', () => {
|
|
78
|
+
beforeEach((): void => {
|
|
79
|
+
tree.nodes = [
|
|
80
|
+
createNode('root', 'Root', []),
|
|
81
|
+
createNode('creme', 'Crème brûlée', ['root']),
|
|
82
|
+
createNode('cremeschnitte', 'Cremeschnitte', ['root']),
|
|
83
|
+
createNode('strasse', 'Straße', ['root']),
|
|
84
|
+
];
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('matches a name with diacritics when they are not typed', (): void => {
|
|
88
|
+
tree.filter('creme');
|
|
89
|
+
expect(visibleNodes()).to.eql(['Crème brûlée', 'Cremeschnitte']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('matches a name without diacritics when they are typed', (): void => {
|
|
93
|
+
tree.filter('crèm');
|
|
94
|
+
expect(visibleNodes()).to.eql(['Crème brûlée', 'Cremeschnitte']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('matches a Latin letter that does not decompose by its ASCII spelling', (): void => {
|
|
98
|
+
tree.filter('strasse');
|
|
99
|
+
expect(visibleNodes()).to.eql(['Straße']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('ignores a character the reader cannot see in the name', (): void => {
|
|
103
|
+
tree.nodes = [
|
|
104
|
+
createNode('root', 'Root', []),
|
|
105
|
+
createNode('creme', 'Creme\u00adschnitte', ['root']),
|
|
106
|
+
];
|
|
107
|
+
tree.filter('cremeschnitte');
|
|
108
|
+
expect(visibleNodes()).to.eql(['Creme\u00adschnitte']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('shows the parents of a node matched without its diacritics', (): void => {
|
|
112
|
+
tree.nodes = [
|
|
113
|
+
createNode('root', 'Root', []),
|
|
114
|
+
createNode('sweets', 'Süßwaren', ['root']),
|
|
115
|
+
createNode('creme', 'Crème brûlée', ['root', 'sweets']),
|
|
116
|
+
];
|
|
117
|
+
tree.filter('creme');
|
|
118
|
+
expect(visibleNodes()).to.eql(['Süßwaren', 'Crème brûlée']);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -33,9 +33,9 @@ export class FinisherStep implements WizardStepInterface {
|
|
|
33
33
|
readonly autoAdvance = false;
|
|
34
34
|
|
|
35
35
|
public resetButtonTitle: string = null;
|
|
36
|
+
public hasError = false;
|
|
36
37
|
|
|
37
38
|
private finisherInstance: FinisherInterface | null = null;
|
|
38
|
-
private hasError = false;
|
|
39
39
|
private readonly task: Task<[SubmissionServiceInterface], FinisherResult>;
|
|
40
40
|
|
|
41
41
|
constructor(private readonly wizard: Wizard, private readonly finisher: SubmissionServiceInterface) {
|
|
@@ -55,7 +55,7 @@ export class FinisherStep implements WizardStepInterface {
|
|
|
55
55
|
public isComplete(): boolean {
|
|
56
56
|
// Finisher step is complete when the task has finished (success or error)
|
|
57
57
|
// This allows the user to close the wizard even when there's an error
|
|
58
|
-
return this.task.status === TaskStatus.COMPLETE;
|
|
58
|
+
return this.task.status === TaskStatus.COMPLETE || this.task.status === TaskStatus.ERROR;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
public async beforeAdvance(): Promise<void> {
|
package/backend/wizard/wizard.ts
CHANGED
|
@@ -332,13 +332,20 @@ export class Wizard extends LitElement {
|
|
|
332
332
|
private renderNextButtons(): TemplateResult {
|
|
333
333
|
const isComplete = this.currentStep?.isComplete();
|
|
334
334
|
let buttonLabel: string;
|
|
335
|
+
let buttonIcon: string = 'actions-arrow-end-alt';
|
|
335
336
|
let resetButtonHtml: TemplateResult | typeof nothing = nothing;
|
|
336
337
|
|
|
337
338
|
if (this.currentStep?.key === 'finisher') {
|
|
338
|
-
buttonLabel = wizardLabels.get('wizard.buttons.finish');
|
|
339
|
-
|
|
340
339
|
const finisherStep: FinisherStep = this.currentStep as FinisherStep;
|
|
341
|
-
if (finisherStep
|
|
340
|
+
if (finisherStep.hasError) {
|
|
341
|
+
// On failure the primary action only closes the wizard
|
|
342
|
+
buttonLabel = wizardLabels.get('wizard.buttons.close');
|
|
343
|
+
buttonIcon = 'actions-close';
|
|
344
|
+
} else {
|
|
345
|
+
buttonLabel = wizardLabels.get('wizard.buttons.finish');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (!finisherStep.hasError && finisherStep?.resetButtonTitle) {
|
|
342
349
|
resetButtonHtml = html`
|
|
343
350
|
<button type="button" class="${classMap({ 'btn': true, 'btn-secondary': true })}"
|
|
344
351
|
@click="${this.handleRestart}"
|
|
@@ -361,7 +368,7 @@ export class Wizard extends LitElement {
|
|
|
361
368
|
@click="${this.handleNext}"
|
|
362
369
|
>
|
|
363
370
|
${buttonLabel}
|
|
364
|
-
<typo3-backend-icon identifier="
|
|
371
|
+
<typo3-backend-icon identifier="${buttonIcon}" bidi size="small"></typo3-backend-icon>
|
|
365
372
|
</button>
|
|
366
373
|
</div>
|
|
367
374
|
`;
|
|
@@ -117,6 +117,24 @@ class AjaxRequest {
|
|
|
117
117
|
return new AjaxResponse(response);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Executes a (by default uncached) PATCH request
|
|
122
|
+
*
|
|
123
|
+
* @param {string | FormData | GenericKeyValue} data
|
|
124
|
+
* @param {RequestInit} init
|
|
125
|
+
* @return {Promise<Response>}
|
|
126
|
+
*/
|
|
127
|
+
public async patch(data: string | FormData | GenericKeyValue, init: RequestInit = {}): Promise<AjaxResponse> {
|
|
128
|
+
const localDefaultOptions: RequestInit = {
|
|
129
|
+
body: typeof data === 'string' || data instanceof FormData ? data : InputTransformer.byHeader(data, init?.headers),
|
|
130
|
+
cache: 'no-cache',
|
|
131
|
+
method: 'PATCH',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const response = await this.send({ ...localDefaultOptions, ...init });
|
|
135
|
+
return new AjaxResponse(response);
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
/**
|
|
121
139
|
* Executes a regular DELETE request
|
|
122
140
|
*
|
|
@@ -50,7 +50,7 @@ describe('@typo3/core/ajax/ajax-request', (): void => {
|
|
|
50
50
|
expect(fetchStub).calledWithMatch(new Request('https://example.com/', { method: 'POST', body: '' }));
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
for (const requestMethod of ['POST', 'PUT', 'DELETE']) {
|
|
53
|
+
for (const requestMethod of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
|
54
54
|
describe(`send a ${requestMethod} request`, (): void => {
|
|
55
55
|
function* requestDataProvider(): any {
|
|
56
56
|
yield [
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the TYPO3 CMS project.
|
|
3
|
+
*
|
|
4
|
+
* It is free software; you can redistribute it and/or modify it under
|
|
5
|
+
* the terms of the GNU General Public License, either version 2
|
|
6
|
+
* of the License, or any later version.
|
|
7
|
+
*
|
|
8
|
+
* For the full copyright and license information, please read the
|
|
9
|
+
* LICENSE.txt file that was distributed with this source code.
|
|
10
|
+
*
|
|
11
|
+
* The TYPO3 project - inspiring people to share!
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import TextNormalizer from '@typo3/core/utility/text-normalizer.js';
|
|
15
|
+
import { expect } from '@open-wc/testing';
|
|
16
|
+
import type { } from 'mocha';
|
|
17
|
+
|
|
18
|
+
describe('@typo3/core/utility/text-normalizer', () => {
|
|
19
|
+
describe('foldCaseAndDiacritics', () => {
|
|
20
|
+
it('returns an empty string unchanged', (): void => {
|
|
21
|
+
expect(TextNormalizer.foldCaseAndDiacritics('')).to.equal('');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('lowercases the value', (): void => {
|
|
25
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Cremeschnitte')).to.equal('cremeschnitte');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('removes Latin diacritics', (): void => {
|
|
29
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Crème brûlée')).to.equal('creme brulee');
|
|
30
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.equal('muller');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('removes Greek and Cyrillic diacritics', (): void => {
|
|
34
|
+
expect(TextNormalizer.foldCaseAndDiacritics('ΆΘΗΝΑ')).to.equal('αθηνα');
|
|
35
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ёлка')).to.equal('елка');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('removes every Vietnamese tone mark', (): void => {
|
|
39
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Tiếng Việt')).to.equal('tieng viet');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('folds a value that is already decomposed to the same result', (): void => {
|
|
43
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Crème'.normalize('NFD'))).to.equal(TextNormalizer.foldCaseAndDiacritics('Crème'.normalize('NFC')));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// A locale aware mapping folds this to a dotless "ı" on a Turkish browser.
|
|
47
|
+
it('folds the capital i to a plain lowercase i', (): void => {
|
|
48
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Import')).to.equal('import');
|
|
49
|
+
expect(TextNormalizer.foldCaseAndDiacritics('İstanbul')).to.equal('istanbul');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('spells out the Latin letters that do not decompose', (): void => {
|
|
53
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Straße')).to.equal('strasse');
|
|
54
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ærø')).to.equal('aero');
|
|
55
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Łódź')).to.equal('lodz');
|
|
56
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Þórður')).to.equal('thordur');
|
|
57
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Đà Nẵng')).to.equal('da nang');
|
|
58
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Ħamrun')).to.equal('hamrun');
|
|
59
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Cœur')).to.equal('coeur');
|
|
60
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Kız')).to.equal('kiz');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('folds an alphabet consistently rather than half of it', (): void => {
|
|
64
|
+
// Danish: "å" decomposes and "æ"/"ø" do not, so without the expansions one
|
|
65
|
+
// half of the alphabet would fold and the other half would not.
|
|
66
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Åse')).to.equal('ase');
|
|
67
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Æble')).to.equal('aeble');
|
|
68
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Øst')).to.equal('ost');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('leaves the language dependent transcriptions alone', (): void => {
|
|
72
|
+
// "ü" decomposes to "u", so the German transcription to "ue" is not applied
|
|
73
|
+
// on top of it -- a value cannot fold to both.
|
|
74
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.equal('muller');
|
|
75
|
+
expect(TextNormalizer.foldCaseAndDiacritics('Müller')).to.not.equal('mueller');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('removes the Hebrew points', (): void => {
|
|
79
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u05e9\u05c1\u05b8\u05dc\u05d5\u05b9\u05dd'))
|
|
80
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u05e9\u05dc\u05d5\u05dd'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('removes the Arabic harakat', (): void => {
|
|
84
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u0645\u064e\u062f\u0652\u0631\u064e\u0633\u064e\u0629'))
|
|
85
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u0645\u062f\u0631\u0633\u0629'));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('folds the Arabic alef variants onto the plain alef', (): void => {
|
|
89
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u0623\u062d\u0645\u062f'))
|
|
90
|
+
.to.equal(TextNormalizer.foldCaseAndDiacritics('\u0627\u062d\u0645\u062f'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps the marks a script writes obligatorily', (): void => {
|
|
94
|
+
// Thai vowel signs, Devanagari matras and the Japanese dakuten separate
|
|
95
|
+
// words rather than accent them, so folding must leave them alone.
|
|
96
|
+
const distinct = (values: string[]): number =>
|
|
97
|
+
new Set(values.map((value: string): string => TextNormalizer.foldCaseAndDiacritics(value))).size;
|
|
98
|
+
expect(distinct(['\u0e01\u0e34', '\u0e01\u0e35', '\u0e01\u0e38'])).to.equal(3);
|
|
99
|
+
expect(distinct(['\u0915\u093f', '\u0915\u0941', '\u0915\u0947'])).to.equal(3);
|
|
100
|
+
expect(distinct(['\u304b', '\u304c', '\u3071', '\u3070'])).to.equal(4);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('leaves Chinese untouched and unifies the compatibility ideographs', (): void => {
|
|
104
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\u4e2d\u6587')).to.equal('\u4e2d\u6587');
|
|
105
|
+
expect(TextNormalizer.foldCaseAndDiacritics('\uf900')).to.equal('\u8c48');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('normalizeInvisibleCharacters', () => {
|
|
110
|
+
it('returns an empty string unchanged', (): void => {
|
|
111
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('')).to.equal('');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('drops a soft hyphen', (): void => {
|
|
115
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Do\u00adnau')).to.equal('Donau');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('drops the zero width characters', (): void => {
|
|
119
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('a\u200bb\u200cc\u200dd\u2060e\ufeff')).to.equal('abcde');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('turns a no-break space into a plain space', (): void => {
|
|
123
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cr\u00e8me\u00a0br\u00fbl\u00e9e')).to.equal('Cr\u00e8me br\u00fbl\u00e9e');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('turns the other space variants into a plain space', (): void => {
|
|
127
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('a\u2009b\u202fc\u3000d')).to.equal('a b c d');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('leaves an ordinary label untouched', (): void => {
|
|
131
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cremeschnitte')).to.equal('Cremeschnitte');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('leaves casing and diacritics to the other function', (): void => {
|
|
135
|
+
expect(TextNormalizer.normalizeInvisibleCharacters('Cr\u00e8me Br\u00fbl\u00e9e')).to.equal('Cr\u00e8me Br\u00fbl\u00e9e');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of the TYPO3 CMS project.
|
|
3
|
+
*
|
|
4
|
+
* It is free software; you can redistribute it and/or modify it under
|
|
5
|
+
* the terms of the GNU General Public License, either version 2
|
|
6
|
+
* of the License, or any later version.
|
|
7
|
+
*
|
|
8
|
+
* For the full copyright and license information, please read the
|
|
9
|
+
* LICENSE.txt file that was distributed with this source code.
|
|
10
|
+
*
|
|
11
|
+
* The TYPO3 project - inspiring people to share!
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Module: @typo3/core/utility/text-normalizer
|
|
16
|
+
*
|
|
17
|
+
* Intl.Collator is deliberately not used: it compares whole strings rather than
|
|
18
|
+
* searching inside one, its result depends on the locale it is given, and at
|
|
19
|
+
* sensitivity "base" it merges the Japanese for "school" and for "cuckoo".
|
|
20
|
+
*
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
class TextNormalizer {
|
|
24
|
+
// The marks a script writes optionally, one constant per script so that each
|
|
25
|
+
// range can be read and checked against Unicode on its own. The marks Thai,
|
|
26
|
+
// Devanagari, Khmer, Lao and Japanese write obligatorily are deliberately
|
|
27
|
+
// absent: there a mark separates one word from another rather than accenting
|
|
28
|
+
// it, so removing it would fold the Japanese for "school" and for "cuckoo"
|
|
29
|
+
// into one value and leave a Thai filter narrowing nothing.
|
|
30
|
+
|
|
31
|
+
/** Combining diacritical marks, written by Latin, Greek and Cyrillic. */
|
|
32
|
+
private static readonly latinGreekCyrillicMarks = /[\u0300-\u036f]/g;
|
|
33
|
+
|
|
34
|
+
/** Hebrew points and cantillation. The gaps are punctuation, not marks. */
|
|
35
|
+
private static readonly hebrewPoints = /[\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7]/g;
|
|
36
|
+
|
|
37
|
+
/** Arabic harakat, hamza and madda, plus the superscript alef. */
|
|
38
|
+
private static readonly arabicHarakat = /[\u064b-\u065f\u0670]/g;
|
|
39
|
+
|
|
40
|
+
// Latin letters Unicode does not decompose, so stripping marks never reaches
|
|
41
|
+
// them. Without these an alphabet folds inconsistently: Danish "å" becomes "a"
|
|
42
|
+
// because it decomposes, while "æ" and "ø" would stay as they are.
|
|
43
|
+
//
|
|
44
|
+
// Only letters whose ASCII spelling is conventional rather than a matter of
|
|
45
|
+
// language are in here. The transcriptions that are a matter of language --
|
|
46
|
+
// German "ü" to "ue" rather than to "u" -- are not, because the decomposition
|
|
47
|
+
// already settles those.
|
|
48
|
+
|
|
49
|
+
/** Their conventional ASCII spelling, applied after the marks are stripped. */
|
|
50
|
+
private static readonly latinExpansions = new Map([
|
|
51
|
+
['ß', 'ss'], ['æ', 'ae'], ['œ', 'oe'], ['þ', 'th'],
|
|
52
|
+
['ø', 'o'], ['đ', 'd'], ['ð', 'd'], ['ł', 'l'],
|
|
53
|
+
['ħ', 'h'], ['ı', 'i'], ['ĸ', 'k'], ['ŋ', 'n'],
|
|
54
|
+
['ŧ', 't'],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** Built from the map above so the letters are listed in one place only. */
|
|
58
|
+
private static readonly latinExpansionPattern =
|
|
59
|
+
new RegExp('[' + [...TextNormalizer.latinExpansions.keys()].join('') + ']', 'g');
|
|
60
|
+
|
|
61
|
+
/** Characters that take no width, so a reader cannot tell they are there. */
|
|
62
|
+
private static readonly zeroWidthCharacters = /[\u00ad\u200b-\u200d\u2060\ufeff]/g;
|
|
63
|
+
|
|
64
|
+
/** The space separators, all compared as a plain U+0020. */
|
|
65
|
+
private static readonly spaceVariants = /[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]/g;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lowercase, strip the optional marks and spell out the Latin letters that do
|
|
69
|
+
* not decompose, so that a filter matches "Crème" when "creme" is typed,
|
|
70
|
+
* "Straße" when "strasse" is typed, and an Arabic label carrying its harakat
|
|
71
|
+
* when it is typed without them.
|
|
72
|
+
*
|
|
73
|
+
* toLocaleLowerCase() is avoided: it follows the browser UI locale, where "I"
|
|
74
|
+
* folds to a dotless "ı".
|
|
75
|
+
*/
|
|
76
|
+
public static foldCaseAndDiacritics(value: string): string {
|
|
77
|
+
return value
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.normalize('NFD')
|
|
80
|
+
.replace(TextNormalizer.latinGreekCyrillicMarks, '')
|
|
81
|
+
.replace(TextNormalizer.hebrewPoints, '')
|
|
82
|
+
.replace(TextNormalizer.arabicHarakat, '')
|
|
83
|
+
.replace(TextNormalizer.latinExpansionPattern, (letter: string): string => TextNormalizer.latinExpansions.get(letter));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop the characters a reader cannot see and unify the space variants, so
|
|
88
|
+
* that a label carrying a soft hyphen or a no-break space still matches what
|
|
89
|
+
* is typed. Both arrive in labels by pasting.
|
|
90
|
+
*/
|
|
91
|
+
public static normalizeInvisibleCharacters(value: string): string {
|
|
92
|
+
return value
|
|
93
|
+
.replace(TextNormalizer.zeroWidthCharacters, '')
|
|
94
|
+
.replace(TextNormalizer.spaceVariants, ' ');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default TextNormalizer;
|
|
@@ -802,7 +802,7 @@ export function addAbstractViewValidationResults(): void {
|
|
|
802
802
|
const validationElement = getStage().getAbstractViewFormElementDomElement(validationResults[i].formElementIdentifierPath);
|
|
803
803
|
|
|
804
804
|
// Set invalid property on Lit web component (FormElementStageItem)
|
|
805
|
-
const stageItem = validationElement
|
|
805
|
+
const stageItem = validationElement?.querySelector('typo3-form-form-element-stage-item') as HTMLElement | null;
|
|
806
806
|
if (stageItem && 'invalid' in stageItem) {
|
|
807
807
|
(stageItem as any).invalid = true;
|
|
808
808
|
}
|