@sneat/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/eslint.config.js +7 -0
  2. package/ng-package.json +7 -0
  3. package/package.json +16 -0
  4. package/project.json +38 -0
  5. package/src/index.ts +3 -0
  6. package/src/lib/components/index.ts +2 -0
  7. package/src/lib/components/sneat-base-modal.component.ts +22 -0
  8. package/src/lib/components/sneat-base.component.spec.ts +71 -0
  9. package/src/lib/components/sneat-base.component.ts +78 -0
  10. package/src/lib/focus.ts +19 -0
  11. package/src/lib/selector/index.ts +6 -0
  12. package/src/lib/selector/multi-selector/index.ts +2 -0
  13. package/src/lib/selector/multi-selector/multi-selector.component.html +26 -0
  14. package/src/lib/selector/multi-selector/multi-selector.component.spec.ts +147 -0
  15. package/src/lib/selector/multi-selector/multi-selector.component.ts +79 -0
  16. package/src/lib/selector/multi-selector/multi-selector.service.spec.ts +91 -0
  17. package/src/lib/selector/multi-selector/multi-selector.service.ts +49 -0
  18. package/src/lib/selector/select-from-list/index.ts +1 -0
  19. package/src/lib/selector/select-from-list/select-from-list.component.html +210 -0
  20. package/src/lib/selector/select-from-list/select-from-list.component.spec.ts +297 -0
  21. package/src/lib/selector/select-from-list/select-from-list.component.ts +283 -0
  22. package/src/lib/selector/selector-base.component.ts +43 -0
  23. package/src/lib/selector/selector-base.service.ts +62 -0
  24. package/src/lib/selector/selector-interfaces.ts +28 -0
  25. package/src/lib/selector/selector-options.ts +18 -0
  26. package/src/test-setup.ts +3 -0
  27. package/tsconfig.json +13 -0
  28. package/tsconfig.lib.json +19 -0
  29. package/tsconfig.lib.prod.json +7 -0
  30. package/tsconfig.spec.json +31 -0
  31. package/vite.config.mts +10 -0
