diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml
index fcaffdf4bd5..97401fca189 100644
--- a/.github/workflows/migration-review.yml
+++ b/.github/workflows/migration-review.yml
@@ -11,7 +11,7 @@ jobs:
if: github.repository_owner == 'TryGhost'
name: Create checklist comment
steps:
- - uses: peter-evans/create-or-update-comment@6fcd282399b3c9ad0bd9bd8025b8fb2c18b085dd
+ - uses: peter-evans/create-or-update-comment@73054821735f5750dfe2ed26313889fde739e425
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
diff --git a/ghost/adapter-manager/package.json b/ghost/adapter-manager/package.json
index 04d9616a7cc..6f6b83f4cdc 100644
--- a/ghost/adapter-manager/package.json
+++ b/ghost/adapter-manager/package.json
@@ -17,7 +17,7 @@
],
"devDependencies": {
"c8": "7.12.0",
- "mocha": "10.0.0",
+ "mocha": "10.1.0",
"should": "13.2.3",
"sinon": "14.0.1"
},
diff --git a/ghost/admin/app/components/gh-explore-iframe.js b/ghost/admin/app/components/gh-explore-iframe.js
index 3a50c68e3eb..446e2c63d84 100644
--- a/ghost/admin/app/components/gh-explore-iframe.js
+++ b/ghost/admin/app/components/gh-explore-iframe.js
@@ -45,6 +45,6 @@ export default class GhExploreIframe extends Component {
}
_handleSiteDataUpdate(data) {
- this.explore.siteData = data.siteData;
+ this.explore.siteData = data?.siteData ?? {};
}
}
diff --git a/ghost/admin/app/components/gh-resource-select.hbs b/ghost/admin/app/components/gh-resource-select.hbs
index 01417f6418e..ae6524352a4 100644
--- a/ghost/admin/app/components/gh-resource-select.hbs
+++ b/ghost/admin/app/components/gh-resource-select.hbs
@@ -1,14 +1,19 @@
-
- {{resource.name}}
-
+ {{resource.title}}
+
diff --git a/ghost/admin/app/components/gh-resource-select.js b/ghost/admin/app/components/gh-resource-select.js
index cbeb446f449..9bc2f08f59c 100644
--- a/ghost/admin/app/components/gh-resource-select.js
+++ b/ghost/admin/app/components/gh-resource-select.js
@@ -1,5 +1,10 @@
import Component from '@glimmer/component';
-import {action} from '@ember/object';
+import {A} from '@ember/array';
+import {action, get} from '@ember/object';
+import {
+ defaultMatcher,
+ filterOptions
+} from 'ember-power-select/utils/group-utils';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
@@ -13,6 +18,45 @@ export default class GhResourceSelect extends Component {
return this.args.renderInPlace === undefined ? false : this.args.renderInPlace;
}
+ get searchField() {
+ return this.args.searchField === undefined ? 'name' : this.args.searchField;
+ }
+
+ @action
+ searchAndSuggest(term, select) {
+ return this.searchAndSuggestTask.perform(term, select);
+ }
+
+ @task
+ *searchAndSuggestTask(term) {
+ let newOptions = this.flatOptions.toArray();
+
+ if (term.length === 0) {
+ return newOptions;
+ }
+
+ // todo: we can do actual filtering on posts here (allow searching when we have lots and lots of posts)
+ yield undefined;
+
+ newOptions = this._filter(A(newOptions), term);
+
+ return newOptions;
+ }
+
+ get matcher() {
+ return this.args.matcher || defaultMatcher;
+ }
+
+ _filter(options, searchText) {
+ let matcher;
+ if (this.searchField) {
+ matcher = (option, text) => this.matcher(get(option, this.searchField), text);
+ } else {
+ matcher = (option, text) => this.matcher(option, text);
+ }
+ return filterOptions(options || [], searchText, matcher);
+ }
+
constructor() {
super(...arguments);
this.fetchOptionsTask.perform();
@@ -38,9 +82,12 @@ export default class GhResourceSelect extends Component {
return options;
}
- get selectedOptions() {
- const resources = this.args.resources || [];
- return this.flatOptions.filter(option => resources.find(resource => resource.id === option.id));
+ get selectedOption() {
+ if (this.args.resource.title) {
+ return this.args.resource;
+ }
+ const resource = this.args.resource ?? {};
+ return this.flatOptions.find(option => resource.id === option.id);
}
@action
@@ -55,16 +102,20 @@ export default class GhResourceSelect extends Component {
return 'Select a page/post';
}
+ get searchPlaceholderText() {
+ if (this.args.type === 'email') {
+ return 'Search emails';
+ }
+ return 'Search posts/pages';
+ }
+
@task
*fetchOptionsTask() {
const options = yield [];
if (this.args.type === 'email') {
const posts = yield this.store.query('post', {filter: '(status:published,status:sent)+newsletter_id:-null', limit: 'all'});
- options.push({
- groupName: 'Emails',
- options: posts.map(mapResource)
- });
+ options.push(...posts.map(mapResource));
this._options = options;
return;
}
@@ -74,8 +125,8 @@ export default class GhResourceSelect extends Component {
function mapResource(resource) {
return {
- name: resource.title,
- id: resource.id
+ id: resource.id,
+ title: resource.title
};
}
diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js
index 4aa23059f99..05a6660a201 100644
--- a/ghost/admin/app/components/koenig-lexical-editor.js
+++ b/ghost/admin/app/components/koenig-lexical-editor.js
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/ember';
import Component from '@glimmer/component';
import React, {Suspense} from 'react';
+import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
@@ -108,11 +109,44 @@ export default class KoenigLexicalEditor extends Component {
}
ReactComponent = () => {
+ const [uploadProgressPercentage] = React.useState(0); // not in use right now, but will need to decide how to handle the percentage state and pass to the Image Cards
+
+ // const uploadProgress = (event) => {
+ // const percentComplete = (event.loaded / event.total) * 100;
+ // setUploadProgressPercentage(percentComplete);
+ // };
+
+ async function imageUploader(files) {
+ function uploadToUrl(formData, url) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', url);
+ // xhr.upload.onprogress = (event) => {
+ // uploadProgress(event);
+ // };
+ xhr.onload = () => resolve(xhr.response);
+ xhr.onerror = () => reject(xhr.statusText);
+ xhr.send(formData);
+ });
+ }
+ const formData = new FormData();
+ formData.append('file', files[0]);
+ const url = `${ghostPaths().apiRoot}/images/upload/`;
+ const response = await uploadToUrl(formData, url);
+ const dataset = JSON.parse(response);
+ const imageUrl = dataset?.images?.[0].url;
+ return {
+ src: imageUrl
+ };
+ }
return (
Loading editor...}>
-
+
diff --git a/ghost/admin/app/components/member-attribution/source-attribution-table.js b/ghost/admin/app/components/member-attribution/source-attribution-table.js
index 71bf61d2db3..1045d828828 100644
--- a/ghost/admin/app/components/member-attribution/source-attribution-table.js
+++ b/ghost/admin/app/components/member-attribution/source-attribution-table.js
@@ -36,7 +36,7 @@ export default class SourceAttributionTable extends Component {
return null;
}
- if (this.sortedSources.length === 5 && !this.unavailableSource.length) {
+ if (this.sortedSources.length === 5 && !this.unavailableSource?.length) {
return null;
}
diff --git a/ghost/admin/app/components/member/activity-feed.hbs b/ghost/admin/app/components/member/activity-feed.hbs
index 8c22e50d032..95d7d54747a 100644
--- a/ghost/admin/app/components/member/activity-feed.hbs
+++ b/ghost/admin/app/components/member/activity-feed.hbs
@@ -6,7 +6,7 @@
{{else}}
- {{#let (members-event-fetcher filter=(members-event-filter member=@member.id) pageSize=5) as |eventsFetcher|}}
+ {{#let (members-event-fetcher filter=(members-event-filter member=@member.id excludedEvents=this.excludedEventTypes) pageSize=5) as |eventsFetcher|}}
diff --git a/ghost/admin/app/components/member/activity-feed.js b/ghost/admin/app/components/member/activity-feed.js
index d9f5654a5a2..ec9e34e1483 100644
--- a/ghost/admin/app/components/member/activity-feed.js
+++ b/ghost/admin/app/components/member/activity-feed.js
@@ -3,6 +3,7 @@ import {action} from '@ember/object';
export default class ActivityFeed extends Component {
linkScrollerTimeout = null; // needs to be global so can be cleared when needed across functions
+ excludedEventTypes = ['email_sent_event'];
@action
enterLinkURL(event) {
@@ -29,4 +30,4 @@ export default class ActivityFeed extends Component {
child.style.transform = 'translateX(0)';
parent.classList.remove('scroller');
}
-}
\ No newline at end of file
+}
diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js
index 3ef84165539..920e53dc8e3 100644
--- a/ghost/admin/app/components/members-activity/event-type-filter.js
+++ b/ghost/admin/app/components/members-activity/event-type-filter.js
@@ -22,6 +22,12 @@ export default class MembersActivityEventTypeFilter extends Component {
if (this.settings.commentsEnabled !== 'off') {
extended.push({event: 'comment_event', icon: 'event-comment', name: 'Comments'});
}
+ if (this.settings.emailTrackClicks) {
+ extended.push({event: 'click_event', icon: 'event-click', name: 'Clicked link'});
+ }
+ if (this.feature.audienceFeedback) {
+ extended.push({event: 'feedback_event', icon: 'event-more-like-this', name: 'Feedback'});
+ }
if (this.args.hiddenEvents?.length) {
return extended.filter(t => !this.args.hiddenEvents.includes(t.event));
diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs
index 8db00eff3be..fcf85471aa8 100644
--- a/ghost/admin/app/components/members/filter-value.hbs
+++ b/ghost/admin/app/components/members/filter-value.hbs
@@ -47,47 +47,15 @@
-{{else if (eq @filter.type 'subscribed')}}
+{{else if (eq @filter.valueType 'options')}}
- {{svg-jar "arrow-down-small"}}
-
-
-{{else if (eq @filter.type 'last_seen_at')}}
-
-
-{{else if (eq @filter.type 'created_at')}}
-
-
-{{else if (eq @filter.type 'status')}}
-
-
-
-{{else if (eq @filter.type 'subscriptions.plan_interval')}}
-
-
- {{svg-jar "arrow-down-small"}}
-
-
-{{else if (eq @filter.type 'subscriptions.status')}}
-
-
- {{svg-jar "arrow-down-small"}}
-
-
-{{else if (eq @filter.type 'subscriptions.start_date')}}
+{{else if (or (eq @filter.type 'last_seen_at') (eq @filter.type 'created_at'))}}
-{{else if (eq @filter.type 'subscriptions.current_period_end')}}
+{{else if (eq @filter.valueType 'date')}}
{
- return {
- id: resource
- };
- });
+ const resource = this.args.filter?.resource || undefined;
+ const resourceId = this.args.filter?.value || undefined;
+ return resource ?? {
+ id: resourceId
+ };
}
@action
- setResourceFilterValue(filter, resources) {
- this.args.setFilterValue(filter, resources.map(resource => resource.id));
+ setResourceFilterValue(filter, resource) {
+ this.args.setResourceValue(filter, resource);
}
}
diff --git a/ghost/admin/app/components/members/filter.hbs b/ghost/admin/app/components/members/filter.hbs
index fc935acefdd..f4bd1ae9c02 100644
--- a/ghost/admin/app/components/members/filter.hbs
+++ b/ghost/admin/app/components/members/filter.hbs
@@ -50,6 +50,7 @@
@index={{index}}
@filter={{filter}}
@setFilterValue={{this.setFilterValue}}
+ @setResourceValue={{this.setResourceValue}}
@onLabelEdit={{@onLabelEdit}}
/>
Apply filters
diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js
index 3223d3b4ff5..22536e9ade4 100644
--- a/ghost/admin/app/components/members/filter.js
+++ b/ghost/admin/app/components/members/filter.js
@@ -7,41 +7,9 @@ import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
-const FILTER_PROPERTIES = [
- // Basic
- {label: 'Name', name: 'name', group: 'Basic', valueType: 'text'},
- {label: 'Email', name: 'email', group: 'Basic', valueType: 'text'},
- // {label: 'Location', name: 'location', group: 'Basic'},
- {label: 'Label', name: 'label', group: 'Basic', valueType: 'array'},
- {label: 'Newsletter subscription', name: 'subscribed', group: 'Basic'},
- {label: 'Last seen', name: 'last_seen_at', group: 'Basic', valueType: 'date'},
- {label: 'Created', name: 'created_at', group: 'Basic', valueType: 'date'},
- {label: 'Signed up on post/page', name: 'signup', group: 'Basic', valueType: 'array', feature: 'memberAttribution'},
-
- // Member subscription
- {label: 'Membership tier', name: 'tier', group: 'Subscription', valueType: 'array'},
- {label: 'Member status', name: 'status', group: 'Subscription'},
- {label: 'Billing period', name: 'subscriptions.plan_interval', group: 'Subscription'},
- {label: 'Stripe subscription status', name: 'subscriptions.status', group: 'Subscription'},
- {label: 'Paid start date', name: 'subscriptions.start_date', valueType: 'date', group: 'Subscription'},
- {label: 'Next billing date', name: 'subscriptions.current_period_end', valueType: 'date', group: 'Subscription'},
- {label: 'Subscription started on post/page', name: 'conversion', group: 'Subscription', valueType: 'array', feature: 'memberAttribution'},
-
- // Emails
- {label: 'Emails sent (all time)', name: 'email_count', group: 'Email'},
- {label: 'Emails opened (all time)', name: 'email_opened_count', group: 'Email'},
- {label: 'Open rate (all time)', name: 'email_open_rate', group: 'Email'},
- {label: 'Received email', name: 'emails.post_id', group: 'Email', valueType: 'array'},
- {label: 'Opened email', name: 'opened_emails.post_id', group: 'Email', valueType: 'array'},
- {label: 'Clicked email', name: 'clicked_links.post_id', group: 'Email', valueType: 'array'}
-
- // {label: 'Emails sent (30 days)', name: 'x', group: 'Email'},
- // {label: 'Emails opened (30 days)', name: 'x', group: 'Email'},
- // {label: 'Open rate (30 days)', name: 'x', group: 'Email'},
- // {label: 'Emails sent (60 days)', name: 'x', group: 'Email'},
- // {label: 'Emails opened (60 days)', name: 'x', group: 'Email'},
- // {label: 'Open rate (60 days)', name: 'x', group: 'Email'},
-];
+function escapeNqlString(value) {
+ return '\'' + value.replace(/'/g, '\\\'') + '\'';
+}
const MATCH_RELATION_OPTIONS = [
{label: 'is', name: 'is'},
@@ -56,13 +24,14 @@ const CONTAINS_RELATION_OPTIONS = [
{label: 'ends with', name: 'ends-with'}
];
+const FEEDBACK_RELATION_OPTIONS = [
+ {label: 'More like this', name: 1},
+ {label: 'Less like this', name: 0}
+];
+
const DATE_RELATION_OPTIONS = [
{label: 'before', name: 'is-less'},
{label: 'on or before', name: 'is-or-less'},
- // TODO: these cause problems because they require multiple NQL statements, eg:
- // created_at:>='2022-03-02 00:00'+created_at:<'2022-03-03 00:00'
- // {label: 'on', name: 'is'},
- // {label: 'not on', name: 'is-not'},
{label: 'after', name: 'is-greater'},
{label: 'on or after', name: 'is-or-greater'}
];
@@ -73,76 +42,369 @@ const NUMBER_RELATION_OPTIONS = [
{label: 'is less than', name: 'is-less'}
];
-const FILTER_RELATIONS_OPTIONS = {
- name: CONTAINS_RELATION_OPTIONS,
- email: CONTAINS_RELATION_OPTIONS,
- label: MATCH_RELATION_OPTIONS,
- tier: MATCH_RELATION_OPTIONS,
- subscribed: MATCH_RELATION_OPTIONS,
- last_seen_at: DATE_RELATION_OPTIONS,
- created_at: DATE_RELATION_OPTIONS,
- status: MATCH_RELATION_OPTIONS,
- 'subscriptions.plan_interval': MATCH_RELATION_OPTIONS,
- 'subscriptions.status': MATCH_RELATION_OPTIONS,
- 'subscriptions.start_date': DATE_RELATION_OPTIONS,
- 'subscriptions.current_period_end': DATE_RELATION_OPTIONS,
- email_count: NUMBER_RELATION_OPTIONS,
- email_opened_count: NUMBER_RELATION_OPTIONS,
- email_open_rate: NUMBER_RELATION_OPTIONS,
- signup: MATCH_RELATION_OPTIONS,
- conversion: MATCH_RELATION_OPTIONS,
- 'emails.post_id': MATCH_RELATION_OPTIONS,
- 'clicked_links.post_id': MATCH_RELATION_OPTIONS,
- 'opened_emails.post_id': MATCH_RELATION_OPTIONS
+// Ideally we should move all the filter definitions to separate files
+const NAME_FILTER = {
+ label: 'Name',
+ name: 'name',
+ group: 'Basic',
+ valueType: 'string',
+ relationOptions: CONTAINS_RELATION_OPTIONS
};
-const FILTER_VALUE_OPTIONS = {
- 'subscriptions.plan_interval': [
- {label: 'Monthly', name: 'month'},
- {label: 'Yearly', name: 'year'}
- ],
- status: [
- {label: 'Paid', name: 'paid'},
- {label: 'Free', name: 'free'},
- {label: 'Complimentary', name: 'comped'}
- ],
- subscribed: [
- {label: 'Subscribed', name: 'true'},
- {label: 'Unsubscribed', name: 'false'}
- ],
- 'subscriptions.status': [
- {label: 'Active', name: 'active'},
- {label: 'Trialing', name: 'trialing'},
- {label: 'Canceled', name: 'canceled'},
- {label: 'Unpaid', name: 'unpaid'},
- {label: 'Past Due', name: 'past_due'},
- {label: 'Incomplete', name: 'incomplete'},
- {label: 'Incomplete - Expired', name: 'incomplete_expired'}
- ]
-};
+const FILTER_PROPERTIES = [
+ // Basic
+ NAME_FILTER,
+ {
+ label: 'Email',
+ name: 'email',
+ group: 'Basic',
+ valueType: 'string',
+ relationOptions: CONTAINS_RELATION_OPTIONS
+ },
+ {
+ label: 'Label',
+ name: 'label',
+ group: 'Basic',
+ valueType: 'array',
+ columnLabel: 'Label',
+ relationOptions: MATCH_RELATION_OPTIONS
+ },
+ {
+ label: 'Newsletter subscription',
+ name: 'subscribed',
+ group: 'Basic',
+ columnLabel: 'Subscribed',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ valueType: 'options',
+ options: [
+ {label: 'Subscribed', name: 'true'},
+ {label: 'Unsubscribed', name: 'false'}
+ ]
+ },
+ {
+ label: 'Last seen',
+ name: 'last_seen_at',
+ group: 'Basic',
+ valueType: 'date',
+ columnLabel: 'Last seen at',
+ relationOptions: DATE_RELATION_OPTIONS
+ },
+ {
+ label: 'Created',
+ name: 'created_at',
+ group: 'Basic',
+ valueType: 'date',
+ relationOptions: DATE_RELATION_OPTIONS
+ },
+ {
+ label: 'Signed up on post/page',
+ name: 'signup',
+ group: 'Basic',
+ valueType: 'string',
+ resource: 'post',
+ feature: 'memberAttribution',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ getColumns: filter => [
+ {
+ label: 'Signed up on',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ }
+ ]
+ },
+
+ // Member subscription
+ {
+ label: 'Membership tier',
+ name: 'tier',
+ group: 'Subscription',
+ valueType: 'array',
+ columnLabel: 'Membership tier',
+ relationOptions: MATCH_RELATION_OPTIONS
+ },
+ {
+ label: 'Member status',
+ name: 'status',
+ group: 'Subscription',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ valueType: 'options',
+ options: [
+ {label: 'Paid', name: 'paid'},
+ {label: 'Free', name: 'free'},
+ {label: 'Complimentary', name: 'comped'}
+ ]
+ },
+ {
+ label: 'Billing period',
+ name: 'subscriptions.plan_interval',
+ group: 'Subscription',
+ columnLabel: 'Billing period',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ valueType: 'options',
+ options: [
+ {label: 'Monthly', name: 'month'},
+ {label: 'Yearly', name: 'year'}
+ ]
+ },
+ {
+ label: 'Stripe subscription status',
+ name: 'subscriptions.status',
+ group: 'Subscription',
+ columnLabel: 'Subscription Status',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ valueType: 'options',
+ options: [
+ {label: 'Active', name: 'active'},
+ {label: 'Trialing', name: 'trialing'},
+ {label: 'Canceled', name: 'canceled'},
+ {label: 'Unpaid', name: 'unpaid'},
+ {label: 'Past Due', name: 'past_due'},
+ {label: 'Incomplete', name: 'incomplete'},
+ {label: 'Incomplete - Expired', name: 'incomplete_expired'}
+ ]
+ },
+ {
+ label: 'Paid start date',
+ name: 'subscriptions.start_date',
+ valueType: 'date',
+ group: 'Subscription',
+ columnLabel: 'Paid start date',
+ relationOptions: DATE_RELATION_OPTIONS
+ },
+ {
+ label: 'Next billing date',
+ name: 'subscriptions.current_period_end',
+ valueType: 'date',
+ group: 'Subscription',
+ columnLabel: 'Next billing date',
+ relationOptions: DATE_RELATION_OPTIONS
+ },
+ {
+ label: 'Subscription started on post/page',
+ name: 'conversion',
+ group: 'Subscription',
+ valueType: 'string',
+ resource: 'post',
+ feature: 'memberAttribution',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ getColumns: filter => [
+ {
+ label: 'Subscription started on',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ }
+ ]
+ },
+
+ // Emails
+ {
+ label: 'Emails sent (all time)',
+ name: 'email_count',
+ group: 'Email',
+ columnLabel: 'Email count',
+ valueType: 'number',
+ relationOptions: NUMBER_RELATION_OPTIONS
+ },
+ {
+ label: 'Emails opened (all time)',
+ name: 'email_opened_count',
+ group: 'Email',
+ columnLabel: 'Email opened count',
+ valueType: 'number',
+ relationOptions: NUMBER_RELATION_OPTIONS
+ },
+ {
+ label: 'Open rate (all time)',
+ name: 'email_open_rate',
+ group: 'Email',
+ valueType: 'number',
+ relationOptions: NUMBER_RELATION_OPTIONS
+ },
+ {
+ label: 'Received email',
+ name: 'emails.post_id',
+ group: 'Email',
+ valueType: 'string',
+ resource: 'email',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ getColumns: filter => [
+ {
+ label: 'Received email',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ }
+ ]
+ },
+ {
+ label: 'Opened email',
+ name: 'opened_emails.post_id',
+ group: 'Email',
+ valueType: 'string',
+ resource: 'email',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ getColumns: filter => [
+ {
+ label: 'Opened email',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ }
+ ]
+ },
+ {
+ label: 'Clicked email',
+ name: 'clicked_links.post_id',
+ group: 'Email',
+ valueType: 'string',
+ resource: 'email',
+ relationOptions: MATCH_RELATION_OPTIONS,
+ getColumns: filter => [
+ {
+ label: 'Clicked email',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ }
+ ]
+ },
+ {
+ label: 'Responded with feedback',
+ name: 'newsletter_feedback',
+ group: 'Email',
+ valueType: 'string',
+ resource: 'email',
+ relationOptions: FEEDBACK_RELATION_OPTIONS,
+ feature: 'audienceFeedback',
+ buildNqlFilter: (filter) => {
+ // Added brackets to make sure we can parse as a single AND filter
+ return `(feedback.post_id:${filter.value}+feedback.score:${filter.relation})`;
+ },
+ parseNqlFilter: (filter) => {
+ if (!filter.$and) {
+ return;
+ }
+ if (filter.$and.length === 2) {
+ if (filter.$and[0]['feedback.post_id'] && filter.$and[1]['feedback.score'] !== undefined) {
+ return {
+ relation: parseInt(filter.$and[1]['feedback.score']),
+ value: filter.$and[0]['feedback.post_id']
+ };
+ }
+ }
+ },
+ getColumns: filter => [
+ {
+ label: 'Email',
+ getValue: () => {
+ return {
+ class: '',
+ text: filter.resource?.title ?? ''
+ };
+ }
+ },
+ {
+ label: 'Feedback',
+ getValue: () => {
+ return {
+ class: 'gh-members-list-feedback',
+ text: filter.relation === 1 ? 'More like this' : 'Less like this',
+ icon: filter.relation === 1 ? 'event-more-like-this' : 'event-less-like-this'
+ };
+ }
+ }
+ ]
+ }
+];
class Filter {
- @tracked type;
@tracked value;
@tracked relation;
- @tracked relationOptions;
+ @tracked properties;
+ @tracked resource;
constructor(options) {
- this.type = options.type;
- this.relation = options.relation;
- this.relationOptions = options.relationOptions;
- this.timezone = options.timezone || 'Etc/UTC';
+ this.properties = options.properties;
+ this.timezone = options.timezone ?? 'Etc/UTC';
- const filterProperty = FILTER_PROPERTIES.find(prop => this.type === prop.name);
+ let defaultRelation = options.properties.relationOptions[0].name;
+ if (options.properties.valueType === 'date') {
+ defaultRelation = 'is-or-less';
+ }
+
+ let defaultValue = '';
+ if (options.properties.valueType === 'options' && options.properties.options.length > 0) {
+ defaultValue = options.properties.options[0].name;
+ } else if (options.properties.valueType === 'array') {
+ defaultValue = [];
+ } else if (options.properties.valueType === 'date') {
+ defaultValue = moment(moment.tz(this.timezone).format('YYYY-MM-DD')).toDate();
+ }
+
+ this.relation = options.relation ?? defaultRelation;
// date string values are passed in as UTC strings
// we need to convert them to the site timezone and make a local date that matches
// so the date string output in the filter inputs is correct
- const value = filterProperty.valueType === 'date' && typeof options.value === 'string'
- ? moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate()
- : options.value;
+ this.value = options.value ?? defaultValue;
+
+ if (this.properties.valueType === 'date' && typeof this.value === 'string') {
+ // Convert string to Date
+ this.value = moment(moment.tz(moment.utc(options.value), this.timezone).format('YYYY-MM-DD')).toDate();
+ }
+
+ // Validate value
+ if (options.properties.valueType === 'options') {
+ if (!options.properties.options.find(option => option.name === this.value)) {
+ this.value = defaultValue;
+ }
+ }
+
+ this.resource = null;
+ }
- this.value = value;
+ get valueType() {
+ return this.properties.valueType;
+ }
+
+ get type() {
+ return this.properties.name;
+ }
+
+ get isResourceFilter() {
+ return typeof this.properties.resource === 'string' && this.properties.valueType === 'string';
+ }
+
+ get relationOptions() {
+ return this.properties.relationOptions;
+ }
+
+ get options() {
+ return this.properties.options ?? [];
+ }
+
+ get isValid() {
+ if (Array.isArray(this.value)) {
+ return !!this.value.length;
+ }
+ return !!this.value;
}
}
@@ -154,16 +416,10 @@ export default class MembersFilter extends Component {
@tracked filters = new TrackedArray([
new Filter({
- type: 'name',
- relation: 'is',
- value: '',
- relationOptions: FILTER_RELATIONS_OPTIONS.name
+ properties: NAME_FILTER
})
]);
- availableFilterRelationsOptions = FILTER_RELATIONS_OPTIONS;
- availableFilterValueOptions = FILTER_VALUE_OPTIONS;
-
get availableFilterProperties() {
let availableFilters = FILTER_PROPERTIES;
const hasMultipleTiers = this.store.peekAll('tier').length > 1;
@@ -212,21 +468,24 @@ export default class MembersFilter extends Component {
@action
parseDefaultFilters() {
if (this.args.defaultFilterParam) {
- this.parseNqlFilter(this.args.defaultFilterParam);
+ // check if it is different before parsing
+ const validFilters = this.validFilters;
+ const currentFilter = this.generateNqlFilter(validFilters);
- // Pass the parsed filter to the parent component
- // this doesn't start a new network request, and doesn't update filterParam again
- this.applyParsedFilter();
+ if (currentFilter !== this.args.defaultFilterParam) {
+ this.parseNqlFilterString(this.args.defaultFilterParam);
+
+ // Pass the parsed filter to the parent component
+ // this doesn't start a new network request, and doesn't update filterParam again
+ this.applyParsedFilter();
+ }
}
}
@action
addFilter() {
this.filters.push(new Filter({
- type: 'name',
- relation: 'is',
- value: '',
- relationOptions: FILTER_RELATIONS_OPTIONS.name
+ properties: NAME_FILTER
}));
this.applySoftFilter();
}
@@ -241,14 +500,19 @@ export default class MembersFilter extends Component {
let query = '';
filters.forEach((filter) => {
- const relationStr = this.getFilterRelationOperator(filter.relation);
const filterProperty = FILTER_PROPERTIES.find(prop => prop.name === filter.type);
+ if (filterProperty.buildNqlFilter) {
+ query += `${filterProperty.buildNqlFilter(filter)}+`;
+ return;
+ }
+ const relationStr = this.getFilterRelationOperator(filter.relation);
+
if (filterProperty.valueType === 'array' && filter.value?.length) {
const filterValue = '[' + filter.value.join(',') + ']';
query += `${filter.type}:${relationStr}${filterValue}+`;
- } else if (filterProperty.valueType === 'text') {
- const filterValue = '\'' + filter.value.replace(/'/g, '\\\'') + '\'';
+ } else if (filterProperty.valueType === 'string') {
+ let filterValue = escapeNqlString(filter.value);
query += `${filter.type}:${relationStr}${filterValue}+`;
} else if (filterProperty.valueType === 'date') {
let filterValue;
@@ -278,6 +542,74 @@ export default class MembersFilter extends Component {
return query.slice(0, -1);
}
+ parseNqlFilterString(filterParam) {
+ let filters;
+
+ try {
+ filters = nql.parse(filterParam);
+ } catch (e) {
+ // Invalid nql filter
+ this.filters = new TrackedArray([]);
+ return;
+ }
+ this.filters = new TrackedArray(this.parseNqlFilter(filters));
+ }
+
+ parseNqlFilter(filter) {
+ const parsedFilters = [];
+
+ // Check custom parsing
+ for (const filterProperties of FILTER_PROPERTIES) {
+ if (filterProperties.parseNqlFilter) {
+ // This filter has a custom parsing function
+ const parsedFilter = filterProperties.parseNqlFilter(filter);
+ if (parsedFilter) {
+ parsedFilters.push(new Filter({
+ properties: filterProperties,
+ timezone: this.settings.timezone,
+ ...parsedFilter
+ }));
+ return parsedFilters;
+ }
+ }
+ }
+
+ if (filter.$and) {
+ parsedFilters.push(...this.parseNqlFilters(filter.$and));
+ } else if (filter.yg) {
+ // Single filter grouped in backets
+ parsedFilters.push(...this.parseNqlFilter(filter.yg));
+ } else {
+ const filterKeys = Object.keys(filter);
+ const validKeys = FILTER_PROPERTIES.map(prop => prop.name);
+
+ for (const key of filterKeys) {
+ if (validKeys.includes(key)) {
+ const parsedFilter = this.parseNqlFilterKey({
+ [key]: filter[key]
+ });
+ if (parsedFilter) {
+ parsedFilters.push(parsedFilter);
+ }
+ }
+ }
+ }
+ return parsedFilters;
+ }
+
+ /**
+ * Parses an array of filters
+ */
+ parseNqlFilters(filters) {
+ const parsedFilters = [];
+
+ for (const filter of filters) {
+ parsedFilters.push(...this.parseNqlFilter(filter));
+ }
+
+ return parsedFilters;
+ }
+
parseNqlFilterKey(nqlFilter) {
const keys = Object.keys(nqlFilter);
const key = keys[0];
@@ -359,53 +691,16 @@ export default class MembersFilter extends Component {
}
if (relation && value) {
- return new Filter({
- type: key,
- relation,
- relationOptions: FILTER_RELATIONS_OPTIONS[key],
- value,
- timezone: this.settings.timezone
- });
- }
- }
-
- parseNqlFilter(filterParam) {
- const validKeys = Object.keys(FILTER_RELATIONS_OPTIONS);
- let filters;
-
- try {
- filters = nql.parse(filterParam);
- } catch (e) {
- // Invalid nql filter
- this.filters = new TrackedArray([]);
- return;
- }
-
- const filterKeys = Object.keys(filters);
-
- let filterData = [];
-
- if (filterKeys?.length === 1 && validKeys.includes(filterKeys[0])) {
- const filterObj = this.parseNqlFilterKey(filters);
- if (filterObj) {
- filterData = [filterObj];
+ const properties = FILTER_PROPERTIES.find(prop => key === prop.name);
+ if (FILTER_PROPERTIES.find(prop => key === prop.name)) {
+ return new Filter({
+ properties,
+ relation,
+ value,
+ timezone: this.settings.timezone
+ });
}
- } else if (filters?.$and) {
- const andFilters = filters?.$and || [];
- filterData = andFilters.filter((nqlFilter) => {
- const _filterKeys = Object.keys(nqlFilter);
- if (_filterKeys?.length === 1 && validKeys.includes(_filterKeys[0])) {
- return true;
- }
- return false;
- }).map((nqlFilter) => {
- return this.parseNqlFilterKey(nqlFilter);
- }).filter((nqlFilter) => {
- return !!nqlFilter;
- });
}
-
- this.filters = new TrackedArray(filterData);
}
getFilterRelationOperator(relation) {
@@ -456,40 +751,21 @@ export default class MembersFilter extends Component {
const newProp = FILTER_PROPERTIES.find(prop => prop.name === newType);
- let defaultValue = this.availableFilterValueOptions[newType]
- ? this.availableFilterValueOptions[newType][0].name
- : '';
-
- if (newProp.valueType === 'array' && !defaultValue) {
- defaultValue = [];
- }
-
- if (newProp.valueType === 'date' && !defaultValue) {
- defaultValue = moment(moment.tz(this.settings.timezone).format('YYYY-MM-DD')).toDate();
- }
-
- let defaultRelation = this.availableFilterRelationsOptions[newType][0].name;
-
- if (newProp.valueType === 'date') {
- defaultRelation = 'is-or-less';
+ if (!newProp) {
+ // eslint-disable-next-line no-console
+ console.warn('Invalid Filter Type Selected', newType);
+ return;
}
const newFilter = new Filter({
- type: newType,
- relation: defaultRelation,
- relationOptions: this.availableFilterRelationsOptions[newType],
- value: defaultValue,
+ properties: newProp,
timezone: this.settings.timezone
});
const filterToSwap = this.filters.find(f => f === filter);
this.filters[this.filters.indexOf(filterToSwap)] = newFilter;
- if (newType !== 'label' && defaultValue) {
- this.applySoftFilter();
- }
-
- if (newType !== 'tier' && defaultValue) {
+ if (newFilter.isValid) {
this.applySoftFilter();
}
}
@@ -503,44 +779,48 @@ export default class MembersFilter extends Component {
@action
setFilterValue(filter, newValue) {
filter.value = newValue;
+ filter.resource = null;
this.applySoftFilter();
}
+ @action
+ setResourceValue(filter, resource) {
+ filter.value = resource.id;
+ filter.resource = resource;
+ this.applySoftFilter();
+ }
+
+ get validFilters() {
+ return this.filters.filter(filter => filter.isValid);
+ }
+
@action
applySoftFilter() {
- const validFilters = this.filters.filter((filter) => {
- if (Array.isArray(filter.value)) {
- return filter.value.length;
- }
- return filter.value;
- });
+ const validFilters = this.validFilters;
const query = this.generateNqlFilter(validFilters);
this.args.onApplySoftFilter(query, validFilters);
+ this.fetchFilterResourcesTask.perform();
}
@action
applyFilter() {
- const validFilters = this.filters.filter((filter) => {
- if (Array.isArray(filter.value)) {
- return filter.value.length;
- }
- return filter.value;
- });
-
+ const validFilters = this.validFilters;
const query = this.generateNqlFilter(validFilters);
this.args.onApplyFilter(query, validFilters);
+ this.fetchFilterResourcesTask.perform();
+ }
+
+ @action
+ applyFiltersPressed(dropdown) {
+ dropdown?.actions.close();
+ this.applyFilter();
}
@action
applyParsedFilter() {
- const validFilters = this.filters.filter((filter) => {
- if (Array.isArray(filter.value)) {
- return filter.value.length;
- }
- return filter.value;
- });
-
+ const validFilters = this.validFilters;
this.args.onApplyParsedFilter(validFilters);
+ this.fetchFilterResourcesTask.perform();
}
@action
@@ -548,10 +828,7 @@ export default class MembersFilter extends Component {
const filters = [];
filters.push(new Filter({
- type: 'name',
- relation: 'is',
- value: '',
- relationOptions: FILTER_RELATIONS_OPTIONS.name
+ properties: NAME_FILTER
}));
this.filters = new TrackedArray(filters);
@@ -563,4 +840,32 @@ export default class MembersFilter extends Component {
const response = yield this.store.query('tier', {filter: 'type:paid'});
this.tiersList = response;
}
+
+ @task({restartable: true})
+ *fetchFilterResourcesTask() {
+ const ids = [];
+ for (const filter of this.filters) {
+ if (filter.isResourceFilter) {
+ // for now we only support post filters
+ if (filter.value && !ids.includes(filter.value)) {
+ ids.push(filter.value);
+ }
+ }
+ }
+ if (ids.length > 0) {
+ const posts = yield this.store.query('post', {limit: 'all', filter: `id:[${ids.join(',')}]`});
+
+ for (const filter of this.filters) {
+ if (filter.isResourceFilter) {
+ // for now we only support post filters
+ if (filter.value) {
+ const post = posts.find(p => p.id === filter.value);
+ if (post) {
+ filter.resource = post;
+ }
+ }
+ }
+ }
+ }
+ }
}
diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs
index 8045c8d72d0..9fe5c609850 100644
--- a/ghost/admin/app/components/members/list-item-column.hbs
+++ b/ghost/admin/app/components/members/list-item-column.hbs
@@ -1,24 +1,15 @@
-{{#if (eq @filterColumn 'label')}}
-
+{{#if (eq this.columnName 'label')}}
+
{{this.labels}}
-{{else if (eq @filterColumn 'tier')}}
-
+{{else if (eq this.columnName 'tier')}}
+
{{this.tiers}}
-{{else if (eq @filterColumn 'status')}}
-
- {{#if (not (is-empty @member.status))}}
- {{capitalize @member.status}}
- {{else}}
- -
- {{/if}}
-
-
-{{else if (eq @filterColumn 'last_seen_at')}}
-
+{{else if (eq this.columnName 'last_seen_at')}}
+
{{#if (not (is-empty @member.lastSeenAtUTC))}}
{{moment-format (moment-site-tz @member.lastSeenAtUTC) "DD MMM YYYY"}}
{{moment-from-now @member.lastSeenAtUTC}}
@@ -27,8 +18,8 @@
{{/if}}
-{{else if (eq @filterColumn 'email_count')}}
-
+{{else if (eq this.columnName 'email_count')}}
+
{{#if (not (is-empty @member.emailCount))}}
{{@member.emailCount}}
{{else}}
@@ -36,8 +27,8 @@
{{/if}}
-{{else if (eq @filterColumn 'email_opened_count')}}
-
+{{else if (eq this.columnName 'email_opened_count')}}
+
{{#if (not (is-empty @member.emailOpenedCount))}}
{{@member.emailOpenedCount}}
{{else}}
@@ -45,8 +36,8 @@
{{/if}}
-{{else if (eq @filterColumn 'subscribed')}}
-
+{{else if (eq this.columnName 'subscribed')}}
+
{{#if (not (is-empty @member.subscribed))}}
{{if @member.subscribed "Yes" "No"}}
{{else}}
@@ -54,8 +45,8 @@
{{/if}}
-{{else if (eq @filterColumn 'subscriptions.status')}}
-
+{{else if (eq this.columnName 'subscriptions.status')}}
+
{{#if (not (is-empty this.mostRecentSubscription.status))}}
{{capitalize this.mostRecentSubscription.status}}
{{else}}
@@ -63,8 +54,8 @@
{{/if}}
-{{else if (eq @filterColumn 'subscriptions.plan_interval')}}
-
+{{else if (eq this.columnName 'subscriptions.plan_interval')}}
+
{{#if (not (is-empty this.mostRecentSubscription.price.interval))}}
{{capitalize this.mostRecentSubscription.price.interval}}
{{else}}
@@ -72,8 +63,8 @@
{{/if}}
-{{else if (eq @filterColumn 'subscriptions.start_date')}}
-
+{{else if (eq this.columnName 'subscriptions.start_date')}}
+
{{#if (not (is-empty this.mostRecentSubscription.start_date))}}
{{moment-format (moment-site-tz this.mostRecentSubscription.start_date) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.start_date}}
@@ -82,8 +73,8 @@
{{/if}}
-{{else if (eq @filterColumn 'subscriptions.current_period_end')}}
-
+{{else if (eq this.columnName 'subscriptions.current_period_end')}}
+
{{#if (not (is-empty this.mostRecentSubscription.current_period_end))}}
{{moment-format (moment-site-tz this.mostRecentSubscription.current_period_end) "DD MMM YYYY"}}
{{moment-from-now this.mostRecentSubscription.current_period_end}}
@@ -91,4 +82,17 @@
-
{{/if}}
+{{else}}
+
+ {{#if this.columnValue}}
+
+ {{#if this.columnValue.icon}}
+ {{svg-jar this.columnValue.icon}}
+ {{/if}}
+ {{this.columnValue.text}}
+
+ {{else}}
+ -
+ {{/if}}
+
{{/if}}
diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js
index 3f19c6410c8..3537ca55f29 100644
--- a/ghost/admin/app/components/members/list-item-column.js
+++ b/ghost/admin/app/components/members/list-item-column.js
@@ -20,4 +20,12 @@ export default class MembersListItemColumn extends Component {
get mostRecentSubscription() {
return mostRecentlyUpdated(get(this.args.member, 'subscriptions'));
}
+
+ get columnName() {
+ return this.args.filterColumn.name;
+ }
+
+ get columnValue() {
+ return this.args.filterColumn?.getValue ? this.args.filterColumn?.getValue(this.args.member) : null;
+ }
}
diff --git a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs
index 4efcf9d12bf..72a0df9f6eb 100644
--- a/ghost/admin/app/components/modals/newsletters/edit/settings.hbs
+++ b/ghost/admin/app/components/modals/newsletters/edit/settings.hbs
@@ -114,22 +114,10 @@
-
-
- {{/liquid-if}}
- {{/let}}
- {{#if (feature "audienceFeedback")}}
- {{#let (eq @openSection "audienceFeedback") as |isOpen|}}
-
- {{svg-jar "newsletter-analytics"}} Newsletter analytics
- {{svg-jar (if isOpen "arrow-up-stroke" "arrow-down-stroke")}}
-
- {{#liquid-if isOpen}}
-
-
+ {{#if (feature "audienceFeedback")}}
- Capture feedback on your content
+ Ask your readers for feedback
-
- {{/liquid-if}}
- {{/let}}
- {{/if}}
+ {{/if}}
+
+
+ {{/liquid-if}}
+ {{/let}}
+
diff --git a/ghost/admin/app/components/posts/analytics.hbs b/ghost/admin/app/components/posts/analytics.hbs
index 4c74febbdaf..e07d441674d 100644
--- a/ghost/admin/app/components/posts/analytics.hbs
+++ b/ghost/admin/app/components/posts/analytics.hbs
@@ -1,5 +1,4 @@
-