mirror of
https://github.com/valitydev/ng-libs.git
synced 2024-11-06 00:35:21 +00:00
CM-57: Filters, Date Range, Number Range components, Fetch Superclass & many utils ... (#17)
This commit is contained in:
parent
f17246db51
commit
15a486aa12
9
.run/start.run.xml
Normal file
9
.run/start.run.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="start" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="start" />
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
56
package-lock.json
generated
56
package-lock.json
generated
@ -4772,6 +4772,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@s-libs/js-core": {
|
||||
"version": "15.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@s-libs/js-core/-/js-core-15.2.0.tgz",
|
||||
"integrity": "sha512-stGBoiF+4eIB+hB9PLL585/XherRZq2vWYkmEDSb2ZSCK8Ouonf2YKsVPoAZ6WRRxXKtw2u5devcxzd1cQw2yw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@s-libs/micro-dash": "^15.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@s-libs/micro-dash": {
|
||||
"version": "15.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@s-libs/micro-dash/-/micro-dash-15.2.0.tgz",
|
||||
"integrity": "sha512-/5er88CinOv1pJmDu6/+gpGFMX+EKPQFw1tXlk/qr5SM4WgcyH8HsZIvV4DarfvW9mAzOhVbiKNwEj6Lc8sXOw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0",
|
||||
"utility-types": "^3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@s-libs/ng-core": {
|
||||
"version": "15.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@s-libs/ng-core/-/ng-core-15.2.0.tgz",
|
||||
"integrity": "sha512-1ImufP5Tw2ZaLE/aCwWMrL9MeiTrECVYzoFK8FVV+3vnqrIkFQ//4xzAJZWNHFEXbokTK+QmDhG1zK3wHI1JSQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^15.0.0",
|
||||
"@angular/core": "^15.0.0",
|
||||
"@s-libs/js-core": "^15.2.0",
|
||||
"@s-libs/micro-dash": "^15.2.0",
|
||||
"@s-libs/rxjs-core": "^15.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@s-libs/rxjs-core": {
|
||||
"version": "15.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@s-libs/rxjs-core/-/rxjs-core-15.2.0.tgz",
|
||||
"integrity": "sha512-N/v8cQFnkQWHdpq0zCtdapT5skUxLB3wtZGaxLEIn9Wuthsq+dc9gBe1ucOke9dzOeVUeqmYYXTUZuQMpXwBZQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@s-libs/js-core": "^15.2.0",
|
||||
"@s-libs/micro-dash": "^15.2.0",
|
||||
"rxjs": "^7.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "15.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-15.2.7.tgz",
|
||||
@ -15548,7 +15596,6 @@
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.10.0.tgz",
|
||||
"integrity": "sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
@ -16275,8 +16322,12 @@
|
||||
},
|
||||
"projects/ng-core": {
|
||||
"name": "@vality/ng-core",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@s-libs/js-core": "^15.2.0",
|
||||
"@s-libs/micro-dash": "^15.2.0",
|
||||
"@s-libs/ng-core": "^15.2.0",
|
||||
"@s-libs/rxjs-core": "^15.2.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -16289,6 +16340,7 @@
|
||||
"@types/lodash-es": "^4.0.0",
|
||||
"coerce-property": ">=15.0.0",
|
||||
"lodash-es": "^4.0.0",
|
||||
"rxjs": ">=7.0.0",
|
||||
"utility-types": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
|
@ -3,5 +3,6 @@
|
||||
"dest": "../../dist/ng-core",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
},
|
||||
"allowedNonPeerDependencies": ["@s-libs/*"]
|
||||
}
|
||||
|
@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "@vality/ng-core",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@s-libs/js-core": "^15.2.0",
|
||||
"@s-libs/micro-dash": "^15.2.0",
|
||||
"@s-libs/ng-core": "^15.2.0",
|
||||
"@s-libs/rxjs-core": "^15.2.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@ -15,8 +19,8 @@
|
||||
"@types/lodash-es": "^4.0.0",
|
||||
"coerce-property": ">=15.0.0",
|
||||
"lodash-es": "^4.0.0",
|
||||
"utility-types": ">=3.0.0",
|
||||
"rxjs": ">=7.0.0"
|
||||
"rxjs": ">=7.0.0",
|
||||
"utility-types": ">=3.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
@ -1,33 +1,20 @@
|
||||
::ng-deep .v-actions {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
place-content: center space-between;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
|
||||
flex-direction: row;
|
||||
@media screen and (max-width: 959px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
& > * {
|
||||
margin-left: 16px;
|
||||
}
|
||||
& > *:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
& > *:first-child:not(:last-child) {
|
||||
margin-right: auto !important;
|
||||
@media screen and (max-width: 959px) {
|
||||
margin-right: initial !important;
|
||||
}
|
||||
margin-right: auto;
|
||||
}
|
||||
& > *:first-child:last-child {
|
||||
margin-left: auto !important;
|
||||
@media screen and (max-width: 959px) {
|
||||
margin-left: initial !important;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
& > * {
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,22 @@
|
||||
<mat-form-field style="width: 100%">
|
||||
<mat-label>Date range</mat-label>
|
||||
<mat-date-range-input [formGroup]="control" [rangePicker]="picker">
|
||||
<input formControlName="start" matStartDate placeholder="Start date" />
|
||||
<input formControlName="end" matEndDate placeholder="End date" />
|
||||
</mat-date-range-input>
|
||||
<mat-datepicker-toggle [for]="picker" matIconSuffix></mat-datepicker-toggle>
|
||||
<mat-date-range-picker #picker>
|
||||
<mat-date-range-picker-actions>
|
||||
<v-actions style="width: 100%">
|
||||
<button
|
||||
[disabled]="!control.value.start && !control.value.end"
|
||||
mat-button
|
||||
(click)="control.reset(); picker.close()"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button color="primary" mat-flat-button matDateRangePickerApply>Apply</button>
|
||||
</v-actions>
|
||||
</mat-date-range-picker-actions>
|
||||
</mat-date-range-picker>
|
||||
</mat-form-field>
|
@ -0,0 +1,31 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ValidationErrors } from '@angular/forms';
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
import { createControlProviders, ValidatedControlSuperclass } from '../../utils';
|
||||
|
||||
@Component({
|
||||
selector: 'v-date-range-field',
|
||||
templateUrl: './date-range-field.component.html',
|
||||
styleUrls: ['./date-range-field.component.scss'],
|
||||
providers: createControlProviders(() => DateRangeFieldComponent),
|
||||
})
|
||||
export class DateRangeFieldComponent extends ValidatedControlSuperclass<DateRange<Date>> {
|
||||
control = this.fb.group({
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
});
|
||||
|
||||
constructor(private fb: NonNullableFormBuilder) {
|
||||
super();
|
||||
}
|
||||
|
||||
override validate(): ValidationErrors | null {
|
||||
return (
|
||||
super.validate() ??
|
||||
(!this.control.value.start || !this.control.value.end
|
||||
? { oneOfTheDatesIsEmpty: true }
|
||||
: null)
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
import { DateRangeFieldComponent } from './date-range-field.component';
|
||||
import { ActionsModule } from '../actions';
|
||||
|
||||
@NgModule({
|
||||
declarations: [DateRangeFieldComponent],
|
||||
imports: [
|
||||
MatDatepickerModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
ReactiveFormsModule,
|
||||
MatButtonModule,
|
||||
ActionsModule,
|
||||
],
|
||||
exports: [DateRangeFieldComponent],
|
||||
})
|
||||
export class DateRangeFieldModule {}
|
@ -0,0 +1,2 @@
|
||||
export * from './date-range-field.module';
|
||||
export * from './date-range-field.component';
|
@ -7,7 +7,7 @@ export type DialogConfig = Record<'small' | 'medium' | 'large', MatDialogConfig<
|
||||
export const DIALOG_CONFIG = new InjectionToken<DialogConfig>('dialogConfig');
|
||||
|
||||
export const BASE_CONFIG: ValuesType<DialogConfig> = {
|
||||
maxHeight: '90vh',
|
||||
maxHeight: '100vh',
|
||||
disableClose: true,
|
||||
autoFocus: false,
|
||||
width: '552px',
|
||||
|
@ -0,0 +1,5 @@
|
||||
<v-dialog noActions title="Filters">
|
||||
<div [ngClass]="['v-filters-dialog-cols-' + (dialogData.filters.repeat$ | async), 'filters']">
|
||||
<ng-container *ngTemplateOutlet="dialogData.filters.contentTemplate"></ng-container>
|
||||
</div>
|
||||
</v-dialog>
|
@ -0,0 +1,14 @@
|
||||
$max-columns: 5;
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@for $i from 1 through $max-columns {
|
||||
::ng-deep .v-filters-dialog-cols-#{$i} {
|
||||
& > *:nth-child(-n + #{$i * 2}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FiltersDialogComponent } from './filters-dialog.component';
|
||||
|
||||
describe('FiltersDialogComponent', () => {
|
||||
let component: FiltersDialogComponent;
|
||||
let fixture: ComponentFixture<FiltersDialogComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [FiltersDialogComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FiltersDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { DialogSuperclass } from '../../../dialog';
|
||||
import { FiltersComponent } from '../../filters.component';
|
||||
|
||||
@Component({
|
||||
selector: 'v-filters-dialog',
|
||||
templateUrl: './filters-dialog.component.html',
|
||||
styleUrls: ['./filters-dialog.component.scss'],
|
||||
})
|
||||
export class FiltersDialogComponent extends DialogSuperclass<
|
||||
FiltersDialogComponent,
|
||||
{ filters: FiltersComponent }
|
||||
> {}
|
@ -0,0 +1,18 @@
|
||||
<v-actions>
|
||||
<button
|
||||
*ngIf="filters.active && filters.clear.observed"
|
||||
mat-icon-button
|
||||
(click)="filters.clear.emit()"
|
||||
>
|
||||
<mat-icon [matBadge]="filters.active || undefined" matBadgeColor="accent"
|
||||
>restart_alt</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="filters.filtersCount - ((filters.displayedFiltersCount$ | async) ?? 0) > 0"
|
||||
mat-icon-button
|
||||
(click)="filters.open()"
|
||||
>
|
||||
<mat-icon>filter_alt</mat-icon>
|
||||
</button>
|
||||
</v-actions>
|
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MoreFiltersButtonComponent } from './more-filters-button.component';
|
||||
|
||||
describe('MoreFiltersButtonComponent', () => {
|
||||
let component: MoreFiltersButtonComponent;
|
||||
let fixture: ComponentFixture<MoreFiltersButtonComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [MoreFiltersButtonComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MoreFiltersButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,12 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
import { FiltersComponent } from '../../filters.component';
|
||||
|
||||
@Component({
|
||||
selector: 'v-more-filters-button',
|
||||
templateUrl: './more-filters-button.component.html',
|
||||
styleUrls: ['./more-filters-button.component.css'],
|
||||
})
|
||||
export class MoreFiltersButtonComponent {
|
||||
@Input() filters!: FiltersComponent;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
<mat-card>
|
||||
<mat-card-content #content [ngClass]="['v-filters-cols-' + (repeat$ | async), 'filters']">
|
||||
<ng-container *ngTemplateOutlet="contentTemplate"></ng-container>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
@ -0,0 +1,18 @@
|
||||
$max-columns: 5;
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-column-gap: 8px;
|
||||
margin-bottom: -22px;
|
||||
padding: 22px !important;
|
||||
}
|
||||
|
||||
@for $i from 1 through $max-columns {
|
||||
::ng-deep .v-filters-cols-#{$i} {
|
||||
grid-template-columns: repeat($i, 1fr);
|
||||
|
||||
& > *:nth-child(n + #{($i * 2) + 1}) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import {
|
||||
Component,
|
||||
ContentChild,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
import { FiltersDialogComponent } from './components/filters-dialog/filters-dialog.component';
|
||||
import { DialogService } from '../dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'v-filters',
|
||||
templateUrl: './filters.component.html',
|
||||
styleUrls: ['./filters.component.scss'],
|
||||
})
|
||||
export class FiltersComponent {
|
||||
@Input() active = 0;
|
||||
@Output() clear = new EventEmitter<void>();
|
||||
|
||||
@ContentChild(TemplateRef, { static: true }) contentTemplate!: TemplateRef<unknown>;
|
||||
@ViewChild('content') content!: ElementRef<HTMLElement>;
|
||||
|
||||
repeat$ = this.breakpointObserver.observe(Object.values(Breakpoints)).pipe(
|
||||
map((b) => {
|
||||
if (b.breakpoints[Breakpoints.XLarge]) return 5;
|
||||
if (b.breakpoints[Breakpoints.Large]) return 4;
|
||||
if (b.breakpoints[Breakpoints.Medium]) return 3;
|
||||
if (b.breakpoints[Breakpoints.Small]) return 2;
|
||||
return 1;
|
||||
})
|
||||
);
|
||||
|
||||
displayedFiltersCount$ = this.repeat$.pipe(map((r) => r * 2));
|
||||
|
||||
get filtersCount() {
|
||||
return this.content?.nativeElement?.children?.length;
|
||||
}
|
||||
|
||||
constructor(private dialog: DialogService, private breakpointObserver: BreakpointObserver) {}
|
||||
|
||||
open() {
|
||||
this.dialog.open(FiltersDialogComponent, { filters: this });
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { LayoutModule } from '@angular/cdk/layout';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
import { FiltersDialogComponent } from './components/filters-dialog/filters-dialog.component';
|
||||
import { MoreFiltersButtonComponent } from './components/more-filters-button/more-filters-button.component';
|
||||
import { FiltersComponent } from './filters.component';
|
||||
import { ActionsModule } from '../actions';
|
||||
import { DialogModule } from '../dialog';
|
||||
|
||||
@NgModule({
|
||||
declarations: [FiltersComponent, MoreFiltersButtonComponent, FiltersDialogComponent],
|
||||
exports: [FiltersComponent, MoreFiltersButtonComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
DialogModule,
|
||||
LayoutModule,
|
||||
ActionsModule,
|
||||
MatBadgeModule,
|
||||
],
|
||||
})
|
||||
export class FiltersModule {}
|
3
projects/ng-core/src/lib/components/filters/index.ts
Normal file
3
projects/ng-core/src/lib/components/filters/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './filters.component';
|
||||
export * from './components/more-filters-button/more-filters-button.component';
|
||||
export * from './filters.module';
|
@ -2,3 +2,6 @@ export * from './dialog';
|
||||
export * from './actions';
|
||||
export * from './table';
|
||||
export * from './confirm-dialog';
|
||||
export * from './filters';
|
||||
export * from './date-range-field';
|
||||
export * from './number-range-field';
|
||||
|
@ -0,0 +1,2 @@
|
||||
export * from './number-range-field.module';
|
||||
export * from './number-range-field.component';
|
@ -0,0 +1,10 @@
|
||||
<div [formGroup]="control" class="wrapper">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ label }} from</mat-label>
|
||||
<input formControlName="start" matInput type="number" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ label }} to</mat-label>
|
||||
<input formControlName="end" matInput type="number" />
|
||||
</mat-form-field>
|
||||
</div>
|
@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 8px;
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { NonNullableFormBuilder } from '@angular/forms';
|
||||
import { WrappedControlSuperclass } from '@s-libs/ng-core';
|
||||
|
||||
import { NumberRangeFieldModule } from './number-range-field.module';
|
||||
import { createControlProviders } from '../../utils';
|
||||
|
||||
export type NumberRange = {
|
||||
start?: number;
|
||||
end?: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'v-number-range-field',
|
||||
templateUrl: './number-range-field.component.html',
|
||||
styleUrls: ['./number-range-field.component.scss'],
|
||||
providers: createControlProviders(() => NumberRangeFieldModule),
|
||||
})
|
||||
export class NumberRangeFieldComponent extends WrappedControlSuperclass<NumberRange> {
|
||||
@Input() label!: string;
|
||||
|
||||
control = this.fb.group<NumberRange>({
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
});
|
||||
|
||||
constructor(private fb: NonNullableFormBuilder) {
|
||||
super();
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
import { NumberRangeFieldComponent } from './number-range-field.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [NumberRangeFieldComponent],
|
||||
imports: [CommonModule, ReactiveFormsModule, MatInputModule],
|
||||
exports: [NumberRangeFieldComponent],
|
||||
})
|
||||
export class NumberRangeFieldModule {}
|
@ -3,9 +3,9 @@ import { Component } from '@angular/core';
|
||||
@Component({
|
||||
selector: 'v-table-actions',
|
||||
template: `
|
||||
<div fxLayout fxLayoutGap="16px">
|
||||
<v-actions>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</v-actions>
|
||||
`,
|
||||
})
|
||||
export class TableActionsComponent {}
|
||||
|
@ -2,6 +2,9 @@ import { Component, ViewChild, TemplateRef, Output, EventEmitter } from '@angula
|
||||
import { MtxGridColumn } from '@ng-matero/extensions/grid';
|
||||
import { Overwrite } from 'utility-types';
|
||||
|
||||
import { Column, ExtColumn } from '../types/column';
|
||||
import { createGridColumn } from '../utils/create-grid-columns';
|
||||
|
||||
@Component({
|
||||
selector: 'v-table-menu-cell-template',
|
||||
template: `
|
||||
@ -45,3 +48,21 @@ export type MenuColumn<T> = Overwrite<
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
export function createOperationMenuColumn<T>(
|
||||
column: Column<T>,
|
||||
items: MenuColumn<T>['data']['items']
|
||||
): ExtColumn<T> {
|
||||
const menuCol = createGridColumn(column);
|
||||
return {
|
||||
type: 'menu',
|
||||
pinned: 'right',
|
||||
width: '0',
|
||||
...menuCol,
|
||||
header: typeof column === 'string' ? '' : menuCol.header,
|
||||
data: {
|
||||
...((menuCol as MenuColumn<T>).data || {}),
|
||||
items,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -10,6 +10,16 @@
|
||||
Update
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasUpdate">
|
||||
<button
|
||||
*ngIf="rowSelectable"
|
||||
[disabled]="inProgress || !hasMore"
|
||||
mat-button
|
||||
(click)="more.emit()"
|
||||
>
|
||||
Get more
|
||||
</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasSizes">
|
||||
<button [disabled]="inProgress" [matMenuTriggerFor]="menu" mat-button>
|
||||
{{ size$ | async }} <mat-icon>table_rows_narrow</mat-icon>
|
||||
@ -24,16 +34,6 @@
|
||||
</button>
|
||||
</mat-menu>
|
||||
</ng-container>
|
||||
<ng-container>
|
||||
<button [matMenuTriggerFor]="columnMenu.menuPanel" mat-button>Columns</button>
|
||||
<mtx-grid-column-menu
|
||||
#columnMenu
|
||||
[columns]="renderedColumns"
|
||||
buttonText="test"
|
||||
class="column-menu"
|
||||
(columnChange)="updateColumns($event)"
|
||||
></mtx-grid-column-menu>
|
||||
</ng-container>
|
||||
<!-- TODO: there are problems with resetting, it does not reset when for example many columns are hidden-->
|
||||
<!-- <button *ngIf="hasReset" mat-button (click)="reset()">Reset</button>-->
|
||||
</v-actions>
|
||||
@ -41,7 +41,22 @@
|
||||
<ng-content select="v-table-actions"></ng-content>
|
||||
</div>
|
||||
</v-actions>
|
||||
|
||||
<div class="details">
|
||||
<div>
|
||||
Quantity: {{ data?.length ?? 0
|
||||
}}{{ hasMore ? ' (more available)' : '/' + (data?.length ?? 0) }}
|
||||
</div>
|
||||
<ng-container>
|
||||
<span [matMenuTriggerFor]="columnMenu.menuPanel" class="action">Customize columns</span>
|
||||
<mtx-grid-column-menu
|
||||
#columnMenu
|
||||
[columns]="renderedColumns"
|
||||
buttonText="test"
|
||||
class="column-menu"
|
||||
(columnChange)="updateColumns($event)"
|
||||
></mtx-grid-column-menu>
|
||||
</ng-container>
|
||||
</div>
|
||||
<mat-card class="table-card">
|
||||
<mtx-grid
|
||||
*ngIf="renderedColumns"
|
||||
@ -49,15 +64,22 @@
|
||||
[cellTemplate]="renderedCellTemplate"
|
||||
[columnHideable]="true"
|
||||
[columnPinnable]="true"
|
||||
[columnResizable]="true"
|
||||
[columns]="renderedColumns"
|
||||
[columnSortable]="true"
|
||||
[data]="data || []"
|
||||
[loading]="inProgress || !columns"
|
||||
[paginationTemplate]="footerTpl"
|
||||
[rowSelectable]="rowSelectable"
|
||||
[rowSelectable]="!!rowSelectable"
|
||||
[rowSelected]="rowSelected"
|
||||
[sortActive]="sortActive || ''"
|
||||
[sortDirection]="sortDirection || ''"
|
||||
[sortDisabled]="inProgress"
|
||||
[sortOnFront]="false"
|
||||
[trackBy]="trackBy"
|
||||
columnHideableChecked="show"
|
||||
(rowSelectionChange)="rowSelectionChange.emit($event)"
|
||||
(sortChange)="sortChange.emit($event)"
|
||||
></mtx-grid>
|
||||
<v-table-menu-cell-template
|
||||
(templateChange)="menuCellTpl = $event"
|
||||
|
@ -2,13 +2,7 @@
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
& > * {
|
||||
margin-top: 32px;
|
||||
}
|
||||
& > *:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
gap: 32px;
|
||||
|
||||
.header {
|
||||
flex-direction: row;
|
||||
@ -38,4 +32,21 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
margin: 0 8px -24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
& > * {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.action {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
TemplateRef,
|
||||
} from '@angular/core';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MtxGridColumn } from '@ng-matero/extensions/grid';
|
||||
import { MtxGrid } from '@ng-matero/extensions/grid/grid';
|
||||
import { untilDestroyed, UntilDestroy } from '@ngneat/until-destroy';
|
||||
@ -34,35 +35,47 @@ export class TableComponent<T> implements OnInit, Progressable, OnChanges {
|
||||
@Input() trackBy: MtxGrid['trackBy'] = undefined as never;
|
||||
@Input() progress?: boolean | number | null = false;
|
||||
|
||||
@Input() @coerceBoolean rowSelectable: boolean = false;
|
||||
@Input() @coerceBoolean rowSelectable: boolean | '' = false;
|
||||
@Input() rowSelected!: T[];
|
||||
@Output() rowSelectionChange = new EventEmitter<T[]>();
|
||||
|
||||
@Input() sizes: boolean | number[] | string = false;
|
||||
@Input() set size(size: number | undefined) {
|
||||
if (size) this.size$.next(size);
|
||||
}
|
||||
|
||||
@Input() @coerceBoolean hasMore?: boolean | string = false;
|
||||
@Input() @coerceBoolean hasMore?: boolean | null | '' = false;
|
||||
@Output() more = new EventEmitter<{ size?: number }>();
|
||||
|
||||
@Output() sizeChange = new EventEmitter<number>();
|
||||
@Output() update = new EventEmitter<{ size?: number }>();
|
||||
|
||||
@Input() sortActive?: string;
|
||||
@Input() sortDirection?: SortDirection;
|
||||
@Output() sortChange = new EventEmitter<Sort>();
|
||||
|
||||
@ContentChild(TableActionsComponent) actions!: TableActionsComponent;
|
||||
|
||||
size$ = new BehaviorSubject<undefined | number>(25);
|
||||
size$ = new BehaviorSubject<undefined | number>(undefined);
|
||||
renderedColumns!: MtxGridColumn<T>[];
|
||||
hasReset = false;
|
||||
|
||||
menuCellTpl!: TemplateRef<unknown>;
|
||||
|
||||
renderedSizes: number[] = [];
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
|
||||
get renderedCellTemplate(): MtxGrid['cellTemplate'] {
|
||||
if (this.cellTemplate instanceof TemplateRef) return this.cellTemplate;
|
||||
return Object.fromEntries(
|
||||
this.renderedColumns
|
||||
.filter((c) => c.type === ('menu' as string))
|
||||
.map((c) => [c.field, this.menuCellTpl])
|
||||
);
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
this.renderedColumns
|
||||
.filter((c) => c.type === ('menu' as string))
|
||||
.map((c) => [c.field, this.menuCellTpl])
|
||||
),
|
||||
...(this.cellTemplate || {}),
|
||||
};
|
||||
}
|
||||
|
||||
get hasUpdate() {
|
||||
@ -73,12 +86,6 @@ export class TableComponent<T> implements OnInit, Progressable, OnChanges {
|
||||
return this.renderedSizes.length;
|
||||
}
|
||||
|
||||
get renderedSizes() {
|
||||
if (Array.isArray(this.sizes)) return this.sizes;
|
||||
if (typeof this.sizes !== 'string' && !this.sizeChange.observed) return [];
|
||||
return [25, 100, 1000];
|
||||
}
|
||||
|
||||
get inProgress() {
|
||||
return !!this.progress;
|
||||
}
|
||||
@ -88,7 +95,19 @@ export class TableComponent<T> implements OnInit, Progressable, OnChanges {
|
||||
}
|
||||
|
||||
ngOnChanges(changes: ComponentChanges<TableComponent<T>>) {
|
||||
if (changes.columns) this.renderedColumns = createGridColumns(this.columns) as never;
|
||||
if (changes.columns) {
|
||||
this.renderedColumns = createGridColumns(this.columns) as never;
|
||||
}
|
||||
if (changes.sizes) {
|
||||
if (Array.isArray(this.sizes)) {
|
||||
this.renderedSizes = this.sizes;
|
||||
} else if (typeof this.sizes !== 'string' && !this.sizeChange.observed) {
|
||||
this.renderedSizes = [];
|
||||
} else {
|
||||
this.renderedSizes = [25, 100, 1000];
|
||||
}
|
||||
this.size$.next(this.renderedSizes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
updateColumns(columns: MtxGridColumn<T>[]) {
|
||||
|
@ -2,6 +2,6 @@ import { MtxGridColumn } from '@ng-matero/extensions/grid';
|
||||
|
||||
import { MenuColumn } from '../components/table-menu-cell-template.component';
|
||||
|
||||
export type ObjectColumn<T> = MtxGridColumn<T> | MenuColumn<T>;
|
||||
export type ExtColumn<T> = MtxGridColumn<T> | MenuColumn<T>;
|
||||
|
||||
export type Column<T> = ObjectColumn<T> | string;
|
||||
export type Column<T> = ExtColumn<T> | string;
|
||||
|
@ -1,17 +1,27 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import isNil from 'lodash-es/isNil';
|
||||
import isObject from 'lodash-es/isObject';
|
||||
import startCase from 'lodash-es/startCase';
|
||||
|
||||
import { Column, ObjectColumn } from '../types/column';
|
||||
import { Column, ExtColumn } from '../types/column';
|
||||
|
||||
export function createGridColumn<T>(col: Column<T>): ObjectColumn<T> {
|
||||
const resCol: ObjectColumn<T> = isObject(col) ? col : { field: col };
|
||||
if (isNil(resCol.header)) resCol.header = startCase(String(resCol.field.split('.').at(-1)));
|
||||
return resCol;
|
||||
export function createGridColumn<T>(col: Column<T>): ExtColumn<T> {
|
||||
const extCol: ExtColumn<T> = isObject(col) ? col : { field: col };
|
||||
if (isNil(extCol.header)) {
|
||||
extCol.header = startCase(String(extCol.field.split('.').at(-1)));
|
||||
}
|
||||
switch (extCol.type) {
|
||||
case 'date':
|
||||
if (!extCol.typeParameter?.format) {
|
||||
if (!extCol.typeParameter) extCol.typeParameter = {};
|
||||
extCol.typeParameter.format = 'dd.MM.yyyy HH:mm:ss';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return extCol;
|
||||
}
|
||||
|
||||
export function createGridColumns<T>(columns: Column<T>[]): ObjectColumn<T>[] {
|
||||
export function createGridColumns<T>(columns: Column<T>[]): ExtColumn<T>[] {
|
||||
return columns?.map((col) => createGridColumn(col)) || [];
|
||||
}
|
||||
|
||||
@ -19,7 +29,7 @@ export function createDescriptionFormatterColumn<T>(
|
||||
field: string,
|
||||
getDescriptionOrDescriptionField: ((data: T) => string) | string,
|
||||
getValue?: (data: T) => string
|
||||
): ObjectColumn<T> {
|
||||
): ExtColumn<T> {
|
||||
return {
|
||||
field,
|
||||
formatter: (data: T) => {
|
||||
@ -34,21 +44,3 @@ export function createDescriptionFormatterColumn<T>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const createDatetimeFormatter =
|
||||
<T>(selectorOrField: keyof T | ((data: T) => string | number | Date)) =>
|
||||
(data: T) =>
|
||||
formatDate(
|
||||
(typeof selectorOrField === 'function'
|
||||
? selectorOrField(data)
|
||||
: data[selectorOrField]) as never,
|
||||
'dd.MM.yyyy HH:mm:ss',
|
||||
'en'
|
||||
);
|
||||
|
||||
export function createDatetimeFormatterColumn<T>(field: string): ObjectColumn<T> {
|
||||
return {
|
||||
field,
|
||||
formatter: createDatetimeFormatter<T>(field as keyof T),
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,86 @@
|
||||
import {
|
||||
shareReplay,
|
||||
Observable,
|
||||
defer,
|
||||
mergeScan,
|
||||
map,
|
||||
BehaviorSubject,
|
||||
ReplaySubject,
|
||||
skipWhile,
|
||||
} from 'rxjs';
|
||||
|
||||
import { inProgressFrom, progressTo } from '../../utils';
|
||||
|
||||
export interface Action<TParams> {
|
||||
type: 'load' | 'more';
|
||||
params?: TParams;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface FetchResult<TResultItem, TContinuationToken = string> {
|
||||
result: TResultItem[];
|
||||
continuationToken?: TContinuationToken;
|
||||
}
|
||||
|
||||
export interface FetchOptions<TContinuationToken = string> {
|
||||
size: number;
|
||||
continuationToken?: TContinuationToken;
|
||||
}
|
||||
|
||||
export interface Accumulator<TResultItem, TParams, TContinuationToken> {
|
||||
result: TResultItem[];
|
||||
size: number;
|
||||
params?: TParams;
|
||||
continuationToken?: TContinuationToken;
|
||||
}
|
||||
|
||||
export abstract class FetchSuperclass<TResultItem, TParams = void, TContinuationToken = string> {
|
||||
result$ = defer(() => this.state$).pipe(map(({ result }) => result));
|
||||
hasMore$ = defer(() => this.state$).pipe(map(({ continuationToken }) => !!continuationToken));
|
||||
isLoading$ = inProgressFrom(
|
||||
() => this.progress$,
|
||||
() => this.state$
|
||||
);
|
||||
|
||||
private fetch$ = new ReplaySubject<Action<TParams>>(1);
|
||||
private progress$ = new BehaviorSubject(0);
|
||||
private state$ = defer(() => this.fetch$.pipe(skipWhile((r) => r.type !== 'load'))).pipe(
|
||||
mergeScan<Action<TParams>, Accumulator<TResultItem, TParams, TContinuationToken>>(
|
||||
(acc, action) => {
|
||||
const params = (action.type === 'load' ? action.params : acc.params) as TParams;
|
||||
const size = action.size ?? acc.size;
|
||||
const continuationToken =
|
||||
action.type === 'more' ? acc.continuationToken : undefined;
|
||||
return this.fetch(params, { size, continuationToken }).pipe(
|
||||
map(({ result, continuationToken }) => ({
|
||||
params,
|
||||
result:
|
||||
action.type === 'load' ? result : [...(acc.result ?? []), ...result],
|
||||
size,
|
||||
continuationToken,
|
||||
})),
|
||||
progressTo(this.progress$)
|
||||
);
|
||||
},
|
||||
{
|
||||
size: 25,
|
||||
result: [],
|
||||
},
|
||||
1
|
||||
),
|
||||
shareReplay({ bufferSize: 1, refCount: true })
|
||||
);
|
||||
|
||||
load(params: TParams, options: { size?: number } = {}): void {
|
||||
this.fetch$.next({ type: 'load', params, size: options.size });
|
||||
}
|
||||
|
||||
more(): void {
|
||||
this.fetch$.next({ type: 'more' });
|
||||
}
|
||||
|
||||
protected abstract fetch(
|
||||
params: TParams,
|
||||
options: FetchOptions<TContinuationToken>
|
||||
): Observable<FetchResult<TResultItem, TContinuationToken>>;
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './fetch-superclass';
|
@ -1,2 +1,4 @@
|
||||
export * from './config';
|
||||
export * from './log';
|
||||
export * from './fetch-superclass';
|
||||
export * from './query-params';
|
||||
|
@ -6,17 +6,17 @@ import { LogError } from './log-error';
|
||||
import { Operation } from './types/operation';
|
||||
|
||||
const DEFAULT_DURATION_MS = 3000;
|
||||
const DEFAULT_ERROR_DURATION_MS = 6000;
|
||||
const DEFAULT_ERROR_DURATION_MS = 10000;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NotifyLogService {
|
||||
constructor(private snackBar: MatSnackBar) {}
|
||||
|
||||
success(message: string = 'Completed successfully'): void {
|
||||
success = (message: string = 'Completed successfully'): void => {
|
||||
this.notify(message);
|
||||
}
|
||||
};
|
||||
|
||||
error(error: unknown, message?: string) {
|
||||
error = (error: unknown, message?: string): void => {
|
||||
const logError = new LogError(error);
|
||||
message = message || logError.message || logError.name;
|
||||
console.warn(
|
||||
@ -30,6 +30,10 @@ export class NotifyLogService {
|
||||
.join('\n')
|
||||
);
|
||||
this.notify(message, DEFAULT_ERROR_DURATION_MS);
|
||||
};
|
||||
|
||||
createErrorOperation(operation: Operation, objectName: string) {
|
||||
return (err: unknown) => this.errorOperation(err, operation, objectName);
|
||||
}
|
||||
|
||||
successOperation(operation: Operation, objectName: string): void {
|
||||
|
3
projects/ng-core/src/lib/services/query-params/index.ts
Normal file
3
projects/ng-core/src/lib/services/query-params/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './query-params.service';
|
||||
export * from './query-params.module';
|
||||
export * from './utils/query-params-serializers';
|
@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { QueryParamsService } from './query-params.service';
|
||||
import { DATE_QUERY_PARAMS_SERIALIZERS } from './utils/date-query-params-serializers';
|
||||
import { QUERY_PARAMS_SERIALIZERS } from './utils/query-params-serializers';
|
||||
|
||||
@NgModule({
|
||||
providers: [
|
||||
{ provide: QUERY_PARAMS_SERIALIZERS, useValue: DATE_QUERY_PARAMS_SERIALIZERS },
|
||||
QueryParamsService,
|
||||
],
|
||||
})
|
||||
export class QueryParamsModule {}
|
@ -0,0 +1,76 @@
|
||||
import { Inject, Injectable, Optional } from '@angular/core';
|
||||
import { ActivatedRoute, Params, Router } from '@angular/router';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import negate from 'lodash-es/negate';
|
||||
import { Observable } from 'rxjs';
|
||||
import { distinctUntilChanged, map, shareReplay, startWith } from 'rxjs/operators';
|
||||
|
||||
import { Serializer } from './types/serializer';
|
||||
import { deserializeQueryParam } from './utils/deserialize-query-param';
|
||||
import { QUERY_PARAMS_SERIALIZERS } from './utils/query-params-serializers';
|
||||
import { serializeQueryParam } from './utils/serialize-query-param';
|
||||
import { isEmpty } from '../../utils';
|
||||
|
||||
type Options = {
|
||||
filter?: (param: unknown, key: string) => boolean;
|
||||
};
|
||||
|
||||
@UntilDestroy()
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class QueryParamsService<P extends object> {
|
||||
params$: Observable<P> = this.route.queryParams.pipe(
|
||||
startWith(this.route.snapshot.queryParams),
|
||||
distinctUntilChanged(isEqual),
|
||||
map((params) => this.deserialize(params)),
|
||||
shareReplay({ refCount: true, bufferSize: 1 })
|
||||
);
|
||||
|
||||
get params(): P {
|
||||
return this.deserialize(this.route.snapshot.queryParams);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
@Optional() @Inject(QUERY_PARAMS_SERIALIZERS) private readonly serializers?: Serializer[]
|
||||
) {
|
||||
// Angular @Optional not support TS syntax: `serializers: Serializer[] = []`
|
||||
if (!this.serializers) {
|
||||
this.serializers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async set(params: P, options?: Options): Promise<boolean> {
|
||||
return await this.router.navigate([], { queryParams: this.serialize(params, options) });
|
||||
}
|
||||
|
||||
async patch(param: Partial<P>): Promise<boolean> {
|
||||
return await this.set({ ...this.params, ...param });
|
||||
}
|
||||
|
||||
async init(param: P): Promise<boolean> {
|
||||
return await this.set({ ...param, ...this.params });
|
||||
}
|
||||
|
||||
private serialize(
|
||||
params: P,
|
||||
{ filter = negate(isEmpty) }: Options = {}
|
||||
): { [key: string]: string } {
|
||||
return Object.entries(params).reduce((acc, [k, v]) => {
|
||||
if (filter(v, k)) acc[k] = serializeQueryParam(v, this.serializers);
|
||||
return acc;
|
||||
}, {} as { [key: string]: string });
|
||||
}
|
||||
|
||||
private deserialize(params: Params): P {
|
||||
return Object.entries(params).reduce((acc, [k, v]) => {
|
||||
try {
|
||||
acc[k as keyof P] = deserializeQueryParam<P[keyof P]>(v, this.serializers);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return acc;
|
||||
}, {} as P);
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export type Serializer<T = unknown> = {
|
||||
id: string;
|
||||
serialize: (v: T) => string;
|
||||
deserialize: (v: string) => T;
|
||||
recognize: (v: T) => boolean;
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
import { DateRange } from '@angular/material/datepicker';
|
||||
|
||||
import { getNoTimeZoneIsoString } from '../../../utils';
|
||||
import { Serializer } from '../types/serializer';
|
||||
|
||||
export const DATE_QUERY_PARAMS_SERIALIZERS: Serializer[] = [
|
||||
{
|
||||
id: 'date',
|
||||
serialize: (date: Date) => getNoTimeZoneIsoString(date),
|
||||
deserialize: (value) => new Date(value),
|
||||
recognize: (value) => value instanceof Date,
|
||||
},
|
||||
{
|
||||
id: 'dateRange',
|
||||
serialize: ({ start, end }: DateRange<Date>) =>
|
||||
`${getNoTimeZoneIsoString(start)}|${getNoTimeZoneIsoString(end)}`,
|
||||
deserialize: (value) => {
|
||||
const [start, end] = value.split('|').map((p) => (p ? new Date(p) : null));
|
||||
return { start, end };
|
||||
},
|
||||
recognize: (value) => {
|
||||
if (typeof value !== 'object') return false;
|
||||
const { start, end, ...other } = value as DateRange<Date>;
|
||||
if (Object.keys(other).length) return false;
|
||||
return start instanceof Date || end instanceof Date;
|
||||
},
|
||||
},
|
||||
] as Serializer[];
|
@ -0,0 +1,12 @@
|
||||
import { Serializer } from '../types/serializer';
|
||||
|
||||
export function deserializeQueryParam<P>(value: string, serializers: Serializer[] = []): P {
|
||||
const serializer = serializers
|
||||
.sort((a, b) => b.id.length - a.id.length)
|
||||
.find((s) => value.startsWith(s.id));
|
||||
return (
|
||||
serializer
|
||||
? serializer.deserialize(value.slice(serializer.id.length))
|
||||
: JSON.parse(value || '')
|
||||
) as P;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
import { Serializer } from '../types/serializer';
|
||||
|
||||
export const QUERY_PARAMS_SERIALIZERS = new InjectionToken<Serializer[]>(
|
||||
'query params serializers'
|
||||
);
|
@ -0,0 +1,8 @@
|
||||
import { Serializer } from '../types/serializer';
|
||||
|
||||
export function serializeQueryParam(value: unknown, serializers: Serializer[] = []): string {
|
||||
const serializer = serializers
|
||||
.sort((a, b) => b.id.length - a.id.length)
|
||||
.find((s) => s.recognize(value));
|
||||
return serializer ? serializer.id + serializer.serialize(value) : JSON.stringify(value);
|
||||
}
|
@ -1,25 +1,21 @@
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
import isNil from 'lodash-es/isNil';
|
||||
import isObject from 'lodash-es/isObject';
|
||||
import { ValuesType } from 'utility-types';
|
||||
|
||||
function isEmptyPrimitive(value: unknown): boolean {
|
||||
return isNil(value) || value === '';
|
||||
}
|
||||
import { isEmpty } from './is-empty';
|
||||
import { isEmptyPrimitive } from './is-empty-primitive';
|
||||
|
||||
function isEmptyObjectOrPrimitive(value: unknown): boolean {
|
||||
return isObject(value) ? isEmpty(value) : isEmptyPrimitive(value);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function clean<T extends ReadonlyArray<any> | ArrayLike<any> | Record<any, any>>(
|
||||
export function clean<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
T extends ReadonlyArray<any> | ArrayLike<any> | Record<any, any>,
|
||||
TAllowRootRemoval extends boolean = false
|
||||
>(
|
||||
value: T,
|
||||
allowRootRemoval = false,
|
||||
allowRootRemoval: TAllowRootRemoval = false as TAllowRootRemoval,
|
||||
isNotDeep = false,
|
||||
filterPredicate: (v: unknown, k?: PropertyKey) => boolean = (v) => !isEmptyObjectOrPrimitive(v)
|
||||
): T | null {
|
||||
filterPredicate: (v: unknown, k?: PropertyKey) => boolean = (v) => !isEmpty(v)
|
||||
): TAllowRootRemoval extends true ? T | null : T {
|
||||
if (!isObject(value) || (value.constructor !== Object && !Array.isArray(value))) return value;
|
||||
if (allowRootRemoval && !filterPredicate(value as never)) return null;
|
||||
if (allowRootRemoval && !filterPredicate(value as never)) return null as never;
|
||||
let result: unknown;
|
||||
const cleanChild = (v: unknown) =>
|
||||
isNotDeep ? v : clean(v as never, allowRootRemoval, isNotDeep, filterPredicate);
|
||||
@ -34,7 +30,7 @@ export function clean<T extends ReadonlyArray<any> | ArrayLike<any> | Record<any
|
||||
.map(([k, v]) => [k, cleanChild(v)] as const)
|
||||
.filter(([k, v]) => filterPredicate(v, k))
|
||||
);
|
||||
return allowRootRemoval && !filterPredicate(result) ? null : (result as never);
|
||||
return allowRootRemoval && !filterPredicate(result) ? (null as never) : (result as never);
|
||||
}
|
||||
|
||||
export function cleanPrimitiveProps<T extends object>(
|
||||
|
6
projects/ng-core/src/lib/utils/date/get-end-of-day.ts
Normal file
6
projects/ng-core/src/lib/utils/date/get-end-of-day.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export function getEndOfDay(date?: Date | null): Date | null {
|
||||
if (!date) return null;
|
||||
const endDate = new Date(date);
|
||||
endDate.setUTCHours(23, 59, 59, 999);
|
||||
return endDate;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { removeTimeZone } from './remove-time-zone';
|
||||
|
||||
export function getNoTimeZoneIsoString(date?: Date | null): string {
|
||||
return date ? removeTimeZone(date).toISOString() : '';
|
||||
}
|
2
projects/ng-core/src/lib/utils/date/index.ts
Normal file
2
projects/ng-core/src/lib/utils/date/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './get-no-time-zone-iso-string';
|
||||
export * from './get-end-of-day';
|
3
projects/ng-core/src/lib/utils/date/remove-time-zone.ts
Normal file
3
projects/ng-core/src/lib/utils/date/remove-time-zone.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function removeTimeZone(date: Date): Date {
|
||||
return new Date(date.valueOf() - date.getTimezoneOffset() * 60_000);
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { AbstractControl, FormControlState } from '@angular/forms';
|
||||
import { Observable } from 'rxjs';
|
||||
import { startWith, map } from 'rxjs/operators';
|
||||
|
||||
import { getValue } from './get-value';
|
||||
|
||||
export function getFormValueChanges<T>(
|
||||
form: AbstractControl<FormControlState<T> | T>,
|
||||
hasStart = false
|
||||
): Observable<T> {
|
||||
return form.valueChanges.pipe(
|
||||
...((hasStart ? [startWith(form.value)] : []) as []),
|
||||
map(() => getValue(form))
|
||||
) as Observable<T>;
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
import omitBy from 'lodash-es/omitBy';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
import { getFormValueChanges } from './get-form-value-changes';
|
||||
import { isEmptyPrimitive } from '../is-empty-primitive';
|
||||
|
||||
export function getValidValueChanges(control: AbstractControl, predicate = isEmptyPrimitive) {
|
||||
return getFormValueChanges(control, true).pipe(
|
||||
filter(() => control.valid),
|
||||
map((value) => omitBy(value, predicate))
|
||||
);
|
||||
}
|
21
projects/ng-core/src/lib/utils/form/get-value.ts
Normal file
21
projects/ng-core/src/lib/utils/form/get-value.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
|
||||
import { hasControls } from './has-controls';
|
||||
|
||||
export function getValue<T extends AbstractControl>(control: T): T['value'] {
|
||||
if (!hasControls(control)) {
|
||||
return control.value as never;
|
||||
}
|
||||
if (Array.isArray(control.controls)) {
|
||||
const result: T[] = [];
|
||||
for (const v of control.controls) {
|
||||
result.push(getValue(v) as T);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const result: Partial<T> = {};
|
||||
for (const [k, v] of Object.entries(control.controls)) {
|
||||
result[k as keyof T] = getValue(v);
|
||||
}
|
||||
return result;
|
||||
}
|
5
projects/ng-core/src/lib/utils/form/has-controls.ts
Normal file
5
projects/ng-core/src/lib/utils/form/has-controls.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
|
||||
|
||||
export function hasControls(control: AbstractControl): control is FormGroup | FormArray {
|
||||
return 'controls' in control;
|
||||
}
|
3
projects/ng-core/src/lib/utils/form/index.ts
Normal file
3
projects/ng-core/src/lib/utils/form/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './get-form-value-changes';
|
||||
export * from './validated-control-superclass';
|
||||
export * from './get-valid-value-changes';
|
@ -0,0 +1,9 @@
|
||||
import { Provider, Type } from '@angular/core';
|
||||
|
||||
import { provideValidators } from './provide-validators';
|
||||
import { provideValueAccessor } from './provide-value-accessor';
|
||||
|
||||
export const createControlProviders = (component: () => Type<unknown>): Provider[] => [
|
||||
provideValueAccessor(component),
|
||||
provideValidators(component),
|
||||
];
|
@ -0,0 +1,6 @@
|
||||
export * from './validated-control-superclass.directive';
|
||||
export * from './provide-validators';
|
||||
export * from './provide-value-accessor';
|
||||
export * from './create-control-providers';
|
||||
export * from './utils/get-errors-tree';
|
||||
export * from './validated-form-control-superclass.directive';
|
@ -0,0 +1,8 @@
|
||||
import { Provider, forwardRef, Type } from '@angular/core';
|
||||
import { NG_VALIDATORS } from '@angular/forms';
|
||||
|
||||
export const provideValidators = (component: () => Type<unknown>): Provider => ({
|
||||
provide: NG_VALIDATORS,
|
||||
useExisting: forwardRef(component),
|
||||
multi: true,
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { Provider, forwardRef, Type } from '@angular/core';
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
export const provideValueAccessor = (component: () => Type<unknown>): Provider => ({
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(component),
|
||||
multi: true,
|
||||
});
|
@ -0,0 +1,28 @@
|
||||
import { AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
|
||||
import { hasControls } from '../../has-controls';
|
||||
|
||||
/**
|
||||
* FormGroup/FormArray don't return internal control errors,
|
||||
* so you need to get internal errors manually
|
||||
*/
|
||||
export function getErrorsTree(control: AbstractControl): ValidationErrors | null {
|
||||
if (control.valid) {
|
||||
return null;
|
||||
}
|
||||
const errors: ValidationErrors = Object.assign({}, control.errors);
|
||||
if (hasControls(control)) {
|
||||
if (Array.isArray(control.controls)) {
|
||||
errors['formArrayErrors'] = control.controls
|
||||
.map((c: AbstractControl) => getErrorsTree(c))
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
errors['formGroupErrors'] = Object.fromEntries(
|
||||
Array.from(Object.entries(control.controls))
|
||||
.map(([k, c]) => [k, getErrorsTree(c)])
|
||||
.filter(([, v]) => !!v)
|
||||
) as ValidationErrors;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { Directive, OnInit } from '@angular/core';
|
||||
import { FormGroup, ValidationErrors, Validator } from '@angular/forms';
|
||||
import { WrappedControlSuperclass } from '@s-libs/ng-core';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
|
||||
import { getErrorsTree } from './utils/get-errors-tree';
|
||||
import { getValue } from '../get-value';
|
||||
|
||||
@Directive()
|
||||
export abstract class ValidatedControlSuperclass<OuterType, InnerType = OuterType>
|
||||
extends WrappedControlSuperclass<OuterType, InnerType>
|
||||
implements OnInit, Validator
|
||||
{
|
||||
protected emptyValue!: InnerType;
|
||||
|
||||
override ngOnInit() {
|
||||
this.emptyValue = getValue(this.control) as InnerType;
|
||||
super.ngOnInit();
|
||||
}
|
||||
|
||||
validate(): ValidationErrors | null {
|
||||
return getErrorsTree(this.control);
|
||||
}
|
||||
|
||||
protected override setUpOuterToInnerErrors$(
|
||||
_outer$: Observable<ValidationErrors>
|
||||
): Observable<ValidationErrors> {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
protected override setUpInnerToOuterErrors$(
|
||||
_inner$: Observable<ValidationErrors>
|
||||
): Observable<ValidationErrors> {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
protected override outerToInnerValue(outer: OuterType): InnerType {
|
||||
if ('controls' in this.control) {
|
||||
if (!outer) return this.emptyValue;
|
||||
if (
|
||||
Object.keys(outer).length < Object.keys((this.control as FormGroup).controls).length
|
||||
)
|
||||
return Object.assign({}, this.emptyValue, outer);
|
||||
}
|
||||
return outer as never;
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
import { Directive } from '@angular/core';
|
||||
import { ValidationErrors, FormControl } from '@angular/forms';
|
||||
import { WrappedControlSuperclass } from '@s-libs/ng-core';
|
||||
import { EMPTY, Observable } from 'rxjs';
|
||||
|
||||
@Directive()
|
||||
export class ValidatedFormControlSuperclass<
|
||||
OuterType,
|
||||
InnerType = OuterType
|
||||
> extends WrappedControlSuperclass<OuterType, InnerType> {
|
||||
// TODO: Validation sometimes doesn't work (is not forwarded higher by nesting) with Angular FormControl
|
||||
control = new FormControl() as FormControl<InnerType>;
|
||||
|
||||
validate(): ValidationErrors | null {
|
||||
return this.control.errors;
|
||||
}
|
||||
|
||||
protected override setUpOuterToInnerErrors$(
|
||||
_outer$: Observable<ValidationErrors>
|
||||
): Observable<ValidationErrors> {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
protected override setUpInnerToOuterErrors$(
|
||||
_inner$: Observable<ValidationErrors>
|
||||
): Observable<ValidationErrors> {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
@ -2,7 +2,10 @@ export * from './json';
|
||||
export * from './string';
|
||||
export * from './operators';
|
||||
export * from './component';
|
||||
export * from './form';
|
||||
export * from './date';
|
||||
|
||||
export * from './clean';
|
||||
export * from './is-empty';
|
||||
export * from './compare-different-types';
|
||||
export * from './is-empty-primitive';
|
||||
|
5
projects/ng-core/src/lib/utils/is-empty-primitive.ts
Normal file
5
projects/ng-core/src/lib/utils/is-empty-primitive.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import isNil from 'lodash-es/isNil';
|
||||
|
||||
export function isEmptyPrimitive(value: unknown): boolean {
|
||||
return isNil(value) || value === '';
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
import _isEmpty from 'lodash-es/isEmpty';
|
||||
import isNil from 'lodash-es/isNil';
|
||||
import isObject from 'lodash-es/isObject';
|
||||
|
||||
import { isEmptyPrimitive } from './is-empty-primitive';
|
||||
|
||||
export function isEmpty(value: unknown): boolean {
|
||||
return isObject(value) ? _isEmpty(value) : isNil(value) || value === '';
|
||||
return isObject(value)
|
||||
? value.constructor === Object && _isEmpty(value)
|
||||
: isEmptyPrimitive(value);
|
||||
}
|
||||
|
@ -1,2 +1,3 @@
|
||||
export * from './progress-to';
|
||||
export * from './in-progress-from';
|
||||
export * from './pass-error';
|
||||
|
15
projects/ng-core/src/lib/utils/operators/pass-error.ts
Normal file
15
projects/ng-core/src/lib/utils/operators/pass-error.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { catchError, EMPTY, MonoTypeOperatorFunction, of } from 'rxjs';
|
||||
|
||||
export function passError<T>(
|
||||
handler: (err: unknown) => void,
|
||||
value?: T
|
||||
): MonoTypeOperatorFunction<T> {
|
||||
return (source) =>
|
||||
source.pipe(
|
||||
catchError((err) => {
|
||||
handler(err);
|
||||
if (arguments.length >= 2) return of(value as T);
|
||||
return EMPTY;
|
||||
})
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user