|
dashboardViewId
|
Type : string
|
|
Required : true
|
|
|
Methods
|
Async
addNewWidget
|
addNewWidget()
|
|
|
|
|
|
removeWidget
|
removeWidget(index: number)
|
|
|
Parameters :
| Name |
Type |
Optional |
| index |
number
|
No
|
|
|
dashboardConfig
|
Type : unknown
|
Default value : linkedSignal(() =>
structuredClone(this.dashboardViewConfig()?.config ?? { widgets: [] }),
)
|
|
|
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from "@angular/cdk/drag-drop";
import { Location } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
linkedSignal,
} from "@angular/core";
import { FormsModule } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatCardModule } from "@angular/material/card";
import { MatDialog } from "@angular/material/dialog";
import { MatIconModule } from "@angular/material/icon";
import { MatSnackBar } from "@angular/material/snack-bar";
import { MatTooltipModule } from "@angular/material/tooltip";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { firstValueFrom } from "rxjs";
import { WidgetComponentSelectComponent } from "../../admin/admin-entity-details/widget-component-select/widget-component-select.component";
import {
AdminWidgetDialogComponent,
AdminWidgetDialogData,
} from "../../admin/admin-widget-dialog/admin-widget-dialog.component";
import { ViewTitleComponent } from "../../common-components/view-title/view-title.component";
import { ConfigService } from "../../config/config.service";
import { DynamicComponentConfig } from "../../config/dynamic-components/dynamic-component-config.interface";
import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive";
import { PREFIX_VIEW_CONFIG } from "../../config/dynamic-routing/view-config.interface";
import { DashboardConfig } from "../../dashboard/dashboard/dashboard.component";
import { DashboardWidgetRegistryService } from "../dashboard-widget-registry.service";
import { HintBoxComponent } from "#src/app/core/common-components/hint-box/hint-box.component";
import { IconButtonComponent } from "#src/app/core/common-components/icon-button/icon-button.component";
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-admin-dashboard",
standalone: true,
imports: [
DynamicComponentDirective,
DragDropModule,
FaIconComponent,
FormsModule,
MatButtonModule,
MatCardModule,
MatTooltipModule,
MatIconModule,
ViewTitleComponent,
HintBoxComponent,
IconButtonComponent,
],
templateUrl: "./admin-dashboard.component.html",
styleUrls: [
"./admin-dashboard.component.scss",
"../../dashboard/dashboard/dashboard.component.scss",
],
})
export class AdminDashboardComponent {
dashboardViewId = input.required<string>();
private readonly dashboardViewConfigKey = computed(
() => PREFIX_VIEW_CONFIG + this.dashboardViewId(),
);
private readonly dashboardViewConfig = computed(
() =>
this.configService.getConfig(this.dashboardViewConfigKey()) as
DynamicComponentConfig<DashboardConfig> | undefined,
);
dashboardConfig = linkedSignal(() =>
structuredClone(this.dashboardViewConfig()?.config ?? { widgets: [] }),
);
private readonly configService = inject(ConfigService);
private readonly dialog = inject(MatDialog);
private readonly location = inject(Location);
private readonly snackBar = inject(MatSnackBar);
private readonly widgetRegistry = inject(DashboardWidgetRegistryService);
drop(event: CdkDragDrop<DynamicComponentConfig[]>) {
if (event.previousContainer === event.container) {
this.dashboardConfig.update((config) => {
const widgets = [...config.widgets];
moveItemInArray(widgets, event.previousIndex, event.currentIndex);
return { ...config, widgets };
});
}
}
async editWidget(widgetConfig: DynamicComponentConfig, idx: number) {
const settingsComponent = this.widgetRegistry.getSettingsComponentForWidget(
widgetConfig.component,
);
if (!settingsComponent) return;
const updatedConfig = await this.openWidgetSettingsDialog(
widgetConfig,
settingsComponent,
);
if (updatedConfig) {
this.dashboardConfig.update((config) => ({
...config,
widgets: config.widgets.map((w, i) => (i === idx ? updatedConfig : w)),
}));
}
}
private async openWidgetSettingsDialog(
widgetConfig: DynamicComponentConfig,
settingsComponent: string,
): Promise<DynamicComponentConfig | undefined> {
const dialogData: AdminWidgetDialogData = {
widgetConfig: { ...widgetConfig },
settingsComponent: settingsComponent,
title: widgetConfig.component,
};
const dialogRef = this.dialog.open(AdminWidgetDialogComponent, {
width: "80vw",
data: dialogData,
});
return firstValueFrom(dialogRef.afterClosed());
}
removeWidget(index: number) {
this.dashboardConfig.update((config) => ({
...config,
widgets: config.widgets.filter((_, i) => i !== index),
}));
}
async addNewWidget() {
// Open the widget selection dialog for dashboard widgets
const dialogRef = this.dialog.open(WidgetComponentSelectComponent, {
width: "400px",
data: { isDashboard: true },
});
const selectedWidget = await firstValueFrom(dialogRef.afterClosed());
if (!selectedWidget) return;
this.dashboardConfig.update((config) => ({
...config,
widgets: [...config.widgets, selectedWidget],
}));
// Open settings dialog when no config was pre-filled, so user can set required fields
const hasNoConfig =
!selectedWidget.config || Object.keys(selectedWidget.config).length === 0;
if (hasNoConfig) {
const newIndex = this.dashboardConfig().widgets.length - 1;
await this.editWidget(selectedWidget, newIndex);
}
}
cancel() {
this.location.back();
}
async save() {
const currentConfig = this.configService.exportConfig(true);
const updatedViewConfig: DynamicComponentConfig<DashboardConfig> = {
component: "Dashboard",
config: this.dashboardConfig(),
};
currentConfig[this.dashboardViewConfigKey()] = updatedViewConfig;
await this.configService.saveConfig(currentConfig);
this.snackBar.open(
$localize`:Save config confirmation message:Dashboard configuration updated`,
undefined,
{ duration: 4000 },
);
this.location.back();
}
}
<div class="padding-regular flex-row flex-wrap justify-space-between">
<app-view-title [displayInPlace]="true" i18n>
Edit Dashboard Widgets
</app-view-title>
<div class="flex-row gap-small">
<button mat-raised-button color="accent" (click)="save()" i18n>Save</button>
<button mat-stroked-button (click)="cancel()" i18n>Cancel</button>
</div>
</div>
<app-hint-box i18n>
Drag and drop the widgets to reorder them. You can configure details of each
widget by clicking on them. Add additional widgets or remove widgets to fully
customize the dashboard to your needs. The dashboard layout is shared by all
users, your changes will be visible to everyone.
</app-hint-box>
<!-- WIDGET PREVIEW AREA -->
<div
cdkDropList
[cdkDropListData]="dashboardConfig().widgets"
(cdkDropListDropped)="drop($event)"
cdkDropListOrientation="mixed"
class="widgets-container drop-list"
>
@for (
widgetConfig of dashboardConfig().widgets;
track $index;
let i = $index
) {
<mat-card
cdkDrag
cdkDragBoundary=".overall-container"
class="widget-preview-card"
>
<fa-icon
icon="grip-vertical"
size="xl"
class="widget-action-icon drag-handle"
matTooltip="You can rearrange the dashboard sections to highlight the most important information first."
i18n-matTooltip
></fa-icon>
<button
mat-icon-button
class="widget-action-icon widget-remove-icon"
color="black"
(click)="removeWidget(i)"
matTooltip="Remove this widget"
i18n-matTooltip
>
<fa-icon icon="times"></fa-icon>
</button>
<mat-card-content class="widget-preview-section">
<!-- Widget Content with Overlay Edit Button -->
<ng-template [appDynamicComponent]="widgetConfig"></ng-template>
<!-- Centered Edit Button Overlay -->
<div class="field-hover-buttons">
<button
mat-stroked-button
color="accent"
class="field-edit-button"
(click)="editWidget(widgetConfig, i)"
i18n
>
Edit Widget
</button>
</div>
</mat-card-content>
</mat-card>
}
<!-- DROP AREA: ADD NEW WIDGET -->
<mat-card class="widget-preview-card drop-area-hint">
<mat-card-content class="add-widget-content">
<app-icon-button
icon="plus-circle"
buttonType="mat-stroked-button"
color="accent"
(buttonClick)="addNewWidget()"
>
<span i18n>Add Widget</span>
</app-icon-button>
</mat-card-content>
</mat-card>
</div>
@use "variables/sizes";
@use "@angular/material/core/style/elevation" as mat-elevation;
@use "variables/colors";
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-button {
margin-right: 8px;
}
::ng-deep app-dashboard-widget {
pointer-events: none;
/* dim preview of actual widgets to highlight admin elements */
opacity: 35%;
}
.widget-interaction-blocker {
position: absolute;
inset: 0;
z-index: 10;
}
.widgets-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
height: auto;
overflow: visible;
grid-auto-rows: unset !important;
}
.widget-preview-card {
position: relative;
border: 2px dotted #ccc;
border-radius: 8px;
overflow: hidden;
&:hover {
border-color: #999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:hover .field-hover-buttons {
opacity: 1;
}
&.drop-area-hint {
border: 2px dashed #ccc;
background-color: #f9f9f9;
min-height: 250px;
&:hover {
border-color: #999;
background-color: #f5f5f5;
}
}
}
.widget-action-icon {
margin: .5em;
}
.widget-preview-section {
padding: 2.5em;
}
.add-widget-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #666;
padding: 2rem;
min-height: 200px;
span {
margin-bottom: 1rem;
font-size: 1rem;
}
}
.add-widget-button {
margin-top: 1rem;
}
.drag-handle {
position: absolute;
top: 50%;
left: -8px;
color: colors.$accent;
cursor: move;
z-index: 20;
transform: translateY(-50%);
padding: 10px;
border-radius: 50%;
}
.widget-remove-icon {
position: absolute;
top: -8px;
right: -8px;
color: black;
font-size: 20px;
z-index: 20;
cursor: pointer;
padding: 10px;
border-radius: 50%;
&:hover {
color: #ff0000;
}
}
// Centered Edit Button
.field-hover-buttons {
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 15;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
}
.field-edit-button {
/* large button to make edit option obvious */
padding: 2.5em;
background: white !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid #ddd;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
.widget-preview-card.widget-remove-hover {
border-color: rgb(255, 0, 0);
background-color: rgba(255, 0, 0, 0.05);
}
// Override Material Card styles for consistency
::ng-deep .widget-preview-card {
.mat-card-header {
padding: 0;
margin: 0;
}
.mat-card-content {
margin: 0;
padding: 0 !important;
height: 100%;
}
}
.widget-container:has(.widget-remove-icon:hover) .widget-preview-card {
border-color: rgb(255, 0, 0) !important;
background-color: rgba(255, 0, 0, 0.1) !important;
}
::ng-deep .cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow:
0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
::ng-deep .cdk-drag-placeholder {
opacity: 0;
}
::ng-deep .cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
::ng-deep
.drop-list.cdk-drop-list-dragging
.widget-preview-card:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
::ng-deep .cdk-drop-list-receiving {
background-color: rgba(0, 0, 0, 0.03);
border: 2px dashed #ccc;
}
@use "mixins/grid-layout";
@use "variables/sizes";
/* The minimum size of a widget for big screens */
/* The minimum size on small screens is whatever size is available */
$min-widget-width: 330px;
/* There will only be one column below this screen width, as defined by the media query */
$max-screen-width: $min-widget-width + sizes.$margin-main-view-right +
sizes.$margin-main-view-left;
$widget-height: 430px;
.widgets-container {
/* Make all rows be equal in height */
grid-auto-rows: $widget-height;
@include grid-layout.adaptive($min-widget-width, $max-screen-width);
}
.grid-item {
transition: all 1s;
}
.dashboard-menu-btn {
position: fixed;
top: 80px; // Adjust based on your header height
right: 20px;
z-index: 1000;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 50%;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
Legend
Html element with directive