diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index b1a7845d..28279f43 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -10,7 +10,7 @@ on: branches: [ "main" ] env: - reactodia_workspace_ref: 'v0.33.0' + reactodia_workspace_ref: 'v0.34.0' jobs: build: diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 41c2f86a..9dc42d7a 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: env: - reactodia_workspace_ref: 'v0.33.0' + reactodia_workspace_ref: 'v0.34.0' jobs: build: diff --git a/docs/components/form-input.md b/docs/components/form-input.md index f535ef29..a878d4c4 100644 --- a/docs/components/form-input.md +++ b/docs/components/form-input.md @@ -1,5 +1,5 @@ --- -title: +title: --- # Form input components @@ -8,8 +8,10 @@ Reactodia provides basic built-in components to edit entity or relation properti | Form input component | Description | |----------------------|-------------| -| [``](/docs/api/workspace/variables/FormInputList.md) | Form input to edit multiple values in a list of specified single value inputs. | -| [``](/docs/api/workspace/functions/FormInputText.md) | Form input to edit a single value as a plain string with an optional language. | +| [``](/docs/api/forms/functions/InputList.md) | Form input to edit multiple values in a list of specified single value inputs. | +| [``](/docs/api/forms/functions/InputText.md) | Form input to edit a single value as a plain string with an optional language. | +| [``](/docs/api/forms/functions/InputSelect.md) | Form input to select a value from a predefined list of variants. | +| [``](/docs/api/forms/functions/InputFile.md) | Form input to upload files and display previously uploaded files. | :::warning Currently form input components are considered **unstable** so there might be breaking changes in their API in the future. @@ -60,10 +62,17 @@ function Example() { search={null} navigator={{expanded: false}} visualAuthoring={{ - inputResolver: (property, inputProps) => - property === RDF_COMMENT - ? - : undefined, + propertyEditor: options => ( + ( + + )} + /> + ), }} /> @@ -72,7 +81,7 @@ function Example() { } function MultilineTextInput(props: Reactodia.FormInputSingleProps) { - return ; + return ; } render(); diff --git a/docs/components/unified-search.md b/docs/components/unified-search.md index aae50cc9..28ce41a3 100644 --- a/docs/components/unified-search.md +++ b/docs/components/unified-search.md @@ -100,9 +100,15 @@ function SearchWithNpm() { }, ], []); + const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { + const {getCommandBus} = context; + getCommandBus(Reactodia.UnifiedSearchTopic) + .trigger('focus', { sectionKey: 'entities' }); + }, []); + return (
- + [`model.getElementType()`](/docs/api/workspace/classes/DataDiagramModel.md#getelementtype) can be used to get the placeholder or loaded data. | +| [`model.createLinkType()`](/docs/api/workspace/classes/DataDiagramModel.md#createlinktype) | Requests to load a relation type if it has not been loaded yet.
[`model.getLinkType()`](/docs/api/workspace/classes/DataDiagramModel.md#getlinktype) can be used to get the placeholder or loaded data. | +| [`model.createPropertyType()`](/docs/api/workspace/classes/DataDiagramModel.md#createpropertytype) | Requests to load a property type if it has not been loaded yet.
[`model.getPropertyType()`](/docs/api/workspace/classes/DataDiagramModel.md#getpropertytype) can be used to get the placeholder or loaded data. | + +#### Example: manual request and subscription for an entity type + +```ts +function MyElementTypeBadge(props: { elementTypeIri }) { + const {elementTypeIri} = props; + const {model} = Reactodia.useWorkspace(); + const t = Reactodia.useTranslation(); + const language = Reactodia.useObservedProperty( + model.events, 'changeLanguage', () => model.language + ); + + const [elementType, setElementType] = React.useState(); + React.useEffect(() => { + setElementType(model.createElementType(elementTypeIri)); + }, [elementTypeIri]); + + const data = Reactodia.useSyncStore( + Reactodia.useEventStore(elementType?.events, 'changeData'), + () => elementType?.data + ); + return ( +
+ {t.formatLabel(data?.label, elementTypeIri, language)} +
+ ); +} +``` + +:::note +When requesting the data manually, make sure to subscribe to created instances to re-render when the data loads via [`useObservedProperty()`](/docs/api/workspace/functions/useObservedProperty.md), [`useEventStore()`](/docs/api/workspace/functions/useEventStore.md) or manual [event subscription](/docs/concepts/event-system.md). +::: + +### `useKeyedSyncStore()` + +[`useKeyedSyncStore`](/docs/api/workspace/functions/useKeyedSyncStore.md) hook allows to subscribe to a set of targets and fetch the data for each: + +| Store | Description | +|-----------------|-------------| +| [`subscribeElementTypes`](/docs/api/workspace/variables/subscribeElementTypes.md) | Subscribe and fetch entity types. | +| [`subscribeLinkTypes`](/docs/api/workspace/variables/subscribeLinkTypes.md) | Subscribe and fetch relation types. | +| [`subscribeElementTypes`](/docs/api/workspace/variables/subscribePropertyTypes.md) | Subscribe and fetch property types. | + +#### Example: subscribe to property types from an [element template](/docs/components/canvas.md#customization) + +```ts +function MyElement(props: Reactodia.TemplateProps) { + const {model} = Reactodia.useWorkspace(); + const t = Reactodia.useTranslation(); + const language = Reactodia.useObservedProperty( + model.events, 'changeLanguage', () => model.language + ); + + const data = props.element instanceof Reactodia.EntityElement + ? props.element.data : undefined; + // Select only properties with at least one value + const properties = Object.entries(data?.properties ?? {}) + .filter(([iri, values]) => values.length > 0); + // Subscribe and fetch property types + Reactodia.useKeyedSyncStore( + Reactodia.subscribePropertyTypes, + properties.map(([iri]) => iri), + model + ); + + return ( +
    + {properties.map(([iri, values])) => { + // Get property type to display + const property = model.getPropertyType(iri); + return ( +
  • + {t.formatLabel(property?.data?.label, iri, language)}{': '} + {values.map(v => v.value).join(', ')} +
  • + ); + }} +
+ ); +} +``` + +### `useProvidedEntities()` + +[`useProvidedEntities`](/docs/api/workspace/functions/useProvidedEntities.md) hook allows to loads entity data for a target set of IRIs even when the entities are not displayed on the canvas at all. + +#### Example: load entity variants for a [select input](/docs/components/form-input.md) + +```ts +function MyInputForShape(props: Forms.InputSingleProps) { + const {factory} = props; + const {model} = Reactodia.useWorkspace(); + + const {data: entities} = Reactodia.useProvidedEntities( + model.dataProvider, + [shapes.Square, shapes.Circle, shapes.Triangle] + ); + const language = Reactodia.useObservedProperty( + model.events, 'changeLanguage', () => model.language + ); + const variants = React.useMemo( + () => Array.from(entities.values(), (item): Forms.InputSelectVariant => ({ + value: factory.namedNode(item.id), + label: model.locale.formatEntityLabel(item, language), + })), + [entities, language, factory] + ); + + return ( + + ); +} +``` + +## Data Locale + +It is possible to customize how library components display graph data by supplying a custom [`DataLocaleProvider`](/docs/api/workspace/interfaces/DataLocaleProvider.md) when calling [model.importLayout()](/docs/api/workspace/classes/DataDiagramModel.md#importlayout). + +Data locale provider can be used to alter the following behavior: + - [locale.selectEntityLabel()](/docs/api/workspace/interfaces/DataLocaleProvider.md#selectentitylabel) and [locale.formatEntityLabel()](/docs/api/workspace/interfaces/DataLocaleProvider.md#formatentitylabel) to select or format default entity label from its properties (by default it looks for `rdfs:label` property values); + - [locale.selectEntityImageUrl()](/docs/api/workspace/interfaces/DataLocaleProvider.md#selectentityimageurl) to select default entity thumbnail image IRI from its properties (by default it looks for `schema:thumbnailUrl` property value); + - [locale.prepareAnchor()](/docs/api/workspace/interfaces/DataLocaleProvider.md#prepareanchor) to provide props for an anchor (`` link) to a resource IRI; + - [locale.resolveAssetUrl()](/docs/api/workspace/interfaces/DataLocaleProvider.md#resolveasseturl) to resolve an IRI/URL to referenced data asset for display or download, e.g. an image (thumbnail) or a downloadable file. + +:::tip +It is possible to extend [`DefaultDataLocaleProvider`](/docs/api/workspace/classes/DefaultDataLocaleProvider.md) to slightly alter its behavior instead of implementing the full [`DataLocaleProvider`](/docs/api/workspace/interfaces/DataLocaleProvider.md) interface. +::: diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 3ee438a2..659bc5ce 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -55,6 +55,7 @@ const config: Config = { { entryPoints: [ `${libraryPathPrefix}/reactodia-workspace/src/workspace.ts`, + `${libraryPathPrefix}/reactodia-workspace/src/forms/index.ts`, `${libraryPathPrefix}/reactodia-workspace/src/layout-sync.ts`, `${libraryPathPrefix}/reactodia-workspace/src/layout.worker.ts`, `${libraryPathPrefix}/reactodia-workspace/src/legacy-styles.tsx`, diff --git a/package-lock.json b/package-lock.json index 922fa374..c8500ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@reactodia/reactodia.github.io", - "version": "0.31.2", + "version": "0.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@reactodia/reactodia.github.io", - "version": "0.31.2", + "version": "0.33.0", "dependencies": { "@docusaurus/core": "3.8.1", "@docusaurus/preset-classic": "3.8.1", @@ -14,7 +14,7 @@ "@easyops-cn/docusaurus-search-local": "^0.51.0", "@mdx-js/react": "^3.1.0", "@reactodia/hashmap": "^0.2.1", - "@reactodia/workspace": "^0.33.0", + "@reactodia/workspace": "^0.34.0", "clsx": "^2.1.1", "n3": "^1.17.2", "prism-react-renderer": "^2.4.1", @@ -4751,9 +4751,9 @@ "license": "MIT" }, "node_modules/@reactodia/workspace": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@reactodia/workspace/-/workspace-0.33.0.tgz", - "integrity": "sha512-VzPkTA7tgPOlv59HSkiHLk2UEeSTr5tqxnJ6oEPHmrTsTbt3UnzC/cAlmeyYSNSbv7RCpZm3g05fQuBC3ZaDvA==", + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@reactodia/workspace/-/workspace-0.34.0.tgz", + "integrity": "sha512-lzRgEXhZRXcbBdKaAH9imfvAXgLWshLyE2etwcd/3tXCJbm52N6AM5irBv+pDxmwv6QwSSl2P8agpulHAHOp3A==", "license": "LGPL-2.1-or-later", "dependencies": { "@reactodia/hashmap": "^0.2.1", diff --git a/package.json b/package.json index b24738ff..0453ebf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@reactodia/reactodia.github.io", - "version": "0.33.0", + "version": "0.34.0", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -21,7 +21,7 @@ "@easyops-cn/docusaurus-search-local": "^0.51.0", "@mdx-js/react": "^3.1.0", "@reactodia/hashmap": "^0.2.1", - "@reactodia/workspace": "^0.33.0", + "@reactodia/workspace": "^0.34.0", "clsx": "^2.1.1", "n3": "^1.17.2", "prism-react-renderer": "^2.4.1", diff --git a/sidebars.ts b/sidebars.ts index 7bc05623..9d6a6a69 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -78,6 +78,10 @@ const sidebars: SidebarsConfig = { label: '@reactodia/workspace', collapsed: false, }, + { + ...findSidebarCategory(typedocItems, 'forms'), + label: '/forms', + }, { ...findSidebarCategory(typedocItems, 'layout-sync'), label: '/layout-sync', diff --git a/src/examples/ExampleMetadata.ts b/src/examples/ExampleMetadata.ts index 806637bc..7af91f24 100644 --- a/src/examples/ExampleMetadata.ts +++ b/src/examples/ExampleMetadata.ts @@ -7,7 +7,7 @@ const owl = vocabulary('http://www.w3.org/2002/07/owl#', [ 'ObjectProperty', ]); -const rdfs = vocabulary('http://www.w3.org/2000/01/rdf-schema#', [ +export const rdfs = vocabulary('http://www.w3.org/2000/01/rdf-schema#', [ 'comment', 'domain', 'range', @@ -16,6 +16,10 @@ const rdfs = vocabulary('http://www.w3.org/2000/01/rdf-schema#', [ 'subPropertyOf', ]); +export const example = vocabulary('http://www.example.com/', [ + 'workflowStatus', +]); + const SIMULATED_DELAY: number = 50; /* ms */ export class ExampleMetadataProvider extends Reactodia.BaseMetadataProvider { @@ -149,18 +153,22 @@ export class ExampleMetadataProvider extends Reactodia.BaseMetadataProvider { valueShape: {termType: 'Literal'}, order: 1, }); - properties.set(rdfs.comment, { + properties.set(example.workflowStatus, { valueShape: {termType: 'Literal'}, order: 2, }); + properties.set(rdfs.comment, { + valueShape: {termType: 'Literal'}, + order: 3, + }); properties.set(Reactodia.schema.thumbnailUrl, { valueShape: {termType: 'NamedNode'}, maxCount: 1, - order: 3, + order: 4, }); properties.set(rdfs.seeAlso, { valueShape: {termType: 'NamedNode'}, - order: 4, + order: 5, }); } return {properties}; @@ -169,8 +177,13 @@ export class ExampleMetadataProvider extends Reactodia.BaseMetadataProvider { await Reactodia.delay(SIMULATED_DELAY, {signal}); const properties = new Map(); if (this.editableRelations.has(linkType)) { + properties.set(example.workflowStatus, { + valueShape: {termType: 'Literal'}, + order: 1, + }); properties.set(rdfs.comment, { valueShape: {termType: 'Literal'}, + order: 2, }); } return {properties}; diff --git a/src/examples/PlaygroundGraphAuthoring.tsx b/src/examples/PlaygroundGraphAuthoring.tsx index 444273be..5ec07795 100644 --- a/src/examples/PlaygroundGraphAuthoring.tsx +++ b/src/examples/PlaygroundGraphAuthoring.tsx @@ -1,8 +1,11 @@ import * as React from 'react'; import * as Reactodia from '@reactodia/workspace'; +import * as Forms from '@reactodia/workspace/forms'; import * as N3 from 'n3'; -import { ExampleMetadataProvider, ExampleValidationProvider } from './ExampleMetadata'; +import { + ExampleMetadataProvider, ExampleValidationProvider, rdfs, example, +} from './ExampleMetadata'; import { ExampleToolbarMenu } from './ExampleCommon'; const Layouts = Reactodia.defineLayoutWorker(() => new Worker( @@ -22,7 +25,7 @@ export function PlaygroundGraphAuthoring() { }); const {onMount} = Reactodia.useLoadedWorkspace(async ({context, signal}) => { - const {model, editor, getCommandBus, performLayout} = context; + const {model, editor, translation: t, getCommandBus, performLayout} = context; editor.setAuthoringMode(true); let turtleData: string; @@ -33,14 +36,22 @@ export function PlaygroundGraphAuthoring() { turtleData = dataSource.data; } - const dataProvider = new Reactodia.RdfDataProvider(); + const uploader = new Forms.MemoryFileUploader({ + factory: Reactodia.Rdf.DefaultDataFactory, + disposeSignal: signal, + }); + const dataProvider = new GraphDataProvider({}, uploader); try { dataProvider.addGraph(new N3.Parser().parse(turtleData)); } catch (err) { throw new Error('Error parsing RDF graph data', {cause: err}); } - await model.importLayout({dataProvider, signal}); + await model.importLayout({ + dataProvider, + locale: new GraphLocaleProvider({model, translation: t}, uploader), + signal, + }); if (dataSource.type === 'url') { const entities = [ @@ -80,26 +91,113 @@ export function PlaygroundGraphAuthoring() { } visualAuthoring={{ - inputResolver: (property, inputProps) => property === 'http://www.w3.org/2000/01/rdf-schema#comment' - ? - : undefined, + propertyEditor: options => ( + { + if (property === Reactodia.schema.thumbnailUrl) { + return ; + } else if (property === rdfs.comment) { + return ( + + ); + } else if (property === example.workflowStatus) { + return ( + + ); + } + return ( + + ); + }} + /> + ), }} />
); } +class GraphDataProvider extends Reactodia.RdfDataProvider { + constructor( + options: Reactodia.RdfDataProviderOptions, + readonly uploader: Forms.FileUploadProvider + ) { + super(options); + } +} + +class GraphLocaleProvider extends Reactodia.DefaultDataLocaleProvider { + constructor( + options: Reactodia.DefaultDataLocaleProviderOptions, + private readonly uploader: Forms.FileUploadProvider + ) { + super(options); + } + + async resolveAssetUrl(assetIri: string, options: { signal?: AbortSignal; }): Promise { + const {signal} = options; + const resolved = await this.uploader.resolveFileUrl(assetIri, {signal}); + return resolved ?? assetIri; + } +} + class RenameSubclassOfProvider extends Reactodia.RenameLinkToLinkStateProvider { override canRename(link: Reactodia.Link): boolean { - return ( - link instanceof Reactodia.AnnotationLink || - link.typeId === 'http://www.w3.org/2000/01/rdf-schema#subClassOf' - ); + return ( + link instanceof Reactodia.AnnotationLink || + link.typeId === 'http://www.w3.org/2000/01/rdf-schema#subClassOf' + ); } } -function MultilineTextInput(props: Reactodia.FormInputSingleProps) { - return ; +function WorkflowStatusInput(props: Forms.InputSingleProps) { + const {factory} = props; + const variants = React.useMemo((): Forms.InputSelectVariant[] => [ + {value: factory.literal('draft'), label: 'draft'}, + {value: factory.literal('reviewed'), label: 'reviewed'}, + {value: factory.literal('published'), label: 'published'}, + ], []); + return ; +} + +function ThumbnailInput(props: Forms.InputMultiProps) { + const {model} = Reactodia.useWorkspace(); + const provider = model.dataProvider instanceof GraphDataProvider + ? model.dataProvider : undefined; + const {data: fileMetadata, error: loadError} = Reactodia.useProvidedEntities( + provider, + props.values.filter(v => v.termType === 'NamedNode').map(v => v.value) + ); + if (!provider) { + return null; + } + return ( + <> + /^image\//.test(item.type)} + fileMetadata={fileMetadata} + /> + {loadError ? ( + + ) : null} + + ); +} + +function MultilineTextInput(props: Forms.InputSingleProps) { + return ; } function ToolbarActionOpenTurtleGraph(props: { diff --git a/src/theme/ReactLiveScope/_forms.js b/src/theme/ReactLiveScope/_forms.js new file mode 100644 index 00000000..f544a522 --- /dev/null +++ b/src/theme/ReactLiveScope/_forms.js @@ -0,0 +1 @@ +export * as Forms from '@reactodia/workspace/forms'; diff --git a/src/theme/ReactLiveScope/index.js b/src/theme/ReactLiveScope/index.js index 7305d44c..91d67227 100644 --- a/src/theme/ReactLiveScope/index.js +++ b/src/theme/ReactLiveScope/index.js @@ -10,6 +10,10 @@ const ReactLiveScope = { const {Reactodia} = require('./_reactodia'); return Reactodia; }, + get Forms() { + const {Forms} = require('./_forms'); + return Forms; + }, get N3() { const {N3} = require('./_n3'); return N3;