diff --git a/client/src/assets/css/NodeProperties.scss b/client/src/assets/css/NodeProperties.scss
index 7058d1c6..a480f2cd 100644
--- a/client/src/assets/css/NodeProperties.scss
+++ b/client/src/assets/css/NodeProperties.scss
@@ -39,3 +39,54 @@
margin-right: 2px;
}
}
+
+.node-properties {
+ .value-cell {
+ position: relative;
+
+ .value-text {
+ word-break: break-word;
+ }
+
+ input,
+ select {
+ width: calc(100% - 52px);
+ font-size: 13px;
+ padding: 1px 4px;
+ }
+
+ .row-actions {
+ float: right;
+ white-space: nowrap;
+ opacity: 0.35;
+ transition: opacity 100ms;
+ }
+
+ &:hover .row-actions {
+ opacity: 1;
+ }
+
+ .row-action {
+ border: none;
+ background: none;
+ padding: 0 3px;
+ cursor: pointer;
+ color: #666;
+ font-size: 12px;
+
+ &:hover {
+ color: #111;
+ }
+
+ &--danger {
+ color: #c00;
+ font-weight: 600;
+ }
+
+ &:disabled {
+ color: #ccc;
+ cursor: default;
+ }
+ }
+ }
+}
diff --git a/client/src/components/NodeProperties.js b/client/src/components/NodeProperties.js
index 048ad2b6..815aaf29 100644
--- a/client/src/components/NodeProperties.js
+++ b/client/src/components/NodeProperties.js
@@ -7,17 +7,201 @@ import React from 'react'
import Button from 'react-bootstrap/Button'
import Table from 'react-bootstrap/Table'
+import { executeQuery } from 'lib/helpers'
+import {
+ buildDeleteMutation,
+ buildSetMutation,
+ coerceValue,
+ isSafePredicate,
+} from 'lib/mutations'
+
import '../assets/css/NodeProperties.scss'
+const isEditable = (value) =>
+ ['string', 'number', 'boolean'].includes(typeof value)
+
export default function NodeProperties({ node, onCollapseNode, onExpandNode }) {
+ const [editingKey, setEditingKey] = React.useState(null)
+ const [draft, setDraft] = React.useState('')
+ const [confirmingDelete, setConfirmingDelete] = React.useState(null)
+ const [busy, setBusy] = React.useState(false)
+ const [error, setError] = React.useState(null)
+ const [savedAt, setSavedAt] = React.useState(null)
+ // Local bump to re-render after we mutate the (shared) node object.
+ const [, setVersion] = React.useState(0)
+
+ const [adding, setAdding] = React.useState(false)
+ const [newPred, setNewPred] = React.useState('')
+ const [newValue, setNewValue] = React.useState('')
+
+ React.useEffect(() => {
+ // Selected node changed - drop any in-progress edit state.
+ setEditingKey(null)
+ setConfirmingDelete(null)
+ setError(null)
+ setAdding(false)
+ }, [node])
+
if (!node) {
return null
}
const { attrs, facets } = node.properties
+ const runMutation = async (mutation, applyLocally) => {
+ setBusy(true)
+ setError(null)
+ try {
+ await executeQuery(mutation, { action: 'mutate' })
+ applyLocally()
+ setSavedAt(Date.now())
+ setEditingKey(null)
+ setConfirmingDelete(null)
+ setAdding(false)
+ setVersion((v) => v + 1)
+ } catch (e) {
+ setError(e?.errors?.[0]?.message || e?.message || 'Mutation failed')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleSave = (key) => {
+ let value
+ try {
+ value = coerceValue(draft, attrs[key])
+ } catch (e) {
+ setError(e.message)
+ return
+ }
+ runMutation(buildSetMutation(node.uid, key, value), () => {
+ attrs[key] = value
+ })
+ }
+
+ const handleDelete = (key) => {
+ runMutation(buildDeleteMutation(node.uid, key, attrs[key]), () => {
+ delete attrs[key]
+ })
+ }
+
+ const handleAdd = () => {
+ const pred = newPred.trim()
+ if (!isSafePredicate(pred)) {
+ setError(`"${pred}" is not a valid predicate name`)
+ return
+ }
+ runMutation(buildSetMutation(node.uid, pred, newValue), () => {
+ attrs[pred] = newValue
+ setNewPred('')
+ setNewValue('')
+ })
+ }
+
+ const renderValueCell = (key) => {
+ const value = attrs[key]
+
+ if (editingKey !== key) {
+ return (
+
+ {JSON.stringify(value)}
+ {node.uid && isEditable(value) && (
+
+
+ {confirmingDelete === key ? (
+
+ ) : (
+
+ )}
+
+ )}
+ |
+ )
+ }
+
+ return (
+
+ {typeof value === 'boolean' ? (
+
+ ) : (
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleSave(key)
+ }
+ if (e.key === 'Escape') {
+ setEditingKey(null)
+ }
+ }}
+ />
+ )}
+
+
+
+
+ |
+ )
+ }
+
return (
-
+
+ {error &&
{error}
}
+
+ {node.uid && !adding && (
+
+ )}
+ {savedAt && Date.now() - savedAt < 4000 && (
+
✓ saved
+ )}
+
{facets && Object.keys(facets).length ? (
diff --git a/client/src/lib/mutations.js b/client/src/lib/mutations.js
new file mode 100644
index 00000000..f4027d1d
--- /dev/null
+++ b/client/src/lib/mutations.js
@@ -0,0 +1,83 @@
+/*
+ * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// Builders for single-triple N-Quad mutations used by inline editing.
+
+export function escapeRdfString(value) {
+ return String(value)
+ .replace(/\\/g, '\\\\')
+ .replace(/"/g, '\\"')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '\\r')
+ .replace(/\t/g, '\\t')
+}
+
+// Predicates go inside <...>; reject anything that could break out.
+export function isSafePredicate(predicate) {
+ return (
+ typeof predicate === 'string' &&
+ predicate.length > 0 &&
+ !/[<>"{}|^`\\\s]/.test(predicate)
+ )
+}
+
+export function valueToRdfLiteral(value) {
+ if (typeof value === 'boolean') {
+ return `"${value}"^^`
+ }
+ if (typeof value === 'number') {
+ if (Number.isInteger(value)) {
+ return `"${value}"^^`
+ }
+ return `"${value}"^^`
+ }
+ return `"${escapeRdfString(value)}"`
+}
+
+const assertSafe = (uid, predicate) => {
+ if (!uid || !/^0x[0-9a-fA-F]+$/.test(String(uid))) {
+ throw new Error(`Invalid uid: ${uid}`)
+ }
+ if (!isSafePredicate(predicate)) {
+ throw new Error(`Invalid predicate: ${predicate}`)
+ }
+}
+
+export function buildSetMutation(uid, predicate, value) {
+ assertSafe(uid, predicate)
+ return `{
+ set {
+ <${uid}> <${predicate}> ${valueToRdfLiteral(value)} .
+ }
+}`
+}
+
+// Deletes one value when given, or every value of the predicate when
+// value is undefined.
+export function buildDeleteMutation(uid, predicate, value) {
+ assertSafe(uid, predicate)
+ const object = value === undefined ? '*' : valueToRdfLiteral(value)
+ return `{
+ delete {
+ <${uid}> <${predicate}> ${object} .
+ }
+}`
+}
+
+// Coerces the user's input string back to the original value's type so
+// edits don't silently change int -> string etc.
+export function coerceValue(input, originalValue) {
+ if (typeof originalValue === 'boolean') {
+ return input === 'true' || input === true
+ }
+ if (typeof originalValue === 'number') {
+ const n = Number(input)
+ if (!Number.isFinite(n)) {
+ throw new Error(`"${input}" is not a number`)
+ }
+ return n
+ }
+ return String(input)
+}
diff --git a/client/src/lib/mutations.test.js b/client/src/lib/mutations.test.js
new file mode 100644
index 00000000..4338f192
--- /dev/null
+++ b/client/src/lib/mutations.test.js
@@ -0,0 +1,90 @@
+/*
+ * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ buildDeleteMutation,
+ buildSetMutation,
+ coerceValue,
+ escapeRdfString,
+ isSafePredicate,
+ valueToRdfLiteral,
+} from './mutations'
+
+describe('escapeRdfString', () => {
+ it('escapes quotes, backslashes and control characters', () => {
+ expect(escapeRdfString('say "hi"\\n')).toBe('say \\"hi\\"\\\\n')
+ expect(escapeRdfString('line1\nline2\ttab')).toBe('line1\\nline2\\ttab')
+ })
+})
+
+describe('isSafePredicate', () => {
+ it('accepts normal predicates', () => {
+ expect(isSafePredicate('name')).toBe(true)
+ expect(isSafePredicate('person.name')).toBe(true)
+ expect(isSafePredicate('my_pred-2')).toBe(true)
+ })
+
+ it('rejects injection attempts and whitespace', () => {
+ expect(isSafePredicate('a> <0x1> {
+ it('types booleans, ints and floats', () => {
+ expect(valueToRdfLiteral(true)).toBe('"true"^^')
+ expect(valueToRdfLiteral(5)).toBe('"5"^^')
+ expect(valueToRdfLiteral(2.5)).toBe('"2.5"^^')
+ })
+
+ it('escapes strings', () => {
+ expect(valueToRdfLiteral('a "b"')).toBe('"a \\"b\\""')
+ })
+})
+
+describe('buildSetMutation', () => {
+ it('builds a single-triple set', () => {
+ const m = buildSetMutation('0x12af', 'name', 'Alice')
+ expect(m).toContain('set {')
+ expect(m).toContain('<0x12af> "Alice" .')
+ })
+
+ it('rejects bad uids and predicates', () => {
+ expect(() => buildSetMutation('not-a-uid', 'name', 'x')).toThrow(
+ 'Invalid uid',
+ )
+ expect(() => buildSetMutation('0x1', 'a> {
+ it('deletes a specific value', () => {
+ expect(buildDeleteMutation('0x1', 'name', 'Alice')).toContain(
+ '<0x1> "Alice" .',
+ )
+ })
+
+ it('deletes all values with *', () => {
+ expect(buildDeleteMutation('0x1', 'name')).toContain('<0x1> * .')
+ })
+})
+
+describe('coerceValue', () => {
+ it('keeps the original type', () => {
+ expect(coerceValue('42', 7)).toBe(42)
+ expect(coerceValue('2.5', 1.0)).toBe(2.5)
+ expect(coerceValue('true', false)).toBe(true)
+ expect(coerceValue('false', true)).toBe(false)
+ expect(coerceValue('hello', 'old')).toBe('hello')
+ })
+
+ it('throws on non-numeric input for numeric fields', () => {
+ expect(() => coerceValue('abc', 5)).toThrow('not a number')
+ })
+})