diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index 03d375413d8..195a33f6b0c 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -137,6 +137,39 @@ describe('useEmberDataSync', () => { expect(invalidateSpy).not.toHaveBeenCalled(); }); + queryTest('invalidates comment queries for mapped Ember comment events', async ({ queryClient, wrapper }) => { + const mock = createMockStateBridge(); + window.EmberBridge = { state: mock.stateBridge }; + + queryClient.setQueryData(['MembersResponseType', '/members'], { members: [] }); + queryClient.setQueryData(['CommentsResponseType', '/comments'], { comments: [] }); + queryClient.setQueryData(['PostsResponseType', '/posts'], { posts: [] }); + + renderHook(() => useEmberDataSync(), { wrapper }); + + await waitFor(() => { + expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function)); + }); + + act(() => { + mock.emit('emberDataChange', { + operation: 'update', + modelName: 'comment', + id: 'member-1', + data: null + }); + }); + + await waitFor(() => { + const queries = queryClient.getQueryCache().getAll(); + const commentQueries = queries.filter(q => q.queryKey[0] === 'CommentsResponseType'); + const nonCommentQueries = queries.filter(q => q.queryKey[0] !== 'CommentsResponseType'); + + expect(commentQueries.every(q => q.state.isInvalidated)).toBe(true); + expect(nonCommentQueries.every(q => !q.state.isInvalidated)).toBe(true); + }); + }); + queryTest('does not subscribe if unmounted before the bridge becomes available', async ({ wrapper }) => { vi.useFakeTimers(); const mock = createMockStateBridge(); @@ -383,4 +416,3 @@ describe('useEmberRouting', () => { }); }); }); - diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index 763f706002f..b154658968d 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -77,6 +77,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { 'user': 'UsersResponseType', 'post': 'PostsResponseType', 'member': 'MembersResponseType', + 'comment': 'CommentsResponseType', 'tag': 'TagsResponseType', 'label': 'LabelsResponseType', 'webhook': 'WebhooksResponseType' @@ -310,4 +311,3 @@ export function useForceUpgrade(): boolean { return true; } - diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 7ee53292ea3..d3b4e9400a3 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -7,15 +7,7 @@ export interface ExportedFile { content: string; } -export interface MembersListSurface { - goto(): Promise; - openActionsMenu(): Promise; - applyLabelFilter(labelName: string): Promise; - getVisibleMemberCount(): Promise; - exportMembers(): Promise; -} - -export class MembersListPage extends AdminPage implements MembersListSurface { +export class MembersListPage extends AdminPage { readonly memberRows: Locator; readonly searchInput: Locator; readonly actionsButton: Locator; diff --git a/e2e/helpers/pages/admin/members/members-page.ts b/e2e/helpers/pages/admin/members/members-page.ts index 3fbe40af0ed..0e725cf420a 100644 --- a/e2e/helpers/pages/admin/members/members-page.ts +++ b/e2e/helpers/pages/admin/members/members-page.ts @@ -1,151 +1,27 @@ import {AdminPage} from '@/admin-pages'; -import {BasePage} from '@/helpers/pages'; -import {Download, JSHandle, Locator, Page} from '@playwright/test'; -import {readFileSync} from 'fs'; -import type {ExportedFile, MembersListSurface} from './members-list-page'; +import {JSHandle, Locator, Page} from '@playwright/test'; -class FilterSection extends BasePage { - readonly actionsButton: Locator; - readonly applyFilterButton: Locator; - - readonly selectType: Locator; - readonly input: Locator; - - constructor(page: Page) { - super(page); - - this.actionsButton = page.getByTestId('members-filter-actions'); - this.applyFilterButton = page.getByTestId('members-apply-filter'); - this.selectType = page.getByTestId('members-filter'); - this.input = page.getByTestId('token-input-search'); - } - - async applyLabel(labelName: string): Promise { - await this.actionsButton.click(); - await this.selectType.selectOption('label'); - - await this.addLabelToLabelFilter(labelName); - - await this.applyFilterButton.click(); - } - - private async addLabelToLabelFilter(labelName: string) { - await this.input.fill(labelName); - await this.page.keyboard.press('Tab'); - } -} - -class SettingsSection extends BasePage { - readonly addLabelForSelectedMembersButton: Locator; - readonly removeLabelForSelectedMembersButton: Locator; - readonly selectLabel: Locator; - readonly confirmAddLabelButton: Locator; - readonly confirmRemoveLabelButton: Locator; - readonly closeModalButton: Locator; - - private readonly labelRemoved: Locator; - private readonly labelAdded: Locator; - - constructor(page: Page) { - super(page); - - this.addLabelForSelectedMembersButton = page.getByTestId('add-label-selected'); - this.removeLabelForSelectedMembersButton = page.getByTestId('remove-label-selected'); - - this.selectLabel = page.getByTestId('label-select'); - this.confirmAddLabelButton = page.getByTestId('confirm'); - this.confirmRemoveLabelButton = page.getByTestId('confirm'); - this.closeModalButton = page.getByTestId('close-modal'); - - this.labelAdded = page.getByTestId('add-label-complete'); - this.labelRemoved = page.getByTestId('remove-label-complete'); - } - - async addLabelToSelectedMembers(labelName: string): Promise { - await this.addLabelForSelectedMembersButton.click(); - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabelOption(labelName); - - await this.confirmAddLabelButton.click(); - await this.labelAdded.waitFor({state: 'visible'}); - } - - async removeLabelFromSelectedMembers(labelName: string): Promise { - await this.removeLabelForSelectedMembersButton.click(); - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabelOption(labelName); - - await this.confirmRemoveLabelButton.click(); - await this.labelRemoved.waitFor({state: 'visible'}); - } - - getSuccessMessage(): Locator { - return this.page.getByTestId('label-success-message'); - } - - private async selectLabelOption(labelName: string): Promise { - await this.selectLabel.waitFor({state: 'visible'}); - await this.selectLabel.click(); - - const dropdown = this.page.locator('.ember-power-select-dropdown').last(); - await dropdown.waitFor({state: 'visible'}); - - const searchInput = dropdown.locator('.ember-power-select-search input'); - await searchInput.waitFor({state: 'visible'}); - await searchInput.fill(labelName); - - const option = dropdown.getByRole('option', {name: labelName, exact: true}); - await option.waitFor({state: 'visible'}); - await option.click(); - } -} - -export class MembersPage extends AdminPage implements MembersListSurface { +export class MembersPage extends AdminPage { readonly newMemberButton: Locator; public readonly loadMoreButton: Locator; public readonly membersListScrollRoot: Locator; readonly memberListItems: Locator; - readonly emptyStateHeading: Locator; - - readonly membersActionsButton: Locator; - readonly exportMembersButton: Locator; - - readonly filterSection: FilterSection; - readonly settingsSection: SettingsSection; constructor(page: Page, {route = 'members'}: {route?: string} = {}) { super(page); this.pageUrl = `/ghost/#/${route}`; - this.membersActionsButton = page.getByTestId('members-actions'); this.newMemberButton = page.getByRole('link', {name: 'New member'}); - this.exportMembersButton = page.getByTestId('export-members'); this.loadMoreButton = page.getByRole('button', {name: 'Load more'}); this.membersListScrollRoot = page.getByTestId('members-list-scroll-root'); this.memberListItems = page.getByTestId('members-list-item'); - this.emptyStateHeading = page.getByRole('heading', {name: 'Start building your audience'}); - - this.filterSection = new FilterSection(page); - this.settingsSection = new SettingsSection(page); } async clickMemberByEmail(email: string): Promise { await this.memberListItems.filter({hasText: email}).click(); } - async openActionsMenu(): Promise { - await this.membersActionsButton.click(); - } - - async applyLabelFilter(labelName: string): Promise { - await this.filterSection.applyLabel(labelName); - } - - async getVisibleMemberCount(): Promise { - return await this.memberListItems.count(); - } - async getMaxRenderedIndex(): Promise { return await this.memberListItems.evaluateAll((rows) => { return rows.reduce((maxIndex, row) => { @@ -213,38 +89,7 @@ export class MembersPage extends AdminPage implements MembersListSurface { return maxRenderedIndex; } - getMemberListItemByIndex(index: number): Locator { - return this.page.locator(`[data-testid="members-list-item"][data-index="${index}"]`); - } - getMemberByName(name: string): Locator { return this.memberListItems.filter({hasText: name}); } - - getMemberEmail(memberName: string): Locator { - return this.memberListItems.filter({hasText: memberName}).getByRole('paragraph'); - } - - async getMemberCount(): Promise { - return await this.memberListItems.count(); - } - - async exportMembers(): Promise { - const download = await this.exportMembersData(); - const suggestedFilename = download.suggestedFilename(); - - const downloadPath = await download.path(); - const downloadContent = readFileSync(downloadPath as string, 'utf-8'); - - return { - suggestedFilename: suggestedFilename, - content: downloadContent - }; - } - - async exportMembersData(): Promise { - const downloadPromise = this.page.waitForEvent('download'); - await this.exportMembersButton.click(); - return await downloadPromise; - } } diff --git a/ghost/admin/app/components/gh-member-single-label-input.hbs b/ghost/admin/app/components/gh-member-single-label-input.hbs deleted file mode 100644 index fffbf012404..00000000000 --- a/ghost/admin/app/components/gh-member-single-label-input.hbs +++ /dev/null @@ -1,18 +0,0 @@ - - {{label.name}} - diff --git a/ghost/admin/app/components/gh-member-single-label-input.js b/ghost/admin/app/components/gh-member-single-label-input.js deleted file mode 100644 index 6b521678c13..00000000000 --- a/ghost/admin/app/components/gh-member-single-label-input.js +++ /dev/null @@ -1,98 +0,0 @@ -import Component from '@glimmer/component'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class GhMemberSingleLabelInput extends Component { - @service store; - @service labelsManager; - - @tracked _selectedLabel = null; - @tracked _searchedLabels = new TrackedArray(); - - _searchedLabelsQuery = null; - _searchedLabelsMeta = null; - - _powerSelectAPI = null; - - get availableLabels() { - return this.labelsManager.labels; - } - - get useServerSideSearch() { - return !this.labelsManager.hasLoadedAll; - } - - constructor(...args) { - super(...args); - this.loadInitialLabelsTask.perform(); - } - - @task - *loadInitialLabelsTask() { - if (!this.labelsManager.hasLoaded) { - yield this.labelsManager.loadMoreTask.perform(); - } - - const sorted = this.availableLabels; - if (this.args.label) { - const found = sorted.find(l => l.id === this.args.label); - if (found) { - this._selectedLabel = found; - } - } else { - this._selectedLabel = sorted[0]; - if (this._selectedLabel) { - this.args.onChange(this._selectedLabel.id); - } - } - } - - @action - registerPowerSelectAPI(api) { - this._powerSelectAPI = api; - } - - @task({drop: true}) - *loadMoreLabelsTask() { - const isSearch = !!this._powerSelectAPI?.searchText; - if (isSearch) { - if (!this.useServerSideSearch) { - return; - } - - if (this.searchLabelsTask.isRunning) { - return; - } - - if (!this._searchedLabelsMeta || (this._searchedLabelsMeta.pagination.pages <= this._searchedLabelsMeta.pagination.page)) { - return; - } - - const page = this._searchedLabelsMeta.pagination.page + 1; - const labels = yield this.labelsManager.searchLabelsTask.perform(this._searchedLabelsQuery, {page}); - this._searchedLabels.push(...labels.toArray()); - this._searchedLabelsMeta = labels.meta; - } else { - yield this.labelsManager.loadMoreTask.perform(); - } - } - - @task - *searchLabelsTask(term) { - this._searchedLabelsQuery = term; - const labels = yield this.labelsManager.searchLabelsTask.perform(term); - this._searchedLabelsMeta = labels.meta; - - this._searchedLabels = new TrackedArray(this.labelsManager.sortLabels(labels.toArray())); - return this._searchedLabels; - } - - @action - updateLabel(label) { - this._selectedLabel = label; - this.args.onChange(label?.id); - } -} diff --git a/ghost/admin/app/components/gh-members-import-mapping-input.hbs b/ghost/admin/app/components/gh-members-import-mapping-input.hbs deleted file mode 100644 index 2f064f44f5e..00000000000 --- a/ghost/admin/app/components/gh-members-import-mapping-input.hbs +++ /dev/null @@ -1,14 +0,0 @@ - - - {{svg-jar "arrow-down-small"}} - \ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-import-mapping-input.js b/ghost/admin/app/components/gh-members-import-mapping-input.js deleted file mode 100644 index 09a0d3f2f6e..00000000000 --- a/ghost/admin/app/components/gh-members-import-mapping-input.js +++ /dev/null @@ -1,39 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const FIELD_MAPPINGS = [ - {label: 'Email', value: 'email'}, - {label: 'Name', value: 'name'}, - {label: 'Note', value: 'note'}, - {label: 'Subscribed to emails', value: 'subscribed_to_emails'}, - {label: 'Stripe Customer ID', value: 'stripe_customer_id'}, - {label: 'Complimentary plan', value: 'complimentary_plan'}, - {label: 'Labels', value: 'labels'}, - {label: 'Created at', value: 'created_at'} -]; - -export default class extends Component { - @service feature; - @tracked availableFields = [ - ...FIELD_MAPPINGS, - ...( - this.feature.importMemberTier ? [{label: 'Tier', value: 'import_tier'}] : [] - ), - ...( - this.feature.giftSubscriptions ? [{label: 'Gift ID', value: 'gift_id'}] : [] - ) - ]; - - get mapTo() { - return this.args.mapTo; - } - - @action - updateMapping(newMapTo) { - if (this.args.updateMapping) { - this.args.updateMapping(this.args.mapFrom, newMapTo); - } - } -} diff --git a/ghost/admin/app/components/gh-members-import-table.hbs b/ghost/admin/app/components/gh-members-import-table.hbs deleted file mode 100644 index b2cecde89d1..00000000000 --- a/ghost/admin/app/components/gh-members-import-table.hbs +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - {{#each this.currentlyDisplayedData as |row|}} - - - - - - {{else}} - - - - {{/each}} - -
Field - - Import as
{{row.key}}{{row.value}}
No data found in the uploaded CSV.
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-import-table.js b/ghost/admin/app/components/gh-members-import-table.js deleted file mode 100644 index 2e09496bf86..00000000000 --- a/ghost/admin/app/components/gh-members-import-table.js +++ /dev/null @@ -1,121 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {run} from '@ember/runloop'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -class MembersFieldMapping { - @tracked _mapping = {}; - - constructor(mapping) { - if (mapping) { - for (const [key, value] of Object.entries(mapping)) { - this._mapping[value] = key; - } - } - } - - get(key) { - return this._mapping[key]; - } - - toJSON() { - return this._mapping; - } - - getKeyByValue(searchedValue) { - for (const [key, value] of Object.entries(this._mapping)) { - if (value === searchedValue) { - return key; - } - } - - return null; - } - - updateMapping(from, to) { - for (const key in this._mapping) { - if (this.get(key) === to) { - this._mapping[key] = null; - } - } - - this._mapping[from] = to; - - // trigger an update - // eslint-disable-next-line no-self-assign - this._mapping = this._mapping; - } -} - -export default class GhMembersImportTable extends Component { - @tracked dataPreviewIndex = 0; - - @service memberImportValidator; - - constructor(...args) { - super(...args); - const mapping = this.memberImportValidator.check(this.args.data); - this.data = this.args.data; - this.mapping = new MembersFieldMapping(mapping); - run.schedule('afterRender', () => this.args.setMapping(this.mapping)); - } - - get currentlyDisplayedData() { - let rows = []; - - if (this.data && this.data.length && this.mapping) { - let currentRecord = this.data[this.dataPreviewIndex]; - - for (const [key, value] of Object.entries(currentRecord)) { - rows.push({ - key: key, - value: value, - mapTo: this.mapping.get(key) - }); - } - } - - return rows; - } - - get hasNextRecord() { - return this.data && !!(this.data[this.dataPreviewIndex + 1]); - } - - get hasPrevRecord() { - return this.data && !!(this.data[this.dataPreviewIndex - 1]); - } - - get currentRecord() { - return this.dataPreviewIndex + 1; - } - - get allRecords() { - if (this.data) { - return this.data; - } else { - return 0; - } - } - - @action - updateMapping(mapFrom, mapTo) { - this.mapping.updateMapping(mapFrom, mapTo); - this.args.setMapping(this.mapping); - } - - @action - next() { - if (this.hasNextRecord) { - this.dataPreviewIndex += 1; - } - } - - @action - prev() { - if (this.hasPrevRecord) { - this.dataPreviewIndex -= 1; - } - } -} diff --git a/ghost/admin/app/components/gh-members-no-members.hbs b/ghost/admin/app/components/gh-members-no-members.hbs deleted file mode 100644 index 3a98b4d3a58..00000000000 --- a/ghost/admin/app/components/gh-members-no-members.hbs +++ /dev/null @@ -1,16 +0,0 @@ -
- {{svg-jar "members-placeholder" class="gh-members-placeholder"}} -

Start building your audience

- {{#if (not-eq this.settings.membersSignupAccess "none")}} -

Use memberships to allow your readers to sign up and subscribe to your content.

- -

Have members already? Add them manually or import from CSV

- {{else}} -

Memberships have been disabled. Adjust your Subscription Access settings to start adding members.

- - Membership settings - - {{/if}} -
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-members-no-members.js b/ghost/admin/app/components/gh-members-no-members.js deleted file mode 100644 index 42692f186ca..00000000000 --- a/ghost/admin/app/components/gh-members-no-members.js +++ /dev/null @@ -1,52 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default class GhMembersNoMembersComponent extends Component { - @service session; - @service store; - @service notifications; - @service settings; - @service membersCountCache; - - @action - addYourself() { - return this.addTask.perform(); - } - - @task({drop: true}) - *addTask() { - const user = yield this.session.user; - const defaultNewsletters = yield this.store.query('newsletter', {filter: 'status:active+subscribe_on_signup:true+visibility:members'}); - - const member = this.store.createRecord('member', { - email: user.get('email'), - name: user.get('name'), - newsletters: defaultNewsletters - }); - - try { - yield member.save(); - - if (this.args.afterCreate) { - this.args.afterCreate(); - } - - this.notifications.showNotification('Member added', - { - description: 'You\'ve added yourself as a member.' - } - ); - - // force update the member count; this otherwise only updates every minute - yield this.membersCountCache.count({}); - - return member; - } catch (error) { - if (error) { - this.notifications.showAPIError(error, {key: 'member.save'}); - } - } - } -} diff --git a/ghost/admin/app/components/members/filter-value.hbs b/ghost/admin/app/components/members/filter-value.hbs deleted file mode 100644 index e6b4358395e..00000000000 --- a/ghost/admin/app/components/members/filter-value.hbs +++ /dev/null @@ -1,144 +0,0 @@ -{{#if (eq @filter.type 'name')}} - - -{{else if (eq @filter.type 'email')}} - - -{{else if (eq @filter.type 'label')}} - - -{{else if (eq @filter.type 'tier_id')}} -
- -
- -{{else if (eq @filter.type 'offer_redemptions')}} -
- -
- -{{else if this.isResourceFilter }} -
- -
- -{{else if (eq @filter.valueType 'options')}} - - - {{svg-jar "arrow-down-small"}} - - -{{else if (eq @filter.type 'email_count')}} - - -{{else if (eq @filter.type 'email_opened_count')}} - - -{{else if (eq @filter.type 'email_open_rate')}} -
- % - -
-{{else if (or (eq @filter.type 'last_seen_at') (eq @filter.type 'created_at'))}} - - -{{else if (eq @filter.valueType 'date')}} - - -{{else}} - -{{/if}} diff --git a/ghost/admin/app/components/members/filter-value.js b/ghost/admin/app/components/members/filter-value.js deleted file mode 100644 index 77177e1ad50..00000000000 --- a/ghost/admin/app/components/members/filter-value.js +++ /dev/null @@ -1,100 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {tracked} from '@glimmer/tracking'; - -export default class MembersFilterValue extends Component { - @tracked filterValue; - - constructor(...args) { - super(...args); - this.filterValue = this.args.filter.value; - } - - get tierFilterValue() { - if (this.args.filter?.type === 'tier_id') { - const tiers = Array.isArray(this.args.filter?.value) ? this.args.filter?.value : []; - return tiers.map((tier) => { - return { - id: tier - }; - }); - } - return []; - } - - get offersFilterValue() { - if (this.args.filter?.type === 'offer_redemptions') { - const offers = Array.isArray(this.args.filter?.value) ? this.args.filter?.value : []; - return offers.map((offer) => { - return { - id: offer - }; - }); - } - return []; - } - - @action - setInputFilterValue(filter, event) { - this.filterValue = event.target.value; - } - - @action - updateInputFilterValue(filter, event) { - if (event.type === 'blur') { - this.filterValue = event.target.value; - } - this.args.setFilterValue(filter, this.filterValue); - } - - @action - updateInputFilterValueOnEnter(filter, event) { - if (event.key === 'Enter') { - event.preventDefault(); - this.args.setFilterValue(filter, this.filterValue); - } - } - - @action - setLabelsFilterValue(filter, labels) { - this.args.setFilterValue(filter, labels.map(label => label.slug)); - } - - @action - setTiersFilterValue(filter, tiers) { - this.args.setFilterValue(filter, tiers.map(tier => tier.id)); - } - - @action - setOffersFilterValue(filter, offers) { - this.args.setFilterValue(filter, offers.map(offer => offer.id)); - } - - get isResourceFilter() { - return !!this.args.filter?.isResourceFilter; - } - - get resourceFilterType() { - if (!this.isResourceFilter) { - return ''; - } - - return this.args.filter?.properties?.resource ?? ''; - } - - get resourceFilterValue() { - if (!this.isResourceFilter) { - return {}; - } - const resource = this.args.filter?.resource || undefined; - const resourceId = this.args.filter?.value || undefined; - return resource ?? { - id: resourceId - }; - } - - @action - 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 deleted file mode 100644 index 7cec3c890a9..00000000000 --- a/ghost/admin/app/components/members/filter.hbs +++ /dev/null @@ -1,108 +0,0 @@ - - - - - {{svg-jar "filter"}} - Filter - {{#if @isFiltered}} - ({{this.totalFilters}}) - {{/if}} - - - - - - - - diff --git a/ghost/admin/app/components/members/filter.js b/ghost/admin/app/components/members/filter.js deleted file mode 100644 index d7f075cd2fc..00000000000 --- a/ghost/admin/app/components/members/filter.js +++ /dev/null @@ -1,675 +0,0 @@ -import Component from '@glimmer/component'; -import moment from 'moment-timezone'; -import nql from '@tryghost/nql-lang'; -import {AUDIENCE_FEEDBACK_FILTER, CREATED_AT_FILTER, EMAIL_CLICKED_FILTER, EMAIL_COUNT_FILTER, EMAIL_FILTER, EMAIL_OPENED_COUNT_FILTER, EMAIL_OPENED_FILTER, EMAIL_OPEN_RATE_FILTER, EMAIL_SENT_FILTER, LABEL_FILTER, LAST_SEEN_FILTER, NAME_FILTER, NEWSLETTERS_FILTERS, NEXT_BILLING_DATE_FILTER, OFFERS_FILTER, PLAN_INTERVAL_FILTER, SIGNUP_ATTRIBUTION_FILTER, STATUS_FILTER, SUBSCRIBED_FILTER, SUBSCRIPTION_ATTRIBUTION_FILTER, SUBSCRIPTION_START_DATE_FILTER, SUBSCRIPTION_STATUS_FILTER, TIER_FILTER} from './filters'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -function escapeNqlString(value) { - return '\'' + value.replace(/'/g, '\\\'') + '\''; -} - -const FILTER_GROUPS = [ - { - name: 'Basic', - filters: [ - NAME_FILTER, - EMAIL_FILTER, - LABEL_FILTER, - SUBSCRIBED_FILTER, - LAST_SEEN_FILTER, - CREATED_AT_FILTER, - SIGNUP_ATTRIBUTION_FILTER - ] - }, - { - name: 'Newsletters', - filters: [ - NEWSLETTERS_FILTERS - ] - }, - { - name: 'Subscription', - filters: [ - TIER_FILTER, - STATUS_FILTER, - PLAN_INTERVAL_FILTER, - SUBSCRIPTION_STATUS_FILTER, - SUBSCRIPTION_START_DATE_FILTER, - NEXT_BILLING_DATE_FILTER, - SUBSCRIPTION_ATTRIBUTION_FILTER - ] - }, - { - name: 'Email', - filters: [ - EMAIL_COUNT_FILTER, - EMAIL_OPENED_COUNT_FILTER, - EMAIL_OPEN_RATE_FILTER, - EMAIL_SENT_FILTER, - EMAIL_OPENED_FILTER, - EMAIL_CLICKED_FILTER, - AUDIENCE_FEEDBACK_FILTER - ] - } -]; - -const FILTER_PROPERTIES = FILTER_GROUPS.flatMap(group => group.filters.map((f) => { - if (typeof f === 'function') { - return (options) => { - return f({ - ...options, - group: group.name - }); - }; - } - - f.group = group.name; - return f; -})); - -class Filter { - @tracked value; - @tracked relation; - @tracked properties; - @tracked resource; - - constructor(options) { - this.properties = options.properties; - this.timezone = options.timezone ?? 'Etc/UTC'; - - 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 - 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; - } - - 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 group() { - return this.properties.group; - } - - get isValid() { - if (Array.isArray(this.value)) { - return !!this.value.length; - } - return !!this.value; - } -} - -export default class MembersFilter extends Component { - @service feature; - @service session; - @service settings; - @service store; - @service membersUtils; - - @tracked filters = new TrackedArray([ - new Filter({ - properties: NAME_FILTER - }) - ]); - - newsletters; - tiersList; - offers; - - @tracked isLoading = false; - - get filterProperties() { - // Ensure we have all required data before proceeding - if (!this.newsletters || !this.tiersList || !this.offers) { - return []; - } - - let availableFilters = FILTER_PROPERTIES; - - // Convert the method filters to properties - availableFilters = availableFilters.flatMap((filter) => { - if (typeof filter === 'function') { - const filters = filter({ - newsletters: this.newsletters ?? [], - feature: this.feature - }); - if (Array.isArray(filters)) { - return filters; - } - return [filters]; - } - return [filter]; - }); - - // only add the offers filter if there are any offers - if (this.offers.length > 0) { - availableFilters = availableFilters.concat(OFFERS_FILTER); - } - - // exclude any filters that are behind disabled feature flags - availableFilters = availableFilters.filter(prop => !prop.feature || this.feature[prop.feature]); - availableFilters = availableFilters.filter(prop => !prop.setting || this.settings[prop.setting]); - - return availableFilters; - } - - get availableFilterProperties() { - let availableFilters = this.filterProperties; - const hasMultipleTiers = this.membersUtils.hasMultipleTiers; - - // exclude tiers filter if site has only single tier - availableFilters = availableFilters - .filter((filter) => { - return filter.name === 'tier_id' ? hasMultipleTiers : true; - }); - - // exclude subscription filters if Stripe isn't connected - if (!this.settings.paidMembersEnabled) { - availableFilters = availableFilters.reject(prop => prop.group === 'Subscription'); - } - - // exclude email filters if email functionality is disabled - if (this.settings.editorDefaultEmailRecipients === 'disabled') { - availableFilters = availableFilters.reject(prop => prop.group === 'Email'); - } - - return availableFilters; - } - - get totalFilters() { - return this.filters?.length; - } - - constructor(...args) { - super(...args); - this.parseDefaultFilters(); - } - - /** - * This method is not super clean as it uses did-update, but for now this is required to make URL changes work - * properly. - * Problem: filter parameter is changed in the members controller by modifying the URL directly - * -> the filters property is not updated in the members controller because the new parameter is not parsed again - * -> we need to listen for changes in the property and parse it again - * -> better future proof solution: move the filter parsing logic elsewhere so it can be parsed in the members controller - */ - @action - async parseDefaultFilters() { - // we need to make sure all the filters are loaded before parsing the default filter - // otherwise the filter will be parsed with the wrong properties - try { - this.isLoading = true; - - await this.fetchTiers.perform(); - await this.fetchNewsletters.perform(); - await this.fetchOffers.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } finally { - this.isLoading = false; - } - - if (this.args.defaultFilterParam) { - // check if it is different before parsing - const validFilters = this.validFilters; - const currentFilter = this.generateNqlFilter(validFilters); - - 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({ - properties: NAME_FILTER - })); - this.applySoftFilter(); - } - - @action - onDropdownClose() { - this.applyFilter(); - } - - generateNqlFilter(filters) { - const nqlDateFormat = 'YYYY-MM-DD HH:mm:ss'; - - let query = ''; - filters.forEach((filter) => { - const filterProperty = this.filterProperties.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 === 'string') { - let filterValue = escapeNqlString(filter.value); - query += `${filter.type}:${relationStr}${filterValue}+`; - } else if (filterProperty.valueType === 'date') { - let filterValue; - - let tzMoment = moment.tz(moment(filter.value).format('YYYY-MM-DD'), this.settings.timezone); - - if (relationStr === '>') { - tzMoment = tzMoment.set({hour: 23, minute: 59, second: 59}); - } - if (relationStr === '>=') { - tzMoment = tzMoment.set({hour: 0, minute: 0, second: 0}); - } - if (relationStr === '<') { - tzMoment = tzMoment.set({hour: 0, minute: 0, second: 0}); - } - if (relationStr === '<=') { - tzMoment = tzMoment.set({hour: 23, minute: 59, second: 59}); - } - - filterValue = `'${tzMoment.utc().format(nqlDateFormat)}'`; - query += `${filter.type}:${relationStr}${filterValue}+`; - } else { - const filterValue = (typeof filter.value === 'string' && filter.value.includes(' ')) ? `'${filter.value}'` : filter.value; - query += `${filter.type}:${relationStr}${filterValue}+`; - } - }); - 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 = []; - for (const filterProperties of this.filterProperties) { - 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 { - const filterKeys = Object.keys(filter); - const validKeys = this.filterProperties.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]; - const nqlValue = nqlFilter[key]; - - const filterProperty = this.filterProperties.find(prop => prop.name === key); - - let relation; - let value; - - if (typeof nqlValue === 'object') { - if (nqlValue.$in !== undefined && filterProperty.valueType === 'array') { - relation = 'is'; - value = nqlValue.$in; - } - - if (nqlValue.$nin !== undefined && filterProperty.valueType === 'array') { - relation = 'is-not'; - value = nqlValue.$nin; - } - - if (nqlValue.$ne !== undefined) { - relation = 'is-not'; - value = nqlValue.$ne; - } - - if (nqlValue.$gt !== undefined) { - relation = 'is-greater'; - value = nqlValue.$gt; - } - - if (nqlValue.$gte !== undefined) { - relation = 'is-or-greater'; - value = nqlValue.$gte; - } - - if (nqlValue.$lt !== undefined) { - relation = 'is-less'; - value = nqlValue.$lt; - } - - if (nqlValue.$lte !== undefined) { - relation = 'is-or-less'; - value = nqlValue.$lte; - } - - if (nqlValue.$regex !== undefined) { - const source = nqlValue.$regex.source; - - if (source.indexOf('^') === 0) { - relation = 'starts-with'; - value = source.substring(1); - } else if (source.indexOf('$') === source.length - 1) { - relation = 'ends-with'; - value = source.slice(0, -1); - } else { - relation = 'contains'; - value = source; - } - - value = value.replace(/\\/g, ''); - } - - if (nqlValue.$not !== undefined) { - relation = 'does-not-contain'; - value = nqlValue.$not.source; - - value = value.replace(/\\/g, ''); - } - } else { - relation = 'is'; - value = nqlValue; - } - - if (typeof value === 'boolean' || typeof value === 'number') { - // Transform it to a string, to keep it compatible with the internally used value in admin - // + make sure false and 0 are truthy - value = value.toString(); - } - - if (relation && value) { - const properties = this.filterProperties.find(prop => key === prop.name); - if (this.filterProperties.find(prop => key === prop.name)) { - return new Filter({ - properties, - relation, - value, - timezone: this.settings.timezone - }); - } - } - } - - getFilterRelationOperator(relation) { - // TODO: unify operator naming with NQL - const relationMap = { - 'is-less': '<', - 'is-or-less': '<=', - is: '', - 'is-not': '-', - 'is-greater': '>', - 'is-or-greater': '>=', - contains: '~', - 'does-not-contain': '-~', - 'starts-with': '~^', - 'ends-with': '~$' - }; - - return relationMap[relation] || ''; - } - - @action - handleSubmitKeyup(e) { - e.preventDefault(); - - if (e.key === 'Enter') { - this.applyFilter(); - } - } - - @action - deleteFilter(filter, event) { - event.stopPropagation(); - event.preventDefault(); - - if (this.filters.length === 1) { - this.resetFilter(); - } else { - this.filters = new TrackedArray(this.filters.reject(f => f === filter)); - this.applySoftFilter(); - } - } - - @action - setFilterType(filter, newType) { - if (newType instanceof Event) { - newType = newType.target.value; - } - - const newProp = this.filterProperties.find(prop => prop.name === newType); - - if (!newProp) { - // eslint-disable-next-line no-console - console.warn('Invalid Filter Type Selected', newType); - return; - } - - const newFilter = new Filter({ - properties: newProp, - timezone: this.settings.timezone - }); - - const filterToSwap = this.filters.find(f => f === filter); - this.filters[this.filters.indexOf(filterToSwap)] = newFilter; - - if (newFilter.isValid) { - this.applySoftFilter(); - } - } - - @action - setFilterRelation(filter, newRelation) { - filter.relation = newRelation; - this.applySoftFilter(); - } - - @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.validFilters; - const query = this.generateNqlFilter(validFilters); - this.args.onApplySoftFilter(query, validFilters); - this.fetchFilterResourcesTask.perform(); - } - - @action - applyFilter() { - 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.validFilters; - this.args.onApplyParsedFilter(validFilters); - this.fetchFilterResourcesTask.perform(); - } - - @action - resetFilter() { - const filters = []; - - filters.push(new Filter({ - properties: NAME_FILTER - })); - - this.filters = new TrackedArray(filters); - this.args.onResetFilter(); - } - - @task({drop: true}) - *fetchTiers() { - const response = yield this.store.query('tier', {filter: 'type:paid'}); - this.tiersList = response; - } - - @task({drop: true}) - *fetchNewsletters() { - const response = yield this.store.query('newsletter', {filter: 'status:active'}); - this.newsletters = response; - return response; - } - - @task({drop: true}) - *fetchOffers() { - const response = yield this.store.findAll('offer'); - this.offers = response; - return 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/filters/audience-feedback.js b/ghost/admin/app/components/members/filters/audience-feedback.js deleted file mode 100644 index 2adb962eb5c..00000000000 --- a/ghost/admin/app/components/members/filters/audience-feedback.js +++ /dev/null @@ -1,50 +0,0 @@ -const FEEDBACK_RELATION_OPTIONS = [ - {label: 'More like this', name: 1}, - {label: 'Less like this', name: 0} -]; - -export const AUDIENCE_FEEDBACK_FILTER = { - label: 'Responded with feedback', - name: 'newsletter_feedback', - valueType: 'string', - resource: 'email', - relationOptions: FEEDBACK_RELATION_OPTIONS, - 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' - }; - } - } - ] -}; diff --git a/ghost/admin/app/components/members/filters/columns/date-column.js b/ghost/admin/app/components/members/filters/columns/date-column.js deleted file mode 100644 index 62bdcb9ef3a..00000000000 --- a/ghost/admin/app/components/members/filters/columns/date-column.js +++ /dev/null @@ -1,13 +0,0 @@ -import moment from 'moment-timezone'; - -export function getDateColumnValue(date, filter) { - if (!date) { - return null; - } - return { - class: '', - text: date ? moment.tz(date, filter.timezone).format('DD MMM YYYY') : '', - subtext: moment(date).from(moment()), - subtextClass: 'gh-members-list-subscribed-moment' - }; -} diff --git a/ghost/admin/app/components/members/filters/created-at.js b/ghost/admin/app/components/members/filters/created-at.js deleted file mode 100644 index f8577711157..00000000000 --- a/ghost/admin/app/components/members/filters/created-at.js +++ /dev/null @@ -1,8 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; - -export const CREATED_AT_FILTER = { - label: 'Created', - name: 'created_at', - valueType: 'date', - relationOptions: DATE_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/email-clicked.js b/ghost/admin/app/components/members/filters/email-clicked.js deleted file mode 100644 index c78dae0c410..00000000000 --- a/ghost/admin/app/components/members/filters/email-clicked.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_CLICKED_FILTER = { - label: 'Clicked email', - name: 'clicked_links.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Clicked email', - setting: 'emailTrackClicks', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-count.js b/ghost/admin/app/components/members/filters/email-count.js deleted file mode 100644 index 03a0a4242f7..00000000000 --- a/ghost/admin/app/components/members/filters/email-count.js +++ /dev/null @@ -1,15 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; - -export const EMAIL_COUNT_FILTER = { - label: 'Emails sent (all time)', - name: 'email_count', - columnLabel: 'Email count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - text: formatNumber(member.emailCount) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-open-rate.js b/ghost/admin/app/components/members/filters/email-open-rate.js deleted file mode 100644 index 6361239b057..00000000000 --- a/ghost/admin/app/components/members/filters/email-open-rate.js +++ /dev/null @@ -1,9 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_OPEN_RATE_FILTER = { - label: 'Open rate (all time)', - name: 'email_open_rate', - valueType: 'number', - setting: 'emailTrackOpens', - relationOptions: NUMBER_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/email-opened-count.js b/ghost/admin/app/components/members/filters/email-opened-count.js deleted file mode 100644 index dece4042add..00000000000 --- a/ghost/admin/app/components/members/filters/email-opened-count.js +++ /dev/null @@ -1,15 +0,0 @@ -import {NUMBER_RELATION_OPTIONS} from './relation-options'; -import {formatNumber} from 'ghost-admin/helpers/format-number'; - -export const EMAIL_OPENED_COUNT_FILTER = { - label: 'Emails opened (all time)', - name: 'email_opened_count', - columnLabel: 'Email opened count', - valueType: 'number', - relationOptions: NUMBER_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - text: formatNumber(member.emailOpenedCount) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-opened.js b/ghost/admin/app/components/members/filters/email-opened.js deleted file mode 100644 index d9535523aa1..00000000000 --- a/ghost/admin/app/components/members/filters/email-opened.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_OPENED_FILTER = { - label: 'Opened email', - name: 'opened_emails.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Opened email', - setting: 'emailTrackOpens', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email-sent.js b/ghost/admin/app/components/members/filters/email-sent.js deleted file mode 100644 index 45fdbb4d6e0..00000000000 --- a/ghost/admin/app/components/members/filters/email-sent.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_SENT_FILTER = { - label: 'Sent email', - name: 'emails.post_id', - valueType: 'string', - resource: 'email', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Sent email', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/email.js b/ghost/admin/app/components/members/filters/email.js deleted file mode 100644 index 1aa094a3953..00000000000 --- a/ghost/admin/app/components/members/filters/email.js +++ /dev/null @@ -1,8 +0,0 @@ -import {CONTAINS_RELATION_OPTIONS} from './relation-options'; - -export const EMAIL_FILTER = { - label: 'Email', - name: 'email', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/index.js b/ghost/admin/app/components/members/filters/index.js deleted file mode 100644 index 6b10fa69ee0..00000000000 --- a/ghost/admin/app/components/members/filters/index.js +++ /dev/null @@ -1,23 +0,0 @@ -export * from './name'; -export * from './email'; -export * from './label'; -export * from './subscribed'; -export * from './last-seen'; -export * from './created-at'; -export * from './signup-attribution'; -export * from './tier'; -export * from './status'; -export * from './plan-interval'; -export * from './subscription-status'; -export * from './subscription-start-date'; -export * from './next-billing-date'; -export * from './subscription-attribution'; -export * from './email-count'; -export * from './email-opened'; -export * from './email-clicked'; -export * from './email-opened-count'; -export * from './email-open-rate'; -export * from './email-clicked'; -export * from './email-sent'; -export * from './audience-feedback'; -export * from './offers'; diff --git a/ghost/admin/app/components/members/filters/label.js b/ghost/admin/app/components/members/filters/label.js deleted file mode 100644 index 857845a2dbe..00000000000 --- a/ghost/admin/app/components/members/filters/label.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const LABEL_FILTER = { - label: 'Label', - name: 'label', - valueType: 'array', - columnLabel: 'Label', - relationOptions: MATCH_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.labels ?? []).map(label => label.name).join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/last-seen.js b/ghost/admin/app/components/members/filters/last-seen.js deleted file mode 100644 index d0667dbda3f..00000000000 --- a/ghost/admin/app/components/members/filters/last-seen.js +++ /dev/null @@ -1,13 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; - -export const LAST_SEEN_FILTER = { - label: 'Last seen', - name: 'last_seen_at', - valueType: 'date', - columnLabel: 'Last seen at', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - return getDateColumnValue(member.lastSeenAtUTC, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/name.js b/ghost/admin/app/components/members/filters/name.js deleted file mode 100644 index c5571d55679..00000000000 --- a/ghost/admin/app/components/members/filters/name.js +++ /dev/null @@ -1,8 +0,0 @@ -import {CONTAINS_RELATION_OPTIONS} from './relation-options'; - -export const NAME_FILTER = { - label: 'Name', - name: 'name', - valueType: 'string', - relationOptions: CONTAINS_RELATION_OPTIONS -}; diff --git a/ghost/admin/app/components/members/filters/next-billing-date.js b/ghost/admin/app/components/members/filters/next-billing-date.js deleted file mode 100644 index 621d8393652..00000000000 --- a/ghost/admin/app/components/members/filters/next-billing-date.js +++ /dev/null @@ -1,15 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const NEXT_BILLING_DATE_FILTER = { - label: 'Next billing date', - name: 'subscriptions.current_period_end', - valueType: 'date', - columnLabel: 'Next billing date', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - const subscription = mostRelevantSubscription(member.subscriptions); - return getDateColumnValue(subscription?.current_period_end, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/offers.js b/ghost/admin/app/components/members/filters/offers.js deleted file mode 100644 index be734dc9a65..00000000000 --- a/ghost/admin/app/components/members/filters/offers.js +++ /dev/null @@ -1,36 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -const getOfferNameForColumn = (offer) => { - if (!offer) { - return null; - } - - if (offer.redemption_type === 'retention') { - if (offer.cadence === 'month') { - return 'Monthly Retention'; - } - - if (offer.cadence === 'year') { - return 'Yearly Retention'; - } - } - - return offer.name; -}; - -export const OFFERS_FILTER = { - label: 'Offers', - name: 'offer_redemptions', - group: 'Subscription', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'array', - columnLabel: 'Offers redeemed', - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.subscriptions ?? []) - .flatMap(sub => (sub.offer_redemptions ?? []).map(getOfferNameForColumn).filter(Boolean)) - .join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/plan-interval.js b/ghost/admin/app/components/members/filters/plan-interval.js deleted file mode 100644 index 79c3cc18992..00000000000 --- a/ghost/admin/app/components/members/filters/plan-interval.js +++ /dev/null @@ -1,24 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; -import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const PLAN_INTERVAL_FILTER = { - label: 'Billing period', - name: 'subscriptions.plan_interval', - columnLabel: 'Billing period', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options: [ - {label: 'Monthly', name: 'month'}, - {label: 'Yearly', name: 'year'} - ], - getColumnValue: (member) => { - const subscription = mostRelevantSubscription(member.subscriptions); - if (!subscription) { - return null; - } - return { - text: capitalizeFirstLetter(subscription.price?.interval) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/relation-options/contains.js b/ghost/admin/app/components/members/filters/relation-options/contains.js deleted file mode 100644 index 62daf9329bc..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/contains.js +++ /dev/null @@ -1,7 +0,0 @@ -export const CONTAINS_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'contains', name: 'contains'}, - {label: 'does not contain', name: 'does-not-contain'}, - {label: 'starts with', name: 'starts-with'}, - {label: 'ends with', name: 'ends-with'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/date.js b/ghost/admin/app/components/members/filters/relation-options/date.js deleted file mode 100644 index 88725e3686a..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/date.js +++ /dev/null @@ -1,6 +0,0 @@ -export const DATE_RELATION_OPTIONS = [ - {label: 'before', name: 'is-less'}, - {label: 'on or before', name: 'is-or-less'}, - {label: 'after', name: 'is-greater'}, - {label: 'on or after', name: 'is-or-greater'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/index.js b/ghost/admin/app/components/members/filters/relation-options/index.js deleted file mode 100644 index a4357d6a83e..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/index.js +++ /dev/null @@ -1,4 +0,0 @@ -export * from './contains'; -export * from './match'; -export * from './date'; -export * from './number'; diff --git a/ghost/admin/app/components/members/filters/relation-options/match.js b/ghost/admin/app/components/members/filters/relation-options/match.js deleted file mode 100644 index a907a9ac05b..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/match.js +++ /dev/null @@ -1,4 +0,0 @@ -export const MATCH_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is not', name: 'is-not'} -]; diff --git a/ghost/admin/app/components/members/filters/relation-options/number.js b/ghost/admin/app/components/members/filters/relation-options/number.js deleted file mode 100644 index b4588892ea7..00000000000 --- a/ghost/admin/app/components/members/filters/relation-options/number.js +++ /dev/null @@ -1,5 +0,0 @@ -export const NUMBER_RELATION_OPTIONS = [ - {label: 'is', name: 'is'}, - {label: 'is greater than', name: 'is-greater'}, - {label: 'is less than', name: 'is-less'} -]; diff --git a/ghost/admin/app/components/members/filters/signup-attribution.js b/ghost/admin/app/components/members/filters/signup-attribution.js deleted file mode 100644 index 6bcf2b27626..00000000000 --- a/ghost/admin/app/components/members/filters/signup-attribution.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SIGNUP_ATTRIBUTION_FILTER = { - label: 'Signed up on post/page', - name: 'signup', - valueType: 'string', - resource: 'post', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Signed up on', - setting: 'membersTrackSources', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/status.js b/ghost/admin/app/components/members/filters/status.js deleted file mode 100644 index 14696a5029b..00000000000 --- a/ghost/admin/app/components/members/filters/status.js +++ /dev/null @@ -1,22 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const STATUS_FILTER = ({feature, group}) => { - const options = [ - {label: 'Paid', name: 'paid'}, - {label: 'Free', name: 'free'}, - {label: 'Complimentary', name: 'comped'} - ]; - - if (feature.giftSubscriptions) { - options.push({label: 'Gift', name: 'gift'}); - } - - return { - label: 'Member status', - name: 'status', - group, - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - options - }; -}; diff --git a/ghost/admin/app/components/members/filters/subscribed.js b/ghost/admin/app/components/members/filters/subscribed.js deleted file mode 100644 index 9a4d143e841..00000000000 --- a/ghost/admin/app/components/members/filters/subscribed.js +++ /dev/null @@ -1,168 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SUBSCRIBED_FILTER = ({newsletters, group}) => { - return { - label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription', - name: 'subscribed', - columnLabel: 'Subscribed', - relationOptions: MATCH_RELATION_OPTIONS, - valueType: 'options', - group: newsletters.length > 1 ? 'Newsletters' : group, - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - if (value === 'email-disabled') { - if (relation === 'is') { - return '(email_disabled:1)'; - } - return '(email_disabled:0)'; - } - - if (relation === 'is') { - if (value === 'subscribed') { - return '(subscribed:true+email_disabled:0)'; - } - return '(subscribed:false+email_disabled:0)'; - } - - // relation === 'is-not' - if (value === 'subscribed') { - return '(subscribed:false,email_disabled:1)'; - } - return '(subscribed:true,email_disabled:1)'; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility - - if (!comparator || comparator.length !== 2) { - const filter = flt; - if (filter && filter.email_disabled !== undefined) { - if (filter.email_disabled) { - return { - value: 'email-disabled', - relation: 'is' - }; - } - return { - value: 'email-disabled', - relation: 'is-not' - }; - } - return; - } - - if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) { - return; - } - - const usedOr = flt.$or !== undefined; - const subscribed = comparator[0].subscribed; - - if (usedOr) { - // Is not - return { - value: !subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is-not' - }; - } - - return { - value: subscribed ? 'subscribed' : 'unsubscribed', - relation: 'is' - }; - }, - options: [ - {label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'}, - {label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'}, - {label: 'Email disabled', name: 'email-disabled'} - ], - getColumnValue: (member) => { - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } - - return member.newsletters.length > 0 ? { - text: 'Subscribed' - } : { - text: 'Unsubscribed' - }; - } - }; -}; - -export const NEWSLETTERS_FILTERS = ({newsletters, group}) => { - if (newsletters.length <= 1) { - return []; - } - return newsletters.map((newsletter) => { - return { - label: newsletter.name, - name: `newsletters.slug:${newsletter.slug}`, - relationOptions: MATCH_RELATION_OPTIONS, - group, - valueType: 'options', - buildNqlFilter: (flt) => { - const relation = flt.relation; - const value = flt.value; - - return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? `(newsletters.slug:${newsletter.slug}+email_disabled:0)` - : `(newsletters.slug:-${newsletter.slug},email_disabled:1)`; - }, - parseNqlFilter: (flt) => { - const comparator = flt.$and || flt.$or; - - if (!comparator || comparator.length !== 2) { - return; - } - - if (!comparator[0]['newsletters.slug'] || comparator[1].email_disabled === undefined) { - return; - } - - let value = comparator[0]['newsletters.slug']; - let invert = false; - if (typeof value === 'object') { - if (!value.$ne) { - // Unsupported relation type - return; - } - invert = true; - value = value.$ne; - } - if (value !== newsletter.slug) { - // This filter is for a different newsletter - return; - } - return { - value: invert ? 'false' : 'true', - relation: 'is' - }; - }, - options: [ - {label: 'Subscribed', name: 'true'}, - {label: 'Unsubscribed', name: 'false'} - ], - columnLabel: newsletter.name, - getColumnValue: (member, flt) => { - const relation = flt.relation; - const value = flt.value; - - if (member.emailSuppression && member.emailSuppression.suppressed) { - return { - text: 'Email disabled' - }; - } - - return { - text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false') - ? 'Subscribed' - : 'Unsubscribed' - }; - } - }; - }); -}; diff --git a/ghost/admin/app/components/members/filters/subscription-attribution.js b/ghost/admin/app/components/members/filters/subscription-attribution.js deleted file mode 100644 index 373b51c0b34..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-attribution.js +++ /dev/null @@ -1,16 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const SUBSCRIPTION_ATTRIBUTION_FILTER = { - label: 'Subscription started on post/page', - name: 'conversion', - valueType: 'string', - resource: 'post', - relationOptions: MATCH_RELATION_OPTIONS, - columnLabel: 'Subscription started on', - setting: 'membersTrackSources', - getColumnValue: (member, filter) => { - return { - text: filter.resource?.title ?? '' - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/subscription-start-date.js b/ghost/admin/app/components/members/filters/subscription-start-date.js deleted file mode 100644 index 771c1e52ecd..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-start-date.js +++ /dev/null @@ -1,15 +0,0 @@ -import {DATE_RELATION_OPTIONS} from './relation-options'; -import {getDateColumnValue} from './columns/date-column'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const SUBSCRIPTION_START_DATE_FILTER = { - label: 'Paid start date', - name: 'subscriptions.start_date', - valueType: 'date', - columnLabel: 'Paid start date', - relationOptions: DATE_RELATION_OPTIONS, - getColumnValue: (member, filter) => { - const subscription = mostRelevantSubscription(member.subscriptions); - return getDateColumnValue(subscription?.start_date, filter); - } -}; diff --git a/ghost/admin/app/components/members/filters/subscription-status.js b/ghost/admin/app/components/members/filters/subscription-status.js deleted file mode 100644 index d6266932062..00000000000 --- a/ghost/admin/app/components/members/filters/subscription-status.js +++ /dev/null @@ -1,29 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; -import {capitalizeFirstLetter} from 'ghost-admin/helpers/capitalize-first-letter'; -import {mostRelevantSubscription} from 'ghost-admin/helpers/most-relevant-subscription'; - -export const SUBSCRIPTION_STATUS_FILTER = { - label: 'Stripe subscription status', - name: 'subscriptions.status', - 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'} - ], - getColumnValue: (member) => { - const subscription = mostRelevantSubscription(member.subscriptions); - if (!subscription) { - return null; - } - return { - text: capitalizeFirstLetter(subscription.status) - }; - } -}; diff --git a/ghost/admin/app/components/members/filters/tier.js b/ghost/admin/app/components/members/filters/tier.js deleted file mode 100644 index bbd1c00e2d7..00000000000 --- a/ghost/admin/app/components/members/filters/tier.js +++ /dev/null @@ -1,15 +0,0 @@ -import {MATCH_RELATION_OPTIONS} from './relation-options'; - -export const TIER_FILTER = { - label: 'Membership tier', - name: 'tier_id', - valueType: 'array', - columnLabel: 'Membership tier', - relationOptions: MATCH_RELATION_OPTIONS, - getColumnValue: (member) => { - return { - class: 'gh-members-list-labels', - text: (member.tiers ?? []).map(label => label.name).join(', ') - }; - } -}; diff --git a/ghost/admin/app/components/members/list-item-column.hbs b/ghost/admin/app/components/members/list-item-column.hbs deleted file mode 100644 index f9feff51411..00000000000 --- a/ghost/admin/app/components/members/list-item-column.hbs +++ /dev/null @@ -1,15 +0,0 @@ - - {{#if this.columnValue}} -
- {{#if this.columnValue.icon}} - {{svg-jar this.columnValue.icon}} - {{/if}} - {{this.columnValue.text}} - {{#if this.columnValue.subtext}} -
{{this.columnValue.subtext}}
- {{/if}} -
- {{else}} - - - {{/if}} -
diff --git a/ghost/admin/app/components/members/list-item-column.js b/ghost/admin/app/components/members/list-item-column.js deleted file mode 100644 index 14b1c5fbdd8..00000000000 --- a/ghost/admin/app/components/members/list-item-column.js +++ /dev/null @@ -1,15 +0,0 @@ -import Component from '@glimmer/component'; - -export default class MembersListItemColumn extends Component { - constructor(...args) { - super(...args); - } - - 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/members/list-item-loading.hbs b/ghost/admin/app/components/members/list-item-loading.hbs deleted file mode 100644 index bf4d6680ded..00000000000 --- a/ghost/admin/app/components/members/list-item-loading.hbs +++ /dev/null @@ -1,13 +0,0 @@ - -
-
-
-
-
-
-
-
- {{#each @filterColumns}} -
- {{/each}} - \ No newline at end of file diff --git a/ghost/admin/app/components/members/list-item.hbs b/ghost/admin/app/components/members/list-item.hbs deleted file mode 100644 index 685d9bf7689..00000000000 --- a/ghost/admin/app/components/members/list-item.hbs +++ /dev/null @@ -1,67 +0,0 @@ - - -
- -
-

{{or @member.name @member.email}}

- {{#if @member.name}} -

{{@member.email}}

- {{/if}} -
-
-
- {{#if this.hasMultipleTiers}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} -
{{this.tiers}}
-
- {{else}} - - {{#if (not (is-empty @member.status))}} - {{capitalize @member.status}} - {{else}} - - - {{/if}} - - {{/if}} - {{#if @newsletterEnabled}} - - {{#if (not (is-empty @member.emailOpenRate))}} - {{@member.emailOpenRate}}% - {{else}} - N/A - {{/if}} - - {{/if}} - - - {{#if (and @member.geolocation @member.geolocation.country)}} - {{#if (and (eq @member.geolocation.country_code "US") @member.geolocation.region)}} - {{@member.geolocation.region}}, US - {{else}} - {{#if @member.geolocation.country}} - {{@member.geolocation.country}} - {{else}} - Unknown - {{/if}} - {{/if}} - {{else}} - Unknown - {{/if}} - - - - {{#if @member.createdAtUTC}} -
{{moment-format (moment-site-tz @member.createdAtUTC) "DD MMM YYYY"}}
-
{{moment-from-now @member.createdAtUTC}}
- {{/if}} -
- - {{#each @filterColumns as |filterColumn|}} - - {{/each}} - diff --git a/ghost/admin/app/components/members/list-item.js b/ghost/admin/app/components/members/list-item.js deleted file mode 100644 index d559550b125..00000000000 --- a/ghost/admin/app/components/members/list-item.js +++ /dev/null @@ -1,19 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class MembersListItem extends Component { - @service store; - - constructor(...args) { - super(...args); - } - - get hasMultipleTiers() { - return this.store.peekAll('tier')?.length > 1; - } - - get tiers() { - const tierData = this.args.member?.tiers || []; - return tierData.map(tier => tier.name).join(', '); - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-add-label.hbs b/ghost/admin/app/components/members/modals/bulk-add-label.hbs deleted file mode 100644 index 1934ecdc7b8..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-add-label.hbs +++ /dev/null @@ -1,83 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-add-label.js b/ghost/admin/app/components/members/modals/bulk-add-label.js deleted file mode 100644 index d4866b929cf..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-add-label.js +++ /dev/null @@ -1,60 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkAddMembersLabelModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - @tracked selectedLabel; - - get isDisabled() { - return !this.args.data.query || !this.selectedLabel; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *addLabelTask() { - try { - const query = new URLSearchParams(this.args.data.query); - const addLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(addLabelUrl, { - data: { - bulk: { - action: 'addLabel', - meta: { - label: { - id: this.selectedLabel - } - } - } - } - }); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-delete.hbs b/ghost/admin/app/components/members/modals/bulk-delete.hbs deleted file mode 100644 index fcf233919cd..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-delete.hbs +++ /dev/null @@ -1,81 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-delete.js b/ghost/admin/app/components/members/modals/bulk-delete.js deleted file mode 100644 index 5bc63c369f4..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-delete.js +++ /dev/null @@ -1,81 +0,0 @@ -import Component from '@glimmer/component'; -import config from 'ghost-admin/config/environment'; -import fetch from 'fetch'; -import moment from 'moment-timezone'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkDeleteMembersModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - - get isDisabled() { - return !this.args.data.query; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *bulkDeleteTask() { - try { - const query = new URLSearchParams(this.args.data.query); - - // Trigger download before deleting. Uses the CSV export endpoint but - // needs to fetch the file and trigger a download directly rather than - // via an iframe. The iframe approach can't tell us when a download has - // started/finished meaning we could end up deleting the data before exporting it - const exportParams = new URLSearchParams(this.args.data.query); - exportParams.set('limit', 'all'); - const exportUrl = `${this.ghostPaths.url.api('members/upload')}?${exportParams.toString()}`; - - yield fetch(exportUrl, {method: 'GET'}) - .then(res => res.blob()) - .then((blob) => { - const blobUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = `members.${moment().format('YYYY-MM-DD')}.csv`; - document.body.appendChild(a); - - if (config.environment !== 'test') { - a.click(); - } - - a.remove(); - URL.revokeObjectURL(blobUrl); - }); - - // backup downloaded, continue with deletion - - const deleteUrl = `${this.ghostPaths.url.api('members')}?${query}`; - - // response contains details of which members failed to be deleted - const response = yield this.ajax.del(deleteUrl); - - this.response = response.meta; - - this.args.data.onComplete?.(); - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-remove-label.hbs b/ghost/admin/app/components/members/modals/bulk-remove-label.hbs deleted file mode 100644 index c5251b4bd58..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-remove-label.hbs +++ /dev/null @@ -1,83 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-remove-label.js b/ghost/admin/app/components/members/modals/bulk-remove-label.js deleted file mode 100644 index 4f9722ef0b5..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-remove-label.js +++ /dev/null @@ -1,60 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkRemoveMembersLabelModal extends Component { - @service ajax; - @service ghostPaths; - - @tracked error; - @tracked response; - @tracked selectedLabel; - - get isDisabled() { - return !this.args.data.query || !this.selectedLabel; - } - - get hasRun() { - return !!(this.error || this.response); - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @task({drop: true}) - *removeLabelTask() { - try { - const query = new URLSearchParams(this.args.data.query); - const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(removeLabelUrl, { - data: { - bulk: { - action: 'removeLabel', - meta: { - label: { - id: this.selectedLabel - } - } - } - } - }); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs b/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs deleted file mode 100644 index 2fd81b2f71c..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.hbs +++ /dev/null @@ -1,113 +0,0 @@ - \ No newline at end of file diff --git a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js b/ghost/admin/app/components/members/modals/bulk-unsubscribe.js deleted file mode 100644 index a584adaf2a4..00000000000 --- a/ghost/admin/app/components/members/modals/bulk-unsubscribe.js +++ /dev/null @@ -1,93 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class BulkUnsubscribeMembersModal extends Component { - @service ajax; - @service ghostPaths; - @service store; - - @tracked error; - @tracked response; - - @tracked selectedNewsletterId = null; - - get isDisabled() { - return !this.args.data.query; - } - - get hasRun() { - return !!(this.error || this.response); - } - - get hasMultipleNewsletters() { - const newsletters = this.store.peekAll('newsletter'); - const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); - if (activeNewsletters.length <= 1) { - return false; - } else { - return true; - } - } - - get newsletterList() { - const newsletters = this.store.peekAll('newsletter'); - const activeNewsletters = newsletters.filter(newsletter => newsletter.status !== 'archived'); - let list = [{ - name: 'All newsletters', - value: 'all' - }]; - activeNewsletters.forEach((newsletter) => { - list.push({ - name: newsletter.name, - value: newsletter.id - }); - }); - return list; - } - - @action - setLabel(label) { - this.selectedLabel = label; - } - - @action - setSelectedNewsletter(newsletter) { - if (newsletter === 'all') { - this.selectedNewsletterId = null; - } else { - this.selectedNewsletterId = newsletter; - } - } - - @task({drop: true}) - *bulkUnsubscribeTask() { - try { - let args = this.args.data.query; - const query = new URLSearchParams(args); - const removeLabelUrl = `${this.ghostPaths.url.api('members/bulk')}?${query}`; - const response = yield this.ajax.put(removeLabelUrl, {data: { - bulk: { - action: 'unsubscribe', - newsletter: (this.selectedNewsletterId ? this.selectedNewsletterId : null), - meta: {} - } - }}); - - this.args.data.onComplete?.(); - - this.response = response?.bulk?.meta; - - return true; - } catch (e) { - if (e.payload?.errors) { - this.error = e.payload.errors[0].message; - } else { - this.error = 'An unknown error occurred. Please try again.'; - } - throw e; - } - } -} diff --git a/ghost/admin/app/components/members/modals/disable-commenting.js b/ghost/admin/app/components/members/modals/disable-commenting.js index cea24ec8600..07e657ae0cd 100644 --- a/ghost/admin/app/components/members/modals/disable-commenting.js +++ b/ghost/admin/app/components/members/modals/disable-commenting.js @@ -26,12 +26,6 @@ export default class DisableCommentingModal extends Component { contentType: 'application/json' }); - // Invalidate React Query cache so comments list reflects changes - if (window.adminXQueryClient) { - window.adminXQueryClient.invalidateQueries({queryKey: ['CommentsResponseType']}); - window.adminXQueryClient.invalidateQueries({queryKey: ['MembersResponseType']}); - } - this.args.data.afterDisable?.(); this.notifications.showNotification(`Commenting has been disabled for ${this.member.name || this.member.email}.`, {type: 'success'}); this.args.close(true); diff --git a/ghost/admin/app/components/modal-import-members.hbs b/ghost/admin/app/components/modal-import-members.hbs deleted file mode 100644 index 99b785fc75a..00000000000 --- a/ghost/admin/app/components/modal-import-members.hbs +++ /dev/null @@ -1,190 +0,0 @@ -
- {{#if (eq this.state 'INIT')}} - -

Need some help? Learn more about importing members or download a sample CSV file.

- {{/if}} - - {{#if (or (eq this.state 'MAPPING') (eq this.state 'UPLOADING'))}} - - {{/if}} - - {{#if (eq this.state 'PROCESSING')}} - - {{/if}} - - {{#if (eq this.state 'COMPLETE')}} - - {{/if}} - - {{#if (eq this.state 'ERROR')}} - - {{/if}} - - - {{svg-jar "close"}} - - - - - - -
\ No newline at end of file diff --git a/ghost/admin/app/components/modal-import-members.js b/ghost/admin/app/components/modal-import-members.js deleted file mode 100644 index 410a22f463f..00000000000 --- a/ghost/admin/app/components/modal-import-members.js +++ /dev/null @@ -1,236 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import moment from 'moment-timezone'; -import unparse from '@tryghost/members-csv/lib/unparse'; -import { - AcceptedResponse, - isDataImportError, - isHostLimitError, - isRequestEntityTooLargeError, - isUnsupportedMediaTypeError, - isVersionMismatchError -} from 'ghost-admin/services/ajax'; -import {computed} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {inject} from 'ghost-admin/decorators/inject'; -import {inject as service} from '@ember/service'; - -export default ModalComponent.extend({ - ajax: service(), - notifications: service(), - store: service(), - - state: 'INIT', - - file: null, - mappingResult: null, - mappingFileData: null, - paramName: 'membersfile', - importResponse: null, - errorMessage: null, - errorHeader: null, - showMappingErrors: false, - showTryAgainButton: true, - - // Allowed actions - confirm: () => {}, - - config: inject(), - - uploadUrl: computed(function () { - return `${ghostPaths().apiRoot}/members/upload/`; - }), - - formData: computed('file', function () { - let formData = new FormData(); - - formData.append(this.paramName, this.file); - - if (this.mappingResult.labels) { - this.mappingResult.labels.forEach((label) => { - formData.append('labels', label.name); - }); - } - - if (this.mappingResult.mapping) { - let mapping = this.mappingResult.mapping.toJSON(); - for (let [key, val] of Object.entries(mapping)) { - formData.append(`mapping[${key}]`, val); - } - } - - return formData; - }), - - actions: { - setFile(file) { - this.set('file', file); - this.set('state', 'MAPPING'); - }, - - setMappingResult(mappingResult) { - this.set('mappingResult', mappingResult); - }, - - setMappingFileData(mappingFileData) { - this.set('mappingFileData', mappingFileData); - }, - - upload() { - if (this.file && !this.mappingResult.error) { - this.generateRequest(); - this.set('showMappingErrors', false); - } else { - this.set('showMappingErrors', true); - } - }, - - reset() { - this.set('showMappingErrors', false); - this.set('errorMessage', null); - this.set('errorHeader', null); - this.set('file', null); - this.set('mapping', null); - this.set('state', 'INIT'); - this.set('showTryAgainButton', true); - }, - - closeModal() { - if (this.state !== 'UPLOADING') { - this._super(...arguments); - } - }, - - // noop - we don't want the enter key doing anything - confirm() {} - }, - - generateRequest() { - let ajax = this.ajax; - let formData = this.formData; - let url = this.uploadUrl; - - this.set('state', 'UPLOADING'); - ajax.post(url, { - data: formData, - processData: false, - contentType: false, - dataType: 'text' - }).then((importResponse) => { - if (importResponse instanceof AcceptedResponse) { - this.set('state', 'PROCESSING'); - } else { - this._uploadSuccess(JSON.parse(importResponse)); - this.set('state', 'COMPLETE'); - } - }).catch((error) => { - this._uploadError(error); - this.set('state', 'ERROR'); - }); - }, - - _uploadSuccess(importResponse) { - let importedCount = importResponse.meta.stats.imported; - const erroredMembers = importResponse.meta.stats.invalid; - let errorCount = erroredMembers.length; - const errorList = {}; - - const errorsWithFormattedMessages = erroredMembers.map((row) => { - const formattedError = row.error - .replace( - 'Value in [members.email] cannot be blank.', - 'Missing email address' - ) - .replace( - 'Value in [members.note] exceeds maximum length of 2000 characters.', - 'Note is too long' - ) - .replace( - 'Value in [members.subscribed] must be one of true, false, 0 or 1.', - 'Value of "Subscribed to emails" must be "true" or "false"' - ) - .replace( - 'Validation (isEmail) failed for email', - 'Invalid email address' - ) - .replace( - /No such customer:[^,]*/, - 'Could not find Stripe customer' - ); - formattedError.split(',').forEach((errorMssg) => { - if (errorList[errorMssg]) { - errorList[errorMssg].count = errorList[errorMssg].count + 1; - } else { - errorList[errorMssg] = { - message: errorMssg, - count: 1 - }; - } - }); - return { - ...row, - error: formattedError - }; - }); - - let errorCsv = unparse(errorsWithFormattedMessages); - let errorCsvBlob = new Blob([errorCsv], {type: 'text/csv'}); - let errorCsvUrl = URL.createObjectURL(errorCsvBlob); - let errorCsvName = importResponse.meta.import_label ? `${importResponse.meta.import_label.name} - Errors.csv` : `Import ${moment().format('YYYY-MM-DD HH:mm')} - Errors.csv`; - - this.set('importResponse', { - importedCount, - errorCount, - errorCsvUrl, - errorCsvName, - errorList: Object.values(errorList) - }); - - // insert auto-created import label into store immediately if present - // ready for filtering the members list - if (importResponse.meta.import_label) { - this.store.pushPayload({ - labels: [importResponse.meta.import_label] - }); - } - - // invoke the passed in confirm action to refresh member data - // @TODO wtf does confirm mean? - this.confirm({label: importResponse.meta.import_label}); - }, - - _uploadError(error) { - let message; - let header = 'Import error'; - - if (isVersionMismatchError(error)) { - this.notifications.showAPIError(error); - } - - // Handle all the specific errors that we know about - if (isUnsupportedMediaTypeError(error)) { - message = 'The file type you uploaded is not supported.'; - } else if (isRequestEntityTooLargeError(error)) { - message = 'The file you uploaded was larger than the maximum file size your server allows.'; - } else if (isDataImportError(error, error.payload)) { - message = htmlSafe(error.payload.errors[0].message); - } else if (isHostLimitError(error) && error?.payload?.errors?.[0]?.code === 'EMAIL_VERIFICATION_NEEDED') { - message = htmlSafe(error.payload.errors[0].message); - - header = 'Woah there cowboy, that\'s a big list'; - this.set('showTryAgainButton', false); - // NOTE: confirm makes sure to refresh the members data in the background - this.confirm(); - } else { // Generic fallback error - message = 'An unexpected error occurred, please try again'; - - console.error(error); // eslint-disable-line - if (error?.payload?.errors?.[0]?.id) { - console.error(`Error ID: ${error.payload.errors[0].id}`); // eslint-disable-line - } - } - - this.set('errorMessage', message); - this.set('errorHeader', header); - } -}); diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs b/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs deleted file mode 100644 index 574c25a4b06..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-mapping.hbs +++ /dev/null @@ -1,28 +0,0 @@ -{{#if this.hasFileData}} - -
-
- -
-
- {{#if (and this.error @showErrors)}} -

{{this.error.message}}

- {{/if}} - - {{#if this.membersStats.memberCount}} -

If an email address in your CSV matches an existing member, they will be updated with the mapped values.

- {{/if}} - -
- - -
-
-{{else}} -
- -
-{{/if}} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-mapping.js b/ghost/admin/app/components/modal-import-members/csv-file-mapping.js deleted file mode 100644 index eb9d3d6c741..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-mapping.js +++ /dev/null @@ -1,72 +0,0 @@ -import Component from '@glimmer/component'; -import MemberImportError from 'ghost-admin/errors/member-import-error'; -import papaparse from 'papaparse'; -import {action} from '@ember/object'; -import {isNone} from '@ember/utils'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -export default class CsvFileMapping extends Component { - @tracked error = null; - @tracked fileData = null; - @tracked labels = null; - - @service membersStats; - - constructor(...args) { - super(...args); - this.parseFileAndGenerateMapping(this.args.file); - } - - parseFileAndGenerateMapping(file) { - papaparse.parse(file, { - header: true, - skipEmptyLines: true, - complete: (result) => { - if (result.data && result.data.length) { - this.fileData = result.data; - } else { - this.fileData = []; - } - this.args.setFileData(this.fileData); - } - }); - } - - get hasFileData() { - return !isNone(this.fileData); - } - - @action - setMapping(mapping) { - if (this.fileData.length === 0) { - this.error = new MemberImportError({ - message: 'File is empty, nothing to import. Please select a different file.' - }); - } else if (!mapping.getKeyByValue('email')) { - this.error = new MemberImportError({ - message: 'Please map "Email" to one of the fields in the CSV.' - }); - } else { - this.error = null; - } - - this.mapping = mapping; - this.setMappingResult(); - } - - @action - updateLabels(labels) { - this.labels = labels; - this.setMappingResult(); - } - - setMappingResult() { - this.args.setMappingResult({ - mapping: this.mapping, - labels: this.labels, - membersCount: this.fileData?.length, - error: this.error - }); - } -} diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.hbs b/ghost/admin/app/components/modal-import-members/csv-file-select.hbs deleted file mode 100644 index bf675cbfc26..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-select.hbs +++ /dev/null @@ -1,20 +0,0 @@ -{{#if this.error}} -
-
{{svg-jar "warning" class="nudge-top--2 w4 h4 fill-red"}}
-

{{this.error.message}}

-
-{{/if}} -
-
- -
- {{svg-jar "upload"}} -
{{this.labelText}}
-
-
-
-
diff --git a/ghost/admin/app/components/modal-import-members/csv-file-select.js b/ghost/admin/app/components/modal-import-members/csv-file-select.js deleted file mode 100644 index 43be7be1ef2..00000000000 --- a/ghost/admin/app/components/modal-import-members/csv-file-select.js +++ /dev/null @@ -1,79 +0,0 @@ -import Component from '@glimmer/component'; -import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax'; -import {action} from '@ember/object'; -import {tracked} from '@glimmer/tracking'; - -export default class CsvFileSelect extends Component { - labelText = 'Select or drop a CSV file'; - - @tracked error = null; - @tracked dragClass = null; - - /* - constructor(...args) { - super(...args); - assert(this.args.setFile); - } - */ - - @action - fileSelected(fileList) { - let [file] = Array.from(fileList); - - try { - this._validateFileType(file); - this.error = null; - } catch (err) { - this.error = err; - return; - } - - this.args.setFile(file); - } - - @action - dragOver(event) { - if (!event.dataTransfer) { - return; - } - - // this is needed to work around inconsistencies with dropping files - // from Chrome's downloads bar - if (navigator.userAgent.indexOf('Chrome') > -1) { - let eA = event.dataTransfer.effectAllowed; - event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy'; - } - - event.stopPropagation(); - event.preventDefault(); - - this.dragClass = '-drag-over'; - } - - @action - dragLeave(event) { - event.preventDefault(); - this.dragClass = null; - } - - @action - drop(event) { - event.preventDefault(); - this.dragClass = null; - if (event.dataTransfer.files) { - this.fileSelected(event.dataTransfer.files); - } - } - - _validateFileType(file) { - let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name); - - if (extension.toLowerCase() !== 'csv') { - throw new UnsupportedMediaTypeError({ - message: 'The file type you uploaded is not supported' - }); - } - - return true; - } -} diff --git a/ghost/admin/app/components/modal-unsubscribe-members.hbs b/ghost/admin/app/components/modal-unsubscribe-members.hbs deleted file mode 100644 index 26895291aaf..00000000000 --- a/ghost/admin/app/components/modal-unsubscribe-members.hbs +++ /dev/null @@ -1,66 +0,0 @@ - -{{svg-jar "close"}} - -{{#if this.confirmed}} -
- {{#if this.error}} -
- {{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}} -
-

- {{this.error}} -

-
-
- {{else}} -
- {{svg-jar "check-circle" class="w4 h4 stroke-green mr2"}} -

- {{gh-pluralize this.response.stats.successful "member"}} - successfully unsubscribed -

-
- {{#if this.response.stats.unsuccessful}} -
- {{svg-jar "warning" class="w4 h4 fill-red mr2 nudge-top--3"}} -
-

- {{gh-pluralize this.response.stats.unsuccessful "member"}} - failed to unsubscribe -

-
-
- {{/if}} - {{/if}} -
-{{else}} - -{{/if}} - - diff --git a/ghost/admin/app/components/modal-unsubscribe-members.js b/ghost/admin/app/components/modal-unsubscribe-members.js deleted file mode 100644 index b462223f491..00000000000 --- a/ghost/admin/app/components/modal-unsubscribe-members.js +++ /dev/null @@ -1,35 +0,0 @@ -import ModalComponent from 'ghost-admin/components/modal-base'; -import {alias} from '@ember/object/computed'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; - -export default ModalComponent.extend({ - membersStats: service(), - - shouldCancelSubscriptions: false, - - // Allowed actions - confirm: () => {}, - - member: alias('model'), - - actions: { - confirm() { - this.unsubscribeMemberTask.perform(); - } - }, - - unsubscribeMemberTask: task(function* () { - try { - const response = yield this.confirm(); - this.set('response', response); - this.set('confirmed', true); - } catch (e) { - if (e.payload?.errors) { - this.set('confirmed', true); - this.set('error', e.payload.errors[0].message); - } - throw e; - } - }).drop() -}); diff --git a/ghost/admin/app/components/offers/segment-select.hbs b/ghost/admin/app/components/offers/segment-select.hbs deleted file mode 100644 index 72c381042c7..00000000000 --- a/ghost/admin/app/components/offers/segment-select.hbs +++ /dev/null @@ -1,22 +0,0 @@ - - {{option.name}} - - -{{#if @showMemberCount}} - -{{/if}} diff --git a/ghost/admin/app/components/offers/segment-select.js b/ghost/admin/app/components/offers/segment-select.js deleted file mode 100644 index 942a21ad6c1..00000000000 --- a/ghost/admin/app/components/offers/segment-select.js +++ /dev/null @@ -1,193 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -const RETENTION_OFFER_OPTIONS = [ - { - cadence: 'month', - id: 'retention:month', - name: 'Monthly Retention' - }, - { - cadence: 'year', - id: 'retention:year', - name: 'Yearly Retention' - } -]; - -export default class OffersSegmentSelect extends Component { - @service store; - - @tracked _options = []; - @tracked offers = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get options() { - return this._options; - } - - get flatOptions() { - const options = []; - - function getOptions(option) { - if (option.options) { - return option.options.forEach(getOptions); - } - - options.push(option); - } - this._options.forEach(getOptions); - return options; - } - - getOfferById(id) { - return this.offers.find((offer) => { - return offer.id === id; - }); - } - - get selectedOptions() { - const selectedIds = new Set((this.args.offers || []).map(offer => offer.id).filter(id => !!id)); - const selected = []; - const consumedIds = new Set(); - const retentionCadenceOptions = this.flatOptions.filter(option => Array.isArray(option.offerIds)); - - retentionCadenceOptions.forEach((option) => { - if (option.offerIds.length > 0 && option.offerIds.every(id => selectedIds.has(id))) { - selected.push(option); - option.offerIds.forEach(id => consumedIds.add(id)); - } - }); - - selectedIds.forEach((id) => { - if (consumedIds.has(id)) { - return; - } - - const option = this.flatOptions.find((flatOption) => { - return !Array.isArray(flatOption.offerIds) && flatOption.id === id; - }); - - if (option) { - selected.push(option); - return; - } - - const offer = this.getOfferById(id); - if (offer) { - selected.push({ - id: offer.id, - name: offer.name, - class: 'segment-offer-redemptions-hidden' - }); - } - }); - - return selected; - } - - @action - setSegment(options) { - const offerIds = new Set(); - - options.forEach((option) => { - if (Array.isArray(option.offerIds)) { - option.offerIds.forEach(id => offerIds.add(id)); - return; - } - - if (option.id) { - offerIds.add(option.id); - } - }); - - const ids = Array.from(offerIds).reduce((result, id) => { - const offer = this.getOfferById(id); - - if (!offer) { - return result; - } - - result.push({ - id, - name: offer.name - }); - - return result; - }, []); - - this.args.onChange?.(ids); - } - - getRetentionOptions(offers) { - const retentionOffersByCadence = { - month: [], - year: [] - }; - - offers.forEach((offer) => { - const redemptionType = offer.redemptionType; - if (redemptionType !== 'retention') { - return; - } - - if (offer.cadence === 'month' || offer.cadence === 'year') { - retentionOffersByCadence[offer.cadence].push(offer.id); - } - }); - - return RETENTION_OFFER_OPTIONS - .filter(definition => retentionOffersByCadence[definition.cadence].length > 0) - .map(definition => ({ - name: definition.name, - id: definition.id, - offerIds: retentionOffersByCadence[definition.cadence], - class: 'segment-offer-redemptions' - })); - } - - @task - *fetchOptionsTask() { - const options = yield []; - - const offers = yield this.store.findAll('offer'); - this.offers = offers; - - if (offers.length > 0) { - const offersGroup = { - groupName: 'Offers', - options: [] - }; - - offers.forEach((offer) => { - if (offer.redemptionType === 'retention') { - return; - } - - offersGroup.options.push({ - name: offer.name, - id: offer.id, - class: 'segment-offer-redemptions' - }); - }); - - offersGroup.options.push(...this.getRetentionOptions(offers)); - - if (offersGroup.options.length > 0) { - options.push(offersGroup); - } - } - - this._options = options; - } -} diff --git a/ghost/admin/app/components/tiers/segment-select.hbs b/ghost/admin/app/components/tiers/segment-select.hbs deleted file mode 100644 index c0e5df44ac2..00000000000 --- a/ghost/admin/app/components/tiers/segment-select.hbs +++ /dev/null @@ -1,22 +0,0 @@ - - {{option.name}} - - -{{#if @showMemberCount}} - -{{/if}} diff --git a/ghost/admin/app/components/tiers/segment-select.js b/ghost/admin/app/components/tiers/segment-select.js deleted file mode 100644 index 421b038a53e..00000000000 --- a/ghost/admin/app/components/tiers/segment-select.js +++ /dev/null @@ -1,100 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; -import {task} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class TiersSegmentSelect extends Component { - @service store; - @service feature; - - @tracked _options = []; - @tracked tiers = []; - - get renderInPlace() { - return this.args.renderInPlace === undefined ? false : this.args.renderInPlace; - } - - constructor() { - super(...arguments); - this.fetchOptionsTask.perform(); - } - - get options() { - return this._options; - } - - get flatOptions() { - const options = []; - - function getOptions(option) { - if (option.options) { - return option.options.forEach(getOptions); - } - - options.push(option); - } - - this._options.forEach(getOptions); - - return options; - } - - get selectedOptions() { - const tierList = (this.args.tiers || []).map((tier) => { - return this.tiers.find((p) => { - return p.id === tier.id || p.id === tier.id; - }); - }).filter(d => !!d); - const tierIdList = tierList.map(d => d.id); - return this.flatOptions.filter(option => tierIdList.includes(option.id)); - } - - @action - setSegment(options) { - let ids = options.mapBy('id').map((id) => { - let tier = this.tiers.find((p) => { - return p.id === id; - }); - return { - id: tier.id, - slug: tier.slug, - name: tier.name - }; - }) || []; - this.args.onChange?.(ids); - } - - @task - *fetchOptionsTask() { - const options = yield []; - - // fetch all tiers with count - // TODO: add `include: 'count.members` to query once API supports - const tiers = yield this.store.query('tier', {filter: 'type:paid', limit: 'all', include: 'monthly_price,yearly_price,benefits'}); - this.tiers = tiers; - - if (tiers.length > 0) { - const tiersGroup = { - groupName: 'Tiers', - options: [] - }; - - tiers.forEach((tier) => { - tiersGroup.options.push({ - name: tier.name, - id: tier.id, - count: tier.count?.members, - class: 'segment-tier' - }); - }); - - options.push(tiersGroup); - if (this.args.selectDefaultTier && !this.args.tiers) { - this.setSegment([tiersGroup.options[0]]); - } - } - - this._options = options; - } -} diff --git a/ghost/admin/app/controllers/member.js b/ghost/admin/app/controllers/member.js index d1d23409b2f..f3ca17829cd 100644 --- a/ghost/admin/app/controllers/member.js +++ b/ghost/admin/app/controllers/member.js @@ -1,4 +1,4 @@ -import Controller, {inject as controller} from '@ember/controller'; +import Controller from '@ember/controller'; import DeleteMemberModal from '../components/members/modals/delete-member'; import DisableCommentingModal from '../components/members/modals/disable-commenting'; import EmberObject, {action, defineProperty} from '@ember/object'; @@ -12,7 +12,6 @@ import {tracked} from '@glimmer/tracking'; const SCRATCH_PROPS = ['name', 'email', 'note']; export default class MemberController extends Controller { - @controller members; @service ajax; @service session; @service dropdown; @@ -24,6 +23,7 @@ export default class MemberController extends Controller { @service notifications; @service router; @service labelsManager; + @service stateBridge; @service store; queryParams = [ @@ -127,6 +127,15 @@ export default class MemberController extends Controller { return `${createdDate} (${memberSince})`; } + invalidateMembersCache() { + this.stateBridge.triggerEmberDataChange('update', 'member', this.member.id, null); + } + + invalidateMemberCommenting() { + this.invalidateMembersCache(); + this.stateBridge.triggerEmberDataChange('update', 'comment', this.member.id, null); + } + // Actions ----------------------------------------------------------------- @action @@ -162,7 +171,7 @@ export default class MemberController extends Controller { member: this.member, afterDelete: () => { this.membersStats.invalidate(); - this.members.refreshData(); + this.invalidateMembersCache(); this.membersCountCache.clear(); this.router.transitionTo(this.membersListPath); } @@ -174,7 +183,7 @@ export default class MemberController extends Controller { this.modals.open(LogoutMemberModal, { member: this.member, afterLogout: () => { - this.members.refreshData(); + this.invalidateMembersCache(); } }); } @@ -184,6 +193,7 @@ export default class MemberController extends Controller { this.modals.open(DisableCommentingModal, { member: this.member, afterDisable: () => { + this.invalidateMemberCommenting(); this.fetchMemberTask.perform(this.member.id); } }); @@ -196,11 +206,7 @@ export default class MemberController extends Controller { const url = this.ghostPaths.url.api('members', this.member.id, 'commenting', 'enable'); await this.ajax.post(url); - // Invalidate React Query cache so comments list reflects changes - if (window.adminXQueryClient) { - window.adminXQueryClient.invalidateQueries({queryKey: ['CommentsResponseType']}); - window.adminXQueryClient.invalidateQueries({queryKey: ['MembersResponseType']}); - } + this.invalidateMemberCommenting(); await this.fetchMemberTask.perform(this.member.id); this.notifications.showNotification(`Commenting has been enabled for ${this.member.name || this.member.email}.`, {type: 'success'}); @@ -241,7 +247,7 @@ export default class MemberController extends Controller { yield member.save(); member.updateLabels(); member.labels.forEach(label => this.labelsManager.addLabel(label)); - this.members.refreshData(); + this.invalidateMembersCache(); this.setInitialRelationshipValues(); diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js deleted file mode 100644 index 204e48bea6f..00000000000 --- a/ghost/admin/app/controllers/members.js +++ /dev/null @@ -1,619 +0,0 @@ -import BulkAddMembersLabelModal from '../components/members/modals/bulk-add-label'; -import BulkDeleteMembersModal from '../components/members/modals/bulk-delete'; -import BulkRemoveMembersLabelModal from '../components/members/modals/bulk-remove-label'; -import BulkUnsubscribeMembersModal from '../components/members/modals/bulk-unsubscribe'; -import Controller from '@ember/controller'; -import fetch from 'fetch'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import moment from 'moment-timezone'; -import {A} from '@ember/array'; -import {TrackedArray} from 'tracked-built-ins'; -import {action} from '@ember/object'; -import {didCancel, task, timeout} from 'ember-concurrency'; -import {ghPluralize} from 'ghost-admin/helpers/gh-pluralize'; -import {inject} from 'ghost-admin/decorators/inject'; -import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; -import {inject as service} from '@ember/service'; -import {tracked} from '@glimmer/tracking'; - -const PAID_PARAMS = [{ - name: 'All members', - value: null -}, { - name: 'Free members', - value: 'false' -}, { - name: 'Paid members', - value: 'true' -}]; - -export default class MembersController extends Controller { - @service ajax; - @service ellaSparse; - @service feature; - @service ghostPaths; - @service membersStats; - @service modals; - @service router; - @service labelsManager; - @service store; - @service utils; - @service settings; - - @inject config; - - queryParams = [ - 'label', - {paidParam: 'paid'}, - {searchParam: 'search'}, - {orderParam: 'order'}, - {filterParam: 'filter'}, - {postAnalytics: 'post'} - ]; - - @tracked members = A([]); - @tracked searchParam = ''; - @tracked searchIsFocused = false; - @tracked filterParam = null; - @tracked softFilterParam = null; - @tracked paidParam = null; - @tracked label = null; - @tracked orderParam = null; - @tracked modalLabel = null; - @tracked showLabelModal = false; - @tracked filters = A([]); - @tracked softFilters = A([]); - @tracked isExporting = false; - - @tracked _searchedLabels = new TrackedArray(); - _searchedLabelsQuery = null; - _searchedLabelsMeta = null; - - @tracked parseFilterParamCounter = 0; - - /** - * Flag used to determine if we should return to the analytics page - */ - @tracked postAnalytics = null; - - get fromAnalytics() { - if (!this.postAnalytics) { - return null; - } - return [this.postAnalytics]; - } - - paidParams = PAID_PARAMS; - - constructor() { - super(...arguments); - } - - // Computed properties ----------------------------------------------------- - - get listHeader() { - let {searchParam, selectedLabel, members} = this; - - if (members.loading) { - return 'Loading...'; - } - - if (searchParam) { - return 'Search result'; - } - - let count = ghPluralize(members.length, 'member'); - - if (selectedLabel && selectedLabel.slug) { - if (members.length > 1) { - return `${count} match current filter`; - } else { - return `${count} matches current filter`; - } - } - - return count; - } - - get hideSearchBar() { - return !this.members.length - && !this.searchParam - && !this.searchIsFocused; - } - - get showingAll() { - return !this.searchParam && !this.paidParam && !this.label && !this.filterParam && !this.softFilterParam; - } - - get availableOrders() { - // don't return anything if email analytics is disabled because - // we don't want to show an order dropdown with only a single option - - if (this.feature.get('emailAnalytics')) { - return [{ - name: 'Newest', - value: null - }, { - name: 'Open rate', - value: 'email_open_rate' - }]; - } - - return []; - } - - get selectedOrder() { - return this.availableOrders.find(order => order.value === this.orderParam); - } - - get availableLabels() { - let options = [{name: 'All labels', slug: null}]; - - options = options.concat(this.labelsManager.labels); - - if (this.label && !options.findBy('slug', this.label)) { - const foundLabel = this.labelsManager.findBySlug(this.label); - if (foundLabel) { - options.push(foundLabel); - } - } - - return options; - } - - @action - async loadInitialLabels() { - if (!this.labelsManager.hasLoaded) { - await this.labelsManager.loadMoreTask.perform(); - } - } - - @task({drop: true}) - *loadMoreLabelsTask(isSearch = false) { - if (isSearch) { - if (this.searchLabelsTask.isRunning) { - return; - } - - if (!this._searchedLabelsMeta || (this._searchedLabelsMeta.pagination.pages <= this._searchedLabelsMeta.pagination.page)) { - return; - } - - const page = this._searchedLabelsMeta.pagination.page + 1; - const labels = yield this.labelsManager.searchLabelsTask.perform(this._searchedLabelsQuery, {page}); - this._searchedLabels.push(...this.labelsManager.sortLabels(labels.toArray())); - this._searchedLabelsMeta = labels.meta; - } else { - yield this.labelsManager.loadMoreTask.perform(); - } - } - - @task - *searchLabelsTask(term) { - this._searchedLabelsQuery = term; - const labels = yield this.labelsManager.searchLabelsTask.perform(term); - this._searchedLabelsMeta = labels.meta; - - this._searchedLabels = new TrackedArray(this.labelsManager.sortLabels(labels.toArray())); - return this._searchedLabels; - } - - get selectedLabel() { - let {label, availableLabels} = this; - return availableLabels.findBy('slug', label); - } - - get labelModalData() { - let label = this.modalLabel; - let labels = this.availableLabels; - - return { - label, - labels - }; - } - - get selectedPaidParam() { - return this.paidParams.findBy('value', this.paidParam) || {value: '!unknown'}; - } - - get isFiltered() { - return !!(this.label || this.paidParam || this.searchParam || this.filterParam); - } - - get availableFilters() { - return this.softFilters.length ? this.softFilters : this.filters; - } - - get filterColumns() { - const columns = this.availableFilters.flatMap((filter) => { - if (filter.properties?.getColumns) { - return filter.properties?.getColumns(filter).map((c) => { - return { - label: filter.properties.columnLabel, // default value if not provided - ...c, - name: filter.type - }; - }); - } - if (filter.properties?.columnLabel) { - return [ - { - name: filter.type, - label: filter.properties.columnLabel, - getValue: filter.properties.getColumnValue ? (member => filter.properties.getColumnValue(member, filter)) : null - } - ]; - } - return []; - }); - // Remove duplicates by label - const uniqueColumns = columns.filter((c, i) => { - return columns.findIndex(c2 => c2.label === c.label) === i; - }); - return uniqueColumns.splice(0, 2); // Maximum 2 columns - } - - /* - * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: - * - Billing period - * - Stripe subscription status - * - Paid start date - * - Next billing date - * - Subscription started on post/page - * - Offers - * - * For more context, see: - * - https://linear.app/tryghost/issue/ENG-1484 - * - https://linear.app/tryghost/issue/ENG-1466 - */ - get isBulkDeletePermitted() { - if (!this.isFiltered) { - return false; - } - - const stripeFilters = this.filters.filter(f => [ - 'subscriptions.plan_interval', - 'subscriptions.status', - 'subscriptions.start_date', - 'subscriptions.current_period_end', - 'conversion', - 'offer_redemptions' - ].includes(f.type)); - - if (stripeFilters && stripeFilters.length >= 1) { - return false; - } - - return true; - } - - includeTierQuery() { - const availableFilters = this.filters.length ? this.filters : this.softFilters; - return availableFilters.some((f) => { - return f.type === 'tier'; - }); - } - - getApiQueryObject({params, extraFilters = []} = {}) { - let {label, paidParam, searchParam, filterParam} = params ? params : this; - - if (filterParam) { - // If the provided filter param is a single filter related to newsletter subscription status - // remove the surrounding brackets to prevent https://github.com/TryGhost/NQL/issues/16 - const BRACKETS_SURROUNDED_RE = /^\(.*\)$/; - const MULTIPLE_GROUPS_RE = /\).*\(/; - - if (BRACKETS_SURROUNDED_RE.test(filterParam) && !MULTIPLE_GROUPS_RE.test(filterParam)) { - filterParam = filterParam.slice(1, -1); - } - } - - let filters = []; - - filters = filters.concat(extraFilters); - - if (label) { - filters.push(`label:'${label}'`); - } - - if (paidParam !== null) { - if (paidParam === 'true') { - filters.push('status:-free'); - } else { - filters.push('status:free'); - } - } - if (filterParam) { - filters.push(filterParam); - } - - let searchQuery = searchParam ? {search: searchParam} : {}; - - return Object.assign({}, {filter: filters.join('+')}, searchQuery); - } - - // Actions ----------------------------------------------------------------- - - @action - refreshData() { - try { - this.fetchMembersTask.perform(); - this.fetchLabelsTask.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - this.membersStats.fetchMemberCount(); - } - - @action - changeOrder(order) { - this.orderParam = order.value; - } - - /** - * A user clicked 'Apply filters' when editing the filter - */ - @action - applyFilter(filterStr, filters) { - this.softFilters = A([]); - this.filterParam = filterStr || null; - this.filters = filters; - } - - /** - * Called to set the filters after the url filterParam has been parsed again - */ - @action - applyParsedFilter(filters) { - this.softFilters = A([]); - this.filters = filters; - } - - /** - * Already start filtering when the user is editing a filter, without applying it to the URL yet, - * and to still allow a cancel action to revert to the previous filters. - */ - @action - applySoftFilter(filterStr, filters) { - this.softFilters = filters; - this.softFilterParam = filterStr || null; - let {label, paidParam, searchParam, orderParam} = this; - this.fetchMembersTask.perform({label, paidParam, searchParam, orderParam, filterParam: filterStr}); - } - - @action - resetSoftFilter() { - if (this.softFilters.length > 0 || !!this.softFilterParam) { - this.softFilters = A([]); - this.softFilterParam = null; - this.fetchMembersTask.perform(); - } - } - - @action - resetFilter() { - this.softFilters = A([]); - this.softFilterParam = null; - this.filters = A([]); - this.filterParam = null; - this.fetchMembersTask.perform(); - } - - @action - search(e) { - this.searchTask.perform(e.target.value); - } - - @action - exportData() { - let exportUrl = ghostPaths().url.api('members/upload'); - let downloadParams = new URLSearchParams(this.getApiQueryObject()); - downloadParams.set('limit', 'all'); - - const url = `${exportUrl}?${downloadParams.toString()}`; - - // Set loading state - this.isExporting = true; - - fetch(url, {method: 'GET'}) - .then(res => res.blob()) - .then((blob) => { - const blobUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - const datetime = (new Date()).toJSON().substring(0, 10); - - a.href = blobUrl; - a.download = `members.${datetime}.csv`; - document.body.appendChild(a); - - a.click(); - - // Cleanup - a.remove(); - URL.revokeObjectURL(blobUrl); - }) - .catch(() => { - // Handle errors silently - // A more robust implementation would show an error notification - }) - .finally(() => { - // Reset loading state - this.isExporting = false; - }); - } - - @action - changeLabel(label, e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - this.label = label.slug; - } - - @action - editLabel(label, e) { - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - let modalLabel = this.availableLabels.findBy('slug', label); - this.modalLabel = modalLabel; - this.showLabelModal = !this.showLabelModal; - } - - @action - toggleLabelModal() { - this.showLabelModal = !this.showLabelModal; - } - - @action - bulkAddLabel() { - this.modals.open(BulkAddMembersLabelModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - bulkRemoveLabel() { - this.modals.open(BulkRemoveMembersLabelModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - bulkUnsubscribe() { - this.modals.open(BulkUnsubscribeMembersModal, { - query: this.getApiQueryObject(), - onComplete: this.resetAndReloadMembers - }); - } - - @action - resetAndReloadMembers() { - this.store.unloadAll('member'); - this.reload(); - } - - @action - bulkDelete() { - this.modals.open(BulkDeleteMembersModal, { - query: this.getApiQueryObject(), - onComplete: () => { - // reset, clear filters, and reload list and counts - this.store.unloadAll('member'); - this.router.transitionTo('members.index', {queryParams: Object.assign(resetQueryParams('members.index'))}); - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - } - }); - } - - @action - changePaidParam(paid) { - this.paidParam = paid.value; - } - - // Tasks ------------------------------------------------------------------- - - @task({restartable: true}) - *searchTask(query) { - yield timeout(250); // debounce - this.searchParam = query; - } - - @task({restartable: true}) - *fetchLabelsTask() { - this.labelsManager.reset(); - yield this.labelsManager.loadMoreTask.perform(); - } - - @task({restartable: true}) - *fetchMembersTask(params) { - // params is undefined when called as a "refresh" of the model - let {label, paidParam, searchParam, orderParam, filterParam} = typeof params === 'undefined' ? this : params; - - // use a fixed created_at date so that subsequent pages have a consistent index - let startDate = new Date(); - - // bypass the stale data shortcut if params change - let forceReload = !params - || label !== this._lastLabel - || paidParam !== this._lastPaidParam - || searchParam !== this._lastSearchParam - || orderParam !== this._lastOrderParam - || filterParam !== this._lastFilterParam; - this._lastLabel = label; - this._lastPaidParam = paidParam; - this._lastSearchParam = searchParam; - this._lastOrderParam = orderParam; - this._lastFilterParam = filterParam; - - // unless we have a forced reload, do not re-fetch the members list unless it's more than a minute old - // keeps navigation between list->details->list snappy - if (!forceReload && this._startDate && !(this._startDate - startDate > 1 * 60 * 1000)) { - return this.members; - } - - this._startDate = startDate; - - this.members = yield this.ellaSparse.array((range = {}, query = {}) => { - const searchQuery = this.getApiQueryObject({ - params, - extraFilters: [`created_at:<='${moment.utc(this._startDate).format('YYYY-MM-DD HH:mm:ss')}'`] - }); - const order = orderParam ? `${orderParam} desc` : `created_at desc`; - const includes = ['labels', 'tiers']; - - query = Object.assign({ - include: includes.join(','), - order, - limit: range.length, - page: range.page - }, searchQuery, query); - - return this.store.query('member', query).then((result) => { - return { - data: result, - total: result.meta.pagination.total - }; - }); - }, { - limit: 50 - }); - } - - // Internal ---------------------------------------------------------------- - - resetFilters(params) { - if (!params?.filterParam) { - this.filters = A([]); - this.softFilterParam = null; - this.softFilters = A([]); - } else { - this.filterParam = params.filterParam; - - // Trigger a did-update call in the filter component, so we get freshly parsed filters - // This is temporary, and a ugly pattern, but essential to make it work for now, until we moved the filter parsing logic - // out of the component - this.parseFilterParamCounter += 1; - } - } - - reload(params) { - this.membersStats.invalidate(); - this.membersStats.fetchCounts(); - this.fetchMembersTask.perform(params); - } -} diff --git a/ghost/admin/app/controllers/members/import.js b/ghost/admin/app/controllers/members/import.js deleted file mode 100644 index 6ea239b50e4..00000000000 --- a/ghost/admin/app/controllers/members/import.js +++ /dev/null @@ -1,28 +0,0 @@ -import Controller, {inject as controller} from '@ember/controller'; -import {action} from '@ember/object'; -import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; -import {inject as service} from '@ember/service'; - -export default class ImportController extends Controller { - @service feature; - @service router; - @controller members; - - @action - refreshMembers({label} = {}) { - if (label) { - let queryParams = Object.assign(resetQueryParams('members.index'), {filter: `label:[${label.slug}]`}); - this.router.transitionTo({queryParams}); - } - this.members.refreshData(); - } - - @action - close(from) { - if (from === 'background') { - return; - } - - this.router.transitionTo('members'); - } -} diff --git a/ghost/admin/app/errors/member-import-error.js b/ghost/admin/app/errors/member-import-error.js deleted file mode 100644 index b41ad2e3f9a..00000000000 --- a/ghost/admin/app/errors/member-import-error.js +++ /dev/null @@ -1,8 +0,0 @@ -export default class EmailFailedError extends Error { - constructor({message, context, type = 'error'}) { - super(message); - this.name = 'MemberImportError'; - this.context = context; - this.type = type; - } -} diff --git a/ghost/admin/app/helpers/reset-query-params.js b/ghost/admin/app/helpers/reset-query-params.js index 68fbc4cc65f..5c2d2d00242 100644 --- a/ghost/admin/app/helpers/reset-query-params.js +++ b/ghost/admin/app/helpers/reset-query-params.js @@ -15,13 +15,6 @@ export const DEFAULT_QUERY_PARAMS = { tag: null, order: null }, - 'members.index': { - label: null, - paid: null, - search: null, - filter: null, - order: null - }, 'members-activity': { excludedEvents: null, member: null diff --git a/ghost/admin/app/routes/member.js b/ghost/admin/app/routes/member.js index 7f0da2f1167..40b367d662d 100644 --- a/ghost/admin/app/routes/member.js +++ b/ghost/admin/app/routes/member.js @@ -5,7 +5,6 @@ import {action} from '@ember/object'; import {inject as service} from '@ember/service'; export default class MembersRoute extends MembersManagementRoute { - @service feature; @service modals; @service router; @service('unsaved-changes') unsavedChanges; diff --git a/ghost/admin/app/routes/members.js b/ghost/admin/app/routes/members.js deleted file mode 100644 index fbc30f49bf6..00000000000 --- a/ghost/admin/app/routes/members.js +++ /dev/null @@ -1,65 +0,0 @@ -import MembersManagementRoute from './members-management'; -import {didCancel} from 'ember-concurrency'; -import {inject as service} from '@ember/service'; - -export default class MembersRoute extends MembersManagementRoute { - @service store; - - queryParams = { - label: {refreshModel: true}, - searchParam: {refreshModel: true, replace: true}, - paidParam: {refreshModel: true}, - orderParam: {refreshModel: true}, - filterParam: {refreshModel: true}, - postAnalytics: {refreshModel: false} - }; - - model(params) { - if (this.feature.membersForward) { - return null; - } - - this.controllerFor('members').resetFilters(params); - return this.controllerFor('members').fetchMembersTask.perform(params); - } - - // trigger a background load of members plus labels for filter dropdown - setupController(controller) { - super.setupController(...arguments); - - if (this.feature.membersForward) { - return; - } - - try { - controller.fetchLabelsTask.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; - } - } - - resetController(controller, _isExiting, transition) { - super.resetController(...arguments); - - if (controller.postAnalytics) { - controller.set('postAnalytics', null); - // Only reset filters if we are not going to member route - // Otherwise the filters will be gone if we return - if (!transition?.to?.name?.startsWith('member')) { - controller.set('filterParam', null); - } - } - } - - buildRouteInfoMetadata() { - return { - titleToken: 'Members', - mainClasses: ['gh-main-fullwidth'] - }; - } -} diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js deleted file mode 100644 index 23eaf73fad4..00000000000 --- a/ghost/admin/app/routes/members/import.js +++ /dev/null @@ -1,4 +0,0 @@ -import MembersManagementRoute from '../members-management'; - -export default class MembersImportRoute extends MembersManagementRoute { -} diff --git a/ghost/admin/app/services/member-import-validator.js b/ghost/admin/app/services/member-import-validator.js deleted file mode 100644 index 061f1f812cb..00000000000 --- a/ghost/admin/app/services/member-import-validator.js +++ /dev/null @@ -1,133 +0,0 @@ -import Service, {inject as service} from '@ember/service'; -import classic from 'ember-classic-decorator'; -import validator from 'validator'; -import {isEmpty} from '@ember/utils'; - -@classic -export default class MemberImportValidatorService extends Service { - @service ajax; - @service feature; - @service membersUtils; - - @service ghostPaths; - - check(data) { - let sampledData = this._sampleData(data); - let mapping = this._detectDataTypes(sampledData); - return mapping; - } - - /** - * Method implements following sampling logic: - * Locate 10 non-empty cells from the start/middle(ish)/end of each column (30 non-empty values in total). - * If the data contains 30 rows or fewer, all rows should be validated. - * - * @param {Array} data JSON objects mapped from CSV file - * @param {number} validationSampleSize number of rows to sample - */ - _sampleData(data, validationSampleSize = 30) { - let validatedSet = [{}]; - - if (data && data.length > validationSampleSize) { - let sampleKeys = Object.keys(data[0]); - - sampleKeys.forEach(function (key) { - const nonEmptyKeyEntries = data.filter(entry => !isEmpty(entry[key])); - let sampledEntries = []; - - if (nonEmptyKeyEntries.length <= validationSampleSize) { - sampledEntries = nonEmptyKeyEntries; - } else { - // take 3 equal parts from head, tail and middle of the data set - const partitionSize = validationSampleSize / 3; - - const head = data.slice(0, partitionSize); - const tail = data.slice((data.length - partitionSize), data.length); - - const middleIndex = Math.floor(data.length / 2); - const middleStartIndex = middleIndex - 2; - const middleEndIndex = middleIndex + 3; - const middle = data.slice(middleStartIndex, middleEndIndex); - - validatedSet.push(...head); - validatedSet.push(...middle); - validatedSet.push(...tail); - } - - sampledEntries.forEach((entry, index) => { - if (!validatedSet[index]) { - validatedSet[index] = {}; - } - - validatedSet[index][key] = entry[key]; - }); - }); - } else { - validatedSet = data; - } - - return validatedSet; - } - - /** - * Detects supported data types and auto-detects following needed for validation: email - * - * Returned "mapping" object contains mappings that could be accepted by the API - * to map validated types. - * @param {Array} data sampled data containing non empty values - */ - _detectDataTypes(data) { - const supportedTypes = [ - 'email', - 'name', - 'note', - 'subscribed_to_emails', - 'complimentary_plan', - 'stripe_customer_id', - 'labels', - 'created_at' - ]; - - if (this.feature.importMemberTier) { - supportedTypes.push('import_tier'); - } - - if (this.feature.giftSubscriptions) { - supportedTypes.push('gift_id'); - } - - const autoDetectedTypes = [ - 'email' - ]; - - let mapping = {}; - let i = 0; - // looping through all sampled data until needed data types are detected - while (i <= (data.length - 1)) { - if (mapping.email && mapping.stripe_customer_id) { - break; - } - - let entry = data[i]; - for (const [key, value] of Object.entries(entry)) { - if (!mapping.email && validator.isEmail(value)) { - mapping.email = key; - continue; - } - - if (!mapping.name && /name/.test(key)) { - mapping.name = key; - continue; - } - - if (!mapping[key] && supportedTypes.includes(key) && !(autoDetectedTypes.includes(key))) { - mapping[key] = key; - } - } - - i += 1; - } - - return mapping; - } -} diff --git a/ghost/admin/app/services/state-bridge.js b/ghost/admin/app/services/state-bridge.js index 2ce3dfd2900..93cdb0cc7c5 100644 --- a/ghost/admin/app/services/state-bridge.js +++ b/ghost/admin/app/services/state-bridge.js @@ -277,8 +277,8 @@ export default class StateBridgeService extends Service.extend(Evented) { // Check if current route matches any of the specified routes const routeMatches = routes.some((route) => { - // Support both exact matches and subpath matches (e.g., "members" - // matches "members.index") + // Support both exact matches and subpath matches (e.g., "settings" + // matches "settings.history") return currentRouteName === route || currentRouteName.startsWith(route + '.'); }); diff --git a/ghost/admin/app/styles/app-dark.css b/ghost/admin/app/styles/app-dark.css index d3f1763fa2d..f52dd4f9a54 100644 --- a/ghost/admin/app/styles/app-dark.css +++ b/ghost/admin/app/styles/app-dark.css @@ -41,7 +41,6 @@ @import "components/browser-preview.css"; @import "components/stacks.css"; @import "components/browser-preview.css"; -@import "components/filter-builder.css"; @import "components/modal-about.css"; @@ -883,34 +882,6 @@ input:focus, } } -/* Members */ -.members-header .view-actions input.gh-members-list-searchfield { - border-color: var(--lightgrey) -} - -.gh-members-help-card { - background: var(--dark-main-bg-color); -} - -.gh-members-chart-header { - background: var(--white); -} -.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - box-shadow: 0 0 0 1px color-mod(var(--darkgrey) l(-27%) blackness(+15%) alpha(50%)); -} - -.gh-members-import-table::before { - background: #191b1f; -} - -.gh-members-import-table::after { - background: #191b1f; -} -.gh-import-member-select, -.gh-import-member-select select { - background: none !important; -} - .fullscreen-modal-email-preview .gh-pe-mobile-container, .fullscreen-modal-email-preview .gh-pe-desktop-container{ background: var(--dark-main-bg-color); @@ -934,10 +905,6 @@ input:focus, border: 1px solid var(--hairline-color-1); } -.members-list .gh-list-header { - background: var(--dark-main-bg-color); -} - /* Members activity */ .gh-member-newsletter-row { border-bottom: 1px solid var(--grey-900); @@ -1054,10 +1021,6 @@ input:focus, background: linear-gradient(90deg, rgba(21,23,26,1) 90%, rgba(21,23,26,0) 100%); } -.gh-filter-builder .gh-filters { - background: var(--whitegrey); -} - .kg-settings-headerstyle-btn-group .gh-btn { border-color: var(--midlightgrey); } @@ -1166,7 +1129,6 @@ kbd { .gh-pages-placeholder, .gh-posts-placeholder, .gh-tags-placeholder, -.gh-members-empty .gh-members-placeholder, .no-posts .gh-members-placeholder { fill: var(--midgrey-d2); } diff --git a/ghost/admin/app/styles/app.css b/ghost/admin/app/styles/app.css index 5d77aee413d..cebb6e94a23 100644 --- a/ghost/admin/app/styles/app.css +++ b/ghost/admin/app/styles/app.css @@ -42,7 +42,6 @@ @import "components/browser-preview.css"; @import "components/stacks.css"; @import "components/browser-preview.css"; -@import "components/filter-builder.css"; @import "components/pintura.css"; @import "components/modal-about.css"; diff --git a/ghost/admin/app/styles/components/filter-builder.css b/ghost/admin/app/styles/components/filter-builder.css deleted file mode 100644 index c9c2950054b..00000000000 --- a/ghost/admin/app/styles/components/filter-builder.css +++ /dev/null @@ -1,186 +0,0 @@ -.gh-filter-builder { - padding: 20px; - max-width: 780px; - min-width: 400px; -} - -.gh-filter-builder h3 { - font-size: 1.9rem; - font-weight: 600; -} - -.gh-filter-builder .gh-filters { - display: grid; - grid-template-columns: 1fr; - grid-gap: 12px; - background: var(--whitegrey-l1); - border-radius: 3px; - padding: 16px; - margin-top: 20px; -} - -.gh-filter-builder .gh-filter-block { - display: flex; - align-items: center; -} - -.gh-filter-builder .gh-filter-block .form-group { - margin: 0; -} - -.gh-filter-builder .gh-filter-inputgroup { - display: grid; - grid-template-columns: 1fr 146px minmax(0, 1fr) 18px; - grid-column-gap: 8px; -} - -.gh-filter-builder .gh-input, -.gh-filter-builder .gh-select, -.gh-filter-builder select { - height: 33px; - font-size: 1.35rem; -} - -.gh-filter-builder .gh-select svg { - width: 9px; - height: 9px; - margin-right: 0; -} - -.gh-filter-builder .gh-delete-filter { - width: 20px; - height: 33px; - margin-left: 4px; - color: var(--middarkgrey); -} - -.gh-filter-builder .gh-delete-filter:hover, -.gh-filter-builder .gh-delete-filter:focus { - color: var(--red); -} - -.gh-filter-builder .gh-delete-filter svg { - width: 10px; - height: 10px; -} - -.gh-add-filter svg { - width: 10px; - height: 10px; - margin: 0 6px 0 2px; -} - -.gh-filter-builder .gh-btn-text.green.gh-add-filter:hover span, -.gh-filter-builder .gh-btn-text.green.gh-add-filter:focus-visible span { - color: #1da42d; -} - -.gh-filter-builder-footer .gh-btn:not(.gh-btn-primary):focus-visible { - color: #394047; - background: #dde0e2; -} - -.gh-filter-builder-footer .gh-btn.gh-btn-primary:focus-visible { - box-shadow: 0 0 0 2px var(--green-d2); -} - -.gh-filter-builder .gh-filter-block-divider { - display: flex; - align-items: center; - font-size: 1.1rem; - font-weight: 500; - letter-spacing: .1px; - color: var(--midgrey); - text-transform: uppercase; - margin: 12px 0; -} - -.gh-filter-builder .gh-filter-block-divider::before { - content: ""; - display: block; - width: 16px; - height: 1px; - background: var(--whitegrey-d2); - margin: 0 4px 0 -16px; -} - -.gh-filter-builder .gh-filter-block-divider::after { - content: ""; - flex-grow: 1; - display: block; - height: 1px; - background: var(--whitegrey-d2); - margin: 0 -16px 0 4px; -} - -.gh-filter-builder-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 20px; -} - -.gh-filter-block .label-token { - margin: 2px !important; -} - -.gh-filter-block .token-segment-tier { - margin: 2px !important; -} - -.gh-filter-block .token-segment-tier .ember-power-select-multiple-remove-btn svg { - margin-right: 0!important; -} - -.gh-filter-builder .ember-power-select-multiple-trigger { - padding: 2px; -} - -.gh-filter-builder .ember-power-select-dropdown.ember-basic-dropdown-content--below { - font-size: 1.3rem; -} - -.gh-filter-builder .ember-power-select-trigger { - max-height: 72px; -} - -.gh-filter-builder .ember-power-select-option { - padding: 6px 0px 6px 12px; -} - -.gh-filter-builder .ember-power-select-multiple-option { - padding: 1px 1px 1px 6px; - z-index: 9999; -} - -.gh-filter-builder .ember-power-select-trigger-multiple-input { - height: 23px; - display: flex; -} - -.gh-filter-builder .ember-power-select-multiple-options { - padding-right: 28px; -} - -@media (max-width: 690px) { - .gh-filter-builder .gh-filter-inputgroup { - grid-template-columns: 1fr 18px; - grid-template-rows: 3fr; - grid-row-gap: 4px; - margin-bottom: 12px; - } - - .gh-filter-builder .gh-filter-inputgroup :not(.gh-delete-filter) { - grid-column: 1 / 2; - } - - .gh-filter-builder .gh-filter-inputgroup .gh-delete-filter { - grid-row: 1/2; - grid-column: 2/3; - } - - .gh-filter-builder .gh-filters { - max-height: calc(75vh - 180px); - overflow-y: auto; - } -} diff --git a/ghost/admin/app/styles/layouts/dashboard.css b/ghost/admin/app/styles/layouts/dashboard.css index d57d9f2f4c2..7a482a39bc3 100644 --- a/ghost/admin/app/styles/layouts/dashboard.css +++ b/ghost/admin/app/styles/layouts/dashboard.css @@ -323,10 +323,6 @@ Dashboard Layout */ margin-bottom: 12px; } -.gh-dashboard .gh-members-help { - margin-top: 0; -} - .gh-dashboard-select { position: absolute; top: 14px; @@ -1654,10 +1650,6 @@ Dashboard Resources */ margin-top: 20px; } -.gh-dashboard-resources .gh-members-help-card { - padding: 24px; -} - .gh-dashboard-resources .gh-dashboare5-article-content { display: flex; flex-direction: row; @@ -1709,26 +1701,6 @@ Dashboard Multi */ margin-top: 20px; } -.gh-dashboard-multi .gh-members-help-card { - flex: 1; - padding: 24px; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - background: var(--white); - border-radius: 3px; - box-shadow: 0 2px 4px rgb(0 0 0 / 7%); - color: #7c8b9a; - font-size: 1.4rem; - transition: none; - margin-right: 16px; -} - -.gh-dashboard-multi .gh-members-help-card:hover { - transform: translate(0); -} - .gh-dashboard-multi .gh-dashboard-list-header { padding-bottom: 12px; } diff --git a/ghost/admin/app/styles/layouts/members.css b/ghost/admin/app/styles/layouts/members.css index 4ee65132afc..664f45d4755 100644 --- a/ghost/admin/app/styles/layouts/members.css +++ b/ghost/admin/app/styles/layouts/members.css @@ -1,10 +1,3 @@ -/* Global -/* ----------------------------------------- */ -:root { - --member-import-table-outline: var(--whitegrey-d2); - --member-import-table-border: var(--whitegrey-d1); -} - /* Members avatar /* ----------------------------------------- */ .gh-member-gravatar { @@ -50,1768 +43,698 @@ box-shadow: 0 0 0 1px var(--main-bg-color); } -/* Members list +/* Shared member rows /* ----------------------------------------- */ -.members-list { - table-layout: fixed; +p.gh-members-list-email { + margin: -2px 0 -1px; } -.members-list-container-stretch { - display: flex; - flex-direction: column; - justify-content: space-between; - min-height: calc(100vh - 144px); /*Height of top menu + negative margin*/ - padding-bottom: 0; +.gh-member-list-avatar { + font-size: 1.65rem; + font-weight: 500; + line-height: 0; + letter-spacing: -0.6px; } -@media (max-width: 1450px) { - .members-list-container-stretch { - min-height: calc(100vh - 176px); - overflow: hidden; - } +.gh-members-list-email, +.gh-members-list-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; } -@media (max-width: 1100px) { - .members-list { - border-bottom: none - } +.gh-list h3.gh-members-name-noname { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.members-list .gh-list-row.header { - z-index: 1; +.gh-member-actions-menu { + top: calc(100% + 6px); + left: auto; + right: 0; } -.members-list .gh-list-header { - position: sticky; - top: 96px; - z-index: 1; - background: var(--white); +.gh-member-actions-menu.fade-out { + animation-duration: .001s; + pointer-events: none; } -.gh-list-scrolling-h .members-list .gh-list-header { - top: 0; +/* Member details +/* ----------------------------------------- */ + +label[for="member-description"] + p { + margin: 0 0 4px; } -.gh-list-with-helpsection { - height: unset; - margin: 0 -48px; +.gh-member-settings .gh-main-section.columns-3 { + grid-column-gap: 48px; } -.members-header .gh-canvas-header-content { - z-index: 1100; /* Ensure the header content is above loading spinner */ +.gh-member-details { + position: sticky; + top: 158px; + left: 0; + height: max-content; } -.members-header .view-actions input.gh-members-list-searchfield { - min-width: 220px; - padding-left: 32px; - height: 34px; - background: var(--whitegrey-l1); - border: var(--input-border); - border-color: transparent; +.gh-member-details h3 { + margin: 0; + padding: 0; + font-size: 1.6rem; + font-weight: 600; } -.members-header.grey .view-actions .gh-btn, -.members-header.grey .view-actions input.gh-members-list-searchfield { - background: color-mod(var(--whitegrey) l(-1%)); +.gh-member-details p { + margin: 0; + padding: 0; + font-size: 1.4rem; + color: var(--darkgrey-l1); } -.members-header .view-actions input.gh-members-list-searchfield:focus { - background: var(--white); - border-color: var(--green); +.gh-member-details a, +.gh-member-details a:hover { + color: var(--darkgrey-l1); + word-break: break-all; } -.members-header .view-actions .gh-input-search-icon { - width: 16px; - height: 16px; - top: 9px; - left: 9px; - fill: var(--midlightgrey); +.gh-member-details-identity { + display: flex; + align-items: center; } -.members-header.black .view-actions input.gh-members-list-searchfield { - background: var(--darkgrey-d1); +.gh-member-details-meta { + display: grid; + padding: 3.2rem 0; } -.gh-members-list-searchfield.active { - border-color: var(--green) !important; - box-shadow: inset 0 0 0 1px var(--green); +.gh-member-details-meta p { + display: flex; + align-items: center; + white-space: nowrap; + min-width: 0; } -.gh-members-list-checkbox { - width: 36px; +.gh-member-details-meta .gh-member-last-seen { + margin-top: -1px; } -p.gh-members-list-email { - margin: -2px 0 -1px; +.gh-member-details-meta svg { + width: 1.6rem; + height: 1.6rem; + margin-right: .8rem; + flex-shrink: 0; } -.gh-members-list-open-rate, -.gh-members-list-geolocation { - width: 150px; +.gh-member-details-meta p a { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-weight: 600; + color: var(--darkgrey); } -.gh-members-list-subscribed-at { - width: 220px; - margin-right: -8px; - padding-right: 0; +.gh-member-details-meta svg path { + stroke: var(--midgrey); } -.gh-members-list-labels { - display: inline-block; - max-width: 300px; - min-width: 220px; - white-space: wrap; +.gh-member-details-meta svg line { + stroke: var(--midgrey); } -.gh-members-list-feedback{ - display: flex; - align-items: center; +.gh-member-commenting-disabled { + margin-top: -1px; } -.gh-members-list-feedback svg { - width: 24px; - min-width: 24px; - height: 24px; - margin-right: 3px; +.gh-member-details-enable-link { + opacity: 0; + transition: opacity 0.15s ease; } -.gh-member-list-avatar { - font-size: 1.65rem; - font-weight: 500; - line-height: 0; - letter-spacing: -0.6px; +.gh-member-commenting-disabled:hover .gh-member-details-enable-link, +.gh-member-commenting-disabled:focus-within .gh-member-details-enable-link { + opacity: 1; } -.gh-member-actions-menu { - top: calc(100% + 6px); - left: auto; - right: 0; +.gh-member-details-enable-link button { + background: none; + border: none; + padding: 0; + color: var(--green); + cursor: pointer; + text-decoration: underline; + font-size: inherit; } -.gh-member-actions-menu.fade-out { - animation-duration: .001s; - pointer-events: none; +.gh-member-details-attribution { + display: grid; + grid-template-columns: 1fr; + padding: 0 0 3.2rem 0; } -.member-link-copied svg { - margin-right: 4px; +.gh-member-details-attribution svg { + width: 1.6rem; + height: 1.6rem; + margin-right: .8rem; + flex-shrink: 0; } -.gh-members-chart-header { +.gh-member-details-attribution p { display: flex; align-items: center; - justify-content: space-between; - padding: 16px 24px 4px 0; - margin-bottom: 10px; + white-space: nowrap; + min-width: 0; } -.gh-members-chart-header .gh-contentfilter { - margin: 0 0 0 20px; - height: 16px; +.gh-member-details-attribution p a, .gh-member-details-attribution p span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-weight: 600; + color: var(--darkgrey); } -.gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - border-radius: 3px; - height: 16px; - padding: 0 8px; - margin-right: 0; +.gh-member-details-attribution p a::first-letter { + text-transform: capitalize; } -.gh-members-chart-dropdown { - margin-left: -103px; +.gh-member-details-attribution svg path, .gh-member-details-attribution svg circle { + stroke: var(--midgrey); + fill: none; } -.gh-members-chart-xlabels { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 1.3rem; - color: var(--middarkgrey); - padding: 0 28px 16px 0; +.gh-member-details-attribution .gh-main-section-header { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--whitegrey); + grid-column: 1 !important; } -.gh-members-chart-summary { +.gh-member-details-stats-container { display: flex; flex-direction: column; - justify-content: space-between; - flex-basis: 28%; - min-width: 280px; } -.gh-members-chart-summary section { - flex: 1 1 auto; - min-width: 0; - min-height: 0; +.gh-member-details-stats { display: flex; flex-direction: column; - align-items: flex-start; - justify-content: center; - padding: 16px 24px; } -.gh-members-chart-summary-heading { - margin: 0; - padding: 0; +.gh-member-details-stat { + display: flex; + flex-direction: column; + margin-bottom: 1.6rem; } -.gh-members-chart.black { - background: var(--black); +.gh-member-details-stats-container .gh-main-section-header { + margin-bottom: 1.6rem; + border-bottom: 1px solid var(--whitegrey); } -.gh-members-chart.black .gh-members-chart-header { - border-color: var(--darkgrey); +.gh-member-details-stat.open-rate span { + margin-left: 2px; + font-size: 1.8rem; } -.gh-members-chart.black .gh-members-chart-summary-heading { - color: var(--midlightgrey); +.gh-members-no-stats p { + color: var(--midgrey); + font-size: 1.3rem; + line-height: 1.5em; } -.gh-members-chart.black .gh-members-chart-summary-data { - color: var(--whitegrey); +textarea.gh-member-details-textarea { + max-width: 100%; + min-width: auto; + min-height: 50px; + height: 85px; } -.gh-members-chart.black .gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger, -.gh-members-chart-box.black .gh-members-chart-header .gh-contentfilter-type .gh-contentfilter-menu-trigger { - background: transparent; - border: 1px solid var(--darkgrey); - color: var(--whitegrey); +.gh-member-info-icon { + width: 18px; + height: 18px; } -.gh-members-chart.black .gh-contentfilter-menu-trigger svg path { - stroke: var(--whitegrey) !important; +.gh-member-email-stats { + font-size: 3.6rem; + color: var(--darkgrey); + line-height: 4.0rem; } -.gh-members-chart-box.black .gh-members-chart-summary-heading { - color: var(--lightgrey); +.gh-member-header-stripeinfo { + display: flex; + align-items: center; + justify-content: flex-start; + min-height: 24px; + margin-top: -8px; } -.gh-members-chart-box.black .gh-members-chart-header { - border-color: var(--darkgrey); +.gh-member-stripe-info { + margin-top: 24px; } -.members-header .gh-contentfilter { - margin-right: 0; +.gh-member-stripe-info p { + font-size: 1.25rem; + font-weight: 400; + margin: 4px 0 0; } -.members-header .gh-contentfilter-tag .gh-contentfilter-menu-trigger { - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - border-top-right-radius: 0px !important; - border-bottom-right-radius: 0px !important; +.gh-member-stripe-table { + width: 100%; + margin: 6px 0 12px; } -.dropdown.members-label-list { - width: 225px; +@media (max-width: 1160px) { + .gh-member-stripe-table { + max-width: 520px; + } } -.dropdown.members-label-list .dropdown-label { - width: 205px; +.gh-member-stripe-table td { + vertical-align: top; + font-size: 1.3rem; } -.gh-members-chart + .content-list .members-list { - margin-top: var(--main-layout-section-vpadding); +.gh-member-stripe-id, +.gh-member-stripe-email { + display: inline-block; + word-break: break-all; } -.gh-members-list-subscribed-moment::first-letter { - text-transform: uppercase; +.gh-member-stripe-label { + color: var(--midgrey-d1); + white-space: nowrap; + padding: 5px 12px 5px 0; + width: 170px; } -.gh-members-list-row .gh-list-data:first-child { - width: 30%; - min-width: 360px; - padding-right: 20px !important; +.gh-member-stripe-data { + padding: 5px 12px 5px 0; } -.gh-members-list-row .gh-list-data[data-test-table-data="status"], - .gh-members-list-row .gh-list-data[data-test-table-data="open-rate"] { - width: 90px; - max-width: 90px; - min-width: 90px; -} +@media (max-width: 1400px) and (min-width: 1160px) { + .gh-member-stripe-row { + display: flex; + flex-direction: column; + } -.gh-members-list-row .gh-list-data[data-test-table-data="location"] { - width: 150px; - max-width: 150px; - min-width: 150px; -} + .gh-member-stripe-label { + padding-bottom: 0; + font-weight: 500; + } -.gh-members-list-row .gh-list-data[data-test-table-data="created-at"] { - width: 120px; - max-width: 120px; - min-width: 120px; -} + .gh-member-stripe-data { + padding-top: 0; + } + .gh-members-comped { + flex-direction: column; + align-items: flex-start; + } -.gh-members-list-row .gh-list-data.member-filter-column { - width: 250px; - max-width: 250px; - min-width: 250px; + .gh-members-comped-switch { + margin-top: 2rem; + } } -/* Ensure table headers have consistent widths with data cells */ -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="status"], -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="email_open_rate"] { - width: 90px; - max-width: 90px; - min-width: 90px; +.gh-members-subscribed-checkbox, +.gh-members-comped-checkbox { + max-width: 100%; + margin-top: 24px; + margin-bottom: 0; } -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="location"] { - width: 150px; - max-width: 150px; - min-width: 150px; +.gh-new-member-avatar { + background: var(--midlightgrey-l1); + width: 81px; + height: 81px; } -.gh-list-scrolling[data-test-table="members"] thead th[data-test-table-column="created"] { - width: 120px; - max-width: 120px; - min-width: 120px; +.gh-member-cancels-on-label { + display: inline-block; + background: color-mod(var(--pink) a(10%)); + border-radius: 4px; + padding: 0px 5px; + margin: -2px 0 -2px -5px; + color: var(--pink); + font-size: 1.3rem; + font-weight: 400; } -@media (max-width: 1100px) { - .gh-members-chart-summary-data { - font-size: 2.8rem; - line-height: 2.8rem; - } +.gh-member-stripe-status { + display: inline-block; + text-transform: capitalize; + margin-right: 6px; } -@media (max-width: 1000px) { - .members-list .gh-list-header, .gh-list-hidecell-m { - display: table-cell; - } +.gh-member-btn-contsub { + border-color: var(--blue); + box-shadow: none; } -@media (max-width: 800px) { - .gh-list-with-helpsection { - margin-left: 0; - margin-right: 0; - } - - .gh-members-list-row .gh-list-data:first-child { - min-width: 280px; - overflow-x: hidden; - } - - .gh-members-list-email, - .gh-members-list-name { - max-width: 200px; - overflow: hidden; - text-overflow: ellipsis; - } +.gh-member-btn-contsub:hover { + border-color: color-mod(var(--blue) l(-7%) saturation(-10%)); } -@media (min-width: 440px) and (max-width: 1000px) { - .gh-members-chart-summary { - flex-direction: row; - } - - .gh-members-chart-summary div { - flex-basis: 33%; - border-bottom: none; - justify-content: flex-start; - } - - .gh-members-chart-summary > div:nth-of-type(1), - .gh-members-chart-summary > div:nth-of-type(2) { - border-right: 1px solid var(--whitegrey); - } +.gh-member-btn-contsub span { + color: var(--blue); } -@media (max-width: 1100px) { - .members-list .gh-list-header, .gh-list-hidecell-m { - display: none; - } - - .gh-members-list-basic { - display: block; - flex: 1 1 100%; - } - - .gh-members-list-subscribed-at { - display: inline-block; - width: auto; - padding: 0 0 16px 4px; - margin-top: -16px; - font-size: 1.2rem; - } - - .gh-members-list-subscribed-at div { - display: inline; - margin-right: 1px; - } - - .gh-members-list-subscribed-moment::first-letter { - text-transform: none; - } - - .gh-members-list-subscribed-moment::before { - content: "("; - } - - .gh-members-list-subscribed-moment::after { - content: ")"; - } - - .gh-members-list-chevron { - display: block; - position: absolute; - right: 0; - top: 0; - bottom: 0; - } - - .gh-list h3.gh-members-name-noname { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .gh-members-subscribed-noname { - display: inline-block; - margin-top: -32px; - padding-bottom: 16px; - } - - .gh-members-list-open-rate { - display: inline-block; - width: auto; - margin-top: -16px; - padding: 0 0 0 49px; - } - - .gh-members-list-open-rate-noname { - margin-top: -32px; - padding-bottom: 16px; - } - - .gh-members-list-geolocation { - display: inline-block; - width: auto; - margin-top: -16px; - padding: 0; - } - - .gh-members-list-geolocation::after { - content: "•"; - } - - .gh-members-geolocation-noname { - margin-top: -32px; - padding-bottom: 16px; - } +.gh-member-btn-contsub:hover span { + color: color-mod(var(--blue) l(-7%) saturation(-10%)); } -@media (max-width: 600px) { - .gh-members-list-subscribed-moment { - display: none; - } - - .gh-members-list-chevron { - display: none; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; - } +.gh-member-internal-info, +.gh-member-stripe { + float: right; } -@media (max-width: 450px) { - .members-header { - justify-content: flex-end; - min-height: 120px; - } - - .members-header.gh-canvas-header.break.tablet .view-actions { - top: 0; - } - - .members-header.gh-canvas-header.break.tablet .view-actions-bottom-row { - width: 100% !important; - max-width: 100% !important; +@media (max-width: 1160px) { + .gh-member-settings .gh-main-section { + display: flex; + flex-direction: column; } - .members-header .view-actions { - margin-top: 2px; + .gh-member-settings .gh-main-section > div { + float: none; width: 100%; } - .members-header .members-actions-dropdown { - display: none; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; + .gh-member-details { + position: relative; + top: unset; + left: unset; } - .view-actions input.gh-members-list-searchfield { - min-width: 0; + .gh-member-header.sticky { + position: relative; } } -/* Members empty state -/* ----------------------------------------- */ - -.gh-members-empty { - display: flex; - flex-direction: column; - flex-grow: 1; - align-items: center; - justify-content: center; - padding: 2vw 4vw; -} - -.gh-members-empty .gh-members-placeholder { - fill: var(--lightgrey); - width: 60px; - height: 60px; - margin-bottom: 32px; -} - -.gh-members-empty h4 { - color: var(--black); - text-align: center; - font-weight: 600; - margin-bottom: 8px; -} - -.gh-members-empty p { - max-width: 390px; - color: var(--midgrey); - text-align: center; - line-height: 1.45em; - margin: 0 0 20px; - padding: 0; -} - -.gh-members-empty .gh-members-empty-secondary-cta { - margin-top: 3.2rem; - max-width: max-content; -} - -.gh-members-empty-secondary-cta a { - font-weight: 500; -} - -.gh-members-help { - margin-top: 40px; - margin-bottom: 0; -} - -.gh-members-help .gh-main-section-content { - display: grid; - grid-template-columns: repeat(2, 1fr); - grid-gap: 24px; - padding: 0; - border: none; - box-shadow: none; -} - -@media (max-width: 1080px) { - .gh-members-help .gh-main-section-content { - grid-template-columns: 1fr; +@media (min-width: 960px) and (max-width: 1160px), +(min-width: 600px) and (max-width: 800px) { + .gh-member-details-stats { + flex-direction: row; + justify-content: space-between; } } -.gh-members-help-card { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: space-between; - padding: 24px; - background: var(--white); - border: 1px solid var(--whitegrey); - color: var(--midgrey); - font-size: 1.4rem; - border-radius: 12px; - box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); - transition: all 0.15s ease-in-out; -} - -.gh-members-help-card p { - line-height: 1.4em; - margin-top: 12px; -} - -.gh-members-help-card .thumbnail { - width: 100%; - height: 200px; - max-width: 200px; - background-size: cover; - background-position: left -80px top 0; - aspect-ratio: 1 / 1; -} - -.gh-members-help-card .thumbnail.right { - background-position: left -40px top 0; -} - -@media (max-width: 620px), -(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; - background-position: unset; - } - - .gh-members-help-card .thumbnail.right { - background-position: unset; - } +.gh-member-labels, +.gh-member-note { + max-width: none; } - - -.gh-members-help-card:hover { - box-shadow: - 0 0 1px rgba(0,0,0,.12), - 0 1px 6px rgba(0,0,0,.03), - 0 8px 10px -8px rgba(0,0,0,.1); - transition: all 0.15s ease-in-out; +.gh-member-cancelstripe-checkbox { + margin-bottom: 4px; } -.gh-members-help-content { +.gh-member-cancelstripe-checkbox label { display: flex; - width: 100%; - height: 100%; -} - -@media (max-width: 620px), -(min-width: 800px) and (max-width: 960px), -(min-width: 1080px) and (max-width: 1440px) { - .gh-members-help-content { - flex-direction: column; - } -} - -.gh-members-help-content .text { - position: relative; - margin: 2rem 0 0 3.2rem; - flex-grow: 1; -} - -.gh-members-help-content .gh-btn-link { - position: absolute; - bottom: 0; - margin: 1rem 0; -} - -@media (min-width: 1440px) and (max-width: 1560px) { - .gh-members-help-content .text { - margin: 0 0 0 2rem; - } - - .gh-members-help-content .gh-btn-link { - margin: 0; - } } -@media (max-width: 620px), -(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; - } - - .gh-members-help-content .gh-btn-link { - margin: 0; - } +.gh-member-cancelstripe-checkbox h4 { + font-size: 1.4rem; + font-weight: 600; + line-height: 1.15em; + margin-top: 2px; } -/* Member details -/* ----------------------------------------- */ - -label[for="member-description"] + p { - margin: 0 0 4px; +.gh-member-cancelstripe-checkbox label p { + margin-top: -2px; + color: var(--middarkgrey); } -.gh-member-settings .gh-main-section.columns-3 { - grid-column-gap: 48px; +.gh-member-cancelstripe-checkbox input:checked + .input-toggle-component { + border-color: color-mod(var(--red) l(-5%)); + background: var(--red); } -.gh-member-details { - position: sticky; - top: 158px; - left: 0; - height: max-content; +.gh-members-no-data { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; } -.gh-member-details h3 { - margin: 0; - padding: 0; - font-size: 1.6rem; - font-weight: 600; +.gh-members-no-data svg { + width: 56px; + height: auto; + margin-bottom: 8px; } -.gh-member-details p { - margin: 0; - padding: 0; - font-size: 1.4rem; - color: var(--darkgrey-l1); +.gh-members-no-data svg path, +.gh-members-no-data svg rect, +.gh-members-no-data svg circle { + stroke-width: 0.8px; } -.gh-member-details a, -.gh-member-details a:hover { - color: var(--darkgrey-l1); - word-break: break-all; -} - -.gh-member-details-identity { - display: flex; - align-items: center; -} - -.gh-member-details-meta { - display: grid; - padding: 3.2rem 0; -} - -.gh-member-details-meta p { - display: flex; - align-items: center; - white-space: nowrap; - min-width: 0; -} - -.gh-member-details-meta .gh-member-last-seen { - margin-top: -1px; -} - -.gh-member-details-meta svg { - width: 1.6rem; - height: 1.6rem; - margin-right: .8rem; - flex-shrink: 0; -} - -.gh-member-details-meta p a { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - font-weight: 600; - color: var(--darkgrey); -} - -.gh-member-details-meta svg path { - stroke: var(--midgrey); -} - -.gh-member-details-meta svg line { - stroke: var(--midgrey); -} - -.gh-member-commenting-disabled { - margin-top: -1px; -} - -.gh-member-details-enable-link { - opacity: 0; - transition: opacity 0.15s ease; -} - -.gh-member-commenting-disabled:hover .gh-member-details-enable-link, -.gh-member-commenting-disabled:focus-within .gh-member-details-enable-link { - opacity: 1; -} - -.gh-member-details-enable-link button { - background: none; - border: none; - padding: 0; - color: var(--green); - cursor: pointer; - text-decoration: underline; - font-size: inherit; -} - -.gh-member-details-attribution { - display: grid; - grid-template-columns: 1fr; - padding: 0 0 3.2rem 0; -} - -.gh-member-details-attribution svg { - width: 1.6rem; - height: 1.6rem; - margin-right: .8rem; - flex-shrink: 0; -} - -.gh-member-details-attribution p { - display: flex; - align-items: center; - white-space: nowrap; - min-width: 0; -} - -.gh-member-details-attribution p a, .gh-member-details-attribution p span { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - font-weight: 600; - color: var(--darkgrey); -} - -.gh-member-details-attribution p a::first-letter { - text-transform: capitalize; -} - -.gh-member-details-attribution svg path, .gh-member-details-attribution svg circle { - stroke: var(--midgrey); - fill: none; -} - -.gh-member-details-attribution .gh-main-section-header { - margin-bottom: 1.6rem; - border-bottom: 1px solid var(--whitegrey); - grid-column: 1 !important; -} - -.gh-member-details-stats-container { - display: flex; - flex-direction: column; -} - -.gh-member-details-stats { - display: flex; - flex-direction: column; -} - -.gh-member-details-stat { - display: flex; - flex-direction: column; - margin-bottom: 1.6rem; -} - -.gh-member-details-stats-container .gh-main-section-header { - margin-bottom: 1.6rem; - border-bottom: 1px solid var(--whitegrey); -} - -.gh-member-details-stat.open-rate span { - margin-left: 2px; - font-size: 1.8rem; -} - -.gh-members-no-stats p { - color: var(--midgrey); - font-size: 1.3rem; - line-height: 1.5em; -} - -textarea.gh-member-details-textarea { - max-width: 100%; - min-width: auto; - min-height: 50px; - height: 85px; -} - -.gh-member-info-icon { - width: 18px; - height: 18px; -} - -.gh-member-email-stats { - font-size: 3.6rem; - color: var(--darkgrey); - line-height: 4.0rem; -} - -.gh-member-header-stripeinfo { - display: flex; - align-items: center; - justify-content: flex-start; - min-height: 24px; - margin-top: -8px; -} - -.gh-member-stripe-info { - margin-top: 24px; -} - -.gh-member-stripe-info p { - font-size: 1.25rem; - font-weight: 400; - margin: 4px 0 0; -} - -.gh-member-stripe-table { - width: 100%; - margin: 6px 0 12px; -} - -@media (max-width: 1160px) { - .gh-member-stripe-table { - max-width: 520px; - } -} - -.gh-member-stripe-table td { - vertical-align: top; - font-size: 1.3rem; -} - -.gh-member-stripe-id, -.gh-member-stripe-email { - display: inline-block; - word-break: break-all; -} - -.gh-member-stripe-label { - color: var(--midgrey-d1); - white-space: nowrap; - padding: 5px 12px 5px 0; - width: 170px; -} - -.gh-member-stripe-data { - padding: 5px 12px 5px 0; -} - -@media (max-width: 1400px) and (min-width: 1160px) { - .gh-member-stripe-row { - display: flex; - flex-direction: column; - } - - .gh-member-stripe-label { - padding-bottom: 0; - font-weight: 500; - } - - .gh-member-stripe-data { - padding-top: 0; - } - .gh-members-comped { - flex-direction: column; - align-items: flex-start; - } - - .gh-members-comped-switch { - margin-top: 2rem; - } -} - -.gh-members-subscribed-checkbox, -.gh-members-comped-checkbox { - max-width: 100%; - margin-top: 24px; - margin-bottom: 0; -} - -.gh-new-member-avatar { - background: var(--midlightgrey-l1); - width: 81px; - height: 81px; -} - -.gh-member-cancels-on-label { - display: inline-block; - background: color-mod(var(--pink) a(10%)); - border-radius: 4px; - padding: 0px 5px; - margin: -2px 0 -2px -5px; - color: var(--pink); - font-size: 1.3rem; - font-weight: 400; -} - -.gh-member-stripe-status { - display: inline-block; - text-transform: capitalize; - margin-right: 6px; -} - -.gh-member-btn-contsub { - border-color: var(--blue); - box-shadow: none; -} - -.gh-member-btn-contsub:hover { - border-color: color-mod(var(--blue) l(-7%) saturation(-10%)); -} - -.gh-member-btn-contsub span { - color: var(--blue); -} - -.gh-member-btn-contsub:hover span { - color: color-mod(var(--blue) l(-7%) saturation(-10%)); -} - -.gh-member-internal-info, -.gh-member-stripe { - float: right; -} - -@media (max-width: 1160px) { - .gh-member-settings .gh-main-section { - display: flex; - flex-direction: column; - } - - .gh-member-settings .gh-main-section > div { - float: none; - width: 100%; - } - - .gh-member-details { - position: relative; - top: unset; - left: unset; - } - - .gh-member-header.sticky { - position: relative; - } -} - -@media (min-width: 960px) and (max-width: 1160px), -(min-width: 600px) and (max-width: 800px) { - .gh-member-details-stats { - flex-direction: row; - justify-content: space-between; - } -} - -.gh-member-labels, -.gh-member-note { - max-width: none; -} - -.gh-member-cancelstripe-checkbox { - margin-bottom: 4px; -} - -.gh-member-cancelstripe-checkbox label { - display: flex; -} - -.gh-member-cancelstripe-checkbox h4 { - font-size: 1.4rem; - font-weight: 600; - line-height: 1.15em; - margin-top: 2px; -} - -.gh-member-cancelstripe-checkbox label p { - margin-top: -2px; - color: var(--middarkgrey); -} - -.gh-member-cancelstripe-checkbox input:checked + .input-toggle-component { - border-color: color-mod(var(--red) l(-5%)); - background: var(--red); -} - -.gh-members-no-data { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; -} - -.gh-members-no-data svg { - width: 56px; - height: auto; - margin-bottom: 8px; -} - -.gh-members-no-data svg path, -.gh-members-no-data svg rect, -.gh-members-no-data svg circle { - stroke-width: 0.8px; -} - -.gh-members-no-data h4 { - font-size: 1.5rem; - letter-spacing: 0; - font-weight: 600; - color: var(--middarkgrey); -} - -.gh-members-no-data p { - margin: 0 40px .3rem; - color: var(--midgrey); - font-size: 1.3rem; - line-height: 1.5em; -} - -.gh-members-no-list h4 { - margin-top: 8px; -} - -.gh-members-no-list svg path { - stroke-width: 1px; -} - -.gh-members-no-subs svg { - width: 52px; - margin-left: 12px; -} - -.gh-members-no-subs svg path, -.gh-members-no-subs svg rect, -.gh-members-no-subs svg circle { - stroke-width: 1px; -} - -.gh-member-newsletters { - padding: 16px 20px; - background: var(--main-bg-color); - box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); - border-radius: 3px; -} - -.gh-member-newsletter-row { - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--grey-250); - padding: 16px 0; -} - -.gh-member-newsletter-row .for-switch { - display: flex; -} - -.gh-member-newsletter-row:first-child { - padding-top: 0; -} - -.gh-member-newsletter-row:last-child { - padding-bottom: 0; - border-bottom: none; -} - -.gh-member-newsletter-title { - font-weight: 600; - font-size: 1.4rem !important; - margin-bottom: 0!important; -} - -.gh-main-section-content.gh-member-newsletter-section { - padding-bottom: 16px; -} - -.gh-member-newsletter-no-data { - padding: 24px 0 28px; -} - -.gh-member-newsletter-no-data .gh-member-newsletter-icon path { - stroke-width: 2.8; -} - -.gh-member-newsletter-no-data a { - color: var(--midgrey); - opacity: 0.6; - font-weight: 500; - letter-spacing: 0.02em; - text-decoration: underline; -} - -.gh-member-newsletter-no-data p { - margin-bottom: 0.8rem; -} - -.gh-member-newsletter-footer { - font-size: 1.3rem; - margin-top: 12px; -} - -.gh-member-feed-container { - display: flex; - flex-grow: 1; - flex-direction: row; - align-items: center; - padding: 1.6rem 0; -} - -.gh-member-feed { - margin: 0; - padding: 20px; - background: var(--white); - box-shadow: 0 1px 4px -1px rgba(0,0,0,0.1); - border-radius: 3px; -} - -.gh-member-settings .gh-member-feed-no-data { - margin: 0; - padding: 24px 0 28px; - background: transparent; - box-shadow: none; -} - -.gh-member-feed-row { - display: flex; - align-items: center; - padding: 0; -} - -.gh-member-feed-activity { - display: flex; - align-items: center; - padding: 12px 0; -} - -.gh-member-feed-activity svg { - width: 16px; - margin-right: 1rem; -} - -.gh-member-feed-row a { - font-weight: 600; - font-size: 14px; - color: var(--darkgrey); -} - -.gh-member-feed-title { - display: table-cell; - padding: 10px 0; - line-height: 1.4em; - vertical-align: middle; - color: var(--darkgrey); - text-align: left; - font-weight: 500; -} - -.gh-member-feed-title:hover { - color: var(--black); -} - -.gh-member-feed-title a { - color: var(--darkgrey); -} - -.gh-member-feed-title a:hover { - color: var(--black); -} - -.gh-member-feed-date { - margin-left: auto; - padding: 10px 0 10px 16px; - color: var(--midgrey); - font-size: 1.3rem; - text-align: right; - white-space: nowrap; -} - -.gh-member-feed-row:hover .gh-member-feed-date { - color: var(--darkgrey); -} - -.gh-member-feed-row:first-child .gh-member-feed-container { - padding-top: 0; -} - -.gh-member-feed-row { - border-bottom: 1px solid var(--grey-250); -} - -.gh-member-feed-detail { - display: flex; - flex-grow: 1; - justify-content: space-between; - align-items: center; - width: 0; -} - -.gh-member-feed-event { - flex: 1; - min-width: 0; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; -} - -.gh-member-feed-event-inner { - color: var(--middarkgrey); - font-weight: 500; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - padding-right: 1rem; - width: 100%; -} - -.gh-member-feed-event-inner:first-letter { - text-transform: uppercase; -} - -.gh-member-feed-icon { - flex-shrink: 0; - width: 24px; - height: 24px; - margin: 0 0.5rem 0 -1px; -} - -.gh-member-feed-icon svg { - width: 100%; - height: 100%; -} - -.gh-member-feed-time { - font-weight: 500; - font-size: 1.3rem; - color: var(--midlightgrey); - white-space: nowrap; -} - -.gh-member-feed-footer { - padding-top: 16px; -} - -.gh-member-feed-footer a { - font-weight: 500; - transition: color 100ms ease; -} - -.gh-member-feed-footer a:hover { - color: #269a34; -} - - -/* Import modal -/* ---------------------------------------------------------- */ - -.fullscreen-modal-import-members { - max-width: unset !important; -} - -.gh-member-import-wrapper { - width: 420px; -} - -.gh-member-import-wrapper.wide { - width: 580px; -} - -.gh-member-import-wrapper .gh-btn.disabled, -.gh-member-import-wrapper .gh-btn.disabled:hover { - cursor: auto !important; - opacity: 0.6 !important; -} - -.gh-member-import-wrapper .gh-btn.disabled span, -.gh-member-import-wrapper .gh-btn.disabled span:hover { - cursor: auto !important; - pointer-events: none; -} - -.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger[aria-disabled=true], -.gh-member-import-wrapper .gh-token-input .ember-power-select-trigger-multiple-input:disabled { - background: var(--whitegrey-l2); -} - -@media (max-width: 600px) { - .gh-member-import-wrapper, - .gh-member-import-wrapper.wide { - width: calc(100vw - 128px); - } -} - -.gh-members-import-uploader { - width: 100%; - min-height: 180px; -} - -.gh-members-import-uploader svg { - width: 3.2rem; - height: 3.2rem; - margin-bottom: 1rem; -} - -.gh-members-import-uploader svg path { - stroke: var(--midlightgrey); -} - -.gh-members-import-uploader:hover svg path { - stroke: var(--midgrey-l1); -} - -.gh-members-import-uploader .description { - color: var(--midgrey); - font-size: 1.4rem; - font-weight: 500; -} - -.gh-members-import-uploader:hover .description { - color: var(--midgrey-d2); -} - -.gh-members-import-file { - min-height: 180px; -} - -.gh-members-import-spinner { - position: relative; - display: flex; - min-height: 182px; - justify-content: center; - align-items: center; - margin-bottom: -20px; -} - -.gh-members-import-spinner .gh-loading-content { - padding-bottom: 0px; -} - -.gh-members-import-spinner .description { - padding-top: 46px; -} - -.gh-members-upload-errorcontainer { - border: 1px solid var(--whitegrey); - border-radius: 4px; - padding: 12px; - margin-bottom: 24px; - color: var(--middarkgrey); -} - -.gh-members-upload-errorcontainer.warning { - border-left: 4px solid var(--yellow); -} - - -.gh-members-upload-errorcontainer.warning p a { - color: color-mod(var(--yellow) l(-12%)); - text-decoration: underline; -} - -.gh-members-upload-errorcontainer.error { - border-left: 4px solid var(--red); -} - -.gh-members-upload-errorcontainer.error p a { - color: var(--red); - text-decoration: underline; -} - -.gh-members-import-errormessage { - font-size: 1.25rem; - font-weight: 600; - margin: 12px 0 0; -} - -p.gh-members-import-errorcontext { - font-size: 1.25rem; - line-height: 1.3em; - margin: 0; - font-weight: 400; -} - -.gh-members-import-mapping .error { - color: var(--red); -} - -.gh-members-import-mappingwrapper.error { - position: relative; -} - -.gh-members-import-mappingwrapper.error::before { - display: block; - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 1px solid red; - z-index: 9999; - pointer-events: none; -} - -.gh-members-import-scrollarea { - position: relative; - max-height: calc(100vh - 350px - 12vw); - overflow-y: scroll; - margin: 0 -32px; - padding: 0 32px; - background: - /* Shadow covers */ - linear-gradient(var(--white) 30%, rgba(255,255,255,0)), - linear-gradient(rgba(255,255,255,0), var(--white) 70%) 0 100%, - - /* Shadows */ - /* radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 0, - radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.12), rgba(0,0,0,0)) -64px 100%; */ - linear-gradient(rgba(0,0,0,0.08), rgba(0,0,0,0)), - linear-gradient(rgba(0,0,0,0), rgba(0,0,0,0.08)) 0 100%; - background-repeat: no-repeat; - background-color: var(--white); - background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px; - - /* Opera doesn't support this in the shorthand */ - background-attachment: local, local, scroll, scroll; - margin-top: 4px; -} - -.gh-members-import-errorheading { - font-size: 1.4rem; - line-height: 1.55em; - margin-top: 2px; +.gh-members-no-data h4 { + font-size: 1.5rem; + letter-spacing: 0; + font-weight: 600; + color: var(--middarkgrey); } -p.gh-members-import-errordetailtext { - font-size: 1.3rem; - line-height: 1.4em; +.gh-members-no-data p { + margin: 0 40px .3rem; color: var(--midgrey); + font-size: 1.3rem; + line-height: 1.5em; } -.gh-members-import-errordetailtext:first-of-type { - border-top: 1px solid var(--lightgrey); - padding-top: 8px; +.gh-members-no-list h4 { margin-top: 8px; } -.gh-members-import-errordetailtext:not(:last-of-type) { - padding-bottom: 4px; - margin-bottom: 6px; +.gh-members-no-list svg path { + stroke-width: 1px; } -.gh-members-import-table { - position: relative; - margin-bottom: 1px; - border-collapse: separate; +.gh-members-no-subs svg { + width: 52px; + margin-left: 12px; } -.gh-members-import-table::before { - position: absolute; - display: block; - content: ""; - top: 0; - left: -33px; - bottom: 0; - height: 100%; - width: 32px; - background: var(--white); +.gh-members-no-subs svg path, +.gh-members-no-subs svg rect, +.gh-members-no-subs svg circle { + stroke-width: 1px; } -.gh-members-import-table::after { - position: absolute; - display: block; - content: ""; - top: 0; - right: -32px; - bottom: 0; - height: 100%; - width: 32px; - background: var(--white); +.gh-member-newsletters { + padding: 16px 20px; + background: var(--main-bg-color); + box-shadow: 0 1px 4px -1px rgb(0 0 0 / 10%); + border-radius: 3px; } -.gh-members-import-table th { - position: sticky; - top: 0; - padding: 3px 8px; - background: var(--whitegrey-l2); - border-left: 1px solid var(--member-import-table-border); - border-top: 1px solid var(--member-import-table-outline); - border-bottom: 1px solid var(--member-import-table-border); +.gh-member-newsletter-row { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--grey-250); + padding: 16px 0; } -.gh-members-import-table tr th:first-of-type { - border-left: 1px solid var(--member-import-table-outline); - width: 180px; +.gh-member-newsletter-row .for-switch { + display: flex; } -.gh-members-import-table tr th:last-of-type { - border-right: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-row:first-child { + padding-top: 0; } -.gh-members-import-table td.empty-cell { - background: color-mod(var(--darkgrey) a(3%) s(+50%)); +.gh-member-newsletter-row:last-child { + padding-bottom: 0; + border-bottom: none; } -.gh-members-import-table td { - padding: 7px 8px 6px; - border-left: 1px solid var(--member-import-table-border); - border-bottom: 1px solid var(--member-import-table-border); - vertical-align: top; +.gh-member-newsletter-title { + font-weight: 600; + font-size: 1.4rem !important; + margin-bottom: 0!important; } -.gh-members-import-table tr td:first-of-type { - border-left: 1px solid var(--member-import-table-outline); - width: 180px; +.gh-main-section-content.gh-member-newsletter-section { + padding-bottom: 16px; } -.gh-members-import-table tr td:last-of-type { - padding: 0; - border-right: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-no-data { + padding: 24px 0 28px; } -.gh-members-import-table tr:last-of-type td { - border-bottom: 1px solid var(--member-import-table-outline); +.gh-member-newsletter-no-data .gh-member-newsletter-icon path { + stroke-width: 2.8; } -.gh-members-import-table td span, -.gh-members-import-table th span { - user-select: none !important; +.gh-member-newsletter-no-data a { + color: var(--midgrey); + opacity: 0.6; + font-weight: 500; + letter-spacing: 0.02em; + text-decoration: underline; } -.gh-members-import-datanav { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 1px 2px rgba(0, 0, 0, 0.05); +.gh-member-newsletter-no-data p { + margin-bottom: 0.8rem; } -p.gh-members-import-errordetail { - font-size: 1.2rem; - line-height: 1.4em; - margin: 10px 0 0 24px; +.gh-member-newsletter-footer { + font-size: 1.3rem; + margin-top: 12px; } -p.gh-members-import-errordetail:first-of-type { - border-top: 1px solid var(--whitegrey); - padding-top: 8px; - margin-top: 8px; +.gh-member-feed-container { + display: flex; + flex-grow: 1; + flex-direction: row; + align-items: center; + padding: 1.6rem 0; } -.gh-import-member-select { - height: auto; - border: none; - background: none; - border-radius: 0; +.gh-member-feed { + margin: 0; + padding: 20px; + background: var(--white); + box-shadow: 0 1px 4px -1px rgba(0,0,0,0.1); + border-radius: 3px; } -.gh-import-member-select select { - height: 34px; - border: none; - font-size: 1.3rem; - line-height: 1em; - padding: 4px 4px 4px 8px; - background: none; - color: var(--middarkgrey); - font-weight: 600; - border-radius: 0; +.gh-member-settings .gh-member-feed-no-data { + margin: 0; + padding: 24px 0 28px; + background: transparent; + box-shadow: none; } -.gh-import-member-select select option { - font-weight: 400; - color: var(--darkgrey); +.gh-member-feed-row { + display: flex; + align-items: center; + padding: 0; } -.gh-import-member-select select:focus { - background: none; - color: var(--middarkgrey); +.gh-member-feed-activity { + display: flex; + align-items: center; + padding: 12px 0; } -.gh-import-member-select.unmapped select, -.gh-import-member-select.unmapped select:focus { - color: var(--midlightgrey); - font-weight: 400; +.gh-member-feed-activity svg { + width: 16px; + margin-right: 1rem; +} + +.gh-member-feed-row a { + font-weight: 600; + font-size: 14px; + color: var(--darkgrey); } -.gh-import-member-select svg { - right: 9px; +.gh-member-feed-title { + display: table-cell; + padding: 10px 0; + line-height: 1.4em; + vertical-align: middle; + color: var(--darkgrey); + text-align: left; + font-weight: 500; } -.gh-members-import-table th.table-cell-field, -.gh-members-import-table td.table-cell-field, -.gh-members-import-table th.table-cell-data, -.gh-members-import-table td.table-cell-data { - max-width: 180px; - overflow-wrap: break-word; +.gh-member-feed-title:hover { + color: var(--black); } -.gh-member-import-resultcontainer { - margin-bottom: 28px; +.gh-member-feed-title a { + color: var(--darkgrey); } -.gh-member-import-result-summary { - flex-basis: 50%; +.gh-member-feed-title a:hover { + color: var(--black); } -.gh-member-import-result-summary h2 { - font-size: 3.6rem; - font-weight: 600; - margin: 0; - padding: 0; +.gh-member-feed-date { + margin-left: auto; + padding: 10px 0 10px 16px; + color: var(--midgrey); + font-size: 1.3rem; + text-align: right; + white-space: nowrap; } -.gh-member-import-result-summary p { +.gh-member-feed-row:hover .gh-member-feed-date { color: var(--darkgrey); - margin: 0; - padding: 0; - line-height: 1.6em; - margin-bottom: 12px; } -.gh-member-import-result-summary p strong { - font-size: 1.5rem; - letter-spacing: 0; +.gh-member-feed-row:first-child .gh-member-feed-container { + padding-top: 0; } -.gh-member-import-errorlist { - width: 100%; - margin: 8px 0 28px; +.gh-member-feed-row { + border-bottom: 1px solid var(--grey-250); } -.gh-member-import-errorlist h4 { - font-size: 13px; - font-weight: 500; - border-bottom: 1px solid var(--whitegrey); - padding-bottom: 8px; - margin-top: 0px; - color: var(--midgrey); +.gh-member-feed-detail { + display: flex; + flex-grow: 1; + justify-content: space-between; + align-items: center; + width: 0; } -.gh-member-import-errorlist ul li { - font-size: 13px; - font-weight: 400; - color: var(--midlightgrey-d2); - padding: 0; - margin-bottom: 6px; +.gh-member-feed-event { + flex: 1; + min-width: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; } -.gh-member-import-resultcontainer hr { - margin: 24px -32px; - border-color: var(--whitegrey); +.gh-member-feed-event-inner { + color: var(--middarkgrey); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-right: 1rem; + width: 100%; } -.gh-member-import-nodata span { - display: flex; - min-height: 144px; - align-items: center; - justify-content: center; - color: var(--midgrey); +.gh-member-feed-event-inner:first-letter { + text-transform: uppercase; } -.gh-member-import-icon-members path, -.gh-member-import-icon-members circle { - stroke-width: 0.85px; +.gh-member-feed-icon { + flex-shrink: 0; + width: 24px; + height: 24px; + margin: 0 0.5rem 0 -1px; } -.gh-member-import-icon-confetti { - color: var(--pink); - margin-left: 12px; +.gh-member-feed-icon svg { + width: 100%; + height: 100%; } -.gh-member-import-icon-confetti path, -.gh-member-import-icon-confetti circle, -.gh-member-import-icon-confetti ellipse { - stroke-width: 0.85px; +.gh-member-feed-time { + font-weight: 500; + font-size: 1.3rem; + color: var(--midlightgrey); + white-space: nowrap; } -.gh-import-member-icon { - color: var(--darkgrey); - width: 54px !important; - height: 54px !important; - margin-right: -8px; +.gh-member-feed-footer { + padding-top: 16px; } -.gh-import-member-icon * { - stroke-width: 0.8px !important; +.gh-member-feed-footer a { + font-weight: 500; + transition: color 100ms ease; } -/* Fixing Firefox's select padding */ -@-moz-document url-prefix() { - .gh-import-member-select select { - padding: 4px; - } +.gh-member-feed-footer a:hover { + color: #269a34; } + /* Email newsletter design settings /* -------------------------------------------------------- */ @@ -2864,71 +1787,6 @@ a.gh-members-emailpreview-subscription-link { margin-top: -16px; } -.gh-members-resource-filter .ember-power-select-selected-item { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-right: 16px; -} - -.members-header .view-actions-top-row > .ember-basic-dropdown-content-wormhole-origin { - position: absolute; -} - -.gh-members-filter-builder { - width: 780px; - margin-top: 8px; -} - -@media (max-width: 980px) { - .gh-members-filter-builder { - width: 640px; - } -} - -@media (max-width: 890px) { - .gh-members-filter-builder { - margin-right: -180px; - } -} - -@media (max-width: 690px) { - .gh-members-filter-builder { - position: fixed; - top: 9vh !important; - left: 10px !important; - right: 10px !important; - width: auto; - min-width: 0; - max-width: none; - margin: 0; - } -} - -@media (max-width: 800px) { - .members-header { - left: 0; - } - - .members-header .gh-canvas-title { - left: 25px; - } - - .members-header .view-actions .gh-members-header-search { - width: 100%; - } -} - -@media (max-width: 430px) { - .members-header .view-actions .gh-contentfilter { - border-right: 1px solid var(--whitegrey-d1); - } - - .gh-contentfilter-menu:last-of-type { - padding-right: 8px; - } -} - /* This needs to be moved once the flag is removed */ .gh-cp-membertier-attribution.gh-membertier-subscription { display: block !important; diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs deleted file mode 100644 index 57249e9e78d..00000000000 --- a/ghost/admin/app/templates/members.hbs +++ /dev/null @@ -1,235 +0,0 @@ -{{#unless this.feature.membersForward}} -
- -
- {{#if this.fromAnalytics}} -
- - Posts - - {{svg-jar "arrow-right-small"}} - - Analytics - - {{svg-jar "arrow-right-small"}}Members -
- {{/if}} -

Members

-
-
-
- -
- -
- - - - - {{svg-jar "settings"}} - - - - - {{#if (not-eq this.settings.membersSignupAccess "none")}} -
  • - - Import members - -
  • - {{/if}} -
  • - {{#if this.members.length}} - - {{else}} - - {{/if}} -
  • - {{#if (and this.members.length this.isFiltered)}} -
  • -
  • - -
  • -
  • - -
  • - {{#if (not-eq this.settings.membersSignupAccess "none")}} -
  • - -
  • - {{/if}} - {{#if this.isBulkDeletePermitted}} -
  • -
  • - -
  • - {{/if}} - {{/if}} -
    -
    - {{#if (not-eq this.settings.membersSignupAccess "none")}} - New memberNew - {{/if}} -
    -
    -
    - - {{#if this.members.loading}} -
    - -
    - {{else}} -
    - {{#if this.members}} -
    - - - - - - {{#if (and (not-eq this.settings.editorDefaultEmailRecipients "disabled") this.settings.emailTrackOpens)}} - - {{/if}} - - - {{#each this.filterColumns as |column|}} - - {{/each}} - - - - {{#if member.is_loading}} - - {{else}} - - {{/if}} - -
    {{this.listHeader}}StatusOpen rateLocationCreated{{column.label}}
    -
    - {{else}} - {{#if this.showingAll}} - - {{else}} -
    - {{svg-jar "members-placeholder" class="gh-members-placeholder"}} -

    No members match the current filter

    - - Show all members - -
    - {{/if}} - {{/if}} - {{#if (lt this.members.length 6)}} - - {{/if}} -
    - {{/if}} -
    - -{{outlet}} - -{{#if this.showUnsubscribeMembersModal}} - -{{/if}} - -{{#if this.showLabelModal}} - -{{/if}} -{{/unless}} diff --git a/ghost/admin/app/templates/members/import.hbs b/ghost/admin/app/templates/members/import.hbs deleted file mode 100644 index b02ea750a14..00000000000 --- a/ghost/admin/app/templates/members/import.hbs +++ /dev/null @@ -1,6 +0,0 @@ -{{#unless this.feature.membersForward}} - -{{/unless}} diff --git a/ghost/admin/mirage/config/members.js b/ghost/admin/mirage/config/members.js index 33efccfc6c3..1a20914548d 100644 --- a/ghost/admin/mirage/config/members.js +++ b/ghost/admin/mirage/config/members.js @@ -275,31 +275,6 @@ export default function mockMembers(server) { members.find(id).destroy(); })); - server.get('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function () { - return new Response(200, { - 'Content-Disposition': 'attachment', - filename: `members.${moment().format('YYYY-MM-DD')}.csv`, - 'Content-Type': 'text/csv' - }, ''); - })); - - server.post('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function ({labels}, request) { - const label = labels.create(); - - // TODO: parse CSV and create member records - for (const kvPair of request.requestBody.entries()) { - const [key, value] = kvPair; - console.log({key, value}); // eslint-disable-line - } - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - })); - server.get('/members/events/', withPermissionsCheck(ALLOWED_ROLES, function ({memberActivityEvents}, {queryParams}) { let {limit, filter, page} = queryParams; diff --git a/ghost/admin/mirage/routes-test.js b/ghost/admin/mirage/routes-test.js index 0f812bf9a4b..bb06925be83 100644 --- a/ghost/admin/mirage/routes-test.js +++ b/ghost/admin/mirage/routes-test.js @@ -85,12 +85,8 @@ export default function () { const url = new URL(request.url, window.location.origin); const limit = url.searchParams.get('limit'); - const ALLOWED_LIMIT_ALL = [ - '/api/admin/members/upload/' - ]; - // limit=all is completely blocked, we shouldn't have any requests reach the server with this - if (limit === 'all' && !ALLOWED_LIMIT_ALL.some(allowed => path.includes(allowed))) { + if (limit === 'all') { throw new Error(`Blocked mirage request with limit=all: ${verb} ${path}.`); } diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 307ac3fd96b..3110169b629 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -52,9 +52,7 @@ "@tryghost/kg-converters": "1.2.1", "@tryghost/koenig-lexical": "1.8.1", "@tryghost/limit-service": "1.5.2", - "@tryghost/members-csv": "2.0.5", "@tryghost/nql": "0.12.10", - "@tryghost/nql-lang": "0.6.4", "@tryghost/string": "0.3.2", "@tryghost/timezone-data": "0.4.18", "animejs": "3.2.2", @@ -98,7 +96,6 @@ "ember-data": "3.24.0", "ember-decorators": "6.1.1", "ember-drag-drop": "0.4.8", - "ember-ella-sparse": "0.16.0", "ember-exam": "6.0.1", "ember-export-application-global": "2.0.1", "ember-fetch": "8.1.2", @@ -137,7 +134,6 @@ "miragejs": "0.1.48", "moment-timezone": "0.5.45", "normalize.css": "3.0.3", - "papaparse": "5.5.3", "postcss-color-mod-function": "3.0.3", "postcss-custom-media": "7.0.8", "postcss-custom-properties": "10.0.0", diff --git a/ghost/admin/public/assets/icons/email-member.svg b/ghost/admin/public/assets/icons/email-member.svg deleted file mode 100644 index 1c218fa2766..00000000000 --- a/ghost/admin/public/assets/icons/email-member.svg +++ /dev/null @@ -1,4 +0,0 @@ - - email-member - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/import-in-progress.svg b/ghost/admin/public/assets/icons/import-in-progress.svg deleted file mode 100644 index eaff7ed932b..00000000000 --- a/ghost/admin/public/assets/icons/import-in-progress.svg +++ /dev/null @@ -1 +0,0 @@ -download-dash-arrow \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-all.svg b/ghost/admin/public/assets/icons/members-all.svg deleted file mode 100644 index 0112982dc14..00000000000 --- a/ghost/admin/public/assets/icons/members-all.svg +++ /dev/null @@ -1,6 +0,0 @@ - - members-all - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-outline.svg b/ghost/admin/public/assets/icons/members-outline.svg deleted file mode 100644 index 06fde14ce63..00000000000 --- a/ghost/admin/public/assets/icons/members-outline.svg +++ /dev/null @@ -1,20 +0,0 @@ - - members-outline - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-paid.svg b/ghost/admin/public/assets/icons/members-paid.svg deleted file mode 100644 index 7df69799e4c..00000000000 --- a/ghost/admin/public/assets/icons/members-paid.svg +++ /dev/null @@ -1,5 +0,0 @@ - - members-paid - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-post.svg b/ghost/admin/public/assets/icons/members-post.svg deleted file mode 100644 index 6cb7aff52bd..00000000000 --- a/ghost/admin/public/assets/icons/members-post.svg +++ /dev/null @@ -1,6 +0,0 @@ - - members-post - - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/icons/members-segment.svg b/ghost/admin/public/assets/icons/members-segment.svg deleted file mode 100644 index bb94fc4ed17..00000000000 --- a/ghost/admin/public/assets/icons/members-segment.svg +++ /dev/null @@ -1,5 +0,0 @@ - - members-segment - - - \ No newline at end of file diff --git a/ghost/admin/public/assets/img/marketing/members-1.jpg b/ghost/admin/public/assets/img/marketing/members-1.jpg deleted file mode 100644 index d5d2022f090..00000000000 Binary files a/ghost/admin/public/assets/img/marketing/members-1.jpg and /dev/null differ diff --git a/ghost/admin/public/assets/img/marketing/members-2.jpg b/ghost/admin/public/assets/img/marketing/members-2.jpg deleted file mode 100644 index 2fd4091c830..00000000000 Binary files a/ghost/admin/public/assets/img/marketing/members-2.jpg and /dev/null differ diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js deleted file mode 100644 index a5800490794..00000000000 --- a/ghost/admin/tests/acceptance/members-test.js +++ /dev/null @@ -1,483 +0,0 @@ -import moment from 'moment-timezone'; -import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -import {beforeEach, describe, it} from 'mocha'; -import {blur, click, currentURL, fillIn, 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.skip('Acceptance: Members Test', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - it('redirects to signin when not authenticated', async function () { - await invalidateSession(); - await visit('/members'); - - expect(currentURL()).to.equal('/signin'); - }); - - it('redirects roles without member management permission to site', async function () { - let role = this.server.create('role', {name: 'Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - await visit('/members'); - - expect(currentURL()).to.equal('/site'); - }); - - describe('as owner', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('does not load or render the Ember members list when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - this.server.createList('member', 2); - - await visit('/members'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-screen-title]')).to.not.exist; - expect(find('[data-test-table="members"]')).to.not.exist; - - const membersRequests = this.server.pretender.handledRequests.filter(request => request.url.match(/\/members\/(\?|$)/)); - expect(membersRequests.length, 'members API requests').to.equal(0); - }); - - it('it renders, can be navigated, can edit member', async function () { - let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(2); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('.gh-members-list-name').textContent, 'member list item title') - .to.equal(member1.name); - - // it does not add ?include=email_recipients - const membersRequests = this.server.pretender.handledRequests.filter(r => r.url.match(/\/members\/(\?|$)/)); - expect(membersRequests[0].url).to.not.have.string('email_recipients'); - - await visit(`/members/${member1.id}`); - - // it shows selected member form - expect(find('[data-test-input="member-name"]').value, 'loads correct member into form') - .to.equal(member1.name); - - expect(find('[data-test-input="member-email"]').value, 'loads correct email into form') - .to.equal(member1.email); - - // trigger save - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await click('[data-test-button="save"]'); - - await click('[data-test-link="members-back"]'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - }); - - it('displays member correctly with blank string name in list item', async function () { - this.server.create('member', { - name: ' ', - email: 'blank@example.com', - createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss') - }); - - await visit('/members'); - - // it lists the member - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('h3').textContent.trim(), 'member list item shows email in h3') - .to.equal('blank@example.com'); - }); - - it('displays member correctly with blank string name in member details', async function () { - let member = this.server.create('member', { - name: ' ', - email: 'blank@example.com', - createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss') - }); - - await visit(`/members/${member.id}`); - // check that the email is in an h3 tag - expect(find('h3').textContent.trim(), 'member details title shows email') - .to.equal('blank@example.com'); - }); - - it('can create a new member', async function () { - this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - // start new member - await click('[data-test-new-member-button="true"]'); - - // it navigates to the new member route - expect(currentURL(), 'new member URL').to.equal('/members/new'); - // it displays the new member form - expect(find('.gh-canvas-header h2').textContent, 'settings pane title') - .to.contain('New'); - - // all fields start blank - findAll('.gh-member-settings-primary .gh-input').forEach(function (elem) { - expect(elem.value, `input field for ${elem.getAttribute('name')}`) - .to.be.empty; - }); - - // save new member - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await fillIn('[data-test-input="member-email"]', 'example@domain.com'); - await blur('[data-test-input="member-email"]'); - - await click('[data-test-button="save"]'); - - expect(find('[data-test-input="member-name"]').value, 'name has been preserved') - .to.equal('New Name'); - - expect(find('[data-test-input="member-email"]').value, 'email has been preserved') - .to.equal('example@domain.com'); - }); - - /* - * Due to a limitation with NQL, member bulk deletion is not permitted if any of the following Stripe subscription filters is used: - * - Billing period - * - Stripe subscription status - * - Paid start date - * - Next billing date - * - Subscription started on post/page - * - Offers - * - * For more context, see: - * - https://linear.app/tryghost/issue/ENG-1484 - * - https://linear.app/tryghost/issue/ENG-1466 - * - * See code: ghost/admin/app/controllers/members.js:isBulkDeletePermitted - * TODO: delete this block of tests once the guardrail has been removed - */ - describe('[Temp] Guardrail against bulk deletion', function () { - it('can bulk delete members if a non-Stripe subscription filter is in use (member tier, status)', async function () { - const tier = this.server.create('tier', {id: 'qwerty123456789'}); - this.server.createList('member', 2, {status: 'free'}); - this.server.createList('member', 2, {status: 'paid', tiers: [tier]}); - - await visit('/members'); - expect(findAll('[data-test-member]').length).to.equal(4); - - // The delete button should not be visible by default - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 1) Membership tier filter: permitted - await visit(`/members?filter=tier_id:[${tier.id}]`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.exist; - - // 2) Member status filter: permitted - await visit('/members?filter=status%3Afree'); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.exist; - }); - - it('cannot bulk delete members if a Stripe subscription filter is in use', async function () { - // Create free and paid members - const tier = this.server.create('tier'); - const offer = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); - this.server.createList('member', 2, {status: 'free'}); - this.server.createList('member', 2, {status: 'paid'}).forEach(member => this.server.create('subscription', {member, planInterval: 'month', status: 'active', start_date: '2000-01-01T00:00:00.000Z', current_period_end: '2000-02-01T00:00:00.000Z', offer: offer, tier: tier})); - this.server.createList('member', 2, {status: 'paid'}).forEach(member => this.server.create('subscription', {member, planInterval: 'year', status: 'active'})); - - await visit('/members'); - expect(findAll('[data-test-member]').length).to.equal(6); - - // The delete button should not be visible by default - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 1) Stripe billing period filter: not permitted - await visit('/members?filter=subscriptions.plan_interval%3Amonth'); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 2) Stripe subscription status filter: not permitted - await visit('/members?filter=subscriptions.status%3Aactive'); - expect(findAll('[data-test-member]').length).to.equal(4); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 3) Stripe paid start date filter: not permitted - await visit(`/members?filter=subscriptions.start_date%3A>'1999-01-01%2005%3A59%3A59'`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 4) Next billing date filter: not permitted - await visit(`/members?filter=subscriptions.current_period_end%3A>'2000-01-01%2005%3A59%3A59'`); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // 5) Offers redeemed filter: not permitted - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${offer.id}'`)); - expect(findAll('[data-test-member]').length).to.equal(2); - await click('[data-test-button="members-actions"]'); - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - }); - }); - - it('can bulk delete members', async function () { - // members to be kept - this.server.createList('member', 6); - - // imported members to be deleted - const label = this.server.create('label'); - this.server.createList('member', 5, {labels: [label]}); - - await visit('/members'); - - expect(findAll('[data-test-member]').length).to.equal(11); - - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // a filter is needed for the delete-selected button to show - await click('[data-test-button="members-filter-actions"]'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'label'); - await click('.gh-member-label-input input'); - await click(`[data-test-label-filter="${label.name}"]`); - await click(`[data-test-button="members-apply-filter"]`); - - expect(findAll('[data-test-member]').length).to.equal(5); - expect(currentURL()).to.equal(`/members?filter=label%3A%5B${label.slug}%5D`); - - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="delete-selected"]')).to.exist; - - await click('[data-test-button="delete-selected"]'); - - expect(find('[data-test-modal="delete-members"]')).to.exist; - expect(find('[data-test-text="delete-count"]')).to.have.text('5 members'); - - // ensure export endpoint gets hit with correct query params when deleting - let exportQueryParams; - this.server.get('/members/upload', (schema, request) => { - exportQueryParams = request.queryParams; - }); - - await click('[data-test-button="confirm"]'); - - expect(exportQueryParams).to.deep.equal({filter: 'label:[label-0]', limit: 'all'}); - - expect(find('[data-test-text="deleted-count"]')).to.have.text('5 members'); - expect(find('[data-test-button="confirm"]')).to.not.exist; - - // members filter is reset - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-member]').length).to.equal(6); - - await click('[data-test-button="close-modal"]'); - - expect(find('[data-test-modal="delete-members"]')).to.not.exist; - }); - - it('formats counts in members actions menu for filtered lists', async function () { - this.server.createList('member', 1000, {status: 'free'}); - - await visit('/members?filter=status%3Afree'); - await click('[data-test-button="members-actions"]'); - - expect(find('[data-test-button="export-members"] span')).to.have.text('Export selected members (1,000)'); - expect(find('[data-test-button="add-label-selected"] span')).to.have.text('Add label for selected members (1,000)'); - expect(find('[data-test-button="remove-label-selected"] span')).to.have.text('Remove label from selected members (1,000)'); - expect(find('[data-test-button="delete-selected"] span')).to.have.text('Delete selected members (1,000)'); - }); - - it('can delete a member (via list)', async function () { - const newsletter = this.server.create('newsletter'); - const label = this.server.create('label'); - this.server.createList('member', 2, {newsletters: [newsletter], labels: [label]}); - - await visit('/members'); - - expect(findAll('[data-test-member]').length).to.equal(2); - - await click('[data-test-member] a'); - - expect(currentURL()).to.match(/members\/\d+/); - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - - expect(find('[data-test-modal="delete-member"]')).to.exist; - - await click('[data-test-modal="delete-member"] [data-test-button="cancel"]'); - - expect(currentURL()).to.match(/members\/\d+/); - expect(find('[data-test-modal="delete-member"]')).to.not.exist; - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - await click('[data-test-modal="delete-member"] [data-test-button="confirm"]'); - - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-modal]')).to.have.length(0); - expect(findAll('[data-test-member]')).to.have.length(1); - }); - - it('can delete a member (via url)', async function () { - const newsletter = this.server.create('newsletter'); - const label = this.server.create('label'); - const [memberOne] = this.server.createList('member', 2, {newsletters: [newsletter], labels: [label]}); - - await visit(`/members/${memberOne.id}`); - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - - expect(find('[data-test-modal="delete-member"]')).to.exist; - - await click('[data-test-modal="delete-member"] [data-test-button="cancel"]'); - - expect(currentURL()).to.match(/members\/\d+/); - expect(find('[data-test-modal="delete-member"]')).to.not.exist; - - await click('[data-test-button="member-actions"]'); - await click('[data-test-button="delete-member"]'); - await click('[data-test-modal="delete-member"] [data-test-button="confirm"]'); - - expect(currentURL()).to.equal('/members'); - expect(findAll('[data-test-modal]')).to.have.length(0); - expect(findAll('[data-test-member]')).to.have.length(1); - }); - }); - describe('as super editor', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Super Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('it renders, can be navigated, can edit member', async function () { - let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(2); - - let member = find('[data-test-list="members-list-item"]'); - expect(member.querySelector('.gh-members-list-name').textContent, 'member list item title') - .to.equal(member1.name); - - // it does not add ?include=email_recipients - const membersRequests = this.server.pretender.handledRequests.filter(r => r.url.match(/\/members\/(\?|$)/)); - expect(membersRequests[0].url).to.not.have.string('email_recipients'); - - await visit(`/members/${member1.id}`); - - // it shows selected member form - expect(find('[data-test-input="member-name"]').value, 'loads correct member into form') - .to.equal(member1.name); - - expect(find('[data-test-input="member-email"]').value, 'loads correct email into form') - .to.equal(member1.email); - - // trigger save - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await click('[data-test-button="save"]'); - - await click('[data-test-link="members-back"]'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - }); - - it('can create a new member', async function () { - this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - // lands on correct page - expect(currentURL(), 'currentURL').to.equal('/members'); - - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); - - // start new member - await click('[data-test-new-member-button="true"]'); - - // it navigates to the new member route - expect(currentURL(), 'new member URL').to.equal('/members/new'); - // it displays the new member form - expect(find('.gh-canvas-header h2').textContent, 'settings pane title') - .to.contain('New'); - - // all fields start blank - findAll('.gh-member-settings-primary .gh-input').forEach(function (elem) { - expect(elem.value, `input field for ${elem.getAttribute('name')}`) - .to.be.empty; - }); - - // save new member - await fillIn('[data-test-input="member-name"]', 'New Name'); - await blur('[data-test-input="member-name"]'); - - await fillIn('[data-test-input="member-email"]', 'example@domain.com'); - await blur('[data-test-input="member-email"]'); - - await click('[data-test-button="save"]'); - - expect(find('[data-test-input="member-name"]').value, 'name has been preserved') - .to.equal('New Name'); - - expect(find('[data-test-input="member-email"]').value, 'email has been preserved') - .to.equal('example@domain.com'); - }); - }); -}); diff --git a/ghost/admin/tests/acceptance/members/filter-test.js b/ghost/admin/tests/acceptance/members/filter-test.js deleted file mode 100644 index 412116113af..00000000000 --- a/ghost/admin/tests/acceptance/members/filter-test.js +++ /dev/null @@ -1,1687 +0,0 @@ -import moment from 'moment-timezone'; -import sinon from 'sinon'; -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {blur, click, currentURL, fillIn, find, findAll, focus} from '@ember/test-helpers'; -import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../../helpers/mock-analytics-apps'; -import {datepickerSelect} from 'ember-power-datepicker/test-support'; -import {enableNewsletters} from '../../helpers/newsletters'; -import {enablePaidMembers} from '../../helpers/members'; -import {enableStripe} from '../../helpers/stripe'; -import {expect} from 'chai'; -import {selectChoose} from 'ember-power-select/test-support/helpers'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../../helpers/visit'; - -describe.skip('Acceptance: Members filtering', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - let clock; - - beforeEach(async function () { - mockAnalyticsApps(); - - this.server.loadFixtures('configs'); - this.server.loadFixtures('settings'); - this.server.loadFixtures('newsletters'); - enableStripe(this.server); - enableNewsletters(this.server, true); - enablePaidMembers(this.server); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - afterEach(function () { - cleanupMockAnalyticsApps(); - clock?.restore(); - }); - - it('has a known base-state', async function () { - this.server.createList('member', 7); - - await visit('/members'); - - // members are listed - expect(find('[data-test-table="members"]')).to.exist; - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows').to.equal(7); - - // export is available - expect(find('[data-test-button="export-members"]'), 'export members button').to.exist; - expect(find('[data-test-button="export-members"]'), 'export members button').to.not.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.not.exist; - expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.not.exist; - expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.not.exist; - - // filter and search are inactive - expect(find('[data-test-input="members-search"]'), 'search input').to.exist; - expect(find('[data-test-input="members-search"]'), 'search input').to.not.have.class('active'); - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - - // standard columns are shown - expect(findAll('[data-test-table="members"] [data-test-table-column]').length).to.equal(4); - }); - - describe('filtering', function () { - it('can filter by label', async function () { - // add some labels to test the selection dropdown - this.server.createList('label', 4); - - // add a labelled member so we can test the filter includes correctly - const label = this.server.create('label'); - this.server.createList('member', 3, {labels: [label]}); - // add some non-labelled members so we can see the filter excludes correctly - this.server.createList('member', 4); - - await visit('/members'); - - const getLabelRequests = () => { - return this.server.pretender.handledRequests.filter((request) => { - return request.url.includes('/ghost/api/admin/labels/'); - }); - }; - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'label'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // value dropdown can open and has all labels - await click(`${filterSelector} .gh-member-label-input`); - expect(findAll(`${filterSelector} [data-test-label-filter]`).length, '# of label options').to.equal(5); - - const labelRequests = getLabelRequests(); - expect(labelRequests.length).to.be.greaterThan(0); - labelRequests.forEach((request) => { - const parsedUrl = new URL(request.url); - expect(parsedUrl.searchParams.get('limit')).to.not.equal('all'); - }); - expect(labelRequests.some((request) => { - const parsedUrl = new URL(request.url); - return parsedUrl.searchParams.get('limit') === '100'; - })).to.be.true; - - // selecting a value updates table - await selectChoose(`${filterSelector} .gh-member-label-input`, label.name); - - expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${label.name}`) - .to.equal(3); - - // table shows labels column+data - expect(find('[data-test-table-column="label"]')).to.exist; - expect(findAll('[data-test-table-data="label"]').length).to.equal(3); - expect(find('[data-test-table-data="label"]')).to.contain.text(label.name); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by tier', async function () { - // add multiple tiers to activate tiers filtering - const newsletter = this.server.create('newsletter', {status: 'active'}); - this.server.createList('tier', 4); - - // add some members with tiers - const tier = this.server.create('tier', {id: 'qwerty123456789'}); - this.server.createList('member', 3, {tiers: [tier], newsletters: [newsletter]}); - - // add some free members so we can see the filter excludes correctly - this.server.createList('member', 4, {newsletters: [newsletter]}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'tier_id'); - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // value dropdown can open and has all labels - await click(`${filterSelector} .gh-tier-token-input`); - expect(findAll(`${filterSelector} [data-test-tiers-segment]`).length, '# of label options').to.equal(5); - - // selecting a value updates table - await selectChoose(`${filterSelector} .gh-tier-token-input`, tier.name); - - expect(findAll('[data-test-list="members-list-item"]').length, `# of filtered member rows - ${tier.name}`) - .to.equal(3); - // table shows labels column+data - expect(find('[data-test-table-column="status"]')).to.exist; - expect(findAll('[data-test-table-data="status"]').length).to.equal(3); - expect(find('[data-test-table-data="status"]')).to.contain.text(tier.name); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by offer redeemed', async function () { - // add some offers to test the selection dropdown - const tier = this.server.create('tier'); - - // create 3 offers - const offer = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); - this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(2, 'day').valueOf()}); - this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(3, 'day').valueOf()}); - this.server.createList('member', 3, {status: 'paid', tiers: [tier]}); - const sub = this.server.create('subscription', {member: this.server.schema.members.first(), tier: tier, offer: offer}); - const member = this.server.schema.members.first(); - member.update({subscriptions: [sub]}); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'offer_redemptions'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - await click(`${filterSelector} [data-test-token-input]`); - // this ensures that the offers are loaded into the multi-select dropdown in the filter - expect(findAll(`${filterSelector} [data-test-offers-segment]`).length, '# of label options').to.equal(3); - - // can set filter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${offer.id}'`)); // ensure that the id is parsed as a string and not an integer - - // only one redeemed offer so only 1 member should be shown - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1); - }); - - it('shows synthetic retention options instead of individual retention offers', async function () { - const tier = this.server.create('tier'); - - this.server.create('offer', { - name: 'Welcome offer', - tier: {id: tier.id}, - redemptionType: 'signup', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Monthly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Monthly retention v2', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - this.server.create('offer', { - name: 'Yearly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'year' - }); - - this.server.createList('member', 2, {status: 'paid', tiers: [tier]}); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'offer_redemptions'); - await click(`${filterSelector} [data-test-token-input]`); - - const offerOptions = findAll(`${filterSelector} [data-test-offers-segment]`).map(node => node.textContent.trim()); - - expect(offerOptions).to.include('Welcome offer'); - expect(offerOptions).to.include('Monthly Retention'); - expect(offerOptions).to.include('Yearly Retention'); - expect(offerOptions).to.not.include('Monthly retention v1'); - expect(offerOptions).to.not.include('Monthly retention v2'); - expect(offerOptions).to.not.include('Yearly retention v1'); - }); - - it('keeps specific retention offer URL filters without listing that version in dropdown', async function () { - const tier = this.server.create('tier'); - const monthlyRetentionV1 = this.server.create('offer', { - name: 'Monthly retention v1', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - const monthlyRetentionV2 = this.server.create('offer', { - name: 'Monthly retention v2', - tier: null, - redemptionType: 'retention', - cadence: 'month' - }); - - const memberA = this.server.create('member', {status: 'paid', tiers: [tier]}); - const memberB = this.server.create('member', {status: 'paid', tiers: [tier]}); - - const subscriptionA = this.server.create('subscription', {member: memberA, tier, offer: monthlyRetentionV1}); - const subscriptionB = this.server.create('subscription', {member: memberB, tier, offer: monthlyRetentionV2}); - - memberA.update({subscriptions: [subscriptionA]}); - memberB.update({subscriptions: [subscriptionB]}); - - await visit('/members?filter=' + encodeURIComponent(`offer_redemptions:'${monthlyRetentionV2.id}'`)); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1); - - await click('[data-test-button="members-filter-actions"]'); - const filterSelector = `[data-test-members-filter="0"]`; - await click(`${filterSelector} [data-test-token-input]`); - - const offerOptions = findAll(`${filterSelector} [data-test-offers-segment]`).map(node => node.textContent.trim()); - - expect(offerOptions).to.include('Monthly Retention'); - expect(offerOptions).to.not.include('Monthly retention v1'); - expect(offerOptions).to.not.include('Monthly retention v2'); - }); - - it('can filter by newsletter subscription when there is only one newsletter', async function () { - // Create a single newsletter - this.server.createList('newsletter', 1); - // Add some members to filter - this.server.createList('member', 3, {subscribed: true, email_disabled: 0}); - this.server.createList('member', 4, {subscribed: false, email_disabled: 0}); - this.server.createList('member', 1, {subscribed: true, email_disabled: 1}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(8); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscribed'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(3); - expect(valueOptions[0]).to.have.value('subscribed'); - expect(valueOptions[1]).to.have.value('unsubscribed'); - expect(valueOptions[2]).to.have.value('email-disabled'); - - // applies default filter subscribed immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed') - .to.equal(3); - - // can change filter to unsubscribed - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'unsubscribed'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed') - .to.equal(4); - expect(find('[data-test-table-column="subscribed"]')).to.exist; - - // can change filter to email-disabled - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'email-disabled'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled') - .to.equal(1); - expect(find('[data-test-table-column="subscribed"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(8); - - // Can set filter to 'subscribed' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(subscribed:true+email_disabled:0)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed - from URL') - .to.equal(3); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('subscribed'); - - // Can set filter to 'unsubscribed' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(subscribed:false+email_disabled:0)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed - from URL') - .to.equal(4); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('unsubscribed'); - - // Can set filter to 'email-disabled' by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent('(email_disabled:1)')); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled - from URL') - .to.equal(1); - await click('[data-test-button="members-filter-actions"]'); - expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('email-disabled'); - }); - - it('can filter by specific newsletter subscription when there are multiple newsletters', async function () { - // Create: - // - 1 subscribed member to newsletter - // - 1 subscribed member to newsletter with email disabled - // - 4 unsubscribed members - const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'}); - const tier = this.server.create('tier'); - - const subscribedMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 0}); - subscribedMember.update({newsletters: [newsletter]}); - - const emailDisabledMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 1}); - emailDisabledMember.update({newsletters: [newsletter]}); - - this.server.createList('member', 4, {subscribed: false, email_disabled: 0}); - - // Test initial member count - await visit('/members'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(6); - - // Test newsletters options are in the filter dropdown - await click('[data-test-button="members-filter-actions"]'); - const newslettersCount = this.server.schema.newsletters.all().models.length; - let options = this.element.querySelectorAll('option'); - let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug')); - expect(matchingOptions).to.have.length(newslettersCount); - - const filterSelector = `[data-test-members-filter="0"]`; - - // Select first newsletter - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, `newsletters.slug:${newsletter.slug}`); - - // Test that the filter has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // Test that the filter has the right operators - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions[0]).to.have.value('true'); - expect(valueOptions[1]).to.have.value('false'); - - // applies default filter subscribed immediately, and only count subscribed members without email disabled - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed') - .to.equal(1); - - // can change filter to unsubscribed - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed') - .to.equal(5); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(6); - - // Can filter members subscribed to that newsletter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}+email_disabled:0`)); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(1); - - // Can filter members unsubscribed to that newsletter by path - await visit('/'); - await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:-${newsletter.slug},email_disabled:1`)); - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(5); - }); - - it('can filter by member status', async function () { - // add some members to filter - this.server.createList('member', 3, {status: 'paid'}); - this.server.createList('member', 4, {status: 'free'}); - this.server.createList('member', 2, {status: 'comped'}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - expect( - find(`${filterSelector} [data-test-select="members-filter"] option[value="status"]`), - 'status filter option' - ).to.exist; - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'status'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(3); - expect(valueOptions[0]).to.have.value('paid'); - expect(valueOptions[1]).to.have.value('free'); - expect(valueOptions[2]).to.have.value('comped'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - paid') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'comped'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - comped') - .to.equal(2); - expect(find('[data-test-table-column="status"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(9); - }); - - it('can filter by billing period', async function () { - // add some members to filter - 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'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscriptions.plan_interval'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(2); - expect(valueOptions[0]).to.have.value('month'); - expect(valueOptions[1]).to.have.value('year'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - month') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'year'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - year') - .to.equal(4); - expect(find('[data-test-table-column="subscriptions.plan_interval"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by stripe subscription status', async function () { - // add some members to filter - 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'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'subscriptions.status'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(2); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-not'); - - // has the right values - const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`); - expect(valueOptions).to.have.length(7); - expect(valueOptions[0]).to.have.value('active'); - expect(valueOptions[1]).to.have.value('trialing'); - expect(valueOptions[2]).to.have.value('canceled'); - expect(valueOptions[3]).to.have.value('unpaid'); - expect(valueOptions[4]).to.have.value('past_due'); - expect(valueOptions[5]).to.have.value('incomplete'); - expect(valueOptions[6]).to.have.value('incomplete_expired'); - - // applies default filter immediately - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - active') - .to.equal(3); - - // can change filter - await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'trialing'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - trialing') - .to.equal(4); - expect(find('[data-test-table-column="subscriptions.status"]')).to.exist; - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by emails sent', async function () { - // add some members to filter - this.server.createList('member', 3, {emailCount: 5}); - this.server.createList('member', 4, {emailCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_count'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '5'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false') - .to.equal(3); - expect(find('[data-test-table-column="email_count"]')).to.exist; - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows') - .to.equal(7); - }); - - it('can filter by emails opened', async function () { - // add some members to filter - this.server.createList('member', 3, {emailOpenedCount: 5}); - this.server.createList('member', 4, {emailOpenedCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_opened_count'); - - // has the right operators - const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '5'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - input 5') - .to.equal(3); - expect(find('[data-test-table-column="email_opened_count"]')).to.exist; - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - cleared') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by open rate', async function () { - // add some members to filter - this.server.createList('member', 3, {emailOpenRate: 50}); - this.server.createList('member', 4, {emailOpenRate: 100}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelector = `[data-test-members-filter="0"]`; - - await fillIn(`${filterSelector} [data-test-select="members-filter"]`, 'email_open_rate'); - - const operatorSelector = `${filterSelector} [data-test-select="members-filter-operator"]`; - - // has the right operators - const operatorOptions = findAll(`${operatorSelector} option`); - expect(operatorOptions).to.have.length(3); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('is-greater'); - expect(operatorOptions[2]).to.have.value('is-less'); - - const valueInput = `${filterSelector} [data-test-input="members-filter-value"]`; - - // has no default filter - expect(find(valueInput)).to.have.value(''); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change filter - await fillIn(valueInput, '50'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - value 50') - .to.equal(3); - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - - // can change operator - await fillIn(operatorSelector, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - operator is-greater') - .to.equal(4); - - // it does not add duplicate column - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - expect(findAll('[data-test-table-column="email_open_rate"]').length).to.equal(1); - - // can clear filter - await fillIn(valueInput, ''); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - cleared') - .to.equal(7); - - // can delete filter - await click('[data-test-delete-members-filter="0"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete') - .to.equal(7); - }); - - it('can filter by last seen date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-02-05 11:50:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3, {lastSeenAt: moment('2022-02-01 11:00:00').format('YYYY-MM-DD HH:mm:ss')}); - this.server.createList('member', 4, {lastSeenAt: moment('2022-02-05 11:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'last_seen_at'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - // has the right default operator - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // has expected default value - expect(find(valueInput)).to.have.value('2022-02-05'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can focus/blur value input without issue - await focus(valueInput); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - after blur') - .to.equal(7); - - // can change operator - await fillIn(operatorSelect, 'is-less'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is before 2022-02-05') - .to.equal(3); - - // can change filter via input - await fillIn(operatorSelect, 'is-greater'); - await fillIn(valueInput, '2022-02-01'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is after 2022-02-01') - .to.equal(4); - - // can change filter via date picker - await fillIn(operatorSelect, 'is-or-greater'); - await datepickerSelect(valueDatePicker, moment.utc('2022-01-01').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is after 2022-01-01') - .to.equal(7); - - // table shows last seen column+data - expect(find('[data-test-table-column="last_seen_at"]')).to.exist; - expect(findAll('[data-test-table-data="last_seen_at"]').length).to.equal(7); - expect(find('[data-test-table-data="last_seen_at"]')).to.contain.trimmed.text('1 Feb 2022'); - expect(find('[data-test-table-data="last_seen_at"]')).to.contain.trimmed.text('4 days ago'); - }); - - it('can filter by created at date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - this.server.createList('member', 3, {createdAt: moment('2022-02-01 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - this.server.createList('member', 4, {createdAt: moment('2022-02-05 12:00:00').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="created_at"]`), 'created_at filter option').to.exist; - - await fillIn(typeSelect, 'created_at'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`created_at:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('created_at'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // "on or after" doesn't break - await fillIn(operatorSelect, 'is-or-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-or-greater after URL change') - .to.equal(7); - - // it does not add extra column to table - expect(find('[data-test-table-column="created_at"]')).to.not.exist; - }); - - it('uses site timezone when filtering by date', async function () { - // with a site timezone UTC-5 (Eastern Time Zone) we would expect date-based NQL filter strings - // to be adjusted to UTC. - // - // Eg. "created on or after 2022-02-22" = `created_at:>='2022-02-22 05:00:00' - // - // we also need to convert back when parsing the NQL-based query param and make sure dates - // shown in the members table match site timezone - - // UTC-5 timezone - this.server.db.settings.update({key: 'timezone'}, {value: 'America/New_York'}); - - // 2022-02-21 signups - this.server.createList('member', 3, {createdAt: moment.utc('2022-02-22 04:00:00.000Z').format('YYYY-MM-DD HH:mm:ss')}); - // 2022-02-22 signups - this.server.createList('member', 4, {createdAt: moment.utc('2022-02-22 05:00:00.000Z').format('YYYY-MM-DD HH:mm:ss')}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - // created dates in table should match the date in site timezone not UTC (in UTC they would all be 21st) - const createdAtFields = findAll('[data-test-list="members-list-item"] [data-test-table-data="created-at"]'); - expect(createdAtFields.filter(el => el.textContent.match(/21 Feb 2022/)).length).to.equal(3); - expect(createdAtFields.filter(el => el.textContent.match(/22 Feb 2022/)).length).to.equal(4); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - - // filter date is transformed to UTC equivalent timeframe when querying - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'created_at'); - await fillIn(operatorSelect, 'is-or-greater'); - await fillIn(valueInput, '2022-02-22'); - await blur(valueInput); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows - post filter') - .to.equal(4); - - // query param is transformed back to expected filter date value - await visit('/'); // TODO: remove once component reacts to filter updates - const filterQuery = encodeURIComponent(`created_at:<='2022-02-22 04:59:59'`); - await visit(`/members?filter=${filterQuery}`); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows - post URL parse') - .to.equal(3); - - await click('[data-test-button="members-filter-actions"]'); - - expect(find(operatorSelect)).to.have.value('is-or-less'); - expect(find(valueInput)).to.have.value('2022-02-21'); - - // it initializes date filter with correct site timezone date - // "local" is 1st March 04:00 but site time is 28th Feb 00:00 - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 04:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - await click('[data-test-delete-members-filter="0"]'); - await click('[data-test-button="members-filter-actions"]'); - await fillIn(typeSelect, 'created_at'); - - expect(find(valueInput)).to.have.value('2022-02-28'); - }); - - it('can filter by paid subscription start date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - 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'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="subscriptions.start_date"]`), 'subscriptions.start_date filter option').to.exist; - - await fillIn(typeSelect, 'subscriptions.start_date'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`subscriptions.start_date:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('subscriptions.start_date'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // it adds extra column to table - expect(find('[data-test-table-column="subscriptions.start_date"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.start_date"]')).to.contain.text('Paid start date'); - expect(findAll('[data-test-table-data="subscriptions.start_date"]').length).to.equal(3); - expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('1 Feb 2022'); - expect(find('[data-test-table-data="subscriptions.start_date"]')).to.contain.text('a month ago'); - }); - - it('can filter by name', async function () { - this.server.create('member', {name: 'test-1'}); - this.server.create('member', {name: 'test-2'}); - this.server.create('member', {name: 'tset-1'}); - this.server.create('member', {name: 'tset-2'}); - this.server.create('member', {name: 'tset-3'}); - this.server.create('member', {name: 'hello'}); - this.server.create('member', {name: 'John O\'Nolan'}); - this.server.create('member', {name: null}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(8); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="name"]`), 'name filter option').to.exist; - - await fillIn(typeSelect, 'name'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(5); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('contains'); - expect(operatorOptions[2]).to.have.value('does-not-contain'); - expect(operatorOptions[3]).to.have.value('starts-with'); - expect(operatorOptions[4]).to.have.value('ends-with'); - - // has expected default operator and value - expect(find(operatorSelect)).to.have.value('is'); - expect(find(valueInput)).to.have.value(''); - - // can change filter - await fillIn(valueInput, 'hello'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is "hello"') - .to.equal(1); - - // can change operator - await fillIn(operatorSelect, 'contains'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "hello"') - .to.equal(1); - - // contains query works - await fillIn(valueInput, 'test'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "test"') - .to.equal(2); - - // starts with query works - await fillIn(operatorSelect, 'starts-with'); - await fillIn(valueInput, 'tset'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - starts with "tset"') - .to.equal(3); - - // ends with query works - await fillIn(operatorSelect, 'ends-with'); - await fillIn(valueInput, '2'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - ends with "2"') - .to.equal(2); - - // does not contain query works - await fillIn(operatorSelect, 'does-not-contain'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - does not contain "2"') - .to.equal(6); - - // can query with escaped chars - await fillIn(operatorSelect, 'contains'); - await fillIn(valueInput, `O'Nolan`); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "O\'Nolan"') - .to.equal(1); - - // no duplicate column added (name is included in the "details" column) - expect(find('[data-test-table-column="name"]')).to.not.exist; - - // can handle contains operator in URL - let filter = encodeURIComponent(`name:~'hello'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "hello"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value('hello'); - - // can handle starts-with operator in URL - filter = encodeURIComponent(`name:~^'tset'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL starts with "tset"') - .to.equal(3); - expect(find(operatorSelect)).to.have.value('starts-with'); - expect(find(valueInput)).to.have.value('tset'); - - // can handle ends-with operator in URL - filter = encodeURIComponent(`name:~$'2'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL ends with "2"') - .to.equal(2); - expect(find(operatorSelect)).to.have.value('ends-with'); - expect(find(valueInput)).to.have.value('2'); - - // can handle does-not-contain operator in URL - filter = encodeURIComponent(`name:-~'2'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL does not contain "2"') - .to.equal(6); - expect(find(operatorSelect)).to.have.value('does-not-contain'); - expect(find(valueInput)).to.have.value('2'); - - // can handle escaped values in URL - filter = encodeURIComponent(`name:~'O\\'Nolan'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "O\'Nolan"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value(`O'Nolan`); - - // can handle regex special chars in URL - filter = encodeURIComponent(`name:~'test+test'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "test+test"') - .to.equal(0); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value(`test+test`); - }); - - it('can filter by email', async function () { - this.server.create('member', {email: 'test-1@one.com'}); - this.server.create('member', {email: 'test-2@one.com'}); - this.server.create('member', {email: 'test-1@two.com'}); - this.server.create('member', {email: 'test-2@two.com'}); - this.server.create('member', {email: 'test-3@two.com'}); - this.server.create('member', {email: 'hello@hi.com'}); - this.server.create('member', {email: 'with+plus@fuzzy.org'}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - const valueInput = `${filterSelect} [data-test-input="members-filter-value"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="email"]`), 'email filter option').to.exist; - - await fillIn(typeSelect, 'email'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(5); - expect(operatorOptions[0]).to.have.value('is'); - expect(operatorOptions[1]).to.have.value('contains'); - expect(operatorOptions[2]).to.have.value('does-not-contain'); - expect(operatorOptions[3]).to.have.value('starts-with'); - expect(operatorOptions[4]).to.have.value('ends-with'); - - // has expected default operator and value - expect(find(operatorSelect)).to.have.value('is'); - expect(find(valueInput)).to.have.value(''); - - // can change filter - await fillIn(valueInput, 'hello@hi.com'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is "hello@hi.com"') - .to.equal(1); - - // can change operator - await fillIn(operatorSelect, 'contains'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "hello"') - .to.equal(1); - - // contains query works - await fillIn(valueInput, 'test'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "test"') - .to.equal(5); - - // starts with query works - await fillIn(operatorSelect, 'starts-with'); - await fillIn(valueInput, 'test-2'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - starts with "test-2"') - .to.equal(2); - - // ends with query works - await fillIn(operatorSelect, 'ends-with'); - await fillIn(valueInput, '.com'); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - ends with ".com"') - .to.equal(6); - - // does not contain query works - await fillIn(operatorSelect, 'does-not-contain'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - does not contain ".com"') - .to.equal(1); - - // can query with special chars - await fillIn(operatorSelect, 'contains'); - await fillIn(valueInput, `with+plus`); - await blur(valueInput); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - contains "with+plus"') - .to.equal(1); - - // no duplicate column added (email is included in the "details" column) - expect(find('[data-test-table-column="email"]')).to.not.exist; - - // can handle contains operator in URL - let filter = encodeURIComponent(`email:~'hello'`); - await visit('/'); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL contains "hello"') - .to.equal(1); - expect(find(operatorSelect)).to.have.value('contains'); - expect(find(valueInput)).to.have.value('hello'); - }); - - it('can filter by next billing date', async function () { - clock = sinon.useFakeTimers({ - now: moment('2022-03-01 09:00:00.000Z').toDate(), - shouldAdvanceTime: true - }); - - // add some members to filter - 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'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(9); - - await click('[data-test-button="members-filter-actions"]'); - - const filterSelect = `[data-test-members-filter="0"]`; - const typeSelect = `${filterSelect} [data-test-select="members-filter"]`; - const operatorSelect = `${filterSelect} [data-test-select="members-filter-operator"]`; - - expect(find(`${filterSelect} [data-test-select="members-filter"] option[value="subscriptions.current_period_end"]`), 'subscriptions.current_period_end filter option').to.exist; - - await fillIn(typeSelect, 'subscriptions.current_period_end'); - - // has the right operators - const operatorOptions = findAll(`${operatorSelect} option`); - expect(operatorOptions).to.have.length(4); - expect(operatorOptions[0]).to.have.value('is-less'); - expect(operatorOptions[1]).to.have.value('is-or-less'); - // expect(operatorOptions[2]).to.have.value('is'); - // expect(operatorOptions[3]).to.have.value('is-not'); - expect(operatorOptions[2]).to.have.value('is-greater'); - expect(operatorOptions[3]).to.have.value('is-or-greater'); - - const valueDateInput = `${filterSelect} [data-test-input="members-filter-value"] [data-test-date-picker-input]`; - const valueDatePicker = `${filterSelect} [data-test-input="members-filter-value"]`; - - // operator defaults to "on or before" - expect(find(operatorSelect)).to.have.value('is-or-less'); - - // value defaults to today's date - expect(find(valueDateInput)).to.have.value('2022-03-01'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - default') - .to.equal(7); - - // can change date - await datepickerSelect(valueDatePicker, moment.utc('2022-02-03').toDate()); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - 2022-02-03') - .to.equal(3); - - // can change operator - await fillIn(operatorSelect, 'is-greater'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - is-greater') - .to.equal(4); - - // can populate filter from URL - // TODO: leaving screen is needed, suggests component is not fully reactive and needs to be torn down. - // - see constructor - await visit(`/`); - const filter = encodeURIComponent(`subscriptions.current_period_end:<='2022-02-01 23:59:59'`); - await visit(`/members?filter=${filter}`); - await click('[data-test-button="members-filter-actions"]'); - - expect(find(typeSelect), 'type select - from URL').to.have.value('subscriptions.current_period_end'); - expect(find(operatorSelect), 'operator select - from URL').to.have.value('is-or-less'); - expect(find(valueDateInput), 'date input - from URL').to.have.value('2022-02-01'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - from URL') - .to.equal(3); - - // it adds extra column to table - expect(find('[data-test-table-column="subscriptions.current_period_end"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.current_period_end"]')).to.contain.text('Next billing date'); - expect(findAll('[data-test-table-data="subscriptions.current_period_end"]').length).to.equal(3); - expect(find('[data-test-table-data="subscriptions.current_period_end"]')).to.contain.text('1 Feb 2022'); - expect(find('[data-test-table-data="subscriptions.current_period_end"]')).to.contain.text('a month ago'); - }); - - it('can handle multiple filters', async function () { - // add some members to filter - 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'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(10); - - await click('[data-test-button="members-filter-actions"]'); - - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'email_open_rate'); - await fillIn('[data-test-members-filter="0"] [data-test-input="members-filter-value"]', '50'); - await blur('[data-test-members-filter="0"] [data-test-input="members-filter-value"]'); - - await click('[data-test-button="add-members-filter"]'); - - await fillIn(`[data-test-members-filter="1"] [data-test-select="members-filter"]`, 'subscriptions.status'); - await fillIn(`[data-test-members-filter="1"] [data-test-select="members-filter-value"]`, 'trialing'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members rows after filter') - .to.equal(3); - - await click('[data-test-button="members-apply-filter"]'); - - // all filtered columns are shown - expect(find('[data-test-table-column="email_open_rate"]')).to.exist; - expect(find('[data-test-table-column="subscriptions.status"]')).to.exist; - - // bulk actions are shown - expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.exist; - expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.exist; - expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.exist; - - /* NOTE: Bulk deletion is disabled temporarily when multiple filters are applied, due to a NQL limitation. - * Re-enable following line once we have fixed the root NQL limitation. - * See https://linear.app/tryghost/issue/ONC-203 - */ - // expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist; - - // filter is active and has # of filters - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.have.class('gh-btn-label-green'); - expect(find('[data-test-button="members-filter-actions"]'), 'filter button').to.contain.text('(2)'); - - // search is inactive - expect(find('[data-test-input="members-search"]'), 'search input').to.exist; - expect(find('[data-test-input="members-search"]'), 'search input').to.not.have.class('active'); - - // can reset filter - await click('[data-test-button="members-filter-actions"]'); - await click('[data-test-button="reset-members-filter"]'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(10); - - // filter is inactive - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - }); - - it('has a no-match state', async function () { - this.server.createList('member', 5).forEach(member => this.server.create('subscription', {member, status: 'active'})); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(5); - - await click('[data-test-button="members-filter-actions"]'); - - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'email_open_rate'); - await fillIn('[data-test-members-filter="0"] [data-test-input="members-filter-value"]', '50'); - await blur('[data-test-members-filter="0"] [data-test-input="members-filter-value"]'); - - await click('[data-test-button="members-apply-filter"]'); - - // replaces members table with the no-matching members state - expect(find('[data-test-table="members"]')).to.not.exist; - expect(find('[data-test-no-matching-members]')).to.exist; - - // search input is hidden - expect(find('[data-test-input="members-search"]')).to.not.be.visible; - - // export is disabled - expect(find('[data-test-button="export-members"]')).to.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]')).to.not.exist; - expect(find('[data-test-button="remove-label-selected"]')).to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]')).to.not.exist; - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // can clear the filter - await click('[data-test-no-matching-members] [data-test-button="show-all-members"]'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.not.have.class('gh-btn-label-green'); - }); - - it('resets filter operator when changing filter type', async function () { - // BUG: changing the filter type was not resetting the filter operator - // meaning you could have an "is-greater" operator applied to an - // "is/is-not" filter type - - this.server.createList('member', 3).forEach(member => this.server.create('subscription', {member, status: 'active'})); - this.server.createList('member', 4, {emailCount: 10}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(7); - - await click('[data-test-button="members-filter-actions"]'); - - const filter = '[data-test-members-filter="0"]'; - - await fillIn(`${filter} [data-test-select="members-filter"]`, 'email_count'); - await fillIn(`${filter} [data-test-select="members-filter-operator"]`, 'is-greater'); - await fillIn(`${filter} [data-test-input="members-filter-value"]`, '9'); - await blur(`${filter} [data-test-input="members-filter-value"]`); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after email_count filter') - .to.equal(4); - - await fillIn(`${filter} [data-test-select="members-filter"]`, 'subscriptions.status'); - - expect(find(`${filter} [data-test-select="members-filter-operator"]`)).to.have.value('is'); - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after email_count filter') - .to.equal(3); - }); - - it('hides paid filters when stripe isn\'t connected', async function () { - // disconnect stripe - this.server.db.settings.update({key: 'paid_members_enabled'}, {value: false}); - this.server.createList('member', 10); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - - expect( - find('[data-test-members-filter="0"] [data-test-select="members-filter"] optgroup[label="Subscription"]'), - 'Subscription option group doesn\'t exist' - ).to.not.exist; - - const filterOptions = findAll('[data-test-members-filter="0"] [data-test-select="members-filter"] option') - .map(option => option.value); - - expect(filterOptions).to.not.include('status'); - expect(filterOptions).to.not.include('subscriptions.plan_interval'); - expect(filterOptions).to.not.include('subscriptions.status'); - }); - - it('hides email filters when email is disabled', async function () { - // disable email - this.server.db.settings.update({key: 'editor_default_email_recipients'}, {value: 'disabled'}); - this.server.createList('member', 10); - - await visit('/members'); - await click('[data-test-button="members-filter-actions"]'); - - expect( - find('[data-test-members-filter="0"] [data-test-select="members-filter"] optgroup[label="Email"]'), - 'Email option group doesn\'t exist' - ).to.not.exist; - - const filterOptions = findAll('[data-test-members-filter="0"] [data-test-select="members-filter"] option') - .map(option => option.value); - - expect(filterOptions).to.not.include('email_count'); - expect(filterOptions).to.not.include('email_opened_count'); - expect(filterOptions).to.not.include('email_open_rate'); - }); - }); - - describe('search', function () { - beforeEach(function () { - // specific member names+emails so search is deterministic - // (default factory has random names+emails) - this.server.create('member', {name: 'X', email: 'x@x.xxx'}); - this.server.create('member', {name: 'Y', email: 'y@y.yyy'}); - this.server.create('member', {name: 'Z', email: 'z@z.zzz'}); - }); - - it('works', async function () { - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(3); - - await fillIn('[data-test-input="members-search"]', 'X'); - - // list updates - expect(findAll('[data-test-list="members-list-item"]').length, '# of members matching "X"') - .to.equal(1); - - // URL reflects search - expect(currentURL()).to.equal('/members?search=X'); - - // search input is active - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - - // bulk actions become available - expect(find('[data-test-button="add-label-selected"]'), 'add label to selected button').to.exist; - expect(find('[data-test-button="remove-label-selected"]'), 'remove label from selected button').to.exist; - expect(find('[data-test-button="unsubscribe-selected"]'), 'unsubscribe selected button').to.exist; - expect(find('[data-test-button="delete-selected"]'), 'delete selected button').to.exist; - - // clearing search returns us to starting state - await fillIn('[data-test-input="members-search"]', ''); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of members after clearing search') - .to.equal(3); - - expect(find('[data-test-input="members-search"]')).to.not.have.class('active'); - }); - - it('populates from query param', async function () { - await visit('/members?search=Y'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(1); - - expect(find('[data-test-input="members-search"]')).to.have.value('Y'); - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - }); - - it('has a no-match state', async function () { - await visit('/members'); - await fillIn('[data-test-input="members-search"]', 'unknown'); - - expect(currentURL()).to.equal('/members?search=unknown'); - - // replaces members table with the no-matching members state - expect(find('[data-test-table="members"]')).to.not.exist; - expect(find('[data-test-no-matching-members]')).to.exist; - - // search input is still shown - expect(find('[data-test-input="members-search"]')).to.be.visible; - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - - // export is disabled - expect(find('[data-test-button="export-members"]')).to.have.attribute('disabled'); - - // bulk actions are hidden - expect(find('[data-test-button="add-label-selected"]')).to.not.exist; - expect(find('[data-test-button="remove-label-selected"]')).to.not.exist; - expect(find('[data-test-button="unsubscribe-selected"]')).to.not.exist; - expect(find('[data-test-button="delete-selected"]')).to.not.exist; - - // can clear the search - await click('[data-test-no-matching-members] [data-test-button="show-all-members"]'); - - expect(currentURL()).to.equal('/members'); - expect(find('[data-test-input="members-search"]')).to.have.value(''); - expect(find('[data-test-input="members-search"]')).to.not.have.class('active'); - expect(findAll('[data-test-list="members-list-item"]').length).to.equal(3); - }); - - it('can search + filter', async function () { - this.server.create('member', {name: 'A', email: 'a@aaa.aaa', subscriptions: [this.server.create('subscription', {status: 'active'})]}); - - await visit('/members'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows') - .to.equal(4); - - await click('[data-test-button="members-filter-actions"]'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter"]', 'subscriptions.status'); - await fillIn('[data-test-members-filter="0"] [data-test-select="members-filter-value"]', 'active'); - await click('[data-test-button="members-apply-filter"]'); - - await fillIn('[data-test-input="members-search"]', 'a'); - - expect(findAll('[data-test-list="members-list-item"]').length, '# of member rows after filter+search') - .to.equal(1); - - // filter is active and has # of filters - expect(find('[data-test-button="members-filter-actions"] span'), 'filter button').to.have.class('gh-btn-label-green'); - expect(find('[data-test-button="members-filter-actions"]'), 'filter button').to.contain.text('(1)'); - - // search input is active - expect(find('[data-test-input="members-search"]')).to.have.class('active'); - }); - }); -}); diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js deleted file mode 100644 index 871f2115d69..00000000000 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ /dev/null @@ -1,256 +0,0 @@ -import {Response} from 'miragejs'; -import {authenticateSession} from 'ember-simple-auth/test-support'; -import {click, currentRouteName, currentURL, find, findAll} from '@ember/test-helpers'; -import {enableLabsFlag} from '../../helpers/labs-flag'; -import {expect} from 'chai'; -import {fileUpload} from '../../helpers/file-upload'; -import {setupApplicationTest} from 'ember-mocha'; -import {setupMirage} from 'ember-cli-mirage/test-support'; -import {visit} from '../../helpers/visit'; - -describe.skip('Acceptance: Members import', function () { - let hooks = setupApplicationTest(); - setupMirage(hooks); - - describe('Owner tests', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Owner'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('can open and close import modal', async function () { - await visit('/members'); - await click('[data-test-button="members-actions"]'); - await click('[data-test-link="import-csv"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; - expect(currentURL()).to.equal('/members/import'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - expect(currentURL()).to.equal('/members'); - }); - - it('has working happy path for small import with no mapper changes and Stripe not connected', async function () { - await visit('/members/import'); - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - expect(find('[data-test-csv-file-mapping]'), 'csv file mapper').to.exist; - expect(find('[data-test-members-import-table]'), 'csv file mapper').to.exist; - expect(findAll('[data-test-members-import-mapper]').length, '# of mapper rows').to.equal(6); - expect(find('[data-test-button="perform-import"]')).to.contain.text(' 1 '); - - await click('[data-test-button="perform-import"]'); - - expect(find('[data-test-modal="import-members"]')).to.contain.text('Import complete'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]')).to.not.exist; - }); - - it('can assign labels in import mapper', async function () { - const label1 = this.server.create('label'); - - await visit('/members/import'); - - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - const labelInput = '[data-test-csv-file-mapping] .gh-member-label-input'; - expect(find(labelInput), 'label input').to.exist; - - const dropdownContentId = find(`${labelInput}`).getAttribute('aria-owns'); - await click(`${labelInput}`); - - expect(findAll(`#${dropdownContentId} li.ember-power-select-option`).length, '# of label options').to.equal(1); - - // label input doesn't allow editing from the import modal - expect(findAll(`#${dropdownContentId} [data-test-edit-label]`).length, '# of label edit buttons').to.equal(0); - - await click(find(`#${dropdownContentId} li.ember-power-select-option`)); - - expect(findAll(`${labelInput} .ember-power-select-multiple-options li`).length, '# of selected labels').to.equal(1); - expect(find(`${labelInput} .ember-power-select-multiple-options li`)).to.contain.text(label1.name); - - let apiLabels = null; - - this.server.post('/members/upload/', function ({labels}, request) { - const label = labels.create(); - - apiLabels = request.requestBody.get('labels'); - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - }); - - await click('[data-test-button="perform-import"]'); - - expect(apiLabels).to.equal(label1.name); - }); - - it('opts out of the Ember import route when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import'); - - expect(currentRouteName()).to.equal('members.import'); - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - }); - - it('preserves query params when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import?filter=label%3AVIP&search=alice'); - - expect(currentRouteName()).to.equal('members.import'); - expect(currentURL()).to.equal('/members/import?filter=label%3AVIP&search=alice'); - }); - }); - describe ('super editors functions', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Super Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('can open and close import modal', async function () { - await visit('/members'); - await click('[data-test-button="members-actions"]'); - await click('[data-test-link="import-csv"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; - expect(currentURL()).to.equal('/members/import'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; - expect(currentURL()).to.equal('/members'); - }); - it('has working happy path for small import with no mapper changes and Stripe not connected', async function () { - await visit('/members/import'); - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - expect(find('[data-test-csv-file-mapping]'), 'csv file mapper').to.exist; - expect(find('[data-test-members-import-table]'), 'csv file mapper').to.exist; - expect(findAll('[data-test-members-import-mapper]').length, '# of mapper rows').to.equal(6); - expect(find('[data-test-button="perform-import"]')).to.contain.text(' 1 '); - - await click('[data-test-button="perform-import"]'); - - expect(find('[data-test-modal="import-members"]')).to.contain.text('Import complete'); - - await click('[data-test-button="close-import-members"]'); - - expect(find('[data-test-modal="import-members"]')).to.not.exist; - }); - - it('can assign labels in import mapper', async function () { - const label1 = this.server.create('label'); - - await visit('/members/import'); - - const csv = `email,name,note,subscribed_to_emails,labels,created_at -testemail@example.com,Test Email,This is a test template for importing your members list to Ghost,true,"vip,promotion",2019-10-30T14:52:08.000Z -`; - - await fileUpload( - '[data-test-fileinput="members-csv"]', - [csv], - {name: 'members.csv', type: 'text/csv'} - ); - - const labelInput = '[data-test-csv-file-mapping] .gh-member-label-input'; - expect(find(labelInput), 'label input').to.exist; - - const dropdownContentId = find(`${labelInput}`).getAttribute('aria-owns'); - await click(`${labelInput}`); - - expect(findAll(`#${dropdownContentId} li.ember-power-select-option`).length, '# of label options').to.equal(1); - - // label input doesn't allow editing from the import modal - expect(findAll(`#${dropdownContentId} [data-test-edit-label]`).length, '# of label edit buttons').to.equal(0); - - await click(find(`#${dropdownContentId} li.ember-power-select-option`)); - - expect(findAll(`${labelInput} .ember-power-select-multiple-options li`).length, '# of selected labels').to.equal(1); - expect(find(`${labelInput} .ember-power-select-multiple-options li`)).to.contain.text(label1.name); - - let apiLabels = null; - - this.server.post('/members/upload/', function ({labels}, request) { - const label = labels.create(); - - apiLabels = request.requestBody.get('labels'); - - return new Response(201, {}, { - meta: { - import_label: label, - stats: {imported: 1, invalid: []} - } - }); - }); - - await click('[data-test-button="perform-import"]'); - - expect(apiLabels).to.equal(label1.name); - }); - }); - describe('Editor functions', function () { - beforeEach(async function () { - this.server.loadFixtures('configs'); - - let role = this.server.create('role', {name: 'Editor'}); - this.server.create('user', {roles: [role]}); - - await authenticateSession(); - }); - - it('Editor cannot access members import', async function () { - await visit('/members/import'); - - expect(currentURL()).to.equal('/site'); - }); - - it('Editor cannot access members import when membersForward is enabled', async function () { - enableLabsFlag(this.server, 'membersForward'); - - await visit('/members/import?filter=label%3AVIP&search=alice'); - - expect(currentURL()).to.equal('/site'); - }); - }); -}); diff --git a/ghost/admin/tests/integration/components/gh-members-import-table-test.js b/ghost/admin/tests/integration/components/gh-members-import-table-test.js deleted file mode 100644 index 5e5442ac200..00000000000 --- a/ghost/admin/tests/integration/components/gh-members-import-table-test.js +++ /dev/null @@ -1,110 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; -import {click, findAll, render} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupRenderingTest} from 'ember-mocha'; - -describe('Integration: Component: gh-members-import-table', function () { - setupRenderingTest(); - - it('renders members data with all the properties', async function () { - this.set('importData', [{ - name: 'Kevin', - email: 'kevin@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); - - it('navigates through data when next and previous are clicked', async function () { - this.set('importData', [{ - name: 'Kevin', - email: 'kevin@example.com' - }, { - name: 'Rish', - email: 'rish@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-next]'); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Rish'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('rish@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-prev]'); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Kevin'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('kevin@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); - - it('cannot navigate through data when only one data item is present', async function () { - this.set('importData', [{ - name: 'Egg', - email: 'egg@example.com' - }]); - this.set('setMapping', () => {}); - - await render(hbs` - - `); - - expect(findAll('table tbody tr').length).to.equal(2); - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-next]'); - - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - - await click('[data-test-import-prev]'); - - expect(findAll('table tbody tr td')[0].textContent).to.equal('name'); - expect(findAll('table tbody tr td')[1].textContent).to.equal('Egg'); - expect(findAll('table tbody tr td')[2].textContent).to.match(/Not imported/); - expect(findAll('table tbody tr td')[3].textContent).to.equal('email'); - expect(findAll('table tbody tr td')[4].textContent).to.equal('egg@example.com'); - expect(findAll('table tbody tr td')[5].textContent).to.match(/Not imported/); - }); -}); diff --git a/ghost/admin/tests/integration/components/modal-import-members-test.js b/ghost/admin/tests/integration/components/modal-import-members-test.js deleted file mode 100644 index 1e6f0df9d05..00000000000 --- a/ghost/admin/tests/integration/components/modal-import-members-test.js +++ /dev/null @@ -1,194 +0,0 @@ -import Pretender from 'pretender'; -import Service from '@ember/service'; -import ghostPaths from 'ghost-admin/utils/ghost-paths'; -import hbs from 'htmlbars-inline-precompile'; -import sinon from 'sinon'; -import {click, find, findAll, render, waitFor} from '@ember/test-helpers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {fileUpload} from '../../helpers/file-upload'; -import {setupRenderingTest} from 'ember-mocha'; - -const notificationsStub = Service.extend({ - showAPIError() { - // noop - to be stubbed - } -}); - -const stubSuccessfulUpload = function (server, delay = 0) { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [200, {'Content-Type': 'application/json'}, '{"url":"/content/images/test.png"}']; - }, delay); -}; - -const stubFailedUpload = function (server, code, error, delay = 0) { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [code, {'Content-Type': 'application/json'}, JSON.stringify({ - errors: [{ - type: error, - message: `Error: ${error}` - }] - })]; - }, delay); -}; - -describe('Integration: Component: modal-import-members-test', function () { - setupRenderingTest(); - - let server; - - beforeEach(function () { - server = new Pretender(); - this.set('uploadUrl', `${ghostPaths().apiRoot}/members/upload/`); - - this.owner.register('service:notifications', notificationsStub); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('renders', async function () { - await render(hbs``); - - expect(find('h1').textContent.trim(), 'default header') - .to.equal('Import members'); - expect(find('.description').textContent.trim(), 'upload label') - .to.equal('Select or drop a CSV file'); - }); - - it('generates request to supplied endpoint', async function () { - stubSuccessfulUpload(server); - - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - await waitFor('table', {timeout: 50}); - - expect(find('label').textContent.trim(), 'labels label') - .to.equal('Label these members'); - expect(find('.gh-btn-green').textContent).to.match(/Import/g); - - await click('.gh-btn-green'); - - expect(server.handledRequests.length).to.equal(1); - expect(server.handledRequests[0].url).to.equal(`${ghostPaths().apiRoot}/members/upload/`); - }); - - it('displays server error', async function () { - stubFailedUpload(server, 415, 'UnsupportedMediaTypeError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/); - }); - - it('displays file too large for server error', async function () { - stubFailedUpload(server, 413, 'RequestEntityTooLargeError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file you uploaded was larger/); - }); - - it('handles file too large error directly from the web server', async function () { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [413, {}, '']; - }); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file you uploaded was larger/); - }); - - it('displays other server-side error with message', async function () { - stubFailedUpload(server, 400, 'UnknownError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/An unexpected error occurred, please try again/); - }); - - it('handles unknown failure', async function () { - server.post(`${ghostPaths().apiRoot}/members/upload/`, function () { - return [500, {'Content-Type': 'application/json'}, '']; - }); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/An unexpected error occurred, please try again/); - }); - - it('triggers notifications.showAPIError for VersionMismatchError', async function () { - let showAPIError = sinon.spy(); - let notifications = this.owner.lookup('service:notifications'); - notifications.set('showAPIError', showAPIError); - - stubFailedUpload(server, 400, 'VersionMismatchError'); - - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(showAPIError.calledOnce).to.be.true; - }); - - it('doesn\'t trigger notifications.showAPIError for other errors', async function () { - let showAPIError = sinon.spy(); - let notifications = this.owner.lookup('service:notifications'); - notifications.set('showAPIError', showAPIError); - - stubFailedUpload(server, 400, 'UnknownError'); - await render(hbs``); - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(showAPIError.called).to.be.false; - }); - - it('validates extension by default', async function () { - stubFailedUpload(server, 415); - - await render(hbs``); - - await fileUpload('input[type="file"]', ['name,email\r\nmembername,memberemail@example.com'], {name: 'test.csv'}); - - // Wait for async CSV parsing to finish - await waitFor('table', {timeout: 50}); - await click('.gh-btn-green'); - - expect(findAll('.failed').length, 'error message is displayed').to.equal(1); - expect(find('.failed').textContent).to.match(/The file type you uploaded is not supported/); - }); -}); diff --git a/ghost/admin/tests/integration/services/member-import-validator-test.js b/ghost/admin/tests/integration/services/member-import-validator-test.js deleted file mode 100644 index 99641d5d1b0..00000000000 --- a/ghost/admin/tests/integration/services/member-import-validator-test.js +++ /dev/null @@ -1,163 +0,0 @@ -import Pretender from 'pretender'; -import Service from '@ember/service'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -let MembersUtilsStub = Service.extend({ - isStripeEnabled: true -}); - -describe('Integration: Service: member-import-validator', function () { - setupTest(); - - let server; - - beforeEach(function () { - server = new Pretender(); - this.owner.register('service:membersUtils', MembersUtilsStub); - }); - - afterEach(function () { - server.shutdown(); - }); - - it('checks correct data without Stripe customer', async function () { - let service = this.owner.lookup('service:member-import-validator'); - - const mapping = await service.check([{ - name: 'Rish', - email: 'validemail@example.com' - }]); - - expect(mapping.email).to.equal('email'); - }); - - describe('data sampling method', function () { - it('returns whole data set when sampled size is less then default 30', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = await service._sampleData([{ - email: 'email@example.com' - }, { - email: 'email2@example.com' - }]); - - expect(result.length).to.equal(2); - }); - - it('returns dataset with sample size for non empty values only', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - let data = [{ - email: null - }, { - email: 'email2@example.com' - }, { - email: 'email3@example.com' - }, { - email: 'email4@example.com' - }, { - email: '' - }]; - - const result = await service._sampleData(data, 3); - - expect(result.length).to.equal(3); - expect(result[0].email).to.equal('email2@example.com'); - expect(result[1].email).to.equal('email3@example.com'); - expect(result[2].email).to.equal('email4@example.com'); - }); - - it('returns dataset with sample size for non empty values for objects with multiple properties', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - let data = [{ - email: null, - other_prop: 'non empty 1' - }, { - email: 'email2@example.com', - other_prop: 'non empty 2' - }, { - email: 'email3@example.com', - other_prop: '' - }, { - email: 'email4@example.com' - }, { - email: '', - other_prop: 'non empty 5' - }]; - - const result = await service._sampleData(data, 3); - - expect(result.length).to.equal(3); - expect(result[0].email).to.equal('email2@example.com'); - expect(result[0].other_prop).to.equal('non empty 1'); - expect(result[1].email).to.equal('email3@example.com'); - expect(result[1].other_prop).to.equal('non empty 2'); - expect(result[2].email).to.equal('email4@example.com'); - expect(result[2].other_prop).to.equal('non empty 5'); - }); - }); - - describe('data detection method', function () { - it('correctly detects only email mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - correo_electronico: 'email@example.com' - }, { - correo_electronico: 'email2@example.com' - }]); - - expect(result.email).to.equal('correo_electronico'); - expect(result.stripe_customer_id).to.equal(undefined); - }); - - it('correctly detects email mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - correo_electronico: 'email@example.com', - stripe_id: '' - }, { - correo_electronico: '', - stripe_id: 'cus_' - }]); - - expect(result.email).to.equal('correo_electronico'); - }); - - it('correctly detects variation of "name" mapping', async function () { - this.owner.register('service:membersUtils', Service.extend({ - isStripeEnabled: false - })); - - let service = this.owner.lookup('service:member-import-validator'); - - const result = service._detectDataTypes([{ - first_name: 'Rish' - }]); - - expect(result.name).to.equal('first_name'); - }); - }); -}); diff --git a/ghost/admin/tests/unit/components/members/filters/offers-test.js b/ghost/admin/tests/unit/components/members/filters/offers-test.js deleted file mode 100644 index 5f099ae491d..00000000000 --- a/ghost/admin/tests/unit/components/members/filters/offers-test.js +++ /dev/null @@ -1,33 +0,0 @@ -import {OFFERS_FILTER} from 'ghost-admin/components/members/filters/offers'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; - -describe('Unit: Component: members/filters/offers', function () { - describe('OFFERS_FILTER.getColumnValue', function () { - it('renders retention offers using cadence labels', function () { - const member = { - subscriptions: [{ - offer_redemptions: [ - {name: 'One month on us', redemption_type: 'retention', cadence: 'month'}, - {name: 'Welcome discount', redemption_type: 'signup', cadence: 'month'}, - {name: 'Two months on us', redemption_type: 'retention', cadence: 'year'} - ] - }] - }; - - const value = OFFERS_FILTER.getColumnValue(member); - - expect(value.text).to.equal('Monthly Retention, Welcome discount, Yearly Retention'); - }); - - it('returns empty text when offer_redemptions is missing', function () { - const member = { - subscriptions: [{}] - }; - - const value = OFFERS_FILTER.getColumnValue(member); - - expect(value.text).to.equal(''); - }); - }); -}); diff --git a/ghost/admin/tests/unit/controllers/member-test.js b/ghost/admin/tests/unit/controllers/member-test.js new file mode 100644 index 00000000000..e3da7b35329 --- /dev/null +++ b/ghost/admin/tests/unit/controllers/member-test.js @@ -0,0 +1,40 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit: Controller: member', function () { + setupTest(); + + let controller; + let triggerEmberDataChange; + + beforeEach(function () { + triggerEmberDataChange = sinon.spy(); + controller = this.owner.lookup('controller:member'); + Object.defineProperty(controller, 'stateBridge', { + configurable: true, + value: {triggerEmberDataChange} + }); + controller.member = {id: 'member-1'}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('invalidates the React members cache through the Ember bridge when member data changes', function () { + controller.invalidateMembersCache(); + + expect(triggerEmberDataChange.calledOnce).to.be.true; + expect(triggerEmberDataChange.calledWith('update', 'member', 'member-1', null)).to.be.true; + }); + + it('notifies the Ember bridge when member commenting changes', function () { + controller.invalidateMemberCommenting(); + + expect(triggerEmberDataChange.calledTwice).to.be.true; + expect(triggerEmberDataChange.firstCall.calledWith('update', 'member', 'member-1', null)).to.be.true; + expect(triggerEmberDataChange.secondCall.calledWith('update', 'comment', 'member-1', null)).to.be.true; + }); +}); diff --git a/ghost/admin/tests/unit/services/state-bridge-test.js b/ghost/admin/tests/unit/services/state-bridge-test.js index ac59f6db949..9420cac968f 100644 --- a/ghost/admin/tests/unit/services/state-bridge-test.js +++ b/ghost/admin/tests/unit/services/state-bridge-test.js @@ -531,7 +531,7 @@ describe('Unit: Service: state-bridge', function () { }); describe('#getRouteUrl', function () { - let postsController, membersController, originalLookup; + let postsController, settingsHistoryController, originalLookup; beforeEach(function () { // Mock controllers @@ -540,9 +540,9 @@ describe('Unit: Service: state-bridge', function () { type: null }); - membersController = EmberObject.create({ - queryParams: [{filterParam: 'filter'}], - filterParam: null + settingsHistoryController = EmberObject.create({ + queryParams: [{excludedEvents: 'excludedEvents'}], + excludedEvents: null }); // Stub the owner's lookup method to return our mock controllers @@ -551,8 +551,8 @@ describe('Unit: Service: state-bridge', function () { if (name === 'controller:posts') { return postsController; } - if (name === 'controller:members') { - return membersController; + if (name === 'controller:settings.history') { + return settingsHistoryController; } // Fall back to original lookup for services, etc. return originalLookup(name); @@ -586,10 +586,10 @@ describe('Unit: Service: state-bridge', function () { }); it('returns base route when on a subpath of the route', function () { - sinon.stub(service.router, 'currentRouteName').get(() => 'members.index'); + sinon.stub(service.router, 'currentRouteName').get(() => 'settings.history'); - const url = service.getRouteUrl('members'); - expect(url).to.equal('members'); + const url = service.getRouteUrl('settings'); + expect(url).to.equal('settings'); }); it('generates URL with provided query params', function () { @@ -639,11 +639,11 @@ describe('Unit: Service: state-bridge', function () { it('handles mapped query params correctly', function () { sinon.stub(service.router, 'currentRouteName').get(() => 'dashboard'); - membersController.set('filterParam', 'status:free'); + settingsHistoryController.set('excludedEvents', 'user.updated'); - // The controller has {filterParam: 'filter'}, so the URL should use 'filter' not 'filterParam' - const url = service.getRouteUrl('members'); - expect(url).to.equal('members?filter=status%3Afree'); + // The controller has {excludedEvents: 'excludedEvents'}, so the URL should use the mapped param + const url = service.getRouteUrl('settings.history'); + expect(url).to.equal('settings.history?excludedEvents=user.updated'); }); it('returns base route when controller does not exist', function () { @@ -697,10 +697,10 @@ describe('Unit: Service: state-bridge', function () { }); it('returns true when current route is a subpath of provided route', function () { - sinon.stub(service.router, 'currentRouteName').get(() => 'members.index'); + sinon.stub(service.router, 'currentRouteName').get(() => 'settings.history'); sinon.stub(service.customViews, 'activeView').get(() => null); - const isActive = service.isRouteActive('members'); + const isActive = service.isRouteActive('settings'); expect(isActive).to.be.true; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22d52b920ca..2bda13aa867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1654,15 +1654,9 @@ importers: '@tryghost/limit-service': specifier: 1.5.2 version: 1.5.2 - '@tryghost/members-csv': - specifier: 2.0.5 - version: 2.0.5 '@tryghost/nql': specifier: 0.12.10 version: 0.12.10 - '@tryghost/nql-lang': - specifier: 0.6.4 - version: 0.6.4 '@tryghost/string': specifier: 0.3.2 version: 0.3.2 @@ -1792,9 +1786,6 @@ importers: ember-drag-drop: specifier: 0.4.8 version: 0.4.8(@babel/core@7.29.0) - ember-ella-sparse: - specifier: 0.16.0 - version: 0.16.0(@babel/core@7.29.0) ember-exam: specifier: 6.0.1 version: 6.0.1(ember-mocha@0.16.2(@babel/core@7.29.0)) @@ -1909,9 +1900,6 @@ importers: normalize.css: specifier: 3.0.3 version: 3.0.3 - papaparse: - specifier: 5.5.3 - version: 5.5.3 postcss-color-mod-function: specifier: 3.0.3 version: 3.0.3 @@ -13022,10 +13010,6 @@ packages: peerDependencies: ember-source: ^3.8 || 4 - ember-ella-sparse@0.16.0: - resolution: {integrity: sha512-JLea/LYH3Juy0XzDCulmmpVXSOEiJIT6Pg4M3yXNVBQasU0eNj+ssO9Orpd7AHq5X4aFGWcKUnsVod3JFuMOfw==} - engines: {node: 8.* || >= 10.*} - ember-eslint-parser@0.5.13: resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==} engines: {node: '>=16.0.0'} @@ -36128,14 +36112,6 @@ snapshots: - '@glint/template' - supports-color - ember-ella-sparse@0.16.0(@babel/core@7.29.0): - dependencies: - ember-cli-babel: 7.26.11 - ember-concurrency: 1.3.0(@babel/core@7.29.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - ember-eslint-parser@0.5.13(@babel/core@7.29.0)(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3): dependencies: '@babel/core': 7.29.0