Merge branch 'combobox' into dev

This commit is contained in:
Elio Struyf
2023-11-01 14:42:39 +01:00
12 changed files with 233 additions and 129 deletions
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -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",
+25 -5
View File
@@ -12,7 +12,7 @@
"@actions/core": "^1.10.0",
"@bendera/vscode-webview-elements": "0.6.2",
"@estruyf/vscode": "^1.1.0",
"@headlessui/react": "1.5.0",
"@headlessui/react": "^1.7.17",
"@heroicons/react": "1.0.4",
"@iarna/toml": "2.2.3",
"@octokit/rest": "^18.12.0",
@@ -348,9 +348,13 @@
}
},
"node_modules/@headlessui/react": {
"version": "1.5.0",
"version": "1.7.17",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz",
"integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==",
"dev": true,
"license": "MIT",
"dependencies": {
"client-only": "^0.0.1"
},
"engines": {
"node": ">=10"
},
@@ -2960,6 +2964,12 @@
"node": ">= 4.0"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"dev": true
},
"node_modules/clipboardy": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
@@ -13314,9 +13324,13 @@
}
},
"@headlessui/react": {
"version": "1.5.0",
"version": "1.7.17",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.17.tgz",
"integrity": "sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==",
"dev": true,
"requires": {}
"requires": {
"client-only": "^0.0.1"
}
},
"@heroicons/react": {
"version": "1.0.4",
@@ -15272,6 +15286,12 @@
"source-map": "~0.6.0"
}
},
"client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"dev": true
},
"clipboardy": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz",
+1 -1
View File
@@ -2526,7 +2526,7 @@
"@actions/core": "^1.10.0",
"@bendera/vscode-webview-elements": "0.6.2",
"@estruyf/vscode": "^1.1.0",
"@headlessui/react": "1.5.0",
"@headlessui/react": "^1.7.17",
"@heroicons/react": "1.0.4",
"@iarna/toml": "2.2.3",
"@octokit/rest": "^18.12.0",
+4
View File
@@ -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,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>
);
};
)
};
+2 -2
View File
@@ -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) => {
+1
View File
@@ -5,6 +5,7 @@ import * as Sentry from '@sentry/react';
import { Integrations } from '@sentry/tracing';
import { SENTRY_LINK, SentryIgnore } from '../constants';
import { RecoilRoot } from 'recoil';
import './styles.css';
// require('@vscode/codicons/dist/codicon.css');
+3
View File
@@ -1,3 +1,6 @@
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
/* Animations */
@keyframes spin {
from {
+8
View File
@@ -0,0 +1,8 @@
const defaultConfig = require("./tailwind.config")
module.exports = {
...defaultConfig,
corePlugins: {
preflight: false,
}
}
+15 -1
View File
@@ -31,7 +31,21 @@ const config = [{
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader', 'postcss-loader']
use: ['style-loader', 'css-loader', {
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': {},
tailwindcss: {
config: path.resolve(__dirname, '../tailwind.panel.js')
},
autoprefixer: {},
},
},
},
}]
},
{
test: /\.m?js/,