diff --git a/browser/data-browser/src/components/CQWrapper.tsx b/browser/data-browser/src/components/CQWrapper.tsx deleted file mode 100644 index 9a32b60be..000000000 --- a/browser/data-browser/src/components/CQWrapper.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { useInsertionEffect } from 'react'; -import { - DefaultTheme, - FlattenSimpleInterpolation, - StyledComponent, -} from 'styled-components'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type CT = React.FunctionComponent; - -type SComp< - C extends CT | keyof JSX.IntrinsicElements, - SP extends object, -> = StyledComponent; - -function getStyleElement(id: string) { - const existingNode = document.getElementById(id); - - if (existingNode) { - return existingNode; - } - - const node = document.createElement('style'); - node.setAttribute('id', id); - document.head.appendChild(node); - - return node; -} - -function addQueryToDom(id: string, query: string) { - const node = getStyleElement(id); - - node.innerHTML = query; -} - -type PossibleComponent = CT | keyof JSX.IntrinsicElements; - -type PropsOfComponent = React.PropsWithChildren[0]>; - -type Attributes = C extends keyof JSX.IntrinsicElements - ? React.PropsWithChildren - : never; - -/** - * Wraps a Styled component and adds Container query logic to it. - * This is a temporary solution until Styled-components adds support for container queries. - * If Container queries are not supported by the browser it falls back to a media query. - */ -export function wrapWithCQ( - Component: SComp, - match: string, - css: string | FlattenSimpleInterpolation, -): SComp { - const CQWrapper = ( - props: C extends CT ? PropsOfComponent : Attributes, - ) => { - // Create an id out of the unique styled component class. - // this ensures we always make only one style element per component instead of one per instance. - const id = `cq-${Component}`; - - useInsertionEffect(() => { - const supportsContainerQueries = CSS.supports( - 'container-type', - 'inline-size', - ); - - const queryType = supportsContainerQueries ? 'container' : 'media'; - - const query = ` - @${queryType} (${match}) { - ${Component} { - ${css} - } - } - `; - - addQueryToDom(id, query); - }, []); - - if (!props) { - throw new Error('Props are required'); - } - - return ( - //@ts-ignore - {props.children} - ); - }; - - // @ts-ignore - return CQWrapper; -} diff --git a/browser/data-browser/src/components/ResourceContextMenu/index.tsx b/browser/data-browser/src/components/ResourceContextMenu/index.tsx index 6f04585c3..127cd38f8 100644 --- a/browser/data-browser/src/components/ResourceContextMenu/index.tsx +++ b/browser/data-browser/src/components/ResourceContextMenu/index.tsx @@ -34,25 +34,40 @@ import { ResourceInline } from '../../views/ResourceInline'; import { ResourceUsage } from '../ResourceUsage'; import { useCurrentSubject } from '../../helpers/useCurrentSubject'; +export enum ContextMenuOptions { + View = 'view', + Data = 'data', + Edit = 'edit', + Refresh = 'refresh', + Scope = 'scope', + Share = 'share', + Delete = 'delete', + History = 'history', + Import = 'import', +} + export interface ResourceContextMenuProps { subject: string; - // ID's of actions that are hidden - hide?: string[]; + // If given only these options will appear in the list. + showOnly?: ContextMenuOptions[]; trigger?: DropdownTriggerRenderFunction; simple?: boolean; /** If it's the primary menu in the navbar. Used for triggering keyboard shortcut */ isMainMenu?: boolean; bindActive?: (active: boolean) => void; + /** Callback that is called after the resource was deleted */ + onAfterDelete?: () => void; } /** Dropdown menu that opens a bunch of actions for some resource */ function ResourceContextMenu({ subject, - hide, + showOnly, trigger, simple, isMainMenu, bindActive, + onAfterDelete, }: ResourceContextMenuProps) { const store = useStore(); const navigate = useNavigate(); @@ -69,6 +84,7 @@ function ResourceContextMenu({ try { await resource.destroy(store); + onAfterDelete?.(); toast.success('Resource deleted!'); if (currentSubject === subject) { @@ -77,7 +93,7 @@ function ResourceContextMenu({ } catch (error) { toast.error(error.message); } - }, [resource, navigate, currentSubject]); + }, [resource, navigate, currentSubject, onAfterDelete]); if (subject === undefined) { return null; @@ -93,14 +109,14 @@ function ResourceContextMenu({ : [ { disabled: location.pathname.startsWith(paths.show), - id: 'view', + id: ContextMenuOptions.View, label: 'normal view', helper: 'Open the regular, default View.', onClick: () => navigate(constructOpenURL(subject)), }, { disabled: location.pathname.startsWith(paths.data), - id: 'data', + id: ContextMenuOptions.Data, label: 'data view', helper: 'View the resource and its properties in the Data View.', shortcut: shortcuts.data, @@ -108,7 +124,7 @@ function ResourceContextMenu({ }, DIVIDER, { - id: 'refresh', + id: ContextMenuOptions.Refresh, icon: , label: 'refresh', helper: @@ -118,7 +134,7 @@ function ResourceContextMenu({ ]), { // disabled: !canWrite || location.pathname.startsWith(paths.edit), - id: 'edit', + id: ContextMenuOptions.Edit, label: 'edit', helper: 'Open the edit form.', icon: , @@ -126,7 +142,7 @@ function ResourceContextMenu({ onClick: () => navigate(editURL(subject)), }, { - id: 'scope', + id: ContextMenuOptions.Scope, label: 'search in', helper: 'Scope search to resource', icon: , @@ -134,7 +150,7 @@ function ResourceContextMenu({ }, { // disabled: !canWrite || history.location.pathname.startsWith(paths.edit), - id: 'share', + id: ContextMenuOptions.Share, label: 'share', icon: , helper: 'Open the share menu', @@ -142,22 +158,21 @@ function ResourceContextMenu({ }, { // disabled: !canWrite, - id: 'delete', + id: ContextMenuOptions.Delete, icon: , label: 'delete', - helper: - 'Fetch the resouce again from the server, possibly see new changes.', + helper: 'Delete this resource.', onClick: () => setShowDeleteDialog(true), }, { - id: 'history', + id: ContextMenuOptions.History, icon: , label: 'history', helper: 'Show the history of this resource', onClick: () => navigate(historyURL(subject)), }, { - id: 'import', + id: ContextMenuOptions.Import, icon: , label: 'import', helper: 'Import Atomic Data to this resource', @@ -165,8 +180,11 @@ function ResourceContextMenu({ }, ]; - const filteredItems = hide - ? items.filter(item => !isItem(item) || !hide.includes(item.id)) + const filteredItems = showOnly + ? items.filter( + item => + !isItem(item) || showOnly.includes(item.id as ContextMenuOptions), + ) : items; const triggerComp = trigger ?? buildDefaultTrigger(); diff --git a/browser/data-browser/src/components/forms/AtomicSelectInput.tsx b/browser/data-browser/src/components/forms/AtomicSelectInput.tsx new file mode 100644 index 000000000..8c2cb5b77 --- /dev/null +++ b/browser/data-browser/src/components/forms/AtomicSelectInput.tsx @@ -0,0 +1,68 @@ +import { Resource, useValue } from '@tomic/react'; +import React from 'react'; +import { InputWrapper } from './InputStyles'; +import styled, { css } from 'styled-components'; + +interface AtomicSelectInputProps { + resource: Resource; + property: string; + options: { + value: string; + label: string; + }[]; + commit?: boolean; +} + +type Props = AtomicSelectInputProps & + Omit, 'onChange' | 'resource'>; + +export function AtomicSelectInput({ + resource, + property, + options, + commit = false, + ...props +}: Props): JSX.Element { + const [value, setValue] = useValue(resource, property, { commit }); + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + }; + + return ( + + + + + + ); +} + +const SelectWrapper = styled.span<{ disabled: boolean }>` + width: 100%; + padding-inline: 0.2rem; + + ${p => + p.disabled && + css` + background-color: ${props => props.theme.colors.bg1}; + `} +`; + +const Select = styled.select` + width: 100%; + border: none; + outline: none; + height: 2rem; + + &:disabled { + color: ${props => props.theme.colors.textLight}; + background-color: transparent; + } +`; diff --git a/browser/data-browser/src/components/forms/ErrorChip.ts b/browser/data-browser/src/components/forms/ErrorChip.ts index c70dd68e0..070bf870b 100644 --- a/browser/data-browser/src/components/forms/ErrorChip.ts +++ b/browser/data-browser/src/components/forms/ErrorChip.ts @@ -1,27 +1,37 @@ -import { styled, keyframes } from 'styled-components'; +import { styled, keyframes, css } from 'styled-components'; const fadeIn = keyframes` from { opacity: 0; - top: var(--error-chip-starting-position); + top: var(--error-chip-start); } to { opacity: 1; - top: 0.5rem; + top: var(--error-chip-end); } `; -export const ErrorChip = styled.span<{ noMovement?: boolean }>` - --error-chip-starting-position: ${p => (p.noMovement ? '0.5rem' : '0rem')}; +export const ErrorChip = styled.span<{ + noMovement?: boolean; + top?: string; +}>` + --error-chip-end: ${p => p.top ?? '0.5rem'}; + --error-chip-start: calc(var(--error-chip-end) - 0.5rem); position: relative; - top: 0.5rem; + top: var(--error-chip-end); background-color: ${p => p.theme.colors.alert}; color: white; padding: 0.25rem 0.5rem; border-radius: ${p => p.theme.radius}; - animation: ${fadeIn} 0.1s ease-in-out; box-shadow: ${p => p.theme.boxShadowSoft}; + ${p => + !p.noMovement + ? css` + animation: ${fadeIn} 0.1s ease-in-out; + ` + : ''} + &::before { --triangle-size: 0.5rem; content: ''; @@ -34,3 +44,8 @@ export const ErrorChip = styled.span<{ noMovement?: boolean }>` clip-path: polygon(0% 100%, 100% 100%, 50% 0%); } `; + +export const ErrorChipInput = styled(ErrorChip)` + position: absolute; + --error-chip-end: ${p => p.top ?? '2rem'}; +`; diff --git a/browser/data-browser/src/components/forms/InputBoolean.tsx b/browser/data-browser/src/components/forms/InputBoolean.tsx index e96a70bdc..d80888828 100644 --- a/browser/data-browser/src/components/forms/InputBoolean.tsx +++ b/browser/data-browser/src/components/forms/InputBoolean.tsx @@ -6,14 +6,16 @@ import { ErrMessage, InputStyled } from './InputStyles'; export default function InputBoolean({ resource, property, + commit, ...props }: InputProps): JSX.Element { const [err, setErr] = useState(undefined); const [value, setValue] = useBoolean(resource, property.subject, { handleValidationError: setErr, + commit, }); - function handleUpdate(e) { + function handleUpdate(e: React.ChangeEvent) { setValue(e.target.checked); } diff --git a/browser/data-browser/src/components/forms/InputMarkdown.tsx b/browser/data-browser/src/components/forms/InputMarkdown.tsx index 67c3accaf..7ff2affbd 100644 --- a/browser/data-browser/src/components/forms/InputMarkdown.tsx +++ b/browser/data-browser/src/components/forms/InputMarkdown.tsx @@ -9,27 +9,37 @@ import { styled } from 'styled-components'; export default function InputMarkdown({ resource, property, + commit, ...props }: InputProps): JSX.Element { + const { darkMode } = useSettings(); + const [err, setErr] = useState(undefined); - const [value, setVale] = useString(resource, property.subject, { + const [value, setValue] = useString(resource, property.subject, { handleValidationError: setErr, + commit: commit, }); - const { darkMode } = useSettings(); + + // We keep a local value that does not update when value is update by anything but the user because the Yamde editor resets cursor position when that happens. + const [localValue, setLocalValue] = useState(value ?? ''); + + const handleChange = (val: string) => { + setLocalValue(val); + setValue(val); + }; return ( <> setVale(e)} + value={localValue} + handler={handleChange} theme={darkMode ? 'dark' : 'light'} required={false} {...props} /> - {/* */} {value !== '' && err && {err.message}} {value === '' && Required} @@ -49,5 +59,32 @@ const YamdeStyling = styled.div` .preview-0-2-9 { background: ${p => p.theme.colors.bg}; font-size: ${p => p.theme.fontSizeBody}rem; + border: none; + border-top: 1px solid ${p => p.theme.colors.bg2}; + + &:focus { + border: none; + border-top: 1px solid ${p => p.theme.colors.bg2}; + } + } + .buttons-0-2-3 { + width: 100%; + } + + .button-0-2-10 { + background-color: ${p => p.theme.colors.bgBody}; + width: unset; + margin-right: unset; + flex: 1; + border: unset; + border-right: 1px solid ${p => p.theme.colors.bg2}; + + &:last-of-type { + border-right: unset; + } + } + + .viewButton-0-2-6:last-of-type { + border-right: unset; } `; diff --git a/browser/data-browser/src/components/forms/InputNumber.tsx b/browser/data-browser/src/components/forms/InputNumber.tsx index a1946cbba..0342baa75 100644 --- a/browser/data-browser/src/components/forms/InputNumber.tsx +++ b/browser/data-browser/src/components/forms/InputNumber.tsx @@ -6,14 +6,16 @@ import { ErrMessage, InputStyled, InputWrapper } from './InputStyles'; export default function InputNumber({ resource, property, + commit, ...props }: InputProps): JSX.Element { const [err, setErr] = useState(undefined); const [value, setValue] = useNumber(resource, property.subject, { handleValidationError: setErr, + commit, }); - function handleUpdate(e) { + function handleUpdate(e: React.ChangeEvent) { if (e.target.value === '') { setValue(undefined); @@ -21,7 +23,6 @@ export default function InputNumber({ } const newval = +e.target.value; - // I pass the error setter for validation purposes setValue(newval); } diff --git a/browser/data-browser/src/components/forms/InputResource.tsx b/browser/data-browser/src/components/forms/InputResource.tsx index b1473a42c..5350aaa50 100644 --- a/browser/data-browser/src/components/forms/InputResource.tsx +++ b/browser/data-browser/src/components/forms/InputResource.tsx @@ -8,11 +8,13 @@ import { ErrorLook } from '../ErrorLook'; export function InputResource({ resource, property, + commit, ...props }: InputProps): JSX.Element { const [error, setError] = useState(undefined); const [subject, setSubject] = useSubject(resource, property.subject, { handleValidationError: setError, + commit, }); if (subject === noNestedSupport) { diff --git a/browser/data-browser/src/components/forms/InputResourceArray.tsx b/browser/data-browser/src/components/forms/InputResourceArray.tsx index 977f0f34f..a4dab346f 100644 --- a/browser/data-browser/src/components/forms/InputResourceArray.tsx +++ b/browser/data-browser/src/components/forms/InputResourceArray.tsx @@ -12,11 +12,13 @@ import { useIndexDependantCallback } from '../../hooks/useIndexDependantCallback export default function InputResourceArray({ resource, property, + commit, ...props }: InputProps): JSX.Element { const [err, setErr] = useState(undefined); const [array, setArray] = useArray(resource, property.subject, { validate: false, + commit, }); /** Add focus to the last added item */ const [lastIsNew, setLastIsNew] = useState(false); diff --git a/browser/data-browser/src/components/forms/InputSlug.tsx b/browser/data-browser/src/components/forms/InputSlug.tsx new file mode 100644 index 000000000..b619a4652 --- /dev/null +++ b/browser/data-browser/src/components/forms/InputSlug.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import { useString, validateDatatype } from '@tomic/react'; +import { InputProps } from './ResourceField'; +import { InputStyled, InputWrapper } from './InputStyles'; +import { stringToSlug } from '../../helpers/stringToSlug'; +import { useValidation } from './formValidation/useValidation'; +import styled from 'styled-components'; +import { ErrorChipInput } from './ErrorChip'; + +export default function InputSlug({ + resource, + property, + commit, + ...props +}: InputProps): JSX.Element { + const [err, setErr, onBlur] = useValidation(); + + const [value, setValue] = useString(resource, property.subject, { + handleValidationError: setErr, + commit, + }); + + const [inputValue, setInputValue] = useState(value); + + function handleUpdate(event: React.ChangeEvent): void { + const newValue = stringToSlug(event.target.value); + setInputValue(newValue); + + setErr(undefined); + + try { + if (newValue === '') { + setValue(undefined); + } else { + validateDatatype(newValue, property.datatype); + setValue(newValue); + } + } catch (e) { + setErr('Invalid Slug'); + } + + if (props.required && newValue === '') { + setErr('Required'); + } + } + + return ( + + + + + {err && {err}} + + ); +} + +const Wrapper = styled.div` + flex: 1; + position: relative; +`; diff --git a/browser/data-browser/src/components/forms/InputString.tsx b/browser/data-browser/src/components/forms/InputString.tsx index befb52919..2a6b61f60 100644 --- a/browser/data-browser/src/components/forms/InputString.tsx +++ b/browser/data-browser/src/components/forms/InputString.tsx @@ -1,35 +1,55 @@ -import React, { useState } from 'react'; -import { useString } from '@tomic/react'; +import React from 'react'; +import { useString, validateDatatype } from '@tomic/react'; import { InputProps } from './ResourceField'; -import { ErrMessage, InputStyled, InputWrapper } from './InputStyles'; +import { InputStyled, InputWrapper } from './InputStyles'; +import styled from 'styled-components'; +import { ErrorChipInput } from './ErrorChip'; +import { useValidation } from './formValidation/useValidation'; export default function InputString({ resource, property, + commit, ...props }: InputProps): JSX.Element { - const [err, setErr] = useState(undefined); + const [err, setErr, onBlur] = useValidation(); + const [value, setValue] = useString(resource, property.subject, { - handleValidationError: setErr, + commit, }); - function handleUpdate(e: React.ChangeEvent): void { - const newval = e.target.value; - // I pass the error setter for validation purposes + function handleUpdate(event: React.ChangeEvent): void { + const newval = event.target.value; setValue(newval); + + try { + validateDatatype(newval, property.datatype); + setErr(undefined); + } catch (e) { + setErr('Invalid value'); + } + + if (props.required && newval === '') { + setErr('Required'); + } } return ( - <> - + + - {value !== '' && err && {err.message}} - {value === '' && Required} - + {err && {err}} + ); } + +const Wrapper = styled.div` + flex: 1; + position: relative; +`; diff --git a/browser/data-browser/src/components/forms/InputSwitcher.tsx b/browser/data-browser/src/components/forms/InputSwitcher.tsx index 533dea23f..73600e3e9 100644 --- a/browser/data-browser/src/components/forms/InputSwitcher.tsx +++ b/browser/data-browser/src/components/forms/InputSwitcher.tsx @@ -7,6 +7,7 @@ import InputResourceArray from './InputResourceArray'; import InputMarkdown from './InputMarkdown'; import InputNumber from './InputNumber'; import InputBoolean from './InputBoolean'; +import InputSlug from './InputSlug'; /** Renders a fitting HTML input depending on the Datatype */ export default function InputSwitcher(props: InputProps): JSX.Element { @@ -20,7 +21,7 @@ export default function InputSwitcher(props: InputProps): JSX.Element { } case Datatype.SLUG: { - return ; + return ; } case Datatype.INTEGER: { diff --git a/browser/data-browser/src/components/forms/ResourceField.tsx b/browser/data-browser/src/components/forms/ResourceField.tsx index 99951f26f..50d6e6c2b 100644 --- a/browser/data-browser/src/components/forms/ResourceField.tsx +++ b/browser/data-browser/src/components/forms/ResourceField.tsx @@ -115,6 +115,7 @@ export type InputProps = { disabled?: boolean; /** Whether the field should be focused on render */ autoFocus?: boolean; + commit?: boolean; }; interface IFieldProps { diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx index 3d198f527..899d1a55b 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -15,6 +15,7 @@ import { ErrorChip } from '../ErrorChip'; import { useValidation } from '../formValidation/useValidation'; interface SearchBoxProps { + autoFocus?: boolean; value: string | undefined; isA?: string; scope?: string; @@ -24,9 +25,11 @@ interface SearchBoxProps { className?: string; onChange: (value: string | undefined) => void; onCreateItem?: (name: string) => void; + onClose?: () => void; } export function SearchBox({ + autoFocus, value, isA, scope, @@ -37,6 +40,7 @@ export function SearchBox({ children, onChange, onCreateItem, + onClose, }: React.PropsWithChildren): JSX.Element { const selectedResource = useResource(value); const triggerRef = useRef(null); @@ -52,16 +56,21 @@ export function SearchBox({ placeholder ?? `Search for a ${isA ? typeResource.title : 'resource'} or enter a URL...`; - const handleExit = useCallback((lostFocus: boolean) => { - setOpen(false); - handleBlur(); + const handleExit = useCallback( + (lostFocus: boolean) => { + setOpen(false); + handleBlur(); - if (!lostFocus) { - triggerRef.current?.focus(); - } else { - setJustFocussed(false); - } - }, []); + if (!lostFocus) { + triggerRef.current?.focus(); + } else { + setJustFocussed(false); + } + + onClose?.(); + }, + [onClose], + ); const handleSelect = useCallback( (newValue: string) => { @@ -97,7 +106,7 @@ export function SearchBox({ } if (selectedResource.error) { - setError('Invalid Resource'); + setError('Invalid Resource', true); return; } @@ -114,6 +123,7 @@ export function SearchBox({ invalid={!!error} > {selectedResource.error - ? 'Invalid Resource' + ? selectedResource.getSubject() : selectedResource.title} ) : ( @@ -210,6 +220,7 @@ const ResourceTitle = styled.span` color: var(--search-box-hightlight); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; `; const PlaceholderText = styled.span` diff --git a/browser/data-browser/src/components/forms/formValidation/useValidation.ts b/browser/data-browser/src/components/forms/formValidation/useValidation.ts index b8d3d2cc4..9479060b9 100644 --- a/browser/data-browser/src/components/forms/formValidation/useValidation.ts +++ b/browser/data-browser/src/components/forms/formValidation/useValidation.ts @@ -6,7 +6,7 @@ export function useValidation( initialValue?: string | undefined, ): [ error: string | undefined, - setError: (error: string | undefined) => void, + setError: (error: Error | string | undefined, immediate?: boolean) => void, onBlur: () => void, ] { const id = useId(); @@ -14,18 +14,27 @@ export function useValidation( const [touched, setTouched] = useState(false); const { setValidations, validations } = useContext(FormValidationContext); - const setError = useCallback((error: string | undefined) => { - setValidations(prev => { - if (prev[id] === error) { - return prev; - } + const setError = useCallback( + (error: Error | string | undefined, immediate = false) => { + const err = error instanceof Error ? error.message : error; - return { - ...prev, - [id]: error, - }; - }); - }, []); + setValidations(prev => { + if (prev[id] === err) { + return prev; + } + + return { + ...prev, + [id]: err, + }; + }); + + if (immediate) { + setTouched(true); + } + }, + [], + ); const handleBlur = useCallback(() => { setTouched(true); diff --git a/browser/data-browser/src/helpers/stringToSlug.ts b/browser/data-browser/src/helpers/stringToSlug.ts index 4b7b2e995..a6f8bf199 100644 --- a/browser/data-browser/src/helpers/stringToSlug.ts +++ b/browser/data-browser/src/helpers/stringToSlug.ts @@ -2,5 +2,6 @@ export function stringToSlug(str: string): string { return str .toLowerCase() .replace(/\s+/g, '-') + .replace(/-+/g, '-') .replace(/[^\w-]+/g, ''); } diff --git a/browser/data-browser/src/views/OntologyPage/Class/AddPropertyButton.tsx b/browser/data-browser/src/views/OntologyPage/Class/AddPropertyButton.tsx new file mode 100644 index 000000000..6145aae79 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Class/AddPropertyButton.tsx @@ -0,0 +1,117 @@ +import { Resource, Store, urls, useStore } from '@tomic/react'; +import React, { useRef, useState } from 'react'; +import styled from 'styled-components'; +import { transition } from '../../../helpers/transition'; +import { FaPlus } from 'react-icons/fa'; +import { SearchBox } from '../../../components/forms/SearchBox'; +import { focusOffsetElement } from '../../../helpers/focusOffsetElement'; +import { useOntologyContext } from '../OntologyContext'; + +interface AddPropertyButtonProps { + creator: Resource; + type: 'required' | 'recommended'; +} + +const BUTTON_WIDTH = 'calc(100% - 5.6rem + 4px)'; //Width is 100% - (2 * 1.8rem for button width) + (2rem for gaps) + (4px for borders) + +async function newProperty(shortname: string, parent: Resource, store: Store) { + const subject = `${parent.getSubject()}/property/${shortname}`; + const resource = store.getResourceLoading(subject, { newResource: true }); + + await resource.addClasses(store, urls.classes.property); + await resource.set(urls.properties.shortname, shortname, store); + await resource.set(urls.properties.description, 'a property', store); + await resource.set(urls.properties.datatype, urls.datatypes.string, store); + await resource.set(urls.properties.parent, parent.getSubject(), store); + await resource.save(store); + + return subject; +} + +export function AddPropertyButton({ + creator, + type, +}: AddPropertyButtonProps): JSX.Element { + const store = useStore(); + const triggerRef = useRef(null); + + const [active, setActive] = useState(false); + + const { ontology, addProperty } = useOntologyContext(); + + const handleSetValue = async (newValue: string | undefined) => { + setActive(false); + + if (!newValue) { + return; + } + + const creatorProp = + type === 'required' + ? urls.properties.requires + : urls.properties.recommends; + creator.pushPropVal(creatorProp, [newValue]); + await creator.save(store); + }; + + const handleCreateProperty = async (shortname: string) => { + const createdSubject = await newProperty(shortname, ontology, store); + await handleSetValue(createdSubject); + + await addProperty(createdSubject); + + focusOffsetElement(-4, triggerRef.current!); + }; + + if (active) { + return ( + + setActive(false)} + onCreateItem={handleCreateProperty} + /> + + ); + } + + return ( + setActive(true)} + ref={triggerRef} + > + + + ); +} + +const SearchBoxWrapper = styled.div` + width: ${BUTTON_WIDTH}; +`; + +const AddButton = styled.button` + background: none; + border: 1px dashed ${p => p.theme.colors.bg2}; + height: 2.5rem; + + width: ${BUTTON_WIDTH}; + border-radius: ${p => p.theme.radius}; + display: flex; + align-items: center; + justify-content: center; + gap: 1ch; + cursor: pointer; + color: ${p => p.theme.colors.textLight}; + + ${transition('border-color', 'color')} + &:hover, + &:focus-visible { + border-style: solid; + border-color: ${p => p.theme.colors.main}; + color: ${p => p.theme.colors.main}; + } +`; diff --git a/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx b/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx new file mode 100644 index 000000000..acf3aab15 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx @@ -0,0 +1,69 @@ +import { urls, useArray, useResource, useString } from '@tomic/react'; +import React from 'react'; +import { Card } from '../../../components/Card'; +import { PropertyLineRead } from '../Property/PropertyLineRead'; +import styled from 'styled-components'; +import { FaCube } from 'react-icons/fa'; +import { Column } from '../../../components/Row'; +import Markdown from '../../../components/datatypes/Markdown'; +import { AtomicLink } from '../../../components/AtomicLink'; +import { toAnchorId } from '../toAnchorId'; + +interface ClassCardReadProps { + subject: string; +} + +export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { + const resource = useResource(subject); + const [description] = useString(resource, urls.properties.description); + const [requires] = useArray(resource, urls.properties.requires); + const [recommends] = useArray(resource, urls.properties.recommends); + + return ( + + + + + {resource.title} + + + Requires + + {requires.length > 0 ? ( + requires.map(s => ) + ) : ( + none + )} + + Recommends + + {recommends.length > 0 ? ( + recommends.map(s => ) + ) : ( + none + )} + + + + ); +} + +const StyledCard = styled(Card)` + padding-bottom: ${p => p.theme.margin}rem; +`; + +const StyledH3 = styled.h3` + display: flex; + align-items: center; + gap: 1ch; + margin-bottom: 0px; + font-size: 1.5rem; +`; + +const StyledH4 = styled.h4` + margin-bottom: 0px; +`; + +const StyledTable = styled.table` + border-collapse: collapse; +`; diff --git a/browser/data-browser/src/views/OntologyPage/Class/ClassCardWrite.tsx b/browser/data-browser/src/views/OntologyPage/Class/ClassCardWrite.tsx new file mode 100644 index 000000000..c60a86537 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Class/ClassCardWrite.tsx @@ -0,0 +1,134 @@ +import { urls, useArray, useProperty, useResource } from '@tomic/react'; +import React, { useCallback } from 'react'; +import { Card } from '../../../components/Card'; +import styled from 'styled-components'; +import { FaCube } from 'react-icons/fa'; +import { Column, Row } from '../../../components/Row'; +import { OntologyDescription } from '../OntologyDescription'; +import { PropertyLineWrite } from '../Property/PropertyLineWrite'; +import InputSwitcher from '../../../components/forms/InputSwitcher'; +import ResourceContextMenu, { + ContextMenuOptions, +} from '../../../components/ResourceContextMenu'; +import { toAnchorId } from '../toAnchorId'; +import { AddPropertyButton } from './AddPropertyButton'; +import { ErrorChipInput } from '../../../components/forms/ErrorChip'; +import { useOntologyContext } from '../OntologyContext'; + +interface ClassCardWriteProps { + subject: string; +} + +const contextOptions = [ContextMenuOptions.Delete, ContextMenuOptions.History]; + +export function ClassCardWrite({ subject }: ClassCardWriteProps): JSX.Element { + const resource = useResource(subject); + const [requires, setRequires] = useArray(resource, urls.properties.requires, { + commit: true, + }); + const [recommends, setRecommends] = useArray( + resource, + urls.properties.recommends, + { commit: true }, + ); + const shortnameProp = useProperty(urls.properties.shortname); + + const { removeClass } = useOntologyContext(); + + const handleDelete = useCallback(() => { + removeClass(subject); + }, [removeClass, subject]); + + const removeProperty = (type: 'requires' | 'recommends', prop: string) => { + if (type === 'requires') { + setRequires(requires.filter(s => s !== prop)); + } else { + setRecommends(recommends.filter(s => s !== prop)); + } + }; + + return ( + + + + + + + + + + + Requires + + {requires.map(s => ( + removeProperty('requires', prop)} + /> + ))} + + + + + Recommends + + {recommends.map(s => ( + removeProperty('recommends', prop)} + /> + ))} + + + + + + + ); +} + +const StyledCard = styled(Card)` + padding-bottom: ${p => p.theme.margin}rem; + max-width: 100rem; + + border: ${p => + p.theme.darkMode ? `1px solid ${p.theme.colors.bg2}` : 'none'}; + + input, + select { + height: 2.5rem; + } + + ${ErrorChipInput} { + --error-chip-end: 2.5rem; + } +`; + +const StyledH4 = styled.h4` + margin-bottom: 0px; +`; + +const ButtonWrapper = styled.li` + margin-left: 0px; + list-style: none; +`; + +const TitleWrapper = styled.div` + display: flex; + align-items: center; + gap: 1ch; + width: min(100%, 50ch); + svg { + font-size: 1.5rem; + } +`; diff --git a/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx b/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx deleted file mode 100644 index acb5609e5..000000000 --- a/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { urls, useArray, useResource, useString } from '@tomic/react'; -import React from 'react'; -import { Card } from '../../components/Card'; -import { PropertyLineRead } from './PropertyLineRead'; -import styled from 'styled-components'; -import { FaCube } from 'react-icons/fa'; -import { Column } from '../../components/Row'; -import Markdown from '../../components/datatypes/Markdown'; -import { AtomicLink } from '../../components/AtomicLink'; - -interface ClassCardReadProps { - subject: string; -} - -export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { - const resource = useResource(subject); - const [description] = useString(resource, urls.properties.description); - const [requires] = useArray(resource, urls.properties.requires); - const [recommends] = useArray(resource, urls.properties.recommends); - - return ( - - - - - - {resource.title} - - - Requires - - {requires.length > 0 ? ( - requires.map(s => ) - ) : ( - none - )} - - Recommends - - {recommends.length > 0 ? ( - recommends.map(s => ) - ) : ( - none - )} - - - - - ); -} - -const StyledCard = styled(Card)` - padding-bottom: ${p => p.theme.margin}rem; -`; - -const StyledLi = styled.li` - margin-left: 0px; - list-style: none; -`; - -const StyledH3 = styled.h3` - display: flex; - align-items: center; - gap: 1ch; - margin-bottom: 0px; - font-size: 1.5rem; -`; - -const StyledH4 = styled.h4` - margin-bottom: 0px; -`; - -const StyledTable = styled.table` - border-collapse: collapse; -`; diff --git a/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx index 9d6d1041f..f27c17124 100644 --- a/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx +++ b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx @@ -5,19 +5,20 @@ import { urls, reverseDatatypeMapping, unknownSubject, + useResource, } from '@tomic/react'; import { ResourceInline } from '../ResourceInline'; -import styled from 'styled-components'; +import { toAnchorId } from './toAnchorId'; +import { useOntologyContext } from './OntologyContext'; interface TypeSuffixProps { resource: Resource; } -export function InlineDatatype({ - resource, -}: TypeSuffixProps): JSX.Element | null { +export function InlineDatatype({ resource }: TypeSuffixProps): JSX.Element { const [datatype] = useString(resource, urls.properties.datatype); const [classType] = useString(resource, urls.properties.classType); + const { hasClass } = useOntologyContext(); const name = reverseDatatypeMapping[datatype ?? unknownSubject]; @@ -29,8 +30,22 @@ export function InlineDatatype({ {name} {'<'} - + {hasClass(classType) ? ( + + ) : ( + + )} {'>'} ); } + +interface LocalLinkProps { + subject: string; +} + +function LocalLink({ subject }: LocalLinkProps): JSX.Element { + const resource = useResource(subject); + + return {resource.title}; +} diff --git a/browser/data-browser/src/views/OntologyPage/NewClassButton.tsx b/browser/data-browser/src/views/OntologyPage/NewClassButton.tsx new file mode 100644 index 000000000..67a03b404 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/NewClassButton.tsx @@ -0,0 +1,152 @@ +import { Datatype, Resource, useStore, validateDatatype } from '@tomic/react'; +import React, { useRef, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import styled from 'styled-components'; +import { transition } from '../../helpers/transition'; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + useDialog, +} from '../../components/Dialog'; +import { Button } from '../../components/Button'; +import { InputStyled, InputWrapper } from '../../components/forms/InputStyles'; +import { stringToSlug } from '../../helpers/stringToSlug'; +import { Column } from '../../components/Row'; +import { newClass, subjectForClass } from './newClass'; + +interface NewClassButtonProps { + resource: Resource; +} + +export function NewClassButton({ resource }: NewClassButtonProps): JSX.Element { + const store = useStore(); + const [inputValue, setInputValue] = useState(''); + const [isValid, setIsValid] = useState(false); + const inputRef = useRef(null); + + const subject = subjectForClass(resource, inputValue); + + const [dialogProps, show, hide, isOpen] = useDialog({ + onSuccess: () => { + newClass(inputValue, resource, store); + }, + }); + + const handleShortNameChange = (e: React.ChangeEvent) => { + const slugValue = stringToSlug(e.target.value); + setInputValue(slugValue); + validate(slugValue); + }; + + const validate = (value: string) => { + if (!value) { + setIsValid(false); + + return; + } + + try { + validateDatatype(value, Datatype.SLUG); + setIsValid(true); + } catch (e) { + setIsValid(false); + } + }; + + const openAndReset = () => { + setInputValue(''); + setIsValid(false); + show(); + + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + hide(false); + } + + if (e.key === 'Enter' && isValid) { + hide(true); + } + }; + + return ( + <> + + Add class + + + {isOpen && ( + <> + +

New Class

+
+ + + + + + + {subject} + + + + + + + + )} +
+ + ); +} + +const DashedButton = styled.button` + width: 100%; + height: 20rem; + display: flex; + align-items: center; + justify-content: center; + gap: 1ch; + appearance: none; + background: none; + border: 2px dashed ${p => p.theme.colors.bg2}; + border-radius: ${p => p.theme.radius}; + color: ${p => p.theme.colors.textLight}; + cursor: pointer; + &:hover, + &:focus-visible { + background: ${p => p.theme.colors.bg}; + border-color: ${p => p.theme.colors.main}; + color: ${p => p.theme.colors.main}; + } + ${transition('background', 'color', 'border-color')} +`; + +const SubjectWrapper = styled.div` + width: 100%; + max-width: 60ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: ${p => p.theme.colors.textLight}; + background-color: ${p => p.theme.colors.bg1}; + padding-inline: 0.5rem; + padding-block: 0.2rem; + border-radius: ${p => p.theme.radius}; +`; diff --git a/browser/data-browser/src/views/OntologyPage/OntologyContext.tsx b/browser/data-browser/src/views/OntologyPage/OntologyContext.tsx new file mode 100644 index 000000000..970249429 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologyContext.tsx @@ -0,0 +1,110 @@ +import { Resource, unknownSubject, urls, useArray } from '@tomic/react'; +import React, { createContext, useCallback, useContext, useMemo } from 'react'; + +interface OntologyContext { + addClass: (subject: string) => Promise; + removeClass: (subject: string) => Promise; + addProperty: (subject: string) => Promise; + removeProperty: (subject: string) => Promise; + hasProperty: (subject: string) => boolean; + hasClass: (subject: string) => boolean; + ontology: Resource; +} + +export const OntologyContext = createContext({ + addClass: () => Promise.resolve(), + removeClass: () => Promise.resolve(), + addProperty: () => Promise.resolve(), + removeProperty: () => Promise.resolve(), + hasProperty: () => false, + hasClass: () => false, + ontology: new Resource(unknownSubject), +}); + +interface OntologyContextProviderProps { + ontology: Resource; +} + +export function OntologyContextProvider({ + ontology, + children, +}: React.PropsWithChildren) { + const [classes, setClasses] = useArray(ontology, urls.properties.classes, { + commit: true, + }); + + const [properties, setProperties] = useArray( + ontology, + urls.properties.properties, + { commit: true }, + ); + + const addClass = useCallback( + async (subject: string) => { + await setClasses([...classes, subject]); + }, + [classes, setClasses], + ); + + const removeClass = useCallback( + async (subject: string) => { + await setClasses(classes.filter(s => s !== subject)); + }, + [classes, setClasses], + ); + + const addProperty = useCallback( + async (subject: string) => { + await setProperties([...properties, subject]); + }, + [properties, setProperties], + ); + + const removeProperty = useCallback( + async (subject: string) => { + await setProperties(properties.filter(s => s !== subject)); + }, + [properties, setProperties], + ); + + const hasProperty = useCallback( + (subject: string): boolean => properties.includes(subject), + [properties], + ); + + const hasClass = useCallback( + (subject: string): boolean => classes.includes(subject), + [classes], + ); + + const context = useMemo( + () => ({ + addClass, + removeClass, + addProperty, + removeProperty, + hasProperty, + hasClass, + ontology, + }), + [ + addClass, + removeClass, + addProperty, + removeProperty, + hasProperty, + hasClass, + ontology, + ], + ); + + return ( + + {children} + + ); +} + +export function useOntologyContext(): OntologyContext { + return useContext(OntologyContext)!; +} diff --git a/browser/data-browser/src/views/OntologyPage/OntologyDescription.tsx b/browser/data-browser/src/views/OntologyPage/OntologyDescription.tsx new file mode 100644 index 000000000..baec0af32 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologyDescription.tsx @@ -0,0 +1,31 @@ +import { + Resource, + useString, + urls, + useProperty, + useCanWrite, +} from '@tomic/react'; +import React from 'react'; +import Markdown from '../../components/datatypes/Markdown'; +import InputMarkdown from '../../components/forms/InputMarkdown'; + +interface OntologyDescriptionProps { + resource: Resource; + edit: boolean; +} + +export function OntologyDescription({ + resource, + edit, +}: OntologyDescriptionProps): JSX.Element { + const [description] = useString(resource, urls.properties.description); + const property = useProperty(urls.properties.description); + + const [canEdit] = useCanWrite(resource); + + if (!edit || !canEdit) { + return ; + } + + return ; +} diff --git a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx index 7820c3967..b5d172527 100644 --- a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx +++ b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx @@ -1,65 +1,101 @@ import React from 'react'; import { ResourcePageProps } from '../ResourcePage'; -import { urls, useArray, useString } from '@tomic/react'; +import { urls, useArray, useCanWrite } from '@tomic/react'; import { OntologySidebar } from './OntologySidebar'; import styled from 'styled-components'; -import { ClassCardRead } from './ClassCardRead'; -import { PropertyCardRead } from './PropertyCardRead'; +import { ClassCardRead } from './Class/ClassCardRead'; +import { PropertyCardRead } from './Property/PropertyCardRead'; import ResourceCard from '../Card/ResourceCard'; import { Button } from '../../components/Button'; -import { Row } from '../../components/Row'; - -enum OntologyViewMode { - Read = 0, - Write, -} +import { Column, Row } from '../../components/Row'; +import { FaEdit, FaEye } from 'react-icons/fa'; +import { OntologyDescription } from './OntologyDescription'; +import { ClassCardWrite } from './Class/ClassCardWrite'; +import { NewClassButton } from './NewClassButton'; +import { toAnchorId } from './toAnchorId'; +import { OntologyContextProvider } from './OntologyContext'; +import { PropertyCardWrite } from './Property/PropertyCardWrite'; export function OntologyPage({ resource }: ResourcePageProps) { - const [description] = useString(resource, urls.properties.description); const [classes] = useArray(resource, urls.properties.classes); const [properties] = useArray(resource, urls.properties.properties); const [instances] = useArray(resource, urls.properties.instances); + const [canWrite] = useCanWrite(resource); - const [viewMode, setViewMode] = React.useState(OntologyViewMode.Read); + const [editMode, setEditMode] = React.useState(false); return ( - - - -

{resource.title}

- -
-
- - - - -

{description}

-

Classes

- - {classes.map(c => ( - - ))} - -

Properties

- - {properties.map(c => ( - - ))} - -

Instances

- - {instances.map(c => ( - - ))} - -
- - Placeholder - -
+ + + + +

{resource.title}

+ {canWrite && + (editMode ? ( + + ) : ( + + ))} +
+
+ + + + + + +

Classes

+ + {classes.map(c => ( +
  • + {editMode ? ( + + ) : ( + + )} +
  • + ))} + {editMode && ( +
  • + +
  • + )} +
    +

    Properties

    + + {properties.map(c => ( +
  • + {editMode ? ( + + ) : ( + + )} +
  • + ))} +
    +

    Instances

    + + {instances.map(c => ( +
  • + +
  • + ))} +
    +
    +
    + {!editMode && ( + + Placeholder + + )} +
    +
    ); } @@ -75,17 +111,24 @@ const TempGraph = styled.div` overflow: hidden; `; -const FullPageWrapper = styled.div` +const FullPageWrapper = styled.div<{ edit: boolean }>` display: grid; - grid-template-areas: 'sidebar title graph' 'sidebar list graph'; - grid-template-columns: 1fr 3fr 2fr; + grid-template-areas: ${p => + p.edit + ? `'sidebar title title' 'sidebar list list'` + : `'sidebar title graph' 'sidebar list graph'`}; + grid-template-columns: minmax(auto, 13rem) 3fr 2fr; grid-template-rows: 4rem auto; width: 100%; min-height: ${p => p.theme.heights.fullPage}; @container (max-width: 950px) { - grid-template-areas: 'sidebar title' 'sidebar graph' 'sidebar list'; - grid-template-columns: 1fr 3fr; + grid-template-areas: ${p => + p.edit + ? `'sidebar title' 'sidebar list' 'sidebar list'` + : `'sidebar title' 'sidebar graph' 'sidebar list'`}; + + grid-template-columns: 1fr 5fr; grid-template-rows: 4rem auto auto; ${TempGraph} { @@ -118,5 +161,10 @@ const GraphSlot = styled.div` const StyledUl = styled.ul` display: flex; flex-direction: column; - gap: 1rem; + gap: 2rem; + + & > li { + margin-left: 0px; + list-style: none; + } `; diff --git a/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx b/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx index 3633f1ead..e9c95eb2f 100644 --- a/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx +++ b/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import { Details } from '../../components/Details'; import { FaAtom, FaCube, FaHashtag } from 'react-icons/fa'; import { ScrollArea } from '../../components/ScrollArea'; +import { toAnchorId } from './toAnchorId'; interface OntologySidebarProps { ontology: Resource; @@ -78,7 +79,9 @@ function Item({ subject }: ItemProps): JSX.Element { return ( - {resource.title} + + {resource.title} + ); } @@ -107,12 +110,12 @@ const StyledLi = styled.li` margin-bottom: 0; `; -const ItemLink = styled.a` +const ItemLink = styled.a<{ error: boolean }>` padding-left: 1rem; padding-block: 0.2rem; border-radius: ${p => p.theme.radius}; display: block; - color: ${p => p.theme.colors.textLight}; + color: ${p => (p.error ? p.theme.colors.alert : p.theme.colors.textLight)}; text-decoration: none; width: 100%; &:hover, @@ -126,5 +129,6 @@ const ItemLink = styled.a` const SideBarScrollArea = styled(ScrollArea)` overflow: hidden; padding: ${p => p.theme.margin}rem; + padding-left: 0.5rem; max-height: 100vh; `; diff --git a/browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx similarity index 74% rename from browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx rename to browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx index eaabc5514..96d5e924f 100644 --- a/browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { Card } from '../../components/Card'; +import { Card } from '../../../components/Card'; import { urls, useArray, useResource, useString } from '@tomic/react'; import { FaHashtag } from 'react-icons/fa'; import styled from 'styled-components'; -import Markdown from '../../components/datatypes/Markdown'; -import { Column, Row } from '../../components/Row'; -import { InlineFormattedResourceList } from '../../components/InlineFormattedResourceList'; -import { InlineDatatype } from './InlineDatatype'; -import { AtomicLink } from '../../components/AtomicLink'; +import Markdown from '../../../components/datatypes/Markdown'; +import { Column, Row } from '../../../components/Row'; +import { InlineFormattedResourceList } from '../../../components/InlineFormattedResourceList'; +import { InlineDatatype } from '../InlineDatatype'; +import { AtomicLink } from '../../../components/AtomicLink'; +import { toAnchorId } from '../toAnchorId'; interface PropertyCardReadProps { subject: string; @@ -21,7 +22,7 @@ export function PropertyCardRead({ const [allowsOnly] = useArray(resource, urls.properties.allowsOnly); return ( - + diff --git a/browser/data-browser/src/views/OntologyPage/Property/PropertyCardWrite.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardWrite.tsx new file mode 100644 index 000000000..fb40c0a7c --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardWrite.tsx @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; +import { Card } from '../../../components/Card'; +import { urls, useCanWrite, useProperty, useResource } from '@tomic/react'; +import { FaHashtag } from 'react-icons/fa'; +import styled from 'styled-components'; +import { Column, Row } from '../../../components/Row'; +import { toAnchorId } from '../toAnchorId'; +import InputSwitcher from '../../../components/forms/InputSwitcher'; +import ResourceContextMenu, { + ContextMenuOptions, +} from '../../../components/ResourceContextMenu'; +import { useOntologyContext } from '../OntologyContext'; +import { PropertyFormCommon } from './PropertyFormCommon'; + +interface PropertyCardWriteProps { + subject: string; +} + +const contextOptions = [ContextMenuOptions.Delete, ContextMenuOptions.History]; + +export function PropertyCardWrite({ + subject, +}: PropertyCardWriteProps): JSX.Element { + const resource = useResource(subject); + const shortnameProp = useProperty(urls.properties.shortname); + const [canEdit] = useCanWrite(resource); + + const { removeProperty } = useOntologyContext(); + + const handleDelete = useCallback(() => { + removeProperty(subject); + }, [removeProperty, subject]); + + return ( + + + + + + + + + + + + + ); +} + +const TitleWrapper = styled.div` + display: flex; + align-items: center; + gap: 1ch; + margin-bottom: 0px; + + svg { + font-size: 1.5rem; + } +`; + +const StyledCard = styled(Card)` + padding-bottom: ${p => p.theme.margin}rem; +`; diff --git a/browser/data-browser/src/views/OntologyPage/Property/PropertyFormCommon.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyFormCommon.tsx new file mode 100644 index 000000000..4ca18aaef --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyFormCommon.tsx @@ -0,0 +1,97 @@ +import { + Resource, + urls, + useProperty, + useResource, + useStore, + useString, +} from '@tomic/react'; +import React, { useCallback } from 'react'; +import { Column, Row } from '../../../components/Row'; +import { SearchBox } from '../../../components/forms/SearchBox'; +import { OntologyDescription } from '../OntologyDescription'; +import { PropertyDatatypePicker } from '../PropertyDatatypePicker'; +import styled from 'styled-components'; +import { newClass } from '../newClass'; +import { toAnchorId } from '../toAnchorId'; +import { useCurrentSubject } from '../../../helpers/useCurrentSubject'; +import InputSwitcher from '../../../components/forms/InputSwitcher'; + +interface PropertyFormCommonProps { + resource: Resource; + canEdit: boolean; + onClassCreated?: () => void; +} + +const datatypesWithExtraControls = new Set([ + urls.datatypes.atomicUrl, + urls.datatypes.resourceArray, +]); + +export function PropertyFormCommon({ + resource, + canEdit, + onClassCreated, +}: PropertyFormCommonProps): JSX.Element { + const store = useStore(); + const [classType, setClassType] = useString( + resource, + urls.properties.classType, + { commit: true }, + ); + const [datatype] = useString(resource, urls.properties.datatype); + const [ontologySubject] = useCurrentSubject(); + const ontologyResource = useResource(ontologySubject); + const allowsOnly = useProperty(urls.properties.allowsOnly); + const handleCreateClass = useCallback( + async (shortname: string) => { + const createdSubject = await newClass(shortname, ontologyResource, store); + await setClassType(createdSubject); + onClassCreated?.(); + + requestAnimationFrame(() => { + document + .getElementById(toAnchorId(createdSubject)) + ?.scrollIntoView({ behavior: 'smooth' }); + }); + }, + [ontologyResource, store, onClassCreated], + ); + + const disableExtras = !datatypesWithExtraControls.has(datatype ?? ''); + + return ( + + + + + Datatype + + + + Classtype + + + + + Allows Only + + + + ); +} + +const LabelText = styled.span` + font-weight: bold; + color: ${p => p.theme.colors.textLight}; +`; diff --git a/browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineRead.tsx similarity index 70% rename from browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx rename to browser/data-browser/src/views/OntologyPage/Property/PropertyLineRead.tsx index 23637811f..c35e5e9f7 100644 --- a/browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineRead.tsx @@ -1,8 +1,9 @@ import { urls, useResource, useString } from '@tomic/react'; import React from 'react'; import styled from 'styled-components'; -import Markdown from '../../components/datatypes/Markdown'; -import { InlineDatatype } from './InlineDatatype'; +import Markdown from '../../../components/datatypes/Markdown'; +import { InlineDatatype } from '../InlineDatatype'; +import { ErrorLook } from '../../../components/ErrorLook'; interface PropertyLineReadProps { subject: string; @@ -14,6 +15,14 @@ export function PropertyLineRead({ const resource = useResource(subject); const [description] = useString(resource, urls.properties.description); + if (resource.error) { + return ( + + Property does not exist anymore + + ); + } + return ( {resource.title} diff --git a/browser/data-browser/src/views/OntologyPage/Property/PropertyLineWrite.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineWrite.tsx new file mode 100644 index 000000000..64a51adda --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineWrite.tsx @@ -0,0 +1,93 @@ +import { urls, useCanWrite, useProperty, useResource } from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import InputSwitcher from '../../../components/forms/InputSwitcher'; +import { Row } from '../../../components/Row'; +import InputString from '../../../components/forms/InputString'; +import { PropertyDatatypePicker } from '../PropertyDatatypePicker'; +import { IconButton } from '../../../components/IconButton/IconButton'; +import { FaSlidersH, FaTimes } from 'react-icons/fa'; +import { useDialog } from '../../../components/Dialog'; +import { PropertyWriteDialog } from './PropertyWriteDialog'; +import { useOntologyContext } from '../OntologyContext'; +import { ErrorLook } from '../../../components/ErrorLook'; +import { Button } from '../../../components/Button'; + +interface PropertyLineWriteProps { + subject: string; + onRemove: (subject: string) => void; +} + +export function PropertyLineWrite({ + subject, + onRemove, +}: PropertyLineWriteProps): JSX.Element { + const resource = useResource(subject); + const shortnameProp = useProperty(urls.properties.shortname); + const descriptionProp = useProperty(urls.properties.description); + const [dialogProps, show, hide] = useDialog(); + const [canEdit] = useCanWrite(resource); + + const { hasProperty } = useOntologyContext(); + + const disabled = !canEdit || !hasProperty(subject); + + if (resource.error) { + return ( + + + + This property does not exist anymore ({subject}) + + + + + ); + } + + return ( + + + + + + + + + onRemove(subject)} + > + + + + + + ); +} + +const ListItem = styled.li` + margin-left: 0px; + list-style: none; +`; + +const StyledErrorLook = styled(ErrorLook)` + max-lines: 2; + overflow: hidden; + flex: 1; +`; diff --git a/browser/data-browser/src/views/OntologyPage/Property/PropertyWriteDialog.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyWriteDialog.tsx new file mode 100644 index 000000000..563907b65 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyWriteDialog.tsx @@ -0,0 +1,53 @@ +import { Resource, urls, useCanWrite, useProperty } from '@tomic/react'; +import React from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + InternalDialogProps, +} from '../../../components/Dialog'; +import styled from 'styled-components'; +import InputSwitcher from '../../../components/forms/InputSwitcher'; +import { PropertyFormCommon } from './PropertyFormCommon'; + +interface PropertyWriteDialogProps { + resource: Resource; + close: () => void; +} + +export function PropertyWriteDialog({ + resource, + close, + ...dialogProps +}: PropertyWriteDialogProps & InternalDialogProps): JSX.Element { + const [canEdit] = useCanWrite(resource); + const shortnameProp = useProperty(urls.properties.shortname); + + return ( + + {dialogProps.show && ( + <> + + + + + + + + )} + + ); +} + +const WiderDialogContent = styled(DialogContent)` + width: min(40rem, 90vw); +`; diff --git a/browser/data-browser/src/views/OntologyPage/PropertyDatatypePicker.tsx b/browser/data-browser/src/views/OntologyPage/PropertyDatatypePicker.tsx new file mode 100644 index 000000000..205601b19 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/PropertyDatatypePicker.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Resource, reverseDatatypeMapping, urls } from '@tomic/react'; +import { AtomicSelectInput } from '../../components/forms/AtomicSelectInput'; +interface PropertyDatatypePickerProps { + resource: Resource; + disabled?: boolean; +} + +const options = Object.entries(reverseDatatypeMapping) + .map(([key, value]) => ({ + value: key, + label: value.toUpperCase(), + })) + .filter(x => x.value !== 'unknown-datatype'); + +export function PropertyDatatypePicker({ + resource, + disabled, +}: PropertyDatatypePickerProps): JSX.Element { + return ( + + ); +} diff --git a/browser/data-browser/src/views/OntologyPage/newClass.ts b/browser/data-browser/src/views/OntologyPage/newClass.ts new file mode 100644 index 000000000..00846eefa --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/newClass.ts @@ -0,0 +1,29 @@ +import { Resource, Store, urls } from '@tomic/react'; + +const DEFAULT_DESCRIPTION = 'Change me'; + +export const subjectForClass = (parent: Resource, shortName: string): string => + `${parent.getSubject()}/class/${shortName}`; + +export async function newClass( + shortName: string, + parent: Resource, + store: Store, +): Promise { + const subject = subjectForClass(parent, shortName); + const resource = store.getResourceLoading(subject, { newResource: true }); + + await resource.addClasses(store, urls.classes.class); + + await resource.set(urls.properties.shortname, shortName, store); + await resource.set(urls.properties.description, DEFAULT_DESCRIPTION, store); + await resource.set(urls.properties.parent, parent.getSubject(), store); + + await resource.save(store); + + parent.pushPropVal(urls.properties.classes, [subject]); + + await parent.save(store); + + return subject; +} diff --git a/browser/data-browser/src/views/OntologyPage/toAnchorId.ts b/browser/data-browser/src/views/OntologyPage/toAnchorId.ts new file mode 100644 index 000000000..39516bed0 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/toAnchorId.ts @@ -0,0 +1 @@ +export const toAnchorId = (subject: string): string => `list-item-${subject}`; diff --git a/browser/lib/src/datatypes.ts b/browser/lib/src/datatypes.ts index 4e79469db..f78c1c376 100644 --- a/browser/lib/src/datatypes.ts +++ b/browser/lib/src/datatypes.ts @@ -189,15 +189,15 @@ export function isNumber(val: JSONValue): val is number { } export const reverseDatatypeMapping = { - [Datatype.ATOMIC_URL]: 'Resource', + [Datatype.STRING]: 'String', + [Datatype.SLUG]: 'Slug', + [Datatype.MARKDOWN]: 'Markdown', + [Datatype.INTEGER]: 'Integer', + [Datatype.FLOAT]: 'Float', [Datatype.BOOLEAN]: 'Boolean', [Datatype.DATE]: 'Date', - [Datatype.FLOAT]: 'Float', - [Datatype.INTEGER]: 'Integer', - [Datatype.MARKDOWN]: 'Markdown', - [Datatype.RESOURCEARRAY]: 'ResourceArray', - [Datatype.SLUG]: 'Slug', - [Datatype.STRING]: 'String', [Datatype.TIMESTAMP]: 'Timestamp', + [Datatype.ATOMIC_URL]: 'Resource', + [Datatype.RESOURCEARRAY]: 'ResourceArray', [Datatype.UNKNOWN]: 'Unknown', };