From b866405000a819d9135a27a0d65f5dcdcbcb1a50 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Mon, 20 Jan 2020 22:29:21 +0200 Subject: [PATCH] feat: add submit method and generate attribute --- .eslintrc.json | 2 +- README.md | 55 ++-- custom-elements.json | 107 +++--- package-lock.json | 117 ++++++- package.json | 6 +- src/StripeBase.js | 141 ++++---- src/StripeElements.js | 11 +- src/stripe-elements.test.js | 493 ++++++++++++++++++++++++---- stories/helpers.js | 15 - stories/storybook-helpers.js | 40 +++ stories/stripe-elements.stories.mdx | 387 ++++++++++------------ test/mock-stripe.js | 11 +- test/test-helpers.js | 36 +- 13 files changed, 985 insertions(+), 436 deletions(-) delete mode 100644 stories/helpers.js create mode 100644 stories/storybook-helpers.js diff --git a/.eslintrc.json b/.eslintrc.json index 8c29c97..b99b7c0 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,7 +7,7 @@ "rules": { "brace-style": ["error", "1tbs", { "allowSingleLine": true }], "operator-linebreak": ["error", "after", { "overrides": { "?": "after", ":": "before" } }], - "indent": ["error", 2, { "flatTernaryExpressions": true }], + "indent": ["error", 2, { "flatTernaryExpressions": true, "SwitchCase": 1 }], "no-only-tests/no-only-tests": "error" }, "globals": { diff --git a/README.md b/README.md index a73f297..2ea658c 100644 --- a/README.md +++ b/README.md @@ -108,32 +108,34 @@ There are 11 properties for each state that you can set which will be read into #### Properties -| Property | Attribute | Modifiers | Type | Default | Description | -|---------------------|--------------------|-----------|-------------------------------|-----------|--------------------------------------------------| -| `action` | `action` | | `String` | | The URL to the form action. Example '/charges'.
If blank or undefined will not submit charges immediately. | -| `billingDetails` | | | `stripe.BillingDetails` | | billing_details object sent to create the payment representation. | -| `brand` | `brand` | readonly | `String` | null | The card brand detected by Stripe | -| `card` | `card` | readonly | `stripe.Element` | null | The Stripe card object. | -| `element` | `element` | readonly | `stripe.elements.Element` | null | Stripe element instance | -| `elements` | `elements` | readonly | `stripe.elements.Elements` | null | Stripe Elements instance | -| `error` | `error` | readonly | `Error\|stripe.Error` | null | Stripe or validation error | -| `hasError` | `has-error` | readonly | `Boolean` | false | Whether the element has an error | -| `hideIcon` | `hide-icon` | | `Boolean` | false | Whether to hide icons in the Stripe form. | -| `hidePostalCode` | `hide-postal-code` | | `Boolean` | false | Whether or not to hide the postal code field.
Useful when you gather shipping info elsewhere. | -| `iconStyle` | `icon-style` | | `'solid'\|'default'` | "default" | Stripe icon style. 'solid' or 'default'. | -| `isComplete` | `is-complete` | | `Boolean` | false | If the form is complete. | -| `isEmpty` | `is-empty` | | `Boolean` | true | If the form is empty. | -| `paymentMethodData` | | | `stripe.PaymentMethodData` | {} | Data passed to stripe.createPaymentMethod. (optional) | -| `publishableKey` | `publishable-key` | | `String` | | Stripe Publishable Key. EG. `pk_test_XXXXXXXXXXXXXXXXXXXXXXXX` | -| `showError` | `show-error` | | `boolean` | false | Whether to display the error message | -| `source` | `source` | readonly | `stripe.Source` | null | Stripe Source | -| `sourceData` | | | `{ owner: stripe.OwnerData }` | {} | Data passed to stripe.createSource. (optional) | -| `stripe` | `stripe` | readonly | `stripe.Stripe` | null | Stripe instance | -| `stripeMount` | | readonly | `Element` | | Stripe Element mount point | -| `stripeReady` | `stripe-ready` | | `Boolean` | false | If the stripe element is ready to receive focus. | -| `token` | `token` | readonly | `stripe.Token` | null | Stripe Token | -| `tokenData` | | | `stripe.TokenOptions` | {} | Data passed to stripe.createToken. (optional) | -| `value` | `value` | | `Object` | {} | Prefilled values for form. Example {postalCode: '90210'} | +| Property | Attribute | Modifiers | Type | Default | Description | +|---------------------|--------------------|-----------|---------------------------------------|-----------|--------------------------------------------------| +| `action` | `action` | | `String` | | The URL to the form action. Example '/charges'.
If blank or undefined will not submit charges immediately. | +| `billingDetails` | | | `stripe.BillingDetails` | {} | billing_details object sent to create the payment representation. (optional) | +| `brand` | `brand` | readonly | `String` | null | The card brand detected by Stripe | +| `card` | `card` | readonly | `stripe.Element` | null | The Stripe card object. | +| `element` | `element` | readonly | `stripe.elements.Element` | null | Stripe element instance | +| `elements` | `elements` | readonly | `stripe.elements.Elements` | null | Stripe Elements instance | +| `error` | `error` | readonly | `Error\|stripe.Error` | null | Stripe or validation error | +| `generate` | `generate` | | `'payment-method'\|'source'\|'token'` | "source" | Type of payment representation to generate. | +| `hasError` | `has-error` | readonly | `Boolean` | false | Whether the element has an error | +| `hideIcon` | `hide-icon` | | `Boolean` | false | Whether to hide icons in the Stripe form. | +| `hidePostalCode` | `hide-postal-code` | | `Boolean` | false | Whether or not to hide the postal code field.
Useful when you gather shipping info elsewhere. | +| `iconStyle` | `icon-style` | | `'solid'\|'default'` | "default" | Stripe icon style. 'solid' or 'default'. | +| `isComplete` | `is-complete` | | `Boolean` | false | If the form is complete. | +| `isEmpty` | `is-empty` | | `Boolean` | true | If the form is empty. | +| `paymentMethod` | `payment-method` | readonly | `stripe.PaymentMethod` | null | Stripe PaymentMethod | +| `paymentMethodData` | | | `stripe.PaymentMethodData` | {} | Data passed to stripe.createPaymentMethod. (optional) | +| `publishableKey` | `publishable-key` | | `String` | | Stripe Publishable Key. EG. `pk_test_XXXXXXXXXXXXXXXXXXXXXXXX` | +| `showError` | `show-error` | | `boolean` | false | Whether to display the error message | +| `source` | `source` | readonly | `stripe.Source` | null | Stripe Source | +| `sourceData` | | | `SourceData` | {} | Data passed to stripe.createSource. (optional) | +| `stripe` | `stripe` | readonly | `stripe.Stripe` | null | Stripe instance | +| `stripeMount` | | readonly | `Element` | | Stripe Element mount point | +| `stripeReady` | `stripe-ready` | | `Boolean` | false | If the stripe element is ready to receive focus. | +| `token` | `token` | readonly | `stripe.Token` | null | Stripe Token | +| `tokenData` | | | `stripe.TokenOptions` | {} | Data passed to stripe.createToken. (optional) | +| `value` | `value` | | `Object` | {} | Prefilled values for form. Example {postalCode: '90210'} | #### Methods @@ -144,6 +146,7 @@ There are 11 properties for each state that you can set which will be read into | `createToken` | `(tokenData?: TokenData): Promise` | Submit payment information to generate a token | | `isPotentiallyValid` | `(): Boolean` | Checks for potential validity. A potentially valid form is one that is not empty, not complete and has no error. A validated form also counts as potentially valid. | | `reset` | `(): void` | Resets the Stripe card. | +| `submit` | `(): Promise` | Generates a payment representation of the type specified by `generate`. | | `validate` | `(): Boolean` | Checks if the Stripe form is valid. | #### Events diff --git a/custom-elements.json b/custom-elements.json index 2735299..17a838a 100644 --- a/custom-elements.json +++ b/custom-elements.json @@ -57,11 +57,32 @@ "type": "Boolean", "default": "false" }, + { + "name": "payment-method", + "description": "Stripe PaymentMethod", + "type": "stripe.PaymentMethod" + }, + { + "name": "source", + "description": "Stripe Source", + "type": "stripe.Source" + }, + { + "name": "token", + "description": "Stripe Token", + "type": "stripe.Token" + }, { "name": "action", "description": "The URL to the form action. Example '/charges'.\nIf blank or undefined will not submit charges immediately.", "type": "String" }, + { + "name": "generate", + "description": "Type of payment representation to generate.", + "type": "'payment-method'|'source'|'token'", + "default": "\"source\"" + }, { "name": "publishable-key", "description": "Stripe Publishable Key. EG. `pk_test_XXXXXXXXXXXXXXXXXXXXXXXX`", @@ -94,20 +115,10 @@ "type": "Boolean", "default": "false" }, - { - "name": "source", - "description": "Stripe Source", - "type": "stripe.Source" - }, { "name": "stripe", "description": "Stripe instance", "type": "stripe.Stripe" - }, - { - "name": "token", - "description": "Stripe Token", - "type": "stripe.Token" } ], "properties": [ @@ -177,6 +188,48 @@ "description": "Stripe Element mount point", "type": "Element" }, + { + "name": "billingDetails", + "description": "billing_details object sent to create the payment representation. (optional)", + "type": "stripe.BillingDetails", + "default": "{}" + }, + { + "name": "paymentMethodData", + "description": "Data passed to stripe.createPaymentMethod. (optional)", + "type": "stripe.PaymentMethodData", + "default": "{}" + }, + { + "name": "sourceData", + "description": "Data passed to stripe.createSource. (optional)", + "type": "SourceData", + "default": "{}" + }, + { + "name": "tokenData", + "description": "Data passed to stripe.createToken. (optional)", + "type": "stripe.TokenOptions", + "default": "{}" + }, + { + "name": "paymentMethod", + "attribute": "payment-method", + "description": "Stripe PaymentMethod", + "type": "stripe.PaymentMethod" + }, + { + "name": "source", + "attribute": "source", + "description": "Stripe Source", + "type": "stripe.Source" + }, + { + "name": "token", + "attribute": "token", + "description": "Stripe Token", + "type": "stripe.Token" + }, { "name": "action", "attribute": "action", @@ -184,8 +237,11 @@ "type": "String" }, { - "name": "billingDetails", - "type": "stripe.BillingDetails" + "name": "generate", + "attribute": "generate", + "description": "Type of payment representation to generate.", + "type": "'payment-method'|'source'|'token'", + "default": "\"source\"" }, { "name": "publishableKey", @@ -200,21 +256,6 @@ "type": "boolean", "default": "false" }, - { - "name": "paymentMethodData", - "description": "Data passed to stripe.createPaymentMethod. (optional)", - "type": "stripe.PaymentMethodData" - }, - { - "name": "sourceData", - "description": "Data passed to stripe.createSource. (optional)", - "type": "{ owner: stripe.OwnerData }" - }, - { - "name": "tokenData", - "description": "Data passed to stripe.createToken. (optional)", - "type": "stripe.TokenOptions" - }, { "name": "element", "attribute": "element", @@ -240,23 +281,11 @@ "type": "Boolean", "default": "false" }, - { - "name": "source", - "attribute": "source", - "description": "Stripe Source", - "type": "stripe.Source" - }, { "name": "stripe", "attribute": "stripe", "description": "Stripe instance", "type": "stripe.Stripe" - }, - { - "name": "token", - "attribute": "token", - "description": "Stripe Token", - "type": "stripe.Token" } ], "events": [ diff --git a/package-lock.json b/package-lock.json index f3ec79d..014c800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@power-elements/stripe-elements", - "version": "2.0.0", + "version": "2.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -3888,6 +3888,17 @@ "resolved": "https://registry.npmjs.org/@pacote/memoize/-/memoize-1.1.1.tgz", "integrity": "sha512-zKrE5isyPJcUrnYsSXNKie/vfoEf63sjmEEyFmhqWV6KDEl/EfbnKMHrVU9zyYc1ToLFvlZ+V0cfKAv/qqGXqg==" }, + "@power-elements/codesandbox-button": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@power-elements/codesandbox-button/-/codesandbox-button-0.0.2.tgz", + "integrity": "sha512-7GA/uGNSJmfk0ajV1pevXwudt7VXvEhLim8kf0Ba0YAh+sCnr0dXtVuGMr5utBQ9gYdQA6S1DRJGGPLikr+19Q==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.3", + "lit-element": "^2.2.1", + "lit-html": "^1.1.2" + } + }, "@power-elements/json-viewer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@power-elements/json-viewer/-/json-viewer-1.0.1.tgz", @@ -7766,6 +7777,12 @@ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", "dev": true }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, "get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -8408,6 +8425,17 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, + "indent-string": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-1.2.2.tgz", + "integrity": "sha1-25m8xYPrarux5I3LsZmamGBBy2s=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1", + "minimist": "^1.1.0", + "repeating": "^1.1.0" + } + }, "indexof": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", @@ -8700,6 +8728,15 @@ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "dev": true }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", @@ -10151,6 +10188,51 @@ "source-map-support": "^0.5.5" } }, + "karma-tap-reporter": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/karma-tap-reporter/-/karma-tap-reporter-0.0.6.tgz", + "integrity": "sha1-e0Hdj6/6euYkh0dFTZM3NXf2knw=" + }, + "karma-tape-reporter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/karma-tape-reporter/-/karma-tape-reporter-1.0.3.tgz", + "integrity": "sha1-3EpHxLQtJOu/Qff1pkbLVX3QSwI=", + "dev": true, + "requires": { + "indent-string": ">=1.2.0 <2.0.0-0", + "js-yaml": "git+https://github.com/terinjokes/js-yaml.git#block-literal-newlines-preserved", + "printf": ">=0.2.0 <0.3.0-0", + "useragent": ">=2.0.9 <3.0.0-0" + }, + "dependencies": { + "argparse": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-0.1.16.tgz", + "integrity": "sha1-z9AeD7uj1srtBJ+9dY1A9lGW9Xw=", + "dev": true, + "requires": { + "underscore": "~1.7.0", + "underscore.string": "~2.4.0" + } + }, + "esprima": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz", + "integrity": "sha1-n1V+CPw7TSbs6d00+Pv0drYlha0=", + "dev": true + }, + "js-yaml": { + "version": "git+https://github.com/terinjokes/js-yaml.git#5e2875560b09052c93912fddef9e96a32454ad8e", + "from": "git+https://github.com/terinjokes/js-yaml.git#block-literal-newlines-preserved", + "dev": true, + "requires": { + "argparse": "~ 0.1.11", + "esprima": "~ 1.0.2", + "indent-string": "~ 1.2.0" + } + } + } + }, "keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -11370,6 +11452,12 @@ "path-key": "^2.0.0" } }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", @@ -11918,6 +12006,12 @@ "integrity": "sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg==", "dev": true }, + "printf": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/printf/-/printf-0.2.5.tgz", + "integrity": "sha1-xDjKLKM+OSdnHbSracDlL5NqTw8=", + "dev": true + }, "prismjs": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.19.0.tgz", @@ -12871,6 +12965,15 @@ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", "dev": true }, + "repeating": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz", + "integrity": "sha1-PUEUIYh3U3SU+X93+Xhfq4EPpKw=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, "replace-ext": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz", @@ -14558,6 +14661,18 @@ "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==", "dev": true }, + "underscore": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.7.0.tgz", + "integrity": "sha1-a7rwh3UA02vjTsqlhODbn+8DUgk=", + "dev": true + }, + "underscore.string": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-2.4.0.tgz", + "integrity": "sha1-jN2PusTi0uoefi6Al8QvRCKA+Fs=", + "dev": true + }, "unherit": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.2.tgz", diff --git a/package.json b/package.json index da573ab..ad28013 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "homepage": "https://github.com/bennypowers/stripe-elements#readme", "husky": { "hooks": { - "pre-commit": "npm run build:analyze:markdown && git add README.md custom-elements.json" + "pre-commit": "npm run build:analyze:markdown && npm run build:analyze:json && git add README.md && git add custom-elements.json" } }, "devDependencies": { @@ -69,6 +69,7 @@ "@open-wc/demoing-storybook": "^1.6.4", "@open-wc/testing": "^2.5.0", "@open-wc/testing-karma": "^3.2.30", + "@power-elements/codesandbox-button": "0.0.2", "@power-elements/json-viewer": "^1.0.1", "@rollup/plugin-commonjs": "^11.0.1", "@rollup/plugin-node-resolve": "^7.0.0", @@ -86,8 +87,10 @@ "eslint-plugin-json": "^2.0.1", "eslint-plugin-no-loops": "^0.3.0", "eslint-plugin-no-only-tests": "^2.4.0", + "husky": "^4.0.10", "karma-helpful-reporter": "^0.3.4", "karma-osx-reporter": "^0.2.1", + "karma-tape-reporter": "^1.0.3", "luhn-js": "^1.1.2", "npm-run-all": "^4.1.5", "patch-package": "^6.2.0", @@ -109,6 +112,7 @@ "@open-wc/lit-helpers": "^0.2.5", "@pacote/memoize": "^1.1.1", "@typed/curry": "^1.0.1", + "karma-tap-reporter": "0.0.6", "lit-element": "^2.2.1" } } diff --git a/src/StripeBase.js b/src/StripeBase.js index 9329f32..12ea720 100644 --- a/src/StripeBase.js +++ b/src/StripeBase.js @@ -9,6 +9,7 @@ import { isRepresentation } from './lib/predicates'; import { stripeMethod } from './stripe-method-decorator'; /** @typedef {stripe.PaymentIntentResponse|stripe.PaymentMethodResponse|stripe.SetupIntentResponse|stripe.TokenResponse|stripe.SourceResponse} PaymentResponse */ +/** @typedef {{ owner: stripe.OwnerData }} SourceData */ /** * @fires 'error-changed' - The new value of error @@ -25,7 +26,61 @@ import { stripeMethod } from './stripe-method-decorator'; * @fires 'token-changed' - The new value of token */ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { - /* PUBLIC FIELDS */ + /* PAYMENT CONFIGURATION */ + + /** + * billing_details object sent to create the payment representation. (optional) + * @type {stripe.BillingDetails} + */ + billingDetails = {}; + + /** + * Data passed to stripe.createPaymentMethod. (optional) + * @type {stripe.PaymentMethodData} + */ + paymentMethodData = {}; + + /** + * Data passed to stripe.createSource. (optional) + * @type {SourceData} + */ + sourceData = {}; + + /** + * Data passed to stripe.createToken. (optional) + * @type {stripe.TokenOptions} + */ + tokenData = {}; + + /* PAYMENT REPRESENTATIONS */ + + /** + * Stripe PaymentMethod + * @type {stripe.PaymentMethod} + * @readonly + */ + @property({ + type: Object, + notify: true, + readOnly: true, + attribute: 'payment-method', + }) paymentMethod = null; + + /** + * Stripe Source + * @type {stripe.Source} + * @readonly + */ + @property({ type: Object, notify: true, readOnly: true }) source = null; + + /** + * Stripe Token + * @type {stripe.Token} + * @readonly + */ + @property({ type: Object, notify: true, readOnly: true }) token = null; + + /* SETTINGS */ /** * The URL to the form action. Example '/charges'. @@ -35,10 +90,11 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { @property({ type: String }) action; /** - * billing_details object sent to create the payment representation. - * @type {stripe.BillingDetails} + * Type of payment representation to generate. + * @type {'payment-method'|'source'|'token'} + * @required */ - billingDetails; + @property({ type: String }) generate = 'source'; /** * Stripe Publishable Key. EG. `pk_test_XXXXXXXXXXXXXXXXXXXXXXXX` @@ -54,25 +110,6 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { /** Whether to display the error message */ @property({ type: Boolean, attribute: 'show-error', reflect: true }) showError = false; - /** - * Data passed to stripe.createPaymentMethod. (optional) - * @type {stripe.PaymentMethodData} - * @prop - */ - paymentMethodData = {}; - - /** - * Data passed to stripe.createSource. (optional) - * @type {{ owner: stripe.OwnerData }} - */ - sourceData = {}; - - /** - * Data passed to stripe.createToken. (optional) - * @type {stripe.TokenOptions} - */ - tokenData = {}; - /* READ-ONLY FIELDS */ /** @@ -109,13 +146,6 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { readOnly: true, }) hasError = false; - /** - * Stripe Source - * @type {stripe.Source} - * @readonly - */ - @property({ type: Object, notify: true, readOnly: true }) source = null; - /** * Stripe instance * @type {stripe.Stripe} @@ -123,13 +153,6 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { */ @property({ type: Object, readOnly: true }) stripe = null; - /** - * Stripe Token - * @type {stripe.Token} - * @readonly - */ - @property({ type: Object, notify: true, readOnly: true }) token = null; - /* PRIVATE FIELDS */ /** @@ -155,8 +178,8 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { * @param {stripe.PaymentMethodData} [paymentMethodData={}] * @resolves {stripe.PaymentMethodResponse} */ - @stripeMethod async createPaymentMethod(paymentMethodData = this.paymentMethodData) { - return this.stripe.createPaymentMethod(this.getPaymentMethodData(paymentMethodData)); + @stripeMethod async createPaymentMethod(paymentMethodData = this.getPaymentMethodData()) { + return this.stripe.createPaymentMethod(paymentMethodData); } /** @@ -185,6 +208,23 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { this.set({ error: null }); } + /** + * Generates a payment representation of the type specified by `generate`. + * @resolves {PaymentResponse} + */ + async submit() { + switch (this.generate) { + case 'payment-method': return this.createPaymentMethod(); + case 'source': return this.createSource(); + case 'token': return this.createToken(); + default: { + const error = new Error(`<${this.constructor.is}>: cannot generate ${this.generate}`); + await this.set({ error }); + throw error; + } + } + } + /* PRIVATE API */ /** @private */ @@ -222,8 +262,8 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { * @private */ @bound async handleResponse(response) { - const { error = null, source = null, token = null } = response; - await this.set({ error, source, token }); + const { error = null, paymentMethod = null, source = null, token = null } = response; + await this.set({ error, paymentMethod, source, token }); if (error) throw error; else return response; } @@ -241,17 +281,6 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { await this.set({ elements, error, stripe }); } - /** - * Fires a Polymer-style prop-changed event. - * @param {String} prop camelCased prop name. - * @private - */ - notify(prop) { - const type = `${dash(prop)}-changed`; - const value = this[camel(prop)]; - this.fire(type, { value }); - } - /** * @param {String} name * @private @@ -259,12 +288,10 @@ export class StripeBase extends ReadOnlyPropertiesMixin(LitNotify(LitElement)) { @bound representationChanged(name) { if (!isRepresentation(name)) return; const value = this[name]; - this.notify(name); + /* istanbul ignore if */ if (!value) return; - const eventType = `stripe-${dash(name)}`; - this.fire(eventType, value); - const selector = `[name=${camel(`stripe-${dash(name)}`)}]`; - const formField = this.form.querySelector(selector); + this.fire(`stripe-${dash(name)}`, value); + const formField = this.form.querySelector(`[name=${camel(`stripe-${dash(name)}`)}]`); formField.removeAttribute('disabled'); formField.value = value; if (this.action) this.form.submit(); diff --git a/src/StripeElements.js b/src/StripeElements.js index a655b4a..9f03776 100644 --- a/src/StripeElements.js +++ b/src/StripeElements.js @@ -391,10 +391,15 @@ export class StripeElements extends LitNotify(StripeBase) { * @return {stripe.PaymentMethodData} data with card property * @private */ - getPaymentMethodData(data) { + getPaymentMethodData() { const type = 'card'; - const { billingDetails, card } = this; - return ({ billing_details: billingDetails, ...data, type, card }); + const { billingDetails, card, paymentMethodData } = this; + return ({ + billing_details: billingDetails, + ...paymentMethodData, + type, + card, + }); } /** diff --git a/src/stripe-elements.test.js b/src/stripe-elements.test.js index 9a252af..1dab0d5 100644 --- a/src/stripe-elements.test.js +++ b/src/stripe-elements.test.js @@ -7,13 +7,13 @@ import { INCOMPLETE_CARD_ERROR, PUBLISHABLE_KEY, SHOULD_ERROR_KEY, - SUCCESSFUL_SOURCE, - SUCCESSFUL_TOKEN, + SUCCESS_RESPONSES, } from '../test/mock-stripe'; import { DEFAULT_PROPS, EMPTY_CC_ERROR, NOTIFYING_PROPS, + NO_STRIPE_CREATE_PAYMENT_METHOD_ERROR, NO_STRIPE_CREATE_SOURCE_ERROR, NO_STRIPE_CREATE_TOKEN_ERROR, NO_STRIPE_JS, @@ -22,6 +22,8 @@ import { appendGlobalStyles, assertFiresStripeChange, assertHasOneGlobalStyleTag, + assertSubmitCalled, + createPaymentMethod, createSource, createToken, element, @@ -48,6 +50,7 @@ import { spyConsoleWarn, spyGetComputedStyleValue, stubFormSubmit, + submit, synthStripeEvent, synthStripeFormValues, testDefaultPropEntry, @@ -71,7 +74,7 @@ const formLightDOM = ({ label = 'Credit or Debit Card', stripeMountId }) => ` const expectedLightDOM = ({ label = 'Credit or Debit Card', stripeMountId }) => `
${formLightDOM({ label, stripeMountId })}
`; -describe('stripe-elements', function() { +describe('', function() { beforeEach(spyConsoleWarn); afterEach(restoreConsoleWarn); afterEach(resetTestState); @@ -81,22 +84,22 @@ describe('stripe-elements', function() { expect(element.constructor.is).to.equal('stripe-elements'); }); - describe('default properties', function defaults() { + describe('uses default for property', function defaults() { beforeEach(setupNoProps); Object.entries(DEFAULT_PROPS).forEach(testDefaultPropEntry); }); - describe('read-only properties', function readOnly() { + describe('has read-only property', function readOnly() { beforeEach(setupNoProps); READ_ONLY_PROPS.forEach(testReadOnlyProp); }); - describe('notifying writable properties', function notifying() { + describe('notifies when setting property', function notifying() { beforeEach(setupNoProps); NOTIFYING_PROPS.filter(not(elem(READ_ONLY_PROPS))).forEach(testWritableNotifyingProp); }); - describe('notifying read-only properties', function notifying() { + describe('notifies when privately setting read-only property', function notifying() { beforeEach(setupNoProps); READ_ONLY_PROPS.filter(elem(NOTIFYING_PROPS)).forEach(testReadonlyNotifyingProp); }); @@ -110,10 +113,10 @@ describe('stripe-elements', function() { describe('without global CSS in the document', function() { beforeEach(setupNoProps); - it('appends a stylesheet to the document when absent', assertHasOneGlobalStyleTag); + it('appends a stylesheet to the document', assertHasOneGlobalStyleTag); }); - describe('When Mocked ShadyDOM polyfill is in use', function shadyDOM() { + describe('when Mocked ShadyDOM polyfill is in use', function shadyDOM() { beforeEach(mockShadyDOM); beforeEach(setupNoProps); afterEach(restoreShadyDOM); @@ -129,7 +132,7 @@ describe('stripe-elements', function() { }); }); - describe('With Native Shadow DOM support', function shadowDOM() { + describe('with Native Shadow DOM support', function shadowDOM() { let nestedElement; let primaryHost; let secondaryHost; @@ -262,6 +265,15 @@ describe('stripe-elements', function() { expect(element.error.message).to.equal(NO_STRIPE_JS); }); + it('throws an error when creating payment method', async function() { + try { + await element.createPaymentMethod(); + expect.fail('Resolved source promise without Stripe.js'); + } catch (err) { + expect(err.message).to.equal(NO_STRIPE_CREATE_PAYMENT_METHOD_ERROR); + } + }); + it('throws an error when creating token', async function() { try { await element.createToken(); @@ -279,6 +291,15 @@ describe('stripe-elements', function() { expect(err.message).to.equal(NO_STRIPE_CREATE_SOURCE_ERROR); } }); + + it('throws an error when calling submit', async function() { + try { + await element.submit(); + expect.fail('Resolved submit promise without Stripe.js'); + } catch (err) { + expect(err.message).to.equal(NO_STRIPE_CREATE_SOURCE_ERROR); + } + }); }); }); @@ -346,15 +367,40 @@ describe('stripe-elements', function() { beforeEach(setupWithPublishableKey(SHOULD_ERROR_KEY)); describe('and a complete, valid form', function() { beforeEach(synthStripeFormValues({ cardNumber: '4242424242424242', mm: '01', yy: '40', cvc: '000' })); + + describe('calling createPaymentMethod()', function() { + beforeEach(createPaymentMethod); + it('sets the `error` property', function() { + expect(element.error.message, 'error').to.equal(SHOULD_ERROR_KEY); + expect(element.paymentMethod, 'paymentMethod').to.be.null; + }); + }); + describe('calling createSource()', function() { beforeEach(createSource); it('sets the `error` property', function() { expect(element.error.message, 'error').to.equal(SHOULD_ERROR_KEY); - expect(element.token, 'token').to.be.null; expect(element.source, 'source').to.be.null; }); }); + describe('calling createToken()', function() { + beforeEach(createToken); + it('sets the `error` property', function() { + expect(element.error.message, 'error').to.equal(SHOULD_ERROR_KEY); + expect(element.token, 'token').to.be.null; + }); + }); + + describe('calling submit()', function() { + it('sets the `error` property', function() { + return element.submit().then(expect.fail, function() { + expect(element.error.message, 'error').to.equal(SHOULD_ERROR_KEY); + expect(element.source, 'source').to.be.null; + }); + }); + }); + describe('calling validate()', function() { beforeEach(validate); it('returns true', function validating() { @@ -435,26 +481,39 @@ describe('stripe-elements', function() { }); }); + describe('calling createPaymentMethod()', function() { + beforeEach(createPaymentMethod); + it('sets the `error` property', function() { + expect(element.paymentMethod, 'paymentMethod').to.be.null; + expect(element.error, 'error').to.equal(INCOMPLETE_CARD_ERROR); + }); + }); + describe('calling createSource()', function callingCreateSource() { beforeEach(createSource); - it('sets the `error` property', function setsError() { expect(element.source, 'source').to.be.null; - expect(element.token, 'token').to.be.null; expect(element.error, 'error').to.equal(INCOMPLETE_CARD_ERROR); }); }); describe('calling createToken()', function() { beforeEach(createToken); - it('sets the `error` property', function() { expect(element.token, 'token').to.be.null; - expect(element.source, 'source').to.be.null; expect(element.error, 'error').to.equal(INCOMPLETE_CARD_ERROR); }); }); + describe('calling submit()', function() { + it('sets the `error` property', function() { + element.submit().then(expect.fail, function() { + expect(element.error, 'error').to.equal(INCOMPLETE_CARD_ERROR); + expect(element.source, 'source').to.be.null; + }); + }); + }); + describe('when stripe fires `ready` event', function cardReady() { beforeEach(synthStripeEvent('ready')); it('fires `stripe-ready` event', async function() { @@ -533,10 +592,33 @@ describe('stripe-elements', function() { describe('with a non-empty, incomplete form', function() { beforeEach(synthStripeFormValues({ cardNumber: '4242424242424242' })); + describe('calling createPaymentMethod()', function() { + beforeEach(createPaymentMethod); + it('sets the `error` property', function() { + expect(element.paymentMethod, 'paymentMethod').to.be.null; + expect(element.error, 'error').to.eql(INCOMPLETE_CARD_ERROR); + }); + + it('sets the `hasError` property', function() { + expect(element.hasError).to.be.true; + }); + + describe('calling validate()', function() { + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns false', function() { + expect(element.isPotentiallyValid()).to.be.false; + }); + }); + }); + describe('calling createSource()', function() { beforeEach(createSource); it('sets the `error` property', function() { - expect(element.token, 'token').to.be.null; expect(element.source, 'source').to.be.null; expect(element.error, 'error').to.eql(INCOMPLETE_CARD_ERROR); }); @@ -562,9 +644,33 @@ describe('stripe-elements', function() { beforeEach(createToken); it('sets the `error` property', function() { expect(element.token, 'token').to.be.null; - expect(element.source, 'source').to.be.null; expect(element.error, 'error').to.eql(INCOMPLETE_CARD_ERROR); }); + + it('sets the `hasError` property', function() { + expect(element.hasError).to.be.true; + }); + + describe('calling validate()', function() { + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns false', function() { + expect(element.isPotentiallyValid()).to.be.false; + }); + }); + }); + + describe('calling submit()', function() { + it('sets the `error` property', function() { + element.submit().then(expect.fail, function() { + expect(element.error, 'error').to.equal(INCOMPLETE_CARD_ERROR); + expect(element.source, 'source').to.be.null; + }); + }); }); }); @@ -579,48 +685,98 @@ describe('stripe-elements', function() { expect(element.isComplete).to.be.true; }); - describe('calling createToken()', function() { - beforeEach(createToken); - - it('fires a `token-changed` event', async function() { - const ev = await oneEvent(element, 'token-changed'); - expect(ev.detail.value).to.equal(SUCCESSFUL_TOKEN); + describe('calling createPaymentMethod()', function() { + it('resolves with the payment method', function() { + return element.createPaymentMethod() + .then(result => expect(result.paymentMethod).to.equal(SUCCESS_RESPONSES.paymentMethod)); }); - it('fires a `stripe-token` event', async function() { - const ev = await oneEvent(element, 'stripe-token'); - expect(ev.detail).to.equal(SUCCESSFUL_TOKEN); - }); - }); + describe('subsequently', function() { + beforeEach(createPaymentMethod); - describe('with `action` property set', function() { - beforeEach(stubFormSubmit); - beforeEach(setProps({ action: '/here' })); - afterEach(restoreFormSubmit); + it('fires a `payment-method-changed` event', async function() { + const ev = await oneEvent(element, 'payment-method-changed'); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.paymentMethod); + }); - describe('calling createSource()', function() { - it('submits the form', async function() { - await createSource(); - expect(element.form.submit).to.have.been.called; + it('fires a `stripe-payment-method` event', async function() { + const ev = await oneEvent(element, 'stripe-payment-method'); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.paymentMethod); }); + describe('calling validate()', function() { + beforeEach(validate); + it('returns true', function() { + expect(element.validate()).to.be.true; + }); + + it('does not set `error`', function() { + expect(element.error).to.be.null; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns true', function() { + expect(element.isPotentiallyValid()).to.be.true; + }); + }); + }); + }); + + describe('calling createSource()', function() { + it('resolves with the source', function() { + return element.createSource() + .then(result => expect(result.source).to.equal(SUCCESS_RESPONSES.source)); + }); + + describe('subsequently', function() { + beforeEach(createSource); it('fires a `source-changed` event', async function() { - createSource(); const ev = await oneEvent(element, 'source-changed'); - expect(ev.detail.value).to.equal(SUCCESSFUL_SOURCE); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.source); }); it('fires a `stripe-source` event', async function() { - createSource(); const ev = await oneEvent(element, 'stripe-source'); - expect(ev.detail).to.equal(SUCCESSFUL_SOURCE); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.source); + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns true', function() { + expect(element.validate()).to.be.true; + }); + + it('does not set `error`', function() { + expect(element.error).to.be.null; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns true', function() { + expect(element.isPotentiallyValid()).to.be.true; + }); }); }); + }); - describe('calling createToken()', function() { + describe('calling createToken()', function() { + it('resolves with the token', function() { + return element.createToken() + .then(result => expect(result.token).to.equal(SUCCESS_RESPONSES.token)); + }); + + describe('subsequently', function() { beforeEach(createToken); - it('submits the form', function() { - expect(element.form.submit).to.have.been.called; + + it('fires a `token-changed` event', async function() { + const ev = await oneEvent(element, 'token-changed'); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.token); + }); + + it('fires a `stripe-token` event', async function() { + const ev = await oneEvent(element, 'stripe-token'); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.token); }); describe('calling validate()', function() { @@ -641,22 +797,207 @@ describe('stripe-elements', function() { }); }); }); + + describe('and generate unset', function() { + describe('calling submit()', function() { + it('resolves with the source', function() { + return element.submit() + .then(result => expect(result.source).to.equal(SUCCESS_RESPONSES.source)); + }); + + describe('subsequently', function() { + beforeEach(submit); + it('fires a `source-changed` event', async function() { + const ev = await oneEvent(element, 'source-changed'); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.source); + }); + + it('fires a `stripe-source` event', async function() { + const ev = await oneEvent(element, 'stripe-source'); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.source); + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns true', function() { + expect(element.validate()).to.be.true; + }); + + it('does not set `error`', function() { + expect(element.error).to.be.null; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns true', function() { + expect(element.isPotentiallyValid()).to.be.true; + }); + }); + }); + }); + }); + + describe('and generate set to `token`', function() { + beforeEach(setProps({ generate: 'token' })); + describe('calling submit()', function() { + it('resolves with the token', function() { + return element.submit() + .then(result => expect(result.token).to.equal(SUCCESS_RESPONSES.token)); + }); + + describe('subsequently', function() { + beforeEach(submit); + it('fires a `token-changed` event', async function() { + const ev = await oneEvent(element, 'token-changed'); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.token); + }); + + it('fires a `stripe-token` event', async function() { + const ev = await oneEvent(element, 'stripe-token'); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.token); + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns true', function() { + expect(element.validate()).to.be.true; + }); + + it('does not set `error`', function() { + expect(element.error).to.be.null; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns true', function() { + expect(element.isPotentiallyValid()).to.be.true; + }); + }); + }); + }); + }); + + describe('and generate set to `payment-method`', function() { + beforeEach(setProps({ generate: 'payment-method' })); + describe('calling submit()', function() { + it('resolves with the payment method', function() { + return element.submit() + .then(result => expect(result.paymentMethod).to.equal(SUCCESS_RESPONSES.paymentMethod)); + }); + + describe('subsequently', function() { + beforeEach(submit); + it('fires a `payment-method-changed` event', async function() { + const ev = await oneEvent(element, 'payment-method-changed'); + expect(ev.detail.value).to.equal(SUCCESS_RESPONSES.paymentMethod); + }); + + it('fires a `stripe-payment-method` event', async function() { + const ev = await oneEvent(element, 'stripe-payment-method'); + expect(ev.detail).to.equal(SUCCESS_RESPONSES.paymentMethod); + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns true', function() { + expect(element.validate()).to.be.true; + }); + + it('does not set `error`', function() { + expect(element.error).to.be.null; + }); + }); + + describe('calling isPotentiallyValid()', function() { + it('returns true', function() { + expect(element.isPotentiallyValid()).to.be.true; + }); + }); + }); + }); + }); + + describe('and generate set to `something-silly`', function() { + beforeEach(setProps({ generate: 'something-silly' })); + describe('calling submit()', function() { + it('rejects', function() { + return element.submit().then(expect.fail, function(err) { + expect(err.message).to.equal(': cannot generate something-silly'); + }); + }); + + describe('subsequently', function() { + beforeEach(() => submit().catch(() => {})); + + it('sets the `error` property', function() { + expect(element.error.message).to.equal(': cannot generate something-silly'); + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + }); + }); + }); + }); + + describe('with `action` property set', function() { + beforeEach(stubFormSubmit); + beforeEach(setProps({ action: '/here' })); + afterEach(restoreFormSubmit); + + describe('calling createPaymentMethod()', function() { + beforeEach(createPaymentMethod); + it('submits the form', assertSubmitCalled); + }); + + describe('calling createSource()', function() { + beforeEach(createSource); + it('submits the form', assertSubmitCalled); + }); + + describe('calling createToken()', function() { + beforeEach(createToken); + it('submits the form', assertSubmitCalled); + }); + }); }); describe('with a card that will be declined', function() { beforeEach(synthStripeFormValues({ cardNumber: '4000000000000002', mm: '01', yy: '40', cvc: '000' })); - describe('calling createSource()', function() { - it('sets the `error` property', async function() { - try { - await element.createSource(); - } catch { + describe('calling createPaymentMethod()', function() { + it('rejects with the Stripe error', async function() { + return element.createPaymentMethod() + .then( + _ => expect.fail('createPaymentMethod Resolved'), + e => expect(e).to.equal(CARD_DECLINED_ERROR) + ); + }); + + describe('subsequently', function() { + beforeEach(createPaymentMethod); + it('sets the `error` property', function() { expect(element.error).to.equal(CARD_DECLINED_ERROR); - expect(element.token).to.be.null; - expect(element.source).to.be.null; - } + expect(element.paymentMethod).to.be.null; + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + + it('retains the `error` property', async function validating() { + expect(element.error).to.equal(CARD_DECLINED_ERROR); + }); + }); }); + }); + describe('calling createSource()', function() { it('rejects with the Stripe error', async function() { return element.createSource() .then( @@ -664,24 +1005,52 @@ describe('stripe-elements', function() { e => expect(e).to.equal(CARD_DECLINED_ERROR) ); }); + + describe('subsequently', function() { + beforeEach(createSource); + it('sets the `error` property', function() { + expect(element.error).to.equal(CARD_DECLINED_ERROR); + expect(element.source).to.be.null; + }); + + describe('calling validate()', function() { + beforeEach(validate); + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + + it('retains the `error` property', async function validating() { + expect(element.error).to.equal(CARD_DECLINED_ERROR); + }); + }); + }); }); describe('calling createToken()', function() { - beforeEach(createToken); - it('sets the `error` property', function() { - expect(element.error.code).to.equal(CARD_DECLINED_ERROR.code); - expect(element.token).to.be.null; - expect(element.source).to.be.null; + it('rejects with the Stripe error', async function() { + return element.createToken() + .then( + _ => expect.fail('createToken Resolved'), + e => expect(e).to.equal(CARD_DECLINED_ERROR) + ); }); - describe('calling validate()', function() { - beforeEach(validate); - it('returns false', function() { - expect(element.validate()).to.be.false; + describe('subsequently', function() { + beforeEach(createToken); + it('sets the `error` property', function() { + expect(element.error).to.equal(CARD_DECLINED_ERROR); + expect(element.token).to.be.null; }); - it('retains the `error` property', async function validating() { - expect(element.error.code).to.equal(CARD_DECLINED_ERROR.code); + describe('calling validate()', function() { + beforeEach(validate); + it('returns false', function() { + expect(element.validate()).to.be.false; + }); + + it('retains the `error` property', async function validating() { + expect(element.error).to.equal(CARD_DECLINED_ERROR); + }); }); }); }); diff --git a/stories/helpers.js b/stories/helpers.js deleted file mode 100644 index 84f9a16..0000000 --- a/stories/helpers.js +++ /dev/null @@ -1,15 +0,0 @@ -export const $ = x => document.querySelector(x); -export const $$ = x => [...document.querySelectorAll(x)]; - -export const PK_LS_KEY = '__STRIPE_PUBLISHABLE_KEY__'; - -export const publishableKey = localStorage.getItem(PK_LS_KEY) || 'pk_test_XXXXXXXXXXXXXXXXXXXXXXXX'; - -const setKey = key => el => - (el.publishableKey = key); - -export const setKeys = selector => ({ target: { value } }) => { - localStorage.setItem(PK_LS_KEY, value); - return $$(selector) - .forEach(setKey(value)); -}; diff --git a/stories/storybook-helpers.js b/stories/storybook-helpers.js new file mode 100644 index 0000000..c563e96 --- /dev/null +++ b/stories/storybook-helpers.js @@ -0,0 +1,40 @@ +export const $ = x => document.querySelector(x); +export const $$ = x => [...document.querySelectorAll(x)]; + +export const PK_LS_KEY = '__STRIPE_PUBLISHABLE_KEY__'; + +export const publishableKey = localStorage.getItem(PK_LS_KEY) || 'pk_test_XXXXXXXXXXXXXXXXXXXXXXXX'; + +const setKey = key => el => + (el.publishableKey = key); + +export const setKeys = selector => ({ target: { value } }) => { + localStorage.setItem(PK_LS_KEY, value); + return $$(selector) + .forEach(setKey(value)); +}; + + +const fieldEntry = field => [field.dataset.ownerProp, field.value]; + +export const ownerPropsIn = element => + Object.fromEntries([...element.querySelectorAll('[data-owner-prop]')].map(fieldEntry)); + +export const siblingSelector = (selector, element) => + element.parentElement.querySelector(selector); + +const hide = el => el.style.display = 'none'; + +export function enableButton({ target: { isComplete, parentElement } }) { + parentElement.querySelector('mwc-button').disabled = !isComplete; +} + +export async function submitThenDisplayResult(event) { + const parent = event.target.parentElement; + const viewer = parent.querySelector('json-viewer'); + const element = parent.querySelector('stripe-elements'); + element.billingDetails = ownerPropsIn(parent); + viewer.object = await element.submit(); + parent.querySelectorAll('mwc-textfield').forEach(hide); + parent.querySelectorAll('mwc-button').forEach(hide); +} diff --git a/stories/stripe-elements.stories.mdx b/stories/stripe-elements.stories.mdx index d0d7f18..3a0413a 100644 --- a/stories/stripe-elements.stories.mdx +++ b/stories/stripe-elements.stories.mdx @@ -4,20 +4,23 @@ import { Preview, Props, Story, + withKnobs, withWebComponentsKnobs } from '@open-wc/demoing-storybook'; import '../stripe-elements.js'; -import '@power-elements/json-viewer'; -import '@material/mwc-textfield'; import '@material/mwc-button'; +import '@material/mwc-textfield'; +import '@power-elements/codesandbox-button'; +import '@power-elements/json-viewer'; -import { publishableKey, $, $$, setKeys } from './helpers.js'; +import { $, $$, enableButton, ownerPropsIn, publishableKey, setKeys, submitThenDisplayResult } from './storybook-helpers.js'; @@ -29,7 +32,7 @@ To get started, add the element to your page with the `publishable-key` attribut [Stripe publishable key](https://dashboard.stripe.com/account/apikeys). You can also set the `publishableKey` DOM property using JavaScript ```html - + ``` **Careful!** never add your **secret key** to an HTML page, only publish your **publishable key**. @@ -49,75 +52,74 @@ Enter your publishable key here (use the test key, not the production key) to ru } +## Create a PaymentMethod -## Create a Token + + { + html` +
+ + + + + Generate PaymentMethod + +
+ ` + }
+
-Once you're set your publishable key and Stripe has instantiated (listen for the `stripe-ready` event if you need to know exactly when this happens), -you may generate a token from the filled-out form by calling the `createToken()` method. +## Create a Source - - { - () => { - const viewer = $('#token-viewer'); - const onStripeToken = event => viewer.object = event.detail; - const onClick = () => $('#token stripe-elements').createToken(); - return html` -
- - Generate Token -
` - } - } -
+ { + html` +
+ + + + + Generate Source + +
+ ` + }
- +## Create a Token -## Create a Source +Once you're set your publishable key and Stripe has instantiated (listen for the `stripe-ready` event if you need to know exactly when this happens), +you may generate a token from the filled-out form by calling the `createToken()` method. - - { - () => { - const onStripeSource = event => $('#source-viewer').object = event.detail; - const fieldEntry = field => [field.dataset.ownerProp, field.value]; - const getCardData = () => Object.fromEntries($$('#source mwc-textfield').map(fieldEntry)); - const onClick = () => $('#source stripe-elements').createSource({ type: 'card', owner: getCardData() }); - return html` -
- - - - - Generate Source -
- ` - } - } -
+ { + html` +
+ + + + + Generate Token + +
+ ` + }
- - ## Validation `` has a `show-error` boolean attribute which will display the error message for you. This is useful for simple validation in cases where you don't need to build your own validation UI. - - { - () => { - const onClick = () => $('#should-error').validate(); - return html` -
- - Validate -
` - } - } -
+ { + html` +
+ + Validate +
+ ` + }
## Advanced Validation @@ -135,24 +137,20 @@ stripe-elements[has-error] { border: 1px solid red; } ``` - - { - () => { - const onClick = () => $('#states stripe-elements').validate(); - return html` - -
- - Validate -
` - } - } -
+ { + html` + +
+ + Validate +
+ ` + }
For more complex needs, you can listen for the `stripe-error` event. @@ -165,42 +163,45 @@ Since `` is a custom-element, you can easily use it across fram ## Using with Plain HTML and JavaScript + + ```html - - + + + - + - - +Submit + + ``` - - ## In a `LitElement` + + {html``} + + ```js -import '@power-elements/stripe-elements/stripe-elements.js'; +import '@power-elements/stripe-elements'; +import '@power-elements/json-viewer'; import { LitElement, html } from 'lit-element'; import { PUBLISHABLE_KEY } from './config.js'; class PaymentForm extends LitElement { render() { return html` - + Submit + + + - + + `; } - onChange({ target: { isComplete, hasError } }) => { - this.shadowRoot.querySelector('button').disabled = !(isComplete && !hasError) + onChange({ target: { isComplete, hasError } }) { + this.submitDisabled = !(isComplete && !hasError); } onClick() { - this.shadowRoot.getElementById('stripe').createSource(); + this.shadowRoot.querySelector("stripe-elements").createSource(); + } + + onSource({ detail: source }) { + this.source = source; } } ``` -### In a Polymer Element - -```html - - - - - - Get Token - - - -``` - - - ### In a Vue Component - + + {html` `} + + ```html ``` - - ### In an Angular Component - + + {html` `} + + ```ts import { Component } from "@angular/core"; +import { PUBLISHABLE_KEY } from './config'; -// trick to get syntax highlighting -const html = x => x; -const template = html` - - +const template = ` - - Get Token - + Get Token - {{ token.id }} - {{ error.message }} + + `; -@Component({ selector: "app-root", template }) +const styleUrls = ["./app.component.css"]; + +@Component({ selector: "app-root", template, styleUrls }) export class AppComponent { - publishableKey: string; + publishableKey: string = PUBLISHABLE_KEY; disabled = true; token?: stripe.Token = null; error?: Error | stripe.Error = null; - createToken(stripeElements: any) { stripeElements.createToken(); } - - setKey({ value }) { - this.publishableKey = value; - } } ``` - - ### In a React Component - + + {html` `} + + ```jsx -import { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect } from "react"; +import ReactDOM from "react-dom"; import { getPropOr, compose } from "crocks"; +import { PUBLISHABLE_KEY } from './config'; import "./styles.css"; const isDisabled = ({ isComplete, isEmpty }) => !isComplete || isEmpty; @@ -407,13 +373,14 @@ const getTarget = getPropOr({}, "target"); const getDetail = getPropOr(null, "detail"); const getToken = getPropOr(null, "token"); -export default () => { +function App() { const stripeRef = useRef(null); - const [publishableKey, setPublishableKey] = useState(null); + const viewerRef = useRef(null); + const inputRef = useRef(null); + const buttonRef = useRef(null); const [disabled, setDisabled] = useState(true); const [token, setToken] = useState(null); const [error, setError] = useState(null); - const setKey = ({ target: { value } }) => setPublishableKey(value); const onChange = compose(setDisabled, isDisabled, getTarget); const onError = compose(setError, getDetail); @@ -421,48 +388,42 @@ export default () => { const onClick = async () => stripeRef.current.createToken() - .then(getToken) + .then(getToken); .then(setToken); useEffect(() => { stripeRef.current.addEventListener("stripe-change", onChange); stripeRef.current.addEventListener("stripe-error", onError); stripeRef.current.addEventListener("stripe-token", onToken); + buttonRef.current.addEventListener("click", onClick); + buttonRef.current.disabled = disabled; + if (token || error) viewerRef.current.object = token; }); return (
- - - - {token && {token.id}} - {error && {error.message}} + + Submit +
); -}; -``` +} - +ReactDOM.render(, document.getElementById("root")); +``` ### In a Preact Component - + + {html` `} + ```jsx +import { loadScripts } from "./loadScripts"; import { render } from "preact"; import { useState, useRef } from "preact/hooks"; import { getPropOr, compose } from "crocks"; +import { PUBLISHABLE_KEY } from './config'; import "./style"; const isDisabled = ({ isComplete, isEmpty }) => !isComplete || isEmpty; @@ -472,11 +433,9 @@ const getToken = getPropOr(null, "token"); export default function App() { const stripeRef = useRef(null); - const [publishableKey, setPublishableKey] = useState(null); const [disabled, setDisabled] = useState(true); const [token, setToken] = useState(null); const [error, setError] = useState(null); - const setKey = ({ target: { value } }) => setPublishableKey(value); const onChange = compose(setDisabled, isDisabled, getTarget); const onError = compose(setError, getDetail); @@ -489,11 +448,6 @@ export default function App() { return (
- - - Submit - - {token && {token.id}} - {error && {error.message}} + Submit + {(error || token) && }
); } + +if (typeof window !== "undefined") { + render(, document.getElementById("root")); +} ``` ## API diff --git a/test/mock-stripe.js b/test/mock-stripe.js index 7019ae5..840dced 100644 --- a/test/mock-stripe.js +++ b/test/mock-stripe.js @@ -2,6 +2,8 @@ import { render, html } from 'lit-html'; import luhn from 'luhn-js'; import creditCardType from 'credit-card-type'; +const assign = target => ([k, v]) => target[k] = v; + export const PUBLISHABLE_KEY = 'pk_test_XXXXXXXXXXXXXXXXXXXXXXXX'; export const INCOMPLETE_CARD_KEY = 'INCOMPLETE_CARD_KEY'; @@ -28,7 +30,11 @@ export const SUCCESSFUL_SOURCE = Object.freeze({ id: 'SUCCESSFUL_SOURCE' }); export const SUCCESSFUL_PAYMENT_METHOD = Object.freeze({ id: 'SUCCESSFUL_PAYMENT_METHOD' }); -const assign = target => ([k, v]) => target[k] = v; +export const SUCCESS_RESPONSES = Object.freeze({ + paymentMethod: SUCCESSFUL_PAYMENT_METHOD, + source: SUCCESSFUL_SOURCE, + token: SUCCESSFUL_TOKEN, +}); const CARD_ERRORS = { '4000000000000002': CARD_DECLINED_ERROR, @@ -141,7 +147,8 @@ export class MockedStripeAPI { return new MockElements({ fonts, locale }); } - async createPaymentMethod({ error = this.keyError }, cardData) { + async createPaymentMethod(paymentMethodData) { + const { error = this.keyError } = paymentMethodData.card; const paymentMethod = error ? undefined : SUCCESSFUL_PAYMENT_METHOD; const response = { error, paymentMethod }; return response; diff --git a/test/test-helpers.js b/test/test-helpers.js index 84830e8..8f53527 100644 --- a/test/test-helpers.js +++ b/test/test-helpers.js @@ -19,22 +19,19 @@ const getTemplate = (props = {}) => html``; /* eslint-disable no-unused-vars */ -@customElement('primary-host') -class PrimaryHost extends LitElement { +@customElement('primary-host') class PrimaryHost extends LitElement { @query('stripe-elements') nestedElement; render() { return getTemplate({ publishableKey: PUBLISHABLE_KEY }); } } -@customElement('secondary-host') -class SecondaryHost extends LitElement { +@customElement('secondary-host') class SecondaryHost extends LitElement { @query('primary-host') primaryHost; render() { return html``; } } -@customElement('tertiary-host') -class TertiaryHost extends LitElement { +@customElement('tertiary-host') class TertiaryHost extends LitElement { @query('secondary-host') secondaryHost; render() { return html``; } @@ -65,6 +62,7 @@ export const DEFAULT_PROPS = Object.freeze({ isComplete: false, isEmpty: true, publishableKey: undefined, + paymentMethod: null, source: null, stripe: null, token: null, @@ -79,6 +77,7 @@ export const READ_ONLY_PROPS = Object.freeze([ 'hasError', 'isComplete', 'isEmpty', + 'paymentMethod', 'source', 'stripe', 'stripeReady', @@ -92,6 +91,7 @@ export const NOTIFYING_PROPS = Object.freeze([ 'hasError', 'isComplete', 'isEmpty', + 'paymentMethod', 'publishableKey', 'source', 'stripeReady', @@ -280,7 +280,7 @@ export function testDefaultPropEntry([name, value]) { export function testReadOnlyProp(name) { it(name, function() { const init = element[name]; - expect(() => element[name] = Math.random()).to.throw; + element[name] = Math.random(); expect(element[name], name).to.equal(init); }); } @@ -311,28 +311,38 @@ export async function assertFiresStripeChange() { expect(type).to.equal(name); } +export function assertSubmitCalled() { + expect(element.form.submit).to.have.been.called; +} + /* ELEMENT METHODS */ +export async function submit() { + const submitPromise = element.submit(); + // don't await result if we need to set up a listener + if (!this?.currentTest?.title.startsWith('fires')) await submitPromise; +} + export async function reset() { spyCardClear(); element.reset(); await element.updateComplete; } -export async function createSource() { - element.createSource(); +export async function createPaymentMethod() { + element.createPaymentMethod(); // don't await result if we need to set up a listener if (!this?.currentTest?.title.startsWith('fires')) await nextFrame(); } -export async function createToken() { - element.createToken(); +export async function createSource() { + element.createSource(); // don't await result if we need to set up a listener if (!this?.currentTest?.title.startsWith('fires')) await nextFrame(); } -export async function createPaymentMethod() { - element.createPaymentMethod(); +export async function createToken() { + element.createToken(); // don't await result if we need to set up a listener if (!this?.currentTest?.title.startsWith('fires')) await nextFrame(); }