diff --git a/CHANGELOG.md b/CHANGELOG.md index ae291bc..d4065b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- It is now possible to add and edit custom queries (#54). + ### Changed - For logged in users not having a username, the webID is displayed (#133). @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Forced CSS's to not return content type application/ld+json, which induced a CORS error on some CSS server versions (#131). +- Queries based on index file now work for any variable, not just ?object (#136). ## [1.2.1] - 2024-06-17 diff --git a/README.md b/README.md index c06fb39..40362ea 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Table of contents: * [Adding variable type](#adding-variable-type) * [Templated queries](#templated-queries) * [Query icons](#query-icons) +* [Custom queries](#custom-queries) * [Representation Mapper](#representation-mapper) * [Using the local pods](#using-the-local-pods) * [Testing](#testing) @@ -140,7 +141,7 @@ The set of sources over which a query will be executed is derived from two *opti If both inputs are present, the query will be executed over the superset of sources. -The (auxiliary) query provided in `sourceIndex.queryLocation` is executed on `sourceIndex.url` and must result in the list of sources. +The (auxiliary) query provided in `sourceIndex.queryLocation` is executed on `sourceIndex.url` and must result in the list of source URLs. If `sourceIndex` is used and there is no `comunicaContext.lenient` property found, one will be created with value `true`. This makes sure that the (main) query can succeed if not all obtained sources are accessible. @@ -178,6 +179,20 @@ For this to work you need to add the icon to the exports in [IconProvider.js](./ We advise to use the [Material UI icons](https://material-ui.com/components/material-icons/) as this is what's used internally in `react-admin` and it is also included in the dependencies. Nevertheless, you can use any React component you want, just make sure it's a functional component. +## Custom queries + +Besides the prepared queries in the configuration file, a user can edit custom queries: + +- To create a custom query, open "Custom Query Editor" from the menu on the left. +- Complete the custom query editor form and click the "CREATE QUERY" button when ready. +- Your new query is added to the "Custom queries" group and you are redirected to the query's result view. +- If not satisfied with the query result, you can click "EDIT QUERY" to further edit your query. + When saving changes, the result is recalculated. +- Because the custom query only lives as long as your browser remembers it, a "SAVE QUERY LINK" button is provided. + Use it to generate a unique URL for this custom query. Copy that URL to your clipboard and save it. + You can then visit that URL any time later, to recreate this query. +- To clean up an unwanted custom query, there is always a button "DELETE QUERY"... + ## Representation Mapper If you want to add your own type representations diff --git a/cypress/e2e/custom-query-editor.cy.js b/cypress/e2e/custom-query-editor.cy.js new file mode 100644 index 0000000..831f326 --- /dev/null +++ b/cypress/e2e/custom-query-editor.cy.js @@ -0,0 +1,270 @@ + +describe("Custom Query Editor tests", () => { + + it("Create a new query", () => { + + cy.visit("/#/customQuery"); + + cy.get('input[name="name"]').type("new query"); + cy.get('textarea[name="description"]').type("new description"); + + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`PREFIX schema: + +SELECT * WHERE { + ?list schema:name ?listTitle; + schema:itemListElement [ + schema:name ?bookTitle; + schema:creator [ + schema:name ?authorName + ] + ]. +}`); + cy.get('input[name="source"]').type("http://localhost:8080/example/wish-list"); + cy.get('button[type="submit"]').click(); + + + // Checking if the book query works + cy.contains("Colleen Hoover").should('exist'); + }); + + it("Create a new query, with multiple sources", () => { + + cy.visit("/#/customQuery"); + + cy.get('input[name="name"]').type("material query"); + cy.get('textarea[name="description"]').type("this query has 3 sources"); + + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`# Query Texon's components +# Datasources: https://css5.onto-deside.ilabt.imec.be/texon/data/dt/out/components.ttl + +PREFIX oo: +PREFIX ao: +PREFIX rdf: +PREFIX rdfs: +PREFIX d: +PREFIX o: + +SELECT DISTINCT ?component ?componentName ?recycledContentPercentage +WHERE { + ?component + a o:Component ; + o:name ?componentName ; + o:recycled-content-percentage ?recycledContentPercentage ; + . +} +ORDER BY ?componentName +`); + cy.get('input[name="source"]').type("http://localhost:8080/verifiable-example/components-vc ; http://localhost:8080/verifiable-example/components-vc-incorrect-proof ; http://localhost:8080/example/components"); + cy.get('button[type="submit"]').click(); + + // Checking if the query works + cy.contains("http://www/example.com/data/component-c01").should('exist'); + }); + + it("Check if all possible parameters are filled in with parameterized URL", () => { + + // Navigate to the URL of a saved query with completely filled-in form + cy.visit("/#/customQuery?name=Query+Name&description=Query+Description&queryString=Sparql+query+text&comunicaContextCheck=on&source=The+Comunica+Source&comunicaContext=%7B%22Advanced+Comunica+Context%22%3Atrue%7D&sourceIndexCheck=on&indexSourceUrl=Index+Source&indexSourceQuery=Index+Query+&askQueryCheck=on&askQuery=%7B%22trueText%22%3A%22+filled+in%22%2C%22falseText%22%3A%22not+filled+in%22%7D&templatedQueryCheck=on&templateOptions=%7B%22firstvariables%22%3A%5B%22only+one%22%5D%7D") + + // Verify that every field is correctly filled-in + cy.get('input[name="name"]').should('have.value', 'Query Name'); + cy.get('textarea[name="description"]').should('have.value', 'Query Description'); + cy.get('textarea[name="queryString"]').should('have.value', 'Sparql query text'); + + cy.get('input[name="source"]').should('have.value', "The Comunica Source"); + cy.get('textarea[name="comunicaContext"]').should('have.value', `{"Advanced Comunica Context":true}`); + + cy.get('input[name="indexSourceUrl"]').should('have.value', "Index Source"); + cy.get('textarea[name="indexSourceQuery"]').should('have.value', "Index Query "); + + cy.get('textarea[name="askQuery"]').should('have.value', `{"trueText":" filled in","falseText":"not filled in"}`); + + cy.get('textarea[name="templateOptions"]').should('have.value', `{"firstvariables":["only one"]}`); + + }) + + it("Successfully edit a query to make it work", () => { + + cy.visit("/#/customQuery"); + + // First create a wrong query + cy.get('input[name="name"]').type("broken query"); + cy.get('textarea[name="description"]').type("just a description"); + + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type("this is faultive querytext") + + cy.get('input[name="source"]').type("http://localhost:8080/example/wish-list"); + + //Submit the faultive query + cy.get('button[type="submit"]').click(); + + cy.contains("Custom queries").click(); + cy.contains("broken query").click(); + + // Verify that there are no results + cy.contains("The result list is empty.").should('exist'); + + // Edit the query + cy.get('button').contains("Edit Query").click(); + + // Give the query a new name and a correct query text + cy.get('input[name="name"]').clear(); + cy.get('input[name="name"]').type("Fixed query"); + + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`PREFIX schema: +SELECT * WHERE { + ?list schema:name ?listTitle; + schema:itemListElement [ + schema:name ?bookTitle; + schema:creator [ + schema:name ?authorName + ] + ]. +}`); + + // Submit the correct query + cy.get('button[type="submit"]').click(); + + // Now we should be on the page of the fixed query + cy.contains("Fixed query").should('exist'); + + // Check if the resulting list appears + cy.contains("Colleen Hoover").should('exist'); + + }) + + it("Saves the correct URL", () => { + + cy.visit("/#/customQuery"); + + // First create a simple query + cy.get('input[name="name"]').type("new query"); + cy.get('textarea[name="description"]').type("new description"); + + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`PREFIX schema: +SELECT * WHERE { + ?list schema:name ?listTitle; + schema:itemListElement [ + schema:name ?bookTitle; + schema:creator [ + schema:name ?authorName + ] + ]. +}`); + cy.get('input[name="source"]').type("http://localhost:8080/example/wish-list"); + cy.get('button[type="submit"]').click(); + + cy.get('button').contains("Save Query").click(); + + cy.get('textarea[name="queryURL"]').invoke('val').then((val) => { + expect(val).to.include('?name=new+query&description=new+description&queryString=PREFIX+schema%3A+%3Chttp%3A%2F%2Fschema.org%2F%3E+%0ASELECT+*+WHERE+%7B%0A++++%3Flist+schema%3Aname+%3FlistTitle%3B%0A++++++schema%3AitemListElement+%5B%0A++++++schema%3Aname+%3FbookTitle%3B%0A++++++schema%3Acreator+%5B%0A++++++++schema%3Aname+%3FauthorName%0A++++++%5D%0A++++%5D.%0A%7D&source=http%3A%2F%2Flocalhost%3A8080%2Fexample%2Fwish-list'); + }); + + + }) + + it("Custom templated query", () => { + + cy.visit("/#/customQuery"); + + cy.get('input[name="name"]').type("custom template"); + cy.get('textarea[name="description"]').type("description for template"); + + // Query handling a variable + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`PREFIX schema: +SELECT ?name ?sameAs_url WHERE { + ?list schema:name ?listTitle; + schema:name ?name; + schema:genre $genre; + schema:sameAs ?sameAs_url; +}` +); + + cy.get('input[name="source"]').type("http://localhost:8080/example/favourite-musicians"); + cy.get('input[name="templatedQueryCheck"]').click() + + cy.get('textarea[name="templateOptions"]').clear() + cy.get('textarea[name="templateOptions"]').type(`{"variables" : { + "genre": [ + "\\"Romantic\\"", + "\\"Baroque\\"", + "\\"Classical\\"" + ] + }}`) + cy.get('button[type="submit"]').click(); + + + cy.get('form').within(() => { + cy.get('#genre').click(); + }); + cy.get('li').contains('Baroque').click(); + + // Comfirm query + cy.get('button[type="submit"]').click(); + + cy.get('.column-name').find('span').contains("Antonio Caldara").should('exist'); + }) + + it("Custom Query With Index File", () => { + + cy.visit("/#/customQuery"); + + cy.get('input[name="name"]').type("custom with index file"); + cy.get('textarea[name="description"]').type("description for index"); + + // Query handling a variable + cy.get('textarea[name="queryString"]').clear(); + cy.get('textarea[name="queryString"]').type(`# Query Texon's components and their materials +# Datasources: https://css5.onto-deside.ilabt.imec.be/texon/data/dt/out/components.ttl https://css5.onto-deside.ilabt.imec.be/texon/data/dt/out/boms.ttl https://css5.onto-deside.ilabt.imec.be/texon/data/dt/out/materials.ttl + +PREFIX oo: +PREFIX ao: +PREFIX rdf: +PREFIX rdfs: +PREFIX d: +PREFIX o: + +SELECT ?component ?componentName ?material ?materialName ?percentage +WHERE { + ?component + a o:Component ; + o:name ?componentName ; + o:has-component-bom [ + o:has-component-material-assoc [ + o:percentage ?percentage ; + o:has-material ?material ; + ]; + ]; + . + ?material o:name ?materialName ; +} +ORDER BY ?componentName` +); + + // No Comunica Sources Required + cy.get('input[name="sourceIndexCheck"]').click() + cy.get('input[name="indexSourceUrl"]').type("http://localhost:8080/example/index-example-texon-only") + + cy.get('textarea[name="indexSourceQuery"]').clear(); + cy.get('textarea[name="indexSourceQuery"]').type(`PREFIX rdf: +PREFIX rdfs: +PREFIX example: + +SELECT ?object +WHERE { + example:index-example rdfs:seeAlso ?object . +}` +) + cy.get('button[type="submit"]').click(); + + cy.contains("http://www/example.com/data/component-c01").should('exist'); + + }) + +}) \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 0f2540b..dabc1c4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ import "./App.css"; -import { Admin, Resource } from "react-admin"; +import { Admin, Resource, CustomRoutes } from "react-admin"; import SparqlDataProvider from "./dataProvider/SparqlDataProvider"; import { Component, useEffect, useState } from "react"; import { @@ -14,10 +14,11 @@ import Dashboard from "./components/Dashboard/Dashboard"; import InteractionLayout from "./components/InteractionLayout/InteractionLayout"; import TemplatedListResultTable from "./components/ListResultTable/TemplatedListResultTable.jsx"; +import { Route } from "react-router-dom"; +import CustomEditor from "./components/Dashboard/CustomQueryEditor/customEditor.jsx"; import configManager from "./configManager/configManager.js"; -const config = configManager.getConfig(); const queryClient = new QueryClient({ defaultOptions: { @@ -33,12 +34,29 @@ const queryClient = new QueryClient({ function App() { const session = getDefaultSession(); const [loggedIn, setLoggedIn] = useState(); + const [config, setConfig] = useState(configManager.getConfig()); + const [configChangeTrigger, setConfigChangeTrigger] = useState(config.queries.length) useEffect(() => { const root = document.documentElement; root.style.setProperty("--text-color", config.textColor); }, []); + useEffect(() => { + const handleConfigChange = (newConfig) => { + setConfig(newConfig); + setConfigChangeTrigger(Date.now().toString()) + }; + + // Listen for config changes + configManager.on('configChanged', handleConfigChange); + + // Clean up the event listener on component unmount + return () => { + configManager.off('configChanged', handleConfigChange); + }; + }, []); + useEffect(() => { session.onLogin(() => setLoggedIn(true)); session.onLogout(() => setLoggedIn(false)); @@ -57,27 +75,37 @@ function App() { }); return ( - + + {configChangeTrigger && config.queries.map((query) => { + return ( + + ); + })} + + } /> {config.queries.map((query) => { - return ( - - ); + if (query.queryGroupId === 'cstm') { + return ( + } /> + ); + } })} - + + ); } diff --git a/src/IconProvider/IconProvider.js b/src/IconProvider/IconProvider.js index b20156c..972f271 100644 --- a/src/IconProvider/IconProvider.js +++ b/src/IconProvider/IconProvider.js @@ -7,6 +7,16 @@ import ListAltIcon from '@mui/icons-material/ListAlt'; import FactoryIcon from '@mui/icons-material/Factory'; import BugReportIcon from '@mui/icons-material/BugReport'; import ConstructionIcon from '@mui/icons-material/Construction'; +import EditNoteIcon from '@mui/icons-material/EditNote'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import AddIcon from '@mui/icons-material/Add'; +import DashboardCustomizeIcon from '@mui/icons-material/DashboardCustomize'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import SaveIcon from '@mui/icons-material/Save'; +import ModeEditIcon from '@mui/icons-material/ModeEdit'; +import TuneIcon from '@mui/icons-material/Tune'; +import SaveAsIcon from '@mui/icons-material/SaveAs'; export default { BrushIcon, @@ -17,5 +27,15 @@ export default { ListAltIcon, FactoryIcon, BugReportIcon, - ConstructionIcon + ConstructionIcon, + EditNoteIcon, + AutoAwesomeIcon, + AddIcon, + DashboardCustomizeIcon, + DeleteIcon, + ContentCopyIcon, + SaveIcon, + ModeEditIcon, + TuneIcon, + SaveAsIcon }; diff --git a/src/components/Dashboard/CustomQueryEditor/customEditor.jsx b/src/components/Dashboard/CustomQueryEditor/customEditor.jsx new file mode 100644 index 0000000..8e04693 --- /dev/null +++ b/src/components/Dashboard/CustomQueryEditor/customEditor.jsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect } from 'react'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import { CardActions, Typography } from '@mui/material'; +import { useLocation, useNavigate } from 'react-router-dom'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import configManager from '../../../configManager/configManager'; +import IconProvider from '../../../IconProvider/IconProvider'; + + +export default function CustomEditor(props) { + + const location = useLocation(); + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: '', + description: '', + source: '', + queryString: '', + comunicaContext: '', + + comunicaContextCheck: false, + sourceIndexCheck: false, + askQueryCheck: false, + templatedQueryCheck: false, + + }); + + const [showError, setShowError] = useState(false); + + const [parsingErrorComunica, setParsingErrorComunica] = useState(false); + const [parsingErrorAsk, setParsingErrorAsk] = useState(false); + const [parsingErrorTemplate, setParsingErrorTemplate] = useState(false); + + const defaultSparqlQuery = `SELECT ?s ?p ?o +WHERE { + ?s ?p ?o +}`; + const defaultSparqlQueryIndexSources = `PREFIX rdfs: +SELECT ?source +WHERE { + ?s rdfs:seeAlso ?source +}`; + const defaultExtraComunicaContext = JSON.stringify({ "lenient": true }, null, 2); + const defaultAskQueryDetails = JSON.stringify({"trueText": "this displays when true.", "falseText": "this displays when false."}, null, 2); + const defaultTemplateOptions = JSON.stringify({"variables" : {"variableOne" : ["option1", "option2", "option3"],"variableTwo" : ["option1", "option2", "option3"]}}, null, 5) + + useEffect(() => { + let searchParams + if (props.newQuery) { + searchParams = new URLSearchParams(location.search); + } else { + const edittingQuery = configManager.getQueryById(props.id); + searchParams = edittingQuery.searchParams; + } + const obj = {} + searchParams.forEach((value, key) => { + obj[key] = value + }) + setFormData(obj) + }, [location.search]); + + const handleSubmit = async (event) => { + event.preventDefault(); + + if (!parsingErrorComunica && !parsingErrorAsk && !parsingErrorTemplate) { + setShowError(false) + const formData = new FormData(event.currentTarget); + const jsonData = Object.fromEntries(formData.entries()); + + const searchParams = new URLSearchParams(jsonData); + jsonData.searchParams = searchParams; + + if (props.newQuery) { + navigate({ search: searchParams.toString() }); + + configManager.addNewQueryGroup('cstm', 'Custom queries', 'EditNoteIcon'); + addQuery(jsonData); + } + else { + const customQuery = configManager.getQueryById(props.id); + updateQuery(jsonData, customQuery); + } + }else{ + setShowError(true) + } + }; + + const handleChange = (event) => { + const { name, value } = event.target; + setFormData((prevFormData) => ({ + ...prevFormData, + [name]: value, + })); + }; + + const handleJSONparsing = (event, errorSetter) => { + const { name, value } = event.target; + errorSetter(false) + + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (error) { + errorSetter(true) + parsedValue = value; + } + + setFormData((prevFormData) => ({ + ...prevFormData, + [name]: parsedValue, + })); + }; + const ensureBoolean = (value) => value === 'on' || value === true; + + const parseAllObjectsToJSON = (dataWithStrings) => { + + const parsedObject = dataWithStrings; + + if (ensureBoolean(dataWithStrings.comunicaContextCheck)) { + parsedObject.comunicaContext = JSON.parse(dataWithStrings.comunicaContext); + + if (!!dataWithStrings.source && dataWithStrings.source.trim() !== '') + parsedObject.comunicaContext.sources = dataWithStrings.source.split(';').map(source => source.trim()); + + } else if (!!dataWithStrings.source && dataWithStrings.source.trim() !== '') { + parsedObject.comunicaContext = { + sources: formData.source.split(';').map(source => source.trim()) + } + } + + if (ensureBoolean(dataWithStrings.sourceIndexCheck)) { + parsedObject.sourcesIndex = { + url: parsedObject.indexSourceUrl, + queryString: parsedObject.indexSourceQuery + } + } + + if (ensureBoolean(dataWithStrings.askQueryCheck)) { + parsedObject.askQuery = JSON.parse(dataWithStrings.askQuery); + } + if (ensureBoolean(dataWithStrings.templatedQueryCheck)) { + + const options = JSON.parse(dataWithStrings.templateOptions); + + if (options.variables){ + parsedObject.variables = options.variables; + } + + // This will serve for the extention of customizable query variables + if (options.templatedVarSourceQueryString){ + parsedObject.templatedVarSourceQueryString = options.templatedVarSourceQueryString; + } + + + } + return parsedObject; + } + + const addQuery = (formData) => { + const creationID = Date.now().toString(); + formData = parseAllObjectsToJSON(formData); + configManager.addQuery({ + ...formData, + id: creationID, + queryGroupId: "cstm", + icon: "AutoAwesomeIcon", + }); + navigate(`/${creationID}`) + }; + + const updateQuery = (formData, customQuery) => { + formData = parseAllObjectsToJSON(formData); + configManager.updateQuery({ + ...customQuery, + ...formData + }); + + navigate(`/${customQuery.id}`) + }; + + + return ( + + + + {props.newQuery ? 'Custom Query Editor' : 'Edit'} + + + Basic Information +
+ + + + + +
+
+ + + + Comunica Context +
+ { + setParsingErrorComunica(false); + setFormData((prevFormData) => ({ + ...prevFormData, + 'comunicaContextCheck': !formData.comunicaContextCheck, + })) + } + } + + />} label="Advanced Comunica Context Settings" /> + +
+ + + + {formData.comunicaContextCheck && +
+ handleJSONparsing(e, setParsingErrorComunica)} + onChange={(e) => handleJSONparsing(e, setParsingErrorComunica)} + sx={{ marginBottom: '16px' }} + /> +
+ } +
+ + + Extra Options +
+ { + setFormData((prevFormData) => ({ + ...prevFormData, + 'sourceIndexCheck': !formData.sourceIndexCheck, + })) + } + } + />} label="Sources from index file" /> + + {formData.sourceIndexCheck && +
+ + + +
+ } + + { + setParsingErrorAsk(false); + setFormData((prevFormData) => ({ + ...prevFormData, + 'askQueryCheck': !formData.askQueryCheck, + })) + } + } + />} label="ASK query" /> + + {formData.askQueryCheck && +
+ handleJSONparsing(e, setParsingErrorAsk)} + onChange={(e) => handleJSONparsing(e, setParsingErrorAsk)} + sx={{ marginBottom: '16px' }} + /> +
+ } + { + setParsingErrorTemplate(false); + setFormData((prevFormData) => ({ + ...prevFormData, + 'templatedQueryCheck': !formData.templatedQueryCheck, + })) + } + } + />} label="Templated query" /> + + {formData.templatedQueryCheck && +
+ handleJSONparsing(e, setParsingErrorTemplate)} + onChange={(e) => handleJSONparsing(e, setParsingErrorTemplate)} + sx={{ marginBottom: '16px' }} + /> +
+ } +
+
+
+ {showError && ( + + Invalid Query. Check the JSON-Syntax + + )} + + + +
+ +
+ ) +} + + + + + diff --git a/src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx b/src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx new file mode 100644 index 0000000..905c484 --- /dev/null +++ b/src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect } from 'react'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Box from '@mui/material/Box'; +import { useNavigate } from 'react-router-dom'; +import IconProvider from '../../../IconProvider/IconProvider'; +import configManager from '../../../configManager/configManager'; + +import TextField from '@mui/material/TextField'; + +export default function CustomQueryEditButton({ queryID, submitted=false }) { + + const customQuery = configManager.getQueryById(queryID); + const navigate = useNavigate(); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [saveOpen, setSaveOpen] = useState(false); + + const [copyURL, setCopyUrl] = useState(''); + const [feedback, setFeedback] = useState(''); + + + const handleEditClick = () => { + navigate(`/${queryID}/editCustom`) + } + + const handleDelete = () => { + setDeleteOpen(false) + navigate(`/`) + configManager.deleteQueryById(queryID) + } + + const handleSave = () => { + + const url = new URL(window.location.href); + const serverURL = `${url.protocol}//${url.hostname}${url.port ? ':' + url.port : ''}`; + + const savedUrl = `${serverURL}/#/customQuery?${customQuery.searchParams.toString()}`; + setCopyUrl(savedUrl); + } + + const handleSaveClose = () => { + setSaveOpen(false) + setFeedback('') + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(copyURL); + setFeedback('Text successfully copied to clipboard!'); + } catch (err) { + console.error('Failed to copy text: ', err); + setFeedback('Failed to copy text.'); + } + }; + + return ( + + + + + + + + + + + + { setDeleteOpen(false) }} + > + + Delete custom query + + + + Are you sure you want to delete this query? + + + + + + + + + { setSaveOpen(false) }} + + > + + Save custom query link + + + + + Use this link to recreate this custom query later. + + + + {feedback} + + + + + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/components/Dashboard/Dashboard.css b/src/components/Dashboard/Dashboard.css index d3efe62..d5c9654 100644 --- a/src/components/Dashboard/Dashboard.css +++ b/src/components/Dashboard/Dashboard.css @@ -1,3 +1,4 @@ #main-content { - margin-top: 30px; + margin: 30px; + } diff --git a/src/components/Dashboard/Dashboard.jsx b/src/components/Dashboard/Dashboard.jsx index d470216..ca362d4 100644 --- a/src/components/Dashboard/Dashboard.jsx +++ b/src/components/Dashboard/Dashboard.jsx @@ -1,6 +1,6 @@ import Card from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; -import {Title} from 'react-admin'; +import { Title } from 'react-admin'; import './Dashboard.css'; import configManager from '../../configManager/configManager'; @@ -15,10 +15,12 @@ function Dashboard() { const introductionText = config.introductionText || 'You change this introduction text via the config file.'; return ( - - - <CardContent>{introductionText}</CardContent> - </Card> + <div> + <Card> + <Title title={title} /> + <CardContent>{introductionText}</CardContent> + </Card> + </div> ); } diff --git a/src/components/InteractionLayout/SelectionMenu/SelectionMenu.jsx b/src/components/InteractionLayout/SelectionMenu/SelectionMenu.jsx index e75e31a..82dd579 100644 --- a/src/components/InteractionLayout/SelectionMenu/SelectionMenu.jsx +++ b/src/components/InteractionLayout/SelectionMenu/SelectionMenu.jsx @@ -1,4 +1,4 @@ -import React, { Component, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useResourceDefinitions } from "ra-core"; import { DashboardMenuItem } from "ra-ui-materialui"; import { Menu } from "react-admin"; @@ -10,87 +10,98 @@ import ListItemText from '@mui/material/ListItemText'; import Collapse from '@mui/material/Collapse'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; -import IconProvider from "../../../IconProvider/IconProvider"; import ListAltIcon from '@mui/icons-material/ListAlt'; - +import IconProvider from "../../../IconProvider/IconProvider"; import configManager from '../../../configManager/configManager'; -/** - * A custom menu as defined in React Admin for selecting the query the user whishes to execute. - * @returns {Component} the selection menu component - */ -function SelectionMenu() { - const config = configManager.getConfig(); - const queryGroups = config.queryGroups || []; - const resources = useResourceDefinitions(); - // adding a list to the group that will contain all the queries for said group - queryGroups.forEach(group => group.queries = []) - // fill in the groups, and put the ones without a group inside the looseQueries list +const SelectionMenu = () => { + const resources = useResourceDefinitions(); + const [config, setConfig] = useState(configManager.getConfig()); + const [openGroups, setOpenGroups] = useState({}); + + let queryGroups = config.queryGroups || []; + queryGroups.forEach(group => group.queries = []); const looseQueries = setUpQueryGroups(queryGroups, resources); + useEffect(() => { + const handleGroupChange = (newConfig) => { + setConfig(newConfig); + + // Open the cstm group when a new custom query is created + if(newConfig.queryGroups.find(group => group.id === 'cstm')){ + setOpenGroups(prevOpenGroups => ({ + ...prevOpenGroups, + ['cstm']: true, + })); + } + }; + + configManager.on('configChanged', handleGroupChange); + + return () => { + configManager.off('configChanged', handleGroupChange); + }; + }, []); + + const handleGroupToggle = (groupId) => { + setOpenGroups(prevOpenGroups => ({ + ...prevOpenGroups, + [groupId]: !prevOpenGroups[groupId], + })); + }; + return ( <ThemeProvider theme={menuItemTheme}> <div style={{ height: '100%', overflowY: 'auto', backgroundColor: 'white' }}> <Menu> <List> <DashboardMenuItem /> - + <Menu.Item to="/customQuery" primaryText="Custom Query Editor" leftIcon={<IconProvider.DashboardCustomizeIcon/>}/> {looseQueries.map(id => ( <Tooltip key={id} placement="right" - title={ - <TooltipContent - title={resources[id].options.label} - description={resources[id].options.descr} /> - } + title={<TooltipContent title={resources[id].options.label} description={resources[id].options.descr} />} > - <div > + <div> <Menu.ResourceItem name={id} /> </div> </Tooltip> ))} </List> - {queryGroups.map((group) => { - const [open, setOpen] = useState(false) - return ( - <List key={group.id} disablePadding > - <ListItemButton onClick={() => { setOpen(!open) }}> - <ListItemIcon> - {getIconComponent(group.icon)} - </ListItemIcon> - <ListItemText primary={group.name} /> - {open ? <ExpandLess /> : <ExpandMore />} - </ListItemButton> - <Collapse in={open} timeout="auto" unmountOnExit> - <List component="div" disablePadding> - {group.queries.map((id) => ( - <Tooltip - key={id} - placement="right" - title={ - <TooltipContent - title={resources[id].options.label} - description={resources[id].options.descr} /> - } - > - <ListItemText sx={{ overflow: 'hidden', ml: 1.5 }} > - <Menu.ResourceItem name={id} /> - </ListItemText> - </Tooltip> - ))} - </List> - </Collapse> - </List> - ) - })} + {queryGroups.map((group) => ( + <List key={group.id} disablePadding> + <ListItemButton onClick={() => handleGroupToggle(group.id)}> + <ListItemIcon> + {getIconComponent(group.icon)} + </ListItemIcon> + <ListItemText primary={group.name} /> + {openGroups[group.id] ? <ExpandLess /> : <ExpandMore />} + </ListItemButton> + <Collapse in={openGroups[group.id]} timeout="auto" unmountOnExit> + <List component="div" disablePadding> + {group.queries.map((id) => ( + <Tooltip + key={id} + placement="right" + title={<TooltipContent title={resources[id].options.label} description={resources[id].options.descr} />} + > + <ListItemText sx={{ overflow: 'hidden', ml: 1.5 }}> + <Menu.ResourceItem name={id} /> + </ListItemText> + </Tooltip> + ))} + </List> + </Collapse> + </List> + ))} </Menu> </div> </ThemeProvider> ); -} +}; const menuItemTheme = createTheme({ components: { @@ -110,53 +121,31 @@ const menuItemTheme = createTheme({ display: "block", whiteSpace: "nowrap", textOverflow: "ellipsis", - } + }, }, }, - } + }, }, }); const getIconComponent = (iconKey) => { const IconComponent = IconProvider[iconKey]; - if (IconComponent) { - return <IconComponent />; - } - return <ListAltIcon />; + return IconComponent ? <IconComponent /> : <ListAltIcon />; }; const TooltipContent = ({ title, description }) => ( - <React.Fragment> - <Box - sx={{ - width: 'fit-content', - backgroundColor: '#6d6d6d', - paddingX: 1, - marginX: -1, - }} - > - <Typography variant="h6" component="div"> - {title} - </Typography> - - <Typography variant="body2" component="div" - sx={{ - fontStyle: 'italic', - marginTop: 1, - }} - > - {description} - </Typography> - </Box> - </React.Fragment> -) + <Box sx={{ width: 'fit-content', backgroundColor: '#6d6d6d', paddingX: 1, marginX: -1 }}> + <Typography variant="h6" component="div">{title}</Typography> + <Typography variant="body2" component="div" sx={{ fontStyle: 'italic', marginTop: 1 }}>{description}</Typography> + </Box> +); const setUpQueryGroups = (queryGroups, resources) => { const looseQueries = []; Object.keys(resources).forEach((id) => { try { if (resources[id].options.queryGroupId === undefined) { - looseQueries.push(id) + looseQueries.push(id); } else { const queryGroup = queryGroups.find(group => group.id === resources[id].options.queryGroupId); if (queryGroup) { @@ -168,8 +157,8 @@ const setUpQueryGroups = (queryGroups, resources) => { } catch (error) { throw new Error(`Error adding queries to a group: ${error.message}`); } - }) + }); return looseQueries; -} +}; export default SelectionMenu; diff --git a/src/components/ListResultTable/QueryResultList/QueryResultList.jsx b/src/components/ListResultTable/QueryResultList/QueryResultList.jsx index 7f9c566..f483a17 100644 --- a/src/components/ListResultTable/QueryResultList/QueryResultList.jsx +++ b/src/components/ListResultTable/QueryResultList/QueryResultList.jsx @@ -8,7 +8,8 @@ import Button from '@mui/material/Button'; import SearchOffIcon from '@mui/icons-material/SearchOff'; import { SvgIcon, Box, Typography } from "@mui/material"; import PropTypes from "prop-types"; - +import CustomQueryEditButton from "../../Dashboard/CustomQueryEditor/customQueryEditButton"; +import IconProvider from "../../../IconProvider/IconProvider"; import configManager from "../../../configManager/configManager"; /** @@ -16,9 +17,11 @@ import configManager from "../../../configManager/configManager"; * @returns {Component} custom ListViewer as defined by react-admin containing the results of the query with each variable its generic field. */ function QueryResultList(props) { - const queryTitle = useResourceDefinition().options.label; + const { resource, changeVariables, submitted } = props; + const resourceDef = useResourceDefinition(); + + const queryTitle = resourceDef.options.label; const { data } = useListContext(props); - const { resource, changeVariables, submitted} = props; const [values, setValues] = useState(undefined); useEffect(() => { if (data && data.length > 0) { @@ -32,27 +35,29 @@ function QueryResultList(props) { const query = configManager.getQueryWorkingCopyById(resource); return ( - <div style={{ paddingLeft: '20px' , paddingRight: '10px' }}> + <div style={{ paddingLeft: '20px', paddingRight: '10px' }}> <Title title={config.title} /> - - {submitted && <Aside changeVariables={changeVariables}/> /* Adding button to make a new query - top left corner */ } + <div style={{ display: 'flex', flexDirection: 'row' }}> + {submitted && <Aside changeVariables={changeVariables} />} + {resourceDef.options.queryGroupId === 'cstm' && <CustomQueryEditButton queryID={resourceDef.name} submitted={submitted} />} + </div> <Typography fontSize={"2rem"} mt={2} > {queryTitle} </Typography> - {values ?( + {values ? ( <ListView title=" " actions={<ActionBar />} {...props} > - <Datagrid header={<TableHeader query={query}/>} bulkActionButtons={false}> - {Object.keys(values).map((key) => { - return ( - <GenericField - key={key} - source={key} - label={key.split("_")[0]} - /> - ); - })} - </Datagrid> - </ListView> - ): - <NoValuesDisplay/> + <Datagrid header={<TableHeader query={query} />} bulkActionButtons={false}> + {Object.keys(values).map((key) => { + return ( + <GenericField + key={key} + source={key} + label={key.split("_")[0]} + /> + ); + })} + </Datagrid> + </ListView> + ) : + <NoValuesDisplay /> } </div> ); @@ -83,23 +88,25 @@ function reduceDataToObject(data) { } const Aside = (props) => { - const {changeVariables} = props; - return( - <div> - <Button variant="contained" onClick={changeVariables}>Change Variables</Button> - </div> -)} + const { changeVariables } = props; + return ( + <Box> + <Button variant="contained" onClick={changeVariables} startIcon={<IconProvider.TuneIcon/>} sx={{ margin: '10px' }}>Change Variables</Button> + </Box> + + ) +} const NoValuesDisplay = () => { - return( - <div> - <Box display="flex" alignItems="center" sx={{m:3}}> - <SvgIcon component={SearchOffIcon} /> - <span>The result list is empty.</span> - </Box> + return ( +<div> + <Box display="flex" alignItems="center" sx={{ m: 3 }}> + <SvgIcon component={SearchOffIcon} /> + <span>The result list is empty.</span> + </Box> </div> - + ) } - + export default QueryResultList; diff --git a/src/components/ListResultTable/QueryResultList/TableHeader/TableHeader.jsx b/src/components/ListResultTable/QueryResultList/TableHeader/TableHeader.jsx index aff92d7..ad2ba02 100644 --- a/src/components/ListResultTable/QueryResultList/TableHeader/TableHeader.jsx +++ b/src/components/ListResultTable/QueryResultList/TableHeader/TableHeader.jsx @@ -54,7 +54,7 @@ function TableHeader({ children }) { > {child.props.label} </span> - {variableOntology[child.props.source] && ( + {!!variableOntology && variableOntology[child.props.source] && ( <Link target="_blank" href={variableOntology[child.props.source]} diff --git a/src/components/ListResultTable/TemplatedQueryForm.jsx b/src/components/ListResultTable/TemplatedQueryForm.jsx index aff4b8b..2413542 100644 --- a/src/components/ListResultTable/TemplatedQueryForm.jsx +++ b/src/components/ListResultTable/TemplatedQueryForm.jsx @@ -1,7 +1,8 @@ -import {Toolbar, SaveButton, SelectInput, SimpleForm, required} from "react-admin"; +import {Toolbar, SaveButton, SelectInput, SimpleForm, required, useResourceDefinition} from "react-admin"; import DoneIcon from '@mui/icons-material/Done'; import {Component, useEffect} from "react"; import PropTypes from "prop-types"; +import CustomQueryEditButton from "../Dashboard/CustomQueryEditor/customQueryEditButton"; const MyToolbar = () => ( <Toolbar> @@ -22,15 +23,17 @@ const TemplatedQueryForm = (props) => { searchPar, } = props; + const resourceDef = useResourceDefinition(); + useEffect(() => { if (submitted){ onSubmit(searchPar); } }, [submitted]) - return ( <SimpleForm toolbar={<MyToolbar />} onSubmit={onSubmit}> + {!!resourceDef.options && resourceDef.options.queryGroupId === 'cstm' && <CustomQueryEditButton queryID={resourceDef.name}/>} {Object.entries(variableOptions).map(([name, options]) => ( <SelectInput key={name} source={name} name={name} label={name} validate={required()} choices={ options.map((option) => ({ diff --git a/src/config.json b/src/config.json index 088c6d2..6aaece5 100644 --- a/src/config.json +++ b/src/config.json @@ -11,7 +11,7 @@ "showMilliseconds": false, "defaultIDP": "http://localhost:8080", "footer": "<p><a href='https://idlab.technology/'>IDLab</a> - <a href='https://www.imec.be/nl'>imec</a> - <a href='https://www.ugent.be/'>UGent</a></p>", - "introductionText": "Please select a query from the menu on the left.", + "introductionText": "Please log in as the appropriate actor and make your choice in the menu on the left.", "queryGroups" : [ { "id": "a-ex", diff --git a/src/configManager/configManager.js b/src/configManager/configManager.js index ae3e29f..2f3f719 100644 --- a/src/configManager/configManager.js +++ b/src/configManager/configManager.js @@ -54,8 +54,22 @@ class ConfigManager extends EventEmitter { this.emit('configChanged', this.config); } + addNewQueryGroup(id, name, icon = null) { + + const groupExists = this.config.queryGroups.find(group => group.id === id); + + if (groupExists === undefined) { + const newGroup = { id, name }; + if (icon) { + newGroup.icon = icon; + } + this.config.queryGroups = [...this.config.queryGroups, newGroup]; + this.emit('configChanged', this.config); + } + } + /** - * Adds as query element to the config.queries array in the configuration + * Adds a query element to the config.queries array in the configuration * * @param {object} newQuery - the query element to be added */ @@ -100,6 +114,58 @@ class ConfigManager extends EventEmitter { } return query.queryString; } + + + /** + * Updates a query element to the config.queries array in the configuration + * @param {Object} updatedQuery - the updated query element to replace + */ + updateQuery(updatedQuery) { + let index = this.config.queries.findIndex(query => query.id === updatedQuery.id); + if (index !== -1) { + this.config.queries[index] = updatedQuery; + } + this.queryWorkingCopies = {}; + this.emit('configChanged', this.config); + } + + /** + * Deletes the query with the given id in the config.queries array in the configuration + * @param {string} id - id property of the query to delete + */ + deleteQueryById(id) { + let index = this.config.queries.findIndex(query => query.id === id); + if (index !== -1) { + this.config.queries.splice(index, 1); + } + this.queryWorkingCopies = {}; + this.emit('configChanged', this.config); + } + + + /** + * Gets the query with the given id in the config.queries array in the configuration + * @param {string} id - id property a query + * @returns {object} the query + */ + getQueryById(id) { + return this.config.queries.find((query) => query.id === id); + } + + /** + * Gets the query text from a query + * @param {object} query - the input query + * @returns {string} the query text + */ + async getQueryText(query) { + + if (query.queryLocation) { + const fetchResult = await fetch(`${this.config.queryFolder}${query.queryLocation}`); + return await fetchResult.text(); + } + return query.queryString; + } + } const configManager = new ConfigManager(); diff --git a/src/dataProvider/SparqlDataProvider.js b/src/dataProvider/SparqlDataProvider.js index 078e066..99b8060 100644 --- a/src/dataProvider/SparqlDataProvider.js +++ b/src/dataProvider/SparqlDataProvider.js @@ -343,18 +343,28 @@ async function configureBindingStream(bindingStream, variables) { const addComunicaContextSourcesFromSourcesIndex = async (sourcesIndex) => { const sourcesList = []; try { - const result = await fetch(`${config.queryFolder}${sourcesIndex.queryLocation}`); - const queryStringIndexSource = await result.text(); + let queryStringIndexSource; + if (sourcesIndex.queryLocation){ + const result = await fetch(`${config.queryFolder}${sourcesIndex.queryLocation}`); + queryStringIndexSource = await result.text(); + }else{ + queryStringIndexSource = sourcesIndex.queryString; + } const bindingsStream = await myEngine.queryBindings(queryStringIndexSource, { sources: [sourcesIndex.url], }); await new Promise((resolve, reject) => { - bindingsStream.on('data', (binding) => { - const source = binding.get('object').value; - if (!sourcesList.includes(source)) { - sourcesList.push(source); + bindingsStream.on('data', (bindings) => { + // the bindings should have exactly one key (any name is allowed) and we accept the value as a source + if (bindings.size == 1) { + for (const term of bindings.values()) { + const source = term.value; + if (!sourcesList.includes(source)) { + sourcesList.push(source); + } + } } }); bindingsStream.on('end', resolve); @@ -365,6 +375,10 @@ const addComunicaContextSourcesFromSourcesIndex = async (sourcesIndex) => { throw new Error(`Error adding sources from index: ${error.message}`); } + if (sourcesList.length == 0) { + throw new Error(`The resulting list of sources is empty`); + } + return sourcesList; };