Added canceled subscriptions in member detail screen (#2287)

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

Showing canceled subscriptions provide a more complete picture of the activity of a member.

- Given there is no `member.product` object when a subscription is canceled, use the `member.subscriptions.price.product` objects instead of `member.products`.
- applied boy-scout rule for linter errors and and code formatting
- removed `multipleTiers` flag conditionals as it's now GA
- set up subscriptions as a separate mirage resource so they are easier to work with
    - updated `PUT /members/:id/` endpoint to match real API's complimentary subscription behaviour
    - modified mirage member serializer to match API output

Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
Thibaut Patel 2022-03-18 17:15:42 +01:00 committed by GitHub
parent 0c4c7582a5
commit f5f69d01b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 680 additions and 194 deletions

View File

@ -1198,3 +1198,21 @@ remove|ember-template-lint|no-invalid-interactive|38|68|38|68|1849f49e96b9809e3b
remove|ember-template-lint|no-action|421|15|421|15|86d78f77ffe339a3beabf8cd2690e383fe8faace|1646611200000|1649199600000|1651791600000|app/templates/settings/labs.hbs
remove|ember-template-lint|no-duplicate-landmark-elements|131|24|131|24|9eb7d301f1f50334e793aafab8f6b9e8905125ab|1646611200000|1649199600000|1651791600000|app/components/modal-product.hbs
remove|ember-template-lint|no-nested-landmark|131|24|131|24|9eb7d301f1f50334e793aafab8f6b9e8905125ab|1646611200000|1649199600000|1651791600000|app/components/modal-product.hbs
remove|ember-template-lint|no-action|12|43|12|43|c658d7c0cbef78ba9818c8a70219b7e1087dea69|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|20|43|20|43|b9a67e97651b70f148a37ae0875d555af81ab6f3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|28|38|28|38|d858959ab1d793b92dae81a046f33d93c84d68fb|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|40|74|40|74|c5e1748caba85674441672f3175ba17db7ed8bc3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|86|129|86|129|82dff20e50ca34a8c05ecd45a8ca2434349cc333|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|91|129|91|129|0856da9acd782baf5792ac652771757db9fd0ad8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|164|78|164|78|35530afd5940b30c02b6e8d9b6bc887b27bfe8c3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|193|78|193|78|10bccb0d99ddf58e4b54d01c6634fcda0fac91e8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|197|78|197|78|d45e015de9916af6c70ef01db2c2b4ea7d7098b4|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|235|70|235|70|35530afd5940b30c02b6e8d9b6bc887b27bfe8c3|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|251|129|251|129|0856da9acd782baf5792ac652771757db9fd0ad8|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|270|36|270|36|82dff20e50ca34a8c05ecd45a8ca2434349cc333|1646611200000|1649199600000|1651791600000|app/components/gh-member-settings-form.hbs
remove|ember-template-lint|no-action|4|53|4|53|463c25195e9aee40a1db8ac9a7e2545080791402|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
remove|ember-template-lint|no-action|46|71|46|71|463c25195e9aee40a1db8ac9a7e2545080791402|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
remove|ember-template-lint|no-action|48|8|48|8|48bafc3d5a5d7e6dc9e7318bb679ec77035c2916|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
remove|ember-template-lint|no-down-event-binding|48|41|48|41|b3e87879e52edc06bb07f1ad0becc6ec762bbb39|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
remove|ember-template-lint|no-invalid-interactive|26|95|26|95|defed375e0064745e03179bf7687ca9aa13f6ac7|1646611200000|1649199600000|1651791600000|app/components/modal-member-product.hbs
add|ember-template-lint|no-invalid-interactive|28|24|28|24|d7193af2cc72230c56b34863b6f8c0e7fbb1b41e|1647475200000|1650063600000|1652655600000|app/components/modal-member-product.hbs

View File

@ -8,16 +8,31 @@
<div class="gh-cp-member-email-name">
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="name" @classNames="max-width">
<label for="member-name">Name</label>
<GhTextInput @id="member-name" @name="name" @value={{this.scratchMember.name}} @tabindex="1" @shouldFocus="{{if this.member.isNew true}}"
@focus-out={{action "setProperty" "name" this.scratchMember.name}} data-test-input="member-name" />
<GhTextInput
@id="member-name"
@name="name"
@value={{this.scratchMember.name}}
@tabindex="1"
@shouldFocus="{{if this.member.isNew true}}"
@focus-out={{fn this.setProperty "name" this.scratchMember.name}}
data-test-input="member-name"
/>
<GhErrorMessage @errors={{this.member.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="email" @classNames="max-width">
<label for="member-email">Email</label>
<GhTextInput @value={{this.scratchMember.email}} @id="member-email" @name="email" @tabindex="2"
@autocapitalize="off" @autocorrect="off" @autocomplete="off"
@focus-out={{action "setProperty" "email" this.scratchMember.email}} data-test-input="member-email"/>
<GhTextInput
@value={{this.scratchMember.email}}
@id="member-email"
@name="email"
@tabindex="2"
@autocapitalize="off"
@autocorrect="off"
@autocomplete="off"
@focus-out={{fn this.setProperty "email" this.scratchMember.email}}
data-test-input="member-email"
/>
<GhErrorMessage @errors={{this.member.errors}} @property="email" />
</GhFormGroup>
</div>
@ -25,7 +40,7 @@
<GhFormGroup @classNames="gh-member-labels">
<label for="label-input">Labels</label>
<GhMemberLabelInput
@onChange={{action "setLabels"}}
@onChange={{this.setLabels}}
@allowEdit={{true}}
@onLabelEdit={{@onLabelEdit}}
@labels={{this.member.labels}}
@ -36,8 +51,15 @@
<GhFormGroup @errors={{this.member.errors}} @hasValidated={{this.member.hasValidated}} @property="note" @classNames="mb0 gh-member-note">
<label for="member-note">Note <span class="midgrey-d1 fw4">(not visible to member)</span></label>
<GhTextarea @id="member-note" @name="note" @class="gh-member-details-textarea" @tabindex="3"
@value={{this.scratchMember.note}} @focus-out={{action "setProperty" "note" this.scratchMember.note}} data-test-input="member-note" />
<GhTextarea
@id="member-note"
@name="note"
@class="gh-member-details-textarea"
@tabindex="3"
@value={{this.scratchMember.note}}
@focus-out={{fn this.setProperty "note" this.scratchMember.note}}
data-test-input="member-note"
/>
<GhErrorMessage @errors={{this.member.errors}} @property="note" />
<p> Maximum: <b>500</b> characters. Youve used
{{gh-count-down-characters this.scratchMember.note 500}}</p>
@ -52,8 +74,13 @@
</div>
<div class="for-switch">
<label class="switch" for="subscribed-checkbox">
<Input @checked={{this.member.subscribed}} @type="checkbox" id="subscribed-checkbox"
name="subscribed" data-test-checkbox="member-subscribed" />
<Input
@checked={{this.member.subscribed}}
@type="checkbox"
id="subscribed-checkbox"
name="subscribed"
data-test-checkbox="member-subscribed"
/>
<span class="input-toggle-component"></span>
</label>
</div>
@ -70,57 +97,55 @@
<div class="gh-main-section-content grey">
<div class="gh-cp-memberproduct-noproduct">
{{#unless this.isCreatingComplimentary}}
<div class="gh-members-no-data gh-members-no-subs">
<span class="lightgrey">{{svg-jar "no-data-subscription"}}</span>
<h4>No subscriptions</h4>
</div>
<div class="gh-members-no-data gh-members-no-subs">
<span class="lightgrey">{{svg-jar "no-data-subscription"}}</span>
<h4>No subscriptions</h4>
</div>
{{/unless}}
{{#unless this.member.isNew}}
{{#if this.isAddComplimentaryAllowed}}
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
{{#if (feature "multipleProducts")}}
{{!-- {{if has multiple products!}} --}}
<button type="button" class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct" {{action (toggle "showMemberProductModal" this)}}>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{!-- {{/if}} --}}
{{else}}
<button type="button" class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct" {{action "addCompedSubscription"}}>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
{{/if}}
{{#if this.isAddComplimentaryAllowed}}
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
<button
type="button"
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct"
{{on "click" (toggle-action "showMemberProductModal" this)}}
data-test-button="add-complimentary"
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
{{/unless}}
{{/if}}
</div>
</div>
{{/unless}}
{{#each this.products as |product|}}
<div class="gh-main-section-content grey gh-member-product-container">
<div class="gh-main-section-content grey gh-member-product-container" data-test-product={{product.id}}>
<div class="gh-main-content-card gh-cp-memberproduct {{if (gt product.subscriptions.length 1) "multiple-subs" ""}}">
<h3 class="gh-memberproduct-name">
<h3 class="gh-memberproduct-name" data-test-text="product-name">
{{product.name}}
{{#if (gt product.subscriptions.length 1)}}
<span class="gh-memberproduct-subcount">{{product.subscriptions.length}} subscriptions</span>
<span class="gh-memberproduct-subcount">{{product.subscriptions.length}} subscriptions</span>
{{/if}}
</h3>
{{#each product.subscriptions as |sub|}}
<div class="gh-memberproduct-subscription">
{{#each product.subscriptions as |sub index|}}
<div class="gh-memberproduct-subscription" data-test-subscription={{index}}>
<div>
<div>
<span class="gh-cp-memberproduct-pricelabel">{{sub.price.nickname}}</span>
&ndash;
{{#if sub.cancel_at_period_end}}
{{#if (eq sub.status "canceled")}}
<span class="gh-cp-memberproduct-renewal">Ended {{sub.validUntil}}</span>
<span class="gh-badge archived" data-test-text="member-subscription-status">Cancelled</span>
{{else if sub.cancel_at_period_end}}
<span class="gh-cp-memberproduct-renewal">Has access until {{sub.validUntil}}</span>
<span class="gh-badge archived">Cancelled</span>
<span class="gh-badge archived" data-test-text="member-subscription-status">Cancelled</span>
{{else}}
<span class="gh-cp-memberproduct-renewal">Renews {{sub.validUntil}}</span>
<span class="gh-badge active">Active</span>
<span class="gh-badge active" data-test-text="member-subscription-status">Active</span>
{{/if}}
</div>
{{#if sub.cancellationReason}}
@ -151,23 +176,36 @@
</div>
<div class="period">{{if (eq sub.price.interval "year") "yearly" "monthly"}}</div>
</div>
{{#if sub.isComplimentary}}
<span class="action-menu">
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
<GhDropdownButton
@dropdownName="subscription-menu-complimentary"
@classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only"
@title="Actions"
data-test-button="subscription-actions"
>
<span>
{{svg-jar "dotdotdot"}}
<span class="hidden">Subscription menu</span>
</span>
</GhDropdownButton>
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
<GhDropdown
@name="subscription-menu-complimentary"
@tagName="ul"
@classNames="product-actions-menu dropdown-menu dropdown-align-right"
>
<li>
<button type="button" {{action "removeComplimentary" product.id}}>
<button
type="button"
{{on "click" (fn this.removeComplimentary (or product.id product.product_id))}}
data-test-button="remove-complimentary"
>
<span class="red">Remove complimentary subscription</span>
</button>
</li>
</GhDropdown>
</span>
{{else}}
<span class="action-menu">
<GhDropdownButton @dropdownName="subscription-menu-{{sub.id}}" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
@ -189,14 +227,16 @@
</a>
</li>
<li>
{{#if sub.cancel_at_period_end}}
<button type="button" {{action "continueSubscription" sub.id}}>
<span>Continue subscription</span>
</button>
{{else}}
<button type="button" {{action "cancelSubscription" sub.id}}>
<span class="red">Cancel subscription</span>
</button>
{{#if (not-eq sub.status "canceled")}}
{{#if sub.cancel_at_period_end}}
<button type="button" {{on "click" (fn this.continueSubscription sub.id)}}>
<span>Continue subscription</span>
</button>
{{else}}
<button type="button" {{on "click" (fn this.cancelSubscription sub.id)}}>
<span class="red">Cancel subscription</span>
</button>
{{/if}}
{{/if}}
</li>
</GhDropdown>
@ -206,74 +246,60 @@
</div>
{{/each}}
{{#if (and (feature "multipleProducts") (eq product.subscriptions.length 0))}}
<div class="gh-memberproduct-subscription">
<div>
{{#if (eq product.subscriptions.length 0)}}
<div class="gh-memberproduct-subscription">
<div>
<span class="gh-cp-memberproduct-pricelabel">Complimentary</span>
<span class="gh-badge active">Active</span>
</div>
<div class="gh-memberproduct-created">Created on</div>
</div>
<div class="flex items-center">
<div class="gh-product-card-price">
<div class="flex items-start">
<span class="currency-symbol">$</span>
<span class="amount">0</span>
<div>
<span class="gh-cp-memberproduct-pricelabel">Complimentary</span>
<span class="gh-badge active">Active</span>
</div>
<div class="period">yearly</div>
<div class="gh-memberproduct-created">Created on</div>
</div>
<div class="flex items-center">
<div class="gh-product-card-price">
<div class="flex items-start">
<span class="currency-symbol">$</span>
<span class="amount">0</span>
</div>
<div class="period">yearly</div>
</div>
<span class="action-menu">
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
<span>
{{svg-jar "dotdotdot"}}
<span class="hidden">Subscription menu</span>
</span>
</GhDropdownButton>
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
<li>
<button type="button" {{on "click" (fn this.removeComplimentary product.id)}}>
<span class="red">Remove complimentary subscription</span>
</button>
</li>
</GhDropdown>
</span>
</div>
<span class="action-menu">
<GhDropdownButton @dropdownName="subscription-menu-complimentary" @classNames="gh-btn gh-btn-outline gh-btn-icon gh-btn-subscription-action icon-only" @title="Actions">
<span>
{{svg-jar "dotdotdot"}}
<span class="hidden">Subscription menu</span>
</span>
</GhDropdownButton>
<GhDropdown @name="subscription-menu-complimentary" @tagName="ul" @classNames="product-actions-menu dropdown-menu dropdown-align-right">
<li>
<button type="button" {{action "removeComplimentary" product.id}}>
<span class="red">Remove complimentary subscription</span>
</button>
</li>
</GhDropdown>
</span>
</div>
</div>
{{/if}}
{{#if (not (feature "multipleProducts"))}}
{{#if this.isAddComplimentaryAllowed}}
<div class="gh-memberproduct-list-footer bt b--whitegrey pt2 {{if this.isCreatingComplimentary "min-height" ""}}">
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
<button type="button" class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct" {{action "addCompedSubscription"}}>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{/if}}
{{/if}}
</div>
</div>
{{/each}}
{{#if (feature "multipleProducts")}}
{{#if (and this.products this.isAddComplimentaryAllowed)}}
<div class="gh-memberproduct-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
<button
type="button"
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct"
{{action (toggle "showMemberProductModal" this)}}
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{/if}}
{{#if (and this.products this.isAddComplimentaryAllowed)}}
<div class="gh-memberproduct-list-footer {{if this.isCreatingComplimentary "min-height" ""}}">
{{#if this.isCreatingComplimentary}}
<GhLoadingSpinner />
{{else}}
<button
type="button"
class="gh-btn gh-btn-text green gh-btn-icon gh-btn-addproduct"
{{on "click" (toggle-action "showMemberProductModal" this)}}
data-test-button="add-complimentary"
>
<span>{{svg-jar "add"}} Add complimentary subscription</span>
</button>
{{/if}}
</div>
{{/if}}
{{/if}}

View File

@ -32,21 +32,37 @@ export default class extends Component {
return false;
}
let products = this.member.get('products');
if (products && products.length > 0) {
if (this.member.get('isNew')) {
return false;
}
if (this.feature.get('multipleProducts')) {
return !!this.productsList?.length;
if (this.member.get('products')?.length > 0) {
return false;
}
return true;
// complimentary subscriptions are assigned to products so it only
// makes sense to show the "add complimentary" buttons when there's a
// product to assign the complimentary subscription to
const hasAnActivePaidProduct = !!this.productsList?.length;
return hasAnActivePaidProduct;
}
get isCreatingComplimentary() {
return this.args.isSaveRunning;
}
get products() {
let products = this.member.get('products') || [];
let subscriptions = this.member.get('subscriptions') || [];
// Create the products from `subscriptions.price.product`
let products = subscriptions
.map(subscription => (subscription.tier || subscription.price.product))
.filter((value, index, self) => {
// Deduplicate by taking the first object by `id`
return typeof value.id !== 'undefined' && self.findIndex(element => (element.product_id || element.id) === (value.product_id || value.id)) === index;
});
let subscriptionData = subscriptions.filter((sub) => {
return !!sub.price;
}).map((sub) => {
@ -66,10 +82,7 @@ export default class extends Component {
for (let product of products) {
let productSubscriptions = subscriptionData.filter((subscription) => {
if (subscription.status === 'canceled') {
return false;
}
return subscription?.price?.product?.product_id === product.id;
return subscription?.price?.product?.product_id === (product.product_id || product.id);
});
product.subscriptions = productSubscriptions;
}
@ -99,10 +112,6 @@ export default class extends Component {
this.fetchProducts.perform();
}
get isCreatingComplimentary() {
return this.args.isSaveRunning;
}
@action
setProperty(property, value) {
this.args.setProperty(property, value);
@ -133,12 +142,6 @@ export default class extends Component {
this.continueSubscriptionTask.perform(subscriptionId);
}
@action
addCompedSubscription() {
this.args.setProperty('comped', true);
this.args.saveMember();
}
@task({drop: true})
*cancelSubscriptionTask(subscriptionId) {
let url = this.ghostPaths.url.api('members', this.member.get('id'), 'subscriptions', subscriptionId);
@ -157,7 +160,10 @@ export default class extends Component {
*removeComplimentaryTask(productId) {
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
let products = this.member.get('products') || [];
const updatedProducts = products.filter(product => product.id !== productId).map(product => ({id: product.id}));
const updatedProducts = products
.filter(product => product.id !== productId)
.map(product => ({id: product.id}));
let response = yield this.ajax.put(url, {
data: {

View File

@ -1,18 +1,18 @@
<header class="modal-header" data-test-modal="delete-user" {{did-insert this.setup}}>
<header class="modal-header" data-test-modal="member-product" {{did-insert this.setup}}>
<h1>Add subscription</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal" }}>
<button type="button" class="close" title="Close" {{on "click" this.close}}>
{{svg-jar "close"}}<span class="hidden">Close</span>
</a>
</button>
<form>
<div class="modal-body">
<p class="gh-member-addcomp-subhed">
<p class="gh-member-addcomp-subhed" data-test-text="select-tier-desc">
Select a tier for <strong>{{or this.member.name this.member.email}}</strong>'s
complimentary subscription.
</p>
{{#if this.activeSubscriptions.length}}
<p class="gh-member-addcomp-warning">
<p class="gh-member-addcomp-warning" data-test-text="sub-cancel-warning">
Adding a complimentary subscription cancels all existing subscriptions of this member.
</p>
{{/if}}
@ -23,10 +23,14 @@
{{else}}
<div class="form-rich-radio">
{{#each this.products as |product|}}
<div class="gh-radio {{if (eq this.selectedProduct product.id) "active"}}" {{on "click" (fn this.setProduct product.id)}}>
<div
class="gh-radio {{if (eq this.selectedProduct product.id) "active"}}"
{{on "click" (fn this.setProduct product.id)}}
data-test-tier-option={{product.id}}
>
<div class="gh-radio-content">
<div class="gh-radio-label">
<div class="description">
<div class="description" data-test-text="tier-desc">
<h4>{{product.name}}</h4>
<p>{{product.description}}</p>
</div>
@ -43,9 +47,14 @@
<div class="modal-footer">
<button
class="gh-btn" data-test-button="cancel-webhook" type="button" {{action "closeModal" }}
class="gh-btn"
type="button"
{{on "click" this.close}}
{{!-- disable mouseDown so it does not trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown" }}>
{{!-- template-lint-disable no-down-event-binding --}}
{{on "mousedown" (optional this.noop)}}
data-test-button="cancel-webhook"
>
<span>Cancel</span>
</button>

View File

@ -69,7 +69,8 @@ export default class ModalMemberProduct extends ModalComponent {
@task({drop: true})
*addProduct() {
let url = this.ghostPaths.url.api(`members/${this.member.get('id')}`);
const url = `${this.ghostPaths.url.api(`members/${this.member.get('id')}`)}?include=products`;
// Cancel existing active subscriptions for member
for (let i = 0; i < this.activeSubscriptions.length; i++) {
const subscription = this.activeSubscriptions[i];
@ -80,7 +81,8 @@ export default class ModalMemberProduct extends ModalComponent {
}
});
}
let response = yield this.ajax.put(url, {
const response = yield this.ajax.put(url, {
data: {
members: [{
id: this.member.get('id'),

View File

@ -577,8 +577,8 @@ p.gh-members-list-email {
}
@media (max-width: 620px),
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
.gh-members-help-card .thumbnail {
max-width: unset;
margin-top: 2rem;
@ -610,8 +610,8 @@ p.gh-members-list-email {
}
@media (max-width: 620px),
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
.gh-members-help-content {
flex-direction: column;
}
@ -640,12 +640,12 @@ p.gh-members-list-email {
}
@media (max-width: 620px),
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
(min-width: 800px) and (max-width: 960px),
(min-width: 1080px) and (max-width: 1440px) {
.gh-members-help-content .text {
margin: 2rem 0 0;
}
.gh-members-help-content .text p {
margin-bottom: 2.8em;
}
@ -909,7 +909,7 @@ textarea.gh-member-details-textarea {
float: none;
width: 100%;
}
.gh-member-details {
position: relative;
top: unset;
@ -2052,6 +2052,10 @@ p.gh-members-import-errordetail:first-of-type {
grid-row-gap: 24px;
}
.gh-member-settings .gh-member-product-container + .gh-member-product-container {
padding-top: 0 !important;
}
.gh-cp-memberproduct {
margin-bottom: 0 !important;
}
@ -2105,8 +2109,8 @@ p.gh-members-import-errordetail:first-of-type {
.gh-memberproduct-list-footer {
position:relative;
margin-top: 12px;
margin-bottom: -8px;
margin-top: 8px;
padding-bottom: 24px;
}
.gh-memberproduct-list-footer.min-height {
@ -2211,10 +2215,6 @@ p.gh-members-import-errordetail:first-of-type {
left: auto;
}
.gh-cp-memberproduct.multiple-subs .product-actions-menu {
top: calc(100% + 6px);
}
.gh-memberproduct-subscription .action-menu .gh-btn-subscription-action:not(:hover) {
border: 1px solid var(--whitegrey);
background: var(--main-bg-color) !important;
@ -2263,4 +2263,4 @@ p.gh-members-import-errordetail:first-of-type {
.gh-members-filter-builder {
width: calc(100% - 100px);
}
}
}

View File

@ -117,7 +117,7 @@ module.exports = function (defaults) {
includePolyfill: false
},
'ember-composable-helpers': {
only: ['optional', 'toggle']
only: ['optional', 'toggle', 'toggle-action']
},
'ember-promise-modals': {
excludeCSS: true

View File

@ -96,24 +96,17 @@ export default function mockMembers(server) {
serializedMember[underscore(key)] = member.attrs[key];
});
// similar deal for associated label models
serializedMember.labels = [];
member.labels.models.forEach((label) => {
const serializedLabel = {};
Object.keys(label.attrs).forEach((key) => {
serializedLabel[underscore(key)] = label.attrs[key];
});
serializedMember.labels.push(serializedLabel);
});
// similar deal for associated models
['labels', 'products', 'subscriptions'].forEach((association) => {
serializedMember[association] = [];
// similar deal for associated product models
serializedMember.products = [];
member.products.models.forEach((product) => {
const serializedProduct = {};
Object.keys(product.attrs).forEach((key) => {
serializedProduct[underscore(key)] = product.attrs[key];
member[association].models.forEach((associatedModel) => {
const serializedAssociation = {};
Object.keys(associatedModel.attrs).forEach((key) => {
serializedAssociation[underscore(key)] = associatedModel.attrs[key];
});
serializedMember[association].push(serializedAssociation);
});
serializedMember.products.push(serializedProduct);
});
return nqlFilter.queryJSON(serializedMember);
@ -184,7 +177,75 @@ export default function mockMembers(server) {
});
});
server.put('/members/:id/');
server.put('/members/:id/', function ({members, products, subscriptions}, {params}) {
const attrs = this.normalizedRequestAttrs();
const member = members.find(params.id);
// API accepts `products: [{id: 'x'}]` which isn't handled natively by mirage
if (attrs.products.length > 0) {
attrs.products.forEach((p) => {
const product = products.find(p.id);
if (!member.products.includes(product)) {
// TODO: serialize products through _active_ subscriptions
member.products.add(product);
subscriptions.create({
member,
product,
comped: true,
plan: {
id: '',
nickname: 'Complimentary',
interval: 'year',
currency: 'USD',
amount: 0
},
status: 'active',
startDate: moment().toISOString(),
defaultPaymentCardLast4: '****',
cancelAtPeriodEnd: false,
cancellationReason: null,
currentPeriodEnd: moment().add(1, 'year').toISOString(),
price: {
id: '',
price_id: '',
nickname: 'Complimentary',
amount: 0,
interval: 'year',
type: 'recurring',
currency: 'USD',
product: {
id: '',
product_id: product.id
}
},
offer: null
});
member.save();
}
});
}
const productIds = (attrs.products || []).map(p => p.id);
member.products.models.forEach((product) => {
if (!productIds.includes(product.id)) {
member.subscriptions.models.filter(sub => sub.product.id === product.id).forEach((sub) => {
member.subscriptions.remove(sub);
});
member.products.remove(product);
}
});
// these are read-only properties so make sure we don't overwrite data
delete attrs.products;
delete attrs.subscriptions;
return member.update(attrs);
});
server.del('/members/:id/');

View File

@ -0,0 +1,4 @@
import {Factory} from 'miragejs';
export default Factory.extend({
});

View File

@ -3,5 +3,6 @@ import {Model, hasMany} from 'miragejs';
export default Model.extend({
labels: hasMany(),
emailRecipients: hasMany(),
products: hasMany()
products: hasMany(),
subscriptions: hasMany()
});

View File

@ -0,0 +1,6 @@
import {Model, belongsTo} from 'miragejs';
export default Model.extend({
member: belongsTo(),
product: belongsTo()
});

View File

@ -9,7 +9,25 @@ export default BaseSerializer.extend({
// embedded records that are included by default in the API
includes.add('labels');
includes.add('subscriptions');
return Array.from(includes);
},
serialize() {
const serialized = BaseSerializer.prototype.serialize.call(this, ...arguments);
// comped subscriptions are returned with a blank ID
// (we use `.comped` internally because mirage resources require an ID)
(serialized.members || [serialized.member]).forEach((member) => {
member.subscriptions.forEach((sub) => {
if (sub.comped) {
sub.id = '';
}
delete sub.comped;
});
});
return serialized;
}
});

View File

@ -0,0 +1,27 @@
import BaseSerializer from './application';
import {underscore} from '@ember/string';
export default BaseSerializer.extend({
embed: true,
include(/*request*/) {
let includes = [];
includes.push('product');
return includes;
},
keyForEmbeddedRelationship(relationshipName) {
if (relationshipName === 'product') {
return 'tier';
}
return underscore(relationshipName);
}
// NOTE: serialize() is not called for embedded records, serialization happens
// on the primary resource, in this case `member`
// TODO: extract subscription serialization and call it here too if we start
// to treat subscriptions as their own non-embedded resource
});

View File

@ -0,0 +1,308 @@
import {authenticateSession} from 'ember-simple-auth/test-support';
import {click, currentURL, find, findAll} from '@ember/test-helpers';
import {enableLabsFlag} from '../../helpers/labs-flag';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {visit} from '../../helpers/visit';
describe('Acceptance: Member details', function () {
let hooks = setupApplicationTest();
setupMirage(hooks);
let clock;
let product;
beforeEach(async function () {
this.server.loadFixtures('configs');
this.server.loadFixtures('settings');
enableLabsFlag(this.server, 'membersLastSeenFilter');
enableLabsFlag(this.server, 'membersTimeFilters');
enableLabsFlag(this.server, 'multipleProducts');
// test with stripe connected and email turned on
// TODO: add these settings to default fixtures
this.server.db.settings.find({key: 'stripe_connect_account_id'})
? this.server.db.settings.update({key: 'stripe_connect_account_id'}, {value: 'stripe_account_id'})
: this.server.create('setting', {key: 'stripe_connect_account_id', value: 'stripe_account_id', group: 'members'});
// needed for membersUtils.isStripeEnabled
this.server.db.settings.find({key: 'stripe_connect_secret_key'})
? this.server.db.settings.update({key: 'stripe_connect_secret_key'}, {value: 'stripe_secret_key'})
: this.server.create('setting', {key: 'stripe_connect_secret_key', value: 'stripe_secret_key', group: 'members'});
this.server.db.settings.find({key: 'stripe_connect_publishable_key'})
? this.server.db.settings.update({key: 'stripe_connect_publishable_key'}, {value: 'stripe_secret_key'})
: this.server.create('setting', {key: 'stripe_connect_publishable_key', value: 'stripe_secret_key', group: 'members'});
this.server.db.settings.find({key: 'editor_default_email_recipients'})
? this.server.db.settings.update({key: 'editor_default_email_recipients'}, {value: 'visibility'})
: this.server.create('setting', {key: 'editor_default_email_recipients', value: 'visibility', group: 'editor'});
// add a default product that complimentary plans can be assigned to
product = this.server.create('product', {
id: '6213b3f6cb39ebdb03ebd810',
name: 'Ghost Subscription',
slug: 'ghost-subscription',
created_at: '2022-02-21T16:47:02.000Z',
updated_at: '2022-03-03T15:37:02.000Z',
description: null,
monthly_price_id: '6220df272fee0571b5dd0a0a',
yearly_price_id: '6220df272fee0571b5dd0a0b',
type: 'paid',
active: true,
welcome_page_url: '/'
});
let role = this.server.create('role', {name: 'Owner'});
this.server.create('user', {roles: [role]});
return await authenticateSession();
});
afterEach(function () {
clock?.restore();
});
it('has a known base-state', async function () {
const member = this.server.create('member', {
id: 1,
subscriptions: [
this.server.create('subscription', {
id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu',
customer: {
id: 'cus_LFmBWoSkB84lnr',
name: 'test',
email: 'test@ghost.org'
},
plan: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
nickname: 'Monthly',
amount: 500,
interval: 'month',
currency: 'USD'
},
status: 'canceled',
start_date: '2022-03-03T15:31:27.000Z',
default_payment_card_last4: '4242',
cancel_at_period_end: false,
cancellation_reason: null,
current_period_end: '2022-04-03T15:31:27.000Z',
price: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
price_id: '6220df272fee0571b5dd0a0a',
nickname: 'Monthly',
amount: 500,
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: product.id
}
},
offer: null
}),
this.server.create('subscription', {
id: 'sub_1KZGi6EGb07FFvyNDjZq98g8',
product,
customer: {
id: 'cus_LFmGicpX4BkQKH',
name: '123',
email: 'test@ghost.org'
},
plan: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
nickname: 'Monthly',
amount: 500,
interval: 'month',
currency: 'USD'
},
status: 'active',
start_date: '2022-03-03T15:36:58.000Z',
default_payment_card_last4: '4242',
cancel_at_period_end: false,
cancellation_reason: null,
current_period_end: '2022-04-03T15:36:58.000Z',
price: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
price_id: '6220df272fee0571b5dd0a0a',
nickname: 'Monthly',
amount: 500,
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: product.id
}
},
offer: null
})
],
products: [
product
]
});
await visit(`/members/${member.id}`);
expect(currentURL()).to.equal(`/members/${member.id}`);
expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions')
.to.equal(2);
});
it('displays correctly one canceled subscription', async function () {
const member = this.server.create('member', {
id: 1,
subscriptions: [
this.server.create('subscription', {
id: 'sub_1KZGcmEGb07FFvyN9jwrwbKu',
product,
customer: {
id: 'cus_LFmBWoSkB84lnr',
name: 'test',
email: 'test@ghost.org'
},
plan: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
nickname: 'Monthly',
amount: 500,
interval: 'month',
currency: 'USD'
},
status: 'canceled',
start_date: '2022-03-03T15:31:27.000Z',
default_payment_card_last4: '4242',
cancel_at_period_end: false,
cancellation_reason: null,
current_period_end: '2022-04-03T15:31:27.000Z',
price: {
id: 'price_1KZGc6EGb07FFvyNkK3umKiX',
price_id: '6220df272fee0571b5dd0a0a',
nickname: 'Monthly',
amount: 500,
interval: 'month',
type: 'recurring',
currency: 'USD',
product: {
id: 'prod_LFmAAmCnnbzrvL',
name: 'Ghost Subscription',
product_id: '6213b3f6cb39ebdb03ebd810'
}
},
offer: null
})
],
products: []
});
await visit(`/members/${member.id}`);
expect(currentURL()).to.equal(`/members/${member.id}`);
expect(findAll('[data-test-subscription]').length, 'displays all member subscriptions')
.to.equal(1);
});
it('can add and remove complimentary subscription', async function () {
const member = this.server.create('member', {name: 'Comp Member Test'});
await visit(`/members/${member.id}`);
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons')
.to.equal(1);
await click('[data-test-button="add-complimentary"]');
expect(find('[data-test-modal="member-product"]'), 'select product modal').to.exist;
expect(find('[data-test-text="select-tier-desc"]')).to.contain.text('Comp Member Test');
expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.exist;
expect(find('[data-test-tier-option="6213b3f6cb39ebdb03ebd810"]')).to.have.class('active');
await click('[data-test-button="save-comp-product"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped')
.to.equal(1);
await click('[data-test-product="6213b3f6cb39ebdb03ebd810"] [data-test-button="subscription-actions"]');
await click('[data-test-product="6213b3f6cb39ebdb03ebd810"] [data-test-button="remove-complimentary"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after remove comped')
.to.equal(0);
});
it('can add complimentary subscription when member has canceled subscriptions', async function () {
const member = this.server.create('member', {
name: 'Comped for canceled sub test',
subscriptions: [
this.server.create('subscription', {
// product, // _Not_ included as `tier` when subscription is canceled
status: 'canceled',
price: {
id: 'price_1',
product: {
id: 'prod_1',
product_id: product.id
}
}
})
]
});
await visit(`/members/${member.id}`);
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons')
.to.equal(1);
await click('[data-test-button="add-complimentary"]');
await click('[data-test-button="save-comp-product"]');
expect(findAll('[data-test-subscription]').length, '# of subscription blocks - after add comped')
.to.equal(2);
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add complimentary buttons - after add comped')
.to.equal(0);
});
it('handles multiple products', async function () {
const product2 = this.server.create('product', {
name: 'Second product',
slug: 'second-product',
created_at: '2022-02-21T16:47:02.000Z',
updated_at: '2022-03-03T15:37:02.000Z',
description: null,
monthly_price_id: '6220df272fee0571b5dd0a0a',
yearly_price_id: '6220df272fee0571b5dd0a0b',
type: 'paid',
active: true,
welcome_page_url: '/'
});
const member = this.server.create('member', {name: 'Multiple product test'});
this.server.create('subscription', {member, product, status: 'canceled', price: {id: '1', product: {product_id: product.id}}});
this.server.create('subscription', {member, product, status: 'canceled', price: {id: '1', product: {product_id: product.id}}});
this.server.create('subscription', {member, product: product2, status: 'canceled', price: {id: '1', product: {product_id: product2.id}}});
await visit(`/members/${member.id}`);
// all products and subscriptions are shown
expect(findAll('[data-test-product]').length, '# of product blocks').to.equal(2);
const p1 = `[data-test-product="${product.id}"]`;
const p2 = `[data-test-product="${product2.id}"]`;
expect(find(`${p1} [data-test-text="product-name"]`)).to.contain.text('Ghost Subscription');
expect(findAll(`${p1} [data-test-subscription]`).length, '# of product 1 subscription blocks').to.equal(2);
expect(find(`${p2} [data-test-text="product-name"]`)).to.contain.text('Second product');
expect(findAll(`${p2} [data-test-subscription]`).length, '# of product 2 subscription blocks').to.equal(1);
// can add complimentary
expect(findAll('[data-test-button="add-complimentary"]').length, '# of add-complimentary buttons').to.equal(1);
await click('[data-test-button="add-complimentary"]');
await click(`[data-test-tier-option="${product2.id}"]`);
await click('[data-test-button="save-comp-product"]');
expect(findAll(`${p2} [data-test-subscription]`).length, '# of product 2 subscription blocks after comp added').to.equal(2);
});
});

View File

@ -265,8 +265,8 @@ describe('Acceptance: Members filtering', function () {
it('can filter by billing period', async function () {
// add some members to filter
this.server.createList('member', 3, {subscriptions: [{plan_interval: 'month'}]});
this.server.createList('member', 4, {subscriptions: [{plan_interval: 'year'}]});
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, planInterval: 'month'}));
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, planInterval: 'year'}));
await visit('/members');
@ -310,8 +310,8 @@ describe('Acceptance: Members filtering', function () {
it('can filter by stripe subscription status', async function () {
// add some members to filter
this.server.createList('member', 3, {subscriptions: [{status: 'active'}]});
this.server.createList('member', 4, {subscriptions: [{status: 'trialing'}]});
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'}));
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
await visit('/members');
@ -764,9 +764,9 @@ describe('Acceptance: Members filtering', function () {
});
// add some members to filter
this.server.createList('member', 3, {subscriptions: [{start_date: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
this.server.createList('member', 4, {subscriptions: [{start_date: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
this.server.createList('member', 2, {subscriptions: []});
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, startDate: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
this.server.createList('member', 2);
await visit('/members');
@ -1085,9 +1085,9 @@ describe('Acceptance: Members filtering', function () {
});
// add some members to filter
this.server.createList('member', 3, {subscriptions: [{current_period_end: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
this.server.createList('member', 4, {subscriptions: [{current_period_end: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}]});
this.server.createList('member', 2, {subscriptions: []});
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
this.server.createList('member', 4).forEach(member => this.server.create('subscription', {member, currentPeriodEnd: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}));
this.server.createList('member', 2);
await visit('/members');
@ -1160,9 +1160,9 @@ describe('Acceptance: Members filtering', function () {
it('can handle multiple filters', async function () {
// add some members to filter
this.server.createList('member', 1, {subscriptions: [{status: 'active'}]});
this.server.createList('member', 2, {subscriptions: [{status: 'trialing'}]});
this.server.createList('member', 3, {emailOpenRate: 50, subscriptions: [{status: 'trialing'}]});
this.server.createList('member', 1).forEach(member => this.server.create('subscription', {member, status: 'active'}));
this.server.createList('member', 2).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
this.server.createList('member', 3, {emailOpenRate: 50}).forEach(member => this.server.create('subscription', {member, status: 'trialing'}));
this.server.createList('member', 4, {emailOpenRate: 100});
await visit('/members');
@ -1216,7 +1216,7 @@ describe('Acceptance: Members filtering', function () {
});
it('has a no-match state', async function () {
this.server.createList('member', 5, {subscriptions: [{status: 'active'}]});
this.server.createList('member', 5).forEach(member => this.server.create('subscription', {member, status: 'active'}));
await visit('/members');
@ -1259,7 +1259,7 @@ describe('Acceptance: Members filtering', function () {
// meaning you could have an "is-greater" operator applied to an
// "is/is-not" filter type
this.server.createList('member', 3, {subscriptions: [{status: 'active'}]});
this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'}));
this.server.createList('member', 4, {emailCount: 10});
await visit('/members');
@ -1414,7 +1414,7 @@ describe('Acceptance: Members filtering', function () {
});
it('can search + filter', async function () {
this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [{status: 'active'}]});
this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [this.server.create('subscription', {status: 'active'})]});
await visit('/members');