@@ -0,0 +1,7 @@
1
+ const baseConfig = require('../../eslint.config.js');
2
+ const { sneatLibConfig } = require('../../eslint.lib.config.js');
3
+
4
+ module.exports = [
5
+ ...baseConfig,
6
+ ...sneatLibConfig(__dirname),
7
+ ];
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/libs/ui",
4
+ "lib": {
5
+ "entryFile": "src/index.ts"
6
+ }
7
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@sneat/ui",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "peerDependencies": {
8
+ "@angular/common": ">=21.0.0",
9
+ "@angular/core": ">=21.0.0",
10
+ "@ionic/angular": ">=8",
11
+ "@sneat/logging": ">=0.1.0"
12
+ },
13
+ "dependencies": {
14
+ "tslib": "2.8.1"
15
+ }
16
+ }
package/project.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "ui",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "projectType": "library",
5
+ "sourceRoot": "libs/ui/src",
6
+ "prefix": "sneat",
7
+ "targets": {
8
+ "build": {
9
+ "executor": "@nx/angular:ng-packagr-lite",
10
+ "outputs": [
11
+ "{workspaceRoot}/dist/libs/ui"
12
+ ],
13
+ "options": {
14
+ "project": "libs/ui/ng-package.json",
15
+ "tsConfig": "libs/ui/tsconfig.lib.json"
16
+ },
17
+ "configurations": {
18
+ "production": {
19
+ "tsConfig": "libs/ui/tsconfig.lib.prod.json"
20
+ },
21
+ "development": {}
22
+ },
23
+ "defaultConfiguration": "production"
24
+ },
25
+ "test": {
26
+ "executor": "@nx/vitest:test",
27
+ "outputs": [
28
+ "{workspaceRoot}/coverage/libs/ui"
29
+ ],
30
+ "options": {
31
+ "tsConfig": "libs/ui/tsconfig.spec.json"
32
+ }
33
+ },
34
+ "lint": {
35
+ "executor": "@nx/eslint:lint"
36
+ }
37
+ }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './lib/focus';
2
+ export * from './lib/components';
3
+ export * from './lib/selector';
@@ -0,0 +1,2 @@
1
+ export * from './sneat-base.component';
2
+ export * from './sneat-base-modal.component';
@@ -0,0 +1,22 @@
1
+ import { inject, Injectable } from '@angular/core';
2
+ import { SneatBaseComponent } from './sneat-base.component';
3
+ import { ModalController } from '@ionic/angular/standalone';
4
+
5
+ @Injectable()
6
+ export abstract class SneatBaseModalComponent extends SneatBaseComponent {
7
+ private readonly modalController = inject(ModalController);
8
+
9
+ protected close(): void {
10
+ this.dismissModal();
11
+ }
12
+
13
+ protected dismissModal(data?: unknown, role?: string, id?: string): void {
14
+ this.modalController
15
+ .dismiss(data, role, id)
16
+ .catch(
17
+ this.errorLogger.logErrorHandler(
18
+ `Failed to close modal ${this.className}`,
19
+ ),
20
+ );
21
+ }
22
+ }
@@ -0,0 +1,71 @@
1
+ import { SneatBaseComponent } from './sneat-base.component';
2
+ import { IErrorLogger } from '@sneat/core';
3
+ import { Subject, Subscription } from 'rxjs';
4
+
5
+ class TestComponent extends SneatBaseComponent {
6
+ constructor(errorLogger: IErrorLogger, className: string) {
7
+ super();
8
+ // @ts-expect-error accessing private property
9
+ this.errorLogger = errorLogger;
10
+ // @ts-expect-error accessing private property
11
+ this.className = className;
12
+ // @ts-expect-error accessing private property
13
+ this.destroyed = new Subject<void>();
14
+ // @ts-expect-error accessing private property
15
+ this.subs = new Subscription();
16
+ }
17
+
18
+ public getDestroyed(): Subject<void> {
19
+ // @ts-expect-error accessing private property
20
+ return this.destroyed;
21
+ }
22
+
23
+ public getSubs(): Subscription {
24
+ // @ts-expect-error accessing private property
25
+ return this.subs;
26
+ }
27
+
28
+ public log(_msg: string) {
29
+ void _msg;
30
+ // override log to avoid console output
31
+ }
32
+ }
33
+
34
+ describe('SneatBaseComponent', () => {
35
+ let component: TestComponent;
36
+ let errorLoggerMock: IErrorLogger;
37
+
38
+ beforeEach(() => {
39
+ errorLoggerMock = {
40
+ logError: vi.fn(),
41
+ logErrorHandler: vi.fn(),
42
+ } as unknown as IErrorLogger;
43
+ // Use Object.create to bypass constructor that calls inject()
44
+ component = Object.create(TestComponent.prototype) as TestComponent;
45
+ // @ts-expect-error accessing private property
46
+ component.errorLogger = errorLoggerMock;
47
+ // @ts-expect-error accessing private property
48
+ component.className = 'TestComponent';
49
+ // @ts-expect-error accessing private property
50
+ component.destroyed = new Subject<void>();
51
+ // @ts-expect-error accessing private property
52
+ component.subs = new Subscription();
53
+
54
+ // @ts-expect-error accessing private property
55
+ vi.spyOn(component.destroyed, 'next');
56
+ // @ts-expect-error accessing private property
57
+ vi.spyOn(component.subs, 'unsubscribe');
58
+ });
59
+
60
+ it('should create', () => {
61
+ expect(component).toBeTruthy();
62
+ });
63
+
64
+ it('should emit on destroyed and unsubscribe when ngOnDestroy is called', () => {
65
+ component.ngOnDestroy();
66
+ // @ts-expect-error accessing private property
67
+ expect(component.destroyed.next).toHaveBeenCalled();
68
+ // @ts-expect-error accessing private property
69
+ expect(component.subs.unsubscribe).toHaveBeenCalled();
70
+ });
71
+ });
@@ -0,0 +1,78 @@
1
+ import { inject, Injectable, InjectionToken, OnDestroy } from '@angular/core';
2
+ import { createSetFocusToInput } from '../focus';
3
+ import { ErrorLogger } from '@sneat/core';
4
+ import { MonoTypeOperatorFunction, Subject, Subscription } from 'rxjs';
5
+ import { takeUntil } from 'rxjs/operators';
6
+
7
+ export interface IConsole {
8
+ log(...data: unknown[]): void;
9
+
10
+ warn(...data: unknown[]): void;
11
+
12
+ trace(...data: unknown[]): void;
13
+
14
+ error(...data: unknown[]): void;
15
+ }
16
+
17
+ export const ClassName = new InjectionToken<string>('className');
18
+
19
+ @Injectable()
20
+ export abstract class SneatBaseComponent implements OnDestroy {
21
+ // protected $isInitialized = signal(false);
22
+ //
23
+ // // eslint-disable-next-line @angular-eslint/contextual-lifecycle
24
+ // public ngOnInit() {
25
+ // console.info(`${this.className}.SneatBaseComponent.ngOnInit()`);
26
+ // // $isInitialized is for workaround for https://angular.dev/errors/NG0950
27
+ // // Required input is accessed before a value is set.
28
+ // // Might be excessive and should be removed if we can find a better way.
29
+ // this.$isInitialized.set(true);
30
+ // }
31
+
32
+ private readonly destroyed = new Subject<void>();
33
+ // Signals that the component is destroyed and should not be used anymore
34
+ protected readonly destroyed$ = this.destroyed.asObservable();
35
+
36
+ // All active subscriptions of a component. Will be unsubscribed on destroy
37
+ protected readonly subs = new Subscription();
38
+
39
+ protected readonly console: IConsole = console;
40
+
41
+ protected readonly errorLogger = inject(ErrorLogger);
42
+
43
+ protected readonly logError = this.errorLogger.logError;
44
+ protected readonly logErrorHandler = this.errorLogger.logErrorHandler;
45
+
46
+ // Passes focus to the input element
47
+ protected readonly setFocusToInput = createSetFocusToInput(this.errorLogger);
48
+
49
+ protected readonly className = inject(ClassName);
50
+
51
+ public constructor() {
52
+ this.log(`${this.className}.SneatBaseComponent.constructor()`);
53
+ }
54
+
55
+ protected log(msg: string, ...data: unknown[]): void {
56
+ this.console.log(msg, ...data);
57
+ }
58
+
59
+ // protected readonly takeUntilDestroyed = <T>() =>
60
+ // takeUntil<T>(this.destroyed$);
61
+ protected takeUntilDestroyed<T>(): MonoTypeOperatorFunction<T> {
62
+ return takeUntil(this.destroyed$);
63
+ }
64
+
65
+ public ngOnDestroy(): void {
66
+ this.log(`${this.className}.SneatBaseComponent.ngOnDestroy()`);
67
+ this.unsubscribe(`${this.className}.SneatBaseComponent.ngOnDestroy()`);
68
+ this.destroyed?.next();
69
+ this.destroyed?.complete();
70
+ }
71
+
72
+ protected unsubscribe(reason?: string): void {
73
+ this.log(
74
+ `${this.className}.SneatBaseComponent.unsubscribe(reason: ${reason})`,
75
+ );
76
+ this.subs.unsubscribe();
77
+ }
78
+ }
@@ -0,0 +1,19 @@
1
+ import { IonInput, IonTextarea } from '@ionic/angular/standalone';
2
+ import { IErrorLogger } from '@sneat/core';
3
+
4
+ export function createSetFocusToInput(errorLogger: IErrorLogger) {
5
+ return (input?: IonInput | IonTextarea, delay = 100): void => {
6
+ if (!input) {
7
+ console.error('can not set focus to undefined input');
8
+ return;
9
+ }
10
+ setTimeout(() => {
11
+ requestAnimationFrame(() => {
12
+ // input.getInputElement().then(el => el.focus()).catch(errorLogger.logErrorHandler('failed to set focus to input'));
13
+ input
14
+ .setFocus()
15
+ .catch(errorLogger.logErrorHandler('failed to set focus to input'));
16
+ });
17
+ }, delay);
18
+ };
19
+ }
@@ -0,0 +1,6 @@
1
+ export * from './selector-interfaces';
2
+ export * from './selector-options';
3
+ export * from './selector-base.service';
4
+ export * from './selector-base.component';
5
+ export * from './multi-selector';
6
+ export * from './select-from-list';
@@ -0,0 +1,2 @@
1
+ export * from './multi-selector.service';
2
+ export * from './multi-selector.component';
@@ -0,0 +1,26 @@
1
+ <ion-card>
2
+ <ion-item-divider color="light">
3
+ <ion-label color="medium">{{ title }}</ion-label>
4
+ <ion-buttons slot="end">
5
+ <ion-button color="medium">
6
+ <ion-icon name="add-outline" />
7
+ <ion-label>Add</ion-label>
8
+ </ion-button>
9
+ </ion-buttons>
10
+ </ion-item-divider>
11
+
12
+ <ion-list>
13
+ @for (item of selectedItems; track item.id) {
14
+ <ion-item>
15
+ <ion-label>{{ item.title }}</ion-label>
16
+ @if (canRemove) {
17
+ <ion-buttons slot="end">
18
+ <ion-button title="Remove" (click)="removeItem($event, item)">
19
+ <ion-icon name="close-outline" />
20
+ </ion-button>
21
+ </ion-buttons>
22
+ }
23
+ </ion-item>
24
+ }
25
+ </ion-list>
26
+ </ion-card>
@@ -0,0 +1,147 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core';
3
+ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
4
+ import { ErrorLogger } from '@sneat/core';
5
+ import { ClassName } from '../../components';
6
+ import { OverlayController } from '../selector-base.component';
7
+ import { MultiSelectorComponent } from './multi-selector.component';
8
+ import { ISelectItem } from '../selector-interfaces';
9
+
10
+ // Suppress Ionic icon warnings - mock console methods to filter Ionic-specific errors
11
+ const originalWarn = console.warn;
12
+ const originalError = console.error;
13
+
14
+ vi.spyOn(console, 'error').mockImplementation((...args: any[]) => {
15
+ const message = args[0]?.toString() || '';
16
+ // Only suppress Ionic/Stencil icon loading errors
17
+ if (
18
+ message.includes('Ionicons') ||
19
+ message.includes('@ionic/core/components/icon') ||
20
+ message.includes('getAssetPath')
21
+ ) {
22
+ return;
23
+ }
24
+ // Let other errors through
25
+ originalError(...args);
26
+ });
27
+
28
+ vi.spyOn(console, 'warn').mockImplementation((...args: any[]) => {
29
+ const message = args[0]?.toString() || '';
30
+ // Only suppress Ionic icon warnings
31
+ if (message.includes('Ionicons') || message.includes('Could not load icon')) {
32
+ return;
33
+ }
34
+ // Let other warnings through
35
+ originalWarn(...args);
36
+ });
37
+
38
+ describe('MultiSelectorComponent', () => {
39
+ let component: MultiSelectorComponent;
40
+ let fixture: ComponentFixture<MultiSelectorComponent>;
41
+
42
+ const mockItems: ISelectItem[] = [
43
+ { id: '1', title: 'Item 1' },
44
+ { id: '2', title: 'Item 2' },
45
+ ];
46
+
47
+ beforeEach(waitForAsync(async () => {
48
+ await TestBed.configureTestingModule({
49
+ imports: [MultiSelectorComponent],
50
+ providers: [
51
+ { provide: ClassName, useValue: 'TestComponent' },
52
+ {
53
+ provide: ErrorLogger,
54
+ useValue: { logError: vi.fn(), logErrorHandler: () => vi.fn() },
55
+ },
56
+ {
57
+ provide: OverlayController,
58
+ useValue: { dismiss: vi.fn().mockResolvedValue(undefined) },
59
+ },
60
+ ],
61
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
62
+ })
63
+ .overrideComponent(MultiSelectorComponent, {
64
+ set: {
65
+ imports: [],
66
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
67
+ providers: [
68
+ { provide: ClassName, useValue: 'TestComponent' },
69
+ {
70
+ provide: OverlayController,
71
+ useValue: { dismiss: vi.fn().mockResolvedValue(undefined) },
72
+ },
73
+ ],
74
+ },
75
+ })
76
+ .compileComponents();
77
+ fixture = TestBed.createComponent(MultiSelectorComponent);
78
+ component = fixture.componentInstance;
79
+ fixture.detectChanges();
80
+ }));
81
+
82
+ it('should create', () => {
83
+ expect(component).toBeTruthy();
84
+ });
85
+
86
+ describe('ngOnChanges', () => {
87
+ it('should set selectedItems when allItems changes', () => {
88
+ component.allItems = mockItems;
89
+ component.selectedIDs = ['1'];
90
+ component.ngOnChanges({
91
+ allItems: new SimpleChange(undefined, mockItems, true),
92
+ });
93
+ // @ts-expect-error accessing protected member
94
+ expect(component.selectedItems).toEqual([mockItems[0]]);
95
+ });
96
+ });
97
+
98
+ describe('removeItem', () => {
99
+ it('should remove item and emit event', () => {
100
+ component.allItems = mockItems;
101
+ component.selectedIDs = ['1', '2'];
102
+ component.ngOnChanges({
103
+ allItems: new SimpleChange(undefined, mockItems, true),
104
+ });
105
+
106
+ const removeSpy = vi.spyOn(component.removeItems, 'emit');
107
+ const event = { stopPropagation: vi.fn() };
108
+ // @ts-expect-error accessing protected member
109
+ component.removeItem(event as any, mockItems[0]);
110
+
111
+ expect(event.stopPropagation).toHaveBeenCalled();
112
+ // @ts-expect-error accessing protected member
113
+ expect(component.selectedItems).toEqual([mockItems[1]]);
114
+ expect(removeSpy).toHaveBeenCalledWith([{ event, item: mockItems[0] }]);
115
+ });
116
+ });
117
+ describe('SelectorBaseComponent coverage', () => {
118
+ it('should close and dismiss overlay', () => {
119
+ // @ts-expect-error accessing protected member
120
+ const dismissSpy = vi.spyOn(component.overlayController, 'dismiss');
121
+ const event = { stopPropagation: vi.fn(), preventDefault: vi.fn() };
122
+ // @ts-expect-error accessing protected member
123
+ component.close(event as any);
124
+ expect(event.stopPropagation).toHaveBeenCalled();
125
+ expect(event.preventDefault).toHaveBeenCalled();
126
+ expect(dismissSpy).toHaveBeenCalled();
127
+ });
128
+
129
+ it('should close without event and dismiss overlay', () => {
130
+ // @ts-expect-error accessing protected member
131
+ const dismissSpy = vi.spyOn(component.overlayController, 'dismiss');
132
+ // @ts-expect-error accessing protected member
133
+ component.close();
134
+ expect(dismissSpy).toHaveBeenCalled();
135
+ });
136
+
137
+ it('should ignore ngOnChanges if allItems not changed', () => {
138
+ // @ts-expect-error accessing protected member
139
+ component.selectedItems = undefined;
140
+ component.ngOnChanges({
141
+ other: new SimpleChange(undefined, 'value', true),
142
+ });
143
+ // @ts-expect-error accessing protected member
144
+ expect(component.selectedItems).toBeUndefined();
145
+ });
146
+ });
147
+ });
@@ -0,0 +1,79 @@
1
+ import {
2
+ Component,
3
+ EventEmitter,
4
+ Input,
5
+ OnChanges,
6
+ Output,
7
+ SimpleChanges,
8
+ } from '@angular/core';
9
+ import {
10
+ IonButton,
11
+ IonButtons,
12
+ IonCard,
13
+ IonIcon,
14
+ IonItem,
15
+ IonItemDivider,
16
+ IonLabel,
17
+ IonList,
18
+ ModalController,
19
+ } from '@ionic/angular/standalone';
20
+ import { ClassName } from '../../components';
21
+ import { ISelectItem, ISelectItemEvent } from '../selector-interfaces';
22
+ import {
23
+ OverlayController,
24
+ SelectorBaseComponent,
25
+ } from '../selector-base.component';
26
+
27
+ @Component({
28
+ selector: 'sneat-multi-selector',
29
+ templateUrl: './multi-selector.component.html',
30
+ imports: [
31
+ IonCard,
32
+ IonItemDivider,
33
+ IonLabel,
34
+ IonButtons,
35
+ IonButton,
36
+ IonItem,
37
+ IonIcon,
38
+ IonList,
39
+ ],
40
+ providers: [
41
+ {
42
+ provide: ClassName,
43
+ useValue: 'MultiSelectorComponent',
44
+ },
45
+ {
46
+ provide: OverlayController,
47
+ useClass: ModalController,
48
+ },
49
+ ],
50
+ })
51
+ export class MultiSelectorComponent<T = ISelectItem>
52
+ extends SelectorBaseComponent<T>
53
+ implements OnChanges
54
+ {
55
+ @Input() title = 'Select';
56
+
57
+ @Input() canRemove = false;
58
+ @Input() public allItems?: ISelectItem[];
59
+ @Input() public selectedIDs?: readonly string[];
60
+
61
+ @Output() readonly removeItems = new EventEmitter<ISelectItemEvent[]>();
62
+ @Output() readonly addItems = new EventEmitter<ISelectItemEvent[]>();
63
+
64
+ protected selectedItems?: ISelectItem[];
65
+
66
+ ngOnChanges(changes: SimpleChanges): void {
67
+ if (changes['allItems']) {
68
+ this.selectedItems = this.allItems?.filter((item) =>
69
+ this.selectedIDs?.includes(item.id),
70
+ );
71
+ }
72
+ }
73
+
74
+ protected removeItem(event: Event, item: ISelectItem): void {
75
+ event.stopPropagation();
76
+ this.selectedItems = this.selectedItems?.filter((i) => i.id !== item.id);
77
+ this.removeItems.emit([{ event, item }]);
78
+ }
79
+ }
@@ -0,0 +1,91 @@
1
+ import { MultiSelectorService } from './multi-selector.service';
2
+ import { ISelectItem } from '../selector-interfaces';
3
+ import { ErrorLogger } from '@sneat/core';
4
+ import { ModalController } from '@ionic/angular/standalone';
5
+
6
+ describe('MultiSelectorService', () => {
7
+ let service: MultiSelectorService;
8
+ let errorLoggerMock: ErrorLogger;
9
+ let modalControllerMock: ModalController;
10
+
11
+ beforeEach(() => {
12
+ errorLoggerMock = {
13
+ logError: vi.fn(),
14
+ logErrorHandler: vi.fn(() => vi.fn()),
15
+ };
16
+ modalControllerMock = {
17
+ create: vi.fn(),
18
+ };
19
+ service = new MultiSelectorService(errorLoggerMock, modalControllerMock);
20
+ });
21
+
22
+ it('should create', () => {
23
+ expect(service).toBeTruthy();
24
+ });
25
+
26
+ describe('selectMultiple', () => {
27
+ const items: ISelectItem[] = [{ id: '1', title: 'test' }];
28
+
29
+ it('should resolve with selected items on success', async () => {
30
+ const modalMock = {
31
+ present: vi.fn().mockReturnValue(Promise.resolve()),
32
+ onDidDismiss: vi.fn().mockReturnValue(
33
+ Promise.resolve({ data: { selectedItems: items } }),
34
+ ),
35
+ };
36
+ modalControllerMock.create.mockReturnValue(Promise.resolve(modalMock));
37
+
38
+ const result = await service.selectMultiple(items, []);
39
+ expect(result).toEqual(items);
40
+ expect(modalControllerMock.create).toHaveBeenCalled();
41
+ expect(modalMock.present).toHaveBeenCalled();
42
+ });
43
+
44
+ it('should reject and log error if modal creation fails', async () => {
45
+ modalControllerMock.create.mockReturnValue(Promise.reject('fail'));
46
+ await expect(service.selectMultiple([], [])).rejects.toBe('fail');
47
+ expect(errorLoggerMock.logError).toHaveBeenCalledWith(
48
+ 'fail',
49
+ 'Failed to create modal',
50
+ );
51
+ });
52
+
53
+ it('should reject and log error if modal presentation fails', async () => {
54
+ const modalMock = {
55
+ present: vi.fn().mockReturnValue(Promise.reject('present-fail')),
56
+ onDidDismiss: vi.fn().mockReturnValue(new Promise(() => { /* never resolves */ })),
57
+ };
58
+ modalControllerMock.create.mockReturnValue(Promise.resolve(modalMock));
59
+ await expect(service.selectMultiple([], [])).rejects.toBe('present-fail');
60
+ expect(errorLoggerMock.logError).toHaveBeenCalledWith(
61
+ 'Failed to present modal',
62
+ );
63
+ });
64
+
65
+ it('should reject and log error if modal dismiss fails', async () => {
66
+ const modalMock = {
67
+ present: vi.fn().mockReturnValue(Promise.resolve()),
68
+ onDidDismiss: vi.fn().mockReturnValue(Promise.reject('dismiss-fail')),
69
+ };
70
+ modalControllerMock.create.mockReturnValue(Promise.resolve(modalMock));
71
+ await expect(service.selectMultiple([], [])).rejects.toBe('dismiss-fail');
72
+ expect(errorLoggerMock.logError).toHaveBeenCalledWith(
73
+ 'dismiss-fail',
74
+ 'Failed to handle modal dismiss',
75
+ );
76
+ });
77
+
78
+ it('should resolve with empty array if selectedItems is missing in response', async () => {
79
+ const modalMock = {
80
+ present: vi.fn().mockReturnValue(Promise.resolve()),
81
+ onDidDismiss: vi.fn().mockReturnValue(
82
+ Promise.resolve({ data: {} }),
83
+ ),
84
+ };
85
+ modalControllerMock.create.mockReturnValue(Promise.resolve(modalMock));
86
+
87
+ const result = await service.selectMultiple([], []);
88
+ expect(result).toEqual([]);
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,49 @@
1
+ import { Inject } from '@angular/core';
2
+ import { ModalController, ModalOptions } from '@ionic/angular/standalone';
3
+ import { ErrorLogger, IErrorLogger } from '@sneat/core';
4
+ import { ISelectItem } from '../selector-interfaces';
5
+ import { MultiSelectorComponent } from './multi-selector.component';
6
+
7
+ export class MultiSelectorService {
8
+ constructor(
9
+ @Inject(ErrorLogger) private readonly errorLogger: IErrorLogger,
10
+ private readonly modalController: ModalController,
11
+ ) {}
12
+
13
+ selectMultiple(
14
+ items: ISelectItem[],
15
+ selectedItems: ISelectItem[],
16
+ ): Promise<ISelectItem[]> {
17
+ const result = new Promise<ISelectItem[]>((resolve, reject) => {
18
+ const modalOptions: ModalOptions = {
19
+ component: MultiSelectorComponent,
20
+ componentProps: {
21
+ items,
22
+ selectedItems,
23
+ },
24
+ keyboardClose: true,
25
+ };
26
+ this.modalController
27
+ .create(modalOptions)
28
+ .then((modal) => {
29
+ modal
30
+ .onDidDismiss()
31
+ .then((res) => resolve(res.data?.selectedItems || []))
32
+ .catch((err) => {
33
+ this.errorLogger.logError(err, 'Failed to handle modal dismiss');
34
+ reject(err);
35
+ });
36
+ modal.present().catch((err) => {
37
+ reject(err);
38
+ this.errorLogger.logError('Failed to present modal');
39
+ });
40
+ })
41
+ .catch((err) => {
42
+ this.errorLogger.logError(err, 'Failed to create modal');
43
+ reject(err);
44
+ });
45
+ });
46
+
47
+ return result;
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ export * from './select-from-list.component';