From c4468f05ce50b16a78c1b04b594248be435db663 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 25 Mar 2026 15:14:50 +0100 Subject: [PATCH] Released React members list on /members ref https://linear.app/ghost/issue/BER-3361/remove-feature-flag-release Moves the React members list onto /members, removes the rollout flag, and keeps a slim Ember shim for route compatibility while the dead Ember list code is cleaned up separately. --- .../advanced/labs/private-features.tsx | 4 - .../app-sidebar/member-sidebar-views.ts | 8 +- .../app-sidebar/nav-content.helpers.test.ts | 37 +- .../layout/app-sidebar/nav-content.helpers.ts | 21 +- .../src/layout/app-sidebar/nav-content.tsx | 14 +- apps/posts/src/routes.tsx | 2 +- e2e/tests/admin/members/custom-views.test.ts | 4 +- e2e/tests/admin/members/import.test.ts | 2 +- .../app/components/posts-list/list-item.hbs | 6 +- ghost/admin/app/controllers/members.js | 606 +----------------- ghost/admin/app/routes/members.js | 48 -- ghost/admin/app/templates/members.hbs | 235 +------ ghost/admin/tests/acceptance/members-test.js | 413 +----------- .../tests/acceptance/members/import-test.js | 19 +- .../tests/unit/services/state-bridge-test.js | 11 +- ghost/core/core/shared/labs.js | 1 - .../admin/__snapshots__/config.test.js.snap | 1 - 17 files changed, 93 insertions(+), 1339 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index cf51bc10dec..82b0eed2df6 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -55,10 +55,6 @@ const features: Feature[] = [{ title: 'Verification flow', description: 'Enable new Email verification webhook-based flow', flag: 'verificationFlow' -}, { - title: 'Members Forward', - description: 'Use the new React-based members list instead of the Ember implementation', - flag: 'membersForward' }, { title: 'Welcome Emails Design Customization', description: 'Enable design customization options for welcome emails', diff --git a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts index 9fd93071885..c03505071c6 100644 --- a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts +++ b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts @@ -15,7 +15,7 @@ function isMemberSidebarView(view: SharedView): view is MemberSidebarView { } function getMemberViewUrl(filter: string) { - return `members-forward?${new URLSearchParams({filter}).toString()}`; + return `members?${new URLSearchParams({filter}).toString()}`; } function isMemberViewActive(currentSearch: string, filter: string) { @@ -25,7 +25,7 @@ function isMemberViewActive(currentSearch: string, filter: string) { export function useMemberSidebarViews() { const location = useLocation(); const sharedViews = useSharedViews('members'); - const isOnMembersForward = location.pathname === '/members-forward'; + const isOnMembers = location.pathname === '/members'; return useMemo(() => { return sharedViews @@ -34,7 +34,7 @@ export function useMemberSidebarViews() { key: `${view.name}:${view.filter.filter}`, name: view.name, to: getMemberViewUrl(view.filter.filter), - isActive: isOnMembersForward && isMemberViewActive(location.search, view.filter.filter) + isActive: isOnMembers && isMemberViewActive(location.search, view.filter.filter) })); - }, [isOnMembersForward, location.search, sharedViews]); + }, [isOnMembers, location.search, sharedViews]); } diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts index 6e6e7df76e2..cddc2326132 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts @@ -2,9 +2,8 @@ import {describe, expect, it} from 'vitest'; import {getMembersNavActiveRoutes, isMembersNavActive} from './nav-content.helpers'; describe('getMembersNavActiveRoutes', () => { - it('always includes members-forward alongside the legacy members routes', () => { + it('returns the members routes owned by the members section', () => { expect(getMembersNavActiveRoutes()).toEqual([ - 'members-forward', 'members', 'member', 'member.new' @@ -13,43 +12,39 @@ describe('getMembersNavActiveRoutes', () => { }); describe('isMembersNavActive', () => { - it('uses the legacy route active state when members forward is disabled', () => { - expect(isMembersNavActive({ - membersForwardEnabled: false, - isOnMembersForward: false, - hasActiveMemberView: false, - isMembersExpanded: false, - isLegacyMembersRouteActive: true - })).toBe(true); - }); - it('marks the base Members link active when a saved member view is active but collapsed', () => { expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembers: true, hasActiveMemberView: true, isMembersExpanded: false, - isLegacyMembersRouteActive: false + isMembersSectionRouteActive: true })).toBe(true); }); it('marks the base Members link inactive when a saved member view is active and expanded', () => { expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembers: true, hasActiveMemberView: true, isMembersExpanded: true, - isLegacyMembersRouteActive: false + isMembersSectionRouteActive: true })).toBe(false); }); it('falls back to the base Members link when no saved member view is active', () => { expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembers: true, + hasActiveMemberView: false, + isMembersExpanded: false, + isMembersSectionRouteActive: false + })).toBe(true); + }); + + it('uses the section route active state when outside the members list route', () => { + expect(isMembersNavActive({ + isOnMembers: false, hasActiveMemberView: false, isMembersExpanded: false, - isLegacyMembersRouteActive: false + isMembersSectionRouteActive: true })).toBe(true); }); }); diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts index ba946c9c852..b7f61a7b205 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts @@ -1,26 +1,19 @@ export function getMembersNavActiveRoutes(): string[] { - // TODO: Remove members-forward once the membersForward flag and legacy route split are gone. - return ['members-forward', 'members', 'member', 'member.new']; + return ['members', 'member', 'member.new']; } export function isMembersNavActive({ - membersForwardEnabled, - isOnMembersForward, + isOnMembers, hasActiveMemberView, isMembersExpanded, - isLegacyMembersRouteActive + isMembersSectionRouteActive }: { - membersForwardEnabled: boolean; - isOnMembersForward: boolean; + isOnMembers: boolean; hasActiveMemberView: boolean; isMembersExpanded: boolean; - isLegacyMembersRouteActive: boolean; + isMembersSectionRouteActive: boolean; }): boolean { - if (!membersForwardEnabled) { - return isLegacyMembersRouteActive; - } - - if (isOnMembersForward) { + if (isOnMembers) { if (!hasActiveMemberView) { return true; } @@ -28,5 +21,5 @@ export function isMembersNavActive({ return !isMembersExpanded; } - return isLegacyMembersRouteActive; + return isMembersSectionRouteActive; } diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 107a675d13b..768cc52e5fa 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -83,7 +83,6 @@ function NavContent({ ...props }: React.ComponentProps) { const memberCount = useMemberCount(); const routing = useEmberRouting(); const commentModerationEnabled = useFeatureFlag('commentModeration'); - const membersForwardEnabled = useFeatureFlag('membersForward'); const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -92,20 +91,19 @@ function NavContent({ ...props }: React.ComponentProps) { const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'}); const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive); const postsExpanded = savedPostsExpanded; - const isOnMembersForward = location.pathname === '/members-forward'; - const hasActiveMemberView = isOnMembersForward && memberViews.some(view => view.isActive); + const isOnMembers = location.pathname === '/members'; + const hasActiveMemberView = isOnMembers && memberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; const membersNavActive = isMembersNavActive({ - membersForwardEnabled, - isOnMembersForward, + isOnMembers, hasActiveMemberView, isMembersExpanded: membersExpanded, - isLegacyMembersRouteActive: routing.isRouteActive(getMembersNavActiveRoutes()) + isMembersSectionRouteActive: routing.isRouteActive(getMembersNavActiveRoutes()) }); const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild); - const membersRoute = membersForwardEnabled ? 'members-forward' : routing.getRouteUrl('members'); + const membersRoute = routing.getRouteUrl('members'); return ( @@ -182,7 +180,7 @@ function NavContent({ ...props }: React.ComponentProps) { {showMembers && ( <> - {membersForwardEnabled && hasMemberViews ? ( + {hasMemberViews ? ( import('@views/comments/comments')) }, { - path: 'members-forward', + path: 'members', lazy: lazyComponent(() => import('@views/members/members')) }, diff --git a/e2e/tests/admin/members/custom-views.test.ts b/e2e/tests/admin/members/custom-views.test.ts index bb5e9c95dc9..bf0cf11fd15 100644 --- a/e2e/tests/admin/members/custom-views.test.ts +++ b/e2e/tests/admin/members/custom-views.test.ts @@ -30,8 +30,6 @@ async function saveCurrentView(page: Page, name: string) { } test.describe('Ghost Admin - Member Saved Views', () => { - test.use({labs: {membersForward: true}}); - let memberFactory: MemberFactory; test.beforeEach(async ({page}) => { @@ -48,7 +46,7 @@ test.describe('Ghost Admin - Member Saved Views', () => { }); const sidebar = new SidebarPage(page); - await page.goto('/ghost/#/members-forward'); + await page.goto('/ghost/#/members'); await addFilter(page, 'Name', 'active-nav'); await saveCurrentView(page, 'View A'); diff --git a/e2e/tests/admin/members/import.test.ts b/e2e/tests/admin/members/import.test.ts index 3d56569e21d..429be53b2ee 100644 --- a/e2e/tests/admin/members/import.test.ts +++ b/e2e/tests/admin/members/import.test.ts @@ -7,7 +7,7 @@ import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Members Import', () => { test('imports members from CSV via the UI', async ({page}) => { - const membersPage = new MembersPage(page, {route: 'members-forward'}); + const membersPage = new MembersPage(page); const importModal = new MembersImportModal(page); const timestamp = Date.now(); diff --git a/ghost/admin/app/components/posts-list/list-item.hbs b/ghost/admin/app/components/posts-list/list-item.hbs index 9bc0e867f70..5ea67cf66e4 100644 --- a/ghost/admin/app/components/posts-list/list-item.hbs +++ b/ghost/admin/app/components/posts-list/list-item.hbs @@ -149,7 +149,7 @@
{{!-- Opened / Signups column --}} {{#if (and @post.showEmailOpenAnalytics @post.showEmailClickAnalytics) }} - + {{#if this.isHovered}} {{format-number @post.email.openedCount}} @@ -169,7 +169,7 @@ {{!-- Clicked / Conversions column --}} {{#if @post.showEmailClickAnalytics }} - + {{#if this.isHovered}} {{format-number @post.count.clicks}} @@ -183,7 +183,7 @@ {{else}} {{#if @post.showEmailOpenAnalytics }} - + {{#if this.isHovered}} {{format-number @post.email.openedCount}} diff --git a/ghost/admin/app/controllers/members.js b/ghost/admin/app/controllers/members.js index 204e48bea6f..e7c60ce56fe 100644 --- a/ghost/admin/app/controllers/members.js +++ b/ghost/admin/app/controllers/members.js @@ -1,619 +1,29 @@ -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'}, + 'filter', + 'search', {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 ----------------------------------------------------------------- + filter = null; + search = ''; + postAnalytics = null; @action refreshData() { - try { - this.fetchMembersTask.perform(); - this.fetchLabelsTask.perform(); - } catch (e) { - // Do not throw cancellation errors - if (didCancel(e)) { - return; - } - - throw e; + if (window.adminXQueryClient) { + window.adminXQueryClient.invalidateQueries({queryKey: ['MembersResponseType']}); + window.adminXQueryClient.invalidateQueries({queryKey: ['LabelsResponseType']}); } 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/routes/members.js b/ghost/admin/app/routes/members.js index 4487c92e7b3..8bd976e9350 100644 --- a/ghost/admin/app/routes/members.js +++ b/ghost/admin/app/routes/members.js @@ -1,54 +1,6 @@ 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; - @service feature; - - queryParams = { - label: {refreshModel: true}, - searchParam: {refreshModel: true, replace: true}, - paidParam: {refreshModel: true}, - orderParam: {refreshModel: true}, - filterParam: {refreshModel: true}, - postAnalytics: {refreshModel: false} - }; - - model(params) { - 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); - - 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', diff --git a/ghost/admin/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index 4cb7ce76ad3..d44fba86576 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -1,233 +1,6 @@ -
- -
- {{#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}} -
    +{{! Members screens live inside the Posts React app }} +{{#unless (feature "inAdminForward")}} + +{{/unless}} {{outlet}} - -{{#if this.showUnsubscribeMembersModal}} - -{{/if}} - -{{#if this.showLabelModal}} - -{{/if}} diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js index 966823b4acc..576c2ce61a6 100644 --- a/ghost/admin/tests/acceptance/members-test.js +++ b/ghost/admin/tests/acceptance/members-test.js @@ -1,7 +1,7 @@ -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 {blur, click, currentURL, fillIn, find} from '@ember/test-helpers'; +import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../helpers/mock-analytics-apps'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -11,6 +11,14 @@ describe('Acceptance: Members Test', function () { let hooks = setupApplicationTest(); setupMirage(hooks); + beforeEach(function () { + mockAnalyticsApps(); + }); + + afterEach(function () { + cleanupMockAnalyticsApps(); + }); + it('redirects to signin when not authenticated', async function () { await invalidateSession(); await visit('/members'); @@ -38,406 +46,36 @@ describe('Acceptance: Members Test', function () { 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('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]}); - + it('mounts the React members screen on the members route', async function () { 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('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); + expect(find('[data-test-posts-component]')).to.exist; }); - }); - 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('can edit an existing member directly', async function () { + let member = this.server.create('member'); - 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); + await visit(`/members/${member.id}`); - expect(find('[data-test-input="member-email"]').value, 'loads correct email into form') - .to.equal(member1.email); + expect(find('[data-test-input="member-name"]').value).to.equal(member.name); + expect(find('[data-test-input="member-email"]').value).to.equal(member.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'); + expect(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('can create a new member directly', async function () { + await visit('/members/new'); - // it lists all members - expect(findAll('[data-test-list="members-list-item"]').length, 'members list count') - .to.equal(1); + expect(currentURL()).to.equal('/members/new'); + expect(find('.gh-canvas-header h2').textContent).to.contain('New'); - // 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"]'); @@ -446,11 +84,8 @@ describe('Acceptance: Members Test', function () { 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'); + expect(find('[data-test-input="member-name"]').value).to.equal('New Name'); + expect(find('[data-test-input="member-email"]').value).to.equal('example@domain.com'); }); }); }); diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js index 1e11fcd6e2e..6b2ddf83c6c 100644 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ b/ghost/admin/tests/acceptance/members/import-test.js @@ -1,5 +1,6 @@ import {Response} from 'miragejs'; import {authenticateSession} from 'ember-simple-auth/test-support'; +import {cleanupMockAnalyticsApps, mockAnalyticsApps} from '../../helpers/mock-analytics-apps'; import {click, currentURL, find, findAll} from '@ember/test-helpers'; import {expect} from 'chai'; import {fileUpload} from '../../helpers/file-upload'; @@ -11,6 +12,14 @@ describe('Acceptance: Members import', function () { let hooks = setupApplicationTest(); setupMirage(hooks); + beforeEach(function () { + mockAnalyticsApps(); + }); + + afterEach(function () { + cleanupMockAnalyticsApps(); + }); + describe('Owner tests', function () { beforeEach(async function () { this.server.loadFixtures('configs'); @@ -22,9 +31,7 @@ describe('Acceptance: Members import', function () { }); 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"]'); + await visit('/members/import'); expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; expect(currentURL()).to.equal('/members/import'); @@ -122,9 +129,7 @@ testemail@example.com,Test Email,This is a test template for importing your memb }); 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"]'); + await visit('/members/import'); expect(find('[data-test-modal="import-members"]'), 'members import modal').to.exist; expect(currentURL()).to.equal('/members/import'); @@ -226,4 +231,4 @@ testemail@example.com,Test Email,This is a test template for importing your memb expect(currentURL()).to.equal('/site'); }); }); -}); \ No newline at end of file +}); diff --git a/ghost/admin/tests/unit/services/state-bridge-test.js b/ghost/admin/tests/unit/services/state-bridge-test.js index d4a43dbbe34..cd498cd1ab5 100644 --- a/ghost/admin/tests/unit/services/state-bridge-test.js +++ b/ghost/admin/tests/unit/services/state-bridge-test.js @@ -523,8 +523,10 @@ describe('Unit: Service: state-bridge', function () { }); membersController = EmberObject.create({ - queryParams: [{filterParam: 'filter'}], - filterParam: null + queryParams: ['filter', 'search', {postAnalytics: 'post'}], + filter: null, + search: '', + postAnalytics: null }); // Stub the owner's lookup method to return our mock controllers @@ -621,11 +623,10 @@ 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'); + membersController.set('postAnalytics', 'post_123'); - // 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'); + expect(url).to.equal('members?post=post_123'); }); it('returns base route when controller does not exist', function () { diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 69bb2cb27b2..6e9ffcc7797 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -49,7 +49,6 @@ const PRIVATE_FEATURES = [ 'indexnow', 'transistor', 'verificationFlow', - 'membersForward', 'welcomeEmailsDesignCustomization', 'pictureImageFormats', 'smarterCounts' diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap index 2c14d9645ba..cf5b01a4086 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/config.test.js.snap @@ -21,7 +21,6 @@ Object { "indexnow": true, "lexicalIndicators": true, "members": true, - "membersForward": true, "pictureImageFormats": true, "smarterCounts": true, "stripeAutomaticTax": true,