mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 03:44:29 +03:00
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:
parent
540c50f331
commit
2555b70456
@ -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>
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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();
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,6 @@ export default class ProductsRoute extends AuthenticatedRoute {
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.findAll('product');
|
||||
return this.store.findAll('product', {include: 'stripe_prices'});
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
@ -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) {
|
||||
|
13
ghost/admin/app/validators/product.js
Normal file
13
ghost/admin/app/validators/product.js
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
Loading…
Reference in New Issue
Block a user