diff --git a/src/elements/public/Transaction/Transaction.test.ts b/src/elements/public/Transaction/Transaction.test.ts index 5af3b503..9cd8cb5c 100644 --- a/src/elements/public/Transaction/Transaction.test.ts +++ b/src/elements/public/Transaction/Transaction.test.ts @@ -1,9 +1,15 @@ +import type { InternalAsyncListControl } from '../../internal/InternalAsyncListControl/InternalAsyncListControl'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { FetchEvent } from '../NucleonElement/FetchEvent'; +import type { Resource } from '@foxy.io/sdk/core'; +import type { Rels } from '@foxy.io/sdk/dist/types/backend'; + import { expect, fixture, waitUntil } from '@open-wc/testing'; -import { Transaction } from './index'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; -import { html } from 'lit-html'; import { createRouter } from '../../../server/index'; -import { FetchEvent } from '../NucleonElement/FetchEvent'; +import { Transaction } from './index'; +import { getTestData } from '../../../testgen/getTestData'; +import { html } from 'lit-html'; import { stub } from 'sinon'; describe('Transaction', () => { @@ -118,6 +124,21 @@ describe('Transaction', () => { expect(new Transaction()).to.be.instanceOf(InternalForm); }); + [ + 'webhooks:dialog:header:copy-json', + 'webhooks:dialog:header:copy-id', + 'webhooks:dialog:timestamps', + 'webhooks:dialog:name', + 'webhooks:dialog:query', + 'webhooks:dialog:encryption-key', + 'webhooks:dialog:delete', + ].forEach(key => { + it(`always hides ${key}`, () => { + const element = new Transaction(); + expect(element.hiddenSelector.matches(key, true)).to.be.true; + }); + }); + it('renders a form header', () => { const form = new Transaction(); const renderHeaderMethod = stub(form, 'renderHeader'); @@ -721,4 +742,93 @@ describe('Transaction', () => { expect(control).to.have.property('form', null); expect(control).to.have.property('item', 'foxy-shipment-card'); }); + + it('renders webhooks as control', async () => { + const router = createRouter(); + const element = await fixture(html` + !evt.defaultPrevented && router.handleEvent(evt)} + > + + `); + + await waitUntil( + () => { + if (!element.in({ idle: 'snapshot' })) return false; + const nucleons = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...nucleons].every(nucleon => nucleon.in({ idle: 'snapshot' })); + }, + '', + { timeout: 5000 } + ); + + const control = element.renderRoot.querySelector('[infer="webhooks"]'); + + expect(control).to.exist; + expect(control).to.have.property('localName', 'foxy-internal-async-list-control'); + expect(control).to.have.attribute('form', 'foxy-webhook-form'); + expect(control).to.have.attribute('item', 'foxy-webhook-card'); + expect(control).to.have.attribute('hide-create-button'); + expect(control).to.have.attribute('hide-delete-button'); + expect(control).to.have.attribute('alert'); + expect(control).to.have.attribute( + 'first', + 'https://demo.api/hapi/webhooks?store_id=0&event_resource=transaction' + ); + + expect(control).to.have.deep.property('itemProps', { + 'resource-uri': 'https://demo.api/hapi/transactions/0', + }); + + expect(control).to.have.deep.property('formProps', { + 'resource-uri': 'https://demo.api/hapi/transactions/0', + }); + }); + + it('supports refeeding multiple webhooks at once', async () => { + const requests: Request[] = []; + const router = createRouter(); + const element = await fixture(html` + { + if (evt.defaultPrevented) return; + requests.push(evt.request.clone()); + router.handleEvent(evt); + }} + > + + `); + + await waitUntil( + () => { + if (!element.in({ idle: 'snapshot' })) return false; + const nucleons = element.renderRoot.querySelectorAll>('foxy-nucleon'); + return [...nucleons].every(nucleon => nucleon.in({ idle: 'snapshot' })); + }, + '', + { timeout: 5000 } + ); + + const control = + element.renderRoot.querySelector('[infer="webhooks"]'); + + expect(control).to.have.nested.property('bulkActions.0.name', 'refeed'); + expect(control).to.have.nested.property('bulkActions.0.onClick').that.is.a('function'); + + const webhooksCollection = await getTestData>( + './hapi/webhooks', + router + ); + + requests.length = 0; + const webhooksArray = webhooksCollection._embedded['fx:webhooks'] as Resource[]; + await control?.bulkActions[0].onClick(webhooksArray); + + const refeedRequest = requests.find(req => req.method === 'POST'); + expect(refeedRequest).to.exist; + expect(refeedRequest?.url).to.equal('https://demo.api/virtual/empty?status=200'); + expect(await refeedRequest?.json()).to.deep.equal({ refeed_hooks: [0], event: 'refeed' }); + }); }); diff --git a/src/elements/public/Transaction/Transaction.ts b/src/elements/public/Transaction/Transaction.ts index e90fcb33..3fc133ef 100644 --- a/src/elements/public/Transaction/Transaction.ts +++ b/src/elements/public/Transaction/Transaction.ts @@ -1,8 +1,11 @@ import type { PropertyDeclarations, TemplateResult } from 'lit-element'; +import type { NucleonElement } from '../NucleonElement/NucleonElement'; +import type { Resource } from '@foxy.io/sdk/core'; import type { Data } from './types'; +import type { Rels } from '@foxy.io/sdk/backend'; +import { BooleanSelector, getResourceId } from '@foxy.io/sdk/core'; import { TranslatableMixin } from '../../../mixins/translatable'; -import { BooleanSelector } from '@foxy.io/sdk/core'; import { ResponsiveMixin } from '../../../mixins/responsive'; import { InternalForm } from '../../internal/InternalForm/InternalForm'; import { ifDefined } from 'lit-html/directives/if-defined'; @@ -38,31 +41,75 @@ export class Transaction extends Base { getCustomerPageHref: ((href: string) => string) | null = null; + private readonly __webhooksBulkActions = [ + { + name: 'refeed', + onClick: async (selection: Resource[]) => { + if (!this.data) return; + + // TODO remove ts-expect-error when SDK has the types + // @ts-expect-error SDK types are incomplete + const url = this.data._links['fx:send_webhooks'].href; + const api = new Transaction.API(this); + const response = await api.fetch(url, { + method: 'POST', + body: JSON.stringify({ + refeed_hooks: selection.map(hook => getResourceId(hook._links['self'].href)), + event: 'refeed', + }), + }); + + selection.forEach(hook => { + Transaction.Rumour('').share({ + related: [ + ...selection.map(hook => hook._links['fx:logs'].href), + ...selection.map(hook => hook._links['fx:statuses'].href), + ], + source: hook._links.self.href, + data: hook, + }); + }); + + if (!response.ok) throw new Error(await response.text()); + }, + }, + ]; + get readonlySelector(): BooleanSelector { + const alwaysMatch = ['billing-addresses', 'webhooks:dialog:url', super.readonlySelector]; const isEditable = Boolean(this.data?._links['fx:void'] ?? this.data?._links['fx:refund']); - return isEditable - ? new BooleanSelector(`${super.readonlySelector} billing-addresses`) - : new BooleanSelector( - `${super.readonlySelector} billing-addresses items attributes custom-fields` - ); + if (!isEditable) alwaysMatch.push('items', 'attributes', 'custom-fields'); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } get hiddenSelector(): BooleanSelector { - const hidden = ['billing-addresses:dialog:delete billing-addresses:dialog:timestamps']; + const alwaysMatch = [ + 'billing-addresses:dialog:delete', + 'billing-addresses:dialog:timestamps', + 'webhooks:dialog:header:copy-json', + 'webhooks:dialog:header:copy-id', + 'webhooks:dialog:timestamps', + 'webhooks:dialog:name', + 'webhooks:dialog:query', + 'webhooks:dialog:encryption-key', + 'webhooks:dialog:delete', + super.readonlySelector, + ]; + const type = this.data?.type; - if (!this.data?._links['fx:subscription']) hidden.push('subscription'); - if (type === 'subscription_modification') hidden.push('actions'); + if (!this.data?._links['fx:subscription']) alwaysMatch.unshift('subscription'); + if (type === 'subscription_modification') alwaysMatch.unshift('actions'); if (type === 'updateinfo') { - hidden.push('not=customer,subscription,payments,custom-fields,attributes'); + alwaysMatch.unshift('not=customer,subscription,payments,custom-fields,attributes'); } if (type === 'subscription_cancellation') { - hidden.push('not=customer,subscription,custom-fields,attributes'); + alwaysMatch.unshift('not=customer,subscription,custom-fields,attributes'); } - return new BooleanSelector(`${super.hiddenSelector} ${hidden.join(' ')}`.trim()); + return new BooleanSelector(alwaysMatch.join(' ').trim()); } get headerSubtitleOptions(): Record { @@ -145,6 +192,7 @@ export class Transaction extends Base { renderBody(): TemplateResult { let shipmentsLink: string | undefined = undefined; + let webhooksLink: string | undefined = undefined; let itemsLink: string | undefined = undefined; const alertStatuses = ['problem', 'pending_fraud_review', 'rejected', 'declined']; @@ -153,10 +201,17 @@ export class Transaction extends Base { if (this.data) { try { const shipmentsUrl = new URL(this.data._links['fx:shipments'].href); + // TODO: Remove the ts-expect-error comment when SDK has the types + // @ts-expect-error SDK doesn't have the types + const webhooksUrl = new URL(this.__storeLoader?.data._links['fx:webhooks'].href ?? ''); const itemsUrl = new URL(this.data._links['fx:items'].href); + shipmentsUrl.searchParams.set('zoom', 'items:item_category'); + webhooksUrl.searchParams.set('event_resource', 'transaction'); itemsUrl.searchParams.set('zoom', 'item_options'); + shipmentsLink = shipmentsUrl.toString(); + webhooksLink = webhooksUrl.toString(); itemsLink = itemsUrl.toString(); } catch { // @@ -254,6 +309,34 @@ export class Transaction extends Base { item="foxy-shipment-card" > + + + + + `; } + + private get __storeLoader() { + type Loader = NucleonElement>; + return this.renderRoot.querySelector('#storeLoader'); + } } diff --git a/src/elements/public/Transaction/index.ts b/src/elements/public/Transaction/index.ts index 07df1f88..f89070f1 100644 --- a/src/elements/public/Transaction/index.ts +++ b/src/elements/public/Transaction/index.ts @@ -4,8 +4,11 @@ import '../AttributeForm/index'; import '../CustomFieldForm/index'; import '../CustomFieldCard/index'; import '../CopyToClipboard/index'; +import '../NucleonElement/index'; import '../ShipmentCard/index'; import '../PaymentCard/index'; +import '../WebhookCard/index'; +import '../WebhookForm/index'; import '../ItemCard/index'; import '../ItemForm/index'; import '../I18n/index'; diff --git a/src/server/hapi/links.ts b/src/server/hapi/links.ts index 6acb05a6..de950110 100644 --- a/src/server/hapi/links.ts +++ b/src/server/hapi/links.ts @@ -119,6 +119,7 @@ export const links: Links = { 'fx:shipments': { href: `./shipments?transaction_id=${id}` }, 'fx:attributes': { href: `./transaction_attributes?transaction_id=${id}` }, 'fx:send_emails': { href: 'https://demo.api/virtual/empty?status=200' }, + 'fx:send_webhooks': { href: 'https://demo.api/virtual/empty?status=200' }, 'fx:subscription': { href: `./subscriptions/${subscription_id}` }, 'fx:applied_taxes': { href: `./applied_taxes?transaction_id=${id}` }, 'fx:custom_fields': { href: `./custom_fields?transaction_id=${id}` }, @@ -149,6 +150,7 @@ export const links: Links = { 'fx:users': { href: `./users?store_id=${id}` }, 'fx:taxes': { href: `./taxes?store_id=${id}` }, 'fx:coupons': { href: `./coupons?store_id=${id}` }, + 'fx:webhooks': { href: `./webhooks?store_id=${id}` }, 'fx:customers': { href: `./customers?store_id=${id}` }, 'fx:attributes': { href: `./store_attributes?store_id=${id}` }, 'fx:transactions': { href: `./transactions?store_id=${id}` }, diff --git a/src/static/translations/transaction/en.json b/src/static/translations/transaction/en.json index 85767d1b..2c232738 100644 --- a/src/static/translations/transaction/en.json +++ b/src/static/translations/transaction/en.json @@ -732,6 +732,93 @@ } } }, + "webhooks": { + "label": "Webhooks", + "helper_text": "This list shows v2 webhooks only. Legacy v1 webhooks are available in Settings > Integrations and on admin.foxycart.com.", + "dialog": { + "close": "Close", + "cancel": "Cancel", + "header_update": "Webhook", + "webhook-form": { + "header": { + "title_existing": "{{ name }}", + "subtitle_transaction": "" + }, + "url": { + "label": "URL", + "helper_text": "" + }, + "statuses": { + "label": "Runs", + "pagination": { + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "status_successful": "Successful", + "status_pending": "Pending", + "status_failed": "Failed", + "date": "{{ value, date }} at {{ value, time }}", + "spinner": { + "loading_busy": "Loading", + "loading_error": "Unknown error", + "loading_empty": "No runs for this transaction yet" + } + } + } + }, + "logs": { + "label": "Logs", + "pagination": { + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "date": "{{ value, date }} at {{ value, time }}", + "spinner": { + "loading_busy": "Loading", + "loading_error": "Unknown error", + "loading_empty": "No runs for this transaction yet" + } + } + } + }, + "spinner": { + "refresh": "Refresh", + "loading_busy": "Loading", + "loading_error": "Unknown error" + } + } + }, + "pagination": { + "select_button_text": "Select", + "cancel_button_text": "Cancel", + "refeed_bulk_action_caption_idle": "Refeed ({{ count }})", + "refeed_bulk_action_caption_busy": "Refeeding...", + "refeed_bulk_action_notification_done": "Selected webhooks were sent successfully.", + "refeed_bulk_action_notification_fail": "Failed to refeed selected webhooks.", + "first": "First", + "last": "Last", + "next": "Next", + "pagination": "{{from}}-{{to}} out of {{total}}", + "previous": "Previous", + "card": { + "status_successful": "OK", + "status_pending": "Pending", + "status_failed": "Failed", + "status_none": "No runs yet", + "spinner": { + "loading_busy": "Loading", + "loading_empty": "No webhooks", + "loading_error": "Unknown error" + } + } + } + }, "summary": { "total_shipping": "Shipping", "total_tax": "Tax",