feat: builds plugin

This commit is contained in:
Jacob Fletcher
2022-02-19 11:02:55 -05:00
commit 1b10111ba9
22 changed files with 21412 additions and 0 deletions

BIN
packages/plugin-search/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = null

3
packages/plugin-search/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.env
dist

View File

@@ -0,0 +1,73 @@
# Payload Search Plugin
[![NPM](https://img.shields.io/npm/v/payload-plugin-search)](https://www.npmjs.com/package/payload-plugin-search)
A plugin for [Payload CMS](https://github.com/payloadcms/payload) to create extremely fast search results from your existing documents.
Core features:
- Creates a `search` collection that:
- Automatically creates, syncs, and deletes search results related to your documents
- Serves search results extremely quickly by saving only search-critical data that you define
- Allows you to sort search results by priority
## Installation
```bash
yarn add payload-plugin-search
# OR
npm i payload-plugin-search
```
## Basic Usage
In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
```js
import { buildConfig } from 'payload/config';
import search from 'payload-search';
const config = buildConfig({
collections: [
{
slug: 'pages',
fields: []
}
],
plugins: [
search({
collections: ['pages'],
defaultPriorities: {
pages: 10
}
})
]
});
export default config;
```
### Options
- `collections`
An array of collections slugs to enable sync-to-search. Enabled collections receive a `beforeChange` and `afterDelete` hook that creates, syncs, and deleted the document to its related search document as it changes over time, and also an `afterDelete` hook that deletes.
- `searchOverrides`
Override anything on the search collection by sending a [Payload Collection Config](https://payloadcms.com/docs/configuration/collections).
```
searchOverrides: {
slug: 'search-results'
}
```
## TypeScript
All types can be directly imported:
```js
import {
SearchConfig,
} from 'payload-plugin-search/dist/types';
```
## Screenshots
<!-- ![screenshot 1](https://github.com/trouble/payload-plugin-search/blob/main/images/screenshot-1.jpg?raw=true) -->

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -0,0 +1,27 @@
{
"name": "payload-starter-typescript",
"description": "Blank template - no collections",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^0.14.24-beta.0"
},
"devDependencies": {
"@types/express": "^4.17.9",
"cross-env": "^7.0.3",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
}
}

View File

@@ -0,0 +1,21 @@
// const payload = require('payload');
import { CollectionConfig } from 'payload/types';
export const Pages: CollectionConfig = {
slug: 'pages',
labels: {
singular: 'Page',
plural: 'Pages',
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
}
],
};

View File

@@ -0,0 +1,16 @@
import { CollectionConfig } from 'payload/types';
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: () => true,
},
fields: [
// Email added by default
// Add more fields as needed
],
};

View File

@@ -0,0 +1,43 @@
import { buildConfig } from 'payload/config';
import path from 'path';
import searchPlugin from '../../dist';
// import searchPlugin from '../../src';
import { Users } from './collections/Users';
import { Pages } from './collections/Pages';
export default buildConfig({
serverURL: 'http://localhost:3000',
admin: {
user: Users.slug,
webpack: (config) => {
const newConfig = {
...config,
resolve: {
...config.resolve,
alias: {
...config.resolve.alias,
react: path.join(__dirname, "../node_modules/react"),
"react-dom": path.join(__dirname, "../node_modules/react-dom"),
"payload": path.join(__dirname, "../node_modules/payload"),
},
},
};
return newConfig;
},
},
collections: [
Users,
Pages
],
plugins: [
searchPlugin({
collections: [
'pages'
],
}),
],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts')
},
});

View File

@@ -0,0 +1,24 @@
import express from 'express';
import payload from 'payload';
require('dotenv').config();
const app = express();
// Redirect root to Admin panel
app.get('/', (_, res) => {
res.redirect('/admin');
});
// Initialize Payload
payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
},
});
// Add your own express routes here
app.listen(3000);

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "../",
"jsx": "react",
},
"ts-node": {
"transpileOnly": true
}
}

File diff suppressed because it is too large Load Diff

BIN
packages/plugin-search/images/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,37 @@
{
"name": "payload-plugin-search",
"version": "1.0.0",
"description": "Search plugin for Payload CMS",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"payload",
"cms",
"plugin",
"typescript",
"react",
"search",
"algolia"
],
"author": "dev@trbl.design",
"license": "MIT",
"peerDependencies": {
"payload": "^0.14.24-beta.0",
"react": "^17.0.2"
},
"devDependencies": {
"payload": "^0.14.24-beta.0",
"react": "^17.0.2",
"typescript": "^4.5.5"
},
"files": [
"dist"
],
"dependencies": {
"escape-html": "^1.0.3"
}
}

View File

@@ -0,0 +1,32 @@
import { CollectionAfterDeleteHook } from 'payload/types';
const deleteFromSearch: CollectionAfterDeleteHook = ({ req: { payload }, doc }) => {
try {
const deleteSearchDoc = async () => {
const searchDocQuery = await payload.find({
collection: 'search',
where: {
'doc.value': {
equals: doc.id,
},
},
depth: 0,
}) as any;
if (searchDocQuery?.docs?.[0]) {
payload.delete({
collection: 'search',
id: searchDocQuery?.docs?.[0]?.id,
});
}
};
deleteSearchDoc();
} catch (err) {
console.error(err);
}
return doc;
};
export default deleteFromSearch;

View File

