From 2a5f1a7a2c1aab86e659a6354ac91427255180ad Mon Sep 17 00:00:00 2001 From: Polle Pas Date: Mon, 29 Apr 2024 15:56:09 +0200 Subject: [PATCH] #873 Add support for resource arrays in tables --- .github/pull_request_template.md | 10 +- browser/data-browser/package.json | 2 +- .../InlineFormattedResourceList.tsx | 7 + .../src/components/TableEditor/Cell.tsx | 9 +- .../TablePage/EditorCells/AtomicURLCell.tsx | 137 ++------ .../TablePage/EditorCells/CellComponents.tsx | 63 ++++ .../EditorCells/MultiRelationCell.tsx | 300 ++++++++++++++++++ .../EditorCells/ResourceArrayCell.tsx | 279 ++-------------- .../EditorCells/ResourceCells/AgentCell.tsx | 5 +- .../EditorCells/ResourceCells/FileCell.tsx | 5 +- .../ResourceCells/ResourceCell.tsx | 26 ++ .../TablePage/EditorCells/SelectCell.tsx | 253 +++++++++++++++ .../src/views/TablePage/EditorCells/Type.ts | 2 +- .../EditorCells/useResourceSearch.ts | 12 + .../PropertyForm/EditPropertyDialog.tsx | 10 +- .../PropertyForm/NewPropertyDialog.tsx | 3 +- .../PropertyForm/PropertyCategoryFormProps.ts | 4 +- .../TablePage/PropertyForm/PropertyForm.tsx | 75 +---- .../PropertyForm/RelationPropertyForm.tsx | 29 +- .../PropertyForm/SelectPropertyForm.tsx | 13 +- .../TablePage/PropertyForm/categories.tsx | 82 +++++ 21 files changed, 862 insertions(+), 464 deletions(-) create mode 100644 browser/data-browser/src/views/TablePage/EditorCells/CellComponents.tsx create mode 100644 browser/data-browser/src/views/TablePage/EditorCells/MultiRelationCell.tsx create mode 100644 browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/ResourceCell.tsx create mode 100644 browser/data-browser/src/views/TablePage/EditorCells/SelectCell.tsx create mode 100644 browser/data-browser/src/views/TablePage/PropertyForm/categories.tsx diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f7a8f5dbb..7106810e7 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,8 @@ -PR Checklist: +## Related Issues -- [ ] Link to related issues: #number +closes #number + +## Checklist - [ ] Add changelog entry linking to issue, describe API changes -- [ ] Add tests (optional) -- [ ] (If new feature) add to description / readme +- [ ] Add or update tests if needed +- [ ] Update docs if needed diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index 0c567c487..aa2c1d7f5 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -78,6 +78,6 @@ "preview": "vite preview", "start": "vite", "test": "jest", - "typecheck": "pnpm exec tsc --noEmit" + "typecheck": "npx tsc --noEmit" } } diff --git a/browser/data-browser/src/components/InlineFormattedResourceList.tsx b/browser/data-browser/src/components/InlineFormattedResourceList.tsx index fabb5542f..fb6d5a700 100644 --- a/browser/data-browser/src/components/InlineFormattedResourceList.tsx +++ b/browser/data-browser/src/components/InlineFormattedResourceList.tsx @@ -2,6 +2,8 @@ import { ResourceInline } from '../views/ResourceInline'; interface InlineFormattedResourceListProps { subjects: string[]; + /** Optional component to render items instead of an inline resource */ + RenderComp?: React.FC<{ subject: string }>; } const formatter = new Intl.ListFormat('en-GB', { @@ -11,6 +13,7 @@ const formatter = new Intl.ListFormat('en-GB', { export function InlineFormattedResourceList({ subjects, + RenderComp, }: InlineFormattedResourceListProps): JSX.Element { // There are rare cases where a resource array can locally have an undefined value, we filter these out to prevent the formatter from throwing an error. const filteredSubjects = subjects.filter(subject => subject !== undefined); @@ -22,6 +25,10 @@ export function InlineFormattedResourceList({ return value; } + if (RenderComp) { + return ; + } + return ; })} diff --git a/browser/data-browser/src/components/TableEditor/Cell.tsx b/browser/data-browser/src/components/TableEditor/Cell.tsx index 6f2239e33..3f1568470 100644 --- a/browser/data-browser/src/components/TableEditor/Cell.tsx +++ b/browser/data-browser/src/components/TableEditor/Cell.tsx @@ -136,7 +136,14 @@ export function Cell({ setCursorMode(CursorMode.Visual); setActiveCell(rowIndex, columnIndex); }, - [setActiveCell, columnIndex, shouldEnterEditMode, cursorMode, isActive], + [ + setActiveCell, + columnIndex, + shouldEnterEditMode, + cursorMode, + isActive, + disabledKeyboardInteractions, + ], ); const handleClick = useCallback(() => { diff --git a/browser/data-browser/src/views/TablePage/EditorCells/AtomicURLCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/AtomicURLCell.tsx index fa32c35f6..cd34847ae 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/AtomicURLCell.tsx +++ b/browser/data-browser/src/views/TablePage/EditorCells/AtomicURLCell.tsx @@ -5,36 +5,25 @@ import { core, server, unknownSubject, - urls, useArray, useResource, useString, useTitle, } from '@tomic/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { FaEdit, FaTimes } from 'react-icons/fa'; -import * as RadixPopover from '@radix-ui/react-popover'; +import { FaEdit } from 'react-icons/fa'; import { styled } from 'styled-components'; import { FileDropzoneInput } from '../../../components/forms/FileDropzone/FileDropzoneInput'; import { InputStyled, InputWrapper, } from '../../../components/forms/InputStyles'; -import { Popover } from '../../../components/Popover'; import { CursorMode, useTableEditorContext, } from '../../../components/TableEditor/TableEditorContext'; import { getIconForClass } from '../../FolderPage/iconMap'; -import { AgentCell } from './ResourceCells/AgentCell'; -import { FileCell } from './ResourceCells/FileCell'; -import { SimpleResourceLink } from './ResourceCells/SimpleResourceLink'; -import { - CellContainer, - DisplayCellProps, - EditCellProps, - ResourceCellProps, -} from './Type'; +import { CellContainer, DisplayCellProps, EditCellProps } from './Type'; import { useResourceSearch } from './useResourceSearch'; import { IconButton } from '../../../components/IconButton/IconButton'; import { AtomicLink } from '../../../components/AtomicLink'; @@ -42,12 +31,19 @@ import { KeyboardInteraction, useCellOptions, } from '../../../components/TableEditor'; +import { ResourceCell } from './ResourceCells/ResourceCell'; +import { + PopoverTrigger, + SearchPopover, + SearchResultWrapper, +} from './CellComponents'; +import { FaXmark } from 'react-icons/fa6'; const useClassType = (subject: string) => { const property = useResource(subject); const classType = useResource(property.props.classtype); - const hasClassType = classType?.getSubject() !== unknownSubject; + const hasClassType = classType?.subject !== unknownSubject; return { classType, @@ -111,28 +107,11 @@ function AtomicURLCellEdit({ const { results, selectedIndex, handleKeyDown } = useResourceSearch( searchValue, - hasClassType ? classType.getSubject() : undefined, + hasClassType ? classType.subject : undefined, + setOpen, handleResultClick, ); - const modifiedHandleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault(); - setOpen(false); - - return; - } - - if (e.key === 'Tab') { - return; - } - - handleKeyDown(e); - }, - [handleKeyDown], - ); - const handleFilesUploaded = useCallback( (files: string[]) => { const file = files[0]; @@ -149,7 +128,7 @@ function AtomicURLCellEdit({ return ( {' '} - {cell.getSubject() === unknownSubject + {cell.subject === unknownSubject ? `select ${hasClassType ? classType.title : 'resource'}` : title} @@ -158,16 +137,16 @@ function AtomicURLCellEdit({ useEffect(() => { if (selectedElement.current) { - selectedElement.current.scrollIntoView(false); + selectedElement.current.scrollIntoView({ block: 'nearest' }); } }, [selectedIndex]); const placehoder = hasClassType ? `Search ${classType.title}` : 'Search...'; const showFileDropzone = - results.length === 0 && classType.getSubject() === urls.classes.file; + results.length === 0 && classType.subject === server.classes.file; const showNoResults = - results.length === 0 && classType.getSubject() !== urls.classes.file; + results.length === 0 && classType.subject !== server.classes.file; return ( - + {results.length > 0 && (
    {results.map((result, index) => ( @@ -208,7 +187,7 @@ function AtomicURLCellEdit({ onFilesUploaded={handleFilesUploaded} /> )} - + ); } @@ -216,27 +195,11 @@ function AtomicURLCellEdit({ function AtomicURLCellDisplay({ value, }: DisplayCellProps): JSX.Element { - const resource = useResource(value as string); - if (!value) { return <>; } - const Comp = resource.matchClass( - { - [core.classes.agent]: AgentCell, - [server.classes.file]: FileCell, - }, - BasicCell, - ); - - return ; -} - -function BasicCell({ resource }: ResourceCellProps) { - const [title] = useTitle(resource); - - return {title}; + return ; } interface ResultProps { @@ -247,7 +210,7 @@ interface ResultProps { function Result({ subject, onClick }: ResultProps) { const resource = useResource(subject); const [title] = useTitle(resource); - const [[classType]] = useArray(resource, urls.properties.isA); + const [[classType]] = useArray(resource, core.properties.isA); const Icon = getIconForClass(classType); @@ -276,13 +239,10 @@ function FileUploadContainer({ row, onChange, }: FileUploadContainerProps): JSX.Element { - const [mimeType] = useString(cellResource, urls.properties.file.mimetype); - const [downloadUrl] = useString( - cellResource, - urls.properties.file.downloadUrl, - ); - const [filename] = useString(cellResource, urls.properties.file.filename); - const [description] = useString(cellResource, urls.properties.description); + const [mimeType] = useString(cellResource, server.properties.mimetype); + const [downloadUrl] = useString(cellResource, server.properties.downloadUrl); + const [filename] = useString(cellResource, server.properties.filename); + const [description] = useString(cellResource, core.properties.description); const isImage = mimeType?.startsWith('image/'); @@ -301,10 +261,10 @@ function FileUploadContainer({ )} {!isImage ? ( - {filename} + {filename} ) : null} onChange(undefined)}> - + ); @@ -340,53 +300,10 @@ const ResultButton = styled.button` } `; -const SearchPopover = styled(Popover)` - padding: 1rem; - border: 1px solid ${p => p.theme.colors.bg2}; - display: flex; - flex-direction: column; - gap: 1rem; -`; - -const ResultWrapper = styled.div` - height: min(90vh, 20rem); - width: min(90vw, 35rem); - overflow-x: hidden; - overflow-y: auto; - - ol { - padding: 0; - margin: 0; - } - - li { - list-style: none; - &[data-selected='true'] button { - background: ${p => p.theme.colors.main}; - color: white; - - svg { - color: white; - } - } - } -`; - const StyledFileDropzoneInput = styled(FileDropzoneInput)` height: 100%; `; -const PopoverTrigger = styled(RadixPopover.Trigger)` - border: none; - background: none; - color: ${p => p.theme.colors.main}; - display: inline-flex; - gap: 1ch; - align-items: center; - user-select: none; - cursor: pointer; -`; - const ViewerWrapper = styled.div` display: flex; justify-content: center; diff --git a/browser/data-browser/src/views/TablePage/EditorCells/CellComponents.tsx b/browser/data-browser/src/views/TablePage/EditorCells/CellComponents.tsx new file mode 100644 index 000000000..f5a0a70c0 --- /dev/null +++ b/browser/data-browser/src/views/TablePage/EditorCells/CellComponents.tsx @@ -0,0 +1,63 @@ +import { styled } from 'styled-components'; +import * as RadixPopover from '@radix-ui/react-popover'; +import { Popover } from '../../../components/Popover'; + +export const AbsoluteCell = styled.div` + position: absolute; + display: flex; + align-items: center; + z-index: 10; + left: 0; + top: 0; + background-color: ${p => p.theme.colors.bg}; + box-shadow: ${p => p.theme.boxShadowSoft}; + border: 2px solid ${p => p.theme.colors.main}; + height: fit-content; + width: 100%; + padding-inline: var(--table-inner-padding); + padding-block: 3px; + min-height: 40px; +`; + +export const SearchPopover = styled(Popover)` + padding: 1rem; + border: 1px solid ${p => p.theme.colors.bg2}; + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const SearchResultWrapper = styled.div` + height: min(90vh, 20rem); + width: min(90vw, 35rem); + overflow-x: hidden; + overflow-y: auto; + + ol { + padding: 0; + margin: 0; + } + + li { + list-style: none; + &[data-selected='true'] button { + background: ${p => p.theme.colors.main}; + color: white; + + svg { + color: white; + } + } + } +`; + +export const PopoverTrigger = styled(RadixPopover.Trigger)` + border: none; + background: none; + color: ${p => p.theme.colors.main}; + display: inline-flex; + gap: 1ch; + align-items: center; + user-select: none; + cursor: pointer; +`; diff --git a/browser/data-browser/src/views/TablePage/EditorCells/MultiRelationCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/MultiRelationCell.tsx new file mode 100644 index 000000000..95945099e --- /dev/null +++ b/browser/data-browser/src/views/TablePage/EditorCells/MultiRelationCell.tsx @@ -0,0 +1,300 @@ +import { + Core, + JSONValue, + unknownSubject, + urls, + useArray, + useResource, + useTitle, +} from '@tomic/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { styled } from 'styled-components'; +import { + InputStyled, + InputWrapper, +} from '../../../components/forms/InputStyles'; +import { useTableEditorContext } from '../../../components/TableEditor/TableEditorContext'; +import { getIconForClass } from '../../FolderPage/iconMap'; +import { CellContainer, DisplayCellProps, EditCellProps } from './Type'; +import { useResourceSearch } from './useResourceSearch'; +import { IconButton } from '../../../components/IconButton/IconButton'; +import { + KeyboardInteraction, + useCellOptions, +} from '../../../components/TableEditor'; +import { InlineFormattedResourceList } from '../../../components/InlineFormattedResourceList'; +import { FaPlus, FaXmark } from 'react-icons/fa6'; +import { + AbsoluteCell, + PopoverTrigger, + SearchPopover, + SearchResultWrapper, +} from './CellComponents'; +import { Row } from '../../../components/Row'; +import { CellOptions } from '../../../components/TableEditor/hooks/useCellOptions'; +import { Checkbox } from '../../../components/forms/Checkbox'; +import { ResourceCell } from './ResourceCells/ResourceCell'; +import { AtomicLink } from '../../../components/AtomicLink'; + +const useClassType = (subject: string) => { + const property = useResource(subject); + + const classType = useResource(property.props.classtype); + const hasClassType = classType?.subject !== unknownSubject; + + return { + classType, + hasClassType, + }; +}; + +function MultiRelationCellEdit({ + value, + onChange, + property, +}: EditCellProps): JSX.Element { + const val = Array.isArray(value) ? value : []; + + const { classType, hasClassType } = useClassType(property); + const [open, setOpen] = useState(true); + const { setCursorMode, activeCellRef } = useTableEditorContext(); + const selectedElement = useRef(null); + + const [searchValue, setSearchValue] = useState(''); + + const cellOptions = useMemo((): CellOptions => { + const disabledKeyboardInteractions = new Set([ + KeyboardInteraction.EditNextRow, + ]); + + if (open) { + disabledKeyboardInteractions.add(KeyboardInteraction.ExitEditMode); + } + + return { + disabledKeyboardInteractions, + hideActiveIndicator: true, + }; + }, [val, open]); + + useCellOptions(cellOptions); + + const handleChange = useCallback((e: React.ChangeEvent) => { + e.preventDefault(); + e.stopPropagation(); + setSearchValue(e.target.value); + }, []); + + const handleResultClick = useCallback( + (result: string) => { + if (!result) return; + + if (val.includes(result)) { + onChange(val.filter(v => v !== result)); + } else { + onChange([...val, result]); + } + }, + [onChange, val], + ); + + const handleRemoveItem = (subject: string) => { + onChange(val.filter(v => v !== subject)); + }; + + const handleOpenChange = useCallback( + (state: boolean) => { + setOpen(state); + }, + [setCursorMode], + ); + + const { results, selectedIndex, handleKeyDown } = useResourceSearch( + searchValue, + hasClassType ? classType.subject : undefined, + setOpen, + handleResultClick, + ); + + const Trigger = useMemo(() => { + return ( + + + + + + ); + }, []); + + useEffect(() => { + if (!open) { + activeCellRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (selectedElement.current) { + selectedElement.current.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + + const placehoder = hasClassType ? `Search ${classType.title}` : 'Search...'; + + const showNoResults = + results.length === 0 && classType.subject !== urls.classes.file; + + return ( + + + {(value as string[])?.map(subject => ( + + ))} + + + + + + {results.length > 0 && ( +
      + {results.map((result, index) => ( +
    1. + +
    2. + ))} +
    + )} + {showNoResults && 'No results'} +
    +
    +
    +
    + ); +} + +interface ResourceItemButtonProps { + subject: string; + onRemove: (subject: string) => void; +} + +function ResourceItemButton({ + subject, + onRemove, +}: ResourceItemButtonProps): JSX.Element { + const resource = useResource(subject); + + return ( + + + {resource.title} + + onRemove(subject)} + > + + + + ); +} + +function MultiRelationCellDisplay({ + value, +}: DisplayCellProps): JSX.Element { + if (!value || !Array.isArray(value)) { + return <>; + } + + return ( +
    + +
    + ); +} + +interface ResultProps { + subject: string; + onClick: (subject: string) => void; + selected: boolean; +} + +function Result({ subject, onClick, selected }: ResultProps) { + const resource = useResource(subject); + const [title] = useTitle(resource); + const [[classType]] = useArray(resource, urls.properties.isA); + + const Icon = getIconForClass(classType); + + return ( + onClick(subject)} tabIndex={-1}> + undefined}> + + {title} + + ); +} + +export const MultiRelationCell: CellContainer = { + Edit: MultiRelationCellEdit, + Display: MultiRelationCellDisplay, +}; + +const ResourceItemButtonWrapper = styled.span` + display: inline-flex; + padding-inline: 1ch; + align-items: center; + border: 1px solid ${p => p.theme.colors.main}; + color: ${p => p.theme.colors.mainDark}; + + border-radius: ${p => p.theme.radius}; +`; + +const ResultButton = styled.button` + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + background: none; + border: none; + color: currentColor; + cursor: pointer; + padding: 0.3rem; + border-radius: ${p => p.theme.radius}; + &:hover { + background: ${p => p.theme.colors.main}; + color: white; + + svg { + color: white; + } + } + + svg { + color: ${p => p.theme.colors.textLight}; + } +`; diff --git a/browser/data-browser/src/views/TablePage/EditorCells/ResourceArrayCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/ResourceArrayCell.tsx index 658760569..c1b9b6d21 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/ResourceArrayCell.tsx +++ b/browser/data-browser/src/views/TablePage/EditorCells/ResourceArrayCell.tsx @@ -1,271 +1,30 @@ -import { - core, - JSONValue, - Store, - useArray, - useResource, - useStore, -} from '@tomic/react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { FaPlus, FaTimes } from 'react-icons/fa'; -import * as RadixPopover from '@radix-ui/react-popover'; -import { styled } from 'styled-components'; -import { IconButton } from '../../../components/IconButton/IconButton'; -import { Popover } from '../../../components/Popover'; -import { SelectableTag, Tag } from '../../../components/Tag'; +import { JSONValue, useResource } from '@tomic/react'; import { CellContainer, DisplayCellProps, EditCellProps } from './Type'; -import { - InputStyled, - InputWrapper, -} from '../../../components/forms/InputStyles'; -import { Row } from '../../../components/Row'; -import { stringToSlug } from '../../../helpers/stringToSlug'; -import { loopingIndex } from '../../../helpers/loopingIndex'; -import { fadeIn } from '../../../helpers/commonAnimations'; -import { - KeyboardInteraction, - useCellOptions, -} from '../../../components/TableEditor'; -import { useTableEditorContext } from '../../../components/TableEditor/TableEditorContext'; -import { CellOptions } from '../../../components/TableEditor/hooks/useCellOptions'; +import { getCategoryFromResource } from '../PropertyForm/categories'; +import { SelectCell } from './SelectCell'; +import { MultiRelationCell } from './MultiRelationCell'; -const TAG_SPACING = '0.5rem'; +function ResourceArrayCellEdit(props: EditCellProps): JSX.Element { + const propResource = useResource(props.property); -const emptyArray: string[] = []; - -function buildListWithTitles( - store: Store, - subjects: string[], - ignore: string[], -): { subject: string; title: string }[] { - return subjects - .filter(v => !ignore.includes(v)) - .map(subject => { - const resource = store.getResourceLoading(subject); - const title = resource?.get(core.properties.shortname) ?? subject; - - return { subject, title: title as string }; - }); -} - -function ResourceArrayCellEdit({ - value, - property, - onChange, -}: EditCellProps): JSX.Element { - const val = (value as string[]) ?? emptyArray; - - const store = useStore(); - const propertyResource = useResource(property); - const [allowsOnly] = useArray(propertyResource, core.properties.allowsOnly); - const [query, setQuery] = useState(''); - const filteredTags = useMemo(() => { - const listWithTitles = buildListWithTitles(store, allowsOnly, val); - - return listWithTitles - .filter(v => v.title.includes(query)) - .map(ft => ft.subject); - }, [store, allowsOnly, val, query]); - const [open, setOpen] = useState(true); - const [selectedIndex, setSelectedIndex] = useState(0); - - const { activeCellRef } = useTableEditorContext(); - - const cellOptions = useMemo((): CellOptions => { - const disabledKeyboardInteractions = new Set([ - KeyboardInteraction.EditNextRow, - ]); - - if (open) { - disabledKeyboardInteractions.add(KeyboardInteraction.ExitEditMode); - } - - return { - disabledKeyboardInteractions, - hideActiveIndicator: true, - }; - }, [val, open]); - - useCellOptions(cellOptions); - - // const listWithTitles = useMemo( - // () => buildListWithTitles(store, allowsOnly, val), - // [allowsOnly, val], - // ); - - const handleSearch = useCallback((e: React.ChangeEvent) => { - setQuery(stringToSlug(e.target.value)); - setSelectedIndex(0); - }, []); - - const handleAddTag = useCallback( - (subject: string) => { - onChange(Array.from(new Set([...val, subject]))); - }, - [val, onChange], - ); - - const handleRemoveTag = useCallback( - (subject: string) => { - onChange(val.filter(tagSubject => tagSubject !== subject)); - }, - [val, onChange], - ); - - const changeSelection = useCallback( - (mod: number) => { - setSelectedIndex(prev => loopingIndex(prev + mod, filteredTags.length)); - }, - [filteredTags], - ); - - useEffect(() => { - if (!open) { - activeCellRef.current?.focus(); - } - }, [open]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - changeSelection(-1); - break; - case 'ArrowDown': - e.preventDefault(); - changeSelection(1); - break; - case 'Enter': - e.preventDefault(); - handleAddTag(filteredTags[selectedIndex]); - break; - case 'Escape': - e.preventDefault(); - - setOpen(false); - break; - } - }, - [changeSelection, filteredTags, selectedIndex, open], - ); - - return ( - - - {val.map(v => ( - - handleRemoveTag(v)} - > - - - - ))} - - - - } - > - - - - - - - {filteredTags.map((v, i) => ( - - ))} - - - - - - - ); -} - -function ResourceArrayCellDisplay({ - value, -}: DisplayCellProps): JSX.Element { - if (!value) { - return <>; + if (getCategoryFromResource(propResource) === 'select') { + return ; + } else { + return ; } - - return ( - - {(value as string[]).map(v => ( - - ))} - - ); } -const StyledIcon = styled(FaPlus)` - animation: ${fadeIn} 0.1s ease-in-out; - color: ${p => p.theme.colors.textLight}; -`; +function ResourceArrayCellDisplay( + props: DisplayCellProps, +): JSX.Element { + const property = useResource(props.property); -const TagIconButton = styled(IconButton)` - height: unset; - width: unset; - padding: unset; - - color: var(--tag-dark-color); - background-blend-mode: lighten; - - &:not([disabled]):hover, - &:not([disabled]):focus { - transform: scale(1.2); - background-color: unset; + if (getCategoryFromResource(property) === 'select') { + return ; + } else { + return ; } -`; - -const AbsoluteCell = styled.div` - position: absolute; - display: flex; - align-items: center; - z-index: 10; - left: 0; - top: 0; - background-color: ${p => p.theme.colors.bg}; - box-shadow: ${p => p.theme.boxShadowSoft}; - border: 2px solid ${p => p.theme.colors.main}; - height: fit-content; - width: 100%; - padding-inline: var(--table-inner-padding); - padding-block: 3px; - min-height: 40px; -`; - -const Content = styled.div` - width: min(40ch, 90vh); - border-radius: ${p => p.theme.radius}; -`; - -const ResultWrapper = styled.div` - padding: ${p => p.theme.margin}rem; -`; - -const SearchInputWrapper = styled(InputWrapper)` - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -`; +} export const ResourceArrayCell: CellContainer = { Edit: ResourceArrayCellEdit, diff --git a/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/AgentCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/AgentCell.tsx index aefdcfab6..529632b53 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/AgentCell.tsx +++ b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/AgentCell.tsx @@ -1,11 +1,12 @@ -import { useTitle } from '@tomic/react'; +import { useResource, useTitle } from '@tomic/react'; import { complement, setLightness } from 'polished'; import { styled } from 'styled-components'; import { ResourceCellProps } from '../Type'; import { SimpleResourceLink } from './SimpleResourceLink'; -export function AgentCell({ resource }: ResourceCellProps) { +export function AgentCell({ subject }: ResourceCellProps) { + const resource = useResource(subject); const [title] = useTitle(resource); return ( diff --git a/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/FileCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/FileCell.tsx index 430df7961..075afd7a2 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/FileCell.tsx +++ b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/FileCell.tsx @@ -1,11 +1,12 @@ -import { properties, useString, useTitle } from '@tomic/react'; +import { properties, useResource, useString, useTitle } from '@tomic/react'; import { styled } from 'styled-components'; import { getFileIcon, imageMimeTypes } from '../../../../helpers/filetypes'; import { ResourceCellProps } from '../Type'; import { SimpleResourceLink } from './SimpleResourceLink'; -export function FileCell({ resource }: ResourceCellProps) { +export function FileCell({ subject }: ResourceCellProps) { + const resource = useResource(subject); const [title] = useTitle(resource); const [mimeType] = useString(resource, properties.file.mimetype); const [downloadUrl] = useString(resource, properties.file.downloadUrl); diff --git a/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/ResourceCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/ResourceCell.tsx new file mode 100644 index 000000000..586945625 --- /dev/null +++ b/browser/data-browser/src/views/TablePage/EditorCells/ResourceCells/ResourceCell.tsx @@ -0,0 +1,26 @@ +import { useResource, core, server, useTitle } from '@tomic/react'; +import { ResourceCellProps } from '../Type'; +import { AgentCell } from './AgentCell'; +import { FileCell } from './FileCell'; +import { SimpleResourceLink } from './SimpleResourceLink'; + +export function ResourceCell({ subject }: ResourceCellProps) { + const resource = useResource(subject); + + const Comp = resource.matchClass( + { + [core.classes.agent]: AgentCell, + [server.classes.file]: FileCell, + }, + BasicCell, + ); + + return ; +} + +function BasicCell({ subject }: ResourceCellProps) { + const resource = useResource(subject); + const [title] = useTitle(resource); + + return {title}; +} diff --git a/browser/data-browser/src/views/TablePage/EditorCells/SelectCell.tsx b/browser/data-browser/src/views/TablePage/EditorCells/SelectCell.tsx new file mode 100644 index 000000000..1fa904b2d --- /dev/null +++ b/browser/data-browser/src/views/TablePage/EditorCells/SelectCell.tsx @@ -0,0 +1,253 @@ +import { + core, + JSONValue, + Store, + useArray, + useResource, + useStore, +} from '@tomic/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import * as RadixPopover from '@radix-ui/react-popover'; +import { styled } from 'styled-components'; +import { IconButton } from '../../../components/IconButton/IconButton'; +import { Popover } from '../../../components/Popover'; +import { SelectableTag, Tag } from '../../../components/Tag'; +import { CellContainer, DisplayCellProps, EditCellProps } from './Type'; +import { + InputStyled, + InputWrapper, +} from '../../../components/forms/InputStyles'; +import { Row } from '../../../components/Row'; +import { stringToSlug } from '../../../helpers/stringToSlug'; +import { loopingIndex } from '../../../helpers/loopingIndex'; +import { fadeIn } from '../../../helpers/commonAnimations'; +import { + KeyboardInteraction, + useCellOptions, +} from '../../../components/TableEditor'; +import { useTableEditorContext } from '../../../components/TableEditor/TableEditorContext'; +import { CellOptions } from '../../../components/TableEditor/hooks/useCellOptions'; +import { AbsoluteCell } from './CellComponents'; +import { FaXmark } from 'react-icons/fa6'; + +const TAG_SPACING = '0.5rem'; + +const emptyArray: string[] = []; + +function buildListWithTitles( + store: Store, + subjects: string[], + ignore: string[], +): { subject: string; title: string }[] { + return subjects + .filter(v => !ignore.includes(v)) + .map(subject => { + const resource = store.getResourceLoading(subject); + const title = resource?.get(core.properties.shortname) ?? subject; + + return { subject, title: title as string }; + }); +} + +function SelectCellEdit({ + value, + property, + onChange, +}: EditCellProps): JSX.Element { + const val = (value as string[]) ?? emptyArray; + + const store = useStore(); + const propertyResource = useResource(property); + const [allowsOnly] = useArray(propertyResource, core.properties.allowsOnly); + const [query, setQuery] = useState(''); + const filteredTags = useMemo(() => { + const listWithTitles = buildListWithTitles(store, allowsOnly, val); + + return listWithTitles + .filter(v => v.title.includes(query)) + .map(ft => ft.subject); + }, [store, allowsOnly, val, query]); + const [open, setOpen] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + + const { activeCellRef } = useTableEditorContext(); + + const cellOptions = useMemo((): CellOptions => { + const disabledKeyboardInteractions = new Set([ + KeyboardInteraction.EditNextRow, + ]); + + if (open) { + disabledKeyboardInteractions.add(KeyboardInteraction.ExitEditMode); + } + + return { + disabledKeyboardInteractions, + hideActiveIndicator: true, + }; + }, [val, open]); + + useCellOptions(cellOptions); + + const handleSearch = useCallback((e: React.ChangeEvent) => { + setQuery(stringToSlug(e.target.value)); + setSelectedIndex(0); + }, []); + + const handleAddTag = useCallback( + (subject: string) => { + onChange(Array.from(new Set([...val, subject]))); + }, + [val, onChange], + ); + + const handleRemoveTag = useCallback( + (subject: string) => { + onChange(val.filter(tagSubject => tagSubject !== subject)); + }, + [val, onChange], + ); + + const changeSelection = useCallback( + (mod: number) => { + setSelectedIndex(prev => loopingIndex(prev + mod, filteredTags.length)); + }, + [filteredTags], + ); + + useEffect(() => { + if (!open) { + activeCellRef.current?.focus(); + } + }, [open]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + changeSelection(-1); + break; + case 'ArrowDown': + e.preventDefault(); + changeSelection(1); + break; + case 'Enter': + e.preventDefault(); + handleAddTag(filteredTags[selectedIndex]); + break; + case 'Escape': + e.preventDefault(); + + setOpen(false); + break; + } + }, + [changeSelection, filteredTags, selectedIndex, open], + ); + + return ( + + + {val.map(v => ( + + handleRemoveTag(v)} + > + + + + ))} + + + + } + > + + + + + + + {filteredTags.map((v, i) => ( + + ))} + + + + + + + ); +} + +function SelectCellDisplay({ + value, +}: DisplayCellProps): JSX.Element { + if (!value) { + return <>; + } + + return ( + + {(value as string[]).map(v => ( + + ))} + + ); +} + +const StyledIcon = styled(FaPlus)` + animation: ${fadeIn} 0.1s ease-in-out; + color: ${p => p.theme.colors.textLight}; +`; + +const TagIconButton = styled(IconButton)` + height: unset; + width: unset; + padding: unset; + + color: var(--tag-dark-color); + background-blend-mode: lighten; + + &:not([disabled]):hover, + &:not([disabled]):focus { + transform: scale(1.2); + background-color: unset; + } +`; + +const Content = styled.div` + width: min(40ch, 90vh); + border-radius: ${p => p.theme.radius}; +`; + +const ResultWrapper = styled.div` + padding: ${p => p.theme.margin}rem; +`; + +const SearchInputWrapper = styled(InputWrapper)` + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +`; + +export const SelectCell: CellContainer = { + Edit: SelectCellEdit, + Display: SelectCellDisplay, +}; diff --git a/browser/data-browser/src/views/TablePage/EditorCells/Type.ts b/browser/data-browser/src/views/TablePage/EditorCells/Type.ts index 1a2511ca0..714a409ab 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/Type.ts +++ b/browser/data-browser/src/views/TablePage/EditorCells/Type.ts @@ -19,5 +19,5 @@ export type CellContainer = { }; export interface ResourceCellProps { - resource: Resource; + subject: string; } diff --git a/browser/data-browser/src/views/TablePage/EditorCells/useResourceSearch.ts b/browser/data-browser/src/views/TablePage/EditorCells/useResourceSearch.ts index 90ebe736c..e628eec18 100644 --- a/browser/data-browser/src/views/TablePage/EditorCells/useResourceSearch.ts +++ b/browser/data-browser/src/views/TablePage/EditorCells/useResourceSearch.ts @@ -5,6 +5,7 @@ import { useSettings } from '../../../helpers/AppSettings'; export function useResourceSearch( searchValue: string, classType: string | undefined, + setOpen: (state: boolean) => void, onResultPick: (result: string) => void, ) { const [selectedIndex, setSelectedIndex] = useState(0); @@ -21,6 +22,17 @@ export function useResourceSearch( const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + setOpen(false); + + return; + } + + if (e.key === 'Tab') { + return; + } + e.stopPropagation(); if (e.key === 'ArrowUp') { diff --git a/browser/data-browser/src/views/TablePage/PropertyForm/EditPropertyDialog.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/EditPropertyDialog.tsx index 069e1cf00..1b47c3f9d 100644 --- a/browser/data-browser/src/views/TablePage/PropertyForm/EditPropertyDialog.tsx +++ b/browser/data-browser/src/views/TablePage/PropertyForm/EditPropertyDialog.tsx @@ -1,6 +1,6 @@ -import { Resource, core, useString } from '@tomic/react'; +import { Resource } from '@tomic/react'; import { useCallback, useEffect, useState } from 'react'; -import { PropertyForm, getCategoryFromDatatype } from './PropertyForm'; +import { PropertyForm } from './PropertyForm'; import { FormValidationContextProvider } from '../../../components/forms/formValidation/FormValidationContextProvider'; import { Dialog, @@ -10,6 +10,7 @@ import { useDialog, } from '../../../components/Dialog'; import { Button } from '../../../components/Button'; +import { getCategoryFromResource } from './categories'; interface EditPropertyDialogProps { resource: Resource; @@ -24,9 +25,7 @@ export function EditPropertyDialog({ }: EditPropertyDialogProps): JSX.Element { const [valid, setValid] = useState(true); - const [datatype] = useString(resource, core.properties.datatype); - - const category = getCategoryFromDatatype(datatype); + const category = getCategoryFromResource(resource); const onSuccess = useCallback(() => { resource.save(); @@ -54,6 +53,7 @@ export function EditPropertyDialog({ ; } diff --git a/browser/data-browser/src/views/TablePage/PropertyForm/PropertyForm.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/PropertyForm.tsx index c8f977076..723fcc7e8 100644 --- a/browser/data-browser/src/views/TablePage/PropertyForm/PropertyForm.tsx +++ b/browser/data-browser/src/views/TablePage/PropertyForm/PropertyForm.tsx @@ -1,4 +1,4 @@ -import { Resource, urls, useString } from '@tomic/react'; +import { core, Resource, useString } from '@tomic/react'; import { useCallback, useEffect, useMemo } from 'react'; import { styled } from 'styled-components'; import { ErrorChip } from '../../../components/forms/ErrorChip'; @@ -7,76 +7,20 @@ import { InputStyled, InputWrapper, } from '../../../components/forms/InputStyles'; -import { buildComponentFactory } from '../../../helpers/buildComponentFactory'; import { stringToSlug } from '../../../helpers/stringToSlug'; -import { CheckboxPropertyForm } from './CheckboxPropertyForm'; -import { DatePropertyForm } from './DatePropertyForm'; -import { FilePropertyForm } from './FilePropertyForm'; -import { NumberPropertyForm } from './NumberPropertyForm'; -import { RelationPropertyForm } from './RelationPropertyForm'; -import { SelectPropertyForm } from './SelectPropertyForm'; -import { TextPropertyForm } from './TextPropertyForm'; - -export type PropertyFormCategory = - | 'text' - | 'number' - | 'date' - | 'checkbox' - | 'file' - | 'select' - | 'relation'; +import { categoryFormFactory, PropertyFormCategory } from './categories'; interface PropertyFormProps { onSubmit: () => void; resource: Resource; category?: PropertyFormCategory; + existingProperty?: boolean; } -export const getCategoryFromDatatype = ( - datatype: string | undefined, -): PropertyFormCategory => { - switch (datatype) { - case urls.datatypes.string: - case urls.datatypes.markdown: - case urls.datatypes.slug: - return 'text'; - case urls.datatypes.integer: - case urls.datatypes.float: - return 'number'; - case urls.datatypes.boolean: - return 'checkbox'; - case urls.datatypes.date: - case urls.datatypes.timestamp: - return 'date'; - case urls.datatypes.resourceArray: - return 'select'; - case urls.datatypes.atomicUrl: - return 'relation'; - } - - throw new Error(`Unknown datatype: ${datatype}`); -}; - -const NoCategorySelected = () => { - return No Type selected; -}; - -const categoryFormFactory = buildComponentFactory( - new Map([ - ['text', TextPropertyForm], - ['number', NumberPropertyForm], - ['checkbox', CheckboxPropertyForm], - ['select', SelectPropertyForm], - ['date', DatePropertyForm], - ['file', FilePropertyForm], - ['relation', RelationPropertyForm], - ]), - NoCategorySelected, -); - export function PropertyForm({ resource, onSubmit, + existingProperty, category, }: PropertyFormProps): JSX.Element { const [nameError, setNameError, onNameBlur] = useValidation('Required'); @@ -95,12 +39,12 @@ export function PropertyForm({ const [name, setName] = useString( resource, - urls.properties.name, + core.properties.name, valueOptions, ); - const [_, setShortName] = useString( + const [shortname, setShortName] = useString( resource, - urls.properties.shortname, + core.properties.shortname, valueOptions, ); @@ -125,6 +69,11 @@ export function PropertyForm({ // If name was already set remove the error. useEffect(() => { + if (existingProperty && !name && shortname) { + setName(shortname); + setNameError(undefined); + } + if (name) { setNameError(undefined); } diff --git a/browser/data-browser/src/views/TablePage/PropertyForm/RelationPropertyForm.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/RelationPropertyForm.tsx index a5cfe610e..e0bf9ab62 100644 --- a/browser/data-browser/src/views/TablePage/PropertyForm/RelationPropertyForm.tsx +++ b/browser/data-browser/src/views/TablePage/PropertyForm/RelationPropertyForm.tsx @@ -3,9 +3,15 @@ import { useEffect } from 'react'; import { styled } from 'styled-components'; import { ResourceSelector } from '../../../components/forms/ResourceSelector'; import { PropertyCategoryFormProps } from './PropertyCategoryFormProps'; +import { Checkbox, CheckboxLabel } from '../../../components/forms/Checkbox'; const valueOpts = { commit: false }; +const RELATION_TYPES = new Set([ + Datatype.RESOURCEARRAY, + Datatype.ATOMIC_URL, +]); + export function RelationPropertyForm({ resource, }: PropertyCategoryFormProps): JSX.Element { @@ -15,9 +21,21 @@ export function RelationPropertyForm({ valueOpts, ); + const [datatype, setDatatype] = useString(resource, core.properties.datatype); + + const handleAllowMultiple = (checked: boolean) => { + if (checked) { + setDatatype(Datatype.RESOURCEARRAY); + } else { + setDatatype(Datatype.ATOMIC_URL); + } + }; + useEffect(() => { - resource.set(core.properties.datatype, Datatype.ATOMIC_URL); - }, []); + if (!RELATION_TYPES.has(resource.props.datatype)) { + setDatatype(Datatype.ATOMIC_URL); + } + }, [setDatatype]); return ( <> @@ -29,6 +47,13 @@ export function RelationPropertyForm({ setSubject={setClassType} /> + + + Allow multiple values + ); } diff --git a/browser/data-browser/src/views/TablePage/PropertyForm/SelectPropertyForm.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/SelectPropertyForm.tsx index ab4e4652d..0c82462f2 100644 --- a/browser/data-browser/src/views/TablePage/PropertyForm/SelectPropertyForm.tsx +++ b/browser/data-browser/src/views/TablePage/PropertyForm/SelectPropertyForm.tsx @@ -31,20 +31,13 @@ export function SelectPropertyForm({ valueOpts, ); - const [subResources, setSubResources] = useArray( - resource, - dataBrowser.properties.subResources, - valueOpts, - ); - const handleNewTag = useCallback( async (tag: Resource) => { await setAllowOnly([...allowOnly, tag.subject]); - await setSubResources([...subResources, tag.subject]); await tag.save(); }, - [allowOnly, setAllowOnly, subResources, setSubResources], + [allowOnly, setAllowOnly], ); const handleDeleteTag = useCallback( @@ -53,15 +46,15 @@ export function SelectPropertyForm({ tag.destroy(); await setAllowOnly(removeFromArray(allowOnly, subject)); - await setSubResources(removeFromArray(subResources, subject)); }, - [store, setAllowOnly, setSubResources, allowOnly, subResources], + [store, setAllowOnly, allowOnly], ); useEffect(() => { resource.addClasses(dataBrowser.classes.selectProperty); resource.set(core.properties.datatype, Datatype.RESOURCEARRAY); + resource.set(core.properties.classtype, dataBrowser.classes.tag); }, []); return ( diff --git a/browser/data-browser/src/views/TablePage/PropertyForm/categories.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/categories.tsx new file mode 100644 index 000000000..baf096b0d --- /dev/null +++ b/browser/data-browser/src/views/TablePage/PropertyForm/categories.tsx @@ -0,0 +1,82 @@ +import { Core, dataBrowser, Datatype, Resource, urls } from '@tomic/react'; +import { CheckboxPropertyForm } from './CheckboxPropertyForm'; +import { DatePropertyForm } from './DatePropertyForm'; +import { FilePropertyForm } from './FilePropertyForm'; +import { NumberPropertyForm } from './NumberPropertyForm'; +import { RelationPropertyForm } from './RelationPropertyForm'; +import { SelectPropertyForm } from './SelectPropertyForm'; +import { TextPropertyForm } from './TextPropertyForm'; +import { buildComponentFactory } from '../../../helpers/buildComponentFactory'; + +export type PropertyFormCategory = + | 'text' + | 'number' + | 'date' + | 'checkbox' + | 'file' + | 'select' + | 'relation'; + +const TEXT_TYPES = new Set([ + Datatype.STRING, + Datatype.MARKDOWN, + Datatype.SLUG, +]); +const NUMBER_TYPES = new Set([Datatype.INTEGER, Datatype.FLOAT]); +const DATE_TYPES = new Set([Datatype.DATE, Datatype.TIMESTAMP]); + +export const getCategoryFromResource = ( + resource: Resource, +): PropertyFormCategory => { + const datatype = resource.props.datatype; + + if (TEXT_TYPES.has(datatype)) { + return 'text'; + } + + if (NUMBER_TYPES.has(datatype)) { + return 'number'; + } + + if (datatype === Datatype.BOOLEAN) { + return 'checkbox'; + } + + if (DATE_TYPES.has(datatype)) { + return 'date'; + } + + if (datatype === Datatype.RESOURCEARRAY) { + if ( + resource.props.classtype === dataBrowser.classes.tag || + resource.hasClasses(urls.classes.constraintProperties.selectProperty) + ) { + return 'select'; + } + + return 'relation'; + } + + if (datatype === Datatype.ATOMIC_URL) { + return 'relation'; + } + + throw new Error(`Unknown datatype: ${datatype}`); +}; + +const NoCategorySelected = () => { + return No Type selected; +}; + +export const categoryFormFactory = buildComponentFactory( + new Map([ + ['text', TextPropertyForm], + ['number', NumberPropertyForm], + ['checkbox', CheckboxPropertyForm], + ['select', SelectPropertyForm], + ['date', DatePropertyForm], + ['file', FilePropertyForm], + ['relation', RelationPropertyForm], + ]), + NoCategorySelected, +);