diff --git a/CHANGELOG.md b/CHANGELOG.md index 2df514b4..95bd4b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### ✨ New features - [#424](https://github.com/estruyf/vscode-front-matter/issues/424): Snippet wrapping to allow easier updates or changes to previously set snippets in the content +- [#585](https://github.com/estruyf/vscode-front-matter/issues/585): New content relationship field type (`contentRelationship`) ### 🎨 Enhancements diff --git a/package.json b/package.json index 537465e2..4624be65 100644 --- a/package.json +++ b/package.json @@ -1019,7 +1019,8 @@ "slug", "divider", "heading", - "customField" + "customField", + "contentRelationship" ], "description": "Define the type of field" }, @@ -1214,6 +1215,17 @@ "default": false, "description": "Specify if the field is required" }, + "contentTypeName": { + "type": "string", + "default": "", + "description": "Specify the content type name to filter content for the contentRelationship field" + }, + "contentTypeValue": { + "type": "string", + "enum": ["path", "slug"], + "default": "path", + "description": "Specify the value to insert for the contentRelationship field" + }, "when": { "type": "object", "description": "Specify the conditions to show the field", @@ -1321,6 +1333,20 @@ ] } }, + { + "if": { + "properties": { + "type": { + "const": "contentRelationship" + } + } + }, + "then": { + "required": [ + "contentTypeName" + ] + } + }, { "if": { "properties": { diff --git a/src/dashboardWebView/models/Page.ts b/src/dashboardWebView/models/Page.ts index f2f9c650..b719aa98 100644 --- a/src/dashboardWebView/models/Page.ts +++ b/src/dashboardWebView/models/Page.ts @@ -6,6 +6,7 @@ export interface Page { // Front matter fields fmFolder: string; fmFilePath: string; + fmRelFilePath: string; fmFileName: string; fmModified: number; fmPublished: number | null | undefined; diff --git a/src/explorerView/ExplorerView.ts b/src/explorerView/ExplorerView.ts index 76366f31..0d28140f 100644 --- a/src/explorerView/ExplorerView.ts +++ b/src/explorerView/ExplorerView.ts @@ -5,7 +5,8 @@ import { ScriptListener, TaxonomyListener, DataListener, - SettingsListener + SettingsListener, + FieldsListener } from './../listeners/panel'; import { SETTING_EXPERIMENTAL, SETTING_EXTENSIBILITY_SCRIPTS, TelemetryEvent } from '../constants'; import { @@ -97,6 +98,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable { webviewView.webview.onDidReceiveMessage(async (msg) => { Logger.info(`Receiving message from webview to panel: ${msg.command}`); + FieldsListener.process(msg); ArticleListener.process(msg); DataListener.process(msg); ExtensionListener.process(msg); diff --git a/src/listeners/dashboard/PagesListener.ts b/src/listeners/dashboard/PagesListener.ts index 3f123085..c26aafde 100644 --- a/src/listeners/dashboard/PagesListener.ts +++ b/src/listeners/dashboard/PagesListener.ts @@ -153,7 +153,7 @@ export class PagesListener extends BaseListener { /** * Retrieve all the markdown pages */ - private static async getPagesData(clear: boolean = false) { + public static async getPagesData(clear: boolean = false, cb?: (pages: Page[]) => void) { const ext = Extension.getInstance(); // Get data from the cache @@ -164,6 +164,10 @@ export class PagesListener extends BaseListener { ); if (cachedPages) { this.sendPageData(cachedPages); + + if (cb) { + cb(cachedPages); + } } } else { PagesParser.reset(); @@ -177,6 +181,10 @@ export class PagesListener extends BaseListener { await this.createSearchIndex(pages); this.sendMsg(DashboardCommand.loading, false); + + if (cb) { + cb(pages); + } }); } @@ -199,7 +207,7 @@ export class PagesListener extends BaseListener { * @param pages */ private static async createSearchIndex(pages: Page[]) { - const pagesIndex = Fuse.createIndex(['title', 'slug', 'description', 'fmBody'], pages); + const pagesIndex = Fuse.createIndex(['title', 'slug', 'description', 'fmBody', 'type'], pages); await Extension.getInstance().setState( ExtensionState.Dashboard.Pages.Index, pagesIndex, diff --git a/src/listeners/panel/FieldsListener.ts b/src/listeners/panel/FieldsListener.ts new file mode 100644 index 00000000..4bd3fbd6 --- /dev/null +++ b/src/listeners/panel/FieldsListener.ts @@ -0,0 +1,61 @@ +import { ExtensionState } from '../../constants'; +import { Page } from '../../dashboardWebView/models'; +import { Extension } from '../../helpers'; +import { PostMessageData } from '../../models'; +import { CommandToCode } from '../../panelWebView/CommandToCode'; +import { PagesListener } from '../dashboard/PagesListener'; +import { BaseListener } from './BaseListener'; +import Fuse from 'fuse.js'; + +export class FieldsListener extends BaseListener { + /** + * Process the messages for the dashboard views + * @param msg + */ + public static process(msg: PostMessageData) { + super.process(msg); + + switch (msg.command) { + case CommandToCode.searchByType: + this.searchByType(msg.command, msg.requestId, msg.payload); + break; + } + } + + /** + * Search by type + * @param command + * @param requestId + * @param payload + * @returns + */ + private static async searchByType(command: string, requestId?: string, type?: string) { + if (!type || !requestId) { + return; + } + + PagesListener.getPagesData(false, async (pages) => { + const fuseOptions: Fuse.IFuseOptions = { + keys: [{ name: 'type', weight: 1 }] + }; + + const pagesIndex = await Extension.getInstance().getState>( + ExtensionState.Dashboard.Pages.Index, + 'workspace' + ); + const fuse = new Fuse(pages || [], fuseOptions, Fuse.parseIndex(pagesIndex)); + const results = fuse.search({ + $and: [ + { + type + } + ] + }); + const pageResults = results.map((page) => page.item); + + console.log('pageResults', pageResults); + + this.sendRequest(command, requestId, pageResults || []); + }); + } +} diff --git a/src/listeners/panel/index.ts b/src/listeners/panel/index.ts index d7c44fcc..f8e80e22 100644 --- a/src/listeners/panel/index.ts +++ b/src/listeners/panel/index.ts @@ -2,6 +2,7 @@ export * from './ArticleListener'; export * from './BaseListener'; export * from './DataListener'; export * from './ExtensionListener'; +export * from './FieldsListener'; export * from './MediaListener'; export * from './ScriptListener'; export * from './SettingsListener'; diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 9259394c..f292835b 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -73,7 +73,8 @@ export type FieldType = | 'list' | 'slug' | 'divider' - | 'heading'; + | 'heading' + | 'contentRelationship'; export interface Field { title?: string; @@ -109,6 +110,10 @@ export interface Field { // Number field options numberOptions?: NumberOptions; + // Content relationship + contentTypeName?: string; + contentTypeValue?: 'path' | 'slug'; + // When clause when?: WhenClause; } diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 2d5ae33b..b4e5a52a 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -40,5 +40,6 @@ export enum CommandToCode { getDataEntries = 'get-data-entries', generateSlug = 'generate-slug', stopServer = 'stop-server', - aiSuggestTaxonomy = 'ai-suggest-taxonomy' + aiSuggestTaxonomy = 'ai-suggest-taxonomy', + searchByType = 'search-by-type' } diff --git a/src/panelWebView/components/Fields/ContentTypeRelationshipField.tsx b/src/panelWebView/components/Fields/ContentTypeRelationshipField.tsx new file mode 100644 index 00000000..8b1ce1ec --- /dev/null +++ b/src/panelWebView/components/Fields/ContentTypeRelationshipField.tsx @@ -0,0 +1,207 @@ +import { ChevronDownIcon, DocumentAddIcon } from '@heroicons/react/outline'; +import Downshift from 'downshift'; +import * as React from 'react'; +import { useEffect, useMemo } from 'react'; +import { BaseFieldProps } from '../../../models'; +import { ChoiceButton } from './ChoiceButton'; +import { FieldTitle } from './FieldTitle'; +import { FieldMessage } from './FieldMessage'; +import { messageHandler } from '@estruyf/vscode/dist/client'; +import { CommandToCode } from '../../CommandToCode'; +import { Page } from '../../../dashboardWebView/models'; + +export interface IContentTypeRelationshipFieldProps extends BaseFieldProps { + contentTypeName?: string; + contentTypeValue?: string; + multiSelect?: boolean; + onChange: (value: string | string[]) => void; +} + +export const ContentTypeRelationshipField: React.FunctionComponent = ({ + label, + description, + value, + contentTypeName, + contentTypeValue, + multiSelect, + onChange, + required +}: React.PropsWithChildren) => { + const [loading, setLoading] = React.useState(false); + const [choices, setChoices] = React.useState([]); + const [pages, setPages] = React.useState([]); + const [crntSelected, setCrntSelected] = React.useState(value); + const dsRef = React.useRef | null>(null); + + const onValueChange = (txtValue: string) => { + if (multiSelect) { + const newValue = [...((crntSelected || []) as string[]), txtValue]; + setCrntSelected(newValue); + onChange(newValue); + } else { + setCrntSelected(txtValue); + onChange(txtValue); + } + }; + + const removeSelected = (txtValue: string) => { + if (multiSelect) { + const newValue = [...(crntSelected || [])].filter((v) => v !== txtValue); + setCrntSelected(newValue); + onChange(newValue); + } else { + setCrntSelected(''); + onChange(''); + } + }; + + const getValue = (value: Page, type: string = "path") => { + if (type === 'path') { + return value.fmRelFilePath || value.fmFilePath; + } + + return `${value[type]}`; + }; + + const getChoiceValue = React.useCallback((value: string) => { + const choice = pages.find( + (p: Page) => getValue(p, contentTypeValue) === value + ); + + if (choice) { + return choice.title; + } + return ''; + }, [choices, contentTypeValue]); + + const availableChoices = useMemo(() => { + return !multiSelect + ? pages + : pages.filter((page: Page) => { + const value = page.fmFilePath; + + if (typeof crntSelected === 'string') { + return crntSelected !== `${value}`; + } else if (crntSelected instanceof Array) { + return crntSelected.indexOf(`${value}`) === -1; + } + + return true; + }); + }, [choices, crntSelected, multiSelect]); + + const showRequiredState = useMemo(() => { + return ( + required && ((crntSelected instanceof Array && crntSelected.length === 0) || !crntSelected) + ); + }, [required, crntSelected]); + + useEffect(() => { + if (crntSelected !== value) { + setCrntSelected(value); + } + }, [value]); + + useEffect(() => { + if (contentTypeName) { + setLoading(true); + messageHandler + .request(CommandToCode.searchByType, contentTypeName) + .then((pages: Page[]) => { + setPages(pages || []); + setChoices((pages || []).map(page => page.title)) + }).finally(() => { + setLoading(false); + }); + } + }, [contentTypeName]); + + return ( +
+ } + required={required} /> + + { + loading ? ( +
+
+ Fetching possible values... +
+
+ ) : ( + <> + onValueChange(selected || '')} + itemToString={(item) => (item ? item : '')} + > + {({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => ( +
+ + +
    + {isOpen + ? availableChoices.map((choice: Page, index) => ( +
  • + {choice.title || ( + Clear value + )} +
  • + )) + : null} +
+
+ )} +
+ + + + {crntSelected instanceof Array + ? crntSelected.map((value: string) => ( + + )) + : crntSelected && ( + + )} + + ) + } +
+ ); +}; diff --git a/src/panelWebView/components/Fields/WrapperField.tsx b/src/panelWebView/components/Fields/WrapperField.tsx index 413e0006..a391e965 100644 --- a/src/panelWebView/components/Fields/WrapperField.tsx +++ b/src/panelWebView/components/Fields/WrapperField.tsx @@ -30,6 +30,7 @@ import { CustomField } from '.'; import { fieldWhenClause } from '../../../utils/fieldWhenClause'; +import { ContentTypeRelationshipField } from './ContentTypeRelationshipField'; export interface IWrapperFieldProps { field: Field; @@ -474,6 +475,23 @@ export const WrapperField: React.FunctionComponent = ({ /> ); + } else if (field.type === 'contentRelationship') { + const pages: string[] = []; + + return ( + + onSendUpdate(field.name, value, parentFields)} + /> + + ); } else if (field.type === 'slug') { return ( diff --git a/src/panelWebView/styles.css b/src/panelWebView/styles.css index 09d6d1f2..ed21d4c2 100644 --- a/src/panelWebView/styles.css +++ b/src/panelWebView/styles.css @@ -326,6 +326,15 @@ button { } } +.metadata_field__wrapper { + position: relative; + height: 50px; + + .metadata_field__loading { + top: 0; + } +} + .metadata_field__loading { border-radius: 0.25rem; backdrop-filter: blur(15px); diff --git a/src/services/PagesParser.ts b/src/services/PagesParser.ts index 383e5709..059b84c6 100644 --- a/src/services/PagesParser.ts +++ b/src/services/PagesParser.ts @@ -196,6 +196,7 @@ export class PagesParser { // FrontMatter properties fmFolder: folderTitle, fmFilePath: filePath, + fmRelFilePath: parseWinPath(filePath).replace(wsFolder?.fsPath || '', ''), fmFileName: fileName, fmDraft: ContentType.getDraftStatus(article?.data), fmModified: modifiedFieldValue ? modifiedFieldValue : fileMtime,