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
This commit is contained in:
Rishabh Garg 2021-04-26 23:52:04 +05:30 committed by GitHub
parent 540c50f331
commit 2555b70456
12 changed files with 309 additions and 47 deletions

View File

@ -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"}}
</span>

View File

@ -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);
}
}
}

View File

@ -13,7 +13,8 @@
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@value={{this.price.nickname}}
@value={{readonly this.price.nickname}}
@input={{action (mut this.price.nickname) value="target.value"}}
@name="name"
@id="name"
@class="gh-input" />
@ -28,10 +29,29 @@
@class="gh-input" />
<GhErrorMessage @errors={{this.price.errors}} @property="description" />
</GhFormGroup>
<GhFormGroup @class="for-select">
<label class="fw6 f8"for="currency">Plan currency</label>
<span class="gh-select mt1">
{{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"}}
</span>
</GhFormGroup>
<div class="gh-product-priceform-period">
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="billing-period">
<label for="billing-period" class="fw6">Billing period</label>
<GhProductsPriceBillingperiod @triggerId="period-input" @value={{this.price.interval}} @disabled={{this.isExistingPrice}} />
<GhProductsPriceBillingperiod
@updatePeriod={{action "updatePeriod"}}
@triggerId="period-input"
@value={{this.price.interval}} @disabled={{this.isExistingPrice}}
/>
</GhFormGroup>
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="price">
<label for="price" class="fw6">Price</label>
@ -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"}}
/>
<span class="gh-input-append"><span class="ttu">{{capitalize (or this.price.currency 'usd')}} </span>/{{or this.price.interval 'month'}}</span>
<span class="gh-input-append"><span class="ttu">{{capitalize (or this.currencyVal "usd")}} </span>/{{or this.periodVal "month"}}</span>
</div>
<GhErrorMessage @errors={{this.price.errors}} @property="price" />
</GhFormGroup>
@ -101,7 +120,7 @@
</button>
<GhTaskButton @buttonText="Save price"
@successText={{this.successText}}
{{!-- @task={{this.saveWebhook}} --}}
@task={{this.savePrice}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="save-webhook" />
data-test-button="save-price" />
</div>

View File

@ -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();

View File

@ -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();
}
}

View File

@ -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

View File

@ -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;
}
}
}
}

View File

@ -11,6 +11,6 @@ export default class ProductsRoute extends AuthenticatedRoute {
}
model() {
return this.store.findAll('product');
return this.store.findAll('product', {include: 'stripe_prices'});
}
}

View File

@ -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;
}
}

View File

@ -81,6 +81,18 @@
<div class="gh-list-header">Subscriptions</div>
<div class="gh-list-header"></div>
</li>
{{#unless this.stripePrices}}
<tr class="gh-list-row {{if price.active "" "gh-price-list-archived"}}">
<td colspan="4" class="gh-list-data">
<div class="gh-price-list-noprices">
<div class="mb2">There are no prices for this product.</div>
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}>
<span>Add price</span>
</button>
</div>
</td>
</tr>
{{/unless}}
{{#each this.stripePrices as |price|}}
<li class="gh-list-row {{if price.active "" "gh-price-list-archived"}}">
<div class="gh-list-data gh-price-list-title">
@ -102,11 +114,11 @@
<div class="gh-list-data gh-price-list-actions">
<div class="gh-price-list-actionlist">
<button class="gh-btn gh-btn-link archive" {{action "openEditPrice" price}}>
<button class="gh-btn gh-btn-link" {{action "openEditPrice" price}}>
<span>Edit</span>
</button>
{{#if price.active}}
<button class="gh-btn gh-btn-link archive">
<button class="gh-btn gh-btn-link archived">
<span>Archive</span>
</button>
{{else}}
@ -118,28 +130,38 @@
{{/each}}
</ol>
{{#if this.stripePrices}}
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}>
<span>New price</span>
<span>Add price</span>
</button>
{{/if}}
</div>
</div>
{{#if this.showPriceModal}}
{{!-- <GhFullscreenModal @modifier="action wide member-product">
<ModalProductPrice
@model={{hash
price=this.priceModel
}}
@confirm={{action "savePrice"}}
@closeModal={{this.closePriceModal}} />
</GhFullscreenModal> --}}
<GhFullscreenModal
@modal="product-price"
@model={{hash
price=this.priceModel
}}
@confirm={{action "savePrice"}}
@close={{this.closePriceModal}}
@modifier="action wide body-scrolling product-price" />
@modifier="action wide body-scrolling product-ssprice" />
{{/if}}
{{#if this.showLeaveSettingsModal}}
{{#if this.showUnsavedChangesModal}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{this.confirmLeave}}
@close={{this.cancelLeave}}
@modifier="action wide"
/>
@confirm={{this.leaveScreen}}
@close={{this.toggleUnsavedChangesModal}}
@modifier="action wide" />
{{/if}}
</section>

View File

@ -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) {

View File

@ -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();
}
}
});