0
Fork 0
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:
Simon Backx 2023-09-08 12:32:06 +02:00 committed by GitHub
parent f663774cf9
commit 669be72673
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1157 additions and 112 deletions

View file

@ -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'
}
}
};

View file

@ -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>
);
};

View file

@ -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: () => {}
}
};

View file

@ -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;

View file

@ -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;

View file

@ -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/'
});

View file

@ -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
}
});

View file

@ -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 (

View file

@ -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 {

View file

@ -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;

View file

@ -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'>

View 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))
};
};

View file

@ -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']>
}

View file

@ -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(),

View file

@ -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>

View file

@ -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

View file

@ -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();
}
}

View file

@ -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);
});
});

View file

@ -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);
}
}
};

View file

@ -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);
}
},

View file

@ -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) {

View file

@ -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:

View file

@ -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",
}
`;

View file

@ -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);
});
});

View file

@ -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",
}
`;

View 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
})
});
});
});

View file

@ -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];
}
}

View file

@ -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})
}
);
}
}

View file

@ -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>;
};

View file

@ -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})
}
}