[epic] Operations payments refactoring (#371)

- added accordion panel list in payments
- added new quick filters(date, shops, invoices, binPan)
- added new details view inlined in list
- added new refunds view
- added new hold detail view
- implemented new error service api
- added base dialog support
This commit is contained in:
Evgenia Grigorieva 2021-01-28 13:21:24 +03:00 committed by GitHub
parent 3d0f1734ae
commit fb5adcc69d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
350 changed files with 10803 additions and 585 deletions

View File

@ -1,8 +1,6 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
process.env.CHROME_BIN = require('puppeteer').executablePath();
module.exports = function (config) {
config.set({
basePath: '',
@ -11,6 +9,7 @@ module.exports = function (config) {
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-spec-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma'),
],
@ -22,13 +21,31 @@ module.exports = function (config) {
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true,
},
reporters: ['progress', 'kjhtml'],
reporters: ['progress', 'kjhtml', 'spec'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: false,
browsers: ['ChromeHeadless'],
browsers: ['ChromeHeadless_no_sandbox'],
browserNoActivityTimeout: 300000,
browserDisconnectTimeout: 300000,
captureTimeout: 300000,
customLaunchers: {
ChromeHeadless_no_sandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-setuid-sandbox', '--headless', '--disable-gpu'],
},
},
singleRun: true,
restartOnFileChange: false,
specReporter: {
maxLogLines: 5, // limit number of lines logged per test
suppressErrorSummary: false, // do not print error summary
suppressFailed: false, // do not print information about failed tests
suppressPassed: false, // do not print information about passed tests
suppressSkipped: true, // do not print information about skipped tests
showSpecTiming: true, // print the time elapsed for each spec
failFast: false, // test would finish with error when a first fail occurs.
},
});
};

View File

@ -27,8 +27,8 @@ module.exports = function (config) {
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['ChromeHeadless_no_sandbox'],
browserNoActivityTimeout: 300000,
browserDisconnectTimeout: 300000,
browserNoActivityTimeout: 30 * 60 * 1000,
browserDisconnectTimeout: 30 * 60 * 1000,
captureTimeout: 300000,
customLaunchers: {
ChromeHeadless_no_sandbox: {
@ -36,7 +36,7 @@ module.exports = function (config) {
flags: ['--no-sandbox', '--disable-setuid-sandbox', '--headless', '--disable-gpu'],
},
},
singleRun: true,
singleRun: false,
restartOnFileChange: true,
specReporter: {
maxLogLines: 5, // limit number of lines logged per test

View File

@ -6,7 +6,7 @@
"stub": "ng serve --port 8000 --configuration=stub-keycloak",
"build": "ng build --prod --extraWebpackConfig webpack.extra.js --progress=false",
"test": "ng test",
"test-ci": "ng test --watch=false",
"test-ci": "ng run dashboard:test-ci",
"lint": "ng lint",
"lint-fix": "ng lint --fix",
"e2e": "ng e2e",

View File

@ -1,10 +1,10 @@
import { NgModule } from '@angular/core';
import { UuidGeneratorModule } from '../../shared';
import { IdGeneratorModule } from '../../shared';
import { OrganizationsService } from './organizations.service';
@NgModule({
imports: [UuidGeneratorModule],
imports: [IdGeneratorModule],
providers: [OrganizationsService],
})
export class OrganizationsModule {}

View File

@ -9,7 +9,7 @@ import {
RoleId,
RolesService,
} from '@dsh/api-codegen/organizations';
import { UuidGeneratorService } from '@dsh/app/shared';
import { IdGeneratorService } from '@dsh/app/shared';
import { WritableOrganization } from './types/writable-organization';
@ -19,7 +19,7 @@ export class OrganizationsService {
private orgsService: OrgsService,
private rolesService: RolesService,
private membersService: MembersService,
private uuidGeneratorService: UuidGeneratorService
private uuidGeneratorService: IdGeneratorService
) {}
listOrgMembership(limit?: number, continuationToken?: string) {

View File

@ -1,3 +1,3 @@
import { Organization } from '../../../api-codegen/organizations';
import { Organization } from '@dsh/api-codegen/organizations';
export type WritableOrganization = Omit<Organization, 'id' | 'createdAt'>;

View File

@ -1,8 +1,11 @@
import { NgModule } from '@angular/core';
import { IdGeneratorModule } from '@dsh/app/shared/services';
import { RefundService } from './refund.service';
@NgModule({
imports: [IdGeneratorModule],
providers: [RefundService],
})
export class RefundModule {}

View File

@ -2,14 +2,22 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { PaymentsService, Refund, RefundParams } from '@dsh/api-codegen/capi/swagger-codegen';
import { genXRequestID } from '../utils';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
@Injectable()
export class RefundService {
constructor(private paymentsService: PaymentsService) {}
constructor(private paymentsService: PaymentsService, private idsService: IdGeneratorService) {}
createRefund(invoiceID: string, paymentID: string, refundParams: RefundParams): Observable<Refund> {
return this.paymentsService.createRefund(genXRequestID(), invoiceID, paymentID, refundParams);
return this.paymentsService.createRefund(
this.idsService.generateRequestID(),
invoiceID,
paymentID,
refundParams
);
}
getRefunds(invoiceID: string, paymentID: string): Observable<Refund[]> {
return this.paymentsService.getRefunds(this.idsService.generateRequestID(), invoiceID, paymentID);
}
}

View File

@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import moment from 'moment';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SearchService } from '@dsh/api-codegen/anapi';
import { InlineResponse20010, PaymentSearchResult, SearchService } from '@dsh/api-codegen/anapi';
import { genXRequestID, toDateLike } from '../utils';
import { Duration, PaymentsSearchParams } from './model';
@ -17,7 +18,7 @@ export class PaymentSearchService {
params: PaymentsSearchParams,
limit: number,
continuationToken?: string
) {
): Observable<InlineResponse20010> {
return this.searchService.searchPayments(
genXRequestID(),
toDateLike(fromTime),
@ -58,13 +59,13 @@ export class PaymentSearchService {
params: PaymentsSearchParams,
limit: number,
continuationToken?: string
) {
): Observable<InlineResponse20010> {
const from = moment().subtract(amount, unit).startOf('d').utc().format();
const to = moment().endOf('d').utc().format();
return this.searchPayments(from, to, params, limit, continuationToken);
}
getPaymentByDuration(duration: Duration, invoiceID: string, paymentID: string) {
getPaymentByDuration(duration: Duration, invoiceID: string, paymentID: string): Observable<PaymentSearchResult> {
return this.searchPaymentsByDuration(
duration,
{

View File

@ -1,11 +1,11 @@
import { NgModule } from '@angular/core';
import { SenderModule as ApiSenderModule } from '../../api-codegen/sender';
import { UuidGeneratorModule } from '../../shared/services';
import { IdGeneratorModule } from '../../shared/services';
import { MessagesService } from './services/messages/messages.service';
@NgModule({
imports: [ApiSenderModule, UuidGeneratorModule],
imports: [ApiSenderModule, IdGeneratorModule],
providers: [MessagesService],
})
export class SenderModule {}

View File

@ -1,11 +1,12 @@
import { Injectable } from '@angular/core';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
import { MessagesService as ApiMessagesService } from '../../../../api-codegen/sender';
import { UuidGeneratorService } from '../../../../shared/services/uuid-generator/uuid-generator.service';
@Injectable()
export class MessagesService {
constructor(private messagesService: ApiMessagesService, private idGeneratorService: UuidGeneratorService) {}
constructor(private messagesService: ApiMessagesService, private idGeneratorService: IdGeneratorService) {}
sendFeedbackEmailMsg(text: string) {
return this.messagesService.sendFeedbackEmailMsg(this.idGeneratorService.generateUUID(), { text });

View File

@ -3,4 +3,4 @@ import { Shop } from '@dsh/api-codegen/capi';
import { findShopByID } from './find-shop-by-id';
import { getShopName } from './get-shop-name';
export const toShopName = (s: Shop[], shopID: string): string | null => getShopName(findShopByID(s, shopID));
export const getShopNameById = (s: Shop[], shopID: string): string | null => getShopName(findShopByID(s, shopID));

View File

@ -6,7 +6,7 @@ import { catchError, first, mapTo, shareReplay, switchMap, switchMapTo, takeLast
import { ApiShopsService, CAPIClaimsService, CAPIPartiesService, createTestShopClaimChangeset } from '@dsh/api';
import { Claim } from '@dsh/api-codegen/capi';
import { ErrorService } from '@dsh/app/shared';
import { NotificationService } from '@dsh/app/shared';
@UntilDestroy()
@Injectable()
@ -23,7 +23,7 @@ export class BootstrapService {
private shopService: ApiShopsService,
private capiClaimsService: CAPIClaimsService,
private capiPartiesService: CAPIPartiesService,
private errorService: ErrorService,
private notificationService: NotificationService,
// TODO: Wait access check
// private organizationsService: OrganizationsService,
// private userService: UserService,
@ -43,8 +43,8 @@ export class BootstrapService {
).pipe(
takeLast(1),
mapTo(true),
catchError((err) => {
this.errorService.error(err, this.transloco.translate('errors.bootstrapAppFailed'));
catchError(() => {
this.notificationService.error(this.transloco.translate('errors.bootstrapAppFailed'));
return of(false);
})
);

View File

@ -7,6 +7,7 @@ import { anyString, instance, mock, verify, when } from 'ts-mockito';
import { MessagesService } from '@dsh/api/sender';
import { ErrorModule, ErrorService, NotificationService } from '@dsh/app/shared/services';
import { getTranslocoModule } from '@dsh/app/shared/tests/get-transloco-module';
import { FeedbackDialogComponent } from './feedback-dialog.component';
@ -27,7 +28,7 @@ describe('FeedbackDialogComponent', () => {
mockNotificationService = mock(NotificationService);
await TestBed.configureTestingModule({
imports: [MatDialogModule, ErrorModule, NoopAnimationsModule],
imports: [getTranslocoModule(), MatDialogModule, ErrorModule, NoopAnimationsModule],
declarations: [FeedbackDialogComponent],
providers: [
{
@ -71,10 +72,11 @@ describe('FeedbackDialogComponent', () => {
});
it("shouldn't send message", () => {
when(mockMessagesService.sendFeedbackEmailMsg(anyString())).thenReturn(throwError('Test error'));
const error = new Error('Test error');
when(mockMessagesService.sendFeedbackEmailMsg(anyString())).thenReturn(throwError(error));
component.send();
verify(mockMessagesService.sendFeedbackEmailMsg('')).once();
verify(mockErrorService.error('Test error')).once();
verify(mockErrorService.error(error)).once();
verify(mockMatDialogRef.close()).never();
expect().nothing();
});

View File

@ -21,6 +21,7 @@
"input",
"keyboard_arrow_down",
"keyboard_arrow_up",
"launch",
"logo",
"logo_white",
"mastercard",

View File

@ -10,7 +10,6 @@ import { InvoiceDetailsService } from './invoice-details.service';
@Component({
templateUrl: 'invoice-details.component.html',
styleUrls: ['invoice-details.component.scss'],
providers: [InvoiceDetailsService],
})
export class InvoiceDetailsComponent implements OnInit {
invoice$ = this.invoiceDetailsService.invoice$;

View File

@ -6,11 +6,11 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslocoModule } from '@ngneat/transloco';
import { InvoiceSearchService, PaymentSearchService } from '@dsh/api/search';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../to-major';
import { CreatePaymentLinkModule } from '../create-payment-link';
import { ShopDetailsModule } from '../shop-details/shop-details.module';
import { CartComponent } from './cart/cart.component';
@ -19,6 +19,7 @@ import { DetailsComponent } from './details/details.component';
import { StatusDetailsItemComponent } from './details/status-details-item';
import { InvoiceDetailsRoutingModule } from './invoice-details-routing.module';
import { InvoiceDetailsComponent } from './invoice-details.component';
import { InvoiceDetailsService } from './invoice-details.service';
import { CreatePaymentLinkDialogComponent, PaymentLinkComponent } from './payment-link';
import { PaymentComponent } from './payments/payment/payment.component';
import { PaymentsComponent } from './payments/payments.component';
@ -50,6 +51,6 @@ import { PaymentsComponent } from './payments/payments.component';
CreatePaymentLinkDialogComponent,
],
exports: [StatusDetailsItemComponent],
providers: [InvoiceSearchService, PaymentSearchService],
providers: [InvoiceSearchService, PaymentSearchService, InvoiceDetailsService],
})
export class InvoiceDetailsModule {}

View File

@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
export const DEBOUNCE_FETCHER_ACTION_TIME = new InjectionToken<number>('debounceFetcherActionTime');
export const DEFAULT_FETCHER_DEBOUNCE_ACTION_TIME = 300;

View File

@ -1,3 +1,5 @@
export * from './partial-fetcher';
export * from './fetch-result';
export * from './fetch-action';
export * from './indicators-partial-fetcher.service';
export * from './consts';

View File

@ -0,0 +1,26 @@
import { Inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
import { SEARCH_LIMIT } from '@dsh/app/sections/tokens';
import { booleanDebounceTime, mapToTimestamp } from '@dsh/operators';
import { DEBOUNCE_FETCHER_ACTION_TIME } from './consts';
import { PartialFetcher } from './partial-fetcher';
// TODO: remove this disable after making partial fetcher with injectable debounce time
/* tslint:disable:no-unused-variable */
@Injectable()
export abstract class IndicatorsPartialFetcher<R, P> extends PartialFetcher<R, P> {
isLoading$: Observable<boolean> = this.doAction$.pipe(booleanDebounceTime(), shareReplay(1));
lastUpdated$: Observable<string> = this.searchResult$.pipe(mapToTimestamp, shareReplay(1));
constructor(
@Inject(SEARCH_LIMIT)
protected searchLimit: number,
@Inject(DEBOUNCE_FETCHER_ACTION_TIME)
protected debounceActionTime: number
) {
super(debounceActionTime);
}
}

View File

@ -1,3 +1,4 @@
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { EMPTY, merge, Observable, of, Subject } from 'rxjs';
import {
debounceTime,
@ -12,15 +13,16 @@ import {
tap,
} from 'rxjs/operators';
import { progress, SHARE_REPLAY_CONF } from '../../custom-operators';
import { progress, SHARE_REPLAY_CONF } from '@dsh/operators';
import { FetchAction } from './fetch-action';
import { FetchFn } from './fetch-fn';
import { FetchResult } from './fetch-result';
import { scanAction, scanFetchResult } from './operators';
// TODO: make fetcher injectable
@UntilDestroy()
export abstract class PartialFetcher<R, P> {
private action$ = new Subject<FetchAction<P>>();
readonly fetchResultChanges$: Observable<{ result: R[]; hasMore: boolean; continuationToken: string }>;
readonly searchResult$: Observable<R[]>;
@ -29,13 +31,16 @@ export abstract class PartialFetcher<R, P> {
readonly doSearchAction$: Observable<boolean>;
readonly errors$: Observable<any>;
private action$ = new Subject<FetchAction<P>>();
// TODO: make a dependency for DI
constructor(debounceActionTime: number = 300) {
const actionWithParams$ = this.getActionWithParams(debounceActionTime);
const fetchResult$ = this.getFetchResult(actionWithParams$);
this.fetchResultChanges$ = fetchResult$.pipe(
map(({ result, continuationToken }) => ({
result,
result: result ?? [],
continuationToken,
hasMore: !!continuationToken,
})),
@ -69,7 +74,9 @@ export abstract class PartialFetcher<R, P> {
this.doSearchAction$,
this.errors$,
this.fetchResultChanges$
).subscribe();
)
.pipe(untilDestroyed(this))
.subscribe();
}
search(value: P) {

View File

@ -3,10 +3,10 @@ import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { CardModule, DetailsItemModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../to-major';
import { StatusDetailsItemModule } from '../status-details-item';
import { DetailsComponent } from './details.component';
import { ErrorToMessagePipe } from './error-to-message.pipe';

View File

@ -5,10 +5,10 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslocoModule } from '@ngneat/transloco';
import { InvoiceSearchService } from '@dsh/api/search';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { SpinnerModule } from '@dsh/components/indicators';
import { CardModule, DetailsItemModule, LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../to-major';
import { StatusDetailsItemModule } from '../status-details-item';
import { InvoiceDetailsComponent } from './invoice-details.component';

View File

@ -7,12 +7,12 @@ import { TranslocoModule, TRANSLOCO_SCOPE } from '@ngneat/transloco';
import { InvoiceModule } from '@dsh/api/invoice';
import { SearchModule } from '@dsh/api/search';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { HumanizeDurationModule } from '../../humanize-duration';
import { ToMajorModule } from '../../to-major';
import { ShopDetailsModule } from '../shop-details/shop-details.module';
import { DetailsModule } from './details';
import { HoldDetailsModule } from './hold-details';

View File

@ -1 +1,2 @@
// TODO: remove module when operations/payments epic migration would be finished
export * from './create-refund.component';

View File

@ -12,11 +12,11 @@ import { AccountService } from '@dsh/api/account';
import { RefundService } from '@dsh/api/refund';
import { RefundSearchService } from '@dsh/api/search';
import { ApiShopsService } from '@dsh/api/shop';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { FormControlsModule } from '@dsh/components/form-controls';
import { DetailsItemModule, LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../to-major';
import { StatusDetailsItemModule } from '../status-details-item';
import { CreateRefundComponent } from './create-refund';
import { RefundItemComponent } from './refund-item';

View File

@ -3,10 +3,10 @@ import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { SpinnerModule } from '@dsh/components/indicators';
import { CardModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../../to-major';
import { PercentDifferenceModule } from '../percent-difference';
import { StatItemComponent } from './stat-item.component';

View File

@ -4,8 +4,8 @@ import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { AnalyticsModule } from '@dsh/api/analytics';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ToMajorModule } from '../../../to-major';
import { BalancesComponent } from './balances.component';
@NgModule({

View File

@ -7,7 +7,7 @@ import { deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { Claim, Modification } from '@dsh/api-codegen/claim-management';
import { ClaimsService } from '@dsh/api/claims';
import { createTestContractCreationModification } from '@dsh/api/claims/claim-party-modification';
import { UuidGeneratorService } from '@dsh/app/shared/services/uuid-generator/uuid-generator.service';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
import { createTestContractPayoutToolModification } from '../../tests/create-test-contract-payout-tool-modification';
import { createTestInternationalLegalEntityModification } from '../../tests/create-test-international-legal-entity-modification';
@ -20,11 +20,11 @@ const TEST_UUID = 'test-uuid';
describe('CreateInternationalShopEntityService', () => {
let service: CreateInternationalShopEntityService;
let mockClaimsService: ClaimsService;
let mockUuidGeneratorService: UuidGeneratorService;
let mockIdGeneratorService: IdGeneratorService;
beforeEach(() => {
mockClaimsService = mock(ClaimsService);
mockUuidGeneratorService = mock(UuidGeneratorService);
mockIdGeneratorService = mock(IdGeneratorService);
});
beforeEach(() => {
@ -36,8 +36,8 @@ describe('CreateInternationalShopEntityService', () => {
useFactory: () => instance(mockClaimsService),
},
{
provide: UuidGeneratorService,
useFactory: () => instance(mockUuidGeneratorService),
provide: IdGeneratorService,
useFactory: () => instance(mockIdGeneratorService),
},
],
});
@ -82,7 +82,7 @@ describe('CreateInternationalShopEntityService', () => {
];
beforeEach(() => {
when(mockUuidGeneratorService.generateUUID()).thenReturn(TEST_UUID);
when(mockIdGeneratorService.generateUUID()).thenReturn(TEST_UUID);
when(mockClaimsService.createClaim(deepEqual(modifications))).thenReturn(of(claim));
when(mockClaimsService.requestReviewClaimByID(claim.id, claim.revision)).thenReturn(of(null));
});

View File

@ -12,13 +12,13 @@ import {
makeShopLocation,
} from '@dsh/api/claims/claim-party-modification';
import { createInternationalContractPayoutToolModification } from '@dsh/api/claims/claim-party-modification/claim-contract-modification/create-international-contract-payout-tool-modification';
import { UuidGeneratorService } from '@dsh/app/shared/services/uuid-generator/uuid-generator.service';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
import { InternationalShopEntityFormValue } from '../../types/international-shop-entity-form-value';
@Injectable()
export class CreateInternationalShopEntityService {
constructor(private claimsService: ClaimsService, private uuidGenerator: UuidGeneratorService) {}
constructor(private claimsService: ClaimsService, private idGenerator: IdGeneratorService) {}
createShop(creationData: InternationalShopEntityFormValue) {
return this.claimsService.createClaim(this.createClaimsModifications(creationData)).pipe(
@ -39,10 +39,10 @@ export class CreateInternationalShopEntityService {
payoutTool,
correspondentPayoutTool = null,
}: InternationalShopEntityFormValue): Modification[] {
const contractorID = this.uuidGenerator.generateUUID();
const contractID = this.uuidGenerator.generateUUID();
const payoutToolID = this.uuidGenerator.generateUUID();
const shopID = this.uuidGenerator.generateUUID();
const contractorID = this.idGenerator.generateUUID();
const contractID = this.idGenerator.generateUUID();
const payoutToolID = this.idGenerator.generateUUID();
const shopID = this.idGenerator.generateUUID();
return [
createInternationalLegalEntityModification(contractorID, {

View File

@ -13,7 +13,7 @@ import { TranslocoModule } from '@ngneat/transloco';
import { ClaimsModule } from '@dsh/api/claims';
import { PayoutToolDetailsModule } from '@dsh/app/shared/components';
import { AutocompleteVirtualScrollModule } from '@dsh/app/shared/components/selects/autocomplete-virtual-scroll';
import { UuidGeneratorModule } from '@dsh/app/shared/services';
import { IdGeneratorModule } from '@dsh/app/shared/services';
import { ButtonModule } from '@dsh/components/buttons';
import { FormatInputModule } from '@dsh/components/form-controls';
import { DetailsItemModule } from '@dsh/components/layout';
@ -46,7 +46,7 @@ import { CreateRussianShopEntityService } from './services/create-russian-shop-e
AutocompleteVirtualScrollModule,
MatDialogModule,
ClaimsModule,
UuidGeneratorModule,
IdGeneratorModule,
],
declarations: [
CreateRussianShopEntityComponent,

View File

@ -8,7 +8,7 @@ import { Contract } from '@dsh/api-codegen/capi';
import { Claim, Modification } from '@dsh/api-codegen/claim-management';
import { ClaimsService } from '@dsh/api/claims';
import { createTestContractCreationModification } from '@dsh/api/claims/claim-party-modification';
import { UuidGeneratorService } from '@dsh/app/shared/services/uuid-generator/uuid-generator.service';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
import { createTestLegalEntityModification } from '../../tests/create-test-legal-entity-modification';
import { createTestRussianContractPayoutToolModification } from '../../tests/create-test-russian-contract-payout-tool-modification';
@ -21,11 +21,11 @@ const TEST_UUID = 'test-uuid';
describe('CreateRussianShopEntityService', () => {
let service: CreateRussianShopEntityService;
let mockClaimsService: ClaimsService;
let mockUuidGeneratorService: UuidGeneratorService;
let mockIdGeneratorService: IdGeneratorService;
beforeEach(() => {
mockClaimsService = mock(ClaimsService);
mockUuidGeneratorService = mock(UuidGeneratorService);
mockIdGeneratorService = mock(IdGeneratorService);
});
beforeEach(() => {
@ -37,8 +37,8 @@ describe('CreateRussianShopEntityService', () => {
useFactory: () => instance(mockClaimsService),
},
{
provide: UuidGeneratorService,
useFactory: () => instance(mockUuidGeneratorService),
provide: IdGeneratorService,
useFactory: () => instance(mockIdGeneratorService),
},
],
});
@ -90,7 +90,7 @@ describe('CreateRussianShopEntityService', () => {
let modifications: Modification[];
beforeEach(() => {
when(mockUuidGeneratorService.generateUUID()).thenReturn(TEST_UUID);
when(mockIdGeneratorService.generateUUID()).thenReturn(TEST_UUID);
});
afterEach(() => {

View File

@ -13,13 +13,13 @@ import {
createShopCreationModification,
makeShopLocation,
} from '@dsh/api/claims/claim-party-modification';
import { UuidGeneratorService } from '@dsh/app/shared/services/uuid-generator/uuid-generator.service';
import { IdGeneratorService } from '@dsh/app/shared/services/id-generator/id-generator.service';
import { RussianShopCreateData } from '../../types/russian-shop-create-data';
@Injectable()
export class CreateRussianShopEntityService {
constructor(private claimsService: ClaimsService, private uuidGenerator: UuidGeneratorService) {}
constructor(private claimsService: ClaimsService, private idGenerator: IdGeneratorService) {}
createShop(creationData: RussianShopCreateData): Observable<Claim> {
return this.claimsService.createClaim(this.createShopCreationModifications(creationData)).pipe(
@ -37,11 +37,11 @@ export class CreateRussianShopEntityService {
payoutToolID: shopPayoutToolID,
bankAccount: { account, bankName, bankPostAccount, bankBik },
}: RussianShopCreateData): PartyModification[] {
const contractorID = this.uuidGenerator.generateUUID();
const contractID = this.uuidGenerator.generateUUID();
const shopID = this.uuidGenerator.generateUUID();
const contractorID = this.idGenerator.generateUUID();
const contractID = this.idGenerator.generateUUID();
const shopID = this.idGenerator.generateUUID();
let payoutToolID = this.uuidGenerator.generateUUID();
let payoutToolID = this.idGenerator.generateUUID();
const payoutChangeset: PartyModification[] = [];
if (isNil(shopPayoutToolID)) {

View File

@ -2,7 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslocoTestingModule } from '@ngneat/transloco';
import { QueryFilterModule } from '@dsh/components/filters/query-filter';
import { QueryFilterModule } from '@dsh/app/shared/components/filters/query-filter';
import { ShopQueryFilterComponent } from './shop-query-filter.component';

View File

@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { QueryFilterModule } from '@dsh/components/filters/query-filter';
import { QueryFilterModule } from '@dsh/app/shared/components/filters/query-filter';
import { ShopQueryFilterComponent } from './shop-query-filter.component';

View File

@ -1,7 +1,8 @@
import { ChangeDetectionStrategy } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ToMajorModule } from '../../../../../../to-major';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { generateMockBalance } from '../../tests/generate-mock-balance';
import { generateMockShop } from '../../tests/generate-mock-shop';
import { ShopBalanceComponent } from './shop-balance.component';

View File

@ -1,7 +1,8 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ToMajorModule } from '../../../../../../to-major';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ShopBalanceComponent } from './shop-balance.component';
@NgModule({

View File

@ -3,13 +3,13 @@ import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { SpinnerModule } from '@dsh/components/indicators';
import { LastUpdatedModule } from '@dsh/components/indicators/last-updated/last-updated.module';
import { AccordionModule, CardModule, ExpandPanelModule, RowModule } from '@dsh/components/layout';
import { ShowMorePanelModule } from '@dsh/components/show-more-panel';
import { ToMajorModule } from '../../../../../to-major';
import { ShopRowHeaderComponent } from './components/shop-row-header/shop-row-header.component';
import { ShopRowComponent } from './components/shop-row/shop-row.component';
import { ShopBalanceModule } from './shop-balance';
@ -34,6 +34,6 @@ import { ShopsListComponent } from './shops-list.component';
ExpandPanelModule,
],
declarations: [ShopsListComponent, ShopRowHeaderComponent, ShopRowComponent],
exports: [ShopsListComponent],
exports: [ShopsListComponent, ShopRowComponent],
})
export class ShopListModule {}

View File

@ -6,13 +6,13 @@ import { TranslocoTestingModule } from '@ngneat/transloco';
import { of } from 'rxjs';
import { instance, mock, verify, when } from 'ts-mockito';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { SpinnerModule } from '@dsh/components/indicators';
import { LastUpdatedModule } from '@dsh/components/indicators/last-updated/last-updated.module';
import { AccordionModule, CardModule, ExpandPanelModule, RowModule } from '@dsh/components/layout';
import { ShowMorePanelModule } from '@dsh/components/show-more-panel';
import { ToMajorModule } from '../../../../../to-major';
import { generateMockShopsItemList } from '../tests/generate-mock-shops-item-list';
import { ShopRowHeaderComponent } from './components/shop-row-header/shop-row-header.component';
import { ShopRowComponent } from './components/shop-row/shop-row.component';

View File

@ -3,7 +3,8 @@
{{ t('title') }}
</div>
<div *ngFor="let payment of payments$ | async as payments; index as i" fxLayout="column" fxLayoutGap="24px">
<div class="dsh-subheading-2">{{ t('payment') }} #{{ payment.id }}</div>
<!-- TODO: refactor payments display -->
<div class="dsh-subheading-2">{{ t('payment') }} #{{ payment.invoiceID + '_' + payment.id }}</div>
<dsh-payment-details [payment]="payment"></dsh-payment-details>
<mat-divider *ngIf="i < payments.length - 1"></mat-divider>
</div>

View File

@ -11,12 +11,11 @@ import {
PaymentDetailsModule,
RefundDetailsModule as ApiRefundDetailsModule,
} from '@dsh/app/shared/components';
import { ApiModelRefsModule } from '@dsh/app/shared/pipes';
import { ApiModelRefsModule, ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../../../../to-major';
import { CancelInvoiceModule } from './cancel-invoice';
import { InvoiceActionsComponent } from './components/invoice-actions/invoice-actions.component';
import { InvoiceCartLineComponent } from './components/invoice-cart-info/cart-info/invoice-cart-line.component';

View File

@ -6,11 +6,10 @@ import { TranslocoModule } from '@ngneat/transloco';
import { InvoiceModule } from '@dsh/api/invoice';
import { InvoiceDetailsModule as ApiInvoiceDetailsModule } from '@dsh/app/shared/components';
import { ApiModelRefsModule } from '@dsh/app/shared/pipes';
import { ApiModelRefsModule, ToMajorModule } from '@dsh/app/shared/pipes';
import { IndicatorsModule } from '@dsh/components/indicators';
import { LayoutModule } from '@dsh/components/layout';
import { ToMajorModule } from '../../../../../to-major';
import { InvoiceRowHeaderComponent } from './components/invoice-row-header/invoice-row-header.component';
import { InvoiceRowComponent } from './components/invoice-row/invoice-row.component';
import { InvoiceDetailsModule } from './invoice-details';

View File

@ -15,6 +15,7 @@ import { TranslocoModule, TRANSLOCO_SCOPE } from '@ngneat/transloco';
import { InvoiceModule } from '@dsh/api/invoice';
import { InvoiceDetailsModule } from '@dsh/app/shared/components';
import { ToMajorModule } from '@dsh/app/shared/pipes';
import { ButtonModule } from '@dsh/components/buttons';
import { EmptySearchResultModule } from '@dsh/components/empty-search-result';
import { FormControlsModule, RangeDatepickerModule } from '@dsh/components/form-controls';
@ -25,7 +26,6 @@ import { ShowMorePanelModule } from '@dsh/components/show-more-panel';
import { TableModule } from '@dsh/components/table';
import { LanguageModule } from '../../../../language';
import { ToMajorModule } from '../../../../to-major';
import { ShopSelectorModule } from '../../../shop-selector';
import { CreateInvoiceModule } from './create-invoice';
import { InvoicesListModule } from './invoices-list';

View File

@ -2,7 +2,7 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { Invoice, Shop } from '@dsh/api-codegen/anapi';
import { toShopName } from '@dsh/api/shop/utils';
import { getShopNameById } from '@dsh/api/shop/utils';
import { InvoicesTableData } from './table';
@ -15,7 +15,7 @@ const toInvoiceTableData = (
status,
createdAt: createdAt as any,
invoiceID: id,
shopName: toShopName(s, shopID),
shopName: getShopNameById(s, shopID),
product,
});

View File

@ -24,3 +24,4 @@
</div>
</div>
</div>
<dsh-scroll-up [hideAfter]="200"></dsh-scroll-up>

View File

@ -5,12 +5,21 @@ import { TranslocoModule } from '@ngneat/transloco';
import { SearchModule } from '@dsh/api/search';
import { LayoutModule } from '@dsh/components/layout';
import { ScrollUpModule } from '@dsh/components/navigation';
import { OperationsRoutingModule } from './operations-routing.module';
import { OperationsComponent } from './operations.component';
@NgModule({
imports: [CommonModule, OperationsRoutingModule, LayoutModule, FlexLayoutModule, SearchModule, TranslocoModule],
imports: [
CommonModule,
OperationsRoutingModule,
LayoutModule,
FlexLayoutModule,
SearchModule,
TranslocoModule,
ScrollUpModule,
],
declarations: [OperationsComponent],
})
export class OperationsModule {}

View File

@ -0,0 +1,4 @@
import { InjectionToken } from '@angular/core';
export const PAYMENTS_UPDATE_DELAY_TOKEN = new InjectionToken<number>('payments-update-delay-token');
export const DEFAULT_PAYMENTS_UPDATE_DELAY = 300;

View File

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

View File

@ -1,26 +0,0 @@
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PaymentSearchResult, Shop } from '@dsh/api-codegen/capi';
import { toShopName } from '@dsh/api/shop/utils';
import { PaymentsTableData } from './table';
const toPaymentTableData = (
{ amount, status, statusChangedAt, invoiceID, shopID, id, currency }: PaymentSearchResult,
s: Shop[]
): PaymentsTableData | null => ({
amount,
status,
currency,
invoiceID,
statusChangedAt: statusChangedAt as any,
paymentID: id,
shopName: toShopName(s, shopID),
});
const paymentsToTableData = (searchResult: PaymentSearchResult[], s: Shop[]) =>
searchResult.map((r) => toPaymentTableData(r, s));
export const mapToPaymentsTableData = (s: Observable<[PaymentSearchResult[], Shop[]]>) =>
s.pipe(map(([searchResult, shops]) => paymentsToTableData(searchResult, shops)));

View File

@ -0,0 +1,18 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatDividerModule } from '@angular/material/divider';
import { TranslocoModule } from '@ngneat/transloco';
import { BaseDialogModule } from '@dsh/app/shared/components/dialog/base-dialog';
import { ButtonModule } from '@dsh/components/buttons';
import { AdditionalFiltersService } from './additional-filters.service';
import { DialogFiltersComponent } from './components/dialog-filters.component';
@NgModule({
imports: [CommonModule, BaseDialogModule, MatDividerModule, FlexLayoutModule, ButtonModule, TranslocoModule],
declarations: [DialogFiltersComponent],
providers: [AdditionalFiltersService],
})
export class AdditionalFiltersModule {}

View File

@ -0,0 +1,13 @@
// TODO: implement unit tests
// describe('AdditionalFiltersService', () => {
// let service: AdditionalFiltersService;
//
// beforeEach(() => {
// TestBed.configureTestingModule({});
// service = TestBed.inject(AdditionalFiltersService);
// });
//
// it('should be created', () => {
// expect(service).toBeTruthy();
// });
// });

View File

@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { PaymentsAdditionalFilters } from '../types/payments-additional-filters';
import { DialogFiltersComponent } from './components/dialog-filters.component';
@Injectable()
export class AdditionalFiltersService {
constructor(private dialog: MatDialog) {}
openFiltersDialog(data: PaymentsAdditionalFilters): Observable<PaymentsAdditionalFilters> {
return this.dialog
.open<DialogFiltersComponent, PaymentsAdditionalFilters>(DialogFiltersComponent, {
panelClass: 'fill-bleed-dialog',
width: '552px',
minHeight: '400px',
disableClose: true,
data,
})
.afterClosed()
.pipe(take(1));
}
}

View File

@ -0,0 +1,16 @@
<ng-container *transloco="let t; read: 'filters'">
<dsh-base-dialog [title]="t('additionalFilters')">
<!-- TODO: add title close button -->
<div>filters should be here</div>
<div actions>
<div fxLayout="row" fxLayoutAlign="space-between center">
<button dsh-button (click)="clear()">
{{ t('clearParams') }}
</button>
<button dsh-button color="accent" (click)="confirm()">
{{ t('save') }}
</button>
</div>
</div>
</dsh-base-dialog>
</ng-container>

View File

@ -0,0 +1,5 @@
:host {
display: flex;
flex-direction: column;
flex-grow: 1;
}

View File

@ -0,0 +1,21 @@
// TODO: implement unit tests
// describe('DialogFiltersComponent', () => {
// let component: DialogFiltersComponent;
// let fixture: ComponentFixture<DialogFiltersComponent>;
//
// beforeEach(async(() => {
// TestBed.configureTestingModule({
// declarations: [DialogFiltersComponent],
// }).compileComponents();
// }));
//
// beforeEach(() => {
// fixture = TestBed.createComponent(DialogFiltersComponent);
// component = fixture.componentInstance;
// fixture.detectChanges();
// });
//
// it('should create', () => {
// expect(component).toBeTruthy();
// });
// });

View File

@ -0,0 +1,25 @@
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PaymentsAdditionalFilters } from '../../types/payments-additional-filters';
@Component({
selector: 'dsh-dialog-filters',
templateUrl: 'dialog-filters.component.html',
styleUrls: ['dialog-filters.component.scss'],
})
export class DialogFiltersComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: PaymentsAdditionalFilters,
private dialogRef: MatDialogRef<DialogFiltersComponent, PaymentsAdditionalFilters>
) {}
clear(): void {
// TODO: fix logic. Should not close dialog. Only reset params
this.dialogRef.close({});
}
confirm(): void {
this.dialogRef.close({ anyField: 'some value' });
}
}

View File

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

View File

@ -0,0 +1,24 @@
<ng-container *transloco="let t; scope: 'operations'; read: 'operations.payments.filter'">
<dsh-filter
[title]="titleValues ? t('binPan') + ' · ' + titleValues : t('binPan')"
[active]="isActive"
(opened)="popupOpened()"
(closed)="popupClosed()"
>
<div class="card-bin-pan-filter-content">
<dsh-filter-button-content>
<form [formGroup]="form" fxLayout="column">
<mat-form-field>
<mat-label>{{ t('first6') }}</mat-label>
<dsh-format-input format="bin" formControlName="bin"></dsh-format-input>
</mat-form-field>
<mat-form-field>
<mat-label>{{ t('last4') }}</mat-label>
<dsh-format-input format="lastDigits" formControlName="pan"></dsh-format-input>
</mat-form-field>
</form>
</dsh-filter-button-content>
<dsh-filter-button-actions (clear)="clear()" (save)="save()"></dsh-filter-button-actions>
</div>
</dsh-filter>
</ng-container>

View File

@ -0,0 +1,7 @@
$content-size: 360px;
.card-bin-pan-filter {
&-content {
width: $content-size;
}
}

View File

@ -0,0 +1,215 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { FlexLayoutModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { instance, mock, verify } from 'ts-mockito';
import { getTranslocoModule } from '@dsh/app/shared/tests/get-transloco-module';
import { FilterComponent, FilterModule } from '@dsh/components/filters/filter';
import { FormatInputModule } from '@dsh/components/form-controls';
import { ComponentChange } from '@dsh/type-utils';
import { CardBinPanFilterComponent } from './card-bin-pan-filter.component';
import { CardBinPan } from './types/card-bin-pan';
describe('CardBinPanFilterComponent', () => {
let component: CardBinPanFilterComponent;
let fixture: ComponentFixture<CardBinPanFilterComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
getTranslocoModule(),
NoopAnimationsModule,
FilterModule,
MatFormFieldModule,
FormatInputModule,
ReactiveFormsModule,
FlexLayoutModule,
],
declarations: [CardBinPanFilterComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CardBinPanFilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('ngOnChanges', () => {
function tickBinPanChanges(
binPan: CardBinPan,
change: Partial<ComponentChange<CardBinPanFilterComponent, 'binPan'>> = {}
): void {
component.binPan = binPan;
component.ngOnChanges({
binPan: {
previousValue: undefined,
currentValue: component.binPan,
firstChange: false,
isFirstChange(): boolean {
return false;
},
...change,
},
});
}
beforeEach(() => {
tickBinPanChanges(
{ bin: null, pan: null },
{
firstChange: true,
isFirstChange(): boolean {
return true;
},
}
);
});
it('should update on any change', () => {
expect(component.titleValues).toBe('');
expect(component.form.value).toEqual({ bin: null, pan: null });
expect(component.isActive).toBe(false);
tickBinPanChanges({ bin: '123456', pan: '1234' });
expect(component.titleValues).toBe('1234 56** **** 1234');
expect(component.form.value).toEqual({ bin: '123456', pan: '1234' });
expect(component.isActive).toBe(true);
});
describe('update title', () => {
it('should format title using values', () => {
tickBinPanChanges({ bin: '111122', pan: '4444' });
expect(component.titleValues).toBe('1111 22** **** 4444');
});
it('should format only bin', () => {
tickBinPanChanges({ bin: '123456', pan: null });
expect(component.titleValues).toBe('1234 56** **** ****');
});
it('should format only pan', () => {
tickBinPanChanges({ bin: null, pan: '1234' });
expect(component.titleValues).toBe('**** **** **** 1234');
});
});
describe('update isActive status', () => {
it('should set isActive to true if any of values in binPan exist', () => {
tickBinPanChanges({ bin: null, pan: '4444' });
expect(component.isActive).toBe(true);
tickBinPanChanges({ bin: '111122', pan: null });
expect(component.isActive).toBe(true);
tickBinPanChanges({ bin: '111122', pan: '4444' });
expect(component.isActive).toBe(true);
});
it('should set isActive to true if none of values in binPan exist', () => {
tickBinPanChanges({ bin: null, pan: null });
expect(component.isActive).toBe(false);
});
});
describe('update form', () => {
it('should form using provided nullable binPan values', () => {
tickBinPanChanges({ bin: null, pan: null });
expect(component.form.value).toEqual({ bin: null, pan: null });
});
it('should form using provided only bin binPan values', () => {
tickBinPanChanges({ bin: '123456', pan: null });
expect(component.form.value).toEqual({ bin: '123456', pan: null });
});
it('should form using provided only pan binPan values', () => {
tickBinPanChanges({ bin: null, pan: '1234' });
expect(component.form.value).toEqual({ bin: null, pan: '1234' });
});
it('should form using provided fully binPan values', () => {
tickBinPanChanges({ bin: '123456', pan: '1234' });
expect(component.form.value).toEqual({ bin: '123456', pan: '1234' });
});
it('should form using provided undefined binPan value', () => {
tickBinPanChanges(undefined);
expect(component.form.value).toEqual({ bin: null, pan: null });
});
});
});
describe('onOpened', () => {
it('should update form value', () => {
expect(component.form.value).toEqual({ bin: null, pan: null });
component.binPan = { bin: '123456', pan: '1234' };
component.popupOpened();
expect(component.form.value).toEqual({ bin: '123456', pan: '1234' });
});
});
describe('onClosed', () => {
it('should emit filter changed using form values', () => {
const spyOnFilterChanged = spyOn(component.filterChanged, 'emit').and.callThrough();
component.form.setValue({ bin: '123456', pan: null });
component.popupClosed();
expect(spyOnFilterChanged).toHaveBeenCalledTimes(1);
expect(spyOnFilterChanged).toHaveBeenCalledWith({ bin: '123456', pan: null });
});
it('should update title and active status using form values', () => {
component.form.setValue({ bin: null, pan: '1234' });
component.popupClosed();
expect(component.titleValues).toBe('**** **** **** 1234');
expect(component.isActive).toBe(true);
});
});
describe('onSave', () => {
it('should close filter component', () => {
const mockFilterComponent = mock(FilterComponent);
component.filter = instance(mockFilterComponent);
component.save();
verify(mockFilterComponent.close()).once();
expect().nothing();
});
});
describe('onClear', () => {
it('should reset form data', () => {
component.form.setValue({ bin: '123456', pan: '1234' });
component.clear();
expect(component.form.value).toEqual({ bin: null, pan: null });
});
});
});

View File

@ -0,0 +1,99 @@
import { Component, EventEmitter, Input, OnChanges, Output, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup } from '@ngneat/reactive-forms';
import { UntilDestroy } from '@ngneat/until-destroy';
import isObject from 'lodash.isobject';
import { makeMaskedCardEnd, makeMaskedCardStart, splitCardNumber } from '@dsh/app/shared/utils/card-formatter';
import { FilterComponent } from '@dsh/components/filters/filter';
import { binValidator, lastDigitsValidator } from '@dsh/components/form-controls';
import { ComponentChanges } from '@dsh/type-utils';
import { CardBinPan } from './types/card-bin-pan';
@UntilDestroy()
@Component({
selector: 'dsh-card-bin-pan-filter',
templateUrl: './card-bin-pan-filter.component.html',
styleUrls: ['./card-bin-pan-filter.component.scss'],
})
export class CardBinPanFilterComponent implements OnChanges {
@ViewChild(FilterComponent) filter: FilterComponent;
@Input() binPan: Partial<CardBinPan>;
@Output() filterChanged = new EventEmitter<Partial<CardBinPan>>();
form: FormGroup<CardBinPan> = this.formBuilder.group({
bin: [null, binValidator],
pan: [null, lastDigitsValidator],
});
titleValues: string;
isActive = false;
constructor(private formBuilder: FormBuilder) {}
ngOnChanges(changes: ComponentChanges<CardBinPanFilterComponent>): void {
if (isObject(changes.binPan)) {
this.updateFilterForm(changes.binPan.currentValue);
this.updateBadgePresentation();
}
}
popupOpened(): void {
this.updateFilterForm(this.binPan);
}
popupClosed(): void {
this.saveFilterData();
}
save(): void {
this.filter.close();
}
clear(): void {
this.clearForm();
}
private saveFilterData(): void {
this.updateBadgePresentation();
this.filterChanged.emit(this.form.value);
}
private updateFilterForm(binPan: Partial<CardBinPan> | undefined): void {
const { bin = null, pan = null } = binPan ?? {};
this.form.setValue({
bin,
pan,
});
}
private updateBadgePresentation(): void {
this.updateTitleValues();
this.updateActiveStatus();
}
private updateActiveStatus(): void {
const { bin, pan } = this.form.value;
this.isActive = Boolean(bin) || Boolean(pan);
}
private updateTitleValues(): void {
const { bin, pan } = this.form.controls;
const binString = bin.valid && Boolean(bin.value) ? bin.value : '';
const panString = pan.valid && Boolean(pan.value) ? pan.value : '';
const maskedBinPart = makeMaskedCardStart(binString, 12);
const maskedPanPart = makeMaskedCardEnd(panString, 4);
const filterValues = splitCardNumber(`${maskedBinPart}${maskedPanPart}`);
this.titleValues = Boolean(binString) || Boolean(panString) ? `${filterValues}` : '';
}
private clearForm(): void {
this.updateFilterForm({
bin: null,
pan: null,
});
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { TranslocoModule } from '@ngneat/transloco';
import { FilterModule } from '@dsh/components/filters/filter';
import { FormatInputModule } from '@dsh/components/form-controls';
import { CardBinPanFilterComponent } from './card-bin-pan-filter.component';
@NgModule({
imports: [
CommonModule,
FilterModule,
MatFormFieldModule,
FormatInputModule,
ReactiveFormsModule,
TranslocoModule,
FlexLayoutModule,
],
declarations: [CardBinPanFilterComponent],
exports: [CardBinPanFilterComponent],
})
export class CardBinPanFilterModule {}

View File

@ -0,0 +1,2 @@
export const BIN_LENGTH = 6;
export const PAN_LENGTH = 4;

View File

@ -0,0 +1,4 @@
export * from './card-bin-pan-filter.module';
export * from './card-bin-pan-filter.component';
export * from './types/card-bin-pan';
export { BIN_LENGTH, PAN_LENGTH } from './consts';

View File

@ -0,0 +1,4 @@
export interface CardBinPan {
bin: string;
pan: string;
}

View File

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

View File

@ -0,0 +1,27 @@
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="16px">
<ng-container *ngIf="filtersData$ | async as filters">
<dsh-daterange-filter
[selected]="filters.daterange"
(selectedChange)="dateRangeChange($event)"
></dsh-daterange-filter>
<dsh-invoices-filter
[selected]="filters.invoiceIDs"
(selectedChange)="invoiceSelectionChange($event)"
></dsh-invoices-filter>
<dsh-filter-shops
[shops]="shops$ | async"
[selected]="selectedShops$ | async"
(selectedChange)="shopSelectionChange($event)"
></dsh-filter-shops>
<dsh-card-bin-pan-filter
[binPan]="filters.binPan"
(filterChanged)="binPanChanged($event)"
></dsh-card-bin-pan-filter>
<!-- hidden till additional filters would be supported-->
<!-- <ng-container *transloco="let t; read: 'filters'">-->
<!-- <dsh-filter-button [active]="isAdditionalFilterApplied" (click)="openFiltersDialog()">-->
<!-- {{ t('additionalFilters') }}-->
<!-- </dsh-filter-button>-->
<!-- </ng-container>-->
</ng-container>
</div>

View File

@ -0,0 +1,383 @@
import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlexLayoutModule } from '@angular/flex-layout';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import moment from 'moment';
import { of } from 'rxjs';
import { deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { Shop } from '@dsh/api-codegen/capi';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { getTranslocoModule } from '@dsh/app/shared/tests/get-transloco-module';
import { AdditionalFiltersService } from './additional-filters';
import { CardBinPanFilterModule } from './card-bin-pan-filter';
import { PaymentsFiltersComponent } from './payments-filters.component';
import { PaymentsFiltersService } from './services/payments-filters/payments-filters.service';
import { ShopsSelectionManagerService } from './services/shops-selection-manager/shops-selection-manager.service';
@Component({
selector: 'dsh-daterange-filter',
template: '',
})
class MockDaterangeFilterComponent {
@Input() selected;
}
@Component({
selector: 'dsh-invoices-filter',
template: '',
})
class MockInvoicesFilterComponent {
@Input() selected;
}
@Component({
selector: 'dsh-filter-shops',
template: '',
})
class MockFilterShopsComponent {
@Input() selected;
@Input() shops;
}
@Component({
selector: 'dsh-filter-button',
template: '',
})
class MockFilterButtonComponent {
@Input() active;
}
describe('PaymentsFiltersComponent', () => {
let component: PaymentsFiltersComponent;
let fixture: ComponentFixture<PaymentsFiltersComponent>;
let mockShopsSelectionManagerService: ShopsSelectionManagerService;
let mockPaymentsFiltersService: PaymentsFiltersService;
let mockAdditionalFiltersService: AdditionalFiltersService;
beforeEach(() => {
mockShopsSelectionManagerService = mock(ShopsSelectionManagerService);
mockPaymentsFiltersService = mock(PaymentsFiltersService);
mockAdditionalFiltersService = mock(AdditionalFiltersService);
});
beforeEach(() => {
when(mockPaymentsFiltersService.filtersData$).thenReturn(
of({
daterange: {
begin: moment(),
end: moment(),
},
})
);
});
async function createComponent() {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, getTranslocoModule(), FlexLayoutModule, CardBinPanFilterModule],
declarations: [
MockDaterangeFilterComponent,
MockInvoicesFilterComponent,
MockFilterShopsComponent,
MockFilterButtonComponent,
PaymentsFiltersComponent,
],
providers: [
{
provide: ShopsSelectionManagerService,
useFactory: () => instance(mockShopsSelectionManagerService),
},
{
provide: PaymentsFiltersService,
useFactory: () => instance(mockPaymentsFiltersService),
},
{
provide: AdditionalFiltersService,
useFactory: () => instance(mockAdditionalFiltersService),
},
],
}).compileComponents();
fixture = TestBed.createComponent(PaymentsFiltersComponent);
component = fixture.componentInstance;
}
describe('creation', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
describe('ngOnInit', () => {
it('should emit filters changed event on filters data change', async () => {
const filtersData = {
daterange: {
begin: moment(),
end: moment(),
},
};
when(mockPaymentsFiltersService.filtersData$).thenReturn(of(filtersData));
await createComponent();
const spyOnFiltersChanged = spyOn(component.filtersChanged, 'emit').and.callThrough();
fixture.detectChanges();
expect(spyOnFiltersChanged).toHaveBeenCalledTimes(1);
expect(spyOnFiltersChanged).toHaveBeenCalledWith(filtersData);
});
it('should update selected ids on filters data change', async () => {
const filtersData = {
daterange: {
begin: moment(),
end: moment(),
},
shopIDs: ['id_one', 'id_two'],
};
when(mockPaymentsFiltersService.filtersData$).thenReturn(of(filtersData));
await createComponent();
fixture.detectChanges();
verify(mockShopsSelectionManagerService.setSelectedIds(deepEqual(['id_one', 'id_two']))).once();
expect().nothing();
});
});
describe('ngOnChanges', () => {
it('should update tick realm changes', async () => {
await createComponent();
fixture.detectChanges();
component.ngOnChanges({
realm: {
previousValue: null,
currentValue: PaymentInstitutionRealm.test,
isFirstChange(): boolean {
return true;
},
firstChange: true,
},
});
verify(mockShopsSelectionManagerService.setRealm(PaymentInstitutionRealm.test)).once();
component.ngOnChanges({
realm: {
previousValue: PaymentInstitutionRealm.test,
currentValue: PaymentInstitutionRealm.live,
isFirstChange(): boolean {
return false;
},
firstChange: false,
},
});
verify(mockShopsSelectionManagerService.setRealm(PaymentInstitutionRealm.live)).once();
expect().nothing();
});
});
describe('openFiltersDialog', () => {
beforeEach(() => {
when(mockPaymentsFiltersService.filtersData$).thenReturn(
of({
daterange: {
begin: moment(),
end: moment(),
},
additional: {
myProperty: null,
},
})
);
});
it('should open dialog', async () => {
await createComponent();
fixture.detectChanges();
when(
mockAdditionalFiltersService.openFiltersDialog(
deepEqual({
myProperty: null,
})
)
).thenReturn(of());
component.openFiltersDialog();
verify(
mockAdditionalFiltersService.openFiltersDialog(
deepEqual({
myProperty: null,
})
)
).once();
expect().nothing();
});
it('should change isAdditionalFilterApplied using response from dialog', async () => {
await createComponent();
fixture.detectChanges();
when(
mockAdditionalFiltersService.openFiltersDialog(
deepEqual({
myProperty: null,
})
)
).thenReturn(of({}));
component.openFiltersDialog();
expect(component.isAdditionalFilterApplied).toBe(false);
when(
mockAdditionalFiltersService.openFiltersDialog(
deepEqual({
myProperty: null,
})
)
).thenReturn(
of({
something: null,
})
);
component.openFiltersDialog();
expect(component.isAdditionalFilterApplied).toBe(true);
});
});
describe('dateRangeChange', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should tick filters changes', () => {
const daterange = {
begin: moment(),
end: moment(),
};
component.dateRangeChange(daterange);
verify(
mockPaymentsFiltersService.changeFilters(
deepEqual({
daterange,
})
)
).once();
expect().nothing();
});
});
describe('invoiceSelectionChange', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should tick filters change', () => {
component.invoiceSelectionChange(['invoice_id', 'another_invoice_id']);
verify(
mockPaymentsFiltersService.changeFilters(
deepEqual({
invoiceIDs: ['invoice_id', 'another_invoice_id'],
})
)
).once();
expect().nothing();
});
});
describe('shopSelectionChange', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should tick filters change', () => {
const testList = new Array(4)
.fill({
id: '',
createdAt: new Date(),
isBlocked: false,
isSuspended: false,
categoryID: 1,
location: {
locationType: 'type',
},
details: {
name: 'my name',
description: 'some description',
},
contractID: 'contractID',
payoutToolID: 'payoutToolID',
scheduleID: 1,
account: {
currency: 'USD',
guaranteeID: 2,
settlementID: 2,
},
})
.map((el: Shop, i: number) => {
return {
...el,
id: `test_id_${i}`,
};
});
component.shopSelectionChange(testList);
verify(
mockPaymentsFiltersService.changeFilters(
deepEqual({
shopIDs: ['test_id_0', 'test_id_1', 'test_id_2', 'test_id_3'],
})
)
).once();
expect().nothing();
});
});
describe('binPanChanged', () => {
beforeEach(async () => {
await createComponent();
fixture.detectChanges();
});
it('should tick filters change', () => {
component.binPanChanged({
bin: '123456',
pan: null,
});
verify(
mockPaymentsFiltersService.changeFilters(
deepEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
})
)
).once();
expect().nothing();
});
});
});

View File

@ -0,0 +1,110 @@
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import isEmpty from 'lodash.isempty';
import isNil from 'lodash.isnil';
import isObject from 'lodash.isobject';
import { Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators';
import { Shop } from '@dsh/api-codegen/capi';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { Daterange } from '@dsh/pipes/daterange';
import { ComponentChange, ComponentChanges } from '@dsh/type-utils';
import { AdditionalFiltersService } from './additional-filters';
import { CardBinPan } from './card-bin-pan-filter';
import { PaymentsFiltersService } from './services/payments-filters/payments-filters.service';
import { ShopsSelectionManagerService } from './services/shops-selection-manager/shops-selection-manager.service';
import { PaymentsAdditionalFilters } from './types/payments-additional-filters';
import { PaymentsFiltersData } from './types/payments-filters-data';
@UntilDestroy()
@Component({
selector: 'dsh-payments-filters',
templateUrl: 'payments-filters.component.html',
})
export class PaymentsFiltersComponent implements OnInit, OnChanges {
@Input() realm: PaymentInstitutionRealm;
@Output() filtersChanged = new EventEmitter<PaymentsFiltersData>();
filtersData$: Observable<PaymentsFiltersData> = this.filtersHandler.filtersData$;
isAdditionalFilterApplied: boolean;
shops$: Observable<Shop[]> = this.shopService.shops$;
selectedShops$: Observable<Shop[]> = this.shopService.selectedShops$;
constructor(
private shopService: ShopsSelectionManagerService,
private filtersHandler: PaymentsFiltersService,
private additionalFilters: AdditionalFiltersService
) {}
ngOnInit(): void {
this.filtersData$.pipe(untilDestroyed(this)).subscribe((filtersData: PaymentsFiltersData) => {
this.filtersChanged.emit(filtersData);
const { shopIDs = [] } = filtersData;
this.shopService.setSelectedIds(shopIDs);
});
}
ngOnChanges(changes: ComponentChanges<PaymentsFiltersComponent>): void {
if (isObject(changes.realm)) {
this.updateRealm(changes.realm);
}
}
openFiltersDialog(): void {
this.filtersData$
.pipe(
take(1),
map((filtersData: PaymentsFiltersData) => filtersData.additional),
switchMap((filters: PaymentsAdditionalFilters) => {
return this.additionalFilters.openFiltersDialog(filters);
}),
untilDestroyed(this)
)
.subscribe((filters: PaymentsAdditionalFilters) => {
this.isAdditionalFilterApplied = !isEmpty(filters);
});
}
dateRangeChange(dateRange: Daterange): void {
this.updateFilters({ daterange: dateRange });
}
invoiceSelectionChange(invoiceIds: string[]): void {
this.updateFilters({ invoiceIDs: invoiceIds });
}
shopSelectionChange(selectedShops: Shop[]): void {
this.updateFilters({
shopIDs: selectedShops.map(({ id }: Shop) => id),
});
}
binPanChanged(binPan: Partial<CardBinPan>): void {
this.updateFilters({
binPan: {
paymentMethod: 'bankCard',
...binPan,
},
});
}
private updateRealm(change: ComponentChange<PaymentsFiltersComponent, 'realm'>): void {
const realm = change.currentValue;
if (isNil(realm)) {
return;
}
this.shopService.setRealm(realm);
}
private updateFilters(change: Partial<PaymentsFiltersData>): void {
this.filtersHandler.changeFilters({
...change,
});
}
}

View File

@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { FilterShopsModule, InvoicesFilterModule } from '@dsh/app/shared/components';
import { DaterangeManagerModule } from '@dsh/app/shared/services/date-range-manager';
import { DaterangeFilterModule } from '@dsh/components/filters/daterange-filter';
import { FilterModule } from '@dsh/components/filters/filter';
import { AdditionalFiltersModule } from './additional-filters';
import { CardBinPanFilterModule } from './card-bin-pan-filter';
import { PaymentsFiltersComponent } from './payments-filters.component';
import { PaymentsFiltersStoreService } from './services/payments-filters-store/payments-filters-store.service';
import { PaymentsFiltersService } from './services/payments-filters/payments-filters.service';
import { ShopsSelectionManagerService } from './services/shops-selection-manager/shops-selection-manager.service';
@NgModule({
imports: [
CommonModule,
AdditionalFiltersModule,
TranslocoModule,
FlexLayoutModule,
FilterModule,
DaterangeFilterModule,
FilterShopsModule,
InvoicesFilterModule,
DaterangeManagerModule,
CardBinPanFilterModule,
],
declarations: [PaymentsFiltersComponent],
exports: [PaymentsFiltersComponent],
providers: [PaymentsFiltersService, PaymentsFiltersStoreService, ShopsSelectionManagerService],
})
export class PaymentsFiltersModule {}

View File

@ -0,0 +1,333 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import moment from 'moment';
import { of } from 'rxjs';
import { deepEqual, instance, mock, when } from 'ts-mockito';
import { DaterangeManagerService } from '@dsh/app/shared/services/date-range-manager';
import { PaymentsFiltersStoreService } from './payments-filters-store.service';
describe('PaymentsFiltersStoreService', () => {
let service: PaymentsFiltersStoreService;
let mockDaterangeManagerService: DaterangeManagerService;
let mockRouter: Router;
let mockActivatedRoute: ActivatedRoute;
const daterange = {
begin: moment(),
end: moment(),
};
const formattedDaterange = {
begin: daterange.begin.format(),
end: daterange.end.format(),
};
beforeEach(() => {
mockDaterangeManagerService = mock(DaterangeManagerService);
mockRouter = mock(Router);
mockActivatedRoute = mock(ActivatedRoute);
});
beforeEach(() => {
when(mockActivatedRoute.queryParams).thenReturn(of({}));
});
beforeEach(() => {
when(mockDaterangeManagerService.serializeDateRange(deepEqual(daterange))).thenReturn(formattedDaterange);
when(mockDaterangeManagerService.deserializeDateRange(deepEqual(formattedDaterange))).thenReturn(daterange);
});
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
PaymentsFiltersStoreService,
{
provide: DaterangeManagerService,
useFactory: () => instance(mockDaterangeManagerService),
},
{
provide: Router,
useFactory: () => instance(mockRouter),
},
{
provide: ActivatedRoute,
useFactory: () => instance(mockActivatedRoute),
},
],
});
service = TestBed.inject(PaymentsFiltersStoreService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('mapToData', () => {
it('should format daterange params', () => {
expect(
service.mapToData({
fromTime: daterange.begin.format(),
toTime: daterange.end.format(),
})
).toEqual({
daterange,
});
});
it('should format invoices and shops ids params', () => {
expect(
service.mapToData({
shopIDs: ['shop-id-1', 'shop-id-2'],
invoiceIDs: ['invoice-id-1', 'invoice-id-2'],
})
).toEqual({
shopIDs: ['shop-id-1', 'shop-id-2'],
invoiceIDs: ['invoice-id-1', 'invoice-id-2'],
});
});
it('should format invoices and shops single id params', () => {
expect(
service.mapToData({
shopIDs: 'shop-id',
invoiceIDs: 'invoice-id',
})
).toEqual({
shopIDs: ['shop-id'],
invoiceIDs: ['invoice-id'],
});
});
it('should parse binPan fully', () => {
expect(
service.mapToData({
first6: '123456',
last4: '1234',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: '1234',
},
});
});
it('should not create binPan object if there is no first6 and last4 exists', () => {
expect(
service.mapToData({
invoiceIDs: 'invoice-id',
})
).toEqual({
invoiceIDs: ['invoice-id'],
});
});
it('should parse bin if first6 has 6 numeric symbols', () => {
expect(
service.mapToData({
first6: '123456',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
});
});
it('should parse pan if last4 has 4 numeric symbols', () => {
expect(
service.mapToData({
last4: '1234',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: '1234',
},
});
});
it('should not parse bin if it has non-numeric symbols', () => {
expect(
service.mapToData({
first6: '12345a',
last4: '1234',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: '1234',
},
});
});
it('should not parse pan if it has non-numeric symbols', () => {
expect(
service.mapToData({
first6: '123456',
last4: '123a',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
});
});
it('should not parse bin if it not 6 numeric symbols', () => {
expect(
service.mapToData({
first6: '12345',
last4: '1234',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: '1234',
},
});
expect(
service.mapToData({
first6: '1234567',
last4: '1234',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: '1234',
},
});
});
it('should not parse pan if it not 4 numeric symbols', () => {
expect(
service.mapToData({
first6: '123456',
last4: '12345',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
});
expect(
service.mapToData({
first6: '123456',
last4: '123',
})
).toEqual({
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
});
});
it('should return empty values as params', () => {
expect(service.mapToData({})).toEqual({});
expect(service.mapToData({ shopIDs: '' })).toEqual({});
expect(service.mapToData({ invoiceIDs: '' })).toEqual({});
});
});
describe('mapToParams', () => {
it('should not set empty params', () => {
expect(
service.mapToParams({
daterange,
shopIDs: [],
invoiceIDs: ['my-invoice-id'],
additional: {
mine: {},
another: null,
},
})
).toEqual({
fromTime: formattedDaterange.begin,
toTime: formattedDaterange.end,
invoiceIDs: ['my-invoice-id'],
});
});
it('should transform bin in first6', () => {
expect(
service.mapToParams({
daterange,
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: null,
},
})
).toEqual({
fromTime: formattedDaterange.begin,
toTime: formattedDaterange.end,
first6: '123456',
});
});
it('should transform pan in last4', () => {
expect(
service.mapToParams({
daterange,
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: '1234',
},
})
).toEqual({
fromTime: formattedDaterange.begin,
toTime: formattedDaterange.end,
last4: '1234',
});
});
it('should transform binPan data in first6 and last4', () => {
expect(
service.mapToParams({
daterange,
binPan: {
paymentMethod: 'bankCard',
bin: '123456',
pan: '1234',
},
})
).toEqual({
fromTime: formattedDaterange.begin,
toTime: formattedDaterange.end,
first6: '123456',
last4: '1234',
});
});
it('should remove binPan from params if bin and pan empty', () => {
expect(
service.mapToParams({
daterange,
binPan: {
paymentMethod: 'bankCard',
bin: null,
pan: null,
},
})
).toEqual({
fromTime: formattedDaterange.begin,
toTime: formattedDaterange.end,
});
});
});
});

View File

@ -0,0 +1,95 @@
import { Injectable } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import isEmpty from 'lodash.isempty';
import isNil from 'lodash.isnil';
import isString from 'lodash.isstring';
import pickBy from 'lodash.pickby';
import { QueryParamsStore } from '@dsh/app/shared/services';
import { DaterangeManagerService } from '@dsh/app/shared/services/date-range-manager';
import { Daterange } from '@dsh/pipes/daterange';
import { wrapValuesToArray } from '@dsh/utils';
import { BIN_LENGTH, PAN_LENGTH } from '../../card-bin-pan-filter';
import { PaymentsFiltersData } from '../../types/payments-filters-data';
@Injectable()
export class PaymentsFiltersStoreService extends QueryParamsStore<PaymentsFiltersData> {
constructor(
private daterangeManager: DaterangeManagerService,
protected router: Router,
protected route: ActivatedRoute
) {
super(router, route);
}
mapToData(params: Params): Partial<PaymentsFiltersData> {
const { fromTime, toTime, ...restParams } = params;
return this.removeUnusedFields({
daterange: this.formatDaterange(fromTime, toTime),
binPan: this.getBinPanParams(params),
...this.getListParams(restParams),
});
}
mapToParams({ daterange, binPan, additional, ...restData }: PaymentsFiltersData): Params {
const { begin: fromTime, end: toTime } = this.daterangeManager.serializeDateRange(daterange);
const { bin = null, pan = null } = binPan ?? {};
return this.removeUnusedFields({
fromTime,
toTime,
first6: bin,
last4: pan,
...additional,
...restData,
});
}
private removeUnusedFields<T>(data: T): T | Partial<T> {
return Object.entries(data).reduce((newData: T | Partial<T>, [key, value]: [string, any]) => {
if (!isEmpty(value)) {
newData[key] = value;
}
return newData;
}, {});
}
private getBinPanParams({ first6, last4 }: Params): PaymentsFiltersData['binPan'] | null {
const bin = Number(first6);
const pan = Number(last4);
const isValidBin = !isNil(first6) && !isNaN(bin) && first6.length === BIN_LENGTH;
const isValidPan = !isNil(last4) && !isNaN(pan) && last4.length === PAN_LENGTH;
if (isValidBin || isValidPan) {
return {
paymentMethod: 'bankCard',
bin: isValidBin ? first6 : null,
pan: isValidPan ? last4 : null,
};
}
return null;
}
private getListParams(params: Params): Partial<PaymentsFiltersData> {
const nonEmptyListParams = pickBy(
params,
(value: unknown, key: keyof PaymentsFiltersData) =>
['shopIDs', 'invoiceIDs'].includes(key) && !isEmpty(value)
);
const stringListParams = pickBy(nonEmptyListParams, (value: unknown) => isString(value));
return {
...nonEmptyListParams,
...wrapValuesToArray(stringListParams),
};
}
private formatDaterange(fromTime: string | undefined, toTime: string | undefined): Daterange | null {
return isNil(fromTime) || isNil(toTime)
? null
: this.daterangeManager.deserializeDateRange({ begin: fromTime, end: toTime });
}
}

View File

@ -0,0 +1,163 @@
import { TestBed } from '@angular/core/testing';
import { cold } from 'jasmine-marbles';
import moment from 'moment';
import { of } from 'rxjs';
import { deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { DaterangeManagerService } from '@dsh/app/shared/services/date-range-manager';
import { PaymentsFiltersStoreService } from '../payments-filters-store/payments-filters-store.service';
import { PaymentsFiltersService } from './payments-filters.service';
describe('PaymentsFiltersService', () => {
let service: PaymentsFiltersService;
let mockDaterangeManagerService: DaterangeManagerService;
let mockPaymentsFiltersStoreService: PaymentsFiltersStoreService;
beforeEach(() => {
mockDaterangeManagerService = mock(DaterangeManagerService);
mockPaymentsFiltersStoreService = mock(PaymentsFiltersStoreService);
});
async function configureTestingModule() {
await TestBed.configureTestingModule({
providers: [
PaymentsFiltersService,
{
provide: DaterangeManagerService,
useFactory: () => instance(mockDaterangeManagerService),
},
{
provide: PaymentsFiltersStoreService,
useFactory: () => instance(mockPaymentsFiltersStoreService),
},
],
});
service = TestBed.inject(PaymentsFiltersService);
}
describe('creation', () => {
it('should be created', async () => {
const defaultDateRange = {
begin: moment(),
end: moment(),
};
when(mockDaterangeManagerService.defaultDaterange).thenReturn(defaultDateRange);
when(mockPaymentsFiltersStoreService.data$).thenReturn(of({}));
await configureTestingModule();
expect(service).toBeTruthy();
});
});
describe('constructor', () => {
const defaultDateRange = {
begin: moment(),
end: moment(),
};
beforeEach(() => {
when(mockDaterangeManagerService.defaultDaterange).thenReturn(defaultDateRange);
});
it('should merge default daterange and query params data', async () => {
when(mockPaymentsFiltersStoreService.data$).thenReturn(
of({
shopIDs: [],
})
);
const expected$ = cold('(a|)', {
a: {
daterange: defaultDateRange,
shopIDs: [],
},
});
await configureTestingModule();
expect(service.filtersData$).toBeObservable(expected$);
});
it('should rewrite default daterange with query params data', async () => {
const storeDaterange = {
begin: moment().startOf('m'),
end: moment().endOf('m'),
};
when(mockPaymentsFiltersStoreService.data$).thenReturn(
of({
daterange: storeDaterange,
shopIDs: [],
})
);
const expected$ = cold('(a|)', {
a: {
daterange: storeDaterange,
shopIDs: [],
},
});
await configureTestingModule();
expect(service.filtersData$).toBeObservable(expected$);
});
it('should use default daterange if query params data has none', async () => {
when(mockPaymentsFiltersStoreService.data$).thenReturn(
of({
shopIDs: ['shop-id'],
invoiceIDs: ['invoice-id'],
})
);
const expected$ = cold('(a|)', {
a: {
daterange: defaultDateRange,
shopIDs: ['shop-id'],
invoiceIDs: ['invoice-id'],
},
});
await configureTestingModule();
expect(service.filtersData$).toBeObservable(expected$);
});
});
describe('changeFilters', () => {
const defaultDateRange = {
begin: moment(),
end: moment(),
};
beforeEach(() => {
when(mockDaterangeManagerService.defaultDaterange).thenReturn(defaultDateRange);
when(mockPaymentsFiltersStoreService.data$).thenReturn(
of({
shopIDs: [],
})
);
});
it('should update properties of existing filters data', async () => {
await configureTestingModule();
service.changeFilters({
shopIDs: ['mine'],
});
verify(
mockPaymentsFiltersStoreService.preserve(
deepEqual({
daterange: defaultDateRange,
shopIDs: ['mine'],
})
)
).once();
expect().nothing();
});
});
});

View File

@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Observable, ReplaySubject } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { DaterangeManagerService } from '@dsh/app/shared/services/date-range-manager';
import { PaymentsFiltersData } from '../../types/payments-filters-data';
import { PaymentsFiltersStoreService } from '../payments-filters-store/payments-filters-store.service';
@UntilDestroy()
@Injectable()
export class PaymentsFiltersService {
filtersData$: Observable<PaymentsFiltersData>;
private filtersChange$ = new ReplaySubject<Partial<PaymentsFiltersData>>(1);
constructor(
private daterangeManager: DaterangeManagerService,
private filtersParamsStore: PaymentsFiltersStoreService
) {
this.initFiltersData();
this.initUpdatesData();
}
changeFilters(dataChange: Partial<PaymentsFiltersData>): void {
this.filtersChange$.next(dataChange);
}
private initFiltersData(): void {
this.filtersData$ = this.filtersParamsStore.data$.pipe(
map((storeData: Partial<PaymentsFiltersData>) => {
return {
daterange: this.daterangeManager.defaultDaterange,
...storeData,
};
})
);
}
private initUpdatesData(): void {
this.filtersChange$
.pipe(
withLatestFrom(this.filtersData$),
map(([dataChange, filtersData]: [Partial<PaymentsFiltersData>, PaymentsFiltersData]) => {
return {
...filtersData,
...dataChange,
};
}),
untilDestroyed(this)
)
.subscribe((updatedData: PaymentsFiltersData) => {
this.filtersParamsStore.preserve(updatedData);
});
}
}

View File

@ -0,0 +1,135 @@
import { TestBed } from '@angular/core/testing';
import { hot } from 'jasmine-marbles';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { instance, mock, when } from 'ts-mockito';
import { Shop } from '@dsh/api-codegen/capi';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { ApiShopsService } from '@dsh/api/shop';
import { ShopsSelectionManagerService } from './shops-selection-manager.service';
describe('ShopsSelectionManagerService', () => {
let service: ShopsSelectionManagerService;
let mockApiShopsService: ApiShopsService;
function createService() {
TestBed.configureTestingModule({
providers: [
ShopsSelectionManagerService,
{
provide: ApiShopsService,
useFactory: () => instance(mockApiShopsService),
},
],
});
service = TestBed.inject(ShopsSelectionManagerService);
}
beforeEach(() => {
mockApiShopsService = mock(ApiShopsService);
});
beforeEach(() => {
const testList = new Array(12)
.fill({
id: '',
createdAt: new Date(),
isBlocked: false,
isSuspended: false,
categoryID: 0,
location: {
locationType: 'type',
},
details: {
name: 'my name',
description: 'some description',
},
contractID: 'contractID',
payoutToolID: 'payoutToolID',
scheduleID: 1,
account: {
currency: 'USD',
guaranteeID: 2,
settlementID: 2,
},
})
.map((el: Shop, i: number) => {
const isOdd = i % 2 === 0;
return {
...el,
id: isOdd ? `test_id_${i}` : `live_id_${i}`,
categoryID: isOdd ? 1 : 2,
};
});
when(mockApiShopsService.shops$).thenReturn(of(testList));
});
describe('creation', () => {
beforeEach(() => {
createService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
describe('setRealm', () => {
beforeEach(() => {
createService();
});
it('should tick shops after realm was inited', () => {
const beforeInit$ = hot('^', {});
expect(service.shops$.pipe(map((shops: Shop[]) => shops.map(({ id }) => id)))).toBeObservable(beforeInit$);
const afterInit$ = hot('a', {
a: ['test_id_0', 'test_id_2', 'test_id_4', 'test_id_6', 'test_id_8', 'test_id_10'],
});
service.setRealm(PaymentInstitutionRealm.test);
expect(service.shops$.pipe(map((shops: Shop[]) => shops.map(({ id }) => id)))).toBeObservable(afterInit$);
});
it('should filter shops by realm even if they changed', () => {
const testRealm$ = hot('a', {
a: ['test_id_0', 'test_id_2', 'test_id_4', 'test_id_6', 'test_id_8', 'test_id_10'],
});
service.setRealm(PaymentInstitutionRealm.test);
expect(service.shops$.pipe(map((shops: Shop[]) => shops.map(({ id }) => id)))).toBeObservable(testRealm$);
const liveRealm$ = hot('a', {
a: ['live_id_1', 'live_id_3', 'live_id_5', 'live_id_7', 'live_id_9', 'live_id_11'],
});
service.setRealm(PaymentInstitutionRealm.live);
expect(service.shops$.pipe(map((shops: Shop[]) => shops.map(({ id }) => id)))).toBeObservable(liveRealm$);
});
});
describe('setSelectedIds', () => {
beforeEach(() => {
createService();
});
it('should change selected list using selected ids', () => {
const liveRealm$ = hot('a', {
a: ['live_id_3', 'live_id_5'],
});
service.setRealm(PaymentInstitutionRealm.live);
service.setSelectedIds(['live_id_3', 'live_id_5']);
expect(service.selectedShops$.pipe(map((shops: Shop[]) => shops.map(({ id }) => id)))).toBeObservable(
liveRealm$
);
});
});
});

View File

@ -0,0 +1,54 @@
import { Injectable } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Observable, ReplaySubject } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
import { Shop } from '@dsh/api-codegen/capi';
import { PaymentInstitutionRealm } from '@dsh/api/model';
import { ApiShopsService } from '@dsh/api/shop';
import { filterShopsByRealm } from '../../../../operators';
@UntilDestroy()
@Injectable()
export class ShopsSelectionManagerService {
shops$: Observable<Shop[]>;
selectedShops$: Observable<Shop[]>;
private realmChanges$ = new ReplaySubject<PaymentInstitutionRealm>(1);
private selectedIds$ = new ReplaySubject<string[]>(1);
constructor(private shopService: ApiShopsService) {
this.initShops();
this.initShopsSelection();
}
setRealm(realm: PaymentInstitutionRealm): void {
this.realmChanges$.next(realm);
}
setSelectedIds(selectedIds: string[]): void {
this.selectedIds$.next(selectedIds);
}
private initShops(): void {
this.shops$ = this.realmChanges$.pipe(filterShopsByRealm(this.shopService.shops$));
}
private initShopsSelection(): void {
this.selectedShops$ = this.selectedIds$.pipe(
map((selectedIds: string[]) => {
return selectedIds.reduce((idsMap: Map<string, string>, id: string) => {
idsMap.set(id, id);
return idsMap;
}, new Map<string, string>());
}),
mergeMap((selectedIds: Map<string, string>) => {
return this.shops$.pipe(map((shops: Shop[]) => [selectedIds, shops]));
}),
map(([selectedIds, shops]: [Map<string, string>, Shop[]]) => {
return shops.map((shop: Shop) => (selectedIds.has(shop.id) ? shop : null)).filter(Boolean);
})
);
}
}

View File

@ -0,0 +1,6 @@
import { CardBinPan } from '../card-bin-pan-filter';
export type PaymentBinPan = {
// can be expanded in the future
paymentMethod: 'bankCard';
} & Partial<CardBinPan>;

View File

@ -0,0 +1,4 @@
// TODO: add filters fields
export interface PaymentsAdditionalFilters {
[key: string]: any;
}

View File

@ -0,0 +1,12 @@
import { Daterange } from '@dsh/pipes/daterange';
import { PaymentBinPan } from './payment-bin-pan';
import { PaymentsAdditionalFilters } from './payments-additional-filters';
export interface PaymentsFiltersData {
daterange: Daterange;
invoiceIDs?: string[];
shopIDs?: string[];
binPan?: PaymentBinPan;
additional?: PaymentsAdditionalFilters;
}

View File

@ -0,0 +1,12 @@
<dsh-row
*transloco="let t; scope: 'operations'; read: 'operations.payments.table'"
fxLayout="row"
fxLayoutAlign="space-between center"
fxLayoutGap="24px"
color="primary"
>
<dsh-row-header-label fxFlex>{{ t('amount') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex>{{ t('status') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex>{{ t('statusChanged') }}</dsh-row-header-label>
<dsh-row-header-label fxFlex fxHide.lt-md> {{ t('shop') }} </dsh-row-header-label>
</dsh-row>

View File

@ -0,0 +1,70 @@
import { ChangeDetectionStrategy } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslocoTestingModule } from '@ngneat/transloco';
import { RowModule } from '@dsh/components/layout';
import { PaymentsRowHeaderComponent } from './payments-row-header.component';
const translationConfig = {
ru: {
operations: {
payments: {
table: {
amount: 'Сумма списания',
status: 'Статус',
statusChanged: 'Статус изменен',
invoice: 'Инвойс',
shop: 'Магазин',
},
},
},
},
};
describe('PaymentsRowHeaderComponent', () => {
let fixture: ComponentFixture<PaymentsRowHeaderComponent>;
let component: PaymentsRowHeaderComponent;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RowModule,
TranslocoTestingModule.withLangs(translationConfig, {
availableLangs: ['ru'],
defaultLang: 'ru',
}),
],
declarations: [PaymentsRowHeaderComponent],
})
.overrideComponent(PaymentsRowHeaderComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
},
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PaymentsRowHeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('template', () => {
it('should render columns with names from translation config', () => {
const columns = fixture.debugElement.queryAll(By.css('dsh-row dsh-row-header-label'));
expect(columns.length).toBe(4);
expect(columns[0].nativeElement.textContent.trim()).toBe('Сумма списания');
expect(columns[1].nativeElement.textContent.trim()).toBe('Статус');
expect(columns[2].nativeElement.textContent.trim()).toBe('Статус изменен');
expect(columns[3].nativeElement.textContent.trim()).toBe('Магазин');
});
});
});

View File

@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'dsh-payments-row-header',
templateUrl: 'payments-row-header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PaymentsRowHeaderComponent {}

View File

@ -0,0 +1,12 @@
<dsh-row fxLayout="row" fxLayoutAlign="space-between center" fxLayoutGap="24px">
<dsh-row-label fxFlex>
<div class="dsh-body-2">
<dsh-balance [amount]="payment.amount" [currency]="payment.currency"></dsh-balance>
</div>
</dsh-row-label>
<dsh-row-label fxFlex>
<dsh-payment-status [status]="payment.status"></dsh-payment-status>
</dsh-row-label>
<dsh-row-label fxFlex>{{ payment.statusChangedAt | date: 'dd MMMM yyyy, HH:mm' }}</dsh-row-label>
<dsh-row-label fxFlex fxHide.lt-md>{{ payment.shopID | shopDetails }}</dsh-row-label>
</dsh-row>

View File

@ -0,0 +1,89 @@
import { ChangeDetectionStrategy } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslocoTestingModule } from '@ngneat/transloco';
import moment from 'moment';
import { PaymentSearchResult } from '@dsh/api-codegen/capi';
import { BalanceModule } from '@dsh/app/shared/components/balance/balance.module';
import { RowModule } from '@dsh/components/layout';
import { generateMockPayment } from '../../../tests/generate-mock-payment';
import { MockShopDetailsPipe } from '../../../tests/mock-shop-details-pipe';
import { PaymentStatusModule } from '../../payment-status';
import { PaymentsRowComponent } from './payments-row.component';
describe('PaymentsRowComponent', () => {
let fixture: ComponentFixture<PaymentsRowComponent>;
let component: PaymentsRowComponent;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RowModule,
PaymentStatusModule,
BalanceModule,
TranslocoTestingModule.withLangs(
{
ru: {
paymentStatus: {
pending: 'Запущен',
processed: 'Обработан',
captured: 'Подтвержден',
cancelled: 'Отменен',
refunded: 'Возвращен',
failed: 'Неуспешен',
},
},
},
{
availableLangs: ['ru'],
defaultLang: 'ru',
}
),
],
declarations: [PaymentsRowComponent, MockShopDetailsPipe],
})
.overrideComponent(PaymentsRowComponent, {
set: {
changeDetection: ChangeDetectionStrategy.Default,
},
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PaymentsRowComponent);
component = fixture.componentInstance;
component.payment = generateMockPayment();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('template', () => {
it('should show balances component if shop was provided', () => {
const date = moment();
component.payment = generateMockPayment({
amount: 20,
currency: 'USD',
status: PaymentSearchResult.StatusEnum.Pending,
statusChangedAt: date.toDate(),
invoiceID: 'id',
id: 'id',
});
fixture.detectChanges();
const labels = fixture.debugElement.queryAll(By.css('dsh-row dsh-row-label'));
expect(labels.length).toBe(4);
expect(labels[0].nativeElement.textContent.trim()).toBe(`$0.20`);
expect(labels[1].nativeElement.textContent.trim()).toBe(`Запущен`);
expect(labels[2].nativeElement.textContent.trim()).toBe(date.format('DD MMMM YYYY, HH:mm'));
expect(labels[3].nativeElement.textContent.trim()).toBe(`shopID_name`);
});
});
});

View File

@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { PaymentSearchResult } from '@dsh/api-codegen/anapi';
@Component({
selector: 'dsh-payments-row',
templateUrl: 'payments-row.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PaymentsRowComponent {
@Input() payment: PaymentSearchResult;
}

View File

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

View File

@ -0,0 +1,2 @@
export * from './payment-detail-header.module';
export * from './payment-detail-header.component';

View File

@ -0,0 +1,8 @@
<div
*transloco="let t; scope: 'operations'; read: 'operations.payments.details'"
fxLayout="row"
fxLayoutAlign="space-between center"
>
<div>{{ t('name') + ' ' + '#' + id }}</div>
<div>{{ changedDate | date: 'dd MMMM yyyy, HH:mm' }}</div>
</div>

View File

@ -0,0 +1,44 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslocoTestingModule } from '@ngneat/transloco';
import { PaymentDetailHeaderComponent } from './payment-detail-header.component';
describe('PaymentDetailHeaderComponent', () => {
let component: PaymentDetailHeaderComponent;
let fixture: ComponentFixture<PaymentDetailHeaderComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslocoTestingModule.withLangs(
{
ru: {
operations: {
payments: {
details: {
name: 'Платеж',
},
},
},
},
},
{
availableLangs: ['ru'],
defaultLang: 'ru',
}
),
],
declarations: [PaymentDetailHeaderComponent],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PaymentDetailHeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,11 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'dsh-payment-detail-header',
templateUrl: 'payment-detail-header.component.html',
changeDetection: ChangeDetectionStrategy.Default,
})
export class PaymentDetailHeaderComponent {
@Input() id: string;
@Input() changedDate: Date;
}

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { TranslocoModule } from '@ngneat/transloco';
import { PaymentDetailHeaderComponent } from './payment-detail-header.component';
@NgModule({
imports: [CommonModule, TranslocoModule, FlexLayoutModule],
declarations: [PaymentDetailHeaderComponent],
exports: [PaymentDetailHeaderComponent],
})
export class PaymentDetailHeaderModule {}

View File

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

View File

@ -0,0 +1,3 @@
<dsh-status *transloco="let paymentStatus; read: 'paymentStatus'" [color]="status | paymentStatusColor">
{{ paymentStatus(status) }}
</dsh-status>

View File

@ -0,0 +1,67 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslocoTestingModule } from '@ngneat/transloco';
import { PaymentSearchResult } from '@dsh/api-codegen/capi';
import { StatusModule } from '@dsh/components/indicators';
import { PaymentStatusComponent } from './payment-status.component';
import { PaymentStatusColorPipe } from './pipes/status-color/status-color.pipe';
describe('PaymentStatusComponent', () => {
let component: PaymentStatusComponent;
let fixture: ComponentFixture<PaymentStatusComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
StatusModule,
TranslocoTestingModule.withLangs(
{
ru: {
paymentStatus: {
pending: 'Запущен',
processed: 'Обработан',
captured: 'Подтвержден',
cancelled: 'Отменен',
refunded: 'Возвращен',
failed: 'Неуспешен',
},
},
},
{
availableLangs: ['ru'],
defaultLang: 'ru',
}
),
],
declarations: [PaymentStatusComponent, PaymentStatusColorPipe],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PaymentStatusComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('template', () => {
it('should render valid status name', () => {
component.status = PaymentSearchResult.StatusEnum.Refunded;
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('dsh-status')).nativeElement.textContent.trim()).toBe('Возвращен');
component.status = PaymentSearchResult.StatusEnum.Processed;
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('dsh-status')).nativeElement.textContent.trim()).toBe('Обработан');
});
});
});

View File

@ -0,0 +1,11 @@
import { Component, Input } from '@angular/core';
import { PaymentSearchResult } from '@dsh/api-codegen/capi';
@Component({
selector: 'dsh-payment-status',
templateUrl: './payment-status.component.html',
})
export class PaymentStatusComponent {
@Input() status: PaymentSearchResult.StatusEnum;
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { TranslocoModule } from '@ngneat/transloco';
import { StatusModule } from '@dsh/components/indicators';
import { PaymentStatusComponent } from './payment-status.component';
import { PaymentStatusColorPipe } from './pipes/status-color/status-color.pipe';
@NgModule({
imports: [CommonModule, StatusModule, TranslocoModule],
declarations: [PaymentStatusComponent, PaymentStatusColorPipe],
exports: [PaymentStatusComponent, PaymentStatusColorPipe],
})
export class PaymentStatusModule {}

View File

@ -0,0 +1,38 @@
import { PaymentSearchResult } from '@dsh/api-codegen/capi';
import { StatusColor } from '../../../../../../../../theme-manager';
import { PaymentStatusColorPipe } from './status-color.pipe';
const statusEnum = PaymentSearchResult.StatusEnum;
describe('PaymentStatusColorPipe', () => {
let pipe: PaymentStatusColorPipe;
beforeEach(() => {
pipe = new PaymentStatusColorPipe();
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
describe('transform', () => {
it('should return "success" color for Captured or Processed statuses', () => {
expect(pipe.transform(statusEnum.Captured)).toBe(StatusColor.success);
expect(pipe.transform(statusEnum.Processed)).toBe(StatusColor.success);
});
it('should return "warn" color for Failed or Cancelled statuses', () => {
expect(pipe.transform(statusEnum.Failed)).toBe(StatusColor.warn);
expect(pipe.transform(statusEnum.Cancelled)).toBe(StatusColor.warn);
});
it('should return "pending" color for Pending status', () => {
expect(pipe.transform(statusEnum.Pending)).toBe(StatusColor.pending);
});
it('should return "neutral" color for Refunded status', () => {
expect(pipe.transform(statusEnum.Refunded)).toBe(StatusColor.neutral);
});
});
});

View File

@ -2,7 +2,7 @@ import { Pipe, PipeTransform } from '@angular/core';
import { PaymentSearchResult } from '@dsh/api-codegen/capi/swagger-codegen';
import { StatusColor } from '../../../../theme-manager';
import { StatusColor } from '../../../../../../../../theme-manager';
@Pipe({
name: 'paymentStatusColor',

View File

@ -0,0 +1,18 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { TranslocoModule } from '@ngneat/transloco';
import { BaseDialogModule } from '@dsh/app/shared/components/dialog/base-dialog';
import { MaxLengthInputModule } from '@dsh/app/shared/components/inputs/max-length-input/max-length-input.module';
import { CancelHoldService } from './cancel-hold.service';
import { CancelHoldDialogComponent } from './components/cancel-hold-dialog/cancel-hold-dialog.component';
@NgModule({
imports: [CommonModule, BaseDialogModule, MaxLengthInputModule, ReactiveFormsModule, FlexModule, TranslocoModule],
declarations: [CancelHoldDialogComponent],
providers: [CancelHoldService],
})
export class CancelHoldModule {}

Some files were not shown because too many files have changed in this diff Show More