From efdbce2d08975c888696c582464c719a45bef179 Mon Sep 17 00:00:00 2001 From: Elio Struyf Date: Wed, 18 May 2022 13:19:55 +0200 Subject: [PATCH] #332 - Adding the new fileData field --- CHANGELOG.md | 4 +- package.json | 74 +++++-- src/helpers/DataFileHelper.ts | 74 +++++++ src/helpers/index.ts | 5 +- src/listeners/dashboard/DataListener.ts | 40 +--- src/listeners/panel/DataListener.ts | 20 +- src/models/PanelSettings.ts | 7 +- src/panelWebView/Command.ts | 1 + src/panelWebView/CommandToCode.ts | 1 + .../components/Fields/DataFileField.tsx | 181 ++++++++++++++++++ .../components/Fields/WrapperField.tsx | 18 +- 11 files changed, 370 insertions(+), 55 deletions(-) create mode 100644 src/helpers/DataFileHelper.ts create mode 100644 src/panelWebView/components/Fields/DataFileField.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db2cfb1..db05c9d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,15 @@ ### 🎨 Enhancements +- JSON schema enhancements for working with data files - [#330](https://github.com/estruyf/vscode-front-matter/issues/330): Allow custom scripts to easily update front matter - [#331](https://github.com/estruyf/vscode-front-matter/issues/331): Added functionality to run other type of scripts +- [#332](https://github.com/estruyf/vscode-front-matter/issues/332): New `dataFile` field which allows you to create data file references - [#333](https://github.com/estruyf/vscode-front-matter/issues/333): Automatically mark Jekyll posts in `_drafts` folder as draft - [#335](https://github.com/estruyf/vscode-front-matter/issues/335): Merge media snippets with content snippets to allow you to define multiple media snippets and use these in your content - [#336](https://github.com/estruyf/vscode-front-matter/issues/336): Support added for inverting the draft field so that SSGs/authors can use a published field instead - [#337](https://github.com/estruyf/vscode-front-matter/issues/337): Allow multiple front matter types to be used. -### ⚡️ Optimizations - ### 🐞 Fixes - [#334](https://github.com/estruyf/vscode-front-matter/issues/334): Fix for locked content folders retrieval diff --git a/package.json b/package.json index f15cea00..ecbd09c8 100644 --- a/package.json +++ b/package.json @@ -454,7 +454,8 @@ }, "file": { "type": "string", - "description": "Path to the file to load. Only JSON or YAML files are supported." + "description": "Path to the file to load. Only JSON or YAML files are supported.", + "default": "[[workspace]]/" }, "fileType": { "type": "string", @@ -466,10 +467,38 @@ "description": "Defines how you want to parse the file. JSON is the default." }, "schema": { + "$id": "#dataFileSchema", "type": "object", "default": {}, "description": "The JSON schema for your data which will be used to render the data form.", - "additionalProperties": true + "additionalProperties": true, + "required": [ + "type", + "properties" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the form." + }, + "type": { + "type": "string", + "description": "Defines the type of the form. Default is 'object'.", + "default": "object" + }, + "required": { + "type": "array", + "description": "Defines the required fields for the form.", + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "description": "Defines the fields of the form.", + "additionalProperties": true + } + } }, "type": { "type": "string", @@ -516,13 +545,11 @@ }, "path": { "type": "string", - "description": "Path to the folder to load files." + "description": "Path to the folder to load files.", + "default": "[[workspace]]/" }, "schema": { - "type": "object", - "default": {}, - "description": "The JSON schema for your data which will be used to render the data form.", - "additionalProperties": true + "$ref": "#dataFileSchema" }, "type": { "type": "string", @@ -563,10 +590,7 @@ "description": "Your unique ID you want to use for your data type." }, "schema": { - "type": "object", - "default": {}, - "description": "The JSON schema for your data which will be used to render the data form.", - "additionalProperties": true + "$ref": "#dataFileSchema" } }, "required": [ @@ -781,7 +805,8 @@ "draft", "fields", "json", - "block" + "block", + "dataFile" ], "description": "Define the type of field" }, @@ -903,6 +928,16 @@ "type": "boolean", "default": false, "description": "Specify if the field is the modified date field" + }, + "dataFileId": { + "type": "string", + "default": "", + "description": "Specify the ID of the data file to use for this field" + }, + "dataFileKey": { + "type": "string", + "default": "", + "description": "Specify the key of the data file to use for this field" } }, "additionalProperties": false, @@ -911,6 +946,21 @@ "name" ], "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "dataFile" + } + } + }, + "then": { + "required": [ + "dataFileId", + "dataFileKey" + ] + } + }, { "if": { "properties": { diff --git a/src/helpers/DataFileHelper.ts b/src/helpers/DataFileHelper.ts new file mode 100644 index 00000000..c4080ad7 --- /dev/null +++ b/src/helpers/DataFileHelper.ts @@ -0,0 +1,74 @@ +import { existsSync, readFileSync } from "fs"; +import { Folders } from "../commands/Folders"; +import { DataFile } from "../models"; +import * as yaml from 'js-yaml'; +import { Logger } from "./Logger"; +import { Notifications } from "./Notifications"; +import { commands } from "vscode"; +import { COMMAND_NAME, SETTING_DATA_FILES } from "../constants"; +import { Settings } from "./SettingsHelper"; + + +export class DataFileHelper { + + /** + * Retrieve the file data + * @param filePath + * @returns + */ + public static get(filePath: string) { + const absPath = Folders.getAbsFilePath(filePath); + if (existsSync(absPath)) { + return readFileSync(absPath, 'utf8'); + } + + return null; + } + + /** + * Get by the id of the data file + * @param id + */ + public static getById(id: string) { + const files = Settings.get(SETTING_DATA_FILES); + + if (!files || files.length === 0) { + return; + } + + const file = files.find(f => f.id === id); + + if (!file) { + return; + } + + return DataFileHelper.process(file); + } + + /** + * Process the data file + * @param data + * @returns + */ + public static async process(data: DataFile) { + try { + const { file, fileType } = data; + const dataFile = DataFileHelper.get(file); + + if (fileType === "yaml") { + return yaml.safeLoad(dataFile || ""); + } else { + return dataFile ? JSON.parse(dataFile) : []; + } + } catch (ex) { + Logger.error(`DataFileHelper::process: ${(ex as Error).message}`); + const btnClick = await Notifications.error(`Something went wrong while processing the data file. Check your file and output log for more information.`, 'Open output'); + + if (btnClick && btnClick === 'Open output') { + commands.executeCommand(COMMAND_NAME.showOutputChannel); + } + + return; + } + } +} \ No newline at end of file diff --git a/src/helpers/index.ts b/src/helpers/index.ts index efc40674..5988e1bd 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -2,6 +2,7 @@ export * from './ArticleHelper'; export * from './ContentType'; export * from './CustomScript'; export * from './DashboardSettings'; +export * from './DataFileHelper'; export * from './DateHelper'; export * from './Extension'; export * from './FilesHelper'; @@ -11,13 +12,15 @@ export * from './ImageHelper'; export * from './Logger'; export * from './MediaHelpers'; export * from './MediaLibrary'; -export * from './MessageHelper'; export * from './Notifications'; +export * from './PanelSettings'; +export * from './PlaceholderHelper'; export * from './Questions'; export * from './Sanitize'; export * from './SeoHelper'; export * from './SettingsHelper'; export * from './SlugHelper'; +export * from './SnippetParser'; export * from './Sorting'; export * from './StringHelpers'; export * from './Telemetry'; diff --git a/src/listeners/dashboard/DataListener.ts b/src/listeners/dashboard/DataListener.ts index bf382d1f..6e61600b 100644 --- a/src/listeners/dashboard/DataListener.ts +++ b/src/listeners/dashboard/DataListener.ts @@ -3,11 +3,10 @@ import { DashboardMessage } from "../../dashboardWebView/DashboardMessage"; import { BaseListener } from "./BaseListener"; import { DashboardCommand } from '../../dashboardWebView/DashboardCommand'; import { Folders } from '../../commands/Folders'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { existsSync, writeFileSync, mkdirSync } from 'fs'; import { dirname } from 'path'; import * as yaml from 'js-yaml'; -import { Logger, Notifications } from '../../helpers'; -import { commands } from 'vscode'; +import { DataFileHelper } from '../../helpers'; export class DataListener extends BaseListener { @@ -57,38 +56,7 @@ export class DataListener extends BaseListener { * @param msgData */ private static async processDataFile(msgData: DataFile) { - try { - const { file } = msgData; - const dataFile = this.getDataFile(file); - - if (msgData.fileType === "yaml") { - const entries = yaml.safeLoad(dataFile || ""); - this.sendMsg(DashboardCommand.dataFileEntries, entries); - } else { - const jsonData = dataFile ? JSON.parse(dataFile) : []; - this.sendMsg(DashboardCommand.dataFileEntries, jsonData); - } - } catch (ex) { - Logger.error(`DataListener::processDataFile: ${(ex as Error).message}`); - const btnClick = await Notifications.error(`Something went wrong while processing the data file. Check your file and output log for more information.`, 'Open output'); - - if (btnClick && btnClick === 'Open output') { - commands.executeCommand(`workbench.panel.output.focus`); - } - } - } - - /** - * Retrieve the file data - * @param file - * @returns - */ - private static getDataFile(file: string) { - const absPath = Folders.getAbsFilePath(file); - if (existsSync(absPath)) { - return readFileSync(absPath, 'utf8'); - } - - return null; + const entries = DataFileHelper.process(msgData); + this.sendMsg(DashboardCommand.dataFileEntries, entries); } } \ No newline at end of file diff --git a/src/listeners/panel/DataListener.ts b/src/listeners/panel/DataListener.ts index f696ada5..abb7d28e 100644 --- a/src/listeners/panel/DataListener.ts +++ b/src/listeners/panel/DataListener.ts @@ -1,3 +1,4 @@ +import { DataFileHelper } from './../../helpers/DataFileHelper'; import { BlockFieldData } from './../../models/BlockFieldData'; import { ImageHelper } from './../../helpers/ImageHelper'; import { Folders } from "../../commands/Folders"; @@ -45,10 +46,16 @@ export class DataListener extends BaseListener { break; case CommandToCode.generateContentType: commands.executeCommand(COMMAND_NAME.generateContentType); + break; case CommandToCode.addMissingFields: commands.executeCommand(COMMAND_NAME.addMissingFields); + break; case CommandToCode.setContentType: commands.executeCommand(COMMAND_NAME.setContentType); + break; + case CommandToCode.getDataEntries: + this.getDataFileEntries(msg.data); + break; } } @@ -101,7 +108,7 @@ export class DataListener extends BaseListener { // Get the current content type const contentType = ArticleHelper.getContentType(updatedMetadata); if (contentType) { - ImageHelper.processImageFields(updatedMetadata, contentType.fields) + ImageHelper.processImageFields(updatedMetadata, contentType.fields); } } @@ -278,6 +285,17 @@ export class DataListener extends BaseListener { } } + /** + * Retrieve the data entries from local data files + * @param data + */ + private static async getDataFileEntries(data: any) { + const entries = await DataFileHelper.getById(data); + if (entries) { + this.sendMsg(Command.dataFileEntries, entries); + } + } + /** * Open a terminal and run the passed command * @param command diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 0dee30ea..01f07729 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -48,7 +48,7 @@ export interface ContentType { pageBundle?: boolean; } -export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file"; +export type FieldType = "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "fields" | "json" | "block" | "file" | "dataFile"; export interface Field { title?: string; @@ -71,6 +71,11 @@ export interface Field { // Date fields isPublishDate?: boolean; isModifiedDate?: boolean; + + // Data file + dataFileId?: string; + dataFileKey?: string; + dataFileValue?: string; } export interface DateInfo { diff --git a/src/panelWebView/Command.ts b/src/panelWebView/Command.ts index c89e789e..ac16ce33 100644 --- a/src/panelWebView/Command.ts +++ b/src/panelWebView/Command.ts @@ -9,4 +9,5 @@ export enum Command { mediaSelectionData = "mediaSelectionData", sendMediaUrl = "sendMediaUrl", updatePlaceholder = "updatePlaceholder", + dataFileEntries = "dataFileEntries", } \ No newline at end of file diff --git a/src/panelWebView/CommandToCode.ts b/src/panelWebView/CommandToCode.ts index 30ab1986..ec574cc0 100644 --- a/src/panelWebView/CommandToCode.ts +++ b/src/panelWebView/CommandToCode.ts @@ -37,4 +37,5 @@ export enum CommandToCode { generateContentType = "generate-content-type", addMissingFields = "add-missing-fields", setContentType = "set-content-type", + getDataEntries = "get-data-entries", } \ No newline at end of file diff --git a/src/panelWebView/components/Fields/DataFileField.tsx b/src/panelWebView/components/Fields/DataFileField.tsx new file mode 100644 index 00000000..4c128262 --- /dev/null +++ b/src/panelWebView/components/Fields/DataFileField.tsx @@ -0,0 +1,181 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; +import { EventData } from '@estruyf/vscode/dist/models'; +import { ChevronDownIcon, DatabaseIcon } from '@heroicons/react/outline'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Command } from '../../Command'; +import { CommandToCode } from '../../CommandToCode'; +import { VsLabel } from '../VscodeComponents'; +import Downshift from 'downshift'; +import { ChoiceButton } from './ChoiceButton'; + +export interface IDataFileFieldProps { + label: string; + dataFileId?: string; + dataFileKey?: string; + dataFileValue?: string; + selected: string | string[]; + multiSelect?: boolean; + onChange: (value: string | string[]) => void; +} + +export const DataFileField: React.FunctionComponent = ({ label, dataFileId, dataFileKey, dataFileValue, selected, multiSelect, onChange }: React.PropsWithChildren) => { + const [ dataEntries, setDataEntries ] = useState(null); + const [ crntSelected, setCrntSelected ] = React.useState(); + const dsRef = React.useRef | null>(null); + + const messageListener = (message: MessageEvent>) => { + const { command, data } = message.data; + + if (command === Command.dataFileEntries) { + setDataEntries(data || null); + } + }; + + const onValueChange = useCallback((txtValue: string) => { + if (multiSelect) { + const newValue = [...(crntSelected || []) as string[], txtValue]; + setCrntSelected(newValue); + onChange(newValue); + } else { + setCrntSelected(txtValue); + onChange(txtValue); + } + }, [crntSelected, multiSelect, onChange]); + + const removeSelected = useCallback((txtValue: string) => { + if (multiSelect) { + const newValue = [...(crntSelected || [])].filter(v => v !== txtValue); + setCrntSelected(newValue); + onChange(newValue); + } else { + setCrntSelected(""); + onChange(""); + } + }, [crntSelected, multiSelect, onChange]); + + const allChoices = useMemo(() => { + if (dataEntries && dataFileKey) { + return dataEntries.map((r: any) => ({ + id: r[dataFileKey], + title: r[dataFileValue || dataFileKey] || r[dataFileKey] + })).filter(r => r.id); + } + return []; + }, [crntSelected, dataEntries, dataFileKey, dataFileValue]); + + const availableChoices = useMemo(() => { + if (allChoices) { + return allChoices.filter(choice => { + if (choice) { + if (typeof crntSelected === 'string') { + return crntSelected !== choice.id; + } else if (crntSelected instanceof Array) { + return crntSelected.indexOf(choice.id) === -1; + } + + return true; + } + + return false; + }); + } + return []; + }, [allChoices]); + + const getChoiceValue = useCallback((id: string) => { + const choice = allChoices.find(r => r.id === id); + if (choice) { + return choice.title; + } + return ""; + }, [allChoices]); + + useEffect(() => { + if (selected) { + if (multiSelect) { + setCrntSelected(typeof selected === 'string' ? [selected] : selected); + return; + } else { + setCrntSelected(selected instanceof Array ? selected[0] : selected); + return; + } + } + + setCrntSelected(multiSelect ? [] : ""); + }, [selected, multiSelect]); + + useEffect(() => { + if (dataFileId) { + Messenger.send(CommandToCode.getDataEntries, dataFileId); + } + }, [dataFileId]); + + useEffect(() => { + Messenger.listen(messageListener); + + return () => { + Messenger.unlisten(messageListener); + } + }, []); + + return ( +
+ +
+ {label} +
+
+ + onValueChange(selected || "")} + itemToString={item => (item ? item : '')}> + {({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => ( +
+ + +
    + { + isOpen ? availableChoices.map((choice, index) => ( +
  • + { choice.title || Clear value } +
  • + )) : null + } +
+
+ )} +
+ + { + crntSelected instanceof Array ? crntSelected.map((value: string) => ( + + )) : ( + crntSelected && ( + + ) + ) + } +
+ ); +}; \ No newline at end of file diff --git a/src/panelWebView/components/Fields/WrapperField.tsx b/src/panelWebView/components/Fields/WrapperField.tsx index a4af9138..6c829502 100644 --- a/src/panelWebView/components/Fields/WrapperField.tsx +++ b/src/panelWebView/components/Fields/WrapperField.tsx @@ -1,9 +1,9 @@ +import { Messenger } from '@estruyf/vscode/dist/client'; import * as React from 'react'; import { useCallback, useEffect, useState } from 'react'; import { DateHelper } from '../../../helpers/DateHelper'; -import { MessageHelper } from '../../../helpers/MessageHelper'; import { BlockFieldData, Field, PanelSettings } from '../../../models'; import { Command } from '../../Command'; import { CommandToCode } from '../../CommandToCode'; @@ -17,6 +17,7 @@ import { IMetadata } from '../Metadata'; import { TagPicker } from '../TagPicker'; import { VsLabel } from '../VscodeComponents'; import { ChoiceField } from './ChoiceField'; +import { DataFileField } from './DataFileField'; import { DateTimeField } from './DateTimeField'; import { DraftField } from './DraftField'; import { FileField } from './FileField'; @@ -98,7 +99,7 @@ export const WrapperField: React.FunctionComponent = ({ // Check if the field value contains a placeholder if (value && typeof value === "string" && value.includes(`{{`) && value.includes(`}}`)) { window.addEventListener('message', listener); - MessageHelper.sendMessage(CommandToCode.updatePlaceholder, { + Messenger.send(CommandToCode.updatePlaceholder, { field: field.name, title: metadata["title"], value @@ -346,6 +347,19 @@ export const WrapperField: React.FunctionComponent = ({ onSubmit={(value) => onSendUpdate(field.name, value, parentFields)} /> ); + } else if (field.type === 'dataFile') { + return ( + + onSendUpdate(field.name, value, parentFields))} /> + + ); } else { console.warn(`Unknown field type: ${field.type}`); return null;