mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Wired up pagination to recommendations (#18018)
fixes https://github.com/TryGhost/Product/issues/3822 fixes https://github.com/TryGhost/Product/issues/3838 This PR became a bit big because it affected multiple parts of Ghost that needed to be updated to prevent breaking anything. ### Backend - Added pagination to the recommendations API's - Updated BookshelfRepository template implementation to handle pagination - Allow to pass `page` and `limit` options to Models `findAll`, to allow fetching a page without also fetching the count/metadata (=> in the repository pattern we prefer to fetch the count explicitly if we need pagination metadata) - Added E2E tests for public recommendations API (content API) - Extended E2E tests of admin recommendations API ### Portal - Corrected recommendations always loaded in Portal. Instead they are now only fetched when the recommendations page is opened. ### Admin-X - Added `usePagination` hook: internally used in the new `usePaginatedQuery` hook. This automatically adds working pagination to a query that can be used to display in a table by passing the `pagination` and `isLoading` results to the `<Table>` - Added placeholder `<LoadingIndicator>` component - Added a loading indicator to `<Table>`. This remembers the previous height of the table, to avoid layout jumps when going to the next page.
This commit is contained in:
parent
f663774cf9
commit
669be72673
30 changed files with 1157 additions and 112 deletions
|
@ -0,0 +1,21 @@
|
|||
import type {Meta, StoryObj} from '@storybook/react';
|
||||
|
||||
import {CenteredLoadingIndicator} from './LoadingIndicator';
|
||||
|
||||
const meta = {
|
||||
title: 'Global / Loading indicator',
|
||||
component: CenteredLoadingIndicator,
|
||||
tags: ['autodocs']
|
||||
} satisfies Meta<typeof CenteredLoadingIndicator>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CenteredLoadingIndicator>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
delay: 1000,
|
||||
style: {
|
||||
height: '400px'
|
||||
}
|
||||
}
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
|
||||
export const LoadingIndicator: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CenteredLoadingIndicatorProps = {
|
||||
delay?: number;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const CenteredLoadingIndicator: React.FC<CenteredLoadingIndicatorProps> = ({delay, style}) => {
|
||||
const [show, setShow] = React.useState(!delay);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (delay) {
|
||||
const timeout = setTimeout(() => {
|
||||
setShow(true);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div className={`flex h-64 items-center justify-center transition-opacity ${show ? 'opacity-100' : 'opacity-0'}`} style={style}>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -13,21 +13,49 @@ type Story = StoryObj<typeof Pagination>;
|
|||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
itemsPerPage: 5,
|
||||
itemsTotal: 15
|
||||
limit: 5,
|
||||
total: 15,
|
||||
page: 1,
|
||||
nextPage: () => {},
|
||||
prevPage: () => {}
|
||||
}
|
||||
};
|
||||
|
||||
export const LessThanMaximum: Story = {
|
||||
args: {
|
||||
itemsPerPage: 9,
|
||||
itemsTotal: 5
|
||||
limit: 9,
|
||||
total: 5,
|
||||
page: 1,
|
||||
nextPage: () => {},
|
||||
prevPage: () => {}
|
||||
}
|
||||
};
|
||||
|
||||
export const MoreThanMaximum: Story = {
|
||||
export const MiddlePage: Story = {
|
||||
args: {
|
||||
itemsPerPage: 5,
|
||||
itemsTotal: 15
|
||||
limit: 5,
|
||||
total: 15,
|
||||
page: 2,
|
||||
nextPage: () => {},
|
||||
prevPage: () => {}
|
||||
}
|
||||
};
|
||||
|
||||
export const LastPage: Story = {
|
||||
args: {
|
||||
limit: 5,
|
||||
total: 15,
|
||||
page: 3,
|
||||
nextPage: () => {},
|
||||
prevPage: () => {}
|
||||
}
|
||||
};
|
||||
|
||||
export const UnknownTotal: Story = {
|
||||
args: {
|
||||
limit: 5,
|
||||
page: 1,
|
||||
nextPage: () => {},
|
||||
prevPage: () => {}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,29 +1,34 @@
|
|||
import Icon from './Icon';
|
||||
import React from 'react';
|
||||
import {PaginationData} from '../../hooks/usePagination';
|
||||
|
||||
interface PaginationProps {
|
||||
itemsPerPage: number;
|
||||
itemsTotal: number;
|
||||
}
|
||||
type PaginationProps = PaginationData
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({page, limit, total, prevPage, nextPage}) => {
|
||||
// Detect loading state
|
||||
const startIndex = (page - 1) * limit + 1;
|
||||
const endIndex = total ? Math.min(total, startIndex + limit - 1) : (startIndex + limit - 1);
|
||||
|
||||
const hasNext = total ? endIndex < total : false;
|
||||
const hasPrev = page > 1;
|
||||
|
||||
const Pagination: React.FC<PaginationProps> = ({itemsPerPage, itemsTotal}) => {
|
||||
/* If there is less than X items total, where X is the number of items per page that we want to show */
|
||||
if (itemsPerPage < itemsTotal) {
|
||||
if (total && limit < total) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs text-grey-700`}>Showing 1-{itemsPerPage} of {itemsTotal}
|
||||
<button type='button'><Icon className="h-[10px] w-[10px] opacity-50 [&>path]:stroke-[3px]" colorClass="text-green" name='chevron-left' />
|
||||
<div className={`flex items-center gap-2 text-xs text-grey-700`}>Showing {startIndex}-{endIndex} of {total}
|
||||
<button type='button' onClick={prevPage}><Icon className={`h-[10px] w-[10px] [&>path]:stroke-[3px] ${!hasPrev ? 'opacity-50' : ''}`} colorClass="text-green" name='chevron-left' />
|
||||
</button>
|
||||
<button className="cursor-pointer" type="button"><Icon className="h-[10px] w-[10px] [&>path]:stroke-[3px]" colorClass="text-green" name='chevron-right' /></button>
|
||||
<button className="cursor-pointer" type="button" onClick={nextPage}><Icon className={`h-[10px] w-[10px] [&>path]:stroke-[3px] ${!hasNext ? 'opacity-50' : ''}`} colorClass="text-green" name='chevron-right' /></button>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
/* If there is more than X items total, where X is the number of items per page that we want to show */
|
||||
} else {
|
||||
return (
|
||||
<div className={`mt-1 flex items-center gap-2 text-xs text-grey-700`}>Showing {itemsTotal} in total
|
||||
<div className={`mt-1 flex items-center gap-2 text-xs text-grey-700`}>Showing {total ?? '?'} in total
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
export default Pagination;
|
||||
|
|
|
@ -4,6 +4,8 @@ import Pagination from './Pagination';
|
|||
import React from 'react';
|
||||
import Separator from './Separator';
|
||||
import clsx from 'clsx';
|
||||
import {CenteredLoadingIndicator} from './LoadingIndicator';
|
||||
import {PaginationData} from '../../hooks/usePagination';
|
||||
|
||||
interface TableProps {
|
||||
/**
|
||||
|
@ -15,9 +17,19 @@ interface TableProps {
|
|||
hint?: string;
|
||||
hintSeparator?: boolean;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
pagination?: PaginationData;
|
||||
}
|
||||
|
||||
const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator, pageTitle, className}) => {
|
||||
const OptionalPagination = ({pagination}: {pagination?: PaginationData}) => {
|
||||
if (!pagination) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Pagination {...pagination}/>;
|
||||
};
|
||||
|
||||
const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator, pageTitle, className, pagination, isLoading}) => {
|
||||
const tableClasses = clsx(
|
||||
(borderTop || pageTitle) && 'border-t border-grey-300',
|
||||
'w-full',
|
||||
|
@ -25,27 +37,50 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
|
|||
className
|
||||
);
|
||||
|
||||
// We want to avoid layout jumps when we load a new page of the table, or when data is invalidated
|
||||
const table = React.useRef<HTMLTableElement>(null);
|
||||
const [tableHeight, setTableHeight] = React.useState<number | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Add resize observer to table
|
||||
if (table.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const height = entries[0].target.clientHeight;
|
||||
setTableHeight(height);
|
||||
});
|
||||
resizeObserver.observe(table.current);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const loadingStyle = React.useMemo(() => {
|
||||
if (tableHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
height: tableHeight
|
||||
};
|
||||
}, [tableHeight]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-full overflow-x-scroll'>
|
||||
{pageTitle && <Heading>{pageTitle}</Heading>}
|
||||
<table className={tableClasses}>
|
||||
{!isLoading && <table ref={table} className={tableClasses}>
|
||||
<tbody>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
{hint &&
|
||||
</table>}
|
||||
{isLoading && <CenteredLoadingIndicator delay={200} style={loadingStyle} />}
|
||||
{(hint || pagination) &&
|
||||
<div className="-mt-px">
|
||||
{hintSeparator && <Separator />}
|
||||
{(hintSeparator || pagination) && <Separator />}
|
||||
<div className="flex justify-between">
|
||||
<Hint>{hint}</Hint>
|
||||
{/* // TODO: Finish pagination component */}
|
||||
{/* <div className={`mt-1 flex items-center gap-2 text-xs text-grey-700`}>Showing 1-5 of 15
|
||||
<button type='button'><Icon colorClass="text-green" name='chevron-left' size="xs" />
|
||||
</button>
|
||||
<button type="button"><Icon colorClass="text-green" name='chevron-right' size="xs" /></button>
|
||||
</div> */}
|
||||
<Pagination itemsPerPage={5} itemsTotal={15}/>
|
||||
<Hint>{hint ?? ' '}</Hint>
|
||||
<OptionalPagination pagination={pagination} />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
|
@ -53,4 +88,4 @@ const Table: React.FC<TableProps> = ({children, borderTop, hint, hintSeparator,
|
|||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
export default Table;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Meta, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createPaginatedQuery} from '../utils/apiRequests';
|
||||
|
||||
export type Mention = {
|
||||
id: string;
|
||||
|
@ -21,7 +21,7 @@ export interface MentionsResponseType {
|
|||
|
||||
const dataType = 'MentionsResponseType';
|
||||
|
||||
export const useBrowseMentions = createQuery<MentionsResponseType>({
|
||||
export const useBrowseMentions = createPaginatedQuery<MentionsResponseType>({
|
||||
dataType,
|
||||
path: '/mentions/'
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Meta, createMutation, createQuery} from '../utils/apiRequests';
|
||||
import {Meta, createMutation, createPaginatedQuery} from '../utils/apiRequests';
|
||||
|
||||
export type Recommendation = {
|
||||
id: string
|
||||
|
@ -27,7 +27,7 @@ export interface RecommendationDeleteResponseType {}
|
|||
|
||||
const dataType = 'RecommendationResponseType';
|
||||
|
||||
export const useBrowseRecommendations = createQuery<RecommendationResponseType>({
|
||||
export const useBrowseRecommendations = createPaginatedQuery<RecommendationResponseType>({
|
||||
dataType,
|
||||
path: '/recommendations/',
|
||||
defaultSearchParams: {}
|
||||
|
@ -36,14 +36,9 @@ export const useBrowseRecommendations = createQuery<RecommendationResponseType>(
|
|||
export const useDeleteRecommendation = createMutation<RecommendationDeleteResponseType, Recommendation>({
|
||||
method: 'DELETE',
|
||||
path: recommendation => `/recommendations/${recommendation.id}/`,
|
||||
updateQueries: {
|
||||
dataType,
|
||||
update: (_: RecommendationDeleteResponseType, currentData, payload) => (currentData && {
|
||||
...(currentData as RecommendationResponseType),
|
||||
recommendations: (currentData as RecommendationResponseType).recommendations.filter((r) => {
|
||||
return r.id !== payload.id;
|
||||
})
|
||||
})
|
||||
// Clear all queries because pagination needs to be re-checked
|
||||
invalidateQueries: {
|
||||
dataType
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -67,11 +62,9 @@ export const useAddRecommendation = createMutation<RecommendationResponseType, P
|
|||
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)
|
||||
})
|
||||
|
||||
// Clear all queries because pagination needs to be re-checked
|
||||
invalidateQueries: {
|
||||
dataType
|
||||
}
|
||||
});
|
||||
|
|
|
@ -15,7 +15,8 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
siteData,
|
||||
handleSave
|
||||
} = useSettingGroup();
|
||||
const {data: {recommendations} = {}} = useBrowseRecommendations();
|
||||
|
||||
const {pagination, data: {recommendations} = {}, isLoading} = useBrowseRecommendations();
|
||||
const [selectedTab, setSelectedTab] = useState('your-recommendations');
|
||||
|
||||
const {updateRoute} = useRouting();
|
||||
|
@ -35,7 +36,7 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
{
|
||||
id: 'your-recommendations',
|
||||
title: 'Your recommendations',
|
||||
contents: (<RecommendationList recommendations={recommendations ?? []} />)
|
||||
contents: (<RecommendationList isLoading={isLoading} pagination={pagination} recommendations={recommendations ?? []}/>)
|
||||
},
|
||||
{
|
||||
id: 'recommending-you',
|
||||
|
@ -45,7 +46,7 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||
];
|
||||
|
||||
const groupDescription = (
|
||||
<>Share favorite sites with your audience after they subscribe. {(recommendations && recommendations.length > 0) && <Link href={recommendationsURL} target='_blank'>Preview</Link>}</>
|
||||
<>Share favorite sites with your audience after they subscribe. {(pagination && pagination.total && pagination.total > 0) && <Link href={recommendationsURL} target='_blank'>Preview</Link>}</>
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -5,9 +5,12 @@ import Table from '../../../../admin-x-ds/global/Table';
|
|||
import TableCell from '../../../../admin-x-ds/global/TableCell';
|
||||
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||
import {Mention} from '../../../../api/mentions';
|
||||
import {PaginationData} from '../../../../hooks/usePagination';
|
||||
|
||||
interface IncomingRecommendationListProps {
|
||||
mentions: Mention[]
|
||||
mentions: Mention[],
|
||||
pagination: PaginationData,
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const IncomingRecommendationItem: React.FC<{mention: Mention}> = ({mention}) => {
|
||||
|
@ -35,9 +38,9 @@ const IncomingRecommendationItem: React.FC<{mention: Mention}> = ({mention}) =>
|
|||
);
|
||||
};
|
||||
|
||||
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({mentions}) => {
|
||||
if (mentions.length) {
|
||||
return <Table>
|
||||
const IncomingRecommendationList: React.FC<IncomingRecommendationListProps> = ({mentions, pagination, isLoading}) => {
|
||||
if (isLoading || mentions.length) {
|
||||
return <Table isLoading={isLoading} pagination={pagination}>
|
||||
{mentions.map(mention => <IncomingRecommendationItem key={mention.id} mention={mention} />)}
|
||||
</Table>;
|
||||
} else {
|
||||
|
|
|
@ -2,14 +2,15 @@ import IncomingRecommendationList from './IncomingRecommendationList';
|
|||
import {useBrowseMentions} from '../../../../api/mentions';
|
||||
|
||||
const IncomingRecommendations: React.FC = () => {
|
||||
const {data: {mentions} = {}} = useBrowseMentions({
|
||||
const {data: {mentions} = {}, pagination, isLoading} = useBrowseMentions({
|
||||
searchParams: {
|
||||
limit: '5',
|
||||
filter: `source:~$'/.well-known/recommendations.json'+verified:true`,
|
||||
order: 'created_at desc'
|
||||
}
|
||||
});
|
||||
|
||||
return (<IncomingRecommendationList mentions={mentions ?? []} />);
|
||||
return (<IncomingRecommendationList isLoading={isLoading} mentions={mentions ?? []} pagination={pagination}/>);
|
||||
};
|
||||
|
||||
export default IncomingRecommendations;
|
||||
|
|
|
@ -8,10 +8,13 @@ import Table from '../../../../admin-x-ds/global/Table';
|
|||
import TableCell from '../../../../admin-x-ds/global/TableCell';
|
||||
import TableRow from '../../../../admin-x-ds/global/TableRow';
|
||||
import useRouting from '../../../../hooks/useRouting';
|
||||
import {PaginationData} from '../../../../hooks/usePagination';
|
||||
import {Recommendation, useDeleteRecommendation} from '../../../../api/recommendations';
|
||||
|
||||
interface RecommendationListProps {
|
||||
recommendations: Recommendation[]
|
||||
recommendations: Recommendation[],
|
||||
pagination: PaginationData,
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recommendation}) => {
|
||||
|
@ -57,10 +60,10 @@ const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recomme
|
|||
);
|
||||
};
|
||||
|
||||
const RecommendationList: React.FC<RecommendationListProps> = ({recommendations}) => {
|
||||
if (recommendations.length) {
|
||||
return <Table hint='Readers will see your recommendations in randomized order' hintSeparator>
|
||||
{recommendations.map(recommendation => <RecommendationItem key={recommendation.id} recommendation={recommendation} />)}
|
||||
const RecommendationList: React.FC<RecommendationListProps> = ({recommendations, pagination, isLoading}) => {
|
||||
if (isLoading || recommendations.length) {
|
||||
return <Table hint='Readers will see your recommendations in randomized order' isLoading={isLoading} pagination={pagination} hintSeparator>
|
||||
{recommendations && recommendations.map(recommendation => <RecommendationItem key={recommendation.id} recommendation={recommendation} />)}
|
||||
</Table>;
|
||||
} else {
|
||||
return <NoValueLabel icon='thumbs-up'>
|
||||
|
|
43
apps/admin-x-settings/src/hooks/usePagination.tsx
Normal file
43
apps/admin-x-settings/src/hooks/usePagination.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import {Meta} from '../utils/apiRequests';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
export interface PaginationData {
|
||||
page: number;
|
||||
pages: number | null;
|
||||
total: number | null;
|
||||
limit: number;
|
||||
setPage: (page: number) => void;
|
||||
nextPage: () => void;
|
||||
prevPage: () => void;
|
||||
}
|
||||
|
||||
export const usePage = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
return {page, setPage};
|
||||
};
|
||||
|
||||
export const usePagination = ({limit, meta, page, setPage}: {meta?: Meta, limit: number, page: number, setPage: React.Dispatch<React.SetStateAction<number>>}): PaginationData => {
|
||||
// Prevent resetting meta when a new page loads
|
||||
const [prevMeta, setPrevMeta] = useState<Meta | undefined>(meta);
|
||||
|
||||
useEffect(() => {
|
||||
if (meta) {
|
||||
setPrevMeta(meta);
|
||||
|
||||
if (meta.pagination.pages < page) {
|
||||
// We probably deleted an item when on the last page: go one page back automatically
|
||||
setPage(meta.pagination.pages);
|
||||
}
|
||||
}
|
||||
}, [meta, setPage, page]);
|
||||
|
||||
return {
|
||||
page,
|
||||
setPage,
|
||||
pages: prevMeta?.pagination.pages ?? null,
|
||||
limit: prevMeta?.pagination.limit ?? limit,
|
||||
total: prevMeta?.pagination.total ?? null,
|
||||
nextPage: () => setPage(Math.min(page + 1, prevMeta?.pagination.pages ?? page)),
|
||||
prevPage: () => setPage(Math.max(1, page - 1))
|
||||
};
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {getGhostPaths} from './helpers';
|
||||
import {useMemo} from 'react';
|
||||
import {usePage, usePagination} from '../hooks/usePagination';
|
||||
import {useServices} from '../components/providers/ServiceProvider';
|
||||
|
||||
export interface Meta {
|
||||
|
@ -123,6 +124,41 @@ export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) =
|
|||
};
|
||||
};
|
||||
|
||||
export const createPaginatedQuery = <ResponseData extends {meta?: Meta}>(options: QueryOptions<ResponseData>) => ({searchParams, ...query}: QueryHookOptions<ResponseData> = {}) => {
|
||||
const {page, setPage} = usePage();
|
||||
const limit = (searchParams?.limit || options.defaultSearchParams?.limit) ? parseInt(searchParams?.limit || options.defaultSearchParams?.limit || '15') : 15;
|
||||
|
||||
const paginatedSearchParams = searchParams || options.defaultSearchParams || {};
|
||||
paginatedSearchParams.page = page.toString();
|
||||
|
||||
const url = apiUrl(options.path, paginatedSearchParams);
|
||||
const fetchApi = useFetchApi();
|
||||
|
||||
const result = useQuery<ResponseData>({
|
||||
queryKey: [options.dataType, url],
|
||||
queryFn: () => fetchApi(url),
|
||||
...query
|
||||
});
|
||||
|
||||
const data = useMemo(() => (
|
||||
(result.data && options.returnData) ? options.returnData(result.data) : result.data)
|
||||
, [result]);
|
||||
|
||||
const pagination = usePagination({
|
||||
page,
|
||||
setPage,
|
||||
limit,
|
||||
// Don't pass the meta data if we are fetching, because then it is probably out of date and this causes issues
|
||||
meta: result.isFetching ? undefined : data?.meta
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
data,
|
||||
pagination
|
||||
};
|
||||
};
|
||||
|
||||
type InfiniteQueryOptions<ResponseData> = Omit<QueryOptions<ResponseData>, 'returnData'> & {
|
||||
returnData: NonNullable<QueryOptions<ResponseData>['returnData']>
|
||||
}
|
||||
|
|
|
@ -864,6 +864,7 @@ export default class App extends React.Component {
|
|||
const contextPage = this.getContextPage({site, page, member});
|
||||
const contextMember = this.getContextMember({page: contextPage, member, customSiteUrl});
|
||||
return {
|
||||
api: this.GhostApi,
|
||||
site,
|
||||
action,
|
||||
brandColor: this.getAccentColor(),
|
||||
|
|
|
@ -2,6 +2,7 @@ import AppContext from '../../AppContext';
|
|||
import {useContext, useState, useEffect} from 'react';
|
||||
import CloseButton from '../common/CloseButton';
|
||||
import {clearURLParams} from '../../utils/notifications';
|
||||
import LoadingPage from './LoadingPage';
|
||||
|
||||
export const RecommendationsPageStyles = `
|
||||
.gh-portal-recommendation-item .gh-portal-list-detail {
|
||||
|
@ -84,21 +85,25 @@ const RecommendationItem = (recommendation) => {
|
|||
};
|
||||
|
||||
const RecommendationsPage = () => {
|
||||
const {site, pageData, t} = useContext(AppContext);
|
||||
const {api, site, pageData, t} = useContext(AppContext);
|
||||
const {title, icon} = site;
|
||||
const {recommendations_enabled: recommendationsEnabled = false} = site;
|
||||
const {recommendations = []} = site;
|
||||
const [recommendations, setRecommendations] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.site.recommendations({limit: 100}).then((data) => {
|
||||
setRecommendations(
|
||||
shuffleRecommendations(data.recommendations
|
||||
));
|
||||
}).catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Show 5 recommendations by default
|
||||
const [numToShow, setNumToShow] = useState(5);
|
||||
|
||||
// Show recommendations in a random order
|
||||
const [shuffledRecommendations, setShuffledRecommendations] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setShuffledRecommendations(shuffleRecommendations([...recommendations]));
|
||||
}, [recommendations]);
|
||||
|
||||
const showAllRecommendations = () => {
|
||||
setNumToShow(recommendations.length);
|
||||
};
|
||||
|
@ -116,10 +121,14 @@ const RecommendationsPage = () => {
|
|||
const heading = pageData && pageData.signup ? t('You\'re subscribed!') : t('Recommendations');
|
||||
const subheading = t(`Here are a few other sites {{siteTitle}} thinks you may enjoy.`, {siteTitle: title});
|
||||
|
||||
if (!recommendationsEnabled || recommendations.length < 1) {
|
||||
if (!recommendationsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recommendations === null) {
|
||||
return <LoadingPage/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='gh-portal-content with-footer'>
|
||||
<CloseButton />
|
||||
|
@ -130,7 +139,7 @@ const RecommendationsPage = () => {
|
|||
<p className="gh-portal-recommendations-description">{subheading}</p>
|
||||
|
||||
<div className="gh-portal-list">
|
||||
{shuffledRecommendations.slice(0, numToShow).map((recommendation, index) => (
|
||||
{recommendations.slice(0, numToShow).map((recommendation, index) => (
|
||||
<RecommendationItem key={index} {...recommendation} />
|
||||
))}
|
||||
</div>
|
||||
|
|
|
@ -114,8 +114,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|||
});
|
||||
},
|
||||
|
||||
recommendations() {
|
||||
const url = contentEndpointFor({resource: 'recommendations'});
|
||||
recommendations({limit}) {
|
||||
let url = contentEndpointFor({resource: 'recommendations'});
|
||||
url = url.replace('limit=all', `limit=${limit}`);
|
||||
return makeRequest({
|
||||
url,
|
||||
method: 'GET',
|
||||
|
@ -537,20 +538,17 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
|
|||
let newsletters = [];
|
||||
let tiers = [];
|
||||
let settings = {};
|
||||
let recommendations = [];
|
||||
|
||||
try {
|
||||
[{settings}, {tiers}, {newsletters}, {recommendations}] = await Promise.all([
|
||||
[{settings}, {tiers}, {newsletters}] = await Promise.all([
|
||||
api.site.settings(),
|
||||
api.site.tiers(),
|
||||
api.site.newsletters(),
|
||||
api.site.recommendations()
|
||||
api.site.newsletters()
|
||||
]);
|
||||
site = {
|
||||
...settings,
|
||||
newsletters,
|
||||
tiers: transformApiTiersData({tiers}),
|
||||
recommendations
|
||||
tiers: transformApiTiersData({tiers})
|
||||
};
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
|
|
|
@ -11,8 +11,9 @@ type Order<T> = {
|
|||
export type ModelClass<T> = {
|
||||
destroy: (data: {id: T}) => Promise<void>;
|
||||
findOne: (data: {id: T}, options?: {require?: boolean}) => Promise<ModelInstance<T> | null>;
|
||||
findAll: (options: {filter?: string; order?: OrderOption}) => Promise<ModelInstance<T>[]>;
|
||||
findAll: (options: {filter?: string; order?: string, page?: number, limit?: number | 'all'}) => Promise<ModelInstance<T>[]>;
|
||||
add: (data: object) => Promise<ModelInstance<T>>;
|
||||
getFilteredCollection: (options: {filter?: string}) => {count(): Promise<number>};
|
||||
}
|
||||
|
||||
export type ModelInstance<T> = {
|
||||
|
@ -23,7 +24,7 @@ export type ModelInstance<T> = {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type OrderOption<T extends Entity<any> = any> = Order<T>[];
|
||||
export type OrderOption<T extends Entity<any> = any> = Order<T>[];
|
||||
|
||||
export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
||||
protected Model: ModelClass<IDType>;
|
||||
|
@ -33,8 +34,16 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
|||
}
|
||||
|
||||
protected abstract toPrimitive(entity: T): object;
|
||||
protected abstract entityFieldToColumn(field: keyof T): string;
|
||||
protected abstract modelToEntity(model: ModelInstance<IDType>): Promise<T|null> | T | null;
|
||||
|
||||
#orderToString(order?: OrderOption<T>) {
|
||||
if (!order || order.length === 0) {
|
||||
return;
|
||||
}
|
||||
return order.map(({field, direction}) => `${this.entityFieldToColumn(field)} ${direction}`).join(',');
|
||||
}
|
||||
|
||||
async save(entity: T): Promise<void> {
|
||||
if (entity.deleted) {
|
||||
await this.Model.destroy({id: entity.id});
|
||||
|
@ -56,7 +65,25 @@ export abstract class BookshelfRepository<IDType, T extends Entity<IDType>> {
|
|||
}
|
||||
|
||||
async getAll({filter, order}: { filter?: string; order?: OrderOption<T> } = {}): Promise<T[]> {
|
||||
const models = await this.Model.findAll({filter, order}) as ModelInstance<IDType>[];
|
||||
const models = await this.Model.findAll({
|
||||
filter,
|
||||
order: this.#orderToString(order)
|
||||
}) as ModelInstance<IDType>[];
|
||||
return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[];
|
||||
}
|
||||
|
||||
async getPage({filter, order, page, limit}: { filter?: string; order?: OrderOption<T>; page: number; limit: number }): Promise<T[]> {
|
||||
const models = await this.Model.findAll({
|
||||
filter,
|
||||
order: this.#orderToString(order),
|
||||
limit,
|
||||
page
|
||||
})
|
||||
return (await Promise.all(models.map(model => this.modelToEntity(model)))).filter(entity => !!entity) as T[];
|
||||
}
|
||||
|
||||
async getCount({filter}: { filter?: string } = {}): Promise<number> {
|
||||
const collection = this.Model.getFilteredCollection({filter});
|
||||
return await collection.count();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,10 @@ class SimpleBookshelfRepository extends BookshelfRepository<string, SimpleEntity
|
|||
birthday: entity.birthday
|
||||
};
|
||||
}
|
||||
|
||||
protected entityFieldToColumn(field: keyof SimpleEntity): string {
|
||||
return field as string;
|
||||
}
|
||||
}
|
||||
|
||||
class Model implements ModelClass<string> {
|
||||
|
@ -49,15 +53,17 @@ class Model implements ModelClass<string> {
|
|||
}
|
||||
return Promise.resolve(item ?? null);
|
||||
}
|
||||
findAll(options: {filter?: string | undefined; order?: {field: string | number | symbol; direction: 'desc' | 'asc';}[] | undefined;}): Promise<ModelInstance<string>[]> {
|
||||
findAll(options: {filter?: string | undefined; order?: string | undefined; page?: number; limit?: number | 'all'}): Promise<ModelInstance<string>[]> {
|
||||
const sorted = this.items.slice().sort((a, b) => {
|
||||
for (const order of options.order ?? []) {
|
||||
const aValue = a.get(order.field as string) as number;
|
||||
const bValue = b.get(order.field as string) as number;
|
||||
for (const order of options.order?.split(',') ?? []) {
|
||||
const [field, direction] = order.split(' ');
|
||||
|
||||
const aValue = a.get(field as string) as number;
|
||||
const bValue = b.get(field as string) as number;
|
||||
if (aValue < bValue) {
|
||||
return order.direction === 'asc' ? -1 : 1;
|
||||
return direction === 'asc' ? -1 : 1;
|
||||
} else if (aValue > bValue) {
|
||||
return order.direction === 'asc' ? 1 : -1;
|
||||
return direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
|
@ -86,6 +92,14 @@ class Model implements ModelClass<string> {
|
|||
this.items.push(item);
|
||||
return Promise.resolve(item);
|
||||
}
|
||||
|
||||
getFilteredCollection() {
|
||||
return this;
|
||||
}
|
||||
|
||||
count() {
|
||||
return Promise.resolve(this.items.length);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BookshelfRepository', function () {
|
||||
|
@ -199,4 +213,112 @@ describe('BookshelfRepository', function () {
|
|||
assert(result[1].age === 24);
|
||||
assert(result[2].age === 5);
|
||||
});
|
||||
|
||||
it('Can retrieve page', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
const entities = [{
|
||||
id: '1',
|
||||
deleted: false,
|
||||
name: 'Kym',
|
||||
age: 24,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '2',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 30,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '3',
|
||||
deleted: false,
|
||||
name: 'Kevin',
|
||||
age: 5,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}];
|
||||
|
||||
for (const entity of entities) {
|
||||
await repository.save(entity);
|
||||
}
|
||||
|
||||
const result = await repository.getPage({
|
||||
order: [{
|
||||
field: 'age',
|
||||
direction: 'desc'
|
||||
}],
|
||||
limit: 5,
|
||||
page: 1
|
||||
});
|
||||
|
||||
assert(result);
|
||||
assert(result.length === 3);
|
||||
assert(result[0].age === 30);
|
||||
assert(result[1].age === 24);
|
||||
assert(result[2].age === 5);
|
||||
});
|
||||
|
||||
it('Can retrieve page without order', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
const entities = [{
|
||||
id: '1',
|
||||
deleted: false,
|
||||
name: 'Kym',
|
||||
age: 24,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '2',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 30,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '3',
|
||||
deleted: false,
|
||||
name: 'Kevin',
|
||||
age: 5,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}];
|
||||
|
||||
for (const entity of entities) {
|
||||
await repository.save(entity);
|
||||
}
|
||||
|
||||
const result = await repository.getPage({
|
||||
order: [],
|
||||
limit: 5,
|
||||
page: 1
|
||||
});
|
||||
|
||||
assert(result);
|
||||
assert(result.length === 3);
|
||||
});
|
||||
|
||||
it('Can retrieve count', async function () {
|
||||
const repository = new SimpleBookshelfRepository(new Model());
|
||||
const entities = [{
|
||||
id: '1',
|
||||
deleted: false,
|
||||
name: 'Kym',
|
||||
age: 24,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '2',
|
||||
deleted: false,
|
||||
name: 'John',
|
||||
age: 30,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}, {
|
||||
id: '3',
|
||||
deleted: false,
|
||||
name: 'Kevin',
|
||||
age: 5,
|
||||
birthday: new Date('2000-01-01').toISOString()
|
||||
}];
|
||||
|
||||
for (const entity of entities) {
|
||||
await repository.save(entity);
|
||||
}
|
||||
|
||||
const result = await repository.getCount({});
|
||||
assert(result === 3);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,16 +9,13 @@ module.exports = {
|
|||
},
|
||||
options: [
|
||||
'limit',
|
||||
'fields',
|
||||
'filter',
|
||||
'order',
|
||||
'debug',
|
||||
'page'
|
||||
],
|
||||
permissions: true,
|
||||
validation: {},
|
||||
async query() {
|
||||
return await recommendations.controller.listRecommendations();
|
||||
async query(frame) {
|
||||
return await recommendations.controller.listRecommendations(frame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,11 +7,14 @@ module.exports = {
|
|||
headers: {
|
||||
cacheInvalidate: false
|
||||
},
|
||||
options: [],
|
||||
options: [
|
||||
'limit',
|
||||
'page'
|
||||
],
|
||||
permissions: true,
|
||||
validation: {},
|
||||
async query() {
|
||||
return await recommendations.controller.listRecommendations();
|
||||
async query(frame) {
|
||||
return await recommendations.controller.listRecommendations(frame);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -44,6 +44,12 @@ module.exports = function (Bookshelf) {
|
|||
});
|
||||
}
|
||||
|
||||
if (options.page && options.limit) {
|
||||
itemCollection
|
||||
.query('limit', options.limit)
|
||||
.query('offset', options.limit * (options.page - 1));
|
||||
}
|
||||
|
||||
const result = await itemCollection.fetchAll(options);
|
||||
if (options.withRelated) {
|
||||
_.each(result.models, function each(item) {
|
||||
|
|
|
@ -45,7 +45,7 @@ module.exports = function (Bookshelf) {
|
|||
case 'findOne':
|
||||
return baseOptions.concat(extraOptions, ['columns', 'require', 'mongoTransformer']);
|
||||
case 'findAll':
|
||||
return baseOptions.concat(extraOptions, ['filter', 'columns', 'mongoTransformer']);
|
||||
return baseOptions.concat(extraOptions, ['filter', 'columns', 'mongoTransformer', 'page', 'limit']);
|
||||
case 'findPage':
|
||||
return baseOptions.concat(extraOptions, ['filter', 'order', 'autoOrder', 'page', 'limit', 'columns', 'mongoTransformer']);
|
||||
default:
|
||||
|
|
|
@ -102,6 +102,16 @@ Object {
|
|||
|
||||
exports[`Recommendations Admin API Can browse 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 5,
|
||||
"next": null,
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"prev": null,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"recommendations": Array [
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
|
@ -123,7 +133,7 @@ exports[`Recommendations Admin API Can browse 2: [headers] 1`] = `
|
|||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "376",
|
||||
"content-length": "463",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -179,6 +189,258 @@ Object {
|
|||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Can request pages 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 10,
|
||||
"next": 2,
|
||||
"page": 1,
|
||||
"pages": 2,
|
||||
"prev": null,
|
||||
"total": 16,
|
||||
},
|
||||
},
|
||||
"recommendations": Array [
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": "Dogs are cute",
|
||||
"favicon": "https://dogpictures.com/favicon.ico",
|
||||
"featured_image": "https://dogpictures.com/dog.jpg",
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": true,
|
||||
"reason": "Because dogs are cute",
|
||||
"title": "Dog Pictures",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://dogpictures.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 0",
|
||||
"title": "Recommendation 0",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation0.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 1",
|
||||
"title": "Recommendation 1",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation1.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 2",
|
||||
"title": "Recommendation 2",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation2.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 3",
|
||||
"title": "Recommendation 3",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation3.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 4",
|
||||
"title": "Recommendation 4",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation4.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 5",
|
||||
"title": "Recommendation 5",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation5.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 6",
|
||||
"title": "Recommendation 6",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation6.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 7",
|
||||
"title": "Recommendation 7",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation7.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 8",
|
||||
"title": "Recommendation 8",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation8.com/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Can request pages 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "2964",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Can request pages 3: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 10,
|
||||
"next": null,
|
||||
"page": 2,
|
||||
"pages": 2,
|
||||
"prev": 1,
|
||||
"total": 16,
|
||||
},
|
||||
},
|
||||
"recommendations": Array [
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 9",
|
||||
"title": "Recommendation 9",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation9.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 10",
|
||||
"title": "Recommendation 10",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation10.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 11",
|
||||
"title": "Recommendation 11",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation11.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 12",
|
||||
"title": "Recommendation 12",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation12.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 13",
|
||||
"title": "Recommendation 13",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation13.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 14",
|
||||
"title": "Recommendation 14",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation14.com/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Can request pages 4: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "1790",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Cannot edit to invalid recommendation state 1: [body] 1`] = `
|
||||
Object {
|
||||
"errors": Array [
|
||||
|
@ -240,3 +502,29 @@ Object {
|
|||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Uses default limit of 5 1: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "1573",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Admin API Uses default limit of 15 1: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "1573",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -2,6 +2,7 @@ const {agentProvider, fixtureManager, mockManager, matchers} = require('../../ut
|
|||
const {anyObjectId, anyErrorId, anyISODateTime, anyContentVersion, anyLocationFor, anyEtag} = matchers;
|
||||
const assert = require('assert/strict');
|
||||
const recommendationsService = require('../../../core/server/services/recommendations');
|
||||
const {Recommendation} = require('@tryghost/recommendations');
|
||||
|
||||
describe('Recommendations Admin API', function () {
|
||||
let agent;
|
||||
|
@ -189,4 +190,74 @@ describe('Recommendations Admin API', function () {
|
|||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('Can request pages', async function () {
|
||||
// Add 15 recommendations using the repository
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const recommendation = Recommendation.create({
|
||||
title: `Recommendation ${i}`,
|
||||
reason: `Reason ${i}`,
|
||||
url: new URL(`https://recommendation${i}.com`),
|
||||
favicon: null,
|
||||
featuredImage: null,
|
||||
excerpt: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
|
||||
await recommendationsService.repository.save(recommendation);
|
||||
}
|
||||
|
||||
const {body: page1} = await agent.get('recommendations/?page=1&limit=10')
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
recommendations: new Array(10).fill({
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(page1.meta.pagination.page, 1);
|
||||
assert.equal(page1.meta.pagination.limit, 10);
|
||||
assert.equal(page1.meta.pagination.pages, 2);
|
||||
assert.equal(page1.meta.pagination.next, 2);
|
||||
assert.equal(page1.meta.pagination.prev, null);
|
||||
assert.equal(page1.meta.pagination.total, 16);
|
||||
|
||||
const {body: page2} = await agent.get('recommendations/?page=2&limit=10')
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
recommendations: new Array(6).fill({
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(page2.meta.pagination.page, 2);
|
||||
assert.equal(page2.meta.pagination.limit, 10);
|
||||
assert.equal(page2.meta.pagination.pages, 2);
|
||||
assert.equal(page2.meta.pagination.next, null);
|
||||
assert.equal(page2.meta.pagination.prev, 1);
|
||||
assert.equal(page2.meta.pagination.total, 16);
|
||||
});
|
||||
|
||||
it('Uses default limit of 5', async function () {
|
||||
const {body: page1} = await agent.get('recommendations/')
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': anyContentVersion,
|
||||
etag: anyEtag
|
||||
});
|
||||
|
||||
assert.equal(page1.meta.pagination.limit, 5);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Recommendations Content API Can paginate recommendations 1: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 5,
|
||||
"next": 2,
|
||||
"page": 1,
|
||||
"pages": 2,
|
||||
"prev": null,
|
||||
"total": 7,
|
||||
},
|
||||
},
|
||||
"recommendations": Array [
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 0",
|
||||
"title": "Recommendation 0",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation0.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 1",
|
||||
"title": "Recommendation 1",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation1.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 2",
|
||||
"title": "Recommendation 2",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation2.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 3",
|
||||
"title": "Recommendation 3",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation3.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 4",
|
||||
"title": "Recommendation 4",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation4.com/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Content API Can paginate recommendations 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=0",
|
||||
"content-length": "1495",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Content API Can paginate recommendations 3: [body] 1`] = `
|
||||
Object {
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 5,
|
||||
"next": null,
|
||||
"page": 2,
|
||||
"pages": 2,
|
||||
"prev": 1,
|
||||
"total": 7,
|
||||
},
|
||||
},
|
||||
"recommendations": Array [
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 5",
|
||||
"title": "Recommendation 5",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation5.com/",
|
||||
},
|
||||
Object {
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"excerpt": null,
|
||||
"favicon": null,
|
||||
"featured_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"one_click_subscribe": false,
|
||||
"reason": "Reason 6",
|
||||
"title": "Recommendation 6",
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "https://recommendation6.com/",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Recommendations Content API Can paginate recommendations 4: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "public, max-age=0",
|
||||
"content-length": "661",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Accept-Version, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
65
ghost/core/test/e2e-api/content/recommendations.test.js
Normal file
65
ghost/core/test/e2e-api/content/recommendations.test.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
const {agentProvider, fixtureManager, matchers} = require('../../utils/e2e-framework');
|
||||
const recommendationsService = require('../../../core/server/services/recommendations');
|
||||
const {Recommendation} = require('@tryghost/recommendations');
|
||||
const {anyObjectId, anyISODateTime} = matchers;
|
||||
|
||||
describe('Recommendations Content API', function () {
|
||||
let agent;
|
||||
|
||||
before(async function () {
|
||||
agent = await agentProvider.getContentAPIAgent();
|
||||
await fixtureManager.init('api_keys');
|
||||
await agent.authenticate();
|
||||
|
||||
// Clear placeholders
|
||||
for (const recommendation of (await recommendationsService.repository.getAll())) {
|
||||
recommendation.delete();
|
||||
await recommendationsService.repository.save(recommendation);
|
||||
}
|
||||
|
||||
// Add 7 recommendations using the repository
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const recommendation = Recommendation.create({
|
||||
title: `Recommendation ${i}`,
|
||||
reason: `Reason ${i}`,
|
||||
url: new URL(`https://recommendation${i}.com`),
|
||||
favicon: null,
|
||||
featuredImage: null,
|
||||
excerpt: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
|
||||
await recommendationsService.repository.save(recommendation);
|
||||
}
|
||||
});
|
||||
|
||||
it('Can paginate recommendations', async function () {
|
||||
await agent.get(`recommendations/`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': matchers.anyContentVersion,
|
||||
etag: matchers.anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
recommendations: new Array(5).fill({
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
})
|
||||
});
|
||||
|
||||
await agent.get(`recommendations/?page=2`)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
'content-version': matchers.anyContentVersion,
|
||||
etag: matchers.anyEtag
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
recommendations: new Array(2).fill({
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
|
@ -50,4 +50,20 @@ export class BookshelfRecommendationRepository extends BookshelfRepository<strin
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
entityFieldToColumn(field: keyof Recommendation): string {
|
||||
const mapping = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
reason: 'reason',
|
||||
excerpt: 'excerpt',
|
||||
featuredImage: 'featured_image',
|
||||
favicon: 'favicon',
|
||||
url: 'url',
|
||||
oneClickSubscribe: 'one_click_subscribe',
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
} as Record<keyof Recommendation, string>;
|
||||
return mapping[field];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,34 @@ function validateURL(object: any, key: string, {required = true, nullable = fals
|
|||
}
|
||||
}
|
||||
|
||||
function validateInteger(object: any, key: string, {required = true, nullable = false} = {}): number|undefined|null {
|
||||
if (typeof object !== 'object' || object === null) {
|
||||
throw new errors.BadRequestError({message: `${key} must be an object`});
|
||||
}
|
||||
|
||||
if (nullable && object[key] === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (object[key] !== undefined && object[key] !== null) {
|
||||
if (typeof object[key] === "string") {
|
||||
// Try to cast to a number
|
||||
const parsed = parseInt(object[key]);
|
||||
if (isNaN(parsed) || !isFinite(parsed)) {
|
||||
throw new errors.BadRequestError({message: `${key} must be a number`});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (typeof object[key] !== "number") {
|
||||
throw new errors.BadRequestError({message: `${key} must be a number`});
|
||||
}
|
||||
return object[key];
|
||||
} else if (required) {
|
||||
throw new errors.BadRequestError({message: `${key} is required`});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class RecommendationController {
|
||||
service: RecommendationService;
|
||||
|
@ -77,6 +105,24 @@ export class RecommendationController {
|
|||
return id;
|
||||
}
|
||||
|
||||
#getFramePage(frame: Frame): number {
|
||||
const page = validateInteger(frame.options, "page", {required: false, nullable: true}) ?? 1;
|
||||
if (page < 1) {
|
||||
throw new errors.BadRequestError({message: "page must be greater or equal to 1"});
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
#getFrameLimit(frame: Frame, defaultLimit = 15): number {
|
||||
const limit = validateInteger(frame.options, "limit", {required: false, nullable: true}) ?? defaultLimit;
|
||||
if (limit < 1) {
|
||||
throw new errors.BadRequestError({message: "limit must be greater or equal to 1"});
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
|
||||
#getFrameRecommendation(frame: Frame): AddRecommendation {
|
||||
if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) {
|
||||
throw new errors.BadRequestError();
|
||||
|
@ -121,7 +167,7 @@ export class RecommendationController {
|
|||
}
|
||||
|
||||
|
||||
#returnRecommendations(...recommendations: Recommendation[]) {
|
||||
#returnRecommendations(recommendations: Recommendation[], meta?: any) {
|
||||
return {
|
||||
data: recommendations.map(r => {
|
||||
return {
|
||||
|
@ -136,14 +182,28 @@ export class RecommendationController {
|
|||
created_at: r.createdAt,
|
||||
updated_at: r.updatedAt
|
||||
};
|
||||
})
|
||||
}),
|
||||
meta
|
||||
}
|
||||
}
|
||||
|
||||
#buildPagination({page, limit, count}: {page: number, limit: number, count: number}) {
|
||||
const pages = Math.ceil(count / limit);
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total: count,
|
||||
pages,
|
||||
prev: page > 1 ? page - 1 : null,
|
||||
next: page < pages ? page + 1 : null
|
||||
}
|
||||
}
|
||||
|
||||
async addRecommendation(frame: Frame) {
|
||||
const recommendation = this.#getFrameRecommendation(frame);
|
||||
return this.#returnRecommendations(
|
||||
await this.service.addRecommendation(recommendation)
|
||||
[await this.service.addRecommendation(recommendation)]
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -152,7 +212,7 @@ export class RecommendationController {
|
|||
const recommendationEdit = this.#getFrameRecommendationEdit(frame);
|
||||
|
||||
return this.#returnRecommendations(
|
||||
await this.service.editRecommendation(id, recommendationEdit)
|
||||
[await this.service.editRecommendation(id, recommendationEdit)]
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -161,9 +221,24 @@ export class RecommendationController {
|
|||
await this.service.deleteRecommendation(id);
|
||||
}
|
||||
|
||||
async listRecommendations() {
|
||||
async listRecommendations(frame: Frame) {
|
||||
const page = this.#getFramePage(frame);
|
||||
const limit = this.#getFrameLimit(frame, 5);
|
||||
const order = [
|
||||
{
|
||||
field: "createdAt" as const,
|
||||
direction: "desc" as const
|
||||
}
|
||||
];
|
||||
|
||||
const count = await this.service.countRecommendations({});
|
||||
const data = (await this.service.listRecommendations({page, limit, order}));
|
||||
|
||||
return this.#returnRecommendations(
|
||||
...(await this.service.listRecommendations())
|
||||
data,
|
||||
{
|
||||
pagination: this.#buildPagination({page, limit, count})
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
import {OrderOption} from "@tryghost/bookshelf-repository";
|
||||
import {Recommendation} from "./Recommendation";
|
||||
|
||||
export interface RecommendationRepository {
|
||||
save(entity: Recommendation): Promise<void>;
|
||||
getById(id: string): Promise<Recommendation | null>;
|
||||
getAll(): Promise<Recommendation[]>;
|
||||
getAll({filter, order}?: {filter?: string, order?: OrderOption<Recommendation>}): Promise<Recommendation[]>;
|
||||
getPage({ filter, order, page, limit }: {
|
||||
filter?: string;
|
||||
order?: OrderOption<Recommendation>;
|
||||
page: number;
|
||||
limit: number;
|
||||
}): Promise<Recommendation[]>;
|
||||
getCount({ filter }?: {
|
||||
filter?: string;
|
||||
}): Promise<number>;
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import {OrderOption} from "@tryghost/bookshelf-repository";
|
||||
import {AddRecommendation, Recommendation} from "./Recommendation";
|
||||
import {RecommendationRepository} from "./RecommendationRepository";
|
||||
import {WellknownService} from "./WellknownService";
|
||||
|
@ -115,7 +116,14 @@ export class RecommendationService {
|
|||
this.sendMentionToRecommendation(existing);
|
||||
}
|
||||
|
||||
async listRecommendations() {
|
||||
return await this.repository.getAll()
|
||||
async listRecommendations({page, limit, filter, order}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption<Recommendation> } = {page: 1, limit: 'all'}) {
|
||||
if (limit === 'all') {
|
||||
return await this.repository.getAll({filter, order})
|
||||
}
|
||||
return await this.repository.getPage({page, limit, filter, order})
|
||||
}
|
||||
|
||||
async countRecommendations({filter}: { filter?: string }) {
|
||||
return await this.repository.getCount({filter})
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue