typo3-typescript-sources 14.3.6 → 14.3.8

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.
Files changed (54) hide show
  1. package/.last-synced-tag +1 -1
  2. package/README-TYPO3.md +1 -1
  3. package/README.md +1 -1
  4. package/backend/column-selector-button.ts +5 -2
  5. package/backend/drag-uploader.ts +78 -86
  6. package/backend/element/contextual-record-edit-trigger.ts +9 -1
  7. package/backend/form-engine/element/extra/select-box-filter.ts +8 -5
  8. package/backend/form-engine/element/online-media-form-element.ts +3 -3
  9. package/backend/form-engine/element/select-tree.ts +16 -2
  10. package/backend/form-engine/field-control/add-record.ts +27 -1
  11. package/backend/form-engine.ts +88 -13
  12. package/backend/layout-module/column-restriction.ts +61 -0
  13. package/backend/layout-module/drag-drop.ts +12 -0
  14. package/backend/layout-module/paste.ts +7 -0
  15. package/backend/localization/localization-wizard.ts +3 -0
  16. package/backend/localization/steps/handler-selection-step.ts +12 -17
  17. package/backend/localization/steps/mode-step.ts +9 -14
  18. package/backend/localization/steps/source-language-step.ts +8 -13
  19. package/backend/localization/steps/target-language-step.ts +12 -17
  20. package/backend/login-refresh.ts +1 -9
  21. package/backend/modal.ts +162 -6
  22. package/backend/notification.ts +90 -6
  23. package/backend/online-media.ts +1 -3
  24. package/backend/record-download-button.ts +4 -5
  25. package/backend/recordlist.ts +5 -0
  26. package/backend/tests/fixtures/modal-autofocus.html +1 -0
  27. package/backend/tests/form-engine/element/extra/select-box-filter-test.ts +215 -0
  28. package/backend/tests/form-engine/element/select-tree-test.ts +121 -0
  29. package/backend/tests/modal-test.ts +91 -0
  30. package/backend/tests/notification-test.ts +176 -0
  31. package/backend/tree/page-tree-element.ts +8 -1
  32. package/backend/wizard/steps/finisher-step.ts +2 -2
  33. package/backend/wizard/wizard.ts +11 -4
  34. package/core/ajax/ajax-request.ts +18 -0
  35. package/core/tests/ajax/ajax-request-test.ts +1 -1
  36. package/core/tests/utility/text-normalizer-test.ts +138 -0
  37. package/core/utility/text-normalizer.ts +98 -0
  38. package/dashboard/dashboard.ts +3 -8
  39. package/filelist/file-list-rename-handler.ts +3 -6
  40. package/filelist/file-list-replace-handler.ts +3 -6
  41. package/filelist/file-list.ts +3 -4
  42. package/form/backend/form-editor/modals-component.ts +114 -5
  43. package/form/backend/form-editor/view-model.ts +1 -1
  44. package/form/backend/form-wizard/steps/settings-step.ts +6 -3
  45. package/package.json +2 -2
  46. package/rte-ckeditor/legacy/ckeditor5-bundle.ts +0 -1
  47. package/rte-ckeditor/plugin/typo3-link.ts +7 -9
  48. package/rte-ckeditor/rte-link-browser.ts +1 -1
  49. package/scheduler/scheduler-add-group.ts +4 -12
  50. package/workspaces/backend.ts +7 -2
  51. package/workspaces/preview.ts +39 -0
  52. package/workspaces/renderable/comment-view.ts +4 -2
  53. package/workspaces/renderable/record-table.ts +134 -80
  54. package/workspaces/workspaces.ts +65 -12
package/.last-synced-tag CHANGED
@@ -1 +1 @@
1
- v14.3.6
1
+ v14.3.8
package/README-TYPO3.md CHANGED
@@ -75,7 +75,7 @@ with each other and with the public. Several TYPO3 teams use Slack as a
75
75
  way to communicate internally and most channels are a welcome place for
76
76
  you to join and get yourself involved.
77
77
 
78
- * Register: https://my.typo3.org/about-mytypo3org/slack
78
+ * Register: https://typo3.community/meet/slack/
79
79
  * Slack: https://typo3.slack.com/
