diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 8c6b40f10..8b48034df 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -2,6 +2,13 @@ This changelog covers all three packages, as they are (for now) updated as a whole +## UNRELEASED + +### Atomic Browser + +- [#747](https://github.com/atomicdata-dev/atomic-server/issues/747) Show ontology classes on new resource page. +- Fix server not rebuilding client when files changed. + ## v0.36.2 ### Atomic Browser diff --git a/browser/data-browser/src/App.tsx b/browser/data-browser/src/App.tsx index fb97b5cd2..39111acae 100644 --- a/browser/data-browser/src/App.tsx +++ b/browser/data-browser/src/App.tsx @@ -1,6 +1,7 @@ import { BrowserRouter } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import { StoreContext, Store } from '@tomic/react'; +import { StyleSheetManager } from 'styled-components'; import { GlobalStyle, ThemeWrapper } from './styling'; import { AppRoutes } from './routes/Routes'; @@ -22,7 +23,7 @@ import { PopoverContainer } from './components/Popover'; import { SkipNav } from './components/SkipNav'; import { ControlLockProvider } from './hooks/useControlLock'; import { FormValidationContextProvider } from './components/forms/formValidation/FormValidationContextProvider'; -import { StyleSheetManager } from 'styled-components'; +import { registerCustomCreateActions } from './components/forms/NewForm/CustomCreateActions'; function fixDevUrl(url: string) { if (isDev()) { @@ -60,6 +61,7 @@ const ErrBoundary = window.bugsnagApiKey // Fetch all the Properties and Classes - this helps speed up the app. store.preloadPropsAndClasses(); +registerCustomCreateActions(); // Register global event handlers. registerHandlers(store); @@ -69,6 +71,7 @@ if (isDev()) { } import isPropValid from '@emotion/is-prop-valid'; +import { NewResourceUIProvider } from './components/forms/NewForm/useNewResourceUI'; // This implements the default behavior from styled-components v5 function shouldForwardProp(propName, target) { @@ -107,10 +110,12 @@ function App(): JSX.Element { - - - - + + + + + + diff --git a/browser/data-browser/src/components/IconButton/IconButton.story.mdx b/browser/data-browser/src/components/IconButton/IconButton.story.mdx deleted file mode 100644 index f935a27b4..000000000 --- a/browser/data-browser/src/components/IconButton/IconButton.story.mdx +++ /dev/null @@ -1,55 +0,0 @@ -import { IconButton, IconButtonVariant } from './index'; -import { Row } from '../Row'; -import { FaPoo } from 'react-icons/fa'; - -# IconButton - -## Demo - -### Default - - - - - -### Color - - - - - -### Size - - - - - -### HTML Button attributes - -IconButton accepts all html button attributes - - - - - -### Variants - -IconButton has the following variants: - -```ts -IconButtonVariant.Simple; -IconButtonVariant.Outline; -IconButtonVariant.Fill; -``` - - - - - - - - - - - - diff --git a/browser/data-browser/src/components/IconButton/IconButton.tsx b/browser/data-browser/src/components/IconButton/IconButton.tsx index ec7f6e4bf..6751c93b4 100644 --- a/browser/data-browser/src/components/IconButton/IconButton.tsx +++ b/browser/data-browser/src/components/IconButton/IconButton.tsx @@ -89,7 +89,7 @@ const IconButtonBase = styled.button` color: ${p => p.theme.colors.text}; font-size: ${p => p.size ?? '1em'}; border: none; - + user-select: none; padding: var(--button-padding); width: calc(${p => p.size} + var(--button-padding) * 2); height: calc(${p => p.size} + var(--button-padding) * 2); @@ -115,7 +115,7 @@ const SimpleIconButton = styled(IconButtonBase)` background-color: ${p => p.theme.colors.bg1}; } - :active { + &:active { background-color: ${p => p.theme.colors.bg2}; } } diff --git a/browser/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx deleted file mode 100644 index b44d3e12e..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/NewBookmarkButton.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { classes, properties, useResource, useTitle } from '@tomic/react'; -import { FormEvent, useCallback, useState } from 'react'; -import { Button } from '../Button'; -import { - Dialog, - DialogActions, - DialogContent, - DialogTitle, - 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'; - -function normalizeWebAddress(url: string) { - if (/^[http://|https://]/i.test(url)) { - return url; - } - - return `https://${url}`; -} - -export function NewBookmarkButton({ - klass, - subtle, - icon, - IconComponent, - parent, - children, - label, -}: NewInstanceButtonProps): JSX.Element { - const resource = useResource(klass); - const [title] = useTitle(resource); - - const [url, setUrl] = useState(''); - - const [dialogProps, show, hide] = useDialog(); - - const createResourceAndNavigate = useCreateAndNavigate(klass, parent); - - const onDone = useCallback( - (e: FormEvent) => { - e.preventDefault(); - - const normalizedUrl = normalizeWebAddress(url); - - createResourceAndNavigate('bookmark', { - [properties.name]: 'New Bookmark', - [properties.bookmark.url]: normalizedUrl, - [properties.isA]: [classes.bookmark], - }); - }, - [url], - ); - - return ( - <> - - {children} - - - -

New Bookmark

