diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a26c908..6fbacb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The possibility to create a custom query from an existing query and to clone a custom query (#141). + +### Changed + +### Fixed + + ## [1.3.0] - 2024-08-07 ### Added diff --git a/README.md b/README.md index 113c196d..01f80ab5 100644 --- a/README.md +++ b/README.md @@ -224,16 +224,26 @@ Nevertheless, you can use any React component you want, just make sure it's a fu ## 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. +The configuration file contains prepared, fixed queries. +In addition, a user can create and edit custom queries, either from scratch or based on an existing query. + +- To create a new custom query from scratch: + - 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. + +- To create a new custom query based on an existing query: + - Open the existing query. + - Click "CLONE AS CUSTOM QUERY" (in a normal query) or "CLONE" (in a custom query). + - Make the desired changes in the form and click the "CREATE QUERY" button when ready. The new custom query behaves as if it were created from scratch. + +- To reproduce a custom query later, a "SAVE QUERY LINK" button is provided. + Use it to generate a unique URL for this custom query. + Visiting that URL any time later, recreates a custom query with the same specifications. + This may be useful to forward a custom query to another user. + - To clean up an unwanted custom query, there is always a button "DELETE QUERY"... ## Representation Mapper diff --git a/cypress/e2e/customize-existing-query.cy.js b/cypress/e2e/customize-existing-query.cy.js new file mode 100644 index 00000000..f83e397a --- /dev/null +++ b/cypress/e2e/customize-existing-query.cy.js @@ -0,0 +1,208 @@ +describe("Customize existing query", () => { + + it("simple query", () => { + cy.visit("/"); + cy.contains("General examples").click(); + cy.contains("A public list of books I'd love to own").click(); + + cy.get('button').contains("Clone as custom query").click(); + + cy.url().should('include', 'customQuery'); + + + cy.get('input[name="name"]').should('have.value', "(Cloned from) A public list of books I'd love to own"); + + cy.get('textarea[name="queryString"]').should('have.value', `PREFIX schema: + +SELECT * WHERE { + ?list schema:name ?listTitle; + schema:itemListElement [ + schema:name ?bookTitle; + schema:creator [ + schema:name ?authorName + ] + ]. +}`); + cy.get('input[name="source"]').should('have.value', "http://localhost:8080/example/wish-list"); + + }) + + it("templated query - fixed variables", () => { + cy.visit("/"); + cy.contains("General examples").click(); + cy.contains("A templated query about musicians").click(); + + cy.get('form').within(() => { + cy.get('#genre').click(); + }); + cy.get('li').contains('Baroque').click(); + + cy.get('button[type="submit"]').click(); + + cy.get('button').contains("Clone as custom query").click(); + cy.url().should('include', 'customQuery'); + + cy.get('input[name="name"]').should('have.value', "(Cloned from) A templated query about musicians"); + + cy.get('textarea[name="queryString"]').should('have.value', `PREFIX schema: + +SELECT ?name ?sameAs_url WHERE { + ?list schema:name ?listTitle; + schema:name ?name; + schema:genre $genre; + schema:sameAs ?sameAs_url; +}`); + + cy.get('textarea[name="variables"]').should('have.value', `{"genre":["\\"Romantic\\"","\\"Baroque\\"","\\"Classical\\""]}`) + + + + + + }) + + it("templated query - indirect variables", () => { + + cy.visit("/"); + cy.contains("For testing only").click(); + cy.contains("A templated query about musicians, two variables (indirect variables)").click(); + + cy.get('form').within(() => { + cy.get('#genre').click(); + }); + cy.get('li').contains('Baroque').click(); + + cy.get('form').within(() => { + cy.get('#sameAsUrl').click(); + }); + cy.get('li').contains('Vivaldi').click(); + + cy.get('button[type="submit"]').click(); + + cy.get('button').contains("Clone as custom query").click(); + cy.url().should('include', 'customQuery'); + + cy.get('input[name="name"]').should('have.value', "(Cloned from) A templated query about musicians, two variables (indirect variables)"); + + cy.get('textarea[name="queryString"]').should('have.value', `PREFIX schema: + +SELECT ?name WHERE { + ?list schema:name ?listTitle; + schema:name ?name; + schema:genre $genre; + schema:sameAs $sameAsUrl; +} +`); + + cy.get('textarea[name="indirectQuery1"]').should('have.value', `PREFIX schema: + +SELECT DISTINCT ?genre +WHERE { + ?list schema:genre ?genre +} +ORDER BY ?genre +`); + + cy.get('textarea[name="indirectQuery2"]').should('have.value', `PREFIX schema: + +SELECT DISTINCT ?sameAsUrl +WHERE { + ?list schema:sameAs ?sameAsUrl +} +ORDER BY ?sameAsUrl +`); + + + + + }) + + it("index file", () => { + cy.visit("/"); + cy.contains("General examples").click(); + cy.contains("Sources from an index file").click(); + + cy.get('button').contains("Clone as custom query").click({ force: true }); // Button is out of FoV so we gotta force the click + + cy.url().should('include', 'customQuery'); + + + cy.get('input[name="name"]').should('have.value', "(Cloned from) Sources from an index file"); + + cy.get('textarea[name="queryString"]').should('have.value', `PREFIX rdfs: +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 +`); + + cy.get('input[name="indexSourceUrl"]').should('have.value', `http://localhost:8080/example/index-example-texon-only`) + cy.get('textarea[name="indexSourceQuery"]').should('have.value', `PREFIX rdf: +PREFIX rdfs: +PREFIX example: + +SELECT ?object +WHERE { + example:index-example rdfs:seeAlso ?object . +} +`) + + }) + + +}) + +describe("Clone and customize existing query, clone the custom after", () => { + + it("clone simple query", () => { + cy.visit("/"); + cy.contains("General examples").click(); + cy.contains("A public list of books I'd love to own").click(); + + cy.get('button').contains("Clone as custom query").click(); + + cy.url().should('include', 'customQuery'); + + + cy.get('input[name="name"]').should('have.value', "(Cloned from) A public list of books I'd love to own"); + + cy.get('textarea[name="queryString"]').should('have.value', `PREFIX schema: + +SELECT * WHERE { + ?list schema:name ?listTitle; + schema:itemListElement [ + schema:name ?bookTitle; + schema:creator [ + schema:name ?authorName + ] + ]. +}` + ); + cy.get('input[name="source"]').should('have.value', "http://localhost:8080/example/wish-list"); + + cy.get('button[type="submit"]').click(); + + cy.contains("Colleen Hoover").should("exist"); + + cy.get('button').contains("Clone").click(); + + cy.url().should('include', 'customQuery'); + + + cy.get('input[name="name"]').should('have.value', "(Cloned) (Cloned from) A public list of books I'd love to own"); + + }) +}) \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index dabc1c44..e9cf6daa 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -15,7 +15,7 @@ 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 CustomEditor from "./components/CustomQueryEditor/customEditor.jsx"; import configManager from "./configManager/configManager.js"; diff --git a/src/IconProvider/IconProvider.js b/src/IconProvider/IconProvider.js index 9b5a2d4b..0b4fc878 100644 --- a/src/IconProvider/IconProvider.js +++ b/src/IconProvider/IconProvider.js @@ -21,6 +21,9 @@ import TuneIcon from '@mui/icons-material/Tune'; import SaveAsIcon from '@mui/icons-material/SaveAs'; import InfoIcon from '@mui/icons-material/Info'; import CloseIcon from '@mui/icons-material/Close'; +import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import FilterNoneIcon from '@mui/icons-material/FilterNone'; export default { BrushIcon, @@ -45,5 +48,8 @@ export default { TuneIcon, SaveAsIcon, InfoIcon, - CloseIcon + CloseIcon, + SettingsSuggestIcon, + ChevronLeftIcon, + FilterNoneIcon }; diff --git a/src/components/CustomQueryEditor/customConversionButton.jsx b/src/components/CustomQueryEditor/customConversionButton.jsx new file mode 100644 index 00000000..e06b0267 --- /dev/null +++ b/src/components/CustomQueryEditor/customConversionButton.jsx @@ -0,0 +1,153 @@ +import { useNavigate } from 'react-router-dom'; +import configManager from "../../configManager/configManager"; +import IconProvider from "../../IconProvider/IconProvider"; +import Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; + +export default function CustomConversionButton({ id }) { + + const navigate = useNavigate(); + const config = configManager.getConfig() + + // We have to make sure we have a deep copy, and not a shallow copy of the original query + const convertedQuery = JSON.parse(JSON.stringify(configManager.getQueryById(id))); + + + const convertQueryToCustom = async () => { + + /* + The convertion logic is split up in apart functions to enhance its clarity. + */ + + await replaceQueryLocationWithQueryString() + await convertSourcesFromSourceIndex() + await convertTemplatedQueriesIndirectVariables() + + convertTemplatedQueriesFixedVariables() + convertComunicaContextAndSources() + convertASKquery() + + // The id and group have to be deleted because a new custom query is going to be made. Name is modified. + delete convertedQuery.id + delete convertedQuery.queryGroupId + convertedQuery.name = `(Cloned from) ${convertedQuery.name}` + + // Generate the search parameters so that we can create a custom query. + const searchParams = handleSearchParams(convertedQuery) + navigate(`/customQuery?${searchParams.toString()}`) + } + + + // Handles the objects parsing and ensure the correct comunica structure is given + function handleSearchParams(queryToHandle) { + + const copyObject = JSON.parse(JSON.stringify(queryToHandle)); + + for (let content in copyObject) { + if (typeof copyObject[content] === 'object') { + copyObject[content] = JSON.stringify(copyObject[content]) + } + } + return new URLSearchParams(copyObject); + } + + + // This function replaces the queryLocation with a querystring, which makes it adapted for a custom query + async function replaceQueryLocationWithQueryString() { + const queryString = await configManager.getQueryText(convertedQuery) + convertedQuery.queryString = queryString + delete convertedQuery.queryLocation + } + + // This function handles the conversion of the comunica sources and context + function convertComunicaContextAndSources() { + + if (convertedQuery.comunicaContext) { + if (convertedQuery.comunicaContext.sources) { + convertedQuery.source = convertedQuery.comunicaContext.sources.join(' ; ') + } + + const keys = Object.keys(convertedQuery.comunicaContext); + const otherKeys = keys.filter(key => key !== 'sources'); + const hasOtherProperties = otherKeys.length > 0; + + if (hasOtherProperties) { + convertedQuery.comunicaContextCheck = "on" + delete convertedQuery.comunicaContext.sources + } else { + delete convertedQuery.comunicaContext + } + + } + } + + // This function handles the conversion of indirect sources + async function convertSourcesFromSourceIndex() { + if (convertedQuery.sourcesIndex) { + + convertedQuery.sourceIndexCheck = "on"; + const queryString = await configManager.getQueryText(convertedQuery.sourcesIndex) + + convertedQuery.indexSourceUrl = convertedQuery.sourcesIndex.url + convertedQuery.indexSourceQuery = queryString + delete convertedQuery.sourcesIndex.queryLocation + } + } + + + + // This function handles the conversion for indirect variables + async function convertTemplatedQueriesIndirectVariables() { + let queryStringList = []; + + if (convertedQuery.indirectVariables) { + convertedQuery.indirectVariablesCheck = "on" + + if (convertedQuery.indirectVariables.queryLocations) { + for (const location of convertedQuery.indirectVariables.queryLocations) { + + const result = await fetch(`${config.queryFolder}${location}`); + const queryStr = await result.text(); + + queryStringList.push(queryStr); + } + } + if (convertedQuery.indirectVariables.queryStrings) { + queryStringList = [...queryStringList, ...convertedQuery.indirectVariables.queryStrings]; + } + + convertedQuery.indirectQueries = queryStringList + + } + } + + // This function handles the logic for fixed variables + function convertTemplatedQueriesFixedVariables() { + if (convertedQuery.variables) { + convertedQuery.directVariablesCheck = "on" + } + } + + // This function handles the logic for the ask query + function convertASKquery() { + if (convertedQuery.askQuery) { + convertedQuery.askQueryCheck = "on" + } + } + + + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/Dashboard/CustomQueryEditor/customEditor.jsx b/src/components/CustomQueryEditor/customEditor.jsx similarity index 94% rename from src/components/Dashboard/CustomQueryEditor/customEditor.jsx rename to src/components/CustomQueryEditor/customEditor.jsx index 5492c201..81334ed8 100644 --- a/src/components/Dashboard/CustomQueryEditor/customEditor.jsx +++ b/src/components/CustomQueryEditor/customEditor.jsx @@ -2,13 +2,14 @@ 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 Box from '@mui/material/Box'; 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'; +import configManager from '../../configManager/configManager'; +import IconProvider from '../../IconProvider/IconProvider'; export default function CustomEditor(props) { @@ -545,17 +546,35 @@ ORDER BY ?genre`; )} + + - + { + props.newQuery ? - { - props.newQuery ? null : - - } + + + : + + + } + + diff --git a/src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx b/src/components/CustomQueryEditor/customQueryEditButton.jsx similarity index 69% rename from src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx rename to src/components/CustomQueryEditor/customQueryEditButton.jsx index 905c4848..d94c2501 100644 --- a/src/components/Dashboard/CustomQueryEditor/customQueryEditButton.jsx +++ b/src/components/CustomQueryEditor/customQueryEditButton.jsx @@ -7,12 +7,12 @@ 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 IconProvider from '../../IconProvider/IconProvider'; +import configManager from '../../configManager/configManager'; import TextField from '@mui/material/TextField'; -export default function CustomQueryEditButton({ queryID, submitted=false }) { +export default function CustomQueryEditButton({ queryID, submitted = false }) { const customQuery = configManager.getQueryById(queryID); const navigate = useNavigate(); @@ -43,6 +43,16 @@ export default function CustomQueryEditButton({ queryID, submitted=false }) { setCopyUrl(savedUrl); } + const handleDuplication = () => { + + const parameterDuplicate = new URLSearchParams(customQuery.searchParams); + + const newName = "(Cloned) " + parameterDuplicate.get('name') + parameterDuplicate.set('name', newName) + + navigate(`/customQuery?${parameterDuplicate.toString()}`) + } + const handleSaveClose = () => { setSaveOpen(false) setFeedback('') @@ -61,32 +71,45 @@ export default function CustomQueryEditButton({ queryID, submitted=false }) { return ( - - - - - - + + + + + + + +
{submitted &&
{queryTitle} {values ? ( diff --git a/src/components/ListResultTable/TemplatedQueryForm.jsx b/src/components/ListResultTable/TemplatedQueryForm.jsx index 24135424..9f55e605 100644 --- a/src/components/ListResultTable/TemplatedQueryForm.jsx +++ b/src/components/ListResultTable/TemplatedQueryForm.jsx @@ -2,7 +2,7 @@ import {Toolbar, SaveButton, SelectInput, SimpleForm, required, useResourceDefin import DoneIcon from '@mui/icons-material/Done'; import {Component, useEffect} from "react"; import PropTypes from "prop-types"; -import CustomQueryEditButton from "../Dashboard/CustomQueryEditor/customQueryEditButton"; +import CustomQueryEditButton from "../CustomQueryEditor/customQueryEditButton"; const MyToolbar = () => (