From 875fe939a5722ee104124c197fde110ad27668a1 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Thu, 31 Aug 2023 11:26:12 +0200 Subject: [PATCH] Wired up adding recommendations via admin-x (#17878) refs https://github.com/TryGhost/Product/issues/3773 --- apps/admin-x-settings/src/api/oembed.ts | 32 +++++ .../src/api/recommendations.ts | 34 +++++ .../AddRecommendationModal.tsx | 128 ++++++++++++++++-- .../AddRecommendationModalConfirm.tsx | 88 ++++++++++-- apps/admin-x-settings/src/hooks/useForm.ts | 9 +- .../admin-x-settings/src/utils/apiRequests.ts | 2 +- ghost/oembed-service/lib/OEmbedService.js | 10 +- .../src/RecommendationController.ts | 2 +- 8 files changed, 277 insertions(+), 28 deletions(-) create mode 100644 apps/admin-x-settings/src/api/oembed.ts diff --git a/apps/admin-x-settings/src/api/oembed.ts b/apps/admin-x-settings/src/api/oembed.ts new file mode 100644 index 0000000000..471c0fab85 --- /dev/null +++ b/apps/admin-x-settings/src/api/oembed.ts @@ -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; + } + }; +}; diff --git a/apps/admin-x-settings/src/api/recommendations.ts b/apps/admin-x-settings/src/api/recommendations.ts index ce876c3c64..77ed51ad6e 100644 --- a/apps/admin-x-settings/src/api/recommendations.ts +++ b/apps/admin-x-settings/src/api/recommendations.ts @@ -13,11 +13,16 @@ export type Recommendation = { updated_at: string|null } +export type EditOrAddRecommendation = Omit & {id?: string}; + export interface RecommendationResponseType { meta?: Meta recommendations: Recommendation[] } +export interface RecommendationEditResponseType extends RecommendationResponseType { +} + export interface RecommendationDeleteResponseType {} const dataType = 'RecommendationResponseType'; @@ -41,3 +46,32 @@ export const useDeleteRecommendation = createMutation({ + 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>({ + 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) + }) + } +}); diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx index a9d39100fe..6d4a9ac5f1 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx @@ -1,40 +1,144 @@ +import AddRecommendationModalConfirm from './AddRecommendationModalConfirm'; import Form from '../../../../admin-x-ds/global/form/Form'; 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 URLTextField from '../../../../admin-x-ds/global/form/URLTextField'; +import useForm from '../../../../hooks/useForm'; 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 = () => { +const AddRecommendationModal: React.FC = ({recommendation, animate}) => { + const modal = useModal(); const {updateRoute} = useRouting(); + const {query: queryOembed} = useGetOembed(); - const openAddNewRecommendationModal = () => { - updateRoute('recommendations/add-confirm'); - }; + const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({ + 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 = {}; + + 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 { + // Closed without saving: reset route updateRoute('recommendations'); }} + animate={animate ?? true} okColor='black' - okLabel='Next' + okLabel={okLabel} size='sm' testId='add-recommendation-modal' title='Add recommendation' - > -
{ + 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' + }); + } + }} + > Need inspiration? Explore thousands of sites to recommend} + error={Boolean(errors.url)} + hint={errors.url || <>Need inspiration? Explore thousands of sites to recommend} placeholder='https://www.example.com' 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')} />
; diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx index e4a396d4c7..778a075e22 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx @@ -1,45 +1,113 @@ +import AddRecommendationModal from './AddRecommendationModal'; import Avatar from '../../../../admin-x-ds/global/Avatar'; import Form from '../../../../admin-x-ds/global/form/Form'; 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 TextArea from '../../../../admin-x-ds/global/form/TextArea'; +import useForm from '../../../../hooks/useForm'; 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 = () => { +const AddRecommendationModalConfirm: React.FC = ({recommendation, animate}) => { + const modal = useModal(); 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 = {}; + return newErrors; + } + }); + + let okLabel = 'Add'; + + if (saveState === 'saving') { + okLabel = 'Adding...'; + } else if (saveState === 'saved') { + okLabel = 'Added'; + } return { + // Closed without saving: reset route updateRoute('recommendations'); }} - cancelLabel='Back' + animate={animate ?? true} + cancelLabel={'Back'} + dirty={true} okColor='black' - okLabel='Add' + okLabel={okLabel} size='sm' 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' + }); + } + }} >
- + {(recommendation.favicon || recommendation.featured_image) && }
- She‘s A Beast - shesabeast.co + {recommendation.title} + {recommendation.url}