Wired up adding recommendations via admin-x (#17878)

refs https://github.com/TryGhost/Product/issues/3773
This commit is contained in:
Simon Backx 2023-08-31 11:26:12 +02:00 committed by GitHub
parent 37512a712d
commit 875fe939a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 277 additions and 28 deletions

View File

@ -0,0 +1,32 @@
import {apiUrl, useFetchApi} from '../utils/apiRequests';
export type OembedResponse = {
metadata: {
title: string | null,
description:string | null,
author: string | null,
publisher: string | null,
thumbnail: string | null,
icon: string | null
}
}
export type OembedRequest = {
url: string,
type: 'mention'
}
export const useGetOembed = () => {
const fetchApi = useFetchApi();
const path = '/oembed/';
return {
async query(searchParams: OembedRequest) {
const url = apiUrl(path, searchParams);
const result = await fetchApi(url, {
method: 'GET'
});
return result as OembedResponse;
}
};
};

View File

@ -13,11 +13,16 @@ export type Recommendation = {
updated_at: string|null updated_at: string|null
} }
export type EditOrAddRecommendation = Omit<Recommendation, 'id'|'created_at'|'updated_at'> & {id?: string};
export interface RecommendationResponseType { export interface RecommendationResponseType {
meta?: Meta meta?: Meta
recommendations: Recommendation[] recommendations: Recommendation[]
} }
export interface RecommendationEditResponseType extends RecommendationResponseType {
}
export interface RecommendationDeleteResponseType {} export interface RecommendationDeleteResponseType {}
const dataType = 'RecommendationResponseType'; const dataType = 'RecommendationResponseType';
@ -41,3 +46,32 @@ export const useDeleteRecommendation = createMutation<RecommendationDeleteRespon
}) })
} }
}); });
export const useEditRecommendation = createMutation<RecommendationEditResponseType, Recommendation>({
method: 'PUT',
path: recommendation => `/recommendations/${recommendation.id}/`,
body: recommendation => ({recommendations: [recommendation]}),
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as RecommendationResponseType),
recommendations: (currentData as RecommendationResponseType).recommendations.map((recommendation) => {
const newRecommendation = newData.recommendations.find(({id}) => id === recommendation.id);
return newRecommendation || recommendation;
})
})
}
});
export const useAddRecommendation = createMutation<RecommendationResponseType, Partial<Recommendation>>({
method: 'POST',
path: () => '/recommendations/',
body: ({...recommendation}) => ({recommendations: [recommendation]}),
updateQueries: {
dataType,
update: (newData, currentData) => (currentData && {
...(currentData as RecommendationResponseType),
recommendations: (currentData as RecommendationResponseType).recommendations.concat(newData.recommendations)
})
}
});

View File

