-
+
+
+ {{ value }}
-
-
+
+
-
-
+
+
+
+
diff --git a/src/lib/inputs/editable-input/editable-input.component.ts b/src/lib/inputs/editable-input/editable-input.component.ts
index c8391bc..ece127b 100644
--- a/src/lib/inputs/editable-input/editable-input.component.ts
+++ b/src/lib/inputs/editable-input/editable-input.component.ts
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, Output } from '@angular/core';
import { Required } from '../../utils/decorators/required.decorator';
-import { CircleButtonType } from '../../buttons/circle-button/circle-button.type';
+import { CircleButtonType } from '../../buttons/types/circle-button.type';
@Component({
selector: 'iqser-editable-input',
diff --git a/src/lib/inputs/input-with-action/input-with-action.component.html b/src/lib/inputs/input-with-action/input-with-action.component.html
new file mode 100644
index 0000000..8591c2b
--- /dev/null
+++ b/src/lib/inputs/input-with-action/input-with-action.component.html
@@ -0,0 +1,25 @@
+
+
+
+ {{ hint }}
+
+
+
+
+
+
+
diff --git a/src/lib/inputs/input-with-action/input-with-action.component.scss b/src/lib/inputs/input-with-action/input-with-action.component.scss
new file mode 100644
index 0000000..f2c22fd
--- /dev/null
+++ b/src/lib/inputs/input-with-action/input-with-action.component.scss
@@ -0,0 +1,14 @@
+:host {
+ display: block;
+}
+
+mat-icon.disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+iqser-circle-button {
+ position: absolute;
+ top: 4px;
+ right: 5px;
+}
diff --git a/src/lib/inputs/input-with-action/input-with-action.component.ts b/src/lib/inputs/input-with-action/input-with-action.component.ts
new file mode 100644
index 0000000..809eccc
--- /dev/null
+++ b/src/lib/inputs/input-with-action/input-with-action.component.ts
@@ -0,0 +1,43 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+
+@Component({
+ selector: 'iqser-input-with-action',
+ templateUrl: './input-with-action.component.html',
+ styleUrls: ['./input-with-action.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class InputWithActionComponent {
+ @Input() placeholder = '';
+ @Input() hint?: string;
+ @Input() width: number | 'full' = 250;
+ @Input() icon?: string;
+ @Input() autocomplete: 'on' | 'off' = 'on';
+ @Input() value = '';
+ @Output() readonly action = new EventEmitter
();
+ @Output() readonly valueChange = new EventEmitter();
+
+ get hasContent(): boolean {
+ return !!this.value?.length;
+ }
+
+ get computedWidth(): string {
+ return this.width === 'full' ? '100%' : `${this.width}px`;
+ }
+
+ reset(): void {
+ this.value = '';
+ this.valueChange.emit(this.value);
+ }
+
+ get isSearch(): boolean {
+ return this.action.observers.length === 0;
+ }
+
+ executeAction($event: MouseEvent): void {
+ $event.stopPropagation();
+
+ if (this.hasContent) {
+ this.action.emit(this.value);
+ }
+ }
+}
diff --git a/src/lib/search/search.service.ts b/src/lib/search/search.service.ts
index 9df6116..81b55dc 100644
--- a/src/lib/search/search.service.ts
+++ b/src/lib/search/search.service.ts
@@ -1,31 +1,19 @@
import { Injectable } from '@angular/core';
-import { FormBuilder } from '@angular/forms';
-import { map, startWith } from 'rxjs/operators';
+import { BehaviorSubject } from 'rxjs';
import { KeysOf } from '../utils/types/utility-types';
-const controlsConfig = {
- query: ['']
-} as const;
-
-type FormControls = { [key in KeysOf]: string };
-
@Injectable()
export class SearchService {
- readonly searchForm = this._formBuilder.group(controlsConfig);
- readonly valueChanges$ = this.searchForm.valueChanges.pipe(
- startWith(''),
- map((values: FormControls) => values.query)
- );
+ private readonly _query$ = new BehaviorSubject('');
+ readonly valueChanges$ = this._query$.asObservable();
private _searchKey!: KeysOf;
- constructor(private readonly _formBuilder: FormBuilder) {}
-
get searchValue(): string {
- return this.searchForm.get('query')?.value as string;
+ return this._query$.getValue();
}
set searchValue(value: string) {
- this.searchForm.patchValue({ query: value });
+ this._query$.next(value);
}
searchIn(entities: T[]): T[] {
@@ -40,7 +28,7 @@ export class SearchService {
}
reset(): void {
- this.searchForm.reset({ query: '' }, { emitEvent: true });
+ this._query$.next('');
}
private _searchField(entity: T): string {
diff --git a/src/lib/services/error-message.service.ts b/src/lib/services/error-message.service.ts
new file mode 100644
index 0000000..a1a5d5a
--- /dev/null
+++ b/src/lib/services/error-message.service.ts
@@ -0,0 +1,19 @@
+import { Injectable } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+import { HttpErrorResponse } from '@angular/common/http';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ErrorMessageService {
+ constructor(private readonly _translateService: TranslateService) {}
+
+ private _parseErrorResponse(err: HttpErrorResponse): string {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/restrict-template-expressions
+ return err?.error?.message?.includes('message') ? ` ${err.error.message.match('"message":"(.*?)\\"')[1]}` : '';
+ }
+
+ getMessage(error: HttpErrorResponse, defaultMessage: string): string {
+ return (this._translateService.instant(defaultMessage) as string) + this._parseErrorResponse(error);
+ }
+}
diff --git a/src/lib/services/toaster.service.ts b/src/lib/services/toaster.service.ts
new file mode 100644
index 0000000..0d19ee6
--- /dev/null
+++ b/src/lib/services/toaster.service.ts
@@ -0,0 +1,85 @@
+import { Injectable } from '@angular/core';
+import { ActiveToast, ToastrService } from 'ngx-toastr';
+import { IndividualConfig } from 'ngx-toastr/toastr/toastr-config';
+import { NavigationStart, Router } from '@angular/router';
+import { TranslateService } from '@ngx-translate/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { filter } from 'rxjs/operators';
+import { ErrorMessageService } from './error-message.service';
+
+const enum NotificationType {
+ SUCCESS = 'SUCCESS',
+ WARNING = 'WARNING',
+ INFO = 'INFO'
+}
+
+export interface ToasterOptions extends IndividualConfig {
+ readonly title?: string;
+ /**
+ * These params are used as interpolateParams for translate service
+ */
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ readonly params?: object;
+ readonly actions?: { readonly title?: string; readonly action: () => void }[];
+}
+
+export interface ErrorToasterOptions extends ToasterOptions {
+ /**
+ * Pass an http error that will be processed by error message service and shown in toast
+ */
+ readonly error?: HttpErrorResponse;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class Toaster {
+ constructor(
+ private readonly _toastr: ToastrService,
+ private readonly _router: Router,
+ private readonly _translateService: TranslateService,
+ private readonly _errorMessageService: ErrorMessageService
+ ) {
+ _router.events.pipe(filter(event => event instanceof NavigationStart)).subscribe(() => {
+ _toastr.clear();
+ });
+ }
+
+ error(message: string, options?: Partial): ActiveToast {
+ let resultedMsg;
+ if (options?.error) resultedMsg = this._errorMessageService.getMessage(options.error, message);
+ else resultedMsg = this._translateService.instant(message, options?.params) as string;
+
+ return this._toastr.error(resultedMsg, options?.title, options);
+ }
+
+ info(message: string, options?: Partial): ActiveToast {
+ return this._showToastNotification(message, NotificationType.INFO, options);
+ }
+
+ success(message: string, options?: Partial): ActiveToast {
+ return this._showToastNotification(message, NotificationType.SUCCESS, options);
+ }
+
+ warning(message: string, options?: Partial): ActiveToast {
+ return this._showToastNotification(message, NotificationType.WARNING, options);
+ }
+
+ private _showToastNotification(
+ message: string,
+ notificationType = NotificationType.INFO,
+ options?: Partial
+ ): ActiveToast {
+ const translatedMsg = this._translateService.instant(message, options?.params) as string;
+
+ switch (notificationType) {
+ case NotificationType.SUCCESS:
+ return this._toastr.success(translatedMsg, options?.title, options);
+ case NotificationType.WARNING:
+ return this._toastr.warning(translatedMsg, options?.title, options);
+ case NotificationType.INFO:
+ default:
+ return this._toastr.info(translatedMsg, options?.title, options);
+ }
+ }
+}
diff --git a/src/lib/tables/entities.service.ts b/src/lib/tables/entities.service.ts
index 99754b9..cfa79ad 100644
--- a/src/lib/tables/entities.service.ts
+++ b/src/lib/tables/entities.service.ts
@@ -4,12 +4,13 @@ import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { FilterService } from '../filtering/filter.service';
import { SearchService } from '../search/search.service';
import { getFilteredEntities } from '../filtering/filter-utils';
+import { Listable } from './models/listable';
const toLengthValue = (entities: unknown[]) => entities?.length ?? 0;
const getLength = pipe(map(toLengthValue), distinctUntilChanged());
@Injectable()
-export class EntitiesService {
+export class EntitiesService {
private readonly _all$ = new BehaviorSubject([]);
readonly all$ = this._all$.asObservable();
readonly allLength$ = this._all$.pipe(getLength);
@@ -18,8 +19,9 @@ export class EntitiesService {
readonly displayed$ = this._getDisplayed$;
readonly displayedLength$ = this.displayed$.pipe(getLength);
- private readonly _selected$ = new BehaviorSubject([]);
+ private readonly _selected$ = new BehaviorSubject<(string | number)[]>([]);
readonly selected$ = this._selected$.asObservable();
+ readonly selectedEntities$ = this._selected$.asObservable().pipe(map(() => this.selected));
readonly selectedLength$ = this._selected$.pipe(getLength);
readonly noData$ = this._noData$;
@@ -34,7 +36,8 @@ export class EntitiesService {
}
get selected(): T[] {
- return Object.values(this._selected$.getValue());
+ const selectedIds = Object.values(this._selected$.getValue());
+ return this.all.filter(a => selectedIds.indexOf(a.id) !== -1);
}
private get _getDisplayed$(): Observable {
@@ -87,7 +90,8 @@ export class EntitiesService {
}
setSelected(newEntities: T[]): void {
- this._selected$.next(newEntities);
+ const selectedIds = newEntities.map(e => e.id);
+ this._selected$.next(selectedIds);
}
isSelected(entity: T): boolean {
diff --git a/src/lib/tables/listing-component.directive.ts b/src/lib/tables/listing-component.directive.ts
index fc5b894..f878981 100644
--- a/src/lib/tables/listing-component.directive.ts
+++ b/src/lib/tables/listing-component.directive.ts
@@ -10,11 +10,12 @@ import { SearchService } from '../search/search.service';
import { KeysOf } from '../utils/types/utility-types';
import { TableColumnConfig } from './models/table-column-config.model';
import { EntitiesService } from './entities.service';
+import { Listable } from './models/listable';
export const DefaultListingServices = [FilterService, SearchService, EntitiesService, SortingService] as const;
@Directive()
-export abstract class ListingComponent extends AutoUnsubscribe implements OnDestroy {
+export abstract class ListingComponent extends AutoUnsubscribe implements OnDestroy {
readonly filterService = this._injector.get(FilterService);
readonly searchService = this._injector.get>(SearchService);
readonly sortingService = this._injector.get>(SortingService);
diff --git a/src/lib/tables/models/listable.ts b/src/lib/tables/models/listable.ts
new file mode 100644
index 0000000..a6635c2
--- /dev/null
+++ b/src/lib/tables/models/listable.ts
@@ -0,0 +1,3 @@
+export interface Listable {
+ readonly id: string | number;
+}
diff --git a/src/lib/tables/table-header/table-header.component.html b/src/lib/tables/table-header/table-header.component.html
index eedc503..d695cf3 100644
--- a/src/lib/tables/table-header/table-header.component.html
+++ b/src/lib/tables/table-header/table-header.component.html
@@ -12,7 +12,7 @@
-
+
diff --git a/src/lib/tables/table-header/table-header.component.ts b/src/lib/tables/table-header/table-header.component.ts
index 6b673bc..158e17c 100644
--- a/src/lib/tables/table-header/table-header.component.ts
+++ b/src/lib/tables/table-header/table-header.component.ts
@@ -3,6 +3,7 @@ import { Required } from '../../utils/decorators/required.decorator';
import { FilterService } from '../../filtering/filter.service';
import { TableColumnConfig } from '../models/table-column-config.model';
import { EntitiesService } from '../entities.service';
+import { Listable } from '../models/listable';
@Component({
selector: 'iqser-table-header',
@@ -10,7 +11,7 @@ import { EntitiesService } from '../entities.service';
styleUrls: ['./table-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class TableHeaderComponent {
+export class TableHeaderComponent {
@Input() @Required() tableHeaderLabel!: string;
@Input() @Required() tableColumnConfigs!: readonly TableColumnConfig[];
@Input() hasEmptyColumn = false;
diff --git a/src/lib/utils/operators.ts b/src/lib/utils/operators.ts
new file mode 100644
index 0000000..3e6c9d0
--- /dev/null
+++ b/src/lib/utils/operators.ts
@@ -0,0 +1,10 @@
+import { map } from 'rxjs/operators';
+import { OperatorFunction } from 'rxjs';
+
+export function get(predicate: (value: T, index: number) => boolean): OperatorFunction {
+ return map(entities => entities.find(predicate));
+}
+
+export function any(predicate: (value: T, index: number) => boolean): OperatorFunction {
+ return map(entities => entities.some(predicate));
+}
diff --git a/src/lib/utils/types/events.type.ts b/src/lib/utils/types/events.type.ts
new file mode 100644
index 0000000..2d5c97d
--- /dev/null
+++ b/src/lib/utils/types/events.type.ts
@@ -0,0 +1,3 @@
+export interface IqserEventTarget extends EventTarget {
+ localName: string;
+}