diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d4809a93..c8f7bebbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,12 @@ jobs: is_production: ${{ steps.build_ext.outputs.is_production }} steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 + + # The changelog needs the history and tags of this branch only. + # fetch-depth: 0 also downloads the build/* deploy branches (about + # 560 MB against 30 MB). + - name: Fetch branch history and tags + run: git fetch --unshallow --tags origin "$GITHUB_REF" - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -38,11 +42,6 @@ jobs: with: tool: git-cliff@2.13.1 - # Do not set GITHUB_REPO. cliff.toml uses no GitHub metadata, and - # the unauthenticated metadata requests hit the API rate limit. - - name: Generate application changelog - run: git-cliff --config cliff.toml --output "${RUNNER_TEMP}/CHANGELOG.md" - - name: Extract branch name id: branch_name shell: bash @@ -78,8 +77,15 @@ jobs: # Do not restore an Nx cache for this build. Nx does not hash the # gitignored src/env/version.ts, so a cached build could deploy # the version info of an earlier commit. - - name: Build application - run: bun run build -- --verbose --configuration=${{ steps.build_ext.outputs.configuration }} + # git-cliff runs alongside the build; `wait` fails the step if it fails. + # Do not set GITHUB_REPO. cliff.toml uses no GitHub metadata, and + # the unauthenticated metadata requests hit the API rate limit. + - name: Build application and changelog + run: | + git-cliff --config cliff.toml --output "${RUNNER_TEMP}/CHANGELOG.md" & + cliff_pid=$! + bun run build -- --verbose --configuration=${{ steps.build_ext.outputs.configuration }} + wait "$cliff_pid" - name: Add application changelog to build run: cp "${RUNNER_TEMP}/CHANGELOG.md" dist/backoffice/browser/CHANGELOG.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3989eafb6..a0597d1f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -82,26 +82,11 @@ jobs: - name: Setup version run: bun run postinstall - - name: Get Playwright version - id: playwright - run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || 'all' }} - + # A browser cache saves no time here: a hit still installs the + # system packages, and a restore takes about as long as a download. - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' run: bunx playwright install --with-deps ${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || '' }} - # The cache holds the browsers only. The system packages still need to be installed. - - name: Install Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: bunx playwright install-deps ${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || '' }} - - name: Run E2E tests run: bunx nx e2e e2e ${{ env.CHROMIUM_ONLY == 'true' && '-- --project=chromium --project=mobile-chrome' || '' }} diff --git a/bun.lock b/bun.lock index f35dd7cce..1be7ca360 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,6 @@ "@xterm/xterm": "^5.5.0", "angular-eslint": "22.6.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "eslint": "^9.8.0", @@ -1109,8 +1108,6 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "blob-util": ["blob-util@2.0.2", "", {}, "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], diff --git a/docs/bundle-size.md b/docs/bundle-size.md index 0cf71cfa6..ad73ec9d3 100644 --- a/docs/bundle-size.md +++ b/docs/bundle-size.md @@ -39,10 +39,14 @@ shares concurrent requests, has a 30-second timeout, and permits retry. Componen must check whether they were destroyed before creating an editor after the load. `markdown-languages.ts` lists the existing web languages and aliases explicitly. -Only `github-dark` is packaged. Shiki uses the JavaScript regex engine, so the -Oniguruma wasm is not downloaded. Add new language loaders there. Importing the +Add new language loaders there. Only `github-dark` is packaged. Shiki uses the +JavaScript regex engine, so the Oniguruma wasm is not downloaded. Importing the Shiki web preset at runtime also brings its unused theme registry into the build. +The markdown pipe loads `markdown-render.ts`, which holds marked and Shiki, on +first use. Pages without markdown do not download them. Do not import that file +statically. + ## Themes and caching `src/material-theme.scss` retains the indigo-pink palette and includes the Material diff --git a/e2e/src/pages/base.page.ts b/e2e/src/pages/base.page.ts index 076a6b3bc..5fd963451 100644 --- a/e2e/src/pages/base.page.ts +++ b/e2e/src/pages/base.page.ts @@ -39,9 +39,6 @@ export abstract class BasePage { await this.page .waitForSelector('sidebar-menu, item-sidebar', { timeout: 30000 }) .catch(() => undefined); - - // Wait for any progress bars to complete - await this.page.waitForTimeout(500); } /** diff --git a/package.json b/package.json index e28285840..b9c8bece4 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@xterm/xterm": "^5.5.0", "angular-eslint": "22.6.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "eslint": "^9.8.0", diff --git a/project.json b/project.json index 1eb407199..d63f45ec1 100644 --- a/project.json +++ b/project.json @@ -29,12 +29,13 @@ { "glob": "**/*", "input": "node_modules/monaco-editor/min/", - "output": "./assets/monaco/min/" - }, - { - "input": "src/environments/dev", - "output": "/assets", - "glob": "*.js" + "output": "./assets/monaco/min/", + "ignore": [ + "**/ts.worker-*.js", + "**/css.worker-*.js", + "**/html.worker-*.js", + "**/nls.messages.*.js" + ] }, { "input": "node_modules/ts-md5/dist/", diff --git a/src/app/admin/api-keys/api-keys.service.ts b/src/app/admin/api-keys/api-keys.service.ts index 458ffda72..7a63b0620 100644 --- a/src/app/admin/api-keys/api-keys.service.ts +++ b/src/app/admin/api-keys/api-keys.service.ts @@ -6,7 +6,6 @@ import { PlaceDomain, PlaceUser, query, - queryUsers, remove, showUser, update, @@ -38,7 +37,6 @@ export class APIKeyService { private _dialog = inject(MatDialog); private _admin_data = inject(AdminDataService); - private _search = signal(''); private _domain = this._admin_data.selectedDomain('api-keys'); private _last_key = signal(null); private _change = signal(0); @@ -84,23 +82,6 @@ export class APIKeyService { this._available_keys.hasValue() ? this._available_keys.value() : [], ); - private readonly _users = resource({ - params: () => ({ - domain: this._domain(), - q: this._search(), - change: this._change(), - }), - loader: async ({ params }) => { - const { domain, q } = params; - if (!domain) return [] as PlaceUser[]; - return (await queryUsers({ authority_id: domain.id, q })).data; - }, - }); - - public readonly users = computed(() => - this._users.hasValue() ? this._users.value() : [], - ); - public setDomain(domain: PlaceDomain) { this._admin_data.setDomain('api-keys', domain); } @@ -109,10 +90,6 @@ export class APIKeyService { return this._admin_data.selectDefaultDomain('api-keys'); } - public setSearch(s: string) { - this._search.set(s); - } - public async newKey() { const ref = this._dialog.open(APIKeyModalComponent, { data: { domain: this._domain() }, diff --git a/src/app/admin/resource-imports.component.ts b/src/app/admin/resource-imports.component.ts index 7727e8b70..81ff49dd2 100644 --- a/src/app/admin/resource-imports.component.ts +++ b/src/app/admin/resource-imports.component.ts @@ -8,14 +8,15 @@ import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { addSystem, query, querySystemsWithEmails } from '@placeos/ts-client'; -import { escapeHtml } from '../common/general'; import { readError } from '../common/errors'; +import { escapeHtml } from '../common/general'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess, notifyWarn, } from '../common/notifications'; +import { collectPages } from '../common/support-access'; import { openConfirmModal } from '../overlays/confirm-modal.component'; import { IconComponent } from '../ui/icon.component'; import { SimpleTableComponent } from '../ui/simple-table.component'; @@ -25,6 +26,8 @@ import { forEachWithLimit } from './signage-plugins/signage-plugins.utilities'; /** Max number of systems created at once */ const IMPORT_LIMIT = 5; +/** Emails per systems lookup. 100 emails make a URL of about 3 KB. */ +const EMAIL_CHUNK_SIZE = 100; export interface ExternalResource { id: string; @@ -326,23 +329,31 @@ export class ResourceImportsComponent implements OnInit { system_id: '', })); const emails = list.map((_) => _.email).filter((_) => _); - const { data } = emails.length - ? await querySystemsWithEmails({ in: emails.join(',') }).catch( - async (err) => { - notifyError( - `Failed to check imported resources. Error: ${await readError(err)}`, - ); - return { data: [] }; - }, - ) - : { data: [] }; - for (const resource of list) { - const system = data.find( - (_) => _.email?.toLowerCase() === resource.email.toLowerCase(), + const system_ids = new Map(); + try { + // Send the emails in chunks to keep each URL under common 8 KB limits + for (let i = 0; i < emails.length; i += EMAIL_CHUNK_SIZE) { + const systems = await collectPages( + querySystemsWithEmails({ + in: emails.slice(i, i + EMAIL_CHUNK_SIZE).join(','), + }), + ); + for (const system of systems) { + const email = system.email?.toLowerCase(); + if (email && !system_ids.has(email)) + system_ids.set(email, system.id); + } + } + } catch (err) { + notifyError( + `Failed to check imported resources. Error: ${await readError(err)}`, ); - if (system) { + } + for (const resource of list) { + const system_id = system_ids.get(resource.email.toLowerCase()); + if (system_id) { resource.imported = true; - resource.system_id = system.id; + resource.system_id = system_id; } } this.resource_list.set(list); diff --git a/src/app/common/locale.service.ts b/src/app/common/locale.service.ts index 25a067fe6..c1e56c3fa 100644 --- a/src/app/common/locale.service.ts +++ b/src/app/common/locale.service.ts @@ -206,19 +206,14 @@ export class LocaleService { ); if (!existing.expiry || existing.expiry < Date.now()) { localStorage.removeItem(`${STORE_KEY}.${locale}`); - const resp = await fetch(`${this.locale_folder}/${locale}.json`); - if (!resp.ok) { + const base_locale_values = await this._baseMappings(locale); + if (!base_locale_values) { delete this._load_promises[locale]; - return console.error( - `Failed to loaded locale file for "${locale}".`, - resp, - ); + return; } - const locale_data = await resp.json(); const locale_override_data = this.zone_id ? await showMetadata(this.zone_id, `locale_${locale}`) : { details: {} }; - const base_locale_values = removeNesting(locale_data); const override_locale_values = removeNesting( locale_override_data.details as Record, ); @@ -242,4 +237,26 @@ export class LocaleService { } delete this._load_promises[locale]; } + + /** + * Mappings from the locale file. The default locale reuses the bundled + * copy that `ready` loads. Returns `null` if the file fails to load. + */ + private async _baseMappings( + locale: string, + ): Promise | null> { + if (locale === this._default_locale) { + await this.ready; + return this._default_mappings; + } + const resp = await fetch(`${this.locale_folder}/${locale}.json`); + if (!resp.ok) { + console.error( + `Failed to loaded locale file for "${locale}".`, + resp, + ); + return null; + } + return removeNesting(await resp.json()); + } } diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts index 2d14ddb29..1e7628025 100644 --- a/src/app/common/support-access.ts +++ b/src/app/common/support-access.ts @@ -191,7 +191,8 @@ export function querySupportZones( ); return Promise.all( groups.map(async ({ group }) => { - const query = { ...options, group_id: group.id }; + // Ask for large pages, as the pages load one after another + const query = { limit: 1000, ...options, group_id: group.id }; return collectPages(queryZones(query)); }), ).then((results) => { diff --git a/src/app/common/uploads.ts b/src/app/common/uploads.ts index c4b500bb3..5f23a5322 100644 --- a/src/app/common/uploads.ts +++ b/src/app/common/uploads.ts @@ -8,8 +8,6 @@ import { token } from '@placeos/ts-client'; import { randomInt } from './general'; import { SubscriptionLike } from './signals'; -import * as blobUtil from 'blob-util'; - /** Token last passed to the upload service */ let upload_token = ''; @@ -78,51 +76,42 @@ export function uploadFile( subscribe(next, error, complete) { let cancelled = false; let state_subscription: SubscriptionLike | null = null; - const fileReader = new FileReader(); - fileReader.addEventListener( - 'loadend', - async (e: ProgressEvent) => { + // Pass the File as is. The upload library reads it in chunks. + (async () => { + syncUploadToken(); + const upload = await uploadNewFile(file, { + permissions, + public: is_public, + } as Record); + if (cancelled) return; + const upload_details: UploadDetails = { + id: randomInt(9999_9999_9999), + name: file.name, + progress: 0, + link: uploadURL(upload.id), + formatted_size: humanReadableByteCount(file.size), + size: file.size, + upload: upload, + }; + state_subscription = upload.state.subscribe((state) => { if (cancelled) return; - const arrayBuffer = e.target.result; - const _blob = blobUtil.arrayBufferToBlob( - arrayBuffer as ArrayBuffer, - file.type, - ); - syncUploadToken(); - const upload = await uploadNewFile(file, { - permissions, - public: is_public, - } as Record); - const upload_details: UploadDetails = { - id: randomInt(9999_9999_9999), - name: file.name, - progress: 0, - link: uploadURL(upload.id), - formatted_size: humanReadableByteCount(file.size), - size: file.size, - upload: upload, - }; - state_subscription = upload.state.subscribe((state) => { - if (cancelled) return; - upload_details.progress = state.progress; - upload_details.link = uploadURL(upload.id); - next(upload_details); - if (state.status === 'FAILED') { - error?.({ - ...upload_details, - error: 'Upload failed', - }); - state_subscription?.unsubscribe(); - } - if (state.status === 'COMPLETED') { - complete?.(); - state_subscription?.unsubscribe(); - } - }); + upload_details.progress = state.progress; + upload_details.link = uploadURL(upload.id); next(upload_details); - }, - ); - fileReader.readAsArrayBuffer(file); + if (state.status === 'FAILED') { + error?.({ + ...upload_details, + error: 'Upload failed', + }); + state_subscription?.unsubscribe(); + } + if (state.status === 'COMPLETED') { + complete?.(); + state_subscription?.unsubscribe(); + } + }); + next(upload_details); + })(); return { unsubscribe: () => { cancelled = true; diff --git a/src/app/domains/domain-applications.component.ts b/src/app/domains/domain-applications.component.ts index e4dc9e623..a10df3853 100644 --- a/src/app/domains/domain-applications.component.ts +++ b/src/app/domains/domain-applications.component.ts @@ -219,6 +219,8 @@ export class DomainApplicationsComponent { } public setShowSecret(id: string, value: boolean): void { + // Every row listens for window mouseup, so skip rows with no change + if (!!this.show_secret()[id] === value) return; this.show_secret.update((state) => ({ ...state, [id]: value })); } } diff --git a/src/app/drivers/driver-state.service.ts b/src/app/drivers/driver-state.service.ts index 62d7c46d2..1545fa4bd 100644 --- a/src/app/drivers/driver-state.service.ts +++ b/src/app/drivers/driver-state.service.ts @@ -56,15 +56,20 @@ export class DriverStateService { private readonly _modules = resource({ params: () => ({ item: this.item(), changed: this._modules_change() }), loader: async ({ params: { item } }) => { - if (!(item instanceof PlaceDriver)) return [] as PlaceModule[]; + if (!(item instanceof PlaceDriver)) + return { data: [] as PlaceModule[], total: 0 }; const response = await queryModules({ driver_id: item.id, - }).catch(() => ({ data: [] })); - return response.data; + }).catch(() => ({ data: [] as PlaceModule[], total: 0 })); + return { data: response.data, total: response.total }; }, }); - public readonly modules = computed(() => this._modules.value() || []); + public readonly modules = computed(() => this._modules.value()?.data || []); + /** Total modules of the driver, or `undefined` while loading */ + public readonly modules_total = computed(() => + this._modules.isLoading() ? undefined : this._modules.value()?.total, + ); private readonly _docs = resource({ params: () => this.item(), diff --git a/src/app/drivers/drivers.component.ts b/src/app/drivers/drivers.component.ts index 9ce319d42..d5ea5c212 100644 --- a/src/app/drivers/drivers.component.ts +++ b/src/app/drivers/drivers.component.ts @@ -1,8 +1,7 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { PlaceDriver, queryModules } from '@placeos/ts-client'; import { extensionsForItem } from '../common/api'; import { PlaceDebugService } from '../common/debug.service'; import { ActiveItemService } from '../common/item.service'; @@ -128,7 +127,7 @@ export class DriversComponent { public readonly loading = this._service.loading; public readonly scroll = signal(0); public readonly open_menu = signal(false); - public readonly device_count = signal(0); + public readonly device_count = this._drivers.modules_total; public readonly docs = this._drivers.docs; public readonly extensions = computed(() => extensionsForItem(this.item(), this.name), @@ -169,28 +168,4 @@ export class DriversComponent { public readonly showUpdateList = () => this._drivers.showUpdateList(); public readonly newItem = () => this._service.create(); - - constructor() { - effect(() => { - const item = this.item(); - // Reload the count after a module is removed - this._drivers.modules_change(); - this.device_count.set(undefined); - this.loadValues(item); - }); - } - - protected async loadValues(item: PlaceDriver) { - if (!item) return; - const query: Record = { - offset: 0, - limit: 1, - driver_id: item.id, - }; - this.device_count.set( - await queryModules(query) - .then(({ total }) => total) - .catch(() => 0), - ); - } } diff --git a/src/app/groups/group-users.component.ts b/src/app/groups/group-users.component.ts index cd2592afb..87a80f437 100644 --- a/src/app/groups/group-users.component.ts +++ b/src/app/groups/group-users.component.ts @@ -37,14 +37,17 @@ import { GroupStateService } from './group-state.service'; {{ 'GROUPS.USERS_BULK' | translate }} -
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.NONE' | translate }} diff --git a/src/app/groups/group-zones.component.ts b/src/app/groups/group-zones.component.ts index 829bbb437..019c03947 100644 --- a/src/app/groups/group-zones.component.ts +++ b/src/app/groups/group-zones.component.ts @@ -37,14 +37,17 @@ import { GroupStateService } from './group-state.service'; {{ 'GROUPS.ZONES_BULK' | translate }}
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }} diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts index 95517bf29..f7d9ebf11 100644 --- a/src/app/modules/module-state.service.ts +++ b/src/app/modules/module-state.service.ts @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { moduleSettings, PlaceModule, + PlaceSystem, showDriver, showEdge, showSystem, @@ -72,13 +73,14 @@ export class ModuleStateService { private readonly _system_list = resource({ params: () => this.item(), loader: async ({ params: item }) => { - if (!(item instanceof PlaceModule)) return []; + if (!(item instanceof PlaceModule)) + return { data: [] as PlaceSystem[], total: 0 }; this._loading.set(true); try { const response = await querySystems({ module_id: item.id, - }).catch(() => ({ data: [] })); - return response.data; + }).catch(() => ({ data: [] as PlaceSystem[], total: 0 })); + return { data: response.data, total: response.total }; } finally { this._loading.set(false); } @@ -86,7 +88,13 @@ export class ModuleStateService { }); /** Systems associated with the active module */ public readonly system_list = computed( - () => this._system_list.value() || [], + () => this._system_list.value()?.data || [], + ); + /** Total systems of the active module, or `undefined` while loading */ + public readonly system_total = computed(() => + this._system_list.isLoading() + ? undefined + : this._system_list.value()?.total, ); public get active_item(): PlaceModule { diff --git a/src/app/modules/modules.component.ts b/src/app/modules/modules.component.ts index a28761f1e..7cfe0511d 100644 --- a/src/app/modules/modules.component.ts +++ b/src/app/modules/modules.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; @@ -7,10 +7,7 @@ import { extensionsForItem } from '../common/api'; import { PlaceDebugService } from '../common/debug.service'; import { ActiveItemService } from '../common/item.service'; import { i18n } from '../common/locale.service'; -import { - isSubsystemUser, - querySupportSystems as querySystems, -} from '../common/support-access'; +import { isSubsystemUser } from '../common/support-access'; import { DebugOutputComponent } from '../ui/debug-output.component'; import { IconComponent } from '../ui/icon.component'; import { ItemDetailsSkeletonComponent } from '../ui/item-details-skeleton.component'; @@ -20,6 +17,7 @@ import { ItemSidebarComponent } from '../ui/item-sidebar.component'; import { ItemTablistComponent } from '../ui/item-tablist.component'; import { SidebarMenuComponent } from '../ui/sidebar-menu.component'; import { TranslatePipe } from '../ui/translate.pipe'; +import { ModuleStateService } from './module-state.service'; @Component({ selector: 'new-modules-view', @@ -114,6 +112,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; export class ModulesComponent { private _service = inject(ActiveItemService); private _debug = inject(PlaceDebugService); + private _module_state = inject(ModuleStateService); public readonly name = 'modules'; public readonly item = computed( @@ -121,7 +120,7 @@ export class ModulesComponent { ); public readonly loading = this._service.loading; /** Number of systems for the active device */ - public readonly system_count = signal(undefined); + public readonly system_count = this._module_state.system_total; public readonly open_menu = signal(false); public readonly scroll = signal(0); public readonly debug_position = this._debug.position; @@ -152,23 +151,4 @@ export class ModulesComponent { ...this.extensions(), ].filter((tab) => !isSubsystemUser() || !['history'].includes(tab.id)), ); - - constructor() { - effect(() => { - const item = this.item(); - this.system_count.set(undefined); - this.loadValues(item); - }); - } - - protected async loadValues(item: PlaceModule) { - if (!item) return; - const query: Record = { - offset: 0, - limit: 1, - module_id: item.id, - }; - // Get system count - this.system_count.set((await querySystems(query)).total); - } } 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 abb00f48d..2c53b8670 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,4 +1,4 @@ -import { Component, Type, inject, signal } from '@angular/core'; +import { Component, Type, computed, inject, signal } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -100,7 +100,7 @@ export interface BulkItemModalData> { @case ('status') { @@ -110,8 +110,8 @@ export interface BulkItemModalData> { [(list)]="item_list" [fields]="available_fields" [required_fields]="required_fields" - [validation_errors]="validation_errors" - [duplicate_warnings]="duplicate_warnings" + [validation_errors]="validation_errors()" + [duplicate_warnings]="duplicate_warnings()" [show_start_modules]="is_system" (next)="showStatus()" (previous)="goTo('match-fields')" @@ -177,7 +177,7 @@ export class BulkItemModalComponent< { id: 'status', label: 'Create' }, ]; /** List of items to bulk add */ - public item_list: HashMap[] = []; + public readonly item_list = signal[]>([]); /** List of raw data to use for bulk add */ public data_list: HashMap[] = []; /** Whether requests are being processed */ @@ -207,13 +207,10 @@ export class BulkItemModalComponent< return this._required_fields; } - public get validation_errors(): string[] { - return this.validateItems(); - } - - public get duplicate_warnings(): string[] { - return this.findDuplicates(); - } + /** Missing required fields, checked again only when the list changes */ + public readonly validation_errors = computed(() => this.validateItems()); + /** Duplicate names or emails, checked again only when the list changes */ + public readonly duplicate_warnings = computed(() => this.findDuplicates()); constructor() { this.available_fields = this.getAvailableFields(); @@ -232,11 +229,13 @@ export class BulkItemModalComponent< } if (is_mapped) { const Resource = this._data.constr; - this.item_list = data.map((item) => { - // Values are already parsed by csvToJson/parseCSV, - // so no need for additional JSON.parse here - return new Resource(item); - }); + this.item_list.set( + data.map((item) => { + // Values are already parsed by csvToJson/parseCSV, + // so no need for additional JSON.parse here + return new Resource(item); + }), + ); this.goTo('list'); } else { // A new file may have different columns, so drop old mappings @@ -247,13 +246,11 @@ export class BulkItemModalComponent< } public showStatus() { - const validation_errors = this.validateItems(); - if (validation_errors.length > 0) { + if (this.validation_errors().length > 0) { notifyError('Fix the highlighted validation errors before saving.'); return; } - const duplicates = this.findDuplicates(); - if (duplicates.length > 0) { + if (this.duplicate_warnings().length > 0) { notifyWarn('Duplicate values were found. Saving will continue.'); } this.loading = true; @@ -338,7 +335,7 @@ export class BulkItemModalComponent< private validateItems(): string[] { const errors: string[] = []; - this.item_list.forEach((item, index) => { + this.item_list().forEach((item, index) => { for (const field of this._required_fields) { const value = item[field]; if (value === undefined || value === null || value === '') { @@ -357,7 +354,7 @@ export class BulkItemModalComponent< const dedup_field = this._data.constr === (PlaceUser as unknown) ? 'email' : 'name'; - this.item_list.forEach((item, index) => { + this.item_list().forEach((item, index) => { const key = String(item[dedup_field] || '') .toLowerCase() .trim(); diff --git a/src/app/overlays/bulk-item-modal/list.component.ts b/src/app/overlays/bulk-item-modal/list.component.ts index 0637da60e..728e6056e 100644 --- a/src/app/overlays/bulk-item-modal/list.component.ts +++ b/src/app/overlays/bulk-item-modal/list.component.ts @@ -108,7 +108,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }} diff --git a/src/app/users/users-state.service.ts b/src/app/users/users-state.service.ts index 018528bf4..54f9b6fe8 100644 --- a/src/app/users/users-state.service.ts +++ b/src/app/users/users-state.service.ts @@ -17,6 +17,7 @@ import { escapeHtml } from '../common/general'; import { ActiveItemService } from '../common/item.service'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess } from '../common/notifications'; +import { openedTabs } from '../common/opened-tabs'; import { waitForEvent } from '../common/signals'; import { GroupBulkAddModalComponent, @@ -38,66 +39,79 @@ export class UsersStateService { public readonly loading = computed( () => this._saving() || - this._counts.isLoading() || + this._metadata_count.isLoading() || this._groups.isLoading(), ); public readonly item = this._service.item; // The metadata tab loads its own list, so only the count is needed here - private readonly _counts = resource({ + private readonly _metadata_count = resource({ params: () => ({ item: this._service.active_item$(), change: this._change(), }), loader: async ({ params }) => { const { item } = params; - if (!(item instanceof PlaceUser)) return {}; - const details = await Promise.all([ - listMetadata(item.id) - .then((d) => d.length) - .catch((_err) => 0), - queryGroupUsers({ user_id: item.id, limit: 1 }) - .then((response) => response.total) - .catch(() => 0), - ]); - const [metadata, groups] = details; - return { - metadata, - groups, - }; + if (!(item instanceof PlaceUser)) return 0; + return listMetadata(item.id) + .then((d) => d.length) + .catch((_err) => 0); }, }); public readonly counts = computed<{ metadata?: number; groups?: number }>( - () => this._counts.value() || { metadata: 0, groups: 0 }, + () => ({ + metadata: this._metadata_count.value() || 0, + groups: this._groups.value()?.total || 0, + }), + ); + + private readonly _user_id = computed(() => { + const item = this.item(); + return item instanceof PlaceUser ? item.id : undefined; + }); + // The group list only loads once its tab is opened. Until then a single + // row query gives the tab count. + private readonly _opened_tabs = openedTabs(this._user_id); + private readonly _load_groups = computed(() => + this._opened_tabs().has('groups'), ); private readonly _groups = resource({ - params: () => ({ item: this.item(), change: this._change() }), + params: () => ({ + item: this.item(), + change: this._change(), + full: this._load_groups(), + }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceUser)) return [] as PlaceGroupUser[]; + const { item, full } = params; + const empty = { data: [] as PlaceGroupUser[], total: 0 }; + if (!(item instanceof PlaceUser)) return empty; const response = await queryGroupUsers({ user_id: item.id, - limit: 1000, + limit: full ? 1000 : 1, }).catch((error) => { notifyError( i18n('USERS.GROUPS_LOAD_ERROR', { error: describeError(error), }), ); - return { data: [] as PlaceGroupUser[] }; + return empty; }); - return response.data.sort((a, b) => - (a.group?.name || a.group_id).localeCompare( - b.group?.name || b.group_id, + if (!full) return { ...empty, total: response.total }; + return { + total: response.total, + data: [...response.data].sort((a, b) => + (a.group?.name || a.group_id).localeCompare( + b.group?.name || b.group_id, + ), ), - ); + }; }, }); - public readonly groups = computed(() => this._groups.value() || []); + public readonly groups = computed(() => this._groups.value()?.data || []); public get active_item(): PlaceUser { return this._service.active_item as unknown as PlaceUser; diff --git a/src/app/zones/zone-groups.component.ts b/src/app/zones/zone-groups.component.ts index a0f840774..6c43c4a2f 100644 --- a/src/app/zones/zone-groups.component.ts +++ b/src/app/zones/zone-groups.component.ts @@ -42,14 +42,17 @@ import { ZonesStateService } from './zones-state.service'; {{ 'ZONES.GROUPS_BULK' | translate }}
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }} diff --git a/src/tests/common/support-access.spec.ts b/src/tests/common/support-access.spec.ts index ec07614df..30a4cdae9 100644 --- a/src/tests/common/support-access.spec.ts +++ b/src/tests/common/support-access.spec.ts @@ -115,6 +115,7 @@ describe('support subsystem access', () => { expect(next).toHaveBeenCalledOnce(); expect(api.queryZones).toHaveBeenCalledTimes(1); expect(api.queryZones).toHaveBeenCalledWith({ + limit: 1000, q: 'office', group_id: 'group-1', }); diff --git a/src/tests/systems/system-state.service.spec.ts b/src/tests/systems/system-state.service.spec.ts new file mode 100644 index 000000000..90e5b6aff --- /dev/null +++ b/src/tests/systems/system-state.service.spec.ts @@ -0,0 +1,139 @@ +import { + ApplicationRef, + provideZonelessChangeDetection, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { PlaceModule, PlaceSystem } from '@placeos/ts-client'; +import { Subject } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaceDebugService } from '../../app/common/debug.service'; +import { ActiveItemService } from '../../app/common/item.service'; +import { SystemStateService } from '../../app/systems/system-state.service'; + +const mocks = vi.hoisted(() => ({ + listMetadata: vi.fn(), + listSystemTriggers: vi.fn(), + listSystemZones: vi.fn(), + queryModules: vi.fn(), + startModule: vi.fn(), + systemSettings: vi.fn(), +})); +vi.mock('@placeos/ts-client', async () => ({ + ...(await vi.importActual( + '@placeos/ts-client/dist/index.es.js', + )), + ...mocks, +})); +vi.mock('../../app/common/item.service', () => ({ + ActiveItemService: class {}, +})); +vi.mock('../../app/common/debug.service', () => ({ + PlaceDebugService: class {}, +})); +vi.mock('../../app/common/support-access', () => ({ + isSubsystemUser: () => false, + querySupportSystems: vi.fn(), +})); +vi.mock('../../app/common/notifications', () => ({ + notifyError: vi.fn(), + notifySuccess: vi.fn(), +})); +vi.mock('../../app/overlays/confirm-modal.component', () => ({ + openConfirmModal: vi.fn(), +})); +vi.mock('../../app/overlays/select-item-modal.component', () => ({ + SelectItemModalComponent: class {}, +})); +vi.mock('../../app/overlays/view-response-modal.component', () => ({ + ViewResponseModalComponent: class {}, +})); +vi.mock('../../app/systems/system-trigger-form.component', () => ({ + SystemTriggerFormComponent: class {}, +})); + +describe('SystemStateService', () => { + const system = new PlaceSystem({ + id: 'sys-1', + modules: ['mod-1'], + zones: ['zone-1'], + }); + const active = signal(null); + let router: { url: string; events: Subject }; + let service: SystemStateService; + + const stable = () => TestBed.inject(ApplicationRef).whenStable(); + const openTab = (tab: string) => { + router.url = `/systems/sys-1/${tab}`; + router.events.next(new NavigationEnd(1, router.url, router.url)); + }; + + beforeEach(() => { + vi.resetAllMocks(); + active.set(null); + router = { url: '/systems/sys-1/about', events: new Subject() }; + mocks.listMetadata.mockResolvedValue([]); + mocks.listSystemTriggers.mockResolvedValue({ data: [], total: 3 }); + mocks.listSystemZones.mockResolvedValue({ data: [] }); + mocks.queryModules.mockResolvedValue({ + data: [new PlaceModule({ id: 'mod-1' })], + }); + mocks.startModule.mockResolvedValue(undefined); + mocks.systemSettings.mockResolvedValue([]); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + SystemStateService, + { + provide: ActiveItemService, + useValue: { item: active, active_item: system }, + }, + { provide: PlaceDebugService, useValue: {} }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: Router, useValue: router }, + ], + }); + service = TestBed.inject(SystemStateService); + }); + + it('loads tab lists only once their tab is opened', async () => { + service.counts(); + service.modules(); + service.zones(); + active.set(system); + await stable(); + + expect(mocks.queryModules).not.toHaveBeenCalled(); + expect(mocks.listSystemZones).not.toHaveBeenCalled(); + expect(mocks.listSystemTriggers).toHaveBeenCalledWith('sys-1', { + limit: 1, + }); + expect(service.counts().triggers).toBe(3); + + openTab('modules'); + await stable(); + + expect(mocks.queryModules).toHaveBeenCalledTimes(1); + expect(service.modules().map(({ id }) => id)).toEqual(['mod-1']); + }); + + it('reloads only the module list after a module power change', async () => { + openTab('modules'); + service.counts(); + service.modules(); + service.associated_settings(); + active.set(system); + await stable(); + vi.clearAllMocks(); + + await service.toggleModulePower(service.modules()[0]); + await stable(); + + expect(mocks.queryModules).toHaveBeenCalledTimes(1); + expect(mocks.listSystemTriggers).not.toHaveBeenCalled(); + expect(mocks.systemSettings).not.toHaveBeenCalled(); + expect(mocks.listMetadata).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/ui/pipes/user.pipe.spec.ts b/src/tests/ui/pipes/user.pipe.spec.ts index 8e678e9db..51868dbd4 100644 --- a/src/tests/ui/pipes/user.pipe.spec.ts +++ b/src/tests/ui/pipes/user.pipe.spec.ts @@ -59,6 +59,17 @@ describe('UserPipe', () => { expect(second_call_count).toBe(first_call_count); }); + it('should share a request that is in flight', async () => { + const { showUser } = await import('@placeos/ts-client'); + + await Promise.all([ + pipe.transform('user-789'), + new UserPipe().transform('user-789'), + ]); + + expect(showUser).toHaveBeenCalledTimes(1); + }); + it('should return user with expected properties', async () => { const result = await pipe.transform('test-user'); expect(result).toHaveProperty('id');