diff --git a/AGENTS.md b/AGENTS.md index faee58db2..894bb0cbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,9 @@ export class MyComponent extends AsyncHandler { - Supports multiple locales (see `app.locales` in settings) - Service worker support for PWA functionality (production builds) - Custom localization support via `public/assets/locale`, **TranslatePipe** and **LocaleService**. `en-AU.json` is the fallback locale +- Do not add Angular Material. Use the controls in `src/app/ui/controls` (form field, select, autocomplete, chip list, tabs, tooltip, spinner, progress bar, dialog close, toasts) and the Angular CDK (dialog, menu, overlay). Their styles are in `src/styles.css` +- Form errors: put `
` in an `app-form-field`. It shows when its `[formField]` or `ngModel` control is touched and invalid +- Add a `ripple` attribute to an element for a press ripple. Use native `` for checkboxes, with `role="switch"` for a switch - Use **IconComponent** for icons. The default icon font is a subset of the icons in the source. After you add icons, run `bun config/icon-subset.ts` (needs `pip install fonttools brotli`). An icon that is not in the subset still shows, but it loads the full 420 KB font - `src/index.html` has a splash that shows until the app loads. Lighthouse measures its title as the largest contentful paint, so keep the title larger than the content of the first page - Use `feat/*`, `fix/*`, `perf/*`, or `refactor/*` for branch names diff --git a/README.md b/README.md index c46bf9088..b7867911c 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ The `Demo Site` workflow moves `backoffice-demo` to `develop` once per day (17:0 ## Stack -- Angular 22 and Angular Material +- Angular 22 with the Angular CDK and the app's own controls (`src/app/ui/controls`) - Nx 22 with the Angular application builder - Vitest and Playwright - Tailwind CSS 4 diff --git a/bun.lock b/bun.lock index 561faa1d8..69103e189 100644 --- a/bun.lock +++ b/bun.lock @@ -28,7 +28,6 @@ "@angular/cli": "22.2.3", "@angular/compiler-cli": "22.2.3", "@angular/language-service": "22.2.3", - "@angular/material": "22.2.4", "@eslint/js": "^9.8.0", "@fontsource-variable/fira-code": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", @@ -133,8 +132,6 @@ "@angular/language-service": ["@angular/language-service@22.2.3", "", {}, "sha512-n7LJ7NaHiC+NMHscCInhHTAvCsBLi8WfF47KuVv1eGj6rgFn1ZMpE81OBIWdJbSdytarwSU3KInaYESjao0ItA=="], - "@angular/material": ["@angular/material@22.2.4", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/cdk": "22.2.4", "@angular/common": "^22.0.0 || ^23.0.0", "@angular/core": "^22.0.0 || ^23.0.0", "@angular/forms": "^22.0.0 || ^23.0.0", "@angular/platform-browser": "^22.0.0 || ^23.0.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-3CFxsj+U4fGQFwY1ZBOAltazAQucNupbzRFIx2d4aW4k94R+C6kQ9EoRi5evRNUtXa2boeF/DuVjY6fBtNfLig=="], - "@angular/platform-browser": ["@angular/platform-browser@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "22.2.3", "@angular/common": "22.2.3", "@angular/core": "22.2.3" }, "optionalPeers": ["@angular/animations"] }, "sha512-eWZGsbG4bSrSg9UB/NE8wORGVrh1u+x6rirPGpMWqq/mTi6q+nS+pnPxAxmiEs8wmPH5nCz5b2pRKQeYUkhAGA=="], "@angular/router": ["@angular/router@22.2.3", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "22.2.3", "@angular/core": "22.2.3", "@angular/platform-browser": "22.2.3", "rxjs": "^6.5.3 || ^7.4.0" } }, "sha512-h4C3iJ3OXM9HItErmTo9fFrFRDj1DSqbvweHAWl8eYJvyuhDD80gBNgdpN8/5gpt9K1HV+PXk0YbyjyQ0zMfPA=="], diff --git a/e2e/src/domains.spec.ts b/e2e/src/domains.spec.ts index f097dd61d..3877f65cd 100644 --- a/e2e/src/domains.spec.ts +++ b/e2e/src/domains.spec.ts @@ -83,7 +83,9 @@ test.describe('Domains', () => { await domains.saveButton.click(); await expect(domains.dialog).toBeVisible(); - await expect(domains.dialog.locator('mat-error')).toHaveCount(2); + await expect( + domains.dialog.locator('app-form-field [error]:visible'), + ).toHaveCount(2); }); test('AC-DOM-003-4: saves the domain and opens it', async () => { @@ -193,7 +195,7 @@ test.describe('Domains', () => { // The list is empty while it loads, so wait for the load to end await expect( - domains.tabContent.locator('mat-progress-bar'), + domains.tabContent.locator('app-progress-bar'), ).toBeHidden(); await expect(domains.tabContent).toContainText( 'No users associated with this domain', diff --git a/e2e/src/navigation.spec.ts b/e2e/src/navigation.spec.ts index c4e542a31..07c516b70 100644 --- a/e2e/src/navigation.spec.ts +++ b/e2e/src/navigation.spec.ts @@ -92,7 +92,7 @@ test.describe('Navigation', () => { await expect(zones.itemName).toHaveText('Level 1'); await expect( page.locator('item-tablist a[href*="/systems"]'), - ).toHaveAttribute('aria-selected', 'true'); + ).toHaveAttribute('aria-current', 'page'); }); test('browser back returns to the previous section', async ({ page }) => { diff --git a/e2e/src/pages/admin.page.ts b/e2e/src/pages/admin.page.ts index 11723ee7c..9b10acf45 100644 --- a/e2e/src/pages/admin.page.ts +++ b/e2e/src/pages/admin.page.ts @@ -54,10 +54,10 @@ export class AdminPage extends BasePage { */ async selectApiKeyDomain(domain = 'PlaceOS Global'): Promise { await this.page - .locator('admin-api-keys admin-domain-select mat-select') + .locator('admin-api-keys admin-domain-select app-select') .click(); await this.page - .locator('mat-option') + .locator('app-option') .filter({ hasText: domain }) .first() .click(); @@ -78,7 +78,7 @@ export class AdminPage extends BasePage { /** User option in the API key user autocomplete panel */ apiKeyUserOption(name: string): Locator { return this.page - .locator('mat-option') + .locator('app-option') .filter({ hasText: name }) .first(); } @@ -100,7 +100,7 @@ export class AdminPage extends BasePage { const label = permission.charAt(0).toUpperCase() + permission.slice(1); await this.apiKeyPermissionsSelect.click(); await this.page - .locator('mat-option') + .locator('app-option') .filter({ hasText: label }) .first() .click(); diff --git a/e2e/src/pages/base.page.ts b/e2e/src/pages/base.page.ts index 6e97e00d1..e4e55b960 100644 --- a/e2e/src/pages/base.page.ts +++ b/e2e/src/pages/base.page.ts @@ -105,7 +105,7 @@ export abstract class BasePage { /** Spinner of the metadata tab */ get metadataLoadingSpinner(): Locator { - return this.page.locator('metadata-display mat-spinner').first(); + return this.page.locator('metadata-display app-spinner').first(); } /** @@ -127,20 +127,20 @@ export abstract class BasePage { .locator('item-details button:has(icon:has-text("more_vert"))') .first() .click(); - await expect(this.page.locator('.mat-mdc-menu-panel')).toBeVisible(); + await expect(this.page.locator('.cdk-menu')).toBeVisible(); } /** Edit entry of the action menu */ get editButton(): Locator { return this.page - .locator('button[mat-menu-item]:has(icon:has-text("edit"))') + .locator('button[role="menuitem"]:has(icon:has-text("edit"))') .first(); } /** Delete entry of the action menu */ get deleteButton(): Locator { return this.page - .locator('button[mat-menu-item]:has(icon:has-text("delete"))') + .locator('button[role="menuitem"]:has(icon:has-text("delete"))') .first(); } @@ -149,7 +149,7 @@ export abstract class BasePage { /** The open modal */ get dialog(): Locator { return this.page - .locator('fullscreen-modal-shell, mat-dialog-container') + .locator('fullscreen-modal-shell, cdk-dialog-container') .first(); } @@ -176,7 +176,7 @@ export abstract class BasePage { this.page.getByRole('heading', { name: 'Discard changes?' }), ).toBeVisible(); await this.page.getByRole('button', { name: 'Discard' }).click(); - await expect(this.page.locator('mat-dialog-container')).toHaveCount(0); + await expect(this.page.locator('cdk-dialog-container')).toHaveCount(0); } /** @@ -262,11 +262,9 @@ export abstract class BasePage { // Shared widgets - /** Snack bar notification with `text`. An old one can still be closing. */ + /** Toast notification with `text`. An old one can still be closing. */ notification(text: string): Locator { - return this.page - .locator('mat-snack-bar-container') - .filter({ hasText: text }); + return this.page.locator('.toast').filter({ hasText: text }); } /** diff --git a/e2e/src/pages/systems.page.ts b/e2e/src/pages/systems.page.ts index 9b74ce21f..50d80626d 100644 --- a/e2e/src/pages/systems.page.ts +++ b/e2e/src/pages/systems.page.ts @@ -30,7 +30,7 @@ export class SystemsPage extends BasePage { * Get the module selector used by execute method controls */ get executeModuleSelect(): Locator { - return this.page.locator('select-system-module mat-select').first(); + return this.page.locator('select-system-module app-select').first(); } /** @@ -49,7 +49,7 @@ export class SystemsPage extends BasePage { .first(); await zone_input.fill(zone_name); await this.page - .locator('mat-option') + .locator('app-option') .filter({ hasText: zone_name }) .first() .click(); diff --git a/e2e/src/repositories.spec.ts b/e2e/src/repositories.spec.ts index eed3c7cdc..202b7d8b7 100644 --- a/e2e/src/repositories.spec.ts +++ b/e2e/src/repositories.spec.ts @@ -132,7 +132,9 @@ test.describe('Repositories', () => { await repositories.saveButton.click(); await expect(repositories.dialog).toBeVisible(); - await expect(repositories.dialog.locator('mat-error')).toHaveText([ + await expect( + repositories.dialog.locator('app-form-field [error]:visible'), + ).toHaveText([ 'A repository name is required', 'A folder name is required', 'A URI is required', diff --git a/e2e/src/systems.spec.ts b/e2e/src/systems.spec.ts index 5938809f0..350a3bf14 100644 --- a/e2e/src/systems.spec.ts +++ b/e2e/src/systems.spec.ts @@ -65,7 +65,9 @@ test.describe('Systems', () => { await expect(systems.dialog).toBeVisible(); await expect( - systems.dialog.locator('mat-error').first(), + systems.dialog + .locator('app-form-field [error]:visible') + .first(), ).toBeVisible(); }); @@ -130,7 +132,7 @@ test.describe('Systems', () => { name: 'Edit https://cam.example.com/old.jpg', }) .click(); - const edit_input = systems.dialog.locator('.mat-chip-edit-input'); + const edit_input = systems.dialog.locator('.chip-edit'); await expect(edit_input).toBeFocused(); await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.type('https://cam.example.com/new.jpg'); @@ -140,7 +142,7 @@ test.describe('Systems', () => { await systems.openEditModal(); const chips = systems.dialog.locator( - '[aria-label="Camera Snapshot URL List"] mat-chip-row', + '[aria-label="Camera Snapshot URL List"] .chip', ); await expect(chips).toHaveCount(1); await expect(chips).toContainText( @@ -211,7 +213,7 @@ test.describe('Systems', () => { await systems.searchExecuteModules('Lighting'); const options = page - .locator('.cdk-overlay-pane mat-option') + .locator('.cdk-overlay-pane app-option') .filter({ hasText: /\S/ }); await expect( options.filter({ hasText: /Lighting/i }).first(), diff --git a/e2e/src/triggers.spec.ts b/e2e/src/triggers.spec.ts index 4d7dc15a6..942f57f86 100644 --- a/e2e/src/triggers.spec.ts +++ b/e2e/src/triggers.spec.ts @@ -81,7 +81,9 @@ test.describe('Triggers', () => { await triggers.saveButton.click(); await expect(triggers.dialog).toBeVisible(); - await expect(triggers.dialog.locator('mat-error')).toBeVisible(); + await expect( + triggers.dialog.locator('app-form-field [error]:visible'), + ).toBeVisible(); }); test('AC-TRG-003-5: saves the trigger and opens it', async () => { diff --git a/e2e/src/users.spec.ts b/e2e/src/users.spec.ts index 567e099d2..a6ea01a62 100644 --- a/e2e/src/users.spec.ts +++ b/e2e/src/users.spec.ts @@ -62,7 +62,9 @@ test.describe('Users', () => { await users.saveButton.click(); await expect(users.dialog).toBeVisible(); - await expect(users.dialog.locator('mat-error')).toHaveCount(4); + await expect( + users.dialog.locator('app-form-field [error]:visible'), + ).toHaveCount(4); }); test('AC-USR-003-6: saves the user and opens them', async () => { diff --git a/e2e/src/zones.spec.ts b/e2e/src/zones.spec.ts index ad90ef893..855010dbb 100644 --- a/e2e/src/zones.spec.ts +++ b/e2e/src/zones.spec.ts @@ -90,7 +90,9 @@ test.describe('Zones', () => { await zones.saveButton.click(); await expect(zones.dialog).toBeVisible(); - await expect(zones.dialog.locator('mat-error')).toBeVisible(); + await expect( + zones.dialog.locator('app-form-field [error]:visible'), + ).toBeVisible(); }); test('AC-ZON-003-5: saves the zone and opens it', async () => { diff --git a/package.json b/package.json index 7de757174..73437d50f 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "@angular/cli": "22.2.3", "@angular/compiler-cli": "22.2.3", "@angular/language-service": "22.2.3", - "@angular/material": "22.2.4", "@eslint/js": "^9.8.0", "@fontsource-variable/fira-code": "^5.3.0", "@fontsource-variable/roboto": "^5.3.0", diff --git a/project.json b/project.json index c41be422b..28abe36e3 100644 --- a/project.json +++ b/project.json @@ -48,8 +48,9 @@ "./node_modules/@fontsource-variable/roboto/wght-italic.css", "./src/fonts/material-symbols.css", "./node_modules/@fontsource-variable/fira-code/index.css", - "./src/styles.css", - "./src/material-theme.scss" + "./node_modules/@angular/cdk/overlay-prebuilt.css", + "./node_modules/@angular/cdk/a11y-prebuilt.css", + "./src/styles.css" ] }, "configurations": { diff --git a/src/app/admin/admin-domain-select.component.ts b/src/app/admin/admin-domain-select.component.ts index 2d879a011..b0f176a97 100644 --- a/src/app/admin/admin-domain-select.component.ts +++ b/src/app/admin/admin-domain-select.component.ts @@ -7,9 +7,10 @@ import { output, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; import { PlaceDomain } from '@placeos/ts-client'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; +import { OptionComponent } from '../ui/controls/option.component'; +import { SelectComponent } from '../ui/controls/select.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { AdminDataService } from './admin-data.service'; @@ -17,8 +18,8 @@ import { AdminDataService } from './admin-data.service'; @Component({ selector: 'admin-domain-select', template: ` - - + @if (all()) { - + {{ 'ADMIN.ALL_DOMAINS' | translate }} - + } @for (domain of domain_list(); track domain.id) { - {{ domain.name }} + {{ domain.name }} } - - + + `, - imports: [FormsModule, MatFormFieldModule, MatSelectModule, TranslatePipe], + imports: [ + OptionComponent, + SelectComponent, + FormFieldComponent, + FormsModule, + TranslatePipe, + ], }) export class AdminDomainSelectComponent { private _data = inject(AdminDataService); diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index 04cad210b..272a7f916 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -1,6 +1,5 @@ import { Component, OnInit, computed, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; import { RouterModule } from '@angular/router'; import { extensionsForItem } from '../common/api'; import { PlaceDebugService } from '../common/debug.service'; @@ -29,7 +28,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; @for (item of tab_list(); track item.id) {
- + - +
- - - @for (scope of scope_list; track scope) { - - {{ scope }} - close - - } - - - {{ - 'ADMIN.APP_KEYS_SCOPES_REQUIRED' | translate - }} - - @for (option of filtered_scopes(); track option) { - - {{ option }} - - } - - + + +
+ {{ 'ADMIN.APP_KEYS_SCOPES_REQUIRED' | translate }} +
+
+ + @for (option of filtered_scopes(); track option) { + + {{ option }} + + } +
- + @if (formModel().user?.id) { } @else { -
+
search @@ -158,19 +136,18 @@ export interface APIKeyModalData { } -
+
@if (user_list_loading()) { - + @@ -189,90 +166,87 @@ export interface APIKeyModalData { }
- {{ - 'COMMON.FIELD_REQUIRED' | translate - }} - - @for (item of users(); track item.id) { - -
- +
+ {{ 'COMMON.FIELD_REQUIRED' | translate }} +
+ + + @for (item of users(); track item.id) { + +
+ +
+
+ {{ item.name || item.email }} +
-
- {{ item.name || item.email }} -
-
- {{ item.email }} -
+ {{ item.email }}
- @if (item.sys_admin || item.support) { - {{ - (item.sys_admin - ? 'COMMON.USER_ADMIN' - : 'COMMON.USER_SUPPORT' - ) | translate - }} - }
- - } - @if (!users()?.length) { - - No results - - } - - + @if (item.sys_admin || item.support) { + {{ + (item.sys_admin + ? 'COMMON.USER_ADMIN' + : 'COMMON.USER_SUPPORT' + ) | translate + }} + } +
+
+ } + @if (!users()?.length) { + + No results + + } +
- - + - {{ + {{ 'ADMIN.PERMISSIONS_NONE' | translate - }} - {{ + }} + {{ 'ADMIN.PERMISSIONS_USER' | translate - }} - {{ + }} + {{ 'ADMIN.PERMISSIONS_SUPPORT' | translate - }} - {{ + }} + {{ 'ADMIN.PERMISSIONS_ADMIN' | translate - }} - - + }} + +
- + - - + - {{ + {{ 'ADMIN.APP_KEYS_TTL_NONE' | translate - }} + }} @for ( preset of ttl_presets; track preset.seconds ) { - {{ + {{ preset.label | translate - }} + }} } - - + +
} @@ -329,22 +303,23 @@ export interface APIKeyModalData { `, styles: [``], imports: [ + OptionComponent, + SelectComponent, + FormFieldComponent, TranslatePipe, FormsModule, FormField, FullscreenModalShellComponent, - MatFormFieldModule, - MatSelectModule, - MatAutocompleteModule, - MatInputModule, - MatChipsModule, + AutocompleteComponent, + AutocompleteTriggerDirective, + ChipListComponent, IconComponent, UserAvatarComponent, ], }) export class APIKeyModalComponent extends AsyncHandler implements OnInit { private _service = inject(APIKeyService); - private _data: APIKeyModalData = inject(MAT_DIALOG_DATA); + private _data: APIKeyModalData = inject(DIALOG_DATA); @Output() public event = new EventEmitter(); public readonly scopes = this._service.available_scopes; @@ -430,8 +405,6 @@ export class APIKeyModalComponent extends AsyncHandler implements OnInit { .includes(search), ); }); - /** List of separator characters for tags */ - public readonly separators: number[] = [ENTER, COMMA, SPACE]; /** Selectable TTL presets, value in seconds from creation */ public readonly ttl_presets: { label: string; seconds: number }[] = [ { label: 'ADMIN.APP_KEYS_TTL_HOUR', seconds: 3600 }, @@ -448,26 +421,6 @@ export class APIKeyModalComponent extends AsyncHandler implements OnInit { this.loadUsers(); }; - public readonly addScope = ( - e: MatChipInputEvent | { input: HTMLInputElement; value: string }, - ) => { - const value = `${e.value || ''}`.trim(); - if (!value) return; - this.formModel.update((model) => ({ - ...model, - scopes: model.scopes.includes(value) - ? model.scopes - : [...model.scopes, value], - })); - e.input.value = ''; - this.scope_search.set(''); - }; - public readonly removeScope = (i: string) => - this.formModel.update((model) => ({ - ...model, - scopes: model.scopes.filter((scope) => scope !== i), - })); - public ngOnInit() { this.domain.set(this._data.domain); if (this.editing) { @@ -509,6 +462,12 @@ export class APIKeyModalComponent extends AsyncHandler implements OnInit { this.search_term.set(this._userLabel(user)); } + /** Select the user that the user picked in the autocomplete */ + public pickUser(value: unknown) { + const user = this.users().find((item) => item === value); + if (user) this.selectUser(user); + } + public clearUser() { this.formModel.update((value) => ({ ...value, @@ -526,10 +485,6 @@ export class APIKeyModalComponent extends AsyncHandler implements OnInit { }, 150); } - public get scope_list(): string[] { - return this.formModel().scopes; - } - public async save() { await submit(this.form, async () => { const metadata = { ...this.formModel() }; diff --git a/src/app/admin/api-keys/api-keys.component.ts b/src/app/admin/api-keys/api-keys.component.ts index c7cbacfb4..25a96f39d 100644 --- a/src/app/admin/api-keys/api-keys.component.ts +++ b/src/app/admin/api-keys/api-keys.component.ts @@ -1,18 +1,16 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { Component, OnInit, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { i18n } from '../../common/locale.service'; import { notifyInfo } from '../../common/notifications'; import { IconComponent } from '../../ui/icon.component'; -import { LoadingLabelDirective } from '../../ui/loading-label.directive'; import { DateFromPipe } from '../../ui/pipes/date-from.pipe'; import { SimpleTableComponent } from '../../ui/simple-table.component'; import { TranslatePipe } from '../../ui/translate.pipe'; import { APIKeyService } from './api-keys.service'; +import { ProgressBarComponent } from '../../ui/controls/progress-bar.component'; +import { TooltipDirective } from '../../ui/controls/tooltip.directive'; import { AdminDomainSelectComponent } from '../admin-domain-select.component'; @Component({ selector: 'admin-api-keys', @@ -27,22 +25,22 @@ import { AdminDomainSelectComponent } from '../admin-domain-select.component'; @@ -68,7 +66,7 @@ import { AdminDomainSelectComponent } from '../admin-domain-select.component';
}
- edit @@ -198,8 +196,8 @@ import { AdminDomainSelectComponent } from '../admin-domain-select.component'; icon default error - matRipple - [matTooltip]="'ADMIN.APP_KEYS_REMOVE' | translate" + ripple + [appTooltip]="'ADMIN.APP_KEYS_REMOVE' | translate" (click)="deleteKey(row)" > delete @@ -209,15 +207,13 @@ import { AdminDomainSelectComponent } from '../admin-domain-select.component'; `, styles: [``], imports: [ + ProgressBarComponent, + TooltipDirective, AdminDomainSelectComponent, - LoadingLabelDirective, TranslatePipe, IconComponent, - MatRippleModule, - MatTooltipModule, DateFromPipe, SimpleTableComponent, - MatProgressBarModule, ], }) export class AdminAPIKeysComponent implements OnInit { diff --git a/src/app/admin/api-keys/api-keys.service.ts b/src/app/admin/api-keys/api-keys.service.ts index c5a42d4ed..3bfe234ea 100644 --- a/src/app/admin/api-keys/api-keys.service.ts +++ b/src/app/admin/api-keys/api-keys.service.ts @@ -1,5 +1,5 @@ +import { Dialog } from '@angular/cdk/dialog'; import { computed, inject, resource, Service, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { create, get, @@ -35,7 +35,7 @@ export function apiKeyExpiry( @Service() export class APIKeyService { - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _admin_data = inject(AdminDataService); private _domain = this._admin_data.selectedDomain('api-keys'); diff --git a/src/app/admin/booking-limits-modal.component.ts b/src/app/admin/booking-limits-modal.component.ts index 613cb5060..9572557a4 100644 --- a/src/app/admin/booking-limits-modal.component.ts +++ b/src/app/admin/booking-limits-modal.component.ts @@ -1,6 +1,6 @@ +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Component, inject, signal } from '@angular/core'; import { form, FormField, submit } from '@angular/forms/signals'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { post } from '@placeos/ts-client'; import { notifyError, notifySuccess } from '../common/notifications'; import { ObjectListFieldComponent } from '../ui/custom-fields/object-list-field.component'; @@ -34,10 +34,9 @@ import { StaffTenantModalData } from './staff-tenant-modal.component'; ], }) export class BookingLimitsModalComponent { - private _data: StaffTenantModalData = inject(MAT_DIALOG_DATA); - private _dialog_ref: MatDialogRef = inject( - MatDialogRef, - ); + private _data: StaffTenantModalData = inject(DIALOG_DATA); + private _dialog_ref: DialogRef = + inject(DialogRef); public readonly tenant = this._data.tenant; public readonly domain = this._data.domain; diff --git a/src/app/admin/broker-form.component.ts b/src/app/admin/broker-form.component.ts index 45f399a3b..4a44ce004 100644 --- a/src/app/admin/broker-form.component.ts +++ b/src/app/admin/broker-form.component.ts @@ -1,4 +1,4 @@ -import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Component, computed, @@ -8,26 +8,19 @@ import { signal, } from '@angular/core'; import { form, FormField } from '@angular/forms/signals'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; import { addBroker, AuthType, PlaceMQTTBroker, updateBroker, } from '@placeos/ts-client'; -import { - addSignalChipItem, - mergeFormItem, - onSaveHotkey, - removeSignalChipItem, - submitFormDialog, -} from '../common/forms'; +import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms'; import { i18n } from '../common/locale.service'; import { DialogEvent } from '../common/types'; +import { ChipListComponent } from '../ui/controls/chip-list.component'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; +import { OptionComponent } from '../ui/controls/option.component'; +import { SelectComponent } from '../ui/controls/select.component'; import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component'; import { IconComponent } from '../ui/icon.component'; import { SettingsToggleComponent } from '../ui/settings-toggle.component'; @@ -59,19 +52,21 @@ import { {{ 'COMMON.FIELD_NAME' | translate }}* - + - {{ - 'ADMIN.BROKERS_NAME_REQUIRED' | translate - }} - +
+ {{ + 'ADMIN.BROKERS_NAME_REQUIRED' + | translate + }} +
+
} @if (form.description) { @@ -79,16 +74,15 @@ import { - + - +
} @if (form.host) { @@ -103,19 +97,21 @@ import { {{ 'ADMIN.BROKERS_FIELD_HOST' | translate }}* - + - {{ - 'ADMIN.BROKERS_HOST_REQUIRED' | translate - }} - +
+ {{ + 'ADMIN.BROKERS_HOST_REQUIRED' + | translate + }} +
+ }
@@ -131,9 +127,8 @@ import { {{ 'ADMIN.BROKERS_FIELD_PORT' | translate }} * - + - +
{{ 'ADMIN.BROKERS_PORT_REQUIRED' | translate }} - - +
+
} @if (form.tls) { @@ -170,19 +165,19 @@ import { 'ADMIN.BROKERS_FIELD_AUTH_TYPE' | translate }} - - + @for (type of auth_types; track type) { - + {{ type.name }} - + } - - + + } @if (auth_type() === 2) { @@ -201,9 +196,8 @@ import { }} * - + - {{ - 'ADMIN.BROKERS_USERNAME_REQUIRED' - | translate - }} - +
+ {{ + 'ADMIN.BROKERS_USERNAME_REQUIRED' + | translate + }} +
+ } @if (form.password) { @@ -231,9 +227,8 @@ import { 'ADMIN.BROKERS_PASSWORD' | translate }} - + visibility - {{ - 'ADMIN.BROKERS_PASSWORD_REQUIRED' - | translate - }} - +
+ {{ + 'ADMIN.BROKERS_PASSWORD_REQUIRED' + | translate + }} +
+ } @@ -279,20 +276,21 @@ import { - + - {{ - 'ADMIN.BROKERS_CERT_REQUIRED' - | translate - }} - +
+ {{ + 'ADMIN.BROKERS_CERT_REQUIRED' + | translate + }} +
+ } } @@ -301,54 +299,20 @@ import { - - + - @for (filter of filters(); track filter) { - - {{ filter }} - - - } - - - + [placeholder]=" + 'ADMIN.BROKERS_FIELD_FILTERS' + | translate + " + [formField]="form.filters" + /> + } @@ -363,22 +327,22 @@ import { `, ], imports: [ - MatFormFieldModule, - MatChipsModule, + OptionComponent, + SelectComponent, + FormFieldComponent, + ChipListComponent, IconComponent, TranslatePipe, FormField, - MatInputModule, - MatSelectModule, SettingsToggleComponent, FullscreenModalShellComponent, ], }) export class BrokerFormComponent { private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); private _data = inject<{ item: PlaceMQTTBroker; readonly?: string }>( - MAT_DIALOG_DATA, + DIALOG_DATA, ); private readonly _name = 'ADMIN.BROKERS'; @@ -407,12 +371,9 @@ export class BrokerFormComponent { name: i18n('ADMIN.BROKERS_AUTH_TYPE_PASS'), }, ]; - /** List of separator characters for filters */ - public readonly separators = [ENTER, COMMA] as const; /** Whether to show password field value */ public readonly show_password = signal(false); public readonly auth_type = computed(() => this.formModel().auth_type); - public readonly filters = computed(() => this.formModel().filters || []); constructor() { onSaveHotkey(() => this.submit()); @@ -438,22 +399,4 @@ export class BrokerFormComponent { }, ); } - - /** - * Add a filter to the list of filters for the item - * @param event Input event - */ - public addFilter(event: MatChipInputEvent): void { - this.formModel.update((model) => ({ - ...model, - filters: addSignalChipItem(model.filters, event), - })); - } - - public removeFilter(existing_filter: string): void { - this.formModel.update((model) => ({ - ...model, - filters: removeSignalChipItem(model.filters, existing_filter), - })); - } } diff --git a/src/app/admin/brokers.component.ts b/src/app/admin/brokers.component.ts index 82a965b6f..3e7bd3639 100644 --- a/src/app/admin/brokers.component.ts +++ b/src/app/admin/brokers.component.ts @@ -1,22 +1,20 @@ import { Component, inject, OnInit, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { JsonPipe } from '@angular/common'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { PlaceMQTTBroker, queryBrokers, removeBroker, } from '@placeos/ts-client'; +import { Dialog } from '@angular/cdk/dialog'; import { describeError } from '../common/errors'; import { escapeHtml } from '../common/general'; import { notifyError } from '../common/notifications'; import { confirmAndRun } from '../overlays/confirm-modal.component'; +import { ProgressBarComponent } from '../ui/controls/progress-bar.component'; +import { TooltipDirective } from '../ui/controls/tooltip.directive'; import { IconComponent } from '../ui/icon.component'; -import { LoadingLabelDirective } from '../ui/loading-label.directive'; import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { BrokerFormComponent } from './broker-form.component'; @@ -33,10 +31,10 @@ import { BrokerFormComponent } from './broker-form.component';
@@ -145,7 +143,7 @@ async function queryAllZones(query_params: Record = {}) { /> @@ -173,7 +171,7 @@ async function queryAllZones(query_params: Record = {}) {
@@ -217,25 +215,15 @@ async function queryAllZones(query_params: Record = {}) { min-width: 10em; margin: 0.25em; } - - mat-card { - margin: 0.5em; - text-align: center; - } `, ], - imports: [ - LoadingLabelDirective, - MatProgressSpinnerModule, - MatRippleModule, - MatDialogModule, - ], + imports: [SpinnerComponent], }) export class PlaceDatabaseDetailsComponent extends AsyncHandler implements OnInit { - private readonly _dialog = inject(MatDialog); + private readonly _dialog = inject(Dialog); /** Where the last MQTT re-sync got to, if this user may see it */ public readonly mqtt_status = signal(null); @@ -363,7 +351,7 @@ export class PlaceDatabaseDetailsComponent panelClass: 'fullscreen-modal', width: '100vw', }); - dialog_ref.afterClosed().subscribe((zones?: PlaceZone[]) => { + dialog_ref.closed.subscribe((zones?: PlaceZone[]) => { if (!zones?.length) return; this.exportSelectedZoneTrees(zones); }); diff --git a/src/app/admin/details.component.ts b/src/app/admin/details.component.ts index c2cdb6db6..b285cc026 100644 --- a/src/app/admin/details.component.ts +++ b/src/app/admin/details.component.ts @@ -1,5 +1,4 @@ import { Component, OnInit, inject, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { apiEndpoint, get } from '@placeos/ts-client'; import { VERSION } from '../../env/version'; @@ -9,6 +8,7 @@ import { ChangelogModalData, } from '../overlays/changelog-modal.component'; +import { Dialog } from '@angular/cdk/dialog'; import { DatePipe, SlicePipe } from '@angular/common'; import { format } from 'date-fns'; import { describeError } from '../common/errors'; @@ -60,13 +60,13 @@ export interface PlaceServiceDetails { {{ 'COMMON.VERSION' | translate }}
-
- + - +
@@ -70,17 +67,16 @@ export interface EdgeModalData { `, ], imports: [ + FormFieldComponent, FullscreenModalShellComponent, - MatFormFieldModule, - MatInputModule, TranslatePipe, FormField, ], }) export class EdgeModalComponent implements OnInit { - private _data = inject(MAT_DIALOG_DATA); + private _data = inject(DIALOG_DATA); private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); public readonly edge = this._data.edge; diff --git a/src/app/admin/edge.component.ts b/src/app/admin/edge.component.ts index 6ae6e9e53..a65f90af9 100644 --- a/src/app/admin/edge.component.ts +++ b/src/app/admin/edge.component.ts @@ -1,17 +1,15 @@ import { Clipboard } from '@angular/cdk/clipboard'; +import { Dialog } from '@angular/cdk/dialog'; import { Component, inject, OnInit, signal } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { PlaceEdge, queryEdges, removeEdge } from '@placeos/ts-client'; import { describeError } from '../common/errors'; import { escapeHtml } from '../common/general'; import { notifyError, notifySuccess } from '../common/notifications'; import { confirmAndRun } from '../overlays/confirm-modal.component'; +import { ProgressBarComponent } from '../ui/controls/progress-bar.component'; +import { TooltipDirective } from '../ui/controls/tooltip.directive'; import { IconComponent } from '../ui/icon.component'; -import { LoadingLabelDirective } from '../ui/loading-label.directive'; import { DateFromPipe } from '../ui/pipes/date-from.pipe'; import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; @@ -23,8 +21,8 @@ import { EdgeModalComponent } from './edge-modal.component'; @if (last_change()?.x_api_key) { @let item = last_change();
@@ -257,9 +248,9 @@ const MAX_UPLOAD_POLLS = (60 * 60 * 1000) / UPLOAD_POLL_MS; icon default error - matRipple + ripple (click)="removeUpload(upload)" - [matTooltip]="'ADMIN.UPLOADS_LIB_REMOVE' | translate" + [appTooltip]="'ADMIN.UPLOADS_LIB_REMOVE' | translate" > delete @@ -267,22 +258,19 @@ const MAX_UPLOAD_POLLS = (60 * 60 * 1000) / UPLOAD_POLL_MS; `, imports: [ + ProgressBarComponent, + TooltipDirective, + FormFieldComponent, AdminDomainSelectComponent, - LoadingLabelDirective, IconComponent, SimpleTableComponent, TranslatePipe, - MatRippleModule, - MatTooltipModule, - MatProgressBarModule, - MatFormFieldModule, - MatInputModule, FormsModule, DateFromPipe, ], }) export class UploadLibraryComponent extends AsyncHandler implements OnInit { - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _clipboard = inject(Clipboard); private _uploads = inject(UploadsService); private _admin_data = inject(AdminDataService); diff --git a/src/app/admin/view-upload-modal.component.ts b/src/app/admin/view-upload-modal.component.ts index d8ef3daed..22a0e2324 100644 --- a/src/app/admin/view-upload-modal.component.ts +++ b/src/app/admin/view-upload-modal.component.ts @@ -1,7 +1,7 @@ +import { DIALOG_DATA } from '@angular/cdk/dialog'; import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { AuthenticatedImageDirective } from '../ui/authenticated-image.directive'; +import { DialogCloseDirective } from '../ui/controls/dialog'; import { DialogTitleDirective } from '../ui/dialog-title.directive'; import { IconComponent } from '../ui/icon.component'; import { UploadInfo } from './upload-library.component'; @@ -13,7 +13,7 @@ import { UploadInfo } from './upload-library.component'; class="border-base-100 bg-base-200 sticky top-0 z-10 m-2 flex w-[calc(100%-1rem)] items-center justify-between rounded-lg border px-4 py-2" >

View Upload

- @@ -48,17 +48,16 @@ import { UploadInfo } from './upload-library.component'; `, styles: [``], imports: [ + DialogCloseDirective, DialogTitleDirective, - MatDialogModule, AuthenticatedImageDirective, IconComponent, - MatRippleModule, ], }) export class ViewUploadModalComponent { private _data = inject<{ upload: UploadInfo; - }>(MAT_DIALOG_DATA); + }>(DIALOG_DATA); public readonly resource = `/api/engine/v2/uploads/${this._data.upload.id}/url`; public readonly type = this._data.upload.mime_type.split('/')[0]; diff --git a/src/app/admin/zone-tree-export-modal.component.ts b/src/app/admin/zone-tree-export-modal.component.ts index 79aac3e2a..60ba39171 100644 --- a/src/app/admin/zone-tree-export-modal.component.ts +++ b/src/app/admin/zone-tree-export-modal.component.ts @@ -1,13 +1,9 @@ import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; import { PlaceZone, queryZones } from '@placeos/ts-client'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { DialogCloseDirective } from '../ui/controls/dialog'; import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component'; import { DialogTitleDirective } from '../ui/dialog-title.directive'; import { IconComponent } from '../ui/icon.component'; @@ -17,7 +13,7 @@ import { IconComponent } from '../ui/icon.component'; template: `

Export Zone Tree

-
@@ -58,7 +54,7 @@ import { IconComponent } from '../ui/icon.component'; @@ -134,13 +141,14 @@ function browserOnline() { } `, imports: [ + ToastOutletComponent, + ProgressBarComponent, + TooltipDirective, GlobalBannerComponent, RouterOutlet, UploadListComponent, - MatProgressBar, GlobalLoadingComponent, IconComponent, - MatTooltip, ], }) export class AppComponent implements OnInit { @@ -169,6 +177,10 @@ export class AppComponent implements OnInit { location.reload(); } + constructor() { + installRipple(inject(DOCUMENT)); + } + public async ngOnInit() { setLoadingMessage('Initialising application...'); this._route.queryParamMap.subscribe((params) => { @@ -178,12 +190,6 @@ export class AppComponent implements OnInit { localStorage.setItem('BACKOFFICE.locale', locale); } }); - // Load the snackbar lazily to keep the overlay code out of the initial bundle - setNotifyOutlet( - import('@angular/material/snack-bar').then(({ MatSnackBar }) => - this._injector.get(MatSnackBar), - ), - ); setTranslationService(this._locale); this.loading.set(true); setLoadingMessage('Loading application settings...'); diff --git a/src/app/common/forms.ts b/src/app/common/forms.ts index 3992fb01d..7d571e82a 100644 --- a/src/app/common/forms.ts +++ b/src/app/common/forms.ts @@ -5,7 +5,6 @@ import { WritableSignal, } from '@angular/core'; import { FieldTree, submit } from '@angular/forms/signals'; -import { MatChipEditedEvent, MatChipInputEvent } from '@angular/material/chips'; import { cleanObject } from '@placeos/ts-client'; import { readError } from './errors'; import { HotkeysService } from './hotkeys.service'; @@ -13,39 +12,6 @@ import { i18n } from './locale.service'; import { notifyError, notifySuccess } from './notifications'; import { DialogEvent, Identity } from './types'; -export function addSignalChipItem( - items: T[], - event: MatChipInputEvent, - transform: (value: string) => T = (value) => value as T, -): T[] { - const value = (event.value || '').trim(); - event.chipInput?.clear(); - if (!value) return items; - const item = transform(value); - return items.includes(item) ? items : [...items, item]; -} - -export function removeSignalChipItem(items: T[], item: T): T[] { - return items.filter((existing) => existing !== item); -} - -/** - * Replace a chip item with its edited value, keeping its position. - * An empty value removes the item. A value already in the list merges into it. - */ -export function editSignalChipItem( - items: string[], - item: string, - event: MatChipEditedEvent, -): string[] { - const value = (event.value || '').trim(); - if (!value) return removeSignalChipItem(items, item); - if (value !== item && items.includes(value)) { - return removeSignalChipItem(items, item); - } - return items.map((existing) => (existing === item ? value : existing)); -} - /** Readable name for a form field key, e.g. `service_account` to "Service account" */ export function fieldLabel(key: string): string { const words = key.replace(/_/g, ' ').trim(); diff --git a/src/app/common/hotkeys.service.ts b/src/app/common/hotkeys.service.ts index 24e6f87a8..1dd8c9082 100644 --- a/src/app/common/hotkeys.service.ts +++ b/src/app/common/hotkeys.service.ts @@ -1,10 +1,6 @@ import { Service, WritableSignal, inject, signal } from '@angular/core'; -import { - MatDialog, - MatDialogRef, - MatDialogState, -} from '@angular/material/dialog'; +import { Dialog, DialogRef } from '@angular/cdk/dialog'; import { unique } from '../common/general'; import { SubscriptionLike } from '../common/signals'; import { HashMap } from '../common/types'; @@ -34,7 +30,7 @@ const KEYBOARD_WIDGETS = [ @Service() export class HotkeysService { - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); /** Map of signals which store press states of keys */ private keydown_states: HashMap> = {}; /** Map of listeners for key state signals */ @@ -212,11 +208,9 @@ export class HotkeysService { return (this.keydown_states[key]?.() || 0) > 0; } - /** Top-most dialog that is open and not closing, or null if there is none */ - private topDialog(): MatDialogRef | null { - const open = this._dialog.openDialogs.filter( - (ref) => ref.getState() === MatDialogState.OPEN, - ); + /** Top-most open dialog, or null if there is none. Closed dialogs leave the list at once */ + private topDialog(): DialogRef | null { + const open = this._dialog.openDialogs; return open[open.length - 1] || null; } @@ -224,7 +218,7 @@ export class HotkeysService { * Whether a listener registered while `owner` was on top may fire now. * A listener from a dialog that has since closed is treated as page level. */ - private isActiveLayer(owner: MatDialogRef | null): boolean { + private isActiveLayer(owner: DialogRef | null): boolean { const layer = owner && this._dialog.openDialogs.includes(owner) ? owner : null; return this.topDialog() === layer; diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts index fe85db031..8f53e6321 100644 --- a/src/app/common/item.service.ts +++ b/src/app/common/item.service.ts @@ -1,5 +1,4 @@ import { Service, inject, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { NavigationEnd, Router } from '@angular/router'; import { PlaceResource } from '@placeos/ts-client'; @@ -18,6 +17,7 @@ import { Identity, } from '../common/types'; // Modals load on demand to keep them out of the first page load +import { Dialog } from '@angular/cdk/dialog'; import type { ConfirmModalComponent, ConfirmModalData, @@ -93,7 +93,7 @@ export class ActiveItemService extends AsyncHandler { private _router = inject(Router); private _settings = inject(SettingsService); private _hotkey = inject(HotkeysService); - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _user = inject(BackofficeUsersService); /** Whether active item is loading */ @@ -318,7 +318,7 @@ export class ActiveItemService extends AsyncHandler { save: actions.save, }, }); - ref.afterClosed().subscribe(() => { + ref.closed.subscribe(() => { this._settings.post('disable_uploads', false); this._items_changed.update((count) => count + 1); }); @@ -387,52 +387,53 @@ export class ActiveItemService extends AsyncHandler { const { CONFIRM_METADATA, ConfirmModalComponent } = await import( '../overlays/confirm-modal.component' ); - const ref = this._dialog.open( - ConfirmModalComponent, - { - ...CONFIRM_METADATA, - data: { - title: i18n( - force_removal - ? 'USERS.FORCE_DELETE' - : `${actions.name}.DELETE`, - ), - content: i18n( - force_removal - ? 'USERS.FORCE_DELETE_MSG' - : `${actions.name}.DELETE_MSG`, - { - name: escapeHtml( - ( - item as PlaceResource & { - display_name?: string; - } - ).display_name || item.name, - ), - }, - ), - extra: actions.delete_extra - ? await actions.delete_extra(item) - : null, - options: cascade - ? [ - { - id: CASCADE_OPTION, - label: i18n(cascade.label), - description: i18n(cascade.description), - details: async () => { - plan = await cascade.plan(item); - const { scope, summary, warnings } = plan; - return { scope, summary, warnings }; - }, + const ref = this._dialog.open< + unknown, + ConfirmModalData, + ConfirmModalComponent + >(ConfirmModalComponent, { + ...CONFIRM_METADATA, + data: { + title: i18n( + force_removal + ? 'USERS.FORCE_DELETE' + : `${actions.name}.DELETE`, + ), + content: i18n( + force_removal + ? 'USERS.FORCE_DELETE_MSG' + : `${actions.name}.DELETE_MSG`, + { + name: escapeHtml( + ( + item as PlaceResource & { + display_name?: string; + } + ).display_name || item.name, + ), + }, + ), + extra: actions.delete_extra + ? await actions.delete_extra(item) + : null, + options: cascade + ? [ + { + id: CASCADE_OPTION, + label: i18n(cascade.label), + description: i18n(cascade.description), + details: async () => { + plan = await cascade.plan(item); + const { scope, summary, warnings } = plan; + return { scope, summary, warnings }; }, - ] - : undefined, - icon: { type: 'icon', content: 'delete' }, - destructive: true, - }, + }, + ] + : undefined, + icon: { type: 'icon', content: 'delete' }, + destructive: true, }, - ); + }); waitForEvent( ref.componentInstance.event, (e: DialogEvent) => e.reason === 'done', diff --git a/src/app/common/notifications.ts b/src/app/common/notifications.ts index 12c33d4dd..e1cd927bf 100644 --- a/src/app/common/notifications.ts +++ b/src/app/common/notifications.ts @@ -1,35 +1,30 @@ -import type { MatSnackBar } from '@angular/material/snack-bar'; +import { signal } from '@angular/core'; -import { log } from './general'; -import { ApplicationIcon } from './types'; +/** Notification shown by the `app-toast-outlet` */ +export interface Toast { + id: number; + /** CSS class of the toast, e.g. `success` or `error` */ + type: string; + message: string; + /** Text of the action button. No button shows when it is empty */ + action: string; + on_action: () => void; +} -/** Snackbar service, or a promise of it while the snackbar module loads */ -let _service: MatSnackBar | Promise | null = null; +/** Most toasts on screen at once. Older toasts are removed first */ +export const MAX_TOASTS = 3; +/** Time a toast stays on screen. Errors stay until dismissed */ +export const TOAST_DURATION_MS = 5000; -/** - * Set the snackbar used for notifications. - * Pass a promise to load the snackbar module lazily. - * Notifications made before it resolves open once it does. - * If the promise rejects (e.g. its chunk fails to load on a flaky network), - * those notifications are dropped with a warning. - */ -export function setNotifyOutlet(snackbar: MatSnackBar | Promise) { - _service = snackbar; - if (snackbar instanceof Promise) { - snackbar.then( - (service) => { - if (_service === snackbar) _service = service; - }, - (error) => - log( - 'Notify', - 'Failed to load the snackbar', - [error], - 'warn', - true, - ), - ); - } +let next_id = 0; +const _toasts = signal([]); + +/** Toasts on screen, oldest first */ +export const toasts = _toasts.asReadonly(); + +/** Remove a toast from the screen */ +export function dismissToast(id: number) { + _toasts.update((list) => list.filter((toast) => toast.id !== id)); } /** @@ -37,51 +32,26 @@ export function setNotifyOutlet(snackbar: MatSnackBar | Promise) { * @param type CSS Class to add to the notification * @param message Message to display on the notificaiton * @param action Display text for the callback action - * @param on_action Callback of action on the notification - * @param icon Icon to render to the left of the notification message + * @param on_action Callback of action on the notification. It defaults to closing it */ export function notify( type: string, message: string, action = 'OK', on_action?: () => void, - _icon: ApplicationIcon = { - type: 'icon', - content: 'info', - }, ): void { - if (!_service) { - throw new Error("Snackbar service hasn't been initialised"); - } - if (_service instanceof Promise) { - // setNotifyOutlet logs a failed load, so ignore the rejection here - _service.then( - (service) => - openNotification(service, type, message, action, on_action), - () => undefined, - ); - return; - } - openNotification(_service, type, message, action, on_action); -} - -function openNotification( - service: MatSnackBar, - type: string, - message: string, - action: string, - on_action?: () => void, -) { - const snackbar_ref = service.open(message, action, { - panelClass: [type], - // Errors stay until dismissed so they can be read and acted on - duration: type === 'error' ? undefined : 5000, - // Screen readers announce errors at once, not after the current speech - politeness: type === 'error' ? 'assertive' : 'polite', - }); - if (action) { - on_action = on_action || (() => snackbar_ref.dismiss()); - snackbar_ref.onAction().subscribe(() => on_action()); + const id = next_id++; + const toast: Toast = { + id, + type, + message, + action: action || '', + on_action: on_action || (() => dismissToast(id)), + }; + _toasts.update((list) => [...list, toast].slice(-MAX_TOASTS)); + // Errors stay until dismissed so they can be read and acted on + if (type !== 'error') { + setTimeout(() => dismissToast(id), TOAST_DURATION_MS); } } @@ -96,12 +66,8 @@ export function notifySuccess( action?: string, on_action?: () => void, ): void { - const icon: ApplicationIcon = { - type: 'icon', - content: 'done', - }; console.debug(msg); - notify('success', msg, action, on_action, icon); + notify('success', msg, action, on_action); } /** @@ -115,12 +81,8 @@ export function notifyError( action?: string, on_action?: () => void, ): void { - const icon: ApplicationIcon = { - type: 'icon', - content: 'error', - }; console.error(msg); - notify('error', msg, action, on_action, icon); + notify('error', msg, action, on_action); } /** @@ -134,12 +96,8 @@ export function notifyWarn( action?: string, on_action?: () => void, ): void { - const icon: ApplicationIcon = { - type: 'icon', - content: 'warning', - }; console.warn(msg); - notify('warn', msg, action, on_action, icon); + notify('warn', msg, action, on_action); } /** diff --git a/src/app/common/signals.ts b/src/app/common/signals.ts index 8a149ac45..5082375df 100644 --- a/src/app/common/signals.ts +++ b/src/app/common/signals.ts @@ -96,12 +96,12 @@ export function waitForClientSignalValue( export function waitForDialog( ref: { componentInstance: { event: Subscribable }; - afterClosed: () => Subscribable; + closed: Subscribable; }, reason: DialogEvent['reason'], ): Promise | undefined> { return Promise.race([ waitForEvent(ref.componentInstance.event, (e) => e.reason === reason), - waitForEvent(ref.afterClosed()), + waitForEvent(ref.closed), ]) as Promise | undefined>; } diff --git a/src/app/common/types.ts b/src/app/common/types.ts index b2e5af529..4a405fcdc 100644 --- a/src/app/common/types.ts +++ b/src/app/common/types.ts @@ -36,7 +36,7 @@ export type ExtensionConditions = /** Item field, operation and value to compare with */ export type ExtensionCondition = [string, ExtensionConditions, unknown]; -/** Shared interface for form modal components opened via MatDialog */ +/** Shared interface for form modal components opened via the CDK Dialog */ export interface FormModalComponent { event: EventEmitter; loading?: string; diff --git a/src/app/common/uploads.service.ts b/src/app/common/uploads.service.ts index 286cc943f..6b9dc85cc 100644 --- a/src/app/common/uploads.service.ts +++ b/src/app/common/uploads.service.ts @@ -1,11 +1,11 @@ +import { Dialog } from '@angular/cdk/dialog'; import { Service, inject, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { lastValueFrom } from 'rxjs'; import { UploadDetails, UploadPermissions, uploadFile } from './uploads'; @Service() export class UploadsService { - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _upload_list = signal([]); @@ -37,7 +37,7 @@ export class UploadsService { const ref = this._dialog.open(UploadPermissionsModalComponent, { data: { file }, }); - const details = await lastValueFrom(ref.afterClosed(), { + const details = await lastValueFrom(ref.closed, { defaultValue: null, }); if (!details) throw new Error('Upload cancelled'); diff --git a/src/app/domains/application-form.component.ts b/src/app/domains/application-form.component.ts index 6c6c10253..3193372fc 100644 --- a/src/app/domains/application-form.component.ts +++ b/src/app/domains/application-form.component.ts @@ -1,7 +1,5 @@ -import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { Component, - computed, effect, EventEmitter, inject, @@ -12,7 +10,6 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { form, FormField } from '@angular/forms/signals'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { addApplication, PlaceApplication, @@ -20,21 +17,14 @@ import { updateApplication, } from '@placeos/ts-client'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Md5 } from 'ts-md5'; -import { - addSignalChipItem, - mergeFormItem, - onSaveHotkey, - removeSignalChipItem, - submitFormDialog, -} from '../common/forms'; +import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms'; import { i18n } from '../common/locale.service'; import { DialogEvent } from '../common/types'; +import { ChipListComponent } from '../ui/controls/chip-list.component'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component'; -import { IconComponent } from '../ui/icon.component'; import { SettingsToggleComponent } from '../ui/settings-toggle.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { @@ -65,17 +55,19 @@ import { {{ 'COMMON.FIELD_NAME' | translate }}*: - + - {{ - 'DOMAINS.APP_NAME_REQUIRED' | translate - }} - +
+ {{ + 'DOMAINS.APP_NAME_REQUIRED' + | translate + }} +
+ } @if (form.scopes) { @@ -85,16 +77,15 @@ import { 'DOMAINS.APP_SCOPES' | translate }}: - + - + } @@ -105,48 +96,17 @@ import { 'DOMAINS.APP_SUBSYSTEMS' | translate }}: - - + - @for ( - item of subsystem_list(); - track item - ) { - -
- {{ item }} -
- -
- } -
- -
+ } @if (form.redirect_uri) { @@ -154,18 +114,19 @@ import { - + - {{ - 'DOMAINS.APP_REDIRECT_URL_REQUIRED' - | translate - }} - +
+ {{ + 'DOMAINS.APP_REDIRECT_URL_REQUIRED' + | translate + }} +
+ }
@@ -185,9 +146,8 @@ import { - + - +
} @@ -213,22 +173,20 @@ import { `, ], imports: [ - MatFormFieldModule, - MatChipsModule, + FormFieldComponent, + ChipListComponent, FormsModule, TranslatePipe, SettingsToggleComponent, FormField, - MatInputModule, - IconComponent, FullscreenModalShellComponent, ], }) export class ApplicationFormComponent implements OnInit { private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); private _data = inject<{ item: PlaceApplication; readonly?: string }>( - MAT_DIALOG_DATA, + DIALOG_DATA, ); private readonly _name = 'DOMAINS.APPLICATION'; private _injector = inject(Injector); @@ -242,20 +200,6 @@ export class ApplicationFormComponent implements OnInit { public readonly loading = signal(null); public readonly heading = signal(''); public readonly client_id = signal(''); - public readonly separators: number[] = [ENTER, COMMA, SPACE]; - public subsystem_list = computed(() => this.formModel().subsystems || []); - - public readonly addSubsystem = (e: MatChipInputEvent) => - this.formModel.update((value) => ({ - ...value, - subsystems: addSignalChipItem(value.subsystems, e), - })); - public readonly removeSubsystem = (i: string) => - this.formModel.update((value) => ({ - ...value, - subsystems: removeSignalChipItem(value.subsystems, i), - })); - constructor() { onSaveHotkey(() => this.submit()); } diff --git a/src/app/domains/applications.utilities.ts b/src/app/domains/applications.utilities.ts index 06c37204d..d39687c36 100644 --- a/src/app/domains/applications.utilities.ts +++ b/src/app/domains/applications.utilities.ts @@ -1,5 +1,5 @@ -import { PlaceApplication } from '@placeos/ts-client'; import { required, SchemaFn, validate } from '@angular/forms/signals'; +import { PlaceApplication } from '@placeos/ts-client'; import { isValidUrl } from '../common/validation'; export interface ApplicationFormModel { @@ -32,6 +32,8 @@ export const applyApplicationFormSchema: SchemaFn = ( ) => { required(path.name); validate(path.redirect_uri, ({ value }) => - isValidUrl(value()) ? undefined : { kind: 'url', message: 'Invalid URL' }, + isValidUrl(value()) + ? undefined + : { kind: 'url', message: 'Invalid URL' }, ); }; diff --git a/src/app/domains/domain-about.component.ts b/src/app/domains/domain-about.component.ts index ce0fba426..9eef5b7bc 100644 --- a/src/app/domains/domain-about.component.ts +++ b/src/app/domains/domain-about.component.ts @@ -5,13 +5,12 @@ import { form, FormField, submit, validate } from '@angular/forms/signals'; import { PlaceDomain } from '@placeos/ts-client'; import { Clipboard } from '@angular/cdk/clipboard'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTabsModule } from '@angular/material/tabs'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { readError } from '../common/errors'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess } from '../common/notifications'; import { validateJSONString } from '../common/validation'; +import { TabsComponent } from '../ui/controls/tabs.component'; +import { TooltipDirective } from '../ui/controls/tooltip.directive'; import { SettingsFieldComponent } from '../ui/custom-fields/settings-field.component'; import { IconComponent } from '../ui/icon.component'; import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; @@ -47,7 +46,7 @@ import { DomainStateService } from './domain-state.service'; @for (domain of item?.email_domains; track domain) { @@ -133,26 +130,34 @@ import { DomainStateService } from './domain-state.service'; - +
@for (subsystem of row.subsystems || []; track subsystem) { - {{ subsystem }} + {{ subsystem }} } - +
- +
@for (scope of scopeList(row); track scope) { - {{ scope }} + {{ scope }} } - +
- edit @@ -81,8 +79,8 @@ import { DomainStateService } from './domain-state.service'; icon default error - matRipple - [matTooltip]="'DOMAINS.AUTHENTICATION_REMOVE' | translate" + ripple + [appTooltip]="'DOMAINS.AUTHENTICATION_REMOVE' | translate" (click)="removeAuthSource(row)" > delete @@ -99,13 +97,11 @@ import { DomainStateService } from './domain-state.service'; `, ], imports: [ - LoadingLabelDirective, + ProgressBarComponent, + TooltipDirective, SimpleTableComponent, IconComponent, TranslatePipe, - MatRippleModule, - MatProgressBarModule, - MatTooltipModule, ], }) export class DomainAuthenticationComponent { diff --git a/src/app/domains/domain-form.component.ts b/src/app/domains/domain-form.component.ts index 652f134bd..977396b69 100644 --- a/src/app/domains/domain-form.component.ts +++ b/src/app/domains/domain-form.component.ts @@ -1,31 +1,15 @@ -import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; -import { - Component, - computed, - EventEmitter, - inject, - Output, - signal, -} from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { Component, EventEmitter, inject, Output, signal } from '@angular/core'; import { form, FormField } from '@angular/forms/signals'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; import { addDomain, PlaceDomain, updateDomain } from '@placeos/ts-client'; -import { - addSignalChipItem, - mergeFormItem, - onSaveHotkey, - removeSignalChipItem, - submitFormDialog, -} from '../common/forms'; +import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms'; import { i18n } from '../common/locale.service'; import { notifyWarn } from '../common/notifications'; import { DialogEvent } from '../common/types'; import { isValidDomain } from '../common/validation'; +import { ChipListComponent } from '../ui/controls/chip-list.component'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component'; -import { IconComponent } from '../ui/icon.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { applyDomainFormSchema, @@ -55,9 +39,8 @@ import { {{ 'COMMON.FIELD_NAME' | translate }}* - + @if (form.name().invalid()) { - +
{{ 'DOMAINS.NAME_REQUIRED' | translate }} - +
} -
+
} @if (form.domain) { @@ -86,9 +69,8 @@ import { > {{ 'DOMAINS.NAME' | translate }} - + - {{ - 'DOMAINS.DOMAIN_REQUIRED' | translate - }} - +
+ {{ + 'DOMAINS.DOMAIN_REQUIRED' + | translate + }} +
+ } @@ -114,9 +99,8 @@ import { > {{ 'DOMAINS.LOGIN_URL' | translate }} - + @if (form.login_url().invalid()) { - +
{{ 'DOMAINS.LOGIN_URL_REQUIRED' | translate }} - +
} -
+ } @if (form.logout_url) { @@ -145,9 +129,8 @@ import { > {{ 'DOMAINS.LOGOUT_URL' | translate }} - + @if (form.logout_url().invalid()) { - +
{{ 'DOMAINS.LOGOUT_URL_REQUIRED' | translate }} - +
} -
+ } @if (form.description) { @@ -170,16 +153,15 @@ import { - + - + } @if (form.email_domains) { @@ -193,85 +175,39 @@ import { > {{ 'DOMAINS.EMAIL_DOMAINS' | translate }} - - + - @for ( - item of email_domain_list(); - track item - ) { - -
- {{ item }} -
- -
- } -
- -
+ } } `, - styles: [ - ` - mat-checkbox { - margin-top: 2.5em; - margin-bottom: 1.5em; - } - - @media screen and (max-width: 640px) { - mat-checkbox { - margin-top: 0; - } - } - `, - ], imports: [ - MatFormFieldModule, - MatChipsModule, - MatInputModule, + FormFieldComponent, + ChipListComponent, TranslatePipe, FormField, - IconComponent, FullscreenModalShellComponent, ], }) export class DomainFormComponent { private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); private _data = inject<{ item: PlaceDomain; readonly?: string }>( - MAT_DIALOG_DATA, + DIALOG_DATA, ); private readonly _name = 'DOMAINS'; @@ -286,27 +222,18 @@ export class DomainFormComponent { `${this._name}.${this._data.item.id ? 'EDIT' : 'NEW'}`, ); - /** List of separator characters for tags */ - public readonly separators: number[] = [ENTER, COMMA, SPACE]; - - public readonly addEmailDomain = (e: MatChipInputEvent) => { - if (!e?.value) return; - if (!isValidDomain(e.value as string)) - return notifyWarn('Invalid email'); - this.formModel.update((value) => ({ - ...value, - email_domains: addSignalChipItem(value.email_domains, e), - })); - }; - public readonly removeEmailDomain = (i: string) => - this.formModel.update((value) => ({ - ...value, - email_domains: removeSignalChipItem(value.email_domains, i), - })); - - public email_domain_list = computed( - () => this.formModel().email_domains || [], - ); + /** + * Update the email domains from the chip list. + * New items that are not valid domains are dropped with a warning. + */ + public setEmailDomains(list: string[]) { + const current = this.formModel().email_domains; + const email_domains = list.filter( + (item) => current.includes(item) || isValidDomain(item), + ); + if (email_domains.length < list.length) notifyWarn('Invalid email'); + this.formModel.update((value) => ({ ...value, email_domains })); + } constructor() { onSaveHotkey(() => this.submit()); diff --git a/src/app/domains/domain-state.service.ts b/src/app/domains/domain-state.service.ts index 8b9d28669..5383d93a3 100644 --- a/src/app/domains/domain-state.service.ts +++ b/src/app/domains/domain-state.service.ts @@ -1,3 +1,4 @@ +import { Dialog } from '@angular/cdk/dialog'; import { computed, inject, @@ -7,7 +8,6 @@ import { signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatDialog } from '@angular/material/dialog'; import { NavigationEnd, Router } from '@angular/router'; import { addApplication, @@ -51,7 +51,7 @@ export type PlaceAuthSource = @Service() export class DomainStateService { private _state = inject(ActiveItemService); - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private readonly _router = inject(Router); private readonly _url = toSignal( diff --git a/src/app/domains/domain-users.component.ts b/src/app/domains/domain-users.component.ts index 384ff2589..aca3918c8 100644 --- a/src/app/domains/domain-users.component.ts +++ b/src/app/domains/domain-users.component.ts @@ -1,7 +1,6 @@ import { Component, inject } from '@angular/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { LoadingLabelDirective } from '../ui/loading-label.directive'; +import { ProgressBarComponent } from '../ui/controls/progress-bar.component'; import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { DomainStateService } from './domain-state.service'; @@ -10,7 +9,7 @@ import { DomainStateService } from './domain-state.service'; selector: 'domain-users', template: `
-
- @if (show_permissions) { - - - - + [labels]="[ + 'GROUPS.TAB_USERS' | translate, + 'GROUPS.FIELD_PERMISSIONS' | translate, + ]" + [(selectedIndex)]="tab" + /> }
@if (!show_permissions || tab() === 0) { - + -
+
search
@if (loading()) { -
- +
+
} - +
@if (list_items().length) { @for (item of list_items(); track item.id) {
- + - +
@@ -120,8 +111,8 @@ import { id="group-authority-label" >{{ 'GROUPS.AUTHORITY_ID' | translate }} - - + + {{ domain.name }} - + } - - + +
- - - @for ( - subsystem of subsystem_list(); - track subsystem - ) { - -
- {{ subsystem }} -
- -
- } -
- + -
+
@@ -233,13 +197,12 @@ import { `, styles: [``], imports: [ - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatChipsModule, + OptionComponent, + SelectComponent, + FormFieldComponent, + ChipListComponent, FormsModule, ItemSearchFieldComponent, - IconComponent, TranslatePipe, FormField, FullscreenModalShellComponent, @@ -249,8 +212,8 @@ import { }) export class GroupFormComponent implements OnInit { private _dialog_ref = - inject>(MatDialogRef); - private _data = inject<{ item: PlaceGroup }>(MAT_DIALOG_DATA); + inject>(DialogRef); + private _data = inject<{ item: PlaceGroup }>(DIALOG_DATA); private readonly _name = 'GROUPS'; @Output() public event = new EventEmitter(); @@ -272,10 +235,6 @@ export class GroupFormComponent implements OnInit { () => this._domain_list.value() || [], ); public readonly parent_group = signal(null); - public readonly subsystem_list = computed( - () => this.formModel().subsystems || [], - ); - public readonly separators: number[] = [ENTER, COMMA]; public readonly permission_flags = GROUP_PERMISSION_FLAGS; public readonly hasPermission = hasGroupPermission; /** Whether AD groups can be found with the staff API */ @@ -344,18 +303,6 @@ export class GroupFormComponent implements OnInit { this.formModel.update((value) => ({ ...value, ad_group_mappings })); } - public readonly addSubsystem = (event: MatChipInputEvent) => - this.formModel.update((value) => ({ - ...value, - subsystems: addSignalChipItem(value.subsystems, event), - })); - - public readonly removeSubsystem = (subsystem: string) => - this.formModel.update((value) => ({ - ...value, - subsystems: removeSignalChipItem(value.subsystems, subsystem), - })); - public async submit(): Promise { await submitFormDialog( this.form, diff --git a/src/app/groups/group-membership.component.ts b/src/app/groups/group-membership.component.ts index d0890d5d0..60c743fdf 100644 --- a/src/app/groups/group-membership.component.ts +++ b/src/app/groups/group-membership.component.ts @@ -6,11 +6,10 @@ import { viewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { i18n } from '../common/locale.service'; +import { ProgressBarComponent } from '../ui/controls/progress-bar.component'; +import { TooltipDirective } from '../ui/controls/tooltip.directive'; import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component'; import { IconComponent } from '../ui/icon.component'; import { @@ -39,9 +38,9 @@ import { groupPermissionLabels } from './group-permissions'; /> - @@ -79,11 +81,10 @@ export interface GroupPermissionsModalResult { `, styles: [``], imports: [ + DialogCloseDirective, DialogTitleDirective, FormsModule, IconComponent, - MatDialogModule, - MatRippleModule, SettingsToggleComponent, TranslatePipe, ], @@ -91,12 +92,12 @@ export interface GroupPermissionsModalResult { export class GroupPermissionsModalComponent { private _dialog_ref = inject< - MatDialogRef< - GroupPermissionsModalComponent, - GroupPermissionsModalResult + DialogRef< + GroupPermissionsModalResult, + GroupPermissionsModalComponent > - >(MatDialogRef); - private _data = inject(MAT_DIALOG_DATA); + >(DialogRef); + private _data = inject(DIALOG_DATA); public readonly title = this._data.title; public readonly show_deny = this._data.show_deny; @@ -112,9 +113,10 @@ export class GroupPermissionsModalComponent { } public save() { - this._dialog_ref.close({ + const result: GroupPermissionsResult = { permissions: this.permissions(), deny: this.deny, - }); + }; + this._dialog_ref.close(result); } } diff --git a/src/app/groups/group-state.service.ts b/src/app/groups/group-state.service.ts index 06b4b1d39..a32660046 100644 --- a/src/app/groups/group-state.service.ts +++ b/src/app/groups/group-state.service.ts @@ -1,5 +1,5 @@ +import { Dialog } from '@angular/cdk/dialog'; import { computed, inject, resource, Service, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { addGroupUser, addGroupZone, @@ -27,7 +27,7 @@ import { groupMembership } from './group-membership'; @Service() export class GroupStateService { private _state = inject(ActiveItemService); - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _changed = signal(0); public readonly item = computed( diff --git a/src/app/modules/module-about.component.ts b/src/app/modules/module-about.component.ts index f8b58533c..f7fb0e8cb 100644 --- a/src/app/modules/module-about.component.ts +++ b/src/app/modules/module-about.component.ts @@ -2,16 +2,14 @@ import { Component, inject, signal } from '@angular/core'; import { PlaceModule } from '@placeos/ts-client'; import { hasSupportRole } from '../common/support-access'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; +import { Dialog } from '@angular/cdk/dialog'; import { RouterModule } from '@angular/router'; +import { SpinnerComponent } from '../ui/controls/spinner.component'; +import { TooltipDirective } from '../ui/controls/tooltip.directive'; import { CustomTooltipComponent } from '../ui/custom-tooltip.component'; import { SettingsFormComponent } from '../ui/forms/settings-form.component'; import { IconComponent } from '../ui/icon.component'; import { ItemTimestampsComponent } from '../ui/item-timestamps.component'; -import { LoadingLabelDirective } from '../ui/loading-label.directive'; import { ModuleRuntimeErrorsModalComponent } from '../ui/module-runtime-errors-modal.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { ModuleStateService } from './module-state.service'; @@ -145,7 +143,7 @@ import { ModuleStateService } from './module-state.service'; @if (item.has_runtime_error && hasSupportRole()) {
@@ -205,7 +203,7 @@ import { ModuleStateService } from './module-state.service';
- +

{{ 'MODULES.LOADING_SETTINGS' | translate }}

} @@ -224,13 +222,11 @@ import { ModuleStateService } from './module-state.service'; `, ], imports: [ + SpinnerComponent, + TooltipDirective, ItemTimestampsComponent, - LoadingLabelDirective, TranslatePipe, - MatProgressSpinnerModule, SettingsFormComponent, - MatRippleModule, - MatTooltipModule, CustomTooltipComponent, IconComponent, RouterModule, @@ -239,7 +235,7 @@ import { ModuleStateService } from './module-state.service'; export class ModuleAboutComponent { public readonly hasSupportRole = hasSupportRole; private _service = inject(ModuleStateService); - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); /** Driver for the active item */ public readonly driver = this._service.driver; @@ -263,7 +259,7 @@ export class ModuleAboutComponent { } public viewErrors() { - this._dialog.open( + this._dialog.open( ModuleRuntimeErrorsModalComponent, { data: this.item.id }, ); diff --git a/src/app/modules/module-form.component.ts b/src/app/modules/module-form.component.ts index fc2dd7c2d..2156b8397 100644 --- a/src/app/modules/module-form.component.ts +++ b/src/app/modules/module-form.component.ts @@ -25,13 +25,13 @@ import { querySupportSystems as querySystems, } from '../common/support-access'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms'; import { i18n } from '../common/locale.service'; import { DialogEvent } from '../common/types'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; +import { OptionComponent } from '../ui/controls/option.component'; +import { SelectComponent } from '../ui/controls/select.component'; import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component'; import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component'; import { SettingsToggleComponent } from '../ui/settings-toggle.component'; @@ -142,19 +142,18 @@ import { {{ 'MODULES.URI' | translate }}* - + - {{ - 'MODULES.URI_REQUIRED' | translate - }} - +
+ {{ 'MODULES.URI_REQUIRED' | translate }} +
+
}
@@ -173,22 +172,21 @@ import { {{ 'MODULES.FIELD_IP' | translate }} * - + @if (form.ip().invalid()) { - +
{{ 'MODULES.IP_REQUIRED' | translate }} - +
} -
+
} @if ( @@ -206,9 +204,8 @@ import { {{ 'MODULES.PORT_NUMBER' | translate }} * - + @if (form.port().invalid()) { - +
{{ 'MODULES.PORT_REQUIRED' | translate }} - +
} -
+
} @@ -272,8 +269,8 @@ import { - - + + {{ level.name | translate }} - + } - - + + } @if (form.notes) { @@ -295,16 +292,15 @@ import { - + - + } @if (form.custom_name) { @@ -312,16 +308,15 @@ import { - + - + } @if (form.edge && !formModel().id) { @@ -348,21 +343,21 @@ import { `, styles: [``], imports: [ + OptionComponent, + SelectComponent, + FormFieldComponent, ItemSearchFieldComponent, TranslatePipe, FormField, - MatFormFieldModule, - MatInputModule, - MatSelectModule, SettingsToggleComponent, FullscreenModalShellComponent, ], }) export class ModuleFormComponent implements OnInit { private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); private _data = inject<{ item: PlaceModule; readonly?: string }>( - MAT_DIALOG_DATA, + DIALOG_DATA, ); private readonly _name = 'MODULES'; diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts index b7e1af1c9..3305306ef 100644 --- a/src/app/modules/module-state.service.ts +++ b/src/app/modules/module-state.service.ts @@ -1,5 +1,5 @@ +import { Dialog } from '@angular/cdk/dialog'; import { computed, inject, resource, Service, signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { moduleSettings, PlaceModule, @@ -19,7 +19,7 @@ import { ViewResponseModalComponent } from '../overlays/view-response-modal.comp @Service() export class ModuleStateService { private _state = inject(ActiveItemService); - private _dialog = inject(MatDialog); + private _dialog = inject(Dialog); private _loading = signal(false); @@ -132,7 +132,7 @@ export class ModuleStateService { /** View Results of the execute */ private viewDetails(content: unknown) { - this._dialog.open( + this._dialog.open( ViewResponseModalComponent, { data: { content }, diff --git a/src/app/modules/module-systems.component.ts b/src/app/modules/module-systems.component.ts index 4f29e281b..5a6c7a9d2 100644 --- a/src/app/modules/module-systems.component.ts +++ b/src/app/modules/module-systems.component.ts @@ -1,12 +1,10 @@ import { Component, computed, inject, model } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; import { RouterModule } from '@angular/router'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; +import { ProgressBarComponent } from '../ui/controls/progress-bar.component'; import { IconComponent } from '../ui/icon.component'; -import { LoadingLabelDirective } from '../ui/loading-label.directive'; import { DateFromPipe } from '../ui/pipes/date-from.pipe'; import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; @@ -17,22 +15,21 @@ import { ModuleStateService } from './module-state.service'; template: `
- -
+ +
search
- +
- {{ 'DOMAINS.AUTHENTICATION_SOURCE_TYPE' | translate }}: - - + @for (type of source_types; track type.id) { - + {{ type.name }} - + } - - + +
} @if (item()) { @@ -112,9 +113,10 @@ export type AuthSourceTypes = 'oauth' | 'saml' | 'ldap'; `, styles: [``], imports: [ + OptionComponent, + SelectComponent, + FormFieldComponent, FullscreenModalShellComponent, - MatFormFieldModule, - MatSelectModule, TranslatePipe, FormsModule, SamlSourceFormComponent, @@ -124,8 +126,8 @@ export type AuthSourceTypes = 'oauth' | 'saml' | 'ldap'; }) export class AuthSourceModalComponent implements OnInit { private _dialog = - inject>(MatDialogRef); - private _data = inject(MAT_DIALOG_DATA); + inject>(DialogRef); + private _data = inject(DIALOG_DATA); /** List of available authentication sources */ public source_types: Identity[] = [ diff --git a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts index e5d489634..d9e2c8bac 100644 --- a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts +++ b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts @@ -1,11 +1,6 @@ import { Component, Type, computed, inject, signal } from '@angular/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; -import { MatRippleModule } from '@angular/material/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { PlaceDriver, PlaceModule, @@ -17,6 +12,7 @@ import { import { unique } from '../../common/general'; import { notifyError, notifyWarn } from '../../common/notifications'; import { HashMap, Identity } from '../../common/types'; +import { DialogCloseDirective } from '../../ui/controls/dialog'; import { DialogTitleDirective } from '../../ui/dialog-title.directive'; import { IconComponent } from '../../ui/icon.component'; import { TranslatePipe } from '../../ui/translate.pipe'; @@ -69,8 +65,8 @@ export interface BulkItemModalData> { @if (!loading) { - `, styles: [``], imports: [ + OptionComponent, + SelectComponent, + FormFieldComponent, TranslatePipe, - MatRippleModule, - MatFormFieldModule, - MatSelectModule, FormsModule, ], }) @@ -189,8 +184,7 @@ export class MatchFieldsComponent implements OnChanges, OnInit { public readonly mapped_field_count = computed(() => { const mapping = this.field_mapping(); - return this.field_list().filter((field) => !!mapping[field.id]) - .length; + return this.field_list().filter((field) => !!mapping[field.id]).length; }); public isRequired(field: unknown): boolean { diff --git a/src/app/overlays/bulk-item-modal/status-list.component.ts b/src/app/overlays/bulk-item-modal/status-list.component.ts index 526f02991..1c21df7f1 100644 --- a/src/app/overlays/bulk-item-modal/status-list.component.ts +++ b/src/app/overlays/bulk-item-modal/status-list.component.ts @@ -7,14 +7,12 @@ import { signal, SimpleChanges, } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { PlaceResource } from '@placeos/ts-client'; import { readError } from '../../common/errors'; import { notifyError } from '../../common/notifications'; +import { SpinnerComponent } from '../../ui/controls/spinner.component'; +import { TooltipDirective } from '../../ui/controls/tooltip.directive'; import { IconComponent } from '../../ui/icon.component'; -import { LoadingLabelDirective } from '../../ui/loading-label.directive'; import { TranslatePipe } from '../../ui/translate.pipe'; import { VirtualScrollComponent } from '../../ui/virtual-scroll.component'; @@ -64,7 +62,7 @@ const BATCH_SIZE = 5; >
-
@@ -324,15 +313,20 @@ export async function confirmAndRun( class="border-base-200 bg-base-200/40 w-full space-y-2 rounded-sm border p-3" [attr.data-option]="option.id" > - + + @if (option.description) { -

+

{{ option.description }}

} @@ -343,7 +337,7 @@ export async function confirmAndRun( details-loading class="flex items-center space-x-2 opacity-60" > - + {{ 'CASCADE.RESOLVING' | translate @@ -403,7 +397,7 @@ export async function confirmAndRun(
- +

{{ loading() }}

@@ -414,15 +408,15 @@ export async function confirmAndRun( > - + } `, imports: [ - MatDialogModule, + DialogCloseDirective, + DialogTitleDirective, + SpinnerComponent, + FormFieldComponent, IconComponent, - MatFormFieldModule, - MatInputModule, FormsModule, - MatProgressSpinnerModule, ], }) export class DuplicateModalComponent { - private _data = inject(MAT_DIALOG_DATA); + private _data = inject(DIALOG_DATA); private _dialog_ref = - inject>(MatDialogRef); + inject>(DialogRef); + /** Whether the user closed the dialog */ + private _closed = false; + + constructor() { + // Closing the dialog destroys this component before `closed` emits + inject(DestroyRef).onDestroy(() => (this._closed = true)); + } /** Emitter for user action on the modal */ public readonly event = output(); @@ -151,7 +156,7 @@ export class DuplicateModalComponent { this.temp.set(new Array(this.times).fill({})); for (let i = 0; i < this.times; i++) { // Stop creating duplicates if the user closes the dialog - if (this._dialog_ref.getState() !== MatDialogState.OPEN) break; + if (this._closed) break; const new_item = new ItemConstructor({ ...item, id: '', @@ -167,6 +172,8 @@ export class DuplicateModalComponent { this.setStatus(i, 'done'); } } + // A closed dialog has no one to tell, and closing it again throws + if (this._closed) return; const clean_list = list.filter((item) => !!item); this.event.emit({ reason: 'done', diff --git a/src/app/overlays/metadata-details-modal.component.ts b/src/app/overlays/metadata-details-modal.component.ts index c14e3fb3a..b2a24c29a 100644 --- a/src/app/overlays/metadata-details-modal.component.ts +++ b/src/app/overlays/metadata-details-modal.component.ts @@ -1,4 +1,4 @@ -import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; import { Component, inject, OnInit, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -8,16 +8,9 @@ import { submit, validate, } from '@angular/forms/signals'; -import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; -import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { addSignalChipItem, removeSignalChipItem } from '../common/forms'; +import { ChipListComponent } from '../ui/controls/chip-list.component'; +import { DialogCloseDirective } from '../ui/controls/dialog'; +import { FormFieldComponent } from '../ui/controls/form-field.component'; import { DialogTitleDirective } from '../ui/dialog-title.directive'; import { IconComponent } from '../ui/icon.component'; import type { MetadataFormModel } from '../ui/metadata-display.component'; @@ -42,7 +35,7 @@ export interface MetadataDetailsModalData { - - } - - + - + - + - + }