From 43a7312a3417957cfa43daafceee304bfa04e196 Mon Sep 17 00:00:00 2001 From: Polle Pas Date: Mon, 2 Oct 2023 13:46:40 +0200 Subject: [PATCH] #648 Add Instances to ontologies --- .vscode/settings.json | 4 +- .../chunks/GraphViewer/reactFlowOverrides.css | 4 + .../src/components/AtomicLink.tsx | 4 - .../NewInstanceButton/NewOntologyButton.tsx | 117 ++++++++++++++++++ .../components/NewInstanceButton/index.tsx | 2 + .../src/components/SideBar/AppMenu.tsx | 10 +- .../OntologySideBar/OntologiesPanel.tsx | 83 +++++++++++++ .../ResourceSideBar/ResourceSideBar.tsx | 5 - .../src/components/SideBar/SideBarItem.ts | 4 + .../src/components/SideBar/SideBarPanel.tsx | 75 +++++++++++ .../src/components/SideBar/index.tsx | 20 ++- .../src/components/SideBar/usePanelList.ts | 47 +++++++ .../forms/FileDropzone/FileDropzoneInput.tsx | 2 - .../forms/NewForm/NewFormDialog.tsx | 41 ++++-- .../src/components/forms/ResourceForm.tsx | 58 ++++----- .../ResourceSelector/ResourceSelector.tsx | 34 ++--- .../forms/SearchBox/SearchBoxWindow.tsx | 4 +- browser/data-browser/src/routes/NewRoute.tsx | 1 + .../data-browser/src/routes/SettingsTheme.tsx | 19 +++ .../src/views/FolderPage/iconMap.ts | 2 + .../OntologyPage/Class/ClassCardRead.tsx | 7 +- .../OntologyPage/CreateInstanceButton.tsx | 100 +++++++++++++++ .../src/views/OntologyPage/OntologyPage.tsx | 4 + browser/pnpm-lock.yaml | 6 +- browser/react/src/useLocalStorage.ts | 25 +++- 25 files changed, 580 insertions(+), 98 deletions(-) create mode 100644 browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx create mode 100644 browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx create mode 100644 browser/data-browser/src/components/SideBar/SideBarPanel.tsx create mode 100644 browser/data-browser/src/components/SideBar/usePanelList.ts create mode 100644 browser/data-browser/src/views/OntologyPage/CreateInstanceButton.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 844b44946..05774d35e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,9 +3,7 @@ "editor.formatOnSave": true, "files.autoSave": "onFocusChange", "rust-analyzer.checkOnSave.command": "clippy", - "editor.rulers": [ - 80 - ], + "search.exclude": { "**/.git": true, "**/node_modules": true, diff --git a/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css b/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css index e0f327673..1f8da72f9 100644 --- a/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css +++ b/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css @@ -3,3 +3,7 @@ border: none; cursor: grab; } + +.react-flow__attribution { + background: unset; +} diff --git a/browser/data-browser/src/components/AtomicLink.tsx b/browser/data-browser/src/components/AtomicLink.tsx index b83964308..f7ea23508 100644 --- a/browser/data-browser/src/components/AtomicLink.tsx +++ b/browser/data-browser/src/components/AtomicLink.tsx @@ -115,10 +115,6 @@ export const LinkView = styled.a` cursor: pointer; pointer-events: ${props => (props.disabled ? 'none' : 'inherit')}; - svg { - font-size: 60%; - } - &:hover { color: ${props => props.theme.colors.mainLight}; text-decoration: ${p => (p.clean ? 'none' : 'underline')}; diff --git a/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx new file mode 100644 index 000000000..cadd2b2c9 --- /dev/null +++ b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx @@ -0,0 +1,117 @@ +import { + Datatype, + classes, + properties, + useResource, + validateDatatype, +} from '@tomic/react'; +import React, { FormEvent, useCallback, useState } from 'react'; +import { Button } from '../Button'; +import { Dialog, DialogActions, DialogContent, useDialog } from '../Dialog'; +import Field from '../forms/Field'; +import { InputStyled, InputWrapper } from '../forms/InputStyles'; +import { Base } from './Base'; +import { useCreateAndNavigate } from './useCreateAndNavigate'; +import { NewInstanceButtonProps } from './NewInstanceButtonProps'; +import { stringToSlug } from '../../helpers/stringToSlug'; +import { styled } from 'styled-components'; + +export function NewOntologyButton({ + klass, + subtle, + icon, + IconComponent, + parent, + children, + label, +}: NewInstanceButtonProps): JSX.Element { + const ontology = useResource(klass); + + const [shortname, setShortname] = useState(''); + const [valid, setValid] = useState(false); + + const createResourceAndNavigate = useCreateAndNavigate(klass, parent); + + const onSuccess = useCallback(async () => { + createResourceAndNavigate('ontology', { + [properties.shortname]: shortname, + [properties.isA]: [classes.ontology], + [properties.description]: 'description', + [properties.classes]: [], + [properties.properties]: [], + [properties.instances]: [], + }); + }, [shortname]); + + const [dialogProps, show, hide] = useDialog({ onSuccess }); + + const onShortnameChange = (e: React.ChangeEvent) => { + const value = stringToSlug(e.target.value); + setShortname(value); + + try { + validateDatatype(value, Datatype.SLUG); + setValid(true); + } catch (_) { + setValid(false); + } + }; + + return ( + <> + + {children} + + +