80
80
 
81
81
  ### Exchange information, ask questions, get help
package/README.md CHANGED
@@ -16,7 +16,7 @@ npm install typo3-typescript-sources
16
16
 
17
17
  - **Original Repository:** [TYPO3/typo3](https://github.com/TYPO3/typo3)
18
18
  - **Source Path:** `Build/Sources/TypeScript/`
19
- - **Current TYPO3 Version:** `v14.3.6`
19
+ - **Current TYPO3 Version:** `v14.3.8`
20
20
 
21
21
  ## 🌿 Branches
22
22
 
@@ -160,7 +160,7 @@ export class ColumnSelectorButton extends PseudoButtonLitElement {
160
160
  text: this.buttonOk,
161
161
  btnClass: 'btn-primary',
162
162
  name: 'update',
163
- trigger: (e: Event, modal: ModalElement): void => this.processSelection(modal)
163
+ form: 'columnSelectorForm',
164
164
  }
165
165
  ],
166
166
  ajaxCallback: (): void => this.handleModalContentLoaded(modal)
@@ -198,7 +198,10 @@ export class ColumnSelectorButton extends PseudoButtonLitElement {
198
198
  return;
199
199
  }
200
200
  // Prevent the form from being submitted as the form data will be send via an ajax request
201
- form.addEventListener('submit', (e: Event): void => { e.preventDefault(); });
201
+ form.addEventListener('submit', (e: Event): void => {
202
+ e.preventDefault();
203
+ this.processSelection(currentModal);
204
+ });
202
205
 
203
206
  const columns: NodeListOf<HTMLInputElement> = currentModal.querySelectorAll(Selectors.columnsSelector);
204
207
  const columnsFilter: HTMLInputElement = currentModal.querySelector(Selectors.columnsFilterSelector);
