mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-08-09 02:12:50 +02:00
#585 - Added content relationship field
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+27
-1
@@ -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": {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Page {
|
||||
// Front matter fields
|
||||
fmFolder: string;
|
||||
fmFilePath: string;
|
||||
fmRelFilePath: string;
|
||||
fmFileName: string;
|
||||
fmModified: number;
|
||||
fmPublished: number | null | undefined;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Page> = {
|
||||
keys: [{ name: 'type', weight: 1 }]
|
||||
};
|
||||
|
||||
const pagesIndex = await Extension.getInstance().getState<Fuse.FuseIndex<Page>>(
|
||||
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 || []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<string | string[]> {
|
||||
contentTypeName?: string;
|
||||
contentTypeValue?: string;
|
||||
multiSelect?: boolean;
|
||||
onChange: (value: string | string[]) => void;
|
||||
}
|
||||
|
||||
export const ContentTypeRelationshipField: React.FunctionComponent<IContentTypeRelationshipFieldProps> = ({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
contentTypeName,
|
||||
contentTypeValue,
|
||||
multiSelect,
|
||||
onChange,
|
||||
required
|
||||
}: React.PropsWithChildren<IContentTypeRelationshipFieldProps>) => {
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [choices, setChoices] = React.useState<string[]>([]);
|
||||
const [pages, setPages] = React.useState<Page[]>([]);
|
||||
const [crntSelected, setCrntSelected] = React.useState<string | string[] | null>(value);
|
||||
const dsRef = React.useRef<Downshift<string> | 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<Page[]>(CommandToCode.searchByType, contentTypeName)
|
||||
.then((pages: Page[]) => {
|
||||
setPages(pages || []);
|
||||
setChoices((pages || []).map(page => page.title))
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [contentTypeName]);
|
||||
|
||||
return (
|
||||
<div className={`metadata_field ${showRequiredState ? 'required' : ''}`}>
|
||||
<FieldTitle
|
||||
label={label}
|
||||
icon={<DocumentAddIcon />}
|
||||
required={required} />
|
||||
|
||||
{
|
||||
loading ? (
|
||||
<div className='metadata_field__wrapper'>
|
||||
<div className='metadata_field__loading'>
|
||||
Fetching possible values...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Downshift
|
||||
ref={dsRef}
|
||||
onSelect={(selected) => onValueChange(selected || '')}
|
||||
itemToString={(item) => (item ? item : '')}
|
||||
>
|
||||
{({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps(undefined, { suppressRefError: true })}
|
||||
className={`metadata_field__choice`}
|
||||
>
|
||||
<button
|
||||
{...getToggleButtonProps({
|
||||
className: `metadata_field__choice__toggle`,
|
||||
disabled: availableChoices.length === 0
|
||||
})}
|
||||
>
|
||||
<span>{`Select ${label}`}</span>
|
||||
<ChevronDownIcon className="icon" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
className={`metadata_field__choice_list ${isOpen ? 'open' : 'closed'}`}
|
||||
{...getMenuProps()}
|
||||
>
|
||||
{isOpen
|
||||
? availableChoices.map((choice: Page, index) => (
|
||||
<li
|
||||
{...getItemProps({
|
||||
key: getValue(choice, contentTypeValue),
|
||||
index,
|
||||
item: getValue(choice, contentTypeValue),
|
||||
})}
|
||||
>
|
||||
{choice.title || (
|
||||
<span className={`metadata_field__choice_list__item`}>Clear value</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
: null}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
|
||||
<FieldMessage
|
||||
name={label.toLowerCase()}
|
||||
description={description}
|
||||
showRequired={showRequiredState}
|
||||
/>
|
||||
|
||||
{crntSelected instanceof Array
|
||||
? crntSelected.map((value: string) => (
|
||||
<ChoiceButton
|
||||
key={value}
|
||||
value={value}
|
||||
title={getChoiceValue(value)}
|
||||
onClick={removeSelected}
|
||||
/>
|
||||
))
|
||||
: crntSelected && (
|
||||
<ChoiceButton
|
||||
key={crntSelected}
|
||||
value={crntSelected}
|
||||
title={getChoiceValue(crntSelected)}
|
||||
onClick={removeSelected}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<IWrapperFieldProps> = ({
|
||||
/>
|
||||
</FieldBoundary>
|
||||
);
|
||||
} else if (field.type === 'contentRelationship') {
|
||||
const pages: string[] = [];
|
||||
|
||||
return (
|
||||
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
|
||||
<ContentTypeRelationshipField
|
||||
label={field.title || field.name}
|
||||
description={field.description}
|
||||
value={fieldValue as string}
|
||||
required={!!field.required}
|
||||
contentTypeName={field.contentTypeName}
|
||||
contentTypeValue={field.contentTypeValue}
|
||||
multiSelect={field.multiple}
|
||||
onChange={(value) => onSendUpdate(field.name, value, parentFields)}
|
||||
/>
|
||||
</FieldBoundary>
|
||||
);
|
||||
} else if (field.type === 'slug') {
|
||||
return (
|
||||
<FieldBoundary key={field.name} fieldName={field.title || field.name}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user