0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-17 23:44:39 -05:00

Added Recommendations browse API to admin-x-settings (#17870)

refs https://github.com/TryGhost/Product/issues/3786
This commit is contained in:
Simon Backx 2023-08-30 12:25:31 +02:00 committed by GitHub
parent b54191dbe4
commit 78ae776c5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 127 additions and 10 deletions

View file

@ -0,0 +1,27 @@
import {Meta, createQuery} from '../utils/apiRequests';
export type Recommendation = {
id: string
title: string
reason: string|null
excerpt: string|null // Fetched from the site meta data
featured_image: string|null // Fetched from the site meta data
favicon: string|null // Fetched from the site meta data
url: string
one_click_subscribe: boolean
created_at: string,
updated_at: string|null
}
export interface RecommendationResponseType {
meta?: Meta
recommendations: Recommendation[]
}
const dataType = 'RecommendationResponseType';
export const useBrowseRecommendations = createQuery<RecommendationResponseType>({
dataType,
path: '/recommendations/',
defaultSearchParams: {}
});

View file

@ -1,15 +1,39 @@
import React from 'react'; import Button from '../../../admin-x-ds/global/Button';
import React, {useState} from 'react';
import RecommendationList from './recommendations/RecommendationList';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import TabView from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup'; import useSettingGroup from '../../../hooks/useSettingGroup';
import {useBrowseRecommendations} from '../../../api/recommendations';
const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => { const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
const { const {
saveState, saveState,
handleSave handleSave
} = useSettingGroup(); } = useSettingGroup();
const {data: {recommendations} = {}} = useBrowseRecommendations();
const [selectedTab, setSelectedTab] = useState('your-recommendations');
const buttons = (
<Button color='green' label='Add recommendation' link={true} onClick={() => {}} />
);
const tabs = [
{
id: 'your-recommendations',
title: 'Your recommendations',
contents: (<RecommendationList recommendations={recommendations ?? []} />)
},
{
id: 'recommending-you',
title: 'Recommending you',
contents: (<RecommendationList recommendations={[]} />)
}
];
return ( return (
<SettingGroup <SettingGroup
customButtons={buttons}
description="Recommend sites to your audience, and get recommended by others." description="Recommend sites to your audience, and get recommended by others."
keywords={keywords} keywords={keywords}
navid='recommendations' navid='recommendations'
@ -18,6 +42,7 @@ const Recommendations: React.FC<{ keywords: string[] }> = ({keywords}) => {
title="Recommendations" title="Recommendations"
onSave={handleSave} onSave={handleSave}
> >
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
</SettingGroup> </SettingGroup>
); );
}; };

View file

@ -21,7 +21,7 @@ const SiteSettings: React.FC = () => {
{/* <Theme keywords={searchKeywords.theme} /> */} {/* <Theme keywords={searchKeywords.theme} /> */}
<DesignSetting keywords={searchKeywords.design} /> <DesignSetting keywords={searchKeywords.design} />
<Navigation keywords={searchKeywords.navigation} /> <Navigation keywords={searchKeywords.navigation} />
{hasRecommendations && <Recommendations keywords={searchKeywords.navigation} />} {hasRecommendations && <Recommendations keywords={searchKeywords.recommendations} />}
</SettingSection> </SettingSection>
</> </>
); );

View file

@ -0,0 +1,42 @@
import Button from '../../../../admin-x-ds/global/Button';
import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
import React from 'react';
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 {Recommendation} from '../../../../api/recommendations';
interface RecommendationListProps {
recommendations: Recommendation[]
}
const RecommendationItem: React.FC<{recommendation: Recommendation}> = ({recommendation}) => {
const action = <Button color='green' label='Delete' link onClick={() => {}} />;
const showDetails = () => {};
return (
<TableRow action={action} hideActions>
<TableCell onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span className='font-medium'>{recommendation.title}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>{recommendation.reason || 'No description'}</span>
</div>
</TableCell>
</TableRow>
);
};
const RecommendationList: React.FC<RecommendationListProps> = ({recommendations}) => {
if (recommendations.length) {
return <Table>
{recommendations.map(recommendation => <RecommendationItem key={recommendation.id} recommendation={recommendation} />)}
</Table>;
} else {
return <NoValueLabel icon='mail-block'>
No recommendations found.
</NoValueLabel>;
}
};
export default RecommendationList;

View file