@@ -0,0 +1,129 @@
import { CollectionAfterChangeHook } from 'payload/types';
const syncWithSearch: CollectionAfterChangeHook = async ({
req: { payload },
doc,
operation,
// @ts-ignore
collection,
}) => {
const {
id,
status,
} = doc || {};
// TODO: inject default priorities here
let defaultPriority = 0;
// TODO: call a function from the config that returns transformed search data
const dataToSave = {
doc: {
relationTo: collection,
value: id,
},
};
try {
if (operation === 'create') {
if (status === 'published') {
payload.create({
collection: 'search',
data: {
...dataToSave,
priority: defaultPriority,
},
});
}
}
if (operation === 'update') {
try {
// find the correct doc to sync with
const searchDocQuery = await payload.find({
collection: 'search',
where: {
'doc.value': {
equals: id,
},
},
depth: 0,
}) as any;
const docs: {
id: string
priority?: number
}[] = searchDocQuery?.docs || [];
const [foundDoc, ...duplicativeDocs] = docs;
// delete all duplicative search docs (docs that reference the same page)
// to ensure the same, out-of-date result does not appear twice (where only syncing the first found doc)
if (duplicativeDocs.length > 0) {
try {
Promise.all(duplicativeDocs.map(({ id: duplicativeDocID }) => payload.delete({
collection: 'search',
id: duplicativeDocID,
})));
} catch (err) {
payload.logger.error(`Error deleting duplicative search documents.`);
}
}
if (foundDoc) {
const {
id: searchDocID,
} = foundDoc;
if (status === 'published') {
// update the doc normally
try {
payload.update({
collection: 'search',
id: searchDocID,
data: {
...dataToSave,
priority: foundDoc.priority || defaultPriority,
},
});
} catch (err) {
payload.logger.error(`Error updating search document.`);
}
}
if (status === 'draft') {
// do not include draft docs in search results, so delete the record
try {
payload.delete({
collection: 'search',
id: searchDocID,
});
} catch (err) {
payload.logger.error(`Error deleting search document.`);
}
}
} else if (status === 'published') {
try {
payload.create({
collection: 'search',
data: {
...dataToSave,
priority: defaultPriority,
},
});
} catch (err) {
payload.logger.error(err);
payload.logger.error(`Error creating search document.`);
}
}
} catch (err) {
payload.logger.error(`Error finding search document.`);
}
}
} catch (err) {
payload.logger.error(err);
payload.logger.error(`Error syncing search document related to ${collection} with id: '${id}'`);
}
return doc;
};
export default syncWithSearch;

View File

@@ -0,0 +1,55 @@
import { CollectionConfig } from 'payload/types';
import { SearchConfig } from '../types';
import deepMerge from '../utilities/deepMerge';
// all settings can be overridden by the config
export const generateSearchCollection = (searchConfig: SearchConfig): CollectionConfig => deepMerge({
slug: searchConfig?.searchOverrides?.slug || 'search',
labels: {
singular: 'Search Result',
plural: 'Search Results',
},
admin: {
useAsTitle: 'title',
defaultColumns: [
'title',
],
description: 'This is a collection of automatically created search results. These results are used by the global site search and will be updated automatically as documents in the CMS are created or updated.',
enableRichTextRelationship: false,
},
access: {
read: (): boolean => true,
create: (): boolean => false,
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'priority',
type: 'number',
admin: {
position: 'sidebar'
}
},
{
name: 'doc',
type: 'relationship',
relationTo: searchConfig.collections || [],
required: true,
index: true,
maxDepth: 0,
admin: {
readOnly: true,
},
},
{
name: 'priority',
type: 'number',
admin: {
position: 'sidebar'
}
},
],
}, searchConfig.searchOverrides || {});

View File

@@ -0,0 +1,55 @@
import { Config } from 'payload/config';
import { generateSearchCollection } from './Search';
import syncWithSearch from './Search/hooks/syncWithSearch';
import deleteFromSearch from './Search/hooks/deleteFromSearch';
import { SearchConfig } from './types';
// import path from 'path';
const Search = (incomingSearchConfig: SearchConfig) => (config: Config): Config => {
const searchConfig: SearchConfig = {
...incomingSearchConfig,
// modify as necessary
};
// add a beforeChange hook to every search-enabled collection
const collectionsWithSearchHooks = config?.collections?.map((collection) => {
const {
hooks: existingHooks
} = collection;
const enabledCollections = searchConfig.collections || [];
const isEnabled = enabledCollections.indexOf(collection.slug) > -1;
if (isEnabled) {
return {
...collection,
hooks: {
...collection.hooks,
beforeChange: [
...(existingHooks?.beforeChange || []),
async (args: any) => syncWithSearch({
...args,
collection: 'pages'
}),
],
afterDelete: [
...(existingHooks?.afterDelete || []),
deleteFromSearch,
],
},
};
}
return collection;
}).filter(Boolean);
return {
...config,
collections: [
...collectionsWithSearchHooks || [],
generateSearchCollection(searchConfig),
],
};
};
export default Search;

View File

@@ -0,0 +1,9 @@
import { CollectionConfig } from 'payload/types';
export type SearchConfig = {
searchOverrides?: Partial<CollectionConfig>
collections?: string[]
defaultPriorities?: {
[collection: string]: number
}
}

View File

@@ -0,0 +1,26 @@
export function isObject(item: unknown): boolean {
return Boolean(item && typeof item === 'object' && !Array.isArray(item));
}
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
// @ts-ignore
if (isObject(source[key])) {
if (!(key in target)) {
// @ts-ignore
Object.assign(output, { [key]: source[key] });
} else {
// @ts-ignore
output[key] = deepMerge(target[key], source[key]);
}
} else {
// @ts-ignore
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es5",
"outDir": "./dist",
"allowJs": true,
"module": "commonjs",
"sourceMap": true,
"jsx": "react",
"esModuleInterop": true,
"declaration": true,
"declarationDir": "./dist",
"skipLibCheck": true,
"strict": true,
},
"include": [
"src/**/*"
],
}

File diff suppressed because it is too large Load Diff