Creating an interim TJSDocument / document attribute bridge

Hi @wasp... Just moving discussion here as it is a better forum post.
So I have made a system with TRL for my friend's homebrew game system, and I'm working on getting document updates to work. Binding values of inputs directly to the TJSDocument isn't yet supported, so this is what I cooked up thus far:
<script>

import { getContext } from "svelte";
import { get } from "svelte/store";

const appState = getContext("ApplicationStateStore");
const document = getContext("DocumentStore");
const item = get(document);
let currentState = item.toObject();
delete currentState['_id'];

function updateDocument() {
item.update(currentState);
}

</script>

<div>

<div class="form-control">
<label>Category</label>
<input bind:value={currentState.system.category} on:change={updateDocument} type="text"/>
</div>

</div>
<script>

import { getContext } from "svelte";
import { get } from "svelte/store";

const appState = getContext("ApplicationStateStore");
const document = getContext("DocumentStore");
const item = get(document);
let currentState = item.toObject();
delete currentState['_id'];

function updateDocument() {
item.update(currentState);
}

</script>

<div>

<div class="form-control">
<label>Category</label>
<input bind:value={currentState.system.category} on:change={updateDocument} type="text"/>
</div>

</div>
Is this in line what is expected, pretty much?
10 Replies
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
It might be a good starter to review the TJSDocument code: https://github.com/typhonjs-fvtt-lib/svelte/blob/main/src/store/document/TJSDocument.js It is a custom store, so you can directly access attributes from the TJSDocument instance itself and there is no reason to use get from svelte/store. In general, except for the very rare case usage of get is an anti-pattern and one that can be avoided with custom stores. It has limited use for when you directly use writable or a basic Svelte store, but even then is a sign of code that can be revised. Another aspect to consider is being careful in naming variables. By using const document you override the browser document reference, so that is something to just keep in mind if you ever need to use it inside of a Svelte component, so it is generally recommended to use doc or some other variable name for the TJSDocument instance. Another aspect to consider with the above approach is perhaps debouncing the updateDocument function so that not every single change hits the underlying update cycle to the Foundry document. Your code rewritten with the initial changes without the debounce aspect will look like this:
<script>

import { getContext } from "svelte";
import { get } from "svelte/store";

const appState = getContext("ApplicationStateStore");
const doc = getContext("DocumentStore");

// TODO: We will be revising this next.
function updateDocument(event) {
$doc.update({ 'system.category': event.target.value });
}

</script>

<div>

<div class="form-control">
<label>Category</label>
<input bind:value={$doc.system.category} on:change={updateDocument} type="text"/>
</div>

</div>
<script>

import { getContext } from "svelte";
import { get } from "svelte/store";

const appState = getContext("ApplicationStateStore");
const doc = getContext("DocumentStore");

// TODO: We will be revising this next.
function updateDocument(event) {
$doc.update({ 'system.category': event.target.value });
}

</script>

<div>

<div class="form-control">
<label>Category</label>
<input bind:value={$doc.system.category} on:change={updateDocument} type="text"/>
</div>

</div>
Wasp
Wasp•2y ago
Thanks, very useful!
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
I'm writing a Svelte action that does some fancy stuff for updating that I'll post in just a bit. Not exactly the solution I'll be employing in TRL to handle all of this, but a useful interim.
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
OK... Here is a basic change event Svelte action that can be reused across input / select / text area elements. Granted I haven't tested it thoroughly and there is always more one can do. Read up on change event as it only fires at certain points like an input element losing focus or Enter key pressed of standard input elements. This is what Foundry uses though as well for standard sheets. This is a good opportunity to learn about Svelte actions. https://svelte.dev/tutorial/actions
import { TJSDocument } from '@typhonjs-fvtt/runtime/svelte/store';

import { safeAccess } from '@typhonjs-fvtt/runtime/svelte/util';

