From 468ed957743f95837e76fbf42280494fb881f9e3 Mon Sep 17 00:00:00 2001 From: Polle Pas Date: Wed, 16 Aug 2023 13:05:42 +0200 Subject: [PATCH 01/13] Upgrade styled-components --- browser/data-browser/package.json | 1 + .../src/routes/SettingsServer/DriveRow.tsx | 35 +++++++++---------- .../src/views/FolderPage/ListView.tsx | 2 +- browser/pnpm-lock.yaml | 3 ++ 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index 299289f37..ee5990426 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -37,6 +37,7 @@ "react-window": "^1.8.7", "remark-gfm": "^3.0.1", "styled-components": "^6.0.7", + "stylis": "4.3.0", "yamde": "^1.7.1" }, "devDependencies": { diff --git a/browser/data-browser/src/routes/SettingsServer/DriveRow.tsx b/browser/data-browser/src/routes/SettingsServer/DriveRow.tsx index b67e9c73b..ef7f788b9 100644 --- a/browser/data-browser/src/routes/SettingsServer/DriveRow.tsx +++ b/browser/data-browser/src/routes/SettingsServer/DriveRow.tsx @@ -1,5 +1,4 @@ -import { styled, css } from 'styled-components'; -import { wrapWithCQ } from '../../components/CQWrapper'; +import { styled } from 'styled-components'; import React from 'react'; import { Button } from '../../components/Button'; import { WSIndicator } from './WSIndicator'; @@ -15,7 +14,9 @@ export interface DriveRowProps { export function DriveRow({ subject, onClick, disabled }: DriveRowProps) { return ( - + <TitleWrapper> + <ResourceInline subject={subject} /> + </TitleWrapper> <Subject>{subject}</Subject> <SelectButton onClick={() => onClick(subject)} disabled={disabled}> Select @@ -26,24 +27,22 @@ export function DriveRow({ subject, onClick, disabled }: DriveRowProps) { ); } -const DriveRowWrapper = wrapWithCQ<object, 'div'>( - styled.div` - --title-font-weight: 500; - display: grid; - grid-template-areas: 'title ws subject button icon'; - grid-template-columns: 20ch 1.3rem auto 10ch 1.3rem; - gap: ${p => p.theme.margin}rem; - align-items: center; - padding-block: 0.3rem; - `, - 'max-width: 500px', - css` +const DriveRowWrapper = styled.div` + --title-font-weight: 500; + display: grid; + grid-template-areas: 'title ws subject button icon'; + grid-template-columns: 20ch 1.3rem auto 10ch 1.3rem; + gap: ${p => p.theme.margin}rem; + align-items: center; + padding-block: 0.3rem; + + @container (max-width: 500px) { grid-template-areas: 'ws title icon' 'subject subject subject' 'button button button'; grid-template-columns: 1.3rem auto 1rem; padding-block: 1rem; --title-font-weight: bold; - `, -); + } +`; const StyledFavoriteButton = styled(FavoriteButton)` grid-area: icon; @@ -68,7 +67,7 @@ const StyledWSIndicator = styled(WSIndicator)` grid-area: ws; `; -const Title = styled(ResourceInline)` +const TitleWrapper = styled.div` grid-area: title; overflow: hidden; white-space: nowrap; diff --git a/browser/data-browser/src/views/FolderPage/ListView.tsx b/browser/data-browser/src/views/FolderPage/ListView.tsx index c8471a681..141f209cc 100644 --- a/browser/data-browser/src/views/FolderPage/ListView.tsx +++ b/browser/data-browser/src/views/FolderPage/ListView.tsx @@ -155,7 +155,7 @@ const LinkWrapper = styled.span` `; const TableRow = styled.tr` - :nth-child(odd) { + &:nth-child(odd) { td { background-color: ${p => p.theme.colors.bg1}; } diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 6021e06aa..50cccecb3 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: styled-components: specifier: ^6.0.7 version: 6.0.7(babel-plugin-styled-components@2.1.4)(react-dom@18.2.0)(react@18.2.0) + stylis: + specifier: 4.3.0 + version: 4.3.0 yamde: specifier: ^1.7.1 version: 1.7.1(react-dom@18.2.0)(react@18.2.0) From a6d737dbe8ca7a65b3dd32a5b0b9606c442f8482 Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Wed, 16 Aug 2023 14:24:50 +0200 Subject: [PATCH 02/13] #648 Ontology read view --- .../src/components/Details/index.tsx | 6 +- browser/data-browser/src/components/Main.tsx | 5 +- .../src/components/datatypes/Markdown.tsx | 4 + .../src/views/OntologyPage/ClassCardRead.tsx | 75 ++++++++++ .../src/views/OntologyPage/InlineDatatype.tsx | 36 +++++ .../src/views/OntologyPage/OntologyPage.tsx | 122 ++++++++++++++++ .../views/OntologyPage/OntologySidebar.tsx | 130 ++++++++++++++++++ .../views/OntologyPage/PropertyCardRead.tsx | 61 ++++++++ .../views/OntologyPage/PropertyLineRead.tsx | 34 +++++ .../src/views/OntologyPage/index.ts | 1 + .../data-browser/src/views/ResourcePage.tsx | 3 + browser/lib/src/datatypes.ts | 14 ++ browser/lib/src/urls.ts | 4 + lib/src/urls.rs | 4 + 14 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/OntologyPage.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx create mode 100644 browser/data-browser/src/views/OntologyPage/index.ts diff --git a/browser/data-browser/src/components/Details/index.tsx b/browser/data-browser/src/components/Details/index.tsx index d3f0a4ec5..3ba17faa9 100644 --- a/browser/data-browser/src/components/Details/index.tsx +++ b/browser/data-browser/src/components/Details/index.tsx @@ -6,7 +6,7 @@ import { Collapse } from '../Collapse'; export interface DetailsProps { open?: boolean; initialState?: boolean; - title: React.ReactElement; + title: React.ReactElement | string; disabled?: boolean; /** Event that fires when a user opens or closes the details */ onStateToggle?: (state: boolean) => void; @@ -101,8 +101,8 @@ const IconButton = styled.button<IconButtonProps>` background-color: transparent; border: none; border-radius: 50%; - :hover, - :focus { + &:hover, + &:focus { background-color: ${p => p.theme.colors.bg1}; } `; diff --git a/browser/data-browser/src/components/Main.tsx b/browser/data-browser/src/components/Main.tsx index 848c070c1..15bf63cdc 100644 --- a/browser/data-browser/src/components/Main.tsx +++ b/browser/data-browser/src/components/Main.tsx @@ -24,9 +24,10 @@ export function Main({ } const StyledMain = React.memo(styled.main<ViewTransitionProps>` + container-type: inline-size; /* Makes the contents fit the entire page */ - height: calc( + /* height: calc( 100% - (${p => p.theme.heights.breadCrumbBar} + ${PARENT_PADDING_BLOCK} * 2) - ); + ); */ ${p => transitionName('resource-page', p.subject)} `); diff --git a/browser/data-browser/src/components/datatypes/Markdown.tsx b/browser/data-browser/src/components/datatypes/Markdown.tsx index 4d2b7b99b..30434a7f3 100644 --- a/browser/data-browser/src/components/datatypes/Markdown.tsx +++ b/browser/data-browser/src/components/datatypes/Markdown.tsx @@ -81,6 +81,10 @@ const MarkdownWrapper = styled.div<MarkdownWrapperProps>` margin-bottom: 1.5rem; } + p:only-child { + margin-bottom: 0; + } + blockquote { margin-inline-start: 0rem; padding-inline-start: 1rem; diff --git a/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx b/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx new file mode 100644 index 000000000..acb5609e5 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/ClassCardRead.tsx @@ -0,0 +1,75 @@ +import { urls, useArray, useResource, useString } from '@tomic/react'; +import React from 'react'; +import { Card } from '../../components/Card'; +import { PropertyLineRead } from './PropertyLineRead'; +import styled from 'styled-components'; +import { FaCube } from 'react-icons/fa'; +import { Column } from '../../components/Row'; +import Markdown from '../../components/datatypes/Markdown'; +import { AtomicLink } from '../../components/AtomicLink'; + +interface ClassCardReadProps { + subject: string; +} + +export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { + const resource = useResource(subject); + const [description] = useString(resource, urls.properties.description); + const [requires] = useArray(resource, urls.properties.requires); + const [recommends] = useArray(resource, urls.properties.recommends); + + return ( + <StyledLi> + <StyledCard> + <Column> + <StyledH3 id={`list-item-${subject}`}> + <FaCube /> + <AtomicLink subject={subject}>{resource.title}</AtomicLink> + </StyledH3> + <Markdown text={description ?? ''} maxLength={1500} /> + <StyledH4>Requires</StyledH4> + <StyledTable> + {requires.length > 0 ? ( + requires.map(s => <PropertyLineRead key={s} subject={s} />) + ) : ( + <span>none</span> + )} + </StyledTable> + <StyledH4>Recommends</StyledH4> + <StyledTable> + {recommends.length > 0 ? ( + recommends.map(s => <PropertyLineRead key={s} subject={s} />) + ) : ( + <span>none</span> + )} + </StyledTable> + </Column> + </StyledCard> + </StyledLi> + ); +} + +const StyledCard = styled(Card)` + padding-bottom: ${p => p.theme.margin}rem; +`; + +const StyledLi = styled.li` + margin-left: 0px; + list-style: none; +`; + +const StyledH3 = styled.h3` + display: flex; + align-items: center; + gap: 1ch; + margin-bottom: 0px; + font-size: 1.5rem; +`; + +const StyledH4 = styled.h4` + margin-bottom: 0px; +`; + +const StyledTable = styled.table` + border-collapse: collapse; +`; diff --git a/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx new file mode 100644 index 000000000..9d6d1041f --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + Resource, + useString, + urls, + reverseDatatypeMapping, + unknownSubject, +} from '@tomic/react'; +import { ResourceInline } from '../ResourceInline'; +import styled from 'styled-components'; + +interface TypeSuffixProps { + resource: Resource; +} + +export function InlineDatatype({ + resource, +}: TypeSuffixProps): JSX.Element | null { + const [datatype] = useString(resource, urls.properties.datatype); + const [classType] = useString(resource, urls.properties.classType); + + const name = reverseDatatypeMapping[datatype ?? unknownSubject]; + + if (!classType) { + return <span>{name}</span>; + } + + return ( + <span> + {name} + {'<'} + <ResourceInline subject={classType} /> + {'>'} + </span> + ); +} diff --git a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx new file mode 100644 index 000000000..7820c3967 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { ResourcePageProps } from '../ResourcePage'; +import { urls, useArray, useString } from '@tomic/react'; +import { OntologySidebar } from './OntologySidebar'; +import styled from 'styled-components'; +import { ClassCardRead } from './ClassCardRead'; +import { PropertyCardRead } from './PropertyCardRead'; +import ResourceCard from '../Card/ResourceCard'; +import { Button } from '../../components/Button'; +import { Row } from '../../components/Row'; + +enum OntologyViewMode { + Read = 0, + Write, +} + +export function OntologyPage({ resource }: ResourcePageProps) { + const [description] = useString(resource, urls.properties.description); + const [classes] = useArray(resource, urls.properties.classes); + const [properties] = useArray(resource, urls.properties.properties); + const [instances] = useArray(resource, urls.properties.instances); + + const [viewMode, setViewMode] = React.useState(OntologyViewMode.Read); + + return ( + <FullPageWrapper> + <TitleSlot> + <Row justify='space-between'> + <h1>{resource.title}</h1> + <Button onClick={() => setViewMode(OntologyViewMode.Write)}> + Edit + </Button> + </Row> + </TitleSlot> + <SidebarSlot> + <OntologySidebar ontology={resource} /> + </SidebarSlot> + <ListSlot> + <p>{description}</p> + <h2>Classes</h2> + <StyledUl> + {classes.map(c => ( + <ClassCardRead key={c} subject={c} /> + ))} + </StyledUl> + <h2>Properties</h2> + <StyledUl> + {properties.map(c => ( + <PropertyCardRead key={c} subject={c} /> + ))} + </StyledUl> + <h2>Instances</h2> + <StyledUl> + {instances.map(c => ( + <ResourceCard key={c} subject={c} id={`list-item-${c}`} /> + ))} + </StyledUl> + </ListSlot> + <GraphSlot> + <TempGraph>Placeholder</TempGraph> + </GraphSlot> + </FullPageWrapper> + ); +} + +const TempGraph = styled.div` + position: sticky; + display: grid; + place-items: center; + background-color: ${p => p.theme.colors.bg1}; + border: 1px solid ${p => p.theme.colors.bg2}; + aspect-ratio: 9 / 16; + border-radius: ${p => p.theme.radius}; + top: 1rem; + overflow: hidden; +`; + +const FullPageWrapper = styled.div` + display: grid; + grid-template-areas: 'sidebar title graph' 'sidebar list graph'; + grid-template-columns: 1fr 3fr 2fr; + grid-template-rows: 4rem auto; + width: 100%; + min-height: ${p => p.theme.heights.fullPage}; + + @container (max-width: 950px) { + grid-template-areas: 'sidebar title' 'sidebar graph' 'sidebar list'; + grid-template-columns: 1fr 3fr; + grid-template-rows: 4rem auto auto; + + ${TempGraph} { + position: static; + aspect-ratio: 16/9; + } + } +`; + +const TitleSlot = styled.div` + grid-area: title; + padding: ${p => p.theme.margin}rem; +`; + +const SidebarSlot = styled.div` + grid-area: sidebar; +`; + +const ListSlot = styled.div` + grid-area: list; + padding: ${p => p.theme.margin}rem; +`; + +const GraphSlot = styled.div` + grid-area: graph; + padding: ${p => p.theme.margin}rem; + height: 100%; +`; + +const StyledUl = styled.ul` + display: flex; + flex-direction: column; + gap: 1rem; +`; diff --git a/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx b/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx new file mode 100644 index 000000000..3633f1ead --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx @@ -0,0 +1,130 @@ +import { Resource, urls, useArray, useResource } from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import { Details } from '../../components/Details'; +import { FaAtom, FaCube, FaHashtag } from 'react-icons/fa'; +import { ScrollArea } from '../../components/ScrollArea'; + +interface OntologySidebarProps { + ontology: Resource; +} + +export function OntologySidebar({ + ontology, +}: OntologySidebarProps): JSX.Element { + const [classes] = useArray(ontology, urls.properties.classes); + const [properties] = useArray(ontology, urls.properties.properties); + const [instances] = useArray(ontology, urls.properties.instances); + + return ( + <Wrapper> + <SideBarScrollArea> + <Details + open + title={ + <Title> + <FaCube /> + Classes + + } + > + + +
+ + Properties + + } + > +
    + {properties.map(c => ( + + ))} +
+
+
+ + Instances + + } + > +
    + {instances.map(c => ( + + ))} +
+
+ + + ); +} + +interface ItemProps { + subject: string; +} + +function Item({ subject }: ItemProps): JSX.Element { + const resource = useResource(subject); + + return ( + + {resource.title} + + ); +} + +const Wrapper = styled.div` + position: sticky; + top: 0px; + display: flex; + flex-direction: column; + background-color: ${p => p.theme.colors.bg}; + height: 100vh; + border-right: 1px solid ${p => p.theme.colors.bg2}; + min-width: 10rem; +`; + +const Title = styled.b` + display: inline-flex; + align-items: center; + gap: 0.8ch; +`; + +const StyledLi = styled.li` + list-style: none; + margin-left: 0; + width: 100%; + margin-bottom: 0; +`; + +const ItemLink = styled.a` + padding-left: 1rem; + padding-block: 0.2rem; + border-radius: ${p => p.theme.radius}; + display: block; + color: ${p => p.theme.colors.textLight}; + text-decoration: none; + width: 100%; + &:hover, + &:focus-visible { + color: ${p => p.theme.colors.text}; + background-color: ${p => p.theme.colors.bg1}; + } + white-space: nowrap; +`; + +const SideBarScrollArea = styled(ScrollArea)` + overflow: hidden; + padding: ${p => p.theme.margin}rem; + max-height: 100vh; +`; diff --git a/browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx b/browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx new file mode 100644 index 000000000..eaabc5514 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/PropertyCardRead.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Card } from '../../components/Card'; +import { urls, useArray, useResource, useString } from '@tomic/react'; +import { FaHashtag } from 'react-icons/fa'; +import styled from 'styled-components'; +import Markdown from '../../components/datatypes/Markdown'; +import { Column, Row } from '../../components/Row'; +import { InlineFormattedResourceList } from '../../components/InlineFormattedResourceList'; +import { InlineDatatype } from './InlineDatatype'; +import { AtomicLink } from '../../components/AtomicLink'; + +interface PropertyCardReadProps { + subject: string; +} + +export function PropertyCardRead({ + subject, +}: PropertyCardReadProps): JSX.Element { + const resource = useResource(subject); + const [description] = useString(resource, urls.properties.description); + const [allowsOnly] = useArray(resource, urls.properties.allowsOnly); + + return ( + + + + + + {resource.title} + + + + + {allowsOnly.length > 0 && ( + <> + Allows only: +
+ +
+ + )} +
+
+ ); +} + +const Heading = styled.h3` + display: flex; + align-items: center; + gap: 1ch; + margin-bottom: 0px; + font-size: 1.5rem; +`; + +const SubHeading = styled.h4` + margin-bottom: 0px; +`; + +const StyledCard = styled(Card)` + padding-bottom: ${p => p.theme.margin}rem; +`; diff --git a/browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx b/browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx new file mode 100644 index 000000000..23637811f --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/PropertyLineRead.tsx @@ -0,0 +1,34 @@ +import { urls, useResource, useString } from '@tomic/react'; +import React from 'react'; +import styled from 'styled-components'; +import Markdown from '../../components/datatypes/Markdown'; +import { InlineDatatype } from './InlineDatatype'; + +interface PropertyLineReadProps { + subject: string; +} + +export function PropertyLineRead({ + subject, +}: PropertyLineReadProps): JSX.Element { + const resource = useResource(subject); + const [description] = useString(resource, urls.properties.description); + + return ( + + {resource.title} + + + + + + + + ); +} + +const StyledTd = styled.td` + padding-inline: 0.5rem; + padding-block: 0.4rem; + vertical-align: top; +`; diff --git a/browser/data-browser/src/views/OntologyPage/index.ts b/browser/data-browser/src/views/OntologyPage/index.ts new file mode 100644 index 000000000..816fcf6c2 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/index.ts @@ -0,0 +1 @@ +export { OntologyPage } from './OntologyPage'; diff --git a/browser/data-browser/src/views/ResourcePage.tsx b/browser/data-browser/src/views/ResourcePage.tsx index 80140734c..7b9764a3b 100644 --- a/browser/data-browser/src/views/ResourcePage.tsx +++ b/browser/data-browser/src/views/ResourcePage.tsx @@ -28,6 +28,7 @@ import { FolderPage } from './FolderPage'; import { ArticlePage } from './Article'; import { TablePage } from './TablePage'; import { Main } from '../components/Main'; +import { OntologyPage } from './OntologyPage'; /** These properties are passed to every View at Page level */ export type ResourcePageProps = { @@ -118,6 +119,8 @@ function selectComponent(klass: string) { return ArticlePage; case urls.classes.table: return TablePage; + case urls.classes.ontology: + return OntologyPage; default: return ResourcePageDefault; } diff --git a/browser/lib/src/datatypes.ts b/browser/lib/src/datatypes.ts index 1de686d2d..4e79469db 100644 --- a/browser/lib/src/datatypes.ts +++ b/browser/lib/src/datatypes.ts @@ -187,3 +187,17 @@ export function isString(val: JSONValue): val is string { export function isNumber(val: JSONValue): val is number { return typeof val === 'number'; } + +export const reverseDatatypeMapping = { + [Datatype.ATOMIC_URL]: 'Resource', + [Datatype.BOOLEAN]: 'Boolean', + [Datatype.DATE]: 'Date', + [Datatype.FLOAT]: 'Float', + [Datatype.INTEGER]: 'Integer', + [Datatype.MARKDOWN]: 'Markdown', + [Datatype.RESOURCEARRAY]: 'ResourceArray', + [Datatype.SLUG]: 'Slug', + [Datatype.STRING]: 'String', + [Datatype.TIMESTAMP]: 'Timestamp', + [Datatype.UNKNOWN]: 'Unknown', +}; diff --git a/browser/lib/src/urls.ts b/browser/lib/src/urls.ts index bbf2d230e..4b3d58bf4 100644 --- a/browser/lib/src/urls.ts +++ b/browser/lib/src/urls.ts @@ -37,6 +37,7 @@ export const classes = { }, table: 'https://atomicdata.dev/classes/Table', tag: 'https://atomicdata.dev/classes/Tag', + ontology: 'https://atomicdata.dev/class/ontology', }; /** List of commonly used Atomic Data Properties. */ @@ -143,6 +144,9 @@ export const properties = { }, color: 'https://atomicdata.dev/properties/color', emoji: 'https://atomicdata.dev/properties/emoji', + classes: 'https://atomicdata.dev/properties/classes', + properties: 'https://atomicdata.dev/properties/properties', + instances: 'https://atomicdata.dev/properties/instances', }; export const datatypes = { diff --git a/lib/src/urls.rs b/lib/src/urls.rs index a35e57574..39f051e7f 100644 --- a/lib/src/urls.rs +++ b/lib/src/urls.rs @@ -19,6 +19,7 @@ pub const MESSAGE: &str = "https://atomicdata.dev/classes/Message"; pub const IMPORTER: &str = "https://atomicdata.dev/classes/Importer"; pub const ERROR: &str = "https://atomicdata.dev/classes/Error"; pub const BOOKMARK: &str = "https://atomicdata.dev/class/Bookmark"; +pub const ONTOLOGY: &str = "https://atomicdata.dev/class/ontology"; // Properties pub const SHORTNAME: &str = "https://atomicdata.dev/properties/shortname"; @@ -115,6 +116,9 @@ pub const IMPORTER_PARENT: &str = "https://atomicdata.dev/properties/importer/pa pub const IMPORTER_OVERWRITE_OUTSIDE: &str = "https://atomicdata.dev/properties/importer/overwrite-outside"; pub const LOCAL_ID: &str = "https://atomicdata.dev/properties/localId"; +pub const PROPERTIES: &str = "https://atomicdata.dev/properties/properties"; +pub const CLASSES: &str = "https://atomicdata.dev/properties/classes"; +pub const INSTANCES: &str = "https://atomicdata.dev/properties/instances"; // Datatypes pub const STRING: &str = "https://atomicdata.dev/datatypes/string"; From 9d641486b95b109739dfc4e986f33bf2c37f845f Mon Sep 17 00:00:00 2001 From: Polle Pas Date: Mon, 28 Aug 2023 15:01:22 +0200 Subject: [PATCH 03/13] #648 New resource picker component --- browser/data-browser/src/App.tsx | 36 ++- .../data-browser/src/components/Button.tsx | 18 +- .../src/components/Dialog/index.tsx | 8 +- .../src/components/SearchFilter.tsx | 2 +- .../src/components/forms/InputResource.tsx | 2 +- .../components/forms/InputResourceArray.tsx | 3 +- .../src/components/forms/InputStyles.tsx | 2 +- .../src/components/forms/ResourceForm.tsx | 5 +- .../forms/ResourceSelector/DropdownInput.tsx | 14 +- .../ResourceSelector/ResourceSelector.tsx | 166 +++-------- .../components/forms/SearchBox/ResultLine.tsx | 97 +++++++ .../components/forms/SearchBox/SearchBox.tsx | 246 ++++++++++++++++ .../forms/SearchBox/SearchBoxWindow.tsx | 273 ++++++++++++++++++ .../src/components/forms/SearchBox/index.ts | 1 + .../forms/formValidation/useValidation.ts | 4 + .../forms/hooks/useAvailableSpace.ts | 9 +- .../src/helpers/focusOffsetElement.ts | 42 +++ browser/data-browser/src/helpers/isURL.ts | 9 + .../data-browser/src/helpers/remToPixels.ts | 6 + browser/data-browser/src/routes/NewRoute.tsx | 2 +- .../PropertyForm/ExternalPropertyDialog.tsx | 47 +-- .../PropertyForm/RelationPropertyForm.tsx | 8 +- 22 files changed, 806 insertions(+), 194 deletions(-) create mode 100644 browser/data-browser/src/components/forms/SearchBox/ResultLine.tsx create mode 100644 browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx create mode 100644 browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx create mode 100644 browser/data-browser/src/components/forms/SearchBox/index.ts create mode 100644 browser/data-browser/src/helpers/focusOffsetElement.ts create mode 100644 browser/data-browser/src/helpers/isURL.ts create mode 100644 browser/data-browser/src/helpers/remToPixels.ts diff --git a/browser/data-browser/src/App.tsx b/browser/data-browser/src/App.tsx index 665146dfb..0d0ae8ff4 100644 --- a/browser/data-browser/src/App.tsx +++ b/browser/data-browser/src/App.tsx @@ -22,6 +22,7 @@ import { DropdownContainer } from './components/Dropdown/DropdownContainer'; import { PopoverContainer } from './components/Popover'; import { SkipNav } from './components/SkipNav'; import { ControlLockProvider } from './hooks/useControlLock'; +import { FormValidationContextProvider } from './components/forms/formValidation/FormValidationContextProvider'; function fixDevUrl(url: string) { if (isDev()) { @@ -82,21 +83,26 @@ function App(): JSX.Element { {/* @ts-ignore fallback component type too strict */} - - - - - - - - - - - - - - - + {/* Default form validation provider. Does not do anyting on its own but will make sure useValidation works without context*/} + undefined} + > + + + + + + + + + + + + + + + + diff --git a/browser/data-browser/src/components/Button.tsx b/browser/data-browser/src/components/Button.tsx index 036083b71..a452b4f06 100644 --- a/browser/data-browser/src/components/Button.tsx +++ b/browser/data-browser/src/components/Button.tsx @@ -22,6 +22,10 @@ export interface ButtonProps className?: string; } +interface ButtonPropsStyled { + gutter?: boolean; +} + const getButtonComp = ({ clean, icon, subtle, alert }: ButtonProps) => { let Comp = ButtonDefault; @@ -38,6 +42,7 @@ const getButtonComp = ({ clean, icon, subtle, alert }: ButtonProps) => { } if (clean) { + // @ts-ignore Comp = ButtonClean; } @@ -48,10 +53,13 @@ export const Button = React.forwardRef< HTMLButtonElement, React.PropsWithChildren >(({ children, loading, ...props }, ref): JSX.Element => { + // Filter out props that should not be passed to the button element or styled component. + const { icon: _icon, ...buttonProps } = props; + const Comp = getButtonComp(props); return ( - + {loading ? : children} ); @@ -60,14 +68,14 @@ export const Button = React.forwardRef< Button.displayName = 'Button'; /** Extremly minimal set of button properties */ -export const ButtonClean = styled.button` +export const ButtonClean = styled.button` cursor: pointer; border: none; font-size: inherit; padding: 0; color: inherit; margin: 0; - -webkit-appearance: none; + appearance: none; background-color: initial; -webkit-tap-highlight-color: transparent; /* Remove the tap / click effect on touch devices */ `; @@ -77,6 +85,7 @@ export const ButtonBase = styled(ButtonClean)` height: 2rem; display: flex; align-items: center; + gap: 1ch; justify-content: center; background-color: ${props => props.theme.colors.main}; color: ${props => props.theme.colors.bg}; @@ -146,7 +155,7 @@ export const ButtonBar = styled(ButtonClean)` /** Button with some optional margins around it */ // eslint-disable-next-line prettier/prettier -export const ButtonDefault = styled(ButtonBase)` +export const ButtonDefault = styled(ButtonBase)` --button-bg-color: ${p => p.theme.colors.main}; --button-bg-color-hover: ${p => p.theme.colors.mainLight}; --button-border-color: ${p => p.theme.colors.main}; @@ -158,7 +167,6 @@ export const ButtonDefault = styled(ButtonBase)` border-radius: ${p => p.theme.radius}; padding-left: ${p => p.theme.margin}rem; padding-right: ${p => p.theme.margin}rem; - /* box-shadow: ${p => (p.subtle ? p.theme.boxShadow : 'none')}; */ display: inline-flex; background-color: var(--button-bg-color); color: var(--button-text-color); diff --git a/browser/data-browser/src/components/Dialog/index.tsx b/browser/data-browser/src/components/Dialog/index.tsx index 1d587b5a0..c8ce82fe2 100644 --- a/browser/data-browser/src/components/Dialog/index.tsx +++ b/browser/data-browser/src/components/Dialog/index.tsx @@ -66,7 +66,7 @@ type DialogSlotComponent = React.FC>; * ); * ``` */ -export function Dialog(props) { +export function Dialog(props: React.PropsWithChildren) { const portalRef = useContext(DialogPortalContext); if (!portalRef.current) { @@ -225,7 +225,8 @@ const DialogActionsSlot = styled(Slot)` const StyledInnerDialog = styled.div` display: grid; grid-template-columns: auto 2rem; - grid-template-rows: 1fr auto 1fr; + grid-template-rows: 1fr auto auto; + gap: 1rem; grid-template-areas: 'title close' 'content content' 'actions actions'; max-height: 100%; `; @@ -263,7 +264,8 @@ const StyledDialog = styled.dialog` background-color: ${props => props.theme.colors.bg}; border-radius: ${props => props.theme.radius}; border: solid 1px ${props => props.theme.colors.bg2}; - max-inline-size: min(90vw, 75ch); + max-inline-size: min(90vw, 100ch); + min-inline-size: min(90vw, 60ch); max-block-size: 100vh; overflow: visible; diff --git a/browser/data-browser/src/components/SearchFilter.tsx b/browser/data-browser/src/components/SearchFilter.tsx index 1b6872305..604f2124e 100644 --- a/browser/data-browser/src/components/SearchFilter.tsx +++ b/browser/data-browser/src/components/SearchFilter.tsx @@ -27,7 +27,7 @@ export function ClassFilter({ filters, setFilters }): JSX.Element { {allProps?.map(propertySubject => ( ` border-radius: ${props => props.theme.radius}; overflow: hidden; - &:hover { + &:hover:has(input:not(:disabled)) { border-color: ${props => props.theme.colors.main}; } &:focus-within { diff --git a/browser/data-browser/src/components/forms/ResourceForm.tsx b/browser/data-browser/src/components/forms/ResourceForm.tsx index e00bb2179..4b28ca3fc 100644 --- a/browser/data-browser/src/components/forms/ResourceForm.tsx +++ b/browser/data-browser/src/components/forms/ResourceForm.tsx @@ -223,12 +223,11 @@ export function ResourceForm({ { + setSubject={set => { setNewProperty(set); }} error={newPropErr} - setError={setNewPropErr} - classType={urls.classes.property} + isA={urls.classes.property} /> {newPropErr && {newPropErr.message}} diff --git a/browser/data-browser/src/components/forms/ResourceSelector/DropdownInput.tsx b/browser/data-browser/src/components/forms/ResourceSelector/DropdownInput.tsx index a5d24103d..67a60b348 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/DropdownInput.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/DropdownInput.tsx @@ -56,6 +56,8 @@ function isCreateOption(option: unknown): option is CreateOption { return option?.type === 'createOption'; } +// TODO: Component is still used in collection page because we want to show a list of properties there even if the user is not searching anything. We should add predetermined options to Searchbox instead. + /** * Renders an input field with a dropdown menu. You can search through the * items, select them from a list, clear the entire thing, add new items. @@ -89,12 +91,12 @@ export const DropdownInput: React.FC = ({ const openMenuButtonRef = useRef(null); const inputRef = useRef(null); const containerRef = useContext(DropdownPortalContext); - const { - ref: inputWrapperRef, - above, - below, - width, - } = useAvailableSpace(isOpen); + const inputWrapperRef = useRef(null); + + const { above, below, width } = useAvailableSpace( + isOpen, + inputWrapperRef, + ); /** Close the menu and set the value */ const handleClickOutside = useCallback(() => { diff --git a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx index 075ad9291..b57d7ac63 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx @@ -1,23 +1,20 @@ -import { urls, useArray, useResource, useStore, useTitle } from '@tomic/react'; -import React, { useState, useCallback, useReducer } from 'react'; -import { ErrMessage, InputWrapper } from '../InputStyles'; -import { DropdownInput } from './DropdownInput'; +import React, { useState, useMemo } from 'react'; import { Dialog, useDialog } from '../../Dialog'; import { useDialogTreeContext } from '../../Dialog/dialogContext'; import { useSettings } from '../../../helpers/AppSettings'; import { styled } from 'styled-components'; import { NewFormDialog } from '../NewForm/NewFormDialog'; -import { useDeferredUpdate } from '../../../hooks/useDeferredUpdate'; +import { SearchBox } from '../SearchBox'; +import { SearchBoxButton } from '../SearchBox/SearchBox'; +import { FaTrash } from 'react-icons/fa'; import { ErrorChip } from '../ErrorChip'; -type SetSubject = (subject: string | undefined) => void; - 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. */ - classType?: string; + isA?: string; /** If true, the form will show an error if it is left empty. */ required?: boolean; /** @@ -25,14 +22,14 @@ interface ResourceSelectorProps { * Handler as the second argument to set an error message. Take the second * argument of a `useString` hook and pass the setString part to this property */ - setSubject: SetSubject; + setSubject: (subject: string | undefined) => void; /** The value (URL of the Resource that is selected) */ value?: string; /** 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; - onValidate?: (e: Error | undefined) => void; disabled?: boolean; autoFocus?: boolean; /** Is used when a new item is created using the ResourceSelector */ @@ -49,163 +46,86 @@ export const ResourceSelector = React.memo(function ResourceSelector({ setSubject, value, handleRemove, - classType, - disabled, onValidate, + error, + isA: classType, + disabled, parent, hideCreateOption, - ...props }: ResourceSelectorProps): JSX.Element { - // TODO: This list should use the user's Pod instead of a hardcoded collection; - const classesCollection = useResource(getCollectionURL(classType)); - const [touched, handleBlur] = useReducer(() => true, false); - const [error, setError] = useState(); - let [options] = useArray( - classesCollection, - urls.properties.collection.members, - ); - const requiredClass = useResource(classType); - const [classTypeTitle] = useTitle(requiredClass); - const store = useStore(); const [dialogProps, showDialog, closeDialog, isDialogOpen] = useDialog(); + const [initialNewTitle, setInitialNewTitle] = useState(''); const { drive } = useSettings(); - const [ - /** The value of the input underneath, updated through a callback */ - inputValue, - setInputValue, - ] = useState(value || ''); - - const updateSubject = useDeferredUpdate( - setSubject, - inputValue as string | undefined, - ); - - const handleUpdate = useCallback( - (newValue: string | undefined) => { - setError(undefined); - updateSubject(newValue); - }, - [updateSubject], - ); - - const onInputChange = useCallback( - (str: string) => { - setInputValue(str); - - try { - new URL(str); - updateSubject(str); - setError(undefined); - } catch (e) { - // Don't cause state changes when the value didn't change. - if (value !== undefined) { - updateSubject(undefined); - } - - if (str !== '') { - setError('Invalid URL'); - } else { - setError(undefined); - } - } - }, - [setInputValue, updateSubject, onValidate], - ); - const { inDialog } = useDialogTreeContext(); - if (options.length === 0) { - options = store.getAllSubjects(); - } - - let placeholder = 'Enter an Atomic URL...'; + const handleCreateItem = useMemo(() => { + if (hideCreateOption) { + return undefined; + } - if (classType && classTypeTitle?.length > 0) { - placeholder = `Select a ${classTypeTitle} or enter a ${classTypeTitle} URL...`; - } - - if (classType && !requiredClass.isReady()) { - placeholder = 'Loading Class...'; - } + return (name: string) => { + setInitialNewTitle(name); + showDialog(); + }; + }, [hideCreateOption, setSubject, showDialog]); return ( - - {touched && error && ( - {error} - )} - {!inDialog && ( + onCreateItem={handleCreateItem} + > + {handleRemove && ( + + + + )} + + {error && {error.message}} + {!inDialog && classType && ( {isDialogOpen && ( )} )} - {required && value === '' && Required} ); }); -/** For a given class URL, this tries to return a URL of a Collection containing these. */ -// TODO: Scope to current store / make adjustable https://github.com/atomicdata-dev/atomic-data-browser/issues/295 -export function getCollectionURL(classtypeUrl?: string): string | undefined { - switch (classtypeUrl) { - case urls.classes.property: - return 'https://atomicdata.dev/properties/?page_size=999'; - case urls.classes.class: - return 'https://atomicdata.dev/classes/?page_size=999'; - case urls.classes.agent: - return 'https://atomicdata.dev/agents/'; - case urls.classes.commit: - return 'https://atomicdata.dev/commits'; - case urls.classes.datatype: - return 'https://atomicdata.dev/datatypes'; - default: - return undefined; - } -} +// We need Wrapper to be able to target this component. +const StyledSearchBox = styled(SearchBox)``; const Wrapper = styled.div` flex: 1; position: relative; --radius: ${props => props.theme.radius}; - ${InputWrapper} { + ${StyledSearchBox} { border-radius: 0; } - &:first-of-type ${InputWrapper} { + &:first-of-type ${StyledSearchBox} { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); } - &:last-of-type ${InputWrapper} { + &:last-of-type ${StyledSearchBox} { border-bottom-left-radius: var(--radius); border-bottom-right-radius: var(--radius); } - &:not(:last-of-type) ${InputWrapper} { + &:not(:last-of-type) ${StyledSearchBox} { border-bottom: none; } `; diff --git a/browser/data-browser/src/components/forms/SearchBox/ResultLine.tsx b/browser/data-browser/src/components/forms/SearchBox/ResultLine.tsx new file mode 100644 index 000000000..f3b023a20 --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/ResultLine.tsx @@ -0,0 +1,97 @@ +import { urls, useResource, useString } from '@tomic/react'; +import React, { useEffect, useRef } from 'react'; +import styled, { css } from 'styled-components'; +import { getIconForClass } from '../../../views/FolderPage/iconMap'; + +interface ResultLineProps { + selected: boolean; + onMouseOver: () => void; + onClick: () => void; +} + +interface ResourceResultLineProps extends ResultLineProps { + subject: string; +} + +export function ResultLine({ + selected, + children, + onMouseOver, + onClick, +}: React.PropsWithChildren): JSX.Element { + const ref = useRef(null); + + useEffect(() => { + if (selected) { + ref.current?.scrollIntoView({ block: 'nearest' }); + } + }, [selected]); + + return ( + onMouseOver()} + onClick={onClick} + > + {children} + + ); +} + +export function ResourceResultLine({ + subject, + ...props +}: ResourceResultLineProps): JSX.Element { + const resource = useResource(subject); + const [isA] = useString(resource, urls.properties.isA); + const [description] = useString(resource, urls.properties.description); + + const Icon = getIconForClass(isA ?? ''); + + return ( + + + {resource.title} + {description && - {description.slice(0, 70)}} + + ); +} + +const Description = styled.span` + white-space: nowrap; + color: ${({ theme }) => theme.colors.textLight}; +`; + +export const ListItem = styled.li<{ selected: boolean }>` + padding: 0.5rem; + list-style: none; + margin: 0; + padding-left: ${({ theme }) => theme.margin}rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.bg2}; + min-width: 100%; + width: 100%; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; + gap: 0.7ch; + + cursor: pointer; + + ${p => + p.selected && + css` + box-shadow: inset 0 0 0px 1px ${({ theme }) => theme.colors.main}; + color: ${({ theme }) => theme.colors.main}; + `} + + svg { + color: ${({ selected, theme }) => + selected ? theme.colors.main : theme.colors.textLight}; + min-width: 1rem; + height: 1rem; + } +`; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx new file mode 100644 index 000000000..3d198f527 --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -0,0 +1,246 @@ +import React, { + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import styled from 'styled-components'; +import { useResource } 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 { ErrorChip } from '../ErrorChip'; +import { useValidation } from '../formValidation/useValidation'; + +interface SearchBoxProps { + value: string | undefined; + isA?: string; + scope?: string; + placeholder?: string; + disabled?: boolean; + required?: boolean; + className?: string; + onChange: (value: string | undefined) => void; + onCreateItem?: (name: string) => void; +} + +export function SearchBox({ + value, + isA, + scope, + placeholder, + disabled, + required, + className, + children, + onChange, + onCreateItem, +}: React.PropsWithChildren): JSX.Element { + const selectedResource = useResource(value); + const triggerRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const typeResource = useResource(isA); + const [open, setOpen] = useState(false); + const containerRef = useContext(DropdownPortalContext); + const [justFocussed, setJustFocussed] = useState(false); + + const [error, setError, handleBlur] = useValidation(); + + const placeholderText = + placeholder ?? + `Search for a ${isA ? typeResource.title : 'resource'} or enter a URL...`; + + const handleExit = useCallback((lostFocus: boolean) => { + setOpen(false); + handleBlur(); + + if (!lostFocus) { + triggerRef.current?.focus(); + } else { + setJustFocussed(false); + } + }, []); + + const handleSelect = useCallback( + (newValue: string) => { + try { + new URL(newValue); + onChange(newValue); + setInputValue(''); + } catch (e) { + // not a URL + } + + handleExit(false); + }, + [inputValue, onChange, handleExit], + ); + + const handleTriggerFocus = () => { + if (justFocussed) { + setJustFocussed(false); + + return; + } + + setOpen(true); + setJustFocussed(true); + }; + + useEffect(() => { + if (!!required && !value) { + setError('Required'); + + return; + } + + if (selectedResource.error) { + setError('Invalid Resource'); + + return; + } + + setError(undefined); + }, [setError, required, value, selectedResource]); + + return ( + + + + { + setOpen(true); + setJustFocussed(true); + }} + > + {value ? ( + + {selectedResource.error + ? 'Invalid Resource' + : selectedResource.title} + + ) : ( + {placeholderText} + )} + + {value && ( + onChange(undefined)} + type='button' + > + + + )} + {children} + {error && ( + {error} + )} + + + + + {open && ( + + )} + + + + ); +} + +const TriggerButton = styled.button<{ empty: boolean }>` + display: flex; + align-items: center; + padding: 0.5rem; + border-radius: ${props => props.theme.radius}; + background-color: ${props => props.theme.colors.bg}; + border: none; + text-align: start; + height: 2rem; + width: 100%; + overflow: hidden; + cursor: text; + color: ${p => (p.empty ? p.theme.colors.textLight : p.theme.colors.text)}; + + &:disabled { + background-color: ${props => props.theme.colors.bg1}; + } +`; + +const TriggerButtonWrapper = styled.div<{ + invalid: boolean; + disabled: boolean; +}>` + --search-box-hightlight: ${p => + p.invalid ? p.theme.colors.alert : p.theme.colors.main}; + display: flex; + position: relative; + border: 1px solid ${props => props.theme.colors.bg2}; + border-radius: ${props => props.theme.radius}; + &:hover, + &:focus-within { + border-color: ${p => + p.disabled ? 'none' : 'var(--search-box-hightlight)'}; + } +`; + +const ResourceTitle = styled.span` + color: var(--search-box-hightlight); + overflow: hidden; + text-overflow: ellipsis; +`; + +const PlaceholderText = styled.span` + color: ${p => p.theme.colors.textLight}; +`; + +export const SearchBoxButton = styled.button` + background-color: ${p => p.theme.colors.bg}; + border: none; + border-left: 1px solid ${p => p.theme.colors.bg2}; + display: flex; + align-items: center; + padding: 0.5rem; + color: ${p => p.theme.colors.textLight}; + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--search-box-hightlight); + background-color: ${p => p.theme.colors.bg1}; + border-color: var(--search-box-hightlight); + } + + &:last-of-type { + border-top-right-radius: ${p => p.theme.radius}; + border-bottom-right-radius: ${p => p.theme.radius}; + } +`; + +const PositionedErrorChip = styled(ErrorChip)` + position: absolute; + top: 2rem; + z-index: 10; +`; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx new file mode 100644 index 000000000..68593d9d9 --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx @@ -0,0 +1,273 @@ +import { urls, useServerSearch } from '@tomic/react'; +import React, { useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; +import { ResourceResultLine, ResultLine } from './ResultLine'; +import { fadeIn } from '../../../helpers/commonAnimations'; +import { ScrollArea } from '../../ScrollArea'; +import { loopingIndex } from '../../../helpers/loopingIndex'; +import { focusOffsetElement } from '../../../helpers/focusOffsetElement'; +import { isURL } from '../../../helpers/isURL'; +import { useAvailableSpace } from '../hooks/useAvailableSpace'; +import { remToPixels } from '../../../helpers/remToPixels'; + +const BOX_HEIGHT_REM = 20; + +interface SearchBoxWindowProps { + searchValue: string; + isA?: string; + scope?: string; + placeholder?: string; + triggerRef: React.RefObject; + onExit: (lostFocus: boolean) => void; + onChange: (value: string) => void; + onSelect: (value: string) => void; + onCreateItem?: (name: string) => void; +} + +export function SearchBoxWindow({ + searchValue, + onChange, + isA, + scope, + placeholder, + triggerRef, + onExit, + onSelect, + onCreateItem, +}: SearchBoxWindowProps): JSX.Element { + const [realIndex, setIndex] = useState(undefined); + const { below } = useAvailableSpace(true, triggerRef); + const wrapperRef = React.useRef(null); + const filters = useMemo( + () => ({ + ...(isA ? { [urls.properties.isA]: isA } : {}), + }), + [isA], + ); + + const { results } = useServerSearch(searchValue, { + filters, + scope, + }); + + const isAboveTrigger = below < remToPixels(BOX_HEIGHT_REM); + + const offset = onCreateItem ? 1 : 0; + + const selectedIndex = + realIndex !== undefined + ? loopingIndex(realIndex, results.length + offset) + : undefined; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + pickSelectedItem(); + + return; + } + + if (e.key === 'Escape') { + onExit(false); + + return; + } + + if (e.key === 'Tab' && e.shiftKey) { + e.preventDefault(); + focusOffsetElement(-1, triggerRef.current!); + + return; + } + + if (e.key === 'Tab') { + e.preventDefault(); + focusOffsetElement(1, triggerRef.current!); + + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + + setIndex(prev => { + if (prev === undefined) { + return 0; + } + + return prev + 1; + }); + + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + + setIndex(prev => (prev ?? 0) - 1); + + return; + } + + setIndex(undefined); + }; + + const handleMouseMove = (i: number) => { + setIndex(i); + }; + + const pickSelectedItem = () => { + if (selectedIndex === undefined) { + onSelect(searchValue); + + return; + } + + if (selectedIndex === 0 && onCreateItem) { + onCreateItem(searchValue); + + return; + } + + onSelect(results[selectedIndex - offset]); + }; + + const handleBlur = () => { + requestAnimationFrame(() => { + if (!wrapperRef.current?.contains(document.activeElement)) { + onExit(true); + } + }); + }; + + const handlePaste: React.ClipboardEventHandler = e => { + const data = e.clipboardData.getData('text'); + + if (isURL(data)) { + e.preventDefault(); + onSelect(data); + } + }; + + return ( + + ) => + onChange(e.target.value) + } + onKeyDown={handleKeyDown} + onPaste={handlePaste} + /> + + {!searchValue && Start Searching} + +
    + {onCreateItem && ( + handleMouseMove(0)} + onClick={() => onCreateItem(searchValue)} + > + Create {searchValue} + + )} + {results.map((result, i) => ( + handleMouseMove(i + offset)} + onClick={pickSelectedItem} + /> + ))} +
+ {!!searchValue && results.length === 0 && ( + No Results + )} +
+
+
+ ); +} + +const Input = styled.input` + border: solid 1px ${p => p.theme.colors.bg2}; + padding: 0.5rem; + height: var(--radix-popover-trigger-height); + width: 100%; + + &:focus-visible { + border-color: ${p => p.theme.colors.main}; + outline: none; + } +`; + +const ResultBox = styled.div` + flex: 1; + border: solid 1px ${p => p.theme.colors.bg2}; + + height: calc(100% - 2rem); + overflow: hidden; +`; + +const Wrapper = styled.div<{ above: boolean }>` + display: flex; + + background-color: ${p => p.theme.colors.bg}; + border-radius: ${p => p.theme.radius}; + box-shadow: ${p => p.theme.boxShadowSoft}; + width: 100%; + height: ${BOX_HEIGHT_REM}rem; + position: absolute; + width: var(--radix-popover-trigger-width); + ${({ above, theme }) => + above + ? css` + bottom: 0; + flex-direction: column-reverse; + + ${Input} { + border-bottom-left-radius: ${theme.radius}; + border-bottom-right-radius: ${theme.radius}; + } + + ${ResultBox} { + border-bottom: none; + border-top-left-radius: ${p => p.theme.radius}; + border-top-right-radius: ${p => p.theme.radius}; + } + ` + : css` + top: calc(var(--radix-popover-trigger-height) * -1); + flex-direction: column; + + ${Input} { + border-top-left-radius: ${theme.radius}; + border-top-right-radius: ${theme.radius}; + } + + ${ResultBox} { + border-top: none; + border-bottom-left-radius: ${p => p.theme.radius}; + border-bottom-right-radius: ${p => p.theme.radius}; + } + `} + left: 0; + + animation: ${fadeIn} 0.2s ease-in-out; +`; +const CenteredMessage = styled.div` + display: grid; + place-items: center; + height: 100%; + width: 100%; + color: ${p => p.theme.colors.textLight}; +`; + +const StyledScrollArea = styled(ScrollArea)` + overflow: hidden; + height: 100%; +`; diff --git a/browser/data-browser/src/components/forms/SearchBox/index.ts b/browser/data-browser/src/components/forms/SearchBox/index.ts new file mode 100644 index 000000000..0ba5871ab --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/index.ts @@ -0,0 +1 @@ +export { SearchBox } from './SearchBox'; diff --git a/browser/data-browser/src/components/forms/formValidation/useValidation.ts b/browser/data-browser/src/components/forms/formValidation/useValidation.ts index ba8726e66..b8d3d2cc4 100644 --- a/browser/data-browser/src/components/forms/formValidation/useValidation.ts +++ b/browser/data-browser/src/components/forms/formValidation/useValidation.ts @@ -16,6 +16,10 @@ export function useValidation( const setError = useCallback((error: string | undefined) => { setValidations(prev => { + if (prev[id] === error) { + return prev; + } + return { ...prev, [id]: error, diff --git a/browser/data-browser/src/components/forms/hooks/useAvailableSpace.ts b/browser/data-browser/src/components/forms/hooks/useAvailableSpace.ts index cfff4acda..fb0cf9c91 100644 --- a/browser/data-browser/src/components/forms/hooks/useAvailableSpace.ts +++ b/browser/data-browser/src/components/forms/hooks/useAvailableSpace.ts @@ -1,7 +1,9 @@ -import { useDeferredValue, useLayoutEffect, useRef, useState } from 'react'; +import { useDeferredValue, useLayoutEffect, useState } from 'react'; -export function useAvailableSpace(trigger: boolean) { - const ref = useRef(null); +export function useAvailableSpace( + trigger: boolean, + ref: React.RefObject, +) { const [space, setSpace] = useState({ above: 0, below: 0, width: 0 }); const deferredTrigger = useDeferredValue(trigger); @@ -15,7 +17,6 @@ export function useAvailableSpace(trigger: boolean) { }, [deferredTrigger]); return { - ref, above: space.above, below: space.below, width: space.width, diff --git a/browser/data-browser/src/helpers/focusOffsetElement.ts b/browser/data-browser/src/helpers/focusOffsetElement.ts new file mode 100644 index 000000000..2758b9102 --- /dev/null +++ b/browser/data-browser/src/helpers/focusOffsetElement.ts @@ -0,0 +1,42 @@ +import { loopingIndex } from './loopingIndex'; + +// CSS Query of all elements that can receive focus. +const QUERY = + 'a[href]:not([disabled]), button:not([disabled]), input:not([disabled], [type=hidden]), [tabindex]:not([disabled]):not([tabindex="-1"]), textarea:not([disabled]), select:not([disabled]), [contenteditable]:not([disabled])'; + +/** + * Focus the element that is offset from the origin in the tab order. + * Effectively simulates the behavour of the tab key but allows for specifying a different origin element from the current activeElement. + **/ +export function focusOffsetElement(offset: number, origin?: Element) { + //add all elements we want to include in our selection + const startElement = origin ?? document.activeElement; + + if (startElement) { + const focussable: Element[] = []; + + document.querySelectorAll(QUERY).forEach(element => { + //check for visibility while always include the current activeElement + if ( + // @ts-ignore + element.offsetWidth > 0 || + // @ts-ignore + element.offsetHeight > 0 || + element === startElement + ) { + focussable.push(element); + } + }); + + const index = focussable.indexOf(startElement); + + if (index > -1) { + const nextElement = + focussable[loopingIndex(index + offset, focussable.length)] || + focussable[0]; + + // @ts-ignore + nextElement.focus(); + } + } +} diff --git a/browser/data-browser/src/helpers/isURL.ts b/browser/data-browser/src/helpers/isURL.ts new file mode 100644 index 000000000..685b574bf --- /dev/null +++ b/browser/data-browser/src/helpers/isURL.ts @@ -0,0 +1,9 @@ +export function isURL(testString: string): boolean { + try { + new URL(testString); + + return true; + } catch { + return false; + } +} diff --git a/browser/data-browser/src/helpers/remToPixels.ts b/browser/data-browser/src/helpers/remToPixels.ts new file mode 100644 index 000000000..d51905d4a --- /dev/null +++ b/browser/data-browser/src/helpers/remToPixels.ts @@ -0,0 +1,6 @@ +/** + * Converts a rem value to pixels. Take user settings into account. + */ +export function remToPixels(rem: number): number { + return rem * parseFloat(getComputedStyle(document.documentElement).fontSize); +} diff --git a/browser/data-browser/src/routes/NewRoute.tsx b/browser/data-browser/src/routes/NewRoute.tsx index 4ab502c9a..3d18118b4 100644 --- a/browser/data-browser/src/routes/NewRoute.tsx +++ b/browser/data-browser/src/routes/NewRoute.tsx @@ -110,7 +110,7 @@ function NewResourceSelector() { setSubject={setClassInputValue} value={classInputValue} error={error} - classType={urls.classes.class} + isA={urls.classes.class} /> (); - const [error, setError] = useState(); + const [isValid, setIsValid] = useState(false); const [recommends, setRecommends] = useArray( tableClassResource, @@ -48,27 +48,28 @@ export function ExternalPropertyDialog({ return ( - -

Add external property

-
- - - {error && {error.message}} - - - - - + + +

Add external property

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

New Class

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

{resource.title}

- -
-
- - - - -

{description}

-

Classes

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

Properties

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

Instances

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

{resource.title}

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

Classes

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

    Properties

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

    Instances

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

    +

    {prefix && `${prefix} `} {link ? ( {title} ) : ( title )} -

    + ); } + +const H1 = styled.h1` + margin-bottom: 0; +`; diff --git a/browser/data-browser/src/routes/DataRoute.tsx b/browser/data-browser/src/routes/DataRoute.tsx index 5a5be7498..e5d2a2604 100644 --- a/browser/data-browser/src/routes/DataRoute.tsx +++ b/browser/data-browser/src/routes/DataRoute.tsx @@ -20,6 +20,10 @@ import { Column, Row } from '../components/Row'; import { ErrorLook } from '../components/ErrorLook'; import { ResourceUsage } from '../components/ResourceUsage'; import { Main } from '../components/Main'; +import { IconButton } from '../components/IconButton/IconButton'; +import { FaArrowLeft } from 'react-icons/fa'; +import { useNavigate } from 'react-router'; +import { constructOpenURL } from '../helpers/navigation'; /** Renders the data of some Resource */ function Data(): JSX.Element { @@ -32,6 +36,7 @@ function Data(): JSX.Element { const [err, setErr] = useState(undefined); const { agent } = useSettings(); const store = useStore(); + const navigate = useNavigate(); if (!subject) { No subject passed; @@ -71,11 +76,24 @@ function Data(): JSX.Element { } } + const handleBackClick = () => { + navigate(constructOpenURL(subject ?? '')); + }; + return (
    - + <Row center gap='1ch'> + <IconButton + size='1.4em' + title={`Back to ${resource.title}`} + onClick={handleBackClick} + > + <FaArrowLeft /> + </IconButton> + <Title resource={resource} prefix='Data for' link /> + </Row> <PropValRow columns> <PropertyLabel title='The URL of the resource'> subject: diff --git a/browser/data-browser/src/routes/EditRoute.tsx b/browser/data-browser/src/routes/EditRoute.tsx index 300ebda24..9e6ea5bc5 100644 --- a/browser/data-browser/src/routes/EditRoute.tsx +++ b/browser/data-browser/src/routes/EditRoute.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router'; import { useResource } from '@tomic/react'; -import { newURL } from '../helpers/navigation'; +import { constructOpenURL, newURL } from '../helpers/navigation'; import { ContainerNarrow } from '../components/Containers'; import { InputStyled } from '../components/forms/InputStyles'; import { ResourceForm } from '../components/forms/ResourceForm'; @@ -10,6 +10,9 @@ import { ClassDetail } from '../components/ClassDetail'; import { Title } from '../components/Title'; import Parent from '../components/Parent'; import { Main } from '../components/Main'; +import { Column, Row } from '../components/Row'; +import { IconButton } from '../components/IconButton/IconButton'; +import { FaArrowLeft } from 'react-icons/fa'; /** Form for instantiating a new Resource from some Class */ export function Edit(): JSX.Element { @@ -30,18 +33,31 @@ export function Edit(): JSX.Element { navigate(newURL(subjectInput)); } + const handleBackClick = () => { + navigate(constructOpenURL(subject ?? '')); + }; + return ( <> <Parent resource={resource} /> <ContainerNarrow> <Main subject={subject}> {subject ? ( - <> - <Title resource={resource} prefix='Edit' /> + <Column> + <Row center gap='1ch'> + <IconButton + title={`Back to ${resource.title}`} + size='1.4em' + onClick={handleBackClick} + > + <FaArrowLeft /> + </IconButton> + <Title resource={resource} prefix='Edit' /> + </Row> <ClassDetail resource={resource} /> {/* Key is required for re-rendering when subject changes */} <ResourceForm resource={resource} key={subject} /> - </> + </Column> ) : ( <form onSubmit={handleClassSet}> <h1>edit a resource</h1> From 0cd78684e685867cb4f4d29c1f2532cfbb91aa47 Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Wed, 6 Sep 2023 13:09:46 +0200 Subject: [PATCH 08/13] #648 Display graph in ontology view --- browser/data-browser/package.json | 2 + .../src/chunks/GraphViewer/FloatingEdge.tsx | 123 +++++ .../src/chunks/GraphViewer/OntologyGraph.tsx | 75 +++ .../src/chunks/GraphViewer/buildGraph.ts | 188 ++++++++ .../src/chunks/GraphViewer/getEdgeParams.ts | 132 ++++++ .../chunks/GraphViewer/reactFlowOverrides.css | 5 + .../src/chunks/GraphViewer/useGraph.ts | 156 +++++++ .../src/views/OntologyPage/Graph.tsx | 33 ++ .../src/views/OntologyPage/OntologyPage.tsx | 25 +- browser/lib/src/urls.ts | 4 + browser/pnpm-lock.yaml | 431 +++++++++++++++++- 11 files changed, 1152 insertions(+), 22 deletions(-) create mode 100644 browser/data-browser/src/chunks/GraphViewer/FloatingEdge.tsx create mode 100644 browser/data-browser/src/chunks/GraphViewer/OntologyGraph.tsx create mode 100644 browser/data-browser/src/chunks/GraphViewer/buildGraph.ts create mode 100644 browser/data-browser/src/chunks/GraphViewer/getEdgeParams.ts create mode 100644 browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css create mode 100644 browser/data-browser/src/chunks/GraphViewer/useGraph.ts create mode 100644 browser/data-browser/src/views/OntologyPage/Graph.tsx diff --git a/browser/data-browser/package.json b/browser/data-browser/package.json index ee5990426..0e842e395 100644 --- a/browser/data-browser/package.json +++ b/browser/data-browser/package.json @@ -8,6 +8,7 @@ "@bugsnag/core": "^7.16.1", "@bugsnag/js": "^7.16.5", "@bugsnag/plugin-react": "^7.16.5", + "@dagrejs/dagre": "^1.0.2", "@dnd-kit/core": "^6.0.5", "@dnd-kit/sortable": "^7.0.1", "@dnd-kit/utilities": "^3.2.0", @@ -35,6 +36,7 @@ "react-router-dom": "^6.9.0", "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.7", + "reactflow": "^11.8.3", "remark-gfm": "^3.0.1", "styled-components": "^6.0.7", "stylis": "4.3.0", diff --git a/browser/data-browser/src/chunks/GraphViewer/FloatingEdge.tsx b/browser/data-browser/src/chunks/GraphViewer/FloatingEdge.tsx new file mode 100644 index 000000000..16e1d2bd9 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/FloatingEdge.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from 'react'; +import { + useStore as useFlowStore, + getBezierPath, + EdgeText, + EdgeProps, + Node, +} from 'reactflow'; +import styled, { useTheme } from 'styled-components'; +import { getEdgeParams, getSelfReferencePath } from './getEdgeParams'; +import { EdgeData } from './buildGraph'; + +const getPathData = ( + sourceNode: Node, + targetNode: Node, + overlapping: boolean, +) => { + // Self referencing edges use a custom path. + if (sourceNode.id === targetNode.id) { + return getSelfReferencePath(sourceNode); + } + + const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams( + sourceNode, + targetNode, + overlapping, + ); + + return getBezierPath({ + sourceX: sx, + sourceY: sy, + sourcePosition: sourcePos, + targetPosition: targetPos, + targetX: tx, + targetY: ty, + }); +}; + +function Label({ text }: { text: string }): JSX.Element | string { + const parts = text.split('\n'); + + if (parts.length === 1) { + return text; + } + + // SVG does not have any auto word wrap so we split the lines manually and offset them. + return ( + <> + {parts.map((part, i) => ( + <tspan x={0} dy={i === 0 ? '-0.3em' : '1.2em'} key={part}> + {part} + </tspan> + ))} + </> + ); +} + +/** + * A custom edge that doesn't clutter the graph as mutch as the default edge. + * It casts a ray from the center of the source node to the center of the target node then draws a bezier curve between the two intersecting border of the nodes. + */ +export function FloatingEdge({ + id, + source, + target, + markerEnd, + style, + label, + data, +}: EdgeProps<EdgeData>) { + const theme = useTheme(); + const sourceNode = useFlowStore( + useCallback(store => store.nodeInternals.get(source), [source]), + ); + const targetNode = useFlowStore( + useCallback(store => store.nodeInternals.get(target), [target]), + ); + + if (!sourceNode || !targetNode) { + return null; + } + + const [path, labelX, labelY] = getPathData( + sourceNode, + targetNode, + !!data?.overlapping, + ); + + return ( + <> + <Path + id={id} + className='react-flow__edge-path' + d={path} + markerEnd={markerEnd} + style={style} + /> + <EdgeText + x={labelX} + y={labelY} + label={<Label text={label as string} />} + labelStyle={{ + fill: theme.colors.text, + }} + labelShowBg + labelBgStyle={{ fill: theme.colors.bg1 }} + labelBgPadding={[2, 4]} + labelBgBorderRadius={2} + /> + </> + ); +} + +const Path = styled.path` + flex-direction: column; + display: flex; + flex-grow: 1; + height: 100%; + + & .react-flow__handle { + opacity: 0; + } +`; diff --git a/browser/data-browser/src/chunks/GraphViewer/OntologyGraph.tsx b/browser/data-browser/src/chunks/GraphViewer/OntologyGraph.tsx new file mode 100644 index 000000000..f1bb34d4c --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/OntologyGraph.tsx @@ -0,0 +1,75 @@ +import { Resource, useStore } from '@tomic/react'; +import React, { useCallback } from 'react'; +import ReactFlow, { + Controls, + useReactFlow, + Node, + ReactFlowProvider, +} from 'reactflow'; +import 'reactflow/dist/style.css'; +import './reactFlowOverrides.css'; +import { buildGraph } from './buildGraph'; +import { FloatingEdge } from './FloatingEdge'; +import { useGraph } from './useGraph'; +import { useEffectOnce } from '../../hooks/useEffectOnce'; +import { toAnchorId } from '../../views/OntologyPage/toAnchorId'; + +const edgeTypes = { + floating: FloatingEdge, +}; + +interface OntologyGraphProps { + ontology: Resource; +} + +/** + * !ASYNC COMPONENT, DO NOT IMPORT DIRECTLY! + * Displays an ontology as a graph. + */ +export default function OntologyGraph({ + ...props +}: OntologyGraphProps): JSX.Element { + return ( + <ReactFlowProvider> + <OntologyGraphInner {...props} /> + </ReactFlowProvider> + ); +} + +function OntologyGraphInner({ ontology }: OntologyGraphProps): JSX.Element { + const store = useStore(); + const { fitView } = useReactFlow(); + + const { nodes, edges, setGraph, handleNodeChange, handleNodeDoubleClick } = + useGraph(ontology); + + useEffectOnce(() => { + buildGraph(ontology, store).then(([n, e]) => { + setGraph(n, e); + + requestAnimationFrame(() => { + fitView(); + }); + }); + }); + + const handleClick = useCallback((_: React.MouseEvent, node: Node) => { + const domId = toAnchorId(node.id); + + document.getElementById(domId)?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + return ( + <ReactFlow + fitView + nodes={nodes} + edges={edges} + edgeTypes={edgeTypes} + onNodesChange={handleNodeChange} + onNodeClick={handleClick} + onNodeDoubleClick={handleNodeDoubleClick} + > + <Controls position='top-left' showInteractive={false} /> + </ReactFlow> + ); +} diff --git a/browser/data-browser/src/chunks/GraphViewer/buildGraph.ts b/browser/data-browser/src/chunks/GraphViewer/buildGraph.ts new file mode 100644 index 000000000..1bc22e994 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/buildGraph.ts @@ -0,0 +1,188 @@ +import { Datatype, Resource, Store, urls } from '@tomic/react'; +import { Node, Edge, MarkerType } from 'reactflow'; +import { randomString } from '../../helpers/randomString'; +import { DefaultTheme } from 'styled-components'; + +const RELEVANT_DATATYPES = [Datatype.ATOMIC_URL, Datatype.RESOURCEARRAY]; + +export interface NodeData { + label: string; + external: boolean; +} + +export enum OverlapIndex { + First, + Second, +} + +export interface EdgeData { + required: boolean; + overlapping: boolean; +} + +interface Routing { + source: string; + target: string; +} + +const label = (text: string, required: boolean): string => + `${required ? '*' : ''}${text}`; + +const newEdge = ( + routing: Routing, + name: string, + required: boolean, + overlapping: boolean, +): Edge<EdgeData> => ({ + ...routing, + id: randomString(), + label: label(name, required), + markerEnd: { + type: MarkerType.ArrowClosed, + width: 15, + height: 15, + }, + type: 'floating', + data: { required, overlapping }, +}); + +const findEdgeWithSameRouting = (edges: Edge[], routing: Routing): number => + edges.findIndex( + edge => edge.source === routing.source && edge.target === routing.target, + ); + +const findAndTagOverlappingEdges = ( + edges: Edge[], + routing: Routing, +): boolean => { + const index = edges.findIndex( + edge => edge.target === routing.source && edge.source === routing.target, + ); + + if (index !== -1) { + edges[index] = { + ...edges[index], + data: { + ...edges[index].data, + overlapping: true, + }, + }; + } + + return index !== -1; +}; + +const mergeEdges = ( + existingEdge: Edge<EdgeData>, + name: string, + isRequired: boolean, +): Edge<EdgeData> => ({ + ...existingEdge, + data: { + required: isRequired || (existingEdge.data?.required ?? false), + overlapping: existingEdge.data?.overlapping ?? false, + }, + label: `${existingEdge.label},\n${label(name, isRequired)}`, +}); + +export async function buildGraph( + ontology: Resource, + store: Store, +): Promise<[Node<NodeData>[], Edge<EdgeData>[]]> { + const classes = ontology.get(urls.properties.classes) as string[]; + // Any classes that are not in the ontology but are referenced by classes that are in the ontology. + const externalClasses: Set<string> = new Set(); + + const nodes: Node[] = []; + const edges: Edge[] = []; + + const classToNode = async ( + classSubject: string, + isExtra = false, + ): Promise<Node<NodeData>> => { + const res = await store.getResourceAsync(classSubject); + + if (!isExtra) { + await createEdges(res); + } + + return { + id: classSubject, + position: { x: 0, y: 100 }, + width: 100, + height: 100, + data: { label: res.title, external: isExtra }, + }; + }; + + const createEdges = async (classResource: Resource) => { + const recommends = (classResource.get(urls.properties.recommends) ?? + []) as string[]; + const requires = (classResource.get(urls.properties.requires) ?? + []) as string[]; + + for (const subject of [...recommends, ...requires]) { + const property = await store.getProperty(subject); + + const isRequired = requires.includes(subject); + + if ( + RELEVANT_DATATYPES.includes(property.datatype) && + property.classType + ) { + const routing = { + source: classResource.getSubject(), + target: property.classType, + }; + + const existingEdgeIndex = findEdgeWithSameRouting(edges, routing); + + if (existingEdgeIndex === -1) { + const isOverlapping = findAndTagOverlappingEdges(edges, routing); + + edges.push( + newEdge(routing, property.shortname, isRequired, isOverlapping), + ); + + if (!classes.includes(property.classType)) { + externalClasses.add(property.classType); + } + + continue; + } + + edges[existingEdgeIndex] = mergeEdges( + edges[existingEdgeIndex], + property.shortname, + isRequired, + ); + } + } + }; + + for (const item of classes) { + nodes.push(await classToNode(item)); + } + + for (const extra of externalClasses) { + nodes.push(await classToNode(extra, true)); + } + + return [nodes, edges]; +} + +export function applyNodeStyling( + nodes: Node<NodeData>[], + theme: DefaultTheme, +): Node<NodeData>[] { + return nodes.map(node => ({ + ...node, + style: { + ...node.style, + backgroundColor: theme.colors.bg, + borderColor: theme.colors.bg2, + color: theme.colors.text, + borderStyle: node.data.external ? 'dashed' : 'solid', + }, + })); +} diff --git a/browser/data-browser/src/chunks/GraphViewer/getEdgeParams.ts b/browser/data-browser/src/chunks/GraphViewer/getEdgeParams.ts new file mode 100644 index 000000000..86f81eb79 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/getEdgeParams.ts @@ -0,0 +1,132 @@ +import { Position, Node } from 'reactflow'; + +// this helper function returns the intersection point +// of the line between the center of the intersectionNode and the target node +function getNodeIntersection(intersectionNode: Node, targetNode: Node) { + // https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a + const { + width: intersectionNodeWidth, + height: intersectionNodeHeight, + positionAbsolute: intersectionNodePosition, + } = intersectionNode; + const targetPosition = targetNode.positionAbsolute; + + const w = intersectionNodeWidth! / 2; + const h = intersectionNodeHeight! / 2; + + const x2 = intersectionNodePosition!.x + w; + const y2 = intersectionNodePosition!.y + h; + const x1 = targetPosition!.x + w; + const y1 = targetPosition!.y + h; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + const x = w * (xx3 + yy3) + x2; + const y = h * (-xx3 + yy3) + y2; + + return { x, y }; +} + +// returns the position (top,right,bottom or right) passed node compared to the intersection point +function getEdgePosition( + node: Node, + intersectionPoint: { x: number; y: number }, +) { + const n = { ...node.positionAbsolute, ...node }; + const nx = Math.round(n.x!); + const ny = Math.round(n.y!); + const px = Math.round(intersectionPoint.x); + const py = Math.round(intersectionPoint.y); + + if (px <= nx + 1) { + return Position.Left; + } + + if (px >= nx + n.width! - 1) { + return Position.Right; + } + + if (py <= ny + 1) { + return Position.Top; + } + + if (py >= n.y! + n.height! - 1) { + return Position.Bottom; + } + + return Position.Top; +} + +export function getEdgeParams( + source: Node, + target: Node, + overlapping: boolean, +) { + const sourceIntersectionPoint = getNodeIntersection(source, target); + const targetIntersectionPoint = getNodeIntersection(target, source); + + const sourcePos = getEdgePosition(source, sourceIntersectionPoint); + const targetPos = getEdgePosition(target, targetIntersectionPoint); + + let sx = sourceIntersectionPoint.x; + + if (overlapping) { + const center = source.positionAbsolute!.x! + source.width! / 2; + const diff = Math.abs(sx - center); + + if (sx < center) { + sx = center + diff; + } else { + sx = center - diff; + } + } + + return { + sx: sx, + sy: sourceIntersectionPoint.y, + tx: targetIntersectionPoint.x, + ty: targetIntersectionPoint.y, + sourcePos, + targetPos, + }; +} + +export function getSelfReferencePath( + node: Node, +): [path: string, labelX: number, labelY: number] { + const { positionAbsolute, width, height } = node; + + const { x, y } = positionAbsolute!; + const HORIZONTAL_START_OFFSET = 20; + const HORIZONTAL_OFFSET = 50; + const VERTICAL_OFFSET = 15; + const BORDER_RADIUS = 10; + + const start = { x: x! + width! - HORIZONTAL_START_OFFSET, y: y! + height! }; + + const path = [ + `M ${start.x}, ${start.y}`, + line(0, VERTICAL_OFFSET - BORDER_RADIUS), + arc(BORDER_RADIUS, BORDER_RADIUS), + line(HORIZONTAL_OFFSET - BORDER_RADIUS, 0), + arc(BORDER_RADIUS, -BORDER_RADIUS), + line(0, (height! + (VERTICAL_OFFSET - BORDER_RADIUS) * 2) * -1), + arc(-BORDER_RADIUS, -BORDER_RADIUS), + line((HORIZONTAL_OFFSET - BORDER_RADIUS) * -1, 0), + arc(-BORDER_RADIUS, BORDER_RADIUS), + line(0, VERTICAL_OFFSET - BORDER_RADIUS), + ].join(', '); + + const labelX = x + width! + HORIZONTAL_OFFSET - HORIZONTAL_START_OFFSET / 2; + const labelY = y + height! / 2; + + return [path, labelX, labelY]; +} + +const line = (x: number, y: number) => `l ${x} ${y}`; + +const arc = (x: number, y: number, sweep = false) => + `a ${x} ${x} 0 0 ${sweep ? 1 : 0} ${x} ${y}`; diff --git a/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css b/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css new file mode 100644 index 000000000..e0f327673 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css @@ -0,0 +1,5 @@ +.react-flow__handle { + background-color: transparent; + border: none; + cursor: grab; +} diff --git a/browser/data-browser/src/chunks/GraphViewer/useGraph.ts b/browser/data-browser/src/chunks/GraphViewer/useGraph.ts new file mode 100644 index 000000000..27d3535b2 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/useGraph.ts @@ -0,0 +1,156 @@ +import { + Edge, + Node, + NodeChange, + NodePositionChange, + applyNodeChanges, +} from 'reactflow'; +import { EdgeData, NodeData, applyNodeStyling } from './buildGraph'; +import { useCallback, useMemo, useState } from 'react'; +import Dagre from '@dagrejs/dagre'; +import { useTheme } from 'styled-components'; +import { Resource, urls, useString } from '@tomic/react'; + +interface CustomNodePositioning { + [key: string]: [x: number, y: number]; +} + +type UseNodeReturn = { + nodes: Node<NodeData>[]; + edges: Edge<EdgeData>[]; + setGraph: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void; + handleNodeChange: (changes: NodeChange[]) => void; + handleNodeDoubleClick: (event: React.MouseEvent, node: Node) => void; +}; + +const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + +const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { + g.setGraph({ rankdir: 'vertical', ranksep: 70 }); + + edges.forEach(edge => g.setEdge(edge.source, edge.target)); + nodes.forEach(node => + g.setNode(node.id, { label: node, width: 120, height: 100 }), + ); + + Dagre.layout(g); + + return { + positionedNodes: nodes.map(node => { + const { x, y } = g.node(node.id); + + return { ...node, position: { x, y } }; + }), + positionedEdges: edges, + }; +}; + +const placeNodesInSpace = ( + nodes: Node[], + edges: Edge[], + customPositioning: CustomNodePositioning, +): [nodes: Node[], edges: Edge[]] => { + const { positionedNodes, positionedEdges } = getLayoutedElements( + nodes, + edges, + ); + + const ajustedNodes = positionedNodes.map(node => { + if (customPositioning[node.id]) { + const [x, y] = customPositioning[node.id]; + + return { ...node, position: { x, y }, positionAbsolute: { x, y } }; + } + + return node; + }); + + return [ajustedNodes, positionedEdges]; +}; + +export function useGraph(ontology: Resource): UseNodeReturn { + const theme = useTheme(); + + const [customPositioningSTR, setCustomPositioningSTR] = useString( + ontology, + urls.properties.ontology.customNodePositioning, + { commit: true }, + ); + + const customPositioning = useMemo( + () => JSON.parse(customPositioningSTR || '{}'), + [customPositioningSTR], + ); + + const [nodes, setNodes] = useState<Node<NodeData>[]>([]); + const [edges, setEdges] = useState<Edge<EdgeData>[]>([]); + const [lastPositionChange, setLastPositionChange] = + useState<NodePositionChange>(); + + const setGraph = useCallback( + (_nodes: Node<NodeData>[], _edges: Edge<EdgeData>[]) => { + const [positionedNodes, positionedEdges] = placeNodesInSpace( + _nodes, + _edges, + customPositioning, + ); + setNodes(applyNodeStyling(positionedNodes, theme)); + setEdges(positionedEdges); + }, + [theme, customPositioning], + ); + + const handleNodeDoubleClick = useCallback( + async (_e: React.MouseEvent, node: Node) => { + const newCustomPositioning = { + ...customPositioning, + }; + + delete newCustomPositioning[node.id]; + + await setCustomPositioningSTR(JSON.stringify(newCustomPositioning)); + + const [positionedNodes] = placeNodesInSpace( + nodes, + edges, + newCustomPositioning, + ); + + setNodes(positionedNodes); + }, + [customPositioning, nodes, edges], + ); + + const handleNodeChange = useCallback( + (changes: NodeChange[]) => { + const change = changes[0]; + + if (change.type === 'position') { + if (change.dragging) { + setLastPositionChange(change); + } else { + setCustomPositioningSTR( + JSON.stringify({ + ...customPositioning, + [change.id]: [ + lastPositionChange!.positionAbsolute?.x, + lastPositionChange!.positionAbsolute?.y, + ], + }), + ); + } + } + + setNodes(prev => applyNodeChanges(changes, prev)); + }, + [customPositioning, lastPositionChange], + ); + + return { + nodes, + edges, + setGraph, + handleNodeChange, + handleNodeDoubleClick, + }; +} diff --git a/browser/data-browser/src/views/OntologyPage/Graph.tsx b/browser/data-browser/src/views/OntologyPage/Graph.tsx new file mode 100644 index 000000000..edfda9893 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Graph.tsx @@ -0,0 +1,33 @@ +import { Resource } from '@tomic/react'; +import React, { Suspense } from 'react'; +import styled from 'styled-components'; + +const OntologyGraph = React.lazy( + () => import('../../chunks/GraphViewer/OntologyGraph'), +); + +interface GraphProps { + ontology: Resource; +} + +export function Graph({ ontology }: GraphProps): JSX.Element { + return ( + <GraphWrapper> + <Suspense fallback='loading...'> + <OntologyGraph ontology={ontology} /> + </Suspense> + </GraphWrapper> + ); +} + +const GraphWrapper = styled.div` + position: var(--ontology-graph-position); + display: grid; + place-items: center; + background-color: ${p => p.theme.colors.bg1}; + border: 1px solid ${p => p.theme.colors.bg2}; + aspect-ratio: var(--ontology-graph-ratio); + border-radius: ${p => p.theme.radius}; + top: 1rem; + overflow: hidden; +`; diff --git a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx index b5d172527..51be7e0f9 100644 --- a/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx +++ b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx @@ -15,6 +15,7 @@ import { NewClassButton } from './NewClassButton'; import { toAnchorId } from './toAnchorId'; import { OntologyContextProvider } from './OntologyContext'; import { PropertyCardWrite } from './Property/PropertyCardWrite'; +import { Graph } from './Graph'; export function OntologyPage({ resource }: ResourcePageProps) { const [classes] = useArray(resource, urls.properties.classes); @@ -91,7 +92,7 @@ export function OntologyPage({ resource }: ResourcePageProps) { </ListSlot> {!editMode && ( <GraphSlot> - <TempGraph>Placeholder</TempGraph> + <Graph ontology={resource} /> </GraphSlot> )} </FullPageWrapper> @@ -99,19 +100,10 @@ export function OntologyPage({ resource }: ResourcePageProps) { ); } -const TempGraph = styled.div` - position: sticky; - display: grid; - place-items: center; - background-color: ${p => p.theme.colors.bg1}; - border: 1px solid ${p => p.theme.colors.bg2}; - aspect-ratio: 9 / 16; - border-radius: ${p => p.theme.radius}; - top: 1rem; - overflow: hidden; -`; - const FullPageWrapper = styled.div<{ edit: boolean }>` + --ontology-graph-position: sticky; + --ontology-graph-ratio: 9 / 16; + display: grid; grid-template-areas: ${p => p.edit @@ -130,11 +122,8 @@ const FullPageWrapper = styled.div<{ edit: boolean }>` grid-template-columns: 1fr 5fr; grid-template-rows: 4rem auto auto; - - ${TempGraph} { - position: static; - aspect-ratio: 16/9; - } + --ontology-graph-position: sticky; + --ontology-graph-ratio: 16/9; } `; diff --git a/browser/lib/src/urls.ts b/browser/lib/src/urls.ts index 4b3d58bf4..98f25ac8d 100644 --- a/browser/lib/src/urls.ts +++ b/browser/lib/src/urls.ts @@ -142,6 +142,10 @@ export const properties = { table: { tableColumnWidths: 'https://atomicdata.dev/properties/tableColumnWidths', }, + ontology: { + customNodePositioning: + 'https://atomicdata.dev/properties/custom-node-positioning', + }, color: 'https://atomicdata.dev/properties/color', emoji: 'https://atomicdata.dev/properties/emoji', classes: 'https://atomicdata.dev/properties/classes', diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 50cccecb3..6cabf0cce 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@bugsnag/plugin-react': specifier: ^7.16.5 version: 7.19.0(@bugsnag/core@7.19.0) + '@dagrejs/dagre': + specifier: ^1.0.2 + version: 1.0.2 '@dnd-kit/core': specifier: ^6.0.5 version: 6.0.8(react-dom@18.2.0)(react@18.2.0) @@ -189,6 +192,9 @@ importers: react-window: specifier: ^1.8.7 version: 1.8.9(react-dom@18.2.0)(react@18.2.0) + reactflow: + specifier: ^11.8.3 + version: 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) remark-gfm: specifier: ^3.0.1 version: 3.0.1 @@ -234,7 +240,7 @@ importers: version: 1.1.0 vite: specifier: ^4.0.4 - version: 4.4.8(@types/node@16.18.39) + version: 4.4.8 vite-plugin-pwa: specifier: ^0.14.1 version: 0.14.7(vite@4.4.8)(workbox-build@6.6.0)(workbox-window@6.6.0) @@ -1656,6 +1662,17 @@ packages: engines: {node: '>=10'} dev: false + /@dagrejs/dagre@1.0.2: + resolution: {integrity: sha512-7N7vEZDlcU4uRHWuL/9RyI8IgM/d4ULR7z2exJALshh7BHF3tFjYL2pW6bQ4mmlDzd2Tr49KJMIY87Be1L6J0w==} + dependencies: + '@dagrejs/graphlib': 2.1.13 + dev: false + + /@dagrejs/graphlib@2.1.13: + resolution: {integrity: sha512-calbMa7Gcyo+/t23XBaqQqon8LlgE9regey4UVoikoenKBXvUnCUL3s9RP6USCxttfr0XWVICtYUuKMdehKqMw==} + engines: {node: '>17.0.0'} + dev: false + /@dnd-kit/accessibility@3.0.1(react@18.2.0): resolution: {integrity: sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==} peerDependencies: @@ -2758,6 +2775,114 @@ packages: '@babel/runtime': 7.22.6 dev: false + /@reactflow/background@11.2.8(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5o41N2LygiNC2/Pk8Ak2rIJjXbKHfQ23/Y9LFsnAlufqwdzFqKA8txExpsMoPVHHlbAdA/xpQaMuoChGPqmyDw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + classcat: 5.0.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/controls@11.1.19(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Vo0LFfAYjiSRMLEII/aeBo+1MT2a0Yc7iLVnkuRTLzChC0EX+A2Fa+JlzeOEYKxXlN4qcDxckRNGR7092v1HOQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + classcat: 5.0.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/core@11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-y6DN8Wy4V4KQBGHFqlj9zWRjLJU6CgdnVwWaEA/PdDg/YUkFBMpZnXqTs60czinoA2rAcvsz50syLTPsj5e+Wg==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@types/d3': 7.4.0 + '@types/d3-drag': 3.0.3 + '@types/d3-selection': 3.0.6 + '@types/d3-zoom': 3.0.4 + classcat: 5.0.4 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/minimap@11.6.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-PSA28dk09RnBHOA1zb45fjQXz3UozSJZmsIpgq49O3trfVFlSgRapxNdGsughWLs7/emg2M5jmi6Vc+ejcfjvQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@types/d3-selection': 3.0.6 + '@types/d3-zoom': 3.0.4 + classcat: 5.0.4 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/node-resizer@2.1.5(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-z/hJlsptd2vTx13wKouqvN/Kln08qbkA+YTJLohc2aJ6rx3oGn9yX4E4IqNxhA7zNqYEdrnc1JTEA//ifh9z3w==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + classcat: 5.0.4 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/node-toolbar@1.2.7(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-vs+Wg1tjy3SuD7eoeTqEtscBfE9RY+APqC28urVvftkrtsN7KlnoQjqDG6aE45jWP4z+8bvFizRWjAhxysNLkg==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + classcat: 5.0.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + zustand: 4.4.1(@types/react@18.2.18)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + /@remix-run/router@1.7.2: resolution: {integrity: sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==} engines: {node: '>=14'} @@ -2924,6 +3049,185 @@ packages: resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} dev: true + /@types/d3-array@3.0.7: + resolution: {integrity: sha512-4/Q0FckQ8TBjsB0VdGFemJOG8BLXUB2KKlL0VmZ+eOYeOnTb/wDRQqYWpBmQ6IlvWkXwkYiot+n9Px2aTJ7zGQ==} + dev: false + + /@types/d3-axis@3.0.3: + resolution: {integrity: sha512-SE3x/pLO/+GIHH17mvs1uUVPkZ3bHquGzvZpPAh4yadRy71J93MJBpgK/xY8l9gT28yTN1g9v3HfGSFeBMmwZw==} + dependencies: + '@types/d3-selection': 3.0.6 + dev: false + + /@types/d3-brush@3.0.3: + resolution: {integrity: sha512-MQ1/M/B5ifTScHSe5koNkhxn2mhUPqXjGuKjjVYckplAPjP9t2I2sZafb/YVHDwhoXWZoSav+Q726eIbN3qprA==} + dependencies: + '@types/d3-selection': 3.0.6 + dev: false + + /@types/d3-chord@3.0.3: + resolution: {integrity: sha512-keuSRwO02c7PBV3JMWuctIfdeJrVFI7RpzouehvBWL4/GGUB3PBNg/9ZKPZAgJphzmS2v2+7vr7BGDQw1CAulw==} + dev: false + + /@types/d3-color@3.1.0: + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + dev: false + + /@types/d3-contour@3.0.3: + resolution: {integrity: sha512-x7G/tdDZt4m09XZnG2SutbIuQqmkNYqR9uhDMdPlpJbcwepkEjEWG29euFcgVA1k6cn92CHdDL9Z+fOnxnbVQw==} + dependencies: + '@types/d3-array': 3.0.7 + '@types/geojson': 7946.0.10 + dev: false + + /@types/d3-delaunay@6.0.1: + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + dev: false + + /@types/d3-dispatch@3.0.3: + resolution: {integrity: sha512-Df7KW3Re7G6cIpIhQtqHin8yUxUHYAqiE41ffopbmU5+FifYUNV7RVyTg8rQdkEagg83m14QtS8InvNb95Zqug==} + dev: false + + /@types/d3-drag@3.0.3: + resolution: {integrity: sha512-82AuQMpBQjuXeIX4tjCYfWjpm3g7aGCfx6dFlxX2JlRaiME/QWcHzBsINl7gbHCODA2anPYlL31/Trj/UnjK9A==} + dependencies: + '@types/d3-selection': 3.0.6 + dev: false + + /@types/d3-dsv@3.0.2: + resolution: {integrity: sha512-DooW5AOkj4AGmseVvbwHvwM/Ltu0Ks0WrhG6r5FG9riHT5oUUTHz6xHsHqJSVU8ZmPkOqlUEY2obS5C9oCIi2g==} + dev: false + + /@types/d3-ease@3.0.0: + resolution: {integrity: sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA==} + dev: false + + /@types/d3-fetch@3.0.3: + resolution: {integrity: sha512-/EsDKRiQkby3Z/8/AiZq8bsuLDo/tYHnNIZkUpSeEHWV7fHUl6QFBjvMPbhkKGk9jZutzfOkGygCV7eR/MkcXA==} + dependencies: + '@types/d3-dsv': 3.0.2 + dev: false + + /@types/d3-force@3.0.5: + resolution: {integrity: sha512-EGG+IWx93ESSXBwfh/5uPuR9Hp8M6o6qEGU7bBQslxCvrdUBQZha/EFpu/VMdLU4B0y4Oe4h175nSm7p9uqFug==} + dev: false + + /@types/d3-format@3.0.1: + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + dev: false + + /@types/d3-geo@3.0.4: + resolution: {integrity: sha512-kmUK8rVVIBPKJ1/v36bk2aSgwRj2N/ZkjDT+FkMT5pgedZoPlyhaG62J+9EgNIgUXE6IIL0b7bkLxCzhE6U4VQ==} + dependencies: + '@types/geojson': 7946.0.10 + dev: false + + /@types/d3-hierarchy@3.1.3: + resolution: {integrity: sha512-GpSK308Xj+HeLvogfEc7QsCOcIxkDwLhFYnOoohosEzOqv7/agxwvJER1v/kTC+CY1nfazR0F7gnHo7GE41/fw==} + dev: false + + /@types/d3-interpolate@3.0.1: + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + dependencies: + '@types/d3-color': 3.1.0 + dev: false + + /@types/d3-path@3.0.0: + resolution: {integrity: sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==} + dev: false + + /@types/d3-polygon@3.0.0: + resolution: {integrity: sha512-D49z4DyzTKXM0sGKVqiTDTYr+DHg/uxsiWDAkNrwXYuiZVd9o9wXZIo+YsHkifOiyBkmSWlEngHCQme54/hnHw==} + dev: false + + /@types/d3-quadtree@3.0.2: + resolution: {integrity: sha512-QNcK8Jguvc8lU+4OfeNx+qnVy7c0VrDJ+CCVFS9srBo2GL9Y18CnIxBdTF3v38flrGy5s1YggcoAiu6s4fLQIw==} + dev: false + + /@types/d3-random@3.0.1: + resolution: {integrity: sha512-IIE6YTekGczpLYo/HehAy3JGF1ty7+usI97LqraNa8IiDur+L44d0VOjAvFQWJVdZOJHukUJw+ZdZBlgeUsHOQ==} + dev: false + + /@types/d3-scale-chromatic@3.0.0: + resolution: {integrity: sha512-dsoJGEIShosKVRBZB0Vo3C8nqSDqVGujJU6tPznsBJxNJNwMF8utmS83nvCBKQYPpjCzaaHcrf66iTRpZosLPw==} + dev: false + + /@types/d3-scale@4.0.4: + resolution: {integrity: sha512-eq1ZeTj0yr72L8MQk6N6heP603ubnywSDRfNpi5enouR112HzGLS6RIvExCzZTraFF4HdzNpJMwA/zGiMoHUUw==} + dependencies: + '@types/d3-time': 3.0.0 + dev: false + + /@types/d3-selection@3.0.6: + resolution: {integrity: sha512-2ACr96USZVjXR9KMD9IWi1Epo4rSDKnUtYn6q2SPhYxykvXTw9vR77lkFNruXVg4i1tzQtBxeDMx0oNvJWbF1w==} + dev: false + + /@types/d3-shape@3.1.2: + resolution: {integrity: sha512-NN4CXr3qeOUNyK5WasVUV8NCSAx/CRVcwcb0BuuS1PiTqwIm6ABi1SyasLZ/vsVCFDArF+W4QiGzSry1eKYQ7w==} + dependencies: + '@types/d3-path': 3.0.0 + dev: false + + /@types/d3-time-format@4.0.0: + resolution: {integrity: sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==} + dev: false + + /@types/d3-time@3.0.0: + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + dev: false + + /@types/d3-timer@3.0.0: + resolution: {integrity: sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==} + dev: false + + /@types/d3-transition@3.0.4: + resolution: {integrity: sha512-512a4uCOjUzsebydItSXsHrPeQblCVk8IKjqCUmrlvBWkkVh3donTTxmURDo1YPwIVDh5YVwCAO6gR4sgimCPQ==} + dependencies: + '@types/d3-selection': 3.0.6 + dev: false + + /@types/d3-zoom@3.0.4: + resolution: {integrity: sha512-cqkuY1ah9ZQre2POqjSLcM8g40UVya/qwEUrNYP2/rCVljbmqKCVcv+ebvwhlI5azIbSEL7m+os6n+WlYA43aA==} + dependencies: + '@types/d3-interpolate': 3.0.1 + '@types/d3-selection': 3.0.6 + dev: false + + /@types/d3@7.4.0: + resolution: {integrity: sha512-jIfNVK0ZlxcuRDKtRS/SypEyOQ6UHaFQBKv032X45VvxSJ6Yi5G9behy9h6tNTHTDGh5Vq+KbmBjUWLgY4meCA==} + dependencies: + '@types/d3-array': 3.0.7 + '@types/d3-axis': 3.0.3 + '@types/d3-brush': 3.0.3 + '@types/d3-chord': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-contour': 3.0.3 + '@types/d3-delaunay': 6.0.1 + '@types/d3-dispatch': 3.0.3 + '@types/d3-drag': 3.0.3 + '@types/d3-dsv': 3.0.2 + '@types/d3-ease': 3.0.0 + '@types/d3-fetch': 3.0.3 + '@types/d3-force': 3.0.5 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.0.4 + '@types/d3-hierarchy': 3.1.3 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.0.0 + '@types/d3-polygon': 3.0.0 + '@types/d3-quadtree': 3.0.2 + '@types/d3-random': 3.0.1 + '@types/d3-scale': 4.0.4 + '@types/d3-scale-chromatic': 3.0.0 + '@types/d3-selection': 3.0.6 + '@types/d3-shape': 3.1.2 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 4.0.0 + '@types/d3-timer': 3.0.0 + '@types/d3-transition': 3.0.4 + '@types/d3-zoom': 3.0.4 + dev: false + /@types/debug@4.1.8: resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} dependencies: @@ -2944,6 +3248,10 @@ packages: fast-json-stable-stringify: 2.1.0 dev: true + /@types/geojson@7946.0.10: + resolution: {integrity: sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==} + dev: false + /@types/graceful-fs@4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: @@ -3860,6 +4168,10 @@ packages: resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} dev: true + /classcat@5.0.4: + resolution: {integrity: sha512-sbpkOw6z413p+HDGcBENe498WM9woqWHiJxCq7nvmxe9WmrUmqfAcxpIwAiMtM5Q3AhYkzXcNQHqsWq0mND51g==} + dev: false + /classnames@2.3.2: resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} dev: false @@ -4081,6 +4393,71 @@ packages: /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + + /d3-transition@3.0.1(d3-selection@3.0.0): + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + dev: false + + /d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + dev: false + /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: true @@ -8017,6 +8394,25 @@ packages: dependencies: loose-envify: 1.4.0 + /reactflow@11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-wuVxJOFqi1vhA4WAEJLK0JWx2TsTiWpxTXTRp/wvpqKInQgQcB49I2QNyNYsKJCQ6jjXektS7H+LXoaVK/pG4A==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/background': 11.2.8(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/controls': 11.1.19(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/core': 11.8.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/minimap': 11.6.3(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-resizer': 2.1.5(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + '@reactflow/node-toolbar': 1.2.7(@types/react@18.2.18)(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -9198,6 +9594,14 @@ packages: tslib: 2.6.1 dev: false + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -9256,7 +9660,7 @@ packages: fast-glob: 3.3.1 pretty-bytes: 6.1.1 rollup: 3.27.2 - vite: 4.4.8(@types/node@16.18.39) + vite: 4.4.8 workbox-build: 6.6.0 workbox-window: 6.6.0 transitivePeerDependencies: @@ -9297,7 +9701,7 @@ packages: fsevents: 2.3.2 dev: true - /vite@4.4.8(@types/node@16.18.39): + /vite@4.4.8: resolution: {integrity: sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -9325,7 +9729,6 @@ packages: terser: optional: true dependencies: - '@types/node': 16.18.39 esbuild: 0.18.17 postcss: 8.4.27 rollup: 3.27.2 @@ -9727,6 +10130,26 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /zustand@4.4.1(@types/react@18.2.18)(react@18.2.0): + resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.18 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false From fe16f1d1ba0157f5796e24dc98ba5b404b1a7820 Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Wed, 6 Sep 2023 14:43:00 +0200 Subject: [PATCH 09/13] Sidebar facelift --- .../src/components/IconButton/IconButton.tsx | 29 +++++- .../src/components/SideBar/About.tsx | 8 +- .../src/components/SideBar/AppMenu.tsx | 90 +++++++++++-------- .../components/SideBar/SideBarMenuItem.tsx | 6 +- .../src/components/SideBar/index.tsx | 14 +-- .../src/components/SideBar/menuItems.tsx | 31 ------- 6 files changed, 97 insertions(+), 81 deletions(-) delete mode 100644 browser/data-browser/src/components/SideBar/menuItems.tsx diff --git a/browser/data-browser/src/components/IconButton/IconButton.tsx b/browser/data-browser/src/components/IconButton/IconButton.tsx index a470b4bfb..588ef66de 100644 --- a/browser/data-browser/src/components/IconButton/IconButton.tsx +++ b/browser/data-browser/src/components/IconButton/IconButton.tsx @@ -7,6 +7,7 @@ export enum IconButtonVariant { Outline, Fill, Colored, + Square, } type ColorProp = keyof DefaultTheme['colors'] | 'inherit'; @@ -56,7 +57,7 @@ export const IconButtonLink = React.forwardRef< HTMLAnchorElement, React.PropsWithChildren<IconButtonLinkProps> >(({ variant, children, color, ...props }, ref) => { - const Comp = ComponentMap.get(variant!) ?? SimpleIconButton; + const Comp = ComponentMap.get(variant ?? IconButtonVariant.Simple)!; return ( <Comp ref={ref} color={color!} as='a' {...props}> @@ -84,8 +85,8 @@ const IconButtonBase = styled.button<ButtonBaseProps>` border: none; padding: var(--button-padding); - width: calc(1em + var(--button-padding) * 2); - height: calc(1em + var(--button-padding) * 2); + width: calc(${p => p.size} + var(--button-padding) * 2); + height: calc(${p => p.size} + var(--button-padding) * 2); &[disabled] { opacity: 0.5; @@ -134,6 +135,27 @@ const OutlineIconButton = styled(IconButtonBase)<ButtonStyleProps>` } `; +const SquareIconButton = styled(IconButtonBase)<ButtonStyleProps>` + color: ${p => (p.color === 'inherit' ? 'inherit' : p.theme.colors[p.color])}; + background-color: ${p => p.theme.colors.bg}; + border-radius: ${p => p.theme.radius}; + border: 1px solid ${p => p.theme.colors.bg2}; + + &:not([disabled]) { + &:hover, + &:focus-visible { + color: ${p => p.theme.colors.main}; + border-color: ${p => p.theme.colors.main}; + box-shadow: ${p => p.theme.boxShadowSoft}; + } + } + + &&:active { + background-color: ${p => p.theme.colors.main}; + color: white; + } +`; + const FillIconButton = styled(IconButtonBase)<ButtonStyleProps>` color: ${p => (p.color === 'inherit' ? 'inherit' : p.theme.colors[p.color])}; background-color: unset; @@ -164,4 +186,5 @@ const ComponentMap = new Map([ [IconButtonVariant.Outline, OutlineIconButton], [IconButtonVariant.Fill, FillIconButton], [IconButtonVariant.Colored, ColoredIconButton], + [IconButtonVariant.Square, SquareIconButton], ]); diff --git a/browser/data-browser/src/components/SideBar/About.tsx b/browser/data-browser/src/components/SideBar/About.tsx index c12e6bd93..dbe328ed2 100644 --- a/browser/data-browser/src/components/SideBar/About.tsx +++ b/browser/data-browser/src/components/SideBar/About.tsx @@ -3,7 +3,7 @@ import { Logo } from '../Logo'; import { SideBarHeader } from './SideBarHeader'; import React from 'react'; import { FaGithub, FaDiscord, FaBook } from 'react-icons/fa'; -import { IconButtonLink } from '../IconButton/IconButton'; +import { IconButtonLink, IconButtonVariant } from '../IconButton/IconButton'; interface AboutItem { icon: React.ReactNode; @@ -32,9 +32,9 @@ const aboutMenuItems: AboutItem[] = [ export function About() { return ( <> - <SideBarHeader> + {/* <SideBarHeader> <Logo style={{ height: '1.1rem', maxWidth: '100%' }} /> - </SideBarHeader> + </SideBarHeader> */} <AboutWrapper> {aboutMenuItems.map(({ href, icon, helper }) => ( <IconButtonLink @@ -45,6 +45,7 @@ export function About() { title={helper} size='1.2em' color='textLight' + variant={IconButtonVariant.Square} > {icon} </IconButtonLink> @@ -57,7 +58,6 @@ export function About() { const AboutWrapper = styled.div` --inner-padding: 0.5rem; display: flex; - /* flex-direction: column; */ align-items: center; gap: 0.5rem; margin-left: calc(1rem - var(--inner-padding)); diff --git a/browser/data-browser/src/components/SideBar/AppMenu.tsx b/browser/data-browser/src/components/SideBar/AppMenu.tsx index ac74bcddd..f3355d949 100644 --- a/browser/data-browser/src/components/SideBar/AppMenu.tsx +++ b/browser/data-browser/src/components/SideBar/AppMenu.tsx @@ -1,16 +1,17 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { FaPlusCircle } from 'react-icons/fa'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + FaCog, + FaInfo, + FaKeyboard, + FaPlusCircle, + FaUser, +} from 'react-icons/fa'; import { constructOpenURL } from '../../helpers/navigation'; import { useCurrentSubject } from '../../helpers/useCurrentSubject'; -import { appMenuItems } from './menuItems'; -import { SideBarHeader } from './SideBarHeader'; import { SideBarMenuItem } from './SideBarMenuItem'; +import styled from 'styled-components'; +import { paths } from '../../routes/paths'; +import { unknownSubject, useCurrentAgent, useResource } from '@tomic/react'; // Non standard event type so we have to type it ourselfs for now. type BeforeInstallPromptEvent = { @@ -26,6 +27,8 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { const event = useRef<BeforeInstallPromptEvent | null>(null); const [subject] = useCurrentSubject(); const [showInstallButton, setShowInstallButton] = useState(false); + const [agent] = useCurrentAgent(); + const agentResource = useResource(agent?.subject ?? unknownSubject); const install = useCallback(() => { if (!event.current) { @@ -53,33 +56,50 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { return () => window.removeEventListener('beforeinstallprompt', listener); }, []); - const items = useMemo(() => { - if (!showInstallButton) { - return appMenuItems; - } - - return [ - { - icon: <FaPlusCircle />, - label: 'Install App', - helper: 'Install app to desktop', - handleClickItem: install, - path: constructOpenURL(subject ?? window.location.href), - }, - ...appMenuItems, - ]; - }, [appMenuItems, showInstallButton, subject]); - return ( - <> - <SideBarHeader>App</SideBarHeader> - {items.map(p => ( + <Section aria-label='App menu'> + <SideBarMenuItem + icon={<FaUser />} + label={agent ? agentResource.title : 'Login'} + helper='See and edit the current Agent / User (u)' + path={paths.agentSettings} + onClick={onItemClick} + /> + <SideBarMenuItem + icon={<FaCog />} + label='Settings' + helper='Edit the theme (t)' + path={paths.themeSettings} + onClick={onItemClick} + /> + <SideBarMenuItem + icon={<FaKeyboard />} + label='Keyboard Shortcuts' + helper='View the keyboard shortcuts (?)' + path={paths.shortcuts} + onClick={onItemClick} + /> + <SideBarMenuItem + icon={<FaInfo />} + label='About' + helper='Welcome page, tells about this app' + path={paths.about} + onClick={onItemClick} + /> + {showInstallButton && ( <SideBarMenuItem - key={p.label} - {...p} - handleClickItem={p.handleClickItem ?? onItemClick} + icon={<FaPlusCircle />} + label='Install App' + helper='Install app to desktop' + path={constructOpenURL(subject ?? window.location.href)} + onClick={install} /> - ))} - </> + )} + </Section> ); } + +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/SideBarMenuItem.tsx b/browser/data-browser/src/components/SideBar/SideBarMenuItem.tsx index 136af1186..bf229b40e 100644 --- a/browser/data-browser/src/components/SideBar/SideBarMenuItem.tsx +++ b/browser/data-browser/src/components/SideBar/SideBarMenuItem.tsx @@ -9,7 +9,7 @@ export interface SideBarMenuItemProps extends AtomicLinkProps { icon?: React.ReactNode; disabled?: boolean; /** Is called when clicking on the item. Used for closing the menu. */ - handleClickItem?: () => void; + onClick?: () => void; } export function SideBarMenuItem({ @@ -19,11 +19,11 @@ export function SideBarMenuItem({ path, href, subject, - handleClickItem, + onClick, }: SideBarMenuItemProps) { return ( <AtomicLink href={href} subject={subject} path={path} clean> - <SideBarItem key={label} title={helper} onClick={handleClickItem}> + <SideBarItem key={label} title={helper} onClick={onClick}> {icon && <SideBarIcon>{icon}</SideBarIcon>} {label} </SideBarItem> diff --git a/browser/data-browser/src/components/SideBar/index.tsx b/browser/data-browser/src/components/SideBar/index.tsx index ca663d6f4..c0ded8bfd 100644 --- a/browser/data-browser/src/components/SideBar/index.tsx +++ b/browser/data-browser/src/components/SideBar/index.tsx @@ -9,6 +9,7 @@ import { NavBarSpacer } from '../NavBarSpacer'; import { AppMenu } from './AppMenu'; import { About } from './About'; import { useMediaQuery } from '../../hooks/useMediaQuery'; +import { Column } from '../Row'; /** Amount of pixels where the sidebar automatically shows */ export const SIDEBAR_TOGGLE_WIDTH = 600; @@ -47,6 +48,7 @@ export function SideBar(): JSX.Element { return ( <SideBarContainer> + {/* @ts-ignore */} <SideBarStyled ref={mountRefs} size={size} @@ -59,8 +61,10 @@ export function SideBar(): JSX.Element { {/* The key is set to make sure the component is re-loaded when the baseURL changes */} <SideBarDriveMemo handleClickItem={closeSideBar} key={drive} /> <MenuWrapper> - <AppMenu onItemClick={closeSideBar} /> - <About /> + <Column> + <AppMenu onItemClick={closeSideBar} /> + <About /> + </Column> </MenuWrapper> <NavBarSpacer baseMargin='1rem' position='bottom' /> <SideBarDragArea ref={dragAreaRef} isDragging={isDragging} /> @@ -83,12 +87,12 @@ interface SideBarOverlayProps { visible: boolean; } -// eslint-disable-next-line prettier/prettier -const SideBarStyled = styled('nav').attrs<SideBarStyledProps>(p => ({ +//@ts-ignore +const SideBarStyled = styled.nav.attrs<SideBarStyledProps>(p => ({ style: { '--width': p.size, }, -}))<SideBarStyledProps>` +}))` z-index: ${p => p.theme.zIndex.sidebar}; box-sizing: border-box; background: ${p => p.theme.colors.bg}; diff --git a/browser/data-browser/src/components/SideBar/menuItems.tsx b/browser/data-browser/src/components/SideBar/menuItems.tsx deleted file mode 100644 index 83073f54b..000000000 --- a/browser/data-browser/src/components/SideBar/menuItems.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { FaCog, FaInfo, FaKeyboard, FaUser } from 'react-icons/fa'; -import React from 'react'; -import { paths } from '../../routes/paths'; -import { SideBarMenuItemProps } from './SideBarMenuItem'; - -export const appMenuItems: SideBarMenuItemProps[] = [ - { - icon: <FaUser />, - label: 'User Settings', - helper: 'See and edit the current Agent / User (u)', - path: paths.agentSettings, - }, - { - icon: <FaCog />, - label: 'Theme Settings', - helper: 'Edit the theme, current Agent, and more. (t)', - path: paths.themeSettings, - }, - { - icon: <FaKeyboard />, - label: 'Keyboard Shortcuts', - helper: 'View the keyboard shortcuts (?)', - path: paths.shortcuts, - }, - { - icon: <FaInfo />, - label: 'About', - helper: 'Welcome page, tells about this app', - path: paths.about, - }, -]; From 8dc79426aa47ea8c9bcd87a147fbc9d45c8de653 Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Wed, 27 Sep 2023 12:08:58 +0200 Subject: [PATCH 10/13] #665 Type generator + Typed Resources --- browser/.eslintrc.cjs | 3 +- browser/bun.lockb | Bin 465953 -> 0 bytes browser/cli/.gitignore | 1 + browser/cli/package.json | 34 ++++ browser/cli/readme.md | 161 ++++++++++++++++++ browser/cli/src/commands/init.ts | 33 ++++ browser/cli/src/commands/ontologies.ts | 49 ++++++ browser/cli/src/config.ts | 28 +++ browser/cli/src/generateBaseObject.ts | 72 ++++++++ browser/cli/src/generateClassExports.ts | 29 ++++ browser/cli/src/generateClasses.ts | 64 +++++++ browser/cli/src/generateIndex.ts | 49 ++++++ browser/cli/src/generateOntology.ts | 68 ++++++++ browser/cli/src/generatePropTypeMapping.ts | 43 +++++ .../cli/src/generateSubjectToNameMapping.ts | 23 +++ browser/cli/src/index.ts | 26 +++ browser/cli/src/store.ts | 35 ++++ browser/cli/src/usage.ts | 7 + browser/cli/src/utils.ts | 4 + browser/cli/tsconfig.json | 17 ++ browser/lib/package.json | 2 +- browser/lib/src/index.ts | 1 + browser/lib/src/ontology.ts | 77 +++++++++ browser/lib/src/resource.ts | 39 ++++- browser/lib/src/store.ts | 25 +-- browser/package.json | 3 +- browser/pnpm-lock.yaml | 27 ++- browser/react/package.json | 2 +- browser/react/src/hooks.ts | 9 +- browser/react/src/useMemberFromCollection.ts | 11 +- 30 files changed, 910 insertions(+), 32 deletions(-) delete mode 100755 browser/bun.lockb create mode 100644 browser/cli/.gitignore create mode 100644 browser/cli/package.json create mode 100644 browser/cli/readme.md create mode 100644 browser/cli/src/commands/init.ts create mode 100644 browser/cli/src/commands/ontologies.ts create mode 100644 browser/cli/src/config.ts create mode 100644 browser/cli/src/generateBaseObject.ts create mode 100644 browser/cli/src/generateClassExports.ts create mode 100644 browser/cli/src/generateClasses.ts create mode 100644 browser/cli/src/generateIndex.ts create mode 100644 browser/cli/src/generateOntology.ts create mode 100644 browser/cli/src/generatePropTypeMapping.ts create mode 100644 browser/cli/src/generateSubjectToNameMapping.ts create mode 100644 browser/cli/src/index.ts create mode 100644 browser/cli/src/store.ts create mode 100644 browser/cli/src/usage.ts create mode 100644 browser/cli/src/utils.ts create mode 100644 browser/cli/tsconfig.json create mode 100644 browser/lib/src/ontology.ts diff --git a/browser/.eslintrc.cjs b/browser/.eslintrc.cjs index aa270894a..e9382c47f 100644 --- a/browser/.eslintrc.cjs +++ b/browser/.eslintrc.cjs @@ -31,8 +31,9 @@ module.exports = { tsconfigRootDir: __dirname, project: [ 'lib/tsconfig.json', + 'cli/tsconfig.json', 'react/tsconfig.json', - 'data-browser/tsconfig.json', + 'data-browser/tsconfig.json' ], }, plugins: ['react', '@typescript-eslint', 'prettier', 'react-hooks', 'jsx-a11y'], diff --git a/browser/bun.lockb b/browser/bun.lockb deleted file mode 100755 index a2e44c79c21aac99909a43ab93af939bc1690c08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 465953 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%0DHx9p;XiKfLM_m+6#`kV5?S!FNCT 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 diff --git a/browser/cli/.gitignore b/browser/cli/.gitignore new file mode 100644 index 000000000..5e56e040e --- /dev/null +++ b/browser/cli/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/browser/cli/package.json b/browser/cli/package.json new file mode 100644 index 000000000..b47d0ad13 --- /dev/null +++ b/browser/cli/package.json @@ -0,0 +1,34 @@ +{ + "version": "0.35.2", + "author": "Polle Pas", + "dependencies": { + "@tomic/lib": "^0.35.2", + "chalk": "^5.3.0" + }, + "devDependencies": { + "typescript": "^4.8" + }, + "description": "", + "license": "MIT", + "name": "@tomic/cli", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "lint": "eslint ./src --ext .js,.ts", + "lint-fix": "eslint ./src --ext .js,.ts --fix", + "prepublishOnly": "pnpm run build && pnpm run lint-fix", + "watch": "tsc --build --watch", + "start": "pnpm watch", + "tsc": "tsc --build", + "typecheck": "tsc --noEmit" + }, + "bin": { + "ad-generate": "./bin/src/index.js" + }, + "type": "module", + "peerDependencies": { + "@tomic/lib": "^0.35.2" + } +} diff --git a/browser/cli/readme.md b/browser/cli/readme.md new file mode 100644 index 000000000..5540532ec --- /dev/null +++ b/browser/cli/readme.md @@ -0,0 +1,161 @@ +# @tomic/cli + +@tomic/cli is a cli tool that helps the developer with creating a front-end for their atomic data project by providing typesafety on resources. + +In atomic data you can create [ontologies](https://atomicdata.dev/class/ontology) that describe your business model. Then you use this tool to generate Typscript types for these ontologies in your front-end. + +```typescript +import { Post } from './ontolgies/blog'; // <--- generated + +const myBlogpost = await store.getResourceAsync<Post>( + 'https://myblog.com/atomic-is-awesome', +); + +const comments = myBlogpost.props.comments; // string[] automatically infered! +``` + +## Getting started + +### Installation + +You can install the package globally or as a dev dependancy of your project. + +**Globally**: + +``` +npm install -g @tomic/cli +``` + +**Dev Dependancy:** + +``` +npm install -D @tomic/cli +``` + +If you've installed it globally you can now run the `ad-generate` command in your command line. +When installing as a dependancy your PATH won't know about the command and so you will have to make a script in your `package.json` and run it via `npm <script_name>` instead. + +```json +"scripts": { + "generate": "ad-generate" +} +``` + +### Generating the files + +To start generating your ontologies you first need to configure the cli. Start by creating the config file by running: + +``` +ad-generate init +``` + +There should now be a file called `atomic.config.json` in the folder where you ran this command. The contents will look like this: + +```json +{ + "outputFolder": "./src/ontologies", + "moduleAlias": "@tomic/lib", + "ontologies": [] +} +``` + +> If you want to change the location where the files are generated you can change the `outputFolder` field. + +Next add the subjects of your atomic ontologies to the `ontologies` array in the config. + +Now we will generate the ontology files. We do this by running the `ad-generate ontologies` command. If your ontologies don't have public read rights you will have to add an agent secret to the command that has access to these resources. + +``` +ad-generate ontologies --agent <AGENT_SECRET> +``` + +> Agent secret can also be preconfigured in the config **but be careful** when using version control as you can easily leak your secret this way. + +After running the command the files will have been generated in the specified output folder along with an `index.ts` file. The only thing left to do is to register our ontologies with @tomic/lib. This should be done as soon in your apps runtime lifecycle as possible, for example in your App.tsx when using React or root index.ts in most cases. + +```typescript +import { initOntologies } from './ontologies'; + +initOntologies(); +``` + +### Using the types + +If everything went well the generated files should now be in the output folder. +In order to gain the benefit of the typings we will need to annotate our resource with its respective class like follows: + +```typescript +import { Book, creativeWorks } from './ontologies/creativeWorks.js'; + +const book = await store.getResourceAsync<Book>( + 'https://mybookstore.com/books/1', +); +``` + +Now we know what properties are required and recommend on this resource so we can safely infer the types + +Because we know `written-by` is a required property on book we can safely infer type string; + +```typescript +const authorSubject = book.get(creativeWorks.properties.writtenBy); // string +``` + +`description` has datatype Markdown and is inferred as string but it is a recommended property and might therefore be undefined + +```typescript +const description = book.get(core.properties.description); // string | undefined +``` + +If the property is not in any ontology we can not infer the type so it will be of type `JSONValue` +(this type includes `undefined`) + +```typescript +const unknownProp = book.get('https://unknownprop.site/prop/42'); // JSONValue +``` + +### Props shorthand + +Because you have initialised your ontologies before lib is aware of what properties exist and what their name and type is. Because of this it is possible to use the props field on a resource and get full intellisense and typing on it. + +```typescript +const book = await store.getResourceAsync<Book>( + 'https://mybookstore.com/books/1', +); + +const name = book.props.name; // string +const description = book.props.description; // string | undefined +``` + +> The props field is a computed property and is readonly. +> +> If you have to read very large number of properties at a time it is more efficient to use the `resource.get()` method instead of the props field because the props field iterates over the resources propval map. + +## Configuration + +@tomic/cli loads the config file from the root of your project. This file should be called `atomic.config.json` and needs to conform to the following interface. + +```typescript +interface AtomicConfig { + /** + * Path relative to this file where the generated files should be written to. + */ + outputFolder: string; + + /** + * [OPTIONAL] The @tomic/lib module identifier. + * The default should be sufficient in most but if you have given the module an alias you should change this value + */ + moduleAlias?: string; + + /** + * [OPTIONAL] The secret of the agent that is used to access your atomic data server. This can also be provided as a command line argument if you don't want to store it in the config file. + * If left empty the public agent is used. + */ + agentSecret?: string; + + /** The list of subjects of your ontologies */ + ontologies: string[]; +} +``` + +Running `ad-generate init` will create this file for you that you can then tweak to your own preferences. diff --git a/browser/cli/src/commands/init.ts b/browser/cli/src/commands/init.ts new file mode 100644 index 000000000..5d85acf71 --- /dev/null +++ b/browser/cli/src/commands/init.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TEMPLATE_CONFIG_FILE = { + outputFolder: './src/ontologies', + moduleAlias: '@tomic/lib', + ontologies: [], +}; + +export const initCommand = async (args: string[]) => { + const forced = args.includes('--force') || args.includes('-f'); + const filePath = path.join(process.cwd(), 'atomic.config.json'); + const stat = fs.statSync(filePath); + + if (stat.isFile() && !forced) { + return console.error( + chalk.red( + `ERROR: File already exists. If you meant to override the existing file, use the command with the ${chalk.cyan( + '--force', + )} flag.`, + ), + ); + } + + console.log(chalk.cyan('Creating atomic.config.json')); + + const template = JSON.stringify(TEMPLATE_CONFIG_FILE, null, 2); + fs.writeFileSync(filePath, template); + + console.log(chalk.green('Done!')); +}; diff --git a/browser/cli/src/commands/ontologies.ts b/browser/cli/src/commands/ontologies.ts new file mode 100644 index 000000000..6553b10b7 --- /dev/null +++ b/browser/cli/src/commands/ontologies.ts @@ -0,0 +1,49 @@ +/* eslint-disable no-console */ + +import * as fs from 'fs'; +import chalk from 'chalk'; + +import * as path from 'path'; +import { generateOntology } from '../generateOntology.js'; +import { atomicConfig } from '../config.js'; +import { generateIndex } from '../generateIndex.js'; + +export const ontologiesCommand = async (_args: string[]) => { + console.log( + chalk.blue( + `Found ${chalk.red( + Object.keys(atomicConfig.ontologies).length, + )} ontologies`, + ), + ); + + for (const subject of Object.values(atomicConfig.ontologies)) { + write(await generateOntology(subject)); + } + + console.log(chalk.blue('Generating index...')); + + write(generateIndex(atomicConfig.ontologies)); + + console.log(chalk.green('Done!')); +}; + +const write = ({ + filename, + content, +}: { + filename: string; + content: string; +}) => { + console.log(chalk.blue(`Writing ${chalk.red(filename)}...`)); + + const filePath = path.join( + process.cwd(), + atomicConfig.outputFolder, + filename, + ); + + fs.writeFileSync(filePath, content); + + console.log(chalk.blue('Wrote to'), chalk.cyan(filePath)); +}; diff --git a/browser/cli/src/config.ts b/browser/cli/src/config.ts new file mode 100644 index 000000000..111904059 --- /dev/null +++ b/browser/cli/src/config.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface AtomicConfig { + /** + * Path relative to this file where the generated files should be written to. + */ + outputFolder: string; + /** + * [OPTIONAL] The @tomic/lib module identifier. + * The default should be sufficient in most but if you have given the module an alias you should change this value + */ + moduleAlias?: string; + /** + * [OPTIONAL] The secret of the agent that is used to access your atomic data server. This can also be provided as a command line argument if you don't want to store it in the config file. + * If left empty the public agent is used. + */ + agentSecret?: string; + /** The list of subjects of your ontologies */ + + ontologies: string[]; +} + +export const atomicConfig: AtomicConfig = JSON.parse( + fs + .readFileSync(path.resolve(process.cwd(), './atomic.config.json')) + .toString(), +); diff --git a/browser/cli/src/generateBaseObject.ts b/browser/cli/src/generateBaseObject.ts new file mode 100644 index 000000000..f48cef3a6 --- /dev/null +++ b/browser/cli/src/generateBaseObject.ts @@ -0,0 +1,72 @@ +import { Resource, urls } from '@tomic/lib'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; + +export type ReverseMapping = Record<string, string>; + +type BaseObject = { + classes: Record<string, string>; + properties: Record<string, string>; +}; + +export const generateBaseObject = async ( + ontology: Resource, +): Promise<[string, ReverseMapping]> => { + if (ontology.error) { + throw ontology.error; + } + + const classes = ontology.get(urls.properties.classes) as string[]; + const properties = ontology.get(urls.properties.properties) as string[]; + const name = camelCaseify(ontology.title); + + const baseObj = { + classes: await listToObj(classes), + properties: await listToObj(properties), + }; + + const objStr = `export const ${name} = { + classes: ${recordToString(baseObj.classes)}, + properties: ${recordToString(baseObj.properties)}, + } as const`; + + return [objStr, createReverseMapping(name, baseObj)]; +}; + +const listToObj = async (list: string[]): Promise<Record<string, string>> => { + const entries = await Promise.all( + list.map(async subject => { + const resource = await store.getResourceAsync(subject); + + return [camelCaseify(resource.title), subject]; + }), + ); + + return Object.fromEntries(entries); +}; + +const recordToString = (obj: Record<string, string>): string => { + const innerSting = Object.entries(obj).reduce( + (acc, [key, value]) => `${acc}\n\t${key}: '${value}',`, + '', + ); + + return `{${innerSting}\n }`; +}; + +const createReverseMapping = ( + ontologyTitle: string, + obj: BaseObject, +): ReverseMapping => { + const reverseMapping: ReverseMapping = {}; + + for (const [name, subject] of Object.entries(obj.classes)) { + reverseMapping[subject] = `${ontologyTitle}.classes.${name}`; + } + + for (const [name, subject] of Object.entries(obj.properties)) { + reverseMapping[subject] = `${ontologyTitle}.properties.${name}`; + } + + return reverseMapping; +}; diff --git a/browser/cli/src/generateClassExports.ts b/browser/cli/src/generateClassExports.ts new file mode 100644 index 000000000..604955f95 --- /dev/null +++ b/browser/cli/src/generateClassExports.ts @@ -0,0 +1,29 @@ +import { Resource, urls } from '@tomic/lib'; +import { ReverseMapping } from './generateBaseObject.js'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; + +export const generateClassExports = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const classes = ontology.getArray(urls.properties.classes) as string[]; + + return classes + .map(subject => { + const res = store.getResourceLoading(subject); + const objectPath = reverseMapping[subject]; + + return createExportLine(res.title, objectPath); + }) + .join('\n'); +}; + +const createExportLine = (title: string, objectPath: string) => + `export type ${capitalize(title)} = typeof ${objectPath};`; + +const capitalize = (str: string): string => { + const camelCased = camelCaseify(str); + + return camelCased.charAt(0).toUpperCase() + camelCased.slice(1); +}; diff --git a/browser/cli/src/generateClasses.ts b/browser/cli/src/generateClasses.ts new file mode 100644 index 000000000..7c2094a01 --- /dev/null +++ b/browser/cli/src/generateClasses.ts @@ -0,0 +1,64 @@ +import { Resource } from '@tomic/lib'; +import { store } from './store.js'; +import { ReverseMapping } from './generateBaseObject.js'; + +export const generateClasses = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const classes = ontology.get( + 'https://atomicdata.dev/properties/classes', + ) as string[]; + const classStringList = classes.map(subject => { + return generateClass(subject, reverseMapping); + }); + + const innerStr = classStringList.join('\n'); + + return `interface Classes { + ${innerStr} + }`; +}; + +const generateClass = ( + subject: string, + reverseMapping: ReverseMapping, +): string => { + const resource = store.getResourceLoading(subject); + + const transformSubject = (str: string) => { + const name = reverseMapping[str]; + + if (!name) { + return `'${str}'`; + } + + return `typeof ${name}`; + }; + + const requires = (resource.get( + 'https://atomicdata.dev/properties/requires', + ) ?? []) as string[]; + const recommends = (resource.get( + 'https://atomicdata.dev/properties/recommends', + ) ?? []) as string[]; + + return classString( + reverseMapping[subject], + requires.map(transformSubject), + recommends.map(transformSubject), + ); +}; + +const classString = ( + key: string, + requires: string[], + recommends: string[], +): string => { + return `[${key}]: { + requires: BaseProps${ + requires.length > 0 ? ' | ' + requires.join(' | ') : '' + }; + recommends: ${recommends.length > 0 ? recommends.join(' | ') : 'never'}; + };`; +}; diff --git a/browser/cli/src/generateIndex.ts b/browser/cli/src/generateIndex.ts new file mode 100644 index 000000000..6b01c5307 --- /dev/null +++ b/browser/cli/src/generateIndex.ts @@ -0,0 +1,49 @@ +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; +import { atomicConfig } from './config.js'; + +enum Inserts { + MODULE_ALIAS = '{{1}}', + IMPORTS = '{{2}}', + REGISTER_ARGS = '{{3}}', +} + +const TEMPLATE = ` +/* ----------------------------------- +* GENERATED WITH ATOMIC-GENERATE +* -------------------------------- */ + +import { registerOntologies } from '${Inserts.MODULE_ALIAS}'; + +${Inserts.IMPORTS} + +export function initOntologies(): void { + registerOntologies(${Inserts.REGISTER_ARGS}); +} +`; + +export const generateIndex = (ontologies: string[]) => { + const names = ontologies.map(x => { + const res = store.getResourceLoading(x); + + return camelCaseify(res.title); + }); + + const importLines = names.map(createImportLine).join('\n'); + const registerArgs = names.join(', '); + + const content = TEMPLATE.replaceAll( + Inserts.MODULE_ALIAS, + atomicConfig.moduleAlias ?? '@tomic/lib', + ) + .replace(Inserts.IMPORTS, importLines) + .replace(Inserts.REGISTER_ARGS, registerArgs); + + return { + filename: 'index.ts', + content, + }; +}; + +const createImportLine = (name: string) => + `import { ${name} } from './${name}.js';`; diff --git a/browser/cli/src/generateOntology.ts b/browser/cli/src/generateOntology.ts new file mode 100644 index 000000000..7eb1066ac --- /dev/null +++ b/browser/cli/src/generateOntology.ts @@ -0,0 +1,68 @@ +import { generateBaseObject } from './generateBaseObject.js'; +import { generateClasses } from './generateClasses.js'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; +// TODO: Replace with actual project config file. +import { generatePropTypeMapping } from './generatePropTypeMapping.js'; +import { generateSubjectToNameMapping } from './generateSubjectToNameMapping.js'; +import { generateClassExports } from './generateClassExports.js'; + +import { atomicConfig } from './config.js'; + +enum Inserts { + MODULE_ALIAS = '{{1}}', + BASE_OBJECT = '{{2}}', + CLASS_EXPORTS = '{{3}}', + CLASSES = '{{4}}', + PROP_TYPE_MAPPING = '{{7}}', + PROP_SUBJECT_TO_NAME_MAPPING = '{{8}}', +} + +const TEMPLATE = ` +/* ----------------------------------- +* GENERATED WITH ATOMIC-GENERATE +* -------------------------------- */ + +import { BaseProps } from '${Inserts.MODULE_ALIAS}' + +${Inserts.BASE_OBJECT} + +${Inserts.CLASS_EXPORTS} + +declare module '${Inserts.MODULE_ALIAS}' { + ${Inserts.CLASSES} + + ${Inserts.PROP_TYPE_MAPPING} + + ${Inserts.PROP_SUBJECT_TO_NAME_MAPPING} +} +`; + +export const generateOntology = async ( + subject: string, +): Promise<{ + filename: string; + content: string; +}> => { + const ontology = await store.getResourceAsync(subject); + const [baseObjStr, reverseMapping] = await generateBaseObject(ontology); + const classesStr = generateClasses(ontology, reverseMapping); + const propertiesStr = generatePropTypeMapping(ontology, reverseMapping); + const subToNameStr = generateSubjectToNameMapping(ontology, reverseMapping); + const classExportsStr = generateClassExports(ontology, reverseMapping); + + const content = TEMPLATE.replaceAll( + Inserts.MODULE_ALIAS, + atomicConfig.moduleAlias ?? '@tomic/lib', + ) + .replace(Inserts.BASE_OBJECT, baseObjStr) + .replace(Inserts.CLASS_EXPORTS, classExportsStr) + .replace(Inserts.CLASSES, classesStr) + .replace(Inserts.PROP_TYPE_MAPPING, propertiesStr) + .replace(Inserts.PROP_SUBJECT_TO_NAME_MAPPING, subToNameStr); + + return { + filename: `${camelCaseify(ontology.title)}.ts`, + content, + }; +}; diff --git a/browser/cli/src/generatePropTypeMapping.ts b/browser/cli/src/generatePropTypeMapping.ts new file mode 100644 index 000000000..2f72dd467 --- /dev/null +++ b/browser/cli/src/generatePropTypeMapping.ts @@ -0,0 +1,43 @@ +import { Datatype, Resource } from '@tomic/lib'; +import { store } from './store.js'; +import { ReverseMapping } from './generateBaseObject.js'; + +const DatatypeToTSTypeMap = { + [Datatype.ATOMIC_URL]: 'string', + [Datatype.RESOURCEARRAY]: 'string[]', + [Datatype.BOOLEAN]: 'boolean', + [Datatype.DATE]: 'string', + [Datatype.TIMESTAMP]: 'string', + [Datatype.INTEGER]: 'number', + [Datatype.FLOAT]: 'number', + [Datatype.STRING]: 'string', + [Datatype.SLUG]: 'string', + [Datatype.MARKDOWN]: 'string', + [Datatype.UNKNOWN]: 'JSONValue', +}; + +export const generatePropTypeMapping = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const properties = (ontology.get( + 'https://atomicdata.dev/properties/properties', + ) ?? []) as string[]; + + const lines = properties + .map(subject => generateLine(subject, reverseMapping)) + .join('\n'); + + return `interface PropTypeMapping { + ${lines} + }`; +}; + +const generateLine = (subject: string, reverseMapping: ReverseMapping) => { + const resource = store.getResourceLoading(subject); + const datatype = resource.get( + 'https://atomicdata.dev/properties/datatype', + ) as Datatype; + + return `[${reverseMapping[subject]}]: ${DatatypeToTSTypeMap[datatype]}`; +}; diff --git a/browser/cli/src/generateSubjectToNameMapping.ts b/browser/cli/src/generateSubjectToNameMapping.ts new file mode 100644 index 000000000..1b596d83b --- /dev/null +++ b/browser/cli/src/generateSubjectToNameMapping.ts @@ -0,0 +1,23 @@ +import { Resource } from '@tomic/lib'; +import { ReverseMapping } from './generateBaseObject.js'; + +export function generateSubjectToNameMapping( + ontology: Resource, + reverseMapping: ReverseMapping, +) { + const properties = ontology.getArray( + 'https://atomicdata.dev/properties/properties', + ) as string[]; + + const lines = properties.map(prop => propLine(prop, reverseMapping)); + + return `interface PropSubjectToNameMapping { + ${lines.join('\n')} + }`; +} + +const propLine = (subject: string, reverseMapping: ReverseMapping) => { + const name = reverseMapping[subject].split('.')[2]; + + return `[${reverseMapping[subject]}]: '${name}',`; +}; diff --git a/browser/cli/src/index.ts b/browser/cli/src/index.ts new file mode 100644 index 000000000..2ca1e9547 --- /dev/null +++ b/browser/cli/src/index.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +import chalk from 'chalk'; +import { usage } from './usage.js'; + +const command = process.argv[2]; + +const commands = new Map<string, () => Promise<void>>(); + +commands.set('ontologies', () => + import('./commands/ontologies.js').then(m => + m.ontologiesCommand(process.argv.slice(3)), + ), +); + +commands.set('init', () => + import('./commands/init.js').then(m => m.initCommand(process.argv.slice(3))), +); + +if (commands.has(command)) { + commands.get(command)?.(); +} else { + console.error(chalk.red('Unknown command'), chalk.cyan(command ?? '')); + console.log(usage); +} diff --git a/browser/cli/src/store.ts b/browser/cli/src/store.ts new file mode 100644 index 000000000..a0a88f98c --- /dev/null +++ b/browser/cli/src/store.ts @@ -0,0 +1,35 @@ +import { Agent, Store } from '@tomic/lib'; +import { atomicConfig } from './config.js'; + +const getCommandIndex = (): number | undefined => { + const agentIndex = process.argv.indexOf('--agent'); + if (agentIndex !== -1) return agentIndex; + + const shortAgentIndex = process.argv.indexOf('-a'); + if (shortAgentIndex !== -1) return shortAgentIndex; + + return undefined; +}; + +const getAgent = (): Agent | undefined => { + let secret; + const agentCommandIndex = getCommandIndex(); + + if (agentCommandIndex) { + secret = process.argv[agentCommandIndex + 1]; + } else { + secret = atomicConfig.agentSecret; + } + + if (!secret) return undefined; + + return Agent.fromSecret(secret); +}; + +export const store = new Store(); + +const agent = getAgent(); + +if (agent) { + store.setAgent(agent); +} diff --git a/browser/cli/src/usage.ts b/browser/cli/src/usage.ts new file mode 100644 index 000000000..0169269ee --- /dev/null +++ b/browser/cli/src/usage.ts @@ -0,0 +1,7 @@ +export const usage = ` +ad-generate <command> + +Commands: + ontologies Generates typescript files for ontologies specified in the config file. + init Creates a template config file. +`; diff --git a/browser/cli/src/utils.ts b/browser/cli/src/utils.ts new file mode 100644 index 000000000..fe6e14062 --- /dev/null +++ b/browser/cli/src/utils.ts @@ -0,0 +1,4 @@ +export const camelCaseify = (str: string) => + str.replace(/-([a-z])/g, g => { + return g[1].toUpperCase(); + }); diff --git a/browser/cli/tsconfig.json b/browser/cli/tsconfig.json new file mode 100644 index 000000000..8a3f78764 --- /dev/null +++ b/browser/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "outDir": "./bin", + "rootDir": ".", + "target": "ESNext", + "moduleResolution": "nodeNext", + "module": "nodeNext", + "noImplicitAny": true, + "strictNullChecks": true, + // We don't need type declarations for a cli app. + "declaration": false + }, + "include": [ + "./src", + ], + "references": [], +} diff --git a/browser/lib/package.json b/browser/lib/package.json index 447d281e6..2be95f095 100644 --- a/browser/lib/package.json +++ b/browser/lib/package.json @@ -1,5 +1,5 @@ { - "version": "0.35.1", + "version": "0.35.2", "author": "Joep Meindertsma", "dependencies": { "@noble/ed25519": "1.6.0", diff --git a/browser/lib/src/index.ts b/browser/lib/src/index.ts index 49cf13edd..f0071a603 100644 --- a/browser/lib/src/index.ts +++ b/browser/lib/src/index.ts @@ -46,3 +46,4 @@ export * from './urls.js'; export * from './truncate.js'; export * from './collection.js'; export * from './collectionBuilder.js'; +export * from './ontology.js'; diff --git a/browser/lib/src/ontology.ts b/browser/lib/src/ontology.ts new file mode 100644 index 000000000..ae6fc3f9d --- /dev/null +++ b/browser/lib/src/ontology.ts @@ -0,0 +1,77 @@ +import { JSONValue } from './value.js'; + +export type BaseObject = { + classes: Record<string, string>; + properties: Record<string, string>; +}; + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Classes {} + +export type BaseProps = + | 'https://atomicdata.dev/properties/isA' + | 'https://atomicdata.dev/properties/parent'; + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PropTypeMapping {} + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PropSubjectToNameMapping {} + +export type Requires<C extends keyof Classes> = Classes[C]['requires']; +export type Recommends<C extends keyof Classes> = Classes[C]['recommends']; + +type PropsOfClass<C extends keyof Classes> = { + [P in Requires<C>]: P; +} & { + [P in Recommends<C>]?: P; +}; + +/** + * Infers the js type a value can have on a resource for the given property. + * If the property is not known in any ontology, it will return JSONValue. + */ +export type InferTypeOfValueInTriple< + Class extends keyof Classes | never = never, + Prop extends string = string, + Returns = Prop extends keyof PropTypeMapping + ? Prop extends Requires<Class> + ? PropTypeMapping[Prop] + : Prop extends Recommends<Class> + ? PropTypeMapping[Prop] | undefined + : PropTypeMapping[Prop] | undefined + : JSONValue, +> = Returns; + +/** Type of the dynamically created resource.props field */ +export type QuickAccesPropType<Class extends keyof Classes | never = never> = { + readonly [Prop in keyof PropsOfClass<Class> as PropSubjectToNameMapping[Prop]]: InferTypeOfValueInTriple< + Class, + Prop + >; +}; + +export type OptionalClass = keyof Classes | never; + +// A map of all known classes and properties to their camelcased shortname. +const globalReverseNameMapping = new Map<string, string>(); + +/** Let atomic lib know your custom ontologies exist */ +export function registerOntologies(...ontologies: BaseObject[]): void { + for (const ontology of ontologies) { + for (const [key, value] of Object.entries(ontology.classes)) { + globalReverseNameMapping.set(value, key); + } + + for (const [key, value] of Object.entries(ontology.properties)) { + globalReverseNameMapping.set(value, key); + } + } +} + +export function getKnownNameBySubject(subject: string): string | undefined { + return globalReverseNameMapping.get(subject); +} diff --git a/browser/lib/src/resource.ts b/browser/lib/src/resource.ts index c98175864..faf8b3fc8 100644 --- a/browser/lib/src/resource.ts +++ b/browser/lib/src/resource.ts @@ -14,6 +14,10 @@ import { applyCommitToResource, Commit, parseCommitResource, + InferTypeOfValueInTriple, + QuickAccesPropType, + getKnownNameBySubject, + OptionalClass, } from './index.js'; /** Contains the PropertyURL / Value combinations */ @@ -29,7 +33,7 @@ export const unknownSubject = 'unknown-subject'; * Describes an Atomic Resource, which has a Subject URL and a bunch of Property * / Value combinations. */ -export class Resource { +export class Resource<C extends OptionalClass = never> { /** If the resource could not be fetched, we put that info here. */ public error?: Error; /** If the commit could not be saved, we put that info here. */ @@ -75,6 +79,20 @@ export class Resource { this.subject) as string; } + public get props(): QuickAccesPropType<C> { + const props: QuickAccesPropType<C> = {}; + + for (const prop of this.propvals.keys()) { + const name = getKnownNameBySubject(prop); + + if (name) { + props[name] = this.get(prop); + } + } + + return props; + } + /** Checks if the content of two Resource instances is equal * Warning: does not check CommitBuilder, loading state */ @@ -138,7 +156,7 @@ export class Resource { * Creates a clone of the Resource, which makes sure the reference is * different from the previous one. This can be useful when doing reference compares. */ - public clone(): Resource { + public clone(): Resource<C> { const res = new Resource(this.subject); res.propvals = structuredClone(this.propvals); res.loading = this.loading; @@ -159,8 +177,10 @@ export class Resource { } /** Get a Value by its property */ - public get<T extends JSONValue = JSONValue>(propUrl: string): T { - return this.propvals.get(propUrl) as T; + public get<Prop extends string, Returns = InferTypeOfValueInTriple<C, Prop>>( + propUrl: Prop, + ): Returns { + return this.propvals.get(propUrl) as Returns; } /** @@ -185,7 +205,7 @@ export class Resource { return valToArray(result); } - /** Get a Value by its property */ + /** Returns a list of classes of this resource */ public getClasses(): string[] { return this.getSubjects(properties.isA); } @@ -518,9 +538,12 @@ export class Resource { * * When undefined is passed as value, the property is removed from the resource. */ - public async set( - prop: string, - value: JSONValue, + public async set< + Prop extends string, + Value extends InferTypeOfValueInTriple<C, Prop>, + >( + prop: Prop, + value: Value, store: Store, /** * Disable validation if you don't need it. It might cause a fetch if the diff --git a/browser/lib/src/store.ts b/browser/lib/src/store.ts index b652b8449..e3efe2670 100644 --- a/browser/lib/src/store.ts +++ b/browser/lib/src/store.ts @@ -15,11 +15,14 @@ import { Commit, JSONADParser, FileOrFileLike, + OptionalClass, } from './index.js'; import { authenticate, fetchWebSocket, startWebsocket } from './websockets.js'; /** Function called when a resource is updated or removed */ -type ResourceCallback = (resource: Resource) => void; +type ResourceCallback<C extends OptionalClass = never> = ( + resource: Resource<C>, +) => void; /** Callback called when the stores agent changes */ type AgentCallback = (agent: Agent | undefined) => void; type ErrorCallback = (e: Error) => void; @@ -202,7 +205,7 @@ export class Store { /** * Always fetches resource from the server then adds it to the store. */ - public async fetchResourceFromServer( + public async fetchResourceFromServer<C extends OptionalClass = never>( /** The resource URL to be fetched */ subject: string, opts: { @@ -220,9 +223,9 @@ export class Store { /** HTTP Body for POSTing */ body?: ArrayBuffer | string; } = {}, - ): Promise<Resource> { + ): Promise<Resource<C>> { if (opts.setLoading) { - const newR = new Resource(subject); + const newR = new Resource<C>(subject); newR.loading = true; this.addResources(newR); } @@ -304,13 +307,13 @@ export class Store { * done in the background . If the subject is undefined, an empty non-saved * resource will be returned. */ - public getResourceLoading( + public getResourceLoading<C extends OptionalClass = never>( subject: string = unknownSubject, opts: FetchOpts = {}, - ): Resource { + ): Resource<C> { // This is needed because it can happen that the useResource react hook is called while there is no subject passed. if (subject === unknownSubject || subject === null) { - const newR = new Resource(unknownSubject, opts.newResource); + const newR = new Resource<C>(unknownSubject, opts.newResource); return newR; } @@ -318,7 +321,7 @@ export class Store { const found = this.resources.get(subject); if (!found) { - const newR = new Resource(subject, opts.newResource); + const newR = new Resource<C>(subject, opts.newResource); newR.loading = true; this.addResources(newR); @@ -345,7 +348,9 @@ export class Store { * store. Not recommended to use this for rendering, because it might cause * resources to be fetched multiple times. */ - public async getResourceAsync(subject: string): Promise<Resource> { + public async getResourceAsync<C extends OptionalClass = never>( + subject: string, + ): Promise<Resource<C>> { const found = this.resources.get(subject); if (found && found.isReady()) { @@ -357,7 +362,7 @@ export class Store { return new Promise((resolve, reject) => { const defaultTimeout = 5000; - const cb = res => { + const cb: ResourceCallback<C> = res => { this.unsubscribe(subject, cb); resolve(res); }; diff --git a/browser/package.json b/browser/package.json index 48f6e3359..e98b483ac 100644 --- a/browser/package.json +++ b/browser/package.json @@ -54,7 +54,8 @@ "packages": [ "lib", "react", - "data-browser" + "data-browser", + "cli" ] }, "packageManager": "pnpm@8.6.12", diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 6cabf0cce..006441aab 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -97,6 +97,18 @@ importers: specifier: ^3.0.5 version: 3.2.7(@types/node@16.18.39) + cli: + dependencies: + '@tomic/lib': + specifier: ^0.35.1 + version: link:../lib + chalk: + specifier: ^5.3.0 + version: 5.3.0 + typescript: + specifier: ^4.8 + version: 4.9.5 + data-browser: dependencies: '@bugsnag/core': @@ -240,7 +252,7 @@ importers: version: 1.1.0 vite: specifier: ^4.0.4 - version: 4.4.8 + version: 4.4.8(@types/node@16.18.39) vite-plugin-pwa: specifier: ^0.14.1 version: 0.14.7(vite@4.4.8)(workbox-build@6.6.0)(workbox-window@6.6.0) @@ -269,6 +281,9 @@ 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 @@ -4125,6 +4140,11 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + /char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -9660,7 +9680,7 @@ packages: fast-glob: 3.3.1 pretty-bytes: 6.1.1 rollup: 3.27.2 - vite: 4.4.8 + vite: 4.4.8(@types/node@16.18.39) workbox-build: 6.6.0 workbox-window: 6.6.0 transitivePeerDependencies: @@ -9701,7 +9721,7 @@ packages: fsevents: 2.3.2 dev: true - /vite@4.4.8: + /vite@4.4.8(@types/node@16.18.39): resolution: {integrity: sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -9729,6 +9749,7 @@ packages: terser: optional: true dependencies: + '@types/node': 16.18.39 esbuild: 0.18.17 postcss: 8.4.27 rollup: 3.27.2 diff --git a/browser/react/package.json b/browser/react/package.json index cfd525f99..21303c125 100644 --- a/browser/react/package.json +++ b/browser/react/package.json @@ -1,5 +1,5 @@ { - "version": "0.35.0", + "version": "0.35.2", "author": "Joep Meindertsma", "description": "Atomic Data React library", "dependencies": { diff --git a/browser/react/src/hooks.ts b/browser/react/src/hooks.ts index b6d8152f0..4ae645abf 100644 --- a/browser/react/src/hooks.ts +++ b/browser/react/src/hooks.ts @@ -22,6 +22,7 @@ import { FetchOpts, unknownSubject, JSONArray, + OptionalClass, } from '@tomic/lib'; import { useDebouncedCallback } from './index.js'; @@ -29,12 +30,12 @@ import { useDebouncedCallback } from './index.js'; * Hook for getting a Resource in a React component. Will try to fetch the * subject and add its parsed values to the store. */ -export function useResource( +export function useResource<C extends OptionalClass = never>( subject: string = unknownSubject, opts?: FetchOpts, -): Resource { +): Resource<C> { const store = useStore(); - const [resource, setResource] = useState<Resource>( + const [resource, setResource] = useState<Resource<C>>( store.getResourceLoading(subject, opts), ); @@ -45,7 +46,7 @@ export function useResource( // When a component mounts, it needs to let the store know that it will subscribe to changes to that resource. useEffect(() => { - function handleNotify(updated: Resource) { + function handleNotify(updated: Resource<C>) { // When a change happens, set the new Resource. setResource(updated); } diff --git a/browser/react/src/useMemberFromCollection.ts b/browser/react/src/useMemberFromCollection.ts index 8b42ac2c8..dc59786b4 100644 --- a/browser/react/src/useMemberFromCollection.ts +++ b/browser/react/src/useMemberFromCollection.ts @@ -1,14 +1,19 @@ -import { Collection, Resource, unknownSubject } from '@tomic/lib'; +import { + Collection, + OptionalClass, + Resource, + unknownSubject, +} from '@tomic/lib'; import { useEffect, useState } from 'react'; import { useResource } from './hooks.js'; /** * Gets a member from a collection by index. Handles pagination for you. */ -export function useMemberFromCollection( +export function useMemberFromCollection<C extends OptionalClass = never>( collection: Collection, index: number, -): Resource { +): Resource<C> { const [subject, setSubject] = useState(unknownSubject); const resource = useResource(subject); From ac617bb53c7ea003efd433b17eb99fa8d602f03c Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Mon, 2 Oct 2023 13:46:40 +0200 Subject: [PATCH 11/13] #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<LinkViewProps>` 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<HTMLInputElement>) => { + const value = stringToSlug(e.target.value); + setShortname(value); + + try { + validateDatatype(value, Datatype.SLUG); + setValid(true); + } catch (_) { + setValid(false); + } + }; + + return ( + <> + <Base + onClick={show} + title={ontology.title} + icon={icon} + IconComponent={IconComponent} + subtle={subtle} + label={label} + > + {children} + </Base> + <Dialog {...dialogProps}> + <H1>New Ontology</H1> + <DialogContent> + <form + onSubmit={(e: FormEvent) => { + e.preventDefault(); + hide(true); + }} + > + <Explanation> + An ontology is a collection of classes and properties that + together describe a concept. Great for data models. + </Explanation> + <Field required label='Shortname'> + <InputWrapper> + <InputStyled + placeholder='my-ontology' + value={shortname} + autoFocus={true} + onChange={onShortnameChange} + /> + </InputWrapper> + </Field> + </form> + </DialogContent> + <DialogActions> + <Button onClick={() => hide(false)} subtle> + Cancel + </Button> + <Button onClick={() => hide(true)} disabled={!valid}> + Create + </Button> + </DialogActions> + </Dialog> + </> + ); +} + +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<string, InstanceButton>([ [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 ( - <Section aria-label='App menu'> + <section aria-label='App menu'> <SideBarMenuItem icon={<FaUser />} label={agent ? agentResource.title : 'Login'} @@ -95,11 +94,6 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { onClick={install} /> )} - </Section> + </section> ); } - -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 ( + <Wrapper> + <StyledScrollArea> + {[...Array(collection.totalMembers).keys()].map(index => ( + <Item key={index} collection={collection} index={index} /> + ))} + </StyledScrollArea> + </Wrapper> + ); +} + +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 <div>loading</div>; + } + + if (resource.error || resource.getSubject() === unknownSubject) { + return ( + <SideBarItem> + <ErrorLook>Invalid Resource</ErrorLook> + </SideBarItem> + ); + } + + return ( + <StyledLink subject={resource.getSubject()} clean> + <SideBarItem> + <Row gap='1ch' center> + <Icon /> + {resource.title} + </Row> + </SideBarItem> + </StyledLink> + ); +} + +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')<SideBarItemProps>` &: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<SideBarPanelProps>): JSX.Element { + const [open, setOpen] = React.useState(true); + + return ( + <Wrapper> + <DeviderButton onClick={() => setOpen(prev => !prev)}> + <PanelDevider> + <Arrow $open={open} /> + {title} + </PanelDevider> + </DeviderButton> + <Collapse open={open}>{children}</Collapse> + </Wrapper> + ); +} + +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 */} <SideBarDriveMemo handleClickItem={closeSideBar} key={drive} /> <MenuWrapper> - <Column> - <AppMenu onItemClick={closeSideBar} /> - <About /> + <Column gap='0.5rem'> + {enabledPanels.has(Panel.Ontologies) && ( + <SideBarPanel title='Ontologies' key={drive}> + <OntologiesPanel /> + </SideBarPanel> + )} + <SideBarPanel title='App'> + <Column> + <AppMenu onItemClick={closeSideBar} /> + <About /> + </Column> + </SideBarPanel> </Column> </MenuWrapper> <NavBarSpacer baseMargin='1rem' position='bottom' /> 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<Panel>; + enablePanel: (panel: Panel) => void; + disablePanel: (panel: Panel) => void; +} => { + const [enabledPanels, setEnabledPanels] = useLocalStorage<Panel[]>( + '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<string, JSONValue>; 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} /> </DialogContent> <DialogActions> @@ -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<Error | undefined>(undefined); const navigate = useNavigate(); - const [newProperty, setNewProperty] = useState<string | undefined>(undefined); /** A list of custom properties, set by the User while editing this form */ const [tempOtherProps, setTempOtherProps] = useState<string[]>([]); - const [otherProps, setOtherProps] = useState<string[]>([]); 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.' > - <PropertyAdder> - {/* TODO: When adding a property, clear the form. Make the button optional / remove it. */} - <Button - subtle - disabled={!newProperty} - onClick={handleAddProp} - title='Add this property' - > - <FaPlus /> - </Button> + <div> <ResourceSelector value={undefined} setSubject={set => { - setNewProperty(set); + handleAddProp(set); }} error={newPropErr} isA={urls.classes.property} /> - </PropertyAdder> + </div> {newPropErr && <ErrMessage>{newPropErr.message}</ErrMessage>} </Field> <UploadForm parentResource={resource} /> @@ -249,6 +238,10 @@ export function ResourceForm({ <ResourceField propertyURL={properties.parent} resource={resource} /> <ResourceField propertyURL={properties.write} resource={resource} /> <ResourceField propertyURL={properties.read} resource={resource} /> + <ResourceField + propertyURL={properties.commit.lastCommit} + resource={resource} + /> </Collapse> {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 ( <Wrapper> <StyledSearchBox value={value} onChange={setSubject} - isA={classType} + isA={isA} required={required} disabled={disabled} onCreateItem={handleCreateItem} @@ -87,14 +85,16 @@ export const ResourceSelector = React.memo(function ResourceSelector({ )} </StyledSearchBox> {error && <PositionedErrorChip>{error.message}</PositionedErrorChip>} - {!inDialog && classType && ( + {!inDialog && isA && ( <Dialog {...dialogProps}> {isDialogOpen && ( <NewFormDialog parent={parent || drive} - classSubject={classType} + classSubject={isA} closeDialog={closeDialog} - initialTitle={initialNewTitle} + initialProps={{ + [urls.properties.shortname]: initialNewTitle, + }} onSave={setSubject} /> )} 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 && <CenteredMessage>Start Searching</CenteredMessage>} <StyledScrollArea> <ul> - {onCreateItem && ( + {onCreateItem ? ( <ResultLine selected={selectedIndex === 0} onMouseOver={() => handleMouseMove(0)} @@ -173,7 +173,7 @@ export function SearchBoxWindow({ > Create {searchValue} </ResultLine> - )} + ) : null} {results.map((result, i) => ( <ResourceResultLine key={result} diff --git a/browser/data-browser/src/routes/NewRoute.tsx b/browser/data-browser/src/routes/NewRoute.tsx index 3d18118b4..a737717e6 100644 --- a/browser/data-browser/src/routes/NewRoute.tsx +++ b/browser/data-browser/src/routes/NewRoute.tsx @@ -53,6 +53,7 @@ function NewResourceSelector() { urls.classes.document, urls.classes.chatRoom, urls.classes.bookmark, + urls.classes.ontology, ]; function handleClassSet(e) { diff --git a/browser/data-browser/src/routes/SettingsTheme.tsx b/browser/data-browser/src/routes/SettingsTheme.tsx index 8efff5cd0..a5acb89f2 100644 --- a/browser/data-browser/src/routes/SettingsTheme.tsx +++ b/browser/data-browser/src/routes/SettingsTheme.tsx @@ -9,6 +9,7 @@ import { Column, Row } from '../components/Row'; import { styled } from 'styled-components'; import { Checkbox, CheckboxLabel } from '../components/forms/Checkbox'; import { Main } from '../components/Main'; +import { Panel, usePanelList } from '../components/SideBar/usePanelList'; export const SettingsTheme: React.FunctionComponent = () => { 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 ( <Main> <ContainerNarrow> @@ -53,6 +64,14 @@ export const SettingsTheme: React.FunctionComponent = () => { </Row> <Heading>Main color</Heading> <MainColorPicker /> + <Heading>Panels</Heading> + <CheckboxLabel> + <Checkbox + checked={enabledPanels.has(Panel.Ontologies)} + onChange={changePanelPref(Panel.Ontologies)} + />{' '} + Enable Ontology panel + </CheckboxLabel> <Heading>Animations</Heading> <CheckboxLabel> <Checkbox diff --git a/browser/data-browser/src/views/FolderPage/iconMap.ts b/browser/data-browser/src/views/FolderPage/iconMap.ts index f9c6911e4..9812e5eac 100644 --- a/browser/data-browser/src/views/FolderPage/iconMap.ts +++ b/browser/data-browser/src/views/FolderPage/iconMap.ts @@ -14,6 +14,7 @@ import { FaHashtag, FaHdd, FaListAlt, + FaShapes, FaShareSquare, FaTable, } from 'react-icons/fa'; @@ -33,6 +34,7 @@ const iconMap = new Map<string, IconType>([ [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 ( - <StyledCard> + <StyledCard subject={subject}> <Column> <StyledH3 id={toAnchorId(subject)}> <FaCube /> @@ -48,8 +50,9 @@ export function ClassCardRead({ subject }: ClassCardReadProps): JSX.Element { ); } -const StyledCard = styled(Card)` +const StyledCard = styled(Card)<ViewTransitionProps>` 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<string | undefined>(); + 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 ? ( + <InstanceButton onClick={() => setActive(true)}> + <FaPlus /> + New Instance + </InstanceButton> + ) : ( + <> + <ChooseClassFormWrapper> + <Column> + <strong>Select the class for this instance</strong> + <ResourceSelector + autoFocus + isA={urls.classes.class} + setSubject={handleClassSelect} + value={classSubject} + /> + </Column> + </ChooseClassFormWrapper> + <Dialog {...dialogProps}> + {isOpen && classSubject && ( + <NewFormDialog + classSubject={classSubject} + closeDialog={close} + onSave={handleSave} + parent={ontology.getSubject()} + /> + )} + </Dialog> + </> + )} + </> + ); +} + +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) { <ResourceCard subject={c} id={toAnchorId(c)} /> </li> ))} + {editMode && <CreateInstanceButton ontology={resource} />} </StyledUl> </Column> </ListSlot> @@ -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<unknown>) => void> +>(); export type SetLocalStorageValue<T> = (value: T | ((val: T) => T)) => void; /** @@ -39,8 +44,12 @@ export function useLocalStorage<T>( // 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<T>( [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]; } From 3675c12eaaf03d159581a70660bceae8a00a5e21 Mon Sep 17 00:00:00 2001 From: Polle Pas <polleps@gmail.com> Date: Mon, 2 Oct 2023 15:12:30 +0200 Subject: [PATCH 12/13] #648 streamline subject creation --- .../NewInstanceButton/NewOntologyButton.tsx | 3 +-- .../NewInstanceButton/useCreateAndNavigate.ts | 11 ++++++---- .../forms/NewForm/NewFormDialog.tsx | 22 +++++-------------- .../src/helpers/getNamePartFromProps.ts | 14 ++++++++++++ browser/lib/src/store.ts | 11 ++++++---- 5 files changed, 34 insertions(+), 27 deletions(-) create mode 100644 browser/data-browser/src/helpers/getNamePartFromProps.ts diff --git a/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx index cadd2b2c9..25994e280 100644 --- a/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx +++ b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx @@ -26,7 +26,6 @@ export function NewOntologyButton({ label, }: NewInstanceButtonProps): JSX.Element { const ontology = useResource(klass); - const [shortname, setShortname] = useState(''); const [valid, setValid] = useState(false); @@ -41,7 +40,7 @@ export function NewOntologyButton({ [properties.properties]: [], [properties.instances]: [], }); - }, [shortname]); + }, [shortname, createResourceAndNavigate]); const [dialogProps, show, hide] = useDialog({ onSuccess }); diff --git a/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts b/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts index 1280d7d3a..7ae28c84c 100644 --- a/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts +++ b/browser/data-browser/src/components/NewInstanceButton/useCreateAndNavigate.ts @@ -10,6 +10,7 @@ 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'; /** * Hook that builds a function that will create a new resource with the given @@ -34,11 +35,13 @@ export function useCreateAndNavigate(klass: string, parent?: string) { /** Do not set a parent for the new resource. Useful for top-level resources */ noParent?: boolean, ): Promise<Resource> => { - const subject = store.createSubject( - className, + const namePart = getNamePartFromProps(propVals); + const newSubject = await store.buildUniqueSubjectFromParts( + [className, namePart], noParent ? undefined : parent, ); - const resource = new Resource(subject, true); + + const resource = new Resource(newSubject, true); await Promise.all([ ...Object.entries(propVals).map(([key, val]) => @@ -49,7 +52,7 @@ export function useCreateAndNavigate(klass: string, parent?: string) { try { await resource.save(store); - navigate(constructOpenURL(subject, extraParams)); + navigate(constructOpenURL(newSubject, extraParams)); toast.success(`${title} created`); store.notifyResourceManuallyCreated(resource); } catch (e) { diff --git a/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx index 484900780..6b620f3dd 100644 --- a/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx +++ b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx @@ -1,10 +1,4 @@ -import { - JSONValue, - properties, - useResource, - useStore, - useTitle, -} from '@tomic/react'; +import { JSONValue, useResource, useStore, useTitle } from '@tomic/react'; import React, { useState, useCallback } from 'react'; import { useEffectOnce } from '../../../hooks/useEffectOnce'; import { Button } from '../../Button'; @@ -17,7 +11,7 @@ import { NewFormProps } from './NewFormPage'; import { NewFormTitle, NewFormTitleVariant } from './NewFormTitle'; import { SubjectField } from './SubjectField'; import { useNewForm } from './useNewForm'; -import { randomString } from '../../../helpers/randomString'; +import { getNamePartFromProps } from '../../../helpers/getNamePartFromProps'; export interface NewFormDialogProps extends NewFormProps { closeDialog: () => void; @@ -55,15 +49,11 @@ export const NewFormDialog = ({ // Onmount we generate a new subject based on the classtype and the user input. useEffectOnce(() => { (async () => { - const namePart = normalizeName( - (initialProps?.[properties.shortname] as string) ?? - (initialProps?.[properties.name] as string) ?? - randomString(8), - ); + const namePart = getNamePartFromProps(initialProps ?? {}); const uniqueSubject = await store.buildUniqueSubjectFromParts( - className, - namePart, + [className, namePart], + parent, ); await setSubjectValue(uniqueSubject); @@ -115,5 +105,3 @@ export const NewFormDialog = ({ </> ); }; - -const normalizeName = (name: string) => name.replaceAll('/t', '-'); diff --git a/browser/data-browser/src/helpers/getNamePartFromProps.ts b/browser/data-browser/src/helpers/getNamePartFromProps.ts new file mode 100644 index 000000000..40639ce41 --- /dev/null +++ b/browser/data-browser/src/helpers/getNamePartFromProps.ts @@ -0,0 +1,14 @@ +import { JSONValue, properties } from '@tomic/react'; +import { randomString } from './randomString'; + +const normalizeName = (name: string) => + encodeURIComponent(name.replaceAll('/t', '-')); + +export const getNamePartFromProps = ( + props: Record<string, JSONValue>, +): string => + normalizeName( + (props?.[properties.shortname] as string) ?? + (props?.[properties.name] as string) ?? + randomString(8), + ); diff --git a/browser/lib/src/store.ts b/browser/lib/src/store.ts index e3efe2670..7ceb1354a 100644 --- a/browser/lib/src/store.ts +++ b/browser/lib/src/store.ts @@ -183,11 +183,13 @@ export class Store { * Will retry until it works. */ public async buildUniqueSubjectFromParts( - ...parts: string[] + parts: string[], + parent?: string, ): Promise<string> { const path = parts.join('/'); + const parentUrl = parent ?? this.getServerUrl(); - return this.findAvailableSubject(path); + return this.findAvailableSubject(path, parentUrl); } /** Creates a random URL. Add a classnme (e.g. 'persons') to make a nicer name */ @@ -773,9 +775,10 @@ export class Store { private async findAvailableSubject( path: string, + parent: string, firstTry = true, ): Promise<string> { - let url = `${this.getServerUrl()}/${path}`; + let url = `${parent}/${path}`; if (!firstTry) { const randomPart = this.randomPart(); @@ -785,7 +788,7 @@ export class Store { const taken = await this.checkSubjectTaken(url); if (taken) { - return this.findAvailableSubject(path, false); + return this.findAvailableSubject(path, parent, false); } return url; From 7915ba628456c5eee20c84c6a259a5e9ac13486c Mon Sep 17 00:00:00 2001 From: AlexMikhalev <alex@metacortex.engineer> Date: Mon, 2 Oct 2023 18:36:23 +0100 Subject: [PATCH 13/13] Example of Earthly, needs fixes for node build paths Signed-off-by: AlexMikhalev <alex@metacortex.engineer> --- Earthfile | 52 +++++++++++++++++++++++++++++++++++++++++++++++ browser/Earthfile | 22 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 Earthfile create mode 100644 browser/Earthfile diff --git a/Earthfile b/Earthfile new file mode 100644 index 000000000..7065b1513 --- /dev/null +++ b/Earthfile @@ -0,0 +1,52 @@ +VERSION 0.7 +PROJECT applied-knowledge-systems/atomic-server +# You can compile front end separately and copy dist folder +# IMPORT ./browser AS browser +FROM rust:latest +WORKDIR /code + +main-pipeline: + PIPELINE --push + TRIGGER push main + TRIGGER pr main + ARG tag=latest + BUILD +build --tag=$tag + +deps: + RUN curl -fsSL https://bun.sh/install | bash + RUN /root/.bun/bin/bun install -y pnpm + # COPY . . + COPY --dir server lib cli desktop Cargo.lock Cargo.toml . + # RUN mkdir src + # RUN touch src/main.rs # adding main.rs stub so cargo fetch works to prepare the cache + RUN cargo fetch + +test: + FROM +deps + RUN cargo test + +build: + FROM +deps + RUN rustup target add x86_64-unknown-linux-musl + RUN apt update && apt install -y musl-tools musl-dev + RUN update-ca-certificates + WORKDIR /app + # FIXME: Joep you need to fix this line and modify Earthfile inside browser + # COPY browser+build/dist ./public + COPY --dir server lib cli desktop Cargo.lock Cargo.toml . + RUN cargo build --release --bin atomic-server --config net.git-fetch-with-cli=true --target x86_64-unknown-linux-musl + RUN strip -s /app/target/x86_64-unknown-linux-musl/release/atomic-server + SAVE ARTIFACT /app/target/x86_64-unknown-linux-musl/release/atomic-server + +docker: + # We only need a small runtime for this step, but make sure glibc is installed + FROM scratch + COPY --chmod=0755 +build/atomic-server /atomic-server-bin + # For a complete list of possible ENV vars or available flags, run with `--help` + ENV ATOMIC_STORE_PATH="/atomic-storage/db" + ENV ATOMIC_CONFIG_PATH="/atomic-storage/config.toml" + ENV ATOMIC_PORT="80" + EXPOSE 80 + VOLUME /atomic-storage + ENTRYPOINT ["/atomic-server-bin"] + SAVE IMAGE --push ghcr.io/applied-knowledge-systems/atomic-server:edge diff --git a/browser/Earthfile b/browser/Earthfile new file mode 100644 index 000000000..1e7313b31 --- /dev/null +++ b/browser/Earthfile @@ -0,0 +1,22 @@ +VERSION 0.7 +PROJECT applied-knowledge-systems/atomic-server +FROM node:latest +WORKDIR browser + +main-pipeline: + PIPELINE --push + TRIGGER push main + TRIGGER pr main + ARG tag=latest + BUILD +build --tag=$tag + +deps: + RUN curl -f https://get.pnpm.io/v6.14.js | node - add --global pnpm + COPY . . + RUN pnpm install --no-frozen-lockfile + SAVE ARTIFACT node_modules /node_modules + +build: + FROM +deps + RUN pnpm run build + SAVE ARTIFACT dist /dist AS LOCAL dist \ No newline at end of file