From 77112a161d6a115bb40352324bc753ee006c1198 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 10:45:15 -0400 Subject: [PATCH 1/2] feat: inline editing of node values from the properties panel Turns Ratel from a read-only viewer into an editor for scalar values (the headline feature of tools like G.V()): - Each attribute row in the node properties panel gets edit and delete actions: edit opens an inline input (number input for numbers, true/false select for booleans), Enter/check saves, Escape cancels; delete is two-step (trash -> 'sure?'). - '+ Add value' row sets a new predicate on the node. - Saves run single-triple N-Quad mutations (commitNow) through the existing dgraph client; values are written with the original value's type (xs:int / xs:float / xs:boolean, escaped strings) so edits never silently retype a predicate. Errors from the server surface in the panel; successful edits update the in-memory node. - lib/mutations.js validates uids (hex) and predicate names (no angle brackets/whitespace - no N-Quad injection), escapes string literals, and is fully unit-tested (11 tests). Mutation output verified against a live Dgraph v25: set string with escaped quotes, set typed int, delete specific/all values - all accepted and the final state matches. Co-Authored-By: Claude Fable 5 --- client/src/assets/css/NodeProperties.scss | 51 +++++ client/src/components/NodeProperties.js | 251 +++++++++++++++++++++- client/src/lib/mutations.js | 83 +++++++ client/src/lib/mutations.test.js | 90 ++++++++ 4 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 client/src/lib/mutations.js create mode 100644 client/src/lib/mutations.test.js 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..1656a43e 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}
} + @@ -48,13 +234,74 @@ export default function NodeProperties({ node, onCollapseNode, onExpandNode }) { ? Object.keys(attrs).map((k) => ( - + {renderValueCell(k)} )) : null} + {adding && ( + + + + + )}
{k}{JSON.stringify(attrs[k])}
+ setNewPred(e.target.value)} + /> + + setNewValue(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + /> + + + + +
+ {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') + }) +}) From db157be882bd59fc808810c00ceba0adcc356602 Mon Sep 17 00:00:00 2001 From: Shaun Patterson Date: Fri, 12 Jun 2026 13:47:37 -0400 Subject: [PATCH 2/2] style: apply trunk formatting Co-Authored-By: Claude Fable 5 --- client/src/components/NodeProperties.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/NodeProperties.js b/client/src/components/NodeProperties.js index 1656a43e..815aaf29 100644 --- a/client/src/components/NodeProperties.js +++ b/client/src/components/NodeProperties.js @@ -220,7 +220,7 @@ export default function NodeProperties({ node, onCollapseNode, onExpandNode }) { - {error &&
{error}
} + {error &&
{error}
}
@@ -299,7 +299,7 @@ export default function NodeProperties({ node, onCollapseNode, onExpandNode }) { )} {savedAt && Date.now() - savedAt < 4000 && ( - ✓ saved + ✓ saved )} {facets && Object.keys(facets).length ? (