CM-57: Filters, Date Range, Number Range components, Fetch Superclass & many utils ... (#17)

This commit is contained in:
Rinat Arsaev 2023-05-17 13:17:00 +04:00 committed by GitHub
parent f17246db51
commit 15a486aa12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1064 additions and 116 deletions

9
.run/start.run.xml Normal file
View 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
View File

@ -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"
}
},

View File

@ -3,5 +3,6 @@
"dest": "../../dist/ng-core",
"lib": {
"entryFile": "src/public-api.ts"
}
},
"allowedNonPeerDependencies": ["@s-libs/*"]
}

View File

@ -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"

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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)
);
}
}

View File

@ -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 {}

View File

@ -0,0 +1,2 @@
export * from './date-range-field.module';
export * from './date-range-field.component';

View File

@ -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',

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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();
});
});

View File

@ -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 }
> {}

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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 });
}
}

View File

@ -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 {}

View File

@ -0,0 +1,3 @@
export * from './filters.component';
export * from './components/more-filters-button/more-filters-button.component';
export * from './filters.module';

View File

@ -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';

View File

@ -0,0 +1,2 @@
export * from './number-range-field.module';
export * from './number-range-field.component';

View File

@ -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>

View File

@ -0,0 +1,5 @@
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 8px;
}

View File

@ -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();
}
}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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,
},
};
}

View File

@ -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"

View File

@ -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;
}
}
}

View File

@ -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>[]) {

View File

@ -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;

View File

@ -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),
};
}

View File

@ -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>>;
}

View File

@ -0,0 +1 @@
export * from './fetch-superclass';

View File

@ -1,2 +1,4 @@
export * from './config';
export * from './log';
export * from './fetch-superclass';
export * from './query-params';

View File

@ -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 {

View File

@ -0,0 +1,3 @@
export * from './query-params.service';
export * from './query-params.module';
export * from './utils/query-params-serializers';

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -0,0 +1,6 @@
export type Serializer<T = unknown> = {
id: string;
serialize: (v: T) => string;
deserialize: (v: string) => T;
recognize: (v: T) => boolean;
};

View File

@ -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[];

View File

@ -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;
}

View File

@ -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'
);

View File

@ -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);
}

View File

@ -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>(

View 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;
}

View File

@ -0,0 +1,5 @@
import { removeTimeZone } from './remove-time-zone';
export function getNoTimeZoneIsoString(date?: Date | null): string {
return date ? removeTimeZone(date).toISOString() : '';
}

View File

@ -0,0 +1,2 @@
export * from './get-no-time-zone-iso-string';
export * from './get-end-of-day';

View File

@ -0,0 +1,3 @@
export function removeTimeZone(date: Date): Date {
return new Date(date.valueOf() - date.getTimezoneOffset() * 60_000);
}

View File

@ -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>;
}

View File

@ -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))
);
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,3 @@
export * from './get-form-value-changes';
export * from './validated-control-superclass';
export * from './get-valid-value-changes';

View File

@ -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),
];

View File

@ -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';

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -0,0 +1,5 @@
import isNil from 'lodash-es/isNil';
export function isEmptyPrimitive(value: unknown): boolean {
return isNil(value) || value === '';
}

View File

@ -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);
}

View File

@ -1,2 +1,3 @@
export * from './progress-to';
export * from './in-progress-from';
export * from './pass-error';

View 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;
})
);
}