/**
* Provides a basic action to update a Foundry document on change events.
*
* @param {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} node - Target input element.
*
* @param {{ doc: TJSDocument, accessor: string }} options - Options
*
* @returns {import('svelte/action').ActionReturn} Action lifecycle methods.
*/
export function updateDoc(node, { doc, accessor } = {})
{
if (!(node instanceof HTMLInputElement) && !(node instanceof HTMLSelectElement) &&
!(node instanceof HTMLTextAreaElement))
{
throw new TypeError(
`updateDoc error: 'node' must be an instance of HTMLInputElement, HTMLSelectElement, or HTMLTextAreaElement.`);
}

if (!(doc instanceof TJSDocument))
{
throw new TypeError(`updateDoc error: 'doc' must be an instance of TJSDocument.`);
}

if (typeof accessor !== 'string')
{
throw new TypeError(`updateDoc error: 'accessor' must be a string.`);
}

let currentDocValue;

const unsubscribe = doc.subscribe(onDocChange);

/**
* Updates `doc` w/ current focused state.
*
* @param {Event} ev - Change event.
*/
function onChange(ev)
{
const document = doc.get();
if (!document)
{
console.warn('updateDoc.onChange warning: no associated document on change.');
return;
}

document.update({ [accessor]: ev.target.value });
}

/**
* @param {foundry.abstract.Document} docRef - Foundry document changing.
*/
function onDocChange(docRef)
{
if (!docRef)
{
console.warn('updateDoc.onDocChange warning: no associated document on change.');
return;
}

const newValue = safeAccess(docRef, accessor);
if (currentDocValue !== newValue)
{
currentDocValue = newValue;
node.value = currentDocValue;
}
}

/**
* Activate listeners.
*/
function activateListeners()
{
node.addEventListener('change', onChange);
}

/**
* Remove listeners.
*/
function removeListeners()
{
node.removeEventListener('change', onChange);
}

activateListeners();

return {
// Currently not implemented, but this is where you'd update the options for this action.
// IE changing the TJSDocument or accessor field.
update: () => {},

destroy: () =>
{
removeListeners();
unsubscribe();
}
};
}
import { TJSDocument } from '@typhonjs-fvtt/runtime/svelte/store';

import { safeAccess } from '@typhonjs-fvtt/runtime/svelte/util';

/**
* Provides a basic action to update a Foundry document on change events.
*
* @param {HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement} node - Target input element.
*
* @param {{ doc: TJSDocument, accessor: string }} options - Options
*
* @returns {import('svelte/action').ActionReturn} Action lifecycle methods.
*/
export function updateDoc(node, { doc, accessor } = {})
{
if (!(node instanceof HTMLInputElement) && !(node instanceof HTMLSelectElement) &&
!(node instanceof HTMLTextAreaElement))
{
throw new TypeError(
`updateDoc error: 'node' must be an instance of HTMLInputElement, HTMLSelectElement, or HTMLTextAreaElement.`);
}

if (!(doc instanceof TJSDocument))
{
throw new TypeError(`updateDoc error: 'doc' must be an instance of TJSDocument.`);
}

if (typeof accessor !== 'string')
{
throw new TypeError(`updateDoc error: 'accessor' must be a string.`);
}

let currentDocValue;

const unsubscribe = doc.subscribe(onDocChange);

/**
* Updates `doc` w/ current focused state.
*
* @param {Event} ev - Change event.
*/
function onChange(ev)
{
const document = doc.get();
if (!document)
{
console.warn('updateDoc.onChange warning: no associated document on change.');
return;
}

document.update({ [accessor]: ev.target.value });
}

/**
* @param {foundry.abstract.Document} docRef - Foundry document changing.
*/
function onDocChange(docRef)
{
if (!docRef)
{
console.warn('updateDoc.onDocChange warning: no associated document on change.');
return;
}

const newValue = safeAccess(docRef, accessor);
if (currentDocValue !== newValue)
{
currentDocValue = newValue;
node.value = currentDocValue;
}
}

/**
* Activate listeners.
*/
function activateListeners()
{
node.addEventListener('change', onChange);
}

/**
* Remove listeners.
*/
function removeListeners()
{
node.removeEventListener('change', onChange);
}

activateListeners();

return {
// Currently not implemented, but this is where you'd update the options for this action.
// IE changing the TJSDocument or accessor field.
update: () => {},

destroy: () =>
{
removeListeners();
unsubscribe();
}
};
}
Use it like this:
<script>
import { TJSDocument } from '@typhonjs-fvtt/runtime/svelte/store';
import { updateDoc } from './updateDoc.js';

