Merge branch 'develop' into feature/responsive-customer-orders

This commit is contained in:
Nino Righi
2023-04-28 10:21:46 +02:00
100 changed files with 3755 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
{
"$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,36 @@
<div>
<div class="inputs overflow-y-auto">
<button
class="ui-input"
type="button"
*ngFor="let input of uiInputGroup?.input"
[class.active]="activeInput === input"
[class.has-options]="input?.hasSelectedOptions() || input?.hasUnselectedOptions() || input?.selected"
(click)="setActiveInput(input)"
>
<div class="grow">
{{ input?.label }}
</div>
<ui-icon icon="arrow_head" size="1rem"></ui-icon>
</button>
</div>
</div>
<shared-filter-input-options
*ngIf="activeInput?.options?.values?.length"
[class.remove-rounded-top-left]="isFirstInputSelected"
class="options"
[inputOptions]="activeInput?.options"
>
</shared-filter-input-options>
<div *ngIf="!activeInput?.options?.values?.length" class="bg-white p-4 rounded" [ngSwitch]="activeInput?.type">
<p *ngIf="activeInput?.description" class="font-bold">{{ activeInput?.description }}</p>
<ng-container *ngIf="inputTemplate">
<ng-container *ngTemplateOutlet="inputTemplate; context: { $implicit: activeInput }"> </ng-container>
</ng-container>
<ng-container *ngIf="!inputTemplate">
<shared-filter-input-text *ngSwitchCase="1" [input]="activeInput"></shared-filter-input-text>
</ng-container>
</div>

View File

@@ -0,0 +1,75 @@
:host {
@apply grid grid-flow-col gap-2;
grid-template-columns: 240px 1fr;
}
ui-icon {
@apply transition transform text-cool-grey;
}
.inputs {
@apply grid grid-flow-row gap-2;
max-height: calc(100vh - 377px);
.ui-input {
@apply flex flex-row items-center border-none outline-none p-4 font-bold text-base bg-white text-black text-left rounded-card transition transform;
}
.has-options {
@apply text-white bg-active-customer;
ui-icon {
@apply text-white;
}
}
.ui-input.active {
@apply text-black bg-white pr-6 -mr-2 rounded-r-none;
ui-icon {
@apply rotate-180 text-cool-grey;
}
}
.space-right {
@apply mr-4;
}
.grow {
@apply flex-grow;
}
}
::ng-deep ui-filter-filter-group-filter .input-options-wrapper {
min-height: 150px;
}
::ng-deep ui-filter-filter-group-filter .remove-rounded-top-left .input-options-wrapper {
@apply rounded-tl-none;
}
::ng-deep .branch ui-filter-filter-group-filter {
.inputs {
.ui-input.has-options {
@apply text-white bg-active-branch;
ui-icon {
@apply text-white;
}
}
.ui-input.active {
@apply text-black bg-white pr-6 -mr-2 rounded-r-none;
ui-icon {
@apply rotate-180 text-active-branch;
}
}
}
}
::ng-deep .branch ui-filter-input-options {
.has-options {
@apply bg-cool-grey;
}
}

View File

@@ -0,0 +1,46 @@
import { Component, ChangeDetectionStrategy, Input, TemplateRef } from '@angular/core';
import { IInputGroup, FilterInput, InputGroup } from '../../tree';
import { FilterComponent } from '../../filter.component';
@Component({
selector: 'shared-filter-filter-group-filter',
templateUrl: 'filter-filter-group-filter.component.html',
styleUrls: ['filter-filter-group-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterFilterGroupFilterComponent {
private _inputGroup: InputGroup;
@Input()
set inputGroup(value: IInputGroup) {
if (value instanceof InputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = InputGroup.create(value);
}
}
get uiInputGroup() {
return this._inputGroup;
}
private _activeInput: FilterInput;
get activeInput() {
return this.uiInputGroup?.input?.find((f) => f?.key === this._activeInput?.key) || this.uiInputGroup?.input?.find((f) => f);
}
get isFirstInputSelected() {
return this.activeInput === this.uiInputGroup?.input?.find((f) => f);
}
get inputTemplate(): TemplateRef<any> {
return this._hostComponent.customInputs?.find((f) => f.key === this.activeInput?.key)?.templateRef;
}
constructor(private _hostComponent: FilterComponent) {}
setActiveInput(input: FilterInput) {
this._activeInput = input;
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterFilterGroupFilterComponent } from './filter-filter-group-filter.component';
import { FilterInputOptionsModule } from '../../shared/filter-input-options';
import { UiIconModule } from '@ui/icon';
import { FilterInputModule } from '../../shared/filter-input';
@NgModule({
imports: [CommonModule, UiIconModule, FilterInputOptionsModule, FilterInputModule],
exports: [FilterFilterGroupFilterComponent],
declarations: [FilterFilterGroupFilterComponent],
})
export class FilterFilterGroupFilterModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-filter-group-filter.component';
export * from './filter-filter-group-filter.module';
// end:ng42.barrel

View File

@@ -0,0 +1 @@
<shared-filter-input-chip *ngFor="let input of uiInputGroup?.input" [input]="input"></shared-filter-input-chip>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-col items-center justify-center gap-4;
}

View File

@@ -0,0 +1,49 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { FilterInputChipModule } from '../../shared/filter-input-chip';
import { InputGroup } from '../../tree';
import { FilterFilterGroupMainComponent } from './filter-filter-group-main.component';
describe('UiFilterFilterGroupMainComponent', () => {
let spectator: Spectator<FilterFilterGroupMainComponent>;
const createComponent = createComponentFactory({
component: FilterFilterGroupMainComponent,
imports: [FilterInputChipModule],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
});
describe('inputGroup', () => {
it('should create an instance of UiInputGroup when value is a plain object', () => {
spectator.setInput({ inputGroup: {} });
expect(spectator.component.uiInputGroup instanceof InputGroup).toBe(true);
});
it('should set the UiInputGroup if it is already an instance of UiInputGroup', () => {
const instance = InputGroup.create({});
spectator.setInput({ inputGroup: instance });
expect(spectator.component.uiInputGroup).toBe(instance);
});
});
describe('ui-filter-input-chip Element', () => {
it('should render an Element for each uiInputGroup.input', () => {
const instance = InputGroup.create({
input: [
{ label: 'Label 1', type: 0 },
{ label: 'label 2', type: 0 },
{ label: 'label 3', type: 0 },
],
});
spectator.setInput({ inputGroup: instance });
spectator.detectComponentChanges();
expect(spectator.queryAll('shared-filter-input-chip').length).toBe(3);
});
});
});

View File

@@ -0,0 +1,27 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { IInputGroup, InputGroup } from '../../tree';
@Component({
selector: 'shared-filter-filter-group-main',
templateUrl: 'filter-filter-group-main.component.html',
styleUrls: ['filter-filter-group-main.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterFilterGroupMainComponent {
private _inputGroup: InputGroup;
@Input()
set inputGroup(value: IInputGroup) {
if (value instanceof InputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = InputGroup.create(value);
}
}
get uiInputGroup() {
return this._inputGroup;
}
constructor() {}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterFilterGroupMainComponent } from './filter-filter-group-main.component';
import { FilterInputChipModule } from '../../shared/filter-input-chip';
@NgModule({
imports: [CommonModule, FilterInputChipModule],
exports: [FilterFilterGroupMainComponent],
declarations: [FilterFilterGroupMainComponent],
})
export class FilterFilterGroupMainModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-filter-group-main.component';
export * from './filter-filter-group-main.module';
// end:ng42.barrel

View File

@@ -0,0 +1,25 @@
<ui-searchbox
[placeholder]="uiInput?.placeholder"
[query]="uiInput?.value"
(queryChange)="onQueryChange($event)"
(complete)="complete.next($event)"
(search)="emitSearch($event)"
(scan)="emitSearch($event)"
[loading]="loading"
[hint]="hint"
[scanner]="scanner"
>
<ui-autocomplete *ngIf="autocompleteProvider">
<button *ngFor="let item of autocompleteResults$ | async" [uiAutocompleteItem]="item.query">
{{ item.display }}
</button>
</ui-autocomplete>
</ui-searchbox>
<ng-container *ngIf="showDescription && uiInput?.description">
<button [uiOverlayTrigger]="tooltipContent" #tooltip="uiOverlayTrigger" class="info-tooltip-button" type="button">
i
</button>
<ui-tooltip #tooltipContent yPosition="above" xPosition="after" [yOffset]="-16">
{{ uiInput.description }}
</ui-tooltip>
</ng-container>

View File

@@ -0,0 +1,12 @@
:host {
@apply relative;
}
.info-tooltip-button {
@apply border-font-customer border-2 border-solid bg-white rounded-md text-base font-bold absolute;
border-style: outset;
width: 31px;
height: 31px;
top: 14px;
right: -45px;
}

View File

@@ -0,0 +1,169 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Optional,
ViewChild,
AfterViewInit,
OnInit,
Inject,
OnDestroy,
ChangeDetectorRef,
Output,
EventEmitter,
} from '@angular/core';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { Observable, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, switchMap, takeUntil, tap } from 'rxjs/operators';
import { FilterAutocomplete, FilterAutocompleteProvider } from '../../providers';
import { IInputGroup, FilterInput, InputGroup } from '../../tree';
@Component({
selector: 'shared-filter-input-group-main',
templateUrl: 'filter-input-group-main.component.html',
styleUrls: ['filter-input-group-main.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
exportAs: 'sharedFilterInputGroupMain',
})
export class FilterInputGroupMainComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild(UiAutocompleteComponent, { read: UiAutocompleteComponent, static: false })
autocompleteComponent: UiAutocompleteComponent;
@Output()
queryChange = new EventEmitter<string>();
@Output()
search = new EventEmitter<string>();
@Input()
loading: boolean;
@Input()
hint: string;
@Input()
showDescription: boolean = true;
@Input()
scanner = false;
private _inputGroup: InputGroup;
@Input()
set inputGroup(value: IInputGroup) {
if (value instanceof InputGroup) {
this._inputGroup = value;
} else {
this._inputGroup = InputGroup.create(value);
}
this.subscribeChanges();
this.initAutocomplete();
}
get uiInput(): FilterInput {
return this._inputGroup?.input?.find((f) => f);
}
private _autocompleteProvider: FilterAutocompleteProvider;
get autocompleteProvider() {
return this._autocompleteProvider;
}
autocompleteResults$: Observable<FilterAutocomplete[]>;
complete = new Subject<string>();
private _cancelComplete = new Subject<void>();
private changeSubscriptions: Subscription;
constructor(
@Inject(FilterAutocompleteProvider) @Optional() private autocompleteProviders: FilterAutocompleteProvider[],
private cdr: ChangeDetectorRef
) {}
// cancle autocomplete
cancelAutocomplete() {
this._cancelComplete.next();
}
ngOnInit() {
this._autocompleteProvider = this.autocompleteProviders?.find((provider) => !!provider);
}
onQueryChange(query: string) {
this.uiInput?.setValue(query);
this.queryChange.emit(query);
}
ngAfterViewInit() {
this.initAutocomplete();
}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
const sub = this.uiInput?.changes?.pipe(filter((changes) => changes?.keys.includes('value'))).subscribe(() => {
this.cdr.markForCheck();
});
if (sub) {
this.changeSubscriptions.add(sub);
}
}
unsubscribeChanges() {
this.changeSubscriptions?.unsubscribe();
this.changeSubscriptions = new Subscription();
}
initAutocomplete() {
this.autocompleteResults$ = this.complete.asObservable().pipe(
debounceTime(this._debounceTimeAutocompleteMilliseconds()),
distinctUntilChanged(),
switchMap(() => this.autocompleteProvider.complete(this.uiInput).pipe(takeUntil(this._cancelComplete))),
tap((complete) => {
if (complete?.length > 0) {
this.autocompleteComponent.open();
} else {
this.autocompleteComponent.close();
}
})
);
}
setAutocompleteProvider(provider: FilterAutocompleteProvider) {
this._autocompleteProvider = provider;
}
private _debounceTimeAutocompleteMilliseconds(): number {
if (!this.autocompleteProvider) {
return;
}
let debounceTimeMilliseconds: number;
switch (this.autocompleteProvider.for) {
case 'catalog':
debounceTimeMilliseconds = 250;
break;
case 'goods-in':
debounceTimeMilliseconds = 300;
break;
case 'goods-out':
debounceTimeMilliseconds = 300;
break;
default:
debounceTimeMilliseconds = 300;
break;
}
return debounceTimeMilliseconds;
}
emitSearch(query: string) {
setTimeout(() => {
this.search.emit(query);
}, 1);
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputGroupMainComponent } from './filter-input-group-main.component';
import { UiAutocompleteModule } from '@ui/autocomplete';
import { UiSearchboxNextModule } from '@ui/searchbox';
import { UiTooltipModule } from 'apps/ui/tooltip/src/public-api';
import { UiCommonModule } from '@ui/common';
@NgModule({
imports: [CommonModule, UiCommonModule, UiSearchboxNextModule, UiAutocompleteModule, UiTooltipModule],
exports: [FilterInputGroupMainComponent],
declarations: [FilterInputGroupMainComponent],
})
export class FilterInputGroupMainModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-group-main.component';
export * from './filter-input-group-main.module';
// end:ng42.barrel

View File

@@ -0,0 +1,3 @@
export * from './filter-filter-group-filter';
export * from './filter-filter-group-main';
export * from './filter-input-group-main';

View File

@@ -0,0 +1,10 @@
<ui-filter-filter-group-main [inputGroup]="uiFilter?.filter | group: 'main'"></ui-filter-filter-group-main>
<ui-filter-input-group-main
*ngIf="uiFilter?.input | group: 'main'; let inputGroupMain"
[inputGroup]="inputGroupMain"
(search)="emitSearch($event)"
[loading]="loading"
[hint]="hint"
[scanner]="scanner"
></ui-filter-input-group-main>
<ui-filter-filter-group-filter [inputGroup]="uiFilter?.filter | group: 'filter'"></ui-filter-filter-group-filter>

View File

@@ -0,0 +1,8 @@
:host {
@apply grid grid-flow-row gap-8;
}
ui-filter-input-group-main {
@apply mx-auto w-full;
max-width: 600px;
}

View File

@@ -0,0 +1,107 @@
import { DOCUMENT } from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
Input,
OnChanges,
SimpleChanges,
Output,
EventEmitter,
ElementRef,
Renderer2,
Inject,
AfterViewInit,
HostListener,
ViewChild,
ContentChildren,
QueryList,
} from '@angular/core';
import { FilterInputGroupMainComponent } from './filter-group/filter-input-group-main';
import { FilterCustomInputDirective } from './shared/filter-input';
import { IFilter, Filter } from './tree/filter';
@Component({
selector: 'shared-filter',
templateUrl: 'filter.component.html',
styleUrls: ['filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterComponent implements OnChanges, AfterViewInit {
@Input()
loading: boolean;
@Input()
hint: string;
@Output()
search = new EventEmitter<string>();
@Input()
filter: IFilter;
@Input()
resizeInputOptionsToElement: string | HTMLElement;
@Input()
scanner = false;
@ViewChild(FilterInputGroupMainComponent)
filterInputGroupMainComponent: FilterInputGroupMainComponent;
@ContentChildren(FilterCustomInputDirective)
customInputs: QueryList<FilterCustomInputDirective>;
get uiFilter() {
return this.filter instanceof Filter && this.filter;
}
constructor(private elementRef: ElementRef, private renderer: Renderer2, @Inject(DOCUMENT) private document: Document) {}
ngOnChanges({ filter }: SimpleChanges): void {
if (filter) {
if (!(this.filter instanceof Filter)) {
this.filter = Filter.create(this.filter);
}
}
}
ngAfterViewInit() {
setTimeout(() => this.resizeInputOptions(), 500);
}
getHtmlElement(element: string | HTMLElement): HTMLElement {
return typeof element === 'string' ? this.document.querySelector(element) : element;
}
@HostListener('window:resize', ['$event'])
@HostListener('window:click', ['$event'])
resizeInputOptions() {
if (this.resizeInputOptionsToElement) {
const inputOptions = this.elementRef.nativeElement.querySelector('.input-options');
const targetElement = this.getHtmlElement(this.resizeInputOptionsToElement);
// set the max-height of the input-options to the height of the actions, depending on the distance between the two
if (inputOptions && targetElement) {
const distanceBetweenElements = this.getDistanceBetweenElements(inputOptions, targetElement) - 15;
this.renderer.setStyle(inputOptions, 'max-height', `${distanceBetweenElements}px`);
}
}
}
getDistanceBetweenElements(element1: HTMLElement, element2: HTMLElement) {
const rect1 = element1.getBoundingClientRect();
const rect2 = element2.getBoundingClientRect();
return Math.abs(rect1.top - rect2.top);
}
emitSearch(query: string) {
setTimeout(() => {
this.search.emit(query);
}, 1);
}
cancelAutocomplete() {
this.filterInputGroupMainComponent?.cancelAutocomplete();
}
}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterComponent } from './filter.component';
import { InputGroupSelectorPipe } from './pipe';
import { FilterInputGroupMainModule } from './filter-group/filter-input-group-main';
import { FilterFilterGroupMainModule } from './filter-group/filter-filter-group-main';
import { FilterFilterGroupFilterModule } from './filter-group/filter-filter-group-filter';
import { FilterInputModule } from './shared/filter-input';
@NgModule({
imports: [CommonModule, FilterInputGroupMainModule, FilterFilterGroupMainModule, FilterFilterGroupFilterModule, FilterInputModule],
exports: [
FilterComponent,
InputGroupSelectorPipe,
FilterInputGroupMainModule,
FilterFilterGroupMainModule,
FilterFilterGroupFilterModule,
FilterInputModule,
],
declarations: [FilterComponent, InputGroupSelectorPipe],
})
export class FilterNextModule {}

View File

@@ -0,0 +1,9 @@
export * from './filter-group';
export * from './order-by-filter';
export * from './pipe';
export * from './providers';
export * from './shared';
export * from './testing';
export * from './tree';
export * from './ui-filter.component';
export * from './ui-filter.module';

View File

@@ -0,0 +1,2 @@
export * from './order-by-filter.component';
export * from './order-by-filter.module';

View File

@@ -0,0 +1,19 @@
<div class="order-by-filter-button-wrapper">
<button
[attr.data-label]="label"
class="order-by-filter-button"
type="button"
*ngFor="let label of orderByKeys"
(click)="setActive(label)"
>
<span>
{{ label }}
</span>
<ui-icon
[class.asc]="label === activeOrderBy?.label && !activeOrderBy.desc"
[class.desc]="label === activeOrderBy?.label && activeOrderBy.desc"
icon="arrow"
size="14px"
></ui-icon>
</button>
</div>

View File

@@ -0,0 +1,54 @@
:host {
@apply box-border flex justify-center items-center;
min-height: 30px;
}
.order-by-filter-button-wrapper {
@apply flex flex-row items-center justify-center;
}
.order-by-filter-button {
@apply bg-transparent outline-none border-none text-regular font-bold flex flex-row justify-center items-center m-0 py-2 px-2;
}
::ng-deep .tablet ui-order-by-filter .order-by-filter-button {
@apply mx-0;
}
ui-icon {
@apply hidden transform ml-2 rounded-full p-1;
transition: 250ms all ease-in-out;
}
ui-icon.asc,
ui-icon.desc {
@apply flex rounded-full visible text-white;
}
ui-icon.asc {
@apply -rotate-90;
}
ui-icon.desc {
@apply rotate-90;
}
::ng-deep .customer ui-order-by-filter {
ui-icon {
@apply bg-active-customer;
}
.order-by-filter-button {
@apply text-active-customer;
}
}
::ng-deep .branch ui-order-by-filter {
ui-icon {
@apply bg-active-branch;
}
.order-by-filter-button {
@apply text-active-branch;
}
}

View File

@@ -0,0 +1,51 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, Output, EventEmitter, OnInit } from '@angular/core';
import { UiOrderBy } from '@ui/filter';
@Component({
selector: 'shared-order-by-filter',
templateUrl: 'order-by-filter.component.html',
styleUrls: ['order-by-filter.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OrderByFilterComponent implements OnInit {
@Input()
orderBy: UiOrderBy[];
@Output()
selectedOrderByChange = new EventEmitter<UiOrderBy>();
get orderByKeys() {
return this.orderBy?.map((ob) => ob.label).filter((key, idx, self) => self.indexOf(key) === idx);
}
get activeOrderBy() {
return this.orderBy?.find((f) => f.selected);
}
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
setTimeout(() => this.cdr.markForCheck(), 0);
}
setActive(orderBy: string) {
const active = this.activeOrderBy;
const orderBys = this.orderBy?.filter((f) => f.label === orderBy);
let next: UiOrderBy;
if (orderBys?.length) {
if (active?.label !== orderBy) {
next = orderBys?.find((f) => !f.desc);
} else if (!active.desc) {
next = orderBys?.find((f) => f.desc);
}
}
this.orderBy?.filter((f) => f.selected)?.forEach((f) => f.setSelected(false));
next?.setSelected(true);
this.selectedOrderByChange.next(next);
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { UiIconModule } from '@ui/icon';
import { OrderByFilterComponent } from './order-by-filter.component';
@NgModule({
imports: [CommonModule, UiIconModule],
exports: [OrderByFilterComponent],
declarations: [OrderByFilterComponent],
providers: [],
})
export class OrderByFilterModule {}

View File

@@ -0,0 +1 @@
export * from './input-group-selector.pipe';

View File

@@ -0,0 +1,17 @@
import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator';
import { IInputGroup } from '../tree';
import { InputGroupSelectorPipe } from './input-group-selector.pipe';
describe('InputGroupSelectorPipe', () => {
let spectator: SpectatorPipe<InputGroupSelectorPipe>;
const createPipe = createPipeFactory(InputGroupSelectorPipe);
const inputGroups: IInputGroup[] = [{ group: 'group1' }, { group: 'group2' }, { group: 'group3' }];
it('should return the IUiInputGroup with group to be unittest', () => {
spectator = createPipe(`{{ (value | group:group)?.group }}`, {
hostProps: { value: inputGroups, group: 'group2' },
});
expect(spectator.element).toHaveText('group2');
});
});

View File

@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
import { IInputGroup } from '../tree';
@Pipe({
name: 'group',
})
export class InputGroupSelectorPipe implements PipeTransform {
transform(value: IInputGroup[], group: string): any {
return value?.find((f) => f?.group === group);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { FilterInput } from '../tree';
export interface FilterAutocomplete {
/**
* Anzeige / Bezeichner
*/
display?: string;
/**
* Id
*/
id?: string;
/**
* Abfragewert
*/
query?: string;
/**
* Art (z.B. Titel, Autor, Verlag, ...)
*/
type?: string;
}
@Injectable()
export abstract class FilterAutocompleteProvider {
abstract readonly for: string;
abstract complete(input: FilterInput): Observable<FilterAutocomplete[]>;
}

View File

@@ -0,0 +1 @@
export * from './filter-autocomplete.provider';

View File

@@ -0,0 +1,30 @@
<button
*ngIf="!uiInput?.hasOptions()"
type="button"
class="ui-filter-chip"
[class.selected]="uiInput?.selected"
(click)="uiInput?.setSelected(!uiInput.selected)"
[attr.data-label]="uiInput?.label"
[attr.data-value]="uiInput?.value"
[attr.data-key]="uiInput?.key"
[attr.data-type]="uiInput?.type"
[attr.data-selected]="uiInput?.selected"
>
{{ uiInput?.label }}
</button>
<ng-container *ngIf="uiInput?.hasOptions()">
<button
*ngFor="let option of uiInput?.options?.values"
type="button"
class="ui-filter-chip"
[class.selected]="option?.selected"
(click)="option?.setSelected(!option.selected)"
[attr.data-label]="uiInput?.label"
[attr.data-value]="uiInput?.value"
[attr.data-key]="uiInput?.key"
[attr.data-type]="uiInput?.type"
[attr.data-selected]="uiInput?.selected"
>
{{ option?.label }}
</button>
</ng-container>

View File

@@ -0,0 +1,21 @@
:host {
@apply block;
}
button.ui-filter-chip {
@apply grid grid-flow-col gap-2 items-center rounded-full text-base px-4 py-3 bg-white text-inactive-customer border-none font-bold;
}
/** styling branch bereich **/
::ng-deep .branch ui-filter-input-chip button.ui-filter-chip {
@apply text-inactive-branch;
}
button.ui-filter-chip.selected {
@apply bg-active-customer text-white;
}
/** styling branch bereich **/
::ng-deep .branch ui-filter-input-chip button.ui-filter-chip.selected {
@apply bg-active-branch;
}

View File

@@ -0,0 +1,72 @@
// import { createComponentFactory, Spectator } from '@ngneat/spectator';
// import { UiIconModule } from '@ui/icon';
// import { UiInput, UiInputGroup, UiInputType } from '../../tree';
// import { UiFilterInputChipComponent } from './filter-input-chip.component';
// describe('UiFilterInputChipComponent', () => {
// let spectator: Spectator<UiFilterInputChipComponent>;
// const createComponent = createComponentFactory({
// component: UiFilterInputChipComponent,
// imports: [UiIconModule],
// });
// beforeEach(() => {
// spectator = createComponent();
// });
// it('should create', () => {
// expect(spectator.component).toBeTruthy();
// });
// describe('input', () => {
// it('should create an instance of UiInput if value is a plain object', () => {
// spectator.setInput({
// input: { type: UiInputType.NotSet },
// });
// expect(spectator.component.uiInput instanceof UiInput).toBe(true);
// });
// });
// describe('button.ui-filter-chip Element', () => {
// it('should have the text content of uiInput.label', () => {
// spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ label: 'My Label', type: UiInputType.NotSet }));
// spectator.detectComponentChanges();
// expect(spectator.query('button.ui-filter-chip')).toContainText('My Label');
// });
// it('should have the class selected if uiInput.selected is true', () => {
// spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ selected: true, type: UiInputType.NotSet }));
// spectator.detectComponentChanges();
// expect(spectator.query('button.ui-filter-chip')).toHaveClass('selected');
// });
// it('should not have the class selected if uiInput.selected is true', () => {
// spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ selected: false, type: UiInputType.NotSet }));
// spectator.detectComponentChanges();
// expect(spectator.query('button.ui-filter-chip')).not.toHaveClass('selected');
// });
// it('should call uiInput.setSelected(!uiInput.selected) on click', () => {
// spyOnProperty(spectator.component, 'uiInput', 'get').and.returnValue(UiInput.create({ type: UiInputType.NotSet }));
// spyOn(spectator.component.uiInput, 'setSelected');
// spectator.detectComponentChanges();
// spectator.click('button.ui-filter-chip');
// expect(spectator.component.uiInput.setSelected).toHaveBeenCalledWith(!spectator.component.uiInput.selected);
// });
// });
// describe('ui-icon Element', () => {
// it('should not be visible if selected is false', () => {
// spectator.setInput({ input: { type: 0, selected: false } });
// spectator.detectComponentChanges();
// expect(spectator.query('ui-icon')).not.toBeVisible();
// });
// it('should be visible if selected is true', () => {
// spectator.setInput({ input: { type: 0, selected: true } });
// spectator.detectComponentChanges();
// expect(spectator.query('ui-icon')).toBeVisible();
// });
// });
// });

View File

@@ -0,0 +1,50 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { merge, Subscription } from 'rxjs';
import { IInput, FilterInput } from '../../tree';
@Component({
selector: 'shared-filter-input-chip',
templateUrl: 'filter-input-chip.component.html',
styleUrls: ['filter-input-chip.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputChipComponent implements OnDestroy {
private _input: FilterInput;
@Input()
set input(value: IInput) {
if (value instanceof FilterInput) {
this._input = value;
} else {
this._input = FilterInput.create(value);
}
this.registerChanges();
}
get uiInput() {
return this._input;
}
private changeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unregisterChanges();
}
registerChanges() {
this.unregisterChanges();
if (this.uiInput) {
merge(this.uiInput.changes, ...(this.uiInput?.options?.values?.map((o) => o.changes) || [])).subscribe(() => {
this.cdr.markForCheck();
});
}
}
unregisterChanges() {
this.changeSubscription?.unsubscribe();
this.changeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputChipComponent } from './filter-input-chip.component';
@NgModule({
imports: [CommonModule],
exports: [FilterInputChipComponent],
declarations: [FilterInputChipComponent],
})
export class FilterInputChipModule {}

View File

@@ -0,0 +1,2 @@
export * from './filter-input-chip.component';
export * from './filter-input-chip.module';

View File

@@ -0,0 +1,24 @@
<div
class="ui-option"
[attr.data-label]="uiOption?.label"
[attr.data-value]="uiOption?.value"
[attr.data-key]="uiOption?.key"
[attr.data-selected]="uiOption?.selected"
[attr.data-target]="uiOption?.expanded"
>
<ui-checkbox [ngModel]="uiOption?.selected" (ngModelChange)="uiOption?.setSelected($event)" [indeterminate]="hasPartiallyCheckedChildren">
{{ uiOption?.label }}
</ui-checkbox>
<button
class="btn-expand"
(click)="uiOption.setExpanded(!uiOption?.expanded)"
[class.expanded]="uiOption?.expanded"
type="button"
*ngIf="uiOption?.values?.length"
>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ng-container *ngIf="uiOption?.expanded">
<ui-input-option-bool class="ml-10" *ngFor="let subOption of uiOption?.values" [option]="subOption"></ui-input-option-bool>
</ng-container>

View File

@@ -0,0 +1,23 @@
:host {
@apply grid grid-flow-row;
}
.ui-option {
@apply px-4 pt-2 pb-4 flex flex-row justify-between items-center;
.btn-expand {
@apply border-none outline-none bg-transparent text-cool-grey;
ui-icon {
@apply transition-all transform rotate-90;
}
}
.btn-expand.expanded ui-icon {
@apply -rotate-90;
}
}
::ng-deep ui-input-option-bool ui-checkbox ui-icon {
@apply text-cool-grey;
}

View File

@@ -0,0 +1,22 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { FilterInputOptionBoolComponent } from './filter-input-option-bool.component';
import { MockComponent } from 'ng-mocks';
import { UiCheckboxComponent } from '@ui/checkbox';
import { FormsModule } from '@angular/forms';
describe('UiFilterInputOptionBoolComponent', () => {
let spectator: Spectator<FilterInputOptionBoolComponent>;
const createComponent = createComponentFactory({
imports: [FormsModule],
component: FilterInputOptionBoolComponent,
declarations: [MockComponent(UiCheckboxComponent)],
});
beforeEach(() => {
spectator = createComponent();
});
it('should create', () => {
expect(spectator.component).toBeDefined();
});
});

View File

@@ -0,0 +1,59 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef, OnDestroy, Output, EventEmitter } from '@angular/core';
import { Subscription } from 'rxjs';
import { IOption, Option } from '../../../tree';
@Component({
selector: 'shared-input-option-bool',
templateUrl: 'filter-input-option-bool.component.html',
styleUrls: ['filter-input-option-bool.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputOptionBoolComponent implements OnDestroy {
private _option: Option;
@Input()
set option(value: IOption) {
if (value instanceof Option) {
this._option = value;
} else {
this._option = Option.create(value);
}
this.subscribeChanges();
}
@Output() optionChange = new EventEmitter<Option>();
get uiOption() {
return this._option;
}
optionChangeSubscription = new Subscription();
get hasPartiallyCheckedChildren() {
const options = this.uiOption?.values ?? [];
return !options.every((option) => option.selected) && options.some((option) => option.selected);
}
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiOption) {
this.optionChangeSubscription.add(
this.uiOption.changes.subscribe((change) => {
this.cdr.markForCheck();
this.optionChange.next(change.target);
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputOptionBoolComponent } from './filter-input-option-bool.component';
import { UiCheckboxModule } from '@ui/checkbox';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
@NgModule({
imports: [CommonModule, FormsModule, UiCheckboxModule, UiIconModule],
exports: [FilterInputOptionBoolComponent],
declarations: [FilterInputOptionBoolComponent],
})
export class FilterInputOptionBoolModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-bool.component';
export * from './filter-input-option-bool.module';
// end:ng42.barrel

View File

@@ -0,0 +1,70 @@
<div class="options-wrapper">
<div
*ngIf="uiStartOption"
class="option"
[attr.data-label]="uiStartOption?.label"
[attr.data-value]="uiStartOption?.value"
[attr.data-key]="uiStartOption?.key"
[attr.data-selected]="uiStartOption?.selected"
>
<div class="option-wrapper">
<span> {{ uiStartOption?.label }}: </span>
<button
class="cta-picker"
[class.open]="dpStartTrigger?.opened"
[uiOverlayTrigger]="dpStart"
#dpStartTrigger="uiOverlayTrigger"
type="button"
>
<span>
{{ uiStartOption?.value | date: 'dd.MM.yy' }}
</span>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ui-datepicker
class="dp-left"
#dpStart
yPosition="below"
xPosition="after"
[ngModel]="uiStartOption?.value"
saveLabel="Übernehmen"
(save)="uiStartOption?.setValue($event)"
>
</ui-datepicker>
</div>
<div
*ngIf="uiStopOption"
class="option"
[attr.data-label]="uiStopOption?.label"
[attr.data-value]="uiStopOption?.value"
[attr.data-key]="uiStopOption?.key"
[attr.data-selected]="uiStopOption?.selected"
>
<div class="option-wrapper">
<span> {{ uiStopOption?.label }}: </span>
<button
class="cta-picker"
[class.open]="dpStopTrigger?.opened"
[uiOverlayTrigger]="dpStop"
#dpStopTrigger="uiOverlayTrigger"
type="button"
>
<span>
{{ uiStopOptionValue | date: 'dd.MM.yy' }}
</span>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ui-datepicker
class="dp-right"
yPosition="below"
xPosition="after"
#dpStop
[ngModel]="uiStopOptionValue"
(save)="setStopValue($event)"
saveLabel="Übernehmen"
>
</ui-datepicker>
</div>
</div>

View File

@@ -0,0 +1,41 @@
:host {
@apply block p-4;
}
.options-wrapper {
@apply grid grid-flow-col justify-start gap-4;
}
.option {
@apply font-bold;
button.cta-picker {
@apply bg-transparent text-base outline-none border-none font-bold inline-flex flex-row items-center justify-between;
width: 100px;
}
ui-icon {
@apply ml-2 transition transform rotate-90 text-cool-grey;
}
button.cta-picker.open ui-icon {
@apply -rotate-90;
}
}
.option-wrapper {
@apply grid grid-flow-col gap-2 items-center;
min-height: 24px;
}
::ng-deep ui-input-option-date-range ui-datepicker.dp-left {
.dp {
left: 102px;
}
}
::ng-deep ui-input-option-date-range ui-datepicker.dp-right {
.dp {
right: -6px;
}
}

View File

@@ -0,0 +1,74 @@
// import { createComponentFactory, Spectator } from '@ngneat/spectator';
// import { UiInput, UiInputType, UiOption } from '../../../tree';
// import { UiFilterInputOptionDateRangeComponent } from './filter-input-option-date-range.component';
// describe('UiFilterInputOptionDateRangeComponent', () => {
// let spectator: Spectator<UiFilterInputOptionDateRangeComponent>;
// const createComponent = createComponentFactory({
// component: UiFilterInputOptionDateRangeComponent,
// });
// beforeEach(() => {
// spectator = createComponent();
// });
// it('should create', () => {
// expect(spectator.component).toBeTruthy();
// });
// describe('set options(value: IUiOption[])', () => {
// it('should create an instance of UiOption when values are not an instance of UiOption', () => {
// spectator.setInput({
// options: [{ key: 'start' }, { key: 'stop' }],
// });
// spectator.component['_options'].forEach((uiOption) => {
// expect(uiOption instanceof UiOption).toBe(true);
// });
// });
// it('should set the values when vales are an isntance of UiOption', () => {
// const option1 = UiOption.create({ key: 'start' });
// const option2 = UiOption.create({ key: 'stop' });
// spectator.setInput({
// options: [option1, option2],
// });
// expect(spectator.component['_options'][0]).toBe(option1);
// expect(spectator.component['_options'][1]).toBe(option2);
// });
// });
// describe('get uiOptions()', () => {
// it('should return the value of _options', () => {
// const option1 = UiOption.create({ key: 'start' });
// const option2 = UiOption.create({ key: 'stop' });
// const options = [option1, option2];
// spectator.component['_options'] = options;
// expect(spectator.component.uiOptions).toBe(options);
// });
// });
// describe('get uiStartOption()', () => {
// it('should retun the option with the key start', () => {
// const option1 = UiOption.create({ key: 'start' });
// const option2 = UiOption.create({ key: 'stop' });
// const options = [option1, option2];
// spectator.component['_options'] = options;
// expect(spectator.component.uiStartOption).toBe(option1);
// });
// });
// describe('get uiStopOption()', () => {
// it('should return the option with the key stop', () => {
// const option1 = UiOption.create({ key: 'start' });
// const option2 = UiOption.create({ key: 'stop' });
// const options = [option1, option2];
// spectator.component['_options'] = options;
// expect(spectator.component.uiStopOption).toBe(option2);
// });
// });
// });

View File

@@ -0,0 +1,70 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IOption, Option } from '../../../tree';
@Component({
selector: 'shared-input-option-date-range',
templateUrl: 'filter-input-option-date-range.component.html',
styleUrls: ['filter-input-option-date-range.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputOptionDateRangeComponent {
private _options: Option[];
@Input()
set options(value: IOption[]) {
this._options = value?.map((option) => (option instanceof Option ? option : Option.create(option)));
this.subscribeChanges();
}
get uiOptions() {
return this._options;
}
get uiStartOption() {
return this.uiOptions?.find((o) => o.key === 'start');
}
get uiStopOption() {
return this.uiOptions?.find((o) => o.key === 'stop');
}
get uiStopOptionValue() {
const stopDate = new Date(this.uiStopOption?.value);
stopDate?.setDate(stopDate?.getDate() - 1); // to update the view correctly after setStopValue() gets called !
return stopDate?.toJSON();
}
optionChangeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiStartOption) {
this.optionChangeSubscription.add(
this.uiStartOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
if (this.uiStopOption) {
this.optionChangeSubscription.add(
this.uiStopOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription?.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
setStopValue(date: Date) {
const stopDate = date;
stopDate?.setDate(stopDate?.getDate() + 1); // to include the selected stop date !
this.uiStopOption?.setValue(stopDate);
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputOptionDateRangeComponent } from './filter-input-option-date-range.component';
import { UiDatepickerModule } from '@ui/datepicker';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
import { UiCommonModule } from '@ui/common';
@NgModule({
imports: [CommonModule, UiCommonModule, UiDatepickerModule, FormsModule, UiIconModule],
exports: [FilterInputOptionDateRangeComponent],
declarations: [FilterInputOptionDateRangeComponent],
})
export class FilterInputOptionDateRangeModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-date-range.component';
export * from './filter-input-option-date-range.module';
// end:ng42.barrel

View File

@@ -0,0 +1,33 @@
<div class="options-wrapper">
<div
class="option"
*ngIf="uiStartOption"
[attr.data-label]="uiStartOption?.label"
[attr.data-value]="uiStartOption?.value"
[attr.data-key]="uiStartOption?.key"
[attr.data-selected]="uiStartOption?.selected"
>
<div class="option-wrapper">
<ui-form-control [label]="uiStartOption?.label">
<input type="text" [ngModel]="uiStartOption?.value" (ngModelChange)="uiStartOption?.setValue($event)" />
</ui-form-control>
</div>
</div>
<div
class="option"
*ngIf="uiStopOption"
[attr.data-label]="uiStopOption?.label"
[attr.data-value]="uiStopOption?.value"
[attr.data-key]="uiStopOption?.key"
[attr.data-selected]="uiStopOption?.selected"
>
<div class="option-wrapper">
<ui-form-control [label]="uiStopOption?.label">
<input type="text" [ngModel]="uiStopOption?.value" (ngModelChange)="uiStopOption?.setValue($event)" />
</ui-form-control>
</div>
</div>
</div>
<p class="ui-filter-date-range-validation">
{{ uiStartOption?.validate() || uiStopOption?.validate() }}
</p>

View File

@@ -0,0 +1,21 @@
:host {
@apply block p-4;
}
.options-wrapper {
@apply grid grid-flow-col justify-start gap-4;
}
.option-wrapper {
@apply grid grid-flow-col gap-2 items-center;
min-height: 24px;
}
.ui-filter-date-range-validation {
@apply text-brand text-base font-bold;
}
ui-form-control input {
max-width: 200px;
@apply px-0 rounded-none;
}

View File

@@ -0,0 +1,58 @@
import { Component, ChangeDetectionStrategy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IOption, Option } from '../../../tree';
@Component({
selector: 'shared-input-option-number-range',
templateUrl: 'filter-input-option-number-range.component.html',
styleUrls: ['filter-input-option-number-range.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InputOptionNumberRangeComponent {
private _options: Option[];
@Input()
set options(value: IOption[]) {
this._options = value?.map((option) => (option instanceof Option ? option : Option.create(option)));
this.subscribeChanges();
}
get uiOptions() {
return this._options;
}
get uiStartOption() {
return this.uiOptions?.find((o) => o.key === 'start');
}
get uiStopOption() {
return this.uiOptions?.find((o) => o.key === 'stop');
}
optionChangeSubscription: Subscription;
constructor(private cdr: ChangeDetectorRef) {}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiStartOption) {
this.optionChangeSubscription.add(
this.uiStartOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
if (this.uiStopOption) {
this.optionChangeSubscription.add(
this.uiStopOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription?.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { InputOptionNumberRangeComponent } from './filter-input-option-number-range.component';
import { UiFormControlModule } from '@ui/form-control';
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [CommonModule, UiFormControlModule, FormsModule],
exports: [InputOptionNumberRangeComponent],
declarations: [InputOptionNumberRangeComponent],
})
export class InputOptionNumberRangeModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-number-range.component';
export * from './filter-input-option-number-range.module';
// end:ng42.barrel

View File

@@ -0,0 +1,24 @@
<div
class="ui-option"
[attr.data-label]="uiOption?.label"
[attr.data-value]="uiOption?.value"
[attr.data-key]="uiOption?.key"
[attr.data-selected]="uiOption?.selected"
>
<div>
<ui-switch [ngModel]="uiOption?.selected" (ngModelChange)="uiOption?.setSelected($event)" labelOn="mit" labelOff="ohne"> </ui-switch>
{{ uiOption?.label }}
</div>
<button
class="btn-expand"
(click)="uiOption.setExpanded(!uiOption?.expanded)"
[class.expanded]="uiOption?.expanded"
type="button"
*ngIf="uiOption?.values?.length"
>
<ui-icon icon="arrow_head" size="1em"></ui-icon>
</button>
</div>
<ng-container *ngIf="uiOption?.expanded">
<ui-input-option-tri-state *ngFor="let subOption of uiOption?.values" [option]="subOption"></ui-input-option-tri-state>
</ng-container>

View File

@@ -0,0 +1,27 @@
:host {
@apply grid grid-flow-row;
}
ui-switch {
@apply mr-2;
}
.ui-option {
@apply px-4 py-2 flex flex-row justify-between items-center;
.btn-expand {
@apply border-none outline-none bg-transparent text-cool-grey;
ui-icon {
@apply transition-all transform rotate-90;
}
}
.btn-expand.expanded ui-icon {
@apply -rotate-90;
}
}
::ng-deep ui-input-option-bool ui-checkbox ui-icon {
@apply text-cool-grey;
}

View File

@@ -0,0 +1,51 @@
import { Component, ChangeDetectionStrategy, OnDestroy, Input, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IOption, Option } from '../../../tree';
@Component({
selector: 'shared-input-option-tri-state',
templateUrl: 'filter-input-option-tri-state.component.html',
styleUrls: ['filter-input-option-tri-state.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InputOptionTriStateComponent implements OnDestroy {
private _option: Option;
@Input()
set option(value: IOption) {
if (value instanceof Option) {
this._option = value;
} else {
this._option = Option.create(value);
}
this.subscribeChanges();
}
get uiOption() {
return this._option;
}
optionChangeSubscription = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
ngOnDestroy() {
this.unsubscribeChanges();
}
subscribeChanges() {
this.unsubscribeChanges();
if (this.uiOption) {
this.optionChangeSubscription.add(
this.uiOption.changes.subscribe(() => {
this.cdr.markForCheck();
})
);
}
}
unsubscribeChanges() {
this.optionChangeSubscription.unsubscribe();
this.optionChangeSubscription = new Subscription();
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { InputOptionTriStateComponent } from './filter-input-option-tri-state.component';
import { FormsModule } from '@angular/forms';
import { UiIconModule } from '@ui/icon';
import { UiSwitchModule } from '@ui/switch';
@NgModule({
imports: [CommonModule, UiSwitchModule, FormsModule, UiIconModule],
exports: [InputOptionTriStateComponent],
declarations: [InputOptionTriStateComponent],
})
export class InputOptionTriStateModule {}

View File

@@ -0,0 +1,4 @@
// start:ng42.barrel
export * from './filter-input-option-tri-state.component';
export * from './filter-input-option-tri-state.module';
// end:ng42.barrel

View File

@@ -0,0 +1,42 @@
<div class="input-options-wrapper">
<div class="hidden-overflow">
<div class="input-options-header" [class.header-shadow]="scrollPersantage > 0">
<button type="button" (click)="setSelected(undefined)">
Alle entfernen
</button>
<button
type="button"
(click)="setSelected(true)"
*ngIf="!uiInputOptions?.max && (uiInputOptions?.parent?.type === 2 || uiInputOptions?.parent?.type === 4)"
>
Alle auswählen
</button>
</div>
</div>
<div class="input-options-wrapper">
<p class="input-desription">
{{ uiInputOptions?.parent?.description }}
</p>
<ng-container *ngIf="uiInputOptions?.parent?.type === 2 || uiInputOptions?.parent?.type === 4">
<div class="input-options" #inputOptionsConainter (scroll)="markForCheck()">
<ng-container *ngIf="uiInputOptions?.parent?.type === 2">
<ui-input-option-bool
*ngFor="let option of uiInputOptions?.values"
[option]="option"
(optionChange)="optionChange($event)"
></ui-input-option-bool>
</ng-container>
<ng-container *ngIf="uiInputOptions?.parent?.type === 4">
<ui-input-option-tri-state *ngFor="let option of uiInputOptions?.values" [option]="option"> </ui-input-option-tri-state>
</ng-container>
</div>
<button class="cta-scroll" [class.up]="scrollPersantage > 20" *ngIf="scrollable" (click)="scroll(20)">
<ui-icon icon="arrow" size="20px"></ui-icon>
</button>
</ng-container>
<ui-input-option-date-range *ngIf="uiInputOptions?.parent?.type === 128" [options]="uiInputOptions?.values">
</ui-input-option-date-range>
<ui-input-option-number-range *ngIf="uiInputOptions?.parent?.type === 4096" [options]="uiInputOptions?.values">
</ui-input-option-number-range>
</div>
</div>

View File

@@ -0,0 +1,60 @@
:host {
@apply block;
}
.input-desription {
@apply my-0 px-4 font-bold text-base;
}
.input-options-wrapper {
grid-template-rows: auto 1fr;
@apply grid bg-white rounded-card;
}
.hidden-overflow {
@apply overflow-hidden;
}
.input-options-header {
@apply grid grid-flow-col justify-end items-center mb-2;
button {
@apply bg-transparent p-4 text-base outline-none border-none font-semibold text-inactive-customer;
}
&.header-shadow {
@apply shadow-card;
}
}
.input-options-wrapper {
@apply relative;
}
.input-options {
@apply overflow-scroll;
}
.cta-scroll {
@apply absolute bottom-4 right-4 shadow-cta border-none outline-none bg-white w-10 h-10 rounded-full flex flex-row flex-nowrap items-center justify-center text-active-customer;
ui-icon {
@apply transition-transform transform rotate-90;
}
&.up ui-icon {
@apply -rotate-90;
}
}
::ng-deep .branch ui-filter-input-options {
.cta-scroll {
@apply text-cool-grey;
}
.input-options-header {
button {
@apply text-cool-grey;
}
}
}

View File

@@ -0,0 +1,17 @@
// import { createComponentFactory, Spectator } from '@ngneat/spectator';
// import { UiFilterInputOptionsComponent } from './filter-input-options.component';
// describe('UiFilterInputOptionsComponent', () => {
// let spectator: Spectator<UiFilterInputOptionsComponent>;
// const createComponent = createComponentFactory({
// component: UiFilterInputOptionsComponent,
// });
// beforeEach(() => {
// spectator = createComponent();
// });
// it('should create', () => {
// expect(spectator.component).toBeTruthy();
// });
// });

View File

@@ -0,0 +1,93 @@
import { Component, ChangeDetectionStrategy, Input, ViewChild, ElementRef, ChangeDetectorRef } from '@angular/core';
import { Subscription } from 'rxjs';
import { IInputOptions, InputOptions, InputType, Option } from '../../tree';
@Component({
selector: 'shared-filter-input-options',
templateUrl: 'filter-input-options.component.html',
styleUrls: ['filter-input-options.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputOptionsComponent {
@ViewChild('inputOptionsConainter', { read: ElementRef, static: false })
inputOptionsConainter: ElementRef;
get inputOptionsConainterNative(): HTMLElement {
return this.inputOptionsConainter?.nativeElement;
}
private _inputOptions: InputOptions;
@Input()
set inputOptions(value: IInputOptions) {
if (value instanceof InputOptions) {
this._inputOptions = value;
} else {
this._inputOptions = InputOptions.create(value);
}
this.markForCheck();
}
get uiInputOptions() {
return this._inputOptions;
}
get scrollable() {
return this.inputOptionsConainterNative?.scrollHeight > this.inputOptionsConainterNative?.clientHeight;
}
get scrollPersantage() {
const scrollHeight = this.inputOptionsConainterNative?.scrollHeight - this.inputOptionsConainterNative?.clientHeight;
if (scrollHeight === 0) {
return 0;
}
const scrollTop = this.inputOptionsConainterNative?.scrollTop;
return (100 / scrollHeight) * scrollTop;
}
constructor(private cdr: ChangeDetectorRef) {}
optionChange(option: Option) {
if (!this.uiInputOptions?.max || !option.selected) {
return;
}
const max = this.uiInputOptions.max;
const selectedOptions = this.uiInputOptions.values.filter((o) => o.selected);
if (selectedOptions.length > max) {
const optionsToUnselect = selectedOptions.filter((o) => o.label !== option.label);
optionsToUnselect.forEach((option) => option.setSelected(false));
}
}
markForCheck() {
setTimeout(() => this.cdr.markForCheck(), 0);
}
setSelected(value?: boolean) {
this.uiInputOptions.values?.forEach((option) => option.setSelected(value));
}
scroll(percent: number) {
if (this.scrollPersantage > percent) {
this.scrollToTop();
} else {
this.scrollBottom();
}
}
scrollToTop() {
this.inputOptionsConainterNative?.scrollTo({ top: 0, behavior: 'smooth' });
}
scrollBottom() {
this.inputOptionsConainterNative?.scrollTo({
top: this.inputOptionsConainterNative?.scrollHeight - this.inputOptionsConainterNative?.clientHeight,
behavior: 'smooth',
});
}
}

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputOptionsComponent } from './filter-input-options.component';
import { FilterInputOptionBoolModule } from './filter-input-option-bool';
import { UiIconModule } from '@ui/icon';
import { FilterInputOptionDateRangeModule } from './filter-input-option-date-range';
import { InputOptionTriStateModule } from './filter-input-option-tri-state';
import { InputOptionNumberRangeModule } from './filter-input-option-number-range';
@NgModule({
imports: [
CommonModule,
UiIconModule,
FilterInputOptionBoolModule,
FilterInputOptionDateRangeModule,
InputOptionTriStateModule,
InputOptionNumberRangeModule,
],
exports: [FilterInputOptionsComponent],
declarations: [FilterInputOptionsComponent],
})
export class FilterInputOptionsModule {}

View File

@@ -0,0 +1,6 @@
export * from './filter-input-option-bool';
export * from './filter-input-option-date-range';
export * from './filter-input-option-number-range';
export * from './filter-input-option-tri-state';
export * from './filter-input-options.component';
export * from './filter-input-options.module';

View File

@@ -0,0 +1,41 @@
import { Directive, Input } from '@angular/core';
import { Subject } from 'rxjs';
import { IInput, FilterInput } from '../../tree';
@Directive({})
export abstract class AbstractUiFilterInputDirective {
private _input: FilterInput;
@Input()
set input(value: IInput) {
if (value instanceof FilterInput) {
this._input = value;
} else {
this._input = FilterInput.create(value);
}
this._onUiInputChange.next(this._input);
}
get input() {
return this._input;
}
get uiInput() {
return this._input;
}
get value() {
return this.uiInput?.value;
}
protected _onUiInputChange = new Subject<FilterInput>();
onUiInputChange$ = this._onUiInputChange.asObservable();
constructor() {}
setValue(value: string) {
this.uiInput.setValue(value, { emitEvent: true });
}
}

View File

@@ -0,0 +1,18 @@
import { Directive, Input, TemplateRef } from '@angular/core';
import { FilterInput } from '../../tree';
export interface UiInputContext {
$implicit: Input;
}
@Directive({ selector: '[sharedFilterCustomInput]' })
export class FilterCustomInputDirective {
@Input('uiFilterCustomInput')
key: string;
constructor(public templateRef: TemplateRef<UiInputContext>) {}
static ngTemplateContextGuard<T>(dir: FilterCustomInputDirective, ctx: any): ctx is UiInputContext {
return ctx.$implicit instanceof Input;
}
}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core';
import { FilterCustomInputDirective } from './custom-input.directive';
import { FilterInputTextModule } from './input-text';
@NgModule({
imports: [FilterInputTextModule],
declarations: [FilterCustomInputDirective],
exports: [FilterInputTextModule, FilterCustomInputDirective],
})
export class FilterInputModule {}

View File

@@ -0,0 +1,4 @@
export * from './input-text';
export * from './abstract-filter-input.directive';
export * from './custom-input.directive';
export * from './filter-input.module';

View File

@@ -0,0 +1,2 @@
export * from './input-text.component';
export * from './input-text.module';

View File

@@ -0,0 +1,4 @@
<ui-form-control [label]="input?.label" class="w-[200px]">
<input type="text" [formControl]="control" />
</ui-form-control>
<p class="text-brand text-base font-bold" *ngIf="control.errors?.pattern">Ungültige {{ input?.label }}</p>

View File

@@ -0,0 +1,48 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { Subscription } from 'rxjs';
import { AbstractUiFilterInputDirective } from '../abstract-filter-input.directive';
@Component({
selector: 'shared-filter-input-text',
templateUrl: 'input-text.component.html',
styleUrls: ['input-text.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterInputTextComponent extends AbstractUiFilterInputDirective implements OnInit, OnDestroy {
private _subscriptions = new Subscription();
control = new FormControl<string>('');
constructor() {
super();
}
ngOnInit() {
this.control.setValue(this.value);
this.updateValidator();
const onInputChangeSub = this.onUiInputChange$.subscribe((input) => {
if (this.control.value !== input.value) this.control.setValue(input.value);
this.updateValidator();
});
const onControlValueChangeSub = this.control.valueChanges.subscribe((value) => {
if (this.value !== value) this.setValue(value);
});
this._subscriptions.add(onInputChangeSub);
this._subscriptions.add(onControlValueChangeSub);
}
ngOnDestroy() {
this._subscriptions.unsubscribe();
}
updateValidator() {
if (this.input.constraint) {
this.control.setValidators(Validators.pattern(this.input.constraint));
}
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterInputTextComponent } from './input-text.component';
import { ReactiveFormsModule } from '@angular/forms';
import { UiFormControlModule } from '@ui/form-control';
@NgModule({
imports: [CommonModule, ReactiveFormsModule, UiFormControlModule],
exports: [FilterInputTextComponent],
declarations: [FilterInputTextComponent],
})
export class FilterInputTextModule {}

View File

@@ -0,0 +1,3 @@
export * from './filter-input';
export * from './filter-input-chip';
export * from './filter-input-options';

View File

@@ -0,0 +1,3 @@
// start:ng42.barrel
export * from './query-settings.data';
// end:ng42.barrel

View File

@@ -0,0 +1,111 @@
import { IFilter } from '../tree';
export const querySettingsData: IFilter = {
input: [{ group: 'main', input: [{ key: 'qs', placeholder: 'ISBN/EAN, Titel, Kundenname', type: 1, constraint: '^.{2,100}$' }] }],
filter: [
{
group: 'main',
input: [
{ key: 'all_branches', label: 'Alle Filialen', type: 2, target: 'filter' },
{ key: 'customer_name', label: 'Kundenname', type: 2, target: 'input' },
{ key: 'product_contributor', label: 'Autor', type: 2, target: 'input' },
{ key: 'product_name', label: 'Titel', type: 2, target: 'input' },
],
},
{
group: 'filter',
input: [
{
key: 'orderitemprocessingstatus',
label: 'Status',
type: 4,
target: 'filter',
options: {
values: [
{ label: 'abgeholt', value: '256' },
{ label: 'angefragt', value: '524288' },
{ label: 'ans Lager (nicht abgeholt)', value: '262144' },
{ label: 'bestellt', value: '16', selected: true },
{ label: 'derzeit nicht lieferbar', value: '16777216' },
{ label: 'eingetroffen', value: '128' },
{ label: 'Lieferant wird ermittelt', value: '8388608' },
{ label: 'nachbestellt', value: '8192', selected: true },
{ label: 'neu', value: '1' },
{ label: 'nicht lieferbar', value: '4096' },
{ label: 'storniert', value: '1024', selected: true },
{ label: 'storniert (Kunde)', value: '512', selected: true },
{ label: 'storniert (Lieferant)', value: '2048', selected: true },
{ label: 'versendet', value: '64' },
{ label: 'weitergeleitet intern', value: '1048576' },
{ label: 'zugestellt', value: '4194304' },
],
},
},
{
key: 'estimatedshippingdate',
label: 'vsl. Lieferdatum',
description: 'Geben Sie das vsl. Lieferdatum ein, um die Suche zu verfeinern.',
type: 128,
target: 'filter',
options: {
values: [
{ key: 'start', label: 'von' },
{ key: 'stop', label: 'bis' },
],
},
},
{
key: 'supplier',
label: 'Lieferant',
type: 2,
target: 'filter',
options: {
values: [
{ label: 'Dummy', value: 'D' },
{ label: 'Filiale', value: 'F' },
{ label: 'H', value: 'H' },
{ label: 'Hugendubel Digital', value: 'DIG' },
{ label: 'KNV', value: 'K' },
{ label: 'KNV-Ausland', value: 'KA' },
{ label: 'KNV-Import', value: 'G' },
{ label: 'KNV-Import (Baker & Taylor)', value: 'T' },
{ label: 'Libri', value: 'L' },
{ label: 'Libri Verlag', value: 'LV1' },
{ label: 'Libri-Import', value: 'I' },
{ label: 'Libri-Import (Bertrams)', value: 'B' },
{ label: 'Petersen', value: 'P' },
{ label: 'Verlag', value: 'LV3' },
{ label: 'Weltbild', value: 'W' },
{ label: 'ZL', value: 'Z' },
],
},
},
{
key: 'channel',
label: 'Bestellkanal',
type: 2,
target: 'filter',
options: {
values: [
{ label: 'hugendubel.de', value: '8;16' },
{ label: 'Filiale', value: '2' },
{ label: 'HSC', value: '4' },
],
},
},
],
},
],
orderBy: [
{
by: 'by1',
desc: true,
label: 'label1',
},
{
by: 'by2',
desc: false,
label: 'label2',
},
],
};

View File

@@ -0,0 +1,25 @@
import { querySettingsData } from '../testing';
import { Filter } from './filter';
import { InputGroup } from './input-group';
import { OrderBy } from './order-by';
describe('Filter', () => {
const testData = querySettingsData;
let uiFilter: Filter;
beforeEach(() => {
spyOn(InputGroup, 'create');
spyOn(OrderBy, 'create');
uiFilter = Filter.create(testData);
});
describe('static create(inputOptions: IUiQuerySettingsDTO)', () => {
it('should return an instance of UiInputDTO', () => {
expect(uiFilter instanceof Filter).toBe(true);
testData.filter.forEach((filter) => expect(InputGroup.create).toHaveBeenCalled());
testData.input.forEach((input) => expect(InputGroup.create).toHaveBeenCalled());
testData.orderBy.forEach((orderBy) => expect(OrderBy.create).toHaveBeenCalled());
});
});
});

View File

@@ -0,0 +1,219 @@
import { QueryTokenDTO } from '@swagger/oms';
import { Subject } from 'rxjs';
import { IInputGroup, InputGroup } from './input-group';
import { InputType } from './input-type.enum';
import { IOrderBy, OrderBy } from './order-by';
const ALLOWED_SUB_OPTIONS = [InputType.Bool, InputType.TriState];
export interface IFilter {
filter?: Array<IInputGroup>;
input?: Array<IInputGroup>;
orderBy?: Array<IOrderBy>;
}
export class Filter implements IFilter {
//#region implements IUiFilterQuerySettingsDTO
private _filter?: Array<InputGroup>;
get filter() {
return this._filter;
}
private _input?: Array<InputGroup>;
get input() {
return this._input;
}
private _orderBy?: Array<OrderBy>;
get orderBy() {
return this._orderBy;
}
//#endregion
readonly changes = new Subject<{ keys: (keyof IFilter)[]; orderBy: Filter }>();
getQueryToken(): QueryTokenDTO {
const token: QueryTokenDTO = {
input: this.getInputAsStringDictionary(),
filter: this.getFilterAsStringDictionary(),
orderBy: this.orderBy?.filter((ob) => ob.selected).map((ob) => ob.toObject()),
};
return token;
}
getQueryParams(): Record<string, string> {
const params: Record<string, string> = {};
this.input?.forEach((inputGroup) => {
const group = inputGroup.group;
inputGroup.input.forEach((input) => {
const key = input.key;
params[`${group}_${key}`] = input.toStringValue();
});
});
this.filter?.forEach((inputGroup) => {
const group = inputGroup.group;
inputGroup.input.forEach((input) => {
const key = input.key;
const values = inputGroup.input
?.filter((i) => i.key === key)
?.map((i) => i.toStringValue())
?.filter((i) => !!i)
?.join(';');
params[`${group}_${key}`] = values;
});
});
this.orderBy
?.filter((ob) => ob.selected)
.forEach((ob) => {
params[`order_by_${ob.by}`] = ob?.desc ? 'desc' : 'asc';
});
return params;
}
fromQueryParams(params: Record<string, string>) {
const groupKeys = Object.keys(params);
groupKeys.forEach((gk) => {
const group = gk.split('_')[0];
const key = gk.substr(group.length + 1);
this.input
?.filter((ig) => ig.group === group)
.forEach((inputGroup) => {
inputGroup.input
.filter((i) => i.key === key)
.forEach((input) => {
input.fromStringValue(key, params[gk]);
});
});
this.filter
?.filter((ig) => ig.group === group)
.forEach((inputGroup) => {
inputGroup.input
.filter((i) => i.key === key)
.forEach((input, index) => {
let value = params[gk];
if (input.type === InputType.Text && value?.includes(';')) {
const splitted = value?.split(';');
if (splitted?.length >= index) {
value = splitted[index];
}
}
input.fromStringValue(key, value);
});
});
});
this.orderBy?.forEach((ob) => {
const order = params[`order_by_${ob.by}`];
const selected = (order && ob.desc && order === 'desc') || (!ob.desc && order === 'asc');
ob.setSelected(selected);
});
}
getInputAsStringDictionary(): Record<string, string> {
const input: Record<string, string> = {};
this.input?.forEach((ig) => {
const group = ig.group;
const inputWithValue = ig.input.find((i) => i.toStringValue());
if (!inputWithValue) {
return;
}
const inputsWithTheSameGroup = this.filter?.find((f) => f.group === group);
const inputsWithTargetInput = inputsWithTheSameGroup?.input?.filter((ig) => ig.selected && ig.target === 'input');
if (inputsWithTargetInput?.length) {
inputsWithTargetInput.forEach((ifk) => {
input[ifk.key] = inputWithValue.toStringValue();
});
} else {
input[inputWithValue.key] = inputWithValue.value;
}
});
return input;
}
getFilterAsStringDictionary(): Record<string, string> {
const filter: Record<string, string> = {};
for (const inputGroup of this.filter) {
for (const input of inputGroup.input.filter((i) => i.target === 'filter')) {
const subKeys = input.subKeys;
let value = undefined;
let key = undefined;
if (subKeys.length === 0 || !ALLOWED_SUB_OPTIONS.includes(input.type)) {
key = input.key;
value = inputGroup.input
?.filter((i) => i.key === key)
?.map((i) => i.toStringValue())
?.filter((i) => !!i)
?.join(';');
if (!!value) {
filter[key] = value;
}
} else {
for (let subKey of subKeys) {
key = `${input.key}.${subKey}`;
const options = input.options.values.filter((value) => value.key === subKey);
value = options
?.map((i) => i.toStringValue())
?.filter((i) => !!i)
?.join(';');
if (!!value) {
filter[key] = value;
}
}
}
}
}
return filter;
}
// getKeyValue():
toObject(): IFilter {
return {
filter: this.filter?.map((filter) => filter.toObject()),
input: this.input?.map((input) => input.toObject()),
orderBy: this.orderBy?.map((orderBy) => orderBy.toObject()),
};
}
static create(settings: IFilter) {
const target = new Filter();
target._filter = settings?.filter?.map((filter) => InputGroup.create(filter, target)) || [];
target._input = settings?.input?.map((input) => InputGroup.create(input, target)) || [];
target._orderBy = settings?.orderBy?.map((orderBy) => OrderBy.create(orderBy, target)) || [];
return target;
}
static getQueryParamsFromQueryTokenDTO(queryToken: QueryTokenDTO): Record<string, string> {
const queryParams: Record<string, string> = {};
if (queryToken.input?.qs) {
queryParams['main_qs'] = queryToken.input.qs;
}
if (!!queryToken.filter) {
for (const key in queryToken.filter) {
queryParams[`filter_${key}`] = queryToken.filter[key];
}
}
return queryParams;
}
}

View File

@@ -0,0 +1,7 @@
export * from './filter';
export * from './input-group';
export * from './input-options';
export * from './input-type.enum';
export * from './input';
export * from './option';
export * from './order-by';

View File

@@ -0,0 +1,35 @@
import { FilterInput } from './input';
import { IInputGroup, InputGroup } from './input-group';
describe('InputGroup', () => {
const testData: IInputGroup = {
description: 'test description',
group: 'main',
focused: true,
input: [
{ key: 'all_branches', label: 'Alle Filialen', type: 2, target: 'filter' },
{ key: 'customer_name', label: 'Kundenname', type: 2, target: 'input' },
{ key: 'product_contributor', label: 'Autor', type: 2, target: 'input' },
{ key: 'product_name', label: 'Titel', type: 2, target: 'input' },
],
};
let inputGroup: InputGroup;
beforeEach(() => {
spyOn(FilterInput, 'create').and.callThrough();
inputGroup = InputGroup.create(testData);
});
describe('static create(inputGroup: IUiInputGroup)', () => {
it('should create and return an instance of UiInputGroup', () => {
expect(inputGroup instanceof InputGroup).toBe(true);
expect(inputGroup.description).toEqual(testData.description);
expect(inputGroup.group).toEqual(testData.group);
testData.input.forEach((input) => expect(FilterInput.create).toHaveBeenCalledWith(input, inputGroup));
expect(inputGroup.label).toEqual(testData.label);
});
});
});

View File

@@ -0,0 +1,60 @@
import { Subject } from 'rxjs';
import { Filter } from './filter';
import { IInput, FilterInput } from './input';
export interface IInputGroup {
readonly description?: string;
readonly group?: string;
readonly input?: Array<IInput>;
readonly label?: string;
readonly focused?: boolean;
}
export class InputGroup implements IInputGroup {
//#region implements IUiFilterInputGroup
private _description?: string;
get description() {
return this._description;
}
private _group?: string;
get group() {
return this._group;
}
private _input?: Array<FilterInput>;
get input() {
return this._input;
}
private _label?: string;
get label() {
return this._label;
}
//#endregion
readonly changes = new Subject<{ keys: (keyof IInputGroup)[]; target: InputGroup }>();
constructor(public readonly parent?: Filter) {}
toObject(): IInputGroup {
return {
description: this.description,
group: this.group,
input: this.input?.map((input) => input.toObject()),
label: this.label,
};
}
static create(inputGroup: IInputGroup, parent?: Filter) {
const target = new InputGroup(parent);
target._description = inputGroup?.description;
target._group = inputGroup?.group;
target._input = inputGroup?.input?.map((input) => FilterInput.create(input, target));
target._label = inputGroup?.label;
return target;
}
}

View File

@@ -0,0 +1,29 @@
import { IInputOptions, InputOptions } from './input-options';
import { Option } from './option';
describe('InputOptions', () => {
const testData: IInputOptions = {
max: 2,
values: [
{ label: 'abgeholt', value: '256' },
{ label: 'angefragt', value: '524288' },
{ label: 'ans Lager (nicht abgeholt)', value: '262144' },
{ label: 'bestellt', value: '16', selected: true },
],
};
let inputOptions: InputOptions;
beforeEach(() => {
spyOn(Option, 'create');
inputOptions = InputOptions.create(testData);
});
describe('static create(inputOptions: IUiInputOptions)', () => {
it('should return an instance of IUiInputOptions', () => {
expect(inputOptions instanceof InputOptions).toBe(true);
expect(inputOptions.max).toBe(testData.max);
testData.values.forEach((value) => expect(Option.create).toHaveBeenCalledWith(value, inputOptions));
});
});
});

View File

@@ -0,0 +1,50 @@
import { Subject } from 'rxjs';
import { FilterInput } from './input';
import { IOption, Option } from './option';
export interface IInputOptions {
readonly max?: number;
readonly values?: Array<IOption>;
}
export class InputOptions implements IInputOptions {
//#region implements IUiFilterInputOptions
private _max?: number;
get max() {
return this._max;
}
private _values?: Array<Option>;
get values() {
return this._values;
}
//#endregion
readonly changes = new Subject<{ keys: (keyof IInputOptions)[]; inputOptions: InputOptions }>();
constructor(public readonly parent?: FilterInput) {}
getSelectedOptions(): Option[] {
return this.values?.map((o) => o.getSelectedOptions()).reduce((agg, options) => [...agg, ...options], []) || [];
}
getUnselectedOptions(): Option[] {
return this.values?.map((o) => o.getUnselectedOptions()).reduce((agg, options) => [...agg, ...options], []) || [];
}
toObject(): IInputOptions {
return {
max: this.max,
values: this.values?.map((value) => value.toObject()),
};
}
static create(inputOptions: IInputOptions, parent?: FilterInput) {
const target = new InputOptions(parent);
target._max = inputOptions?.max;
target._values = inputOptions?.values?.map((value) => Option.create(value, target));
return target;
}
}

View File

@@ -0,0 +1,19 @@
export enum InputType {
NotSet = 0,
Text = 1,
Bool = 2,
TriState = 4,
InputSelector = 8,
Date = 16,
DateTime = 32,
Time = 64,
DateRange = 128,
DateTimeRange = 256,
TimeRange = 512,
Integer = 1024,
Decimal = 2048,
Number = 3072,
IntegerRange = 4096,
DecimalRange = 8192,
NumberRange = 12288,
}

View File

@@ -0,0 +1,287 @@
import { Subject } from 'rxjs';
import { InputGroup } from './input-group';
import { IInputOptions, InputOptions } from './input-options';
import { InputType } from './input-type.enum';
export interface IInput {
readonly constraint?: string;
readonly description?: string;
readonly key?: string;
readonly label?: string;
readonly maxValue?: string;
readonly minValue?: string;
readonly options?: IInputOptions;
readonly placeholder?: string;
readonly target?: string;
readonly type: InputType;
readonly value?: string;
readonly selected?: boolean;
}
export class FilterInput implements IInput {
//#region implements IUiFilterInputDTO
private _constraint?: string;
get constraint() {
return this._constraint;
}
private _description?: string;
get description() {
return this._description;
}
private _key?: string;
get key() {
return this._key;
}
get subKeys() {
return this._options?.values?.filter((value) => !!value.key).map((value) => value.key) ?? [];
}
private _label?: string;
get label() {
return this._label;
}
private _maxValue?: string;
get maxValue() {
return this._maxValue;
}
private _minValue?: string;
get minValue() {
return this._minValue;
}
private _options?: InputOptions;
get options() {
return this._options;
}
private _placeholder?: string;
get placeholder() {
return this._placeholder;
}
private _target?: string;
get target() {
return this._target;
}
private _type = InputType.NotSet;
get type() {
return this._type;
}
private _value?: string;
get value() {
return this._value;
}
private _selected?: boolean;
get selected() {
if (this.hasOptions()) {
return this.options?.values.filter((s) => s.selected)?.length === this.options?.values?.length;
}
return this._selected;
}
//#endregion
readonly changes = new Subject<{ keys: (keyof IInput)[]; target: FilterInput }>();
private _errors: Record<string, string> | null;
get errors() {
return this._errors;
}
constructor(public readonly parent?: InputGroup) {}
toStringValue() {
switch (this.type) {
case InputType.Bool:
case InputType.InputSelector:
return this.boolToStringValue();
case InputType.Text:
case InputType.Integer:
return this.textToStringValue();
case InputType.DateRange:
return this.dateRangeToStringValue();
case InputType.IntegerRange:
case InputType.NumberRange:
return this.rangeToStringValue();
case InputType.TriState:
return this.triStateToString();
}
}
fromStringValue(key: string, value: string) {
if (this.key === key) {
if (this.type === InputType.Text || this.type === InputType.Integer) {
this.setValue(value);
} else {
if (this.options) {
this.options?.values?.forEach((option) => {
option.trySetFromValue(value);
});
} else {
this.setValue(value);
}
}
}
}
private boolToStringValue() {
if (this.hasOptions()) {
return this.getSelectedOptions()
.map((options) => options.value ?? options.key)
.join(';');
}
return this.selected ? String(this.selected) : undefined;
}
private dateRangeToStringValue() {
if (this.hasOptions()) {
const selected = this.getSelectedOptions();
if (selected?.length) {
const range = selected.reduce((res, option) => {
const cp: Record<string, string> = { ...res };
cp[option.key] = option.value;
return cp;
}, {});
if (range['start'] && range['stop']) {
return `"${range['start']}"-"${range['stop']}"`;
} else if (range['start']) {
return `"${range['start']}"-`;
} else if (range['stop']) {
return `-"${range['stop']}"`;
}
}
}
return this.selected ? this.value : undefined;
}
private rangeToStringValue() {
if (this.hasOptions()) {
const selected = this.getSelectedOptions();
if (selected?.length) {
const range = selected.reduce((res, option) => {
const cp: Record<string, string> = { ...res };
cp[option.key] = option.value;
return cp;
}, {});
if (range['start'] && range['stop']) {
return `${range['start']}-${range['stop']}`;
} else if (range['start']) {
return `${range['start']}-`;
} else if (range['stop']) {
return `-${range['stop']}`;
}
}
}
return this.selected ? this.value : undefined;
}
private textToStringValue() {
if (this.hasOptions()) {
return this.getSelectedOptions()
.map((v) => v.value)
.join(';');
}
return this.selected ? String(this.value) : undefined;
}
private triStateToString() {
if (this.hasOptions()) {
const selected = this.getSelectedOptions()?.map((options) => options.value ?? options.key);
const unselected = this.getUnselectedOptions()?.map((options) => `!${options.value ?? options.key}`);
return [...selected, ...unselected].join(';');
}
}
hasOptions() {
return !!this.options?.values?.length || false;
}
getSelectedOptions() {
return this.options?.getSelectedOptions() || [];
}
getUnselectedOptions() {
return this.options?.getUnselectedOptions() || [];
}
hasSelectedOptions() {
return this.getSelectedOptions().length > 0;
}
hasUnselectedOptions() {
return this.getUnselectedOptions().length > 0;
}
setValue(value: string, { emitEvent } = { emitEvent: true }) {
if (this.value !== value) {
this._value = value;
this.setSelected(!!value, { emitEvent: false });
if (emitEvent) {
this.changes.next({ keys: ['value', 'selected'], target: this });
}
}
}
setSelected(value: boolean, { emitEvent } = { emitEvent: true }) {
if (this.selected !== value) {
this._selected = value;
this.options?.values?.forEach((f) => f.setSelected(value, { emitEvent }));
if (emitEvent) {
this.changes.next({ keys: ['selected'], target: this });
}
}
}
toObject(): IInput {
return {
type: this.type,
constraint: this.constraint,
description: this.description,
key: this.key,
label: this.label,
maxValue: this.maxValue,
minValue: this.minValue,
options: this.options?.toObject(),
placeholder: this.placeholder,
selected: this.selected,
target: this.target,
value: this.value,
};
}
static create(input: IInput, parent?: InputGroup) {
const target = new FilterInput(parent);
target._constraint = input?.constraint;
target._description = input?.description;
target._key = input?.key;
target._label = input?.label;
target._maxValue = input?.maxValue;
target._minValue = input?.minValue;
target._options = input?.options ? InputOptions.create(input.options, target) : undefined;
target._placeholder = input?.placeholder;
target._target = input?.target;
target._type = input?.type;
target._value = input?.value;
target._selected = input?.selected;
return target;
}
}

View File

@@ -0,0 +1,51 @@
import { IOption, Option } from './option';
describe('Option', () => {
describe('static create(inputOptions: IUiOptionDTO)', () => {
const testData: IOption = {
description: 'description',
enabled: false,
key: 'key',
label: 'label',
maxValue: '2',
minValue: '1',
placeholder: 'placeholder',
selected: true,
value: 'value',
values: [
{
description: 'sub description',
enabled: true,
key: 'sub key',
label: 'sub label',
maxValue: 'sub 2',
minValue: 'sub 1',
placeholder: 'sub placeholder',
selected: false,
value: 'sub value',
},
],
};
let uiOption: Option;
beforeEach(() => {
spyOn(Option, 'create').and.callThrough();
uiOption = Option.create(testData);
});
it('should return an instance of UiInputDTO', () => {
expect(uiOption instanceof Option).toBe(true);
expect(uiOption.description).toBe(testData.description);
expect(uiOption.enabled).toBe(testData.enabled);
expect(uiOption.key).toBe(testData.key);
expect(uiOption.label).toBe(testData.label);
expect(uiOption.maxValue).toBe(testData.maxValue);
expect(uiOption.minValue).toBe(testData.minValue);
expect(uiOption.placeholder).toBe(testData.placeholder);
expect(uiOption.value).toBe(testData.value);
testData.values.forEach((value) => expect(Option.create).toHaveBeenCalledWith(value, uiOption));
});
});
});

View File

@@ -0,0 +1,344 @@
import { Subject } from 'rxjs';
import { InputOptions } from './input-options';
import { InputType } from './input-type.enum';
export interface IOption {
description?: string;
enabled?: boolean;
key?: string;
label?: string;
maxValue?: string;
minValue?: string;
placeholder?: string;
selected?: boolean;
value?: string;
values?: Array<IOption>;
expanded?: boolean;
}
export class Option implements IOption {
//#region implements IUiFilterOptionDTO
private _description?: string;
get description() {
return this._description;
}
private _enabled?: boolean;
get enabled() {
return this._enabled;
}
private _key?: string;
get key() {
return this._key;
}
private _label?: string;
get label() {
return this._label;
}
private _maxValue?: string;
get maxValue() {
return this._maxValue;
}
private _minValue?: string;
get minValue() {
return this._minValue;
}
private _placeholder?: string;
get placeholder() {
return this._placeholder;
}
private _selected?: boolean;
get selected() {
if (this.values?.length) {
return this.values.filter((f) => f.selected).length === this.values.length;
}
if (this.getParentInput()?.type === InputType.DateRange) {
return !!this.value;
}
return this._selected;
}
private _value?: string;
get value() {
return this._value;
}
private _values?: Array<Option>;
get values() {
return this._values;
}
private _expanded?: boolean;
get expanded() {
return this._expanded;
}
//#endregion
readonly changes = new Subject<{ keys: (keyof IOption)[]; target: Option }>();
constructor(public readonly parent?: Option | InputOptions) {}
getParentInput() {
return this.getParentInputOptions()?.parent;
}
getParentInputOptions() {
let parent = this.parent;
while (!!parent) {
if (parent instanceof InputOptions) {
return parent;
}
parent = parent.parent;
}
}
hasChildren() {
return !!this.values?.length;
}
getSelectedOptions(includeSelf: boolean = true): Option[] {
const selected: Option[] = [];
if (this.selected && includeSelf) {
selected.push(this);
}
const selectedChildren = this.values?.map((f) => f.getSelectedOptions()).reduce((agg, options) => [...agg, ...options], []);
if (selectedChildren?.length) {
selected.push(...selectedChildren);
}
return selected;
}
getUnselectedOptions(): Option[] {
if (this?.getParentInput()?.type === InputType.TriState && this.selected === false) {
return [this];
} else if (this.hasChildren()) {
return this.values.map((f) => f.getUnselectedOptions()).reduce((agg, options) => [...agg, ...options], []);
}
return [];
}
setSelected(value: boolean, { emitEvent } = { emitEvent: true }) {
this._selected = value;
const type = this.getParentInput()?.type;
if (!value && (type === InputType.DateRange || type === InputType.NumberRange || type === InputType.IntegerRange)) {
this.setValue(undefined, { emitEvent: false });
}
this.values?.forEach((option) => option.setSelected(value));
if (emitEvent) {
this.changes.next({ keys: ['selected', 'value'], target: this });
}
}
setExpanded(value: boolean, { emitEvent } = { emitEvent: true }) {
if (this.expanded !== value) {
this._expanded = value;
if (emitEvent) {
this.changes.next({ keys: ['expanded'], target: this });
}
}
}
setValue(value: string | Date, { emitEvent } = { emitEvent: true }) {
if (value instanceof Date) {
value = value?.toJSON();
}
if (this.value !== value) {
this._value = value;
this.setSelected(!!this.value, { emitEvent: false });
if (emitEvent) {
this.changes.next({ keys: ['value', 'selected'], target: this });
}
}
}
toStringValue() {
switch (this.getParentInput()?.type) {
case InputType.Bool:
case InputType.InputSelector:
return this.boolToStringValue();
}
}
hasOptions() {
return this._values?.length > 0 ?? false;
}
private boolToStringValue() {
if (this.hasOptions()) {
return this.getSelectedOptions(false)
.map((options) => options.value ?? options.key)
.join(';');
}
return this.selected ? String(this.value ?? this.key) : undefined;
}
trySetFromValue(value: string) {
const type = this.getParentInput()?.type;
switch (type) {
case InputType.Bool:
case InputType.InputSelector:
this.trySetBoolFromValue(value);
break;
case InputType.TriState:
this.trySetTriStateFromValue(value);
break;
case InputType.NumberRange:
case InputType.IntegerRange:
this.trySetFromNumberRange(value);
break;
case InputType.DateRange:
this.trySetFromDateRange(value);
break;
}
}
trySetFromNumberRange(value: string) {
const split = value?.split('-');
if (this.key === 'start') {
this.setValue(split[0]);
} else if (this.key === 'stop') {
this.setValue(split[1]);
}
}
trySetFromDateRange(value: string) {
let split: string[] = [];
if (value) {
if (value?.includes('"-"')) {
split = value.split('"-"');
} else if (value?.includes('-"')) {
split = value.split('-"');
} else if (value?.includes('"-')) {
split = value.split('"-');
}
}
if (this.key === 'start') {
if (split[0]) {
split[0] = split[0].replace(new RegExp('"', 'g'), '');
}
this.setValue(split[0]);
} else if (this.key === 'stop') {
if (split[1]) {
split[1] = split[1].replace(new RegExp('"', 'g'), '');
}
this.setValue(split[1]);
}
}
trySetBoolFromValue(value: string) {
const valueSplits = value?.split(';');
if (valueSplits.includes(this.value)) {
this.setSelected(true);
} else {
this.setSelected(undefined);
this.values?.forEach((option) => option?.trySetBoolFromValue(value));
}
}
trySetTriStateFromValue(value: string) {
const valueSplits = value?.split(';');
if (valueSplits.includes(this.value)) {
this.setSelected(true);
} else if (valueSplits.includes(`!${this.value}`)) {
this.setSelected(false);
} else {
this.setSelected(undefined);
}
}
validate(): string | null {
const input = this.getParentInput();
switch (input?.type) {
// NumberRange
case InputType.NumberRange:
case InputType.IntegerRange:
return this.validateNumber() || this.validateNumberRange();
}
return null;
}
validateNumber(): string | null {
if (!Number.isNaN(+this.value)) {
if (!Number.isNaN(+this.minValue) && +this.value < +this.minValue) {
return `Der Wert darf nicht kleiner als ${this.minValue} sein.`;
} else if (!Number.isNaN(+this.maxValue) && +this.value > +this.maxValue) {
return `Der Wert darf nicht größer als ${this.maxValue} sein.`;
} else if (!!this.value && !/^[0-9]+$/.exec(this.value)) {
return `Es werden nur ganzzahlige Werte akzeptiert.`;
}
}
return null;
}
validateNumberRange(): string | null {
if (!Number.isNaN(+this.value)) {
const start = this.parent.values.find((v) => v.key === 'start');
const stop = this.parent.values.find((v) => v.key === 'stop');
if (!Number.isNaN(+start.value) && !Number.isNaN(+stop.value) && +start.value > +stop.value) {
return `Der Wert "${start.value}" darf nicht gößer sein als "${stop.value}"`;
}
}
return null;
}
toObject(): IOption {
return {
description: this.description,
enabled: this.enabled,
expanded: this.expanded,
key: this.key,
label: this.label,
maxValue: this.maxValue,
minValue: this.minValue,
placeholder: this.placeholder,
selected: this.selected,
value: this.value,
values: this.values?.map((value) => value.toObject()),
};
}
static create(option: IOption, parent?: Option | InputOptions) {
const target = new Option(parent);
target._description = option?.description;
target._enabled = option?.enabled;
target._key = option?.key;
target._label = option?.label;
target._maxValue = option?.maxValue;
target._minValue = option?.minValue;
target._placeholder = option?.placeholder;
target._selected = option?.selected;
target._value = option?.value;
target._values = option?.values?.map((value) => Option.create(value, target));
target._expanded = option?.expanded;
return target;
}
}

View File

@@ -0,0 +1,24 @@
import { IOrderBy, OrderBy } from './order-by';
describe('OrderBy', () => {
const testData: IOrderBy = {
by: 'by',
desc: true,
label: 'label',
};
let orderBy: OrderBy;
beforeEach(() => {
orderBy = OrderBy.create(testData);
});
describe('static create(inputOptions: IUiOrderByDTO)', () => {
it('should return an instance of UiInputDTO', () => {
expect(orderBy instanceof OrderBy).toBe(true);
expect(orderBy.by).toEqual(testData.by);
expect(orderBy.desc).toEqual(testData.desc);
expect(orderBy.label).toEqual(testData.label);
});
});
});

View File

@@ -0,0 +1,68 @@
import { Subject } from 'rxjs';
import { Filter } from './filter';
export interface IOrderBy {
by?: string;
desc?: boolean;
label?: string;
selected?: boolean;
}
export class OrderBy implements IOrderBy {
//#region implements IUiFilterOrderByDTO
private _by?: string;
get by() {
return this._by;
}
private _desc?: boolean;
get desc() {
return this._desc;
}
private _label?: string;
get label() {
return this._label;
}
private _selected?: boolean;
get selected() {
return this._selected;
}
//#endregion
constructor(public readonly parent?: Filter) {}
readonly changes = new Subject<{ keys: (keyof IOrderBy)[]; orderBy: OrderBy }>();
setSelected(value: boolean, { emitEvent }: { emitEvent: boolean } = { emitEvent: true }) {
if (this.selected !== value) {
this._selected = value;
if (emitEvent) {
this.changes.next({ keys: ['selected'], orderBy: this });
}
}
}
toObject(): IOrderBy {
return {
by: this.by,
desc: this.desc,
label: this.label,
selected: this.selected,
};
}
static create(orderBy: IOrderBy, parent?: Filter) {
const target = new OrderBy(parent);
target._by = orderBy?.by;
target._desc = orderBy?.desc;
target._label = orderBy?.label;
target._selected = orderBy?.selected;
return target;
}
}

View File

View File

@@ -0,0 +1,6 @@
{
"$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

@@ -0,0 +1,35 @@
<div class="searchbox-input-wrapper">
<input
class="searchbox-input"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
/>
<button tabindex="-1" *ngIf="input.value" class="searchbox-clear-btn" type="button">
<ui-icon icon="close" size="1rem"></ui-icon>
</button>
<ng-container *ngIf="!loading">
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
*ngIf="!canScan"
(click)="emitSearch()"
[disabled]="completeValue !== query"
>
<ui-icon icon="search" size="1.5rem"></ui-icon>
</button>
<button tabindex="0" class="searchbox-scan-btn" type="button" *ngIf="canScan" (click)="startScan()">
<ui-svg-icon icon="barcode-scan" [size]="32"></ui-svg-icon>
</button>
</ng-container>
<div *ngIf="loading" class="searchbox-load-indicator">
<ui-icon icon="spinner" size="32px"></ui-icon>
</div>
</div>
<ng-content select="ui-autocomplete"></ng-content>

View File

@@ -0,0 +1,25 @@
:host {
@apply inline-block bg-surface text-surface-content rounded-button;
box-shadow: 0px 6px 24px rgba(206, 212, 219, 0.8);
}
.searchbox-input-wrapper {
@apply flex flex-row h-14 items-stretch;
}
.searchbox-input {
@apply w-full bg-transparent outline-none pl-4 flex-shrink flex-grow text-lg caret-brand font-bold;
}
.searchbox-search-btn,
.searchbox-scan-btn {
@apply w-14 h-14 bg-brand text-white rounded-button grid items-center justify-center flex-shrink-0 flex-grow-0;
}
.searchbox-clear-btn {
@apply w-14 h-14 bg-transparent text-black rounded-button grid items-center justify-center flex-shrink-0 flex-grow-0;
}
.searchbox-load-indicator {
@apply animate-spin w-14 h-14 grid items-center justify-center flex-shrink-0 flex-grow-0;
}

View File

@@ -0,0 +1,239 @@
import {
Component,
ChangeDetectionStrategy,
Output,
EventEmitter,
Input,
OnDestroy,
AfterViewInit,
ElementRef,
ViewChild,
ContentChild,
ChangeDetectorRef,
HostBinding,
AfterContentInit,
HostListener,
forwardRef,
Optional,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { UiAutocompleteComponent } from '@ui/autocomplete';
import { UiFormControlDirective } from '@ui/form-control';
import { containsElement } from '@utils/common';
import { Subscription } from 'rxjs';
import { ScanAdapterService } from '@adapter/scan';
@Component({
selector: 'shared-searchbox',
templateUrl: 'searchbox.component.html',
styleUrls: ['searchbox.component.scss'],
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SearchboxComponent), multi: true }],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchboxComponent extends UiFormControlDirective<any>
implements AfterViewInit, OnDestroy, AfterContentInit, ControlValueAccessor {
disabled: boolean;
type = 'text';
@ViewChild('input', { read: ElementRef, static: true })
input: ElementRef;
@ContentChild(UiAutocompleteComponent)
autocomplete: UiAutocompleteComponent;
@Input()
focusAfterViewInit: boolean = true;
@Input()
placeholder: string = '';
private _query = '';
@Input()
get query() {
return this._query;
}
set query(value: string) {
this._query = value;
this.completeValue = value;
}
@Output()
queryChange = new EventEmitter<string>();
@Output()
search = new EventEmitter<string>();
completeValue = this.query;
@Output()
complete = new EventEmitter<string>();
@Output()
scan = new EventEmitter<string>();
@Input()
loading = false;
@Input()
scanner = false;
@Input()
hint: string = '';
@Input()
autocompleteValueSelector: (item: any) => string = (item: any) => item;
get valueEmpty(): boolean {
return !!this.query;
}
clear(): void {
this.setQuery('');
}
@HostBinding('class.autocomplete-opend')
get autocompleteOpen() {
return this.autocomplete?.opend;
}
get canScan() {
return !this.query && this.scanner && this.scanAdapterService?.isReady();
}
get canClear() {
return !!this.query;
}
get showHint() {
return !!this.hint;
}
subscriptions = new Subscription();
onChange = (_: any) => {};
onTouched = () => {};
constructor(
private cdr: ChangeDetectorRef,
private elementRef: ElementRef<HTMLElement>,
@Optional() private scanAdapterService: ScanAdapterService
) {
super();
}
writeValue(obj: any): void {
this.setQuery(obj, false);
this.completeValue = obj;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
ngAfterViewInit() {
if (this.focusAfterViewInit) {
this.focus();
}
}
ngAfterContentInit() {
if (this.autocomplete) {
this.subscriptions.add(
this.autocomplete?.selectItem.subscribe((item) => {
this.setQuery(item);
this.search.emit(item);
this.autocomplete?.close();
})
);
}
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
startScan() {
if (!!this.scanAdapterService?.isReady()) {
this.subscriptions.add(
this.scanAdapterService.scan().subscribe((result) => {
this.scan.emit(result);
this.setQuery(result);
this.autocomplete?.close();
})
);
}
}
setQuery(query: string, emitEvent: boolean = true, complete?: boolean) {
this._query = query;
if (emitEvent) {
this.queryChange.emit(query);
this.onChange(query);
this.onTouched();
}
if (complete) {
this.completeValue = query;
this.complete.emit(query);
}
this.cdr.markForCheck();
}
focus() {
this.input?.nativeElement?.focus();
}
clearHint() {
this.hint = '';
this.focused.emit(true);
this.cdr.markForCheck();
}
onKeyup(event: KeyboardEvent) {
if (event.key === 'Enter') {
if (this.autocomplete?.opend && this.autocomplete?.activeItem) {
this.setQuery(this.autocomplete?.activeItem?.item);
this.autocomplete?.close();
}
this.search.emit(this.query);
event.preventDefault();
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
this.handleArrowUpDownEvent(event);
}
}
handleArrowUpDownEvent(event: KeyboardEvent) {
this.autocomplete?.handleKeyboardEvent(event);
if (this.autocomplete?.activeItem) {
const query = this.autocompleteValueSelector(this.autocomplete.activeItem.item);
this.setQuery(query, false, false);
}
}
@HostListener('window:click', ['$event'])
focusLost(event: MouseEvent) {
if (this.autocomplete?.opend && !containsElement(this.elementRef.nativeElement, event.target as Element)) {
this.autocomplete?.close();
}
}
emitSearch() {
this.search.emit(this.query);
}
@HostListener('focusout', ['$event'])
onBlur() {
this.onTouched();
this.focused.emit(false);
this.cdr.markForCheck();
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SearchboxComponent } from './searchbox.component';
import { UiIconModule } from '@ui/icon';
import { FormsModule } from '@angular/forms';
@NgModule({
imports: [CommonModule, UiIconModule, FormsModule],
exports: [SearchboxComponent],
declarations: [SearchboxComponent],
})
export class SearchboxModule {}

View File

@@ -0,0 +1,3 @@
export * from './lib/searchbox.component';
export * from './lib/searchbox.module';
export * from './lib/searchbox.component';