Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-only-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@matdata/yasgui": minor
---

Add read-only workspaces. A workspace with `readOnly: true` (or the **Read-only** checkbox in the workspace settings) can still be browsed and its queries opened, but it is never offered as a save target, and its queries cannot be updated, renamed, moved or deleted. Saving a query opened from a read-only workspace opens "Save as" so a copy can be stored in another workspace.
8 changes: 8 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,14 @@ This feature is designed for **power users** who want to:
3. Add one or more workspaces (Git or SPARQL) and choose a **Default workspace**
4. (Optional) Click **Validate access** to test the configuration and show how many managed queries are already saved in that workspace

**Read-only workspaces:** tick **Read-only** in a workspace's settings (or set `readOnly: true` when adding it through `persistentConfig.addOrUpdateWorkspace`) to protect a shared or published set of queries. Queries in a read-only workspace can be browsed and opened, but:

- the workspace is never offered as a save target, and is marked "(read-only)" in the Query Browser
- saving a query opened from it (`Ctrl+S`, the save button or the tab menu) opens **Save as**, so the changes can only be stored as a copy in another workspace
- rename, move and delete are not available in the Query Browser, and renaming the tab only changes the tab label

When every configured workspace is read-only, the save button is hidden.

Note: the recommended type of workspace is SPARQL. It is more feature rich (allows to save endpoints and descriptions) and is much faster. The Git based workspace uses HTTP calls to the Git provider API, several calls are required to read/write queries, which makes it slower.

##### SPARQL workspaces
Expand Down
28 changes: 26 additions & 2 deletions packages/yasgui/src/Tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { ManagedTabMetadata } from "./queryManagement/types";
import { hashQueryText } from "./queryManagement/textHash";
import SaveManagedQueryModal from "./queryManagement/SaveManagedQueryModal";
import { saveManagedQuery } from "./queryManagement/saveManagedQuery";
import { getWritableWorkspaces, isWorkspaceReadOnly } from "./queryManagement/readOnlyWorkspace";
import { getWorkspaceBackend } from "./queryManagement/backends/getWorkspaceBackend";
import { asWorkspaceBackendError } from "./queryManagement/backends/errors";
import { normalizeQueryFilename } from "./queryManagement/normalizeQueryFilename";
Expand Down Expand Up @@ -238,6 +239,12 @@ export class Tab extends EventEmitter {
return;
}

// A query from a read-only workspace can never be updated in place; offer to save a copy elsewhere.
if (isWorkspaceReadOnly(workspace)) {
await this.saveAsManagedQuery();
return;
}

const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });

