diff --git a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.html b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.html index f46d76d80cb..4ab324e2a79 100644 --- a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.html +++ b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.html @@ -6,7 +6,7 @@ ></ix-label> <div class="icon-group" [attr.aria-label]="label"> - @for (option of options | keyvalue; track option) { + @for (option of options | keyvalue: keepOrder; track option) { <button mat-icon-button type="button" diff --git a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts new file mode 100644 index 00000000000..f558aae6d20 --- /dev/null +++ b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.spec.ts @@ -0,0 +1,94 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormControl } from '@ngneat/reactive-forms'; +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { IxIconGroupComponent } from 'app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component'; +import { IxIconGroupHarness } from 'app/modules/ix-forms/components/ix-icon-group/ix-icon-group.harness'; +import { IxLabelHarness } from 'app/modules/ix-forms/components/ix-label/ix-label.harness'; +import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module'; + +describe('IxIconGroupComponent', () => { + let spectator: SpectatorHost<IxIconGroupComponent>; + let loader: HarnessLoader; + let iconGroupHarness: IxIconGroupHarness; + const formControl = new FormControl(); + const createHost = createHostFactory({ + component: IxIconGroupComponent, + imports: [ + ReactiveFormsModule, + IxFormsModule, + ], + declareComponent: false, + }); + + beforeEach(async () => { + spectator = createHost( + `<ix-icon-group + [options]="options" + [label]="label" + [tooltip]="tooltip" + [required]="required" + [formControl]="formControl" + ></ix-icon-group>`, + { + hostProps: { + formControl, + options: new Map<string, string>([ + ['edit', 'mdi-pencil'], + ['delete', 'mdi-delete'], + ]), + label: 'Icon group', + tooltip: 'This is a tooltip', + required: true, + }, + }, + ); + + loader = TestbedHarnessEnvironment.loader(spectator.fixture); + iconGroupHarness = await loader.getHarness(IxIconGroupHarness); + }); + + describe('rendering', () => { + it('renders ix-label and passes label, hint, tooltip and required', async () => { + const label = await loader.getHarness(IxLabelHarness.with({ label: 'Icon group' })); + + expect(label).toBeTruthy(); + expect(await label.isRequired()).toBe(true); + + const tooltip = await label.getTooltip(); + expect(tooltip).toBeTruthy(); + expect(await tooltip.getMessage()).toBe('This is a tooltip'); + }); + + it('shows buttons for provided options', async () => { + const buttons = await iconGroupHarness.getButtons(); + expect(buttons).toHaveLength(2); + + const icons = await iconGroupHarness.getIcons(); + expect(icons).toHaveLength(2); + expect(await icons[0].getName()).toBe('mdi-pencil'); + expect(await icons[1].getName()).toBe('mdi-delete'); + }); + + it('does not highlight any buttons when no value is set', async () => { + expect(await iconGroupHarness.getValue()).toBe(''); + }); + + it('highlights button with selected value', async () => { + formControl.setValue('edit'); + expect(await iconGroupHarness.getValue()).toBe('edit'); + }); + }); + + it('updates form control value when user presses the button', async () => { + const buttons = await iconGroupHarness.getButtons(); + await buttons[1].click(); + expect(formControl.value).toBe('delete'); + }); + + it('disables buttons when form control is disabled', async () => { + formControl.disable(); + expect(await iconGroupHarness.isDisabled()).toBe(true); + }); +}); diff --git a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.ts b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.ts index eced223e0c3..1119ed92e29 100644 --- a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.ts +++ b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component.ts @@ -14,7 +14,6 @@ import { UntilDestroy } from '@ngneat/until-destroy'; export class IxIconGroupComponent implements ControlValueAccessor { @Input({ required: true }) options: Map<string, string>; @Input() label: string; - @Input() hint: string; @Input() tooltip: string; @Input() required: boolean; @@ -53,4 +52,8 @@ export class IxIconGroupComponent implements ControlValueAccessor { this.writeValue(value); this.onChange(this.value); } + + protected keepOrder(): number { + return 0; + } } diff --git a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.harness.ts b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.harness.ts index 87637c946ff..d827ad9e434 100644 --- a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.harness.ts +++ b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.harness.ts @@ -2,6 +2,7 @@ import { BaseHarnessFilters, ComponentHarness, HarnessPredicate, parallel, } from '@angular/cdk/testing'; import { MatButtonHarness } from '@angular/material/button/testing'; +import { MatIconHarness } from '@angular/material/icon/testing'; import { IxLabelHarness } from 'app/modules/ix-forms/components/ix-label/ix-label.harness'; import { IxFormControlHarness } from 'app/modules/ix-forms/interfaces/ix-form-control-harness.interface'; import { getErrorText } from 'app/modules/ix-forms/utils/harness.utils'; @@ -19,6 +20,7 @@ export class IxIconGroupHarness extends ComponentHarness implements IxFormContro } getButtons = this.locatorForAll(MatButtonHarness); + getIcons = this.locatorForAll(MatIconHarness.with({ ancestor: '.icon-group' })); getErrorText = getErrorText; async getLabelText(): Promise<string> { @@ -29,9 +31,13 @@ export class IxIconGroupHarness extends ComponentHarness implements IxFormContro return label.getLabel(); } - async getValue(): Promise<string> { - const selectedButton = this.locatorFor(MatButtonHarness.with({ selector: '.selected' }))(); - return (await (await selectedButton).host()).getAttribute('aria-label'); + async getValue(): Promise<string | undefined> { + const selectedButton = await this.locatorForOptional(MatButtonHarness.with({ selector: '.selected' }))(); + if (!selectedButton) { + return ''; + } + + return (await selectedButton.host()).getAttribute('aria-label'); } async setValue(value: string): Promise<void> { diff --git a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.spec.ts b/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.spec.ts deleted file mode 100644 index e726092d70c..00000000000 --- a/src/app/modules/ix-forms/components/ix-icon-group/ix-icon-group.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NgControl } from '@angular/forms'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { MockComponent } from 'ng-mocks'; -import { IxErrorsComponent } from 'app/modules/ix-forms/components/ix-errors/ix-errors.component'; -import { IxIconGroupComponent } from 'app/modules/ix-forms/components/ix-icon-group/ix-icon-group.component'; -import { IxLabelComponent } from 'app/modules/ix-forms/components/ix-label/ix-label.component'; - -describe('IxIconGroupComponent', () => { - let spectator: Spectator<IxIconGroupComponent>; - const createComponent = createComponentFactory({ - component: IxIconGroupComponent, - declarations: [ - MockComponent(IxLabelComponent), - MockComponent(IxErrorsComponent), - ], - providers: [NgControl], - }); - - beforeEach(() => { - spectator = createComponent(); - spectator.setInput('options', new Map([ - ['edit', 'mdi-pencil'], - ['delete', 'mdi-delete'], - ])); - }); - - describe('setDisabledState()', () => { - it('when called with false, sets \'isDisabled\' to false', () => { - spectator.component.setDisabledState(false); - expect(spectator.component.isDisabled).toBeFalsy(); - }); - it('when called with true, sets \'isDisabled\' to true', () => { - spectator.component.setDisabledState(true); - expect(spectator.component.isDisabled).toBeTruthy(); - }); - it('when called with false, button is not disabled', () => { - spectator.component.setDisabledState(false); - spectator.detectChanges(); - expect(spectator.query('button')).not.toBeDisabled(); - }); - it('when called with true, button is disabled', () => { - spectator.component.setDisabledState(true); - spectator.detectChanges(); - expect(spectator.query('button')).toBeDisabled(); - }); - }); - - describe('writeValue()', () => { - it('when called with edit, sets value to that value', () => { - spectator.component.writeValue('edit'); - expect(spectator.component.value).toBe('edit'); - }); - - it('when called with unexist value, resets value to null', () => { - spectator.component.writeValue('unexist'); - expect(spectator.component.value).toBeNull(); - }); - }); - - describe('onValueChanged()', () => { - it('when called with delete, sets value to be delete', () => { - spectator.component.onValueChanged('delete'); - expect(spectator.component.value).toBe('delete'); - }); - }); -}); diff --git a/src/app/modules/ix-forms/components/ix-label/ix-label.harness.ts b/src/app/modules/ix-forms/components/ix-label/ix-label.harness.ts index 6090a597728..8df4f2c2536 100644 --- a/src/app/modules/ix-forms/components/ix-label/ix-label.harness.ts +++ b/src/app/modules/ix-forms/components/ix-label/ix-label.harness.ts @@ -1,6 +1,7 @@ import { BaseHarnessFilters, ComponentHarness, HarnessPredicate, } from '@angular/cdk/testing'; +import { IxTooltipHarness } from 'app/modules/tooltip/tooltip.harness'; export interface IxLabelFilters extends BaseHarnessFilters { label: string; @@ -9,6 +10,8 @@ export interface IxLabelFilters extends BaseHarnessFilters { export class IxLabelHarness extends ComponentHarness { static hostSelector = 'ix-label'; + readonly getTooltip = this.locatorFor(IxTooltipHarness); + static with(options: IxLabelFilters): HarnessPredicate<IxLabelHarness> { return new HarnessPredicate(IxLabelHarness, options) .addOption('label', options.label, (harness, label) => HarnessPredicate.stringMatches(harness.getLabel(), label)); @@ -22,4 +25,9 @@ export class IxLabelHarness extends ComponentHarness { return label.text({ exclude: '.required' }); } + + async isRequired(): Promise<boolean> { + const required = await this.locatorForOptional('.required')(); + return Boolean(required); + } } diff --git a/src/app/modules/tooltip/tooltip.harness.ts b/src/app/modules/tooltip/tooltip.harness.ts new file mode 100644 index 00000000000..0ff9f4a6fe0 --- /dev/null +++ b/src/app/modules/tooltip/tooltip.harness.ts @@ -0,0 +1,10 @@ +import { ComponentHarness } from '@angular/cdk/testing'; + +export class IxTooltipHarness extends ComponentHarness { + static hostSelector = 'ix-tooltip'; + + async getMessage(): Promise<string> { + const message = await this.locatorForOptional('.tooltip-message')(); + return message ? message.text() : ''; + } +} diff --git a/src/app/pages/dashboard/components/dashboard/dashboard.component.html b/src/app/pages/dashboard/components/dashboard/dashboard.component.html index 57b06a42937..2885ada18df 100644 --- a/src/app/pages/dashboard/components/dashboard/dashboard.component.html +++ b/src/app/pages/dashboard/components/dashboard/dashboard.component.html @@ -64,7 +64,7 @@ [totalGroups]="renderedGroups().length" (moveUp)="onMoveGroup(i, -1)" (moveDown)="onMoveGroup(i, 1)" - (edit)="onEditGroup(group)" + (edit)="onEditGroup(i)" (delete)="onDeleteGroup(group)" ></ix-widget-group-controls> diff --git a/src/app/pages/dashboard/components/dashboard/dashboard.component.spec.ts b/src/app/pages/dashboard/components/dashboard/dashboard.component.spec.ts index 39dc626146c..d1aec0bb71d 100644 --- a/src/app/pages/dashboard/components/dashboard/dashboard.component.spec.ts +++ b/src/app/pages/dashboard/components/dashboard/dashboard.component.spec.ts @@ -15,7 +15,7 @@ import { WidgetGroupComponent } from 'app/pages/dashboard/components/widget-grou import { WidgetGroupFormComponent } from 'app/pages/dashboard/components/widget-group-form/widget-group-form.component'; import { DashboardStore } from 'app/pages/dashboard/services/dashboard.store'; import { WidgetGroup, WidgetGroupLayout } from 'app/pages/dashboard/types/widget-group.interface'; -import { IxChainedSlideInService } from 'app/services/ix-chained-slide-in.service'; +import { ChainedComponentResponse, IxChainedSlideInService } from 'app/services/ix-chained-slide-in.service'; describe('DashboardComponent', () => { const groupA: WidgetGroup = { layout: WidgetGroupLayout.Full, slots: [] }; @@ -89,6 +89,23 @@ describe('DashboardComponent', () => { .toHaveBeenCalledWith(WidgetGroupFormComponent, true, groupA); }); + it('updates a widget group after group is edited in WidgetGroupComponent', async () => { + const updatedGroup = { ...groupA, layout: WidgetGroupLayout.Halves }; + + jest.spyOn(spectator.inject(IxChainedSlideInService), 'open') + .mockReturnValue(of({ response: updatedGroup } as ChainedComponentResponse)); + + const editIcon = await loader.getHarness(IxIconHarness.with({ name: 'edit' })); + await editIcon.click(); + + const groups = spectator.queryAll(WidgetGroupComponent); + expect(groups).toHaveLength(4); + expect(groups[0].group).toEqual(updatedGroup); + expect(groups[1].group).toEqual(groupB); + expect(groups[2].group).toEqual(groupC); + expect(groups[3].group).toEqual(groupD); + }); + it('removes a widget when delete button is pressed', async () => { const deleteIcon = await loader.getHarness(IxIconHarness.with({ name: 'delete' })); await deleteIcon.click(); diff --git a/src/app/pages/dashboard/components/dashboard/dashboard.component.ts b/src/app/pages/dashboard/components/dashboard/dashboard.component.ts index ff7af2997eb..3aee09cd367 100644 --- a/src/app/pages/dashboard/components/dashboard/dashboard.component.ts +++ b/src/app/pages/dashboard/components/dashboard/dashboard.component.ts @@ -10,6 +10,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { EmptyType } from 'app/enums/empty-type.enum'; import { EmptyConfig } from 'app/interfaces/empty-config.interface'; +import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service'; import { WidgetGroupFormComponent } from 'app/pages/dashboard/components/widget-group-form/widget-group-form.component'; import { DashboardStore } from 'app/pages/dashboard/services/dashboard.store'; import { WidgetGroup } from 'app/pages/dashboard/types/widget-group.interface'; @@ -55,6 +56,7 @@ export class DashboardComponent implements OnInit { private slideIn: IxChainedSlideInService, private errorHandler: ErrorHandlerService, private translate: TranslateService, + private snackbar: SnackbarService, ) {} ngOnInit(): void { @@ -62,7 +64,6 @@ export class DashboardComponent implements OnInit { this.loadGroups(); } - // TODO: Enter configuration mode. Probably store layout that is being edited in a new service. protected onConfigure(): void { this.isEditing.set(true); } @@ -77,17 +78,27 @@ export class DashboardComponent implements OnInit { .open(WidgetGroupFormComponent, true) .pipe(untilDestroyed(this)) .subscribe((response: ChainedComponentResponse) => { - if (response.response) { - this.renderedGroups.update((groups) => [...groups, response.response as WidgetGroup]); + if (!response.response) { + return; } + + this.renderedGroups.update((groups) => [...groups, response.response as WidgetGroup]); }); } - protected onEditGroup(group: WidgetGroup): void { + protected onEditGroup(i: number): void { + const editedGroup = this.renderedGroups()[i]; this.slideIn - .open(WidgetGroupFormComponent, true, group) + .open(WidgetGroupFormComponent, true, editedGroup) .pipe(untilDestroyed(this)) - .subscribe(() => { + .subscribe((response) => { + if (!response.response) { + return; + } + + this.renderedGroups.update((groups) => { + return groups.map((group, index) => (index === i ? response.response as WidgetGroup : group)); + }); }); } @@ -109,11 +120,13 @@ export class DashboardComponent implements OnInit { }); } - // TODO: Filter out fully empty groups somewhere. protected onSave(): void { this.dashboardStore.save(this.renderedGroups()) .pipe(this.errorHandler.catchError(), untilDestroyed(this)) - .subscribe(() => this.isEditing.set(false)); + .subscribe(() => { + this.isEditing.set(false); + this.snackbar.success(this.translate.instant('Dashboard settings saved')); + }); } private loadGroups(): void { diff --git a/src/app/pages/dashboard/components/widget-group-form/widget-group-form.component.scss b/src/app/pages/dashboard/components/widget-group-form/widget-group-form.component.scss index 1159b60a932..468553f08ef 100644 --- a/src/app/pages/dashboard/components/widget-group-form/widget-group-form.component.scss +++ b/src/app/pages/dashboard/components/widget-group-form/widget-group-form.component.scss @@ -3,15 +3,15 @@ background-color: var(--bg1); display: flex; flex-direction: column; + margin-bottom: 5px; margin-left: -20px; margin-right: -20px; - margin-top: 20px; padding: 15px 10px; } .form-row { display: flex; - gap: 8px; + gap: 20px; ix-select, ix-icon-group { diff --git a/src/app/pages/dashboard/services/dashboard.store.ts b/src/app/pages/dashboard/services/dashboard.store.ts index ddca1a0ac83..09be33d0d86 100644 --- a/src/app/pages/dashboard/services/dashboard.store.ts +++ b/src/app/pages/dashboard/services/dashboard.store.ts @@ -6,7 +6,6 @@ import { Observable, catchError, filter, finalize, map, switchMap, tap, } from 'rxjs'; import { WidgetName } from 'app/enums/widget-name.enum'; -import { LoggedInUser } from 'app/interfaces/ds-cache.interface'; import { demoWidgets } from 'app/pages/dashboard/services/demo-widgets.constant'; import { WidgetGroup, WidgetGroupLayout } from 'app/pages/dashboard/types/widget-group.interface'; import { SomeWidgetSettings, WidgetType } from 'app/pages/dashboard/types/widget.interface'; @@ -77,11 +76,11 @@ export class DashboardStore extends ComponentStore<DashboardState> { ); }); - save(groups: WidgetGroup[]): Observable<LoggedInUser> { + save(groups: WidgetGroup[]): Observable<void> { this.toggleLoadingState(true); return this.ws.call('auth.set_attribute', ['dashState', groups]).pipe( - switchMap(() => this.authService.refetchUser()), + switchMap(() => this.authService.refreshUser()), finalize(() => this.toggleLoadingState(false)), ); } diff --git a/src/app/pages/dashboard/types/widget-group.interface.ts b/src/app/pages/dashboard/types/widget-group.interface.ts index ec994d8dceb..0763772bf7c 100644 --- a/src/app/pages/dashboard/types/widget-group.interface.ts +++ b/src/app/pages/dashboard/types/widget-group.interface.ts @@ -48,8 +48,8 @@ export const layoutToSlotSizes = { export const widgetGroupIcons = new Map<WidgetGroupLayout, string>([ [WidgetGroupLayout.Full, 'ix:layout_full'], - [WidgetGroupLayout.Quarters, 'ix:layout_quarters'], - [WidgetGroupLayout.Halves, 'ix:layout_halves'], [WidgetGroupLayout.HalfAndQuarters, 'ix:layout_half_and_quarters'], + [WidgetGroupLayout.Halves, 'ix:layout_halves'], [WidgetGroupLayout.QuartersAndHalf, 'ix:layout_quarters_and_half'], + [WidgetGroupLayout.Quarters, 'ix:layout_quarters'], ]); diff --git a/src/app/services/auth/auth.service.ts b/src/app/services/auth/auth.service.ts index a7b47af19e3..a4962d0a7a3 100644 --- a/src/app/services/auth/auth.service.ts +++ b/src/app/services/auth/auth.service.ts @@ -195,10 +195,6 @@ export class AuthService { ); } - refetchUser(): Observable<LoggedInUser> { - return this.getLoggedInUserInformation(); - } - private processLoginResult(wasLoggedIn: boolean): Observable<LoginResult> { return of(wasLoggedIn).pipe( switchMap((loggedIn) => {