|
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: [] }),
)
|
|
|
|
Readonly
previewWidgets
|
Type : unknown
|
Default value : computed(() =>
this.dashboardConfig().widgets.map((widget) => {
let resolved = this.resolvedWidgets.get(widget);
if (!resolved) {
resolved = resolveLocaleConfig(widget);
this.resolvedWidgets.set(widget, resolved);
}
return resolved;
}),
)
|
|
|
resolved copies, for the live widget preview only.
Cached per widget, because a preview gets rebuilt (and reloads its data)
whenever it is passed a new object: editing one widget must not rebuild all.
|
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 { resolveLocaleConfig } from "../../language/active-locale";
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(),
);
// raw, because save() writes this back to the config document - a resolved
// value would drop every other configured language
private readonly dashboardViewConfig = computed(() =>
this.configService.getRawConfig<DynamicComponentConfig<DashboardConfig>>(
this.dashboardViewConfigKey(),
),
);
dashboardConfig = linkedSignal(() =>
structuredClone(this.dashboardViewConfig()?.config ?? { widgets: [] }),
);
/**
* resolved copies, for the live widget preview only.
* Cached per widget, because a preview gets rebuilt (and reloads its data)
* whenever it is passed a new object: editing one widget must not rebuild all.
*/
readonly previewWidgets = computed(() =>
this.dashboardConfig().widgets.map((widget) => {
let resolved = this.resolvedWidgets.get(widget);
if (!resolved) {
resolved = resolveLocaleConfig(widget);
this.resolvedWidgets.set(widget, resolved);
}
return resolved;
}),
);
private readonly resolvedWidgets = new WeakMap<
DynamicComponentConfig,
DynamicComponentConfig
>();
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]="previewWidgets()[i]"></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-inline-end: 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: 0.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%;
inset-inline-start: -8px;
color: colors.$accent;
cursor: move;
z-index: 20;
transform: translateY(-50%);
padding: 10px;
border-radius: 50%;
}
.widget-remove-icon {
position: absolute;
top: -8px;
inset-inline-end: -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%;
// physical `left` on purpose: this centers the element geometrically together with the
// transform below, so it must not mirror - `inset-inline-start` would offset it by its
// own width in RTL, because the transform pulls towards the same edge in both directions.
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
inset-inline-end: 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