mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-26 04:08:01 +03:00
Added recommend back URL (#18382)
refs https://github.com/TryGhost/Product/issues/3958 - Disabled automatic network retries for external site lookups (=> timed out to 5s in every situation because it returned 404 when a site doesn't implement the Ghost api) - Disabled representing a modal when it is already present on hash changes - Added support for search params in modals - Handle `?url` search param in the addRecommendationModal
This commit is contained in:
parent
95ec7b5016
commit
05215734af
@ -14,7 +14,7 @@ export default defineConfig({
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Hardcode to use all cores in CI */
|
||||
workers: process.env.CI ? '100%' : undefined,
|
||||
workers: process.env.CI ? '100%' : (process.env.PLAYWRIGHT_SLOWMO ? 1 : undefined),
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
@ -3,7 +3,7 @@ import TextField, {TextFieldProps} from './TextField';
|
||||
import validator from 'validator';
|
||||
import {useFocusContext} from '../../providers/DesignSystemProvider';
|
||||
|
||||
const formatUrl = (value: string, baseUrl?: string) => {
|
||||
export const formatUrl = (value: string, baseUrl?: string) => {
|
||||
let url = value.trim();
|
||||
|
||||
if (!url) {
|
||||
|
@ -31,7 +31,8 @@ export const useExternalGhostSite = () => {
|
||||
const result = await fetchApi(url, {
|
||||
method: 'GET',
|
||||
credentials: 'omit', // Allow CORS wildcard,
|
||||
timeout: 5000
|
||||
timeout: 5000,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// We need to validate all data types here for extra safety
|
||||
|
@ -30,7 +30,8 @@ export const RouteContext = createContext<RoutingContextData>({
|
||||
|
||||
export type RoutingModalProps = {
|
||||
pathName: string;
|
||||
params?: Record<string, string>
|
||||
params?: Record<string, string>,
|
||||
searchParams?: URLSearchParams
|
||||
}
|
||||
|
||||
const modalPaths: {[key: string]: ModalName} = {
|
||||
@ -85,6 +86,7 @@ const handleNavigation = (currentRoute: string | undefined) => {
|
||||
let url = new URL(hash, domain);
|
||||
|
||||
const pathName = getHashPath(url.pathname);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
if (pathName) {
|
||||
const [, currentModalName] = Object.entries(modalPaths).find(([modalPath]) => matchRoute(currentRoute || '', modalPath)) || [];
|
||||
@ -93,9 +95,9 @@ const handleNavigation = (currentRoute: string | undefined) => {
|
||||
return {
|
||||
pathName,
|
||||
changingModal: modalName && modalName !== currentModalName,
|
||||
modal: (path && modalName) ?
|
||||
modal: (path && modalName) ? // we should consider adding '&& modalName !== currentModalName' here, but this breaks tests
|
||||
import('./routing/modals').then(({default: modals}) => {
|
||||
NiceModal.show(modals[modalName] as ModalComponent, {pathName, params: matchRoute(pathName, path)});
|
||||
NiceModal.show(modals[modalName] as ModalComponent, {pathName, params: matchRoute(pathName, path), searchParams});
|
||||
}) :
|
||||
undefined
|
||||
};
|
||||
|
@ -8,8 +8,10 @@ import useForm from '../../../../hooks/useForm';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {AlreadyExistsError} from '../../../../utils/errors';
|
||||
import {EditOrAddRecommendation, RecommendationResponseType, useGetRecommendationByUrl} from '../../../../api/recommendations';
|
||||
import {LoadingIndicator} from '../../../../admin-x-ds/global/LoadingIndicator';
|
||||
import {RoutingModalProps} from '../../../providers/RoutingProvider';
|
||||
import {dismissAllToasts, showToast} from '../../../../admin-x-ds/global/Toast';
|
||||
import {formatUrl} from '../../../../admin-x-ds/global/form/URLTextField';
|
||||
import {trimSearchAndHash} from '../../../../utils/url';
|
||||
import {useExternalGhostSite} from '../../../../api/external-ghost-site';
|
||||
import {useGetOembed} from '../../../../api/oembed';
|
||||
@ -19,7 +21,11 @@ interface AddRecommendationModalProps {
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModalProps> = ({recommendation, animate}) => {
|
||||
const doFormatUrl = (url: string) => {
|
||||
return formatUrl(url).save;
|
||||
};
|
||||
|
||||
const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModalProps> = ({searchParams, recommendation, animate}) => {
|
||||
const [enterPressed, setEnterPressed] = useState(false);
|
||||
const modal = useModal();
|
||||
const {updateRoute} = useRouting();
|
||||
@ -27,10 +33,18 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
const {query: queryExternalGhostSite} = useExternalGhostSite();
|
||||
const {query: getRecommendationByUrl} = useGetRecommendationByUrl();
|
||||
|
||||
// Handle a URL that was passed via the URL
|
||||
const initialUrl = recommendation ? '' : (searchParams?.get('url') ?? '');
|
||||
const {save: initialUrlCleaned} = initialUrl ? formatUrl(initialUrl) : {save: ''};
|
||||
|
||||
// Show loading view when we had an initial URL
|
||||
const didInitialSubmit = React.useRef(false);
|
||||
const [showLoadingView, setShowLoadingView] = React.useState(!!initialUrlCleaned);
|
||||
|
||||
const {formState, updateForm, handleSave, errors, saveState, clearError} = useForm({
|
||||
initialState: recommendation ?? {
|
||||
title: '',
|
||||
url: '',
|
||||
url: initialUrlCleaned,
|
||||
reason: '',
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
@ -89,6 +103,10 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
|
||||
// Switch modal without changing the route (the second modal is not reachable by URL)
|
||||
modal.remove();
|
||||
|
||||
// todo: we should change the URL, but this also keeps adding a new modal -> infinite loop
|
||||
// updateRoute('recommendations/add?url=' + encodeURIComponent(updatedRecommendation.url));
|
||||
|
||||
NiceModal.show(AddRecommendationModalConfirm, {
|
||||
animate: false,
|
||||
recommendation: updatedRecommendation
|
||||
@ -108,11 +126,16 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
newErrors.url = 'Please enter a valid URL.';
|
||||
}
|
||||
|
||||
// If we have errors: close direct submit view
|
||||
if (showLoadingView) {
|
||||
setShowLoadingView(Object.keys(newErrors).length === 0);
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}
|
||||
});
|
||||
|
||||
const saveForm = async () => {
|
||||
const onOk = React.useCallback(async () => {
|
||||
if (saveState === 'saving') {
|
||||
// Already saving
|
||||
return;
|
||||
@ -120,7 +143,9 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
|
||||
dismissAllToasts();
|
||||
try {
|
||||
await handleSave({force: true});
|
||||
if (await handleSave({force: true})) {
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof AlreadyExistsError ? e.message : 'Something went wrong while checking this URL, please try again.';
|
||||
showToast({
|
||||
@ -128,22 +153,44 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// If we have errors: close direct submit view
|
||||
if (showLoadingView) {
|
||||
setShowLoadingView(false);
|
||||
}
|
||||
}, [handleSave, saveState, showLoadingView, setShowLoadingView]);
|
||||
|
||||
// Make sure we submit initially when opening in loading view state
|
||||
React.useEffect(() => {
|
||||
if (showLoadingView && !didInitialSubmit.current) {
|
||||
didInitialSubmit.current = true;
|
||||
onOk();
|
||||
}
|
||||
}, [showLoadingView, onOk]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enterPressed) {
|
||||
saveForm();
|
||||
onOk();
|
||||
setEnterPressed(false); // Reset for future use
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formState]);
|
||||
|
||||
const formatUrl = (url: string) => {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
if (showLoadingView) {
|
||||
return <Modal
|
||||
afterClose={() => {
|
||||
// Closed without saving: reset route
|
||||
updateRoute('recommendations');
|
||||
}}
|
||||
animate={animate ?? true}
|
||||
backDropClick={false}
|
||||
cancelLabel=''
|
||||
okLabel=''
|
||||
size='sm'
|
||||
>
|
||||
<LoadingIndicator />
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
return <Modal
|
||||
afterClose={() => {
|
||||
@ -158,7 +205,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
size='sm'
|
||||
testId='add-recommendation-modal'
|
||||
title='Add recommendation'
|
||||
onOk={saveForm}
|
||||
onOk={onOk}
|
||||
>
|
||||
<p className="mt-4">You can recommend any site your audience will find valuable, not just those published on Ghost.</p>
|
||||
<Form
|
||||
@ -172,7 +219,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
placeholder='https://www.example.com'
|
||||
title='URL'
|
||||
value={formState.url}
|
||||
onBlur={() => updateForm(state => ({...state, url: formatUrl(formState.url)}))}
|
||||
onBlur={() => updateForm(state => ({...state, url: doFormatUrl(formState.url)}))}
|
||||
onChange={(e) => {
|
||||
clearError?.('url');
|
||||
updateForm(state => ({...state, url: e.target.value}));
|
||||
@ -180,7 +227,7 @@ const AddRecommendationModal: React.FC<RoutingModalProps & AddRecommendationModa
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
updateForm(state => ({...state, url: formatUrl(formState.url)}));
|
||||
updateForm(state => ({...state, url: doFormatUrl(formState.url)}));
|
||||
setEnterPressed(true);
|
||||
}
|
||||
}}
|
||||
|
@ -27,6 +27,7 @@ interface RequestOptions {
|
||||
};
|
||||
credentials?: 'include' | 'omit' | 'same-origin';
|
||||
timeout?: number;
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
export const useFetchApi = () => {
|
||||
@ -34,7 +35,7 @@ export const useFetchApi = () => {
|
||||
const sentryDSN = useSentryDSN();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return async <ResponseData = any>(endpoint: string | URL, options: RequestOptions = {}) => {
|
||||
return async <ResponseData = any>(endpoint: string | URL, options: RequestOptions = {}): Promise<ResponseData> => {
|
||||
// By default, we set the Content-Type header to application/json
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
'app-pragma': 'no-cache',
|
||||
@ -56,6 +57,7 @@ export const useFetchApi = () => {
|
||||
// 1. Server Unreachable error from the browser (code 0 or TypeError), typically from short internet blips
|
||||
// 2. Maintenance error from Ghost, upgrade in progress so API is temporarily unavailable
|
||||
let attempts = 0;
|
||||
let shouldRetry = options.retry === true || options.retry === undefined;
|
||||
let retryingMs = 0;
|
||||
const startTime = Date.now();
|
||||
const maxRetryingMs = 15_000;
|
||||
@ -75,7 +77,7 @@ export const useFetchApi = () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
while (attempts === 0 || shouldRetry) {
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
headers: {
|
||||
@ -97,7 +99,7 @@ export const useFetchApi = () => {
|
||||
} catch (error) {
|
||||
retryingMs = Date.now() - startTime;
|
||||
|
||||
if (import.meta.env.MODE !== 'development' && retryableErrors.some(errorClass => error instanceof errorClass) && retryingMs <= maxRetryingMs) {
|
||||
if (shouldRetry && (import.meta.env.MODE !== 'development' && retryableErrors.some(errorClass => error instanceof errorClass) && retryingMs <= maxRetryingMs)) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, retryPeriods[attempts] || retryPeriods[retryPeriods.length - 1]);
|
||||
});
|
||||
@ -122,6 +124,11 @@ export const useFetchApi = () => {
|
||||
throw newError;
|
||||
};
|
||||
}
|
||||
|
||||
// Used for type checking
|
||||
// this can't happen, but TS isn't smart enough to undeerstand that the loop will never exit without an error or return
|
||||
// because of shouldRetry + attemps usage combination
|
||||
return undefined as never;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -366,7 +366,7 @@ exports[`Incoming Recommendation Emails Sends an email if we receive a recommend
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Recommend back</a></td>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations/add?url=https%3A%2F%2Fwww.otherghostsite.com%2F\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Recommend back</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -563,7 +563,7 @@ exports[`Incoming Recommendation Emails Sends an email if we receive a recommend
|
||||
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;\\">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Recommend back</a></td>
|
||||
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/ghost/#/settings-x/recommendations/add?url=https%3A%2F%2Fwww.otherghostsite.com%2F\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Recommend back</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -98,7 +98,7 @@ export class IncomingRecommendationService {
|
||||
|
||||
// Check if we are also recommending this URL
|
||||
const existing = await this.#recommendationService.countRecommendations({
|
||||
filter: `url:~^'${url}'`
|
||||
filter: `url:~'${url}'`
|
||||
});
|
||||
const recommendingBack = existing > 0;
|
||||
|
||||
|
@ -494,6 +494,10 @@ class StaffServiceEmails {
|
||||
}
|
||||
return array.slice(0,limit);
|
||||
});
|
||||
|
||||
this.Handlebars.registerHelper('encodeURIComponent', function (string) {
|
||||
return encodeURIComponent(string);
|
||||
});
|
||||
}
|
||||
|
||||
async renderHTML(templateName, data) {
|
||||
|
@ -59,7 +59,7 @@
|
||||
{{#if recommendation.recommendingBack}}
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{siteUrl}}ghost/#/settings-x/recommendations" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: {{accentColor}};">View recommendations</a></td>
|
||||
{{else}}
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{siteUrl}}ghost/#/settings-x/recommendations" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: {{accentColor}};">Recommend back</a></td>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{siteUrl}}ghost/#/settings-x/recommendations/add?url={{ encodeURIComponent recommendation.url }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: {{accentColor}};">Recommend back</a></td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
</tbody>
|
||||
|
Loading…
Reference in New Issue
Block a user