Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/control/src/app/bootstrap.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ export class BootstrapComponent extends AsyncHandler implements OnInit {
}
},
});
public readonly space_list = computed(() => this._space_list.value() ?? []);
public readonly space_list = computed(() =>
this._space_list.hasValue() ? this._space_list.value() : [],
);

constructor() {
super();
Expand Down
6 changes: 5 additions & 1 deletion apps/control/src/app/control-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ export class ControlStateService extends AsyncHandler {
return list.filter((_) => _.meeting_url.startsWith(url));
},
});
public readonly events = computed(() => this._events.value() ?? []);
public readonly events = computed(() =>
this._events.hasValue() ? this._events.value() : [],
);

public get id() {
return this._id();
Expand Down Expand Up @@ -634,6 +636,8 @@ export class ControlStateService extends AsyncHandler {
const id = this._id();
if (!id) return;

// Release bindings for aliases from the previous list or room
this.unsubWith(`listen:${type}/`);
if (type === 'input') this._input_data.set([]);
else this._output_data.set([]);

Expand Down
4 changes: 3 additions & 1 deletion apps/control/src/app/tabbed-view/tab-outlet.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,8 @@ export class TabOutletComponent extends AsyncHandler {
});

private _user_action = signal(false);
/** Driver's selected tab. A separate computed so other system changes do not re-run the tab sync. */
private _selected_tab = computed(() => this.system()?.selected_tab);
private _available_inputs = this._service.available_inputs;
private _route_tab = toSignal(
this._route.paramMap.pipe(map((params) => params.get('tab') || '')),
Expand All @@ -308,7 +310,7 @@ export class TabOutletComponent extends AsyncHandler {
if (tab) this.active_tab.set(tab);
});
effect(() => {
const selected_tab = this.system()?.selected_tab;
const selected_tab = this._selected_tab();
this.timeout(
'update_tab',
() => {
Expand Down
2 changes: 1 addition & 1 deletion apps/control/src/app/topbar-header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export class TopbarHeaderComponent extends AsyncHandler {
actions[TOOLTIP.CAMERA].show = cams?.length > 0 && !speaker_track;
actions[TOOLTIP.HELP].show = help_items?.length > 0;
actions[TOOLTIP.LIGHT_LEVELS].show = light_levels != null;
actions[TOOLTIP.LIGHT_SCENES].show = l_scenes != null;
actions[TOOLTIP.LIGHT_SCENES].show = l_scenes?.length > 0;

return actions;
});
Expand Down
18 changes: 15 additions & 3 deletions apps/control/src/app/ui/select-meeting-modal.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { CalendarEvent, i18n, notifySuccess } from '@placeos/common';
import {
CalendarEvent,
i18n,
notifyError,
notifySuccess,
} from '@placeos/common';
import {
IconComponent,
openConfirmModal,
Expand Down Expand Up @@ -133,9 +138,16 @@ export class SelectMeetingModalComponent {
},
this._dialog,
);
details.loading(i18n('APP.CONTROL.MEETING_JOIN_LOADING'));
if (details.reason !== 'done') return;
await this._service.setEvent(e);
details.loading(i18n('APP.CONTROL.MEETING_JOIN_LOADING'));
try {
await this._service.setEvent(e);
} catch (error) {
notifyError(i18n('APP.CONTROL.MEETING_JOIN_ERROR', { error }));
return;
} finally {
details.close();
}
notifySuccess(i18n('APP.CONTROL.MEETING_JOIN_SUCCESS'));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
this._dialog_ref.close();
};
Expand Down
12 changes: 9 additions & 3 deletions apps/control/src/app/ui/source-select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@angular/core';
import { MatRippleModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { unique } from '@placeos/common';
import { i18n, notifyError, unique } from '@placeos/common';
import { TranslatePipe } from '@placeos/components';
import { ControlStateService, RoomInput } from '../control-state.service';

Expand Down Expand Up @@ -131,8 +131,14 @@ export class SourceSelectComponent {

public async selectSource(input: RoomInput) {
this.loading.set(true);
await this._state.setRoute(input.id, this.output());
this.loading.set(false);
try {
await this._state.setRoute(input.id, this.output());
} catch (error) {
notifyError(i18n('APP.CONTROL.ROUTE_ERROR', { error }));
return;
} finally {
this.loading.set(false);
}
this.source.emit(input);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ActivatedRoute, Router } from '@angular/router';
import { i18n, notifyError } from '@placeos/common';
import { IconComponent, TranslatePipe } from '@placeos/components';
import { getModule } from '@placeos/ts-client';
import { ControlStateService } from '../control-state.service';
Expand Down Expand Up @@ -135,8 +136,14 @@ export class VideoCallDialViewComponent {
const system_id = this._control.id;
const mod = getModule(system_id, 'VidConf');
this.loading.set(true);
await mod.execute('dial', [dial_number]);
this.loading.set(false);
try {
await mod.execute('dial', [dial_number]);
} catch (error) {
notifyError(i18n('APP.CONTROL.VC_DIAL_ERROR', { error }));
return;
} finally {
this.loading.set(false);
}
if (this.redirect()) {
this._router.navigate(['call'], { relativeTo: this._route });
}
Expand Down
20 changes: 20 additions & 0 deletions apps/control/src/app/video-call/video-call-page.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import {
Injector,
OnInit,
computed,
effect,
inject,
input,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatRippleModule } from '@angular/material/core';
Expand Down Expand Up @@ -239,6 +241,9 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit {
private _router = inject(Router);
private _injector = inject(Injector);

/** Whether the page has already left for the ended call */
private _left = false;

public readonly redirect = input(true);
public readonly present_output = input('');
public readonly loading = signal('');
Expand Down Expand Up @@ -287,6 +292,17 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit {
this._onCallEnded();
};

constructor() {
super();
// Leave the page when the call ends remotely (far-end hang-up or drop)
let had_call = false;
effect(() => {
const has_call = !!this.call();
if (had_call && !has_call) untracked(() => this._onCallEnded());
had_call = has_call;
});
}

public async ngOnInit() {
this.loading.set(i18n('APP.CONTROL.VC_LOADING'));
this.timeout(
Expand All @@ -308,7 +324,11 @@ export class VideoCallPageComponent extends AsyncHandler implements OnInit {
mod.execute('selected_camera', [camera]);
}

/** Leave the page once. A local hang-up and the call status clearing both end up here. */
private _onCallEnded() {
this.loading.set('');
if (this._left) return;
this._left = true;
if (this.redirect())
this._router.navigate(['/panel', this._control.id]);
}
Expand Down
7 changes: 6 additions & 1 deletion apps/control/src/app/video-call/video-call-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export interface VideoCallDetails {
Ice: string;
}

/** Statuses that do not count as a live call */
const INACTIVE_STATUSES: readonly CallStatus[] = ['Idle', 'Disconnecting'];

@Injectable({
providedIn: 'root',
})
Expand All @@ -57,7 +60,9 @@ export class VideoCallStateService extends AsyncHandler {
public readonly call = computed<VideoCallDetails | null>(() => {
const calls = this._calls();
for (const key in calls) {
if (calls[key].Status) return calls[key];
const status = calls[key]?.Status;
if (status && !INACTIVE_STATUSES.includes(status))
return calls[key];
}
return null;
});
Expand Down
19 changes: 18 additions & 1 deletion apps/control/src/tests/bootstrap.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router } from '@angular/router';
import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/vitest';
import {
createRoutingFactory,
SpectatorRouting,
} from '@ngneat/spectator/vitest';
import { MockModule, MockProvider } from 'ng-mocks';

import { SettingsService } from '@placeos/common';
import { SpacesService } from '@placeos/events';
import * as client from '@placeos/ts-client';
import { of } from 'rxjs';
import { BootstrapComponent } from '../app/bootstrap.component';

vi.mock('@placeos/ts-client', { spy: true });

describe('BootstrapComponent', () => {
let spectator: SpectatorRouting<BootstrapComponent>;
const createComponent = createRoutingFactory({
Expand Down Expand Up @@ -95,4 +101,15 @@ describe('BootstrapComponent', () => {
vi.runOnlyPendingTimers();
expect(localStorage.getItem('PLACEOS.CONTROL.system')).toBeFalsy();
});

it('should show no results when the system search fails', async () => {
vi.mocked(client.querySystems).mockRejectedValue(new Error('401'));
spectator.component.system_id.set('Room');
spectator.detectChanges();
await vi.advanceTimersByTimeAsync(400);
expect(client.querySystems).toHaveBeenCalled();
expect(() => spectator.component.space_list()).not.toThrow();
expect(spectator.component.space_list()).toEqual([]);
expect(() => spectator.detectChanges()).not.toThrow();
});
});
53 changes: 50 additions & 3 deletions apps/control/src/tests/control-state.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
import {
createServiceFactory,
SpectatorService,
} from '@ngneat/spectator/vitest';
import { CalendarService, SpacesService } from '@placeos/events';
import { of } from 'rxjs';

vi.mock('@placeos/ts-client', { spy: true });

import * as client from '@placeos/ts-client';
import { ControlStateService } from '../app/control-state.service';

describe('ControlStateService', () => {
let spectator: SpectatorService<ControlStateService>;
/** Latest binding callback for each bound variable name */
let bindings: Record<string, (value: unknown) => void>;
/** Names of bindings that were released */
let released: string[];
const createService = createServiceFactory({
service: ControlStateService,
providers: [
{ provide: MatDialog, useValue: { open: vi.fn() } },
{ provide: CalendarService, useValue: { calendars: of([]) } },
{ provide: SpacesService, useValue: { loadSpaces: vi.fn() } },
{
provide: SpacesService,
useValue: { loadSpaces: vi.fn(), loadSpace: vi.fn() },
},
],
});

beforeEach(() => (spectator = createService()));
beforeEach(() => {
bindings = {};
released = [];
vi.mocked(client.getModule).mockImplementation(
() =>
({
variable: (name: string) => ({
bindThenSubscribe: (cb: (value: unknown) => void) => {
bindings[name] = cb;
return () => released.push(name);
},
bind: () => () => null,
listen: () =>
Object.assign(() => undefined, {
subscribe: () => () => null,
}),
}),
execute: vi.fn(async () => null),
}) as unknown as ReturnType<typeof client.getModule>,
);
spectator = createService();
});

it('should create service', () => {
expect(spectator.service).toBeTruthy();
Expand All @@ -28,4 +64,15 @@ describe('ControlStateService', () => {
expect(spectator.service.system().volume).toBe(55);
vi.useRealTimers();
});

it('should release source bindings that are no longer listed', () => {
spectator.service.setID('sys-1');
TestBed.tick();
bindings['inputs'](['a', 'b']);
TestBed.tick();
expect(bindings['input/a']).toBeDefined();
bindings['inputs'](['b']);
TestBed.tick();
expect(released).toContain('input/a');
});
});
19 changes: 17 additions & 2 deletions apps/control/src/tests/tabbed-view/tab-outlet.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { MockPipe, MockProvider } from 'ng-mocks';
import {
BindingDirective,
IconComponent,
SafePipe,
TranslatePipe,
} from '@placeos/components';
import { ControlStateService } from '../../app/control-state.service';
Expand Down Expand Up @@ -39,7 +38,6 @@ describe('TabOutletComponent', () => {
mockDirective(BindingDirective),
MockPipe(TranslatePipe, (v) => v),
MockPipe(MarkdownPipe, (v) => v),
MockPipe(SafePipe, (v) => v),
],
providers: [
MockProvider(ControlStateService, {
Expand Down Expand Up @@ -208,4 +206,21 @@ describe('TabOutletComponent', () => {
spectator.detectChanges();
expect(spectator.query('[output-actions]')).toExist();
});

it('should not jump back to the driver tab on unrelated system changes', () => {
vi.useFakeTimers();
const service: any = spectator.inject(ControlStateService);
const navigate = vi
.spyOn(spectator.router, 'navigate')
.mockResolvedValue(true);
service.system.set({ selected_tab: 'tab1' });
spectator.detectChanges();
vi.advanceTimersByTime(600);
expect(navigate).toHaveBeenCalledTimes(1);
service.system.set({ selected_tab: 'tab1', volume: 40 });
spectator.detectChanges();
vi.advanceTimersByTime(600);
expect(navigate).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});
8 changes: 8 additions & 0 deletions apps/control/src/tests/topbar-header.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,12 @@ describe('TopbarHeaderComponent', () => {
spectator.inject(ControlStateService).changeRoom,
).not.toHaveBeenCalled();
});

it('should hide the lighting scenes action when the room has no scenes', () => {
spectator = createComponent();
const action = spectator.component
.action_list()
.find((_) => _.id === 'lighting_scenes');
expect(action.show).toBe(false);
});
});
Loading
Loading