New Ontology

+ +
{ + e.preventDefault(); + hide(true); + }} + > + + An ontology is a collection of classes and properties that + together describe a concept. Great for data models. + + + + + + +
+
+ + + + +
+ + ); +} + +const H1 = styled.h1` + margin: 0; +`; + +const Explanation = styled.p` + color: ${p => p.theme.colors.textLight}; + max-width: 60ch; +`; diff --git a/browser/data-browser/src/components/NewInstanceButton/index.tsx b/browser/data-browser/src/components/NewInstanceButton/index.tsx index fd822e29f..649d7f8be 100644 --- a/browser/data-browser/src/components/NewInstanceButton/index.tsx +++ b/browser/data-browser/src/components/NewInstanceButton/index.tsx @@ -5,6 +5,7 @@ import { NewInstanceButtonProps } from './NewInstanceButtonProps'; import { NewInstanceButtonDefault } from './NewInstanceButtonDefault'; import { useSettings } from '../../helpers/AppSettings'; import { NewTableButton } from './NewTableButton'; +import { NewOntologyButton } from './NewOntologyButton'; type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; @@ -12,6 +13,7 @@ type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; const classMap = new Map([ [classes.bookmark, NewBookmarkButton], [classes.table, NewTableButton], + [classes.ontology, NewOntologyButton], ]); /** A button for creating a new instance of some thing */ diff --git a/browser/data-browser/src/components/SideBar/AppMenu.tsx b/browser/data-browser/src/components/SideBar/AppMenu.tsx index f3355d949..e9a31ea70 100644 --- a/browser/data-browser/src/components/SideBar/AppMenu.tsx +++ b/browser/data-browser/src/components/SideBar/AppMenu.tsx @@ -9,7 +9,6 @@ import { import { constructOpenURL } from '../../helpers/navigation'; import { useCurrentSubject } from '../../helpers/useCurrentSubject'; import { SideBarMenuItem } from './SideBarMenuItem'; -import styled from 'styled-components'; import { paths } from '../../routes/paths'; import { unknownSubject, useCurrentAgent, useResource } from '@tomic/react'; @@ -57,7 +56,7 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { }, []); return ( -
+
} label={agent ? agentResource.title : 'Login'} @@ -95,11 +94,6 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { onClick={install} /> )} -
+
); } - -const Section = styled.section` - border-top: 1px solid ${p => p.theme.colors.bg2}; - padding-top: ${p => p.theme.margin}rem; -`; diff --git a/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx b/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx new file mode 100644 index 000000000..fd4dbbc2f --- /dev/null +++ b/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import styled from 'styled-components'; +import { + Collection, + unknownSubject, + urls, + useCollection, + useMemberFromCollection, +} from '@tomic/react'; +import { SideBarItem } from '../SideBarItem'; +import { Row } from '../../Row'; +import { AtomicLink } from '../../AtomicLink'; +import { getIconForClass } from '../../../views/FolderPage/iconMap'; +import { ScrollArea } from '../../ScrollArea'; +import { ErrorLook } from '../../ErrorLook'; + +export function OntologiesPanel(): JSX.Element | null { + const { collection } = useCollection({ + property: urls.properties.isA, + value: urls.classes.ontology, + }); + + return ( + + + {[...Array(collection.totalMembers).keys()].map(index => ( + + ))} + + + ); +} + +const Wrapper = styled.div` + padding-top: 0; + max-height: 10rem; + overflow: hidden; +`; + +const StyledScrollArea = styled(ScrollArea)` + height: 10rem; + overflow-x: hidden; +`; + +interface ItemProps { + index: number; + collection: Collection; +} + +function Item({ index, collection }: ItemProps): JSX.Element { + const resource = useMemberFromCollection(collection, index); + + const Icon = getIconForClass(urls.classes.ontology); + + if (resource.loading) { + return
loading
; + } + + if (resource.error || resource.getSubject() === unknownSubject) { + return ( + + Invalid Resource + + ); + } + + return ( + + + + + {resource.title} + + + + ); +} + +const StyledLink = styled(AtomicLink)` + flex: 1; + overflow: hidden; + white-space: nowrap; +`; diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx index 9fb63811f..b52b37dcd 100644 --- a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx @@ -135,11 +135,6 @@ const TextWrapper = styled.span` display: inline-flex; align-items: center; gap: 0.4rem; - - svg { - /* color: ${p => p.theme.colors.text}; */ - font-size: 0.8em; - } `; const SideBarErrorWrapper = styled(TextWrapper)` diff --git a/browser/data-browser/src/components/SideBar/SideBarItem.ts b/browser/data-browser/src/components/SideBar/SideBarItem.ts index e041c5bd9..e91e78b12 100644 --- a/browser/data-browser/src/components/SideBar/SideBarItem.ts +++ b/browser/data-browser/src/components/SideBar/SideBarItem.ts @@ -26,4 +26,8 @@ export const SideBarItem = styled('span')` &:active { background-color: ${p => p.theme.colors.bg2}; } + + svg { + font-size: 0.8rem; + } `; diff --git a/browser/data-browser/src/components/SideBar/SideBarPanel.tsx b/browser/data-browser/src/components/SideBar/SideBarPanel.tsx new file mode 100644 index 000000000..8d87b15c7 --- /dev/null +++ b/browser/data-browser/src/components/SideBar/SideBarPanel.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Collapse } from '../Collapse'; +import { FaCaretRight } from 'react-icons/fa'; +import { transition } from '../../helpers/transition'; + +interface SideBarPanelProps { + title: string; +} + +export function SideBarPanel({ + children, + title, +}: React.PropsWithChildren): JSX.Element { + const [open, setOpen] = React.useState(true); + + return ( + + setOpen(prev => !prev)}> + + + {title} + + + {children} + + ); +} + +export const PanelDevider = styled.h2` + font-size: inherit; + font-weight: normal; + font-family: inherit; + width: 100%; + display: flex; + align-items: center; + gap: 1ch; + + margin-bottom: 0; + + &::before, + &::after { + content: ''; + flex: 1; + border-top: 1px solid ${p => p.theme.colors.bg2}; + } + + cursor: pointer; + &:hover, + &:focus { + &::before, + &::after { + border-color: ${p => p.theme.colors.text}; + } + } +`; + +const DeviderButton = styled.button` + background: none; + border: none; + margin: 0; + padding: 0; +`; + +const Arrow = styled(FaCaretRight)<{ $open: boolean }>` + transform: rotate(${p => (p.$open ? '90deg' : '0deg')}); + ${transition('transform')} +`; + +const Wrapper = styled.div` + width: 100%; + max-height: fit-content; + display: flex; + flex-direction: column; +`; diff --git a/browser/data-browser/src/components/SideBar/index.tsx b/browser/data-browser/src/components/SideBar/index.tsx index c0ded8bfd..0031c5e38 100644 --- a/browser/data-browser/src/components/SideBar/index.tsx +++ b/browser/data-browser/src/components/SideBar/index.tsx @@ -10,6 +10,9 @@ import { AppMenu } from './AppMenu'; import { About } from './About'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { Column } from '../Row'; +import { OntologiesPanel } from './OntologySideBar/OntologiesPanel'; +import { SideBarPanel } from './SideBarPanel'; +import { Panel, usePanelList } from './usePanelList'; /** Amount of pixels where the sidebar automatically shows */ export const SIDEBAR_TOGGLE_WIDTH = 600; @@ -31,6 +34,8 @@ export function SideBar(): JSX.Element { maxSize: 2000, }); + const { enabledPanels } = usePanelList(); + const mountRefs = useCombineRefs([ref, targetRef]); /** @@ -61,9 +66,18 @@ export function SideBar(): JSX.Element { {/* The key is set to make sure the component is re-loaded when the baseURL changes */} - - - + + {enabledPanels.has(Panel.Ontologies) && ( + + + + )} + + + + + + diff --git a/browser/data-browser/src/components/SideBar/usePanelList.ts b/browser/data-browser/src/components/SideBar/usePanelList.ts new file mode 100644 index 000000000..ae3a7cc0a --- /dev/null +++ b/browser/data-browser/src/components/SideBar/usePanelList.ts @@ -0,0 +1,47 @@ +import { useLocalStorage } from '@tomic/react'; + +export enum Panel { + Ontologies = 'ontologies', +} + +import { useCallback, useMemo } from 'react'; + +export const usePanelList = (): { + enabledPanels: Set; + enablePanel: (panel: Panel) => void; + disablePanel: (panel: Panel) => void; +} => { + const [enabledPanels, setEnabledPanels] = useLocalStorage( + 'sidebar-panels', + [], + ); + + const enablePanel = useCallback( + (panel: Panel) => { + if (!enabledPanels.includes(panel)) { + setEnabledPanels([...enabledPanels, panel]); + } + }, + [enabledPanels, setEnabledPanels], + ); + + const disablePanel = useCallback( + (panel: Panel) => { + if (enabledPanels.includes(panel)) { + setEnabledPanels(enabledPanels.filter(p => p !== panel)); + } + }, + [enabledPanels, setEnabledPanels], + ); + + const enabledPanelsSet = useMemo( + () => new Set(enabledPanels), + [enabledPanels], + ); + + return { + enabledPanels: enabledPanelsSet, + enablePanel, + disablePanel, + }; +}; diff --git a/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx b/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx index 69b11f7b6..6bbe271fb 100644 --- a/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx +++ b/browser/data-browser/src/components/forms/FileDropzone/FileDropzoneInput.tsx @@ -62,8 +62,6 @@ export function FileDropzoneInput({ } const VisualDropZone = styled.div` - background-color: ${p => - p.theme.darkMode ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'}; backdrop-filter: blur(10px); border: 2px dashed ${p => p.theme.colors.bg2}; border-radius: ${p => p.theme.radius}; diff --git a/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx index 98a793291..484900780 100644 --- a/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx +++ b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx @@ -1,4 +1,10 @@ -import { properties, useResource, useStore, useTitle } from '@tomic/react'; +import { + JSONValue, + properties, + useResource, + useStore, + useTitle, +} from '@tomic/react'; import React, { useState, useCallback } from 'react'; import { useEffectOnce } from '../../../hooks/useEffectOnce'; import { Button } from '../../Button'; @@ -11,10 +17,11 @@ import { NewFormProps } from './NewFormPage'; import { NewFormTitle, NewFormTitleVariant } from './NewFormTitle'; import { SubjectField } from './SubjectField'; import { useNewForm } from './useNewForm'; +import { randomString } from '../../../helpers/randomString'; export interface NewFormDialogProps extends NewFormProps { closeDialog: () => void; - initialTitle: string; + initialProps?: Record; onSave: (subject: string) => void; parent: string; } @@ -23,15 +30,13 @@ export interface NewFormDialogProps extends NewFormProps { export const NewFormDialog = ({ classSubject, closeDialog, - initialTitle, + initialProps, onSave, parent, }: NewFormDialogProps): JSX.Element => { const klass = useResource(classSubject); const [className] = useTitle(klass); const store = useStore(); - // Wrap in useState to avoid changing the value when the prop changes. - const [initialShortname] = useState(initialTitle); const [subject, setSubject] = useState(store.createSubject()); @@ -49,13 +54,24 @@ export const NewFormDialog = ({ // Onmount we generate a new subject based on the classtype and the user input. useEffectOnce(() => { - store.buildUniqueSubjectFromParts(className, initialShortname).then(val => { - setSubjectValue(val); - }); + (async () => { + const namePart = normalizeName( + (initialProps?.[properties.shortname] as string) ?? + (initialProps?.[properties.name] as string) ?? + randomString(8), + ); - // Set the shortname to the initial user input of a dropdown. - // In the future we might need to change this when we want to have forms other than `property` and`class` in dialogs. - resource.set(properties.shortname, initialShortname, store); + const uniqueSubject = await store.buildUniqueSubjectFromParts( + className, + namePart, + ); + + await setSubjectValue(uniqueSubject); + + for (const [prop, value] of Object.entries(initialProps ?? {})) { + await resource.set(prop, value, store); + } + })(); }); const [save, saving, error] = useSaveResource(resource, onResourceSave); @@ -84,6 +100,7 @@ export const NewFormDialog = ({ classSubject={classSubject} key={`${classSubject}+${subjectValue}`} variant={ResourceFormVariant.Dialog} + onSave={onResourceSave} /> @@ -98,3 +115,5 @@ export const NewFormDialog = ({ ); }; + +const normalizeName = (name: string) => name.replaceAll('/t', '-'); diff --git a/browser/data-browser/src/components/forms/ResourceForm.tsx b/browser/data-browser/src/components/forms/ResourceForm.tsx index 4b28ca3fc..170cf92ab 100644 --- a/browser/data-browser/src/components/forms/ResourceForm.tsx +++ b/browser/data-browser/src/components/forms/ResourceForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useArray, @@ -13,8 +13,7 @@ import { Client, useStore, } from '@tomic/react'; -import { styled } from 'styled-components'; -import { FaCaretDown, FaCaretRight, FaPlus } from 'react-icons/fa'; +import { FaCaretDown, FaCaretRight } from 'react-icons/fa'; import { constructOpenURL } from '../../helpers/navigation'; import { Button } from '../Button'; @@ -43,6 +42,7 @@ export interface ResourceFormProps { resource: Resource; variant?: ResourceFormVariant; + onSave?: () => void; } const nonEssentialProps = [ @@ -50,6 +50,7 @@ const nonEssentialProps = [ properties.parent, properties.read, properties.write, + properties.commit.lastCommit, ]; /** Form for editing and creating a Resource */ @@ -57,6 +58,7 @@ export function ResourceForm({ classSubject, resource, variant, + onSave, }: ResourceFormProps): JSX.Element { const [isAArray] = useArray(resource, properties.isA); @@ -72,10 +74,8 @@ export function ResourceForm({ const [klassIsa] = useString(klass, properties.isA); const [newPropErr, setNewPropErr] = useState(undefined); const navigate = useNavigate(); - const [newProperty, setNewProperty] = useState(undefined); /** A list of custom properties, set by the User while editing this form */ const [tempOtherProps, setTempOtherProps] = useState([]); - const [otherProps, setOtherProps] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); const store = useStore(); const wasNew: boolean = resource.new; @@ -84,14 +84,14 @@ export function ResourceForm({ // We need to read the earlier .new state, because the resource is no // longer new after it was saved, during this callback wasNew && store.notifyResourceManuallyCreated(resource); + onSave?.(); navigate(constructOpenURL(resource.getSubject())); }); // I'm not entirely sure if debouncing is needed here. const debouncedResource = useDebounce(resource, 5000); const [_canWrite, canWriteErr] = useCanWrite(debouncedResource); - /** Builds otherProps */ - useEffect(() => { + const otherProps = useMemo(() => { const allProps = Array.from(resource.getPropVals().keys()); const prps = allProps.filter(prop => { @@ -108,8 +108,8 @@ export function ResourceForm({ return propIsNotRenderedYet && isEssential; }); - setOtherProps(prps.concat(tempOtherProps)); - // I actually want to run this useEffect every time the requires / recommends + return [...prps, ...tempOtherProps]; + // I actually want to run this memo every time the requires / recommends // array changes, but that leads to a weird loop, so that's what the length is for }, [resource, tempOtherProps, requires.length, recommends.length]); @@ -134,23 +134,23 @@ export function ResourceForm({ ); } - function handleAddProp() { + function handleAddProp(newProp: string | undefined) { setNewPropErr(undefined); - if (!Client.isValidSubject(newProperty)) { + if (!Client.isValidSubject(newProp)) { setNewPropErr(new Error('Invalid URL')); return; } - if (!newProperty) { + if (!newProp) { return; } if ( - tempOtherProps.includes(newProperty) || - requires.includes(newProperty) || - recommends.includes(newProperty) + tempOtherProps.includes(newProp) || + requires.includes(newProp) || + recommends.includes(newProp) ) { setNewPropErr( new Error( @@ -158,10 +158,8 @@ export function ResourceForm({ ), ); } else { - setTempOtherProps(tempOtherProps.concat(newProperty)); + setTempOtherProps(prev => [...prev, newProp]); } - - setNewProperty(undefined); } function handleDelete(propertyURL: string) { @@ -211,25 +209,16 @@ export function ResourceForm({ label='add another property...' helper='In Atomic Data, any Resource could have any single Property. Use this field to add new property-value combinations to your resource.' > - - {/* TODO: When adding a property, clear the form. Make the button optional / remove it. */} - +
{ - setNewProperty(set); + handleAddProp(set); }} error={newPropErr} isA={urls.classes.property} /> - +
{newPropErr && {newPropErr.message}} @@ -249,6 +238,10 @@ export function ResourceForm({ + {variant !== ResourceFormVariant.Dialog && ( <> @@ -265,8 +258,3 @@ export function ResourceForm({ ResourceForm.defaultProps = { variant: ResourceFormVariant.Default, }; - -const PropertyAdder = styled.div` - display: flex; - flex-direction: row; -`; diff --git a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx index b57d7ac63..b9a3d5780 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx @@ -8,15 +8,9 @@ import { SearchBox } from '../SearchBox'; import { SearchBoxButton } from '../SearchBox/SearchBox'; import { FaTrash } from 'react-icons/fa'; import { ErrorChip } from '../ErrorChip'; +import { urls } from '@tomic/react'; interface ResourceSelectorProps { - /** - * Whether a certain type of Class is required here. Pass the URL of the - * class. Is used for constructing a list of options. - */ - isA?: string; - /** If true, the form will show an error if it is left empty. */ - required?: boolean; /** * This callback is called when the Subject Changes. You can pass an Error * Handler as the second argument to set an error message. Take the second @@ -25,10 +19,15 @@ interface ResourceSelectorProps { setSubject: (subject: string | undefined) => void; /** The value (URL of the Resource that is selected) */ value?: string; + /** + * Whether a certain type of Class is required here. Pass the URL of the + * class. Is used for constructing a list of options. + */ + isA?: string; + /** If true, the form will show an error if it is left empty. */ + required?: boolean; /** A function to remove this item. Only relevant in arrays. */ handleRemove?: () => void; - /** Only pass an error if it is applicable to this specific field */ - onValidate?: (valid: boolean) => void; error?: Error; disabled?: boolean; autoFocus?: boolean; @@ -46,9 +45,8 @@ export const ResourceSelector = React.memo(function ResourceSelector({ setSubject, value, handleRemove, - onValidate, error, - isA: classType, + isA, disabled, parent, hideCreateOption, @@ -60,7 +58,7 @@ export const ResourceSelector = React.memo(function ResourceSelector({ const { inDialog } = useDialogTreeContext(); const handleCreateItem = useMemo(() => { - if (hideCreateOption) { + if (hideCreateOption || !isA) { return undefined; } @@ -68,14 +66,14 @@ export const ResourceSelector = React.memo(function ResourceSelector({ setInitialNewTitle(name); showDialog(); }; - }, [hideCreateOption, setSubject, showDialog]); + }, [hideCreateOption, setSubject, showDialog, isA]); return ( {error && {error.message}} - {!inDialog && classType && ( + {!inDialog && isA && ( {isDialogOpen && ( )} diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx index 68593d9d9..3810a56b3 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx @@ -165,7 +165,7 @@ export function SearchBoxWindow({ {!searchValue && Start Searching}
    - {onCreateItem && ( + {onCreateItem ? ( handleMouseMove(0)} @@ -173,7 +173,7 @@ export function SearchBoxWindow({ > Create {searchValue} - )} + ) : null} {results.map((result, i) => ( { const { @@ -18,6 +19,16 @@ export const SettingsTheme: React.FunctionComponent = () => { setViewTransitionsEnabled, } = useSettings(); + const { enabledPanels, enablePanel, disablePanel } = usePanelList(); + + const changePanelPref = (panel: Panel) => (state: boolean) => { + if (state) { + enablePanel(panel); + } else { + disablePanel(panel); + } + }; + return (
    @@ -53,6 +64,14 @@ export const SettingsTheme: React.FunctionComponent = () => { Main color + Panels + + {' '} + Enable Ontology panel + Animations ([ [classes.property, FaCubes], [classes.table, FaTable], [classes.property, FaHashtag], + [classes.ontology, FaShapes], ]); export function getIconForClass( diff --git a/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx b/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx index acf3aab15..e3fe97693 100644 --- a/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx +++ b/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx @@ -8,6 +8,8 @@ import { Column } from '../../../components/Row'; import Markdown from '../../../components/datatypes/Markdown'; import { AtomicLink } from '../../../components/AtomicLink'; import { toAnchorId } from '../toAnchorId'; +import { ViewTransitionProps } from '../../../helpers/ViewTransitionProps'; +import { transitionName } from '../../../helpers/transitionName'; interface ClassCardReadProps { subject: string; @@ -20,7 +22,7 @@ export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { const [recommends] = useArray(resource, urls.properties.recommends); return ( - + @@ -48,8 +50,9 @@ export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { ); } -const StyledCard = styled(Card)` +const StyledCard = styled(Card)` padding-bottom: ${p => p.theme.margin}rem; + ${props => transitionName('resource-page', props.subject)}; `; const StyledH3 = styled.h3` diff --git a/browser/data-browser/src/views/OntologyPage/CreateInstanceButton.tsx b/browser/data-browser/src/views/OntologyPage/CreateInstanceButton.tsx new file mode 100644 index 000000000..993c2eac4 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/CreateInstanceButton.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { Resource, urls, useStore } from '@tomic/react'; +import styled from 'styled-components'; +import { FaPlus } from 'react-icons/fa'; +import { ResourceSelector } from '../../components/forms/ResourceSelector'; +import { Column } from '../../components/Row'; +import { NewFormDialog } from '../../components/forms/NewForm/NewFormDialog'; +import { Dialog, useDialog } from '../../components/Dialog'; + +interface CreateInstanceButtonProps { + ontology: Resource; +} + +export function CreateInstanceButton({ ontology }: CreateInstanceButtonProps) { + const store = useStore(); + const [active, setActive] = useState(false); + const [classSubject, setClassSubject] = useState(); + const [dialogProps, show, close, isOpen] = useDialog(); + + const handleClassSelect = (subject: string | undefined) => { + setClassSubject(subject); + + if (subject === undefined) { + return; + } + + show(); + }; + + const handleSave = (subject: string) => { + ontology.pushPropVal(urls.properties.instances, [subject], true); + ontology.save(store); + setClassSubject(undefined); + setActive(false); + }; + + return ( + <> + {!active ? ( + setActive(true)}> + + New Instance + + ) : ( + <> + + + Select the class for this instance + + + + + {isOpen && classSubject && ( + + )} + + + )} + + ); +} + +const InstanceButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + gap: 1ch; + + cursor: pointer; + appearance: none; + border: 2px dashed ${p => p.theme.colors.bg2}; + height: 10rem; + background-color: transparent; + border-radius: ${p => p.theme.radius}; + color: ${p => p.theme.colors.textLight}; + &:hover, + &:focus { + border-color: ${p => p.theme.colors.main}; + color: ${p => p.theme.colors.main}; + background-color: ${p => p.theme.colors.bg}; + } +`; + +const ChooseClassFormWrapper = styled.div` + min-height: 10rem; + border: 2px dashed ${p => p.theme.colors.bg2}; + background-color: ${p => p.theme.colors.bg}; + border-radius: ${p => p.theme.radius}; + padding: ${p => p.theme.margin}rem; +`; diff --git a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx index 51be7e0f9..7f13fc3e1 100644 --- a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx +++ b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx @@ -16,6 +16,7 @@ import { toAnchorId } from './toAnchorId'; import { OntologyContextProvider } from './OntologyContext'; import { PropertyCardWrite } from './Property/PropertyCardWrite'; import { Graph } from './Graph'; +import { CreateInstanceButton } from './CreateInstanceButton'; export function OntologyPage({ resource }: ResourcePageProps) { const [classes] = useArray(resource, urls.properties.classes); @@ -87,6 +88,7 @@ export function OntologyPage({ resource }: ResourcePageProps) { ))} + {editMode && } @@ -125,6 +127,8 @@ const FullPageWrapper = styled.div<{ edit: boolean }>` --ontology-graph-position: sticky; --ontology-graph-ratio: 16/9; } + + padding-bottom: 3rem; `; const TitleSlot = styled.div` diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 006441aab..cd41a1fac 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -100,11 +100,12 @@ importers: cli: dependencies: '@tomic/lib': - specifier: ^0.35.1 + specifier: ^0.35.2 version: link:../lib chalk: specifier: ^5.3.0 version: 5.3.0 + devDependencies: typescript: specifier: ^4.8 version: 4.9.5 @@ -281,9 +282,6 @@ importers: '@types/fast-json-stable-stringify': specifier: ^2.1.0 version: 2.1.0 - '@types/yargs': - specifier: ^17.0.24 - version: 17.0.24 chai: specifier: ^4.3.4 version: 4.3.7 diff --git a/browser/react/src/useLocalStorage.ts b/browser/react/src/useLocalStorage.ts index 95aa48669..604ce8a8f 100644 --- a/browser/react/src/useLocalStorage.ts +++ b/browser/react/src/useLocalStorage.ts @@ -1,4 +1,9 @@ -import { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; + +const listeners = new Map< + string, + Set<(value: React.SetStateAction) => void> +>(); export type SetLocalStorageValue = (value: T | ((val: T) => T)) => void; /** @@ -39,8 +44,12 @@ export function useLocalStorage( // Allow value to be a function so we have same API as useState const valueToStore = value instanceof Function ? value(storedValue) : value; + // Save state - setStoredValue(valueToStore); + for (const listener of listeners.get(key) || []) { + listener(valueToStore); + } + // Save to local storage window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { @@ -51,5 +60,17 @@ export function useLocalStorage( [storedValue, key], ); + useEffect(() => { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + + listeners.get(key)?.add(setStoredValue as (value: unknown) => void); + + return () => { + listeners.get(key)?.delete(setStoredValue as (value: unknown) => void); + }; + }, [key]); + return [storedValue, setValue]; }