diff --git a/jena-fuseki2/jena-fuseki-ui/src/services/fuseki.service.js b/jena-fuseki2/jena-fuseki-ui/src/services/fuseki.service.js index 8668dd977de..38394f43e3c 100644 --- a/jena-fuseki2/jena-fuseki-ui/src/services/fuseki.service.js +++ b/jena-fuseki2/jena-fuseki-ui/src/services/fuseki.service.js @@ -240,6 +240,41 @@ class FusekiService { throw new Error(error.response.data) }) } + + /** + * Fetch all prefix mappings of a dataset from its prefixes service. + * @param {string} datasetName - The name of the dataset with the prefix mappings + * @param {string} endpointName - name of the dataset's prefixes-r or prefixes-rw endpoint + * @returns {Promise>} all prefix mappings of the dataset + */ + async getPrefixes (datasetName, endpointName) { + return axios.get(this.getFusekiUrl(`/${datasetName}/${endpointName}`)) + } + + /** + * Add a prefix mapping to a dataset or replace an existing mapping. + * @param {string} datasetName - The name of the dataset with the prefix mappings + * @param {string} endpointName - name of the dataset's prefixes-rw endpoint + * @param {string} prefix - The prefix name to add or update + * @param {string} uri - The namespace URI the prefix expands to + * @returns {Promise>} + */ + async updatePrefix (datasetName, endpointName, prefix, uri) { + const params = new URLSearchParams({ prefix, uri }) + return axios.post(this.getFusekiUrl(`/${datasetName}/${endpointName}`), params) + } + + /** + * Deletes a saved prefix from a dataset. + * @param {string} datasetName - The name of the dataset with the prefix mappings + * @param {string} endpointName - The name of the dataset's prefixes-rw endpoint + * @param {string} prefix - The prefix to remove + * @returns {Promise>} + */ + async removePrefix (datasetName, endpointName, prefix) { + return axios.delete(this.getFusekiUrl(`/${datasetName}/${endpointName}`), + { params: { prefix } }) + } } export default FusekiService diff --git a/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js b/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js index d54d9fca033..17340797847 100644 --- a/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js +++ b/jena-fuseki2/jena-fuseki-ui/src/services/mock/json-server.js @@ -16,6 +16,7 @@ */ import jsonServer from 'json-server' +import { DEFAULT_PREFIXES } from '../../utils/prefixes.js' const PORT = process.env.FUSEKI_PORT || 3030 @@ -96,6 +97,16 @@ server.post('/\\$/datasets', (req, res) => { 'srv.type': 'upload', 'srv.description': 'File Upload', 'srv.endpoints': ['upload'] + }, + { + 'srv.type': 'prefixes-r', + 'srv.description': 'Read prefixes', + 'srv.endpoints': ['prefixes'] + }, + { + 'srv.type': 'prefixes-rw', + 'srv.description': 'Read-write prefixes', + 'srv.endpoints': ['updatePrefixes'] } ] } @@ -267,6 +278,49 @@ server.post('/:datasetName/data', (req, res) => { .send() }) +// PREFIXES +// In-memory prefix store per dataset, mirroring the Fuseki prefixes +// service semantics. +const PREFIXES = {} +const PREFIX_PATTERN = /^[A-Za-z]([\w.-]*\w)?$/ + +const prefixesFor = (datasetName) => { + if (!PREFIXES[datasetName]) { + PREFIXES[datasetName] = Object.fromEntries( + DEFAULT_PREFIXES.map(p => [p.prefix, p.uri])) + } + return PREFIXES[datasetName] +} + +const listPrefixes = (req, res) => { + res.jsonp( + Object.entries(prefixesFor(req.params.datasetName)) + .map(([prefix, uri]) => ({ prefix, uri })) + ) +} +server.get('/:datasetName/prefixes', listPrefixes) +// The UI reads via the rw endpoint when the dataset declares one. +server.get('/:datasetName/updatePrefixes', listPrefixes) + +server.post('/:datasetName/updatePrefixes', (req, res) => { + const { prefix, uri } = req.body + if (!prefix || !PREFIX_PATTERN.test(prefix)) { + res.status(400).send(`Invalid prefix name: '${prefix}'`) + return + } + if (!uri || /\s/.test(uri)) { + res.status(400).send(`Invalid prefix URI: '${uri}'`) + return + } + prefixesFor(req.params.datasetName)[prefix] = uri + res.sendStatus(200) +}) + +server.delete('/:datasetName/updatePrefixes', (req, res) => { + delete prefixesFor(req.params.datasetName)[req.query.prefix] + res.sendStatus(200) +}) + // PING // GET PING STATUS server.get('/\\$/ping', (req, res) => { @@ -286,6 +340,9 @@ server.get('/tests/reset', (req, res) => { for (const dataset in DATASETS) { delete DATASETS[dataset] } + for (const dataset in PREFIXES) { + delete PREFIXES[dataset] + } } catch (e) { console.log(e) } diff --git a/jena-fuseki2/jena-fuseki-ui/src/utils/prefixes.js b/jena-fuseki2/jena-fuseki-ui/src/utils/prefixes.js new file mode 100644 index 00000000000..a6855f50b9f --- /dev/null +++ b/jena-fuseki2/jena-fuseki-ui/src/utils/prefixes.js @@ -0,0 +1,28 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common prefixes offered in the query editor when a dataset has no + * prefixes service, or its prefix store is empty. Uses the same + * {prefix, uri} shape as the Fuseki prefixes service responses. + */ +export const DEFAULT_PREFIXES = [ + { prefix: 'rdf', uri: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' }, + { prefix: 'rdfs', uri: 'http://www.w3.org/2000/01/rdf-schema#' }, + { prefix: 'owl', uri: 'http://www.w3.org/2002/07/owl#' }, + { prefix: 'xsd', uri: 'http://www.w3.org/2001/XMLSchema#' } +] diff --git a/jena-fuseki2/jena-fuseki-ui/src/utils/validation.js b/jena-fuseki2/jena-fuseki-ui/src/utils/validation.js index fd45116f91f..e8e399ea77f 100644 --- a/jena-fuseki2/jena-fuseki-ui/src/utils/validation.js +++ b/jena-fuseki2/jena-fuseki-ui/src/utils/validation.js @@ -46,3 +46,26 @@ export function validateGraphName (graphName) { // If it reached this part, then it's a valid graph name. return true } + +const PREFIX_NAME_PATTERN = /^[A-Za-z]([\w.-]*\w)?$/ + +/** + * Validates a prefix name for the Fuseki prefixes service. + * + * @param {string} prefix - The prefix name, e.g. "foaf". + * @return {boolean} - true iff the prefix name is valid. + */ +export function validatePrefixName (prefix) { + return prefix !== '' && PREFIX_NAME_PATTERN.test(prefix) +} + +/** + * Validates a prefix namespace URI for the Fuseki prefixes service. + * + * @param {string} uri - The namespace URI the prefix expands to. + * @return {boolean} - true iff the URI looks valid. + */ +export function validatePrefixUri (uri) { + // Same rules as graph names: a non-empty, space-free, parseable URI. + return validateGraphName(uri) +} diff --git a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Query.vue b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Query.vue index 33e541c8a4c..15f1b271a97 100644 --- a/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Query.vue +++ b/jena-fuseki2/jena-fuseki-ui/src/views/dataset/Query.vue @@ -59,12 +59,101 @@
{{ prefix.text }} + :key="prefix.text" + class="d-inline-block me-2" + > + + + {{ prefix.text }} + + +
+
+
+ + + + +
+ Please enter a valid prefix. +
+
+ Please enter a valid URI. +
+
+
@@ -184,7 +273,11 @@ import Yasqe from '@zazuko/yasqe' import Yasr from '@zazuko/yasr' import GeoPlugin from 'yasgui-geo-tg' import { createShareableLink } from '@/utils/query' +import { displayError, displayNotification } from '@/utils' +import { DEFAULT_PREFIXES } from '@/utils/prefixes' +import { validatePrefixName, validatePrefixUri } from '@/utils/validation' import { nextTick } from 'vue' +import { Popover } from 'bootstrap' import currentDatasetMixin from '@/mixins/current-dataset' import currentDatasetMixinNavigationGuards from '@/mixins/current-dataset-navigation-guards' @@ -207,6 +300,9 @@ WHERE { } LIMIT 25` +// The shared defaults in the {text, uri} shape the badge template uses. +const defaultPrefixes = () => DEFAULT_PREFIXES.map(p => ({ text: p.prefix, uri: p.uri })) + export default { name: 'DatasetQuery', @@ -247,14 +343,18 @@ export default { text: 'Selection of classes' } ], - prefixes: [ - { uri: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', text: 'rdf' }, - { uri: 'http://www.w3.org/2000/01/rdf-schema#', text: 'rdfs' }, - { uri: 'http://www.w3.org/2002/07/owl#', text: 'owl' }, - { uri: 'http://www.w3.org/2001/XMLSchema#', text: 'xsd' } - ], + prefixes: defaultPrefixes(), currentQueryPrefixes: [], - currentDatasetUrl: '' + currentDatasetUrl: '', + currentPopover: null, + addingPrefix: false, + showAddPrefixForm: false, + newPrefix: { + prefix: '', + uri: '' + }, + // Validation state is only displayed after the first submit attempt. + addPrefixValidated: false } }, @@ -264,6 +364,53 @@ export default { return '' } return `/${this.datasetName}/${this.services.query['srv.endpoints'][0]}` + }, + + /** + * The current dataset's prefixes service, or null if it does not declare one. + * + * @returns {{endpoint: string, writable: boolean}|null} + */ + prefixesService () { + if (!this.services) { + return null + } + const svc = this.services['prefixes-rw'] || this.services['prefixes-r'] || null + return svc + ? { + endpoint: svc['srv.endpoints'][0], + writable: !!this.services['prefixes-rw'] + } + : null + }, + + /** + * True iff the current dataset's prefixes can be edited from the UI. + */ + prefixesWritable () { + return !!(this.prefixesService && this.prefixesService.writable) + }, + + /** + * Bootstrap validation class for the new-prefix name input; empty + * until the first submit attempt, live-updating afterwards. + */ + addPrefixNameClass () { + if (!this.addPrefixValidated) { + return '' + } + return validatePrefixName(this.newPrefix.prefix) ? 'is-valid' : 'is-invalid' + }, + + /** + * Bootstrap validation class for the new-prefix URI input; empty + * until the first submit attempt, live-updating afterwards. + */ + addPrefixUriClass () { + if (!this.addPrefixValidated) { + return '' + } + return validatePrefixUri(this.newPrefix.uri) ? 'is-valid' : 'is-invalid' } }, @@ -342,6 +489,10 @@ export default { this.yasqe.options.requestConfig.endpoint = this.$fusekiService.getFusekiUrl(val) } }, + prefixesService: function (val, oldVal) { + this.closeAddPrefixForm() + this.loadPrefixes() + }, contentTypeSelect: function (val, oldVal) { if (this.yasqe) { this.yasqe.options.requestConfig.acceptHeaderSelect = this.contentTypeSelect @@ -389,7 +540,151 @@ export default { this.yasqe.addPrefixes(newPrefix) this.currentQueryPrefixes.push(prefix.uri) } + }, + /** + * Replaces the default prefix list with the prefixes of the current + * dataset. The defaults are restored when the dataset has no prefixes + * service, when its prefix store is empty, and when fetching fails. + */ + async loadPrefixes () { + this.hidePopover() + if (!this.prefixesService) { + this.prefixes = defaultPrefixes() + return + } + try { + const res = await this.$fusekiService + .getPrefixes(this.datasetName, this.prefixesService.endpoint) + this.prefixes = res.data.length !== 0 + ? res.data.map(p => ({ text: p.prefix, uri: p.uri })) + : defaultPrefixes() + } catch (error) { + this.prefixes = defaultPrefixes() + displayError(this, error) + } + }, + /** + * Validates both form fields for adding a prefix, turning on the + * live validation display. + * + * @returns {boolean} true iff both fields are valid + */ + validateAddPrefixForm () { + this.addPrefixValidated = true + return validatePrefixName(this.newPrefix.prefix) && validatePrefixUri(this.newPrefix.uri) + }, + /** + * Clears the add-prefix form fields and their validation state. + */ + resetAddPrefixForm () { + this.newPrefix = { + prefix: '', + uri: '' + } + this.addPrefixValidated = false + }, + /** + * Shows the add-prefix form (replacing the "+" pill) and focuses + * its first input. + */ + openAddPrefixForm () { + this.showAddPrefixForm = true + this.$nextTick(() => { + const input = document.getElementById('add-prefix-name') + if (input) { + input.focus() + } + }) + }, + /** + * Hides the add-prefix form (restoring the "+" pill) and resets it. + */ + closeAddPrefixForm () { + this.showAddPrefixForm = false + this.resetAddPrefixForm() + }, + /** + * Adds or replaces a prefix mapping on the dataset via its + * read-write prefixes endpoint, then refetches the list. + */ + async addPrefix () { + if (this.addingPrefix || !this.prefixesWritable) { + return + } + if (!this.validateAddPrefixForm()) { + return + } + this.addingPrefix = true + try { + await this.$fusekiService + .updatePrefix(this.datasetName, this.prefixesService.endpoint, this.newPrefix.prefix, this.newPrefix.uri) + displayNotification(this, `Prefix ${this.newPrefix.prefix} added`) + this.closeAddPrefixForm() + await this.loadPrefixes() + } catch (error) { + // Surface the server's validation message + displayError(this, (error.response && error.response.data) || error) + } finally { + this.addingPrefix = false + } + }, + /** + * Removes a prefix mapping from the dataset via its read-write + * prefixes endpoint, then refetches the list. + * + * @param {{text: string, uri: string}} prefix - The prefix badge entry to remove. + */ + async removePrefix (prefix) { + try { + await this.$fusekiService + .removePrefix(this.datasetName, this.prefixesService.endpoint, prefix.text) + displayNotification(this, `Prefix ${prefix.text} removed`) + await this.loadPrefixes() + } catch (error) { + displayError(this, (error.response && error.response.data) || error) + } + }, + /** + * Opens the confirmation popover for the given element id prefix, + * closing any other popover first. + * + * @param {string} id - Id prefix shared by the popover's trigger button. + */ + showPopover (id) { + if (this.currentPopover !== null) { + if (this.currentPopover.__id === id) { + return + } + this.hidePopover() + } + const unwrap = ref => Array.isArray(ref) ? ref[0] : ref + const content = unwrap(this.$refs[`${id}-content`]) + const trigger = unwrap(this.$refs[`${id}-button`]) + const popover = new Popover(trigger, { + html: true, + content, + trigger: 'manual', + placement: 'auto' + }) + popover.__id = id + popover.show() + this.currentPopover = popover + }, + /** + * Closes the currently open confirmation popover, if any. + */ + hidePopover () { + if (this.currentPopover === null) { + return + } + this.currentPopover.hide() + this.currentPopover.dispose() + this.currentPopover = null } + }, + + beforeUnmount () { + this.hidePopover() } } @@ -425,4 +720,19 @@ export default { .yasr .yasr_btnGroup .select_geo .plugin_icon { margin-bottom: 20%; } +.badge .btn-close.remove-prefix { + font-size: .65em; +} +.badge.add-prefix-pill { + cursor: pointer; + color: #6c757d; + background-color: transparent; + border: 1px dashed #adb5bd; + opacity: .45; + transition: opacity .15s ease-in-out; +} +.badge.add-prefix-pill:hover, +.badge.add-prefix-pill:focus { + opacity: 1; +} diff --git a/jena-fuseki2/jena-fuseki-ui/tests/unit/services/fuseki.service.spec.js b/jena-fuseki2/jena-fuseki-ui/tests/unit/services/fuseki.service.spec.js index dabbfa7ef0f..4697ccb06ab 100644 --- a/jena-fuseki2/jena-fuseki-ui/tests/unit/services/fuseki.service.spec.js +++ b/jena-fuseki2/jena-fuseki-ui/tests/unit/services/fuseki.service.spec.js @@ -361,4 +361,39 @@ describe('FusekiService', () => { } fusekiService.pathname = originalPathname }) + it('gets the prefixes of a dataset', async () => { + const stub = sinon.stub(axios, 'get') + const mappings = [ + { prefix: 'rdf', uri: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' }, + { prefix: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ] + stub.resolves(Promise.resolve({ + data: mappings + })) + const response = await fusekiService.getPrefixes('jena', 'prefixes') + expect(stub.calledWith('/jena/prefixes')).to.equal(true) + expect(response.data).to.deep.equal(mappings) + stub.restore() + }) + it('updates a prefix', async () => { + const stub = sinon.stub(axios, 'post') + stub.resolves(Promise.resolve({})) + await fusekiService.updatePrefix('jena', 'prefixes-rw', 'foaf', 'http://xmlns.com/foaf/0.1/') + expect(stub.called).to.equal(true) + const [url, params] = stub.getCall(0).args + expect(url).to.equal('/jena/prefixes-rw') + expect(params.get('prefix')).to.equal('foaf') + expect(params.get('uri')).to.equal('http://xmlns.com/foaf/0.1/') + stub.restore() + }) + it('removes a prefix', async () => { + const stub = sinon.stub(axios, 'delete') + stub.resolves(Promise.resolve({})) + await fusekiService.removePrefix('jena', 'prefixes-rw', 'foaf') + expect(stub.called).to.equal(true) + const [url, config] = stub.getCall(0).args + expect(url).to.equal('/jena/prefixes-rw') + expect(config.params.prefix).to.equal('foaf') + stub.restore() + }) }) diff --git a/jena-fuseki2/jena-fuseki-ui/tests/unit/utils/validation.spec.js b/jena-fuseki2/jena-fuseki-ui/tests/unit/utils/validation.spec.js index 89a6e98fd50..0dd1200114b 100644 --- a/jena-fuseki2/jena-fuseki-ui/tests/unit/utils/validation.spec.js +++ b/jena-fuseki2/jena-fuseki-ui/tests/unit/utils/validation.spec.js @@ -15,7 +15,7 @@ * limitations under the License. */ import { describe, expect, it } from 'vitest' -import { validateGraphName } from '@/utils/validation' +import { validateGraphName, validatePrefixName, validatePrefixUri } from '@/utils/validation' const VALID_GRAPH_NAMES = [ // From issue GH-2370 discussion @@ -62,6 +62,51 @@ const INVALID_GRAPH_NAMES = [ 'http%3A//www.example.com/other/graph' ] +const VALID_PREFIX_NAMES = [ + 'a', + 'Z', + 'foaf', + 'a1', + 'a_', + 'a_b', + 'a-b', + 'a.b', + 'a..b', + 'a1-2b', + 'skos-xl' +] + +const INVALID_PREFIX_NAMES = [ + '', + ' ', + '1a', + '_a', + '-a', + '.a', + 'a.', + 'a-', + 'a b', + ' a', + 'a:b', + 'a/b', + 'préfix' +] + +const VALID_PREFIX_URIS = [ + 'http://example.org/ns#', + 'https://example.com/a?b=c', + 'urn:example:ns', + 'http://xmlns.com/foaf/0.1/' +] + +const INVALID_PREFIX_URIS = [ + '', + ' ', + 'http://exa mple.com/', + 'not a uri', + 'example.org/ns' +] + describe('validation', () => { it('Should reject empty graph names', () => { expect(validateGraphName('')).to.equals(false) @@ -81,4 +126,24 @@ describe('validation', () => { expect(validateGraphName(graphName), `Rejected valid graph name "${graphName}"`).to.equals(true) } }) + it('Should accept valid prefix names', () => { + for (let prefix of VALID_PREFIX_NAMES) { + expect(validatePrefixName(prefix), `Rejected valid prefix name "${prefix}"`).to.equals(true) + } + }) + it('Should reject invalid prefix names', () => { + for (let prefix of INVALID_PREFIX_NAMES) { + expect(validatePrefixName(prefix), `Accepted invalid prefix name "${prefix}"`).to.equals(false) + } + }) + it('Should accept valid prefix URIs', () => { + for (let uri of VALID_PREFIX_URIS) { + expect(validatePrefixUri(uri), `Rejected valid prefix URI "${uri}"`).to.equals(true) + } + }) + it('Should reject invalid prefix URIs', () => { + for (let uri of INVALID_PREFIX_URIS) { + expect(validatePrefixUri(uri), `Accepted invalid prefix URI "${uri}"`).to.equals(false) + } + }) }) diff --git a/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/query.vue.spec.js b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/query.vue.spec.js index 32ead25c20f..9066a322ba2 100644 --- a/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/query.vue.spec.js +++ b/jena-fuseki2/jena-fuseki-ui/tests/unit/views/dataset/query.vue.spec.js @@ -16,8 +16,27 @@ */ import { flushPromises, mount } from '@vue/test-utils' import { nextTick } from 'vue' -import Query from '@/views/dataset/Query.vue' import { vi } from 'vitest' +import Query from '@/views/dataset/Query.vue' +import { Popover } from 'bootstrap' + +// jsdom cannot lay out Bootstrap popovers; stub the Popover class and +// record created instances so tests can assert show/hide/dispose calls. +// Vitest hoists this above the imports, so `Popover` resolves to the stub. +vi.mock('bootstrap', () => { + class PopoverStub { + constructor (trigger, options) { + this.trigger = trigger + this.options = options + this.show = vi.fn() + this.hide = vi.fn() + this.dispose = vi.fn() + PopoverStub.instances.push(this) + } + } + PopoverStub.instances = [] + return { Popover: PopoverStub } +}) const FAKE_FUSEKI_URL = 'https://localhost:1234/fuseki/' @@ -25,8 +44,10 @@ const $routeMock = { query: {} } -const mountFunction = options => { - const mountOptions = Object.assign(options || {}, { +const mountFunction = (options = {}) => { + const { mocks, ...mountOptions } = options + return mount(Query, { + ...mountOptions, shallow: true, global: { mocks: { @@ -35,13 +56,15 @@ const mountFunction = options => { getFusekiUrl () { return FAKE_FUSEKI_URL } - } + }, + $toast: { + error () {}, + notification () {} + }, + ...mocks } } }) - return mount(Query, { - ...mountOptions - }) } describe('Query view', () => { @@ -131,4 +154,342 @@ describe('Query view', () => { // See issue https://github.com/apache/jena/issues/1611 expect(requestConfig.acceptHeaderGraph).equals(wrapper.vm.$data.contentTypeGraph) }) + + describe('prefixes service', () => { + const datasetName = 'test' + const querySvc = { 'srv.type': 'query', 'srv.endpoints': ['sparql'] } + const prefixesR = { 'srv.type': 'prefixes-r', 'srv.endpoints': ['prefixes'] } + const prefixesRW = { 'srv.type': 'prefixes-rw', 'srv.endpoints': ['updatePrefixes'] } + const serverDataWith = services => ({ + datasets: [ + { + 'ds.name': `/${datasetName}`, + 'ds.services': services + } + ] + }) + const fusekiServiceMock = ({ getPrefixes, updatePrefix, removePrefix } = {}) => ({ + updatePrefix, + removePrefix, + getFusekiUrl () { + return FAKE_FUSEKI_URL + }, + getPrefixes + }) + + it('detects no prefixes service when the dataset does not declare one', async () => { + const wrapper = mountFunction({ + props: { datasetName } + }) + expect(wrapper.vm.prefixesService).equals(null) + wrapper.vm.serverData = serverDataWith([querySvc]) + await nextTick() + expect(wrapper.vm.prefixesService).equals(null) + }) + + it('detects a read-only prefixes service', async () => { + const wrapper = mountFunction({ + props: { datasetName } + }) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + expect(wrapper.vm.prefixesService).deep.equals({ + endpoint: 'prefixes', + writable: false + }) + }) + + it('prefers the read-write prefixes service over the read-only one', async () => { + const wrapper = mountFunction({ + props: { datasetName } + }) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR, prefixesRW]) + await nextTick() + expect(wrapper.vm.prefixesService).deep.equals({ + endpoint: 'updatePrefixes', + writable: true + }) + }) + + it('does not fetch prefixes when there is no prefixes service', async () => { + const getPrefixes = vi.fn() + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { $fusekiService: fusekiServiceMock({ getPrefixes }) } + }) + const defaults = JSON.parse(JSON.stringify(wrapper.vm.prefixes)) + wrapper.vm.serverData = serverDataWith([querySvc]) + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(0) + expect(wrapper.vm.prefixes).deep.equals(defaults) + }) + + it('replaces the default prefixes with the dataset prefixes', async () => { + const getPrefixes = vi.fn().mockResolvedValue({ + data: [ + { prefix: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ] + }) + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { $fusekiService: fusekiServiceMock({ getPrefixes }) } + }) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(1) + expect(getPrefixes.mock.calls[0]).deep.equals([datasetName, 'prefixes']) + expect(wrapper.vm.prefixes).deep.equals([ + { text: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ]) + }) + + it('keeps the default prefixes and reports the error when fetching fails', async () => { + const getPrefixes = vi.fn().mockRejectedValue(new Error('403 Forbidden')) + const toastError = vi.fn() + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { + $fusekiService: fusekiServiceMock({ getPrefixes }), + $toast: { error: toastError, notification () {} } + } + }) + const defaults = JSON.parse(JSON.stringify(wrapper.vm.prefixes)) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(1) + expect(toastError.mock.calls.length).equals(1) + expect(wrapper.vm.prefixes).deep.equals(defaults) + }) + + it('falls back to the default prefixes when the prefix store is empty', async () => { + const getPrefixes = vi.fn().mockResolvedValue({ data: [] }) + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { $fusekiService: fusekiServiceMock({ getPrefixes }) } + }) + const defaults = JSON.parse(JSON.stringify(wrapper.vm.prefixes)) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(1) + expect(wrapper.vm.prefixes).deep.equals(defaults) + }) + + it('restores the default prefixes when the dataset has no prefixes service', async () => { + const getPrefixes = vi.fn().mockResolvedValue({ + data: [ + { prefix: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ] + }) + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { $fusekiService: fusekiServiceMock({ getPrefixes }) } + }) + const defaults = JSON.parse(JSON.stringify(wrapper.vm.prefixes)) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + await flushPromises() + expect(wrapper.vm.prefixes).deep.equals([ + { text: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ]) + // Simulate navigating to a dataset that declares no prefixes service. + wrapper.vm.serverData = serverDataWith([querySvc]) + await nextTick() + await flushPromises() + expect(wrapper.vm.prefixes).deep.equals(defaults) + }) + + it('does not reload prefixes when the SPARQL endpoint input changes', async () => { + const getPrefixes = vi.fn().mockResolvedValue({ + data: [ + { prefix: 'foaf', uri: 'http://xmlns.com/foaf/0.1/' } + ] + }) + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { $fusekiService: fusekiServiceMock({ getPrefixes }) } + }) + wrapper.vm.serverData = serverDataWith([querySvc, prefixesR]) + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(1) + // Editing the free-text SPARQL Endpoint field must not refetch. + wrapper.vm.currentDatasetUrl = '/other/sparql' + await nextTick() + await flushPromises() + expect(getPrefixes.mock.calls.length).equals(1) + }) + + describe('write path', () => { + const FOAF_URI = 'http://xmlns.com/foaf/0.1/' + const mountWritable = async ({ services, ...serviceMocks } = {}) => { + const getPrefixes = serviceMocks.getPrefixes || + vi.fn().mockResolvedValue({ data: [{ prefix: 'foaf', uri: FOAF_URI }] }) + const toast = { error: vi.fn(), notification: vi.fn() } + const wrapper = mountFunction({ + props: { datasetName }, + mocks: { + $fusekiService: fusekiServiceMock({ ...serviceMocks, getPrefixes }), + $toast: toast + } + }) + wrapper.vm.serverData = serverDataWith(services || [querySvc, prefixesR, prefixesRW]) + await nextTick() + await flushPromises() + await nextTick() + return { wrapper, getPrefixes, toast } + } + + beforeEach(() => { + Popover.instances.length = 0 + }) + + // The add form is collapsed behind the "+" pill by default. + const openAddForm = async wrapper => { + await wrapper.find('#add-prefix-pill').trigger('click') + await nextTick() + } + + it('hides the write controls when the prefixes service is read-only', async () => { + const { wrapper } = await mountWritable({ services: [querySvc, prefixesR] }) + expect(wrapper.find('.remove-prefix').exists()).equals(false) + expect(wrapper.find('#add-prefix-pill').exists()).equals(false) + expect(wrapper.find('#add-prefix-form').exists()).equals(false) + }) + + it('shows the write controls when the prefixes service is read-write', async () => { + const { wrapper } = await mountWritable() + expect(wrapper.findAll('.remove-prefix').length).equals(1) + expect(wrapper.find('#add-prefix-pill').exists()).equals(true) + // The add form only appears once the pill is clicked. + expect(wrapper.find('#add-prefix-form').exists()).equals(false) + await openAddForm(wrapper) + expect(wrapper.find('#add-prefix-pill').exists()).equals(false) + expect(wrapper.find('#add-prefix-form').exists()).equals(true) + }) + + it('collapses and resets the add form on cancel', async () => { + const { wrapper } = await mountWritable() + await openAddForm(wrapper) + await wrapper.find('#add-prefix-name').setValue('ex') + await wrapper.find('#add-prefix-cancel').trigger('click') + expect(wrapper.find('#add-prefix-form').exists()).equals(false) + expect(wrapper.find('#add-prefix-pill').exists()).equals(true) + expect(wrapper.vm.newPrefix).deep.equals({ prefix: '', uri: '' }) + }) + + it('opens a confirmation popover on remove click, without toggling the prefix', async () => { + const { wrapper } = await mountWritable() + const toggleSpy = vi.spyOn(wrapper.vm, 'togglePrefix') + await wrapper.find('.remove-prefix').trigger('click') + expect(toggleSpy.mock.calls.length).equals(0) + expect(Popover.instances.length).equals(1) + const popover = Popover.instances[0] + // The content element proves the v-for $refs array was unwrapped. + expect(popover.options.content instanceof HTMLElement).equals(true) + expect(popover.show.mock.calls.length).equals(1) + }) + + it('adds a prefix and refetches the list', async () => { + const updatePrefix = vi.fn().mockResolvedValue({}) + const { wrapper, getPrefixes, toast } = await mountWritable({ updatePrefix }) + await openAddForm(wrapper) + await wrapper.find('#add-prefix-name').setValue('ex') + await wrapper.find('#add-prefix-uri').setValue('http://example.org/ns#') + await wrapper.find('#add-prefix-form').trigger('submit') + await flushPromises() + expect(updatePrefix.mock.calls).deep.equals([ + [datasetName, 'updatePrefixes', 'ex', 'http://example.org/ns#'] + ]) + expect(getPrefixes.mock.calls.length).equals(2) + expect(wrapper.vm.newPrefix).deep.equals({ prefix: '', uri: '' }) + expect(toast.notification.mock.calls.length).equals(1) + }) + + it('rejects an invalid prefix client-side without calling the server', async () => { + const updatePrefix = vi.fn() + const { wrapper } = await mountWritable({ updatePrefix }) + await openAddForm(wrapper) + await wrapper.find('#add-prefix-name').setValue('1bad') + await wrapper.find('#add-prefix-uri').setValue('http://example.org/ns#') + await wrapper.find('#add-prefix-form').trigger('submit') + await flushPromises() + expect(updatePrefix.mock.calls.length).equals(0) + expect(wrapper.find('#add-prefix-name').classes()).contains('is-invalid') + expect(wrapper.find('#add-prefix-uri').classes()).contains('is-valid') + }) + + it('clears the validation error as the user corrects the field', async () => { + const updatePrefix = vi.fn() + const { wrapper } = await mountWritable({ updatePrefix }) + await openAddForm(wrapper) + await wrapper.find('#add-prefix-name').setValue('1bad') + await wrapper.find('#add-prefix-uri').setValue('http://example.org/ns#') + await wrapper.find('#add-prefix-form').trigger('submit') + await flushPromises() + expect(wrapper.find('#add-prefix-name').classes()).contains('is-invalid') + // Correcting the field updates the validation state without another submit. + await wrapper.find('#add-prefix-name').setValue('good') + expect(wrapper.find('#add-prefix-name').classes()).contains('is-valid') + expect(wrapper.find('#add-prefix-name').classes()).not.contains('is-invalid') + }) + + it('surfaces a server 400 and keeps the form contents', async () => { + const updatePrefix = vi.fn().mockRejectedValue({ + response: { status: 400, data: 'Invalid prefix' } + }) + const { wrapper, getPrefixes, toast } = await mountWritable({ updatePrefix }) + await openAddForm(wrapper) + await wrapper.find('#add-prefix-name').setValue('ex') + await wrapper.find('#add-prefix-uri').setValue('http://example.org/ns#') + await wrapper.find('#add-prefix-form').trigger('submit') + await flushPromises() + expect(toast.error.mock.calls).deep.equals([['Invalid prefix']]) + expect(wrapper.vm.newPrefix).deep.equals({ prefix: 'ex', uri: 'http://example.org/ns#' }) + expect(getPrefixes.mock.calls.length).equals(1) + expect(wrapper.vm.addingPrefix).equals(false) + }) + + it('ignores a second submit while a request is in flight', async () => { + const updatePrefix = vi.fn().mockReturnValue(new Promise(() => {})) + const { wrapper } = await mountWritable({ updatePrefix }) + wrapper.vm.newPrefix = { prefix: 'ex', uri: 'http://example.org/ns#' } + wrapper.vm.addPrefix() + wrapper.vm.addPrefix() + expect(updatePrefix.mock.calls.length).equals(1) + }) + + it('removes a prefix after popover confirmation and refetches', async () => { + const removePrefix = vi.fn().mockResolvedValue({}) + const { wrapper, getPrefixes, toast } = await mountWritable({ removePrefix }) + await wrapper.find('.remove-prefix').trigger('click') + await wrapper.find('div[role=popover] button.btn-primary').trigger('click') + await flushPromises() + const popover = Popover.instances[0] + expect(popover.hide.mock.calls.length).equals(1) + expect(popover.dispose.mock.calls.length).equals(1) + expect(removePrefix.mock.calls).deep.equals([ + [datasetName, 'updatePrefixes', 'foaf'] + ]) + expect(getPrefixes.mock.calls.length).equals(2) + expect(toast.notification.mock.calls.length).equals(1) + }) + + it('does not remove a prefix when the popover is cancelled', async () => { + const removePrefix = vi.fn() + const { wrapper, getPrefixes } = await mountWritable({ removePrefix }) + await wrapper.find('.remove-prefix').trigger('click') + await wrapper.find('div[role=popover] button.btn-secondary').trigger('click') + await flushPromises() + const popover = Popover.instances[0] + expect(popover.hide.mock.calls.length).equals(1) + expect(popover.dispose.mock.calls.length).equals(1) + expect(removePrefix.mock.calls.length).equals(0) + expect(getPrefixes.mock.calls.length).equals(1) + }) + }) + }) })