diff --git a/projects/step-core/src/lib/modules/attachments/components/attachment-preview/attachment-preview.component.ts b/projects/step-core/src/lib/modules/attachments/components/attachment-preview/attachment-preview.component.ts index 3ade603c6f..3bb9aa4888 100644 --- a/projects/step-core/src/lib/modules/attachments/components/attachment-preview/attachment-preview.component.ts +++ b/projects/step-core/src/lib/modules/attachments/components/attachment-preview/attachment-preview.component.ts @@ -46,7 +46,7 @@ export class AttachmentPreviewComponent { readonly showDownload = input(true); readonly withBorder = input(true); - private streamingStatus = computed(() => this._streamingStatus.status()); + private readonly streamingStatus = computed(() => this._streamingStatus.status()); protected readonly isStreamingInProgress = computed(() => { const status = this.streamingStatus(); diff --git a/projects/step-core/src/lib/modules/basics/directives/undragged-click.directive.ts b/projects/step-core/src/lib/modules/basics/directives/undragged-click.directive.ts index 95b77d8c07..08cad1587a 100644 --- a/projects/step-core/src/lib/modules/basics/directives/undragged-click.directive.ts +++ b/projects/step-core/src/lib/modules/basics/directives/undragged-click.directive.ts @@ -47,8 +47,8 @@ export class UndraggedClickDirective { private bindWindowListeners(): void { this.removeWindowListeners?.(); - const move = (event: MouseEvent) => this.guard.pointerMove(event); - const up = () => { + const move = (event: MouseEvent): void => this.guard.pointerMove(event); + const up = (): void => { this.guard.pointerUp(); this.removeWindowListeners?.(); this.removeWindowListeners = undefined; diff --git a/projects/step-core/src/lib/modules/custom-registeries/custom-registries.module.ts b/projects/step-core/src/lib/modules/custom-registeries/custom-registries.module.ts index 087c0ce4a3..ea4cb54210 100644 --- a/projects/step-core/src/lib/modules/custom-registeries/custom-registries.module.ts +++ b/projects/step-core/src/lib/modules/custom-registeries/custom-registries.module.ts @@ -53,6 +53,7 @@ export * from './services/wizard-registry.service'; export * from './services/execution-custom-panel-registry.service'; export * from './services/entity-menu-items-registry.service'; export * from './services/automation-package-entity-table-registry.service'; +export * from './services/grid-settings-registry.service'; export { ItemInfo } from './services/base-registry.service'; export * from './shared/custom-registry-item'; export * from './shared/custom-registry-type.enum'; diff --git a/projects/step-core/src/lib/modules/custom-registeries/services/execution-custom-panel-registry.service.ts b/projects/step-core/src/lib/modules/custom-registeries/services/execution-custom-panel-registry.service.ts index 6bce30ec5b..68252d6ead 100644 --- a/projects/step-core/src/lib/modules/custom-registeries/services/execution-custom-panel-registry.service.ts +++ b/projects/step-core/src/lib/modules/custom-registeries/services/execution-custom-panel-registry.service.ts @@ -1,12 +1,13 @@ import { BaseRegistryService } from './base-registry.service'; -import { Injectable, Type } from '@angular/core'; +import { inject, Injectable, Type } from '@angular/core'; import { CustomComponent } from '../shared/custom-component'; import { CustomRegistryItem } from '../shared/custom-registry-item'; import { CustomRegistryType } from '../shared/custom-registry-type.enum'; +import { GridElementInfo, GridSettingsRegistryService } from './grid-settings-registry.service'; +import { EXECUTION_REPORT_GRID } from '../../execution-common/types/execution-report-grid'; -export interface ExecutionCustomPanelMetadata { +export interface ExecutionCustomPanelMetadata extends Omit { cssClassName?: string; - colSpan?: number; } export interface ExecutionCustomPanelRegistryItem extends CustomRegistryItem { @@ -19,6 +20,8 @@ export type ExecutionCustomPanelItemInfo = Pick { diff --git a/projects/step-core/src/lib/modules/custom-registeries/services/grid-settings-registry.service.ts b/projects/step-core/src/lib/modules/custom-registeries/services/grid-settings-registry.service.ts new file mode 100644 index 0000000000..a3d242b9ff --- /dev/null +++ b/projects/step-core/src/lib/modules/custom-registeries/services/grid-settings-registry.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; + +export interface GridElementInfo { + id: string; + title: string; + weight: number; + widthInCells: number; + heightInCells: number; + minWidthInCells?: number; + minHeightInCells?: number; +} + +@Injectable({ + providedIn: 'root', +}) +export class GridSettingsRegistryService { + private girdSettings: Map = new Map(); + + register(gridId: string, gridElement: GridElementInfo): void { + if (!this.girdSettings.has(gridId)) { + this.girdSettings.set(gridId, []); + } + this.girdSettings.get(gridId)!.push(gridElement); + } + + getSettings(gridId: string): ReadonlyArray { + return (this.girdSettings.get(gridId) ?? []).map((item) => ({ ...item })); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.html b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.html new file mode 100644 index 0000000000..40b3726403 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.html @@ -0,0 +1 @@ + diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.scss b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.ts b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.ts new file mode 100644 index 0000000000..c087971a05 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-drag-handle/grid-drag-handle.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { GridDragHandleDirective } from '../../directives/grid-drag-handle.directive'; + +@Component({ + selector: 'step-grid-drag-handle', + imports: [], + templateUrl: './grid-drag-handle.component.html', + styleUrl: './grid-drag-handle.component.scss', + hostDirectives: [GridDragHandleDirective], +}) +export class GridDragHandleComponent {} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.html b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.html new file mode 100644 index 0000000000..76eefdf65c --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.html @@ -0,0 +1 @@ +{{ displayTitle() }} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.scss b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.ts b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.ts new file mode 100644 index 0000000000..a7fc3383e9 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-element-title/grid-element-title.component.ts @@ -0,0 +1,20 @@ +import { Component, computed, inject } from '@angular/core'; +import { GRID_LAYOUT_CONFIG } from '../../injectables/grid-layout-config.token'; +import { GridElementDirective } from '../../directives/grid-element.directive'; + +@Component({ + selector: 'step-grid-element-title', + imports: [], + templateUrl: './grid-element-title.component.html', + styleUrl: './grid-element-title.component.scss', +}) +export class GridElementTitleComponent { + private _gridConfig = inject(GRID_LAYOUT_CONFIG); + private _gridElement = inject(GridElementDirective); + + protected readonly displayTitle = computed(() => { + const id = this._gridElement.elementId(); + const title = this._gridConfig.defaultElementParamsMap[id]?.title; + return title ?? id; + }); +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.html b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.html new file mode 100644 index 0000000000..f2930c5eee --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.html @@ -0,0 +1,11 @@ + +@if (_gridEditable.editMode()) { + @for (hiddenWidget of hiddenWidgets(); track hiddenWidget) { +
+ + + +
+ } +} +
diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.scss b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.scss new file mode 100644 index 0000000000..bd0042aac4 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.scss @@ -0,0 +1,104 @@ +@use 'projects/step-core/styles/core-variables' as var; + +step-grid-layout { + $cols-count: var(--style__cols-count); + + display: grid; + grid-template-columns: repeat($cols-count, calc((100% / $cols-count) - 1.5rem)); + grid-auto-rows: 13rem; + row-gap: 2.4rem; + column-gap: 1.5rem; + position: relative; + + &.hidden { + visibility: hidden; + } + + .step-grid-element { + position: relative; + + step-grid-drag-handle { + display: none; + position: absolute; + background: var.$gray-400; + opacity: 0.1; + min-height: 3rem; + top: 0; + left: 0; + width: 100%; + z-index: 5; + } + + step-grid-resizer { + display: none; + position: absolute; + bottom: 0.2rem; + right: 0.2rem; + } + } + + .preview { + border-style: solid; + border-width: 0.1rem; + background: var.$blue-50; + border-color: var.$blue-600; + opacity: 0.5; + border-radius: 0.5rem; + position: absolute; + display: none; + + $animation-duration: 0.2s; + transition: + top $animation-duration ease, + left $animation-duration ease, + width $animation-duration ease, + height $animation-duration ease; + + &.preview-invalid { + background: var.$red-50; + border-color: var.$red-650; + } + } + + &.show-preview { + .preview { + display: block; + } + } + + &.is-resize { + cursor: nwse-resize; + } + + &.edit-mode .step-grid-element { + step-grid-drag-handle, + step-grid-resizer { + display: block; + } + .step-grid-drag-handle { + cursor: grab; + } + } + + .hidden-placeholder { + border: dashed 0.1rem var.$black; + display: flex; + flex-direction: column; + align-items: center; + + step-grid-element-title { + $size: 1.6rem; + font-size: $size; + position: absolute; + top: calc(50% - ($size / 2)); + } + + /* + .drag-handle { + background: var.$gray-50; + min-height: 3rem; + width: 100%; + } + */ + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.ts b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.ts new file mode 100644 index 0000000000..6b580e57b9 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-layout/grid-layout.component.ts @@ -0,0 +1,109 @@ +import { + afterNextRender, + AfterViewInit, + Component, + computed, + contentChildren, + effect, + ElementRef, + inject, + signal, + untracked, + viewChild, + ViewEncapsulation, +} from '@angular/core'; +import { StepBasicsModule } from '../../../basics/step-basics.module'; +import { WidgetsPositionsStateService } from '../../injectables/widgets-positions-state.service'; +import { GridDimensionsDirective } from '../../directives/grid-dimensions.directive'; +import { GridElementResizerService } from '../../injectables/grid-element-resizer.service'; +import { GridElementDragService } from '../../injectables/grid-element-drag.service'; +import { GRID_COLUMN_COUNT } from '../../injectables/grid-column-count.token'; +import { GridEditableDirective } from '../../directives/grid-editable.directive'; +import { GridEditableService } from '../../injectables/grid-editable.service'; +import { GRID_LAYOUT_CONFIG } from '../../injectables/grid-layout-config.token'; +import { GridElementDirective } from '../../directives/grid-element.directive'; +import { GridResizerComponent } from '../grid-resizer/grid-resizer.component'; +import { GridElementTitleComponent } from '../grid-element-title/grid-element-title.component'; +import { GridDragHandleComponent } from '../grid-drag-handle/grid-drag-handle.component'; + +@Component({ + selector: 'step-grid-layout', + imports: [ + StepBasicsModule, + GridElementDirective, + GridResizerComponent, + GridElementTitleComponent, + GridDragHandleComponent, + ], + templateUrl: './grid-layout.component.html', + styleUrl: './grid-layout.component.scss', + encapsulation: ViewEncapsulation.None, + host: { + '[class.show-preview]': 'showPreview()', + '[class.is-resize]': 'isResize()', + '[class.edit-mode]': '_gridEditable.editMode()', + '[class.hidden]': '!isInitialised()', + '[style.--style__cols-count]': '_colCount', + }, + hostDirectives: [ + GridDimensionsDirective, + { + directive: GridEditableDirective, + inputs: ['editMode'], + }, + ], + providers: [WidgetsPositionsStateService, GridElementResizerService, GridElementDragService], +}) +export class GridLayoutComponent implements AfterViewInit { + protected readonly _colCount = inject(GRID_COLUMN_COUNT); + protected readonly _gridEditable = inject(GridEditableService); + private _gridLayoutConfig = inject(GRID_LAYOUT_CONFIG); + private _gridElementResizer = inject(GridElementResizerService); + private _gridElementDragService = inject(GridElementDragService); + private _widgetPositions = inject(WidgetsPositionsStateService); + + private readonly isRenderComplete = signal(false); + private readonly preview = viewChild>('preview'); + protected readonly isInitialised = computed(() => this._widgetPositions.isInitialized()); + + protected readonly isResize = computed(() => this._gridElementResizer.resizeInProgress()); + + protected readonly showPreview = computed(() => { + const isResize = this.isResize(); + const isDrag = this._gridElementDragService.dragInProgress(); + return !!isResize || !!isDrag; + }); + + protected readonly invalidPreview = computed(() => this._gridElementDragService.dragNotApplied()); + + private allWidgets = this._gridLayoutConfig.defaultElementParams.map((item) => item.id); + private readonly renderedWidgets = contentChildren(GridElementDirective); + private readonly renderedWidgetsIds = computed(() => { + const renderedWidgets = this.renderedWidgets(); + const ids = renderedWidgets.map((item) => untracked(() => item.elementId())); + return new Set(ids); + }); + + protected readonly hiddenWidgets = computed(() => { + const renderedWidgetsIds = this.renderedWidgetsIds(); + return this.allWidgets.filter((id) => !renderedWidgetsIds.has(id)); + }); + + private effectHiddenWidgetsChange = effect(() => { + const hiddenWidgets = this.hiddenWidgets(); + const isRenderComplete = this.isRenderComplete(); + if (isRenderComplete) { + this._widgetPositions.setHiddenWidgets(hiddenWidgets); + } + }); + + constructor() { + afterNextRender(() => this.isRenderComplete.set(true)); + } + + ngAfterViewInit(): void { + const previewElement = untracked(() => this.preview())!.nativeElement; + this._gridElementResizer.setupPreviewElement(previewElement); + this._gridElementDragService.setupPreviewElement(previewElement); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.html b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.html new file mode 100644 index 0000000000..a3c788a4d4 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.html @@ -0,0 +1 @@ + diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.scss b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.scss new file mode 100644 index 0000000000..9d01ef18f9 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.scss @@ -0,0 +1,6 @@ +step-icon { + transform: rotateX(180deg); + &:hover { + cursor: nwse-resize; + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.ts b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.ts new file mode 100644 index 0000000000..4aabd9dc19 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/components/grid-resizer/grid-resizer.component.ts @@ -0,0 +1,30 @@ +import { Component, inject, untracked } from '@angular/core'; +import { StepIconsModule } from '../../../step-icons/step-icons.module'; +import { GridElementDirective } from '../../directives/grid-element.directive'; +import { GridElementResizerService } from '../../injectables/grid-element-resizer.service'; +import { GridEditableService } from '../../injectables/grid-editable.service'; + +@Component({ + selector: 'step-grid-resizer', + imports: [StepIconsModule], + templateUrl: './grid-resizer.component.html', + styleUrl: './grid-resizer.component.scss', + host: { + '(mousedown)': 'handleMouseDown($event)', + }, +}) +export class GridResizerComponent { + private _gridElement = inject(GridElementDirective); + private _gridElementResizer = inject(GridElementResizerService); + private _gridEditable = inject(GridEditableService); + + protected handleMouseDown(event: MouseEvent): void { + const isEditMode = untracked(() => this._gridEditable.editMode()); + if (!isEditMode) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this._gridElementResizer.resizeStart(this._gridElement._elRef.nativeElement); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-dimensions.directive.ts b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-dimensions.directive.ts new file mode 100644 index 0000000000..a11fbee80e --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-dimensions.directive.ts @@ -0,0 +1,98 @@ +import { computed, Directive, ElementRef, forwardRef, inject, untracked } from '@angular/core'; +import { ElementSizeDirective } from '../../basics/step-basics.module'; +import { DOCUMENT } from '@angular/common'; +import { GridDimensionsService } from '../injectables/grid-dimensions.service'; + +@Directive({ + selector: '[stepGridDimensions]', + hostDirectives: [ElementSizeDirective], + providers: [ + { + provide: GridDimensionsService, + useExisting: forwardRef(() => GridDimensionsDirective), + }, + ], +}) +export class GridDimensionsDirective implements GridDimensionsService { + private _elementRef = inject>(ElementRef); + private _elementSize = inject(ElementSizeDirective, { self: true }); + private _doc = inject(DOCUMENT); + + private readonly gridStyles = computed(() => { + const width = this._elementSize.width(); + return this._doc?.defaultView?.getComputedStyle(this._elementRef.nativeElement); + }); + + private readonly gridDimensions = computed(() => { + const gridStyles = this.gridStyles(); + const columnGapStr = gridStyles?.['columnGap'] ?? ''; + let columnGap = parseFloat(columnGapStr); + columnGap = isNaN(columnGap) ? 0 : columnGap; + + const rowGapStr = gridStyles?.['rowGap'] ?? ''; + let rowGap = parseFloat(rowGapStr); + rowGap = isNaN(rowGap) ? 0 : rowGap; + + const templateColumns = gridStyles?.['gridTemplateColumns']; + const colWidthStr = templateColumns?.split?.(' ')?.[0] ?? ''; + let colWidth = parseFloat(colWidthStr); + colWidth = isNaN(colWidth) ? 0 : colWidth; + + const templateRows = gridStyles?.['gridTemplateRows']; + const rowHeightStr = templateRows?.split?.(' ')?.[0] ?? ''; + let rowHeight = parseFloat(rowHeightStr); + rowHeight = isNaN(rowHeight) ? 0 : rowHeight; + return { + columnGap, + rowGap, + colWidth, + rowHeight, + }; + }); + + get columnGap(): number { + return untracked(() => this.gridDimensions()).columnGap; + } + + get rowGap(): number { + return untracked(() => this.gridDimensions()).rowGap; + } + + determineCellsWidth(colIndex: number): number { + const { colWidth, columnGap } = untracked(() => this.gridDimensions()); + const size = colWidth + columnGap; + return colIndex * size; + } + + determineCellsHeight(rowIndex: number): number { + const { rowHeight, rowGap } = untracked(() => this.gridDimensions()); + const size = rowHeight + rowGap; + return rowIndex * size; + } + + determineCellColumn(x: number): number { + const { colWidth, columnGap } = untracked(() => this.gridDimensions()); + const size = colWidth + columnGap; + if (size === 0) { + return -1; + } + let col = Math.floor(x / size); + if (x % size !== 0) { + col++; + } + return col; + } + + determineCellRow(y: number): number { + const { rowHeight, rowGap } = untracked(() => this.gridDimensions()); + const size = rowHeight + rowGap; + if (size === 0) { + return -1; + } + let row = Math.floor(y / size); + if (y % size !== 0) { + row++; + } + return row; + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-drag-handle.directive.ts b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-drag-handle.directive.ts new file mode 100644 index 0000000000..ad2802b4fa --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-drag-handle.directive.ts @@ -0,0 +1,27 @@ +import { Directive, inject, untracked } from '@angular/core'; +import { GridElementDragService } from '../injectables/grid-element-drag.service'; +import { GridEditableService } from '../injectables/grid-editable.service'; +import { GridElementDirective } from './grid-element.directive'; + +@Directive({ + selector: '[stepGridDragHandle]', + host: { + class: 'step-grid-drag-handle', + '(mousedown)': 'handleMouseDown($event)', + }, +}) +export class GridDragHandleDirective { + private _gridElement = inject(GridElementDirective); + private _gridElementDrag = inject(GridElementDragService); + private _gridEditable = inject(GridEditableService); + + protected handleMouseDown(event: MouseEvent): void { + const isEditMode = untracked(() => this._gridEditable.editMode()); + if (!isEditMode) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this._gridElementDrag.dragStart(this._gridElement._elRef.nativeElement); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-editable.directive.ts b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-editable.directive.ts new file mode 100644 index 0000000000..dfa43529da --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-editable.directive.ts @@ -0,0 +1,15 @@ +import { Directive, forwardRef, input } from '@angular/core'; +import { GridEditableService } from '../injectables/grid-editable.service'; + +@Directive({ + selector: '[stepGridEditable]', + providers: [ + { + provide: GridEditableService, + useExisting: forwardRef(() => GridEditableDirective), + }, + ], +}) +export class GridEditableDirective implements GridEditableService { + readonly editMode = input(false); +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-element.directive.ts b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-element.directive.ts new file mode 100644 index 0000000000..07d7c1cb9f --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/directives/grid-element.directive.ts @@ -0,0 +1,88 @@ +import { + afterNextRender, + computed, + Directive, + effect, + ElementRef, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { WidgetsPositionsStateService } from '../injectables/widgets-positions-state.service'; +import { WidgetPosition } from '../types/widget-position'; + +@Directive({ + selector: '[stepGridElement]', + host: { + class: 'step-grid-element', + '[attr.data-grid-element-id]': 'elementId()', + '[style.grid-column]': 'gridColumn()', + '[style.grid-row]': 'gridRow()', + }, +}) +export class GridElementDirective { + private _positionsState = inject(WidgetsPositionsStateService); + + private readonly isRenderComplete = signal(false); + + readonly _elRef = inject>(ElementRef); + readonly elementId = input.required({ alias: 'stepGridElement' }); + + private readonly position = computed(() => { + const elementId = this.elementId(); + const positions = this._positionsState.positions(); + return positions[elementId]; + }); + + private effectInitializePosition = effect(() => { + const position = this.position(); + const isRenderComplete = this.isRenderComplete(); + const isPositionStateInitialized = this._positionsState.isInitialized(); + if (isRenderComplete && isPositionStateInitialized && !position) { + this.updatePositionIfRequired(); + } + }); + + protected readonly gridColumn = computed(() => { + const position = this.position(); + if (!position) { + return undefined; + } + const { column, widthInCells } = position; + if (widthInCells === 1) { + return column.toString(); + } + return `${column} / ${column + widthInCells}`; + }); + + protected readonly gridRow = computed(() => { + const position = this.position(); + if (!position) { + return undefined; + } + const { row, heightInCells } = position; + if (heightInCells === 1) { + return row.toString(); + } + return `${row} / ${row + heightInCells}`; + }); + + constructor() { + afterNextRender(() => this.isRenderComplete.set(true)); + } + + private updatePositionIfRequired(): void { + const id = untracked(() => this.elementId()); + if (!id) { + return; + } + let position = this._positionsState.getPosition(id); + if (!!position) { + return; + } + const positionParams = this._positionsState.findProperPosition(1, 1); + position = new WidgetPosition(id, positionParams); + this._positionsState.updatePosition(position); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/index.ts b/projects/step-core/src/lib/modules/editable-grid-layout/index.ts new file mode 100644 index 0000000000..07406657f6 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/index.ts @@ -0,0 +1,27 @@ +import { GridLayoutComponent } from './components/grid-layout/grid-layout.component'; +import { GridResizerComponent } from './components/grid-resizer/grid-resizer.component'; +import { GridDragHandleDirective } from './directives/grid-drag-handle.directive'; +import { GridElementDirective } from './directives/grid-element.directive'; +import { GridElementTitleComponent } from './components/grid-element-title/grid-element-title.component'; +import { GridEditableDirective } from './directives/grid-editable.directive'; +import { GridDragHandleComponent } from './components/grid-drag-handle/grid-drag-handle.component'; + +export const EDITABLE_GRID_LAYOUT_EXPORTS = [ + GridLayoutComponent, + GridResizerComponent, + GridDragHandleDirective, + GridDragHandleComponent, + GridElementDirective, + GridElementTitleComponent, + GridEditableDirective, +]; + +export * from './components/grid-layout/grid-layout.component'; +export * from './components/grid-resizer/grid-resizer.component'; +export * from './directives/grid-element.directive'; +export * from './directives/grid-drag-handle.directive'; +export * from './components/grid-drag-handle/grid-drag-handle.component'; +export * from './directives/grid-editable.directive'; +export * from './directives/grid-dimensions.directive'; +export * from './injectables/grid-layout-config.token'; +export * from './components/grid-element-title/grid-element-title.component'; diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-column-count.token.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-column-count.token.ts new file mode 100644 index 0000000000..b0f6fc73c6 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-column-count.token.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; + +const COLUMN_COUNT = 8; + +export const GRID_COLUMN_COUNT = new InjectionToken('Grid column count', { + providedIn: 'root', + factory: () => COLUMN_COUNT, +}); diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-dimensions.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-dimensions.service.ts new file mode 100644 index 0000000000..a0f9831001 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-dimensions.service.ts @@ -0,0 +1,8 @@ +export abstract class GridDimensionsService { + abstract readonly columnGap: number; + abstract readonly rowGap: number; + abstract determineCellsWidth(colIndex: number): number; + abstract determineCellsHeight(rowIndex: number): number; + abstract determineCellColumn(x: number): number; + abstract determineCellRow(y: number): number; +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-editable.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-editable.service.ts new file mode 100644 index 0000000000..199bf61066 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-editable.service.ts @@ -0,0 +1,5 @@ +import { Signal } from '@angular/core'; + +export abstract class GridEditableService { + abstract readonly editMode: Signal; +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-drag.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-drag.service.ts new file mode 100644 index 0000000000..beee6fce1e --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-drag.service.ts @@ -0,0 +1,140 @@ +import { computed, ElementRef, inject, Injectable, OnDestroy, Renderer2, signal, untracked } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { WidgetsPositionsStateService } from './widgets-positions-state.service'; +import { GridDimensionsService } from './grid-dimensions.service'; +import { gridElementId } from '../types/grid-element-id'; +import { PositionToApply } from '../types/position-to-apply'; + +@Injectable() +export class GridElementDragService implements OnDestroy { + private _elementRef = inject>(ElementRef); + private _renderer = inject(Renderer2); + private _doc = inject(DOCUMENT); + private _widgetsPositions = inject(WidgetsPositionsStateService); + private _gridDimensions = inject(GridDimensionsService); + + private previewElement?: HTMLElement; + private stopDragMove?: () => void; + private stopDragEnd?: () => void; + + private readonly draggedElement = signal(undefined); + + readonly dragInProgress = computed(() => { + const draggedElement = this.draggedElement(); + return !!draggedElement; + }); + + private readonly dragNotAppliedInternal = signal(false); + readonly dragNotApplied = this.dragNotAppliedInternal.asReadonly(); + + ngOnDestroy(): void { + this.stopDragMove?.(); + this.stopDragEnd?.(); + } + + setupPreviewElement(previewElement: HTMLElement): void { + this.previewElement = previewElement; + } + + dragStart(element: HTMLElement): void { + if (!!untracked(() => this.draggedElement())) { + return; + } + this.dragNotAppliedInternal.set(false); + this.draggedElement.set(element); + + const preview = this.previewElement; + if (preview) { + preview.style.top = `${element.offsetTop}px`; + preview.style.left = `${element.offsetLeft}px`; + preview.style.width = `${element.clientWidth}px`; + preview.style.height = `${element.clientHeight}px`; + } + + this.stopDragMove = this._renderer.listen(this._doc.body, 'mousemove', (event: MouseEvent) => this.dragMove(event)); + this.stopDragEnd = this._renderer.listen(this._doc.body, 'mouseup', (event: MouseEvent) => this.dragEnd(event)); + } + + private dragMove(event: MouseEvent): void { + const element = untracked(() => this.draggedElement()); + const preview = this.previewElement; + if (!element || !preview) { + return; + } + + let left = `${element.offsetLeft}px`; + let top = `${element.offsetTop}px`; + let width = `${element.clientWidth}px`; + let height = `${element.clientHeight}px`; + + const result = this.determineDragPosition(element, event.clientX, event.clientY); + if (result) { + const position = result.position; + const newLeft = this._gridDimensions.determineCellsWidth(position.column - 1); + const newTop = this._gridDimensions.determineCellsHeight(position.row - 1); + const newWidth = this._gridDimensions.determineCellsWidth(position.widthInCells) - this._gridDimensions.columnGap; + const newHeight = this._gridDimensions.determineCellsHeight(position.heightInCells) - this._gridDimensions.rowGap; + left = `${newLeft}px`; + top = `${newTop}px`; + width = `${newWidth}px`; + height = `${newHeight}px`; + } + + this.dragNotAppliedInternal.set(!result?.canBeApplied); + + preview.style.left = left; + preview.style.top = top; + preview.style.width = width; + preview.style.height = height; + } + + private dragEnd(event: MouseEvent): void { + const element = untracked(() => this.draggedElement()); + if (!element) { + return; + } + const id = gridElementId(element); + if (!id) { + return; + } + + const result = this.determineDragPosition(element, event.clientX, event.clientY); + if (result?.canBeApplied) { + const position = result.position; + if (position.id !== id) { + this._widgetsPositions.swapPositions(id, position.id); + } else { + this._widgetsPositions.updatePosition(position); + } + } + + this.stopDragMove?.(); + this.stopDragMove = undefined; + this.stopDragEnd?.(); + this.stopDragEnd = undefined; + this.draggedElement.set(undefined); + this.dragNotAppliedInternal.set(false); + } + + private determineDragPosition(element: HTMLElement, mouseX: number, mouseY: number): PositionToApply | undefined { + const id = gridElementId(element); + if (!id) { + return undefined; + } + + const originalPosition = this._widgetsPositions.getPosition(id); + if (!originalPosition) { + return undefined; + } + + const gridRect = this._elementRef.nativeElement.getBoundingClientRect(); + const column = this._gridDimensions.determineCellColumn(mouseX - gridRect.x); + const row = this._gridDimensions.determineCellRow(mouseY - gridRect.y); + + const position = originalPosition.clone(); + position.column = column; + position.row = row; + + return this._widgetsPositions.correctPositionForDrag(id, position); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-resizer.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-resizer.service.ts new file mode 100644 index 0000000000..7192a03028 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-element-resizer.service.ts @@ -0,0 +1,120 @@ +import { computed, inject, Injectable, OnDestroy, Renderer2, signal, untracked } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { WidgetsPositionsStateService } from './widgets-positions-state.service'; +import { GridDimensionsService } from './grid-dimensions.service'; +import { WidgetPosition } from '../types/widget-position'; +import { gridElementId } from '../types/grid-element-id'; + +@Injectable() +export class GridElementResizerService implements OnDestroy { + private _renderer = inject(Renderer2); + private _doc = inject(DOCUMENT); + private _widgetsPositions = inject(WidgetsPositionsStateService); + private _gridDimensions = inject(GridDimensionsService); + + private previewElement?: HTMLElement; + private stopResizeMove?: () => void; + private stopResizeEnd?: () => void; + + private readonly resizedElement = signal(undefined); + + readonly resizeInProgress = computed(() => { + const resizedElement = this.resizedElement(); + return !!resizedElement; + }); + + ngOnDestroy(): void { + this.stopResizeMove?.(); + this.stopResizeEnd?.(); + } + + setupPreviewElement(previewElement: HTMLElement): void { + this.previewElement = previewElement; + } + + resizeStart(element: HTMLElement): void { + if (!!untracked(() => this.resizedElement())) { + return; + } + this.resizedElement.set(element); + + const preview = this.previewElement; + if (preview) { + preview.style.top = `${element.offsetTop}px`; + preview.style.left = `${element.offsetLeft}px`; + preview.style.width = `${element.clientWidth}px`; + preview.style.height = `${element.clientHeight}px`; + } + + this.stopResizeMove = this._renderer.listen(this._doc.body, 'mousemove', (event: MouseEvent) => + this.resizeMove(event), + ); + this.stopResizeEnd = this._renderer.listen(this._doc.body, 'mouseup', (event: MouseEvent) => this.resizeEnd(event)); + } + + private resizeMove(event: MouseEvent): void { + const element = untracked(() => this.resizedElement()); + const preview = this.previewElement; + if (!element || !preview) { + return; + } + + let left = `${element.offsetLeft}px`; + let top = `${element.offsetTop}px`; + let width = `${element.clientWidth}px`; + let height = `${element.clientHeight}px`; + + const position = this.determineResizedPosition(element, event.clientX, event.clientY); + if (position) { + const newWidth = this._gridDimensions.determineCellsWidth(position.widthInCells) - this._gridDimensions.columnGap; + const newHeight = this._gridDimensions.determineCellsHeight(position.heightInCells) - this._gridDimensions.rowGap; + width = `${newWidth}px`; + height = `${newHeight}px`; + } + + preview.style.left = left; + preview.style.top = top; + preview.style.width = width; + preview.style.height = height; + } + + private resizeEnd(event: MouseEvent): void { + const element = untracked(() => this.resizedElement()); + if (!element) { + return; + } + + const position = this.determineResizedPosition(element, event.clientX, event.clientY); + if (position) { + this._widgetsPositions.updatePosition(position); + } + + this.stopResizeMove?.(); + this.stopResizeMove = undefined; + this.stopResizeEnd?.(); + this.stopResizeEnd = undefined; + this.resizedElement.set(undefined); + } + + private determineResizedPosition(element: HTMLElement, mouseX: number, mouseY: number): WidgetPosition | undefined { + const id = gridElementId(element); + if (!id) { + return undefined; + } + const rect = element.getBoundingClientRect(); + const distanceX = mouseX - rect.x; + const distanceY = mouseY - rect.y; + + if (distanceX <= 0 || distanceY <= 0) { + return undefined; + } + + const column = this._gridDimensions.determineCellColumn(element.offsetLeft + this._gridDimensions.columnGap); + const row = this._gridDimensions.determineCellRow(element.offsetTop + this._gridDimensions.rowGap); + const widthInCells = this._gridDimensions.determineCellColumn(distanceX); + const heightInCells = this._gridDimensions.determineCellRow(distanceY); + + const widgetPosition = new WidgetPosition(id, { column, row, widthInCells, heightInCells }); + return this._widgetsPositions.correctPositionForResize(id, widgetPosition); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-layout-config.token.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-layout-config.token.ts new file mode 100644 index 0000000000..45a07ac9d7 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-layout-config.token.ts @@ -0,0 +1,29 @@ +import { inject, InjectionToken, Provider } from '@angular/core'; +import { GridElementInfo, GridSettingsRegistryService } from '../../custom-registeries/custom-registries.module'; + +export interface GridLayoutConfig { + gridId: string; + defaultElementParams: GridElementInfo[]; + defaultElementParamsMap: Record; +} + +export const GRID_LAYOUT_CONFIG = new InjectionToken('Grid layout config'); + +export const provideGridLayoutConfig = (gridId: string, params: GridElementInfo[] = []): Provider => ({ + provide: GRID_LAYOUT_CONFIG, + useFactory: () => { + const _gridSettingsRegistry = inject(GridSettingsRegistryService); + const registeredElements = _gridSettingsRegistry.getSettings(gridId); + const defaultElementParams = [...registeredElements, ...params].sort((a, b) => a.weight - b.weight); + + const defaultElementParamsMap = defaultElementParams.reduce( + (res, item) => { + res[item.id] = item; + return res; + }, + {} as Record, + ); + + return { gridId, defaultElementParams, defaultElementParamsMap }; + }, +}); diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-persistence-positions.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-persistence-positions.service.ts new file mode 100644 index 0000000000..1279daa36d --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-persistence-positions.service.ts @@ -0,0 +1,54 @@ +import { inject, Injectable } from '@angular/core'; +import { WidgetPosition, WidgetPositionParams } from '../types/widget-position'; +import { catchError, map, Observable, of, timer } from 'rxjs'; +import { GridSessionStorageService } from './grid-session-storage.service'; + +@Injectable({ + providedIn: 'root', +}) +export class GridPersistencePositionsService { + private _storage = inject(GridSessionStorageService); + + savePositions(gridId: string, position: Record): Observable { + const positionParams = Object.entries(position).reduce( + (res, [key, position]) => { + res[key] = { + row: position.row, + column: position.column, + widthInCells: position.widthInCells, + heightInCells: position.heightInCells, + }; + return res; + }, + {} as Record, + ); + const paramsJson = JSON.stringify(positionParams); + this._storage.setItem(gridId, paramsJson); + return timer(100).pipe(map(() => {})); + } + + loadPositions(gridId: string): Observable> { + return timer(250).pipe( + map(() => gridId), + map((gridId) => this._storage.getItem(gridId)), + map((paramsJson) => { + if (!paramsJson) { + return {} as Record; + } + return JSON.parse(paramsJson) as Record; + }), + catchError((err) => { + return of({} as Record); + }), + map((params) => + Object.entries(params).reduce( + (res, [key, positionParam]) => { + res[key] = new WidgetPosition(key, positionParam); + return res; + }, + {} as Record, + ), + ), + ); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-session-storage.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-session-storage.service.ts new file mode 100644 index 0000000000..d153b34ac8 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/grid-session-storage.service.ts @@ -0,0 +1,11 @@ +import { inject, Injectable } from '@angular/core'; +import { SESSION_STORAGE, StorageProxy } from '../../basics/step-basics.module'; + +@Injectable({ + providedIn: 'root', +}) +export class GridSessionStorageService extends StorageProxy { + constructor() { + super(inject(SESSION_STORAGE), 'GRID'); + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/injectables/widgets-positions-state.service.ts b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/widgets-positions-state.service.ts new file mode 100644 index 0000000000..6ccbb45760 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/injectables/widgets-positions-state.service.ts @@ -0,0 +1,501 @@ +import { computed, DestroyRef, inject, Injectable, OnDestroy, signal, untracked } from '@angular/core'; +import { WidgetPosition, WidgetPositionParams } from '../types/widget-position'; +import { GRID_COLUMN_COUNT } from './grid-column-count.token'; +import { GridEditableService } from './grid-editable.service'; +import { WidgetIDs } from '../types/widget-ids'; +import { GRID_LAYOUT_CONFIG } from './grid-layout-config.token'; +import { GridPersistencePositionsService } from './grid-persistence-positions.service'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { debounceTime, switchMap } from 'rxjs'; +import { GridElementInfo } from '../../custom-registeries/custom-registries.module'; +import { PositionToApply } from '../types/position-to-apply'; + +const EMPTY = 0; + +@Injectable() +export class WidgetsPositionsStateService implements OnDestroy { + private _gridConfig = inject(GRID_LAYOUT_CONFIG); + private _colCount = inject(GRID_COLUMN_COUNT); + private _gridEditable = inject(GridEditableService); + private _gridPersistencePositions = inject(GridPersistencePositionsService); + private _destroyRef = inject(DestroyRef); + + private widgetIDs = new WidgetIDs(this._gridConfig.defaultElementParams.map((item) => item.id)); + + private readonly isInitializedInternal = signal(false); + readonly isInitialized = this.isInitializedInternal.asReadonly(); + + private readonly hiddenWidgets = signal([]); + private readonly positionsStateInternal = signal>({}); + + private readonly positionsState$ = toObservable(this.positionsStateInternal); + + readonly positions = computed(() => { + const positions = this.positionsStateInternal(); + const hiddenWidgets = this.hiddenWidgets(); + const isEditMode = this._gridEditable.editMode(); + if (isEditMode) { + return positions; + } + return this.realignPositionsWithHiddenWidgets(positions, hiddenWidgets); + }); + + private readonly fieldBottom = computed(() => { + const positions = Object.values(this.positionsStateInternal()); + if (!positions.length) { + return 0; + } + return Math.max(...positions.map((item) => item.bottomEdge)); + }); + + private readonly fieldState = computed(() => { + const positions = Object.values(this.positionsStateInternal()); + const fieldBottom = this.fieldBottom(); + if (!positions.length || !fieldBottom) { + return new Uint8Array(0); + } + const size = this._colCount * fieldBottom; + + const field = new Uint8Array(size); + positions.forEach((item) => this.fillPosition(field, item)); + return field; + }); + + constructor() { + this.initialize(); + } + + ngOnDestroy(): void { + this.widgetIDs.destroy(); + } + + getPosition(elementId: string): WidgetPosition | undefined { + return untracked(() => this.positionsStateInternal())[elementId]; + } + + updatePosition(position: WidgetPosition): void { + this.positionsStateInternal.update((value) => ({ + ...value, + [position.id]: position, + })); + } + + swapPositions(aElementId: string, bElementId: string): void { + const positions = untracked(() => this.positionsStateInternal()); + const positionA = positions[aElementId]; + const positionB = positions[bElementId]; + if (!positionA || !positionB) { + return; + } + const newPositionA = new WidgetPosition(aElementId, positionB); + const newPositionB = new WidgetPosition(bElementId, positionA); + this.positionsStateInternal.update((value) => ({ + ...value, + [newPositionA.id]: newPositionA, + [newPositionB.id]: newPositionB, + })); + } + + correctPositionForDrag(elementId: string, position: WidgetPosition): PositionToApply | undefined { + // Erase information about original position to avoid conflicts for current element + if (!this.clearElementPosition(elementId)) { + return undefined; + } + + //If position is taken by other widget, return other's widgets original position + if (this.isCellTaken(position.row, position.column)) { + const otherWidgetPosition = Object.values(untracked(() => this.positionsStateInternal())).find( + (pos) => pos.id !== elementId && pos.includesPoint(position.row, position.column), + ); + + if (!otherWidgetPosition) { + return undefined; + } + + const positionLimits = this.getElementLimits(elementId); + const otherPositionLimits = this.getElementLimits(otherWidgetPosition.id); + + // check the possibility to swap + // to do it both widgets should fit to the limits of each other + const canBeApplied = + this.isFit({ + width: positionLimits.minWidthInCells, + height: positionLimits.minHeightInCells, + availableWidth: otherWidgetPosition.widthInCells, + availableHeight: otherWidgetPosition.heightInCells, + }) && + this.isFit({ + width: otherPositionLimits.minWidthInCells, + height: otherPositionLimits.minHeightInCells, + availableWidth: position.widthInCells, + availableHeight: position.heightInCells, + }); + + return { canBeApplied, position: otherWidgetPosition }; + } + + const result = position.clone(); + this.applyEdgeLimits(result, this._colCount); + + // Determine width / height + let row = 0; + let column = 0; + let heightInCells = 0; + let widthInCells = 0; + for (let r = result.topEdge; r <= result.bottomEdge; r++) { + row = r; + let widthInCellPerRow = 0; + for (let c = result.leftEdge; c <= result.rightEdge; c++) { + column = c; + if (this.isCellTaken(row, column)) { + break; + } + widthInCellPerRow++; + } + widthInCells = Math.max(widthInCells, widthInCellPerRow); + if (this.isCellTaken(row, column)) { + if (heightInCells === 0) { + heightInCells = result.heightInCells; + } + if (widthInCells === 0) { + widthInCells = result.widthInCells; + } + break; + } + heightInCells++; + } + + if (heightInCells <= 0 || widthInCells <= 0) { + return undefined; + } + result.widthInCells = widthInCells; + result.heightInCells = heightInCells; + + const limits = this.getElementLimits(elementId); + const canBeApplied = this.isFit({ + width: limits.minWidthInCells, + height: limits.minHeightInCells, + availableWidth: result.widthInCells, + availableHeight: result.heightInCells, + }); + + return { canBeApplied, position: result }; + } + + correctPositionForResize(elementId: string, position: WidgetPosition): WidgetPosition | undefined { + // Erase information about original position to avoid conflicts for current element + if (!this.clearElementPosition(elementId)) { + return undefined; + } + + const result = position.clone(); + this.applyEdgeLimits(result, this._colCount); + + let r: number; + let c: number; + + // Check top edge + r = result.row; + for (c = result.leftEdge; c <= result.rightEdge; c++) { + while (this.isCellTaken(r, c) && r <= result.bottomEdge + 1) { + r++; + } + } + if (r > result.bottomEdge) { + return undefined; + } + result.row = r; + + // Check right edge + c = result.rightEdge; + for (r = result.topEdge; r <= result.bottomEdge; r++) { + while (this.isCellTaken(r, c) && c >= result.leftEdge - 1) { + c--; + } + } + if (c < result.leftEdge) { + return undefined; + } + result.widthInCells -= result.rightEdge - c; + + // Check bottom edge + r = result.bottomEdge; + for (c = result.leftEdge; c <= result.rightEdge; c++) { + while (this.isCellTaken(r, c) && r >= result.topEdge - 1) { + r--; + } + } + if (r < result.topEdge) { + return undefined; + } + result.heightInCells -= result.bottomEdge - r; + + // Check left edge + c = result.column; + for (r = result.topEdge; r <= result.bottomEdge; r++) { + while (this.isCellTaken(r, c) && c <= result.rightEdge + 1) { + c++; + } + } + if (c > result.rightEdge) { + return undefined; + } + result.column = c; + + // Check inside cells + let row = 0; + let column = 0; + let newHeight = 0; + let newWidth = -1; + for (r = result.topEdge; r <= result.bottomEdge; r++) { + row = r; + let localNewWidth = 0; + for (c = result.leftEdge; c <= result.rightEdge; c++) { + column = c; + if (this.isCellTaken(row, column)) { + break; + } + localNewWidth++; + } + newWidth = newWidth < 0 ? localNewWidth : Math.min(newWidth, localNewWidth); + if (this.isCellTaken(row, column)) { + break; + } + newHeight++; + } + + result.widthInCells = newWidth; + result.heightInCells = newHeight; + + //apply min limits + const { minWidthInCells, minHeightInCells } = this.getElementLimits(elementId); + this.applyMinLimits(result, minWidthInCells, minHeightInCells); + + return result; + } + + findProperPosition(widthInCells: number, heightInCells: number): WidgetPositionParams { + // First search for last widget + const field = untracked(() => this.fieldState()); + const widgetPositions = untracked(() => this.positionsStateInternal()); + + let widgetId: string | undefined = undefined; + + for (let index = field.length - 1; index >= 0; index--) { + if (field[index] !== EMPTY) { + widgetId = this.widgetIDs.getStringIdByNumber(field[index]); + break; + } + } + + // If there is no widgets, define new one at first position + if (widgetId === undefined) { + return { row: 1, column: 1, widthInCells, heightInCells }; + } + + const lastWidgetPosition = widgetPositions[widgetId]; + + // If last widget heights smaller the new widget's height - allocate new widget on the new row + const nextRow = lastWidgetPosition.bottomEdge + 1; + if (lastWidgetPosition.heightInCells < heightInCells) { + return { row: nextRow, column: 1, widthInCells, heightInCells }; + } + + // Check if there is enough available cells in width after last widget + const column = lastWidgetPosition.rightEdge + 1; + const rightEdge = column + widthInCells - 1; + if (rightEdge > this._colCount) { + //If not allocate new widget on the new row + return { row: nextRow, column: 1, widthInCells, heightInCells }; + } + + // In case of available space, allocate new widget just after the last one + return { row: lastWidgetPosition.row, column, widthInCells, heightInCells }; + } + + setHiddenWidgets(widgetsIds: string[]): void { + this.hiddenWidgets.set(widgetsIds); + } + + private initialize(): void { + const isInitialized = untracked(() => this.isInitialized()); + if (isInitialized) { + return; + } + this._gridPersistencePositions.loadPositions(this._gridConfig.gridId).subscribe((positions) => { + this.positionsStateInternal.set(positions); + this.determineInitialPositions(); + this.isInitializedInternal.set(true); + this.setupPositionsSync(); + }); + } + + private isCellTaken(row: number, column: number): boolean { + const field = untracked(() => this.fieldState()); + const index = this.getFieldIndex(row, column); + if (index >= field.length) { + return false; + } + return !!field[index]; + } + + private fillPosition(field: Uint8Array, position: WidgetPosition, isClear?: boolean): void { + const fillValue = isClear ? EMPTY : this.widgetIDs.getNumericIdByString(position.id); + + for (let r = position.topEdge; r <= position.bottomEdge; r++) { + for (let c = position.leftEdge; c <= position.rightEdge; c++) { + const index = this.getFieldIndex(r, c); + field[index] = fillValue; + } + } + } + + private clearElementPosition(elementId: string): boolean { + const originalPosition = untracked(() => this.positionsStateInternal())[elementId]; + if (!originalPosition) { + return false; + } + const field = untracked(() => this.fieldState()); + this.fillPosition(field, originalPosition, true); + return true; + } + + private getFieldIndex(row: number, column: number): number { + return (row - 1) * this._colCount + (column - 1); + } + + private realignPositionsWithHiddenWidgets( + originalPositions: Record, + hiddenWidgets: string[], + ): Record { + const positions = Object.values(originalPositions); + if (!positions.length || !hiddenWidgets?.length) { + return originalPositions; + } + const field = untracked(() => this.fieldState()); + const fieldBottom = untracked(() => this.fieldBottom()); + const hiddenWidgetsNumIds = new Set(hiddenWidgets.map((idStr) => this.widgetIDs.getNumericIdByString(idStr))); + + // This arrays show, how many rows are skipped above each row + const hiddenRowsState: number[] = new Array(fieldBottom); + + for (let row = 1; row <= fieldBottom; row++) { + let hiddenColCount = 0; + for (let col = 1; col <= this._colCount; col++) { + const index = this.getFieldIndex(row, col); + const widgetId = field[index]; + if (hiddenWidgetsNumIds.has(widgetId) || widgetId === EMPTY) { + hiddenColCount++; + } + } + const rowIndex = row - 1; + hiddenRowsState[rowIndex] = rowIndex === 0 ? 0 : hiddenRowsState[rowIndex - 1]; + if (hiddenColCount === this._colCount) { + hiddenRowsState[rowIndex]++; + } + } + + const result = positions.reduce( + (res, position) => { + const idNum = this.widgetIDs.getNumericIdByString(position.id); + if (hiddenWidgetsNumIds.has(idNum)) { + return res; + } + + const updatesPos = position.clone(); + const hiddenRows = hiddenRowsState[updatesPos.row - 1]; + updatesPos.row -= hiddenRows; + res[position.id] = updatesPos; + + return res; + }, + {} as Record, + ); + + return result; + } + + private determineInitialPositions(): void { + for (const info of this._gridConfig.defaultElementParams) { + if (!!this.getPosition(info.id)) { + continue; + } + const positionParams = this.findProperPosition(info.widthInCells, info.heightInCells); + const position = new WidgetPosition(info.id, positionParams); + this.updatePosition(position); + } + } + + private setupPositionsSync(): void { + this.positionsState$ + .pipe( + debounceTime(300), + switchMap((positions) => this._gridPersistencePositions.savePositions(this._gridConfig.gridId, positions)), + takeUntilDestroyed(this._destroyRef), + ) + .subscribe(); + } + + private getElementLimits(elementId: string): Pick { + return this._gridConfig.defaultElementParamsMap?.[elementId] ?? {}; + } + + private applyEdgeLimits(pos: WidgetPosition, maxRightEdgeInCells?: number, maxBottomEdgeInCells?: number): void { + if (pos.row <= 0) { + pos.row = 1; + } + + if (pos.column <= 0) { + pos.column = 1; + } + + if (maxRightEdgeInCells !== undefined) { + if (pos.rightEdge > maxRightEdgeInCells) { + const diff = Math.abs(maxRightEdgeInCells - pos.rightEdge); + pos.widthInCells -= diff; + } + } + + if (maxBottomEdgeInCells !== undefined) { + if (pos.bottomEdge > maxBottomEdgeInCells) { + const diff = Math.abs(maxBottomEdgeInCells - pos.bottomEdge); + pos.heightInCells -= diff; + } + } + } + + private applyMinLimits(pos: WidgetPosition, minWidthInCells: number = 1, minHeightInCells: number = 1): void { + if (pos.row <= 0) { + pos.row = 1; + } + + if (pos.column <= 0) { + pos.column = 1; + } + + pos.widthInCells = Math.max(pos.widthInCells, minWidthInCells); + pos.heightInCells = Math.max(pos.heightInCells, minHeightInCells); + } + + private isFit({ + width, + height, + availableWidth, + availableHeight, + }: { + width?: number; + height?: number; + availableWidth?: number; + availableHeight?: number; + }): boolean { + let isWidthFit = true; + if (width !== undefined && availableWidth !== undefined) { + isWidthFit = width <= availableWidth; + } + let isHeightFit = true; + if (height !== undefined && availableHeight !== undefined) { + isHeightFit = height <= availableHeight; + } + return isWidthFit && isHeightFit; + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/types/grid-element-id.ts b/projects/step-core/src/lib/modules/editable-grid-layout/types/grid-element-id.ts new file mode 100644 index 0000000000..95e7ebe185 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/types/grid-element-id.ts @@ -0,0 +1 @@ +export const gridElementId = (element: HTMLElement): string | undefined => element.dataset?.['gridElementId']; diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/types/position-to-apply.ts b/projects/step-core/src/lib/modules/editable-grid-layout/types/position-to-apply.ts new file mode 100644 index 0000000000..2ee0943330 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/types/position-to-apply.ts @@ -0,0 +1,6 @@ +import { WidgetPosition } from './widget-position'; + +export interface PositionToApply { + readonly canBeApplied: boolean; + readonly position: WidgetPosition; +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-ids.ts b/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-ids.ts new file mode 100644 index 0000000000..4d2156c0c6 --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-ids.ts @@ -0,0 +1,25 @@ +export class WidgetIDs { + private widgetIdsNumStr = new Map(); + private widgetIdsStrNum = new Map(); + + constructor(widgetKeys: string[]) { + widgetKeys.forEach((idStr, index) => { + const idNum = index + 1; + this.widgetIdsNumStr.set(idNum, idStr); + this.widgetIdsStrNum.set(idStr, idNum); + }); + } + + destroy(): void { + this.widgetIdsNumStr.clear(); + this.widgetIdsStrNum.clear(); + } + + getNumericIdByString(idString: string): number { + return this.widgetIdsStrNum.get(idString) ?? -1; + } + + getStringIdByNumber(idNumber: number): string { + return this.widgetIdsNumStr.get(idNumber) ?? ''; + } +} diff --git a/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-position.ts b/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-position.ts new file mode 100644 index 0000000000..f43cc0835f --- /dev/null +++ b/projects/step-core/src/lib/modules/editable-grid-layout/types/widget-position.ts @@ -0,0 +1,47 @@ +export interface WidgetPositionParams { + column: number; + row: number; + widthInCells: number; + heightInCells: number; +} + +export class WidgetPosition implements WidgetPositionParams { + column: number; + row: number; + widthInCells: number; + heightInCells: number; + + constructor( + public readonly id: string, + params: WidgetPositionParams, + ) { + this.column = params.column; + this.row = params.row; + this.widthInCells = params.widthInCells; + this.heightInCells = params.heightInCells; + } + + get leftEdge(): number { + return this.column; + } + + get rightEdge(): number { + return this.column + this.widthInCells - 1; + } + + get topEdge(): number { + return this.row; + } + + get bottomEdge(): number { + return this.row + this.heightInCells - 1; + } + + includesPoint(row: number, column: number): boolean { + return row >= this.topEdge && row <= this.bottomEdge && column >= this.leftEdge && column <= this.rightEdge; + } + + clone(): WidgetPosition { + return new WidgetPosition(this.id, this); + } +} diff --git a/projects/step-core/src/lib/modules/execution-common/index.ts b/projects/step-core/src/lib/modules/execution-common/index.ts index 1e92c1bc27..2832ade935 100644 --- a/projects/step-core/src/lib/modules/execution-common/index.ts +++ b/projects/step-core/src/lib/modules/execution-common/index.ts @@ -8,5 +8,6 @@ export * from './types/execution-params-config'; export * from './types/execution-view-mode'; export * from './pipes/execution-url.pipe'; export * from './pipes/execution-name.pipe'; +export * from './types/execution-report-grid'; export const EXECUTION_COMMON_EXPORTS = [ExecutionUrlPipe, ExecutionNamePipe]; diff --git a/projects/step-core/src/lib/modules/execution-common/types/execution-report-grid.ts b/projects/step-core/src/lib/modules/execution-common/types/execution-report-grid.ts new file mode 100644 index 0000000000..ba68d7d5c1 --- /dev/null +++ b/projects/step-core/src/lib/modules/execution-common/types/execution-report-grid.ts @@ -0,0 +1 @@ +export const EXECUTION_REPORT_GRID = 'executionReportGrid'; diff --git a/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.html b/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.html index 7bb1f57857..7a3d35703d 100644 --- a/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.html +++ b/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.html @@ -3,7 +3,7 @@
diff --git a/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.scss b/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.scss index e139b712fa..fe8fe477b8 100644 --- a/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.scss +++ b/projects/step-core/src/lib/modules/json-viewer-ext/components/json-view-expanded/json-view-expanded.component.scss @@ -16,9 +16,9 @@ align-items: flex-start; padding-top: 0.2rem; padding-bottom: 0.2rem; - padding-left: calc(var(--level, 0) * 1.5rem); + padding-left: calc(var(--style__padding-left, 0) * 1.5rem); &.additional-shift { - padding-left: calc(var(--level, 0) * 1.5rem + 3rem); + padding-left: calc(var(--style__padding-left, 0) * 1.5rem + 3rem); } margin-left: 0.1rem; border-radius: 1rem; diff --git a/projects/step-core/src/lib/modules/routing/injectables/view-registry.service.ts b/projects/step-core/src/lib/modules/routing/injectables/view-registry.service.ts index 31e967a01f..2c82782eb0 100644 --- a/projects/step-core/src/lib/modules/routing/injectables/view-registry.service.ts +++ b/projects/step-core/src/lib/modules/routing/injectables/view-registry.service.ts @@ -40,10 +40,10 @@ export class ViewRegistryService implements OnDestroy { private isNavigationInitializedInternal$ = new BehaviorSubject(false); readonly isNavigationInitialized$ = this.isNavigationInitializedInternal$.asObservable(); - registeredViews: { [key: string]: CustomView } = {}; + registeredViews: Record = {}; registeredMenuEntries: MenuEntry[] = []; registeredMenuIds: string[] = []; - registeredDashlets: { [key: string]: Dashlet[] | undefined } = {}; + registeredDashlets: Record = {}; private static registeredRoutes: string[] = []; @@ -69,7 +69,7 @@ export class ViewRegistryService implements OnDestroy { /** * Registers basic set of main- and submenu entries */ - registerStandardMenuEntries() { + registerStandardMenuEntries(): void { // Main Menus this.registerMenuEntry('Design', 'automation-root', 'edit', { weight: 10 }); this.registerMenuEntry('Reporting', 'execute-root', 'file-check-03', { weight: 20 }); @@ -135,7 +135,7 @@ export class ViewRegistryService implements OnDestroy { * @deprecated use getCustomView instead * @param view */ - getViewTemplate(view: string) { + getViewTemplate(view: string): string { return this.getCustomView(view).template; } @@ -143,7 +143,7 @@ export class ViewRegistryService implements OnDestroy { * @deprecated use getCustomView instead * @param view */ - isPublicView(view: string) { + isPublicView(view: string): boolean { return this.getCustomView(view).isPublicView; } @@ -151,7 +151,7 @@ export class ViewRegistryService implements OnDestroy { * @deprecated use getCustomView instead * @param view */ - isStaticView(view: string) { + isStaticView(view: string): boolean | undefined { return this.getCustomView(view).isStaticView; } @@ -320,7 +320,7 @@ export class ViewRegistryService implements OnDestroy { const dashlets = this.getDashletsInternal(path); // weightless dashlets should be last - const normalizeWeight = (weight?: number) => weight || Infinity; + const normalizeWeight = (weight?: number): number => weight || Infinity; const result = dashlets.sort((a, b) => normalizeWeight(a.weight) - normalizeWeight(b.weight)); return result; diff --git a/projects/step-core/src/lib/step-core.module.ts b/projects/step-core/src/lib/step-core.module.ts index dbddae5e05..f676ddb857 100644 --- a/projects/step-core/src/lib/step-core.module.ts +++ b/projects/step-core/src/lib/step-core.module.ts @@ -79,6 +79,7 @@ import { KEYWORDS_COMMON_IMPORTS } from './modules/keywords-common'; import { RESOURCE_INPUT_IMPORTS } from './modules/resource-input'; import { ScreenInputTypePipe } from './pipes/screen-input-type.pipe'; import { ScreenInputOptionsPipe } from './pipes/screen-input-options.pipe'; +import { EDITABLE_GRID_LAYOUT_EXPORTS } from './modules/editable-grid-layout'; @NgModule({ declarations: [ @@ -154,6 +155,7 @@ import { ScreenInputOptionsPipe } from './pipes/screen-input-options.pipe'; ARTEFACTS_COMMON_EXPORTS, ATTACHMENTS_EXPORTS, SEARCH_EXPORTS, + EDITABLE_GRID_LAYOUT_EXPORTS, ClampFadeDirective, ], exports: [ @@ -225,6 +227,7 @@ import { ScreenInputOptionsPipe } from './pipes/screen-input-options.pipe'; ExtractUrlPipe, ExtractQueryParamsPipe, IncludesStringPipe, + EDITABLE_GRID_LAYOUT_EXPORTS, ], providers: [ CORE_INITIALIZER, @@ -360,3 +363,4 @@ export * from './pipes/extract-url.pipe'; export * from './pipes/extract-query-params.pipe'; export * from './modules/search'; export * from './shared/no-access-entity-error'; +export * from './modules/editable-grid-layout'; diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-errors-widget/alt-execution-errors-widget.component.html b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-errors-widget/alt-execution-errors-widget.component.html index c55c4e8b60..0c5eca547f 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-errors-widget/alt-execution-errors-widget.component.html +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-errors-widget/alt-execution-errors-widget.component.html @@ -26,5 +26,6 @@ } } + diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-progress/alt-execution-progress.component.ts b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-progress/alt-execution-progress.component.ts index df46365a6d..8f59443a17 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-progress/alt-execution-progress.component.ts +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-progress/alt-execution-progress.component.ts @@ -9,6 +9,7 @@ import { signal, viewChild, ViewEncapsulation, + model, } from '@angular/core'; import { catchError, @@ -177,6 +178,8 @@ export class AltExecutionProgressComponent protected readonly isSmallScreen = toSignal(this._isSmallScreen$); private readonly toggleRequestWarning = viewChild('requestWarningRef', { read: ToggleRequestWarningDirective }); + readonly gridEditMode = model(false); + readonly timeRangeOptions: TimeRangePickerSelection[] = [ { type: 'FULL' }, ...TimeSeriesConfig.EXECUTION_PAGE_TIME_SELECTION_OPTIONS, diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.html b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.html index 0eb22ffb18..98fd8d8de0 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.html +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.html @@ -3,6 +3,20 @@ +@if (!(_isSmallScreen$ | async)) { + + + +} diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.scss b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.scss index e69de29bb2..9a23645336 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.scss +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.scss @@ -0,0 +1,15 @@ +@use 'projects/step-core/styles/core-variables' as var; + +:host { + display: flex; + align-items: center; + gap: 1rem; +} + +button.active { + background: var.$blue-10; + + step-icon { + color: var.$dark-blue; + } +} diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.ts b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.ts index f1f676e228..34a356360b 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.ts +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report-controls/alt-execution-report-controls.component.ts @@ -1,5 +1,7 @@ import { Component, inject } from '@angular/core'; import { AltExecutionReportPrintService } from '../../services/alt-execution-report-print.service'; +import { AltExecutionStateService } from '../../services/alt-execution-state.service'; +import { IS_SMALL_SCREEN } from '@exense/step-core'; @Component({ selector: 'step-alt-execution-report-controls', @@ -8,5 +10,11 @@ import { AltExecutionReportPrintService } from '../../services/alt-execution-rep standalone: false, }) export class AltExecutionReportControlsComponent { + protected _executionsState = inject(AltExecutionStateService); protected _printService = inject(AltExecutionReportPrintService); + protected readonly _isSmallScreen$ = inject(IS_SMALL_SCREEN); + + protected toggleGridEdit(): void { + this._executionsState.gridEditMode.update((value) => !value); + } } diff --git a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report/alt-execution-report.component.html b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report/alt-execution-report.component.html index 5913c857f0..fd938a83f2 100644 --- a/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report/alt-execution-report.component.html +++ b/projects/step-frontend/src/lib/modules/execution/components/alt-execution-report/alt-execution-report.component.html @@ -1,8 +1,11 @@ -
+ @if (_state.errors$ | async; as errors) { } @if (_testCasesState.summary$ | async; as testCasesSummary) { } @if (_mode === ViewMode.VIEW) { - + } @if (_mode === ViewMode.VIEW) { } @if (_state.executionId$ | async; as executionId) { } - + + + + + + @if (_state.currentOperations$ | async; as currentOperations) { - + } @if (_state.execution$ | async; as execution) { @for (panel of customPanels; track panel.type) { - + } } - @if (_mode === ViewMode.PRINT) { - - } -
+ @if (_mode === ViewMode.PRINT) {