mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Converted Comments-UI App to TypeScript and React hooks (#17760)
refs https://github.com/TryGhost/Product/issues/3504 - App component now uses React hooks intead of React class component - App is now written in TypeScript - All JavaScript is now removed from the Comments-UI project - Removed `PopupNotification` because these were never displayed - Removed `action` from AppContext (never used) - Moved options parsing out of `index.ts` into a separate utility file, similar to the signup-form - Improved reliability of some editor tests by always waiting for the editor to be focused (was not always the case) + added an utility method for this
This commit is contained in:
parent
d9cee38a77
commit
f1b51729fc
18 changed files with 389 additions and 449 deletions
4
.github/scripts/dev.js
vendored
4
.github/scripts/dev.js
vendored
|
@ -128,11 +128,11 @@ if (DASH_DASH_ARGS.includes('lexical')) {
|
|||
// Safari needs HTTPS for it to work
|
||||
// To make this work, you'll need a CADDY server running in front
|
||||
// Note the port is different because of this extra layer. Use the following Caddyfile:
|
||||
// https://localhost:4174 {
|
||||
// https://localhost:41730 {
|
||||
// reverse_proxy http://127.0.0.1:4173
|
||||
// }
|
||||
|
||||
COMMAND_GHOST.env['editor__url'] = 'https://localhost:4174/koenig-lexical.umd.js';
|
||||
COMMAND_GHOST.env['editor__url'] = 'https://localhost:41730/koenig-lexical.umd.js';
|
||||
} else {
|
||||
COMMAND_GHOST.env['editor__url'] = 'http://localhost:4173/koenig-lexical.umd.js';
|
||||
}
|
||||
|
|
|
@ -1,304 +0,0 @@
|
|||
import ContentBox from './components/ContentBox';
|
||||
import PopupBox from './components/PopupBox';
|
||||
import React from 'react';
|
||||
import setupGhostApi from './utils/api';
|
||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||
import {AppContext} from './AppContext';
|
||||
import {CommentsFrame} from './components/Frame';
|
||||
import {createPopupNotification} from './utils/helpers';
|
||||
import {hasMode} from './utils/check-mode';
|
||||
|
||||
function AuthFrame({adminUrl, onLoad}) {
|
||||
if (!adminUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const iframeStyle = {
|
||||
display: 'none'
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe data-frame="admin-auth" src={adminUrl + 'auth-frame/'} style={iframeStyle} title="auth-frame" onLoad={onLoad}></iframe>
|
||||
);
|
||||
}
|
||||
|
||||
export default class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
action: 'init:running',
|
||||
initStatus: 'running',
|
||||
member: null,
|
||||
admin: null,
|
||||
comments: null,
|
||||
pagination: null,
|
||||
popupNotification: null,
|
||||
customSiteUrl: props.customSiteUrl,
|
||||
postId: props.postId,
|
||||
popup: null,
|
||||
accentColor: props.accentColor,
|
||||
secundaryFormCount: 0
|
||||
};
|
||||
this.adminApi = null;
|
||||
this.GhostApi = null;
|
||||
|
||||
// Bind to make sure we have a variable reference (and don't need to create a new binded function in our context value every time the state changes)
|
||||
this.dispatchAction = this.dispatchAction.bind(this);
|
||||
this.initAdminAuth = this.initAdminAuth.bind(this);
|
||||
}
|
||||
|
||||
/** Initialize comments setup on load, fetch data and setup state*/
|
||||
async initSetup() {
|
||||
try {
|
||||
// Fetch data from API, links, preview, dev sources
|
||||
const {member} = await this.fetchApiData();
|
||||
const {comments, pagination, count} = await this.fetchComments();
|
||||
|
||||
const state = {
|
||||
member,
|
||||
action: 'init:success',
|
||||
initStatus: 'success',
|
||||
comments,
|
||||
pagination,
|
||||
commentCount: count
|
||||
};
|
||||
|
||||
this.setState(state);
|
||||
} catch (e) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(`[Comments] Failed to initialize:`, e);
|
||||
/* eslint-enable no-console */
|
||||
this.setState({
|
||||
action: 'init:failed',
|
||||
initStatus: 'failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initAdminAuth() {
|
||||
if (this.adminApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.adminApi = this.setupAdminAPI();
|
||||
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await this.adminApi.getUser();
|
||||
} catch (e) {
|
||||
// Loading of admin failed. Could be not signed in, or a different error (not important)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[Comments] Failed to fetch current admin user:`, e);
|
||||
}
|
||||
|
||||
const state = {
|
||||
admin
|
||||
};
|
||||
|
||||
this.setState(state);
|
||||
} catch (e) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(`[Comments] Failed to initialize admin authentication:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle actions from across App and update App state */
|
||||
async dispatchAction(action, data) {
|
||||
if (isSyncAction(action)) {
|
||||
// Makes sure we correctly handle the old state
|
||||
// because updates to state may be asynchronous
|
||||
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
|
||||
this.setState((state) => {
|
||||
return SyncActionHandler({action, data, state, api: this.GhostApi, adminApi: this.adminApi});
|
||||
});
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.timeoutId);
|
||||
this.setState({
|
||||
action: `${action}:running`
|
||||
});
|
||||
try {
|
||||
const updatedState = await ActionHandler({action, data, state: this.state, api: this.GhostApi, adminApi: this.adminApi});
|
||||
this.setState(updatedState);
|
||||
|
||||
/** Reset action state after short timeout if not failed*/
|
||||
if (updatedState && updatedState.action && !updatedState.action.includes(':failed')) {
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.setState({
|
||||
action: ''
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
// todo: Keep this error log here until we implement popup notifications?
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
const popupNotification = createPopupNotification({
|
||||
type: `${action}:failed`,
|
||||
autoHide: true, closeable: true, status: 'error', state: this.state,
|
||||
meta: {
|
||||
error
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
action: `${action}:failed`,
|
||||
popupNotification
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch site and member session data with Ghost Apis */
|
||||
async fetchApiData() {
|
||||
const {siteUrl, customSiteUrl, apiUrl, apiKey} = this.props;
|
||||
|
||||
try {
|
||||
this.GhostApi = this.props.api || setupGhostApi({siteUrl, apiUrl, apiKey});
|
||||
const {member} = await this.GhostApi.init();
|
||||
return {member};
|
||||
} catch (e) {
|
||||
if (hasMode(['dev', 'test'], {customSiteUrl})) {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch first few comments */
|
||||
async fetchComments() {
|
||||
const dataPromise = this.GhostApi.comments.browse({page: 1, postId: this.state.postId});
|
||||
const countPromise = this.GhostApi.comments.count({postId: this.state.postId});
|
||||
|
||||
const [data, count] = await Promise.all([dataPromise, countPromise]);
|
||||
|
||||
return {
|
||||
comments: data.comments,
|
||||
pagination: data.meta.pagination,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
setupAdminAPI() {
|
||||
const frame = document.querySelector('iframe[data-frame="admin-auth"]');
|
||||
let uid = 1;
|
||||
let handlers = {};
|
||||
const adminOrigin = new URL(this.props.adminUrl).origin;
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== adminOrigin) {
|
||||
// Other message that is not intended for us
|
||||
return;
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
/* eslint-disable no-console */
|
||||
console.error('Error parsing event data', err);
|
||||
/* eslint-enable no-console */
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[data.uid];
|
||||
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete handlers[data.uid];
|
||||
|
||||
handler(data.error, data.result);
|
||||
});
|
||||
|
||||
function callApi(action, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function handler(error, result) {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(result);
|
||||
}
|
||||
uid += 1;
|
||||
handlers[uid] = handler;
|
||||
frame.contentWindow.postMessage(JSON.stringify({
|
||||
uid,
|
||||
action,
|
||||
...args
|
||||
}), adminOrigin);
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
async getUser() {
|
||||
const result = await callApi('getUser');
|
||||
if (!result || !result.users) {
|
||||
return null;
|
||||
}
|
||||
return result.users[0];
|
||||
},
|
||||
async hideComment(id) {
|
||||
return await callApi('hideComment', {id});
|
||||
},
|
||||
async showComment(id) {
|
||||
return await callApi('showComment', {id});
|
||||
}
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
/**Get final App level context from App state*/
|
||||
getContextFromState() {
|
||||
const {action, popupNotification, customSiteUrl, member, comments, pagination, commentCount, postId, admin, popup, secundaryFormCount} = this.state;
|
||||
return {
|
||||
action,
|
||||
popupNotification,
|
||||
customSiteUrl,
|
||||
member,
|
||||
admin,
|
||||
comments,
|
||||
pagination,
|
||||
commentCount,
|
||||
postId,
|
||||
title: this.props.title,
|
||||
showCount: this.props.showCount,
|
||||
colorScheme: this.props.colorScheme,
|
||||
avatarSaturation: this.props.avatarSaturation,
|
||||
accentColor: this.props.accentColor,
|
||||
commentsEnabled: this.props.commentsEnabled,
|
||||
publication: this.props.publication,
|
||||
secundaryFormCount: secundaryFormCount,
|
||||
popup,
|
||||
|
||||
// Warning: make sure we pass a variable here (binded in constructor), because if we create a new function here, it will also change when anything in the state changes
|
||||
// causing loops in useEffect hooks that depend on dispatchAction
|
||||
dispatchAction: this.dispatchAction
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initSetup();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
/**Clear timeouts and event listeners on unmount */
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const done = this.state.initStatus === 'success';
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={this.getContextFromState()}>
|
||||
<CommentsFrame>
|
||||
<ContentBox done={done} />
|
||||
</CommentsFrame>
|
||||
<AuthFrame adminUrl={this.props.adminUrl} onLoad={this.initAdminAuth}/>
|
||||
<PopupBox />
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
172
apps/comments-ui/src/App.tsx
Normal file
172
apps/comments-ui/src/App.tsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
import AuthFrame from './AuthFrame';
|
||||
import ContentBox from './components/ContentBox';
|
||||
import PopupBox from './components/PopupBox';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import setupGhostApi from './utils/api';
|
||||
import {ActionHandler, SyncActionHandler, isSyncAction} from './actions';
|
||||
import {AdminApi, setupAdminAPI} from './utils/adminApi';
|
||||
import {AppContext, AppContextType, CommentsOptions, DispatchActionType, EditableAppContext} from './AppContext';
|
||||
import {CommentsFrame} from './components/Frame';
|
||||
import {useOptions} from './utils/options';
|
||||
|
||||
type AppProps = {
|
||||
scriptTag: HTMLElement;
|
||||
};
|
||||
|
||||
function createContext(options: CommentsOptions, state: EditableAppContext): AppContextType {
|
||||
return {
|
||||
...options,
|
||||
...state,
|
||||
dispatchAction: (() => {}) as DispatchActionType
|
||||
};
|
||||
}
|
||||
|
||||
const App: React.FC<AppProps> = ({scriptTag}) => {
|
||||
const options = useOptions(scriptTag);
|
||||
const [state, setFullState] = useState<EditableAppContext>({
|
||||
initStatus: 'running',
|
||||
member: null,
|
||||
admin: null,
|
||||
comments: [],
|
||||
pagination: null,
|
||||
commentCount: 0,
|
||||
secundaryFormCount: 0,
|
||||
popup: null
|
||||
});
|
||||
|
||||
const api = React.useMemo(() => {
|
||||
return setupGhostApi({
|
||||
siteUrl: options.siteUrl,
|
||||
apiUrl: options.apiUrl!,
|
||||
apiKey: options.apiKey!
|
||||
})
|
||||
}, [options]);
|
||||
|
||||
const [adminApi, setAdminApi] = useState<AdminApi|null>(null);
|
||||
|
||||
const context = createContext(options, state)
|
||||
|
||||
const setState = useCallback((newState: Partial<EditableAppContext> | ((state: EditableAppContext) => Partial<EditableAppContext>)) => {
|
||||
setFullState((state) => {
|
||||
if (typeof newState === 'function') {
|
||||
newState = newState(state);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
...newState
|
||||
}
|
||||
});
|
||||
}, [setFullState]);
|
||||
|
||||
const dispatchAction = useCallback(async (action, data) => {
|
||||
if (isSyncAction(action)) {
|
||||
// Makes sure we correctly handle the old state
|
||||
// because updates to state may be asynchronous
|
||||
// so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function
|
||||
setState((state) => {
|
||||
return SyncActionHandler({action, data, state, api, adminApi: adminApi!, options})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a bit a ugly hack, but only reliable way to make sure we can get the latest state asynchronously
|
||||
// without creating infinite rerenders because dispatchAction needs to change on every state change
|
||||
// So state shouldn't be a dependency of dispatchAction
|
||||
setState((state) => {
|
||||
ActionHandler({action, data, state, api, adminApi: adminApi!, options}).then((updatedState) => {
|
||||
setState({...updatedState});
|
||||
}).catch(console.error);
|
||||
|
||||
// No immediate changes
|
||||
return {};
|
||||
});
|
||||
}, [api, adminApi, options]); // Do not add state or context as a dependency here -> infinite render loop
|
||||
context.dispatchAction = dispatchAction as DispatchActionType;
|
||||
|
||||
const initAdminAuth = async () => {
|
||||
if (adminApi || !options.adminUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const adminApi = setupAdminAPI({
|
||||
adminUrl: options.adminUrl
|
||||
});
|
||||
setAdminApi(adminApi);
|
||||
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await adminApi.getUser();
|
||||
} catch (e) {
|
||||
// Loading of admin failed. Could be not signed in, or a different error (not important)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[Comments] Failed to fetch current admin user:`, e);
|
||||
}
|
||||
|
||||
setState({
|
||||
admin
|
||||
});
|
||||
} catch (e) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(`[Comments] Failed to initialize admin authentication:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
/** Fetch first few comments */
|
||||
const fetchComments = async () => {
|
||||
const dataPromise = api.comments.browse({page: 1, postId: options.postId});
|
||||
const countPromise = api.comments.count({postId: options.postId});
|
||||
|
||||
const [data, count] = await Promise.all([dataPromise, countPromise]);
|
||||
|
||||
return {
|
||||
comments: data.comments,
|
||||
pagination: data.meta.pagination,
|
||||
count: count
|
||||
};
|
||||
}
|
||||
|
||||
/** Initialize comments setup on load, fetch data and setup state*/
|
||||
const initSetup = async () => {
|
||||
try {
|
||||
// Fetch data from API, links, preview, dev sources
|
||||
const {member} = await api.init();
|
||||
const {comments, pagination, count} = await fetchComments();
|
||||
|
||||
const state = {
|
||||
member,
|
||||
initStatus: 'success',
|
||||
comments,
|
||||
pagination,
|
||||
commentCount: count
|
||||
};
|
||||
|
||||
setState(state);
|
||||
} catch (e) {
|
||||
/* eslint-disable no-console */
|
||||
console.error(`[Comments] Failed to initialize:`, e);
|
||||
/* eslint-enable no-console */
|
||||
setState({
|
||||
initStatus: 'failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initSetup();
|
||||
}, []);
|
||||
|
||||
const done = state.initStatus === 'success';
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={context}>
|
||||
<CommentsFrame>
|
||||
<ContentBox done={done} />
|
||||
</CommentsFrame>
|
||||
<AuthFrame adminUrl={options.adminUrl} onLoad={initAdminAuth}/>
|
||||
<PopupBox />
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
|
@ -3,17 +3,6 @@ import React, {useContext} from 'react';
|
|||
import {ActionType, Actions, SyncActionType, SyncActions} from './actions';
|
||||
import {Page} from './pages';
|
||||
|
||||
export type PopupNotification = {
|
||||
type: string,
|
||||
status: string,
|
||||
autoHide: boolean,
|
||||
closeable: boolean,
|
||||
duration: number,
|
||||
meta: any,
|
||||
message: string,
|
||||
count: number
|
||||
}
|
||||
|
||||
export type Member = {
|
||||
id: string,
|
||||
uuid: string,
|
||||
|
@ -44,10 +33,23 @@ export type AddComment = {
|
|||
html: string
|
||||
}
|
||||
|
||||
export type AppContextType = {
|
||||
action: string,
|
||||
popupNotification: PopupNotification | null,
|
||||
customSiteUrl: string | undefined,
|
||||
export type CommentsOptions = {
|
||||
siteUrl: string,
|
||||
apiKey: string | undefined,
|
||||
apiUrl: string | undefined,
|
||||
postId: string,
|
||||
adminUrl: string | undefined,
|
||||
colorScheme: string| undefined,
|
||||
avatarSaturation: number | undefined,
|
||||
accentColor: string,
|
||||
commentsEnabled: string | undefined,
|
||||
title: string | null,
|
||||
showCount: boolean,
|
||||
publication: string
|
||||
};
|
||||
|
||||
export type EditableAppContext = {
|
||||
initStatus: string,
|
||||
member: null | any,
|
||||
admin: null | any,
|
||||
comments: Comment[],
|
||||
|
@ -58,22 +60,18 @@ export type AppContextType = {
|
|||
total: number
|
||||
} | null,
|
||||
commentCount: number,
|
||||
postId: string,
|
||||
title: string,
|
||||
showCount: boolean,
|
||||
colorScheme: string | undefined,
|
||||
avatarSaturation: number | undefined,
|
||||
accentColor: string | undefined,
|
||||
commentsEnabled: string | undefined,
|
||||
publication: string,
|
||||
secundaryFormCount: number,
|
||||
popup: Page | null,
|
||||
}
|
||||
|
||||
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
|
||||
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'] : {}) => T extends ActionType ? Promise<void> : void
|
||||
}
|
||||
|
||||
// Copy time from AppContextType
|
||||
export type DispatchActionType = AppContextType['dispatchAction'];
|
||||
export const AppContext = React.createContext<AppContextType>({} as any);
|
||||
|
||||
export const AppContextProvider = AppContext.Provider;
|
||||
|
|
14
apps/comments-ui/src/AuthFrame.tsx
Normal file
14
apps/comments-ui/src/AuthFrame.tsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
type Props = {
|
||||
adminUrl: string|undefined;
|
||||
onLoad: () => void;
|
||||
};
|
||||
const AuthFrame: React.FC<Props> = ({adminUrl, onLoad}) => {
|
||||
const iframeStyle = {
|
||||
display: 'none'
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe data-frame="admin-auth" src={adminUrl + 'auth-frame/'} style={iframeStyle} title="auth-frame" onLoad={onLoad}></iframe>
|
||||
);
|
||||
}
|
||||
export default AuthFrame;
|
|
@ -1,13 +1,14 @@
|
|||
import {AddComment, AppContextType, Comment} from './AppContext';
|
||||
import {AddComment, Comment, CommentsOptions, EditableAppContext} from './AppContext';
|
||||
import {AdminApi} from './utils/adminApi';
|
||||
import {GhostApi} from './utils/api';
|
||||
import {Page} from './pages';
|
||||
|
||||
async function loadMoreComments({state, api}: {state: AppContextType, api: GhostApi}): Promise<Partial<AppContextType>> {
|
||||
async function loadMoreComments({state, api, options}: {state: EditableAppContext, api: GhostApi, options: CommentsOptions}): Promise<Partial<EditableAppContext>> {
|
||||
let page = 1;
|
||||
if (state.pagination && state.pagination.page) {
|
||||
page = state.pagination.page + 1;
|
||||
}
|
||||
const data = await api.comments.browse({page, postId: state.postId});
|
||||
const data = await api.comments.browse({page, postId: options.postId});
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
return {
|
||||
|
@ -16,7 +17,7 @@ async function loadMoreComments({state, api}: {state: AppContextType, api: Ghost
|
|||
};
|
||||
}
|
||||
|
||||
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: AppContextType, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<AppContextType>> {
|
||||
async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise<Partial<EditableAppContext>> {
|
||||
const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit});
|
||||
|
||||
// Note: we store the comments from new to old, and show them in reverse order
|
||||
|
@ -33,7 +34,7 @@ async function loadMoreReplies({state, api, data: {comment, limit}}: {state: App
|
|||
};
|
||||
}
|
||||
|
||||
async function addComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: AddComment}) {
|
||||
async function addComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: AddComment}) {
|
||||
const data = await api.comments.add({comment});
|
||||
comment = data.comments[0];
|
||||
|
||||
|
@ -43,7 +44,7 @@ async function addComment({state, api, data: comment}: {state: AppContextType, a
|
|||
};
|
||||
}
|
||||
|
||||
async function addReply({state, api, data: {reply, parent}}: {state: AppContextType, api: GhostApi, data: {reply: any, parent: any}}) {
|
||||
async function addReply({state, api, data: {reply, parent}}: {state: EditableAppContext, api: GhostApi, data: {reply: any, parent: any}}) {
|
||||
let comment = reply;
|
||||
comment.parent_id = parent.id;
|
||||
|
||||
|
@ -73,7 +74,7 @@ async function addReply({state, api, data: {reply, parent}}: {state: AppContextT
|
|||
};
|
||||
}
|
||||
|
||||
async function hideComment({state, adminApi, data: comment}: {state: AppContextType, adminApi: any, data: {id: string}}) {
|
||||
async function hideComment({state, adminApi, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) {
|
||||
await adminApi.hideComment(comment.id);
|
||||
|
||||
return {
|
||||
|
@ -106,7 +107,7 @@ async function hideComment({state, adminApi, data: comment}: {state: AppContextT
|
|||
};
|
||||
}
|
||||
|
||||
async function showComment({state, api, adminApi, data: comment}: {state: AppContextType, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||
async function showComment({state, api, adminApi, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) {
|
||||
await adminApi.showComment(comment.id);
|
||||
|
||||
// We need to refetch the comment, to make sure we have an up to date HTML content
|
||||
|
@ -137,7 +138,7 @@ async function showComment({state, api, adminApi, data: comment}: {state: AppCon
|
|||
};
|
||||
}
|
||||
|
||||
async function likeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||
async function likeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
|
||||
await api.comments.like({comment});
|
||||
|
||||
return {
|
||||
|
@ -183,7 +184,7 @@ async function reportComment({api, data: comment}: {api: GhostApi, data: {id: st
|
|||
return {};
|
||||
}
|
||||
|
||||
async function unlikeComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||
async function unlikeComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
|
||||
await api.comments.unlike({comment});
|
||||
|
||||
return {
|
||||
|
@ -222,7 +223,7 @@ async function unlikeComment({state, api, data: comment}: {state: AppContextType
|
|||
};
|
||||
}
|
||||
|
||||
async function deleteComment({state, api, data: comment}: {state: AppContextType, api: GhostApi, data: {id: string}}) {
|
||||
async function deleteComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, data: {id: string}}) {
|
||||
await api.comments.edit({
|
||||
comment: {
|
||||
id: comment.id,
|
||||
|
@ -260,7 +261,7 @@ async function deleteComment({state, api, data: comment}: {state: AppContextType
|
|||
};
|
||||
}
|
||||
|
||||
async function editComment({state, api, data: {comment, parent}}: {state: AppContextType, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
|
||||
async function editComment({state, api, data: {comment, parent}}: {state: EditableAppContext, api: GhostApi, data: {comment: Partial<Comment> & {id: string}, parent?: Comment}}) {
|
||||
const data = await api.comments.edit({
|
||||
comment
|
||||
});
|
||||
|
@ -288,7 +289,7 @@ async function editComment({state, api, data: {comment, parent}}: {state: AppCon
|
|||
};
|
||||
}
|
||||
|
||||
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: AppContextType, api: GhostApi}) {
|
||||
async function updateMember({data, state, api}: {data: {name: string, expertise: string}, state: EditableAppContext, api: GhostApi}) {
|
||||
const {name, expertise} = data;
|
||||
const patchData: {name?: string, expertise?: string} = {};
|
||||
|
||||
|
@ -336,13 +337,13 @@ function closePopup() {
|
|||
};
|
||||
}
|
||||
|
||||
function increaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||
function increaseSecundaryFormCount({state}: {state: EditableAppContext}) {
|
||||
return {
|
||||
secundaryFormCount: state.secundaryFormCount + 1
|
||||
};
|
||||
}
|
||||
|
||||
function decreaseSecundaryFormCount({state}: {state: AppContextType}) {
|
||||
function decreaseSecundaryFormCount({state}: {state: EditableAppContext}) {
|
||||
return {
|
||||
secundaryFormCount: state.secundaryFormCount - 1
|
||||
};
|
||||
|
@ -381,20 +382,20 @@ export function isSyncAction(action: string): action is SyncActionType {
|
|||
}
|
||||
|
||||
/** Handle actions in the App, returns updated state */
|
||||
export async function ActionHandler({action, data, state, api, adminApi}: {action: ActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
|
||||
export async function ActionHandler({action, data, state, api, adminApi, options}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Promise<Partial<EditableAppContext>> {
|
||||
const handler = Actions[action];
|
||||
if (handler) {
|
||||
return await handler({data, state, api, adminApi} as any) || {};
|
||||
return await handler({data, state, api, adminApi, options} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Handle actions in the App, returns updated state */
|
||||
export function SyncActionHandler({action, data, state, api, adminApi}: {action: SyncActionType, data: any, state: AppContextType, api: GhostApi, adminApi: any}) {
|
||||
export function SyncActionHandler({action, data, state, api, adminApi, options}: {action: SyncActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Partial<EditableAppContext> {
|
||||
const handler = SyncActions[action];
|
||||
if (handler) {
|
||||
// Do not await here
|
||||
return handler({data, state, api, adminApi} as any) || {};
|
||||
return handler({data, state, api, adminApi, options} as any) || {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -37,7 +37,11 @@ export default class IFrame extends Component<any> {
|
|||
|
||||
if (this.props.onResize) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(new ResizeObserver(_ => this.props.onResize(this.iframeRoot)))?.observe?.(this.iframeRoot);
|
||||
(new ResizeObserver(_ => {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.props.onResize(this.iframeRoot);
|
||||
});
|
||||
}))?.observe?.(this.iframeRoot);
|
||||
}
|
||||
|
||||
// This is a bit hacky, but prevents us to need to attach even listeners to all the iframes we have
|
||||
|
|
|
@ -36,35 +36,6 @@ function getRootDiv(scriptTag: HTMLElement) {
|
|||
return elem;
|
||||
}
|
||||
|
||||
function getSiteData(scriptTag: HTMLElement) {
|
||||
/**
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
let dataset = scriptTag?.dataset;
|
||||
|
||||
if (!scriptTag && process.env.NODE_ENV === 'development') {
|
||||
// Use queryparams in test mode
|
||||
dataset = Object.fromEntries(new URLSearchParams(window.location.search).entries());
|
||||
} else if (!scriptTag) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const siteUrl = dataset.ghostComments;
|
||||
const apiKey = dataset.key;
|
||||
const apiUrl = dataset.api;
|
||||
const adminUrl = dataset.admin;
|
||||
const postId = dataset.postId;
|
||||
const colorScheme = dataset.colorScheme;
|
||||
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
|
||||
const accentColor = dataset.accentColor ?? '#000000';
|
||||
const commentsEnabled = dataset.commentsEnabled;
|
||||
const title = dataset.title === 'null' ? null : dataset.title;
|
||||
const showCount = dataset.count === 'true';
|
||||
const publication = dataset.publication ?? ''; // TODO: replace with dynamic data from script
|
||||
|
||||
return {siteUrl, apiKey, apiUrl, postId, adminUrl, colorScheme, avatarSaturation, accentColor, commentsEnabled, title, showCount, publication};
|
||||
}
|
||||
|
||||
function handleTokenUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('token')) {
|
||||
|
@ -77,16 +48,12 @@ function init() {
|
|||
const scriptTag = getScriptTag();
|
||||
const root = getRootDiv(scriptTag);
|
||||
|
||||
// const customSiteUrl = getSiteUrl();
|
||||
const {siteUrl: customSiteUrl, ...siteData} = getSiteData(scriptTag);
|
||||
const siteUrl = customSiteUrl || window.location.origin;
|
||||
|
||||
try {
|
||||
handleTokenUrl();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
{<App customSiteUrl={customSiteUrl} siteUrl={siteUrl} {...siteData} />}
|
||||
{<App scriptTag={scriptTag} />}
|
||||
</React.StrictMode>,
|
||||
root
|
||||
);
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
Before Width: | Height: | Size: 2.6 KiB |
70
apps/comments-ui/src/utils/adminApi.ts
Normal file
70
apps/comments-ui/src/utils/adminApi.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
export function setupAdminAPI({adminUrl}: {adminUrl: string}) {
|
||||
const frame = document.querySelector('iframe[data-frame="admin-auth"]') as HTMLIFrameElement;
|
||||
let uid = 1;
|
||||
const handlers: Record<string, (error: Error|undefined, result: any) => void> = {};
|
||||
const adminOrigin = new URL(adminUrl).origin;
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.origin !== adminOrigin) {
|
||||
// Other message that is not intended for us
|
||||
return;
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
/* eslint-disable no-console */
|
||||
console.error('Error parsing event data', err);
|
||||
/* eslint-enable no-console */
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[data.uid];
|
||||
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete handlers[data.uid];
|
||||
|
||||
handler(data.error, data.result);
|
||||
});
|
||||
|
||||
function callApi(action: string, args?: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
function handler(error: Error|undefined, result: any) {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
}
|
||||
return resolve(result);
|
||||
}
|
||||
uid += 1;
|
||||
handlers[uid] = handler;
|
||||
frame.contentWindow!.postMessage(JSON.stringify({
|
||||
uid,
|
||||
action,
|
||||
...args
|
||||
}), adminOrigin);
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
async getUser() {
|
||||
const result = await callApi('getUser');
|
||||
if (!result || !result.users) {
|
||||
return null;
|
||||
}
|
||||
return result.users[0];
|
||||
},
|
||||
async hideComment(id: string) {
|
||||
return await callApi('hideComment', {id});
|
||||
},
|
||||
async showComment(id: string) {
|
||||
return await callApi('showComment', {id});
|
||||
}
|
||||
};
|
||||
|
||||
return api;
|
||||
}
|
||||
export type AdminApi = ReturnType<typeof setupAdminAPI>;
|
|
@ -1,22 +0,0 @@
|
|||
export const isDevMode = function ({customSiteUrl = ''} = {}) {
|
||||
if (customSiteUrl && process.env.NODE_ENV === 'development') {
|
||||
return false;
|
||||
}
|
||||
return (process.env.NODE_ENV === 'development');
|
||||
};
|
||||
|
||||
export const isTestMode = function () {
|
||||
return (process.env.NODE_ENV === 'test');
|
||||
};
|
||||
|
||||
const modeFns = {
|
||||
dev: isDevMode,
|
||||
test: isTestMode
|
||||
};
|
||||
|
||||
export const hasMode = (modes: ('dev' | 'test')[] = [], options: {customSiteUrl?: string} = {}) => {
|
||||
return modes.some((mode) => {
|
||||
const modeFn = modeFns[mode];
|
||||
return !!(modeFn && modeFn(options));
|
||||
});
|
||||
};
|
|
@ -1,30 +1,4 @@
|
|||
import {Comment, PopupNotification} from '../AppContext';
|
||||
|
||||
export const createPopupNotification = ({type, status, autoHide, duration = 2600, closeable, state, message, meta = {}}: {
|
||||
type: string,
|
||||
status: string,
|
||||
autoHide: boolean,
|
||||
duration?: number,
|
||||
closeable: boolean,
|
||||
state: any,
|
||||
message: string,
|
||||
meta?: any
|
||||
}): PopupNotification => {
|
||||
let count = 0;
|
||||
if (state && state.popupNotification) {
|
||||
count = (state.popupNotification.count || 0) + 1;
|
||||
}
|
||||
return {
|
||||
type,
|
||||
status,
|
||||
autoHide,
|
||||
closeable,
|
||||
duration,
|
||||
meta,
|
||||
message,
|
||||
count
|
||||
};
|
||||
};
|
||||
import {Comment} from '../AppContext';
|
||||
|
||||
export function formatNumber(number: number): string {
|
||||
if (number !== 0 && !number) {
|
||||
|
|
47
apps/comments-ui/src/utils/options.ts
Normal file
47
apps/comments-ui/src/utils/options.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import React, {useMemo} from 'react';
|
||||
import {CommentsOptions} from '../AppContext';
|
||||
|
||||
export function useOptions(scriptTag: HTMLElement) {
|
||||
const buildOptions = React.useCallback(() => {
|
||||
/**
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
const dataset = scriptTag.dataset;
|
||||
const siteUrl = dataset.ghostComments || window.location.origin;
|
||||
const apiKey = dataset.key;
|
||||
const apiUrl = dataset.api;
|
||||
const adminUrl = dataset.admin;
|
||||
const postId = dataset.postId || '';
|
||||
const colorScheme = dataset.colorScheme;
|
||||
const avatarSaturation = dataset.avatarSaturation ? parseInt(dataset.avatarSaturation) : undefined;
|
||||
const accentColor = dataset.accentColor ?? '#000000';
|
||||
const commentsEnabled = dataset.commentsEnabled;
|
||||
const title = dataset.title === 'null' ? null : (dataset.title ?? ''); // Null means use the default title. Missing = no title.
|
||||
const showCount = dataset.count === 'true';
|
||||
const publication = dataset.publication ?? ''; // TODO: replace with dynamic data from script
|
||||
|
||||
const options = {siteUrl, apiKey, apiUrl, postId, adminUrl, colorScheme, avatarSaturation, accentColor, commentsEnabled, title, showCount, publication};
|
||||
return options;
|
||||
}, [scriptTag]);
|
||||
|
||||
const initialOptions = useMemo(() => buildOptions(), []);
|
||||
const [options, setOptions] = React.useState<CommentsOptions>(initialOptions);
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new MutationObserver((mutationList) => {
|
||||
if (mutationList.some(mutation => mutation.type === 'attributes')) {
|
||||
setOptions(buildOptions());
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(scriptTag, {
|
||||
attributes: true
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scriptTag, buildOptions]);
|
||||
|
||||
return options;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import {MockedApi, initialize} from '../utils/e2e';
|
||||
import {MockedApi, initialize, waitEditorFocused} from '../utils/e2e';
|
||||
import {expect, test} from '@playwright/test';
|
||||
|
||||
test.describe('Actions', async () => {
|
||||
|
@ -91,6 +91,11 @@ test.describe('Actions', async () => {
|
|||
const editor = frame.getByTestId('form-editor');
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
await page.pause();
|
||||
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Type some text
|
||||
await page.keyboard.type('This is a reply 123');
|
||||
await expect(editor).toHaveText('This is a reply 123');
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {MockedApi, getHeight, getModifierKey, initialize, selectText, setClipboard} from '../utils/e2e';
|
||||
import {MockedApi, getHeight, getModifierKey, initialize, selectText, setClipboard, waitEditorFocused} from '../utils/e2e';
|
||||
import {expect, test} from '@playwright/test';
|
||||
|
||||
test.describe('Editor', async () => {
|
||||
|
@ -26,6 +26,9 @@ test.describe('Editor', async () => {
|
|||
|
||||
await editor.click({force: true});
|
||||
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Wait for animation to finish
|
||||
await page.waitForTimeout(200);
|
||||
const newEditorHeight = await getHeight(editor);
|
||||
|
@ -95,6 +98,8 @@ test.describe('Editor', async () => {
|
|||
const editorHeight = await getHeight(editor);
|
||||
|
||||
await editor.click({force: true});
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Wait for animation to finish
|
||||
await page.waitForTimeout(200);
|
||||
|
@ -134,6 +139,9 @@ test.describe('Editor', async () => {
|
|||
|
||||
await editor.click({force: true});
|
||||
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Type in the editor
|
||||
await editor.type('> This is a quote');
|
||||
await page.keyboard.press('Enter');
|
||||
|
@ -169,7 +177,9 @@ test.describe('Editor', async () => {
|
|||
|
||||
// Check focused
|
||||
const editorEditable = frame.getByTestId('editor');
|
||||
await expect(editorEditable).toBeFocused();
|
||||
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Type in the editor
|
||||
await editor.type('Click here to go to a new page');
|
||||
|
@ -205,9 +215,10 @@ test.describe('Editor', async () => {
|
|||
|
||||
await editor.click({force: true});
|
||||
|
||||
// Check focused
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
const editorEditable = frame.getByTestId('editor');
|
||||
await expect(editorEditable).toBeFocused();
|
||||
|
||||
// Type in the editor
|
||||
await editor.type('Click here to go to a new page');
|
||||
|
@ -243,9 +254,10 @@ test.describe('Editor', async () => {
|
|||
|
||||
await editor.click({force: true});
|
||||
|
||||
// Check focused
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
const editorEditable = frame.getByTestId('editor');
|
||||
await expect(editorEditable).toBeFocused();
|
||||
|
||||
// Type in the editor
|
||||
await editor.type('Click here to go to a new page');
|
||||
|
@ -280,9 +292,8 @@ test.describe('Editor', async () => {
|
|||
|
||||
await editor.click({force: true});
|
||||
|
||||
// Check focused
|
||||
const editorEditable = frame.getByTestId('editor');
|
||||
await expect(editorEditable).toBeFocused();
|
||||
// Wait for focused
|
||||
await waitEditorFocused(editor);
|
||||
|
||||
// Type in the editor
|
||||
await editor.type('This is line 1');
|
||||
|
|
|
@ -6,13 +6,13 @@ function rgbToHsl(r: number, g: number, b: number) {
|
|||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
var max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
var h, s, l = (max + min) / 2;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
var d = max - min;
|
||||
const d = max - min;
|
||||
s = Math.round(l > 0.5 ? d / (2 - max - min) : d / (max + min) * 10) / 10;
|
||||
|
||||
switch (max) {
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import {E2E_PORT} from '../../playwright.config';
|
||||
import {Locator, Page} from '@playwright/test';
|
||||
import {MockedApi} from './MockedApi';
|
||||
import {expect} from '@playwright/test';
|
||||
|
||||
export const MOCKED_SITE_URL = 'https://localhost:1234';
|
||||
export {MockedApi};
|
||||
|
||||
export async function waitEditorFocused(editor: Locator) {
|
||||
// Wait for focused
|
||||
const internalEditor = editor.getByTestId('editor');
|
||||
await expect(internalEditor).toBeFocused();
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe: string) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
|
|
|
@ -15,9 +15,6 @@
|
|||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
/* Temporary */
|
||||
"allowJs": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
|
|
Loading…
Add table
Reference in a new issue