+ } @else if (groups_failed()) {
+
+ {{ 'SIGNAGE_MANAGER.GROUPS_LOAD_ERROR' | translate }}
+
} @else {
{{ 'SIGNAGE_MANAGER.NO_MANAGEABLE_GROUPS' | translate }}
@@ -265,18 +265,21 @@ export class SignageGroupListComponent {
private readonly _group_admin = inject(SignageGroupAdminService);
public readonly groups = this._group_admin.manageable_signage_groups;
- public readonly root_groups =
- this._group_admin.root_manageable_signage_groups;
+ public readonly groups_failed =
+ this._group_admin.manageable_signage_groups_failed;
public readonly selected_group = this._group_admin.managed_group;
public readonly search = signal('');
public readonly expanded_groups =
this._group_admin.signage_group_tree_expanded;
- public readonly tree_nodes = signal([]);
public readonly show_search_results = computed(
() => !!this.search().trim(),
);
public readonly levelAccessor = (row: GroupListRow) => row.level;
public readonly trackByRow = (_: number, row: GroupListRow) => row.group.id;
+ // The tree caches levels by this key. Rows are new objects on every
+ // rebuild, so keying by object would lose the level of reused rows.
+ public readonly expansionKey = (row: GroupListRow) => row.group.id;
+ /** Child groups of each group, sorted by name */
public readonly child_lookup = computed(() => {
const lookup: Record = {};
for (const group of this.groups()) {
@@ -300,241 +303,70 @@ export class SignageGroupListComponent {
group.id.toLowerCase().includes(search),
);
});
+ /**
+ * Rows of the group tree, built from the full list of manageable groups.
+ * Groups whose parent is not in the list are roots. Children show when
+ * their parent is expanded.
+ */
public readonly visible_group_rows = computed(() => {
+ const groups = this.groups();
+ const lookup = this.child_lookup();
+ const expanded = this.expanded_groups();
+ const ids = new Set(groups.map(({ id }) => id));
const rows: GroupListRow[] = [];
- for (const node of this.tree_nodes()) {
- this.flattenNode(node, 0, rows);
+ // Each group shows once, so a parent cycle cannot loop forever
+ const seen = new Set();
+ const visit = (group: PlaceGroup, level: number) => {
+ if (seen.has(group.id)) return;
+ seen.add(group.id);
+ rows.push({ group, level });
+ if (!expanded[group.id]) return;
+ for (const child of lookup[group.id] || []) visit(child, level + 1);
+ };
+ for (const group of groups) {
+ if (!group.parent_id || !ids.has(group.parent_id)) visit(group, 0);
}
return rows;
});
constructor() {
+ // Open the branches down to the selected group, so it is visible
effect(() => {
- const root_groups = this.root_groups();
- const existing_roots = untracked(() => this.tree_nodes());
- this.tree_nodes.set(
- root_groups.map((group) => {
- const existing = existing_roots.find(
- (node) => node.group.id === group.id,
- );
- return existing
- ? this.syncNode(existing)
- : this.createNode(group);
- }),
- );
- });
-
- effect(() => {
- this.groups();
+ const groups = this.groups();
const selected_group = this.selected_group();
if (this.show_search_results() || !selected_group?.id) return;
- untracked(() => this.syncSelectedPath(selected_group.id));
+ untracked(() => this._expandPath(selected_group, groups));
});
}
- public onExpandedChange(node: GroupTreeNode, expanded: boolean) {
+ public setExpanded(group: PlaceGroup, expanded: boolean) {
this.expanded_groups.update((state) => ({
...state,
- [node.group.id]: expanded,
+ [group.id]: expanded,
}));
- if (
- !expanded ||
- this.hasLoadedChildren(node) ||
- node.children_loading
- ) {
- return;
- }
- this.tree_nodes.update((nodes) =>
- this.updateNode(nodes, node.group.id, (item) => ({
- ...item,
- children_loading: true,
- })),
- );
- this.loadChildren(node.group.id);
}
- public isExpanded(group_or_node: GroupTreeNode | PlaceGroup | string) {
- return !!this.expanded_groups()[this.getGroupId(group_or_node)];
+ public isExpanded(group: PlaceGroup) {
+ return !!this.expanded_groups()[group.id];
}
- public childCount(group_or_id: GroupTreeNode | PlaceGroup | string) {
- if (
- typeof group_or_id !== 'string' &&
- 'children_loaded' in group_or_id
- ) {
- if (group_or_id.children_loaded) return group_or_id.children.length;
- group_or_id = group_or_id.group;
- }
- const group_id = this.getGroupId(group_or_id);
- return (
- this.child_lookup()[group_id]?.length ||
- (typeof group_or_id === 'string'
- ? 0
- : group_or_id.children_count || 0)
- );
+ public childCount(group: PlaceGroup) {
+ return this.child_lookup()[group.id]?.length || 0;
}
public selectGroup(group: PlaceGroup) {
this._group_admin.managed_group_id.set(group.id);
}
- private createNode(group: PlaceGroup): GroupTreeNode {
- return {
- group,
- children: [],
- children_loaded: false,
- children_loading: false,
- };
- }
-
- private async loadChildren(group_id: string) {
- const children = await this._group_admin
- .groupChildren(group_id)
- .catch(() => this.child_lookup()[group_id] || []);
- this.applyLoadedChildren(group_id, children);
- }
-
- private applyLoadedChildren(group_id: string, children: PlaceGroup[]) {
- this.tree_nodes.update((nodes) =>
- this.updateNode(nodes, group_id, (item) => ({
- ...item,
- children_loaded: true,
- children_loading: false,
- children: children.map((group) => {
- const existing = item.children.find(
- (child) => child.group.id === group.id,
- );
- return existing
- ? this.syncNode(existing)
- : this.createNode(group);
- }),
- })),
- );
- }
-
- private syncNode(node: GroupTreeNode): GroupTreeNode {
- const group = this.findGroup(node.group.id) || node.group;
- if (!node.children_loaded) return { ...node, group };
- const children = node.children.map((child) => this.syncNode(child));
- return { ...node, group, children };
- }
-
- private findGroup(group_id: string) {
- return this.groups().find(({ id }) => id === group_id);
- }
-
- private getGroupPath(group_id: string) {
- const groups = this.groups();
- const root_ids = new Set(
- this.tree_nodes().map(({ group }) => group.id),
- );
- if (!group_id || !root_ids.size) return [];
- if (root_ids.has(group_id)) return [group_id];
- const group_path = [group_id];
- // Stops on a repeated group, so a parent cycle cannot loop forever
- const seen = new Set(group_path);
- let current_group = groups.find((group) => group.id === group_id);
- while (current_group?.parent_id && !seen.has(current_group.parent_id)) {
- seen.add(current_group.parent_id);
- group_path.unshift(current_group.parent_id);
- if (root_ids.has(current_group.parent_id)) {
- return group_path;
- }
- current_group = groups.find(
- (group) => group.id === current_group.parent_id,
- );
- }
- return root_ids.has(group_path[0]) ? group_path : [];
- }
-
- private getExpansionPath(group_id: string) {
- const group_path = this.getGroupPath(group_id);
- if (!group_path.length) return [];
- return this.childCount(group_id) > 0
- ? group_path
- : group_path.slice(0, -1);
- }
-
- private syncSelectedPath(group_id: string) {
- this.ensureGroupPathLoaded(group_id);
- this.expandGroupPath(group_id);
- }
-
- private ensureGroupPathLoaded(group_id: string) {
- for (const current_group_id of this.getExpansionPath(group_id)) {
- const node = this.findTreeNode(this.tree_nodes(), current_group_id);
- if (node?.children_loaded) continue;
- this.loadChildren(current_group_id);
- }
- }
-
- private expandGroupPath(group_id: string) {
- const expansion_path = this.getExpansionPath(group_id);
- if (!expansion_path.length) return;
- const state = untracked(() => this.expanded_groups());
- let changed = false;
+ // Expands the ancestors of the group, and the group itself when it has
+ // children
+ private _expandPath(group: PlaceGroup, groups: PlaceGroup[]) {
+ const path = groupHierarchy(group, groups);
+ if (!this.childCount(group)) path.pop();
+ const state = this.expanded_groups();
+ if (path.every(({ id }) => state[id])) return;
const next_state = { ...state };
- for (const current_group_id of expansion_path) {
- if (next_state[current_group_id]) continue;
- next_state[current_group_id] = true;
- changed = true;
- }
- if (changed) {
- this.expanded_groups.set(next_state);
- }
- }
-
- private getGroupId(group_or_node: GroupTreeNode | PlaceGroup | string) {
- if (typeof group_or_node === 'string') return group_or_node;
- return 'children_loaded' in group_or_node
- ? group_or_node.group.id
- : group_or_node.id;
- }
-
- private hasLoadedChildren(node: GroupTreeNode) {
- return (
- node.children_loaded &&
- (node.children.length > 0 || this.childCount(node.group.id) === 0)
- );
- }
-
- private findTreeNode(
- nodes: GroupTreeNode[],
- group_id: string,
- ): GroupTreeNode | null {
- for (const node of nodes) {
- if (node.group.id === group_id) return node;
- if (!node.children.length) continue;
- const child_node = this.findTreeNode(node.children, group_id);
- if (child_node) return child_node;
- }
- return null;
- }
-
- private updateNode(
- nodes: GroupTreeNode[],
- group_id: string,
- callback: (node: GroupTreeNode) => GroupTreeNode,
- ): GroupTreeNode[] {
- return nodes.map((node) => {
- if (node.group.id === group_id) return callback(node);
- if (!node.children.length) return node;
- return {
- ...node,
- children: this.updateNode(node.children, group_id, callback),
- };
- });
- }
-
- private flattenNode(
- node: GroupTreeNode,
- level: number,
- flat_nodes: GroupListRow[],
- ) {
- flat_nodes.push({ ...node, level });
- if (!this.isExpanded(node)) return;
- for (const child of node.children) {
- this.flattenNode(child, level + 1, flat_nodes);
- }
+ for (const { id } of path) next_state[id] = true;
+ this.expanded_groups.set(next_state);
}
}
diff --git a/apps/signage-manager/src/app/groups/signage-group-permission-labels.component.ts b/apps/signage-manager/src/app/groups/signage-group-permission-labels.component.ts
new file mode 100644
index 00000000000..6efca312506
--- /dev/null
+++ b/apps/signage-manager/src/app/groups/signage-group-permission-labels.component.ts
@@ -0,0 +1,28 @@
+import { Component, computed, input } from '@angular/core';
+import { TranslatePipe } from '@placeos/components';
+import { groupPermissionLabels } from './signage-group-permissions-modal.component';
+
+/** Comma separated labels of a group permission mask, or "default" when the
+ * mask sets no permission. Used by the user, zone and AD group rows. */
+@Component({
+ selector: 'signage-group-permission-labels',
+ template: `
+ @for (label of labels(); track label) {
+ {{ label | translate }}
+ @if (!$last) {
+ ,
+ }
+ } @empty {
+ {{
+ 'SIGNAGE_MANAGER.DEFAULT_PERMISSIONS' | translate
+ }}
+ }
+ `,
+ imports: [TranslatePipe],
+})
+export class SignageGroupPermissionLabelsComponent {
+ public readonly permissions = input(0);
+ public readonly labels = computed(() =>
+ groupPermissionLabels(this.permissions()),
+ );
+}
diff --git a/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts b/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts
index eee6f2270d1..d144fd24c5d 100644
--- a/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts
+++ b/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts
@@ -1,14 +1,18 @@
-import { Component, inject } from '@angular/core';
+import { Component, ElementRef, inject } from '@angular/core';
import { TranslatePipe } from '@placeos/components';
import { SignageGroupAdminService } from './signage-group-admin.service';
+type GroupTab = 'users' | 'zones';
+
@Component({
selector: 'signage-group-tabs',
imports: [TranslatePipe],
template: `
-
@for (tab of tabs; track tab.id) {
{{ tab.label | translate }}
}
-
+
`,
})
export class SignageGroupTabsComponent {
private readonly _group_admin = inject(SignageGroupAdminService);
+ private readonly _element = inject
>(ElementRef);
public readonly active_tab = this._group_admin.managed_group_tab;
- public readonly tabs = [
- { id: 'users' as const, label: 'SIGNAGE_MANAGER.TAB_USERS' },
- { id: 'zones' as const, label: 'SIGNAGE_MANAGER.TAB_ZONES' },
+ public readonly tabs: { id: GroupTab; label: string }[] = [
+ { id: 'users', label: 'SIGNAGE_MANAGER.TAB_USERS' },
+ { id: 'zones', label: 'SIGNAGE_MANAGER.TAB_ZONES' },
];
+
+ /** Arrow keys, Home and End move between the tabs, as in a tab list */
+ public onKeydown(event: KeyboardEvent) {
+ const index = this.tabs.findIndex(({ id }) => id === this.active_tab());
+ const last = this.tabs.length - 1;
+ const targets: Record = {
+ ArrowLeft: index > 0 ? index - 1 : last,
+ ArrowRight: index < last ? index + 1 : 0,
+ Home: 0,
+ End: last,
+ };
+ const next = targets[event.key];
+ if (next === undefined) return;
+ event.preventDefault();
+ const tab = this.tabs[next].id;
+ this.active_tab.set(tab);
+ this._element.nativeElement
+ .querySelector(`#group-${tab}-tab`)
+ ?.focus();
+ }
}
diff --git a/apps/signage-manager/src/app/groups/signage-group-user-select-modal.component.ts b/apps/signage-manager/src/app/groups/signage-group-user-select-modal.component.ts
index eba645c89ac..7505f4b1efc 100644
--- a/apps/signage-manager/src/app/groups/signage-group-user-select-modal.component.ts
+++ b/apps/signage-manager/src/app/groups/signage-group-user-select-modal.component.ts
@@ -11,6 +11,7 @@ import { MatRippleModule } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { SignageGroupAdminService } from './signage-group-admin.service';
@@ -49,7 +50,21 @@ import { SignageGroupAdminService } from './signage-group-admin.service';
"
/>
- @if (users().length > 0) {
+ @if (loading()) {
+
+
+
+ } @else if (failed()) {
+
+
error
+
+ {{ 'SIGNAGE_MANAGER.USER_SEARCH_ERROR' | translate }}
+
+
+ } @else if (users().length > 0) {
@for (user of users(); track user.id || user.email) {
this._search_debounced.value() ?? '',
loader: ({ params }) => this._group_admin.searchGroupUsers(params),
});
+ public readonly loading = computed(() => this._users.isLoading());
+ /** The search failed. Reading the value of a failed resource throws. */
+ public readonly failed = computed(() => !!this._users.error());
public readonly users = computed(() => {
+ if (!this._users.hasValue()) return [];
const exclude_ids = new Set(this._data.exclude_ids || []);
- return (this._users.value() || []).filter(
- (user) => !exclude_ids.has(user.id) && !exclude_ids.has(user.email),
- );
+ return this._users
+ .value()
+ .filter(
+ (user) =>
+ !exclude_ids.has(user.id) && !exclude_ids.has(user.email),
+ );
});
}
diff --git a/apps/signage-manager/src/app/groups/signage-group-users.component.ts b/apps/signage-manager/src/app/groups/signage-group-users.component.ts
index 5431fa3c43c..58c8abf9dc8 100644
--- a/apps/signage-manager/src/app/groups/signage-group-users.component.ts
+++ b/apps/signage-manager/src/app/groups/signage-group-users.component.ts
@@ -8,10 +8,8 @@ import { IconComponent, TranslatePipe } from '@placeos/components';
import { PlaceGroupUser } from '@placeos/ts-client';
import { dialogClosed } from '../signage-service.util';
import { SignageGroupAdminService } from './signage-group-admin.service';
-import {
- groupPermissionLabels,
- SignageGroupPermissionsModalComponent,
-} from './signage-group-permissions-modal.component';
+import { SignageGroupPermissionLabelsComponent } from './signage-group-permission-labels.component';
+import { SignageGroupPermissionsModalComponent } from './signage-group-permissions-modal.component';
import { SignageGroupUserSelectModalComponent } from './signage-group-user-select-modal.component';
@Component({
@@ -52,6 +50,15 @@ import { SignageGroupUserSelectModalComponent } from './signage-group-user-selec