tunablestunables version 0.2.0

Screens

Vue composables

tunables/vue holds what a form, a history or a catalog screen needs, with no markup: state, drafts, problems and the calls that change things. The Nuxt layer's screens are built on these; build your own on them when you use another design system, or Vue without Nuxt.

Providing a client

Every composable calls tunables through the client provided above it. Provide one once, in your root component:

import { fetchClient } from 'tunables/client'
import { provideTunables } from 'tunables/vue'

provideTunables(fetchClient({ baseURL: '/api/tunables' }))

Or at the app level, with the injection key:

import { createApp } from 'vue'
import { fetchClient } from 'tunables/client'
import { TUNABLES_CLIENT } from 'tunables/vue'

createApp(App).provide(TUNABLES_CLIENT, fetchClient()).mount('#app')

fetchClient takes a baseURL (/api/tunables by default), a fetch of your own, headers for every request (an object or a function, such as one that adds a CSRF token) and credentials (same-origin by default). orpcClient does the same over an oRPC client; see The Nuxt layer.

The client's group(id, scope?), save(id, { values, scope? }) and reset(id, { keys?, scope? }) take a scope path too, for code that calls it directly.

A request the server refused or could not answer throws a TunablesClientError with its status. A refused change does not throw: it comes back with its problems.

useGroupForm

One group's form.

<script setup lang="ts">
import { useGroupForm, useUnloadGuard } from 'tunables/vue'

const form = useGroupForm('limits')
useUnloadGuard(form.dirty)
</script>

<template>
  <form v-if="form.group.value" @submit.prevent="form.save()">
    <label v-for="field in form.group.value.fields" :key="field.key">
      {{ field.label }}
      <input v-model.number="form.draft[field.key]" type="number">
      <small v-for="problem in form.fieldProblems(field.key)" :key="problem">{{ problem }}</small>
    </label>
    <input v-model="form.reason.value" placeholder="Why">
    <button :disabled="!form.dirty.value || form.saving.value">
      Save
    </button>
    <p v-if="form.confirm.value">
      {{ form.confirm.value }}
      <button type="button" @click="form.confirmAndSave()">
        Save anyway
      </button>
    </p>
  </form>
</template>
ReturnsWhat it is
groupThe group's description: title, sections, fields with labels, help, units and defaults.
entryThe stored entry.
draftA reactive copy of the values, to bind inputs to.
dirty, dirtyKeysWhether, and which, fields differ from what is stored.
problems, fieldProblems(key), generalProblemsWhat the last save answered, all, per field, or about the whole change.
confirmThe question the last save asked, if any.
reasonThe reason to send with the save.
save(), confirmAndSave(), cancelConfirm()Save the changed fields; send them again confirmed; drop the question.
isDefault(field), toDefault(field), discard()Check a field against its default; put its draft back to it; drop every unsaved change.
loading, saving, error, lastSaved, refresh()State, the last outcome, and a reload.

save sends only the fields that changed, and resolves to whether they were saved.

At a scope

Pass a scope to edit that scope's own values, such as a workspace's owner on their workspace's page:

const form = useGroupForm('limits', { scope: { workspace: 'acme' } })

scope may be a ref; the form reloads when it changes. The entry then carries layers, and the form adds:

ReturnsWhat it is
scopeThe path, or undefined for the whole system.
editableThe fields this scope may set. Show only these.
inheritingThe fields that will use the inherited value again on save.
useInherited(key)Stop setting a field here: its draft goes back to what it inherits, and the save sends null.
setHere(key)Set a field here again, after useInherited. Call it when the person edits the field.

entry.value.layers[key] says, per field, what it inherits (inherited, inheritedDisplay), from where (fromLabel, such as Pro plan), whether this scope sets its own (overridden), and its bound (rule: free, at-most or at-least).

The others

ComposableGives
useTunablesDescription()description of every group, preset set and catalog, fetched once per client; error, pending, refresh().
useOverview()entries of every group, for All settings; loading, error, refresh().
useHistory(target, { pageSize })revisions newest first, more, loadMore(), refresh(), and restore(revision, { reason, confirmed }). For a scope's history, the target's id is the scope key: { kind: 'group', name: 'limits', id: 'workspace=acme' }.
usePresets(set)presets with how many are assigned each; create, update, remove, makeDefault, assign, assignments.
useCatalog(catalog)entries with what uses each, available; add, edit, retire, reinstate.
useUnloadGuard(dirty)Asks before the page unloads while dirty is true. Add your router's own leave guard beside it.
useTunablesClient()The provided client, for anything the composables do not cover.

Each composable that takes an id also takes a ref, and reloads when it changes. Each change resolves to its outcome and refreshes the list when it was saved.

displayValue(field, value) reads a value the way the server does, from the field's description, for a value still being edited.