@@ -371,7 +371,21 @@ export default class DragUploader {
371
371
  * @param {FileList} files
372
372
  */
373
373
  public processFiles(files: FileList): void {
374
- this.queueLength = files.length;
374
+ const validFiles: File[] = [];
375
+ for (const file of Array.from(files)) {
376
+ const error = this.validateFile(file);
377
+ if (error === null) {
378
+ validFiles.push(file);
379
+ } else {
380
+ Notification.error(coreLabels.get('file_upload.notUploaded.title'), error);
381
+ }
382
+ }
383
+ this.fileInput.value = '';
384
+ if (validFiles.length === 0) {
385
+ return;
386
+ }
387
+
388
+ this.queueLength = validFiles.length;
375
389
 
376
390
  if (this.fileList.parentElement.hasAttribute('hidden')) {
377
391
  // Show the filelist (table)
@@ -385,11 +399,11 @@ export default class DragUploader {
385
399
  this.overallProgressBar = document.createElement('typo3-backend-progress-bar');
386
400
  document.body.appendChild(this.overallProgressBar);
387
401
  this.overallProgressBar.start();
388
- this.percentagePerFile = 1 / files.length;
402
+ this.percentagePerFile = 1 / validFiles.length;
389
403
 
390
404
  // Check for each file if is already exist before adding it to the queue
391
405
  const ajaxCalls: Promise<void>[] = [];
392
- Array.from(files).forEach((file: File) => {
406
+ validFiles.forEach((file: File) => {
393
407
  const request = new AjaxRequest(TYPO3.settings.ajaxUrls.file_exists).withQueryArguments({
394
408
  fileName: file.name,
395
409
  fileTarget: this.target,
@@ -418,8 +432,6 @@ export default class DragUploader {
418
432
  this.overallProgressBar.done();
419
433
  }
420
434
  });
421
-
422
- this.fileInput.value = '';
423
435
  }
424
436
 
425
437
  public fileInDropzone = (): void => {
@@ -631,6 +643,37 @@ export default class DragUploader {
631
643
  document.dispatchEvent(new CustomEvent(uploadFinishedEventName));
632
644
  });
633
645
  }
646
+
647
+ /**
648
+ * @returns the reason why the file cannot be uploaded, null if it can be uploaded
649
+ */
650
+ private validateFile(file: File): string | null {
651
+ const extension = file.name.split('.').pop().toLowerCase();
652
+ if (this.maxFileSize > 0 && file.size > this.maxFileSize) {
653
+ return coreLabels.get('file_upload.maxFileSizeExceeded', {
654
+ '0': file.name,
655
+ '1': FormatUtility.fileSizeAsString(this.maxFileSize),
656
+ });
657
+ }
658
+ if (this.fileDenyPattern && file.name.match(this.fileDenyPattern)) {
659
+ return coreLabels.get('file_upload.fileNotAllowed', {
660
+ '0': file.name,
661
+ });
662
+ }
663
+ if (this.filesExtensionsAllowed && !this.filesExtensionsAllowed.split(',').includes(extension)) {
664
+ return coreLabels.get('file_upload.notUploaded.extensionExpected', {
665
+ '0': file.name,
666
+ '1': this.filesExtensionsAllowed.split(',').join(', '),
667
+ });
668
+ }
669
+ if (this.filesExtensionsDisallowed && this.filesExtensionsDisallowed.split(',').includes(extension)) {
670
+ return coreLabels.get('file_upload.notUploaded.extensionDisallowed', {
671
+ '0': file.name,
672
+ '1': extension,
673
+ });
674
+ }
675
+ return null;
676
+ }
634
677
  }
635
678
 
636
679
  class FileQueueItem {
@@ -697,72 +740,41 @@ class FileQueueItem {
697
740
  // set dummy file icon
698
741
  this.iconCol.innerHTML = '<typo3-backend-icon identifier="mimetypes-other-other" />';
699
742
 
700
- // check file size
701
- if (this.dragUploader.maxFileSize > 0 && this.file.size > this.dragUploader.maxFileSize) {
702
- this.updateMessage(coreLabels.get('file_upload.maxFileSizeExceeded', {
703
- '0': this.file.name,
704
- '1': FormatUtility.fileSizeAsString(this.dragUploader.maxFileSize),
705
- }));
706
- this.progressBar.value = 100;
707
- this.progressBar.severity = SeverityEnum.error;
708
-
709
- // check filename/extension against deny pattern
710
- } else if (this.dragUploader.fileDenyPattern && this.file.name.match(this.dragUploader.fileDenyPattern)) {
711
- this.updateMessage(coreLabels.get('file_upload.fileNotAllowed', {
712
- '0': this.file.name,
713
- }));
714
- this.progressBar.value = 100;
715
- this.progressBar.severity = SeverityEnum.error;
716
-
717
- } else if (this.isProhibitedByAllowedExtensionsList()) {
718
- this.updateMessage(coreLabels.get('file_upload.fileExtensionExpected', {
719
- '0': this.dragUploader.filesExtensionsAllowed,
720
- }));
721
- this.progressBar.value = 100;
722
- this.progressBar.severity = SeverityEnum.error;
723
- } else if (this.isProhibitedByDisallowedExtensionsList()) {
724
- this.updateMessage(coreLabels.get('file_upload.fileExtensionDisallowed', {
725
- '0': this.dragUploader.filesExtensionsDisallowed,
726
- }));
727
- this.progressBar.value = 100;
728
- this.progressBar.severity = SeverityEnum.error;
729
- } else {
730
- this.updateMessage('- ' + FormatUtility.fileSizeAsString(this.file.size));
731
-
732
- const formData = new FormData();
733
- formData.append('data[upload][1][target]', this.dragUploader.target);
734
- formData.append('data[upload][1][data]', '1');
735
- formData.append('overwriteExistingFiles', this.override);
736
- formData.append('redirect', '');
737
- formData.append('upload_1', this.file);
738
-
739
- // We use XMLHttpRequest as we need the `progress` event which isn't supported by fetch()
740
- const xhr = new XMLHttpRequest();
741
- xhr.onreadystatechange = (): void => {
742
- if (xhr.readyState === XMLHttpRequest.DONE) {
743
- if (xhr.status === 200) {
744
- try {
745
- const response = JSON.parse(xhr.responseText);
746
- if (!response.hasErrors) {
747
- this.uploadSuccess(response);
748
- } else {
749
- this.uploadError(xhr);
750
- }
751
- }
752
- catch {
753
- // In case JSON can not be parsed, the upload failed due to server errors,
754
- // e.g. "POST Content-Length exceeds limit". Just handle as upload error.
743
+ this.updateMessage('- ' + FormatUtility.fileSizeAsString(this.file.size));
744
+
745
+ const formData = new FormData();
746
+ formData.append('data[upload][1][target]', this.dragUploader.target);
747
+ formData.append('data[upload][1][data]', '1');
748
+ formData.append('overwriteExistingFiles', this.override);
749
+ formData.append('redirect', '');
750
+ formData.append('upload_1', this.file);
751
+
752
+ // We use XMLHttpRequest as we need the `progress` event which isn't supported by fetch()
753
+ const xhr = new XMLHttpRequest();
754
+ xhr.onreadystatechange = (): void => {
755
+ if (xhr.readyState === XMLHttpRequest.DONE) {
756
+ if (xhr.status === 200) {
757
+ try {
758
+ const response = JSON.parse(xhr.responseText);
759
+ if (!response.hasErrors) {
760
+ this.uploadSuccess(response);
761
+ } else {
755
762
  this.uploadError(xhr);
756
763
  }
757
- } else {
764
+ }
765
+ catch {
766
+ // In case JSON can not be parsed, the upload failed due to server errors,
767
+ // e.g. "POST Content-Length exceeds limit". Just handle as upload error.
758
768
  this.uploadError(xhr);
759
769
  }
770
+ } else {
771
+ this.uploadError(xhr);
760
772
  }
761
- };
762
- xhr.upload.addEventListener('progress', (e: ProgressEvent) => this.updateProgress(e));
763
- xhr.open('POST', TYPO3.settings.ajaxUrls.file_process);
764
- xhr.send(formData);
765
- }
773
+ }
774
+ };
775
+ xhr.upload.addEventListener('progress', (e: ProgressEvent) => this.updateProgress(e));
776
+ xhr.open('POST', TYPO3.settings.ajaxUrls.file_process);
777
+ xhr.send(formData);
766
778
  }
767
779
 
768
780
  /**
@@ -925,26 +937,6 @@ class FileQueueItem {
925
937
  this.row.append(document.createElement('td'));
926
938
  }
927
939
  }
928
-
929
- public isProhibitedByAllowedExtensionsList(): boolean {
930
- if (!this.dragUploader.filesExtensionsAllowed) {
931
- return false;
932
- }
933
- const extension = this.file.name.split('.').pop();
934
- const allowed = this.dragUploader.filesExtensionsAllowed.split(',');
935
-
936
- return !allowed.includes(extension.toLowerCase());
937
- }
938
-
939
- public isProhibitedByDisallowedExtensionsList(): boolean {
940
- if (!this.dragUploader.filesExtensionsDisallowed) {
941
- return false;
942
- }
943
- const extension = this.file.name.split('.').pop();
944
- const disallowed = this.dragUploader.filesExtensionsDisallowed.split(',');
945
-
946
- return disallowed.includes(extension.toLowerCase());
947
- }
948
940
  }
949
941
 
950
942
  DragUploader.init();
@@ -27,11 +27,17 @@ import labels from '~labels/backend.alt_doc';
27
27
  *
28
28
  * `url` attribute points to the contextual edit route
29
29
  * `edit-url` points to the full FormEngine route.
30
+ * `context` identifies what's being edited, used to persist the sheet's
31
+ * width independently per context. This might be just the database table
32
+ * (e.g. "pages" and "sys_category" are resized independently), but callers
33
+ * may pass a more specific value later, e.g. "tt_content.textmedia" vs.
34
+ * "tt_content.header", once per-CType widths are needed.
30
35
  *
31
36
  * @example
32
37
  * <typo3-backend-contextual-record-edit-trigger
33
38
  * url="/typo3/record/edit/contextual?..."
34
- * edit-url="/typo3/record/edit?...">
39
+ * edit-url="/typo3/record/edit?..."
40
+ * context="pages">
35
41
  * Edit record
36
42
  * </typo3-backend-contextual-record-edit-trigger>
37
43
  */
@@ -39,6 +45,7 @@ import labels from '~labels/backend.alt_doc';
39
45
  export class ContextualRecordEditTriggerElement extends PseudoButtonLitElement {
40
46
  @property({ type: String }) url: string;
41
47
  @property({ type: String, attribute: 'edit-url' }) editUrl: string;
48
+ @property({ type: String }) context: string = '';
42
49
 
43
50
  protected override async buttonActivated(): Promise<void> {
44
51
  if (Persistent.isset('contextualRecordEdit') && Persistent.get('contextualRecordEdit') == 0) {
@@ -54,6 +61,7 @@ export class ContextualRecordEditTriggerElement extends PseudoButtonLitElement {
54
61
  size: Sizes.expand,
55
62
  position: Positions.sheet,
56
63
  hideHeader: true,
64
+ resizeIdentifier: `contextualEdit${this.context ? `.${this.context}` : ''}`,
57
65
  });
58
66
  this.setupMessageHandling(modal);
59
67
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import RegularEvent from '@typo3/core/event/regular-event';
15
+ import TextNormalizer from '@typo3/core/utility/text-normalizer';
15
16
 
16
17
  enum Selectors {
17
18
  fieldContainerSelector = '.t3js-formengine-field-group',
@@ -25,6 +26,7 @@ enum Selectors {
25
26
  class SelectBoxFilter {
26
27
  private readonly selectElement: HTMLSelectElement = null;
27
28
  private availableOptions: NodeListOf<HTMLOptionElement> = null;
29
+ private foldedLabels: string[] = null;
28
30
 
29
31
  constructor(selectElement: HTMLSelectElement) {
30
32
  this.selectElement = selectElement;
@@ -73,14 +75,15 @@ class SelectBoxFilter {
73
75
  private filter(filterText: string): void {
74
76
  if (this.availableOptions === null) {
75
77
  this.availableOptions = this.selectElement.querySelectorAll('option');
78
+ this.foldedLabels = Array.from(this.availableOptions, (option: HTMLOptionElement): string =>
79
+ TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(option.textContent)));
76
80
  }
77
81
 
78
- // Escape filter text to prevent RegExp() exceptions
79
- const escapedFilterText = filterText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ // A plain substring match, no RegExp involved, therefore nothing to escape either
83
+ const foldedFilterText = TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(filterText));
80
84
 
81
- const matchFilter = new RegExp(escapedFilterText, 'i');
82
- this.availableOptions.forEach((option: HTMLOptionElement): void => {
83
- option.hidden = filterText.length > 0 && option.textContent.match(matchFilter) === null;
85
+ this.availableOptions.forEach((option: HTMLOptionElement, index: number): void => {
86
+ option.hidden = filterText.length > 0 && !this.foldedLabels[index].includes(foldedFilterText);
84
87
  SelectBoxFilter.toggleOptGroup(option);
85
88
  });
86
89
  }
@@ -30,14 +30,14 @@ export class OnlineMediaFormElement extends LitElement {
30
30
 
31
31
  protected override render(): TemplateResult {
32
32
  return html`
33
- <form @submit="${ this.dispatchSubmitEvent }">
33
+ <form id="online-media-form" @submit="${ this.dispatchSubmitEvent }">
34
34
  <div class="form-control-wrap">
35
- <input type="text" class="form-control" name="online-media-url" placeholder="${this.placeholder}" required>
35
+ <input type="text" class="form-control" name="online-media-url" placeholder="${this.placeholder}" required autofocus>
36
36
  <div class="form-text">
37
37
  ${this.allowedExtensionsHelpText}<br>
38
38
  <ul class="badge-list">
39
39
  ${this.allowedExtensions.split(',').map((ext: string) => html`
40
- <li><span class="badge badge-secondary">${ext.trim().toUpperCase()}</span></li>
40
+ <li><span class="badge badge-secondary">${ext.trim().toLowerCase()}</span></li>
41
41
  `)}
42
42
  </ul>
43
43
  </div>
@@ -14,6 +14,7 @@
14
14
  import { html, type TemplateResult } from 'lit';
15
15
  import { Tree, type TreeSettings } from '@typo3/backend/tree/tree';
16
16
  import { customElement, state } from 'lit/decorators.js';
17
+ import TextNormalizer from '@typo3/core/utility/text-normalizer';
17
18
  import type { TreeNodeInterface } from '@typo3/backend/tree/tree-node';
18
19
 
19
20
  interface SelectTreeSettings extends TreeSettings {
@@ -48,6 +49,18 @@ export class SelectTree extends Tree
48
49
  this.addEventListener('typo3:tree:nodes-prepared', this.prepareLoadedNodes);
49
50
  }
50
51
 
52
+ /**
53
+ * Fold both sides of the comparison the filter performs, so that a node is
54
+ * found whether or not the diacritics of its name are typed.
55
+ *
56
+ * The names are folded on every keystroke rather than kept beside the node,
57
+ * because writing to a node triggers a re-render and the nodes are replaced
58
+ * whenever a part of the tree is loaded.
59
+ */
60
+ private static fold(value: string): string {
61
+ return TextNormalizer.foldCaseAndDiacritics(TextNormalizer.normalizeInvisibleCharacters(value));
62
+ }
63
+
51
64
  /**
52
65
  * Expand all nodes and refresh view
53
66
  */
@@ -89,7 +102,8 @@ export class SelectTree extends Tree
89
102
  this.nodes[0].__expanded = false;
90
103
  }
91
104
  const firstNode = this.nodes[0];
92
- const regex = new RegExp(searchTerm, 'i');
105
+ // A plain substring match, no RegExp involved, therefore nothing to escape either
106
+ const foldedSearchTerm = SelectTree.fold(searchTerm ?? '');
93
107
 
94
108
  this.nodes.forEach((node: any) => {
95
109
  // skip the root node in searches
@@ -100,7 +114,7 @@ export class SelectTree extends Tree
100
114
  node.__expanded = false;
101
115
  node.__hidden = true;
102
116
 
103
- if (regex.test(node.name)) {
117
+ if (SelectTree.fold(node.name).includes(foldedSearchTerm)) {
104
118
  results.push(node);
105
119
  }
106
120
  });
@@ -33,7 +33,33 @@ class AddRecord {
33
33
  private readonly registerClickHandler = (e: Event): void => {
34
34
  e.preventDefault();
35
35
 
36
- FormEngine.preventFollowLinkIfNotSaved(this.controlElement.getAttribute('href'));
36
+ const href = this.controlElement.getAttribute('href');
37
+ const url = new URL(href, window.location.origin);
38
+ const ownerUid = url.searchParams.get('P[uid]') || '';
39
+ if (ownerUid.startsWith('NEW')) {
40
+ // The record this field belongs to is itself still unsaved:
41
+ // Wizard/AddController can only link a newly created record back into
42
+ // an already-persisted uid, so a plain "leave without saving?" prompt
43
+ // would both discard this record AND silently fail to link anything.
44
+ // Deliberately not passing along this href's own P[returnUrl] here: for
45
+ // a field belonging to an inline/IRRE child that was itself added via
46
+ // AJAX (record_inline_create), that value is whatever URL rendered the
47
+ // child's markup at ajax-creation time. The ajax endpoint's own URL,
48
+ // not a real, navigable page. EditDocumentController computes the
49
+ // correct return-to-this-document URL itself once the save completes.
50
+ FormEngine.preventFollowAddRecordLinkIfNotSaved({
51
+ originalUid: ownerUid,
52
+ ownerTable: url.searchParams.get('P[table]') || '',
53
+ ownerField: url.searchParams.get('P[field]') || '',
54
+ table: url.searchParams.get('P[params][table]') || '',
55
+ pid: url.searchParams.get('P[params][pid]') || '',
56
+ setValue: url.searchParams.get('P[params][setValue]') || 'append',
57
+ flexFormPath: url.searchParams.get('P[flexFormPath]') || '',
58
+ });
59
+ return;
60
+ }
61
+
62
+ FormEngine.preventFollowLinkIfNotSaved(href);
37
63
  };
38
64
  }
39
65
 
@@ -62,6 +62,7 @@ const enum FormAction {
62
62
  saveAndView = '_savedokview',
63
63
  saveAndNew = '_savedoknew',
64
64
  duplicate = '_duplicatedoc',
65
+ saveAndAddRecord = '_savedokaddrecord',
65
66
  }
66
67
 
67
68
  type OnChangeFieldHandlerCallback = (data: object, e: Event) => void;
@@ -134,7 +135,7 @@ export default (function() {
134
135
  btnClass: 'btn-' + Severity.getCssClass(Severity.warning),
135
136
  name: 'ok',
136
137
  trigger: () => {
137
- FormEngine.closeModalsRecursive();
138
+ modal.hideModal();
138
139
  saveDocumentWithoutValidation();
139
140
  }
140
141
  }
@@ -900,6 +901,84 @@ export default (function() {
900
901
  }
901
902
  };
902
903
 
904
+ /**
905
+ * Show modal to confirm following a "+ add related record" (fieldControl.addRecord)
906
+ * link whose OWN record is itself still new/unsaved. Since Wizard/AddController
907
+ * can only link a newly created record back into an already-persisted uid,
908
+ * discarding here means abandoning the whole attempt (closing the document,
909
+ * same as preventExitIfNotSavedCallback) rather than following through to
910
+ * create an orphaned record that could never be linked back anyway. The
911
+ * only way to actually continue to the add-record wizard is "save first".
912
+ *
913
+ * @param {Object.<string, string>} addRecordParams
914
+ * @returns {Boolean}
915
+ */
916
+ FormEngine.preventFollowAddRecordLinkIfNotSaved = function(addRecordParams: {[key: string]: string}): boolean {
917
+ const title = backendAltDocLabels.get('label.confirm.add_record_unsaved_parent.title');
918
+ const content = backendAltDocLabels.get('label.confirm.add_record_unsaved_parent.content');
919
+ const buttons: Array<{text: string, btnClass: string, name: string, active?: boolean}> = [
920
+ {
921
+ text: backendAltDocLabels.get('buttons.confirm.close_without_save.no'),
922
+ btnClass: 'btn-default',
923
+ name: 'no'
924
+ },
925
+ {
926
+ text: backendAltDocLabels.get('buttons.confirm.close_without_save.yes'),
927
+ btnClass: 'btn-default',
928
+ name: 'yes'
929
+ }
930
+ ];
931
+ if (document.querySelector('.has-error') === null) {
932
+ buttons.push({
933
+ text: backendAltDocLabels.get('buttons.confirm.add_record_unsaved_parent.save'),
934
+ btnClass: 'btn-primary',
935
+ name: 'save',
936
+ active: true
937
+ });
938
+ }
939
+
940
+ const modal = Modal.confirm(title, content, Severity.warning, buttons);
941
+ modal.addEventListener('button.clicked', function(e: Event) {
942
+ modal.hideModal();
943
+ const name = (e.target as HTMLButtonElement).name;
944
+ if (name === 'yes') {
945
+ FormEngine.closeDocument();
946
+ } else if (name === 'save') {
947
+ FormEngine.saveAndContinueToAddRecord(addRecordParams);
948
+ }
949
+ });
950
+ return false;
951
+ };
952
+
953
+ /**
954
+ * Saves the current document, then (server-side, once the record this
955
+ * addRecordParams.originalUid placeholder was saved under is known)
956
+ * redirects on to Wizard/AddController for the field identified by
957
+ * addRecordParams, see FormAction::savedokaddrecord() /
958
+ * EditDocumentController::processData().
959
+ *
960
+ * @param {Object.<string, string>} addRecordParams
961
+ */
962
+ FormEngine.saveAndContinueToAddRecord = function(addRecordParams: {[key: string]: string}): void {
963
+ Object.keys(addRecordParams).forEach((key: string): void => {
964
+ const input = document.createElement('input');
965
+ input.type = 'hidden';
966
+ input.name = 'addRecordAfterSave[' + key + ']';
967
+ input.value = addRecordParams[key];
968
+ FormEngine.formElement.append(input);
969
+ });
970
+ const actionInput = document.createElement('input');
971
+ actionInput.type = 'hidden';
972
+ actionInput.name = FormAction.saveAndAddRecord;
973
+ actionInput.value = '1';
974
+ FormEngine.formElement.append(actionInput);
975
+ // Submitted as a regular save on top: "_savedokaddrecord" is only understood by
976
+ // EditDocumentController, while other hosts of FormEngine forms (site configuration,
977
+ // constant editor, ...) test "_savedok" literally. Without it, such a form would
978
+ // neither save nor create anything and silently discard the submitted data.
979
+ submitFormWithAction(FormAction.save);
980
+ };
981
+
903
982
  /**
904
983
  * Show modal to confirm closing the document without saving
905
984
  */
@@ -954,8 +1033,12 @@ export default (function() {
954
1033
  };
955
1034
 
956
1035
  /**
957
- * Enables all doc header buttons.
958
- * Called after FormEngine initialization is complete to restore button interactivity.
1036
+ * Releases the doc header button bar, which is rendered "inert" as long as the
1037
+ * ui block is shown, to prevent unintended operations while FormEngine is loading.
1038
+ * Called after FormEngine initialization is complete.
1039
+ *
1040
+ * The block is applied to the button bar as a whole, never to the single buttons,
1041
+ * so that the disabled state of individual buttons remains untouched.
959
1042
  */
960
1043
  FormEngine.enableDocHeaderButtons = function(): void {
961
1044
  const docHeaderBar = document.querySelector('.t3js-module-docheader-buttons');
@@ -963,16 +1046,8 @@ export default (function() {
963
1046
  return;
964
1047
  }
965
1048
 
966
- // Enable all buttons and links in the doc header
967
- docHeaderBar.querySelectorAll('button, a, input[type="submit"]').forEach((element: HTMLElement): void => {
968
- if (element instanceof HTMLButtonElement || element instanceof HTMLInputElement) {
969
- element.disabled = false;
970
- } else if (element instanceof HTMLAnchorElement) {
971
- // For anchor elements - remove TYPO3 disabled state
972
- element.classList.remove('disabled');
973
- element.removeAttribute('aria-disabled');
974
- }
975
- });
1049
+ docHeaderBar.removeAttribute('inert');
1050
+ docHeaderBar.removeAttribute('aria-busy');
976
1051
  };
977
1052
 
978
1053
  /**
@@ -0,0 +1,61 @@
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/backend/layout-module/column-restriction
16
+ *
17
+ * Client-side mirror of the server-side backend layout colPos restriction
18
+ * (\TYPO3\CMS\Backend\View\BackendLayoutView::isCTypeAllowedInColPosByPage()).
19
+ * Used by drag-drop and paste in the page layout module to avoid offering
20
+ * actions that the DataHandler would reject anyway.
21
+ *
22
+ * @internal
23
+ */
24
+
25
+ function toList(value: string | undefined): string[] {
26
+ return (value ?? '')
27
+ .split(',')
28
+ .map((item: string): string => item.trim())
29
+ .filter((item: string): boolean => item !== '');
30
+ }
31
+
32
+ /**
33
+ * Evaluates whether the given CType is allowed in the column described by the
34
+ * "data-allowed-content-types" / "data-disallowed-content-types" attributes.
35
+ * Mirrors the server-side logic: disallowed wins, an empty allow list allows all.
36
+ */
37
+ export function isContentTypeAllowedInColumn(cType: string, columnElement: HTMLElement | null): boolean {
38
+ if (columnElement === null) {
39
+ return true;
40
+ }
41
+
42
+ const disallowed = toList(columnElement.dataset.disallowedContentTypes);
43
+ if (disallowed.includes(cType)) {
44
+ return false;
45
+ }
46
+
47
+ const allowed = toList(columnElement.dataset.allowedContentTypes);
48
+ if (allowed.length > 0 && !allowed.includes(cType)) {
49
+ return false;
50
+ }
51
+
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Resolves the column of the given element (nearest ancestor carrying a colPos)
57
+ * and evaluates whether the CType is allowed there.
58
+ */
59
+ export function isContentTypeAllowedForTarget(cType: string, target: HTMLElement): boolean {
60
+ return isContentTypeAllowedInColumn(cType, target.closest('[data-colpos]'));
61
+ }