Daterange & Multiselest filters (#267)

This commit is contained in:
Rinat Arsaev 2020-08-11 16:51:06 +03:00 committed by GitHub
parent d0744efc7f
commit 8cf96c561f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 1300 additions and 146 deletions

View File

@ -28,6 +28,7 @@
"isempty",
"keycloak",
"mastercard",
"multiselect",
"ngneat",
"pdfmake",
"rbkmoney",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,8 @@ export class SearchFormComponent implements OnInit {
shareReplay(1)
);
shops$ = this.searchFormService.shops$;
constructor(private searchFormService: SearchFormService) {}
ngOnInit() {

View File

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

View File

@ -0,0 +1,10 @@
{
"title": "Выберите период",
"menu": {
"today": "Сегодня",
"currentWeek": "Текущая неделя",
"threeMonths": "3 месяца",
"year": "{{year}} год",
"anotherPeriod": "Другой период"
}
}

View File

@ -0,0 +1,8 @@
{
"today": "Сегодня",
"from": "С",
"fromStartWith2": "Со",
"to": "по",
"currentWeek": "Текущая неделя",
"year": "год"
}

View File

@ -0,0 +1,4 @@
{
"label": "Магазин",
"searchInputLabel": "Выбранные магазины"
}

View File

@ -6,13 +6,5 @@
"threeMonths": "3 месяца",
"year": "{{year}} год",
"period": "Указать период..."
},
"rangeDate": {
"today": "Сегодня",
"from": "С",
"fromStartWith2": "Со",
"to": "по",
"currentWeek": "Текущая неделя",
"year": "год"
}
}

View File

@ -5,6 +5,8 @@
"back": "Назад",
"next": "Далее",
"create": "Создать",
"save": "Сохранить",
"clear": "Очистить",
"clearForm": "Очистить форму",
"ago": "назад",
"cancel": "Отмена",

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
.dsh-daterange-filter-menu-item {
cursor: pointer;
&-another {
cursor: default;
}
}

View File

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

View File

@ -0,0 +1 @@
export * from './daterange-filter-menu.component';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
export * from './daterange-filter.component';
export * from './daterange-filter.module';
export { Daterange } from '@dsh/pipes/daterange';

View File

@ -0,0 +1,5 @@
@import './filter-button/filter-button-theme';
@mixin dsh-filter-theme($theme) {
@include dsh-filter-button-theme($theme);
}

View File

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

View File

@ -0,0 +1,9 @@
$dsh-filter-button-actions-padding: 24px;
:host {
display: block;
.actions {
padding: $dsh-filter-button-actions-padding;
}
}

View File

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

View File

@ -0,0 +1 @@
export * from './filter-button-actions.component';

View File

@ -0,0 +1 @@
<ng-content></ng-content>

View File

@ -0,0 +1,6 @@
$dsh-filter-button-padding: 24px;
:host {
display: block;
padding: $dsh-filter-button-padding;
}

View File

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

View File

@ -0,0 +1 @@
export * from './filter-button-content.component';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from './filter-button.component';

View File

@ -0,0 +1 @@
<div fxLayout fxLayoutGap="16px"><ng-content></ng-content></div>

View File

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

View File

@ -0,0 +1 @@
export * from './filter-group.component';

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

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

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

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

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

View File

@ -0,0 +1 @@
export * from './filters.module';

View File

@ -0,0 +1,3 @@
export * from './multiselect-filter.component';
export * from './multiselect-filter-option';
export * from './multiselect-filter.module';

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

View File

@ -0,0 +1 @@
export * from './multiselect-filter-option.component';

View File

@ -0,0 +1,3 @@
<mat-checkbox [checked]="selected$ | async" (change)="toggle.emit()">
<ng-content></ng-content>
</mat-checkbox>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ $indent: 10px;
&-wrapper {
perspective: 2000px;
margin: 15px;
width: 100%;
}

View File

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

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

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

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

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

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

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

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

View File

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

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

View File

@ -1,3 +1,4 @@
export * from './replace';
export * from './mapping';
export * from './map-tuple';
export * from './component-changes';

View File

@ -18,7 +18,8 @@
"resolveJsonModule": true,
"noUnusedParameters": true,
"paths": {
"@dsh/components/*": ["src/components/*"]
"@dsh/components/*": ["src/components/*"],
"@dsh/pipes/*": ["src/pipes/*"]
}
},
"angularCompilerOptions": {

View File

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