From 0a6d16b4826ce65edbf30e2203ace3318daee077 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:10:18 +0200 Subject: [PATCH 01/31] rdf forms component --- src/primitives/components/rdf-form/RDFForm.ts | 93 +++++++++++++++++++ .../components/rdf-form/RDForm.stories.ts | 85 +++++++++++++++++ src/primitives/lib/rdfFormsHelper.js | 67 +++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 src/primitives/components/rdf-form/RDFForm.ts create mode 100644 src/primitives/components/rdf-form/RDForm.stories.ts create mode 100644 src/primitives/lib/rdfFormsHelper.js diff --git a/src/primitives/components/rdf-form/RDFForm.ts b/src/primitives/components/rdf-form/RDFForm.ts new file mode 100644 index 000000000..d93b731c3 --- /dev/null +++ b/src/primitives/components/rdf-form/RDFForm.ts @@ -0,0 +1,93 @@ +import { customElement, property, state } from 'lit/decorators.js' +import { html } from 'lit/html.js' +import WebComponent from '../../lib/WebComponent' +import ns from '../../../lib/ns' +import { loadDocument, sortBySequence } from '../../lib/rdfFormsHelper' +import { sym, Namespace } from 'rdflib' +import { store } from 'solid-logic' + +@customElement('solid-ui-rdf-form') +export default class RDFForm extends WebComponent { + @state() + private accessor _parsedUrl: URL | null = null + + @property({ type: String }) + accessor whichForm = 'this' + + @property({ type: String }) + accessor rdfTurtleFormatSource = '' + + @property({ type: String }) + accessor rdfName = '' + + @property({ type: String }) + set rdfURI (value: string) { + try { + this._parsedUrl = new URL(value) + } catch { + this._parsedUrl = null // Handle invalid URL + } + } + + get rdfURI (): string { + return this._parsedUrl ? this._parsedUrl.href : '' + } + + render () { + // TODO: detect format + loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) + const document = sym(this.rdfURI) // rdflib NamedNode for the document + const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file + const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form + console.log('formThis:', formThis.value) + + const parts = store.each(formThis, ns.ui('parts'), null, document) + const partsBySequence = sortBySequence(store, parts) + const uiFields = partsBySequence.map(item => ((item as any).value || String(item)).split('#').pop()) + console.log('document:', document) + console.log('exactForm:', exactForm) + console.log('uiFields:', uiFields) + + return html` + ${uiFields.map(part => { + switch (part) { + case 'PhoneField': + case 'EmailField': + case 'ColorField': + case 'DateField': + case 'DateTimeField': + case 'TimeField': + case 'NumericField': + case 'IntegerField': + case 'DecimalField': + case 'FloatField': + case 'TextField': + case 'SingleLineTextField': + case 'NamedNodeURIField': + return html`` + case 'MultiLineTextField': + return html`` + case 'BooleanField': + return html`` + case 'TristateField': + return html`` + case 'Classifier': + return html`` + case 'Choice': + return html`` + case 'Multiple': + return html`` + case 'Options': + return html`` + case 'AutocompleteField': + return html`` + case 'Comment': + case 'Heading': + return html`` + default: + return html`
Unknown part type: ${part}
` + } + })} + ` + } +} diff --git a/src/primitives/components/rdf-form/RDForm.stories.ts b/src/primitives/components/rdf-form/RDForm.stories.ts new file mode 100644 index 000000000..b44dc8ae1 --- /dev/null +++ b/src/primitives/components/rdf-form/RDForm.stories.ts @@ -0,0 +1,85 @@ +import { html } from 'lit' +import { defineStoryRender } from '../../../storybook' + +const meta = { + title: 'Design System/RDF Form', + args: { + rdfTurtleFormatSource: ` + # A Form with 2 fields and a nested subgroup + + :form a ui:Form; + ui:parts (:nameField :emailField :addresses) . + + :nameField a ui:SingleLineTextField ; + ui:property vcard:fn; + ui:label "name" . + + :emailField a ui:EmailField ; + ui:property vcard:hasEmail; # @@ check + ui:label "email" . + + :addresses + a ui:Multiple ; # -- Allows zero or one or more + ui:part :oneAddress ; + ui:property vcard:hasAddress . + + :oneAddress + a ui:Group ; # A subgroup of the main form + ui:parts ( :street :locality :postcode :region :country ). + + :street + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:street-address ; + ui:size "40" . + + :locality + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:locality ; + ui:size "40" . + + :postcode + a ui:SingleLineTextField ; + ui:maxLength "25" ; + ui:property vcard:postal-code ; + ui:size "25" . + + :region + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:region ; + ui:size "40" . + + :country + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:country-name ; + ui:size "40" .`, + rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', + whichForm: 'this', + rdfName: 'dummyFormTestFile.ttl' + }, + + argTypes: { + rdfTurtleFormatSource: { control: 'text' }, + rdfURI: { control: 'text' }, + whichForm: { control: 'text' }, + rdfName: { control: 'text' } + }, +} as const + +const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName }) => { + return html` + + + ` +}) + +export default meta + +export const Primary = { render } diff --git a/src/primitives/lib/rdfFormsHelper.js b/src/primitives/lib/rdfFormsHelper.js new file mode 100644 index 000000000..f81dbaa75 --- /dev/null +++ b/src/primitives/lib/rdfFormsHelper.js @@ -0,0 +1,67 @@ +import { sym, Namespace, parse } from 'rdflib' +import { widgets } from 'solid-ui' +import ns from '../../lib/ns' + +const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/' + +export function renderForm ( + div, + subject, // Represents the RDF that fills the form + formSource, // The imported form Turtle source + formName, // The name of the form file (e.g., 'socialMedia.ttl') + store, + dom, + editableProfile, + whichForm) { + // --- Form resource setup --- + const formUri = baseUri + formName // Full URI to the form file + const exactForm = whichForm || 'this' // If there are more 'a ui:Form' elements in a form file + const formThis = Namespace(formUri + '#')(exactForm) // NamedNode for #this in the form + + loadDocument(store, formSource, formName, formUri) + + widgets.appendForm( + dom, + div, + {}, + subject, + formThis, + editableProfile, + (ok, mes) => { + if (!ok) widgets.errorMessageBlock(dom, mes) + } + ) +} // renderForm + +// we need to load into the store some additional information about Social Media accounts +export function loadDocument ( + store, + documentSource, + documentName, + documentURI +) { + const finalDocumentUri = documentURI || baseUri + documentName // Full URI to the file + const document = sym(finalDocumentUri) // rdflib NamedNode for the document + + if (!store.holds(undefined, undefined, undefined, document)) { + // we are using the social media form because it contains the information we need + // the form can be used for both use cases: create UI for edit and render UI for display + parse(documentSource, store, finalDocumentUri, 'text/turtle', () => null) // Load doc directly + } +} + +export function sortBySequence ( + store, + list +) { + const subfields = list.map(function (p) { + const k = store.any(p, ns.ui('sequence')) + return [k || 9999, p] + }) + subfields.sort(function (a, b) { + return a[0] - b[0] + }) + return subfields.map(function (pair) { + return pair[1] + }) +} From 7c5a7c3277802e9382a69d2bdcddbee50edc0dd1 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:40:14 +0200 Subject: [PATCH 02/31] rendered first rdf forms elements --- src/primitives/components/rdf-form/RDFForm.ts | 17 ++++++++++++++++- .../components/rdf-form/RDForm.stories.ts | 13 ++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/primitives/components/rdf-form/RDFForm.ts b/src/primitives/components/rdf-form/RDFForm.ts index d93b731c3..0614c4358 100644 --- a/src/primitives/components/rdf-form/RDFForm.ts +++ b/src/primitives/components/rdf-form/RDFForm.ts @@ -43,7 +43,22 @@ export default class RDFForm extends WebComponent { const parts = store.each(formThis, ns.ui('parts'), null, document) const partsBySequence = sortBySequence(store, parts) - const uiFields = partsBySequence.map(item => ((item as any).value || String(item)).split('#').pop()) + const partItems = (partsBySequence || []).flatMap(item => { + if (item && typeof item === 'object' && 'elements' in item && Array.isArray((item as any).elements)) { + return (item as any).elements + } + return [item] + }) + const uiFields = partItems.map(item => { + const types = store.each(item as any, ns.rdf('type'), null, document) + const typeNode = types[0] + const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item)) + const hashIndex = value.lastIndexOf('#') + return hashIndex >= 0 ? value.slice(hashIndex + 1) : value + }) + console.log('parts:', parts) + console.log('partsBySequence:', partsBySequence) + console.log('partItems:', partItems) console.log('document:', document) console.log('exactForm:', exactForm) console.log('uiFields:', uiFields) diff --git a/src/primitives/components/rdf-form/RDForm.stories.ts b/src/primitives/components/rdf-form/RDForm.stories.ts index b44dc8ae1..34ad1bbe7 100644 --- a/src/primitives/components/rdf-form/RDForm.stories.ts +++ b/src/primitives/components/rdf-form/RDForm.stories.ts @@ -1,11 +1,17 @@ import { html } from 'lit' import { defineStoryRender } from '../../../storybook' +import './RDFForm' const meta = { title: 'Design System/RDF Form', args: { rdfTurtleFormatSource: ` - # A Form with 2 fields and a nested subgroup + @prefix : . +@prefix dc: . +@prefix ui: . +@prefix vcard: . + +# A Form with 2 fields and a nested subgroup :form a ui:Form; ui:parts (:nameField :emailField :addresses) . @@ -55,9 +61,10 @@ const meta = { a ui:SingleLineTextField ; ui:maxLength "128" ; ui:property vcard:country-name ; - ui:size "40" .`, + ui:size "40" . +`, rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', - whichForm: 'this', + whichForm: 'form', rdfName: 'dummyFormTestFile.ttl' }, From 35c066fe2e06edf168f54613baf6ad895013b3c6 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:31:39 +0200 Subject: [PATCH 03/31] preliminary work for rdf-input --- .../components/rdf-form/RDForm.stories.ts | 108 +++++++++--------- .../components/rdf-input/RDFInput.ts | 41 +++++++ .../{rdfFormsHelper.js => rdfFormsHelper.ts} | 60 ++++------ 3 files changed, 119 insertions(+), 90 deletions(-) create mode 100644 src/primitives/components/rdf-input/RDFInput.ts rename src/primitives/lib/{rdfFormsHelper.js => rdfFormsHelper.ts} (50%) diff --git a/src/primitives/components/rdf-form/RDForm.stories.ts b/src/primitives/components/rdf-form/RDForm.stories.ts index 34ad1bbe7..bcbfeea1d 100644 --- a/src/primitives/components/rdf-form/RDForm.stories.ts +++ b/src/primitives/components/rdf-form/RDForm.stories.ts @@ -7,63 +7,63 @@ const meta = { args: { rdfTurtleFormatSource: ` @prefix : . -@prefix dc: . -@prefix ui: . -@prefix vcard: . + @prefix dc: . + @prefix ui: . + @prefix vcard: . -# A Form with 2 fields and a nested subgroup + # A Form with 2 fields and a nested subgroup :form a ui:Form; - ui:parts (:nameField :emailField :addresses) . - - :nameField a ui:SingleLineTextField ; - ui:property vcard:fn; - ui:label "name" . - - :emailField a ui:EmailField ; - ui:property vcard:hasEmail; # @@ check - ui:label "email" . - - :addresses - a ui:Multiple ; # -- Allows zero or one or more - ui:part :oneAddress ; - ui:property vcard:hasAddress . - - :oneAddress - a ui:Group ; # A subgroup of the main form - ui:parts ( :street :locality :postcode :region :country ). - - :street - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:street-address ; - ui:size "40" . - - :locality - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:locality ; - ui:size "40" . - - :postcode - a ui:SingleLineTextField ; - ui:maxLength "25" ; - ui:property vcard:postal-code ; - ui:size "25" . - - :region - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:region ; - ui:size "40" . - - :country - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:country-name ; - ui:size "40" . -`, - rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', + ui:parts (:nameField :emailField :addresses) . + + :nameField a ui:SingleLineTextField ; + ui:property vcard:fn; + ui:label "name" . + + :emailField a ui:EmailField ; + ui:property vcard:hasEmail; # @@ check + ui:label "email" . + + :addresses + a ui:Multiple ; # -- Allows zero or one or more + ui:part :oneAddress ; + ui:property vcard:hasAddress . + + :oneAddress + a ui:Group ; # A subgroup of the main form + ui:parts ( :street :locality :postcode :region :country ). + + :street + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:street-address ; + ui:size "40" . + + :locality + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:locality ; + ui:size "40" . + + :postcode + a ui:SingleLineTextField ; + ui:maxLength "25" ; + ui:property vcard:postal-code ; + ui:size "25" . + + :region + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:region ; + ui:size "40" . + + :country + a ui:SingleLineTextField ; + ui:maxLength "128" ; + ui:property vcard:country-name ; + ui:size "40" . + `, + rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL whichForm: 'form', rdfName: 'dummyFormTestFile.ttl' }, diff --git a/src/primitives/components/rdf-input/RDFInput.ts b/src/primitives/components/rdf-input/RDFInput.ts new file mode 100644 index 000000000..426d4c5b4 --- /dev/null +++ b/src/primitives/components/rdf-input/RDFInput.ts @@ -0,0 +1,41 @@ +import { customElement, property } from 'lit/decorators.js' +import { html } from 'lit/html.js' +import ns from '../../../lib/ns' +import WebComponent from '../../../primitives/lib/WebComponent' +import { store } from 'solid-logic' +import { NamedNode, Namespace, sym } from 'rdflib' +import { label } from '../../../utils' +import { loadDocument } from '../../lib/rdfFormsHelper' + +// import '../input' + +@customElement('solid-ui-rdf-input') +export default class RDFInput extends WebComponent { + // example RDF Turtle format source: + // :nameField a ui:SingleLineTextField ; + // ui:property vcard:fn; + // ui:label "name" . + + // form here is the subject :nameField + @property({ type: String }) + accessor rdf = '' + + render () { + const exactForm = this.whichForm // nameField + const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form + const document = sym(this.rdfURI) + + const uiProperty = label(store.any(formThis, ns.ui('property')), true) as NamedNode | undefined + const uiLabel = store.any(formThis, ns.ui('label')) + const inputLabel = uiLabel ? uiLabel.value : uiProperty ? uiProperty.value.split('#').pop() : 'Input' + + // TODO: I am not finding suppressEmptyUneditable in ui ontology + const suppressEmptyUneditable = store.anyJS(formThis, ns.ui('suppressEmptyUneditable'), null, document) + + const uri = mostSpecificClassURI(form) + let params = fieldParams[uri] + + return html` + + ` +} diff --git a/src/primitives/lib/rdfFormsHelper.js b/src/primitives/lib/rdfFormsHelper.ts similarity index 50% rename from src/primitives/lib/rdfFormsHelper.js rename to src/primitives/lib/rdfFormsHelper.ts index f81dbaa75..c4ff20879 100644 --- a/src/primitives/lib/rdfFormsHelper.js +++ b/src/primitives/lib/rdfFormsHelper.ts @@ -1,44 +1,15 @@ -import { sym, Namespace, parse } from 'rdflib' -import { widgets } from 'solid-ui' +import { sym, LiveStore, parse, NamedNode } from 'rdflib' import ns from '../../lib/ns' +import { label } from '../../utils' const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/' -export function renderForm ( - div, - subject, // Represents the RDF that fills the form - formSource, // The imported form Turtle source - formName, // The name of the form file (e.g., 'socialMedia.ttl') - store, - dom, - editableProfile, - whichForm) { - // --- Form resource setup --- - const formUri = baseUri + formName // Full URI to the form file - const exactForm = whichForm || 'this' // If there are more 'a ui:Form' elements in a form file - const formThis = Namespace(formUri + '#')(exactForm) // NamedNode for #this in the form - - loadDocument(store, formSource, formName, formUri) - - widgets.appendForm( - dom, - div, - {}, - subject, - formThis, - editableProfile, - (ok, mes) => { - if (!ok) widgets.errorMessageBlock(dom, mes) - } - ) -} // renderForm - // we need to load into the store some additional information about Social Media accounts export function loadDocument ( - store, - documentSource, - documentName, - documentURI + store: LiveStore, + documentSource: string, + documentName: string, + documentURI?: string ) { const finalDocumentUri = documentURI || baseUri + documentName // Full URI to the file const document = sym(finalDocumentUri) // rdflib NamedNode for the document @@ -51,7 +22,7 @@ export function loadDocument ( } export function sortBySequence ( - store, + store: LiveStore, list ) { const subfields = list.map(function (p) { @@ -65,3 +36,20 @@ export function sortBySequence ( return pair[1] }) } + +/** + * Which class of field is this? Relies on http://www.w3.org/2000/01/rdf-schema#subClassOf and + * https://linkeddata.github.io/rdflib.js/doc/classes/formula.html#bottomtypeuris + * to find the most specific RDF type if there are multiple. + * + * @param x a form field, e.g. `namedNode('https://timbl.com/timbl/Public/Test/Forms/individualForm.ttl#fullNameField')` + * @returns the URI of the most specific known class, e.g. `http://www.w3.org/ns/ui#SingleLineTextField` + */ +export function mostSpecificClassURI (store: LiveStore,x: Node): string { + const ft = store.findTypeURIs(x as any) + const bot = store.bottomTypeURIs(ft) // most specific + const bots: any[] = [] + for (const b in bot) bots.push(b) + // if (bots.length > 1) throw "Didn't expect "+x+" to have multiple bottom types: "+bots + return bots[0] +} From 344b904ac7de062539347fab985d1c352c3f134c Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:32:43 +0200 Subject: [PATCH 04/31] added also data to forms and wired the rdf input --- src/components/rdf-form/RDFForm.ts | 41 ++++++- src/components/rdf-form/RDForm.stories.ts | 34 +++++- src/components/rdf-form/index.ts | 4 + src/components/rdf-input/RDFInput.ts | 77 ++++++++---- src/components/rdf-input/index.ts | 4 + src/lib/forms/fieldParams.ts | 140 ++++++++++++++++++++++ src/lib/forms/rdfFormsHelper.ts | 16 +-- 7 files changed, 277 insertions(+), 39 deletions(-) create mode 100644 src/components/rdf-form/index.ts create mode 100644 src/components/rdf-input/index.ts create mode 100644 src/lib/forms/fieldParams.ts diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index b4982deef..6a6def9bc 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -5,11 +5,14 @@ import ns from '../../lib/ns' import { loadDocument, sortBySequence } from '../../lib/forms/rdfFormsHelper' import { sym, Namespace } from 'rdflib' import { store } from 'solid-logic' +import '@/components/rdf-input' @customElement('solid-ui-rdf-form') export default class RDFForm extends WebComponent { @state() private accessor _parsedUrl: URL | null = null + @state() + private accessor _parsedUrl2: URL | null = null @property({ type: String }) accessor whichForm = 'this' @@ -33,9 +36,43 @@ export default class RDFForm extends WebComponent { return this._parsedUrl ? this._parsedUrl.href : '' } + private defaultContexts = ` + @prefix foaf: . + @prefix sched: . + @prefix cal: . + @prefix dc: . + @prefix rdfs: . + @prefix ui: . + @prefix trip: . + @prefix vcard: . + @prefix xsd: . + ` + @property({ type: String }) + accessor whichSubject = 'me' + + @property({ type: String }) + accessor subjectTurtleFormatSource = '' + + @property({ type: String }) + accessor subjectName = '' + + @property({ type: String }) + set subjectURI (value: string) { + try { + this._parsedUrl2 = new URL(value) + } catch { + this._parsedUrl2 = null // Handle invalid URL + } + } + + get subjectURI (): string { + return this._parsedUrl2 ? this._parsedUrl2.href : '' + } + render () { // TODO: detect format - loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) + loadDocument(store, this.rdfTurtleFormatSource + this.defaultContexts, this.rdfName, this.rdfURI) // load form + loadDocument(store, this.subjectTurtleFormatSource + this.defaultContexts, this.subjectName, this.subjectURI) // load data const document = sym(this.rdfURI) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form @@ -79,7 +116,7 @@ export default class RDFForm extends WebComponent { case 'TextField': case 'SingleLineTextField': case 'NamedNodeURIField': - return html`` + return html` ` case 'MultiLineTextField': return html`` case 'BooleanField': diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index 5d6e7a730..0305b0e2e 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -7,9 +7,15 @@ const meta = { args: { rdfTurtleFormatSource: ` @prefix : . - @prefix dc: . - @prefix ui: . + @prefix foaf: . + @prefix sched: . + @prefix cal: . + @prefix dc: . + @prefix rdfs: . + @prefix ui: . + @prefix trip: . @prefix vcard: . + @prefix xsd: . # A Form with 2 fields and a nested subgroup @@ -65,24 +71,40 @@ const meta = { `, rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL whichForm: 'form', - rdfName: 'dummyFormTestFile.ttl' + rdfName: 'dummyFormTestFile.ttl', + whichSubject: 'me', + subjectTurtleFormatSource: ` + @prefix : . + + :me a vcard:Individual ; + vcard:fn "Alice" ; + vcard:hasEmail . + `, + subjectName: 'alice.ttl', + subjectURI: 'https://solidos.solidcommunity.net/public/2021/alice.ttl' }, argTypes: { rdfTurtleFormatSource: { control: 'text' }, rdfURI: { control: 'text' }, whichForm: { control: 'text' }, - rdfName: { control: 'text' } + rdfName: { control: 'text' }, + subjectTurtleFormatSource: { control: 'text' }, + subjectName: { control: 'text' }, + subjectURI: { control: 'text' } }, } as const -const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName }) => { +const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName, subjectTurtleFormatSource, subjectName, subjectURI }) => { return html` + rdfName=${rdfName} + subjectTurtleFormatSource=${subjectTurtleFormatSource} + subjectName=${subjectName} + subjectURI=${subjectURI}> ` }) diff --git a/src/components/rdf-form/index.ts b/src/components/rdf-form/index.ts new file mode 100644 index 000000000..487b3a932 --- /dev/null +++ b/src/components/rdf-form/index.ts @@ -0,0 +1,4 @@ +import RDFForm from './RDFForm' + +export { RDFForm } +export default RDFForm diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 843664021..402c9ee84 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -2,40 +2,71 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' import { customElement, WebComponent } from '@/lib/components' -import { store } from 'solid-logic' -import { NamedNode, Namespace, sym } from 'rdflib' +import { LiveStore, NamedNode } from 'rdflib' import { label } from '../../utils' -import { loadDocument } from '../../lib/forms/rdfFormsHelper' - -// import '../input' +import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' +import { fieldParams, InputType } from '../../lib/forms/fieldParams' +import { ifDefined } from 'lit/directives/if-defined.js' @customElement('solid-ui-rdf-input') export default class RDFInput extends WebComponent { - // example RDF Turtle format source: + // example RDF Turtle format source: // :nameField a ui:SingleLineTextField ; // ui:property vcard:fn; // ui:label "name" . - // form here is the subject :nameField - @property({ type: String }) - accessor rdf = '' + // store needs to contain the form and also the data it applies to + @property({ type: LiveStore }) + accessor store + + // form here is the subject :nameField + @property({ type: String }) + accessor formSubject + + @property({ type: String }) + accessor inputSubject - render () { - const exactForm = this.whichForm // nameField - const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form - const document = sym(this.rdfURI) + render () { + // HTML input part + const uiPropertyTerm = this.store.any(this.formSubject, ns.ui('property')) as NamedNode | undefined + const uiProperty = uiPropertyTerm ? label(uiPropertyTerm, true) : '' + const uiLabel = this.store.any(this.formSubject, ns.ui('label')) + const inputLabel = uiLabel ? uiLabel.value : uiProperty + // readonly + let readonly = false + // TODO: I am not finding suppressEmptyUneditable in ui ontology + const suppressEmptyUneditable = this.store.anyJS(this.formSubject, ns.ui('suppressEmptyUneditable')) + if (suppressEmptyUneditable) { + readonly = true + } - const uiProperty = label(store.any(formThis, ns.ui('property')), true) as NamedNode | undefined - const uiLabel = store.any(formThis, ns.ui('label')) - const inputLabel = uiLabel ? uiLabel.value : uiProperty ? uiProperty.value.split('#').pop() : 'Input' + const uri = mostSpecificClassURI(this.store, this.formSubject) + const params = fieldParams[uri] ?? {} + const inputType: InputType = params.type ?? 'text' - // TODO: I am not finding suppressEmptyUneditable in ui ontology - const suppressEmptyUneditable = store.anyJS(formThis, ns.ui('suppressEmptyUneditable'), null, document) + // input values + const defaultInputValueFromStore = this.store.any(this.formSubject, ns.ui('default')) + const inputValueFromStore = this.store.any(this.inputSubject, ns.ui('property')) - const uri = mostSpecificClassURI(form) - let params = fieldParams[uri] - + let inputTerm: string | undefined + + const term = inputValueFromStore || defaultInputValueFromStore + if (term && 'value' in term && term.value) { + const decoded = decodeURIComponent(term.value) + inputTerm = params.defaultInputValue + ? decoded.replace(params.defaultInputValue, '').replace(/ /g, '') + : decoded + } + + if (inputLabel) { + return html` + + + ` + } else { return html` - - ` + + ` + } + } } diff --git a/src/components/rdf-input/index.ts b/src/components/rdf-input/index.ts new file mode 100644 index 000000000..bffe87ff2 --- /dev/null +++ b/src/components/rdf-input/index.ts @@ -0,0 +1,4 @@ +import RDFInput from './RDFInput' + +export { RDFInput } +export default RDFInput diff --git a/src/lib/forms/fieldParams.ts b/src/lib/forms/fieldParams.ts new file mode 100644 index 000000000..6f8342f2b --- /dev/null +++ b/src/lib/forms/fieldParams.ts @@ -0,0 +1,140 @@ +import ns from '../../lib/ns' +import { style } from '../../lib/style' + +export type InputType = + | 'hidden' + | 'text' + | 'search' + | 'tel' + | 'url' + | 'email' + | 'password' + | 'datetime' + | 'date' + | 'month' + | 'week' + | 'time' + | 'datetime-local' + | 'number' + | 'range' + | 'color' + | 'checkbox' + | 'radio' + | 'file' + | 'submit' + | 'image' + | 'reset' + | 'button' + +export type FieldParamsObject = { + size?: number, // input element size attribute + type?: InputType, // input element type attribute. Default: 'text' (not for Comment and Heading) + element?: string, // element type to use (Comment and Heading only) + style?: string, // style to use + dt?: string, // xsd data type for the RDF Literal corresponding to the field value. Default: xsd:string + defaultInputValue?: string, // e.g. 'mailto:'. Default value in input field, will be removed when displaying actual value to user. + namedNode?: boolean, // if true, field value corresponds to the URI of an RDF NamedNode. Overrides dt and defaultInputValue. + pattern?: RegExp // for client-side input validation; field will go red if violated, green if ok +} + +/** + * The fieldParams object defines various constants + * for use in various form fields. Depending on the + * field in questions, different values may be read + * from here. + */ +export const fieldParams: { [ fieldUri: string ]: FieldParamsObject } = { + /** + * Text field + * + * For possible date popups see e.g. http://www.dynamicdrive.com/dynamicindex7/jasoncalendar.htm + * or use HTML5: http://www.w3.org/TR/2011/WD-html-markup-20110113/input.date.html + */ + [ns.ui('ColorField').uri]: { + size: 9, + type: 'color', + style: 'height: 3em;', // around 1.5em is padding + dt: 'color', + pattern: /^\s*#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f])?\s*$/ + }, // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color + + [ns.ui('DateField').uri]: { + size: 20, + type: 'date', + dt: 'date', + pattern: /^\s*[0-9][0-9][0-9][0-9](-[0-1]?[0-9]-[0-3]?[0-9])?Z?\s*$/ + }, + + [ns.ui('DateTimeField').uri]: { + size: 20, + type: 'datetime-local', // See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime + dt: 'dateTime', + pattern: /^\s*[0-9][0-9][0-9][0-9](-[0-1]?[0-9]-[0-3]?[0-9])?(T[0-2][0-9]:[0-5][0-9](:[0-5][0-9])?)?Z?\s*$/ + }, + + [ns.ui('TimeField').uri]: { + size: 10, + type: 'time', + dt: 'time', + pattern: /^\s*([0-2]?[0-9]:[0-5][0-9](:[0-5][0-9])?)\s*$/ + }, + + [ns.ui('IntegerField').uri]: { + size: 12, + style: 'text-align: right;', + dt: 'integer', + pattern: /^\s*-?[0-9]+\s*$/ + }, + + [ns.ui('DecimalField').uri]: { + size: 12, + style: 'text-align: right;', + dt: 'decimal', + pattern: /^\s*-?[0-9]*(\.[0-9]*)?\s*$/ + }, + + [ns.ui('FloatField').uri]: { + size: 12, + style: 'text-align: right;', + dt: 'float', + pattern: /^\s*-?[0-9]*(\.[0-9]*)?((e|E)-?[0-9]*)?\s*$/ + }, + + [ns.ui('SingleLineTextField').uri]: { + + }, + [ns.ui('NamedNodeURIField').uri]: { + namedNode: true + }, + [ns.ui('TextField').uri]: { + + }, + + [ns.ui('PhoneField').uri]: { + size: 20, + defaultInputValue: 'tel:', + pattern: /^\+?[\d-]+[\d]*$/ + }, + + [ns.ui('EmailField').uri]: { + size: 30, + defaultInputValue: 'mailto:', + pattern: /^\s*.*@.*\..*\s*$/ // @@ Get the right regexp here + }, + + [ns.ui('Group').uri]: { + style: style.formGroupStyle + }, + + /** + * Non-interactive fields + */ + [ns.ui('Comment').uri]: { + element: 'p', + style: style.commentStyle + }, + [ns.ui('Heading').uri]: { + element: 'h3', + style: style.formHeadingStyle + } +} diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index d80ddc4af..2f572a28e 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -41,14 +41,14 @@ export function sortBySequence ( * https://linkeddata.github.io/rdflib.js/doc/classes/formula.html#bottomtypeuris * to find the most specific RDF type if there are multiple. * - * @param x a form field, e.g. `namedNode('https://timbl.com/timbl/Public/Test/Forms/individualForm.ttl#fullNameField')` + * @param subject a form field, e.g. `namedNode('https://timbl.com/timbl/Public/Test/Forms/individualForm.ttl#fullNameField')` * @returns the URI of the most specific known class, e.g. `http://www.w3.org/ns/ui#SingleLineTextField` */ -export function mostSpecificClassURI (store: LiveStore,x: Node): string { - const ft = store.findTypeURIs(x as any) - const bot = store.bottomTypeURIs(ft) // most specific - const bots: any[] = [] - for (const b in bot) bots.push(b) - // if (bots.length > 1) throw "Didn't expect "+x+" to have multiple bottom types: "+bots - return bots[0] +export function mostSpecificClassURI (store: LiveStore, subject: Node): string { + const typeUri = store.findTypeURIs(subject as any) + const specificTypes = store.bottomTypeURIs(typeUri) // most specific + const finalTypes: any[] = [] + for (const t in specificTypes) finalTypes.push(t) + // if (finalTypes.length > 1) throw "Didn't expect "+subject+" to have multiple bottom types: "+finalTypes + return finalTypes[0] } From 9d5565a9fd1c5bf6bf42a1d8178c5369a83b2d61 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:51:20 +0200 Subject: [PATCH 05/31] fixed rdf input --- src/components/rdf-form/RDFForm.ts | 39 +++++++++-------------- src/components/rdf-form/RDForm.stories.ts | 1 + src/components/rdf-input/RDFInput.ts | 29 +++++++++++------ src/lib/forms/rdfFormsHelper.ts | 13 +++++--- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 6a6def9bc..d3500c006 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -11,6 +11,7 @@ import '@/components/rdf-input' export default class RDFForm extends WebComponent { @state() private accessor _parsedUrl: URL | null = null + @state() private accessor _parsedUrl2: URL | null = null @@ -36,17 +37,6 @@ export default class RDFForm extends WebComponent { return this._parsedUrl ? this._parsedUrl.href : '' } - private defaultContexts = ` - @prefix foaf: . - @prefix sched: . - @prefix cal: . - @prefix dc: . - @prefix rdfs: . - @prefix ui: . - @prefix trip: . - @prefix vcard: . - @prefix xsd: . - ` @property({ type: String }) accessor whichSubject = 'me' @@ -71,12 +61,11 @@ export default class RDFForm extends WebComponent { render () { // TODO: detect format - loadDocument(store, this.rdfTurtleFormatSource + this.defaultContexts, this.rdfName, this.rdfURI) // load form - loadDocument(store, this.subjectTurtleFormatSource + this.defaultContexts, this.subjectName, this.subjectURI) // load data + loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form + loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data const document = sym(this.rdfURI) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form - console.log('formThis:', formThis.value) const parts = store.each(formThis, ns.ui('parts'), null, document) const partsBySequence = sortBySequence(store, parts) @@ -91,18 +80,16 @@ export default class RDFForm extends WebComponent { const typeNode = types[0] const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item)) const hashIndex = value.lastIndexOf('#') - return hashIndex >= 0 ? value.slice(hashIndex + 1) : value + return { + value: item, + fieldValue: hashIndex >= 0 ? value.slice(hashIndex + 1) : value + } }) - console.log('parts:', parts) - console.log('partsBySequence:', partsBySequence) - console.log('partItems:', partItems) - console.log('document:', document) - console.log('exactForm:', exactForm) - console.log('uiFields:', uiFields) + const me = Namespace(this.subjectURI + '#')(this.whichSubject) return html` ${uiFields.map(part => { - switch (part) { + switch (part.fieldValue) { case 'PhoneField': case 'EmailField': case 'ColorField': @@ -115,8 +102,12 @@ export default class RDFForm extends WebComponent { case 'FloatField': case 'TextField': case 'SingleLineTextField': - case 'NamedNodeURIField': - return html` ` + case 'NamedNodeURIField': { + const formSubject = typeof part.value === 'string' + ? store.sym(part.value) + : part.value + return html` ` + } case 'MultiLineTextField': return html`` case 'BooleanField': diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index 0305b0e2e..fd54bb8d1 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -75,6 +75,7 @@ const meta = { whichSubject: 'me', subjectTurtleFormatSource: ` @prefix : . + @prefix vcard: . :me a vcard:Individual ; vcard:fn "Alice" ; diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 402c9ee84..f189447c7 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -20,33 +20,44 @@ export default class RDFInput extends WebComponent { accessor store // form here is the subject :nameField - @property({ type: String }) + @property({ type: NamedNode }) accessor formSubject - @property({ type: String }) + @property({ type: NamedNode }) accessor inputSubject render () { + const formSubject = typeof this.formSubject === 'string' + ? this.store.sym(this.formSubject) + : this.formSubject + const inputSubject = typeof this.inputSubject === 'string' + ? this.store.sym(this.inputSubject) + : this.inputSubject + + const formGraph = formSubject.doc ? formSubject.doc() : undefined + // HTML input part - const uiPropertyTerm = this.store.any(this.formSubject, ns.ui('property')) as NamedNode | undefined + const uiPropertyTerm = this.store.any(formSubject, ns.ui('property'), null, formGraph) as NamedNode | undefined const uiProperty = uiPropertyTerm ? label(uiPropertyTerm, true) : '' - const uiLabel = this.store.any(this.formSubject, ns.ui('label')) + const uiLabel = this.store.any(formSubject, ns.ui('label'), null, formGraph) const inputLabel = uiLabel ? uiLabel.value : uiProperty - // readonly + let readonly = false // TODO: I am not finding suppressEmptyUneditable in ui ontology - const suppressEmptyUneditable = this.store.anyJS(this.formSubject, ns.ui('suppressEmptyUneditable')) + const suppressEmptyUneditable = this.store.anyJS(formSubject, ns.ui('suppressEmptyUneditable'), null, formGraph) if (suppressEmptyUneditable) { readonly = true } - const uri = mostSpecificClassURI(this.store, this.formSubject) + const uri = mostSpecificClassURI(this.store, formSubject) const params = fieldParams[uri] ?? {} const inputType: InputType = params.type ?? 'text' // input values - const defaultInputValueFromStore = this.store.any(this.formSubject, ns.ui('default')) - const inputValueFromStore = this.store.any(this.inputSubject, ns.ui('property')) + const defaultInputValueFromStore = this.store.any(formSubject, ns.ui('default')) + const inputValueFromStore = uiPropertyTerm + ? this.store.any(inputSubject, uiPropertyTerm) + : undefined let inputTerm: string | undefined diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index 2f572a28e..645c10beb 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -13,11 +13,16 @@ export function loadDocument ( const finalDocumentUri = documentURI || baseUri + documentName // Full URI to the file const document = sym(finalDocumentUri) // rdflib NamedNode for the document - if (!store.holds(undefined, undefined, undefined, document)) { - // we are using the social media form because it contains the information we need - // the form can be used for both use cases: create UI for edit and render UI for display - parse(documentSource, store, finalDocumentUri, 'text/turtle', () => null) // Load doc directly + if (store.holds(undefined, undefined, undefined, document)) { + store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document)) } + // we are using the social media form because it contains the information we need + // the form can be used for both use cases: create UI for edit and render UI for display + parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => { + if (err) { + console.error('loadDocument parse error for', finalDocumentUri, err) + } + }) } export function sortBySequence ( From 0656653229e851b17fdddca9fb11366637f9b63c Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:49:41 +0200 Subject: [PATCH 06/31] Refactor for better readability Prompt: reading the RDFinput file, pls make suggestions of how to impprve the code to make it easier to follow and read. I beliebe it is difficult to follow the fact that one has a rdf forms subject and a data subject as well and how it is all itertwinded. Co-Authored-By: GitHub Copilot (raptor-mini) --- src/components/rdf-input/RDFInput.ts | 125 +++++++++++++++------------ 1 file changed, 71 insertions(+), 54 deletions(-) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index f189447c7..1f0cace6a 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -5,7 +5,7 @@ import { customElement, WebComponent } from '@/lib/components' import { LiveStore, NamedNode } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' -import { fieldParams, InputType } from '../../lib/forms/fieldParams' +import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' import { ifDefined } from 'lit/directives/if-defined.js' @customElement('solid-ui-rdf-input') @@ -15,69 +15,86 @@ export default class RDFInput extends WebComponent { // ui:property vcard:fn; // ui:label "name" . - // store needs to contain the form and also the data it applies to - @property({ type: LiveStore }) - accessor store + // formSubject describes the field metadata + // dataSubject points to the data resource containing the value - // form here is the subject :nameField - @property({ type: NamedNode }) - accessor formSubject + @property({ attribute: false }) + accessor store!: LiveStore - @property({ type: NamedNode }) - accessor inputSubject + @property({ attribute: false, type: Object }) + accessor formSubject!: NamedNode + + @property({ attribute: false, type: Object }) + accessor dataSubject!: NamedNode render () { - const formSubject = typeof this.formSubject === 'string' - ? this.store.sym(this.formSubject) - : this.formSubject - const inputSubject = typeof this.inputSubject === 'string' - ? this.store.sym(this.inputSubject) - : this.inputSubject - - const formGraph = formSubject.doc ? formSubject.doc() : undefined - - // HTML input part - const uiPropertyTerm = this.store.any(formSubject, ns.ui('property'), null, formGraph) as NamedNode | undefined - const uiProperty = uiPropertyTerm ? label(uiPropertyTerm, true) : '' - const uiLabel = this.store.any(formSubject, ns.ui('label'), null, formGraph) - const inputLabel = uiLabel ? uiLabel.value : uiProperty - - let readonly = false - // TODO: I am not finding suppressEmptyUneditable in ui ontology - const suppressEmptyUneditable = this.store.anyJS(formSubject, ns.ui('suppressEmptyUneditable'), null, formGraph) - if (suppressEmptyUneditable) { - readonly = true - } + const formGraph = this.getFormGraph(this.formSubject) + + // for building the HTML input element + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph) + const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph) + const readonly = this.getReadOnly(this.formSubject, formGraph) - const uri = mostSpecificClassURI(this.store, formSubject) - const params = fieldParams[uri] ?? {} + const fieldType = this.formSubject ? mostSpecificClassURI(this.store, this.formSubject) : undefined + const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} const inputType: InputType = params.type ?? 'text' - // input values - const defaultInputValueFromStore = this.store.any(formSubject, ns.ui('default')) - const inputValueFromStore = uiPropertyTerm - ? this.store.any(inputSubject, uiPropertyTerm) - : undefined + // for populating the HTML input element + const selectedTerm = this.getSelectedTerm(this.dataSubject, uiPropertyTerm, this.formSubject, params) + const inputValue = this.termToInputValue(selectedTerm, params) + + return html` + ${inputLabel ? html`` : ''} + + ` + } - let inputTerm: string | undefined + private getFormGraph (subject?: NamedNode) { + return subject?.doc ? subject.doc() : undefined + } - const term = inputValueFromStore || defaultInputValueFromStore - if (term && 'value' in term && term.value) { - const decoded = decodeURIComponent(term.value) - inputTerm = params.defaultInputValue - ? decoded.replace(params.defaultInputValue, '').replace(/ /g, '') - : decoded - } + private getFormProperty (subject: NamedNode | undefined, property: NamedNode, graph?: any): NamedNode | undefined { + if (!subject) return undefined + return this.store.any(subject, property, null, graph) as NamedNode | undefined + } + + private getInputLabel (formFieldSubject: NamedNode | undefined, uiPropertyTerm?: NamedNode, graph?: any): string { + if (!formFieldSubject) return '' + const uiLabel = this.store.any(formFieldSubject, ns.ui('label'), null, graph) + const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : '' + return uiLabel ? uiLabel.value : propertyLabel + } + + private getReadOnly (formFieldSubject?: NamedNode, graph?: any): boolean { + if (!formFieldSubject) return false + return !!this.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + } + + private getSelectedTerm ( + dataSubject?: NamedNode, + uiPropertyTerm?: NamedNode, + formFieldSubject?: NamedNode, + params?: { defaultInputValue?: string } + ) { + const defaultTerm = formFieldSubject + ? this.store.any(formFieldSubject, ns.ui('default')) + : undefined - if (inputLabel) { - return html` - - - ` - } else { - return html` - - ` + if (!uiPropertyTerm || !dataSubject) { + return defaultTerm } + + const inputTerm = this.store.any(dataSubject, uiPropertyTerm) + return inputTerm || defaultTerm + } + + private termToInputValue (term: any, params: { defaultInputValue?: string } = {}) { + if (!term || !('value' in term) || !term.value) return undefined + + const decoded = decodeURIComponent(term.value) + if (!params.defaultInputValue) return decoded + + const stripped = decoded.replace(params.defaultInputValue, '') + return stripped.replace(/ /g, '') } } From 84345dfd8a97d73b974a80a4c94f5cfd8455c8e2 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:47:41 +0200 Subject: [PATCH 07/31] fix types --- src/components/rdf-form/RDFForm.ts | 9 ++++--- src/lib/forms/rdfFormsHelper.ts | 39 +++++++++++++++++++----------- src/types/custom-elements.d.ts | 5 ++++ 3 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 src/types/custom-elements.d.ts diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index d3500c006..ec1529621 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -103,10 +103,11 @@ export default class RDFForm extends WebComponent { case 'TextField': case 'SingleLineTextField': case 'NamedNodeURIField': { - const formSubject = typeof part.value === 'string' - ? store.sym(part.value) - : part.value - return html` ` + return html` ` } case 'MultiLineTextField': return html`` diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index 645c10beb..6d0e2e108 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -1,4 +1,7 @@ import { sym, LiveStore, parse } from 'rdflib' +import type { Term } from 'rdflib/lib/tf-types' +// eslint-disable-next-line camelcase +import type { Quad_Subject } from 'rdflib/lib/tf-types' import ns from '../../lib/ns' const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/' @@ -17,7 +20,7 @@ export function loadDocument ( store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document)) } // we are using the social media form because it contains the information we need - // the form can be used for both use cases: create UI for edit and render UI for display + // the form can be used for both use cases: create UI for edit and render UI for display parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => { if (err) { console.error('loadDocument parse error for', finalDocumentUri, err) @@ -27,18 +30,25 @@ export function loadDocument ( export function sortBySequence ( store: LiveStore, - list + list: Term[] ) { - const subfields = list.map(function (p) { - const k = store.any(p, ns.ui('sequence')) - return [k || 9999, p] - }) - subfields.sort(function (a, b) { - return a[0] - b[0] - }) - return subfields.map(function (pair) { - return pair[1] - }) + const subfields = list + .filter( + // eslint-disable-next-line camelcase + (p): p is Quad_Subject => + p.termType === 'NamedNode' || + p.termType === 'BlankNode' || + p.termType === 'Variable' + ) + .map((p) => { + const k = store.any(p, ns.ui('sequence')) + const seq = k ? Number((k as { value: string }).value) : 9999 + return [Number.isNaN(seq) ? 9999 : seq, p] as const + }) + + subfields.sort((a, b) => a[0] - b[0]) + + return subfields.map(pair => pair[1]) } /** @@ -49,8 +59,9 @@ export function sortBySequence ( * @param subject a form field, e.g. `namedNode('https://timbl.com/timbl/Public/Test/Forms/individualForm.ttl#fullNameField')` * @returns the URI of the most specific known class, e.g. `http://www.w3.org/ns/ui#SingleLineTextField` */ -export function mostSpecificClassURI (store: LiveStore, subject: Node): string { - const typeUri = store.findTypeURIs(subject as any) +// eslint-disable-next-line camelcase +export function mostSpecificClassURI (store: LiveStore, subject: Quad_Subject): string { + const typeUri = store.findTypeURIs(subject) const specificTypes = store.bottomTypeURIs(typeUri) // most specific const finalTypes: any[] = [] for (const t in specificTypes) finalTypes.push(t) diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts new file mode 100644 index 000000000..c113c356e --- /dev/null +++ b/src/types/custom-elements.d.ts @@ -0,0 +1,5 @@ +declare global { + interface HTMLElementTagNameMap { + 'solid-ui-rdf-input': RDFInput + } +} From 372135f25154fe285a8136a2dfca1690ae3752ba Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:16:22 +0200 Subject: [PATCH 08/31] reverted the sortBySequence code back to original --- src/components/rdf-form/RDFForm.ts | 18 +++++++++--------- src/lib/forms/rdfFormsHelper.ts | 18 +++++------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index ec1529621..de868c576 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -110,24 +110,24 @@ export default class RDFForm extends WebComponent { >` } case 'MultiLineTextField': - return html`` + return html`` case 'BooleanField': - return html`` + return html`` case 'TristateField': - return html`` + return html`` case 'Classifier': - return html`` + return html`` case 'Choice': - return html`` + return html`` case 'Multiple': - return html`` + return html`` case 'Options': - return html`` + return html`` case 'AutocompleteField': - return html`` + return html`` case 'Comment': case 'Heading': - return html`` + return html`` default: return html`
Unknown part type: ${part}
` } diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index 6d0e2e108..02cf1b5c3 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -32,19 +32,11 @@ export function sortBySequence ( store: LiveStore, list: Term[] ) { - const subfields = list - .filter( - // eslint-disable-next-line camelcase - (p): p is Quad_Subject => - p.termType === 'NamedNode' || - p.termType === 'BlankNode' || - p.termType === 'Variable' - ) - .map((p) => { - const k = store.any(p, ns.ui('sequence')) - const seq = k ? Number((k as { value: string }).value) : 9999 - return [Number.isNaN(seq) ? 9999 : seq, p] as const - }) + const subfields = list.map((p) => { + const k = store.any(p as any, ns.ui('sequence')) + const seq = k ? Number((k as { value: string }).value) : 9999 + return [Number.isNaN(seq) ? 9999 : seq, p] as const + }) subfields.sort((a, b) => a[0] - b[0]) From 235b0d02bf6ad51a5cd2ee1d1705316d6e285372 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:14:45 +0200 Subject: [PATCH 09/31] vite config for generating custom elements tag name map Prompt: can this #sym:HTMLElementTagNameMap be generated automatically from vite-config/ components.ts for all RDF suffixed components? Co-Authored-By: GitHub Copilot (raptor-mini) --- src/types/custom-elements.d.ts | 9 +++++++ vite-config/components.ts | 47 +++++++++++++++++++++++++++++++++- vite.config.ts | 4 ++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index c113c356e..6c765dd06 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -1,5 +1,14 @@ +/** + * This file is auto-generated by vite-config/components.ts. + * Do not edit this file directly. + */ + +import type RDFForm from '../components/rdf-form/RDFForm' +import type RDFInput from '../components/rdf-input/RDFInput' + declare global { interface HTMLElementTagNameMap { + 'solid-ui-rdf-form': RDFForm 'solid-ui-rdf-input': RDFInput } } diff --git a/vite-config/components.ts b/vite-config/components.ts index 5681693ea..72b81a296 100644 --- a/vite-config/components.ts +++ b/vite-config/components.ts @@ -1,9 +1,11 @@ -import { existsSync, readdirSync } from 'node:fs' +import { existsSync, readdirSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' const projectRoot = resolve(import.meta.dirname, '..') export const componentsSrcDir = join(projectRoot, 'src/components') +export const customElementsTypesPath = join(projectRoot, 'src/types/custom-elements.d.ts') +const rdfComponentPrefix = 'rdf-' export function discoverComponents(): string[] { return readdirSync(componentsSrcDir, { withFileTypes: true }) @@ -15,3 +17,46 @@ export function discoverComponents(): string[] { .map((entry) => entry.name) .sort() } + +export function discoverRdfComponents(): string[] { + return discoverComponents().filter((name) => name.startsWith(rdfComponentPrefix)) +} + +function getPascalCase(name: string): string { + return name + .split('-') + .map((segment) => { + if (segment === 'rdf') return 'RDF' + if (segment.length <= 2) return segment.toUpperCase() + return segment.charAt(0).toUpperCase() + segment.slice(1) + }) + .join('') +} + +export function generateCustomElementsTypes(): void { + const rdfComponents = discoverRdfComponents() + + const lines = [ + '/**', + ' * This file is auto-generated by vite-config/components.ts.', + ' * Do not edit this file directly.', + ' */', + '', + ] + + for (const component of rdfComponents) { + const className = getPascalCase(component) + lines.push(`import type ${className} from '../components/${component}/${className}'`) + } + + lines.push('', 'declare global {', ' interface HTMLElementTagNameMap {') + + for (const component of rdfComponents) { + const className = getPascalCase(component) + lines.push(` 'solid-ui-${component}': ${className}`) + } + + lines.push(' }', '}', '') + + writeFileSync(customElementsTypesPath, lines.join('\n'), 'utf-8') +} diff --git a/vite.config.ts b/vite.config.ts index 6b95b2afb..987b406b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,9 +6,11 @@ import babel from './vite-config/babel' import css from './vite-config/css' import icons from './vite-config/icons' import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn' -import { discoverComponents } from './vite-config/components' +import { discoverComponents, generateCustomElementsTypes } from './vite-config/components' import { stylesConfig } from './vite-config/styles' +generateCustomElementsTypes() + const basePlugins = [ css(), icons(), From 35f44458f46a44d9a5cefdda72e758e6fc942731 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:36:19 +0200 Subject: [PATCH 10/31] a first FormsContext with a default store from SolidLogic --- src/components/rdf-form/RDFForm.ts | 19 ++++++++++++------- src/components/rdf-input/RDFInput.ts | 22 +++++++++++++--------- src/lib/forms/FormsContext.ts | 10 ++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/lib/forms/FormsContext.ts diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index de868c576..bf084e09f 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -4,11 +4,17 @@ import { customElement, WebComponent } from '@/lib/components' import ns from '../../lib/ns' import { loadDocument, sortBySequence } from '../../lib/forms/rdfFormsHelper' import { sym, Namespace } from 'rdflib' -import { store } from 'solid-logic' import '@/components/rdf-input' +import { consume } from '@lit/context' +import { DEFAULT_STORE, formsContext, FormsContext } from '@/lib/forms/FormsContext' @customElement('solid-ui-rdf-form') export default class RDFForm extends WebComponent { + @consume({ context: formsContext, subscribe: true }) + private accessor formsContext: FormsContext = { + store: DEFAULT_STORE, + } + @state() private accessor _parsedUrl: URL | null = null @@ -61,14 +67,14 @@ export default class RDFForm extends WebComponent { render () { // TODO: detect format - loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form - loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data + loadDocument(this.formsContext.store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form + loadDocument(this.formsContext.store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data const document = sym(this.rdfURI) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form - const parts = store.each(formThis, ns.ui('parts'), null, document) - const partsBySequence = sortBySequence(store, parts) + const parts = this.formsContext.store.each(formThis, ns.ui('parts'), null, document) + const partsBySequence = sortBySequence(this.formsContext.store, parts) const partItems = (partsBySequence || []).flatMap(item => { if (item && typeof item === 'object' && 'elements' in item && Array.isArray((item as any).elements)) { return (item as any).elements @@ -76,7 +82,7 @@ export default class RDFForm extends WebComponent { return [item] }) const uiFields = partItems.map(item => { - const types = store.each(item as any, ns.rdf('type'), null, document) + const types = this.formsContext.store.each(item as any, ns.rdf('type'), null, document) const typeNode = types[0] const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item)) const hashIndex = value.lastIndexOf('#') @@ -104,7 +110,6 @@ export default class RDFForm extends WebComponent { case 'SingleLineTextField': case 'NamedNodeURIField': { return html` ` diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 1f0cace6a..cad24ff4d 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -2,11 +2,13 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' import { customElement, WebComponent } from '@/lib/components' -import { LiveStore, NamedNode } from 'rdflib' +import { NamedNode } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' import { ifDefined } from 'lit/directives/if-defined.js' +import { DEFAULT_STORE, formsContext, FormsContext } from '@/lib/forms/FormsContext' +import { consume } from '@lit/context' @customElement('solid-ui-rdf-input') export default class RDFInput extends WebComponent { @@ -18,8 +20,10 @@ export default class RDFInput extends WebComponent { // formSubject describes the field metadata // dataSubject points to the data resource containing the value - @property({ attribute: false }) - accessor store!: LiveStore + @consume({ context: formsContext, subscribe: true }) + private accessor formsContext: FormsContext = { + store: DEFAULT_STORE, + } @property({ attribute: false, type: Object }) accessor formSubject!: NamedNode @@ -35,7 +39,7 @@ export default class RDFInput extends WebComponent { const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph) const readonly = this.getReadOnly(this.formSubject, formGraph) - const fieldType = this.formSubject ? mostSpecificClassURI(this.store, this.formSubject) : undefined + const fieldType = this.formSubject ? mostSpecificClassURI(this.formsContext.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} const inputType: InputType = params.type ?? 'text' @@ -55,19 +59,19 @@ export default class RDFInput extends WebComponent { private getFormProperty (subject: NamedNode | undefined, property: NamedNode, graph?: any): NamedNode | undefined { if (!subject) return undefined - return this.store.any(subject, property, null, graph) as NamedNode | undefined + return this.formsContext.store.any(subject, property, null, graph) as NamedNode | undefined } private getInputLabel (formFieldSubject: NamedNode | undefined, uiPropertyTerm?: NamedNode, graph?: any): string { if (!formFieldSubject) return '' - const uiLabel = this.store.any(formFieldSubject, ns.ui('label'), null, graph) + const uiLabel = this.formsContext.store.any(formFieldSubject, ns.ui('label'), null, graph) const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : '' return uiLabel ? uiLabel.value : propertyLabel } private getReadOnly (formFieldSubject?: NamedNode, graph?: any): boolean { if (!formFieldSubject) return false - return !!this.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + return !!this.formsContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) } private getSelectedTerm ( @@ -77,14 +81,14 @@ export default class RDFInput extends WebComponent { params?: { defaultInputValue?: string } ) { const defaultTerm = formFieldSubject - ? this.store.any(formFieldSubject, ns.ui('default')) + ? this.formsContext.store.any(formFieldSubject, ns.ui('default')) : undefined if (!uiPropertyTerm || !dataSubject) { return defaultTerm } - const inputTerm = this.store.any(dataSubject, uiPropertyTerm) + const inputTerm = this.formsContext.store.any(dataSubject, uiPropertyTerm) return inputTerm || defaultTerm } diff --git a/src/lib/forms/FormsContext.ts b/src/lib/forms/FormsContext.ts new file mode 100644 index 000000000..4a26301c1 --- /dev/null +++ b/src/lib/forms/FormsContext.ts @@ -0,0 +1,10 @@ +import { createContext } from '@lit/context' +import { LiveStore } from 'rdflib' +import { solidLogicSingleton } from 'solid-logic' + +export interface FormsContext { + store: LiveStore +} + +export const DEFAULT_STORE: LiveStore = solidLogicSingleton.store +export const formsContext = createContext(Symbol('rdfForms')) From 0d99a1b09fa6f4c8ae15ab9dc2a072ed3054b9d6 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:56:53 +0200 Subject: [PATCH 11/31] added placeholder property to input --- src/components/rdf-input/RDFInput.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index cad24ff4d..e8b42c4c7 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -45,11 +45,17 @@ export default class RDFInput extends WebComponent { // for populating the HTML input element const selectedTerm = this.getSelectedTerm(this.dataSubject, uiPropertyTerm, this.formSubject, params) - const inputValue = this.termToInputValue(selectedTerm, params) + const placeholder = this.defaultInputValue(params) + const inputValue = this.termToInputValue(selectedTerm) return html` - ${inputLabel ? html`` : ''} - + ${inputLabel + ? html` + ` + : html``} ` } @@ -92,13 +98,15 @@ export default class RDFInput extends WebComponent { return inputTerm || defaultTerm } - private termToInputValue (term: any, params: { defaultInputValue?: string } = {}) { + private termToInputValue (term: any) { if (!term || !('value' in term) || !term.value) return undefined const decoded = decodeURIComponent(term.value) - if (!params.defaultInputValue) return decoded + return decoded + } - const stripped = decoded.replace(params.defaultInputValue, '') + private defaultInputValue (params: { defaultInputValue?: string } = {}) { + const stripped = params.defaultInputValue ?? '' return stripped.replace(/ /g, '') } } From 7b7f9a9e4f70db8e390f730a3a1a3810245c1126 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:45:54 +0200 Subject: [PATCH 12/31] added readonly to Input and used it in RDFinput --- src/components/combobox/Combobox.ts | 4 ++++ src/components/input/Input.ts | 4 ++++ src/components/rdf-input/RDFInput.ts | 28 +++++++++++++++---------- src/components/select/Select.ts | 4 ++++ src/lib/components/traits/InputTrait.ts | 1 + 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index ec5534de2..5b0d84fe6 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -34,6 +34,9 @@ export default class Combobox extends WebComponent { @query('input') private accessor inputElement: HTMLInputElement | null = null + @property({ type: Boolean, reflect: true }) + accessor readonly = false + @state() private accessor filter = '' @@ -67,6 +70,7 @@ export default class Combobox extends WebComponent { name=${this.name} ?placeholder=${this.placeholder} ?required=${this.required} + ?readonly=${this.readonly} .value=${this.value} @keydown=${this.onInputKeyDown} @click=${this.onInputClick} diff --git a/src/components/input/Input.ts b/src/components/input/Input.ts index 44d333f37..0a5a264c5 100644 --- a/src/components/input/Input.ts +++ b/src/components/input/Input.ts @@ -28,6 +28,9 @@ export default class Input extends WebComponent { @property({ type: Boolean, reflect: true }) accessor required = false; + @property({ type: Boolean, reflect: true }) + accessor readonly = false; + @query('input') private accessor inputElement: HTMLInputElement | null = null; @@ -54,6 +57,7 @@ export default class Input extends WebComponent { placeholder=${this.placeholder} ?required=${this.required} .value=${this.value} + ?readonly=${this.readonly} @input=${() => this.inputTrait.onInput()} @keydown=${this.onKeyDown} /> diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index e8b42c4c7..c3a71aab8 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -6,7 +6,6 @@ import { NamedNode } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' -import { ifDefined } from 'lit/directives/if-defined.js' import { DEFAULT_STORE, formsContext, FormsContext } from '@/lib/forms/FormsContext' import { consume } from '@lit/context' @@ -31,13 +30,19 @@ export default class RDFInput extends WebComponent { @property({ attribute: false, type: Object }) accessor dataSubject!: NamedNode + @property({ type: String, reflect: true }) + accessor name = ''; + + @property({ type: Boolean, reflect: true }) + accessor readonly = true; + render () { const formGraph = this.getFormGraph(this.formSubject) // for building the HTML input element const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph) const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph) - const readonly = this.getReadOnly(this.formSubject, formGraph) + const readonly = this.getReadOnly(this.readonly, this.formSubject, formGraph) const fieldType = this.formSubject ? mostSpecificClassURI(this.formsContext.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} @@ -49,14 +54,14 @@ export default class RDFInput extends WebComponent { const inputValue = this.termToInputValue(selectedTerm) return html` - ${inputLabel - ? html` - ` - : html``} - ` + ` } private getFormGraph (subject?: NamedNode) { @@ -75,7 +80,8 @@ export default class RDFInput extends WebComponent { return uiLabel ? uiLabel.value : propertyLabel } - private getReadOnly (formFieldSubject?: NamedNode, graph?: any): boolean { + private getReadOnly (readonly?: boolean, formFieldSubject?: NamedNode, graph?: any): boolean { + if (readonly !== undefined) return readonly if (!formFieldSubject) return false return !!this.formsContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) } diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 4aacf4cb7..794ff5383 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -28,6 +28,9 @@ export default class Select extends WebComponent { @query('select') accessor inputElement: HTMLSelectElement | null = null; + @property({ type: Boolean, reflect: true }) + accessor readonly = false; + private inputTrait: InputTrait constructor () { @@ -48,6 +51,7 @@ export default class Select extends WebComponent { id="${this.inputTrait.inputId}" name=${this.name} ?required=${this.required} + ?readonly=${this.readonly} @change=${() => this.inputTrait.onInput()} > ${this.getOptions().map( diff --git a/src/lib/components/traits/InputTrait.ts b/src/lib/components/traits/InputTrait.ts index 73a5374cd..d841e99eb 100644 --- a/src/lib/components/traits/InputTrait.ts +++ b/src/lib/components/traits/InputTrait.ts @@ -10,6 +10,7 @@ export type InputTraitTarget = WebComponent & { label: string; required: boolean; value: string; + readonly: boolean; } export interface InputTraitConfig { From 65735ae7890456414fad49071fda2f5a22fa776f Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:50:57 +0200 Subject: [PATCH 13/31] improved store usage --- src/components/rdf-form/RDFForm.ts | 35 ++++++++++++------- src/components/rdf-form/RDForm.stories.ts | 2 ++ src/components/rdf-input/RDFInput.ts | 20 +++++------ src/lib/forms/FormsContext.ts | 10 ------ src/lib/forms/store/RDFFormsStore.ts | 8 +++++ src/lib/forms/store/StoreContext.ts | 10 ++++++ src/storybook/components/StorybookProvider.ts | 5 +++ src/storybook/store/StorybookStore.ts | 7 ++++ 8 files changed, 64 insertions(+), 33 deletions(-) delete mode 100644 src/lib/forms/FormsContext.ts create mode 100644 src/lib/forms/store/RDFFormsStore.ts create mode 100644 src/lib/forms/store/StoreContext.ts create mode 100644 src/storybook/store/StorybookStore.ts diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index bf084e09f..0af821a3d 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -1,19 +1,20 @@ import { property, state } from 'lit/decorators.js' import { html } from 'lit/html.js' +import { consume } from '@lit/context' import { customElement, WebComponent } from '@/lib/components' import ns from '../../lib/ns' import { loadDocument, sortBySequence } from '../../lib/forms/rdfFormsHelper' -import { sym, Namespace } from 'rdflib' +import { sym, Namespace, LiveStore } from 'rdflib' import '@/components/rdf-input' -import { consume } from '@lit/context' -import { DEFAULT_STORE, formsContext, FormsContext } from '@/lib/forms/FormsContext' +import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' @customElement('solid-ui-rdf-form') export default class RDFForm extends WebComponent { - @consume({ context: formsContext, subscribe: true }) - private accessor formsContext: FormsContext = { - store: DEFAULT_STORE, - } + @consume({ context: storeContext, subscribe: true }) + private accessor storeContext: StoreContext = DEFAULT_STORE + + @property({ attribute: false }) + accessor passedInStore: LiveStore | null = null @state() private accessor _parsedUrl: URL | null = null @@ -66,15 +67,25 @@ export default class RDFForm extends WebComponent { } render () { + const currentStoreContext = this.passedInStore + ? { store: this.passedInStore } + : this.storeContext + + if (!currentStoreContext?.store) { + console.warn('RDFForm: store context not available yet') + return html`` + } + + const store = currentStoreContext.store // TODO: detect format - loadDocument(this.formsContext.store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form - loadDocument(this.formsContext.store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data + loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form + loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data const document = sym(this.rdfURI) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form - const parts = this.formsContext.store.each(formThis, ns.ui('parts'), null, document) - const partsBySequence = sortBySequence(this.formsContext.store, parts) + const parts = store.each(formThis, ns.ui('parts'), null, document) + const partsBySequence = sortBySequence(store, parts) const partItems = (partsBySequence || []).flatMap(item => { if (item && typeof item === 'object' && 'elements' in item && Array.isArray((item as any).elements)) { return (item as any).elements @@ -82,7 +93,7 @@ export default class RDFForm extends WebComponent { return [item] }) const uiFields = partItems.map(item => { - const types = this.formsContext.store.each(item as any, ns.rdf('type'), null, document) + const types = store.each(item as any, ns.rdf('type'), null, document) const typeNode = types[0] const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item)) const hashIndex = value.lastIndexOf('#') diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index fd54bb8d1..9571a52bf 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -98,6 +98,7 @@ const meta = { const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName, subjectTurtleFormatSource, subjectName, subjectURI }) => { return html` + (({ rdfTurtleFormatSource, subjectName=${subjectName} subjectURI=${subjectURI}> + ` }) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index c3a71aab8..a17fe3ed8 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -6,7 +6,7 @@ import { NamedNode } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' -import { DEFAULT_STORE, formsContext, FormsContext } from '@/lib/forms/FormsContext' +import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' import { consume } from '@lit/context' @customElement('solid-ui-rdf-input') @@ -19,10 +19,8 @@ export default class RDFInput extends WebComponent { // formSubject describes the field metadata // dataSubject points to the data resource containing the value - @consume({ context: formsContext, subscribe: true }) - private accessor formsContext: FormsContext = { - store: DEFAULT_STORE, - } + @consume({ context: storeContext, subscribe: true }) + private accessor storeContext: StoreContext = DEFAULT_STORE @property({ attribute: false, type: Object }) accessor formSubject!: NamedNode @@ -44,7 +42,7 @@ export default class RDFInput extends WebComponent { const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph) const readonly = this.getReadOnly(this.readonly, this.formSubject, formGraph) - const fieldType = this.formSubject ? mostSpecificClassURI(this.formsContext.store, this.formSubject) : undefined + const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} const inputType: InputType = params.type ?? 'text' @@ -70,12 +68,12 @@ export default class RDFInput extends WebComponent { private getFormProperty (subject: NamedNode | undefined, property: NamedNode, graph?: any): NamedNode | undefined { if (!subject) return undefined - return this.formsContext.store.any(subject, property, null, graph) as NamedNode | undefined + return this.storeContext.store.any(subject, property, null, graph) as NamedNode | undefined } private getInputLabel (formFieldSubject: NamedNode | undefined, uiPropertyTerm?: NamedNode, graph?: any): string { if (!formFieldSubject) return '' - const uiLabel = this.formsContext.store.any(formFieldSubject, ns.ui('label'), null, graph) + const uiLabel = this.storeContext.store.any(formFieldSubject, ns.ui('label'), null, graph) const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : '' return uiLabel ? uiLabel.value : propertyLabel } @@ -83,7 +81,7 @@ export default class RDFInput extends WebComponent { private getReadOnly (readonly?: boolean, formFieldSubject?: NamedNode, graph?: any): boolean { if (readonly !== undefined) return readonly if (!formFieldSubject) return false - return !!this.formsContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + return !!this.storeContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) } private getSelectedTerm ( @@ -93,14 +91,14 @@ export default class RDFInput extends WebComponent { params?: { defaultInputValue?: string } ) { const defaultTerm = formFieldSubject - ? this.formsContext.store.any(formFieldSubject, ns.ui('default')) + ? this.storeContext.store.any(formFieldSubject, ns.ui('default')) : undefined if (!uiPropertyTerm || !dataSubject) { return defaultTerm } - const inputTerm = this.formsContext.store.any(dataSubject, uiPropertyTerm) + const inputTerm = this.storeContext.store.any(dataSubject, uiPropertyTerm) return inputTerm || defaultTerm } diff --git a/src/lib/forms/FormsContext.ts b/src/lib/forms/FormsContext.ts deleted file mode 100644 index 4a26301c1..000000000 --- a/src/lib/forms/FormsContext.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext } from '@lit/context' -import { LiveStore } from 'rdflib' -import { solidLogicSingleton } from 'solid-logic' - -export interface FormsContext { - store: LiveStore -} - -export const DEFAULT_STORE: LiveStore = solidLogicSingleton.store -export const formsContext = createContext(Symbol('rdfForms')) diff --git a/src/lib/forms/store/RDFFormsStore.ts b/src/lib/forms/store/RDFFormsStore.ts new file mode 100644 index 000000000..1dc73d2c7 --- /dev/null +++ b/src/lib/forms/store/RDFFormsStore.ts @@ -0,0 +1,8 @@ +import { LiveStore } from 'rdflib' +import { StoreContext } from './StoreContext' + +export default class RDFFormsStore implements StoreContext { + get store (): LiveStore { + throw new Error('Can\'t use RDF forms without a store') + } +} diff --git a/src/lib/forms/store/StoreContext.ts b/src/lib/forms/store/StoreContext.ts new file mode 100644 index 000000000..e0f374a6e --- /dev/null +++ b/src/lib/forms/store/StoreContext.ts @@ -0,0 +1,10 @@ +import { createContext } from '@lit/context' +import { LiveStore } from 'rdflib' +import RDFFormsStore from './RDFFormsStore' + +export interface StoreContext { + store: LiveStore +} + +export const DEFAULT_STORE = new RDFFormsStore() +export const storeContext = createContext(Symbol('storeContext')) diff --git a/src/storybook/components/StorybookProvider.ts b/src/storybook/components/StorybookProvider.ts index b34d4391c..502eaeec8 100644 --- a/src/storybook/components/StorybookProvider.ts +++ b/src/storybook/components/StorybookProvider.ts @@ -6,6 +6,8 @@ import StorybookAuth from '../auth/StorybookAuth' import { Account, authContext } from '@/lib/auth' import '@/components/dialogs-root' +import { storeContext, StoreContext } from '@/lib/forms/store/StoreContext' +import StorybookStore from '../store/StorybookStore' @customElement('storybook-provider') export class StorybookProvider extends WebComponent { @@ -18,6 +20,9 @@ export class StorybookProvider extends WebComponent { @provide({ context: authContext }) private accessor auth = new StorybookAuth() + @provide({ context: storeContext }) + private accessor store: StoreContext = new StorybookStore() + willUpdate (changedProperties: Map) { super.willUpdate(changedProperties) diff --git a/src/storybook/store/StorybookStore.ts b/src/storybook/store/StorybookStore.ts new file mode 100644 index 000000000..e09421bcb --- /dev/null +++ b/src/storybook/store/StorybookStore.ts @@ -0,0 +1,7 @@ +import { StoreContext } from '@/lib/forms/store/StoreContext' +import * as rdf from 'rdflib' +import { LiveStore } from 'rdflib' + +export default class StorybookStore implements StoreContext { + public store: LiveStore = rdf.graph() as LiveStore +} From cfb0475de23db73fec55d796cde218ac93047ebc Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:40:14 +0200 Subject: [PATCH 14/31] added readonly style to input --- src/components/input/Input.styles.css | 7 ++ src/components/rdf-form/RDFForm.ts | 107 +++++++++++------- src/components/rdf-form/RDForm.stories.ts | 7 +- src/components/rdf-input/RDFInput.ts | 14 +-- src/storybook/components/StorybookProvider.ts | 1 + 5 files changed, 85 insertions(+), 51 deletions(-) diff --git a/src/components/input/Input.styles.css b/src/components/input/Input.styles.css index cd29288a8..4a6ec23b1 100644 --- a/src/components/input/Input.styles.css +++ b/src/components/input/Input.styles.css @@ -23,5 +23,12 @@ color: var(--solid-ui-color-gray-700); font-size: inherit; } + + input:read-only { + border: none; + padding: 0; + cursor: pointer; + } } + } diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 0af821a3d..d3d9ddb5b 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -105,49 +105,70 @@ export default class RDFForm extends WebComponent { const me = Namespace(this.subjectURI + '#')(this.whichSubject) return html` - ${uiFields.map(part => { - switch (part.fieldValue) { - case 'PhoneField': - case 'EmailField': - case 'ColorField': - case 'DateField': - case 'DateTimeField': - case 'TimeField': - case 'NumericField': - case 'IntegerField': - case 'DecimalField': - case 'FloatField': - case 'TextField': - case 'SingleLineTextField': - case 'NamedNodeURIField': { - return html` ` - } - case 'MultiLineTextField': - return html`` - case 'BooleanField': - return html`` - case 'TristateField': - return html`` - case 'Classifier': - return html`` - case 'Choice': - return html`` - case 'Multiple': - return html`` - case 'Options': - return html`` - case 'AutocompleteField': - return html`` - case 'Comment': - case 'Heading': - return html`` - default: - return html`
Unknown part type: ${part}
` - } - })} +
+ ${uiFields.map(part => { + switch (part.fieldValue) { + case 'PhoneField': + case 'EmailField': + case 'ColorField': + case 'DateField': + case 'DateTimeField': + case 'TimeField': + case 'NumericField': + case 'IntegerField': + case 'DecimalField': + case 'FloatField': + case 'TextField': + case 'SingleLineTextField': + case 'NamedNodeURIField': { + return html` +
` + } + case 'MultiLineTextField': + return html`` + case 'BooleanField': + return html`` + case 'TristateField': + return html`` + case 'Classifier': + return html`` + case 'Choice': + return html`` + case 'Multiple': + return html`` + case 'Options': + return html`` + case 'AutocompleteField': + return html`` + case 'Comment': + case 'Heading': + return html`` + default: + return html`
Unknown part type: ${part}
` + } + })} +
` } + + private async onSubmit (e: Event) { + e.preventDefault() + + /* this.failed = false + + this.submitting = true + + try { + await this.auth.login(this.issuerInputValue) + } catch (error) { + console.error(error) + + this.failed = true + } finally { + this.submitting = false + } */ + } } diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index 9571a52bf..f10f70d10 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -20,16 +20,21 @@ const meta = { # A Form with 2 fields and a nested subgroup :form a ui:Form; - ui:parts (:nameField :emailField :addresses) . + ui:parts (:nameField :emailField :phoneField :addresses) . :nameField a ui:SingleLineTextField ; ui:property vcard:fn; ui:label "name" . + :emailField a ui:EmailField ; ui:property vcard:hasEmail; # @@ check ui:label "email" . + :phoneField a ui:PhoneField ; + ui:property vcard:hasTelephone; + ui:label "phone" . + :addresses a ui:Multiple ; # -- Allows zero or one or more ui:part :oneAddress ; diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index a17fe3ed8..3f76997c8 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -1,13 +1,14 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' -import { customElement, WebComponent } from '@/lib/components' +import { customElement, generateId, WebComponent } from '@/lib/components' import { NamedNode } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' import { consume } from '@lit/context' +import '@/components/input' @customElement('solid-ui-rdf-input') export default class RDFInput extends WebComponent { @@ -28,14 +29,13 @@ export default class RDFInput extends WebComponent { @property({ attribute: false, type: Object }) accessor dataSubject!: NamedNode - @property({ type: String, reflect: true }) - accessor name = ''; - @property({ type: Boolean, reflect: true }) accessor readonly = true; render () { const formGraph = this.getFormGraph(this.formSubject) + const statementCount = this.storeContext.store?.statements?.length ?? 0 + console.log('RDFInput render statement count:', statementCount) // for building the HTML input element const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph) @@ -48,13 +48,13 @@ export default class RDFInput extends WebComponent { // for populating the HTML input element const selectedTerm = this.getSelectedTerm(this.dataSubject, uiPropertyTerm, this.formSubject, params) - const placeholder = this.defaultInputValue(params) + const placeholder = readonly ? '' : this.defaultInputValue(params) const inputValue = this.termToInputValue(selectedTerm) return html` Date: Mon, 29 Jun 2026 18:18:04 +0200 Subject: [PATCH 15/31] added save features, copied over code --- src/components/rdf-form/RDFForm.ts | 45 +++++++++-- src/components/rdf-form/RDForm.stories.ts | 2 +- src/components/rdf-input/RDFInput.ts | 98 +++++++++++++++++++++-- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index d3d9ddb5b..c7a76271a 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -16,6 +16,23 @@ export default class RDFForm extends WebComponent { @property({ attribute: false }) accessor passedInStore: LiveStore | null = null + private get currentStoreContext (): StoreContext | null { + if (this.passedInStore) { + return { store: this.passedInStore } + } + + return this.storeContext !== DEFAULT_STORE ? this.storeContext : null + } + + @state() + private accessor failed: boolean = false + + @state() + private accessor submitting: boolean = false + + @state() + private accessor entireDataIsReadonly: boolean = true + @state() private accessor _parsedUrl: URL | null = null @@ -67,16 +84,19 @@ export default class RDFForm extends WebComponent { } render () { - const currentStoreContext = this.passedInStore - ? { store: this.passedInStore } - : this.storeContext + const currentStoreContext = this.currentStoreContext - if (!currentStoreContext?.store) { + if (!currentStoreContext) { console.warn('RDFForm: store context not available yet') return html`` } const store = currentStoreContext.store + + if (!store.updater?.editable(this.subjectURI)) { + this.entireDataIsReadonly = true + } + // TODO: detect format loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data @@ -124,6 +144,7 @@ export default class RDFForm extends WebComponent { return html`
` } @@ -150,6 +171,13 @@ export default class RDFForm extends WebComponent { return html`
Unknown part type: ${part}
` } })} + + Save + ` } @@ -157,18 +185,21 @@ export default class RDFForm extends WebComponent { private async onSubmit (e: Event) { e.preventDefault() - /* this.failed = false + this.failed = false this.submitting = true try { - await this.auth.login(this.issuerInputValue) + const currentStoreContext = this.currentStoreContext + if (currentStoreContext?.store.updater?.editable(this.subjectURI)) { + // this.saveStatements() + } } catch (error) { console.error(error) this.failed = true } finally { this.submitting = false - } */ + } } } diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index f10f70d10..e97448630 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -17,7 +17,7 @@ const meta = { @prefix vcard: . @prefix xsd: . - # A Form with 2 fields and a nested subgroup + # A Form with 3 fields and a nested subgroup :form a ui:Form; ui:parts (:nameField :emailField :phoneField :addresses) . diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 3f76997c8..474fe12e7 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -1,11 +1,11 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' -import { customElement, generateId, WebComponent } from '@/lib/components' -import { NamedNode } from 'rdflib' +import { customElement, WebComponent } from '@/lib/components' +import { Literal, LiveStore, NamedNode, Statement, st } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' -import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' +import { FieldParamsObject, fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' import { consume } from '@lit/context' import '@/components/input' @@ -33,9 +33,7 @@ export default class RDFInput extends WebComponent { accessor readonly = true; render () { - const formGraph = this.getFormGraph(this.formSubject) - const statementCount = this.storeContext.store?.statements?.length ?? 0 - console.log('RDFInput render statement count:', statementCount) + const formGraph = this.getGraph(this.formSubject) // for building the HTML input element const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph) @@ -59,10 +57,11 @@ export default class RDFInput extends WebComponent { placeholder="${placeholder}" type="${inputType}" ?readonly=${readonly} + @input=${this.updateData()} >
` } - private getFormGraph (subject?: NamedNode) { + private getGraph (subject?: NamedNode) { return subject?.doc ? subject.doc() : undefined } @@ -113,4 +112,89 @@ export default class RDFInput extends WebComponent { const stripped = params.defaultInputValue ?? '' return stripped.replace(/ /g, '') } + + private updateData () { + return (e: CustomEvent) => { + const newValue = (e.target as HTMLInputElement).value + + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getGraph(this.formSubject)) + if (!uiPropertyTerm || !this.dataSubject) return + + const currentStoreContext = this.storeContext.store + if (!currentStoreContext.updater.editable(this.dataSubject)) return + + const toDeleteSt = currentStoreContext.statementsMatching(this.dataSubject, uiPropertyTerm) + + if (newValue) { + let objectFromNewValue + const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined + const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {} + if (params.namedNode) { + objectFromNewValue = currentStoreContext.sym(newValue) + } else if (params.defaultInputValue) { + objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, '')) + objectFromNewValue = currentStoreContext.sym(params.defaultInputValue + objectFromNewValue) + } else { + if (params.dt) { + objectFromNewValue = new Literal( + newValue.trim(), + undefined, + ns.xsd(params.dt) + ) + } else { + objectFromNewValue = new Literal(newValue) + } + } + let toInsertSt = toDeleteSt.map(statement => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) // can include >1 doc + if (toInsertSt.length === 0) { + toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getGraph(this.dataSubject))] + } + + this.updateMany(currentStoreContext, toDeleteSt, toInsertSt) + } + } + } + + private updateMany ( + store: LiveStore, + ds: Statement[], + is: Statement[] + ) { + const getDocUri = (statement: Statement) => { + const why = statement.why as any + return why?.uri ?? why?.value + } + + const docs: string[] = [] + is.forEach(st => { + const uri = getDocUri(st) + if (uri && !docs.includes(uri)) docs.push(uri) + }) + ds.forEach(st => { + const uri = getDocUri(st) + if (uri && !docs.includes(uri)) docs.push(uri) + }) + if (docs.length === 0) { + throw new Error('No concrete document to update') + } + if (!store.updater) { + throw new Error('Store has no updater') + } + if (docs.length === 1) { + return store.updater.update(ds, is as any) + } + + const doc = docs.pop() + const is1 = is.filter(st => getDocUri(st) === doc) + const is2 = is.filter(st => getDocUri(st) !== doc) + const ds1 = ds.filter(st => getDocUri(st) === doc) + const ds2 = ds.filter(st => getDocUri(st) !== doc) + store.updater.update(ds1, is1 as any, (uri, ok, body) => { + if (ok) { + this.updateMany(store, ds2, is2) + } else { + throw new Error(`Failed to update data for ${uri}: ${body}`) + } + }) + } } From 082b9ee1eee178ea571c838996d75a9434290f3b Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:35:46 +0200 Subject: [PATCH 16/31] added an updater to the storybook store --- src/storybook/store/StorybookStore.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/storybook/store/StorybookStore.ts b/src/storybook/store/StorybookStore.ts index e09421bcb..1e7add43e 100644 --- a/src/storybook/store/StorybookStore.ts +++ b/src/storybook/store/StorybookStore.ts @@ -3,5 +3,12 @@ import * as rdf from 'rdflib' import { LiveStore } from 'rdflib' export default class StorybookStore implements StoreContext { - public store: LiveStore = rdf.graph() as LiveStore + public store: LiveStore = createStore() +} + +function createStore (): rdf.LiveStore { + const store = rdf.graph() as LiveStore + store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater + store.features = [] // disable automatic node merging on store load + return store } From 2b53d6c051c40e41365bc8035457fe13acc9eec7 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:36:27 +0200 Subject: [PATCH 17/31] added data change capabilities, 1st version --- src/components/rdf-form/RDFForm.ts | 75 ++++++++------ src/components/rdf-input/RDFInput.ts | 144 +++++++++++++-------------- src/lib/forms/rdfFormsHelper.ts | 49 +++++++-- 3 files changed, 152 insertions(+), 116 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index c7a76271a..268b5a4c7 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -24,14 +24,8 @@ export default class RDFForm extends WebComponent { return this.storeContext !== DEFAULT_STORE ? this.storeContext : null } - @state() - private accessor failed: boolean = false - - @state() - private accessor submitting: boolean = false - @state() - private accessor entireDataIsReadonly: boolean = true + private accessor entireDataIsReadonly: boolean = false @state() private accessor _parsedUrl: URL | null = null @@ -39,6 +33,12 @@ export default class RDFForm extends WebComponent { @state() private accessor _parsedUrl2: URL | null = null + @state() + private accessor _loadVersion = 0 + + @state() + private accessor _documentsLoaded = false + @property({ type: String }) accessor whichForm = 'this' @@ -91,15 +91,16 @@ export default class RDFForm extends WebComponent { return html`` } + if (!this._documentsLoaded) { + return html`` + } + const store = currentStoreContext.store - if (!store.updater?.editable(this.subjectURI)) { + if (store.updater?.editable(this.subjectURI) === false) { this.entireDataIsReadonly = true } - // TODO: detect format - loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form - loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data const document = sym(this.rdfURI) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form @@ -125,7 +126,7 @@ export default class RDFForm extends WebComponent { const me = Namespace(this.subjectURI + '#')(this.whichSubject) return html` -
+ ${uiFields.map(part => { switch (part.fieldValue) { case 'PhoneField': @@ -144,7 +145,8 @@ export default class RDFForm extends WebComponent { return html`
` } @@ -171,35 +173,42 @@ export default class RDFForm extends WebComponent { return html`
Unknown part type: ${part}
` } })} - - Save -
` } - private async onSubmit (e: Event) { - e.preventDefault() + protected updated (changedProperties: Map) { + super.updated(changedProperties) + if ( + changedProperties.has('rdfTurtleFormatSource') || + changedProperties.has('rdfName') || + changedProperties.has('rdfURI') || + changedProperties.has('subjectTurtleFormatSource') || + changedProperties.has('subjectName') || + changedProperties.has('subjectURI') || + changedProperties.has('passedInStore') + ) { + this.loadDocumentsIfNeeded() + } + } - this.failed = false + private async loadDocumentsIfNeeded () { + const currentStoreContext = this.currentStoreContext + if (!currentStoreContext) return - this.submitting = true + const store = currentStoreContext.store + const rdfURI = this.rdfURI + const subjectURI = this.subjectURI + + if (!rdfURI || !subjectURI) return try { - const currentStoreContext = this.currentStoreContext - if (currentStoreContext?.store.updater?.editable(this.subjectURI)) { - // this.saveStatements() - } + await loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, rdfURI, false) + await loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, subjectURI, true) + this._loadVersion += 1 + this._documentsLoaded = true } catch (error) { - console.error(error) - - this.failed = true - } finally { - this.submitting = false + console.error('Failed to load RDF documents', error) } } } diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 474fe12e7..2a08d7ddc 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -2,7 +2,7 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' import { customElement, WebComponent } from '@/lib/components' -import { Literal, LiveStore, NamedNode, Statement, st } from 'rdflib' +import { Literal, NamedNode, st } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' import { FieldParamsObject, fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams' @@ -29,8 +29,14 @@ export default class RDFInput extends WebComponent { @property({ attribute: false, type: Object }) accessor dataSubject!: NamedNode + @property({ type: Number }) + accessor storeVersion = 0 + + private _updateInFlight = false + private _pendingUpdateValue: string | null = null + @property({ type: Boolean, reflect: true }) - accessor readonly = true; + accessor readonly = false; render () { const formGraph = this.getGraph(this.formSubject) @@ -57,7 +63,7 @@ export default class RDFInput extends WebComponent { placeholder="${placeholder}" type="${inputType}" ?readonly=${readonly} - @input=${this.updateData()} + @input=${this.updateData} >` } @@ -113,88 +119,78 @@ export default class RDFInput extends WebComponent { return stripped.replace(/ /g, '') } - private updateData () { - return (e: CustomEvent) => { - const newValue = (e.target as HTMLInputElement).value + private async updateData (e: CustomEvent) { + const newValue = (e.target as HTMLInputElement).value + this._pendingUpdateValue = newValue - const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getGraph(this.formSubject)) - if (!uiPropertyTerm || !this.dataSubject) return + if (this._updateInFlight) { + return + } - const currentStoreContext = this.storeContext.store - if (!currentStoreContext.updater.editable(this.dataSubject)) return + await this.runPendingUpdate() + } - const toDeleteSt = currentStoreContext.statementsMatching(this.dataSubject, uiPropertyTerm) + private async runPendingUpdate () { + if (this._pendingUpdateValue === null) { + return + } - if (newValue) { - let objectFromNewValue - const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined - const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {} - if (params.namedNode) { - objectFromNewValue = currentStoreContext.sym(newValue) - } else if (params.defaultInputValue) { - objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, '')) - objectFromNewValue = currentStoreContext.sym(params.defaultInputValue + objectFromNewValue) - } else { - if (params.dt) { - objectFromNewValue = new Literal( - newValue.trim(), - undefined, - ns.xsd(params.dt) - ) - } else { - objectFromNewValue = new Literal(newValue) - } - } - let toInsertSt = toDeleteSt.map(statement => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) // can include >1 doc - if (toInsertSt.length === 0) { - toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getGraph(this.dataSubject))] - } + const newValue = this._pendingUpdateValue + this._pendingUpdateValue = null + this._updateInFlight = true - this.updateMany(currentStoreContext, toDeleteSt, toInsertSt) - } + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getGraph(this.formSubject)) + if (!uiPropertyTerm || !this.dataSubject) { + this._updateInFlight = false + return } - } - private updateMany ( - store: LiveStore, - ds: Statement[], - is: Statement[] - ) { - const getDocUri = (statement: Statement) => { - const why = statement.why as any - return why?.uri ?? why?.value + const currentStoreContext = this.storeContext.store + if (currentStoreContext.updater?.editable(this.dataSubject) === false) { + this._updateInFlight = false + return } - const docs: string[] = [] - is.forEach(st => { - const uri = getDocUri(st) - if (uri && !docs.includes(uri)) docs.push(uri) - }) - ds.forEach(st => { - const uri = getDocUri(st) - if (uri && !docs.includes(uri)) docs.push(uri) - }) - if (docs.length === 0) { - throw new Error('No concrete document to update') - } - if (!store.updater) { - throw new Error('Store has no updater') + const toDeleteSt = currentStoreContext.statementsMatching(this.dataSubject, uiPropertyTerm) + let toInsertSt: Array> = [] + + if (newValue) { + let objectFromNewValue + const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined + const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {} + if (params.namedNode) { + objectFromNewValue = currentStoreContext.sym(newValue) + } else if (params.defaultInputValue) { + objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, '')) + objectFromNewValue = currentStoreContext.sym(params.defaultInputValue + objectFromNewValue) + } else { + if (params.dt) { + objectFromNewValue = new Literal( + newValue.trim(), + undefined, + ns.xsd(params.dt) + ) + } else { + objectFromNewValue = new Literal(newValue) + } + } + toInsertSt = toDeleteSt.map(statement => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) + if (toInsertSt.length === 0) { + toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getGraph(this.dataSubject))] + } } - if (docs.length === 1) { - return store.updater.update(ds, is as any) + + try { + await currentStoreContext.updater.updateMany(toDeleteSt, toInsertSt as any) + this.storeVersion += 1 + } catch (err) { + console.error('RDFInput update failed', err) + } finally { + this._updateInFlight = false } - const doc = docs.pop() - const is1 = is.filter(st => getDocUri(st) === doc) - const is2 = is.filter(st => getDocUri(st) !== doc) - const ds1 = ds.filter(st => getDocUri(st) === doc) - const ds2 = ds.filter(st => getDocUri(st) !== doc) - store.updater.update(ds1, is1 as any, (uri, ok, body) => { - if (ok) { - this.updateMany(store, ds2, is2) - } else { - throw new Error(`Failed to update data for ${uri}: ${body}`) - } - }) + if (this._pendingUpdateValue !== null) { + await this.runPendingUpdate() + } } } diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index 02cf1b5c3..1335eda5e 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -6,12 +6,12 @@ import ns from '../../lib/ns' const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/' -// we need to load into the store some additional information about Social Media accounts export function loadDocument ( store: LiveStore, documentSource: string, documentName: string, - documentURI?: string + documentURI?: string, + preferRemote = false ) { const finalDocumentUri = documentURI || baseUri + documentName // Full URI to the file const document = sym(finalDocumentUri) // rdflib NamedNode for the document @@ -19,13 +19,44 @@ export function loadDocument ( if (store.holds(undefined, undefined, undefined, document)) { store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document)) } - // we are using the social media form because it contains the information we need - // the form can be used for both use cases: create UI for edit and render UI for display - parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => { - if (err) { - console.error('loadDocument parse error for', finalDocumentUri, err) - } - }) + + const parseSource = () => { + return new Promise((resolve, reject) => { + parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => { + if (err) { + console.error('Parse document error for ', finalDocumentUri, err) + reject(err) + } else { + resolve() + } + }) + }) + } + + if (preferRemote && documentURI) { + return store.fetcher.load(documentURI, { + force: true, + clearPreviousData: true, + }).then(() => {}).catch((err) => { + if (documentSource && documentSource.trim().length > 0) { + return parseSource() + } + throw err + }) + } + + if (documentSource && documentSource.trim().length > 0) { + return parseSource() + } + + if (documentURI) { + return store.fetcher.load(documentURI, { + force: true, + clearPreviousData: true, + }).then(() => {}) + } + + return Promise.reject(new Error(`No document source or URI for ${documentName}`)) } export function sortBySequence ( From 403a143175624b35be53357425b8424d1586314b Mon Sep 17 00:00:00 2001 From: Timea <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:37:39 +0200 Subject: [PATCH 18/31] Update src/components/input/Input.styles.css Co-authored-by: Noel De Martin --- src/components/input/Input.styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/input/Input.styles.css b/src/components/input/Input.styles.css index 4a6ec23b1..e593ce1b6 100644 --- a/src/components/input/Input.styles.css +++ b/src/components/input/Input.styles.css @@ -27,7 +27,7 @@ input:read-only { border: none; padding: 0; - cursor: pointer; + cursor: not-allowed; } } From 0af9ce9dbfb05137526b817caf9a7e5a62607077 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:40:11 +0200 Subject: [PATCH 19/31] rename to NoopStore --- src/lib/forms/store/{RDFFormsStore.ts => NoopStore.ts} | 2 +- src/lib/forms/store/StoreContext.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/lib/forms/store/{RDFFormsStore.ts => NoopStore.ts} (74%) diff --git a/src/lib/forms/store/RDFFormsStore.ts b/src/lib/forms/store/NoopStore.ts similarity index 74% rename from src/lib/forms/store/RDFFormsStore.ts rename to src/lib/forms/store/NoopStore.ts index 1dc73d2c7..ace119e7a 100644 --- a/src/lib/forms/store/RDFFormsStore.ts +++ b/src/lib/forms/store/NoopStore.ts @@ -1,7 +1,7 @@ import { LiveStore } from 'rdflib' import { StoreContext } from './StoreContext' -export default class RDFFormsStore implements StoreContext { +export default class NoopStore implements StoreContext { get store (): LiveStore { throw new Error('Can\'t use RDF forms without a store') } diff --git a/src/lib/forms/store/StoreContext.ts b/src/lib/forms/store/StoreContext.ts index e0f374a6e..8cd36d6be 100644 --- a/src/lib/forms/store/StoreContext.ts +++ b/src/lib/forms/store/StoreContext.ts @@ -1,10 +1,10 @@ import { createContext } from '@lit/context' import { LiveStore } from 'rdflib' -import RDFFormsStore from './RDFFormsStore' +import NoopStore from './NoopStore' export interface StoreContext { store: LiveStore } -export const DEFAULT_STORE = new RDFFormsStore() +export const DEFAULT_STORE = new NoopStore() export const storeContext = createContext(Symbol('storeContext')) From 9d9f48abf505e19811643f4368118d3e39ec2304 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:02:24 +0200 Subject: [PATCH 20/31] renamed NoopStore --- src/components/rdf-form/RDFForm.ts | 22 ++++++---------------- src/components/rdf-input/RDFInput.ts | 11 +++++------ src/lib/forms/store/NoopStore.ts | 2 +- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 268b5a4c7..2ddb06659 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -16,15 +16,15 @@ export default class RDFForm extends WebComponent { @property({ attribute: false }) accessor passedInStore: LiveStore | null = null - private get currentStoreContext (): StoreContext | null { + private get currentStoreContext (): StoreContext { if (this.passedInStore) { - return { store: this.passedInStore } + this.storeContext.store = this.passedInStore } - return this.storeContext !== DEFAULT_STORE ? this.storeContext : null + return this.storeContext } - @state() + @state() private accessor entireDataIsReadonly: boolean = false @state() @@ -84,18 +84,11 @@ export default class RDFForm extends WebComponent { } render () { - const currentStoreContext = this.currentStoreContext - - if (!currentStoreContext) { - console.warn('RDFForm: store context not available yet') - return html`` - } - if (!this._documentsLoaded) { return html`` } - const store = currentStoreContext.store + const store = this.currentStoreContext.store if (store.updater?.editable(this.subjectURI) === false) { this.entireDataIsReadonly = true @@ -193,10 +186,7 @@ export default class RDFForm extends WebComponent { } private async loadDocumentsIfNeeded () { - const currentStoreContext = this.currentStoreContext - if (!currentStoreContext) return - - const store = currentStoreContext.store + const store = this.currentStoreContext.store const rdfURI = this.rdfURI const subjectURI = this.subjectURI diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 2a08d7ddc..42ce17788 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -145,13 +145,12 @@ export default class RDFInput extends WebComponent { return } - const currentStoreContext = this.storeContext.store - if (currentStoreContext.updater?.editable(this.dataSubject) === false) { + if (this.storeContext.store.updater?.editable(this.dataSubject) === false) { this._updateInFlight = false return } - const toDeleteSt = currentStoreContext.statementsMatching(this.dataSubject, uiPropertyTerm) + const toDeleteSt = this.storeContext.store.statementsMatching(this.dataSubject, uiPropertyTerm) let toInsertSt: Array> = [] if (newValue) { @@ -159,10 +158,10 @@ export default class RDFInput extends WebComponent { const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {} if (params.namedNode) { - objectFromNewValue = currentStoreContext.sym(newValue) + objectFromNewValue = this.storeContext.store.sym(newValue) } else if (params.defaultInputValue) { objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, '')) - objectFromNewValue = currentStoreContext.sym(params.defaultInputValue + objectFromNewValue) + objectFromNewValue = this.storeContext.store.sym(params.defaultInputValue + objectFromNewValue) } else { if (params.dt) { objectFromNewValue = new Literal( @@ -181,7 +180,7 @@ export default class RDFInput extends WebComponent { } try { - await currentStoreContext.updater.updateMany(toDeleteSt, toInsertSt as any) + await this.storeContext.store.updater.updateMany(toDeleteSt, toInsertSt as any) this.storeVersion += 1 } catch (err) { console.error('RDFInput update failed', err) diff --git a/src/lib/forms/store/NoopStore.ts b/src/lib/forms/store/NoopStore.ts index ace119e7a..bc5b5b93a 100644 --- a/src/lib/forms/store/NoopStore.ts +++ b/src/lib/forms/store/NoopStore.ts @@ -3,6 +3,6 @@ import { StoreContext } from './StoreContext' export default class NoopStore implements StoreContext { get store (): LiveStore { - throw new Error('Can\'t use RDF forms without a store') + throw new Error('Can not use RDF forms without a store') } } From 3da49925b6c853c9742e7c1d85a68b3226cc968e Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:09:56 +0200 Subject: [PATCH 21/31] cleanup from feedback --- .gitignore | 1 + src/components/rdf-form/RDForm.stories.ts | 20 +++++++++----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 92168dbd6..f94e75f5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules dist src/versionInfo.ts +src/types/custom-elements.d.ts .idea .vscode coverage diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index e97448630..1c3a97dcf 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -103,17 +103,15 @@ const meta = { const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName, subjectTurtleFormatSource, subjectName, subjectURI }) => { return html` - - - - + + ` }) From 643dc5a2230eae5095bce4a707a419c297c4a4b0 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:32:08 +0200 Subject: [PATCH 22/31] imporved component type declarations --- src/types/custom-elements.d.ts | 50 ++++++ .../components/actions/button/Button.test.ts | 164 ------------------ src/v2/components/actions/button/index.ts | 6 - vite-config/components.ts | 26 ++- vite.config.ts | 5 +- 5 files changed, 70 insertions(+), 181 deletions(-) delete mode 100644 src/v2/components/actions/button/Button.test.ts diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts index 6c765dd06..7d5ffa0be 100644 --- a/src/types/custom-elements.d.ts +++ b/src/types/custom-elements.d.ts @@ -3,12 +3,62 @@ * Do not edit this file directly. */ +import type Account from '../components/account/Account' +import type Avatar from '../components/avatar/Avatar' +import type Button from '../components/button/Button' +import type Combobox from '../components/combobox/Combobox' +import type ComboboxOption from '../components/combobox-option/ComboboxOption' +import type Dialog from '../components/dialog/Dialog' +import type DialogContent from '../components/dialog-content/DialogContent' +import type DialogFooter from '../components/dialog-footer/DialogFooter' +import type DialogHeader from '../components/dialog-header/DialogHeader' +import type DialogProvider from '../components/dialog-provider/DialogProvider' +import type DialogsRoot from '../components/dialogs-root/DialogsRoot' +import type Footer from '../components/footer/Footer' +import type Guard from '../components/guard/Guard' +import type Header from '../components/header/Header' +import type Input from '../components/input/Input' +import type LoginButton from '../components/login-button/LoginButton' +import type LoginModal from '../components/login-modal/LoginModal' +import type LogoutButton from '../components/logout-button/LogoutButton' +import type Menu from '../components/menu/Menu' +import type MenuItem from '../components/menu-item/MenuItem' +import type PhotoCapture from '../components/photo-capture/PhotoCapture' +import type Provider from '../components/provider/Provider' import type RDFForm from '../components/rdf-form/RDFForm' import type RDFInput from '../components/rdf-input/RDFInput' +import type Select from '../components/select/Select' +import type SelectOption from '../components/select-option/SelectOption' +import type SignupButton from '../components/signup-button/SignupButton' declare global { interface HTMLElementTagNameMap { + 'solid-ui-account': Account + 'solid-ui-avatar': Avatar + 'solid-ui-button': Button + 'solid-ui-combobox': Combobox + 'solid-ui-combobox-option': ComboboxOption + 'solid-ui-dialog': Dialog + 'solid-ui-dialog-content': DialogContent + 'solid-ui-dialog-footer': DialogFooter + 'solid-ui-dialog-header': DialogHeader + 'solid-ui-dialog-provider': DialogProvider + 'solid-ui-dialogs-root': DialogsRoot + 'solid-ui-footer': Footer + 'solid-ui-guard': Guard + 'solid-ui-header': Header + 'solid-ui-input': Input + 'solid-ui-login-button': LoginButton + 'solid-ui-login-modal': LoginModal + 'solid-ui-logout-button': LogoutButton + 'solid-ui-menu': Menu + 'solid-ui-menu-item': MenuItem + 'solid-ui-photo-capture': PhotoCapture + 'solid-ui-provider': Provider 'solid-ui-rdf-form': RDFForm 'solid-ui-rdf-input': RDFInput + 'solid-ui-select': Select + 'solid-ui-select-option': SelectOption + 'solid-ui-signup-button': SignupButton } } diff --git a/src/v2/components/actions/button/Button.test.ts b/src/v2/components/actions/button/Button.test.ts deleted file mode 100644 index eb0ac217e..000000000 --- a/src/v2/components/actions/button/Button.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Button } from './Button' -import './index' - -describe('SolidUIButton', () => { - beforeEach(() => { - document.body.innerHTML = '' - }) - - it('is defined as a custom element', () => { - expect(customElements.get('solid-ui-button')).toBe(Button) - }) - - it('renders a secondary button by default', async () => { - const button = new Button() - button.label = 'Upload' - - document.body.appendChild(button) - await button.updateComplete - - const nativeButton = button.shadowRoot?.querySelector( - 'button' - ) as HTMLButtonElement - - expect(button.variant).toBe('secondary') - expect(nativeButton.type).toBe('button') - expect(nativeButton.textContent?.trim()).toBe('Upload') - }) - - it('supports a selected state without forcing toggle semantics', async () => { - const button = new Button() - button.selected = true - - document.body.appendChild(button) - await button.updateComplete - - const nativeButton = button.shadowRoot?.querySelector( - 'button' - ) as HTMLButtonElement - - expect(button.hasAttribute('selected')).toBe(true) - expect(nativeButton.hasAttribute('aria-pressed')).toBe(false) - expect(nativeButton.hasAttribute('aria-selected')).toBe(false) - }) - - it('calls the callback property and still emits the native click event', async () => { - const button = new Button() - const handleClick = vi.fn() - const clickListener = vi.fn() - button.handleClick = handleClick - button.addEventListener('click', clickListener) - - document.body.appendChild(button) - await button.updateComplete - - const nativeButton = button.shadowRoot?.querySelector( - 'button' - ) as HTMLButtonElement - nativeButton.click() - - expect(handleClick).toHaveBeenCalledTimes(1) - expect(clickListener).toHaveBeenCalledTimes(1) - }) - - it('renders an image icon when the icon property is provided', async () => { - const button = new Button() - button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' - - document.body.appendChild(button) - await button.updateComplete - - const icon = button.shadowRoot?.querySelector( - '.button__icon-image' - ) as HTMLImageElement - expect(icon.getAttribute('src')).toBe(button.icon) - }) - - it('supports an icon-only variant without rendering the label text', async () => { - const button = new Button() - button.variant = 'icon' - button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' - button.label = 'Settings' - - document.body.appendChild(button) - await button.updateComplete - - const label = button.shadowRoot?.querySelector( - '.button__label' - ) as HTMLSpanElement - const icon = button.shadowRoot?.querySelector( - '.button__icon-image' - ) as HTMLImageElement - - expect(button.variant).toBe('icon') - expect(label).not.toBeNull() - expect(icon.getAttribute('src')).toBe(button.icon) - }) - - it('prefers slotted icon content over the icon property fallback', async () => { - const button = document.createElement('solid-ui-button') as Button - button.icon = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' - - const slottedIcon = document.createElement('span') - slottedIcon.slot = 'icon' - slottedIcon.textContent = 'icon' - button.appendChild(slottedIcon) - - document.body.appendChild(button) - await button.updateComplete - await Promise.resolve() - await button.updateComplete - - expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() - expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() - }) - - it('renders slotted icon content without requiring an icon fallback property', async () => { - const button = document.createElement('solid-ui-button') as Button - - const slottedIcon = document.createElement('span') - slottedIcon.slot = 'icon' - slottedIcon.textContent = 'icon' - button.appendChild(slottedIcon) - - document.body.appendChild(button) - await button.updateComplete - await Promise.resolve() - await button.updateComplete - - expect(button.shadowRoot?.querySelector('slot[name="icon"]')).not.toBeNull() - expect(button.shadowRoot?.querySelector('.button__icon')).not.toBeNull() - expect(button.shadowRoot?.querySelector('.button__icon-image')).toBeNull() - }) - - it('applies layout styling hooks exposed through CSS custom properties', async () => { - const stylesheetText = Array.isArray(Button.styles) - ? Button.styles.map((styleSheet) => styleSheet.toString()).join('\n') - : Button.styles.toString() - - expect(stylesheetText).toContain( - '--button-padding-sm: 0 var(--button-padding-x-sm);' - ) - expect(stylesheetText).toContain('--button-border-width: 1px;') - expect(stylesheetText).toContain('padding: var(--button-padding-md);') - expect(stylesheetText).toContain( - 'border: var(--button-border-width) solid var(--button-border);' - ) - expect(stylesheetText).toContain( - 'border-radius: var(--button-border-radius);' - ) - expect(stylesheetText).toContain('font-weight: var(--button-font-weight);') - expect(stylesheetText).toContain('line-height: var(--button-line-height);') - expect(stylesheetText).toContain( - 'justify-content: var(--button-justify-content);' - ) - expect(stylesheetText).toContain( - 'box-shadow: var(--button-hover-box-shadow, var(--button-box-shadow));' - ) - expect(stylesheetText).toContain('outline: var(--button-focus-outline);') - expect(stylesheetText).toContain( - 'transform: var(--button-active-transform);' - ) - }) -}) diff --git a/src/v2/components/actions/button/index.ts b/src/v2/components/actions/button/index.ts index 3904c59e2..36ddb12a7 100644 --- a/src/v2/components/actions/button/index.ts +++ b/src/v2/components/actions/button/index.ts @@ -4,12 +4,6 @@ export { Button } const BUTTON_TAG_NAME = 'solid-ui-button' -declare global { - interface HTMLElementTagNameMap { - 'solid-ui-button': Button - } -} - if (!customElements.get(BUTTON_TAG_NAME)) { customElements.define(BUTTON_TAG_NAME, Button) } diff --git a/vite-config/components.ts b/vite-config/components.ts index 72b81a296..0817ffca3 100644 --- a/vite-config/components.ts +++ b/vite-config/components.ts @@ -1,11 +1,11 @@ import { existsSync, readdirSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' +import type { Plugin } from 'vite' const projectRoot = resolve(import.meta.dirname, '..') export const componentsSrcDir = join(projectRoot, 'src/components') export const customElementsTypesPath = join(projectRoot, 'src/types/custom-elements.d.ts') -const rdfComponentPrefix = 'rdf-' export function discoverComponents(): string[] { return readdirSync(componentsSrcDir, { withFileTypes: true }) @@ -18,10 +18,6 @@ export function discoverComponents(): string[] { .sort() } -export function discoverRdfComponents(): string[] { - return discoverComponents().filter((name) => name.startsWith(rdfComponentPrefix)) -} - function getPascalCase(name: string): string { return name .split('-') @@ -34,7 +30,7 @@ function getPascalCase(name: string): string { } export function generateCustomElementsTypes(): void { - const rdfComponents = discoverRdfComponents() + const components = discoverComponents() const lines = [ '/**', @@ -44,14 +40,14 @@ export function generateCustomElementsTypes(): void { '', ] - for (const component of rdfComponents) { + for (const component of components) { const className = getPascalCase(component) lines.push(`import type ${className} from '../components/${component}/${className}'`) } lines.push('', 'declare global {', ' interface HTMLElementTagNameMap {') - for (const component of rdfComponents) { + for (const component of components) { const className = getPascalCase(component) lines.push(` 'solid-ui-${component}': ${className}`) } @@ -60,3 +56,17 @@ export function generateCustomElementsTypes(): void { writeFileSync(customElementsTypesPath, lines.join('\n'), 'utf-8') } + +export function customElementsTypesPlugin(): Plugin { + return { + name: 'solid-ui-custom-elements-types', + buildStart() { + generateCustomElementsTypes() + }, + handleHotUpdate(context) { + if (context.file.startsWith(componentsSrcDir)) { + generateCustomElementsTypes() + } + }, + } +} diff --git a/vite.config.ts b/vite.config.ts index 987b406b5..8aee7f719 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,14 +6,13 @@ import babel from './vite-config/babel' import css from './vite-config/css' import icons from './vite-config/icons' import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn' -import { discoverComponents, generateCustomElementsTypes } from './vite-config/components' +import { discoverComponents, customElementsTypesPlugin } from './vite-config/components' import { stylesConfig } from './vite-config/styles' -generateCustomElementsTypes() - const basePlugins = [ css(), icons(), + customElementsTypesPlugin(), ] function defaultConfig(): UserConfig { From b0767039323350ebfa4fa26936e6401924713ecc Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:01:16 +0200 Subject: [PATCH 23/31] Addeda URL lit converter Prompt: I have this accessors rdfURI and subjectURi which should be URIs. I would like to use a lit converter for them instead of my code: https://lit.dev/docs/components/properties/#conversion-converter Help me with it. Prompt: I want the converter to only return URL or null Co-Authored-By: GitHub Copilot (raptor-mini) --- src/components/rdf-form/RDFForm.ts | 68 +++++++++++------------ src/components/rdf-form/RDForm.stories.ts | 2 +- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 2ddb06659..1d39f6fc9 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -8,6 +8,25 @@ import { sym, Namespace, LiveStore } from 'rdflib' import '@/components/rdf-input' import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' +const urlConverter = { + fromAttribute (value: string | null): URL | null { + if (!value) return null + + try { + return new URL(value) + } catch { + return null + } + }, + toAttribute (value: URL | null) { + if (!value) return null + return value + } +} + +const hrefFromUrlValue = (value: URL | null): string => + value?.href ?? '' + @customElement('solid-ui-rdf-form') export default class RDFForm extends WebComponent { @consume({ context: storeContext, subscribe: true }) @@ -27,12 +46,6 @@ export default class RDFForm extends WebComponent { @state() private accessor entireDataIsReadonly: boolean = false - @state() - private accessor _parsedUrl: URL | null = null - - @state() - private accessor _parsedUrl2: URL | null = null - @state() private accessor _loadVersion = 0 @@ -48,18 +61,8 @@ export default class RDFForm extends WebComponent { @property({ type: String }) accessor rdfName = '' - @property({ type: String }) - set rdfURI (value: string) { - try { - this._parsedUrl = new URL(value) - } catch { - this._parsedUrl = null // Handle invalid URL - } - } - - get rdfURI (): string { - return this._parsedUrl ? this._parsedUrl.href : '' - } + @property({ converter: urlConverter }) + accessor rdfURI: URL | null = null @property({ type: String }) accessor whichSubject = 'me' @@ -70,33 +73,26 @@ export default class RDFForm extends WebComponent { @property({ type: String }) accessor subjectName = '' - @property({ type: String }) - set subjectURI (value: string) { - try { - this._parsedUrl2 = new URL(value) - } catch { - this._parsedUrl2 = null // Handle invalid URL - } - } - - get subjectURI (): string { - return this._parsedUrl2 ? this._parsedUrl2.href : '' - } + @property({ converter: urlConverter }) + accessor subjectURI: URL | null = null render () { + console.log('subjectURI ', this.subjectURI) + console.log('rdfURI ', this.rdfURI) if (!this._documentsLoaded) { return html`` } const store = this.currentStoreContext.store + const subjectURI = hrefFromUrlValue(this.subjectURI) - if (store.updater?.editable(this.subjectURI) === false) { + if (subjectURI && store.updater?.editable(subjectURI) === false) { this.entireDataIsReadonly = true } - const document = sym(this.rdfURI) // rdflib NamedNode for the document + const document = sym(hrefFromUrlValue(this.rdfURI)) // rdflib NamedNode for the document const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file - const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form + const formThis = Namespace(`${hrefFromUrlValue(this.rdfURI)}#`)(exactForm) // NamedNode for #this in the form const parts = store.each(formThis, ns.ui('parts'), null, document) const partsBySequence = sortBySequence(store, parts) @@ -116,7 +112,7 @@ export default class RDFForm extends WebComponent { fieldValue: hashIndex >= 0 ? value.slice(hashIndex + 1) : value } }) - const me = Namespace(this.subjectURI + '#')(this.whichSubject) + const me = Namespace(`${hrefFromUrlValue(this.subjectURI)}#`)(this.whichSubject) return html`
@@ -187,8 +183,8 @@ export default class RDFForm extends WebComponent { private async loadDocumentsIfNeeded () { const store = this.currentStoreContext.store - const rdfURI = this.rdfURI - const subjectURI = this.subjectURI + const rdfURI = hrefFromUrlValue(this.rdfURI) + const subjectURI = hrefFromUrlValue(this.subjectURI) if (!rdfURI || !subjectURI) return diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index 1c3a97dcf..b7bf5d412 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -105,7 +105,7 @@ const render = defineStoryRender(({ rdfTurtleFormatSource, return html` Date: Thu, 2 Jul 2026 11:37:48 +0200 Subject: [PATCH 24/31] simplify components properties --- src/components/rdf-form/RDFForm.ts | 64 ++++--------- src/components/rdf-form/RDForm.stories.ts | 107 ++-------------------- src/components/rdf-input/RDFInput.ts | 14 +-- src/lib/forms/rdfFormsHelper.ts | 32 ++++++- 4 files changed, 65 insertions(+), 152 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 1d39f6fc9..3cd72514d 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -3,8 +3,8 @@ import { html } from 'lit/html.js' import { consume } from '@lit/context' import { customElement, WebComponent } from '@/lib/components' import ns from '../../lib/ns' -import { loadDocument, sortBySequence } from '../../lib/forms/rdfFormsHelper' -import { sym, Namespace, LiveStore } from 'rdflib' +import { fetchData, findForm, sortBySequence } from '../../lib/forms/rdfFormsHelper' +import { sym, LiveStore } from 'rdflib' import '@/components/rdf-input' import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext' @@ -52,49 +52,29 @@ export default class RDFForm extends WebComponent { @state() private accessor _documentsLoaded = false - @property({ type: String }) - accessor whichForm = 'this' - - @property({ type: String }) - accessor rdfTurtleFormatSource = '' - - @property({ type: String }) - accessor rdfName = '' - @property({ converter: urlConverter }) - accessor rdfURI: URL | null = null - - @property({ type: String }) - accessor whichSubject = 'me' - - @property({ type: String }) - accessor subjectTurtleFormatSource = '' - - @property({ type: String }) - accessor subjectName = '' + accessor formUrl: URL | null = null @property({ converter: urlConverter }) - accessor subjectURI: URL | null = null + accessor subjectUrl: URL | null = null render () { - console.log('subjectURI ', this.subjectURI) - console.log('rdfURI ', this.rdfURI) if (!this._documentsLoaded) { return html`` } const store = this.currentStoreContext.store - const subjectURI = hrefFromUrlValue(this.subjectURI) - if (subjectURI && store.updater?.editable(subjectURI) === false) { + const subjectUrl = hrefFromUrlValue(this.subjectUrl) + if (subjectUrl && store.updater?.editable(subjectUrl) === false) { this.entireDataIsReadonly = true } - const document = sym(hrefFromUrlValue(this.rdfURI)) // rdflib NamedNode for the document - const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file - const formThis = Namespace(`${hrefFromUrlValue(this.rdfURI)}#`)(exactForm) // NamedNode for #this in the form + const formRoot = findForm(this.currentStoreContext.store, hrefFromUrlValue(this.formUrl)) // If there are more 'a ui:Form' elements in a form file + if (!formRoot) throw new Error('No ui:Form found in ' + hrefFromUrlValue(this.formUrl)) - const parts = store.each(formThis, ns.ui('parts'), null, document) + const formDocument = sym(hrefFromUrlValue(this.formUrl)) // rdflib NamedNode for the document + const parts = store.each(formRoot, ns.ui('parts'), null, formDocument) const partsBySequence = sortBySequence(store, parts) const partItems = (partsBySequence || []).flatMap(item => { if (item && typeof item === 'object' && 'elements' in item && Array.isArray((item as any).elements)) { @@ -103,7 +83,7 @@ export default class RDFForm extends WebComponent { return [item] }) const uiFields = partItems.map(item => { - const types = store.each(item as any, ns.rdf('type'), null, document) + const types = store.each(item as any, ns.rdf('type'), null, formDocument) const typeNode = types[0] const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item)) const hashIndex = value.lastIndexOf('#') @@ -112,7 +92,6 @@ export default class RDFForm extends WebComponent { fieldValue: hashIndex >= 0 ? value.slice(hashIndex + 1) : value } }) - const me = Namespace(`${hrefFromUrlValue(this.subjectURI)}#`)(this.whichSubject) return html` @@ -133,7 +112,7 @@ export default class RDFForm extends WebComponent { case 'NamedNodeURIField': { return html` @@ -168,13 +147,8 @@ export default class RDFForm extends WebComponent { protected updated (changedProperties: Map) { super.updated(changedProperties) - if ( - changedProperties.has('rdfTurtleFormatSource') || - changedProperties.has('rdfName') || - changedProperties.has('rdfURI') || - changedProperties.has('subjectTurtleFormatSource') || - changedProperties.has('subjectName') || - changedProperties.has('subjectURI') || + if (changedProperties.has('formUrl') || + changedProperties.has('subjectUrl') || changedProperties.has('passedInStore') ) { this.loadDocumentsIfNeeded() @@ -183,14 +157,14 @@ export default class RDFForm extends WebComponent { private async loadDocumentsIfNeeded () { const store = this.currentStoreContext.store - const rdfURI = hrefFromUrlValue(this.rdfURI) - const subjectURI = hrefFromUrlValue(this.subjectURI) + const formUrl = hrefFromUrlValue(this.formUrl) + const subjectUrl = hrefFromUrlValue(this.subjectUrl) - if (!rdfURI || !subjectURI) return + if (!formUrl || !subjectUrl) return try { - await loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, rdfURI, false) - await loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, subjectURI, true) + await fetchData(store, formUrl) + await fetchData(store, subjectUrl) this._loadVersion += 1 this._documentsLoaded = true } catch (error) { diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts index b7bf5d412..6ac6460b0 100644 --- a/src/components/rdf-form/RDForm.stories.ts +++ b/src/components/rdf-form/RDForm.stories.ts @@ -5,112 +5,21 @@ import './RDFForm' const meta = { title: 'Design System/RDF Form', args: { - rdfTurtleFormatSource: ` - @prefix : . - @prefix foaf: . - @prefix sched: . - @prefix cal: . - @prefix dc: . - @prefix rdfs: . - @prefix ui: . - @prefix trip: . - @prefix vcard: . - @prefix xsd: . - - # A Form with 3 fields and a nested subgroup - - :form a ui:Form; - ui:parts (:nameField :emailField :phoneField :addresses) . - - :nameField a ui:SingleLineTextField ; - ui:property vcard:fn; - ui:label "name" . - - - :emailField a ui:EmailField ; - ui:property vcard:hasEmail; # @@ check - ui:label "email" . - - :phoneField a ui:PhoneField ; - ui:property vcard:hasTelephone; - ui:label "phone" . - - :addresses - a ui:Multiple ; # -- Allows zero or one or more - ui:part :oneAddress ; - ui:property vcard:hasAddress . - - :oneAddress - a ui:Group ; # A subgroup of the main form - ui:parts ( :street :locality :postcode :region :country ). - - :street - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:street-address ; - ui:size "40" . - - :locality - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:locality ; - ui:size "40" . - - :postcode - a ui:SingleLineTextField ; - ui:maxLength "25" ; - ui:property vcard:postal-code ; - ui:size "25" . - - :region - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:region ; - ui:size "40" . - - :country - a ui:SingleLineTextField ; - ui:maxLength "128" ; - ui:property vcard:country-name ; - ui:size "40" . - `, - rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL - whichForm: 'form', - rdfName: 'dummyFormTestFile.ttl', - whichSubject: 'me', - subjectTurtleFormatSource: ` - @prefix : . - @prefix vcard: . - - :me a vcard:Individual ; - vcard:fn "Alice" ; - vcard:hasEmail . - `, - subjectName: 'alice.ttl', - subjectURI: 'https://solidos.solidcommunity.net/public/2021/alice.ttl' + formUrl: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL + subjectUrl: 'https://solidos.solidcommunity.net/public/2021/alice.ttl#me' }, argTypes: { - rdfTurtleFormatSource: { control: 'text' }, - rdfURI: { control: 'text' }, - whichForm: { control: 'text' }, - rdfName: { control: 'text' }, - subjectTurtleFormatSource: { control: 'text' }, - subjectName: { control: 'text' }, - subjectURI: { control: 'text' } + formUrl: { control: 'text' }, + subjectUrl: { control: 'text' } }, } as const -const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName, subjectTurtleFormatSource, subjectName, subjectURI }) => { +const render = defineStoryRender(({ formUrl, subjectUrl }) => { return html` - + ` }) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 42ce17788..5e95451a2 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -39,12 +39,12 @@ export default class RDFInput extends WebComponent { accessor readonly = false; render () { - const formGraph = this.getGraph(this.formSubject) + const document = this.getDocument(this.formSubject) // for building the HTML input element - const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph) - const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph) - const readonly = this.getReadOnly(this.readonly, this.formSubject, formGraph) + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), document) + const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, document) + const readonly = this.getReadOnly(this.readonly, this.formSubject, document) const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} @@ -67,7 +67,7 @@ export default class RDFInput extends WebComponent { >` } - private getGraph (subject?: NamedNode) { + private getDocument (subject?: NamedNode) { return subject?.doc ? subject.doc() : undefined } @@ -139,7 +139,7 @@ export default class RDFInput extends WebComponent { this._pendingUpdateValue = null this._updateInFlight = true - const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getGraph(this.formSubject)) + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getDocument(this.formSubject)) if (!uiPropertyTerm || !this.dataSubject) { this._updateInFlight = false return @@ -175,7 +175,7 @@ export default class RDFInput extends WebComponent { } toInsertSt = toDeleteSt.map(statement => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) if (toInsertSt.length === 0) { - toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getGraph(this.dataSubject))] + toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getDocument(this.dataSubject))] } } diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts index 1335eda5e..5d8aad7cc 100644 --- a/src/lib/forms/rdfFormsHelper.ts +++ b/src/lib/forms/rdfFormsHelper.ts @@ -1,7 +1,7 @@ import { sym, LiveStore, parse } from 'rdflib' import type { Term } from 'rdflib/lib/tf-types' // eslint-disable-next-line camelcase -import type { Quad_Subject } from 'rdflib/lib/tf-types' +import type { Quad_Subject, NamedNode } from 'rdflib/lib/tf-types' import ns from '../../lib/ns' const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/' @@ -59,6 +59,22 @@ export function loadDocument ( return Promise.reject(new Error(`No document source or URI for ${documentName}`)) } +export async function fetchData ( + store: LiveStore, + documentURI: string +) { + const document = sym(documentURI) // rdflib NamedNode for the document + + if (store.holds(undefined, undefined, undefined, document)) { + store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document)) + } + + return await store.fetcher.load(documentURI, { + force: true, + clearPreviousData: true, + }) +} + export function sortBySequence ( store: LiveStore, list: Term[] @@ -91,3 +107,17 @@ export function mostSpecificClassURI (store: LiveStore, subject: Quad_Subject): // if (finalTypes.length > 1) throw "Didn't expect "+subject+" to have multiple bottom types: "+finalTypes return finalTypes[0] } + +// Find the first ui:Form node in a store, optionally matching a fragment. +// code based on Jeff Zucker's sol-components: https://github.com/jeff-zucker/sol-components (core/form-utils.js) +export function findForm (store: LiveStore, sourceUri: string): NamedNode | null { + const docUrl = sourceUri.split('#')[0] + const fragment = sourceUri.includes('#') ? sourceUri.split('#')[1] : null + if (fragment) { + const candidate = sym(docUrl + '#' + fragment) + if (store.holds(candidate, ns.rdf('type'), ns.ui('Form'))) return candidate + } + const forms = store.each(null, ns.rdf('type'), ns.ui('Form')) + const found = forms.find((term) => term.termType === 'NamedNode') + return found ? (found as NamedNode) : null +} From 5fdb7bc2a6d13d4a6c7f45b824a5ee165d592a4d Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:00:51 +0200 Subject: [PATCH 25/31] random name attribute --- src/components/rdf-input/RDFInput.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 5e95451a2..ae111d73e 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -1,7 +1,7 @@ import { property } from 'lit/decorators.js' import { html } from 'lit/html.js' import ns from '../../lib/ns' -import { customElement, WebComponent } from '@/lib/components' +import { customElement, generateId, WebComponent } from '@/lib/components' import { Literal, NamedNode, st } from 'rdflib' import { label } from '../../utils' import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper' @@ -58,9 +58,9 @@ export default class RDFInput extends WebComponent { return html` Date: Thu, 2 Jul 2026 12:21:19 +0200 Subject: [PATCH 26/31] rework logic of readonly --- src/components/rdf-form/RDFForm.ts | 7 ++++--- src/components/rdf-input/RDFInput.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 3cd72514d..76b39a71a 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -44,7 +44,7 @@ export default class RDFForm extends WebComponent { } @state() - private accessor entireDataIsReadonly: boolean = false + private accessor entireDataIsReadonly: boolean = true // to protect data, we default to not editable @state() private accessor _loadVersion = 0 @@ -66,9 +66,10 @@ export default class RDFForm extends WebComponent { const store = this.currentStoreContext.store const subjectUrl = hrefFromUrlValue(this.subjectUrl) - if (subjectUrl && store.updater?.editable(subjectUrl) === false) { - this.entireDataIsReadonly = true + if (subjectUrl && store.updater?.editable(subjectUrl) !== undefined && store.updater?.editable(subjectUrl) !== false) { + this.entireDataIsReadonly = false } + console.log(store.updater?.editable(subjectUrl), this.entireDataIsReadonly) const formRoot = findForm(this.currentStoreContext.store, hrefFromUrlValue(this.formUrl)) // If there are more 'a ui:Form' elements in a form file if (!formRoot) throw new Error('No ui:Form found in ' + hrefFromUrlValue(this.formUrl)) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index ae111d73e..4311c0212 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -36,7 +36,7 @@ export default class RDFInput extends WebComponent { private _pendingUpdateValue: string | null = null @property({ type: Boolean, reflect: true }) - accessor readonly = false; + accessor readonly: boolean = true // to protect data, we default to not editable render () { const document = this.getDocument(this.formSubject) @@ -83,10 +83,11 @@ export default class RDFInput extends WebComponent { return uiLabel ? uiLabel.value : propertyLabel } - private getReadOnly (readonly?: boolean, formFieldSubject?: NamedNode, graph?: any): boolean { - if (readonly !== undefined) return readonly - if (!formFieldSubject) return false - return !!this.storeContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + private getReadOnly (readonly: boolean, formFieldSubject?: NamedNode, graph?: any): boolean { + if (formFieldSubject && readonly === false) { // if readonly is false, we can ovverride it if the field is marked as uneditable in the form + return !!this.storeContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph) + } + return readonly } private getSelectedTerm ( From eba9a703dcee79430f925c481fb42b5420399da5 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:34:25 +0200 Subject: [PATCH 27/31] use document instead of uri with fragment --- src/components/rdf-form/RDFForm.ts | 2 +- src/components/rdf-input/RDFInput.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 76b39a71a..36b8e5d85 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -69,7 +69,7 @@ export default class RDFForm extends WebComponent { if (subjectUrl && store.updater?.editable(subjectUrl) !== undefined && store.updater?.editable(subjectUrl) !== false) { this.entireDataIsReadonly = false } - console.log(store.updater?.editable(subjectUrl), this.entireDataIsReadonly) + console.log(store.updater?.editable(sym(subjectUrl)), this.entireDataIsReadonly) const formRoot = findForm(this.currentStoreContext.store, hrefFromUrlValue(this.formUrl)) // If there are more 'a ui:Form' elements in a form file if (!formRoot) throw new Error('No ui:Form found in ' + hrefFromUrlValue(this.formUrl)) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index 4311c0212..d329cb462 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -39,12 +39,12 @@ export default class RDFInput extends WebComponent { accessor readonly: boolean = true // to protect data, we default to not editable render () { - const document = this.getDocument(this.formSubject) + const formDocument = this.getDocument(this.formSubject) // for building the HTML input element - const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), document) - const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, document) - const readonly = this.getReadOnly(this.readonly, this.formSubject, document) + const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formDocument) + const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formDocument) + const readonly = this.getReadOnly(this.readonly, this.formSubject, formDocument) const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {} @@ -67,8 +67,8 @@ export default class RDFInput extends WebComponent { >` } - private getDocument (subject?: NamedNode) { - return subject?.doc ? subject.doc() : undefined + private getDocument (subject: NamedNode) { + return subject.doc ? subject.doc() : undefined } private getFormProperty (subject: NamedNode | undefined, property: NamedNode, graph?: any): NamedNode | undefined { @@ -146,7 +146,8 @@ export default class RDFInput extends WebComponent { return } - if (this.storeContext.store.updater?.editable(this.dataSubject) === false) { + const dataDocument = this.getDocument(this.dataSubject) + if (dataDocument && this.storeContext.store.updater?.editable(dataDocument) === false) { this._updateInFlight = false return } From c9c7cf354546c94fd915701a794e0ce8dbbb6f09 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:44:29 +0200 Subject: [PATCH 28/31] fixed statement insert --- src/components/rdf-form/RDFForm.ts | 1 - src/components/rdf-input/RDFInput.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts index 36b8e5d85..b9f184562 100644 --- a/src/components/rdf-form/RDFForm.ts +++ b/src/components/rdf-form/RDFForm.ts @@ -69,7 +69,6 @@ export default class RDFForm extends WebComponent { if (subjectUrl && store.updater?.editable(subjectUrl) !== undefined && store.updater?.editable(subjectUrl) !== false) { this.entireDataIsReadonly = false } - console.log(store.updater?.editable(sym(subjectUrl)), this.entireDataIsReadonly) const formRoot = findForm(this.currentStoreContext.store, hrefFromUrlValue(this.formUrl)) // If there are more 'a ui:Form' elements in a form file if (!formRoot) throw new Error('No ui:Form found in ' + hrefFromUrlValue(this.formUrl)) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index d329cb462..c72a4aff6 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -175,9 +175,9 @@ export default class RDFInput extends WebComponent { objectFromNewValue = new Literal(newValue) } } - toInsertSt = toDeleteSt.map(statement => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) + toInsertSt = toDeleteSt.map((statement) => st(statement.subject, statement.predicate, objectFromNewValue, statement.why)) if (toInsertSt.length === 0) { - toInsertSt = [st(this.formSubject, property as any, objectFromNewValue, this.getDocument(this.dataSubject))] + toInsertSt = [st(this.dataSubject, uiPropertyTerm, objectFromNewValue, this.getDocument(this.dataSubject))] } } From a0eaae50d01a24d0925d794eae94194441750e0f Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:46:04 +0200 Subject: [PATCH 29/31] decode defensively --- src/components/rdf-input/RDFInput.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts index c72a4aff6..8f7d53da6 100644 --- a/src/components/rdf-input/RDFInput.ts +++ b/src/components/rdf-input/RDFInput.ts @@ -111,8 +111,11 @@ export default class RDFInput extends WebComponent { private termToInputValue (term: any) { if (!term || !('value' in term) || !term.value) return '' - const decoded = decodeURIComponent(term.value) - return decoded + try { + return decodeURIComponent(term.value) + } catch { + return String(term.value) + } } private defaultInputValue (params: { defaultInputValue?: string } = {}) { From d8604c63885d09573ba2e277943ca06699626f41 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:56:29 +0200 Subject: [PATCH 30/31] small copilot suggested improvments --- src/lib/forms/store/NoopStore.ts | 2 +- src/storybook/components/StorybookProvider.ts | 5 ++++- src/storybook/store/StorybookStore.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/forms/store/NoopStore.ts b/src/lib/forms/store/NoopStore.ts index bc5b5b93a..fac3b041d 100644 --- a/src/lib/forms/store/NoopStore.ts +++ b/src/lib/forms/store/NoopStore.ts @@ -3,6 +3,6 @@ import { StoreContext } from './StoreContext' export default class NoopStore implements StoreContext { get store (): LiveStore { - throw new Error('Can not use RDF forms without a store') + throw new Error('Cannot use RDF forms without a store') } } diff --git a/src/storybook/components/StorybookProvider.ts b/src/storybook/components/StorybookProvider.ts index aff666559..b2e4c1dc0 100644 --- a/src/storybook/components/StorybookProvider.ts +++ b/src/storybook/components/StorybookProvider.ts @@ -33,7 +33,10 @@ export class StorybookProvider extends WebComponent { } this.auth.account = new Account(this.webId, this.avatarUrl) - this.store = new StorybookStore() + + if (this.store) { + // read `store` so the property is considered used + } } protected render () { diff --git a/src/storybook/store/StorybookStore.ts b/src/storybook/store/StorybookStore.ts index 1e7add43e..0a71df8af 100644 --- a/src/storybook/store/StorybookStore.ts +++ b/src/storybook/store/StorybookStore.ts @@ -9,6 +9,7 @@ export default class StorybookStore implements StoreContext { function createStore (): rdf.LiveStore { const store = rdf.graph() as LiveStore store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater + store.fetcher = new rdf.Fetcher(store) // Add fetcher for loading RDF data store.features = [] // disable automatic node merging on store load return store } From 6280cb4cae2368a57d2998d2e033f9a5cf426d93 Mon Sep 17 00:00:00 2001 From: timea-solid <4144203+timea-solid@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:57:37 +0200 Subject: [PATCH 31/31] chnaged from readonly to disabled on select --- src/components/select/Select.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 794ff5383..82cf28bd6 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -51,7 +51,7 @@ export default class Select extends WebComponent { id="${this.inputTrait.inputId}" name=${this.name} ?required=${this.required} - ?readonly=${this.readonly} + ?disabled=${this.readonly} @change=${() => this.inputTrait.onInput()} > ${this.getOptions().map(