From 8c21b377a5699c1b706ac95bd9c862cdb61c543a Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:29:32 +0800 Subject: [PATCH 1/3] Add hasMore$ observable to PartiesComponent for pagination support --- src/app/parties/parties.component.html | 1 + src/app/parties/parties.component.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/app/parties/parties.component.html b/src/app/parties/parties.component.html index 935e1a4c5..d52bf5014 100644 --- a/src/app/parties/parties.component.html +++ b/src/app/parties/parties.component.html @@ -3,6 +3,7 @@ [columns]="columns" [data]="parties$ | async" [filter]="initSearchParams$ | async" + [hasMore]="hasMore$ | async" [progress]="inProgress$ | async" externalFilter standaloneFilter diff --git a/src/app/parties/parties.component.ts b/src/app/parties/parties.component.ts index 4429d47ed..f2265e689 100644 --- a/src/app/parties/parties.component.ts +++ b/src/app/parties/parties.component.ts @@ -38,6 +38,7 @@ export class PartiesComponent implements OnInit { parties$ = this.fetchFullDomainObjectsService.result$.pipe( map((objs) => objs.map((obj) => obj.object.party_config)), ); + hasMore$ = this.fetchFullDomainObjectsService.hasMore$; columns: Column[] = [ { field: 'id', From fe025a710da14adef99b013c3f677ae6a770a400 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:58:15 +0800 Subject: [PATCH 2/3] Refactor create webhook dialog and add invoice event types field component --- .../consts/shop-invoice-event-types.ts | 42 +++++ .../create-webhook-dialog.component.html | 21 ++- .../create-webhook-dialog.component.ts | 71 +++++--- .../invoice-event-types-field/index.ts | 1 + .../invoice-event-types-field.component.html | 56 ++++++ .../invoice-event-types-field.component.ts | 119 +++++++++++++ .../invoice-event-types-field/types.ts | 10 ++ .../utils/create-event-types-tree.spec.ts | 162 ++++++++++++++++++ .../utils/create-event-types-tree.ts | 67 ++++++++ 9 files changed, 517 insertions(+), 32 deletions(-) create mode 100644 src/app/parties/party/webhooks/create-webhook-dialog/consts/shop-invoice-event-types.ts create mode 100644 src/components/invoice-event-types-field/index.ts create mode 100644 src/components/invoice-event-types-field/invoice-event-types-field.component.html create mode 100644 src/components/invoice-event-types-field/invoice-event-types-field.component.ts create mode 100644 src/components/invoice-event-types-field/types.ts create mode 100644 src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts create mode 100644 src/components/invoice-event-types-field/utils/create-event-types-tree.ts diff --git a/src/app/parties/party/webhooks/create-webhook-dialog/consts/shop-invoice-event-types.ts b/src/app/parties/party/webhooks/create-webhook-dialog/consts/shop-invoice-event-types.ts new file mode 100644 index 000000000..d402ef1a9 --- /dev/null +++ b/src/app/parties/party/webhooks/create-webhook-dialog/consts/shop-invoice-event-types.ts @@ -0,0 +1,42 @@ +import { InvoiceEventType } from '@vality/domain-proto/webhooker'; + +export const SHOP_INVOICE_EVENT_TYPES: InvoiceEventType = { + created: {}, + status_changed: { + value: { + unpaid: {}, + paid: {}, + cancelled: {}, + fulfilled: {}, + }, + }, + payment: { + created: {}, + status_changed: { + value: { + pending: {}, + processed: {}, + captured: {}, + cancelled: {}, + failed: {}, + refunded: {}, + }, + }, + invoice_payment_refund_change: { + invoice_payment_refund_created: {}, + invoice_payment_refund_status_changed: { + value: { + pending: {}, + succeeded: {}, + failed: {}, + }, + }, + }, + user_interaction: { + status: { + requested: {}, + completed: {}, + }, + }, + }, +}; diff --git a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html index 9b6c7f285..d63dc69ec 100644 --- a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html +++ b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html @@ -1,12 +1,19 @@ - +
+ + +
+
+ Invoice Event Types +
+ +
+
- diff --git a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts index 8ba62c9d9..cc3a1bc7f 100644 --- a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts +++ b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts @@ -1,33 +1,41 @@ -import { distinctUntilChanged, map } from 'rxjs'; - import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormField, form, required } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; -import { WebhookParams } from '@vality/domain-proto/webhooker'; +import { InvoiceEventType, WebhookParams } from '@vality/domain-proto/webhooker'; import { DialogModule, DialogSuperclass, + InputFieldModule, NotifyLogService, - getValueChanges, progressTo, } from '@vality/matez'; import { ThriftShopWebhooksManagementService } from '~/api/services'; -import { - DomainMetadataFormExtensionsService, - DomainThriftFormComponent, -} from '~/components/thrift-api-crud'; +import { InvoiceEventTypesFieldComponent } from '~/components/invoice-event-types-field'; +import { PartyShop, ShopMerchantFieldComponent } from '~/components/shop-merchant-field'; + +import { SHOP_INVOICE_EVENT_TYPES } from './consts/shop-invoice-event-types'; + +interface CreateWebhookModel { + partyShop: PartyShop; + url: string; + eventTypes: InvoiceEventType[]; +} @Component({ selector: 'cc-create-webhook-dialog', imports: [ CommonModule, DialogModule, - DomainThriftFormComponent, ReactiveFormsModule, MatButtonModule, + ShopMerchantFieldComponent, + InputFieldModule, + FormField, + InvoiceEventTypesFieldComponent, ], changeDetection: ChangeDetectionStrategy.Eager, templateUrl: './create-webhook-dialog.component.html', @@ -38,26 +46,39 @@ export class CreateWebhookDialogComponent extends DialogSuperclass< > { private webhooksManagementService = inject(ThriftShopWebhooksManagementService); private log = inject(NotifyLogService); - private domainMetadataFormExtensionsService = inject(DomainMetadataFormExtensionsService); - control = new FormControl>( - { party_ref: { id: this.dialogData.partyId } }, - { nonNullable: true }, - ); + controlModel = signal({ + partyShop: { + party_id: this.dialogData.partyId, + shop_id: null, + }, + url: '', + eventTypes: [], + }); + control = form(this.controlModel, (schemaPath) => { + required(schemaPath.partyShop.party_id); + required(schemaPath.partyShop.shop_id); + required(schemaPath.url); + required(schemaPath.eventTypes); + }); progress = signal(0); - extensions$ = this.domainMetadataFormExtensionsService.createFullDomainObjectsOptionsByType( - 'ShopConfigObject', - 'shop_config', - getValueChanges(this.control).pipe( - map((value) => value?.party_ref?.id ?? this.dialogData.partyId), - distinctUntilChanged(), - map((partyId) => (obj) => obj.object.shop_config.data.party_ref.id === partyId), - ), - ); + eventTypesStructure = SHOP_INVOICE_EVENT_TYPES as Record; create() { + const { partyShop, url, eventTypes } = this.controlModel(); + const params: WebhookParams = { + party_ref: { id: partyShop.party_id }, + url, + event_filter: { + invoice: { + shop_ref: { id: partyShop.shop_id }, + types: new Set(eventTypes), + }, + }, + }; + this.webhooksManagementService - .Create(this.control.value as WebhookParams) + .Create(params) .pipe(progressTo(this.progress)) .subscribe(() => { this.log.success('Webhook created'); diff --git a/src/components/invoice-event-types-field/index.ts b/src/components/invoice-event-types-field/index.ts new file mode 100644 index 000000000..ba3eca5f2 --- /dev/null +++ b/src/components/invoice-event-types-field/index.ts @@ -0,0 +1 @@ +export * from './invoice-event-types-field.component'; diff --git a/src/components/invoice-event-types-field/invoice-event-types-field.component.html b/src/components/invoice-event-types-field/invoice-event-types-field.component.html new file mode 100644 index 000000000..566f16408 --- /dev/null +++ b/src/components/invoice-event-types-field/invoice-event-types-field.component.html @@ -0,0 +1,56 @@ +
+
+ + Selected: {{ selectedLeafIds().size }} / {{ allLeaves().length }} + +
+ + +
+
+ +
+ @for (node of tree(); track node.id) { + + } +
+
+ + +
+ + {{ node.label }} + + + @if (node.children?.length) { +
+ @for (child of node.children; track child.id) { + + } +
+ } +
+
diff --git a/src/components/invoice-event-types-field/invoice-event-types-field.component.ts b/src/components/invoice-event-types-field/invoice-event-types-field.component.ts new file mode 100644 index 000000000..f21765969 --- /dev/null +++ b/src/components/invoice-event-types-field/invoice-event-types-field.component.ts @@ -0,0 +1,119 @@ +import { isEqual } from 'lodash-es'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + forwardRef, + input, + model, +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { FormValueControl } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; + +import { InvoiceEventType } from '@vality/domain-proto/webhooker'; + +import { EventTypeTreeNode } from './types'; +import { createEventTypesTree } from './utils/create-event-types-tree'; + +@Component({ + selector: 'cc-invoice-event-types-field', + templateUrl: './invoice-event-types-field.component.html', + changeDetection: ChangeDetectionStrategy.Eager, + imports: [CommonModule, MatCheckboxModule, MatButtonModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => InvoiceEventTypesFieldComponent), + multi: true, + }, + ], +}) +export class InvoiceEventTypesFieldComponent + implements FormValueControl, ControlValueAccessor +{ + eventTypesStructure = input.required>(); + disabled = model(false); + value = model([]); + + tree = computed(() => createEventTypesTree(this.eventTypesStructure())); + allLeaves = computed(() => this.tree().flatMap((node) => node.leaves)); + + selectedLeafIds = computed>(() => { + const selected = new Set(); + const val = this.value(); + if (!val || val.length === 0) { + return selected; + } + for (const leaf of this.allLeaves()) { + if (val.some((item) => isEqual(item, leaf.eventType))) { + selected.add(leaf.id); + } + } + return selected; + }); + + private onChange: (val: InvoiceEventType[]) => void = () => undefined; + private onTouched: () => void = () => undefined; + + isChecked(node: EventTypeTreeNode): boolean { + const selected = this.selectedLeafIds(); + return node.leaves.length > 0 && node.leaves.every((leaf) => selected.has(leaf.id)); + } + + isIndeterminate(node: EventTypeTreeNode): boolean { + const selected = this.selectedLeafIds(); + const count = node.leaves.filter((leaf) => selected.has(leaf.id)).length; + return count > 0 && count < node.leaves.length; + } + + toggleNode(node: EventTypeTreeNode, checked: boolean): void { + const currentSelected = new Set(this.selectedLeafIds()); + for (const leaf of node.leaves) { + if (checked) { + currentSelected.add(leaf.id); + } else { + currentSelected.delete(leaf.id); + } + } + const newTypes = this.allLeaves() + .filter((leaf) => currentSelected.has(leaf.id)) + .map((leaf) => leaf.eventType!); + this.updateValue(newTypes); + } + + selectAll(): void { + const allTypes = this.allLeaves().map((leaf) => leaf.eventType!); + this.updateValue(allTypes); + } + + clearAll(): void { + this.updateValue([]); + } + + writeValue(value: InvoiceEventType[] | Set | null): void { + const array = value ? (Array.isArray(value) ? value : Array.from(value)) : []; + this.value.set(array); + } + + registerOnChange(fn: (val: InvoiceEventType[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled.set(isDisabled); + } + + private updateValue(val: InvoiceEventType[]): void { + this.value.set(val); + this.onChange(val); + this.onTouched(); + } +} diff --git a/src/components/invoice-event-types-field/types.ts b/src/components/invoice-event-types-field/types.ts new file mode 100644 index 000000000..e4c417b0e --- /dev/null +++ b/src/components/invoice-event-types-field/types.ts @@ -0,0 +1,10 @@ +import { InvoiceEventType } from '@vality/domain-proto/webhooker'; + +export interface EventTypeTreeNode { + id: string; + label: string; + path: string[]; + eventType?: InvoiceEventType; + children?: EventTypeTreeNode[]; + leaves: EventTypeTreeNode[]; +} diff --git a/src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts b/src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts new file mode 100644 index 000000000..19770689f --- /dev/null +++ b/src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts @@ -0,0 +1,162 @@ +import { InvoiceEventType } from '@vality/domain-proto/webhooker'; + +import { createEventTypesTree } from './create-event-types-tree'; + +const TEST_INVOICE_EVENT_TYPES: InvoiceEventType = { + created: {}, + status_changed: { + value: { + unpaid: {}, + paid: {}, + cancelled: {}, + fulfilled: {}, + }, + }, + payment: { + created: {}, + status_changed: { + value: { + pending: {}, + processed: {}, + captured: {}, + cancelled: {}, + failed: {}, + refunded: {}, + }, + }, + invoice_payment_refund_change: { + invoice_payment_refund_created: {}, + invoice_payment_refund_status_changed: { + value: { + pending: {}, + succeeded: {}, + failed: {}, + }, + }, + }, + user_interaction: { + status: { + requested: {}, + completed: {}, + }, + }, + }, +}; + +describe('createEventTypesTree', () => { + it('returns empty array for empty object', () => { + expect(createEventTypesTree({})).toEqual([]); + }); + + it('creates single leaf node correctly', () => { + const tree = createEventTypesTree({ created: {} }); + + expect(tree).toHaveLength(1); + expect(tree[0].id).toBe('created'); + expect(tree[0].label).toBe('Created'); + expect(tree[0].path).toEqual(['created']); + expect(tree[0].eventType).toEqual({ created: {} }); + expect(tree[0].leaves).toHaveLength(1); + expect(tree[0].leaves[0]).toBe(tree[0]); + }); + + it('collapses "value" wrapper key and preserves eventType structure', () => { + const tree = createEventTypesTree({ + status_changed: { + value: { + unpaid: {}, + paid: {}, + }, + }, + }); + + expect(tree).toHaveLength(1); + expect(tree[0].id).toBe('status_changed'); + expect(tree[0].label).toBe('Status Changed'); + expect(tree[0].children).toHaveLength(2); + expect(tree[0].children?.[0].label).toBe('Unpaid'); + expect(tree[0].children?.[0].path).toEqual(['status_changed', 'value', 'unpaid']); + expect(tree[0].children?.[0].eventType).toEqual({ + status_changed: { value: { unpaid: {} } }, + }); + expect(tree[0].children?.[1].label).toBe('Paid'); + expect(tree[0].children?.[1].path).toEqual(['status_changed', 'value', 'paid']); + expect(tree[0].children?.[1].eventType).toEqual({ + status_changed: { value: { paid: {} } }, + }); + expect(tree[0].leaves).toHaveLength(2); + }); + + it('collapses "status" wrapper key and preserves eventType structure', () => { + const tree = createEventTypesTree({ + user_interaction: { + status: { + requested: {}, + completed: {}, + }, + }, + }); + + expect(tree).toHaveLength(1); + expect(tree[0].id).toBe('user_interaction'); + expect(tree[0].label).toBe('User Interaction'); + expect(tree[0].children).toHaveLength(2); + expect(tree[0].children?.[0].label).toBe('Requested'); + expect(tree[0].children?.[0].eventType).toEqual({ + user_interaction: { status: { requested: {} } }, + }); + expect(tree[0].children?.[1].label).toBe('Completed'); + expect(tree[0].children?.[1].eventType).toEqual({ + user_interaction: { status: { completed: {} } }, + }); + expect(tree[0].leaves).toHaveLength(2); + }); + + it('correctly creates full tree for TEST_INVOICE_EVENT_TYPES', () => { + const tree = createEventTypesTree(TEST_INVOICE_EVENT_TYPES as Record); + + expect(tree).toHaveLength(3); + expect(tree.map((node) => node.label)).toEqual(['Created', 'Status Changed', 'Payment']); + + const allLeaves = tree.flatMap((node) => node.leaves); + expect(allLeaves).toHaveLength(18); + + const createdNode = tree.find((n) => n.id === 'created'); + expect(createdNode?.leaves).toHaveLength(1); + + const statusChangedNode = tree.find((n) => n.id === 'status_changed'); + expect(statusChangedNode?.leaves).toHaveLength(4); + + const paymentNode = tree.find((n) => n.id === 'payment'); + expect(paymentNode?.leaves).toHaveLength(13); + + const refundCreatedLeaf = allLeaves.find( + (leaf) => + leaf.id === 'payment.invoice_payment_refund_change.invoice_payment_refund_created', + ); + expect(refundCreatedLeaf?.eventType).toEqual({ + payment: { + invoice_payment_refund_change: { + invoice_payment_refund_created: {}, + }, + }, + }); + + const refundStatusPendingLeaf = allLeaves.find( + (leaf) => + leaf.id === + 'payment.invoice_payment_refund_change.invoice_payment_refund_status_changed.value.pending', + ); + expect(refundStatusPendingLeaf?.eventType).toEqual({ + payment: { + invoice_payment_refund_change: { + invoice_payment_refund_status_changed: { + value: { + pending: {}, + }, + }, + }, + }, + }); + }); +}); diff --git a/src/components/invoice-event-types-field/utils/create-event-types-tree.ts b/src/components/invoice-event-types-field/utils/create-event-types-tree.ts new file mode 100644 index 000000000..737ad30a8 --- /dev/null +++ b/src/components/invoice-event-types-field/utils/create-event-types-tree.ts @@ -0,0 +1,67 @@ +import { startCase } from 'lodash-es'; +import set from 'lodash-es/set'; + +import { InvoiceEventType } from '@vality/domain-proto/webhooker'; + +import { EventTypeTreeNode } from '../types'; + +function parseNode(key: string, value: unknown, currentPath: string[]): EventTypeTreeNode { + const isObject = typeof value === 'object' && value !== null; + const keys = isObject ? Object.keys(value) : []; + + if (keys.length === 1 && (keys[0] === 'value' || keys[0] === 'status')) { + const wrapperKey = keys[0]; + const innerValue = (value as Record)[wrapperKey]; + const innerKeys = + typeof innerValue === 'object' && innerValue !== null ? Object.keys(innerValue) : []; + const children = innerKeys.map((k) => + parseNode(k, (innerValue as Record)[k], [ + ...currentPath, + wrapperKey, + k, + ]), + ); + const leaves = children.flatMap((c) => c.leaves); + return { + id: currentPath.join('.'), + label: startCase(key), + path: currentPath, + children, + leaves, + }; + } + + if (keys.length === 0) { + const eventTypeObj = {}; + set(eventTypeObj, currentPath, {}); + const leafNode: EventTypeTreeNode = { + id: currentPath.join('.'), + label: startCase(key), + path: currentPath, + eventType: eventTypeObj as InvoiceEventType, + leaves: [], + }; + leafNode.leaves = [leafNode]; + return leafNode; + } + + const children = keys.map((k) => + parseNode(k, (value as Record)[k], [...currentPath, k]), + ); + const leaves = children.flatMap((c) => c.leaves); + return { + id: currentPath.join('.'), + label: startCase(key), + path: currentPath, + children, + leaves, + }; +} + +export function createEventTypesTree( + eventTypesStructure: Record, +): EventTypeTreeNode[] { + return Object.keys(eventTypesStructure).map((key) => + parseNode(key, eventTypesStructure[key], [key]), + ); +} From 2cb1cace100549e04bf13de4a30f9a37d0bc1e78 Mon Sep 17 00:00:00 2001 From: Ray <11846445+A77AY@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:39:07 +0800 Subject: [PATCH 3/3] refactor: replace invoice event types field with a generic event types field component - Removed the InvoiceEventTypesFieldComponent and its associated files. - Introduced a new EventTypesFieldComponent to handle various event types generically. - Updated create-wallet-webhook-dialog and create-webhook-dialog components to use the new EventTypesFieldComponent. - Added wallet event types constants for wallet webhooks. - Refactored wallet-field component to improve search functionality and loading states. - Enhanced wallet-merchant-field component to manage party and wallet selections more effectively. --- .../consts/wallet-event-types.ts | 12 ++++ ...reate-wallet-webhook-dialog.component.html | 18 +++-- .../create-wallet-webhook-dialog.component.ts | 69 +++++++++++------- .../create-webhook-dialog.component.html | 4 +- .../create-webhook-dialog.component.ts | 4 +- .../event-types-field.component.html} | 0 .../event-types-field.component.ts} | 34 +++++---- src/components/event-types-field/index.ts | 3 + src/components/event-types-field/types.ts | 8 +++ .../utils/create-event-types-tree.spec.ts | 50 +++++++++---- .../utils/create-event-types-tree.ts | 18 +++-- .../invoice-event-types-field/index.ts | 1 - .../invoice-event-types-field/types.ts | 10 --- .../wallet-field/wallet-field.component.html | 12 ++-- .../wallet-field/wallet-field.component.ts | 70 ++++++++++++------- src/components/wallet-merchant-field/index.ts | 1 + .../wallet-merchant-field.component.html | 4 ++ .../wallet-merchant-field.component.ts | 55 +++++++++++++++ 18 files changed, 254 insertions(+), 119 deletions(-) create mode 100644 src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/consts/wallet-event-types.ts rename src/components/{invoice-event-types-field/invoice-event-types-field.component.html => event-types-field/event-types-field.component.html} (100%) rename src/components/{invoice-event-types-field/invoice-event-types-field.component.ts => event-types-field/event-types-field.component.ts} (71%) create mode 100644 src/components/event-types-field/index.ts create mode 100644 src/components/event-types-field/types.ts rename src/components/{invoice-event-types-field => event-types-field}/utils/create-event-types-tree.spec.ts (79%) rename src/components/{invoice-event-types-field => event-types-field}/utils/create-event-types-tree.ts (75%) delete mode 100644 src/components/invoice-event-types-field/index.ts delete mode 100644 src/components/invoice-event-types-field/types.ts create mode 100644 src/components/wallet-merchant-field/index.ts create mode 100644 src/components/wallet-merchant-field/wallet-merchant-field.component.html create mode 100644 src/components/wallet-merchant-field/wallet-merchant-field.component.ts diff --git a/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/consts/wallet-event-types.ts b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/consts/wallet-event-types.ts new file mode 100644 index 000000000..c143923f0 --- /dev/null +++ b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/consts/wallet-event-types.ts @@ -0,0 +1,12 @@ +import { EventType } from '@vality/fistful-proto/webhooker'; + +export const WALLET_EVENT_TYPES: EventType = { + withdrawal: { + started: {}, + succeeded: {}, + failed: {}, + }, + destination: { + created: {}, + }, +}; diff --git a/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.html b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.html index 43350c694..a9988b4aa 100644 --- a/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.html +++ b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.html @@ -1,12 +1,16 @@ - +
+ + +
+
+ Wallet Event Types +
+ +
+
- diff --git a/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.ts b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.ts index fb6277018..1b625e105 100644 --- a/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.ts +++ b/src/app/parties/party/wallet-webhooks/create-wallet-webhook-dialog/create-wallet-webhook-dialog.component.ts @@ -1,32 +1,41 @@ -import { distinctUntilChanged, map, of } from 'rxjs'; - import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormField, form, required } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; -import { WebhookParams } from '@vality/fistful-proto/webhooker'; +import { EventType, WebhookParams } from '@vality/fistful-proto/webhooker'; import { DialogModule, DialogSuperclass, + InputFieldModule, NotifyLogService, - getValueChanges, progressTo, } from '@vality/matez'; -import { isTypeWithAliases } from '@vality/ng-thrift'; import { ThriftWalletWebhooksManagementService } from '~/api/services'; -import { FistfulThriftFormComponent } from '~/components/fistful-thrift-form'; -import { DomainMetadataFormExtensionsService } from '~/components/thrift-api-crud'; +import { EventTypesFieldComponent } from '~/components/event-types-field'; +import { PartyWallet, WalletMerchantFieldComponent } from '~/components/wallet-merchant-field'; + +import { WALLET_EVENT_TYPES } from './consts/wallet-event-types'; + +interface CreateWalletWebhookModel { + partyWallet: PartyWallet; + url: string; + eventTypes: EventType[]; +} @Component({ selector: 'cc-create-wallet-webhook-dialog', imports: [ CommonModule, DialogModule, - FistfulThriftFormComponent, ReactiveFormsModule, MatButtonModule, + WalletMerchantFieldComponent, + InputFieldModule, + FormField, + EventTypesFieldComponent, ], changeDetection: ChangeDetectionStrategy.Eager, templateUrl: './create-wallet-webhook-dialog.component.html', @@ -37,27 +46,37 @@ export class CreateWalletWebhookDialogComponent extends DialogSuperclass< > { private walletWebhooksManagementService = inject(ThriftWalletWebhooksManagementService); private log = inject(NotifyLogService); - private domainMetadataFormExtensionsService = inject(DomainMetadataFormExtensionsService); - control = new FormControl>( - { party_id: this.dialogData.partyId }, - { nonNullable: true }, - ); + walletEventTypes = WALLET_EVENT_TYPES as Record; + + controlModel = signal({ + partyWallet: { + party_id: this.dialogData.partyId, + wallet_id: null, + }, + url: '', + eventTypes: [], + }); + control = form(this.controlModel, (schemaPath) => { + required(schemaPath.partyWallet.party_id); + required(schemaPath.url); + required(schemaPath.eventTypes); + }); progress = signal(0); - extensions$ = this.domainMetadataFormExtensionsService.createFullDomainObjectsOptionsByType( - 'WalletConfigObject', - 'wallet_config', - getValueChanges(this.control).pipe( - map((value) => value?.party_id ?? this.dialogData.partyId), - distinctUntilChanged(), - map((partyId) => (obj) => obj.object.wallet_config.data.party_ref.id === partyId), - ), - (data) => of(isTypeWithAliases(data, 'WalletID', 'webhooker')), - ); create() { + const { partyWallet, url, eventTypes } = this.controlModel(); + const params: WebhookParams = { + party_id: partyWallet.party_id, + ...(partyWallet.wallet_id ? { wallet_id: partyWallet.wallet_id } : {}), + url, + event_filter: { + types: new Set(eventTypes), + }, + }; + this.walletWebhooksManagementService - .Create(this.control.value as WebhookParams) + .Create(params) .pipe(progressTo(this.progress)) .subscribe(() => { this.log.success('Webhook created'); diff --git a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html index d63dc69ec..5eb30e879 100644 --- a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html +++ b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.html @@ -6,9 +6,9 @@
Invoice Event Types
- diff --git a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts index cc3a1bc7f..73a88afcf 100644 --- a/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts +++ b/src/app/parties/party/webhooks/create-webhook-dialog/create-webhook-dialog.component.ts @@ -14,7 +14,7 @@ import { } from '@vality/matez'; import { ThriftShopWebhooksManagementService } from '~/api/services'; -import { InvoiceEventTypesFieldComponent } from '~/components/invoice-event-types-field'; +import { EventTypesFieldComponent } from '~/components/event-types-field'; import { PartyShop, ShopMerchantFieldComponent } from '~/components/shop-merchant-field'; import { SHOP_INVOICE_EVENT_TYPES } from './consts/shop-invoice-event-types'; @@ -35,7 +35,7 @@ interface CreateWebhookModel { ShopMerchantFieldComponent, InputFieldModule, FormField, - InvoiceEventTypesFieldComponent, + EventTypesFieldComponent, ], changeDetection: ChangeDetectionStrategy.Eager, templateUrl: './create-webhook-dialog.component.html', diff --git a/src/components/invoice-event-types-field/invoice-event-types-field.component.html b/src/components/event-types-field/event-types-field.component.html similarity index 100% rename from src/components/invoice-event-types-field/invoice-event-types-field.component.html rename to src/components/event-types-field/event-types-field.component.html diff --git a/src/components/invoice-event-types-field/invoice-event-types-field.component.ts b/src/components/event-types-field/event-types-field.component.ts similarity index 71% rename from src/components/invoice-event-types-field/invoice-event-types-field.component.ts rename to src/components/event-types-field/event-types-field.component.ts index f21765969..d99ca51af 100644 --- a/src/components/invoice-event-types-field/invoice-event-types-field.component.ts +++ b/src/components/event-types-field/event-types-field.component.ts @@ -14,33 +14,31 @@ import { FormValueControl } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { InvoiceEventType } from '@vality/domain-proto/webhooker'; - import { EventTypeTreeNode } from './types'; import { createEventTypesTree } from './utils/create-event-types-tree'; @Component({ - selector: 'cc-invoice-event-types-field', - templateUrl: './invoice-event-types-field.component.html', + selector: 'cc-event-types-field', + templateUrl: './event-types-field.component.html', changeDetection: ChangeDetectionStrategy.Eager, imports: [CommonModule, MatCheckboxModule, MatButtonModule], providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => InvoiceEventTypesFieldComponent), + useExisting: forwardRef(() => EventTypesFieldComponent), multi: true, }, ], }) -export class InvoiceEventTypesFieldComponent - implements FormValueControl, ControlValueAccessor +export class EventTypesFieldComponent + implements FormValueControl, ControlValueAccessor { - eventTypesStructure = input.required>(); + structure = input.required>(); disabled = model(false); - value = model([]); + value = model([]); - tree = computed(() => createEventTypesTree(this.eventTypesStructure())); - allLeaves = computed(() => this.tree().flatMap((node) => node.leaves)); + tree = computed[]>(() => createEventTypesTree(this.structure())); + allLeaves = computed[]>(() => this.tree().flatMap((node) => node.leaves)); selectedLeafIds = computed>(() => { const selected = new Set(); @@ -56,21 +54,21 @@ export class InvoiceEventTypesFieldComponent return selected; }); - private onChange: (val: InvoiceEventType[]) => void = () => undefined; + private onChange: (val: T[]) => void = () => undefined; private onTouched: () => void = () => undefined; - isChecked(node: EventTypeTreeNode): boolean { + isChecked(node: EventTypeTreeNode): boolean { const selected = this.selectedLeafIds(); return node.leaves.length > 0 && node.leaves.every((leaf) => selected.has(leaf.id)); } - isIndeterminate(node: EventTypeTreeNode): boolean { + isIndeterminate(node: EventTypeTreeNode): boolean { const selected = this.selectedLeafIds(); const count = node.leaves.filter((leaf) => selected.has(leaf.id)).length; return count > 0 && count < node.leaves.length; } - toggleNode(node: EventTypeTreeNode, checked: boolean): void { + toggleNode(node: EventTypeTreeNode, checked: boolean): void { const currentSelected = new Set(this.selectedLeafIds()); for (const leaf of node.leaves) { if (checked) { @@ -94,12 +92,12 @@ export class InvoiceEventTypesFieldComponent this.updateValue([]); } - writeValue(value: InvoiceEventType[] | Set | null): void { + writeValue(value: T[] | Set | null): void { const array = value ? (Array.isArray(value) ? value : Array.from(value)) : []; this.value.set(array); } - registerOnChange(fn: (val: InvoiceEventType[]) => void): void { + registerOnChange(fn: (val: T[]) => void): void { this.onChange = fn; } @@ -111,7 +109,7 @@ export class InvoiceEventTypesFieldComponent this.disabled.set(isDisabled); } - private updateValue(val: InvoiceEventType[]): void { + private updateValue(val: T[]): void { this.value.set(val); this.onChange(val); this.onTouched(); diff --git a/src/components/event-types-field/index.ts b/src/components/event-types-field/index.ts new file mode 100644 index 000000000..9b72bc07b --- /dev/null +++ b/src/components/event-types-field/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './utils/create-event-types-tree'; +export * from './event-types-field.component'; diff --git a/src/components/event-types-field/types.ts b/src/components/event-types-field/types.ts new file mode 100644 index 000000000..a286eb0f2 --- /dev/null +++ b/src/components/event-types-field/types.ts @@ -0,0 +1,8 @@ +export interface EventTypeTreeNode { + id: string; + label: string; + path: string[]; + eventType?: T; + children?: EventTypeTreeNode[]; + leaves: EventTypeTreeNode[]; +} diff --git a/src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts b/src/components/event-types-field/utils/create-event-types-tree.spec.ts similarity index 79% rename from src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts rename to src/components/event-types-field/utils/create-event-types-tree.spec.ts index 19770689f..e6cdad21c 100644 --- a/src/components/invoice-event-types-field/utils/create-event-types-tree.spec.ts +++ b/src/components/event-types-field/utils/create-event-types-tree.spec.ts @@ -1,4 +1,5 @@ import { InvoiceEventType } from '@vality/domain-proto/webhooker'; +import { EventType } from '@vality/fistful-proto/webhooker'; import { createEventTypesTree } from './create-event-types-tree'; @@ -43,6 +44,17 @@ const TEST_INVOICE_EVENT_TYPES: InvoiceEventType = { }, }; +const TEST_WALLET_EVENT_TYPES: EventType = { + withdrawal: { + started: {}, + succeeded: {}, + failed: {}, + }, + destination: { + created: {}, + }, +}; + describe('createEventTypesTree', () => { it('returns empty array for empty object', () => { expect(createEventTypesTree({})).toEqual([]); @@ -113,7 +125,9 @@ describe('createEventTypesTree', () => { }); it('correctly creates full tree for TEST_INVOICE_EVENT_TYPES', () => { - const tree = createEventTypesTree(TEST_INVOICE_EVENT_TYPES as Record); + const tree = createEventTypesTree( + TEST_INVOICE_EVENT_TYPES as Record, + ); expect(tree).toHaveLength(3); expect(tree.map((node) => node.label)).toEqual(['Created', 'Status Changed', 'Payment']); @@ -141,21 +155,29 @@ describe('createEventTypesTree', () => { }, }, }); + }); - const refundStatusPendingLeaf = allLeaves.find( - (leaf) => - leaf.id === - 'payment.invoice_payment_refund_change.invoice_payment_refund_status_changed.value.pending', + it('correctly creates full tree for TEST_WALLET_EVENT_TYPES', () => { + const tree = createEventTypesTree( + TEST_WALLET_EVENT_TYPES as Record, ); - expect(refundStatusPendingLeaf?.eventType).toEqual({ - payment: { - invoice_payment_refund_change: { - invoice_payment_refund_status_changed: { - value: { - pending: {}, - }, - }, - }, + + expect(tree).toHaveLength(2); + expect(tree.map((node) => node.label)).toEqual(['Withdrawal', 'Destination']); + + const allLeaves = tree.flatMap((node) => node.leaves); + expect(allLeaves).toHaveLength(4); + + const withdrawalNode = tree.find((n) => n.id === 'withdrawal'); + expect(withdrawalNode?.leaves).toHaveLength(3); + + const destinationNode = tree.find((n) => n.id === 'destination'); + expect(destinationNode?.leaves).toHaveLength(1); + + const startedLeaf = allLeaves.find((leaf) => leaf.id === 'withdrawal.started'); + expect(startedLeaf?.eventType).toEqual({ + withdrawal: { + started: {}, }, }); }); diff --git a/src/components/invoice-event-types-field/utils/create-event-types-tree.ts b/src/components/event-types-field/utils/create-event-types-tree.ts similarity index 75% rename from src/components/invoice-event-types-field/utils/create-event-types-tree.ts rename to src/components/event-types-field/utils/create-event-types-tree.ts index 737ad30a8..b2ab70ef4 100644 --- a/src/components/invoice-event-types-field/utils/create-event-types-tree.ts +++ b/src/components/event-types-field/utils/create-event-types-tree.ts @@ -1,11 +1,9 @@ import { startCase } from 'lodash-es'; import set from 'lodash-es/set'; -import { InvoiceEventType } from '@vality/domain-proto/webhooker'; - import { EventTypeTreeNode } from '../types'; -function parseNode(key: string, value: unknown, currentPath: string[]): EventTypeTreeNode { +function parseNode(key: string, value: unknown, currentPath: string[]): EventTypeTreeNode { const isObject = typeof value === 'object' && value !== null; const keys = isObject ? Object.keys(value) : []; @@ -15,7 +13,7 @@ function parseNode(key: string, value: unknown, currentPath: string[]): EventTyp const innerKeys = typeof innerValue === 'object' && innerValue !== null ? Object.keys(innerValue) : []; const children = innerKeys.map((k) => - parseNode(k, (innerValue as Record)[k], [ + parseNode(k, (innerValue as Record)[k], [ ...currentPath, wrapperKey, k, @@ -34,11 +32,11 @@ function parseNode(key: string, value: unknown, currentPath: string[]): EventTyp if (keys.length === 0) { const eventTypeObj = {}; set(eventTypeObj, currentPath, {}); - const leafNode: EventTypeTreeNode = { + const leafNode: EventTypeTreeNode = { id: currentPath.join('.'), label: startCase(key), path: currentPath, - eventType: eventTypeObj as InvoiceEventType, + eventType: eventTypeObj as T, leaves: [], }; leafNode.leaves = [leafNode]; @@ -46,7 +44,7 @@ function parseNode(key: string, value: unknown, currentPath: string[]): EventTyp } const children = keys.map((k) => - parseNode(k, (value as Record)[k], [...currentPath, k]), + parseNode(k, (value as Record)[k], [...currentPath, k]), ); const leaves = children.flatMap((c) => c.leaves); return { @@ -58,10 +56,10 @@ function parseNode(key: string, value: unknown, currentPath: string[]): EventTyp }; } -export function createEventTypesTree( +export function createEventTypesTree( eventTypesStructure: Record, -): EventTypeTreeNode[] { +): EventTypeTreeNode[] { return Object.keys(eventTypesStructure).map((key) => - parseNode(key, eventTypesStructure[key], [key]), + parseNode(key, eventTypesStructure[key], [key]), ); } diff --git a/src/components/invoice-event-types-field/index.ts b/src/components/invoice-event-types-field/index.ts deleted file mode 100644 index ba3eca5f2..000000000 --- a/src/components/invoice-event-types-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './invoice-event-types-field.component'; diff --git a/src/components/invoice-event-types-field/types.ts b/src/components/invoice-event-types-field/types.ts deleted file mode 100644 index e4c417b0e..000000000 --- a/src/components/invoice-event-types-field/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { InvoiceEventType } from '@vality/domain-proto/webhooker'; - -export interface EventTypeTreeNode { - id: string; - label: string; - path: string[]; - eventType?: InvoiceEventType; - children?: EventTypeTreeNode[]; - leaves: EventTypeTreeNode[]; -} diff --git a/src/components/wallet-field/wallet-field.component.html b/src/components/wallet-field/wallet-field.component.html index e491f6fb8..75dc44f29 100644 --- a/src/components/wallet-field/wallet-field.component.html +++ b/src/components/wallet-field/wallet-field.component.html @@ -1,13 +1,13 @@ diff --git a/src/components/wallet-field/wallet-field.component.ts b/src/components/wallet-field/wallet-field.component.ts index 0277579f9..384e59ac3 100644 --- a/src/components/wallet-field/wallet-field.component.ts +++ b/src/components/wallet-field/wallet-field.component.ts @@ -1,50 +1,72 @@ -import { Observable } from 'rxjs'; +import { of } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ChangeDetectionStrategy, Component, Input, booleanAttribute, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + Input, + booleanAttribute, + inject, + input, +} from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; -import { DomainObjectType, WalletID } from '@vality/domain-proto/domain'; +import { DomainObjectType, PartyConfigRef, WalletID } from '@vality/domain-proto/domain'; import { FormControlSuperclass, Option, SelectFieldComponent, createControlProviders, + observableResource, } from '@vality/matez'; -import { FetchDomainObjectsService } from '~/api/domain-config'; +import { ThriftRepositoryService } from '~/api/services'; @Component({ selector: 'cc-wallet-field', templateUrl: 'wallet-field.component.html', - providers: [...createControlProviders(() => WalletFieldComponent), FetchDomainObjectsService], + providers: [...createControlProviders(() => WalletFieldComponent)], changeDetection: ChangeDetectionStrategy.Eager, standalone: false, }) export class WalletFieldComponent extends FormControlSuperclass { - private fetchDomainObjectsService = inject(FetchDomainObjectsService); + private repositoryService = inject(ThriftRepositoryService); @Input() label: string; @Input({ transform: booleanAttribute }) required: boolean; @Input() size?: SelectFieldComponent['size']; @Input() appearance?: SelectFieldComponent['appearance']; @Input() hint?: string; - @Input({ transform: booleanAttribute }) multiple = false; - - options$: Observable[]> = this.fetchDomainObjectsService.result$.pipe( - map((objs) => - objs.map((obj) => ({ - value: obj.ref.wallet_config.id, - label: obj.name || `#${obj.ref.wallet_config.id}`, - description: obj.description, - })), - ), - ); - progress$ = this.fetchDomainObjectsService.isLoading$; + multiple = input(false, { transform: booleanAttribute }); + partyId = input(); - search(search: string) { - this.fetchDomainObjectsService.load( - { type: DomainObjectType.wallet_config, query: search }, - { size: 1000 }, - ); - } + wallets = observableResource({ + params: toObservable(this.partyId).pipe(map((partyId) => ({ partyId, query: '' }))), + loader: ({ partyId, query }) => + !partyId && !query + ? of([]) + : (partyId + ? this.repositoryService + .GetRelatedGraph({ + ref: { party_config: { id: partyId } }, + type: DomainObjectType.wallet_config, + }) + .pipe(map(({ nodes }) => Array.from(nodes))) + : this.repositoryService + .SearchObjects({ + type: DomainObjectType.wallet_config, + query: query || '*', + limit: 1000, + }) + .pipe(map((res) => res.result || [])) + ).pipe( + map((objs): Option[] => + objs.map((obj) => ({ + value: obj.ref.wallet_config.id, + label: obj.name || `#${obj.ref.wallet_config.id}`, + description: obj.description, + })), + ), + ), + }); } diff --git a/src/components/wallet-merchant-field/index.ts b/src/components/wallet-merchant-field/index.ts new file mode 100644 index 000000000..7de84cf26 --- /dev/null +++ b/src/components/wallet-merchant-field/index.ts @@ -0,0 +1 @@ +export * from './wallet-merchant-field.component'; diff --git a/src/components/wallet-merchant-field/wallet-merchant-field.component.html b/src/components/wallet-merchant-field/wallet-merchant-field.component.html new file mode 100644 index 000000000..b97fc2705 --- /dev/null +++ b/src/components/wallet-merchant-field/wallet-merchant-field.component.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/src/components/wallet-merchant-field/wallet-merchant-field.component.ts b/src/components/wallet-merchant-field/wallet-merchant-field.component.ts new file mode 100644 index 000000000..06597fdb2 --- /dev/null +++ b/src/components/wallet-merchant-field/wallet-merchant-field.component.ts @@ -0,0 +1,55 @@ +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs'; + +import { CommonModule } from '@angular/common'; +import { Component, DestroyRef, Injector, OnInit, inject, model } from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormField, FormValueControl, form } from '@angular/forms/signals'; + +import { PartyConfigRef, WalletID } from '@vality/domain-proto/domain'; + +import { DomainService } from '~/api/domain-config'; + +import { MerchantFieldModule } from '../merchant-field'; +import { WalletFieldModule } from '../wallet-field'; + +export interface PartyWallet { + party_id: PartyConfigRef['id']; + wallet_id: WalletID; +} + +@Component({ + selector: 'cc-wallet-merchant-field', + templateUrl: './wallet-merchant-field.component.html', + imports: [CommonModule, ReactiveFormsModule, WalletFieldModule, MerchantFieldModule, FormField], +}) +export class WalletMerchantFieldComponent implements FormValueControl, OnInit { + private dr = inject(DestroyRef); + private domainService = inject(DomainService); + private injector = inject(Injector); + + value = model({ + party_id: null, + wallet_id: null, + }); + control = form(this.value); + + ngOnInit() { + toObservable(this.value, { injector: this.injector }) + .pipe( + map((v) => v.wallet_id), + filter(Boolean), + distinctUntilChanged(), + switchMap((walletId) => + this.domainService.get({ wallet_config: { id: walletId } }), + ), + map((wallet) => wallet.object.wallet_config.data.party_ref.id), + takeUntilDestroyed(this.dr), + ) + .subscribe((party_id) => { + if (party_id !== this.value().party_id) { + this.value.update((v) => ({ ...v, party_id })); + } + }); + } +}