#585 - Added content relationship field

This commit is contained in:
Elio Struyf
2023-05-17 13:40:12 +02:00
parent 7a2a2004f6
commit 9515b74182
13 changed files with 347 additions and 6 deletions
+1
View File
@@ -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
View File
@@ -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": {
+1
View File
@@ -6,6 +6,7 @@ export interface Page {
// Front matter fields
fmFolder: string;
fmFilePath: string;
fmRelFilePath: string;
fmFileName: string;
fmModified: number;
fmPublished: number | null | undefined;
+3 -1
View File
@@ -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);
+10 -2
View File
@@ -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,
+61
View File
@@ -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 || []);
});
}
}
+1
View File
@@ -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';
+6 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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}>
+9
View File
@@ -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);
+1
View File
@@ -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,