Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion apps/admin/src/ember-bridge/ember-bridge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -383,4 +416,3 @@ describe('useEmberRouting', () => {
});
});
});

2 changes: 1 addition & 1 deletion apps/admin/src/ember-bridge/ember-bridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record<string, string> = {
'user': 'UsersResponseType',
'post': 'PostsResponseType',
'member': 'MembersResponseType',
'comment': 'CommentsResponseType',
'tag': 'TagsResponseType',
'label': 'LabelsResponseType',
'webhook': 'WebhooksResponseType'
Expand Down Expand Up @@ -310,4 +311,3 @@ export function useForceUpgrade(): boolean {

return true;
}

10 changes: 1 addition & 9 deletions e2e/helpers/pages/admin/members/members-list-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,7 @@ export interface ExportedFile {
content: string;
}

export interface MembersListSurface {
goto(): Promise<unknown>;
openActionsMenu(): Promise<void>;
applyLabelFilter(labelName: string): Promise<void>;
getVisibleMemberCount(): Promise<number>;
exportMembers(): Promise<ExportedFile>;
}

export class MembersListPage extends AdminPage implements MembersListSurface {
export class MembersListPage extends AdminPage {
readonly memberRows: Locator;
readonly searchInput: Locator;
readonly actionsButton: Locator;
Expand Down
159 changes: 2 additions & 157 deletions e2e/helpers/pages/admin/members/members-page.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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;
Comment on lines 5 to 8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent public readonly on locators used externally.

newMemberButton (line 5) and memberListItems (line 8) are declared as just readonly, while loadMoreButton and membersListScrollRoot (lines 6–7) are public readonly. Per the coding guidelines, locators used with assertions must be public readonly. The inconsistency means any test directly asserting against newMemberButton or memberListItems would get a TypeScript private visibility error.

♻️ Proposed fix
-    readonly newMemberButton: Locator;
+    public readonly newMemberButton: Locator;
     public readonly loadMoreButton: Locator;
     public readonly membersListScrollRoot: Locator;
-    readonly memberListItems: Locator;
+    public readonly memberListItems: Locator;

As per coding guidelines: "Page Objects should expose locators as public readonly when used with assertions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/helpers/pages/admin/members/members-page.ts` around lines 5 - 8, Make the
locator visibility consistent by changing the declarations of newMemberButton
and memberListItems to public readonly so they match loadMoreButton and
membersListScrollRoot; update the class member declarations for newMemberButton
and memberListItems (the Locator fields) to use public readonly so tests can
access them for assertions without TS visibility errors.

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<void> {
await this.memberListItems.filter({hasText: email}).click();
}

async openActionsMenu(): Promise<void> {
await this.membersActionsButton.click();
}

async applyLabelFilter(labelName: string): Promise<void> {
await this.filterSection.applyLabel(labelName);
}

async getVisibleMemberCount(): Promise<number> {
return await this.memberListItems.count();
}

async getMaxRenderedIndex(): Promise<number> {
return await this.memberListItems.evaluateAll((rows) => {
return rows.reduce((maxIndex, row) => {
Expand Down Expand Up @@ -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<number> {
return await this.memberListItems.count();
}

async exportMembers(): Promise<ExportedFile> {
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<Download> {
const downloadPromise = this.page.waitForEvent('download');
await this.exportMembersButton.click();
return await downloadPromise;
}
}
18 changes: 0 additions & 18 deletions ghost/admin/app/components/gh-member-single-label-input.hbs

This file was deleted.

98 changes: 0 additions & 98 deletions ghost/admin/app/components/gh-member-single-label-input.js

This file was deleted.

Loading
Loading