diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dc73ce0..b04f006c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [6.0.0] - 2022-01-xx + +### 🎨 Enhancements + +- [#197](https://github.com/estruyf/vscode-front-matter/issues/197): Support for multi-dimensional content type fields on content creation and editing. + ## [5.10.0] - 2022-01-10 ### 🎨 Enhancements diff --git a/assets/media/styles.css b/assets/media/styles.css index d9cb4f9f..c193c441 100644 --- a/assets/media/styles.css +++ b/assets/media/styles.css @@ -446,12 +446,33 @@ input:checked + .field__toggle__slider:before { margin-bottom: 1rem; } +.vscode-dark .metadata_field__box { + background: rgba(255, 255, 255, 0.1); + border: 2px dashed rgba(255, 255, 255, 0.2); +} + +.vscode-light .metadata_field__box { + background: rgba(0, 0, 0, 0.1); + border: 2px dashed rgba(0, 0, 0, 0.2); +} + +.metadata_field__box { + background: rgba(255, 255, 255, 0.1); + border: 2px dashed rgba(255, 255, 255, 0.2); + margin-bottom: .5rem; + padding: .5rem 1rem; +} + .metadata_field__label { display: flex; align-items: center; margin-bottom: .5rem; } +.metadata_field__label.metadata_field__label_parent { + justify-content: center; +} + .metadata_field__label svg { margin-right: .5rem; } diff --git a/package.json b/package.json index f2537f6e..1f90d11d 100644 --- a/package.json +++ b/package.json @@ -417,6 +417,7 @@ "description": "Specifies the type of content you want to create." }, "fields": { + "$id": "#contenttypefield", "type": "array", "description": "Define the fields of the content type", "items": { @@ -435,7 +436,8 @@ "taxonomy", "tags", "categories", - "draft" + "draft", + "object" ], "description": "Define the type of field" }, @@ -447,6 +449,10 @@ "type": "string", "description": "Title to show in the UI" }, + "default": { + "type": "string", + "description": "Default value" + }, "choices": { "type": "array", "description": "Define your choices", @@ -494,7 +500,8 @@ "type": "string", "default": "", "description": "The ID of your taxonomy field" - } + }, + "fields": { "$ref": "#contenttypefield" } }, "additionalProperties": false, "required": [ @@ -529,6 +536,20 @@ "choices" ] } + }, + { + "if": { + "properties": { + "type": { + "const": "object" + } + } + }, + "then": { + "required": [ + "fields" + ] + } } ] } diff --git a/src/explorerView/ExplorerView.ts b/src/explorerView/ExplorerView.ts index 9d01b868..e278ebe6 100644 --- a/src/explorerView/ExplorerView.ts +++ b/src/explorerView/ExplorerView.ts @@ -314,7 +314,7 @@ export class ExplorerView implements WebviewViewProvider, Disposable { /** * Update the metadata of the article */ - public async updateMetadata({field, value }: { field: string, value: any, fieldData?: { multiple: boolean, value: string[] } }) { + public async updateMetadata({field, parents, value }: { field: string, value: any, parents?: string[], fieldData?: { multiple: boolean, value: string[] } }) { if (!field) { return; } @@ -333,12 +333,18 @@ export class ExplorerView implements WebviewViewProvider, Disposable { const dateFields = contentType.fields.filter((field) => field.type === "datetime"); const imageFields = contentType.fields.filter((field) => field.type === "image" && field.multiple); + // Support multi-level fields + let parentObj = article.data; + for (const parent of parents || []) { + parentObj = parentObj[parent]; + } + for (const dateField of dateFields) { if ((field === dateField.name) && value) { - article.data[field] = Article.formatDate(new Date(value)); + parentObj[field] = Article.formatDate(new Date(value)); } else if (!imageFields.find(f => f.name === field)) { // Only override the field data if it is not an multiselect image field - article.data[field] = value; + parentObj[field] = value; } } @@ -346,15 +352,15 @@ export class ExplorerView implements WebviewViewProvider, Disposable { if (field === imageField.name) { // If value is an array, it means it comes from the explorer view itself (deletion) if (Array.isArray(value)) { - article.data[field] = value || []; + parentObj[field] = value || []; } else { // Otherwise it is coming from the media dashboard (addition) - let fieldValue = article.data[field]; + let fieldValue = parentObj[field]; if (fieldValue && !Array.isArray(fieldValue)) { fieldValue = [fieldValue]; } const crntData = Object.assign([], fieldValue); const allRelPaths = [...(crntData || []), value]; - article.data[field] = [...new Set(allRelPaths)].filter(f => f); + parentObj[field] = [...new Set(allRelPaths)].filter(f => f); } } } diff --git a/src/helpers/ContentType.ts b/src/helpers/ContentType.ts index a30d174c..900e7ed6 100644 --- a/src/helpers/ContentType.ts +++ b/src/helpers/ContentType.ts @@ -1,7 +1,7 @@ import { PagesListener } from './../listeners/PagesListener'; import { ArticleHelper, Settings } from "."; import { SETTINGS_CONTENT_DRAFT_FIELD, SETTING_TAXONOMY_CONTENT_TYPES } from "../constants"; -import { ContentType as IContentType, DraftField } from '../models'; +import { ContentType as IContentType, DraftField, Field } from '../models'; import { Uri, workspace, window, commands } from 'vscode'; import { Folders } from "../commands/Folders"; import { Questions } from "./Questions"; @@ -109,15 +109,7 @@ export class ContentType { return; } - let data: any = {}; - - for (const field of contentType.fields) { - if (field.name === "title") { - data[field.name] = titleValue; - } else { - data[field.name] = null; - } - } + let data: any = this.processFields(contentType, titleValue, {}); data = ArticleHelper.updateDates(Object.assign({}, data)); @@ -136,4 +128,27 @@ export class ContentType { // Trigger a refresh for the dashboard PagesListener.refresh(); } + + /** + * Process all content type fields + * @param contentType + * @param data + */ + private static processFields(obj: IContentType | Field, titleValue: string, data: any) { + if (obj.fields) { + for (const field of obj.fields) { + if (field.name === "title") { + data[field.name] = titleValue; + } else { + if (field.type === "object") { + data[field.name] = this.processFields(field, titleValue, {}); + } else { + data[field.name] = field.default || ""; + } + } + } + } + + return data; + } } \ No newline at end of file diff --git a/src/models/PanelSettings.ts b/src/models/PanelSettings.ts index 942ed702..2bcba5e8 100644 --- a/src/models/PanelSettings.ts +++ b/src/models/PanelSettings.ts @@ -34,13 +34,15 @@ export interface ContentType { export interface Field { title?: string; name: string; - type: "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy"; + type: "string" | "number" | "datetime" | "boolean" | "image" | "choice" | "tags" | "categories" | "draft" | "taxonomy" | "object"; choices?: string[] | Choice[]; single?: boolean; multiple?: boolean; isPreviewImage?: boolean; hidden?: boolean; taxonomyId?: string; + default?: string; + fields?: Field[]; } export interface DateInfo { diff --git a/src/panelWebView/components/Metadata.tsx b/src/panelWebView/components/Metadata.tsx index 230325e6..4a7c7161 100644 --- a/src/panelWebView/components/Metadata.tsx +++ b/src/panelWebView/components/Metadata.tsx @@ -19,10 +19,15 @@ import useContentType from '../../hooks/useContentType'; import { DateHelper } from '../../helpers/DateHelper'; import FieldBoundary from './ErrorBoundary/FieldBoundary'; import { DraftField } from './Fields/DraftField'; +import { VsLabel } from './VscodeComponents'; +import { CogIcon } from '@heroicons/react/outline'; +export interface IMetadata { + [prop: string]: string[] | string | null | IMetadata; +} export interface IMetadataProps { settings: PanelSettings | undefined; - metadata: { [prop: string]: string[] | string | null }; + metadata: IMetadata; focusElm: TagType | null; unsetFocus: () => void; } @@ -30,13 +35,14 @@ export interface IMetadataProps { const Metadata: React.FunctionComponent = ({settings, metadata, focusElm, unsetFocus}: React.PropsWithChildren) => { const contentType = useContentType(settings, metadata); - const sendUpdate = (field: string | undefined, value: any) => { + const sendUpdate = (field: string | undefined, value: any, parents: string[]) => { if (!field) { return; } MessageHelper.sendMessage(CommandToCode.updateMetadata, { field, + parents, value }); }; @@ -50,7 +56,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, return null; } - const renderFields = (ctFields: Field[]) => { + const renderFields = (ctFields: Field[], parent: IMetadata, parentFields: string[] = []) => { if (!ctFields) { return; } @@ -61,7 +67,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, } if (field.type === 'datetime') { - const dateValue = metadata[field.name] ? getDate(metadata[field.name] as string) : null; + const dateValue = parent[field.name] ? getDate(parent[field.name] as string) : null; return ( @@ -69,7 +75,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, label={field.title || field.name} date={dateValue} format={settings?.date?.format} - onChange={(date => sendUpdate(field.name, date))} /> + onChange={(date => sendUpdate(field.name, date, parentFields))} /> ); } else if (field.type === 'boolean') { @@ -78,12 +84,12 @@ const Metadata: React.FunctionComponent = ({settings, metadata, sendUpdate(field.name, checked)} /> + checked={!!parent[field.name] as any} + onChanged={(checked) => sendUpdate(field.name, checked, parentFields)} /> ); } else if (field.type === 'string') { - const textValue = metadata[field.name]; + const textValue = parent[field.name]; let limit = -1; if (field.name === 'title') { @@ -99,12 +105,12 @@ const Metadata: React.FunctionComponent = ({settings, metadata, singleLine={field.single} limit={limit} rows={3} - onChange={(value) => sendUpdate(field.name, value)} + onChange={(value) => sendUpdate(field.name, value, parentFields)} value={textValue as string || null} /> ); } else if (field.type === 'number') { - const fieldValue = metadata[field.name]; + const fieldValue = parent[field.name]; let nrValue: number | null = parseInt(fieldValue as string); if (isNaN(nrValue)) { nrValue = null; @@ -115,7 +121,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, sendUpdate(field.name, value)} + onChange={(value) => sendUpdate(field.name, value, parentFields)} value={nrValue} /> ); @@ -125,15 +131,15 @@ const Metadata: React.FunctionComponent = ({settings, metadata, sendUpdate(field.name, value))} /> + onChange={(value => sendUpdate(field.name, value, parentFields))} /> ); } else if (field.type === 'choice') { const choices = field.choices || []; - const choiceValue = metadata[field.name]; + const choiceValue = parent[field.name]; return ( @@ -142,7 +148,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, selected={choiceValue as string} choices={choices} multiSelect={field.multiple} - onChange={(value => sendUpdate(field.name, value))} /> + onChange={(value => sendUpdate(field.name, value, parentFields))} /> ); } else if (field.type === 'tags') { @@ -152,7 +158,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, type={TagType.tags} label={field.title || field.name} icon={} - crntSelected={metadata[field.name] as string[] || []} + crntSelected={parent[field.name] as string[] || []} options={settings?.tags || []} freeform={settings.freeform} focussed={focusElm === TagType.tags} @@ -161,7 +167,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, ); } else if (field.type === 'taxonomy') { const taxonomyData = settings.customTaxonomy.find(ct => ct.id === field.taxonomyId); - const selectedValues = metadata[field.name] || []; + const selectedValues = parent[field.name] || []; return ( @@ -185,7 +191,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, type={TagType.categories} label={field.title || field.name} icon={} - crntSelected={metadata.categories as string[] || []} + crntSelected={parent.categories as string[] || []} options={settings.categories} freeform={settings.freeform} focussed={focusElm === TagType.categories} @@ -194,7 +200,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, ); } else if (field.type === 'draft') { const draftField = settings?.draftField; - const value = metadata[field.name]; + const value = parent[field.name]; return ( @@ -203,9 +209,28 @@ const Metadata: React.FunctionComponent = ({settings, metadata, type={draftField.type} choices={draftField.choices || []} value={value as boolean | string | null | undefined} - onChanged={(value: boolean | string) => sendUpdate(field.name, value)} /> + onChanged={(value: boolean | string) => sendUpdate(field.name, value, parentFields)} /> ); + } else if (field.type === 'object') { + if (field.fields && parent && parent[field.name]) { + const subMetadata = parent[field.name] as IMetadata; + return ( + +
+ +
+ {field.title || field.name} +
+
+ + { renderFields(field.fields, subMetadata, [...parentFields, field.name]) } +
+
+ ); + } + + return null; } else { return null; } @@ -216,7 +241,7 @@ const Metadata: React.FunctionComponent = ({settings, metadata, { - renderFields(contentType?.fields) + renderFields(contentType?.fields, metadata) } {