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/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/.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/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 diff --git a/browser/bun.lockb b/browser/bun.lockb deleted file mode 100755 index a2e44c79c..000000000 Binary files a/browser/bun.lockb and /dev/null differ 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( + '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 ` 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 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( + '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( + '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; + +type BaseObject = { + classes: Record; + properties: Record; +}; + +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> => { + 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 => { + 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 Promise>(); + +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 + +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/data-browser/package.json b/browser/data-browser/package.json index 299289f37..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,8 +36,10 @@ "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", "yamde": "^1.7.1" }, "devDependencies": { 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/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) => ( + + {part} + + ))} + + ); +} + +/** + * 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) { + 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 ( + <> + + } + 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 ( + + + + ); +} + +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 ( + + + + ); +} 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 => ({ + ...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, + name: string, + isRequired: boolean, +): Edge => ({ + ...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[], Edge[]]> { + 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 = new Set(); + + const nodes: Node[] = []; + const edges: Edge[] = []; + + const classToNode = async ( + classSubject: string, + isExtra = false, + ): Promise> => { + 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[], + theme: DefaultTheme, +): Node[] { + 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..1f8da72f9 --- /dev/null +++ b/browser/data-browser/src/chunks/GraphViewer/reactFlowOverrides.css @@ -0,0 +1,9 @@ +.react-flow__handle { + background-color: transparent; + border: none; + cursor: grab; +} + +.react-flow__attribution { + background: unset; +} 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[]; + edges: Edge[]; + setGraph: (nodes: Node[], edges: Edge[]) => 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[]>([]); + const [edges, setEdges] = useState[]>([]); + const [lastPositionChange, setLastPositionChange] = + useState(); + + const setGraph = useCallback( + (_nodes: Node[], _edges: Edge[]) => { + 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/components/AtomicLink.tsx b/browser/data-browser/src/components/AtomicLink.tsx index b83964308..f7ea23508 100644 --- a/browser/data-browser/src/components/AtomicLink.tsx +++ b/browser/data-browser/src/components/AtomicLink.tsx @@ -115,10 +115,6 @@ export const LinkView = styled.a` cursor: pointer; pointer-events: ${props => (props.disabled ? 'none' : 'inherit')}; - svg { - font-size: 60%; - } - &:hover { color: ${props => props.theme.colors.mainLight}; text-decoration: ${p => (p.clean ? 'none' : 'underline')}; diff --git a/browser/data-browser/src/components/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/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/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` 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/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/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 >(({ variant, children, color, ...props }, ref) => { - const Comp = ComponentMap.get(variant!) ?? SimpleIconButton; + const Comp = ComponentMap.get(variant ?? IconButtonVariant.Simple)!; return ( @@ -84,8 +85,8 @@ const IconButtonBase = styled.button` 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)` } `; +const SquareIconButton = styled(IconButtonBase)` + 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)` 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/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` + 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/NewInstanceButton/NewOntologyButton.tsx b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx new file mode 100644 index 000000000..25994e280 --- /dev/null +++ b/browser/data-browser/src/components/NewInstanceButton/NewOntologyButton.tsx @@ -0,0 +1,116 @@ +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, createResourceAndNavigate]); + + const [dialogProps, show, hide] = useDialog({ onSuccess }); + + const onShortnameChange = (e: React.ChangeEvent) => { + const value = stringToSlug(e.target.value); + setShortname(value); + + try { + validateDatatype(value, Datatype.SLUG); + setValid(true); + } catch (_) { + setValid(false); + } + }; + + return ( + <> + + {children} + + +

New Ontology

+ +
{ + e.preventDefault(); + hide(true); + }} + > + + An ontology is a collection of classes and properties that + together describe a concept. Great for data models. + + + + + + +
+
+ + + + +
+ + ); +} + +const H1 = styled.h1` + margin: 0; +`; + +const Explanation = styled.p` + color: ${p => p.theme.colors.textLight}; + max-width: 60ch; +`; diff --git a/browser/data-browser/src/components/NewInstanceButton/index.tsx b/browser/data-browser/src/components/NewInstanceButton/index.tsx index fd822e29f..649d7f8be 100644 --- a/browser/data-browser/src/components/NewInstanceButton/index.tsx +++ b/browser/data-browser/src/components/NewInstanceButton/index.tsx @@ -5,6 +5,7 @@ import { NewInstanceButtonProps } from './NewInstanceButtonProps'; import { NewInstanceButtonDefault } from './NewInstanceButtonDefault'; import { useSettings } from '../../helpers/AppSettings'; import { NewTableButton } from './NewTableButton'; +import { NewOntologyButton } from './NewOntologyButton'; type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; @@ -12,6 +13,7 @@ type InstanceButton = (props: NewInstanceButtonProps) => JSX.Element; const classMap = new Map([ [classes.bookmark, NewBookmarkButton], [classes.table, NewTableButton], + [classes.ontology, NewOntologyButton], ]); /** A button for creating a new instance of some thing */ diff --git a/browser/data-browser/src/components/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 => { - 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/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/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 => ( - + {/* - + */} {aboutMenuItems.map(({ href, icon, helper }) => ( {icon} @@ -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..e9a31ea70 100644 --- a/browser/data-browser/src/components/SideBar/AppMenu.tsx +++ b/browser/data-browser/src/components/SideBar/AppMenu.tsx @@ -1,16 +1,16 @@ -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 { 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 +26,8 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { const event = useRef(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 +55,45 @@ export function AppMenu({ onItemClick }: AppMenuProps): JSX.Element { return () => window.removeEventListener('beforeinstallprompt', listener); }, []); - const items = useMemo(() => { - if (!showInstallButton) { - return appMenuItems; - } - - return [ - { - icon: , - label: 'Install App', - helper: 'Install app to desktop', - handleClickItem: install, - path: constructOpenURL(subject ?? window.location.href), - }, - ...appMenuItems, - ]; - }, [appMenuItems, showInstallButton, subject]); - return ( - <> - App - {items.map(p => ( +
+ } + label={agent ? agentResource.title : 'Login'} + helper='See and edit the current Agent / User (u)' + path={paths.agentSettings} + onClick={onItemClick} + /> + } + label='Settings' + helper='Edit the theme (t)' + path={paths.themeSettings} + onClick={onItemClick} + /> + } + label='Keyboard Shortcuts' + helper='View the keyboard shortcuts (?)' + path={paths.shortcuts} + onClick={onItemClick} + /> + } + label='About' + helper='Welcome page, tells about this app' + path={paths.about} + onClick={onItemClick} + /> + {showInstallButton && ( } + label='Install App' + helper='Install app to desktop' + path={constructOpenURL(subject ?? window.location.href)} + onClick={install} /> - ))} - + )} +
); } diff --git a/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx b/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx new file mode 100644 index 000000000..fd4dbbc2f --- /dev/null +++ b/browser/data-browser/src/components/SideBar/OntologySideBar/OntologiesPanel.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import styled from 'styled-components'; +import { + Collection, + unknownSubject, + urls, + useCollection, + useMemberFromCollection, +} from '@tomic/react'; +import { SideBarItem } from '../SideBarItem'; +import { Row } from '../../Row'; +import { AtomicLink } from '../../AtomicLink'; +import { getIconForClass } from '../../../views/FolderPage/iconMap'; +import { ScrollArea } from '../../ScrollArea'; +import { ErrorLook } from '../../ErrorLook'; + +export function OntologiesPanel(): JSX.Element | null { + const { collection } = useCollection({ + property: urls.properties.isA, + value: urls.classes.ontology, + }); + + return ( + + + {[...Array(collection.totalMembers).keys()].map(index => ( + + ))} + + + ); +} + +const Wrapper = styled.div` + padding-top: 0; + max-height: 10rem; + overflow: hidden; +`; + +const StyledScrollArea = styled(ScrollArea)` + height: 10rem; + overflow-x: hidden; +`; + +interface ItemProps { + index: number; + collection: Collection; +} + +function Item({ index, collection }: ItemProps): JSX.Element { + const resource = useMemberFromCollection(collection, index); + + const Icon = getIconForClass(urls.classes.ontology); + + if (resource.loading) { + return
loading
; + } + + if (resource.error || resource.getSubject() === unknownSubject) { + return ( + + Invalid Resource + + ); + } + + return ( + + + + + {resource.title} + + + + ); +} + +const StyledLink = styled(AtomicLink)` + flex: 1; + overflow: hidden; + white-space: nowrap; +`; diff --git a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx index 9fb63811f..b52b37dcd 100644 --- a/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx +++ b/browser/data-browser/src/components/SideBar/ResourceSideBar/ResourceSideBar.tsx @@ -135,11 +135,6 @@ const TextWrapper = styled.span` display: inline-flex; align-items: center; gap: 0.4rem; - - svg { - /* color: ${p => p.theme.colors.text}; */ - font-size: 0.8em; - } `; const SideBarErrorWrapper = styled(TextWrapper)` diff --git a/browser/data-browser/src/components/SideBar/SideBarItem.ts b/browser/data-browser/src/components/SideBar/SideBarItem.ts index e041c5bd9..e91e78b12 100644 --- a/browser/data-browser/src/components/SideBar/SideBarItem.ts +++ b/browser/data-browser/src/components/SideBar/SideBarItem.ts @@ -26,4 +26,8 @@ export const SideBarItem = styled('span')` &:active { background-color: ${p => p.theme.colors.bg2}; } + + svg { + font-size: 0.8rem; + } `; diff --git a/browser/data-browser/src/components/SideBar/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 ( - + {icon && {icon}} {label} diff --git a/browser/data-browser/src/components/SideBar/SideBarPanel.tsx b/browser/data-browser/src/components/SideBar/SideBarPanel.tsx new file mode 100644 index 000000000..8d87b15c7 --- /dev/null +++ b/browser/data-browser/src/components/SideBar/SideBarPanel.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Collapse } from '../Collapse'; +import { FaCaretRight } from 'react-icons/fa'; +import { transition } from '../../helpers/transition'; + +interface SideBarPanelProps { + title: string; +} + +export function SideBarPanel({ + children, + title, +}: React.PropsWithChildren): JSX.Element { + const [open, setOpen] = React.useState(true); + + return ( + + setOpen(prev => !prev)}> + + + {title} + + + {children} + + ); +} + +export const PanelDevider = styled.h2` + font-size: inherit; + font-weight: normal; + font-family: inherit; + width: 100%; + display: flex; + align-items: center; + gap: 1ch; + + margin-bottom: 0; + + &::before, + &::after { + content: ''; + flex: 1; + border-top: 1px solid ${p => p.theme.colors.bg2}; + } + + cursor: pointer; + &:hover, + &:focus { + &::before, + &::after { + border-color: ${p => p.theme.colors.text}; + } + } +`; + +const DeviderButton = styled.button` + background: none; + border: none; + margin: 0; + padding: 0; +`; + +const Arrow = styled(FaCaretRight)<{ $open: boolean }>` + transform: rotate(${p => (p.$open ? '90deg' : '0deg')}); + ${transition('transform')} +`; + +const Wrapper = styled.div` + width: 100%; + max-height: fit-content; + display: flex; + flex-direction: column; +`; diff --git a/browser/data-browser/src/components/SideBar/index.tsx b/browser/data-browser/src/components/SideBar/index.tsx index ca663d6f4..0031c5e38 100644 --- a/browser/data-browser/src/components/SideBar/index.tsx +++ b/browser/data-browser/src/components/SideBar/index.tsx @@ -9,6 +9,10 @@ import { NavBarSpacer } from '../NavBarSpacer'; 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; @@ -30,6 +34,8 @@ export function SideBar(): JSX.Element { maxSize: 2000, }); + const { enabledPanels } = usePanelList(); + const mountRefs = useCombineRefs([ref, targetRef]); /** @@ -47,6 +53,7 @@ export function SideBar(): JSX.Element { return ( + {/* @ts-ignore */} - - + + {enabledPanels.has(Panel.Ontologies) && ( + + + + )} + + + + + + + @@ -83,12 +101,12 @@ interface SideBarOverlayProps { visible: boolean; } -// eslint-disable-next-line prettier/prettier -const SideBarStyled = styled('nav').attrs(p => ({ +//@ts-ignore +const SideBarStyled = styled.nav.attrs(p => ({ style: { '--width': p.size, }, -}))` +}))` 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: , - label: 'User Settings', - helper: 'See and edit the current Agent / User (u)', - path: paths.agentSettings, - }, - { - icon: , - label: 'Theme Settings', - helper: 'Edit the theme, current Agent, and more. (t)', - path: paths.themeSettings, - }, - { - icon: , - label: 'Keyboard Shortcuts', - helper: 'View the keyboard shortcuts (?)', - path: paths.shortcuts, - }, - { - icon: , - label: 'About', - helper: 'Welcome page, tells about this app', - path: paths.about, - }, -]; diff --git a/browser/data-browser/src/components/SideBar/usePanelList.ts b/browser/data-browser/src/components/SideBar/usePanelList.ts new file mode 100644 index 000000000..ae3a7cc0a --- /dev/null +++ b/browser/data-browser/src/components/SideBar/usePanelList.ts @@ -0,0 +1,47 @@ +import { useLocalStorage } from '@tomic/react'; + +export enum Panel { + Ontologies = 'ontologies', +} + +import { useCallback, useMemo } from 'react'; + +export const usePanelList = (): { + enabledPanels: Set; + enablePanel: (panel: Panel) => void; + disablePanel: (panel: Panel) => void; +} => { + const [enabledPanels, setEnabledPanels] = useLocalStorage( + 'sidebar-panels', + [], + ); + + const enablePanel = useCallback( + (panel: Panel) => { + if (!enabledPanels.includes(panel)) { + setEnabledPanels([...enabledPanels, panel]); + } + }, + [enabledPanels, setEnabledPanels], + ); + + const disablePanel = useCallback( + (panel: Panel) => { + if (enabledPanels.includes(panel)) { + setEnabledPanels(enabledPanels.filter(p => p !== panel)); + } + }, + [enabledPanels, setEnabledPanels], + ); + + const enabledPanelsSet = useMemo( + () => new Set(enabledPanels), + [enabledPanels], + ); + + return { + enabledPanels: enabledPanelsSet, + enablePanel, + disablePanel, + }; +}; diff --git a/browser/data-browser/src/components/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/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` 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/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/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/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 1b3235492..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) { @@ -27,7 +29,7 @@ export function InputResource({
(undefined); const [array, setArray] = useArray(resource, property.subject, { validate: false, + commit, }); /** Add focus to the last added item */ const [lastIsNew, setLastIsNew] = useState(false); @@ -79,8 +81,7 @@ export default function InputResourceArray({ value={subject} setSubject={handleSetSubjectList[index]} error={errMaybe(index)} - onValidate={setErr} - classType={property.classType} + isA={property.classType} handleRemove={handleRemoveRowList[index]} parent={resource.getSubject()} {...props} 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/InputStyles.tsx b/browser/data-browser/src/components/forms/InputStyles.tsx index f56b0ccbb..993ed25c9 100644 --- a/browser/data-browser/src/components/forms/InputStyles.tsx +++ b/browser/data-browser/src/components/forms/InputStyles.tsx @@ -34,7 +34,7 @@ export const InputWrapper = styled.div` 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/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/NewForm/NewFormDialog.tsx b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx index 98a793291..6b620f3dd 100644 --- a/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx +++ b/browser/data-browser/src/components/forms/NewForm/NewFormDialog.tsx @@ -1,4 +1,4 @@ -import { 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'; @@ -11,10 +11,11 @@ import { NewFormProps } from './NewFormPage'; import { NewFormTitle, NewFormTitleVariant } from './NewFormTitle'; import { SubjectField } from './SubjectField'; import { useNewForm } from './useNewForm'; +import { getNamePartFromProps } from '../../../helpers/getNamePartFromProps'; export interface NewFormDialogProps extends NewFormProps { closeDialog: () => void; - initialTitle: string; + initialProps?: Record; onSave: (subject: string) => void; parent: string; } @@ -23,15 +24,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 +48,20 @@ 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 = getNamePartFromProps(initialProps ?? {}); - // 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], + parent, + ); + + 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 +90,7 @@ export const NewFormDialog = ({ classSubject={classSubject} key={`${classSubject}+${subjectValue}`} variant={ResourceFormVariant.Dialog} + onSave={onResourceSave} /> 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/ResourceForm.tsx b/browser/data-browser/src/components/forms/ResourceForm.tsx index e00bb2179..170cf92ab 100644 --- a/browser/data-browser/src/components/forms/ResourceForm.tsx +++ b/browser/data-browser/src/components/forms/ResourceForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useArray, @@ -13,8 +13,7 @@ import { Client, useStore, } from '@tomic/react'; -import { styled } from 'styled-components'; -import { FaCaretDown, FaCaretRight, FaPlus } from 'react-icons/fa'; +import { FaCaretDown, FaCaretRight } from 'react-icons/fa'; import { constructOpenURL } from '../../helpers/navigation'; import { Button } from '../Button'; @@ -43,6 +42,7 @@ export interface ResourceFormProps { resource: Resource; variant?: ResourceFormVariant; + onSave?: () => void; } const nonEssentialProps = [ @@ -50,6 +50,7 @@ const nonEssentialProps = [ properties.parent, properties.read, properties.write, + properties.commit.lastCommit, ]; /** Form for editing and creating a Resource */ @@ -57,6 +58,7 @@ export function ResourceForm({ classSubject, resource, variant, + onSave, }: ResourceFormProps): JSX.Element { const [isAArray] = useArray(resource, properties.isA); @@ -72,10 +74,8 @@ export function ResourceForm({ const [klassIsa] = useString(klass, properties.isA); const [newPropErr, setNewPropErr] = useState(undefined); const navigate = useNavigate(); - const [newProperty, setNewProperty] = useState(undefined); /** A list of custom properties, set by the User while editing this form */ const [tempOtherProps, setTempOtherProps] = useState([]); - const [otherProps, setOtherProps] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); const store = useStore(); const wasNew: boolean = resource.new; @@ -84,14 +84,14 @@ export function ResourceForm({ // We need to read the earlier .new state, because the resource is no // longer new after it was saved, during this callback wasNew && store.notifyResourceManuallyCreated(resource); + onSave?.(); navigate(constructOpenURL(resource.getSubject())); }); // I'm not entirely sure if debouncing is needed here. const debouncedResource = useDebounce(resource, 5000); const [_canWrite, canWriteErr] = useCanWrite(debouncedResource); - /** Builds otherProps */ - useEffect(() => { + const otherProps = useMemo(() => { const allProps = Array.from(resource.getPropVals().keys()); const prps = allProps.filter(prop => { @@ -108,8 +108,8 @@ export function ResourceForm({ return propIsNotRenderedYet && isEssential; }); - setOtherProps(prps.concat(tempOtherProps)); - // I actually want to run this useEffect every time the requires / recommends + return [...prps, ...tempOtherProps]; + // I actually want to run this memo every time the requires / recommends // array changes, but that leads to a weird loop, so that's what the length is for }, [resource, tempOtherProps, requires.length, recommends.length]); @@ -134,23 +134,23 @@ export function ResourceForm({ ); } - function handleAddProp() { + function handleAddProp(newProp: string | undefined) { setNewPropErr(undefined); - if (!Client.isValidSubject(newProperty)) { + if (!Client.isValidSubject(newProp)) { setNewPropErr(new Error('Invalid URL')); return; } - if (!newProperty) { + if (!newProp) { return; } if ( - tempOtherProps.includes(newProperty) || - requires.includes(newProperty) || - recommends.includes(newProperty) + tempOtherProps.includes(newProp) || + requires.includes(newProp) || + recommends.includes(newProp) ) { setNewPropErr( new Error( @@ -158,10 +158,8 @@ export function ResourceForm({ ), ); } else { - setTempOtherProps(tempOtherProps.concat(newProperty)); + setTempOtherProps(prev => [...prev, newProp]); } - - setNewProperty(undefined); } function handleDelete(propertyURL: string) { @@ -211,26 +209,16 @@ export function ResourceForm({ label='add another property...' helper='In Atomic Data, any Resource could have any single Property. Use this field to add new property-value combinations to your resource.' > - - {/* TODO: When adding a property, clear the form. Make the button optional / remove it. */} - +
{ - setNewProperty(set); + setSubject={set => { + handleAddProp(set); }} error={newPropErr} - setError={setNewPropErr} - classType={urls.classes.property} + isA={urls.classes.property} /> - +
{newPropErr && {newPropErr.message}} @@ -250,6 +238,10 @@ export function ResourceForm({ + {variant !== ResourceFormVariant.Dialog && ( <> @@ -266,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/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..b9a3d5780 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx @@ -1,38 +1,34 @@ -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; +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. - */ - classType?: 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 * 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; + /** + * 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 */ error?: Error; - onValidate?: (e: Error | undefined) => void; disabled?: boolean; autoFocus?: boolean; /** Is used when a new item is created using the ResourceSelector */ @@ -49,163 +45,87 @@ export const ResourceSelector = React.memo(function ResourceSelector({ setSubject, value, handleRemove, - classType, + error, + isA, disabled, - onValidate, 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...'; - - if (classType && classTypeTitle?.length > 0) { - placeholder = `Select a ${classTypeTitle} or enter a ${classTypeTitle} URL...`; - } + const handleCreateItem = useMemo(() => { + if (hideCreateOption || !isA) { + return undefined; + } - if (classType && !requiredClass.isReady()) { - placeholder = 'Loading Class...'; - } + return (name: string) => { + setInitialNewTitle(name); + showDialog(); + }; + }, [hideCreateOption, setSubject, showDialog, isA]); return ( - - {touched && error && ( - {error} - )} - {!inDialog && ( + onCreateItem={handleCreateItem} + > + {handleRemove && ( + + + + )} + + {error && {error.message}} + {!inDialog && isA && ( {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..899d1a55b --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -0,0 +1,257 @@ +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 { + autoFocus?: boolean; + value: string | undefined; + isA?: string; + scope?: string; + placeholder?: string; + disabled?: boolean; + required?: boolean; + className?: string; + onChange: (value: string | undefined) => void; + onCreateItem?: (name: string) => void; + onClose?: () => void; +} + +export function SearchBox({ + autoFocus, + value, + isA, + scope, + placeholder, + disabled, + required, + className, + children, + onChange, + onCreateItem, + onClose, +}: 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); + } + + onClose?.(); + }, + [onClose], + ); + + 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', true); + + return; + } + + setError(undefined); + }, [setError, required, value, selectedResource]); + + return ( + + + + { + setOpen(true); + setJustFocussed(true); + }} + > + {value ? ( + + {selectedResource.error + ? selectedResource.getSubject() + : 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; + white-space: nowrap; +`; + +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..3810a56b3 --- /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} + + ) : null} + {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..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,14 +14,27 @@ export function useValidation( const [touched, setTouched] = useState(false); const { setValidations, validations } = useContext(FormValidationContext); - const setError = useCallback((error: string | undefined) => { - setValidations(prev => { - return { - ...prev, - [id]: error, - }; - }); - }, []); + const setError = useCallback( + (error: Error | string | undefined, immediate = false) => { + const err = error instanceof Error ? error.message : 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/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/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 => + normalizeName( + (props?.[properties.shortname] as string) ?? + (props?.[properties.name] as string) ?? + randomString(8), + ); 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/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/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> diff --git a/browser/data-browser/src/routes/NewRoute.tsx b/browser/data-browser/src/routes/NewRoute.tsx index 4ab502c9a..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) { @@ -110,7 +111,7 @@ function NewResourceSelector() { setSubject={setClassInputValue} value={classInputValue} error={error} - classType={urls.classes.class} + isA={urls.classes.class} /> </div> <FileDropzoneInput 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 ( <DriveRowWrapper> - <Title subject={subject} /> + <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/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/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/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/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<HTMLButtonElement>(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 ( + <SearchBoxWrapper> + <SearchBox + autoFocus + value='' + onChange={handleSetValue} + isA={urls.classes.property} + onClose={() => setActive(false)} + onCreateItem={handleCreateProperty} + /> + </SearchBoxWrapper> + ); + } + + return ( + <AddButton + title={`add ${type} property`} + onClick={() => setActive(true)} + ref={triggerRef} + > + <FaPlus /> + </AddButton> + ); +} + +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..e3fe97693 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Class/ClassCardRead.tsx @@ -0,0 +1,72 @@ +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'; +import { ViewTransitionProps } from '../../../helpers/ViewTransitionProps'; +import { transitionName } from '../../../helpers/transitionName'; + +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 ( + <StyledCard subject={subject}> + <Column> + <StyledH3 id={toAnchorId(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> + ); +} + +const StyledCard = styled(Card)<ViewTransitionProps>` + padding-bottom: ${p => p.theme.margin}rem; + ${props => transitionName('resource-page', props.subject)}; +`; + +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 ( + <StyledCard> + <Column id={toAnchorId(subject)}> + <Row center justify='space-between'> + <TitleWrapper> + <FaCube /> + <InputSwitcher + commit + resource={resource} + property={shortnameProp} + /> + </TitleWrapper> + <ResourceContextMenu + subject={subject} + showOnly={contextOptions} + onAfterDelete={handleDelete} + /> + </Row> + <OntologyDescription edit resource={resource} /> + <StyledH4>Requires</StyledH4> + <Column as='ul' gap='0.5rem'> + {requires.map(s => ( + <PropertyLineWrite + key={s} + subject={s} + onRemove={prop => removeProperty('requires', prop)} + /> + ))} + <ButtonWrapper> + <AddPropertyButton creator={resource} type='required' /> + </ButtonWrapper> + </Column> + <StyledH4>Recommends</StyledH4> + <Column as='ul' gap='0.5rem'> + {recommends.map(s => ( + <PropertyLineWrite + key={s} + subject={s} + onRemove={prop => removeProperty('recommends', prop)} + /> + ))} + <ButtonWrapper> + <AddPropertyButton creator={resource} type='recommended' /> + </ButtonWrapper> + </Column> + </Column> + </StyledCard> + ); +} + +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/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/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/InlineDatatype.tsx b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx new file mode 100644 index 000000000..f27c17124 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/InlineDatatype.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { + Resource, + useString, + urls, + reverseDatatypeMapping, + unknownSubject, + useResource, +} from '@tomic/react'; +import { ResourceInline } from '../ResourceInline'; +import { toAnchorId } from './toAnchorId'; +import { useOntologyContext } from './OntologyContext'; + +interface TypeSuffixProps { + resource: Resource; +} + +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]; + + if (!classType) { + return <span>{name}</span>; + } + + return ( + <span> + {name} + {'<'} + {hasClass(classType) ? ( + <LocalLink subject={classType} /> + ) : ( + <ResourceInline subject={classType} /> + )} + {'>'} + </span> + ); +} + +interface LocalLinkProps { + subject: string; +} + +function LocalLink({ subject }: LocalLinkProps): JSX.Element { + const resource = useResource(subject); + + return <a href={`#${toAnchorId(subject)}`}>{resource.title}</a>; +} 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<HTMLInputElement>(null); + + const subject = subjectForClass(resource, inputValue); + + const [dialogProps, show, hide, isOpen] = useDialog({ + onSuccess: () => { + newClass(inputValue, resource, store); + }, + }); + + const handleShortNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { + 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<HTMLInputElement>) => { + if (e.key === 'Escape') { + hide(false); + } + + if (e.key === 'Enter' && isValid) { + hide(true); + } + }; + + return ( + <> + <DashedButton onClick={openAndReset}> + <FaPlus /> Add class + </DashedButton> + <Dialog {...dialogProps}> + {isOpen && ( + <> + <DialogTitle> + <h1>New Class</h1> + </DialogTitle> + <DialogContent> + <Column> + <InputWrapper> + <InputStyled + ref={inputRef} + placeholder='shortname' + value={inputValue} + onChange={handleShortNameChange} + onKeyDown={handleKeyDown} + /> + </InputWrapper> + + <SubjectWrapper key={0}>{subject}</SubjectWrapper> + </Column> + </DialogContent> + <DialogActions> + <Button onClick={() => hide(false)} subtle> + Cancel + </Button> + <Button onClick={() => hide(true)} disabled={!isValid}> + Save + </Button> + </DialogActions> + </> + )} + </Dialog> + </> + ); +} + +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<void>; + removeClass: (subject: string) => Promise<void>; + addProperty: (subject: string) => Promise<void>; + removeProperty: (subject: string) => Promise<void>; + hasProperty: (subject: string) => boolean; + hasClass: (subject: string) => boolean; + ontology: Resource; +} + +export const OntologyContext = createContext<OntologyContext | undefined>({ + 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<OntologyContextProviderProps>) { + 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 ( + <OntologyContext.Provider value={context}> + {children} + </OntologyContext.Provider> + ); +} + +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 <Markdown text={description ?? ''} />; + } + + return <InputMarkdown commit resource={resource} property={property} />; +} 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..7f13fc3e1 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologyPage.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { ResourcePageProps } from '../ResourcePage'; +import { urls, useArray, useCanWrite } from '@tomic/react'; +import { OntologySidebar } from './OntologySidebar'; +import styled from 'styled-components'; +import { ClassCardRead } from './Class/ClassCardRead'; +import { PropertyCardRead } from './Property/PropertyCardRead'; +import ResourceCard from '../Card/ResourceCard'; +import { Button } from '../../components/Button'; +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'; +import { Graph } from './Graph'; +import { CreateInstanceButton } from './CreateInstanceButton'; + +export function OntologyPage({ resource }: ResourcePageProps) { + 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 [editMode, setEditMode] = React.useState(false); + + return ( + <OntologyContextProvider ontology={resource}> + <FullPageWrapper edit={editMode}> + <TitleSlot> + <Row justify='space-between'> + <h1>{resource.title}</h1> + {canWrite && + (editMode ? ( + <Button onClick={() => setEditMode(false)}> + <FaEye /> + Read + </Button> + ) : ( + <Button onClick={() => setEditMode(true)}> + <FaEdit /> + Edit + </Button> + ))} + </Row> + </TitleSlot> + <SidebarSlot> + <OntologySidebar ontology={resource} /> + </SidebarSlot> + <ListSlot> + <Column> + <OntologyDescription edit={editMode} resource={resource} /> + <h2>Classes</h2> + <StyledUl> + {classes.map(c => ( + <li key={c}> + {editMode ? ( + <ClassCardWrite subject={c} /> + ) : ( + <ClassCardRead subject={c} /> + )} + </li> + ))} + {editMode && ( + <li> + <NewClassButton resource={resource} /> + </li> + )} + </StyledUl> + <h2>Properties</h2> + <StyledUl> + {properties.map(c => ( + <li key={c}> + {editMode ? ( + <PropertyCardWrite subject={c} /> + ) : ( + <PropertyCardRead subject={c} /> + )} + </li> + ))} + </StyledUl> + <h2>Instances</h2> + <StyledUl> + {instances.map(c => ( + <li key={c}> + <ResourceCard subject={c} id={toAnchorId(c)} /> + </li> + ))} + {editMode && <CreateInstanceButton ontology={resource} />} + </StyledUl> + </Column> + </ListSlot> + {!editMode && ( + <GraphSlot> + <Graph ontology={resource} /> + </GraphSlot> + )} + </FullPageWrapper> + </OntologyContextProvider> + ); +} + +const FullPageWrapper = styled.div<{ edit: boolean }>` + --ontology-graph-position: sticky; + --ontology-graph-ratio: 9 / 16; + + display: grid; + 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: ${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; + --ontology-graph-position: sticky; + --ontology-graph-ratio: 16/9; + } + + padding-bottom: 3rem; +`; + +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: 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 new file mode 100644 index 000000000..e9c95eb2f --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/OntologySidebar.tsx @@ -0,0 +1,134 @@ +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'; +import { toAnchorId } from './toAnchorId'; + +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 + + } + > +
    + {classes.map(c => ( + + ))} +
+ +
+ + 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<{ error: boolean }>` + padding-left: 1rem; + padding-block: 0.2rem; + border-radius: ${p => p.theme.radius}; + display: block; + color: ${p => (p.error ? p.theme.colors.alert : 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; + padding-left: 0.5rem; + max-height: 100vh; +`; diff --git a/browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx new file mode 100644 index 000000000..96d5e924f --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyCardRead.tsx @@ -0,0 +1,62 @@ +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'; +import { toAnchorId } from '../toAnchorId'; + +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/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/Property/PropertyLineRead.tsx b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineRead.tsx new file mode 100644 index 000000000..c35e5e9f7 --- /dev/null +++ b/browser/data-browser/src/views/OntologyPage/Property/PropertyLineRead.tsx @@ -0,0 +1,43 @@ +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 { ErrorLook } from '../../../components/ErrorLook'; + +interface PropertyLineReadProps { + subject: string; +} + +export function PropertyLineRead({ + subject, +}: PropertyLineReadProps): JSX.Element { + const resource = useResource(subject); + const [description] = useString(resource, urls.properties.description); + + if (resource.error) { + return ( + + Property does not exist anymore + + ); + } + + 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/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/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/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/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/data-browser/src/views/TablePage/PropertyForm/ExternalPropertyDialog.tsx b/browser/data-browser/src/views/TablePage/PropertyForm/ExternalPropertyDialog.tsx index 77b4f24de..f9c9b0db4 100644 --- a/browser/data-browser/src/views/TablePage/PropertyForm/ExternalPropertyDialog.tsx +++ b/browser/data-browser/src/views/TablePage/PropertyForm/ExternalPropertyDialog.tsx @@ -9,7 +9,7 @@ import { import { ResourceSelector } from '../../../components/forms/ResourceSelector'; import { Resource, urls, useArray } from '@tomic/react'; import { Button } from '../../../components/Button'; -import { ErrorLook } from '../../../components/ErrorLook'; +import { FormValidationContextProvider } from '../../../components/forms/formValidation/FormValidationContextProvider'; interface ExternalPropertyDialogProps { open: boolean; @@ -23,7 +23,7 @@ export function ExternalPropertyDialog({ tableClassResource, }: ExternalPropertyDialogProps): JSX.Element { const [subject, setSubject] = useState(); - 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} ); } 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/datatypes.ts b/browser/lib/src/datatypes.ts index 1de686d2d..f78c1c376 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.STRING]: 'String', + [Datatype.SLUG]: 'Slug', + [Datatype.MARKDOWN]: 'Markdown', + [Datatype.INTEGER]: 'Integer', + [Datatype.FLOAT]: 'Float', + [Datatype.BOOLEAN]: 'Boolean', + [Datatype.DATE]: 'Date', + [Datatype.TIMESTAMP]: 'Timestamp', + [Datatype.ATOMIC_URL]: 'Resource', + [Datatype.RESOURCEARRAY]: 'ResourceArray', + [Datatype.UNKNOWN]: 'Unknown', +}; 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; + properties: Record; +}; + +// 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 = Classes[C]['requires']; +export type Recommends = Classes[C]['recommends']; + +type PropsOfClass = { + [P in Requires]: P; +} & { + [P in Recommends]?: 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 + ? PropTypeMapping[Prop] + : Prop extends Recommends + ? PropTypeMapping[Prop] | undefined + : PropTypeMapping[Prop] | undefined + : JSONValue, +> = Returns; + +/** Type of the dynamically created resource.props field */ +export type QuickAccesPropType = { + readonly [Prop in keyof PropsOfClass 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(); + +/** 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 { /** 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 { + const props: QuickAccesPropType = {}; + + 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 { 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(propUrl: string): T { - return this.propvals.get(propUrl) as T; + public get>( + 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, + >( + 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..7ceb1354a 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 = ( + resource: Resource, +) => void; /** Callback called when the stores agent changes */ type AgentCallback = (agent: Agent | undefined) => void; type ErrorCallback = (e: Error) => void; @@ -180,11 +183,13 @@ export class Store { * Will retry until it works. */ public async buildUniqueSubjectFromParts( - ...parts: string[] + parts: string[], + parent?: string, ): Promise { 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 */ @@ -202,7 +207,7 @@ export class Store { /** * Always fetches resource from the server then adds it to the store. */ - public async fetchResourceFromServer( + public async fetchResourceFromServer( /** The resource URL to be fetched */ subject: string, opts: { @@ -220,9 +225,9 @@ export class Store { /** HTTP Body for POSTing */ body?: ArrayBuffer | string; } = {}, - ): Promise { + ): Promise> { if (opts.setLoading) { - const newR = new Resource(subject); + const newR = new Resource(subject); newR.loading = true; this.addResources(newR); } @@ -304,13 +309,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( subject: string = unknownSubject, opts: FetchOpts = {}, - ): Resource { + ): Resource { // 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(unknownSubject, opts.newResource); return newR; } @@ -318,7 +323,7 @@ export class Store { const found = this.resources.get(subject); if (!found) { - const newR = new Resource(subject, opts.newResource); + const newR = new Resource(subject, opts.newResource); newR.loading = true; this.addResources(newR); @@ -345,7 +350,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 { + public async getResourceAsync( + subject: string, + ): Promise> { const found = this.resources.get(subject); if (found && found.isReady()) { @@ -357,7 +364,7 @@ export class Store { return new Promise((resolve, reject) => { const defaultTimeout = 5000; - const cb = res => { + const cb: ResourceCallback = res => { this.unsubscribe(subject, cb); resolve(res); }; @@ -768,9 +775,10 @@ export class Store { private async findAvailableSubject( path: string, + parent: string, firstTry = true, ): Promise { - let url = `${this.getServerUrl()}/${path}`; + let url = `${parent}/${path}`; if (!firstTry) { const randomPart = this.randomPart(); @@ -780,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; diff --git a/browser/lib/src/urls.ts b/browser/lib/src/urls.ts index bbf2d230e..98f25ac8d 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. */ @@ -141,8 +142,15 @@ 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', + properties: 'https://atomicdata.dev/properties/properties', + instances: 'https://atomicdata.dev/properties/instances', }; export const datatypes = { 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 6021e06aa..cd41a1fac 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -97,6 +97,19 @@ importers: specifier: ^3.0.5 version: 3.2.7(@types/node@16.18.39) + cli: + dependencies: + '@tomic/lib': + 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 + data-browser: dependencies: '@bugsnag/core': @@ -108,6 +121,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,12 +205,18 @@ 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 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) @@ -1653,6 +1675,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: @@ -2755,6 +2788,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'} @@ -2921,6 +3062,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: @@ -2941,6 +3261,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: @@ -3814,6 +4138,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'} @@ -3857,6 +4186,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 @@ -4078,6 +4411,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 @@ -8014,6 +8412,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'} @@ -9195,6 +9612,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 @@ -9724,6 +10149,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 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( subject: string = unknownSubject, opts?: FetchOpts, -): Resource { +): Resource { const store = useStore(); - const [resource, setResource] = useState( + const [resource, setResource] = useState>( 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) { // When a change happens, set the new Resource. setResource(updated); } diff --git a/browser/react/src/useLocalStorage.ts b/browser/react/src/useLocalStorage.ts index 95aa48669..604ce8a8f 100644 --- a/browser/react/src/useLocalStorage.ts +++ b/browser/react/src/useLocalStorage.ts @@ -1,4 +1,9 @@ -import { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; + +const listeners = new Map< + string, + Set<(value: React.SetStateAction) => void> +>(); export type SetLocalStorageValue = (value: T | ((val: T) => T)) => void; /** @@ -39,8 +44,12 @@ export function useLocalStorage( // Allow value to be a function so we have same API as useState const valueToStore = value instanceof Function ? value(storedValue) : value; + // Save state - setStoredValue(valueToStore); + for (const listener of listeners.get(key) || []) { + listener(valueToStore); + } + // Save to local storage window.localStorage.setItem(key, JSON.stringify(valueToStore)); } catch (error) { @@ -51,5 +60,17 @@ export function useLocalStorage( [storedValue, key], ); + useEffect(() => { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + + listeners.get(key)?.add(setStoredValue as (value: unknown) => void); + + return () => { + listeners.get(key)?.delete(setStoredValue as (value: unknown) => void); + }; + }, [key]); + return [storedValue, setValue]; } 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( collection: Collection, index: number, -): Resource { +): Resource { const [subject, setSubject] = useState(unknownSubject); const resource = useResource(subject); diff --git a/lib/src/populate.rs b/lib/src/populate.rs index 4102918bf..8270ea1a1 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(), @@ -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(), 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";