-
- -
- - - setUrl(e.target.value)} - /> - - -
-
- - - - -
- - ); -} diff --git a/browser/data-browser/src/components/NewInstanceButton/NewInstanceButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewInstanceButton.tsx new file mode 100644 index 000000000..9f2249e31 --- /dev/null +++ b/browser/data-browser/src/components/NewInstanceButton/NewInstanceButton.tsx @@ -0,0 +1,53 @@ +import { useResource } from '@tomic/react'; + +import { useSettings } from '../../helpers/AppSettings'; +import { useNewResourceUI } from '../forms/NewForm/useNewResourceUI'; +import { Base } from './Base'; +import { IconType } from 'react-icons'; + +interface NewInstanceButtonProps { + /** URL of the Class to be instantiated */ + klass: string; + subtle?: boolean; + icon?: boolean; + IconComponent?: IconType; + /** subject of the parent Resource, which will be passed to the form */ + parent?: string; + /** Give explicit label. If missing, uses the Shortname of the Class */ + label?: string; + className?: string; +} + +/** A button for creating a new instance of some thing */ +export function NewInstanceButton({ + klass, + subtle, + icon, + IconComponent, + parent, + children, + label, + className, +}: React.PropsWithChildren): JSX.Element { + const { drive } = useSettings(); + const classResource = useResource(klass); + const showNewResourceUI = useNewResourceUI(); + + const onClick = () => { + showNewResourceUI(klass, parent ?? drive); + }; + + return ( + + {children} + + ); +} diff --git a/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx b/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx deleted file mode 100644 index e59e4d334..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonDefault.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { useResource, useTitle } from '@tomic/react'; -import { NewInstanceButtonProps } from './NewInstanceButtonProps'; -import { Base } from './Base'; -import { useDefaultNewInstanceHandler } from './useDefaultNewInstanceHandler'; - -/** Default handler for the new Instance button. DO NOT USE DIRECTLY. */ -export function NewInstanceButtonDefault({ - klass, - subtle, - icon, - IconComponent, - parent, - children, - label, - className, -}: NewInstanceButtonProps): JSX.Element { - const classResource = useResource(klass); - const [title] = useTitle(classResource); - - const onClick = useDefaultNewInstanceHandler(klass, parent); - - return ( - - {children} - - ); -} diff --git a/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonProps.ts b/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonProps.ts deleted file mode 100644 index 36a7f5117..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/NewInstanceButtonProps.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IconType } from 'react-icons'; - -interface Props { - /** URL of the Class to be instantiated */ - klass: string; - subtle?: boolean; - icon?: boolean; - IconComponent?: IconType; - /** subject of the parent Resource, which will be passed to the form */ - parent?: string; - /** Give explicit label. If missing, uses the Shortname of the Class */ - label?: string; - className?: string; -} - -export type NewInstanceButtonProps = React.PropsWithChildren; diff --git a/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx deleted file mode 100644 index fdacc35e8..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { - Datatype, - classes, - properties, - useResource, - validateDatatype, -} from '@tomic/react'; -import { 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, createResourceAndNavigate]); - - 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/NewTableButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewTableButton.tsx deleted file mode 100644 index 7bd52fb6b..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/NewTableButton.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { - classes, - properties, - useResource, - useStore, - useTitle, -} from '@tomic/react'; -import { FormEvent, useCallback, useState } from 'react'; -import { Button } from '../Button'; -import { - Dialog, - DialogActions, - DialogContent, - DialogTitle, - 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'; -import { BetaBadge } from '../BetaBadge'; - -const instanceOpts = { - newResource: true, -}; - -export function NewTableButton({ - klass, - subtle, - icon, - IconComponent, - parent, - children, - label, -}: NewInstanceButtonProps): JSX.Element { - const store = useStore(); - const resource = useResource(klass); - const [instanceSubject] = useState(() => store.createSubject('class')); - const instanceResource = useResource(instanceSubject, instanceOpts); - - const [title] = useTitle(resource); - const [name, setName] = useState(''); - - const createResourceAndNavigate = useCreateAndNavigate(klass, parent); - - const onCancel = useCallback(() => { - instanceResource.destroy(store); - }, []); - - const onSuccess = useCallback(async () => { - await instanceResource.set(properties.shortname, stringToSlug(name), store); - await instanceResource.set( - properties.description, - `Represents a row in the ${name} table`, - store, - ); - await instanceResource.set(properties.isA, [classes.class], store); - await instanceResource.set(properties.parent, parent, store); - await instanceResource.set(properties.recommends, [properties.name], store); - await instanceResource.save(store); - - createResourceAndNavigate('table', { - [properties.name]: name, - [properties.classType]: instanceResource.getSubject(), - [properties.isA]: [classes.table], - }); - }, [name, instanceResource]); - - const [dialogProps, show, hide] = useDialog({ onCancel, onSuccess }); - - return ( - <> - - {children} - - - -

New Table

- -
- -
{ - e.preventDefault(); - hide(true); - }} - > - - - setName(e.target.value)} - /> - - -
-
- - - - -
- - ); -} - -const WiderDialogContent = styled(DialogContent)` - width: min(80vw, 20rem); -`; - -const RelativeDialogTitle = styled(DialogTitle)` - display: flex; - align-items: flex-start; - gap: 1ch; -`; diff --git a/browser/data-browser/src/components/NewInstanceButton/index.ts b/browser/data-browser/src/components/NewInstanceButton/index.ts new file mode 100644 index 000000000..7f43e3c87 --- /dev/null +++ b/browser/data-browser/src/components/NewInstanceButton/index.ts @@ -0,0 +1 @@ +export * from './NewInstanceButton'; diff --git a/browser/data-browser/src/components/NewInstanceButton/index.tsx b/browser/data-browser/src/components/NewInstanceButton/index.tsx deleted file mode 100644 index e31cf77e0..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/index.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { classes } from '@tomic/react'; - -import { NewBookmarkButton } from './NewBookmarkButton'; -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; - -/** If your New Instance button requires custom logic, such as a custom dialog */ -const classMap = new Map([ - [classes.bookmark, NewBookmarkButton], - [classes.table, NewTableButton], - [classes.ontology, NewOntologyButton], -]); - -/** A button for creating a new instance of some thing */ -export default function NewInstanceButton( - props: NewInstanceButtonProps, -): JSX.Element { - const { klass, parent } = props; - const { drive } = useSettings(); - - const Comp = classMap.get(klass) ?? NewInstanceButtonDefault; - - return ; -} - -export { useDefaultNewInstanceHandler } from './useDefaultNewInstanceHandler'; -export { useCreateAndNavigate } from './useCreateAndNavigate'; diff --git a/browser/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx b/browser/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx deleted file mode 100644 index 53e35df09..000000000 --- a/browser/data-browser/src/components/NewInstanceButton/useDefaultNewInstanceHandler.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { - classes, - properties, - useResource, - useStore, - useString, -} from '@tomic/react'; -import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useSettings } from '../../helpers/AppSettings'; -import { newURL } from '../../helpers/navigation'; -import { useCreateAndNavigate } from './useCreateAndNavigate'; - -/** - * Returns a function that can be used to create a new instance of the given Class. - * This is the place where you can add custom behavior for certain classes. - * By default, we're redirected to an empty Form for the new instance. - * For some Classes, though, we'd rather have some values are pre-filled (e.g. a new ChatRoom with a `new chatroom` title). - * For others, we want to render a custom form, perhaps with a different layout. - */ -export function useDefaultNewInstanceHandler(klass: string, parent?: string) { - const store = useStore(); - const { setDrive } = useSettings(); - const navigate = useNavigate(); - - const classResource = useResource(klass); - const [shortname] = useString(classResource, properties.shortname); - - const createResourceAndNavigate = useCreateAndNavigate(klass, parent); - - const onClick = useCallback(async () => { - try { - switch (klass) { - case classes.chatRoom: { - createResourceAndNavigate('chatRoom', { - [properties.name]: 'Untitled ChatRoom', - [properties.isA]: [classes.chatRoom], - }); - break; - } - - case classes.document: { - createResourceAndNavigate('document', { - [properties.isA]: [classes.document], - [properties.name]: 'Untitled Document', - }); - break; - } - - case classes.folder: { - createResourceAndNavigate('folder', { - [properties.isA]: [classes.folder], - [properties.name]: 'Untitled Folder', - [properties.displayStyle]: classes.displayStyles.list, - }); - break; - } - - case classes.drive: { - const agent = store.getAgent(); - - if (!agent || agent.subject === undefined) { - throw new Error( - 'No agent set in the Store, required when creating a Drive', - ); - } - - const newResource = await createResourceAndNavigate( - 'drive', - { - [properties.isA]: [classes.drive], - [properties.write]: [agent.subject], - [properties.read]: [agent.subject], - }, - undefined, - true, - ); - - const agentResource = await store.getResourceAsync(agent.subject); - agentResource.pushPropVal(properties.drives, [ - newResource.getSubject(), - ]); - agentResource.save(store); - setDrive(newResource.getSubject()); - break; - } - - default: { - // Opens an `Edit` form with the class and a decent subject name - navigate(newURL(klass, parent, store.createSubject(shortname))); - } - } - } catch (e) { - store.notifyError(e); - } - }, [klass, store, parent, createResourceAndNavigate]); - - return onClick; -} diff --git a/browser/data-browser/src/components/ProgressBar.tsx b/browser/data-browser/src/components/ProgressBar.tsx index c25f6a1bc..a3446c91d 100644 --- a/browser/data-browser/src/components/ProgressBar.tsx +++ b/browser/data-browser/src/components/ProgressBar.tsx @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import { styled } from 'styled-components'; interface ProgressBarProps { value: number; diff --git a/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx b/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx index 8d24e72ce..9a640dd5c 100644 --- a/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx +++ b/browser/data-browser/src/components/SideBar/DriveSwitcher.tsx @@ -1,4 +1,4 @@ -import { classes, Resource, urls, useResources } from '@tomic/react'; +import { Resource, core, server, useResources } from '@tomic/react'; import { useMemo } from 'react'; import { FaCog, @@ -15,13 +15,13 @@ import { useSavedDrives } from '../../hooks/useSavedDrives'; import { paths } from '../../routes/paths'; import { DIVIDER, DropdownMenu } from '../Dropdown'; import { buildDefaultTrigger } from '../Dropdown/DefaultTrigger'; -import { useDefaultNewInstanceHandler } from '../NewInstanceButton'; +import { useNewResourceUI } from '../forms/NewForm/useNewResourceUI'; const Trigger = buildDefaultTrigger(, 'Open Drive Settings'); function getTitle(resource: Resource): string { return ( - (resource.get(urls.properties.name) as string) ?? resource.getSubject() + (resource.get(core.properties.name) as string) ?? resource.getSubject() ); } @@ -44,10 +44,7 @@ export function DriveSwitcher() { navigate(constructOpenURL(subject)); }; - const createNewDrive = useDefaultNewInstanceHandler( - classes.drive, - agent?.subject, - ); + const createNewResource = useNewResourceUI(); const items = useMemo( () => [ @@ -89,7 +86,8 @@ export function DriveSwitcher() { label: 'New Drive', icon: , helper: 'Create a new drive', - onClick: createNewDrive, + onClick: () => + createNewResource(server.classes.drive, agent?.subject ?? ''), }, ], [savedDrivesMap, drive, historyMap], diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts new file mode 100644 index 000000000..290153d1a --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts @@ -0,0 +1,73 @@ +import { dataBrowser, core, classes, server } from '@tomic/react'; +import { registerBasicInstanceHandler } from '../useNewResourceUI'; + +/** + * These handlers do not show any UI / inputs when creating new instances. + * This is where they can have hardcoded default values or custom logic. + */ +export const registerBasicInstanceHandlers = () => { + registerBasicInstanceHandler( + dataBrowser.classes.folder, + async (parent, createAndNavigate) => { + await createAndNavigate( + dataBrowser.classes.folder, + { + [core.properties.name]: 'Untitled Folder', + [dataBrowser.properties.displayStyle]: classes.displayStyles.list, + }, + parent, + ); + }, + ); + + registerBasicInstanceHandler( + dataBrowser.classes.chatroom, + async (parent, createAndNavigate) => { + await createAndNavigate( + dataBrowser.classes.chatroom, + { + [core.properties.name]: 'Untitled ChatRoom', + }, + parent, + ); + }, + ); + + registerBasicInstanceHandler( + dataBrowser.classes.document, + async (parent, createAndNavigate) => { + createAndNavigate( + dataBrowser.classes.document, + { + [core.properties.name]: 'Untitled Document', + }, + parent, + ); + }, + ); + + registerBasicInstanceHandler( + server.classes.drive, + async (_parent, createAndNavigate, { store, settings }) => { + const agent = store.getAgent(); + + if (!agent || agent.subject === undefined) { + throw new Error( + 'No agent set in the Store, required when creating a Drive', + ); + } + + const newResource = await createAndNavigate(server.classes.drive, { + [core.properties.write]: [agent.subject], + [core.properties.read]: [agent.subject], + }); + + const agentResource = await store.getResourceAsync(agent.subject); + agentResource.pushPropVal(server.properties.drives, [ + newResource.getSubject(), + ]); + agentResource.save(store); + settings.setDrive(newResource.getSubject()); + }, + ); +}; diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx new file mode 100644 index 000000000..fe2203f8a --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewBookmarkDialog.tsx @@ -0,0 +1,87 @@ +import { core, dataBrowser } from '@tomic/react'; +import { useState, useCallback, FormEvent, useEffect, FC } from 'react'; +import { Button } from '../../../../Button'; +import { + useDialog, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from '../../../../Dialog'; +import Field from '../../../Field'; +import { InputWrapper, InputStyled } from '../../../InputStyles'; +import { CustomResourceDialogProps } from '../../useNewResourceUI'; +import { useCreateAndNavigate } from '../../../../../hooks/useCreateAndNavigate'; + +function normalizeWebAddress(url: string) { + if (/^[http://|https://]/i.test(url)) { + return url; + } + + return `https://${url}`; +} + +export const NewBookmarkDialog: FC = ({ + parent, + onClose, +}) => { + const [url, setUrl] = useState(''); + + const [dialogProps, show, hide] = useDialog({ onCancel: onClose }); + + const createResourceAndNavigate = useCreateAndNavigate(); + + const onDone = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + const normalizedUrl = normalizeWebAddress(url); + + createResourceAndNavigate( + dataBrowser.classes.bookmark, + { + [core.properties.name]: 'New Bookmark', + [dataBrowser.properties.url]: normalizedUrl, + }, + parent, + ); + + onClose(); + }, + [url, onClose], + ); + + useEffect(() => { + show(); + }, []); + + return ( + + +

New Bookmark

+
+ +
+ + + setUrl(e.target.value)} + /> + + +
+
+ + + + +
+ ); +}; diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewCollectionDialog.tsx b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewCollectionDialog.tsx new file mode 100644 index 000000000..39b5d5841 --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewCollectionDialog.tsx @@ -0,0 +1,101 @@ +import { collections, core } from '@tomic/react'; +import { useState, useCallback, FormEvent, useEffect, FC } from 'react'; +import { Button } from '../../../../Button'; +import { + useDialog, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from '../../../../Dialog'; +import Field from '../../../Field'; +import { InputWrapper, InputStyled } from '../../../InputStyles'; +import { CustomResourceDialogProps } from '../../useNewResourceUI'; +import { useCreateAndNavigate } from '../../../../../hooks/useCreateAndNavigate'; +import { ResourceSelector } from '../../../ResourceSelector'; + +export const NewCollectionDialog: FC = ({ + parent, + onClose, +}) => { + const [name, setName] = useState('New Collection'); + const [valueFilter, setValue] = useState(); + const [propertyFilter, setProperty] = useState(); + + const [dialogProps, show, hide] = useDialog({ onCancel: onClose }); + + const createResourceAndNavigate = useCreateAndNavigate(); + + const onDone = useCallback( + (e: FormEvent) => { + e.preventDefault(); + + createResourceAndNavigate( + collections.classes.collection, + { + [core.properties.name]: name, + [collections.properties.value]: valueFilter, + [collections.properties.property]: propertyFilter, + [collections.properties.pageSize]: 30, + [collections.properties.currentPage]: 0, + }, + parent, + ); + + onClose(); + }, + [valueFilter, onClose, propertyFilter], + ); + + useEffect(() => { + show(); + }, []); + + return ( + + +

New Collection

+
+ +
+ + + setName(e.target.value)} + /> + + + +
+ +
+
+ + + setValue(e.target.value)} + /> + + +
+
+ + + + +
+ ); +}; diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx new file mode 100644 index 000000000..a810813be --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx @@ -0,0 +1,105 @@ +import { validateDatatype, Datatype, core } from '@tomic/react'; +import { useState, useCallback, FormEvent, FC, useEffect } from 'react'; +import { styled } from 'styled-components'; +import { stringToSlug } from '../../../../../helpers/stringToSlug'; +import { Button } from '../../../../Button'; +import { + useDialog, + Dialog, + DialogContent, + DialogActions, +} from '../../../../Dialog'; +import Field from '../../../Field'; +import { InputWrapper, InputStyled } from '../../../InputStyles'; +import { CustomResourceDialogProps } from '../../useNewResourceUI'; +import { useCreateAndNavigate } from '../../../../../hooks/useCreateAndNavigate'; + +export const NewOntologyDialog: FC = ({ + parent, + onClose, +}) => { + const [shortname, setShortname] = useState(''); + const [valid, setValid] = useState(false); + + const createResourceAndNavigate = useCreateAndNavigate(); + + const onSuccess = useCallback(async () => { + createResourceAndNavigate( + core.classes.ontology, + { + [core.properties.shortname]: shortname, + [core.properties.description]: 'description', + [core.properties.classes]: [], + [core.properties.properties]: [], + [core.properties.instances]: [], + }, + parent, + ); + + onClose(); + }, [shortname, createResourceAndNavigate, onClose, parent]); + + const [dialogProps, show, hide] = useDialog({ onSuccess, onCancel: onClose }); + + const onShortnameChange = (e: React.ChangeEvent) => { + const value = stringToSlug(e.target.value); + setShortname(value); + + try { + validateDatatype(value, Datatype.SLUG); + setValid(true); + } catch (_) { + setValid(false); + } + }; + + useEffect(() => { + show(); + }, []); + + return ( + +

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/forms/NewForm/CustomCreateActions/CustomForms/NewTableDialog.tsx b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewTableDialog.tsx new file mode 100644 index 000000000..2b961f893 --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/NewTableDialog.tsx @@ -0,0 +1,130 @@ +import { useResource, Core, dataBrowser, core, useStore } from '@tomic/react'; +import { useState, useCallback, useEffect, FormEvent, FC } from 'react'; +import { styled } from 'styled-components'; +import { stringToSlug } from '../../../../../helpers/stringToSlug'; +import { BetaBadge } from '../../../../BetaBadge'; +import { Button } from '../../../../Button'; +import { + useDialog, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '../../../../Dialog'; +import Field from '../../../Field'; +import { InputWrapper, InputStyled } from '../../../InputStyles'; +import type { CustomResourceDialogProps } from '../../useNewResourceUI'; +import { useCreateAndNavigate } from '../../../../../hooks/useCreateAndNavigate'; + +const instanceOpts = { + newResource: true, +}; + +export const NewTableDialog: FC = ({ + parent, + onClose, +}) => { + const store = useStore(); + const [instanceSubject] = useState(() => store.createSubject('class')); + const instanceResource = useResource( + instanceSubject, + instanceOpts, + ); + + const [name, setName] = useState(''); + + const createResourceAndNavigate = useCreateAndNavigate(); + + const onCancel = useCallback(() => { + instanceResource.destroy(store); + onClose(); + }, [onClose, instanceResource, store]); + + const onSuccess = useCallback(async () => { + await instanceResource.set( + core.properties.shortname, + stringToSlug(name), + store, + ); + await instanceResource.set( + core.properties.description, + `Represents a row in the ${name} table`, + store, + ); + await instanceResource.set( + core.properties.isA, + [core.classes.class], + store, + ); + await instanceResource.set(core.properties.parent, parent, store); + await instanceResource.set( + core.properties.recommends, + [core.properties.name], + store, + ); + await instanceResource.save(store); + + createResourceAndNavigate( + dataBrowser.classes.table, + { + [core.properties.name]: name, + [core.properties.classtype]: instanceResource.getSubject(), + }, + parent, + ); + + onClose(); + }, [name, instanceResource, store, onClose, parent]); + + const [dialogProps, show, hide] = useDialog({ onCancel, onSuccess }); + + useEffect(() => { + show(); + }, []); + + return ( + + +

New Table

+ +
+ +
{ + e.preventDefault(); + hide(true); + }} + > + + + setName(e.target.value)} + /> + + +
+
+ + + + +
+ ); +}; + +const WiderDialogContent = styled(DialogContent)` + width: min(80vw, 20rem); +`; + +const RelativeDialogTitle = styled(DialogTitle)` + display: flex; + align-items: flex-start; + gap: 1ch; +`; diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/index.ts b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/index.ts new file mode 100644 index 000000000..20adb421c --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/CustomForms/index.ts @@ -0,0 +1,16 @@ +import { dataBrowser, core, collections } from '@tomic/react'; +import { registerNewResourceDialog } from '../../useNewResourceUI'; +import { NewBookmarkDialog } from './NewBookmarkDialog'; +import { NewOntologyDialog } from './NewOntologyDialog'; +import { NewTableDialog } from './NewTableDialog'; +import { NewCollectionDialog } from './NewCollectionDialog'; + +export const registerCustomForms = () => { + registerNewResourceDialog(dataBrowser.classes.bookmark, NewBookmarkDialog); + registerNewResourceDialog(core.classes.ontology, NewOntologyDialog); + registerNewResourceDialog(dataBrowser.classes.table, NewTableDialog); + registerNewResourceDialog( + collections.classes.collection, + NewCollectionDialog, + ); +}; diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/index.ts b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/index.ts new file mode 100644 index 000000000..80d382ed0 --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/index.ts @@ -0,0 +1,7 @@ +import { registerBasicInstanceHandlers } from './BasicInstanceHandlers'; +import { registerCustomForms } from './CustomForms'; + +export const registerCustomCreateActions = () => { + registerCustomForms(); + registerBasicInstanceHandlers(); +}; diff --git a/browser/data-browser/src/components/forms/NewForm/NewFormPage.tsx b/browser/data-browser/src/components/forms/NewForm/NewFormPage.tsx index fbf6c823c..15db7fc0f 100644 --- a/browser/data-browser/src/components/forms/NewForm/NewFormPage.tsx +++ b/browser/data-browser/src/components/forms/NewForm/NewFormPage.tsx @@ -4,6 +4,7 @@ import { ResourceForm } from '../ResourceForm'; import { NewFormTitle } from './NewFormTitle'; import { SubjectField } from './SubjectField'; import { useNewForm } from './useNewForm'; +import { Column } from '../../Row'; export interface NewFormProps { classSubject: string; @@ -28,7 +29,7 @@ export const NewFormFullPage = ({ if (!initialized) return <>Initializing Resource; return ( - <> + - + ); }; diff --git a/browser/data-browser/src/components/forms/NewForm/NewFormTitle.tsx b/browser/data-browser/src/components/forms/NewForm/NewFormTitle.tsx index 4287c1694..717917586 100644 --- a/browser/data-browser/src/components/forms/NewForm/NewFormTitle.tsx +++ b/browser/data-browser/src/components/forms/NewForm/NewFormTitle.tsx @@ -2,8 +2,10 @@ import { properties, useResource, useString, useTitle } from '@tomic/react'; import { useState } from 'react'; import { FaInfo } from 'react-icons/fa'; import { AtomicLink } from '../../AtomicLink'; -import { Button } from '../../Button'; import Markdown from '../../datatypes/Markdown'; +import { Column, Row } from '../../Row'; +import { styled } from 'styled-components'; +import { IconButton, IconButtonVariant } from '../../IconButton/IconButton'; export enum NewFormTitleVariant { FullPage, @@ -34,27 +36,32 @@ export const NewFormTitle: React.FC = ({ const [klassDescription] = useString(klass, properties.description); const [showDetails, setShowDetails] = useState(false); - const HeadingComp = variantHeaderMapping.get(variant!) ?? 'h2'; + const headingType = variantHeaderMapping.get(variant!) ?? 'h2'; return ( - <> - - new{' '} - {classSubject ? ( - {klassTitle} - ) : ( - 'Resource' - )} - - + + {showDetails && klassDescription && } - + ); }; + +const Heading = styled.h1` + margin: 0; +`; diff --git a/browser/data-browser/src/components/forms/NewForm/useNewResourceUI.tsx b/browser/data-browser/src/components/forms/NewForm/useNewResourceUI.tsx new file mode 100644 index 000000000..38266aa84 --- /dev/null +++ b/browser/data-browser/src/components/forms/NewForm/useNewResourceUI.tsx @@ -0,0 +1,125 @@ +import { Core, Store, useStore } from '@tomic/react'; +import { + FC, + PropsWithChildren, + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { + useCreateAndNavigate, + CreateAndNavigate, +} from '../../../hooks/useCreateAndNavigate'; +import { AppSettings, useSettings } from '../../../helpers/AppSettings'; +import { newURL } from '../../../helpers/navigation'; +import { useNavigateWithTransition } from '../../../hooks/useNavigateWithTransition'; + +export interface CustomResourceDialogProps { + parent: string; + onClose: () => void; +} + +/** When creating a new resource, the matched handler is called */ +export type BasicInstanceHandler = ( + parent: string, + createAndNavigate: CreateAndNavigate, + context: { + store: Store; + settings: AppSettings; + }, +) => Promise; + +interface NewResourceUIContext { + showNewResourceUI: (classType: string, parent: string) => void; +} + +const dialogs = new Map>(); +const basicNewInstanceHandlers = new Map(); + +/** + * Returns a function that when called, renders UI to create a new Resource of the given class. + * + * Use {@link registerNewResourceDialog} to register a custom dialog for a given class. + */ +export function useNewResourceUI() { + const { showNewResourceUI } = useContext(NewResourceUIContext); + + return showNewResourceUI; +} + +/** Call this when adding a new custom New Resource Form / Dialog. */ +export const registerNewResourceDialog = ( + classSubject: string, + component: FC, +) => { + dialogs.set(classSubject, component); +}; + +/** Call this when adding a new custom action for a New Resource that does _not_ require inputs. + * For example, creating a new Folder does not require any inputs, so it can be handled without any UI. + */ +export const registerBasicInstanceHandler = ( + classSubject: string, + handler: BasicInstanceHandler, +) => { + basicNewInstanceHandlers.set(classSubject, handler); +}; + +const NewResourceUIContext = createContext({ + showNewResourceUI: () => undefined, +}); + +/** Renders the Dialog used when creating new resources. */ +export function NewResourceUIProvider({ children }: PropsWithChildren) { + const store = useStore(); + const settings = useSettings(); + const createAndNavigate = useCreateAndNavigate(); + const [Dialog, setDialog] = useState(undefined); + const navigate = useNavigateWithTransition(); + + const showNewResourceUI = useCallback(async (isA: string, parent: string) => { + // Show a dialog if one is registered for the given class + if (dialogs.has(isA)) { + const onClose = () => { + setDialog(undefined); + }; + + const Comp = dialogs.get(isA)!; + setDialog(); + + return; + } + + // If a basicInstanceHandler is registered for the class, create a resource of the given class with some default values. + if (basicNewInstanceHandlers.has(isA)) { + basicNewInstanceHandlers.get(isA)?.(parent, createAndNavigate, { + store, + settings, + }); + + return; + } + + // Default behaviour. Navigate to a new resource form for the given class. + const classResource = await store.getResourceAsync(isA); + navigate( + newURL(isA, parent, store.createSubject(classResource.props.shortname)), + ); + }, []); + + const context = useMemo( + () => ({ + showNewResourceUI, + }), + [showNewResourceUI], + ); + + return ( + + {children} + {Dialog} + + ); +} diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx index 41936222d..ff49eacc7 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -4,7 +4,7 @@ import { removeCachedSearchResults, useResource, useStore } from '@tomic/react'; import { DropdownPortalContext } from '../../Dropdown/dropdownContext'; import * as RadixPopover from '@radix-ui/react-popover'; import { SearchBoxWindow } from './SearchBoxWindow'; -import { FaTimes } from 'react-icons/fa'; +import { FaSearch, FaTimes } from 'react-icons/fa'; import { ErrorChip } from '../ErrorChip'; import { useValidation } from '../formValidation/useValidation'; @@ -138,7 +138,10 @@ export function SearchBox({ : selectedResource.title} ) : ( - {placeholderText} + <> + + {placeholderText} + )} {value && ( @@ -186,6 +189,7 @@ const TriggerButton = styled.button<{ $empty: boolean }>` border: none; text-align: start; height: 2rem; + gap: 0.5rem; width: 100%; overflow: hidden; cursor: text; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx index 4b4e1ca2c..0f710a1ee 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx @@ -8,6 +8,7 @@ import { useRef, useState, } from 'react'; +import { FaSearch } from 'react-icons/fa'; import { styled, css } from 'styled-components'; import { ResourceResultLine, ResultLine } from './ResultLine'; import { fadeIn } from '../../../helpers/commonAnimations'; @@ -173,16 +174,19 @@ export function SearchBoxWindow({ return ( - ) => - onChange(e.target.value) - } - onKeyDown={handleKeyDown} - onPaste={handlePaste} - /> + + + ) => + onChange(e.target.value) + } + onKeyDown={handleKeyDown} + onPaste={handlePaste} + /> + {!searchValue && Start Searching} @@ -215,18 +219,34 @@ export function SearchBoxWindow({ ); } -const Input = styled.input` +const SearchInputWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; border: solid 1px ${p => p.theme.colors.bg2}; - padding: 0.5rem; height: var(--radix-popover-trigger-height); + padding-inline-start: 0.5rem; width: 100%; - &:focus-visible { + & svg { + color: ${p => p.theme.colors.textLight}; + } + &:focus-within { border-color: ${p => p.theme.colors.main}; outline: none; } `; +const Input = styled.input` + padding: 0.5rem; + height: 100%; + flex: 1; + border: none; + &:focus-visible { + outline: none; + } +`; + const ResultBox = styled.div` flex: 1; border: solid 1px ${p => p.theme.colors.bg2}; @@ -251,7 +271,7 @@ const Wrapper = styled.div<{ $above: boolean }>` bottom: 0; flex-direction: column-reverse; - ${Input} { + ${SearchInputWrapper}, ${Input} { border-bottom-left-radius: ${theme.radius}; border-bottom-right-radius: ${theme.radius}; } @@ -266,7 +286,7 @@ const Wrapper = styled.div<{ $above: boolean }>` top: calc(var(--radix-popover-trigger-height) * -1); flex-direction: column; - ${Input} { + ${SearchInputWrapper}, ${Input} { border-top-left-radius: ${theme.radius}; border-top-right-radius: ${theme.radius}; } diff --git a/browser/data-browser/src/helpers/AppSettings.tsx b/browser/data-browser/src/helpers/AppSettings.tsx index 45de1d95b..8606e75e3 100644 --- a/browser/data-browser/src/helpers/AppSettings.tsx +++ b/browser/data-browser/src/helpers/AppSettings.tsx @@ -115,7 +115,7 @@ export const AppSettingsContextProvider = ( }; /** A bunch of getters and setters for client-side app settings */ -interface AppSettings { +export interface AppSettings { /** Whether the App should render in dark mode. Checks user preferences. */ darkMode: boolean; /** 'always', 'never' or 'auto' */ diff --git a/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts b/browser/data-browser/src/hooks/useCreateAndNavigate.ts similarity index 53% rename from browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts rename to browser/data-browser/src/hooks/useCreateAndNavigate.ts index 7ae28c84c..b10a77e49 100644 --- a/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts +++ b/browser/data-browser/src/hooks/useCreateAndNavigate.ts @@ -1,59 +1,58 @@ -import { - JSONValue, - properties, - Resource, - useResource, - useStore, - useTitle, -} from '@tomic/react'; +import { Core, JSONValue, Resource, core, useStore } from '@tomic/react'; import { useCallback } from 'react'; import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; -import { constructOpenURL } from '../../helpers/navigation'; -import { getNamePartFromProps } from '../../helpers/getNamePartFromProps'; +import { constructOpenURL } from '../helpers/navigation'; +import { getNamePartFromProps } from '../helpers/getNamePartFromProps'; + +export type CreateAndNavigate = ( + isA: string, + propVals: Record, + parent?: string, + extraParams?: Record, +) => Promise; /** * Hook that builds a function that will create a new resource with the given * properties and then navigate to it. * - * @param klass The type of resource to create a new instance of. - * @param parent The parent resource of the new resource. - * @returns A createAndNavigate function. + * @returns A {@link CreateAndNavigate} function. */ -export function useCreateAndNavigate(klass: string, parent?: string) { +export function useCreateAndNavigate(): CreateAndNavigate { const store = useStore(); - const classTypeResource = useResource(klass); - const [title] = useTitle(classTypeResource); const navigate = useNavigate(); return useCallback( async ( - className: string, + isA: string, propVals: Record, + parent?: string, /** Query parameters for the resource / endpoint */ extraParams?: Record, - /** Do not set a parent for the new resource. Useful for top-level resources */ - noParent?: boolean, ): Promise => { + const classResource = await store.getResourceAsync(isA); + const namePart = getNamePartFromProps(propVals); const newSubject = await store.buildUniqueSubjectFromParts( - [className, namePart], - noParent ? undefined : parent, + [classResource.props.shortname, namePart], + parent, ); const resource = new Resource(newSubject, true); + await resource.addClasses(store, isA); + await Promise.all([ ...Object.entries(propVals).map(([key, val]) => resource.set(key, val, store), ), - !noParent && resource.set(properties.parent, parent, store), + !!parent && resource.set(core.properties.parent, parent, store), ]); try { await resource.save(store); navigate(constructOpenURL(newSubject, extraParams)); - toast.success(`${title} created`); + toast.success(`${classResource.title} created`); store.notifyResourceManuallyCreated(resource); } catch (e) { store.notifyError(e); @@ -61,6 +60,6 @@ export function useCreateAndNavigate(klass: string, parent?: string) { return resource; }, - [store, classTypeResource, title, navigate, parent], + [store, navigate, parent], ); } diff --git a/browser/data-browser/src/routes/NewResource/BaseButtons.tsx b/browser/data-browser/src/routes/NewResource/BaseButtons.tsx new file mode 100644 index 000000000..9d6c90ff2 --- /dev/null +++ b/browser/data-browser/src/routes/NewResource/BaseButtons.tsx @@ -0,0 +1,26 @@ +import { core, dataBrowser } from '@tomic/react'; +import { ButtonSection } from './ButtonSection'; +import { ClassButton } from './ClassButton'; + +interface BaseButtonsProps { + parent: string; +} + +const buttons = [ + dataBrowser.classes.table, + dataBrowser.classes.folder, + dataBrowser.classes.document, + dataBrowser.classes.chatroom, + dataBrowser.classes.bookmark, + core.classes.ontology, +]; + +export function BaseButtons({ parent }: BaseButtonsProps): JSX.Element { + return ( + + {buttons.map(classType => ( + + ))} + + ); +} diff --git a/browser/data-browser/src/routes/NewResource/ButtonSection.tsx b/browser/data-browser/src/routes/NewResource/ButtonSection.tsx new file mode 100644 index 000000000..a8d503908 --- /dev/null +++ b/browser/data-browser/src/routes/NewResource/ButtonSection.tsx @@ -0,0 +1,45 @@ +import { PropsWithChildren } from 'react'; +import { Row } from '../../components/Row'; +import { styled } from 'styled-components'; + +interface ButtonSectionProps { + title: string; +} + +export function ButtonSection({ + title, + children, +}: PropsWithChildren): JSX.Element { + return ( + <> + {title} + {children} + + ); +} + +const Heading = styled.h2` + display: flex; + align-items: center; + font-size: 1rem; + gap: 1ch; + width: 100%; + color: ${({ theme }) => theme.colors.textLight}; + font-weight: normal; + margin: 0; + font-family: ${({ theme }) => theme.fontFamily}; + + /* &::before, + &::after { + content: ''; + border-bottom: 1px solid ${({ theme }) => theme.colors.bg2}; + flex: 1; + } */ + + /* &::before { + width: 1rem; + } + + &::after { + } */ +`; diff --git a/browser/data-browser/src/routes/NewResource/ClassButton.tsx b/browser/data-browser/src/routes/NewResource/ClassButton.tsx new file mode 100644 index 000000000..e7baffa72 --- /dev/null +++ b/browser/data-browser/src/routes/NewResource/ClassButton.tsx @@ -0,0 +1,27 @@ +import { useResource, useTitle } from '@tomic/react'; +import { getIconForClass } from '../../views/FolderPage/iconMap'; +import { NewInstanceButton } from '../../components/NewInstanceButton'; + +interface ClassButtonProps { + classType: string; + parent: string; +} + +export function ClassButton({ + classType, + parent, +}: ClassButtonProps): JSX.Element { + const classResource = useResource(classType); + const [label] = useTitle(classResource); + + return ( + + ); +} diff --git a/browser/data-browser/src/routes/NewResource/NewRoute.tsx b/browser/data-browser/src/routes/NewResource/NewRoute.tsx new file mode 100644 index 000000000..ce8646545 --- /dev/null +++ b/browser/data-browser/src/routes/NewResource/NewRoute.tsx @@ -0,0 +1,98 @@ +import { useResource, urls } from '@tomic/react'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +import { constructOpenURL, useQueryString } from '../../helpers/navigation'; +import { ContainerNarrow } from '../../components/Containers'; +import { ResourceSelector } from '../../components/forms/ResourceSelector'; +import { useSettings } from '../../helpers/AppSettings'; +import { ResourceInline } from '../../views/ResourceInline'; +import { styled } from 'styled-components'; +import { FileDropzoneInput } from '../../components/forms/FileDropzone/FileDropzoneInput'; +import toast from 'react-hot-toast'; +import { NewFormFullPage } from '../../components/forms/NewForm/NewFormPage'; +import { Main } from '../../components/Main'; +import { BaseButtons } from './BaseButtons'; +import { OntologySections } from './OntologySections'; +import { useNewResourceUI } from '../../components/forms/NewForm/useNewResourceUI'; + +/** Start page for instantiating a new Resource from some Class */ +function NewRoute(): JSX.Element { + const [classSubject] = useQueryString('classSubject'); + + return ( + + {classSubject ? ( + + ) : ( + + )} + + ); +} + +function NewResourceSelector() { + const [parentSubject] = useQueryString('parentSubject'); + const { drive } = useSettings(); + const calculatedParent = parentSubject || drive; + const parentResource = useResource(calculatedParent); + + const navigate = useNavigate(); + const showNewResourceUI = useNewResourceUI(); + + function handleClassSet(subject: string | undefined) { + if (!subject) { + return; + } + + showNewResourceUI(subject, calculatedParent); + } + + const onUploadComplete = useCallback( + (files: string[]) => { + toast.success(`Uploaded ${files.length} files.`); + + if (parentSubject) { + navigate(constructOpenURL(parentSubject)); + } + }, + [parentSubject, navigate], + ); + + return ( +
+ +

+ Create new resource{' '} + {parentSubject && ( + <> + {`under `} + + + )} +

+
+ +
+ + + +
+
+ ); +} + +const StyledForm = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.margin * 2}rem; +`; + +export default NewRoute; diff --git a/browser/data-browser/src/routes/NewResource/OntologySections.tsx b/browser/data-browser/src/routes/NewResource/OntologySections.tsx new file mode 100644 index 000000000..2b538b0f1 --- /dev/null +++ b/browser/data-browser/src/routes/NewResource/OntologySections.tsx @@ -0,0 +1,54 @@ +import { ButtonSection } from './ButtonSection'; +import { Core, core, useResource, useServerSearch } from '@tomic/react'; +import { ClassButton } from './ClassButton'; +import { FC } from 'react'; +import { useSettings } from '../../helpers/AppSettings'; + +interface OntologySectionsProps { + parent: string; +} + +export function OntologySections({ + parent, +}: OntologySectionsProps): JSX.Element { + const { drive } = useSettings(); + + const { results } = useServerSearch('', { + filters: { + [core.properties.isA]: core.classes.ontology, + }, + parents: [drive], + allowEmptyQuery: true, + limit: 100, + }); + + return ( + <> + {results.map(subject => ( + + ))} + + ); +} + +interface OntologySectionProps { + subject: string; + parent: string; +} + +const OntologySection: FC = ({ subject, parent }) => { + const ontology = useResource(subject); + const classes = ontology.props.classes ?? []; + + if (classes.length === 0) { + return null; + } + + return ( + + {classes.map(classType => ( + + ))} + + ); +}; diff --git a/browser/data-browser/src/routes/NewRoute.tsx b/browser/data-browser/src/routes/NewRoute.tsx deleted file mode 100644 index 07ebe0874..000000000 --- a/browser/data-browser/src/routes/NewRoute.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { useResource, useString, useTitle, urls } from '@tomic/react'; -import { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router'; - -import { - constructOpenURL, - newURL, - useQueryString, -} from '../helpers/navigation'; -import { ContainerNarrow } from '../components/Containers'; -import NewIntanceButton from '../components/NewInstanceButton'; -import { ResourceSelector } from '../components/forms/ResourceSelector'; -import { Button } from '../components/Button'; -import { useSettings } from '../helpers/AppSettings'; -import { Row } from '../components/Row'; -import { ResourceInline } from '../views/ResourceInline'; -import { styled } from 'styled-components'; -import { FileDropzoneInput } from '../components/forms/FileDropzone/FileDropzoneInput'; -import toast from 'react-hot-toast'; -import { getIconForClass } from '../views/FolderPage/iconMap'; -import { NewFormFullPage } from '../components/forms/NewForm/NewFormPage'; -import { Main } from '../components/Main'; - -/** Start page for instantiating a new Resource from some Class */ -function NewRoute(): JSX.Element { - const [classSubject] = useQueryString('classSubject'); - - return ( - - {classSubject ? ( - - ) : ( - - )} - - ); -} - -function NewResourceSelector() { - const [parentSubject] = useQueryString('parentSubject'); - const { drive } = useSettings(); - const calculatedParent = parentSubject || drive; - const parentResource = useResource(calculatedParent); - const [error, setError] = useState(undefined); - const [classInputValue, setClassInputValue] = useState(); - const classFull = useResource(classInputValue); - const [className] = useString(classFull, urls.properties.shortname); - const navigate = useNavigate(); - - const buttons = [ - urls.classes.table, - urls.classes.folder, - urls.classes.document, - urls.classes.chatRoom, - urls.classes.bookmark, - urls.classes.ontology, - ]; - - function handleClassSet(e) { - if (!classInputValue) { - setError(new Error('Please select a class')); - - return; - } - - e.preventDefault(); - navigate(newURL(classInputValue, calculatedParent)); - } - - const onUploadComplete = useCallback( - (files: string[]) => { - toast.success(`Uploaded ${files.length} files.`); - - if (parentSubject) { - navigate(constructOpenURL(parentSubject)); - } - }, - [parentSubject, navigate], - ); - - return ( -
- -

- Create new resource{' '} - {parentSubject && ( - <> - {`under `} - - - )} -

- - {classInputValue && ( - - )} - {!classInputValue && ( - <> - {buttons.map(classType => ( - - ))} - - )} - -
- -
- -
-
- ); -} - -const StyledForm = styled.form` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.margin}rem; -`; - -export default NewRoute; - -interface WrappedButtonProps { - classType: string; - parent: string; -} - -function WrappedButton({ classType, parent }: WrappedButtonProps): JSX.Element { - const classResource = useResource(classType); - const [label] = useTitle(classResource); - - return ( - - ); -} diff --git a/browser/data-browser/src/routes/Routes.tsx b/browser/data-browser/src/routes/Routes.tsx index 59fef27c1..69959ed2c 100644 --- a/browser/data-browser/src/routes/Routes.tsx +++ b/browser/data-browser/src/routes/Routes.tsx @@ -4,7 +4,7 @@ import { Route, Routes } from 'react-router-dom'; import Show from './ShowRoute'; import { Search } from './SearchRoute'; -import NewRoute from './NewRoute'; +import NewRoute from './NewResource/NewRoute'; import { SettingsTheme } from './SettingsTheme'; import { Edit } from './EditRoute'; import Data from './DataRoute'; diff --git a/browser/data-browser/src/routes/SettingsServer/DrivesCard.tsx b/browser/data-browser/src/routes/SettingsServer/DrivesCard.tsx index bf1a84734..39db1fe3f 100644 --- a/browser/data-browser/src/routes/SettingsServer/DrivesCard.tsx +++ b/browser/data-browser/src/routes/SettingsServer/DrivesCard.tsx @@ -1,4 +1,4 @@ -import NewIntanceButton from '../../components/NewInstanceButton'; +import { NewInstanceButton } from '../../components/NewInstanceButton'; import { Card, CardInsideFull, CardRow } from '../../components/Card'; import { urls } from '@tomic/react'; import { styled } from 'styled-components'; @@ -56,7 +56,7 @@ const ContainerCard = styled(Card)` padding-top: 0; `; -const StyledNewInstanceButton = styled(NewIntanceButton)` +const StyledNewInstanceButton = styled(NewInstanceButton)` border: none; box-shadow: none; padding: 0; diff --git a/browser/data-browser/src/views/Article/ArticlePage.tsx b/browser/data-browser/src/views/Article/ArticlePage.tsx index dbefaa2e7..5b8e22aea 100644 --- a/browser/data-browser/src/views/Article/ArticlePage.tsx +++ b/browser/data-browser/src/views/Article/ArticlePage.tsx @@ -13,12 +13,12 @@ import { ContainerWide } from '../../components/Containers'; import { EditableTitle } from '../../components/EditableTitle'; import UploadForm from '../../components/forms/UploadForm'; import { NewCard } from '../../components/NewCard'; -import { useCreateAndNavigate } from '../../components/NewInstanceButton'; import { Column } from '../../components/Row'; import ResourceCard from '../Card/ResourceCard'; import { ResourcePageProps } from '../ResourcePage'; import { ArticleCover } from './ArticleCover'; import { ArticleDescription } from './ArticleDescription'; +import { useCreateAndNavigate } from '../../hooks/useCreateAndNavigate'; export function ArticlePage({ resource }: ResourcePageProps): JSX.Element { const [lastCommit] = useString(resource, properties.commit.lastCommit); @@ -26,18 +26,18 @@ export function ArticlePage({ resource }: ResourcePageProps): JSX.Element { const [canEdit] = useCanWrite(resource); const children = useChildren(resource); - const createAndNavigate = useCreateAndNavigate( - classes.article, - resource.getSubject(), - ); + const createAndNavigate = useCreateAndNavigate(); const createNewArticle = useCallback(() => { - createAndNavigate('article', { - [properties.isA]: [classes.article], - [properties.name]: 'New Article', - [properties.publishedAt]: getTimestampNow(), - [properties.description]: '', - }); + createAndNavigate( + classes.article, + { + [properties.name]: 'New Article', + [properties.publishedAt]: getTimestampNow(), + [properties.description]: '', + }, + resource.getSubject(), + ); }, [createAndNavigate]); return ( diff --git a/browser/data-browser/src/views/ClassPage.tsx b/browser/data-browser/src/views/ClassPage.tsx index f2a3c026d..74ef9839d 100644 --- a/browser/data-browser/src/views/ClassPage.tsx +++ b/browser/data-browser/src/views/ClassPage.tsx @@ -10,7 +10,7 @@ import { ClassDetail } from '../components/ClassDetail'; import { CodeBlock } from '../components/CodeBlock'; import { ContainerNarrow } from '../components/Containers'; import { ValueForm } from '../components/forms/ValueForm'; -import NewInstanceButton from '../components/NewInstanceButton'; +import { NewInstanceButton } from '../components/NewInstanceButton'; import { Title } from '../components/Title'; import { Column, Row } from '../components/Row'; import { ResourcePageProps } from './ResourcePage'; diff --git a/browser/data-browser/src/views/CollectionPage.tsx b/browser/data-browser/src/views/CollectionPage.tsx index f4a0bc0ef..6014dad0d 100644 --- a/browser/data-browser/src/views/CollectionPage.tsx +++ b/browser/data-browser/src/views/CollectionPage.tsx @@ -22,7 +22,7 @@ import { useViewport } from '../helpers/useMedia'; import { Button } from '../components/Button'; import { ContainerFull } from '../components/Containers'; import Markdown from '../components/datatypes/Markdown'; -import NewInstanceButton from '../components/NewInstanceButton'; +import { NewInstanceButton } from '../components/NewInstanceButton'; import ResourceCard from './Card/ResourceCard'; import Table from '../components/Table'; import { useSubjectParam } from '../helpers/useCurrentSubject'; diff --git a/browser/e2e/tests/e2e.spec.ts b/browser/e2e/tests/e2e.spec.ts index 6cdb4d8ce..a6796685e 100644 --- a/browser/e2e/tests/e2e.spec.ts +++ b/browser/e2e/tests/e2e.spec.ts @@ -96,39 +96,43 @@ test.describe('data-browser', async () => { await editProfileAndCommit(page); }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars test('collections & data view', async ({ page }) => { - await openAtomic(page); - // collections, pagination, sorting - await openSubject(page, 'https://atomicdata.dev/properties'); - await page.click( - '[data-test="sort-https://atomicdata.dev/properties/description"]', - ); - // These values can change as new Properties are added to atomicdata.dev - const firstPageText = 'text=A base64 serialized JSON object'; - const secondPageText = 'text=include-nested'; - await expect(page.locator(firstPageText)).toBeVisible(); - await page.click('[data-test="next-page"]'); - await expect(page.locator(firstPageText)).not.toBeVisible(); - await expect(page.locator(secondPageText)).toBeVisible(); - - // context menu, keyboard & data view - await page.click(contextMenu); - await page.keyboard.press('Enter'); - await expect(page.locator('text=JSON-AD')).toBeVisible(); - await page.click('[data-test="fetch-json-ad"]'); - await expect( - page.locator( - 'text="https://atomicdata.dev/properties/collection/members": [', - ), - ).toBeVisible(); - await page.click('[data-test="fetch-json"]'); - await expect(page.locator('text= "members": [')).toBeVisible(); - await page.click('[data-test="fetch-json-ld"]'); - await expect(page.locator('text="current-page": {')).toBeVisible(); - await page.click('[data-test="fetch-turtle"]'); - await expect(page.locator('text= { diff --git a/browser/e2e/tests/test-utils.ts b/browser/e2e/tests/test-utils.ts index e67821516..bbb17b965 100644 --- a/browser/e2e/tests/test-utils.ts +++ b/browser/e2e/tests/test-utils.ts @@ -237,7 +237,6 @@ export async function newResource(klass: string, page: Page) { if (klass.startsWith('https://')) { await fillSearchBox(page, 'Search for a class or enter a URL', klass); await page.keyboard.press('Enter'); - await page.click(`button:has-text("new ")`); } else { await page.locator(`button:has-text("${klass}")`).click(); } diff --git a/browser/react/src/useServerSearch.tsx b/browser/react/src/useServerSearch.tsx index dc9161140..8de1156a2 100644 --- a/browser/react/src/useServerSearch.tsx +++ b/browser/react/src/useServerSearch.tsx @@ -26,6 +26,7 @@ interface SearchOptsHook extends SearchOpts { * respresents milliseconds. */ debounce?: number; + allowEmptyQuery?: boolean; } const noResultsResult = { @@ -88,7 +89,7 @@ export function useServerSearch( [results, resource.loading, resource.error], ); - if (!query) { + if (!query && !opts.allowEmptyQuery) { return noResultsResult; } diff --git a/server/build.rs b/server/build.rs index 58d6e9b33..b244e2a8c 100644 --- a/server/build.rs +++ b/server/build.rs @@ -1,4 +1,8 @@ -use std::{path::PathBuf, time::SystemTime}; +use std::{ + fs::{self, Metadata}, + path::PathBuf, + time::SystemTime, +}; macro_rules! p { ($($tokens: tt)*) => { @@ -27,6 +31,7 @@ fn main() -> std::io::Result<()> { if should_build(&dirs) { build_js(&dirs); + let _ = fs::remove_dir_all(&dirs.js_dist_tmp); dircpy::copy_dir(&dirs.js_dist_source, &dirs.js_dist_tmp)?; } else if dirs.js_dist_tmp.exists() { p!("Found {}, skipping copy", dirs.js_dist_tmp.display()); @@ -61,38 +66,19 @@ fn should_build(dirs: &Dirs) -> bool { if let Ok(tmp_dist_index_html) = std::fs::metadata(format!("{}/index.html", dirs.js_dist_tmp.display())) { - let dist_time = tmp_dist_index_html - .modified() - .unwrap() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(); - for entry in walkdir::WalkDir::new(&dirs.src_browser) + let has_changes = walkdir::WalkDir::new(&dirs.src_browser) .into_iter() - .filter_map(|e| { - // ignore ds store - if let Ok(e) = e { - if e.path().to_str().unwrap().contains(".DS_Store") { - return None; - } - Some(e) - } else { - None - } + .filter_entry(|entry| { + entry + .file_name() + .to_str() + .map(|s| !s.starts_with(".DS_Store")) + .unwrap_or(false) }) - { - if entry.path().is_file() { - let src_time = entry - .metadata() - .unwrap() - .modified() - .unwrap() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(); - if src_time >= dist_time { - p!("Source file modified: {:?}, rebuilding...", entry.path()); - return true; - } - } + .any(|entry| is_older_than(&entry.unwrap(), &tmp_dist_index_html)); + + if has_changes { + return true; } p!("No changes in JS source files, skipping JS build."); @@ -145,3 +131,29 @@ fn build_js(dirs: &Dirs) { ); } } + +fn is_older_than(dir_entry: &walkdir::DirEntry, dist_meta: &Metadata) -> bool { + let dist_time = dist_meta + .modified() + .unwrap() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + + if dir_entry.path().is_file() { + let src_time = dir_entry + .metadata() + .unwrap() + .modified() + .unwrap() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap(); + if src_time >= dist_time { + p!( + "Source file modified: {:?}, rebuilding...", + dir_entry.path() + ); + return true; + } + } + false +}