@ -12,6 +12,7 @@ Object {
"one_click_subscribe": true, "one_click_subscribe": true,
"reason": "Because dogs are cute", "reason": "Because dogs are cute",
"title": "Dog Pictures", "title": "Dog Pictures",
"updated_at": null,
"url": "https://dogpictures.com", "url": "https://dogpictures.com",
}, },
], ],
@ -22,7 +23,7 @@ exports[`Recommendations Admin API Can add a full recommendation 2: [headers] 1`
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "335", "content-length": "353",
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -45,6 +46,7 @@ Object {
"one_click_subscribe": false, "one_click_subscribe": false,
"reason": null, "reason": null,
"title": "Dog Pictures", "title": "Dog Pictures",
"updated_at": null,
"url": "https://dogpictures.com", "url": "https://dogpictures.com",
}, },
], ],
@ -55,7 +57,7 @@ exports[`Recommendations Admin API Can add a minimal recommendation 2: [headers]
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "244", "content-length": "262",
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -110,6 +112,7 @@ Object {
"one_click_subscribe": true, "one_click_subscribe": true,
"reason": "Because dogs are cute", "reason": "Because dogs are cute",
"title": "Dog Pictures", "title": "Dog Pictures",
"updated_at": null,
"url": "https://dogpictures.com", "url": "https://dogpictures.com",
}, },
], ],
@ -120,7 +123,7 @@ exports[`Recommendations Admin API Can browse 2: [headers] 1`] = `
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "335", "content-length": "353",
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -155,6 +158,7 @@ Object {
"one_click_subscribe": false, "one_click_subscribe": false,
"reason": "Because cats are cute", "reason": "Because cats are cute",
"title": "Cat Pictures", "title": "Cat Pictures",
"updated_at": null,
"url": "https://catpictures.com", "url": "https://catpictures.com",
}, },
], ],
@ -165,7 +169,7 @@ exports[`Recommendations Admin API Can edit recommendation 2: [headers] 1`] = `
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "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", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "336", "content-length": "354",
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View file

@ -10,6 +10,11 @@ describe('Recommendations Admin API', function () {
agent = await agentProvider.getAdminAPIAgent(); agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts'); await fixtureManager.init('posts');
await agent.loginAsOwner(); await agent.loginAsOwner();
// Clear placeholders
for (const recommendation of (await recommendationsService.repository.getAll())) {
await recommendationsService.repository.remove(recommendation.id);
}
}); });
afterEach(function () { afterEach(function () {

View file

@ -2,7 +2,17 @@ import {Recommendation} from "./Recommendation";
import {RecommendationRepository} from "./RecommendationRepository"; import {RecommendationRepository} from "./RecommendationRepository";
export class InMemoryRecommendationRepository implements RecommendationRepository { export class InMemoryRecommendationRepository implements RecommendationRepository {
recommendations: Recommendation[] = []; recommendations: Recommendation[] = [
new Recommendation({
title: "The Pragmatic Programmer",
reason: "This is a great book for any developer, regardless of their experience level. It's a classic that's stood the test of time.",
excerpt: "The Pragmatic Programmer is one of those rare tech books youll read, re-read, and read again over the years. Whether youre new to the field or an experienced practitioner, youll come away with fresh insights each and every time.",
featuredImage: "https://www.thepragmaticprogrammer.com/image.png",
favicon: "https://www.thepragmaticprogrammer.com/favicon.ico",
url: "https://www.thepragmaticprogrammer.com/",
oneClickSubscribe: false
})
];
async add(recommendation: Recommendation): Promise<Recommendation> { async add(recommendation: Recommendation): Promise<Recommendation> {
this.recommendations.push(recommendation); this.recommendations.push(recommendation);

View file

@ -10,8 +10,9 @@ export class Recommendation {
url: string url: string
oneClickSubscribe: boolean oneClickSubscribe: boolean
createdAt: Date createdAt: Date
updatedAt: Date|null
constructor(data: {id?: string, title: string, reason: string|null, excerpt: string|null, featuredImage: string|null, favicon: string|null, url: string, oneClickSubscribe: boolean, createdAt?: Date}) { constructor(data: {id?: string, title: string, reason: string|null, excerpt: string|null, featuredImage: string|null, favicon: string|null, url: string, oneClickSubscribe: boolean, createdAt?: Date, updatedAt?: Date|null}) {
this.id = data.id ?? ObjectId().toString(); this.id = data.id ?? ObjectId().toString();
this.title = data.title; this.title = data.title;
this.reason = data.reason; this.reason = data.reason;
@ -22,5 +23,7 @@ export class Recommendation {
this.oneClickSubscribe = data.oneClickSubscribe; this.oneClickSubscribe = data.oneClickSubscribe;
this.createdAt = data.createdAt ?? new Date(); this.createdAt = data.createdAt ?? new Date();
this.createdAt.setMilliseconds(0); this.createdAt.setMilliseconds(0);
this.updatedAt = data.updatedAt ?? null;
this.updatedAt?.setMilliseconds(0);
} }
} }

View file

@ -65,7 +65,7 @@ export class RecommendationController {
const recommendation = frame.data.recommendations[0]; const recommendation = frame.data.recommendations[0];
const cleanedRecommendation: Omit<Recommendation, 'id'|'createdAt'> = { const cleanedRecommendation: Omit<Recommendation, 'id'|'createdAt'|'updatedAt'> = {
title: validateString(recommendation, "title") ?? '', title: validateString(recommendation, "title") ?? '',
url: validateString(recommendation, "url") ?? '', url: validateString(recommendation, "url") ?? '',
@ -115,6 +115,7 @@ export class RecommendationController {
url: r.url, url: r.url,
one_click_subscribe: r.oneClickSubscribe, one_click_subscribe: r.oneClickSubscribe,
created_at: r.createdAt, created_at: r.createdAt,
updated_at: r.updatedAt
}; };
}) })
} }