// Basic TJSDocument setup: do something else specific in your use case.
const doc = new TJSDocument(game.items.get('<ID>'));
</script>

<input use:updateDoc={{ doc, accessor: 'name' }}>
<script>
import { TJSDocument } from '@typhonjs-fvtt/runtime/svelte/store';
import { updateDoc } from './updateDoc.js';

// Basic TJSDocument setup: do something else specific in your use case.
const doc = new TJSDocument(game.items.get('<ID>'));
</script>

<input use:updateDoc={{ doc, accessor: 'name' }}>
accessor should be a . separated string with the attribute field you are reading / updating. Here is a brief example with the "Hello World demo" modified like above:
Wasp
Wasp•2y ago
Look at that beauty Looks like 0.0.24 material to me 😄
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
It's certainly one way of handling document updates specifically around change events. I'm going to be hard pressed just to finish the API refactoring / API docs before 0.0.24 drops. I'm working remotely from mid-July through October and won't have as much time to work on TRL during that time though probably a little bit. This is one handy way to use a Svelte action though that can be repeatedly applied. The problem with getting that into 0.0.24 is that it should be thoroughly tested against a variety of input sources / checkboxes, etc. and I'm definitely not going to have time for that. Perhaps if you can give it a go and start working w/ this action and finding any corner cases it could possibly be included. In the future I'd like to get more of a reactive solution in place where on going input updates the underlying document as well instead of change events.
Wasp
Wasp•2y ago
I'll definitely test it out and see what edge cases emerge - my friend's system is entirely homebrew and made by him and his friends, so it's very low-stakes to work on, which is nice 😄
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
Very cool.. Yeah I'll point folks to this post as it'll likely be handy for others too. It should be noted that there is no handling at all for many edge cases like when the underlying document is deleted for instance. Ideally though as the developer you can handle that in other ways; all of this will be much smoother in a forthcoming TRL release.
Wasp
Wasp•2y ago
I implemented this pretty neatly, thanks for this! I added a check so that it can be used with checkboxes and such too:
let valueKey = "value";
switch(node.type){
case "text":
case "number":
valueKey = "value";
break;
case "checkbox":
valueKey = "checked";
break;
}
let valueKey = "value";
switch(node.type){
case "text":
case "number":
valueKey = "value";
break;
case "checkbox":
valueKey = "checked";
break;
}
so that the value can be gotten like this:
document.update({ [accessor]: ev.target[valueKey] });
document.update({ [accessor]: ev.target[valueKey] });
TyphonJS (Michael)
TyphonJS (Michael)OP•2y ago
Excellent. Yeah there will be a few tweaks for sure. The default case of the switch should also fall through to value as an input element without a type is a text input, but I suppose you don't have to add that as valueKey is "value" to begin with. Perhaps you just have to add the cases where it will change, etc. I assume that there also need to be some changes on how checkbox inputs are set from doc values in onDocChange. I will consider adding this action in a future TRL update, but I'd like to thoroughly evaluate all input element types. This action approach is useful when directly using forms / input elements to modify document contents. The 2nd half of the TJSDocument work and building managed stores for document attributes for "real time" reactive updates is definitely more involved, but will allow access to stores in a managed way similar to the reactive embedded collections API.
Want results from more Discord servers?
Add your server