From 2555b70456a59c6ef18e3f764a39b991568c7e3a Mon Sep 17 00:00:00 2001 From: Rishabh Garg Date: Mon, 26 Apr 2021 23:52:04 +0530 Subject: [PATCH] Wired new Products settings UI to API data (#1930) refs TryGhost/Team#627 This updates the new Products settings screens to use real data from API for existing Custom Products and Prices to populate them on UI and allow site owners to edit them. - List all Products of site and allow editing Product name - List all Prices for a product and allow editing individual Price names - Add new Prices on a Product --- .../gh-products-price-billingperiod.hbs | 4 + .../gh-products-price-billingperiod.js | 17 +++- .../app/components/modal-product-price.hbs | 33 +++++-- .../app/components/modal-product-price.js | 87 +++++++++++++++++-- .../admin/app/controllers/settings/product.js | 58 +++++++++++-- ghost/admin/app/mixins/validation-engine.js | 4 +- ghost/admin/app/routes/settings/product.js | 56 +++++++++++- ghost/admin/app/routes/settings/products.js | 2 +- ghost/admin/app/styles/layouts/products.css | 38 ++++++-- .../admin/app/templates/settings/product.hbs | 40 +++++++-- ghost/admin/app/transforms/stripe-price.js | 4 +- ghost/admin/app/validators/product.js | 13 +++ 12 files changed, 309 insertions(+), 47 deletions(-) create mode 100644 ghost/admin/app/validators/product.js diff --git a/ghost/admin/app/components/gh-products-price-billingperiod.hbs b/ghost/admin/app/components/gh-products-price-billingperiod.hbs index c9811c9bab..3a6fbf144a 100644 --- a/ghost/admin/app/components/gh-products-price-billingperiod.hbs +++ b/ghost/admin/app/components/gh-products-price-billingperiod.hbs @@ -6,6 +6,10 @@ @optionValuePath="period" @optionLabelPath="label" @optionTargetPath="period" + @includeBlank={{false}} + @promptIsSelectable={{false}} + @prompt="Select Interval" + @update={{action "updatePeriod"}} /> {{svg-jar "arrow-down-small"}} \ No newline at end of file diff --git a/ghost/admin/app/components/gh-products-price-billingperiod.js b/ghost/admin/app/components/gh-products-price-billingperiod.js index f583bfc400..d9a76b5eb6 100644 --- a/ghost/admin/app/components/gh-products-price-billingperiod.js +++ b/ghost/admin/app/components/gh-products-price-billingperiod.js @@ -1,4 +1,5 @@ import Component from '@glimmer/component'; +import {action} from '@ember/object'; import {inject as service} from '@ember/service'; const PERIODS = [ @@ -6,11 +7,16 @@ const PERIODS = [ {label: 'Yearly', period: 'year'} ]; -export default class GhPostsListItemComponent extends Component { +export default class GhProductsPriceBillingPeriodComponent extends Component { @service feature; @service session; @service settings; + constructor() { + super(...arguments); + this.availablePeriods = PERIODS; + } + get value() { const {value} = this.args; return value; @@ -19,8 +25,11 @@ export default class GhPostsListItemComponent extends Component { const {disabled} = this.args; return disabled || false; } - constructor() { - super(...arguments); - this.availablePeriods = PERIODS; + + @action + updatePeriod(newPeriod) { + if (this.args.updatePeriod) { + this.args.updatePeriod(this.args.value, newPeriod); + } } } diff --git a/ghost/admin/app/components/modal-product-price.hbs b/ghost/admin/app/components/modal-product-price.hbs index 2c18ca1683..4cb866622e 100644 --- a/ghost/admin/app/components/modal-product-price.hbs +++ b/ghost/admin/app/components/modal-product-price.hbs @@ -13,7 +13,8 @@ @@ -28,10 +29,29 @@ @class="gh-input" /> + + + + {{one-way-select this.selectedCurrencyObj + id="currency" + name="currency" + options=(readonly this.allCurrencies) + optionValuePath="value" + optionLabelPath="label" + disabled=this.isExistingPrice + update=(action "setCurrency") + }} + {{svg-jar "arrow-down-small"}} + +
- + @@ -40,10 +60,9 @@ @value={{this.price.amount}} @type="number" @disabled={{this.isExistingPrice}} - {{!-- @input={{action (mut this._scratchStripeMonthlyAmount) value="target.value"}} --}} - {{!-- @focus-out={{action "validateStripePlans"}} --}} + @input={{action "setAmount" value="target.value"}} /> - {{capitalize (or this.price.currency 'usd')}} /{{or this.price.interval 'month'}} + {{capitalize (or this.currencyVal "usd")}} /{{or this.periodVal "month"}}
@@ -101,7 +120,7 @@ + data-test-button="save-price" /> \ No newline at end of file diff --git a/ghost/admin/app/components/modal-product-price.js b/ghost/admin/app/components/modal-product-price.js index bd6d88ade2..0b4236cee5 100644 --- a/ghost/admin/app/components/modal-product-price.js +++ b/ghost/admin/app/components/modal-product-price.js @@ -1,12 +1,50 @@ import ModalBase from 'ghost-admin/components/modal-base'; import classic from 'ember-classic-decorator'; import {action} from '@ember/object'; +import {currencies} from 'ghost-admin/utils/currency'; +import {task} from 'ember-concurrency-decorators'; import {tracked} from '@glimmer/tracking'; // TODO: update modals to work fully with Glimmer components @classic export default class ModalProductPrice extends ModalBase { @tracked model; + @tracked price; + @tracked currencyVal; + @tracked periodVal; + + init() { + super.init(...arguments); + this.price = { + ...(this.model.price || {}) + }; + this.topCurrencies = currencies.slice(0, 5).map((currency) => { + return { + value: currency.isoCode.toLowerCase(), + label: `${currency.isoCode} - ${currency.name}`, + isoCode: currency.isoCode + }; + }); + this.currencies = currencies.slice(5, currencies.length).map((currency) => { + return { + value: currency.isoCode.toLowerCase(), + label: `${currency.isoCode} - ${currency.name}`, + isoCode: currency.isoCode + }; + }); + this.allCurrencies = [ + { + groupName: '—', + options: this.get('topCurrencies') + }, + { + groupName: '—', + options: this.get('currencies') + } + ]; + this.currencyVal = this.price.currency || 'usd'; + this.periodVal = this.price.interval || 'month'; + } get title() { if (this.isExistingPrice) { @@ -15,18 +53,22 @@ export default class ModalProductPrice extends ModalBase { return 'New Price'; } - get price() { - return this.model.price || {}; - } - get isExistingPrice() { return !!this.model.price; } + get currency() { + return this.price.currency || 'usd'; + } + + get selectedCurrencyObj() { + return this.currencies.findBy('value', this.price.currency) || this.topCurrencies.findBy('value', this.price.currency); + } + // TODO: rename to confirm() when modals have full Glimmer support @action confirmAction() { - this.confirm(this.role); + this.confirm(this.price); this.close(); } @@ -36,16 +78,43 @@ export default class ModalProductPrice extends ModalBase { this.closeModal(); } - // @action - // setRoleFromModel() { - // this.role = this.model; - // } + @task({drop: true}) + *savePrice() { + try { + const priceObj = { + ...this.price, + amount: (this.price.amount || 0) * 100 + }; + if (!priceObj.id) { + priceObj.active = 1; + priceObj.currency = priceObj.currency || 'usd'; + priceObj.interval = priceObj.interval || 'month'; + priceObj.type = 'recurring'; + } + yield this.confirm(priceObj); + } catch (error) { + this.notifications.showAPIError(error, {key: 'price.save.failed'}); + } finally { + this.send('closeModal'); + } + } actions = { confirm() { this.confirmAction(...arguments); }, + updatePeriod(oldPeriod, newPeriod) { + this.price.interval = newPeriod; + this.periodVal = newPeriod; + }, + setAmount(amount) { + this.price.amount = !isNaN(amount) ? parseInt(amount) : 0; + }, + setCurrency(currency) { + this.price.currency = currency.value; + this.currencyVal = currency.value; + }, // needed because ModalBase uses .send() for keyboard events closeModal() { this.close(); diff --git a/ghost/admin/app/controllers/settings/product.js b/ghost/admin/app/controllers/settings/product.js index 72fc9eea03..86decef92d 100644 --- a/ghost/admin/app/controllers/settings/product.js +++ b/ghost/admin/app/controllers/settings/product.js @@ -1,5 +1,5 @@ import Controller from '@ember/controller'; -import {action} from '@ember/object'; +import EmberObject, {action} from '@ember/object'; import {inject as service} from '@ember/service'; import {task} from 'ember-concurrency-decorators'; import {tracked} from '@glimmer/tracking'; @@ -10,6 +10,7 @@ export default class ProductController extends Controller { @tracked showLeaveSettingsModal = false; @tracked showPriceModal = false; @tracked priceModel = null; + @tracked showUnsavedChangesModal = false; get product() { return this.model; @@ -29,12 +30,35 @@ export default class ProductController extends Controller { return (this.product.stripePrices || []).length; } - leaveRoute(transition) { - if (this.settings.get('hasDirtyAttributes')) { - transition.abort(); - this.leaveSettingsTransition = transition; - this.showLeaveSettingsModal = true; + @action + toggleUnsavedChangesModal(transition) { + let leaveTransition = this.leaveScreenTransition; + + if (!transition && this.showUnsavedChangesModal) { + this.leaveScreenTransition = null; + this.showUnsavedChangesModal = false; + return; } + + if (!leaveTransition || transition.targetName === leaveTransition.targetName) { + this.leaveScreenTransition = transition; + + // if a save is running, wait for it to finish then transition + if (this.saveTask.isRunning) { + return this.saveTask.last.then(() => { + transition.retry(); + }); + } + + // we genuinely have unsaved data, show the modal + this.showUnsavedChangesModal = true; + } + } + + @action + leaveScreen() { + this.product.rollbackAttributes(); + return this.leaveScreenTransition.retry(); } @action @@ -62,6 +86,26 @@ export default class ProductController extends Controller { this.leaveSettingsTransition = null; } + @action + save() { + return this.saveTask.perform(); + } + + @action + savePrice(price) { + const stripePrices = this.product.stripePrices.map((d) => { + if (d.id === price.id) { + return EmberObject.create(price); + } + return d; + }); + if (!price.id) { + stripePrices.push(EmberObject.create(price)); + } + this.product.set('stripePrices', stripePrices); + this.saveTask.perform(); + } + @action closePriceModal() { this.showPriceModal = false; @@ -69,6 +113,6 @@ export default class ProductController extends Controller { @task({drop: true}) *saveTask() { - return yield this.settings.save(); + return yield this.product.save(); } } diff --git a/ghost/admin/app/mixins/validation-engine.js b/ghost/admin/app/mixins/validation-engine.js index 0a074d2da0..4bbc5633f4 100644 --- a/ghost/admin/app/mixins/validation-engine.js +++ b/ghost/admin/app/mixins/validation-engine.js @@ -10,6 +10,7 @@ import Mixin from '@ember/object/mixin'; import Model from '@ember-data/model'; import NavItemValidator from 'ghost-admin/validators/nav-item'; import PostValidator from 'ghost-admin/validators/post'; +import ProductValidator from 'ghost-admin/validators/product'; import RSVP from 'rsvp'; import ResetValidator from 'ghost-admin/validators/reset'; import SettingValidator from 'ghost-admin/validators/setting'; @@ -54,7 +55,8 @@ export default Mixin.create({ integration: IntegrationValidator, webhook: WebhookValidator, label: LabelValidator, - snippet: SnippetValidator + snippet: SnippetValidator, + product: ProductValidator }, // This adds the Errors object to the validation engine, and shouldn't affect diff --git a/ghost/admin/app/routes/settings/product.js b/ghost/admin/app/routes/settings/product.js index c62c70a628..360171b346 100644 --- a/ghost/admin/app/routes/settings/product.js +++ b/ghost/admin/app/routes/settings/product.js @@ -1,8 +1,19 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import {action} from '@ember/object'; import {inject as service} from '@ember/service'; export default class ProductRoute extends AuthenticatedRoute { @service store + @service router; + + _requiresBackgroundRefresh = true; + + constructor() { + super(...arguments); + this.router.on('routeWillChange', (transition) => { + this.showUnsavedChangesModal(transition); + }); + } model(params) { if (params.product_id) { @@ -12,15 +23,54 @@ export default class ProductRoute extends AuthenticatedRoute { } } - actions = { - willTransition(transition) { - return this.controller.leaveRoute(transition); + beforeModel() { + super.beforeModel(...arguments); + return this.session.user.then((user) => { + if (!user.isOwnerOrAdmin) { + return this.transitionTo('home'); + } + }); + } + + setupController(controller, product) { + super.setupController(...arguments); + if (this._requiresBackgroundRefresh) { + if (product.get('id')) { + return this.store.queryRecord('product', {id: product.get('id'), include: 'stripe_prices'}); + } } } + deactivate() { + super.deactivate(...arguments); + // clean up newly created records and revert unsaved changes to existing + this.controller.product.rollbackAttributes(); + this._requiresBackgroundRefresh = true; + } + + @action + save() { + this.controller.save(); + } + buildRouteInfoMetadata() { return { titleToken: 'Settings - Products' }; } + + showUnsavedChangesModal(transition) { + if (transition.from && transition.from.name === this.routeName && transition.targetName) { + let {controller} = this; + + // product.changedAttributes is always true for new products but number of changed attrs is reliable + let isChanged = Object.keys(controller.product.changedAttributes()).length > 0; + + if (!controller.product.isDeleted && isChanged) { + transition.abort(); + controller.toggleUnsavedChangesModal(transition); + return; + } + } + } } diff --git a/ghost/admin/app/routes/settings/products.js b/ghost/admin/app/routes/settings/products.js index 5c2290e14d..0713b7be2b 100644 --- a/ghost/admin/app/routes/settings/products.js +++ b/ghost/admin/app/routes/settings/products.js @@ -11,6 +11,6 @@ export default class ProductsRoute extends AuthenticatedRoute { } model() { - return this.store.findAll('product'); + return this.store.findAll('product', {include: 'stripe_prices'}); } } diff --git a/ghost/admin/app/styles/layouts/products.css b/ghost/admin/app/styles/layouts/products.css index 822473e5b9..f2f564e17e 100644 --- a/ghost/admin/app/styles/layouts/products.css +++ b/ghost/admin/app/styles/layouts/products.css @@ -121,6 +121,7 @@ justify-content: flex-end; width: 100%; opacity: 0; + line-height: 1; } .gh-price-list .gh-list-row:hover .gh-price-list-actionlist { @@ -129,13 +130,32 @@ .gh-price-list-actionlist a, .gh-price-list-actionlist button { + margin-left: 15px; + padding: 0; line-height: 0; - margin-left: 24px; - color: var(--darkgrey); - font-weight: 500; } -.gh-price-list-actionlist button.archive { +.gh-price-list-actionlist a span, +.gh-price-list-actionlist button span { + display: inline-block; + line-height: 1; + height: unset; + border-radius: 3px; + padding: 4px 6px; + color: var(--darkgrey); + font-weight: 500; + font-size: 1.2rem !important; + text-transform: uppercase; +} + +.gh-price-list-actionlist a:hover span, +.gh-price-list-actionlist button:hover span { + background: var(--whitegrey); +} + +.gh-price-list-actionlist a.archived:hover span, +.gh-price-list-actionlist button.archived:hover span { + background: color-mod(var(--red) a(10%)); color: var(--red); } @@ -157,6 +177,14 @@ opacity: 0.5; } +.gh-price-list-noprices { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 0; + color: var(--midgrey); +} + .product-actions-menu { top: calc(100% + 6px); right: 10px; @@ -177,4 +205,4 @@ display: grid; grid-template-columns: 1fr 1fr; grid-gap: 20px; -} \ No newline at end of file +} diff --git a/ghost/admin/app/templates/settings/product.hbs b/ghost/admin/app/templates/settings/product.hbs index 69d82a342c..6c4ba917a3 100644 --- a/ghost/admin/app/templates/settings/product.hbs +++ b/ghost/admin/app/templates/settings/product.hbs @@ -81,6 +81,18 @@
Subscriptions
+ {{#unless this.stripePrices}} + + +
+
There are no prices for this product.
+ +
+ + + {{/unless}} {{#each this.stripePrices as |price|}}
  • @@ -102,11 +114,11 @@
    - {{#if price.active}} - {{else}} @@ -118,28 +130,38 @@ {{/each}} + {{#if this.stripePrices}} + {{/if}}
    {{#if this.showPriceModal}} + {{!-- + + --}} + @modifier="action wide body-scrolling product-ssprice" /> {{/if}} - {{#if this.showLeaveSettingsModal}} + {{#if this.showUnsavedChangesModal}} + @confirm={{this.leaveScreen}} + @close={{this.toggleUnsavedChangesModal}} + @modifier="action wide" /> {{/if}} \ No newline at end of file diff --git a/ghost/admin/app/transforms/stripe-price.js b/ghost/admin/app/transforms/stripe-price.js index fee1872699..a3ecaa4fdc 100644 --- a/ghost/admin/app/transforms/stripe-price.js +++ b/ghost/admin/app/transforms/stripe-price.js @@ -4,7 +4,9 @@ import {A as emberA, isArray as isEmberArray} from '@ember/array'; export default Transform.extend({ deserialize(serialized = []) { - return emberA(serialized.map(StripePrice.create.bind(StripePrice))); + const stripePrices = serialized.map(itemDetails => StripePrice.create(itemDetails)); + + return emberA(stripePrices); }, serialize(deserialized) { diff --git a/ghost/admin/app/validators/product.js b/ghost/admin/app/validators/product.js new file mode 100644 index 0000000000..75a88812e0 --- /dev/null +++ b/ghost/admin/app/validators/product.js @@ -0,0 +1,13 @@ +import BaseValidator from './base'; +import validator from 'validator'; + +export default BaseValidator.create({ + properties: ['name'], + + name(model) { + if (!validator.isLength(model.name || '', 0, 191)) { + model.errors.add('name', 'Name cannot be longer than 191 characters.'); + this.invalidate(); + } + } +});