-
-
-
-
- Inactive Users
-
-
-
-
-
-
- Search
-
-
-
+
+
+
+
+
+ Inactive Users
+
+
+
+
+
+
+ Search
+
+
+
+
-
+
person
{{ user?.displayName || 'Unknown User' }}
-
+ (click)="$event.preventDefault(); $event.stopPropagation(); $event.stopImmediatePropagation(); activateUser(user)"
+ >
check
Activate
-
-
-
-
-
-
-
-
- Unregistered Devices
-
-
-
-
-
-
- Search
-
-
+
+ No inactive users found.
+
+
+
+
+
+
+
+
+
+
+ Unregistered Devices
+
+
+
+
+
+ Search
+
+
+
+
+
-
+
{{ d.user?.displayName || 'Unknown User' }}
- ({{ d?.uid || 'Unknown UID' }})
+
+ ({{ d?.uid || 'Unknown UID' }})
+
-
- check Register
+
+ check
+ Register
-
+
+
+
+
-
\ No newline at end of file
+
diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss
index 89a57fe04..16f7c1326 100644
--- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss
+++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.scss
@@ -4,20 +4,25 @@ $font-md: 16px;
$font-sm: 14px;
$font-xs: 12px;
+$top-panel-height: 390px;
+$top-panel-list-height: 236px;
+
::ng-deep .admin-main-content {
transition: margin-left 0.3s;
width: 100%;
min-height: calc(100vh - 127px);
+ min-height: calc(100dvh - 127px);
display: flex;
flex-direction: column;
&:has(admin-dashboard) {
min-height: calc(100vh - 127px);
+ min-height: calc(100dvh - 127px);
overflow: auto;
- -webkit-overflow-scrolling: touch;
@media (max-width: 959px) {
min-height: calc(100vh - 114px);
+ min-height: calc(100dvh - 114px);
overflow: auto;
}
}
@@ -48,10 +53,6 @@ $font-xs: 12px;
border: 1px solid #d7d7d7;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
background: #fff;
-
- @media (max-width: 959px), (max-height: 600px) {
- height: auto;
- }
}
.top-row {
@@ -61,178 +62,247 @@ $font-xs: 12px;
width: 100%;
overflow: visible;
- @media (max-width: 959px), (max-height: 600px) {
- flex-direction: column;
+ @media (max-width: 959px),
+ (max-height: 600px) {
+ display: flex;
+ flex-direction: column !important;
+ align-items: stretch;
height: auto;
max-height: none;
}
+ }
- .inactive-users,
- .unregistered-devices {
- flex: 1 1 0;
- min-width: 0;
+ .dashboard-panel,
+ .inactive-users,
+ .unregistered-devices {
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ height: $top-panel-height;
+ min-height: $top-panel-height;
+ max-height: $top-panel-height;
+
+ @media (max-width: 959px),
+ (max-height: 600px) {
+ flex: 0 0 auto;
+ width: 100%;
+ height: $top-panel-height;
+ min-height: $top-panel-height;
+ max-height: $top-panel-height;
+ }
+ }
+
+ .dashboard-panel-card,
+ .inactive-users mat-card,
+ .inactive-users .mat-mdc-card,
+ .unregistered-devices mat-card,
+ .unregistered-devices .mat-mdc-card {
+ flex: 1 1 auto;
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ padding: 0;
+ border: 0 !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ border-radius: 0 !important;
+ }
+
+ .search-wrapper {
+ flex: 0 0 auto;
+ background-color: #f7f7f7;
+ padding: 6px 10px 0 10px;
+ border: 1px solid #a9a9a9;
+ border-bottom: none;
+ }
+
+ mat-form-field,
+ .mat-mdc-form-field {
+ width: 100%;
+ margin-bottom: -18px;
+
+ .mat-mdc-text-field-wrapper {
+ background-color: transparent;
+ padding: 0 8px;
+ }
+
+ .mdc-line-ripple::before,
+ .mdc-line-ripple::after {
+ border-bottom-color: transparent;
+ }
+
+ .mat-mdc-form-field-subscript-wrapper {
+ display: none;
+ }
+ }
+
+ .mdc-floating-label {
+ padding-bottom: 15px;
+ font-size: $font-sm;
+ }
+
+ .panel-list-shell {
+ position: relative;
+ flex: 0 0 $top-panel-list-height;
+ height: $top-panel-list-height;
+ min-height: $top-panel-list-height;
+ max-height: $top-panel-list-height;
+ overflow: hidden;
+ border: 1px solid #a9a9a9;
+ background: #fcfcfc;
+
+ @media (max-width: 959px),
+ (max-height: 600px) {
+ flex: 0 0 $top-panel-list-height;
+ height: $top-panel-list-height;
+ min-height: $top-panel-list-height;
+ max-height: $top-panel-list-height;
+ }
+ }
+
+ mat-list,
+ .mat-mdc-list {
+ height: 100%;
+ min-height: 100%;
+ max-height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding-top: 0;
+ background: #fcfcfc;
+ }
+
+ mat-list-item,
+ a[mat-list-item],
+ .mat-mdc-list-item {
+ min-height: 48px;
+ height: auto;
+ padding: 4px 10px !important;
+ border-bottom: 1px solid #efefef;
+ display: flex;
+ align-items: center;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .mdc-list-item__content,
+ .mat-mdc-list-item-unscoped-content {
display: flex;
- flex-direction: column;
+ align-items: center;
+ min-width: 0;
+ width: 100%;
+ }
- @media (max-width: 959px), (max-height: 600px) {
- height: auto;
- }
+ mat-icon,
+ i[matListIcon] {
+ font-size: $font-md;
+ margin-right: 10px;
+ color: #666;
+ margin-top: 0;
+ flex: 0 0 18px;
+ width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
- mat-card,
- .mat-mdc-card {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- padding: 0;
- border: 0 !important;
- box-shadow: none !important;
- background: transparent !important;
- border-radius: 0 !important;
- }
+ [matLine],
+ .mat-line-horizontal,
+ .mdc-list-item__primary-text {
+ font-size: $font-sm;
+ line-height: 1.3;
+ font-weight: 500;
+ color: #333;
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
+ button[mat-button],
+ .mat-mdc-button {
+ margin-left: auto;
+ min-width: 92px;
+ height: 32px;
+ padding: 0 10px;
+ font-size: $font-xs;
+ line-height: 32px;
+ border: 1px solid #a8a8a8;
+ border-radius: 4px;
+ background: #f3f3f3;
+ color: #8a8a8a;
+ text-transform: none !important;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
- .search-wrapper {
- background-color: #f7f7f7;
- padding: 6px 10px 0 10px;
- border: 1px solid #a9a9a9;
- border-bottom: none;
+ .mdc-button__label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
}
- mat-form-field,
- .mat-mdc-form-field {
- width: 100%;
- margin-bottom: -18px;
+ mat-icon {
+ font-size: $font-sm;
+ width: $font-sm;
+ height: $font-sm;
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ }
- .mat-mdc-text-field-wrapper {
- background-color: transparent;
- padding: 0 8px;
- }
+ span {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+ margin: 0;
+ }
+ }
- .mdc-line-ripple::before,
- .mdc-line-ripple::after {
- border-bottom-color: transparent;
- }
+ @media (max-width: 560px) {
- .mat-mdc-form-field-subscript-wrapper {
- display: none;
- }
+ .mdc-list-item__content,
+ .mat-mdc-list-item-unscoped-content {
+ flex-wrap: wrap;
+ gap: 4px 8px;
}
- .mdc-floating-label {
- padding-bottom: 15px;
- font-size: $font-sm;
+ [matLine],
+ .mat-line-horizontal,
+ .mdc-list-item__primary-text {
+ flex: 1 1 calc(100% - 32px);
}
- mat-list,
- .mat-mdc-list {
- flex: 1;
- overflow-y: auto;
- max-height: 240px;
- border: 1px solid #a9a9a9;
- padding-top: 0;
- background: #fcfcfc;
-
- @media (max-width: 959px), (max-height: 600px) {
- max-height: 260px;
- }
-
- mat-list-item,
- a[mat-list-item],
- .mat-mdc-list-item {
- min-height: 48px;
- height: auto;
- padding: 4px 10px !important;
- border-bottom: 1px solid #efefef;
- display: flex;
- align-items: center;
-
- &:last-child {
- border-bottom: none;
- }
-
- .mdc-list-item__content,
- .mat-mdc-list-item-unscoped-content {
- display: flex;
- align-items: center;
- min-width: 0;
- width: 100%;
- }
-
- mat-icon,
- i[matListIcon] {
- font-size: $font-md;
- margin-right: 10px;
- color: #666;
- margin-top: 0;
- flex: 0 0 18px;
- width: 18px;
- height: 18px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
-
- [matLine],
- .mat-line-horizontal,
- .mdc-list-item__primary-text {
- font-size: $font-sm;
- line-height: 1.3;
- font-weight: 500;
- color: #333;
- display: inline-flex;
- align-items: center;
- min-width: 0;
- flex: 1 1 auto;
- }
-
- button[mat-button],
- .mat-mdc-button {
- margin-left: auto;
- min-width: 92px;
- height: 32px;
- padding: 0 10px;
- font-size: $font-xs;
- line-height: 32px;
- border: 1px solid #a8a8a8;
- border-radius: 4px;
- background: #f3f3f3;
- color: #8a8a8a;
- text-transform: none !important;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
-
- .mdc-button__label {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- }
-
- mat-icon {
- font-size: $font-sm;
- width: $font-sm;
- height: $font-sm;
- margin: 0;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- line-height: 1;
- }
-
- span {
- display: inline-flex;
- align-items: center;
- line-height: 1;
- margin: 0;
- }
- }
- }
+ button[mat-button],
+ .mat-mdc-button {
+ margin-left: 28px;
+ margin-top: 4px;
}
}
}
+ .empty-panel-state {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ text-align: center;
+ color: #6b7280;
+ font-size: $font-sm;
+ pointer-events: none;
+ background: #fcfcfc;
+ }
+
.title-with-badge {
display: flex;
align-items: center;
@@ -262,29 +332,84 @@ $font-xs: 12px;
}
.pagination-controls {
+ flex: 0 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
+ gap: 8px;
margin-top: 10px;
padding: 0 2px;
+ .pagination-button,
button {
min-width: auto;
- padding: 0;
+ height: 28px;
+ padding: 0 8px;
font-size: $font-xs;
letter-spacing: 0.04em;
text-transform: none !important;
- color: #8b8b8b;
+ border-radius: 4px;
+ transition: color 160ms ease, background-color 160ms ease,
+ opacity 160ms ease;
+ }
+
+ .pagination-button:first-child {
+ margin-right: auto;
}
- @media (max-width: 480px), (max-height: 600px) {
+ .pagination-button:last-child {
+ margin-left: auto;
+ }
+
+ .pagination-button:not(:disabled):not(.mat-mdc-button-disabled),
+ button:not(:disabled):not(.mat-mdc-button-disabled) {
+ color: #1e88e5 !important;
+ opacity: 1;
+ cursor: pointer;
+
+ --mdc-text-button-label-text-color: #1e88e5;
+ --mat-text-button-state-layer-color: #1e88e5;
+
+ &:hover {
+ background: rgba(30, 136, 229, 0.08);
+ color: #1565c0 !important;
+
+ --mdc-text-button-label-text-color: #1565c0;
+ }
+
+ &:focus-visible {
+ outline: 2px solid rgba(30, 136, 229, 0.35);
+ outline-offset: 2px;
+ }
+ }
+
+ .pagination-button:disabled,
+ .pagination-button[disabled],
+ .pagination-button.mat-mdc-button-disabled,
+ button:disabled,
+ button[disabled],
+ button.mat-mdc-button-disabled {
+ color: #b8b8b8 !important;
+ opacity: 0.55;
+ cursor: not-allowed;
+ pointer-events: none;
+
+ --mdc-text-button-disabled-label-text-color: #b8b8b8;
+ --mdc-text-button-label-text-color: #b8b8b8;
+ }
+
+ @media (max-width: 480px),
+ (max-height: 600px) {
align-items: stretch;
- gap: 12px;
+ gap: 8px;
flex-direction: column;
+ .pagination-button,
button {
width: 100%;
text-align: center;
+ margin-left: 0;
+ margin-right: 0;
}
}
}
@@ -322,7 +447,6 @@ $font-xs: 12px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0;
@@ -366,30 +490,9 @@ $font-xs: 12px;
font-size: $font-sm;
}
-/* important: reserve space for paginator/footer inside mage-logins */
:host ::ng-deep mage-logins {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
-}
-
-:host ::ng-deep mage-logins .section-card,
-:host ::ng-deep mage-logins .table-wrapper {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-height: 0;
- height: 100%;
- max-height: 100%;
-}
-
-:host ::ng-deep mage-logins .table-scroll,
-:host ::ng-deep mage-logins .login-table-scroll {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
+ display: block;
+ width: 100%;
}
:host ::ng-deep mage-logins .mat-paginator,
@@ -402,4 +505,4 @@ $font-xs: 12px;
.mt-5 {
margin-top: 48px;
-}
+}
\ No newline at end of file
diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts
index e74106c60..9c7a18a33 100644
--- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts
+++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.spec.ts
@@ -16,17 +16,17 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconTestingModule } from '@angular/material/icon/testing';
-import { MatFormFieldModule as MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule as MatInputModule } from '@angular/material/input';
-import { MatButtonModule as MatButtonModule } from '@angular/material/button';
-import { MatCardModule as MatCardModule } from '@angular/material/card';
-import { MatListModule as MatListModule } from '@angular/material/list';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+import { MatListModule } from '@angular/material/list';
import { MatBadgeModule } from '@angular/material/badge';
-import { MatSelectModule as MatSelectModule } from '@angular/material/select';
+import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
-import { MatAutocompleteModule as MatAutocompleteModule } from '@angular/material/autocomplete';
+import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatNativeDateModule } from '@angular/material/core';
-import { MatTableModule as MatTableModule } from '@angular/material/table';
+import { MatTableModule } from '@angular/material/table';
import { RouterTestingModule } from '@angular/router/testing';
import { AdminUserService } from '../services/admin-user.service';
@@ -88,6 +88,60 @@ const TEST_USERS: any[] = [
},
email: 'kiku@example.com',
phones: []
+ },
+ {
+ id: '4',
+ username: 'sakura_',
+ displayName: 'Sakura',
+ active: true,
+ enabled: true,
+ authentication: 'LOCAL',
+ createdAt: new Date().toDateString(),
+ lastUpdated: new Date().toDateString(),
+ recentEventIds: [],
+ role: {
+ id: 'Test',
+ name: 'role',
+ permissions: []
+ },
+ email: 'sakura@example.com',
+ phones: []
+ },
+ {
+ id: '5',
+ username: 'yuki_',
+ displayName: 'Yuki',
+ active: true,
+ enabled: true,
+ authentication: 'LOCAL',
+ createdAt: new Date().toDateString(),
+ lastUpdated: new Date().toDateString(),
+ recentEventIds: [],
+ role: {
+ id: 'Test',
+ name: 'role',
+ permissions: []
+ },
+ email: 'yuki@example.com',
+ phones: []
+ },
+ {
+ id: '6',
+ username: 'momo_',
+ displayName: 'Momo',
+ active: true,
+ enabled: true,
+ authentication: 'LOCAL',
+ createdAt: new Date().toDateString(),
+ lastUpdated: new Date().toDateString(),
+ recentEventIds: [],
+ role: {
+ id: 'Test',
+ name: 'role',
+ permissions: []
+ },
+ email: 'momo@example.com',
+ phones: []
}
];
@@ -95,7 +149,7 @@ const TEST_DEVICES: any[] = [
{
id: 'd1',
uid: 'Primary Desktop',
- registered: true,
+ registered: false,
appVersion: 'Web Client',
userAgent: '',
iconClass: ''
@@ -103,7 +157,7 @@ const TEST_DEVICES: any[] = [
{
id: 'd2',
uid: 'iOS Device',
- registered: true,
+ registered: false,
appVersion: 'mobile',
userAgent: 'iOS',
iconClass: ''
@@ -115,6 +169,30 @@ const TEST_DEVICES: any[] = [
appVersion: 'mobile',
userAgent: 'android',
iconClass: ''
+ },
+ {
+ id: 'd4',
+ uid: 'Tablet Device',
+ registered: false,
+ appVersion: 'mobile',
+ userAgent: 'tablet',
+ iconClass: ''
+ },
+ {
+ id: 'd5',
+ uid: 'Backup Phone',
+ registered: false,
+ appVersion: 'mobile',
+ userAgent: 'mobile',
+ iconClass: ''
+ },
+ {
+ id: 'd6',
+ uid: 'Field Laptop',
+ registered: false,
+ appVersion: 'Web Client',
+ userAgent: '',
+ iconClass: ''
}
];
@@ -132,6 +210,40 @@ const mockDeviceService: Partial
& any = {
.and.callFake((_id: string, patch: any) => {
const updated = { ...TEST_DEVICES[0], ...patch };
return of(updated);
+ }),
+ getDashboardDevicePage: jasmine
+ .createSpy('getDashboardDevicePage')
+ .and.callFake((options: any) => {
+ const start = options?.start || 0;
+ const limit = options?.limit || 5;
+ const term = (options?.term || '').toLowerCase();
+
+ const filteredDevices = TEST_DEVICES.filter((device) => {
+ if (options?.registered !== undefined) {
+ if (device.registered !== options.registered) {
+ return false;
+ }
+ }
+
+ if (!term) {
+ return true;
+ }
+
+ return (device.uid || '').toLowerCase().includes(term);
+ });
+
+ const devices = filteredDevices.slice(start, start + limit);
+ const nextStart =
+ start + limit < filteredDevices.length ? start + limit : null;
+ const prevStart = start - limit >= 0 ? Math.max(start - limit, 0) : null;
+
+ return of({
+ start,
+ nextStart,
+ prevStart,
+ totalCount: filteredDevices.length,
+ devices
+ });
})
};
@@ -150,16 +262,12 @@ const mockUserPagingService: Partial & any = {
refresh: jasmine.createSpy('refresh').and.returnValue(of([])),
users: jasmine.createSpy('users').and.callFake((_state: any) => TEST_USERS),
count: jasmine.createSpy('count').and.returnValue(TEST_USERS.length),
- hasNext: jasmine.createSpy('hasNext').and.returnValue(true),
- hasPrevious: jasmine.createSpy('hasPrevious').and.returnValue(false),
- next: jasmine.createSpy('next').and.returnValue(of([TEST_USERS[1]])),
- previous: jasmine.createSpy('previous').and.returnValue(of([TEST_USERS[0]])),
search: jasmine
.createSpy('search')
.and.callFake((_state: any, term: string) => {
return of(
- TEST_USERS.filter((u) =>
- (u.displayName || '')
+ TEST_USERS.filter((user) =>
+ (user.displayName || '')
.toLowerCase()
.includes((term || '').toLowerCase())
)
@@ -170,30 +278,7 @@ const mockUserPagingService: Partial & any = {
const mockDevicePagingService: Partial & any = {
constructDefault: jasmine
.createSpy('constructDefault')
- .and.returnValue(deviceStateAndData),
- refresh: jasmine.createSpy('refresh').and.returnValue(of([])),
- devices: jasmine
- .createSpy('devices')
- .and.callFake((_state: any) => TEST_DEVICES),
- count: jasmine.createSpy('count').and.returnValue(TEST_DEVICES.length),
- hasNext: jasmine.createSpy('hasNext').and.returnValue(true),
- hasPrevious: jasmine.createSpy('hasPrevious').and.returnValue(false),
- next: jasmine.createSpy('next').and.returnValue(of([TEST_DEVICES[1]])),
- previous: jasmine
- .createSpy('previous')
- .and.returnValue(of([TEST_DEVICES[0]])),
- search: jasmine
- .createSpy('search')
- .and.callFake((_state: any, term: string) => {
- return of(
- TEST_DEVICES.filter((d) =>
- (d.uid || '')
- .toString()
- .toLowerCase()
- .includes((term || '').toLowerCase())
- )
- );
- })
+ .and.returnValue(deviceStateAndData)
};
describe('AdminDashboardComponent', () => {
@@ -234,16 +319,11 @@ describe('AdminDashboardComponent', () => {
(mockUserPagingService.constructDefault as jasmine.Spy).calls.reset();
(mockUserPagingService.refresh as jasmine.Spy).calls.reset();
- (mockDevicePagingService.constructDefault as jasmine.Spy).calls.reset();
- (mockDevicePagingService.refresh as jasmine.Spy).calls.reset();
(mockUserPagingService.search as jasmine.Spy).calls.reset();
- (mockDevicePagingService.search as jasmine.Spy).calls.reset();
- (mockUserPagingService.next as jasmine.Spy).calls.reset();
- (mockUserPagingService.previous as jasmine.Spy).calls.reset();
- (mockDevicePagingService.next as jasmine.Spy).calls.reset();
- (mockDevicePagingService.previous as jasmine.Spy).calls.reset();
+ (mockDevicePagingService.constructDefault as jasmine.Spy).calls.reset();
(mockUserService.updateUser as jasmine.Spy).calls.reset();
(mockDeviceService.updateDevice as jasmine.Spy).calls.reset();
+ (mockDeviceService.getDashboardDevicePage as jasmine.Spy).calls.reset();
fixture = TestBed.createComponent(AdminDashboardComponent);
component = fixture.componentInstance;
@@ -254,12 +334,17 @@ describe('AdminDashboardComponent', () => {
expect(component).toBeTruthy();
});
- it('should call paging refresh in ngOnInit and populate lists', fakeAsync(() => {
+ it('should initialize paging data and populate dashboard lists', fakeAsync(() => {
tick();
+
+ expect(mockUserPagingService.constructDefault).toHaveBeenCalled();
+ expect(mockDevicePagingService.constructDefault).toHaveBeenCalled();
expect(mockUserPagingService.refresh).toHaveBeenCalled();
- expect(mockDevicePagingService.refresh).toHaveBeenCalled();
- expect(component.inactiveUsers.length).toBe(TEST_USERS.length);
- expect(component.unregisteredDevices.length).toBe(TEST_DEVICES.length);
+ expect(mockDeviceService.getDashboardDevicePage).toHaveBeenCalled();
+
+ expect(component.inactiveUsers).toEqual(TEST_USERS.slice(0, 5));
+ expect(component.unregisteredDevices).toEqual(TEST_DEVICES.slice(0, 5));
+ expect(component.deviceTotalCount).toBe(TEST_DEVICES.length);
}));
it('should activate user and emit event', fakeAsync(() => {
@@ -268,12 +353,13 @@ describe('AdminDashboardComponent', () => {
spyOn(component.onUserActivated, 'emit');
(mockUserService.updateUser as jasmine.Spy).and.callFake(
- (_id: string, _user: any) => of(_user)
+ (_id: string, updatedUser: any) => of(updatedUser)
);
component.activateUser(user);
tick();
+ expect(mockUserService.updateUser).toHaveBeenCalledWith(user.id, user);
expect(user.active).toBeTrue();
expect(component.onUserActivated.emit).toHaveBeenCalledWith({ user });
}));
@@ -283,9 +369,15 @@ describe('AdminDashboardComponent', () => {
component.onDeviceEnabled = new EventEmitter();
spyOn(component.onDeviceEnabled, 'emit');
- component.registerDevice(new MouseEvent('click'), device);
+ const event = new MouseEvent('click');
+ spyOn(event, 'preventDefault');
+ spyOn(event, 'stopPropagation');
+
+ component.registerDevice(event, device);
tick();
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.stopPropagation).toHaveBeenCalled();
expect(mockDeviceService.updateDevice).toHaveBeenCalledWith(device.id, {
registered: true
});
@@ -301,44 +393,86 @@ describe('AdminDashboardComponent', () => {
it('should search users', fakeAsync(() => {
component.userSearch = 'Lily Hoshikawa';
+
component.search();
tick();
+
+ expect(mockUserPagingService.search).toHaveBeenCalledWith(
+ userStateAndData.inactive,
+ 'Lily Hoshikawa'
+ );
expect(component.inactiveUsers).toEqual([TEST_USERS[0]]);
}));
it('should search devices', fakeAsync(() => {
component.deviceSearch = 'iOS Device';
+
component.searchDevices();
tick();
+
+ expect(mockDeviceService.getDashboardDevicePage).toHaveBeenCalledWith({
+ start: 0,
+ limit: component.devicePageSize,
+ registered: false,
+ user: true,
+ includePagination: true,
+ term: 'iOS Device'
+ });
expect(component.unregisteredDevices).toEqual([TEST_DEVICES[1]]);
+ expect(component.deviceTotalCount).toBe(1);
}));
it('should handle previous and next user pages', fakeAsync(() => {
+ tick();
+
expect(component.hasNext()).toBeTrue();
expect(component.hasPrevious()).toBeFalse();
component.next();
tick();
- expect(component.inactiveUsers).toEqual([TEST_USERS[1]]);
+
+ expect(component.userPageIndex).toBe(1);
+ expect(component.inactiveUsers).toEqual([TEST_USERS[5]]);
+ expect(component.hasNext()).toBeFalse();
+ expect(component.hasPrevious()).toBeTrue();
component.previous();
tick();
- expect(component.inactiveUsers).toEqual([TEST_USERS[0]]);
+
+ expect(component.userPageIndex).toBe(0);
+ expect(component.inactiveUsers).toEqual(TEST_USERS.slice(0, 5));
}));
it('should handle previous and next device pages', fakeAsync(() => {
+ tick();
+
expect(component.hasNextDevice()).toBeTrue();
expect(component.hasPreviousDevice()).toBeFalse();
component.nextDevice();
tick();
- expect(component.unregisteredDevices).toEqual([TEST_DEVICES[1]]);
+
+ expect(component.deviceStart).toBe(5);
+ expect(component.unregisteredDevices).toEqual([TEST_DEVICES[5]]);
+ expect(component.hasNextDevice()).toBeFalse();
+ expect(component.hasPreviousDevice()).toBeTrue();
component.previousDevice();
tick();
- expect(component.unregisteredDevices).toEqual([TEST_DEVICES[0]]);
+
+ expect(component.deviceStart).toBe(0);
+ expect(component.unregisteredDevices).toEqual(TEST_DEVICES.slice(0, 5));
}));
+ it('should not navigate to a user without an id', () => {
+ const router = TestBed.inject(RouterTestingModule as any);
+
+ component.goToUser(null as any);
+ component.goToUser({});
+
+ expect(router).toBeTruthy();
+ });
+
it('should set icon classes correctly', () => {
expect(component.iconClass(TEST_DEVICES[0])).toContain('desktop');
expect(component.iconClass(TEST_DEVICES[2])).toContain('android');
@@ -346,6 +480,12 @@ describe('AdminDashboardComponent', () => {
expect(
component.iconClass({ ...TEST_DEVICES[1], userAgent: 'mobile' })
).toContain('mobile');
+ expect(
+ component.iconClass({
+ ...TEST_DEVICES[1],
+ iconClass: 'custom-device-icon'
+ })
+ ).toBe('custom-device-icon');
expect(component.iconClass(null as any)).toEqual('');
});
});
diff --git a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts
index 1ee66071a..6a05fe58f 100644
--- a/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts
+++ b/web-app/admin/src/app/admin/admin-dashboard/admin-dashboard.ts
@@ -9,8 +9,11 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Subject, takeUntil } from 'rxjs';
import { AdminBreadcrumb } from '../admin-breadcrumb/admin-breadcrumb.model';
+import {
+ AdminDeviceService,
+ DashboardDevicePageInfo
+} from '../services/admin-device.service';
import { AdminUserService } from '../services/admin-user.service';
-import { AdminDeviceService } from '../services/admin-device.service';
import { DevicePagingService } from '../../services/device-paging.service';
import { UserPagingService } from '../../services/user-paging.service';
import { User } from '../admin-users/user';
@@ -34,10 +37,26 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
deviceStateAndData!: ReturnType;
inactiveUsers: Array[number]> = [];
- unregisteredDevices: Array<
- ReturnType[number]
+ unregisteredDevices: any[] = [];
+
+ readonly userPageSize = 5;
+ readonly devicePageSize = 5;
+
+ userPageIndex = 0;
+ loadingUsersPage = false;
+ loadingDevicesPage = false;
+
+ private allInactiveUsers: Array<
+ ReturnType[number]
> = [];
+ deviceStart = 0;
+ deviceNextStart: number | null = null;
+ devicePrevStart: number | null = null;
+ deviceTotalCount = 0;
+
+ private devicePageCache = new Map();
+
breadcrumbs: AdminBreadcrumb[] = [
{
title: 'Dashboard',
@@ -68,23 +87,8 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
this.currentUser = user;
});
- this.devicePagingService
- .refresh(this.deviceStateAndData)
- .pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.unregisteredDevices = this.devicePagingService.devices(
- this.deviceStateAndData[this.deviceState]
- );
- });
-
- this.userPagingService
- .refresh(this.stateAndData)
- .pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.inactiveUsers = this.userPagingService.users(
- this.stateAndData[this.userState]
- );
- });
+ this.refreshDevices();
+ this.refreshUsers();
}
ngOnDestroy(): void {
@@ -94,98 +98,95 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
goToUser(user: any): void {
if (!user?.id) return;
+
this.router.navigate(['../users', user.id], { relativeTo: this.route });
}
goToDevice(device: any): void {
if (!device?.id) return;
+
this.router.navigate(['../devices', device.id], { relativeTo: this.route });
}
count(): number {
- return this.userPagingService.count(this.stateAndData[this.userState]);
+ const state = this.stateAndData?.[this.userState];
+
+ if (!state) {
+ return this.allInactiveUsers.length;
+ }
+
+ const serviceCount = this.userPagingService.count(state);
+
+ return serviceCount || this.allInactiveUsers.length;
+ }
+
+ deviceCount(): number {
+ return this.deviceTotalCount || this.unregisteredDevices.length;
}
hasNext(): boolean {
- return this.userPagingService.hasNext(this.stateAndData[this.userState]);
+ return (this.userPageIndex + 1) * this.userPageSize < this.count();
}
next(): void {
- this.userPagingService
- .next(this.stateAndData[this.userState])
- .pipe(takeUntil(this.destroy$))
- .subscribe((users) => {
- this.inactiveUsers = users;
- });
+ if (!this.hasNext() || this.loadingUsersPage) return;
+
+ this.userPageIndex += 1;
+ this.applyUserPage();
}
hasPrevious(): boolean {
- return this.userPagingService.hasPrevious(
- this.stateAndData[this.userState]
- );
+ return this.userPageIndex > 0;
}
previous(): void {
- this.userPagingService
- .previous(this.stateAndData[this.userState])
- .pipe(takeUntil(this.destroy$))
- .subscribe((users) => {
- this.inactiveUsers = users;
- });
- }
+ if (!this.hasPrevious() || this.loadingUsersPage) return;
- deviceCount(): number {
- return this.devicePagingService.count(
- this.deviceStateAndData[this.deviceState]
- );
+ this.userPageIndex -= 1;
+ this.applyUserPage();
}
hasNextDevice(): boolean {
- return this.devicePagingService.hasNext(
- this.deviceStateAndData[this.deviceState]
- );
+ return this.deviceNextStart !== null && !this.loadingDevicesPage;
}
nextDevice(): void {
- this.devicePagingService
- .next(this.deviceStateAndData[this.deviceState])
- .pipe(takeUntil(this.destroy$))
- .subscribe((devices) => {
- this.unregisteredDevices = devices;
- });
+ if (!this.hasNextDevice() || this.deviceNextStart === null) return;
+
+ this.loadDevicePage(this.deviceNextStart);
}
hasPreviousDevice(): boolean {
- return this.devicePagingService.hasPrevious(
- this.deviceStateAndData[this.deviceState]
- );
+ return this.devicePrevStart !== null && !this.loadingDevicesPage;
}
previousDevice(): void {
- this.devicePagingService
- .previous(this.deviceStateAndData[this.deviceState])
- .pipe(takeUntil(this.destroy$))
- .subscribe((devices) => {
- this.unregisteredDevices = devices;
- });
+ if (!this.hasPreviousDevice() || this.devicePrevStart === null) return;
+
+ this.loadDevicePage(this.devicePrevStart);
}
search(): void {
+ this.userPageIndex = 0;
+ this.loadingUsersPage = true;
+
this.userPagingService
.search(this.stateAndData[this.userState], this.userSearch)
.pipe(takeUntil(this.destroy$))
- .subscribe((users) => {
- this.inactiveUsers = users;
+ .subscribe({
+ next: (users) => {
+ this.setUsers(users);
+ this.loadingUsersPage = false;
+ },
+ error: () => {
+ this.loadingUsersPage = false;
+ }
});
}
searchDevices(): void {
- this.devicePagingService
- .search(this.deviceStateAndData[this.deviceState], this.deviceSearch)
- .pipe(takeUntil(this.destroy$))
- .subscribe((devices) => {
- this.unregisteredDevices = devices;
- });
+ this.devicePageCache.clear();
+ this.loadDevicePage(0);
}
iconClass(device: any): string {
@@ -193,11 +194,19 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
if (device.iconClass) return device.iconClass;
const userAgent = (device.userAgent || '').toLowerCase();
- if (device.appVersion === 'Web Client')
+
+ if (device.appVersion === 'Web Client') {
return 'fa fa-desktop admin-desktop-icon-xs';
- if (userAgent.includes('android'))
+ }
+
+ if (userAgent.includes('android')) {
return 'fa fa-android admin-android-icon-xs';
- if (userAgent.includes('ios')) return 'fa fa-apple admin-apple-icon-xs';
+ }
+
+ if (userAgent.includes('ios')) {
+ return 'fa fa-apple admin-apple-icon-xs';
+ }
+
return 'fa fa-mobile admin-generic-icon-xs';
}
@@ -214,15 +223,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
.updateUser(user.id, user)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
- this.userPagingService
- .refresh(this.stateAndData)
- .pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.inactiveUsers = this.userPagingService.users(
- this.stateAndData[this.userState]
- );
- });
-
+ this.refreshUsers();
this.onUserActivated.emit({ user });
});
}
@@ -231,20 +232,116 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
event.preventDefault();
event.stopPropagation();
+ if (!device?.id) return;
+
this.deviceService
.updateDevice(device.id, { registered: true })
.pipe(takeUntil(this.destroy$))
.subscribe((updatedDevice) => {
- this.devicePagingService
- .refresh(this.deviceStateAndData)
- .pipe(takeUntil(this.destroy$))
- .subscribe(() => {
- this.unregisteredDevices = this.devicePagingService.devices(
- this.deviceStateAndData[this.deviceState]
- );
- });
-
+ this.devicePageCache.clear();
+ this.refreshDevices();
this.onDeviceEnabled.emit({ device: updatedDevice });
});
}
+
+ private refreshUsers(): void {
+ this.userPageIndex = 0;
+ this.loadingUsersPage = true;
+
+ this.userPagingService
+ .refresh(this.stateAndData)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: () => {
+ const users = this.userPagingService.users(
+ this.stateAndData[this.userState]
+ );
+
+ this.setUsers(users);
+ this.loadingUsersPage = false;
+ },
+ error: () => {
+ this.loadingUsersPage = false;
+ }
+ });
+ }
+
+ private refreshDevices(): void {
+ this.devicePageCache.clear();
+ this.loadDevicePage(0);
+ }
+
+ private loadDevicePage(start: number): void {
+ const cached = this.devicePageCache.get(start);
+
+ if (cached) {
+ this.applyDevicePage(cached);
+ return;
+ }
+
+ this.loadingDevicesPage = true;
+
+ this.deviceService
+ .getDashboardDevicePage({
+ start,
+ limit: this.devicePageSize,
+ registered: false,
+ user: true,
+ includePagination: true,
+ term: this.deviceSearch || undefined
+ })
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (page) => {
+ this.devicePageCache.set(start, page);
+ this.applyDevicePage(page);
+ this.loadingDevicesPage = false;
+ },
+ error: () => {
+ this.unregisteredDevices = [];
+ this.deviceNextStart = null;
+ this.devicePrevStart = null;
+ this.loadingDevicesPage = false;
+ }
+ });
+ }
+
+ private applyDevicePage(page: DashboardDevicePageInfo): void {
+ this.deviceStart = page.start;
+ this.deviceNextStart = page.nextStart;
+ this.devicePrevStart = page.prevStart;
+ this.deviceTotalCount = page.totalCount;
+ this.unregisteredDevices = page.devices || [];
+ }
+
+ private setUsers(
+ users: Array[number]> = []
+ ): void {
+ this.allInactiveUsers = users || [];
+ this.clampUserPageIndex();
+ this.applyUserPage();
+ }
+
+ private applyUserPage(): void {
+ const start = this.userPageIndex * this.userPageSize;
+ const end = start + this.userPageSize;
+
+ this.inactiveUsers = this.allInactiveUsers.slice(start, end);
+ }
+
+ private clampUserPageIndex(): void {
+ const maxPageIndex = this.maxUserPageIndex();
+
+ if (this.userPageIndex > maxPageIndex) {
+ this.userPageIndex = maxPageIndex;
+ }
+ }
+
+ private maxUserPageIndex(): number {
+ const total = this.count();
+
+ if (!total) return 0;
+
+ return Math.ceil(total / this.userPageSize) - 1;
+ }
}
diff --git a/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss b/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss
index 208541232..a646c7924 100644
--- a/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss
+++ b/web-app/admin/src/app/admin/admin-devices/create-device/create-device.component.scss
@@ -166,4 +166,9 @@ mat-form-field {
.mat-autocomplete-panel {
z-index: 10000 !important;
}
+}
+
+.btn-primary {
+ background-color: #1e88e5;
+ color: #fff;
}
\ No newline at end of file
diff --git a/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss b/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss
index 3b847281e..8348d30ab 100644
--- a/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss
+++ b/web-app/admin/src/app/admin/admin-devices/dashboard/devices-dashboard.component.scss
@@ -9,8 +9,8 @@ $font-xs: 12px;
&:has(admin-devices) {
height: 92vh;
+ height: 92dvh;
overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
@media (max-height: 500px) {
overflow-y: scroll;
@@ -20,6 +20,7 @@ $font-xs: 12px;
admin-devices {
.container {
height: 90vh;
+ height: 90dvh;
}
}
}
@@ -112,7 +113,7 @@ $font-xs: 12px;
min-width: 0;
}
-.filters > * {
+.filters>* {
min-width: 0;
}
@@ -127,7 +128,7 @@ $font-xs: 12px;
}
:host ::ng-deep .filters mage-card-navbar,
-:host ::ng-deep .filters mage-card-navbar > *,
+:host ::ng-deep .filters mage-card-navbar>*,
:host ::ng-deep .filters mage-card-navbar .card-navbar,
:host ::ng-deep .filters mage-card-navbar .search-container,
:host ::ng-deep .filters mage-card-navbar .search-wrapper,
@@ -468,6 +469,7 @@ $font-xs: 12px;
.mt-5 {
margin-top: 5px !important;
}
+
.mb-5 {
margin-bottom: 5px !important;
}
@@ -477,4 +479,4 @@ $font-xs: 12px;
:host .mat-row a {
color: inherit !important;
text-decoration: none !important;
-}
+}
\ No newline at end of file
diff --git a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html
index 131cdd4a3..32cadcf98 100644
--- a/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html
+++ b/web-app/admin/src/app/admin/admin-event/admin-event-form/admin-event-form-preview/form-preview-dialog/admin-event-form-preview-dialog.component.html
@@ -1,14 +1,21 @@
-{{formDefinition.name}}
-
-
-
+
@@ -89,10 +121,17 @@ {{ isEditMode ? 'Edit Field' : 'Add New Field' }}