const expectedVersionTag = (() => {
Expand Down Expand Up @@ -295,6 +302,11 @@ export class Tab extends EventEmitter {
}

public async saveAsManagedQuery(): Promise<void> {
if (getWritableWorkspaces(this.yasgui.persistentConfig.getWorkspaces()).length === 0) {
window.alert("There is no workspace to save to. Read-only workspaces cannot be saved to.");
return;
}

const modal = new SaveManagedQueryModal(this.yasgui);

const defaults = this.getDefaultSaveModalValues();
Expand Down Expand Up @@ -328,11 +340,17 @@ export class Tab extends EventEmitter {
window.alert("Selected workspace no longer exists");
return;
}
if (isWorkspaceReadOnly(workspace)) {
window.alert("This workspace is read-only");
return;
}

const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });
const meta = this.getManagedQueryMetadata();

const expectedVersionTag = (() => {
// A version tag only applies when saving over the same query in the same workspace.
if (meta?.workspaceId !== workspace.id) return undefined;
if (!meta?.lastSavedVersionRef) return undefined;
if (meta.backendType === "git") return (meta.lastSavedVersionRef as any)?.commitSha;
return (meta.lastSavedVersionRef as any)?.managedQueryVersionIri;
Expand Down Expand Up @@ -1036,6 +1054,12 @@ export class Tab extends EventEmitter {
return;
}

// Queries in a read-only workspace cannot be renamed; only the tab label changes.
if (isWorkspaceReadOnly(workspace)) {
this.setName(nextName);
return;
}

const backend = getWorkspaceBackend(workspace, { persistentConfig: this.yasgui.persistentConfig });

if (meta.backendType === "sparql") {
Expand Down Expand Up @@ -1396,8 +1420,8 @@ export class Tab extends EventEmitter {
private updateSaveButtonVisibility() {
if (!this.yasqe) return;
const workspaces = this.yasgui.persistentConfig.getWorkspaces();
const hasWorkspaces = workspaces && workspaces.length > 0;
this.yasqe.setSaveButtonVisible(hasWorkspaces);
const hasWritableWorkspaces = !!workspaces && getWritableWorkspaces(workspaces).length > 0;
this.yasqe.setSaveButtonVisible(hasWritableWorkspaces);
}

private initSaveManagedQueryIcon() {
Expand Down
8 changes: 6 additions & 2 deletions packages/yasgui/src/queryManagement/QueryBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getEndpointToAutoSwitch } from "./openManagedQuery";
import { hashQueryText } from "./textHash";
import type { BackendType, VersionRef, ManagedTabMetadata } from "./types";
import { normalizeQueryFilename } from "./normalizeQueryFilename";
import { isWorkspaceReadOnly } from "./readOnlyWorkspace";
import SaveManagedQueryModal from "./SaveManagedQueryModal";
import QueryHistoryModal from "./QueryHistoryModal";

Expand Down Expand Up @@ -288,7 +289,7 @@ export default class QueryBrowser {
for (const w of workspaces) {
const opt = document.createElement("option");
opt.value = w.id;
opt.textContent = w.label;
opt.textContent = isWorkspaceReadOnly(w) ? `${w.label} (read-only)` : w.label;
this.workspaceSelectEl.appendChild(opt);
}

Expand Down Expand Up @@ -1379,7 +1380,10 @@ export default class QueryBrowser {
`folders:${foldersPart}`,
].join(";");

this.setStatus(rootEntries.length ? "" : "No queries, add one by saving a tab to this workspace.");
const emptyStatus = isWorkspaceReadOnly(workspace)
? "No queries in this read-only workspace."
: "No queries, add one by saving a tab to this workspace.";
this.setStatus(rootEntries.length ? "" : emptyStatus);
if (signature !== this.lastRenderedSignature) {
this.lastRenderedSignature = signature;
this.renderTree(backend);
Expand Down
8 changes: 6 additions & 2 deletions packages/yasgui/src/queryManagement/SaveManagedQueryModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type Yasgui from "../index";
import { addClass, removeClass } from "@matdata/yasgui-utils";
import { getWorkspaceBackend } from "./backends/getWorkspaceBackend";
import { normalizeQueryFilename } from "./normalizeQueryFilename";
import { getWritableWorkspaces } from "./readOnlyWorkspace";
import type { FolderEntry, WorkspaceConfig } from "./types";

import "./SaveManagedQueryModal.scss";
Expand Down Expand Up @@ -466,9 +467,12 @@ export default class SaveManagedQueryModal {
}

public async show(defaults?: Partial<SaveManagedQueryModalResult>): Promise<SaveManagedQueryModalResult> {
const workspaces = this.yasgui.persistentConfig.getWorkspaces();
// Read-only workspaces are never offered as a save target.
const workspaces = getWritableWorkspaces(this.yasgui.persistentConfig.getWorkspaces());
const isWritable = (id: string | undefined) => !!id && workspaces.some((w) => w.id === id);
const activeWorkspaceId = this.yasgui.persistentConfig.getActiveWorkspaceId();
const selectedWorkspaceId = defaults?.workspaceId || activeWorkspaceId || (workspaces[0]?.id ?? "");
const selectedWorkspaceId =
[defaults?.workspaceId, activeWorkspaceId].find(isWritable) || (workspaces[0]?.id ?? "");

const orderedWorkspaces = selectedWorkspaceId
? [
Expand Down
17 changes: 15 additions & 2 deletions packages/yasgui/src/queryManagement/WorkspaceSettingsForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ ORDER BY ?workspace`;

const nameEl = document.createElement("div");
addClass(nameEl, "workspaceListLabel");
nameEl.textContent = workspace.label || workspace.id;
nameEl.textContent = (workspace.label || workspace.id) + (workspace.readOnly ? " (read-only)" : "");
row.appendChild(nameEl);

const actions = document.createElement("div");
Expand Down Expand Up @@ -388,6 +388,18 @@ ORDER BY ?workspace`;
descriptionInput.value = existing?.description || "";
addClass(descriptionInput, "settingsInput");

const readOnlyContainer = document.createElement("div");
addClass(readOnlyContainer, "checkboxContainer");
const readOnlyInput = document.createElement("input");
readOnlyInput.type = "checkbox";
readOnlyInput.id = "workspaceReadOnly";
readOnlyInput.checked = !!existing?.readOnly;
const readOnlyLabel = document.createElement("label");
readOnlyLabel.htmlFor = "workspaceReadOnly";
readOnlyLabel.textContent = "Read-only (queries can be opened, but never saved to this workspace)";
readOnlyContainer.appendChild(readOnlyInput);
readOnlyContainer.appendChild(readOnlyLabel);

const dynamic = document.createElement("div");

const renderDynamic = () => {
Expand Down Expand Up @@ -700,7 +712,7 @@ ORDER BY ?workspace`;
status.textContent = "";
const getConfig = (dynamic as any).__getConfig as undefined | (() => WorkspaceConfig);
if (!getConfig) return;
const config = getConfig();
const config: WorkspaceConfig = { ...getConfig(), readOnly: readOnlyInput.checked || undefined };

if (config.type === "git" && !isNonEmpty(config.auth.token)) {
status.textContent = "Git token is required.";
Expand Down Expand Up @@ -728,6 +740,7 @@ ORDER BY ?workspace`;
body.appendChild(this.wrapField("Label", labelInput));
body.appendChild(this.wrapField("Description", descriptionInput));
body.appendChild(dynamic);
body.appendChild(readOnlyContainer);
body.appendChild(status);

modal.appendChild(header);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { FolderEntry, ReadResult, VersionInfo, WriteQueryOptions } from "../types";
import type { WorkspaceBackend } from "./WorkspaceBackend";
import { WorkspaceBackendError } from "./errors";

/**
* Wraps a backend so that only read operations are exposed.
* Optional write operations (rename/move/delete) are left undefined so the UI hides them,
* and `writeQuery` always rejects.
*/
export default class ReadOnlyWorkspaceBackend implements WorkspaceBackend {
public readonly type;

constructor(private readonly inner: WorkspaceBackend) {
this.type = inner.type;
if (inner.searchByName) this.searchByName = (query) => inner.searchByName!(query);
if (inner.getQueryUri) this.getQueryUri = (queryId) => inner.getQueryUri!(queryId);
}

searchByName?: (query: string) => Promise<FolderEntry[]>;

getQueryUri?: (queryId: string) => string | undefined;

validateAccess(): Promise<void> {
return this.inner.validateAccess();
}

listFolder(folderId?: string): Promise<FolderEntry[]> {
return this.inner.listFolder(folderId);
}

readQuery(queryId: string): Promise<ReadResult> {
return this.inner.readQuery(queryId);
}

listVersions(queryId: string): Promise<VersionInfo[]> {
return this.inner.listVersions(queryId);
}

readVersion(queryId: string, versionId: string): Promise<ReadResult> {
return this.inner.readVersion(queryId, versionId);
}

async writeQuery(_queryId: string, _queryText: string, _options?: WriteQueryOptions): Promise<void> {
throw new WorkspaceBackendError("FORBIDDEN", "This workspace is read-only");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { WorkspaceConfig } from "../types";
import type { WorkspaceBackend } from "./WorkspaceBackend";
import GitWorkspaceBackend from "./GitWorkspaceBackend";
import SparqlWorkspaceBackend from "./SparqlWorkspaceBackend";
import ReadOnlyWorkspaceBackend from "./ReadOnlyWorkspaceBackend";
import { GithubProviderClient } from "./GithubProviderClient";
import { GitlabProviderClient } from "./GitlabProviderClient";
import { BitbucketProviderClient } from "./BitbucketProviderClient";
Expand Down Expand Up @@ -106,6 +107,16 @@ export function getWorkspaceBackend(
options?: {
persistentConfig?: PersistentConfig;
},
): WorkspaceBackend {
const backend = createWorkspaceBackend(config, options);
return config.readOnly ? new ReadOnlyWorkspaceBackend(backend) : backend;
}

function createWorkspaceBackend(
config: WorkspaceConfig,
options?: {
persistentConfig?: PersistentConfig;
},
): WorkspaceBackend {
const registered = registeredBackends.get(config.id);
if (registered) return registered;
Expand Down
2 changes: 2 additions & 0 deletions packages/yasgui/src/queryManagement/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ export * from "./validateWorkspaceConfig";
export * from "./textHash";
export * from "./browserFilter";
export * from "./openManagedQuery";
export * from "./readOnlyWorkspace";
export * from "./backends/WorkspaceBackend";
export * from "./backends/errors";
export * from "./backends/getWorkspaceBackend";

export { default as InMemoryWorkspaceBackend } from "./backends/InMemoryWorkspaceBackend";
export { default as GitWorkspaceBackend } from "./backends/GitWorkspaceBackend";
export { default as SparqlWorkspaceBackend } from "./backends/SparqlWorkspaceBackend";
export { default as ReadOnlyWorkspaceBackend } from "./backends/ReadOnlyWorkspaceBackend";
9 changes: 9 additions & 0 deletions packages/yasgui/src/queryManagement/readOnlyWorkspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { WorkspaceConfig } from "./types";

export function isWorkspaceReadOnly(workspace: WorkspaceConfig | undefined): boolean {
return !!workspace?.readOnly;
}

export function getWritableWorkspaces(workspaces: WorkspaceConfig[]): WorkspaceConfig[] {
return workspaces.filter((w) => !isWorkspaceReadOnly(w));
}
5 changes: 5 additions & 0 deletions packages/yasgui/src/queryManagement/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export interface WorkspaceConfigBase {
label: string;
description?: string;
type: BackendType;
/**
* When true, queries can be browsed and opened from this workspace, but never saved, updated,
* renamed, moved or deleted in it. Edits to a query opened from it can only be saved to another workspace.
*/
readOnly?: boolean;
createdAt?: string;
updatedAt?: string;
}
Expand Down
74 changes: 74 additions & 0 deletions test/unit/query-management-read-only-workspace-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as chai from "chai";
import { describe, it } from "mocha";

import InMemoryWorkspaceBackend from "../../packages/yasgui/src/queryManagement/backends/InMemoryWorkspaceBackend.js";
import ReadOnlyWorkspaceBackend from "../../packages/yasgui/src/queryManagement/backends/ReadOnlyWorkspaceBackend.js";
import {
getWorkspaceBackend,
registerWorkspaceBackend,
unregisterWorkspaceBackend,
} from "../../packages/yasgui/src/queryManagement/backends/getWorkspaceBackend.js";
import {
getWritableWorkspaces,
isWorkspaceReadOnly,
} from "../../packages/yasgui/src/queryManagement/readOnlyWorkspace.js";
import type { WorkspaceBackend } from "../../packages/yasgui/src/queryManagement/backends/WorkspaceBackend.js";
import type { SparqlWorkspaceConfig } from "../../packages/yasgui/src/queryManagement/types.js";

const expect = chai.expect;

function sparqlWorkspace(id: string, readOnly?: boolean): SparqlWorkspaceConfig {
return { id, label: id, type: "sparql", endpoint: "https://example.org/sparql", workspaceIri: `urn:${id}`, readOnly };
}

describe("Query management - read-only workspaces", () => {
it("excludes read-only workspaces from save targets", () => {
const workspaces = [sparqlWorkspace("a"), sparqlWorkspace("demo", true), sparqlWorkspace("b", false)];

expect(isWorkspaceReadOnly(workspaces[1])).to.equal(true);
expect(isWorkspaceReadOnly(workspaces[2])).to.equal(false);
expect(getWritableWorkspaces(workspaces).map((w) => w.id)).to.deep.equal(["a", "b"]);
});

it("allows reading but rejects writes", async () => {
const inner = new InMemoryWorkspaceBackend();
await inner.writeQuery("folder/q.sparql", "SELECT 1 WHERE {}", { message: "v1" });
const backend = new ReadOnlyWorkspaceBackend(inner);

const entries = await backend.listFolder();
expect(entries.some((e) => e.kind === "folder" && e.id === "folder")).to.equal(true);
expect((await backend.readQuery("folder/q.sparql")).queryText).to.equal("SELECT 1 WHERE {}");

let error: any;
try {
await backend.writeQuery("folder/q.sparql", "SELECT 2 WHERE {}");
} catch (e) {
error = e;
}
expect(error?.code).to.equal("FORBIDDEN");
expect((await inner.readQuery("folder/q.sparql")).queryText).to.equal("SELECT 1 WHERE {}");
});

it("does not expose rename, move or delete", () => {
const backend: WorkspaceBackend = new ReadOnlyWorkspaceBackend(new InMemoryWorkspaceBackend());

expect(backend.renameQuery).to.equal(undefined);
expect(backend.moveQuery).to.equal(undefined);
expect(backend.deleteQuery).to.equal(undefined);
expect(backend.renameFolder).to.equal(undefined);
expect(backend.deleteFolder).to.equal(undefined);
});

it("wraps the backend of a read-only workspace", () => {
const inner = new InMemoryWorkspaceBackend();
registerWorkspaceBackend("ro", inner);
registerWorkspaceBackend("rw", inner);
try {
expect(getWorkspaceBackend(sparqlWorkspace("ro", true))).to.be.instanceOf(ReadOnlyWorkspaceBackend);
expect(getWorkspaceBackend(sparqlWorkspace("rw"))).to.equal(inner);
} finally {
unregisterWorkspaceBackend("ro");
unregisterWorkspaceBackend("rw");
}
});
});
Loading