Added feature flagging system for Comments UI (#20984)

ref PLG-229

- Previously we had no way of using Ghost labs flags in Comments UI.
- With this change, we now get Labs data from the existing content
  settings endpoint.
- Additionally, we have a `useLabs` hook that can be accessed from
  anywhere in the App to put those awesome new features behind a flag
for staging
- And we can pass labs params to the initialiser for testing.

For more details: https://ghost.slack.com/archives/C06TQR9SHSM/p1726133527960489
This commit is contained in:
Ronald Langeveld 2024-09-12 18:36:37 +09:00 committed by GitHub
parent 6704705c86
commit 6d3317fcfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 135 additions and 18 deletions

View File

@ -26,7 +26,8 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
pagination: null,
commentCount: 0,
secundaryFormCount: 0,
popup: null
popup: null,
labs: null
});
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -135,15 +136,15 @@ const App: React.FC<AppProps> = ({scriptTag}) => {
const initSetup = async () => {
try {
// Fetch data from API, links, preview, dev sources
const {member} = await api.init();
const {member, labs} = await api.init();
const {comments, pagination, count} = await fetchComments();
const state = {
member,
initStatus: 'success',
comments,
pagination,
commentCount: count
commentCount: count,
labs: labs
};
setState(state);

View File

@ -33,6 +33,10 @@ export type AddComment = {
html: string
}
export type LabsContextType = {
[key: string]: boolean
}
export type CommentsOptions = {
locale: string,
siteUrl: string,
@ -40,7 +44,7 @@ export type CommentsOptions = {
apiUrl: string | undefined,
postId: string,
adminUrl: string | undefined,
colorScheme: string| undefined,
colorScheme: string | undefined,
avatarSaturation: number | undefined,
accentColor: string,
commentsEnabled: string | undefined,
@ -63,15 +67,16 @@ export type EditableAppContext = {
commentCount: number,
secundaryFormCount: number,
popup: Page | null,
labs: LabsContextType | null
}
export type TranslationFunction = (key: string, replacements?: Record<string, string|number>) => string;
export type TranslationFunction = (key: string, replacements?: Record<string, string | number>) => string;
export type AppContextType = EditableAppContext & CommentsOptions & {
// This part makes sure we can add automatic data and return types to the actions when using context.dispatchAction('actionName', data)
// eslint-disable-next-line @typescript-eslint/ban-types
t: TranslationFunction,
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends {data: any} ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise<void> : void
dispatchAction: <T extends ActionType | SyncActionType>(action: T, data: Parameters<(typeof Actions & typeof SyncActions)[T]>[0] extends { data: any } ? Parameters<(typeof Actions & typeof SyncActions)[T]>[0]['data'] : any) => T extends ActionType ? Promise<void> : void
}
// Copy time from AppContextType
@ -81,3 +86,14 @@ export const AppContext = React.createContext<AppContextType>({} as any);
export const AppContextProvider = AppContext.Provider;
export const useAppContext = () => useContext(AppContext);
// create a hook that will only get labs data from the context
export const useLabs = () => {
try {
const context = useAppContext();
return context.labs;
} catch (e) {
return null;
}
};

View File

@ -4,12 +4,13 @@ import ContentTitle from './ContentTitle';
import MainForm from './forms/MainForm';
import Pagination from './Pagination';
import {ROOT_DIV_ID} from '../../utils/constants';
import {useAppContext} from '../../AppContext';
import {useAppContext, useLabs} from '../../AppContext';
import {useEffect} from 'react';
const Content = () => {
const {pagination, member, comments, commentCount, commentsEnabled, title, showCount, secundaryFormCount} = useAppContext();
const commentsElements = comments.slice().reverse().map(comment => <Comment key={comment.id} comment={comment} />);
const labs = useLabs();
const paidOnly = commentsEnabled === 'paid';
const isPaidMember = member && !!member.paid;
@ -47,6 +48,9 @@ const Content = () => {
: null
}
</div>
{
labs?.testFlag ? <div data-testid="this-comes-from-a-flag" style={{display: 'none'}}></div> : null
}
</>
);
};

View File

@ -1,4 +1,4 @@
import {AddComment, Comment} from '../AppContext';
import {AddComment, Comment, LabsContextType} from '../AppContext';
function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {siteUrl: string, apiUrl: string, apiKey: string}) {
const apiPath = 'members/api';
@ -286,7 +286,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
});
}
},
init: (() => {}) as () => Promise<{ member: any; }>
init: (() => {}) as () => Promise<{ member: any; labs: any}>
};
api.init = async () => {
@ -294,7 +294,18 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
api.member.sessionData()
]);
return {member};
let labs = {};
try {
const settings = await api.site.settings();
if (settings.settings.labs) {
Object.assign(labs, settings.settings.labs);
}
} catch (e) {
labs = {};
}
return {member, labs};
};
return api;
@ -302,3 +313,4 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}: {site
export default setupGhostApi;
export type GhostApi = ReturnType<typeof setupGhostApi>;
export type LabsType = LabsContextType;

View File

@ -0,0 +1,44 @@
import {MockedApi, initialize} from '../utils/e2e';
import {expect, test} from '@playwright/test';
test.describe('Labs', async () => {
test('Can toggle content based on Lab settings', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
testFlag: true
}
});
await expect(frame.getByTestId('this-comes-from-a-flag')).toHaveCount(1);
});
test('test div is hidden if flag is not set', async ({page}) => {
const mockedApi = new MockedApi({});
mockedApi.setMember({});
mockedApi.addComment({
html: '<p>This is comment 1</p>'
});
const {frame} = await initialize({
mockedApi,
page,
publication: 'Publisher Weekly',
labs: {
testFlag: false
}
});
await expect(frame.getByTestId('this-comes-from-a-flag')).not.toBeVisible();
});
});

