Wired products data to Product settings screen (#1927)

refs https://github.com/TryGhost/Team/issues/627

Wires the real Products data from API to the mock Product settings screen including Product Prices list as well as opening of edit/new price modals. The new Product setting is still behind the developer experiment flag as is under active development, the changes in this commit only allows readonly data but not save/edit upstream.

Co-authored-by: Fabien O'Carroll <fabien@allou.is>
This commit is contained in:
Rishabh Garg 2021-04-22 22:17:19 +05:30 committed by GitHub
parent 9855197e07
commit a544005c1f
16 changed files with 226 additions and 87 deletions

View File

@ -1,5 +1,7 @@
<span class="gh-select">
<OneWaySelect
@value={{this.value}}
@disabled={{this.disabled}}
@options={{this.availablePeriods}}
@optionValuePath="period"
@optionLabelPath="label"

View File

@ -1,13 +1,26 @@
import Component from '@ember/component';
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
const PERIODS = [
{label: 'Monthly', period: 'monthly'},
{label: 'Yearly', period: 'yearly'}
{label: 'Monthly', period: 'month'},
{label: 'Yearly', period: 'year'}
];
export default Component.extend({
init() {
this._super(...arguments);
export default class GhPostsListItemComponent extends Component {
@service feature;
@service session;
@service settings;
get value() {
const {value} = this.args;
return value;
}
get disabled() {
const {disabled} = this.args;
return disabled || false;
}
constructor() {
super(...arguments);
this.availablePeriods = PERIODS;
}
});
}

View File

@ -1,5 +1,5 @@
<header class="modal-header" data-test-modal="webhook-form">
<h1 data-test-text="title">New price</h1>
<h1 data-test-text="title">{{this.title}}</h1>
</header>
<button class="close" href title="Close" {{action "closeModal"}} {{action (optional this.noop) on="mouseDown"}}>
{{svg-jar "close"}}
@ -13,7 +13,7 @@
<GhFormGroup @errors={{this.price.errors}} @hasValidated={{this.price.hasValidated}} @property="name">
<label for="name" class="fw6">Name</label>
<GhTextInput
@value=''
@value={{this.price.nickname}}
@name="name"
@id="name"
@class="gh-input" />
@ -31,18 +31,19 @@
<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" />
<GhProductsPriceBillingperiod @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>
<div class="flex items-center justify-center gh-input-group gh-labs-price-label">
<GhTextInput
@value=''
@value={{this.price.amount}}
@type="number"
@disabled={{this.isExistingPrice}}
{{!-- @input={{action (mut this._scratchStripeMonthlyAmount) value="target.value"}} --}}
{{!-- @focus-out={{action "validateStripePlans"}} --}}
/>
<span class="gh-input-append"><span class="ttu">USD</span>/month</span>
<span class="gh-input-append"><span class="ttu">{{capitalize (or this.price.currency 'usd')}} </span>/{{or this.price.interval 'month'}}</span>
</div>
<GhErrorMessage @errors={{this.price.errors}} @property="price" />
</GhFormGroup>

View File

@ -1,3 +1,54 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import ModalBase from 'ghost-admin/components/modal-base';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {tracked} from '@glimmer/tracking';
export default ModalComponent.extend({});
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalProductPrice extends ModalBase {
@tracked model;
get title() {
if (this.isExistingPrice) {
return `Price - ${this.price.nickname || 'No Name'}`;
}
return 'New Price';
}
get price() {
return this.model.price || {};
}
get isExistingPrice() {
return !!this.model.price;
}
// TODO: rename to confirm() when modals have full Glimmer support
@action
confirmAction() {
this.confirm(this.role);
this.close();
}
@action
close(event) {
event?.preventDefault?.();
this.closeModal();
}
// @action
// setRoleFromModel() {
// this.role = this.model;
// }
actions = {
confirm() {
this.confirmAction(...arguments);
},
// needed because ModalBase uses .send() for keyboard events
closeModal() {
this.close();
}
}
}

View File

@ -9,6 +9,25 @@ export default class ProductController extends Controller {
@tracked showLeaveSettingsModal = false;
@tracked showPriceModal = false;
@tracked priceModel = null;
get product() {
return this.model;
}
get stripePrices() {
const stripePrices = this.model.stripePrices || [];
return stripePrices.map((d) => {
return {
...d,
amount: d.amount / 100
};
});
}
get noOfPrices() {
return (this.product.stripePrices || []).length;
}
leaveRoute(transition) {
if (this.settings.get('hasDirtyAttributes')) {
@ -18,6 +37,18 @@ export default class ProductController extends Controller {
}
}
@action
async openEditPrice(price) {
this.priceModel = price;
this.showPriceModal = true;
}
@action
async openNewPrice() {
this.priceModel = null;
this.showPriceModal = true;
}
@action
async confirmLeave() {
this.settings.rollbackAttributes();

View File

@ -1,3 +1,7 @@
import Controller from '@ember/controller';
export default class ProductsController extends Controller {}
export default class ProductsController extends Controller {
get products() {
return this.model;
}
}

View File

@ -0,0 +1,11 @@
import Helper from '@ember/component/helper';
import {getSymbol} from 'ghost-admin/utils/currency';
import {inject as service} from '@ember/service';
export default class CurrencySymbolHelper extends Helper {
@service feature;
compute([currency]) {
return getSymbol(currency);
}
}

View File

@ -0,0 +1,10 @@
import Model, {attr} from '@ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
export default Model.extend(ValidationEngine, {
validationType: 'product',
name: attr('string'),
slug: attr('string'),
stripePrices: attr('stripe-price')
});

View File

@ -0,0 +1,12 @@
import EmberObject from '@ember/object';
export default EmberObject.extend({
id: 'ID in Ghost',
stripe_price_id: 'ID of the Stripe Price',
stripe_product_id: 'ID of the Stripe Product the Stripe Price is associated with',
nickname: 'price nickname e.g. "Monthly"',
amount: 'amount in smallest denomination e.g. cents, so value for 5 dollars would be 500',
currency: 'e.g. usd',
type: 'either one_time or recurring',
interval: 'will be `null` if type is one_time, otherwise how often price charges e.g "month", "year"'
});

View File

@ -54,7 +54,8 @@ Router.map(function () {
this.route('settings.code-injection', {path: '/settings/code-injection'});
this.route('settings.products', {path: '/settings/products'});
this.route('settings.product', {path: '/settings/product'});
this.route('settings.product.new', {path: '/settings/product/new'});
this.route('settings.product', {path: '/settings/product/:product_id'});
this.route('settings.theme', {path: '/settings/theme'}, function () {
this.route('uploadtheme');

View File

@ -2,10 +2,14 @@ import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {inject as service} from '@ember/service';
export default class ProductRoute extends AuthenticatedRoute {
@service settings;
@service store
model() {
this.settings.reload();
model(params) {
if (params.product_id) {
return this.store.queryRecord('product', {id: params.product_id, include: 'stripe_prices'});
} else {
return this.store.createRecord('product');
}
}
actions = {

View File

@ -0,0 +1,6 @@
import ProductRoute from '../product';
export default class NewProductRoute extends ProductRoute {
controllerName = 'settings.product';
templateName = 'settings.product';
}

View File

@ -1,9 +1,16 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {inject as service} from '@ember/service';
export default class ProductsRoute extends AuthenticatedRoute {
@service store
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Products'
};
}
model() {
return this.store.findAll('product');
}
}

View File

@ -5,7 +5,7 @@
<span>{{svg-jar "arrow-right"}}</span>
<LinkTo @route="settings.products">Products</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Product name
{{product.name}}
</h2>
<section class="view-actions">
<span class="dropdown">
@ -45,8 +45,8 @@
</GhFormGroup> --}}
<div class="gh-product-details-fields">
<GhFormGroup @property="name" @classNames="max-width">
<label for="member-name">Product name</label>
<GhTextInput data-test-input="member-name" />
<label for="product-name">Product name</label>
<GhTextInput data-test-input="product-name" @id="product-name" @value={{product.name}}/>
<GhErrorMessage @property="name" />
</GhFormGroup>
@ -76,95 +76,60 @@
<div class="gh-main-section-block p0">
<ol class="gh-price-list gh-list">
<li class="gh-list-row header">
<div class="gh-list-header">Prices(3)</div>
<div class="gh-list-header">Prices({{this.noOfPrices}})</div>
<div class="gh-list-header">Price</div>
<div class="gh-list-header">Subscriptions</div>
<div class="gh-list-header"></div>
</li>
<li class="gh-list-row">
{{#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">
<h3 class="gh-price-list-name">
Monthly
{{price.nickname}}
</h3>
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
Full access
TODO: Description
</p>
</div>
<div class="gh-list-data gh-price-list-price">
<span>$5 USD / month</span>
<span>{{currency-symbol price.currency}}{{price.amount}} / {{price.interval}}</span>
</div>
<div class="gh-list-data gh-price-list-subscriptions">
<span>91</span>
<span>TODO</span>
</div>
<div class="gh-list-data gh-price-list-actions">
<div class="gh-price-list-actionlist">
<LinkTo @route="settings.product">Edit</LinkTo>
<button class="gh-btn gh-btn-link archive"><span>Archive</span></button>
</div>
</div>
</li>
<li class="gh-list-row">
<div class="gh-list-data gh-price-list-title">
<h3 class="gh-price-list-name">
Yearly
</h3>
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
37% discount
</p>
</div>
<div class="gh-list-data gh-price-list-price">
<span>$50 USD / year</span>
</div>
<div class="gh-list-data gh-price-list-subscriptions">
<span>76</span>
</div>
<div class="gh-list-data gh-price-list-actions">
<div class="gh-price-list-actionlist">
<LinkTo @route="settings.product">Edit</LinkTo>
<button class="gh-btn gh-btn-link archive"><span>Archive</span></button>
</div>
</div>
</li>
<li class="gh-list-row gh-price-list-archived">
<div class="gh-list-data gh-price-list-title">
<h3 class="gh-price-list-name">
Complimentary <span class="gh-badge archived">Archived</span>
</h3>
<p class="ma0 pa0 f8 midgrey gh-price-list-description">
Free of charge subscription
</p>
</div>
<div class="gh-list-data gh-price-list-price">
<span>$0 USD</span>
</div>
<div class="gh-list-data gh-price-list-subscriptions">
<span>18</span>
</div>
<div class="gh-list-data gh-price-list-actions">
<div class="gh-price-list-actionlist">
<button class="gh-btn gh-btn-link"><span>Activate</span></button>
<button class="gh-btn gh-btn-link archive" {{action "openEditPrice" price}}>
<span>Edit</span>
</button>
{{#if price.active}}
<button class="gh-btn gh-btn-link archive">
<span>Archive</span>
</button>
{{else}}
<button class="gh-btn gh-btn-link"><span>Activate</span></button>
{{/if}}
</div>
</div>
</li>
{{/each}}
</ol>
<button type="button" class="gh-btn gh-btn-green" {{action (toggle "showPriceModal" this)}}>
<button type="button" class="gh-btn gh-btn-green" {{action "openNewPrice"}}>
<span>New price</span>
</button>
</div>
</div>
{{#if this.showPriceModal}}
<GhFullscreenModal @modal="product-price"
<GhFullscreenModal
@modal="product-price"
@model={{hash
price=this.priceModel
}}
@close={{this.closePriceModal}}
@modifier="action wide body-scrolling product-price" />
{{/if}}

View File

@ -6,7 +6,7 @@
Products
</h2>
<section class="view-actions">
<LinkTo @route="settings.products" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New product</span></LinkTo>
<LinkTo @route="settings.product.new" class="gh-btn gh-btn-primary" data-test-new-member-button="true"><span>New product</span></LinkTo>
</section>
</GhCanvasHeader>
@ -17,23 +17,25 @@
<div class="gh-list-header gh-list-cellwidth-70"></div>
<div class="gh-list-header gh-list-cellwidth-30"></div>
</li>
{{#each this.products as |product|}}
<li class="gh-list-row">
<LinkTo @route="settings.product" class="gh-list-data gh-list-cellwidth-70 gh-product-list-title">
<LinkTo @route="settings.product" @model={{product}} class="gh-list-data gh-list-cellwidth-70 gh-product-list-title">
<h3 class="gh-product-list-name">
Product name
{{product.name}}
</h3>
<p class="ma0 pa0 f8 midgrey gh-product-list-description">
Product description
TODO: Product description
</p>
</LinkTo>
<LinkTo @route="settings.product" class="gh-list-data gh-list-cellwidth-30 gh-product-list-chevron">
<LinkTo @route="settings.product" @model={{product}} class="gh-list-data gh-list-cellwidth-30 gh-product-list-chevron">
<div class="gh-product-chevron">
{{!-- <span>330 members</span> --}}
<span>{{svg-jar "arrow-right" class="w6 h6 fill-midgrey pa1"}}</span>
</div>
</LinkTo>
</li>
{{/each}}
</ol>
</section>
</section>

View File

@ -0,0 +1,19 @@
import StripePrice from 'ghost-admin/models/stripe-price';
import Transform from '@ember-data/serializer/transform';
import {A as emberA, isArray as isEmberArray} from '@ember/array';
export default Transform.extend({
deserialize(serialized = []) {
return emberA(serialized.map(StripePrice.create.bind(StripePrice)));
},
serialize(deserialized) {
if (isEmberArray(deserialized)) {
return deserialized.map((item) => {
return item;
}).compact();
} else {
return [];
}
}
});