Skip to content

Edit rows with a working set

A working set holds the rows a form is editing, the changes made to them, and the one call that writes them. This page builds a form on one. qlive-test’s src/app/bar/Edit.tsx is the same form as working code, and what the merge does behind it is MergeService.

The server side is Enable merging for a type – a type without a version column is editable here and merges like a type nobody else is editing.

A query whose rows are to be edited selects id and version on every row, and id on the rows of a many-to-many field:

query Q_Bar($config: QueryConfig!) {
queryBarDocument(config: $config) {
type
config
rows {
id name num description version
bazes { id name }
}
}
}

version is the base every write of that row is held to; the id of each baz is what an association names. A row of a versioned type that arrives without its version registers like any other and refuses the write that would need one, naming the query that read it – so this is a mistake you hear about, not one that loses an update.

const bars: Q_BarResult = useInjection(Q_Bar);
const [ws] = useState(() => {
const set = new WorkingSet();
set.register(bars);
return set;
});

The working set is made outside React and lives as long as the editing does. register() walks the document and remembers what every row looked like and which version it was read at – anything with an id is an entity, whatever type it is and however deep it sits, so registering the document a view renders registers everything that view can edit.

Registering once, in the initializer, is deliberate: a view that rendered before its rows were registered could not edit them, and a merge that lands refreshes the documents and registers what comes back by itself.

const {dirty, conflicts, view, merge, undo, setView} = useWorkingSet(ws, {watch: true});

A working set is a store like a query document, so this is the same three lines useInjection() is: the component re-renders whenever anything changes – a field typed into a draft, a row created, a merge coming back with conflicts. dirty drives the save and undo buttons; conflicts is empty until a merge comes back with some.

watch is live updates below, and optional.

const bar = ws.edit(row);
<input value={bar.name ?? ""} onChange={e => { bar.name = e.target.value; }}/>

edit() returns the draft of a row: the same row with every change made to it so far, and writes to it recorded rather than applied. The row itself is never touched.

A draft is read rather than kept – hold the row, call edit() on every render. It is not the row (draft !== row), there is one per row however many components ask for it, and handing a draft back in returns it unchanged, so calling it twice is free.

An input hands over a string whatever the field is, and a working set records the value it is given. Convert on the way in where the field is not a string:

onChange={e => { bar.num = Number(e.target.value); }}
const bar = ws.edit(row);
const merge = useMerge(bar);
for (const name of fields)
{
const field = merge.field(name);
// <input className={field.className} .../>
}

One hook per row, not per field. A hook cannot be called from a loop over a field list, so what useMerge() returns is an accessor: field(name) is an ordinary function a generic renderer calls as often as it likes, in a loop, in a callback, or in a child component it was handed. merge.of(row) reaches another row of the same working set without a second hook.

field.className goes on the input and carries the status:

status
unchanged nobody touched it since the row was read
changed the user changed it and nobody else did
conflict both changed it, and the user’s value is the one standing
resolved both changed it and the user looked at it and chose
remoteChanged somebody else changed it and the user did not, so their value was taken

The accessor also answers the question in the other direction – changedFields(), conflictedFields(), resolvedFields(), remoteChangedFields() – for a summary line or a check of whether anything is left to decide.

A many-to-many field is set like any other field and means something else: it says which rows this one is associated with, and the merge turns the difference into associations gained and lost.

bar.bazes = checked
? [...bar.bazes, baz]
: bar.bazes.filter(associated => associated.id !== baz.id);

The rows in it can come from any query, or from ws.create(). Nothing here writes a Baz, and nothing here knows the link table: an association is the pair of rows, so it needs neither a link row’s id nor a version, not even the bar’s – a view that only edits associations can leave version out. Nor can an association conflict. Adding one somebody else just added, or removing one somebody else just removed, leaves the database as asked.

A field whose link table needs values of its own besides the two foreign keys is read-only, and writing it throws. Edit the link rows instead.

const fresh = ws.create("Bar", {name: "New bar"});
ws.delete(row);

create() generates the id here rather than in the database, so that new rows can refer to each other before the server has seen any of them – a new Bar and a new Baz associated with it go over in one merge. What comes back is a draft like any other.

delete() marks a row for deletion; a row that was only ever created here is dropped instead, there being nothing to delete and nothing to tell the server about.

<button disabled={!dirty} onClick={() => merge()}>Save</button>

merge() writes everything the working set holds – every change and every deletion, in one transaction or none of them.

It landed. The changes are gone and the registered documents run their query again. A version left standing in a document that stayed on screen would fail the next edit, so refreshing is part of a merge that landed rather than the application’s chore.

It did not. Nothing was written. The user’s changes are all still here, the fields that clashed are marked, and every conflicted row now stands against the version that is in the database – so saving again writes the user’s values over the other ones. That second save is deliberately theirs to make: a working set never re-sends by itself.

A conflict comes back already standing as the user’s value, because the person present typed it on purpose and the person who did not is not here to argue. Resolving is a correction, and this is what offers it:

field.status === "conflict" && field.storedKnown && (
<>
<button onClick={() => field.resolve("mine")}>yours: {String(field.mine)}</button>
<button onClick={() => field.resolve("stored")}>saved: {String(field.stored)}</button>
</>
)

mine is what the user has, stored is what is in the database as far as this working set has been told. Choosing stored leaves the other value in place and does not throw the user’s away, so choosing mine afterwards brings it back. resolveWith(value) takes a third value that is neither – separate from resolve() rather than an overload of it, because the choices are strings and a string field could not tell resolve("stored") from a user meaning to store the word.

Ask storedKnown first. It is false where the field is known to have changed and not known to what: a push message carries a mask and no values, and a type that did not declare resolveConflicts carries none either. Labeling the read value “saved” there names it as something it is not, so show the marking and leave the buttons out:

field.status === "conflict" && !field.storedKnown && (
<span>somebody else changed this too</span>
)

merge.gone is the other end of it: the row was deleted while the user was editing. There is nothing to merge into and no field of it carries a conflict.

setView("mine"); // what the user typed
setView("stored"); // what is in the database
setView("merged"); // the two folded together -- the default

One flag for the whole working set, and no input has to know about it: a draft read is what returns the value, so the same form renders all three. merged is the one a form wants – the user’s edits, with the fields somebody else changed and this user has no opinion about taken silently.

undo() takes every change back, leaving the rows as they were registered. Conflicts go with them, and so do the decisions taken about them: they are all about a write that no longer exists. What stays is what the working set was told about the database – a field somebody else changed is still changed, and the form still shows it as such. That is knowledge rather than unsaved work.

clear() drops everything, the registered documents included. After it, nothing is being edited.

useWorkingSet(ws, {watch: true});

With the flag on, other people’s writes to the rows this working set holds land in it as they happen: a field somebody else changed takes the status it would have taken at save time, so the user sees it while they are still typing. Nothing else changes – the merge is still what detects a conflict and still the authority on the version – so a working set read without it works and finds out at save time.

A view that sets this must not also call useDocumentWatch() on the document these rows came from. That is the same news twice, and acting on the document half means update(), which swaps out the row objects the drafts are standing on.

ws.accessor(row) is the plain call under useMerge(), for a form library binding to it, a test, or a headless check. ws.raw(row) gives the current values of a draft as a plain object, for anything that wants a value rather than a draft. mergeWorkingSet(changes, deletions, config) is the write without a working set around it, for a service with nobody in front of it – and new WorkingSet({conflictValues: false}) is the same caller declining a copy of the other user’s values it has no use for.