mirror of
https://github.com/valitydev/dashboard.git
synced 2024-11-06 10:35:21 +00:00
Daterange & Multiselest filters (#267)
This commit is contained in:
parent
d0744efc7f
commit
8cf96c561f
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -28,6 +28,7 @@
|
||||
"isempty",
|
||||
"keycloak",
|
||||
"mastercard",
|
||||
"multiselect",
|
||||
"ngneat",
|
||||
"pdfmake",
|
||||
"rbkmoney",
|
||||
|
@ -0,0 +1,13 @@
|
||||
<dsh-multiselect-filter
|
||||
*transloco="let t; scope: 'filter-shops'; read: 'filterShops'"
|
||||
[label]="t.label"
|
||||
[searchInputLabel]="t.searchInputLabel"
|
||||
[searchPredicate]="searchShopPredicate"
|
||||
[compareWith]="compareWithShops"
|
||||
[selected]="selected"
|
||||
(selectedChange)="this.selectedChange.emit($event)"
|
||||
>
|
||||
<dsh-multiselect-filter-option *ngFor="let shop of shops" [value]="shop">
|
||||
{{ shop.details.name }}
|
||||
</dsh-multiselect-filter-option>
|
||||
</dsh-multiselect-filter>
|
@ -0,0 +1,23 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { Shop } from '../../../api-codegen/capi';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter-shops',
|
||||
templateUrl: 'filter-shops.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterShopsComponent {
|
||||
@Input() shops: Shop[];
|
||||
@Input() selected?: Pick<Shop, 'id'>[];
|
||||
@Output() selectedChange = new EventEmitter<Shop[]>();
|
||||
|
||||
searchShopPredicate(data: Shop, searchStr: string): boolean {
|
||||
const lowerSearchStr = searchStr.trim().toLowerCase();
|
||||
return data.details.name.toLowerCase().includes(lowerSearchStr) || data.id.includes(lowerSearchStr);
|
||||
}
|
||||
|
||||
compareWithShops(s1: Shop, s2: Shop): boolean {
|
||||
return s1.id === s2.id;
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { TranslocoModule } from '@ngneat/transloco';
|
||||
|
||||
import { MultiselectFilterModule } from '@dsh/components/filters/multiselect-filter';
|
||||
|
||||
import { FilterShopsComponent } from './filter-shops.component';
|
||||
|
||||
const EXPORTED_DECLARATIONS = [FilterShopsComponent];
|
||||
|
||||
@NgModule({
|
||||
imports: [MultiselectFilterModule, CommonModule, TranslocoModule],
|
||||
declarations: EXPORTED_DECLARATIONS,
|
||||
exports: EXPORTED_DECLARATIONS,
|
||||
})
|
||||
export class FilterShopsModule {}
|
2
src/app/components/filter/filter-shops/index.ts
Normal file
2
src/app/components/filter/filter-shops/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './filter-shops.component';
|
||||
export * from './filter-shops.module';
|
1
src/app/components/filter/index.ts
Normal file
1
src/app/components/filter/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './filter-shops';
|
1
src/app/components/index.ts
Normal file
1
src/app/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './filter';
|
@ -12,12 +12,14 @@ import { TranslocoModule, TRANSLOCO_SCOPE } from '@ngneat/transloco';
|
||||
|
||||
import { ButtonModule } from '@dsh/components/buttons';
|
||||
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
|
||||
import { FiltersModule } from '@dsh/components/filters';
|
||||
import { FormControlsModule, RangeDatepickerModule } from '@dsh/components/form-controls';
|
||||
import { IndicatorsModule } from '@dsh/components/indicators';
|
||||
import { LayoutModule } from '@dsh/components/layout';
|
||||
import { StateNavModule } from '@dsh/components/navigation';
|
||||
import { TableModule } from '@dsh/components/table';
|
||||
|
||||
import { FilterShopsModule } from '../../../../components';
|
||||
import { LanguageModule } from '../../../../language';
|
||||
import { ToMajorModule } from '../../../../to-major';
|
||||
import { ShopSelectorModule } from '../../../shop-selector';
|
||||
@ -51,6 +53,8 @@ import { TableComponent } from './table';
|
||||
RangeDatepickerModule,
|
||||
EmptySearchResultModule,
|
||||
ShopSelectorModule,
|
||||
FilterShopsModule,
|
||||
FiltersModule,
|
||||
],
|
||||
declarations: [PaymentsComponent, SearchFormComponent, PaymentStatusColorPipe, TableComponent],
|
||||
providers: [{ provide: TRANSLOCO_SCOPE, useValue: 'main' }],
|
||||
|
@ -39,6 +39,8 @@ export class SearchFormComponent implements OnInit {
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
shops$ = this.searchFormService.shops$;
|
||||
|
||||
constructor(private searchFormService: SearchFormService) {}
|
||||
|
||||
ngOnInit() {
|
||||
|
@ -8,8 +8,10 @@ import { filter, pluck, shareReplay, startWith, take } from 'rxjs/operators';
|
||||
|
||||
import { binValidator, lastDigitsValidator } from '@dsh/components/form-controls';
|
||||
|
||||
import { ShopService } from '../../../../../api';
|
||||
import { Shop } from '../../../../../api-codegen/capi';
|
||||
import { RouteEnv } from '../../../../route-env';
|
||||
import { removeEmptyProperties } from '../../operators';
|
||||
import { filterShopsByEnv, removeEmptyProperties } from '../../operators';
|
||||
import { toFormValue } from '../../to-form-value';
|
||||
import { toQueryParams } from '../../to-query-params';
|
||||
import { PaymentSearchFormValue } from './payment-search-form-value';
|
||||
@ -25,7 +27,18 @@ export class SearchFormService {
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
constructor(private fb: FormBuilder, private router: Router, private route: ActivatedRoute) {
|
||||
shops$: Observable<Shop[]> = this.route.params.pipe(
|
||||
pluck('envID'),
|
||||
filterShopsByEnv(this.shopService.shops$),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private shopService: ShopService
|
||||
) {
|
||||
this.formValueChanges$.subscribe((formValues) =>
|
||||
this.router.navigate([location.pathname], { queryParams: toQueryParams(formValues) })
|
||||
);
|
||||
|
10
src/assets/i18n/daterange-filter/ru.json
Normal file
10
src/assets/i18n/daterange-filter/ru.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Выберите период",
|
||||
"menu": {
|
||||
"today": "Сегодня",
|
||||
"currentWeek": "Текущая неделя",
|
||||
"threeMonths": "3 месяца",
|
||||
"year": "{{year}} год",
|
||||
"anotherPeriod": "Другой период"
|
||||
}
|
||||
}
|
8
src/assets/i18n/daterange/ru.json
Normal file
8
src/assets/i18n/daterange/ru.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"today": "Сегодня",
|
||||
"from": "С",
|
||||
"fromStartWith2": "Со",
|
||||
"to": "по",
|
||||
"currentWeek": "Текущая неделя",
|
||||
"year": "год"
|
||||
}
|
4
src/assets/i18n/filter-shops/ru.json
Normal file
4
src/assets/i18n/filter-shops/ru.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Магазин",
|
||||
"searchInputLabel": "Выбранные магазины"
|
||||
}
|
@ -6,13 +6,5 @@
|
||||
"threeMonths": "3 месяца",
|
||||
"year": "{{year}} год",
|
||||
"period": "Указать период..."
|
||||
},
|
||||
"rangeDate": {
|
||||
"today": "Сегодня",
|
||||
"from": "С",
|
||||
"fromStartWith2": "Со",
|
||||
"to": "по",
|
||||
"currentWeek": "Текущая неделя",
|
||||
"year": "год"
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,8 @@
|
||||
"back": "Назад",
|
||||
"next": "Далее",
|
||||
"create": "Создать",
|
||||
"save": "Сохранить",
|
||||
"clear": "Очистить",
|
||||
"clearForm": "Очистить форму",
|
||||
"ago": "назад",
|
||||
"cancel": "Отмена",
|
||||
|
@ -0,0 +1,5 @@
|
||||
@import './daterange-filter-selector/daterange-filter-menu-theme';
|
||||
|
||||
@mixin dsh-daterange-filter-theme($theme) {
|
||||
@include dsh-daterange-filter-menu-theme($theme);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
@import '~@angular/material/theming';
|
||||
|
||||
@mixin dsh-daterange-filter-menu-theme($theme) {
|
||||
$primary: map-get($theme, primary);
|
||||
|
||||
.dsh-daterange-filter-menu-item-active {
|
||||
color: mat-color($primary, 400);
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
<div
|
||||
*transloco="let t; scope: 'daterange-filter'; read: 'daterangeFilter.menu'"
|
||||
fxLayout="column"
|
||||
fxLayoutGap="32px"
|
||||
class="dsh-body-1"
|
||||
>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === 'today' }"
|
||||
(click)="this.selectToday()"
|
||||
>
|
||||
{{ t.today }}
|
||||
</div>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === type.currentWeek }"
|
||||
(click)="this.selectCurrentWeek()"
|
||||
>
|
||||
{{ t.currentWeek }}
|
||||
</div>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === type.currentMonth }"
|
||||
(click)="this.selectCurrentMonth()"
|
||||
>
|
||||
{{ current | date: 'LLLL' | titlecase }}
|
||||
</div>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === type.threeMonths }"
|
||||
(click)="this.selectThreeMonths()"
|
||||
>
|
||||
{{ t.threeMonths }}
|
||||
</div>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === type.currentYear }"
|
||||
(click)="this.selectCurrentYear()"
|
||||
>
|
||||
{{ t.year | translocoParams: { year: current | date: 'y' } }}
|
||||
</div>
|
||||
<div
|
||||
class="dsh-daterange-filter-menu-item-another"
|
||||
[ngClass]="{ 'dsh-daterange-filter-menu-item-active': selectedType === type.another }"
|
||||
>
|
||||
{{ t.anotherPeriod }}
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,7 @@
|
||||
.dsh-daterange-filter-menu-item {
|
||||
cursor: pointer;
|
||||
|
||||
&-another {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core';
|
||||
import moment from 'moment';
|
||||
|
||||
import {
|
||||
Daterange,
|
||||
daterangeCurrentType,
|
||||
DaterangeCurrentType,
|
||||
isDaterange,
|
||||
isMonthsRange,
|
||||
} from '@dsh/pipes/daterange';
|
||||
|
||||
import { ComponentChanges } from '../../../../type-utils';
|
||||
|
||||
enum Type {
|
||||
today = 'today',
|
||||
currentWeek = 'currentWeek',
|
||||
currentMonth = 'currentMonth',
|
||||
currentYear = 'currentYear',
|
||||
threeMonths = 'threeMonths',
|
||||
another = 'another',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-daterange-filter-menu',
|
||||
templateUrl: 'daterange-filter-menu.component.html',
|
||||
styleUrls: ['daterange-filter-menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DaterangeFilterMenuComponent implements OnChanges {
|
||||
@Input() selected: Daterange;
|
||||
@Output() selectedChange = new EventEmitter<Daterange>();
|
||||
|
||||
readonly current = moment();
|
||||
readonly type = Type;
|
||||
selectedType: Type;
|
||||
|
||||
ngOnChanges({ selected: daterange }: ComponentChanges<DaterangeFilterMenuComponent>): void {
|
||||
if (daterange) {
|
||||
this.selectedType = this.getType(daterange.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
selectToday() {
|
||||
this.selectedChange.emit({ begin: moment().startOf('day'), end: moment().endOf('day') });
|
||||
}
|
||||
|
||||
selectCurrentWeek() {
|
||||
this.selectedChange.emit({ begin: moment().startOf('week'), end: moment().endOf('week') });
|
||||
}
|
||||
|
||||
selectCurrentMonth() {
|
||||
this.selectedChange.emit({ begin: moment().startOf('month'), end: moment().endOf('month') });
|
||||
}
|
||||
|
||||
selectThreeMonths() {
|
||||
this.selectedChange.emit({
|
||||
begin: moment().subtract(2, 'months').startOf('month'),
|
||||
end: moment().endOf('month'),
|
||||
});
|
||||
}
|
||||
|
||||
selectCurrentYear() {
|
||||
this.selectedChange.emit({ begin: moment().startOf('year'), end: moment().endOf('year') });
|
||||
}
|
||||
|
||||
private getType(s: Daterange) {
|
||||
if (!isDaterange(s)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
isMonthsRange(s) &&
|
||||
s.begin.isSame(moment().subtract(2, 'months'), 'months') &&
|
||||
s.end.isSame(moment(), 'months')
|
||||
) {
|
||||
return Type.threeMonths;
|
||||
}
|
||||
switch (daterangeCurrentType(s)) {
|
||||
case DaterangeCurrentType.today:
|
||||
return Type.today;
|
||||
case DaterangeCurrentType.currentMonth:
|
||||
return Type.currentMonth;
|
||||
case DaterangeCurrentType.currentWeek:
|
||||
return Type.currentWeek;
|
||||
case DaterangeCurrentType.currentYear:
|
||||
return Type.currentYear;
|
||||
default:
|
||||
return Type.another;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './daterange-filter-menu.component';
|
@ -0,0 +1,34 @@
|
||||
<dsh-filter
|
||||
#filter
|
||||
*transloco="let t; scope: 'daterange-filter'; read: 'daterangeFilter'"
|
||||
[title]="(savedSelected$ | async | daterange) || t.title"
|
||||
[active]="!!(savedSelected$ | async)"
|
||||
(closed)="save()"
|
||||
>
|
||||
<dsh-filter-button-content fxLayout fxLayoutGap="20px">
|
||||
<dsh-daterange-filter-menu
|
||||
[selected]="selected$ | async"
|
||||
(selectedChange)="this.select$.next($event)"
|
||||
></dsh-daterange-filter-menu>
|
||||
<div fxLayout="column" fxLayoutGap="16px" fxLayoutAlign=" center">
|
||||
<div class="dsh-body-2">{{ (selected$ | async | daterange) || t.title }}</div>
|
||||
<div fxLayout fxLayoutGap="48px">
|
||||
<mat-calendar
|
||||
class="calendar"
|
||||
(selectedChange)="beginDateChange($event)"
|
||||
[selected]="(selected$ | async)?.begin?.toDate()"
|
||||
[startAt]="(selected$ | async)?.begin?.toDate()"
|
||||
#beginCalendar
|
||||
></mat-calendar>
|
||||
<mat-calendar
|
||||
class="calendar"
|
||||
(selectedChange)="endDateChange($event)"
|
||||
[selected]="(selected$ | async)?.end?.toDate()"
|
||||
[startAt]="(selected$ | async)?.end?.toDate()"
|
||||
#endCalendar
|
||||
></mat-calendar>
|
||||
</div>
|
||||
</div>
|
||||
</dsh-filter-button-content>
|
||||
<dsh-filter-button-actions (clear)="clear()" (save)="save(); filter.close()"></dsh-filter-button-actions>
|
||||
</dsh-filter>
|
@ -0,0 +1,19 @@
|
||||
$dsh-daterange-filter-calendar-width: 278px;
|
||||
|
||||
.calendar {
|
||||
width: $dsh-daterange-filter-calendar-width;
|
||||
|
||||
& ::ng-deep {
|
||||
.mat-calendar-controls {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mat-calendar-header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mat-calendar-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
|
||||
import { MatCalendar } from '@angular/material/datepicker';
|
||||
import moment from 'moment';
|
||||
import { merge, Subject } from 'rxjs';
|
||||
import { map, pluck, scan, shareReplay, withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
import { Daterange, isDaterange } from '@dsh/pipes/daterange';
|
||||
|
||||
import { ComponentChanges } from '../../../type-utils';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-daterange-filter',
|
||||
templateUrl: 'daterange-filter.component.html',
|
||||
styleUrls: ['daterange-filter.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DaterangeFilterComponent implements OnChanges {
|
||||
@Input() selected?: Partial<Daterange>;
|
||||
@Output() selectedChange = new EventEmitter<Daterange>();
|
||||
|
||||
@ViewChild('beginCalendar') beginCalendar: MatCalendar<Date>;
|
||||
@ViewChild('endCalendar') endCalendar: MatCalendar<Date>;
|
||||
|
||||
save$ = new Subject();
|
||||
select$ = new Subject<Partial<Daterange>>();
|
||||
inputSelect$ = new Subject<Partial<Daterange>>();
|
||||
|
||||
selected$ = merge(this.select$, this.inputSelect$).pipe(
|
||||
scan((acc, s) => {
|
||||
const begin = s.begin !== undefined ? s.begin : acc.begin;
|
||||
const end = s.end !== undefined ? s.end : acc.end;
|
||||
if (begin && end && begin.isAfter(end)) {
|
||||
return s.begin ? { begin } : { end };
|
||||
}
|
||||
return { begin, end };
|
||||
}, {} as Partial<Daterange>),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
savedSelected$ = merge(this.inputSelect$, this.save$).pipe(
|
||||
withLatestFrom(this.selected$),
|
||||
pluck(1),
|
||||
map((s) => (isDaterange(s) ? s : null)),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.save$
|
||||
.pipe(
|
||||
withLatestFrom(this.selected$),
|
||||
pluck(1),
|
||||
map((s) => (isDaterange(s) ? s : null))
|
||||
)
|
||||
.subscribe((s) => {
|
||||
this.selectedChange.next(s);
|
||||
if (!s) {
|
||||
this.clear();
|
||||
}
|
||||
});
|
||||
this.select$.subscribe(({ begin, end }) => {
|
||||
if (begin) {
|
||||
this.beginCalendar.activeDate = begin.toDate();
|
||||
}
|
||||
if (end) {
|
||||
this.endCalendar.activeDate = end.toDate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges({ selected }: ComponentChanges<DaterangeFilterComponent>): void {
|
||||
if (selected) {
|
||||
this.inputSelect$.next(selected.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
beginDateChange(begin: Date) {
|
||||
this.select$.next({ begin: moment(begin).startOf('day') });
|
||||
}
|
||||
|
||||
endDateChange(end: Date) {
|
||||
this.select$.next({ end: moment(end).endOf('day') });
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.select$.next({ begin: null, end: null });
|
||||
}
|
||||
|
||||
save() {
|
||||
this.save$.next();
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FlexLayoutModule } from '@angular/flex-layout';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { TranslocoModule } from '@ngneat/transloco';
|
||||
|
||||
import { DaterangeModule } from '@dsh/pipes/daterange';
|
||||
|
||||
import { FilterModule } from '../filter';
|
||||
import { DaterangeFilterMenuComponent } from './daterange-filter-selector';
|
||||
import { DaterangeFilterComponent } from './daterange-filter.component';
|
||||
|
||||
const EXPORTED_DECLARATIONS = [DaterangeFilterComponent, DaterangeFilterMenuComponent];
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, FlexLayoutModule, FilterModule, TranslocoModule, DaterangeModule, MatDatepickerModule],
|
||||
declarations: EXPORTED_DECLARATIONS,
|
||||
exports: EXPORTED_DECLARATIONS,
|
||||
})
|
||||
export class DaterangeFilterModule {}
|
3
src/components/filters/daterange-filter/index.ts
Normal file
3
src/components/filters/daterange-filter/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './daterange-filter.component';
|
||||
export * from './daterange-filter.module';
|
||||
export { Daterange } from '@dsh/pipes/daterange';
|
5
src/components/filters/filter/_filter-theme.scss
Normal file
5
src/components/filters/filter/_filter-theme.scss
Normal file
@ -0,0 +1,5 @@
|
||||
@import './filter-button/filter-button-theme';
|
||||
|
||||
@mixin dsh-filter-theme($theme) {
|
||||
@include dsh-filter-button-theme($theme);
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="actions">
|
||||
<ng-content></ng-content>
|
||||
<div *transloco="let t" fxFlex fxLayoutAlign="space-between" fxLayoutGap="20px">
|
||||
<div>
|
||||
<button dsh-button (click)="clear.emit($event)">{{ t.clear }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<button dsh-button color="accent" (click)="save.emit($event)">{{ t.save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,9 @@
|
||||
$dsh-filter-button-actions-padding: 24px;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.actions {
|
||||
padding: $dsh-filter-button-actions-padding;
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter-button-actions',
|
||||
templateUrl: 'filter-button-actions.component.html',
|
||||
styleUrls: ['filter-button-actions.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterButtonActionsComponent {
|
||||
@Output() clear = new EventEmitter<MouseEvent>();
|
||||
@Output() save = new EventEmitter<MouseEvent>();
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './filter-button-actions.component';
|
@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
@ -0,0 +1,6 @@
|
||||
$dsh-filter-button-padding: 24px;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
padding: $dsh-filter-button-padding;
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter-button-content',
|
||||
templateUrl: 'filter-button-content.component.html',
|
||||
styleUrls: ['filter-button-content.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterButtonContentComponent {}
|
@ -0,0 +1 @@
|
||||
export * from './filter-button-content.component';
|
@ -0,0 +1,24 @@
|
||||
@import '~@angular/material/theming';
|
||||
|
||||
@mixin dsh-filter-button-theme($theme) {
|
||||
$foreground: map-get($theme, foreground);
|
||||
$primary: map-get($theme, primary);
|
||||
|
||||
.dsh-filter-button {
|
||||
border-color: map-get($foreground, dividers);
|
||||
color: map-get($foreground, text);
|
||||
|
||||
&:hover:enabled,
|
||||
&-active:enabled {
|
||||
border-color: mat-color($primary, 400);
|
||||
}
|
||||
|
||||
&-active:enabled {
|
||||
color: mat-color($primary, 400);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: mat-color($foreground, disabled-text, 0.38);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
<button class="dsh-filter-button dsh-body-1" [disabled]="disabled" [ngClass]="{ 'dsh-filter-button-active': active }">
|
||||
<ng-content></ng-content>
|
||||
</button>
|
@ -0,0 +1,40 @@
|
||||
$dsh-filter-button-height: 32px;
|
||||
$dsh-filter-button-padding-top-bottom: 16px;
|
||||
$dsh-filter-button-max-width: 250px;
|
||||
$dsh-filter-button-content-padding: 24px;
|
||||
|
||||
$dsh-filter-button-border-width: 1px;
|
||||
$dsh-filter-button-active-border-width: 2px;
|
||||
|
||||
$dsh-filter-button-transition-time: 200ms;
|
||||
|
||||
.dsh-filter-button {
|
||||
border: $dsh-filter-button-border-width solid;
|
||||
border-radius: $dsh-filter-button-height;
|
||||
height: $dsh-filter-button-height;
|
||||
padding: 0 $dsh-filter-button-padding-top-bottom;
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
max-width: $dsh-filter-button-max-width;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
transition: border-color $dsh-filter-button-transition-time ease, color $dsh-filter-button-transition-time ease;
|
||||
|
||||
&-content,
|
||||
&-actions {
|
||||
padding: $dsh-filter-button-content-padding;
|
||||
}
|
||||
|
||||
&-active {
|
||||
border-width: $dsh-filter-button-active-border-width;
|
||||
}
|
||||
|
||||
&:enabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
:host:disabled {
|
||||
pointer-events: none;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
|
||||
import { coerceBoolean } from '../../../../utils';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter-button',
|
||||
templateUrl: 'filter-button.component.html',
|
||||
styleUrls: ['filter-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterButtonComponent {
|
||||
@Input() @coerceBoolean active = false;
|
||||
@Input() @coerceBoolean disabled = false;
|
||||
}
|
1
src/components/filters/filter/filter-button/index.ts
Normal file
1
src/components/filters/filter/filter-button/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './filter-button.component';
|
@ -0,0 +1 @@
|
||||
<div fxLayout fxLayoutGap="16px"><ng-content></ng-content></div>
|
@ -0,0 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter-group',
|
||||
templateUrl: 'filter-group.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterGroupComponent {}
|
1
src/components/filters/filter/filter-group/index.ts
Normal file
1
src/components/filters/filter/filter-group/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './filter-group.component';
|
9
src/components/filters/filter/filter.component.html
Normal file
9
src/components/filters/filter/filter.component.html
Normal file
@ -0,0 +1,9 @@
|
||||
<dsh-filter-button [disabled]="disabled" [active]="active" [dshDropdownTriggerFor]="dropdown">
|
||||
{{ title }}
|
||||
</dsh-filter-button>
|
||||
|
||||
<dsh-dropdown #dropdown="dshDropdown" hasArrow="false" position="left" offset="16px 0 0" (closed)="closed.emit()">
|
||||
<ng-template>
|
||||
<ng-content></ng-content>
|
||||
</ng-template>
|
||||
</dsh-dropdown>
|
24
src/components/filters/filter/filter.component.ts
Normal file
24
src/components/filters/filter/filter.component.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
|
||||
|
||||
import { DropdownComponent } from '@dsh/components/layout/dropdown';
|
||||
|
||||
import { coerceBoolean } from '../../../utils';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-filter',
|
||||
templateUrl: 'filter.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FilterComponent {
|
||||
@Input() title: string;
|
||||
@Input() @coerceBoolean active = false;
|
||||
@Input() @coerceBoolean disabled = false;
|
||||
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
@ViewChild(DropdownComponent) dropdown: DropdownComponent;
|
||||
|
||||
close() {
|
||||
this.dropdown.close();
|
||||
}
|
||||
}
|
41
src/components/filters/filter/filter.module.ts
Normal file
41
src/components/filters/filter/filter.module.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FlexLayoutModule } from '@angular/flex-layout';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslocoModule } from '@ngneat/transloco';
|
||||
|
||||
import { ButtonModule } from '../../buttons/button';
|
||||
import { DropdownModule } from '../../layout/dropdown';
|
||||
import { FilterButtonComponent } from './filter-button';
|
||||
import { FilterButtonActionsComponent } from './filter-button-actions';
|
||||
import { FilterButtonContentComponent } from './filter-button-content';
|
||||
import { FilterGroupComponent } from './filter-group';
|
||||
import { FilterComponent } from './filter.component';
|
||||
|
||||
const EXPORTED_DECLARATIONS = [
|
||||
FilterComponent,
|
||||
FilterButtonComponent,
|
||||
FilterButtonActionsComponent,
|
||||
FilterButtonContentComponent,
|
||||
FilterGroupComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
DropdownModule,
|
||||
MatDividerModule,
|
||||
ButtonModule,
|
||||
TranslocoModule,
|
||||
FlexLayoutModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
declarations: EXPORTED_DECLARATIONS,
|
||||
exports: EXPORTED_DECLARATIONS,
|
||||
})
|
||||
export class FilterModule {}
|
6
src/components/filters/filter/index.ts
Normal file
6
src/components/filters/filter/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './filter.component';
|
||||
export * from './filter.module';
|
||||
export * from './filter-button';
|
||||
export * from './filter-button-actions';
|
||||
export * from './filter-button-content';
|
||||
export * from './filter-group';
|
10
src/components/filters/filters.module.ts
Normal file
10
src/components/filters/filters.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { DaterangeFilterModule } from './daterange-filter';
|
||||
import { FilterModule } from './filter';
|
||||
import { MultiselectFilterModule } from './multiselect-filter';
|
||||
|
||||
@NgModule({
|
||||
exports: [FilterModule, DaterangeFilterModule, MultiselectFilterModule],
|
||||
})
|
||||
export class FiltersModule {}
|
1
src/components/filters/index.ts
Normal file
1
src/components/filters/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './filters.module';
|
3
src/components/filters/multiselect-filter/index.ts
Normal file
3
src/components/filters/multiselect-filter/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './multiselect-filter.component';
|
||||
export * from './multiselect-filter-option';
|
||||
export * from './multiselect-filter.module';
|
22
src/components/filters/multiselect-filter/mapItemsToLabel.ts
Normal file
22
src/components/filters/multiselect-filter/mapItemsToLabel.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export const mapItemsToLabel = (
|
||||
s: Observable<{
|
||||
selectedItemsLabels: string[];
|
||||
label: string;
|
||||
searchInputLabel: string;
|
||||
itemsCountToDisplayLabel?: number;
|
||||
}>
|
||||
): Observable<string> =>
|
||||
s.pipe(
|
||||
map(({ selectedItemsLabels, label, searchInputLabel, itemsCountToDisplayLabel = 3 }) => {
|
||||
const { length } = selectedItemsLabels;
|
||||
if (length === 0) {
|
||||
return label;
|
||||
} else if (length <= itemsCountToDisplayLabel) {
|
||||
return selectedItemsLabels.join(', ');
|
||||
}
|
||||
return `${searchInputLabel} · ${length}`;
|
||||
})
|
||||
);
|
@ -0,0 +1 @@
|
||||
export * from './multiselect-filter-option.component';
|
@ -0,0 +1,3 @@
|
||||
<mat-checkbox [checked]="selected$ | async" (change)="toggle.emit()">
|
||||
<ng-content></ng-content>
|
||||
</mat-checkbox>
|
@ -0,0 +1,46 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-multiselect-filter-option',
|
||||
templateUrl: 'multiselect-filter-option.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MultiselectFilterOptionComponent<T = any> {
|
||||
@Input() value: T;
|
||||
|
||||
toggle = new EventEmitter<void>();
|
||||
|
||||
@HostBinding('style.display') styleDisplay = 'block';
|
||||
|
||||
@ViewChild(MatCheckbox, { read: ElementRef }) private content: ElementRef;
|
||||
|
||||
get label() {
|
||||
return (this.content?.nativeElement?.textContent || '').trim();
|
||||
}
|
||||
|
||||
get selected() {
|
||||
return this.selected$.value;
|
||||
}
|
||||
|
||||
selected$ = new BehaviorSubject(false);
|
||||
displayed$ = new BehaviorSubject(true);
|
||||
|
||||
display(isDisplay: boolean) {
|
||||
this.displayed$.next(isDisplay);
|
||||
this.styleDisplay = isDisplay ? 'block' : 'none';
|
||||
}
|
||||
|
||||
select(isSelected: boolean) {
|
||||
this.selected$.next(isSelected);
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
<dsh-filter [title]="title$ | async" [active]="!!(savedSelectedOptions$ | async)?.length" (closed)="save()" #filter>
|
||||
<dsh-filter-button-content>
|
||||
<div
|
||||
class="content"
|
||||
[ngClass]="{ 'content-without-items': !(displayedOptions$ | async)?.length }"
|
||||
fxLayout="column"
|
||||
fxLayoutGap="8px"
|
||||
>
|
||||
<mat-form-field fxFlex>
|
||||
<mat-label>{{ label }}</mat-label>
|
||||
<input matInput [formControl]="searchControl" />
|
||||
</mat-form-field>
|
||||
<div
|
||||
class="items-wrapper"
|
||||
[ngClass]="{ 'items-wrapper-overflow': (displayedOptions$ | async)?.length > 5 }"
|
||||
*ngIf="(displayedOptions$ | async)?.length"
|
||||
fxLayout="column"
|
||||
>
|
||||
<div class="items">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dsh-filter-button-content>
|
||||
<dsh-filter-button-actions (clear)="clear()" (save)="save(); filter.close()"></dsh-filter-button-actions>
|
||||
</dsh-filter>
|
@ -0,0 +1,30 @@
|
||||
$dsh-multiselect-filter-content-max-height: 272px;
|
||||
$dsh-multiselect-filter-content-width: 312px;
|
||||
$dsh-multiselect-filter-content-padding: 24px;
|
||||
$dsh-multiselect-filter-content-without-items-bottom-padding: 8px;
|
||||
|
||||
$dsh-multiselect-filter-options-gap: 16px;
|
||||
$dsh-multiselect-filter-options-padding-fix: 8px;
|
||||
|
||||
.content {
|
||||
width: $dsh-multiselect-filter-content-width;
|
||||
max-height: $dsh-multiselect-filter-content-max-height;
|
||||
|
||||
&-without-items {
|
||||
margin-bottom: $dsh-multiselect-filter-content-without-items-bottom-padding -
|
||||
$dsh-multiselect-filter-content-padding;
|
||||
}
|
||||
|
||||
.items-wrapper {
|
||||
overflow-y: auto;
|
||||
padding-bottom: $dsh-multiselect-filter-options-padding-fix;
|
||||
margin-bottom: -$dsh-multiselect-filter-options-padding-fix;
|
||||
|
||||
.items {
|
||||
margin-top: -$dsh-multiselect-filter-options-gap;
|
||||
& > ::ng-deep * {
|
||||
margin-top: $dsh-multiselect-filter-options-gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,154 @@
|
||||
import {
|
||||
AfterContentInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ContentChildren,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
QueryList,
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { BehaviorSubject, combineLatest, merge, Observable, ReplaySubject, Subject } from 'rxjs';
|
||||
import { map, mapTo, pluck, shareReplay, startWith, switchMap, withLatestFrom } from 'rxjs/operators';
|
||||
|
||||
import { ComponentChanges } from '../../../type-utils';
|
||||
import { mapItemsToLabel } from './mapItemsToLabel';
|
||||
import { MultiselectFilterOptionComponent } from './multiselect-filter-option';
|
||||
|
||||
@Component({
|
||||
selector: 'dsh-multiselect-filter',
|
||||
templateUrl: 'multiselect-filter.component.html',
|
||||
styleUrls: ['multiselect-filter.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MultiselectFilterComponent<T = any> implements OnInit, OnChanges, AfterContentInit {
|
||||
@Input() label: string;
|
||||
@Input() searchInputLabel?: string;
|
||||
@Input() searchPredicate?: (value: T, searchStr: string) => boolean;
|
||||
|
||||
@Input() selected?: T[];
|
||||
@Output() selectedChange = new EventEmitter<T[]>();
|
||||
|
||||
@ContentChildren(MultiselectFilterOptionComponent) options: QueryList<MultiselectFilterOptionComponent<T>>;
|
||||
|
||||
searchControl = this.fb.control('');
|
||||
|
||||
private save$ = new Subject<void>();
|
||||
private clear$ = new Subject<void>();
|
||||
private selectFromInput$ = new ReplaySubject<T[]>(1);
|
||||
|
||||
private options$ = new BehaviorSubject<MultiselectFilterOptionComponent<T>[]>([]);
|
||||
private selectedValues$ = new BehaviorSubject<T[]>([]);
|
||||
|
||||
savedSelectedOptions$: Observable<MultiselectFilterOptionComponent<T>[]> = combineLatest([
|
||||
merge(this.selectFromInput$, this.save$.pipe(withLatestFrom(this.selectedValues$), pluck(1))),
|
||||
this.options$,
|
||||
]).pipe(
|
||||
map(([selected]) => this.mapInputValuesToOptions(selected)),
|
||||
startWith([]),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
displayedOptions$: Observable<MultiselectFilterOptionComponent<T>[]> = combineLatest([
|
||||
this.options$,
|
||||
this.searchControl.valueChanges.pipe(startWith(this.searchControl.value)),
|
||||
]).pipe(
|
||||
map(([options, searchStr]) => options.filter((o) => this.checkDisplayOption(o, searchStr))),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
title$: Observable<string> = this.savedSelectedOptions$.pipe(
|
||||
map((selectedOptions) => ({
|
||||
selectedItemsLabels: selectedOptions.map(({ label }) => label),
|
||||
label: this.label,
|
||||
searchInputLabel: this.searchInputLabel,
|
||||
})),
|
||||
mapItemsToLabel,
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
@Input() compareWith?: (o1: T, o2: T) => boolean = (o1, o2) => o1 === o2;
|
||||
|
||||
constructor(private fb: FormBuilder) {}
|
||||
|
||||
ngOnInit() {
|
||||
combineLatest([this.displayedOptions$, this.options$]).subscribe(([displayedOptions, options]) =>
|
||||
options.forEach((o) => o.display(displayedOptions.includes(o)))
|
||||
);
|
||||
combineLatest([this.selectedValues$, this.options$]).subscribe(([selectedValues, options]) =>
|
||||
options.forEach((o) => o.select(this.includesValue(selectedValues, o.value)))
|
||||
);
|
||||
merge(this.selectFromInput$, this.clear$.pipe(mapTo([]))).subscribe((selectedValues) =>
|
||||
this.selectedValues$.next(selectedValues)
|
||||
);
|
||||
this.options$
|
||||
.pipe(
|
||||
switchMap((options) => merge(...options.map((option) => option.toggle.pipe(mapTo(option.value))))),
|
||||
withLatestFrom(this.selectedValues$),
|
||||
map(([o, selected]) => this.toggleValue(selected, o))
|
||||
)
|
||||
.subscribe((selected) => this.selectedValues$.next(selected));
|
||||
this.save$
|
||||
.pipe(
|
||||
withLatestFrom(this.selectedValues$),
|
||||
pluck(1),
|
||||
map((selected) => this.mapInputValuesToOptions(selected)),
|
||||
map((options) => options.map(({ value }) => value))
|
||||
)
|
||||
.subscribe((selectedValues) => this.selectedChange.emit(selectedValues));
|
||||
}
|
||||
|
||||
ngAfterContentInit() {
|
||||
this.options.changes
|
||||
.pipe(
|
||||
startWith(this.options),
|
||||
map((o: MultiselectFilterComponent<T>['options']) => o.toArray())
|
||||
)
|
||||
.subscribe((o) => this.options$.next(o));
|
||||
}
|
||||
|
||||
ngOnChanges({ selected }: ComponentChanges<MultiselectFilterComponent>) {
|
||||
if (selected && selected.currentValue) {
|
||||
this.selectFromInput$.next(selected.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
private checkDisplayOption(option: MultiselectFilterOptionComponent, searchStr: string) {
|
||||
if (option.selected) {
|
||||
return true;
|
||||
} else if (this.searchPredicate) {
|
||||
return this.searchPredicate(option.value, searchStr);
|
||||
}
|
||||
return option.label.toLowerCase().trim().includes(searchStr.toLowerCase().trim());
|
||||
}
|
||||
|
||||
private includesValue(values: T[], value: T) {
|
||||
return values.findIndex((s) => this.compareWith(s, value)) !== -1;
|
||||
}
|
||||
|
||||
private toggleValue(values: T[], value: T) {
|
||||
const idx = values.findIndex((s) => this.compareWith(s, value));
|
||||
const newSelected = values.slice();
|
||||
idx === -1 ? newSelected.push(value) : newSelected.splice(idx, 1);
|
||||
return newSelected;
|
||||
}
|
||||
|
||||
private mapInputValuesToOptions(inputValues: T[]) {
|
||||
return inputValues
|
||||
.map((s) => this.options.toArray().find((o) => this.compareWith(s, o.value)))
|
||||
.filter((v) => v);
|
||||
}
|
||||
|
||||
save() {
|
||||
this.searchControl.patchValue('');
|
||||
this.save$.next();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.searchControl.patchValue('');
|
||||
this.clear$.next();
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FlexLayoutModule } from '@angular/flex-layout';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TranslocoModule } from '@ngneat/transloco';
|
||||
|
||||
import { ButtonModule } from '../../buttons';
|
||||
import { FilterModule } from '../filter';
|
||||
import { MultiselectFilterOptionComponent } from './multiselect-filter-option';
|
||||
import { MultiselectFilterComponent } from './multiselect-filter.component';
|
||||
|
||||
const EXPORTED_DECLARATIONS = [MultiselectFilterComponent, MultiselectFilterOptionComponent];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDividerModule,
|
||||
ButtonModule,
|
||||
TranslocoModule,
|
||||
FlexLayoutModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
ReactiveFormsModule,
|
||||
FilterModule,
|
||||
],
|
||||
declarations: EXPORTED_DECLARATIONS,
|
||||
exports: EXPORTED_DECLARATIONS,
|
||||
})
|
||||
export class MultiselectFilterModule {}
|
@ -8,7 +8,7 @@
|
||||
|
||||
.dsh-range-datepicker {
|
||||
&-button {
|
||||
border-color: mat-color($foreground, divider, 0.12);
|
||||
border-color: map-get($foreground, dividers);
|
||||
color: $text;
|
||||
|
||||
&:enabled {
|
||||
|
@ -1,105 +0,0 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { Inject, LOCALE_ID, Pipe, PipeTransform } from '@angular/core';
|
||||
import { TranslocoService } from '@ngneat/transloco';
|
||||
import moment, { Moment } from 'moment';
|
||||
|
||||
@Pipe({ name: 'rangeDate' })
|
||||
export class RangeDatePipe implements PipeTransform {
|
||||
constructor(@Inject(LOCALE_ID) private locale: string, private transloco: TranslocoService) {}
|
||||
|
||||
transform({ begin, end }: { begin: Moment; end: Moment }): string {
|
||||
if (begin.isSame(begin.clone().startOf('year'), 'day') && end.isSame(end.clone().endOf('year'), 'day')) {
|
||||
return this.toYearStr(begin, end);
|
||||
}
|
||||
if (begin.isSame(begin.clone().startOf('month'), 'day') && end.isSame(end.clone().endOf('month'), 'day')) {
|
||||
return this.toMonthStr(begin, end);
|
||||
}
|
||||
if (begin.isSame(moment().startOf('week'), 'day') && end.isSame(moment().endOf('week'), 'day')) {
|
||||
return this.rangeDateTranslate('currentWeek');
|
||||
}
|
||||
if (begin.isSame(moment().startOf('day'), 'day') && end.isSame(moment().endOf('day'), 'day')) {
|
||||
return this.rangeDateTranslate('today');
|
||||
}
|
||||
return this.toDateStr(begin, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2020 год
|
||||
* С 2019 по 2020 год
|
||||
*/
|
||||
private toYearStr(begin: Moment, end: Moment) {
|
||||
const endStr = `${end.year()} ${this.rangeDateTranslate('year')}`;
|
||||
|
||||
if (begin.isSame(end, 'year')) {
|
||||
return endStr;
|
||||
}
|
||||
|
||||
const fromStr = this.rangeDateTranslate('from');
|
||||
const toStr = this.rangeDateTranslate('to');
|
||||
|
||||
return `${fromStr} ${begin.year()} ${toStr} ${endStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Январь
|
||||
* Январь 2020
|
||||
*
|
||||
* С января по март
|
||||
* С января 2019 по март 2019 / С декабря 2019 по март 2020
|
||||
*/
|
||||
private toMonthStr(begin: Moment, end: Moment) {
|
||||
const fromStr = this.rangeDateTranslate('from');
|
||||
const toStr = this.rangeDateTranslate('to');
|
||||
|
||||
const currentYear = this.isCurrentYear(begin, end);
|
||||
const beginStr = this.formatDate(begin, false, true, !currentYear);
|
||||
const endStr = this.formatStandaloneDate(end, false, true, !currentYear);
|
||||
|
||||
if (begin.isSame(end, 'month')) {
|
||||
return this.capitalizeFirstLetter(endStr);
|
||||
}
|
||||
|
||||
return `${fromStr} ${beginStr} ${toStr} ${endStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2 января
|
||||
* 2 января 2020
|
||||
*
|
||||
* Со 2 по 8 марта / Со 2 января по 8 марта
|
||||
* Со 2 по 8 марта 2019 / Со 2 января 2019 по 8 марта 2020
|
||||
*/
|
||||
private toDateStr(begin: Moment, end: Moment) {
|
||||
const fromByDayStr = this.rangeDateTranslate(begin.date() === 2 ? 'fromStartWith2' : 'from');
|
||||
const toStr = this.rangeDateTranslate('to');
|
||||
|
||||
const beginStr = this.formatDate(begin, true, !begin.isSame(end, 'month'), !begin.isSame(end, 'year'));
|
||||
const endStr = this.formatDate(end, true, true, !this.isCurrentYear(begin, end));
|
||||
|
||||
if (begin.isSame(end, 'day')) {
|
||||
return endStr;
|
||||
}
|
||||
|
||||
return `${fromByDayStr} ${beginStr} ${toStr} ${endStr}`;
|
||||
}
|
||||
|
||||
private isCurrentYear(begin: Moment, end: Moment) {
|
||||
return moment().isSame(begin, 'year') && moment().isSame(end, 'year');
|
||||
}
|
||||
|
||||
private capitalizeFirstLetter(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
private rangeDateTranslate(key: string) {
|
||||
return this.transloco.translate(`rangeDate.${key}`, null, 'range-datepicker|scoped');
|
||||
}
|
||||
|
||||
private formatDate(date: Moment, d: boolean = false, m: boolean = false, y: boolean = false) {
|
||||
return formatDate(date.toDate(), [d && 'd', m && 'MMMM', y && 'y'].filter((v) => v).join(' '), this.locale);
|
||||
}
|
||||
|
||||
private formatStandaloneDate(date: Moment, d: boolean = false, m: boolean = false, y: boolean = false) {
|
||||
return formatDate(date.toDate(), [d && 'd', m && 'LLLL', y && 'y'].filter((v) => v).join(' '), this.locale);
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@
|
||||
>
|
||||
<div class="dsh-range-datepicker-input-content">
|
||||
<ng-container *ngIf="publicValue?.begin && publicValue?.end; else placeholder" [ngSwitch]="period">
|
||||
{{ publicValue | rangeDate }}
|
||||
{{ publicValue | daterange }}
|
||||
</ng-container>
|
||||
<ng-template #placeholder>
|
||||
{{ t.selectPeriod }}
|
||||
|
@ -11,8 +11,8 @@ import { TranslocoModule } from '@ngneat/transloco';
|
||||
import { SatDatepickerModule, SatNativeDateModule } from 'saturn-datepicker';
|
||||
|
||||
import { ButtonToggleModule } from '@dsh/components/buttons/button-toggle';
|
||||
import { DaterangeModule } from '@dsh/pipes/daterange';
|
||||
|
||||
import { RangeDatePipe } from './range-date.pipe';
|
||||
import { RangeDatepickerComponent } from './range-datepicker.component';
|
||||
|
||||
@NgModule({
|
||||
@ -29,8 +29,9 @@ import { RangeDatepickerComponent } from './range-datepicker.component';
|
||||
CommonModule,
|
||||
TranslocoModule,
|
||||
MatDividerModule,
|
||||
DaterangeModule,
|
||||
],
|
||||
declarations: [RangeDatepickerComponent, RangeDatePipe],
|
||||
declarations: [RangeDatepickerComponent],
|
||||
exports: [RangeDatepickerComponent],
|
||||
})
|
||||
export class RangeDatepickerModule {}
|
||||
|
@ -1,7 +1,12 @@
|
||||
import { FlexibleConnectedPositionStrategy, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
|
||||
import {
|
||||
ConnectedPosition,
|
||||
FlexibleConnectedPositionStrategy,
|
||||
Overlay,
|
||||
OverlayConfig,
|
||||
OverlayRef,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { TemplatePortal } from '@angular/cdk/portal';
|
||||
import { Directive, ElementRef, HostListener, Input, OnDestroy, Renderer2, ViewContainerRef } from '@angular/core';
|
||||
import get from 'lodash.get';
|
||||
import { Key } from 'ts-keycode-enum';
|
||||
|
||||
import { DropdownComponent } from './dropdown.component';
|
||||
@ -10,6 +15,20 @@ import { State } from './open-close-animation';
|
||||
const WRAPPER_OFFSET = 15;
|
||||
const OVERLAY_SELECTOR = '.cdk-overlay-container';
|
||||
|
||||
const POSITION_CENTER: ConnectedPosition = {
|
||||
originX: 'center',
|
||||
originY: 'bottom',
|
||||
overlayX: 'center',
|
||||
overlayY: 'top',
|
||||
};
|
||||
|
||||
const POSITION_LEFT: ConnectedPosition = {
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
};
|
||||
|
||||
@Directive({
|
||||
selector: '[dshDropdownTriggerFor]',
|
||||
exportAs: 'dshDropdownTrigger',
|
||||
@ -29,12 +48,13 @@ export class DropdownTriggerDirective implements OnDestroy {
|
||||
private renderer: Renderer2
|
||||
) {}
|
||||
|
||||
private get dropdownEl(): HTMLElement {
|
||||
return get(this.overlayRef, 'overlayElement.firstChild');
|
||||
private get dropdownEl() {
|
||||
return this.overlayRef?.overlayElement?.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
private get triangleEl(): HTMLElement {
|
||||
return get(this.dropdownEl, 'firstChild.firstChild');
|
||||
private get triangleEl() {
|
||||
const triangleEl = this.dropdownEl?.firstChild?.firstChild as HTMLElement;
|
||||
return typeof triangleEl?.getBoundingClientRect === 'function' ? triangleEl : null;
|
||||
}
|
||||
|
||||
get overlayRef() {
|
||||
@ -58,20 +78,20 @@ export class DropdownTriggerDirective implements OnDestroy {
|
||||
if (!this.overlayRef.hasAttached()) {
|
||||
const portal = this.getPortal();
|
||||
this.overlayRef.attach(portal);
|
||||
this.dropdown.state = State.open;
|
||||
this.dropdown.open();
|
||||
this.updatePosition();
|
||||
this.addWindowListeners();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dropdown.state = State.closed;
|
||||
this.dropdown.close();
|
||||
this.removeWindowListeners();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.overlayRef.hasAttached()) {
|
||||
if (this.dropdown.state === 'open') {
|
||||
if (this.dropdown.state$.value === State.open) {
|
||||
this.close();
|
||||
}
|
||||
} else {
|
||||
@ -107,7 +127,7 @@ export class DropdownTriggerDirective implements OnDestroy {
|
||||
}
|
||||
|
||||
private animationDoneHandler = () => {
|
||||
if (this.dropdown.state === State.closed) {
|
||||
if (this.dropdown.state$.value === State.closed) {
|
||||
this.overlayRef.detach();
|
||||
} else {
|
||||
this.updatePosition();
|
||||
@ -122,14 +142,7 @@ export class DropdownTriggerDirective implements OnDestroy {
|
||||
.withPush(true)
|
||||
.withDefaultOffsetX(0)
|
||||
.withLockedPosition()
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'center',
|
||||
originY: 'bottom',
|
||||
overlayX: 'center',
|
||||
overlayY: 'top',
|
||||
},
|
||||
]),
|
||||
.withPositions([this.dropdown.position === 'center' ? POSITION_CENTER : POSITION_LEFT]),
|
||||
scrollStrategy: this.overlay.scrollStrategies.reposition(),
|
||||
width: this.dropdown.getCorrectedWidth(),
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
<ng-template cdk-portal>
|
||||
<div class="dsh-dropdown-wrapper">
|
||||
<div [@openClose]="state" (@openClose.done)="animationDone$.next($event)">
|
||||
<div class="dsh-dropdown-triangle" [style.left]="triangleLeftOffset"></div>
|
||||
<div class="dsh-dropdown-wrapper" [ngStyle]="{ margin: offset }">
|
||||
<div [@openClose]="state$ | async" (@openClose.done)="animationDone$.next($event)">
|
||||
<div *ngIf="hasArrow" class="dsh-dropdown-triangle" [style.left]="triangleLeftOffset"></div>
|
||||
<div class="dsh-dropdown">
|
||||
<ng-container *ngTemplateOutlet="contentTemplateRef"></ng-container>
|
||||
</div>
|
||||
|
@ -10,7 +10,6 @@ $indent: 10px;
|
||||
|
||||
&-wrapper {
|
||||
perspective: 2000px;
|
||||
margin: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Component, ContentChild, EventEmitter, Input, Output, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
|
||||
import { coerceBoolean } from '../../../utils';
|
||||
import { openCloseAnimation, State } from './open-close-animation';
|
||||
|
||||
/**
|
||||
@ -18,12 +19,17 @@ const FULL_WIDTH = '99.99%';
|
||||
export class DropdownComponent {
|
||||
@Input() width?: number | string;
|
||||
@Input() disableClose = false;
|
||||
@Output() backdropClick? = new EventEmitter<MouseEvent>();
|
||||
@Input() @coerceBoolean hasArrow = true;
|
||||
@Input() position: 'left' | 'center' = 'center';
|
||||
@Input() offset = '15px';
|
||||
|
||||
@Output() backdropClick = new EventEmitter<MouseEvent>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
@ViewChild(TemplateRef, { static: true }) templateRef: TemplateRef<any>;
|
||||
@ContentChild(TemplateRef, { static: true }) contentTemplateRef: TemplateRef<any>;
|
||||
|
||||
state = State.closed;
|
||||
state$ = new BehaviorSubject(State.closed);
|
||||
triangleLeftOffset: string;
|
||||
animationDone$ = new Subject();
|
||||
|
||||
@ -41,6 +47,15 @@ export class DropdownComponent {
|
||||
return widthPx;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.state$.next(State.closed);
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
open() {
|
||||
this.state$.next(State.open);
|
||||
}
|
||||
|
||||
private isAutoSize(size: string | number) {
|
||||
return !size || (typeof size === 'string' && size.slice(-1) === '%');
|
||||
}
|
||||
|
33
src/pipes/daterange/daterange-current-type.ts
Normal file
33
src/pipes/daterange/daterange-current-type.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import moment from 'moment';
|
||||
|
||||
import { Daterange } from './daterange';
|
||||
import { isDay, isMonth, isYear } from './daterange-type';
|
||||
|
||||
export const isCurrentYear = ({ begin, end }: Daterange) => isYear({ begin, end }) && moment().isSame(begin, 'year');
|
||||
|
||||
export const isCurrentMonth = ({ begin, end }: Daterange) => isMonth({ begin, end }) && begin.isSame(moment(), 'month');
|
||||
|
||||
export const isCurrentWeek = ({ begin, end }: Daterange) =>
|
||||
begin.isSame(moment().startOf('week'), 'day') && end.isSame(moment().endOf('week'), 'day');
|
||||
|
||||
export const isToday = ({ begin, end }: Daterange) => isDay({ begin, end }) && begin.isSame(moment(), 'day');
|
||||
|
||||
export enum DaterangeCurrentType {
|
||||
currentYear = 'currentYear',
|
||||
currentMonth = 'currentMonth',
|
||||
currentWeek = 'currentWeek',
|
||||
today = 'today',
|
||||
}
|
||||
|
||||
export const daterangeCurrentType = (daterange: Daterange): DaterangeCurrentType => {
|
||||
if (isToday(daterange)) {
|
||||
return DaterangeCurrentType.today;
|
||||
} else if (isCurrentWeek(daterange)) {
|
||||
return DaterangeCurrentType.currentWeek;
|
||||
} else if (isCurrentMonth(daterange)) {
|
||||
return DaterangeCurrentType.currentMonth;
|
||||
} else if (isCurrentYear(daterange)) {
|
||||
return DaterangeCurrentType.currentYear;
|
||||
}
|
||||
return null;
|
||||
};
|
31
src/pipes/daterange/daterange-type.ts
Normal file
31
src/pipes/daterange/daterange-type.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { Daterange } from './daterange';
|
||||
|
||||
export const isYearsRange = ({ begin, end }: Daterange) =>
|
||||
begin.isSame(begin.clone().startOf('year'), 'day') && end.isSame(end.clone().endOf('year'), 'day');
|
||||
|
||||
export const isYear = ({ begin, end }: Daterange) => isYearsRange({ begin, end }) && begin.isSame(end, 'year');
|
||||
|
||||
export const isMonthsRange = ({ begin, end }: Daterange) =>
|
||||
begin.isSame(begin.clone().startOf('month'), 'day') && end.isSame(end.clone().endOf('month'), 'day');
|
||||
|
||||
export const isMonth = ({ begin, end }: Daterange) => isMonthsRange({ begin, end }) && begin.isSame(end, 'month');
|
||||
|
||||
export const isDay = ({ begin, end }: Daterange) => begin.isSame(end, 'days');
|
||||
|
||||
export enum DaterangeType {
|
||||
years = 'years',
|
||||
year = 'year',
|
||||
months = 'months',
|
||||
month = 'month',
|
||||
days = 'days',
|
||||
day = 'day',
|
||||
}
|
||||
|
||||
export const daterangeType = (daterange: Daterange): DaterangeType => {
|
||||
if (isYearsRange(daterange)) {
|
||||
return isYear(daterange) ? DaterangeType.year : DaterangeType.years;
|
||||
} else if (isMonthsRange(daterange)) {
|
||||
return isMonth(daterange) ? DaterangeType.month : DaterangeType.months;
|
||||
}
|
||||
return isDay(daterange) ? DaterangeType.day : DaterangeType.days;
|
||||
};
|
16
src/pipes/daterange/daterange.module.ts
Normal file
16
src/pipes/daterange/daterange.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { TranslocoModule } from '@ngneat/transloco';
|
||||
|
||||
import { DaterangePipe } from './daterange.pipe';
|
||||
import { DaterangeService } from './daterange.service';
|
||||
|
||||
const EXPORTED_DECLARATIONS = [DaterangePipe];
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TranslocoModule],
|
||||
declarations: EXPORTED_DECLARATIONS,
|
||||
exports: EXPORTED_DECLARATIONS,
|
||||
providers: [DaterangeService],
|
||||
})
|
||||
export class DaterangeModule {}
|
23
src/pipes/daterange/daterange.pipe.ts
Normal file
23
src/pipes/daterange/daterange.pipe.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
import { Daterange } from './daterange';
|
||||
import { DaterangeService } from './daterange.service';
|
||||
|
||||
@Pipe({ name: 'daterange' })
|
||||
export class DaterangePipe implements PipeTransform {
|
||||
private daterange$ = new BehaviorSubject<Partial<Daterange>>({});
|
||||
private result = '';
|
||||
|
||||
constructor(daterangeService: DaterangeService) {
|
||||
this.daterange$
|
||||
.pipe(switchMap((daterange) => daterangeService.switchToDaterangeStr(daterange)))
|
||||
.subscribe((r) => (this.result = r));
|
||||
}
|
||||
|
||||
transform(daterange: Partial<Daterange>): string {
|
||||
this.daterange$.next(daterange || {});
|
||||
return this.result;
|
||||
}
|
||||
}
|
114
src/pipes/daterange/daterange.service.ts
Normal file
114
src/pipes/daterange/daterange.service.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { Inject, Injectable, LOCALE_ID } from '@angular/core';
|
||||
import { TranslocoService } from '@ngneat/transloco';
|
||||
import { Moment } from 'moment';
|
||||
import { merge, Observable, of } from 'rxjs';
|
||||
import { map, pluck, scan, shareReplay, switchMap } from 'rxjs/operators';
|
||||
|
||||
import { Daterange, isDaterange } from './daterange';
|
||||
import { isCurrentWeek, isCurrentYear, isToday } from './daterange-current-type';
|
||||
import { isMonth, isMonthsRange, isYearsRange } from './daterange-type';
|
||||
|
||||
@Injectable()
|
||||
export class DaterangeService {
|
||||
private translations$ = this.loadTranslations();
|
||||
|
||||
constructor(@Inject(LOCALE_ID) private locale: string, private transloco: TranslocoService) {}
|
||||
|
||||
switchToDaterangeStr(daterange: Partial<Daterange>): Observable<string> {
|
||||
if (!isDaterange(daterange)) {
|
||||
return of('');
|
||||
} else if (isYearsRange(daterange)) {
|
||||
return this.toYearStr(daterange);
|
||||
} else if (isMonthsRange(daterange)) {
|
||||
return this.toMonthStr(daterange);
|
||||
} else if (isCurrentWeek(daterange)) {
|
||||
return this.translations$.pipe(pluck('currentWeek'));
|
||||
} else if (isToday(daterange)) {
|
||||
return this.translations$.pipe(pluck('today'));
|
||||
}
|
||||
return this.toDateStr(daterange);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2020 год
|
||||
* С 2019 по 2020 год
|
||||
*/
|
||||
toYearStr({ begin, end }: Daterange) {
|
||||
return this.translations$.pipe(
|
||||
map((t) => {
|
||||
const endStr = `${end.year()} ${t.year}`;
|
||||
if (begin.isSame(end, 'year')) {
|
||||
return endStr;
|
||||
}
|
||||
return `${t.from} ${begin.year()} ${t.to} ${endStr}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Январь
|
||||
* Январь 2020
|
||||
*
|
||||
* С января по март
|
||||
* С января 2019 по март 2019 / С декабря 2019 по март 2020
|
||||
*/
|
||||
toMonthStr({ begin, end }: Daterange) {
|
||||
return this.translations$.pipe(
|
||||
map((t) => {
|
||||
const currentYear = isCurrentYear({ begin, end });
|
||||
const beginStr = this.formatDate(begin, false, true, !currentYear);
|
||||
const endStr = this.formatStandaloneDate(end, false, true, !currentYear);
|
||||
if (isMonth({ begin, end })) {
|
||||
return this.capitalizeFirstLetter(endStr);
|
||||
}
|
||||
return `${t.from} ${beginStr} ${t.to} ${endStr}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2 января
|
||||
* 2 января 2020
|
||||
*
|
||||
* Со 2 по 8 марта / Со 2 января по 8 марта
|
||||
* Со 2 по 8 марта 2019 / Со 2 января 2019 по 8 марта 2020
|
||||
*/
|
||||
toDateStr({ begin, end }: Daterange) {
|
||||
return this.translations$.pipe(
|
||||
map((t) => {
|
||||
const beginStr = this.formatDate(begin, true, !begin.isSame(end, 'month'), !begin.isSame(end, 'year'));
|
||||
const endStr = this.formatDate(end, true, true, !isCurrentYear({ begin, end }));
|
||||
if (begin.isSame(end, 'day')) {
|
||||
return endStr;
|
||||
}
|
||||
return `${begin.date() === 2 ? t.fromStartWith2 : t.from} ${beginStr} ${t.to} ${endStr}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private capitalizeFirstLetter(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
private formatDate(date: Moment, d: boolean = false, m: boolean = false, y: boolean = false) {
|
||||
return formatDate(date.toDate(), [d && 'd', m && 'MMMM', y && 'y'].filter((v) => v).join(' '), this.locale);
|
||||
}
|
||||
|
||||
private formatStandaloneDate(date: Moment, d: boolean = false, m: boolean = false, y: boolean = false) {
|
||||
return formatDate(date.toDate(), [d && 'd', m && 'LLLL', y && 'y'].filter((v) => v).join(' '), this.locale);
|
||||
}
|
||||
|
||||
private translate(key: string) {
|
||||
return this.transloco.selectTranslate(key, null, 'daterange|scoped');
|
||||
}
|
||||
|
||||
private loadTranslations() {
|
||||
const words = ['from', 'fromStartWith2', 'to', 'today', 'currentWeek', 'year'] as const;
|
||||
return of(words).pipe(
|
||||
switchMap((w) => merge(...w.map((word) => this.translate(word).pipe(map((t: string) => ({ [word]: t })))))),
|
||||
scan((acc, t) => ({ ...acc, ...t }), {} as { [N in typeof words[number]]: string }),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
}
|
9
src/pipes/daterange/daterange.ts
Normal file
9
src/pipes/daterange/daterange.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Moment } from 'moment';
|
||||
|
||||
export interface Daterange {
|
||||
begin: Moment;
|
||||
end: Moment;
|
||||
}
|
||||
|
||||
export const isDaterange = (daterange: Partial<Daterange>): daterange is Daterange =>
|
||||
!!daterange?.begin && !!daterange?.end;
|
6
src/pipes/daterange/index.ts
Normal file
6
src/pipes/daterange/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './daterange.module';
|
||||
export * from './daterange.pipe';
|
||||
export * from './daterange.service';
|
||||
export * from './daterange-type';
|
||||
export * from './daterange-current-type';
|
||||
export * from './daterange';
|
@ -28,6 +28,8 @@
|
||||
@import '../../components/indicators/colored-icon/colored-icon-theme';
|
||||
@import '../../components/indicators/last-updated/last-updated-theme';
|
||||
@import '../../components/navigation/state-nav/state-nav-theme';
|
||||
@import '../../components/filters/filter/filter-theme';
|
||||
@import '../../components/filters/daterange-filter/daterange-filter-theme';
|
||||
@import '../../components/navigation/navbar/navbar-theme';
|
||||
@import '../../components/global-banner/global-banner-theme';
|
||||
|
||||
@ -88,6 +90,8 @@
|
||||
@include dsh-webhooks-panels-list-theme($theme);
|
||||
@include dsh-wallets-panels-list-theme($theme);
|
||||
@include dsh-shop-selector-theme($theme);
|
||||
@include dsh-filter-theme($theme);
|
||||
@include dsh-daterange-filter-theme($theme);
|
||||
@include dsh-global-banner-theme($theme);
|
||||
@include dsh-row-theme($theme);
|
||||
@include dsh-accordion-theme($theme);
|
||||
|
10
src/type-utils/component-changes.ts
Normal file
10
src/type-utils/component-changes.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { SimpleChange } from '@angular/core';
|
||||
|
||||
export interface ComponentChange<T, P extends keyof T> extends Omit<SimpleChange, 'previousValue' | 'currentValue'> {
|
||||
previousValue: T[P];
|
||||
currentValue: T[P];
|
||||
}
|
||||
|
||||
export type ComponentChanges<T> = {
|
||||
[P in keyof T]?: ComponentChange<T, P>;
|
||||
};
|
@ -1,3 +1,4 @@
|
||||
export * from './replace';
|
||||
export * from './mapping';
|
||||
export * from './map-tuple';
|
||||
export * from './component-changes';
|
||||
|
@ -18,7 +18,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"noUnusedParameters": true,
|
||||
"paths": {
|
||||
"@dsh/components/*": ["src/components/*"]
|
||||
"@dsh/components/*": ["src/components/*"],
|
||||
"@dsh/pipes/*": ["src/pipes/*"]
|
||||
}
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
|
@ -4,7 +4,14 @@
|
||||
"directive-selector": [true, ["attribute", "element"], "dsh", ["camelCase", "kebab-case"]],
|
||||
"component-selector": [true, ["attribute", "element"], "dsh", "kebab-case"],
|
||||
"template-no-negated-async": false,
|
||||
"import-blacklist": [true, "rxjs/Rx", "lodash", "lodash-es", ".", [".*\\./components/.*", "src/.+"]],
|
||||
"import-blacklist": [
|
||||
true,
|
||||
"rxjs/Rx",
|
||||
"lodash",
|
||||
"lodash-es",
|
||||
".",
|
||||
[".*\\./components/.*", ".*\\./pipes/.*", "src/.+"]
|
||||
],
|
||||
"no-unused-variable": [true, { "ignore-pattern": "^_" }],
|
||||
"variable-name": {
|
||||
"options": ["allow-leading-underscore", "ban-keywords", "check-format", "allow-pascal-case"]
|
||||
|
Loading…
Reference in New Issue
Block a user