diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd942..41be676 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 save and load custom queries to and from a pod (#140). + +### Changed + +### Fixed + + ## [1.3.1] - 2024-08-27 ### Added diff --git a/README.md b/README.md index ab58f36..ed10d86 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ Table of contents: * [Custom queries](#custom-queries) * [Representation Mapper](#representation-mapper) * [Using the local pods](#using-the-local-pods) +* [Advanced topics](#advanced-topics) + * [Adapting this project to your needs](#adapting-this-project-to-your-needs) + * [Converting custom queries into common queries](#converting-custom-queries-into-common-queries) * [Testing](#testing) * [Testing the production version](#testing-the-production-version) * [Testing the development version](#testing-the-development-version) @@ -137,7 +140,7 @@ The configuration file `main/src/config.json` follows a simple structure. "description": "Description of the query", "icon": "The key to the icon for the query. This is optional and a default menu icon will be used when left empty.", "comunicaContext": { - "sources": "Initial list of sources over which the query should be executed", + "sources": "Initial array of sources over which the query should be executed", "useProxy": "True or false, whether the query should be executed through the proxy or not. This field is optional and defaults to false.", ... any other field that can be used in the Comunica query engine https://comunica.dev/docs/query/advanced/context/ }, @@ -207,10 +210,10 @@ If all possible values for the template variables are fixed and hence can be wri * In the config file: * Add a `variables` object in the query's entry in the configuration file. * In the `variables` object, for each template variable, add a property with name equal to the template variable's identifier. - * Set each such property's value to an array strings, where each string is a possible value for the corresponding template variable. + * Set each such property's value to an array of strings, where each string is a possible value for the corresponding template variable. Note that template variables' values are not restricted to strings: URIs for example are also possible. -As a consequence, for strings the surround double quotes `"` must be added to the values in the list. +As a consequence, for strings the surround double quotes `"` must be added to the values in the array. For URIs you must add surrounding angle brackets `<>`. Other literals (integers for example) don't have to be surrounded with extra delimiters. This is shown in the configuration structure above. @@ -264,12 +267,16 @@ In addition, a user can create and edit custom queries, either from scratch or b * 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. +* To share a custom query, a "SHARE QUERY" 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. + This may be useful to share a custom query to another user or to save it for yourself. -* To clean up an unwanted custom query, there is always a button "DELETE QUERY"... +* To clean up an unwanted custom query, there is always a button "DELETE QUERY"... + +**Warning**: custom queries are stored in your browser's memory and will disappear if the browser page is refreshed or when switching logins. + +Logged in users however have the possibility to save/load their custom queries to/from a selectable location in their Solid pod, via the buttons in the Dashboard. ## Representation Mapper @@ -314,9 +321,39 @@ You can make use of these for your own tests. Follow these steps: These files will be available in the pod relative to `http://localhost:8080/example/`. * Prepare the pods by executing `npm run reset:pods` in directory `test`. +## Advanced topics + +### Adapting this project to your needs + +The easiest way to adapt this project to your needs is: + +1. Make your own fork on github. +2. Concentrate on the files in the `main` subdirectory. +3. Add your own queries in the `main/public/queries` directory and in general, your own resources in the `main/public` directory. +4. Write your own `main/src/config.json` file, following the [configuration file documentation above](#configuration-file). +5. Run or build as documented above. + +### Converting custom queries into common queries + +Once you have your basic configuration working, you may extend it with custom queries interactively with the query editor +and save these to a file in a pod. +You can convert such custom queries into common queries, by adding them to `main/src/config.json`. +Follow these steps to get started: + +1. **Open and view the file with custom queries** using a tool, such as [Penny](https://penny.vincenttunru.com/). The file has JSON syntax and contains an array of query objects. +2. **Copy the query objects of interest** to the `"queries"` array in `main/src/config.json`. + Note that the various queries that were documented in the [configuration file documentation above](#configuration-file) in `"queryLocation"` properties, + appear here as `"queryString"` variants, with inline contents rather than references to query files (`*.rq`). + Leave as is or convert to query files as you like. + Inline queries may be hard to read due to the difficult newline coding in JSON syntax. +3. **Update the `"queryGroupId"` property** in all these queries, to separate them from the custom queries. Ensure the group exists in the `"queryGroups"` array, or create a new group if you prefer. +4. **Update the `"id"` property**, to avoid conflicts with remaining custom queries: the id must be unique and it also defines the position in the query group. +5. **Adapt any other properties** according to your preferences. +6. **Save `main/src/config.json`**, rerun or rebuild and refresh your browser to test. + ## Testing -For testing we use [Cypress](https://www.cypress.io/). +For testing with the provided configuration file, we use [Cypress](https://www.cypress.io/). > It is important to test the production version at least at the end of a development cycle. diff --git a/main/src/IconProvider/IconProvider.js b/main/src/IconProvider/IconProvider.js index 0b4fc87..e1ffde2 100644 --- a/main/src/IconProvider/IconProvider.js +++ b/main/src/IconProvider/IconProvider.js @@ -24,6 +24,11 @@ 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'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import HourglassTopIcon from '@mui/icons-material/HourglassTop'; +import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder'; +import FolderOffIcon from '@mui/icons-material/FolderOff'; export default { BrushIcon, @@ -51,5 +56,10 @@ export default { CloseIcon, SettingsSuggestIcon, ChevronLeftIcon, - FilterNoneIcon + FilterNoneIcon, + CloudDownloadIcon, + CloudUploadIcon, + HourglassTopIcon, + CreateNewFolderIcon, + FolderOffIcon }; diff --git a/main/src/authenticationProvider/authenticationProvider.js b/main/src/authenticationProvider/authenticationProvider.js index a52bd3b..e5b16ef 100644 --- a/main/src/authenticationProvider/authenticationProvider.js +++ b/main/src/authenticationProvider/authenticationProvider.js @@ -46,6 +46,7 @@ export default { await queryEngine.invalidateHttpCache(); const session = getDefaultSession(); await session.logout(); + window.location.reload(); return false; }, checkAuth: async function checkAuth() { diff --git a/main/src/components/CustomQueryEditor/customEditor.jsx b/main/src/components/CustomQueryEditor/customEditor.jsx index 81334ed..7563bbe 100644 --- a/main/src/components/CustomQueryEditor/customEditor.jsx +++ b/main/src/components/CustomQueryEditor/customEditor.jsx @@ -11,8 +11,12 @@ import Checkbox from '@mui/material/Checkbox'; import configManager from '../../configManager/configManager'; import IconProvider from '../../IconProvider/IconProvider'; +import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; + export default function CustomEditor(props) { + const session = getDefaultSession(); + const loggedIn = session.info.isLoggedIn; const location = useLocation(); const navigate = useNavigate(); @@ -30,7 +34,7 @@ export default function CustomEditor(props) { }); const [showError, setShowError] = useState(false); - const [editError, setEditError] = useState(false) + const [editError, setEditError] = useState(false); const [parsingErrorComunica, setParsingErrorComunica] = useState(false); const [parsingErrorAsk, setParsingErrorAsk] = useState(false); const [parsingErrorTemplate, setParsingErrorTemplate] = useState(false); @@ -64,12 +68,12 @@ ORDER BY ?genre`; "(etc...)" ], "(etc...)": [] - }, null, 5) + }, null, 5); useEffect(() => { try { - let searchParams + let searchParams; if (props.newQuery) { searchParams = new URLSearchParams(location.search); } else { @@ -78,16 +82,16 @@ ORDER BY ?genre`; } const obj = {} searchParams.forEach((value, key) => { - obj[key] = value + obj[key] = value; }) if (obj.indirectQueries) { - setIndirectVariableSourceList(JSON.parse(obj.indirectQueries)) + setIndirectVariableSourceList(JSON.parse(obj.indirectQueries)); } - setFormData(obj) + setFormData(obj); } catch (error) { - setEditError(true) + setEditError(true); } }, [location.search]); @@ -97,12 +101,12 @@ ORDER BY ?genre`; event.preventDefault(); if (!parsingErrorComunica && !parsingErrorAsk && !parsingErrorTemplate) { - setShowError(false) + setShowError(false); const formData = new FormData(event.currentTarget); const jsonData = Object.fromEntries(formData.entries()); if (jsonData.indirectVariablesCheck) { - jsonData.indirectQueries = JSON.stringify(indirectVariableSourceList) + jsonData.indirectQueries = JSON.stringify(indirectVariableSourceList); } const searchParams = new URLSearchParams(jsonData); @@ -119,7 +123,7 @@ ORDER BY ?genre`; updateQuery(jsonData, customQuery); } } else { - setShowError(true) + setShowError(true); } }; @@ -133,20 +137,20 @@ ORDER BY ?genre`; }; const handleIndirectVariablesChange = (event, index) => { - const newList = [...indirectVariableSourceList] - newList[index] = event.target.value - setIndirectVariableSourceList(newList) + const newList = [...indirectVariableSourceList]; + newList[index] = event.target.value; + setIndirectVariableSourceList(newList); } const handleJSONparsing = (event, errorSetter) => { const { name, value } = event.target; - errorSetter(false) + errorSetter(false); let parsedValue; try { parsedValue = JSON.parse(value); } catch (error) { - errorSetter(true) + errorSetter(true); parsedValue = value; } @@ -191,7 +195,7 @@ ORDER BY ?genre`; } if (ensureBoolean(dataWithStrings.indirectVariablesCheck)) { - parsedObject.indirectVariables = { queryStrings: JSON.parse(dataWithStrings.indirectQueries) } + parsedObject.indirectVariables = { queryStrings: JSON.parse(dataWithStrings.indirectQueries) }; } return parsedObject; @@ -199,12 +203,12 @@ ORDER BY ?genre`; // These are the functions for the addition and removal of indirect variable input fields const handleIndirectVariableSource = () => { - setIndirectVariableSourceList([...indirectVariableSourceList, ""]) + setIndirectVariableSourceList([...indirectVariableSourceList, ""]); } const handleIndirectVariableSourceRemove = (index) => { const newList = [...indirectVariableSourceList]; newList.splice(index, 1); - setIndirectVariableSourceList(newList) + setIndirectVariableSourceList(newList); } // These Functions are the submit functions for whether the creation or edit of a custom query @@ -218,7 +222,7 @@ ORDER BY ?genre`; queryGroupId: "cstm", icon: "AutoAwesomeIcon", }); - navigate(`/${creationID}`) + navigate(`/${creationID}`); }; const updateQuery = (formData, customQuery) => { @@ -230,11 +234,18 @@ ORDER BY ?genre`; icon: customQuery.icon }); - navigate(`/${customQuery.id}`) + navigate(`/${customQuery.id}`); }; return ( + {!loggedIn && + + Warning! You are not logged in, so custom queries cannot be saved to a pod. + The SHARE QUERY button is a solution to save a custom query (as a link). + + } + ({ ...prevFormData, 'comunicaContextCheck': !formData.comunicaContextCheck, - })) + })); } } @@ -358,7 +369,7 @@ ORDER BY ?genre`; setFormData((prevFormData) => ({ ...prevFormData, 'sourceIndexCheck': !formData.sourceIndexCheck, - })) + })); } } />} label="Indirect sources" /> @@ -410,7 +421,7 @@ ORDER BY ?genre`; setFormData((prevFormData) => ({ ...prevFormData, 'directVariablesCheck': !formData.directVariablesCheck, - })) + })); } } />} label="Fixed Variables" /> @@ -447,7 +458,7 @@ ORDER BY ?genre`; setFormData((prevFormData) => ({ ...prevFormData, 'indirectVariablesCheck': !formData.indirectVariablesCheck, - })) + })); } } />} label="Indirect Variables" /> @@ -508,7 +519,7 @@ ORDER BY ?genre`; setFormData((prevFormData) => ({ ...prevFormData, 'askQueryCheck': !formData.askQueryCheck, - })) + })); } } />} label="ASK query" /> @@ -563,7 +574,7 @@ ORDER BY ?genre`; : - + + + + From: + + + setLoadPodUri(e.target.value)} + variant="outlined" + sx={{ flexGrow: 1, marginX: '15px' }} + /> + + {loadSuccesMessage} + + { setConfirmDialog(false) }} + > + + WARNING: Loading custom queries from a pod + + + { setConfirmDialog(false) }} + sx={{ + position: 'absolute', + right: 8, + top: 8, + color: "gray", + }} + > + + + + + + + You have two options: you can either add new queries, or overwrite existing queries.

+ + If you choose to add, new custom queries will be added alongside the existing local custom queries. + Local custom queries with matching "id" properties will not be overwritten.

+ + If you choose to overwrite, all of your local custom queries will be deleted and replaced with the new ones.

+ + To save your current combination of local custom queries as is, you may want to save them to a separate file on the pod before proceeding. +
+
+ + + + + +
+ +
+ + + + + {saveErrorMessage} + + + + + To: + + + setSavePodUri(e.target.value)} + variant="outlined" + sx={{ flexGrow: 1, marginX: '15px' }} + /> + + {saveSuccesMessage} + + + ) +} diff --git a/main/src/configManager/configManager.js b/main/src/configManager/configManager.js index 26b272d..ce8dfaa 100644 --- a/main/src/configManager/configManager.js +++ b/main/src/configManager/configManager.js @@ -135,11 +135,11 @@ class ConfigManager extends EventEmitter { async getQueryText(query) { if (query.queryLocation) { - - if(!query.queryLocation.endsWith('.rq')){ + + if (!query.queryLocation.endsWith('.rq')) { return undefined } - + const fetchResult = await fetch(`${this.config.queryFolder}${query.queryLocation}`); return await fetchResult.text(); } @@ -147,7 +147,7 @@ class ConfigManager extends EventEmitter { if (query.queryString) { // WEIRD: figured that the queryString was too fast or something? so this simulates a promise like the fetchQuery - // => 1 milisecond does not impact the flow at all, at the contrary it fixes a weird issue... + // => 1 milisecond does not impact the flow at all, at the contrary it fixes a weird issue... await new Promise(resolve => setTimeout(resolve, 1)); return query.queryString; } @@ -155,6 +155,78 @@ class ConfigManager extends EventEmitter { return undefined; } + + /** + * + * @returns a list of all existing customQueries + */ + getCustomQueries() { + return this.config.queries.filter(query => query.queryGroupId === "cstm"); + } + + + /** + * deletes all existing customQueries + */ + deleteCustomQueries() { + this.config.queries = this.config.queries.filter(query => query.queryGroupId !== "cstm"); + } + + /** + * + * @returns a boolean whether or not there are custom queries + */ + localCustomQueriesPresent() { + return this.getCustomQueries().length !== 0; + } + + + /** + * adds the custom queries from the pod to the local custom queries. + * 2 possibilieties: append or overwrite + * @param {list} queriesToAdd - the list of queries retrieved from the pod + * @param {boolean} overwriteLoad - whether or not the existing queries must be overwritten + */ + addCustomQueriesToQueryList(queriesToAdd, overwriteLoad) { + // First make sure there is the custom query group + this.addNewQueryGroup('cstm', 'Custom queries', 'EditNoteIcon'); + + if (overwriteLoad) { + // Clear all previous custom queries to overwrite + this.deleteCustomQueries(); + } + + // Make sure no duplicates are added + const existingQueries = this.config.queries; + const uniqueQueriesToAdd = queriesToAdd.filter(queryToAdd => { + return !existingQueries.some(existingQuery => + existingQuery.id === queryToAdd.id + ); + }); + + this.config.queries = [...existingQueries, ...uniqueQueriesToAdd]; + this.emit('configChanged', this.config); + } + + + + /** + * This is a simple rudimentary format validator + * to check if the objects retrieved from the pod are in fact queries. + * It does not cover everything but covers most of the probabilities. + * @param {object} query - the query that has to be checked + * @returns + */ + basicQueryFormatValidator(query) { + const id = "id" in query; + const name = "name" in query; + const description = "description" in query; + const queryString = "queryString" in query; + const searchParams = "searchParams" in query; + + return id && name && description && queryString && searchParams; + } + } const configManager = new ConfigManager(); diff --git a/test/cypress/e2e/custom-query-editor.cy.js b/test/cypress/e2e/custom-query-editor.cy.js index d122c23..87e52fb 100644 --- a/test/cypress/e2e/custom-query-editor.cy.js +++ b/test/cypress/e2e/custom-query-editor.cy.js @@ -160,7 +160,7 @@ SELECT * WHERE { 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('button').contains("Share 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'); diff --git a/test/cypress/e2e/save-custom-queries-on-pod.cy.js b/test/cypress/e2e/save-custom-queries-on-pod.cy.js new file mode 100644 index 0000000..f1a6e30 --- /dev/null +++ b/test/cypress/e2e/save-custom-queries-on-pod.cy.js @@ -0,0 +1,102 @@ +describe("Saving custom queries on pods - logged out", () => { + + it("Interaction with pods should be disabled when logged out", () => { + cy.visit("/#"); + + cy.get('button').contains("Load All").should('be.disabled'); + cy.get('button').contains("Save All").should('be.disabled'); + + cy.get('input[name="loadFrom"]').should('be.disabled'); + cy.get('input[name="saveTo"]').should('be.disabled'); + }) +}) + +describe("Saving custom queries on pods - logged in", () => { + + beforeEach(() => { + // Log in before each individual test + cy.visit("/"); + + cy.get('[aria-label="Profile"]').click(); + cy.contains('[role="menuitem"]', "Login").click(); + + cy.get('input[name="idp"]').clear(); + cy.get('input[name="idp"]').type("http://localhost:8080"); + cy.contains("Login").click(); + + cy.origin('http://localhost:8080', () => { + cy.get("input#email").type("hello@example.com"); + cy.get("input#password").type("abc123"); + cy.contains("button", "Log in").click(); + cy.contains("button", "Authorize").click(); + }); + + cy.url().should("eq", "http://localhost:5173/"); + + cy.visit("/#"); + }); + + it("Trying to save with no queries gives the right remark", () => { + + // You have no custom queries. + cy.get('button').contains("Save All").click(); + cy.contains("You have no custom queries.").should("exist"); + + }) + + it("Make a custom query and upload it", () => { + + //make 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(); + + cy.visit("/#"); + + cy.contains("new query").should("exist"); + + cy.get('input[name="saveTo"]').clear(); + cy.get('input[name="saveTo"]').type('http://localhost:8080/example/testFolder/customQueriesTest/myQueriesTests.json'); + + // You have no custom queries. + cy.get('button').contains("Save All").click(); + cy.contains("Successfully saved you queries on the pod!").should("exist"); + + }) + + it("Load the previously made query from the pod", () => { + + cy.contains("new query").should("not.exist"); + + cy.get('input[name="loadFrom"]').clear(); + cy.get('input[name="loadFrom"]').type('http://localhost:8080/example/testFolder/customQueriesTest/myQueriesTests.json'); + + + cy.get('button').contains("Load All").click(); + // cy.get('button[type="submit"]').contains("Load queries").click(); + + cy.contains("new query").should("exist"); + cy.contains("new query").click(); + + //check if the query works + cy.contains("Colleen Hoover").should('exist'); + + }) + +}) \ No newline at end of file