View File

@ -355,7 +355,6 @@ test.describe('Options', async () => {
});
expect(titleColor).toBe('rgb(0, 0, 0)');
});
});
});

View File

@ -1,17 +1,19 @@
import nql from '@tryghost/nql';
import {buildComment, buildMember, buildReply} from './fixtures';
import {buildComment, buildMember, buildReply, buildSettings} from './fixtures';
export class MockedApi {
comments: any[];
postId: string;
member: any;
settings: any;
#lastCommentDate = new Date('2021-01-01T00:00:00.000Z');
constructor({postId = 'ABC', comments = [], member = undefined}: {postId?: string, comments?: any[], member?: any}) {
constructor({postId = 'ABC', comments = [], member = undefined, settings = {}}: {postId?: string, comments?: any[], member?: any, settings?: any}) {
this.postId = postId;
this.comments = comments;
this.member = member;
this.settings = settings;
}
addComment(overrides: any = {}) {
@ -49,6 +51,10 @@ export class MockedApi {
this.member = buildMember(overrides);
}
setSettings(overrides) {
this.settings = buildSettings(overrides);
}
commentsCounts() {
return {
[this.postId]: this.comments.length
@ -281,5 +287,14 @@ export class MockedApi {
)
});
});
// get settings from content api
await page.route(`${path}/settings/*`, async (route) => {
await route.fulfill({
status: 200,
body: JSON.stringify(this.settings)
});
});
}
}

View File

@ -1,6 +1,6 @@
import {E2E_PORT} from '../../playwright.config';
import {LabsType, MockedApi} from './MockedApi';
import {Locator, Page} from '@playwright/test';
import {MockedApi} from './MockedApi';
import {expect} from '@playwright/test';
export const MOCKED_SITE_URL = 'https://localhost:1234';
@ -84,7 +84,7 @@ export async function mockAdminAuthFrame204({admin, page}) {
});
}
export async function initialize({mockedApi, page, bodyStyle, ...options}: {
export async function initialize({mockedApi, page, bodyStyle, labs = {}, key = '12345678', api = MOCKED_SITE_URL, ...options}: {
mockedApi: MockedApi,
page: Page,
path?: string;
@ -101,8 +101,18 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: {
publication?: string,
postId?: string,
bodyStyle?: string,
labs?: LabsType
}) {
const sitePath = MOCKED_SITE_URL;
mockedApi.setSettings({
settings: {
labs: {
...labs
}
}
});
await page.route(sitePath, async (route) => {
await route.fulfill({
status: 200,
@ -124,6 +134,14 @@ export async function initialize({mockedApi, page, bodyStyle, ...options}: {
options.postId = mockedApi.postId;
}
if (!options.key) {
options.key = key;
}
if (!options.api) {
options.api = api;
}
await page.evaluate((data) => {
const scriptTag = document.createElement('script');
scriptTag.src = data.url;
@ -194,7 +212,7 @@ export async function setClipboard(page, text) {
}
export function getModifierKey() {
const os = require('os');
const os = require('os'); // eslint-disable-line @typescript-eslint/no-var-requires
const platform = os.platform();
if (platform === 'darwin') {
return 'Meta';

View File

@ -16,6 +16,14 @@ export function buildMember(override: any = {}) {
};
}
export function buildSettings(override: any = {}) {
return {
meta: {},
settings: {},
...override
};
}
export function buildComment(override: any = {}) {
return {
id: ObjectId().toString(),
@ -31,7 +39,7 @@ export function buildComment(override: any = {}) {
replies: 0,
likes: 0,
...override.count
},
}
};
}