mirror of
https://github.com/estruyf/vscode-front-matter.git
synced 2026-03-28 17:42:40 +01:00
New content type relationship field implementation
This commit is contained in:
1
.frontmatter/database/pinnedItemsDb.json
Normal file
1
.frontmatter/database/pinnedItemsDb.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -27,6 +27,7 @@
|
||||
"common.refreshSettings": "Refresh settings",
|
||||
"common.pin": "Pin",
|
||||
"common.unpin": "Unpin",
|
||||
"common.noResults": "No results",
|
||||
|
||||
"settings.view.common": "Common",
|
||||
"settings.view.contentFolders": "Content folders",
|
||||
|
||||
@@ -111,6 +111,10 @@ export enum LocalizationKey {
|
||||
* Unpin
|
||||
*/
|
||||
commonUnpin = 'common.unpin',
|
||||
/**
|
||||
* No results
|
||||
*/
|
||||
commonNoResults = 'common.noResults',
|
||||
/**
|
||||
* Common
|
||||
*/
|
||||
|
||||
@@ -6,21 +6,23 @@ import { LocalizationKey } from '../../../localization';
|
||||
export interface IChoiceButtonProps {
|
||||
title: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
onClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export const ChoiceButton: React.FunctionComponent<IChoiceButtonProps> = ({
|
||||
title,
|
||||
value,
|
||||
className,
|
||||
onClick
|
||||
}: React.PropsWithChildren<IChoiceButtonProps>) => {
|
||||
return (
|
||||
<button
|
||||
title={l10n.t(LocalizationKey.commonRemoveValue, title)}
|
||||
className="metadata_field__choice__button"
|
||||
className={`metadata_field__choice__button text-left ${className || ""}`}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{title}
|
||||
<span>{title}</span>
|
||||
<XIcon className={`metadata_field__choice__button_icon`} />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Combobox, Transition } from '@headlessui/react';
|
||||
import * as React from 'react';
|
||||
import { BaseFieldProps } from '../../../models';
|
||||
import { Fragment, useEffect, useMemo } from 'react';
|
||||
import { Page } from '../../../dashboardWebView/models';
|
||||
import { messageHandler } from '@estruyf/vscode/dist/client/webview';
|
||||
import { CommandToCode } from '../../CommandToCode';
|
||||
import { ChevronDownIcon } from '@heroicons/react/outline';
|
||||
|
||||
export interface IComboboxFieldProps extends BaseFieldProps<string | string[]> {
|
||||
contentTypeName?: string;
|
||||
contentTypeValue?: string;
|
||||
multiSelect?: boolean;
|
||||
onChange: (value: string | string[]) => void;
|
||||
}
|
||||
|
||||
export const ComboboxField: React.FunctionComponent<IComboboxFieldProps> = ({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
contentTypeName,
|
||||
contentTypeValue,
|
||||
multiSelect,
|
||||
onChange,
|
||||
required
|
||||
}: React.PropsWithChildren<IComboboxFieldProps>) => {
|
||||
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 availableChoices = useMemo(() => {
|
||||
return pages.filter((page: Page) => {
|
||||
const value = contentTypeValue === "slug" ? page.slug : page.fmFilePath;
|
||||
|
||||
if (typeof crntSelected === 'string') {
|
||||
return crntSelected !== `${value}` && !value.includes(crntSelected);
|
||||
} else if (crntSelected instanceof Array) {
|
||||
const selected = crntSelected.filter((v) => v === `${value}` || value.includes(v));
|
||||
return selected.length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [choices, crntSelected, multiSelect, contentTypeValue]);
|
||||
|
||||
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 (
|
||||
<Combobox
|
||||
value={crntSelected}
|
||||
onChange={(value) => null}
|
||||
>
|
||||
<div className="relative mt-1">
|
||||
|
||||
<div className="relative w-full cursor-default overflow-hidden text-left focus:outline-none">
|
||||
<Combobox.Input
|
||||
className="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0"
|
||||
onChange={(e) => console.log(e)} />
|
||||
|
||||
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2 w-8">
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Combobox.Options
|
||||
className="absolute mt-1 max-h-60 w-full overflow-auto py-1 text-base focus:outline-none z-50 bg-black">
|
||||
{availableChoices.map((choice) => (
|
||||
<Combobox.Option key={choice.fmFilePath} value={choice.fmFileName}>
|
||||
{choice.title}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
)
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ChevronDownIcon, DocumentAddIcon } from '@heroicons/react/outline';
|
||||
import Downshift from 'downshift';
|
||||
import { Combobox, Transition } from '@headlessui/react';
|
||||
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 { Fragment, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Page } from '../../../dashboardWebView/models';
|
||||
import { messageHandler } from '@estruyf/vscode/dist/client/webview';
|
||||
import { CommandToCode } from '../../CommandToCode';
|
||||
import { ChevronDownIcon, DocumentAddIcon } from '@heroicons/react/outline';
|
||||
import * as l10n from '@vscode/l10n';
|
||||
import { LocalizationKey } from '../../../localization';
|
||||
import { FieldTitle } from './FieldTitle';
|
||||
import { FieldMessage } from './FieldMessage';
|
||||
import { ChoiceButton } from './ChoiceButton';
|
||||
import useDropdownStyle from '../../hooks/useDropdownStyle';
|
||||
|
||||
export interface IContentTypeRelationshipFieldProps extends BaseFieldProps<string | string[]> {
|
||||
@@ -34,34 +34,25 @@ export const ContentTypeRelationshipField: React.FunctionComponent<IContentTypeR
|
||||
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 [filter, setFilter] = React.useState<string | undefined>(undefined);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const { getDropdownStyle } = useDropdownStyle(inputRef as any);
|
||||
const { getDropdownStyle } = useDropdownStyle(inputRef as any, '6px');
|
||||
|
||||
const onValueChange = (txtValue: string) => {
|
||||
if (multiSelect) {
|
||||
const crntValue = typeof crntSelected === 'string' ? [crntSelected] : crntSelected;
|
||||
const newValue = [...((crntValue || []) as string[]), txtValue];
|
||||
const uniqueValues = [...new Set(newValue)];
|
||||
setCrntSelected(uniqueValues);
|
||||
onChange(uniqueValues);
|
||||
} 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('');
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Check the required state
|
||||
*/
|
||||
const showRequiredState = useMemo(() => {
|
||||
return (
|
||||
required && ((crntSelected instanceof Array && crntSelected.length === 0) || !crntSelected)
|
||||
);
|
||||
}, [required, crntSelected]);
|
||||
|
||||
/**
|
||||
* Retrieve the value based on the field setting
|
||||
* @param value
|
||||
* @param type
|
||||
* @returns
|
||||
*/
|
||||
const getValue = (value: Page, type: string = "path") => {
|
||||
if (type === 'path') {
|
||||
return value.fmRelFilePath || value.fmFilePath;
|
||||
@@ -70,7 +61,10 @@ export const ContentTypeRelationshipField: React.FunctionComponent<IContentTypeR
|
||||
return `${value[type]}`;
|
||||
};
|
||||
|
||||
const getChoiceValue = React.useCallback((value: string) => {
|
||||
/**
|
||||
* Retrieve choice value to display
|
||||
*/
|
||||
const getChoiceValue = useCallback((value: string) => {
|
||||
const choice = pages.find(
|
||||
(p: Page) => getValue(p, contentTypeValue) === value
|
||||
);
|
||||
@@ -79,35 +73,80 @@ export const ContentTypeRelationshipField: React.FunctionComponent<IContentTypeR
|
||||
return choice.title;
|
||||
}
|
||||
return '';
|
||||
}, [choices, contentTypeValue]);
|
||||
}, [pages, choices, contentTypeValue]);
|
||||
|
||||
/**
|
||||
* On selecting an option
|
||||
* @param txtValue
|
||||
*/
|
||||
const onSelect = (option: string) => {
|
||||
setFilter(undefined);
|
||||
|
||||
if (multiSelect) {
|
||||
if (option) {
|
||||
const crntValue = typeof crntSelected === 'string' ? [crntSelected] : crntSelected;
|
||||
const newValue = [...((crntValue || []) as string[]), option];
|
||||
const uniqueValues = [...new Set(newValue)];
|
||||
setCrntSelected(uniqueValues);
|
||||
onChange(uniqueValues);
|
||||
}
|
||||
} else if (option) {
|
||||
setCrntSelected(option);
|
||||
onChange(option);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a selected value
|
||||
* @param txtValue
|
||||
*/
|
||||
const removeSelected = useCallback((txtValue: string) => {
|
||||
if (multiSelect) {
|
||||
const newValue = [...(crntSelected || [])].filter((v) => v !== txtValue);
|
||||
setCrntSelected(newValue);
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setCrntSelected('');
|
||||
onChange('');
|
||||
}
|
||||
}, [multiSelect, crntSelected, onChange]);
|
||||
|
||||
/**
|
||||
* Retrieve the available choices
|
||||
*/
|
||||
const availableChoices = useMemo(() => {
|
||||
return pages.filter((page: Page) => {
|
||||
const value = contentTypeValue === "slug" ? page.slug : page.fmFilePath;
|
||||
|
||||
let toShow = true;
|
||||
|
||||
if (typeof crntSelected === 'string') {
|
||||
return crntSelected !== `${value}` && !value.includes(crntSelected);
|
||||
toShow = crntSelected !== `${value}` && !value.includes(crntSelected);
|
||||
} else if (crntSelected instanceof Array) {
|
||||
const selected = crntSelected.filter((v) => v === `${value}` || value.includes(v));
|
||||
return selected.length === 0;
|
||||
toShow = selected.length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
if (toShow && filter) {
|
||||
return page.title.toLowerCase().includes(filter);
|
||||
}
|
||||
|
||||
return toShow;
|
||||
});
|
||||
}, [choices, crntSelected, multiSelect, contentTypeValue]);
|
||||
|
||||
const showRequiredState = useMemo(() => {
|
||||
return (
|
||||
required && ((crntSelected instanceof Array && crntSelected.length === 0) || !crntSelected)
|
||||
);
|
||||
}, [required, crntSelected]);
|
||||
}, [choices, crntSelected, multiSelect, contentTypeValue, filter]);
|
||||
|
||||
/**
|
||||
* Retrieve the selected value
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (crntSelected !== value) {
|
||||
setCrntSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
/**
|
||||
* Retrieve the pages based on the content type
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (contentTypeName) {
|
||||
setLoading(true);
|
||||
@@ -137,83 +176,94 @@ export const ContentTypeRelationshipField: React.FunctionComponent<IContentTypeR
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Downshift
|
||||
ref={dsRef}
|
||||
onSelect={(selected) => onValueChange(selected || '')}
|
||||
itemToString={(item) => (item ? item : '')}
|
||||
>
|
||||
{({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps(undefined, { suppressRefError: true })}
|
||||
ref={inputRef}
|
||||
className={`metadata_field__choice`}
|
||||
>
|
||||
<button
|
||||
{...getToggleButtonProps({
|
||||
className: `metadata_field__choice__toggle`,
|
||||
disabled: availableChoices.length === 0
|
||||
})}
|
||||
>
|
||||
<span>{`Select ${label}`}</span>
|
||||
<ChevronDownIcon className="icon" />
|
||||
</button>
|
||||
<Combobox
|
||||
value={crntSelected}
|
||||
onChange={onSelect}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
|
||||
<ul
|
||||
className={`field_dropdown metadata_field__choice_list ${isOpen ? 'open' : 'closed'}`}
|
||||
style={{
|
||||
bottom: getDropdownStyle(isOpen)
|
||||
}}
|
||||
{...getMenuProps()}
|
||||
>
|
||||
{
|
||||
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`}>
|
||||
{l10n.t(LocalizationKey.commonClearValue)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div className="relative w-full cursor-default overflow-hidden text-left focus:outline-none border border-solid border-[var(--vscode-inputValidation-infoBorder)] rounded">
|
||||
<Combobox.Input
|
||||
className="w-full border-none py-2 pl-3 pr-10 leading-5 focus:ring-0 text-[var(--vscode-input-foreground)]"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
value={filter || ""}
|
||||
placeholder={l10n.t(LocalizationKey.panelFieldsChoiceFieldSelect, label)}
|
||||
ref={inputRef} />
|
||||
|
||||
<Combobox.Button
|
||||
className="absolute inset-y-0 right-0 flex items-center w-8 bg-inherit rounded-none text-[var(--vscode-input-foreground)] hover:text-[var(--vscode-button-foreground)]">
|
||||
<ChevronDownIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
|
||||
<FieldMessage
|
||||
name={label.toLowerCase()}
|
||||
description={description}
|
||||
showRequired={showRequiredState}
|
||||
/>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
afterLeave={() => setFilter('')}
|
||||
>
|
||||
<Combobox.Options
|
||||
className="field_dropdown absolute max-h-60 w-full shadow-lg overflow-auto py-1 px-0 space-y-1 text-base focus:outline-none z-50 bg-[var(--vscode-dropdown-background)] text-[var(--vscode-dropdown-foreground)] border border-solid border-[var(--vscode-dropdown-border)]"
|
||||
style={{
|
||||
bottom: getDropdownStyle(open)
|
||||
}}
|
||||
>
|
||||
{availableChoices.map((choice) => (
|
||||
<Combobox.Option
|
||||
key={choice.fmFilePath}
|
||||
value={getValue(choice, contentTypeValue)}
|
||||
className={({ active }) => `py-[var(--input-padding-vertical)] px-[var(--input-padding-horizontal)] list-none cursor-pointer hover:text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] ${active ? "text-[var(--vscode-button-foreground)] bg-[var(--vscode-button-hoverBackground)] " : ""}`}>
|
||||
{choice.title}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{availableChoices.length === 0 ? (
|
||||
<div className="relative cursor-default select-none text-center">
|
||||
{l10n.t(LocalizationKey.commonNoResults)}
|
||||
</div>
|
||||
) : null}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
<FieldMessage
|
||||
name={label.toLowerCase()}
|
||||
description={description}
|
||||
showRequired={showRequiredState}
|
||||
/>
|
||||
|
||||
{
|
||||
pages.length > 0 && (
|
||||
crntSelected instanceof Array
|
||||
? crntSelected.map((value: string) => (
|
||||
<ChoiceButton
|
||||
key={value}
|
||||
value={value}
|
||||
className='w-full mr-0 flex justify-between'
|
||||
title={getChoiceValue(value)}
|
||||
onClick={removeSelected}
|
||||
/>
|
||||
))
|
||||
: crntSelected && (
|
||||
<ChoiceButton
|
||||
key={crntSelected}
|
||||
value={crntSelected}
|
||||
className='w-full mr-0 flex justify-between'
|
||||
title={getChoiceValue(crntSelected)}
|
||||
onClick={removeSelected}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
};
|
||||
@@ -33,7 +33,6 @@ import { fieldWhenClause } from '../../../utils/fieldWhenClause';
|
||||
import { ContentTypeRelationshipField } from './ContentTypeRelationshipField';
|
||||
import * as l10n from '@vscode/l10n';
|
||||
import { LocalizationKey } from '../../../localization';
|
||||
import { ComboboxField } from './ComboboxField';
|
||||
|
||||
export interface IWrapperFieldProps {
|
||||
field: Field;
|
||||
@@ -486,18 +485,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
|
||||
} else if (field.type === 'contentRelationship') {
|
||||
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)}
|
||||
/> */}
|
||||
|
||||
<ComboboxField
|
||||
<ContentTypeRelationshipField
|
||||
label={field.title || field.name}
|
||||
description={field.description}
|
||||
value={fieldValue as string}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export default function useDropdownStyle(inputRef: React.MutableRefObject<HTMLInputElement | null>) {
|
||||
const bottomStyle = "calc(100% - 38px)";
|
||||
export default function useDropdownStyle(inputRef: React.MutableRefObject<HTMLInputElement | null>, inputHeight?: string) {
|
||||
const bottomStyle = `calc(100% - ${inputHeight || '38px'})`;
|
||||
const listItemHeight = 28;
|
||||
|
||||
const getDropdownStyle = useCallback((isOpen) => {
|
||||
|
||||
Reference in New Issue
Block a user