diff --git a/package.json b/package.json index cf54778..458216a 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "react-admin": "^3.6.2", "react-dom": "^16.13.1", "react-scripts": "3.4.1", - "tripledoc": "^4.3.4" + "tripledoc": "^4.3.4", + "uuid": "^8.3.0" }, "scripts": { "start": "react-scripts start", diff --git a/public/index.html b/public/index.html index aa069f2..5a2dbed 100644 --- a/public/index.html +++ b/public/index.html @@ -1,21 +1,19 @@ - - - - - - - - - - - React App - - - -
- - - + + + + \ No newline at end of file diff --git a/src/dataProvider.js b/src/dataProvider.js index c4ad471..17e8a7e 100644 --- a/src/dataProvider.js +++ b/src/dataProvider.js @@ -1,61 +1,129 @@ +/* global Comunica */ import auth from 'solid-auth-client'; import { createDocument, fetchDocument } from 'tripledoc'; import { rdf, schema, solid, space } from 'rdf-namespaces'; +import * as uuid from 'uuid'; const resourceDefinitions = { products: { schema: schema.Product, fields: { - id: { + identifier: { schema: schema.identifier, - type: String, + // This is used to cast data correctly in SPARQL queries + type: 'xsd:string', + // Name of this field inside the document + // We renamed the product id field to identifier + // This is because we need the react admin id field to be the document IRI, + // allowing easier getOne, delete, etc. as we don't have to query documents + // matching the domain id + documentName: 'id', }, name: { schema: schema.name, - type: String, + type: 'xsd:string', + // Used to mark a field as compatible with the fulltext search + // from the react-admin SearchInput mapped on `filter.q` + fullTextSearch: true, + }, + width: { + schema: schema.width, + type: 'xsd:integer', + }, + height: { + schema: schema.height, + type: 'xsd:integer', }, - // reference: { - // schema: schema.productID, - // type: String, - // }, - // width: { - // schema: schema.width, - // type: Number, - // }, - // height: { - // schema: schema.height, - // type: Number, - // }, - // description: { - // schema: schema.description, - // type: String, - // } } }, } +const engine = Comunica.newEngine(); + export const dataProvider = { async getList(resource, params) { const session = await auth.currentSession(); + const definition = resourceDefinitions[resource]; - if (!session || !session.webId) { - const error = new Error(); - error.status = 401; - throw error; - } - - const resourceDocument = await getResourceList( + const resourceRef = await getResourceListRef( session.webId, resource, - resourceDefinitions[resource].schema - ); - const records = resourceDocument.getSubjectsOfType( - resourceDefinitions[resource].schema + definition.schema ); + + const config = { + sources: [resourceRef], + httpIncludeCredentials: true, + }; + + const countQuery = ` + SELECT + # Here we define a variable named ?count which will contain + # the result of the COUNT function. + # Any field used in the SELECT clause must be declared in the WHERE clause + (COUNT(?id) as ?count) + WHERE { + # First, ?iri. The IRI of the subject (a resource record, like a product) + # Second the IRI of the field we want (here the identifier of the record) + # Third, the name we give it in the query context + ?iri <${schema.identifier}> ?id . + ${Object + .keys(definition.fields) + .map(buildFilter(resource, params.filter)) + .join('\n') + } + } + `; + + const countResponse = await engine.query(countQuery, config); + const countBindings = await countResponse.bindings(); + const total = parseInt(countBindings[0].toJS()['?count'].value); + + if (total === 0) { + return { + data: [], + total, + }; + } + + const limit = params.pagination.perPage; + const offset = (params.pagination.page - 1) * limit; + + // Examples DESC(?name) or ASC(?name) + const order = `${params.sort.order.toUpperCase()}(?${params.sort.field})`; + + const query = ` + SELECT + # Builds the list of fields to retrieve + # ?iri returns the record IRI which will be used as the react-admin id + # We then build a list of variables for each field in the resource definition + ?iri ${Object + .keys(definition.fields) + .map(field => `?${definition.fields[field].documentName || field}`) + .join(' ') + } + WHERE { + ${Object + .keys(definition.fields) + .map(buildFilter(resource, params.filter)) + .join('\n') + } + } + ORDER BY ${order} + LIMIT ${limit} + OFFSET ${offset} + `; + + const response = await engine.query(query, config); + // bindings are a special object specific to Comunica engine + const bindings = await response.bindings(); + // They need to be parsed + const data = bindings.map(resolveRecord(resource)); + return Promise.resolve({ - data: records.map(resolveResource(resource)), - total: records.length, + data, + total, }); }, async create(resource, params) { @@ -73,52 +141,236 @@ export const dataProvider = { resource, resourceDefinition.schema ); + const data = { + identifier: uuid.v4(), + ...params.data, + } const record = resourceDocument.addSubject(); record.addRef(rdf.type, resourceDefinition.schema); Object.keys(resourceDefinition.fields).forEach(property => { const fieldDefinition = resourceDefinition.fields[property]; - switch (fieldDefinition.type) { - case String: - record.addString(fieldDefinition.schema, params.data[property]); - break; - case Number: - record.addNumber(fieldDefinition.schema, params.data[property]); - break; - default: - console.log('Unknown type'); - break; - } + record.addLiteral(fieldDefinition.schema, data[property]); }); await resourceDocument.save([record]); - return { data: record }; - } + // Ensure the Comunica engine will refetch the data + engine.invalidateHttpCache(resourceDocument.asRef()); + + return { data: { + id: record.asRef(), + ...data, + } }; + }, + async deleteMany(resource, params) { + const session = await auth.currentSession(); + + if (!session || !session.webId) { + const error = new Error(); + error.status = 401; + throw error; + } + + const resourceDefinition = resourceDefinitions[resource]; + const resourceDocument = await getResourceList( + session.webId, + resource, + resourceDefinition.schema + ); + + params.ids.forEach((id) => { + resourceDocument.removeSubject(id); + }); + + await resourceDocument.save(); + // Ensure the Comunica engine will refetch the data + engine.invalidateHttpCache(resourceDocument.asRef()); + + return { data: params.ids }; + }, + async delete(resource, params) { + const session = await auth.currentSession(); + + if (!session || !session.webId) { + const error = new Error(); + error.status = 401; + throw error; + } + + const resourceDefinition = resourceDefinitions[resource]; + const resourceDocument = await getResourceList( + session.webId, + resource, + resourceDefinition.schema + ); + + resourceDocument.removeSubject(params.id); + await resourceDocument.save(); + // Ensure the Comunica engine will refetch the data + engine.invalidateHttpCache(resourceDocument.asRef()); + + return { data: params.previousData }; + }, + async getOne(resource, params) { + const session = await auth.currentSession(); + + if (!session || !session.webId) { + const error = new Error(); + error.status = 401; + throw error; + } + + const resourceDefinition = resourceDefinitions[resource]; + const resourceDocument = await getResourceList( + session.webId, + resource, + resourceDefinition.schema + ); + + const record = resourceDocument.getSubject(params.id); + const data = resolveRecordFromTripleDoc(resource)(record); + return { data }; + }, + async update(resource, params) { + const session = await auth.currentSession(); + + if (!session || !session.webId) { + const error = new Error(); + error.status = 401; + throw error; + } + + const resourceDefinition = resourceDefinitions[resource]; + const resourceDocument = await getResourceList( + session.webId, + resource, + resourceDefinition.schema + ); + + const subject = resourceDocument.getSubject(params.id); + updateTripleDocFromRecord(resource)(subject, params.data); + await resourceDocument.save(); + // Ensure the Comunica engine will refetch the data + engine.invalidateHttpCache(resourceDocument.asRef()); + + return { data: params.data }; + }, + async updateMany(resource, params) { + const session = await auth.currentSession(); + + if (!session || !session.webId) { + const error = new Error(); + error.status = 401; + throw error; + } + + const resourceDefinition = resourceDefinitions[resource]; + const resourceDocument = await getResourceList( + session.webId, + resource, + resourceDefinition.schema + ); + + params.ids.forEach(id => { + const subject = resourceDocument.getSubject(id); + updateTripleDocFromRecord(resource)(subject, params.data); + }); + await resourceDocument.save(); + // Ensure the Comunica engine will refetch the data + engine.invalidateHttpCache(resourceDocument.asRef()); + + return { data: params.data }; + }, }; -function resolveResource(resource) { +const buildFilter = (resource, filter) => field => { const resourceDefinition = resourceDefinitions[resource]; - return record => { - return Object.keys(resourceDefinition.fields).reduce((acc, field) => { - const fieldDefinition = resourceDefinition.fields[field]; + const definition = resourceDefinition.fields[field]; + + const name = definition.documentName || field; + + // Syntax of a WHERE clause => `SUBJECT SCHEMA_IRI VARIABLE_NAME .` + // The dot at the end is required for all lines except the last. + // We always add it to keep things simple. + + // Here we wrap the clause inside an OPTIONAL clause to ensure fields + // with undefined values won't exclude any records for the results set. + + const whereClause = `OPTIONAL { ?iri <${definition.schema}> ?${name} } .`; + + // Syntax of a FILTER clause => FILTER EXPRESSION + if (filter.q && definition.fullTextSearch) { + // Here we use the regex function as the expression + // regex(TARGET_VARIABLE, REGEXP, OPTIONS) + return `${whereClause} FILTER regex(?${name}, "${filter.q}", "i")`; + } + + if (filter[field]) { + // This is a strictly equal filter. + // The expression is (TARGET_VARIABLE = "VALUE"^^XML_SCHEMA_TYPE) + // We must cast the value into its XML Schema type + return `${whereClause} FILTER (?${name} = "${filter[field]}"^^${definition.type})`; + } + + return whereClause; +} + +const resolveRecord = resource => record => { + const definition = resourceDefinitions[resource]; + + // toJS returns an object with a property for each variable declared + // in the SELECT clause of the query. Hence, they are all prefixed by ? + const data = record.toJS(); + return Object.keys(definition.fields).reduce((acc, field) => ({ + ...acc, + [field]: data[`?${definition.fields[field].documentName || field}`]?.value, + }), { + // Ensure we have an id which is the record IRI + id: data['?iri'].value + }); +}; + +const resolveRecordFromTripleDoc = resource => record => { + const definition = resourceDefinitions[resource]; + + return Object.keys(definition.fields).reduce((acc, field) => { + const fieldDefinition = definition.fields[field]; switch(fieldDefinition.type) { - case String: + case 'xsd:string': acc[field] = record.getString(fieldDefinition.schema); break; - case Number: - acc[field] = record.getNumber(fieldDefinition.schema); + case 'xsd:integer': + acc[field] = record.getInteger(fieldDefinition.schema); break; default: console.log('Unknown type'); break; } return acc; - }, {}); - } + }, { id: record.asRef() }); +}; + +const updateTripleDocFromRecord = resource => (subject, record) => { + const definition = resourceDefinitions[resource]; + + Object.keys(definition.fields).forEach(field => { + const fieldDefinition = definition.fields[field]; + switch(fieldDefinition.type) { + case 'xsd:string': + subject.setString(fieldDefinition.schema, record[field]); + break; + case 'xsd:integer': + subject.setInteger(fieldDefinition.schema, record[field]); + break; + default: + console.log('Unknown type'); + break; + } + }); } -export async function getResourceList(webId, resource, typeRef) { +export async function getResourceListRef(webId, resource, typeRef) { // 1. Check if a Document tracking this resource already exists. const webIdDoc = await fetchDocument(webId); const profile = webIdDoc.getSubject(webId); @@ -133,6 +385,12 @@ export async function getResourceList(webId, resource, typeRef) { // 3. If it does exist, fetch that Document const resourceListRef = resourceListEntry.getRef(solid.instance); + + return resourceListRef; +} + +export async function getResourceList(webId, resource, typeRef) { + const resourceListRef = await getResourceListRef(webId, resource, typeRef); return fetchDocument(resourceListRef); } diff --git a/src/products/ProductCreate.js b/src/products/ProductCreate.js index dd4fdd9..bf275d1 100644 --- a/src/products/ProductCreate.js +++ b/src/products/ProductCreate.js @@ -1,12 +1,13 @@ import * as React from 'react'; -import { Create, SimpleForm, TextInput } from 'react-admin'; +import { Create, SimpleForm, TextInput, NumberInput } from 'react-admin'; export function ProductCreate(props) { return ( - + + ) diff --git a/src/products/ProductEdit.js b/src/products/ProductEdit.js new file mode 100644 index 0000000..9d9f2f0 --- /dev/null +++ b/src/products/ProductEdit.js @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { Edit, SimpleForm, TextInput, NumberInput } from 'react-admin'; + +export function ProductEdit(props) { + return ( + + + + + + + + + ) +} diff --git a/src/products/ProductList.js b/src/products/ProductList.js index 29051eb..abc951f 100644 --- a/src/products/ProductList.js +++ b/src/products/ProductList.js @@ -1,12 +1,23 @@ import * as React from 'react'; -import { List, Datagrid, TextField } from 'react-admin'; +import { List, Datagrid, TextField, Filter, SearchInput, TextInput, NumberField, NumberInput, EditButton } from 'react-admin'; + +const ProductFilter = props => ( + + + + + +) export function ProductList(props) { return ( - + }> - + + + + ) diff --git a/src/products/index.js b/src/products/index.js index 31fa3c7..b958d69 100644 --- a/src/products/index.js +++ b/src/products/index.js @@ -1,7 +1,9 @@ import { ProductList } from './ProductList'; import { ProductCreate } from './ProductCreate'; +import { ProductEdit } from './ProductEdit'; export const products = { list: ProductList, - create: ProductCreate + create: ProductCreate, + edit: ProductEdit }; diff --git a/yarn.lock b/yarn.lock index 2c5a5b9..164f1dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12734,6 +12734,11 @@ uuid@^7.0.0, uuid@^7.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" + integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"