@ -1,40 +1,144 @@
import AddRecommendationModalConfirm from './AddRecommendationModalConfirm';
import Form from '../../../../admin-x-ds/global/form/Form'; import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal'; import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import URLTextField from '../../../../admin-x-ds/global/form/URLTextField'; import URLTextField from '../../../../admin-x-ds/global/form/URLTextField';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation} from '../../../../api/recommendations';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useGetOembed} from '../../../../api/oembed';
interface AddRecommendationModalProps {} interface AddRecommendationModalProps {
recommendation?: EditOrAddRecommendation,
animate?: boolean
}
const AddRecommendationModal: React.FC<AddRecommendationModalProps> = () => { const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommendation, animate}) => {
const modal = useModal();
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
const {query: queryOembed} = useGetOembed();
const openAddNewRecommendationModal = () => { const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({
updateRoute('recommendations/add-confirm'); initialState: recommendation ?? {
}; title: '',
url: '',
reason: '',
excerpt: null,
featured_image: null,
favicon: null,
one_click_subscribe: false
},
onSave: async () => {
// Todo: Fetch metadata and pass it along
const oembed = await queryOembed({
url: formState.url,
type: 'mention'
});
if (!oembed) {
showToast({
type: 'pageError',
message: 'Could not fetch metadata for this URL, please try again later'
});
return;
}
// Switch modal without changing the route (the second modal is not reachable by URL)
modal.remove();
NiceModal.show(AddRecommendationModalConfirm, {
animate: false,
recommendation: {
...formState,
title: oembed.metadata.title ?? formState.title,
excerpt: oembed.metadata.description ?? formState.excerpt,
featured_image: oembed.metadata.thumbnail ?? formState.featured_image,
favicon: oembed.metadata.icon ?? formState.favicon
}
});
},
onValidate: () => {
const newErrors: Record<string, string> = {};
try {
const u = new URL(formState.url);
// Check domain includes a dot
if (!u.hostname.includes('.')) {
newErrors.url = 'Please enter a valid URL';
}
} catch (e) {
newErrors.url = 'Please enter a valid URL';
}
return newErrors;
}
});
let okLabel = 'Next';
if (saveState === 'saving') {
okLabel = 'Checking...';
}
// TODO: Add error message
return <Modal return <Modal
afterClose={() => { afterClose={() => {
// Closed without saving: reset route
updateRoute('recommendations'); updateRoute('recommendations');
}} }}
animate={animate ?? true}
okColor='black' okColor='black'
okLabel='Next' okLabel={okLabel}
size='sm' size='sm'
testId='add-recommendation-modal' testId='add-recommendation-modal'
title='Add recommendation' title='Add recommendation'
> onOk={async () => {
<Form if (saveState === 'saving') {
// Already saving
return;
}
toast.remove();
try {
if (await handleSave({force: true})) {
// Already handled
} else {
showToast({
type: 'pageError',
message: 'One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
} catch (e) {
showToast({
type: 'pageError',
message: 'Something went wrong while checking this URL, please try again'
});
}
}}
><Form
marginBottom={false} marginBottom={false}
marginTop marginTop
> >
<URLTextField <URLTextField
baseUrl='' baseUrl=''
hint={<>Need inspiration? <a className='text-green' href="https://www.ghost.org/explore" rel="noopener noreferrer" target='_blank'>Explore thousands of sites</a> to recommend</>} error={Boolean(errors.url)}
hint={errors.url || <>Need inspiration? <a className='text-green' href="https://www.ghost.org/explore" rel="noopener noreferrer" target='_blank'>Explore thousands of sites</a> to recommend</>}
placeholder='https://www.example.com' placeholder='https://www.example.com'
title='URL' title='URL'
onChange={openAddNewRecommendationModal} value={formState.url}
onBlur={validate}
onChange={u => updateForm((state) => {
if (u.length && !u.startsWith('http://') && !u.startsWith('https://')) {
u = 'https://' + u;
}
return {
...state,
url: u
};
})}
onKeyDown={() => clearError?.('url')}
/> />
</Form> </Form>
</Modal>; </Modal>;

View File

@ -1,45 +1,113 @@
import AddRecommendationModal from './AddRecommendationModal';
import Avatar from '../../../../admin-x-ds/global/Avatar'; import Avatar from '../../../../admin-x-ds/global/Avatar';
import Form from '../../../../admin-x-ds/global/form/Form'; import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal'; import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react'; import React from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea'; import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting'; import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation, useAddRecommendation} from '../../../../api/recommendations';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
interface AddRecommendationModalProps {} interface AddRecommendationModalProps {
recommendation: EditOrAddRecommendation,
animate?: boolean
}
const AddRecommendationModal: React.FC<AddRecommendationModalProps> = () => { const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({recommendation, animate}) => {
const modal = useModal();
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
const {mutateAsync: addRecommendation} = useAddRecommendation();
const {formState, updateForm, handleSave, saveState} = useForm({
initialState: {
...recommendation
},
onSave: async () => {
await addRecommendation(formState);
modal.remove();
updateRoute('recommendations');
},
onValidate: () => {
const newErrors: Record<string, string> = {};
return newErrors;
}
});
let okLabel = 'Add';
if (saveState === 'saving') {
okLabel = 'Adding...';
} else if (saveState === 'saved') {
okLabel = 'Added';
}
return <Modal return <Modal
afterClose={() => { afterClose={() => {
// Closed without saving: reset route
updateRoute('recommendations'); updateRoute('recommendations');
}} }}
cancelLabel='Back' animate={animate ?? true}
cancelLabel={'Back'}
dirty={true}
okColor='black' okColor='black'
okLabel='Add' okLabel={okLabel}
size='sm' size='sm'
testId='add-recommendation-modal' testId='add-recommendation-modal'
title='Add recommendation' title={'Add recommendation'}
onCancel={() => {
if (saveState === 'saving') {
// Already saving
return;
}
// Switch modal without changing the route, but pass along any changes that were already made
modal.remove();
NiceModal.show(AddRecommendationModal, {
animate: false,
recommendation: {
...formState
}
});
}}
onOk={async () => {
if (saveState === 'saving') {
// Already saving
return;
}
toast.remove();
if (await handleSave({force: true})) {
// Already handled
} else {
showToast({
type: 'pageError',
message: 'One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
}}
> >
<Form <Form
marginBottom={false} marginBottom={false}
marginTop marginTop
> >
<div className='mb-4 flex items-center gap-3 rounded-sm border border-grey-300 p-3'> <div className='mb-4 flex items-center gap-3 rounded-sm border border-grey-300 p-3'>
<Avatar image='https://www.shesabeast.co/content/images/size/w256h256/2022/08/transparent-icon-black-copy-gray-bar.png' labelColor='white' /> {(recommendation.favicon || recommendation.featured_image) && <Avatar image={recommendation.favicon ?? recommendation.featured_image!} labelColor='white' />}
<div className={`flex grow flex-col`}> <div className={`flex grow flex-col`}>
<span className='mb-0.5 font-medium'>Shes A Beast</span> <span className='mb-0.5 font-medium'>{recommendation.title}</span>
<span className='text-xs leading-snug text-grey-700'>shesabeast.co</span> <span className='text-xs leading-snug text-grey-700'>{recommendation.url}</span>
</div> </div>
</div> </div>
<TextArea <TextArea
clearBg={true} clearBg={true}
rows={3} rows={3}
title="Reason for recommending" title="Reason for recommending"
value={formState.reason ?? ''}
onChange={e => updateForm(state => ({...state, reason: e.target.value}))}
/> />
</Form> </Form>
</Modal>; </Modal>;
}; };
export default NiceModal.create(AddRecommendationModal); export default NiceModal.create(AddRecommendationModalConfirm);

View File

@ -11,7 +11,10 @@ export type ErrorMessages = Record<string, string | undefined>
export interface FormHook<State> { export interface FormHook<State> {
formState: State; formState: State;
saveState: SaveState; saveState: SaveState;
handleSave: () => Promise<boolean>; /**
* Validate and save the state. Use the `force` option to save even when there are no changes made (e.g., initial state should be saveable)
*/
handleSave: (options?: {force?: boolean}) => Promise<boolean>;
/** /**
* Update the form state and mark the form as dirty. Should be used in input events * Update the form state and mark the form as dirty. Should be used in input events
*/ */
@ -59,12 +62,12 @@ const useForm = <State>({initialState, onSave, onValidate}: {
}; };
// function to save the changed settings via API // function to save the changed settings via API
const handleSave = async () => { const handleSave = async (options: {force?: boolean} = {}) => {
if (!validate()) { if (!validate()) {
return false; return false;
} }
if (saveState !== 'unsaved') { if (saveState !== 'unsaved' && !options.force) {
return true; return true;
} }

View File

@ -82,7 +82,7 @@ export const useFetchApi = () => {
const {apiRoot} = getGhostPaths(); const {apiRoot} = getGhostPaths();
const apiUrl = (path: string, searchParams: Record<string, string> = {}) => { export const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
const url = new URL(`${apiRoot}${path}`, window.location.origin); const url = new URL(`${apiRoot}${path}`, window.location.origin);
url.search = new URLSearchParams(searchParams).toString(); url.search = new URLSearchParams(searchParams).toString();
return url.toString(); return url.toString();

View File

@ -203,6 +203,13 @@ class OEmbedService {
gotOpts.retry = 0; gotOpts.retry = 0;
} }
const pickFn = (sizes, pickDefault) => {
// Prioritize apple touch icon with sizes > 180
const appleTouchIcon = sizes.find(item => item.rel.includes('apple') && item.sizes && item.size.width >= 180);
const svgIcon = sizes.find(item => item.href.endsWith('svg'));
return appleTouchIcon || svgIcon || pickDefault(sizes);
};
const metascraper = require('metascraper')([ const metascraper = require('metascraper')([
require('metascraper-url')(), require('metascraper-url')(),
require('metascraper-title')(), require('metascraper-title')(),
@ -211,7 +218,8 @@ class OEmbedService {
require('metascraper-publisher')(), require('metascraper-publisher')(),
require('metascraper-image')(), require('metascraper-image')(),
require('metascraper-logo-favicon')({ require('metascraper-logo-favicon')({
gotOpts gotOpts,
pickFn
}), }),
require('metascraper-logo')() require('metascraper-logo')()
]); ]);

View File

@ -14,7 +14,7 @@ function validateString(object: any, key: string, {required = true} = {}): strin
throw new errors.BadRequestError({message: `${key} must be an object`}); throw new errors.BadRequestError({message: `${key} must be an object`});
} }
if (object[key] !== undefined) { if (object[key] !== undefined && object[key] !== null) {
if (typeof object[key] !== "string") { if (typeof object[key] !== "string") {
throw new errors.BadRequestError({message: `${key} must be a string`}); throw new errors.BadRequestError({message: `${key} must be a string`});
} }