diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.html
new file mode 100644
index 000000000..b5fea017b
--- /dev/null
+++ b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.html
@@ -0,0 +1,27 @@
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.scss b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.scss
new file mode 100644
index 000000000..804cb7d57
--- /dev/null
+++ b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.scss
@@ -0,0 +1,16 @@
+@import 'mixins';
+@import 'vars';
+
+$field-line: #c7cfd7;
+
+.field-placeholder {
+ border: 2px dashed $field-line;
+ border-radius: 0;
+ margin-bottom: .25rem;
+ margin-top: 0;
+ min-height: 4rem;
+}
+
+.cdk-drop-list-dragging {
+ border: 0;
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.ts
new file mode 100644
index 000000000..21328ca05
--- /dev/null
+++ b/frontend/src/app/features/schemas/pages/schema/fields/field-group.component.ts
@@ -0,0 +1,91 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import { CdkDragDrop } from '@angular/cdk/drag-drop';
+import { ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core';
+import { AppSettingsDto, FieldDto, FieldGroup, LanguageDto, LocalStoreService, RootFieldDto, SchemaDto, Settings, StatefulComponent } from '@app/shared';
+
+interface State {
+ // The when the section is collapsed.
+ isCollapsed: boolean;
+}
+
+@Component({
+ selector: 'sqx-field-group[fieldGroup][languages][schema][settings]',
+ styleUrls: ['./field-group.component.scss'],
+ templateUrl: './field-group.component.html',
+})
+export class FieldGroupComponent extends StatefulComponent {
+ @Output()
+ public sorted = new EventEmitter>();
+
+ @Input()
+ public languages!: ReadonlyArray;
+
+ @Input()
+ public parent?: RootFieldDto;
+
+ @Input()
+ public settings!: AppSettingsDto;
+
+ @Input()
+ public sortable = false;
+
+ @Input()
+ public schema!: SchemaDto;
+
+ @Input()
+ public fieldsEmpty = false;
+
+ @Input()
+ public fieldGroup!: FieldGroup;
+
+ public trackByFieldFn: (_index: number, field: FieldDto) => any;
+
+ public get hasAnyFields() {
+ return this.parent ? this.parent.nested.length > 0 : this.schema.fields.length > 0;
+ }
+
+ constructor(changeDetector: ChangeDetectorRef,
+ private readonly localStore: LocalStoreService,
+ ) {
+ super(changeDetector, {
+ isCollapsed: false,
+ });
+
+ this.changes.subscribe(state => {
+ if (this.fieldGroup?.separator && this.schema) {
+ this.localStore.setBoolean(this.isCollapsedKey(), state.isCollapsed);
+ }
+ });
+
+ this.trackByFieldFn = this.trackByField.bind(this);
+ }
+
+ public ngOnInit() {
+ if (this.fieldGroup?.separator && this.schema) {
+ const isCollapsed = this.localStore.getBoolean(this.isCollapsedKey());
+
+ this.next({ isCollapsed });
+ }
+ }
+
+ public toggle() {
+ this.next(s => ({
+ ...s,
+ isCollapsed: !s.isCollapsed,
+ }));
+ }
+
+ public trackByField(_index: number, field: FieldDto) {
+ return field.fieldId + this.schema.id;
+ }
+
+ private isCollapsedKey(): string {
+ return Settings.Local.FIELD_COLLAPSED(this.schema?.id, this.fieldGroup.separator?.fieldId);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html
new file mode 100644
index 000000000..3d337425e
--- /dev/null
+++ b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.html
@@ -0,0 +1,18 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.scss b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.ts b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.ts
new file mode 100644
index 000000000..206dad409
--- /dev/null
+++ b/frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.ts
@@ -0,0 +1,79 @@
+/*
+ * Squidex Headless CMS
+ *
+ * @license
+ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
+ */
+
+import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AppSettingsDto, FieldDto, FieldGroup, groupFields, LanguageDto, RootFieldDto, SchemaDto } from '@app/shared';
+
+@Component({
+ selector: 'sqx-sortable-field-list[fields][languages][settings]',
+ styleUrls: ['./sortable-field-list.component.scss'],
+ templateUrl: './sortable-field-list.component.html',
+})
+export class SortableFieldListComponent {
+ @Output()
+ public sorted = new EventEmitter>();
+
+ @Input()
+ public languages!: ReadonlyArray;
+
+ @Input()
+ public parent?: RootFieldDto;
+
+ @Input()
+ public settings!: AppSettingsDto;
+
+ @Input()
+ public schema!: SchemaDto;
+
+ @Input()
+ public sortable = false;
+
+ @Input()
+ public fieldsEmpty = false;
+
+ @Input()
+ public set fields(value: ReadonlyArray) {
+ this.fieldGroups = groupFields(value, true);
+ }
+
+ public fieldGroups: FieldGroup[] = [];
+
+ public sortGroups(event: CdkDragDrop) {
+ this.onSort(event);
+ }
+
+ public sortFields(event: CdkDragDrop) {
+ this.onSort(event);
+ }
+
+ private onSort(event: CdkDragDrop) {
+ if (event.previousContainer === event.container) {
+ moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
+ } else {
+ transferArrayItem(
+ event.previousContainer.data,
+ event.container.data,
+ event.previousIndex,
+ event.currentIndex);
+ }
+
+ const result: FieldDto[] = [];
+
+ for (const group of this.fieldGroups) {
+ if (group.separator) {
+ result.push(group.separator);
+ }
+
+ for (const field of group.fields) {
+ result.push(field);
+ }
+ }
+
+ this.sorted.emit(result);
+ }
+}
\ No newline at end of file