Skip to content

yrby-forms: collaborative form fields (gem + npm elements) - #73

Open
jpcamara wants to merge 1 commit into
mainfrom
feat/yrby-forms
Open

yrby-forms: collaborative form fields (gem + npm elements)#73
jpcamara wants to merge 1 commit into
mainfrom
feat/yrby-forms

Conversation

@jpcamara

Copy link
Copy Markdown
Owner

Adds a third gem, yrby-forms, and a second npm package, packages/forms (npm yrby-forms): plain Rails form fields that collaborate over one shared document per record, with the values materialized back into the columns.

The gem

  • has_collaborative_fields :status, :priority, :summary, :description declares which attributes collaborate. Tiers resolve per attribute: Rails enums are LWW, :string/:text columns are text tier (Y.Text), everything else is LWW. text:/lww: kwargs force a tier and win over detection; an unknown attribute raises at declaration; encrypted: true stores CRDT state through Y::EncryptedDocument.
  • One Y::Document named "fields" per record for the whole set (the Y::Document.for association pattern): LWW entries live in a "fields" map share, each text-tier field in a "fields/<name>" Y.Text share.
  • refresh_collaborative_fields reloads the document under the record lock, reads it (read_map/read_text), assigns the declared fields, and saves with validate: false — false when the document has no state. LWW values cast through the attribute types. Undeclared map keys never reach assign_attributes: the map is client-written, so this filter is the line between collaboration and mass assignment.
  • Form helpers: form.collaborative_fields renders the <collaborative-form> container (channel name, signed GlobalID scoped to Y::Forms.sgid_purpose, doc key, presence identity from Y::Forms.identity); form.collaborative_field :status wraps the stock input for the attribute — or a block-supplied one — in <collaborative-field name tier>.
  • yrby_forms:install generates a FormFieldsChannel (signed-GlobalID locate, deny-by-default authorized?, storage through the record's document, refresh after every append) and invokes yrby:tables.

The npm package

Two custom elements, no framework, same tsc dual-build + node --test tooling as packages/client:

  • <collaborative-form> builds the consumer (createConsumer, or setConsumer / a consumer property for AnyCable), a Y.Doc, and yrby-client's ActionCableProvider from its attributes, and reflects the provider status into its status attribute.
  • <collaborative-field> binds the one control inside it by tier. LWW: map entry ↔ control value on input/change, remote-echo-safe via transaction origins, checkboxes as booleans. Text: a minimal diff-splice binding (common prefix/suffix → one delete + one insert) between the control and the Y.Text, keeping the local caret across remote splices.
  • Presence: awareness carries { name, color, field } set on focus/blur; a field a remote peer is in shows their color as an outline and their name as a chip. One injected stylesheet, suppressible by id.

Tests and e2e

  • Ruby: tier detection (enum/string/text/integer/boolean/date, forced kwargs, unknown-attribute raise), the materializer against JS-generated fixture bytes (new FormFields fixture in the generator), undeclared-key rejection, the encrypted variant, helper output (sgid purpose scoping included), and the generator. packaging_test now pins the three-way gemspec separation.
  • JS: node --test under jsdom drives the real elements — two wired forms converging an LWW select and a checkbox, concurrent text merge, caret preservation across a remote splice, presence chip/outline rendering, status reflection.
  • E2e: the demo gains /tickets/:id (Ticket model, FormFieldsChannel over Y::Document storage, the helpers, the packaged elements) and frontend/form_fields_e2e.mjs drives two real browsers: the LWW field converges last-write-wins, the text field merges concurrent typing, presence appears on the peer, and the materialized columns match the converged state (a == columns == b). Runs in the demo job's Puma section; a new forms CI job runs the package tests on Node 20/22.

Docs

README-forms.md documents the whole system, including the identity posture (names are cosmetic, access is enforced by the sgid + authorized?) and the documented limitation: no server→document write-back until yrby#33 — the document is client-authored, the columns are the materialized view. Root README gains a one-paragraph pointer; CHANGELOG-forms.md starts the gem's changelog.

A third gem (yrby-forms) and a second npm package (packages/forms) that
make plain Rails form fields collaborate over one shared document per
record.

The gem:
- has_collaborative_fields declares which attributes collaborate. Tier
  detection per attribute: Rails enums are LWW, string/text columns are
  text tier (Y.Text), everything else is LWW; text:/lww: kwargs force a
  tier and an unknown attribute raises at declaration. encrypted: true
  stores state through Y::EncryptedDocument.
- One Y::Document named "fields" per record: LWW entries in a "fields"
  map share, one "fields/<name>" Y.Text share per text-tier field.
- refresh_collaborative_fields materializes the document into the
  declared columns under the record lock (save! validate: false), casting
  LWW values through the attribute types. Undeclared map keys are dropped
  before assign_attributes: the map is client-written, so this is the
  line between collaboration and mass assignment.
- Form helpers: form.collaborative_fields renders the
  <collaborative-form> container (channel, purpose-scoped signed
  GlobalID, doc key, identity from Y::Forms.identity);
  form.collaborative_field wraps the stock input for the attribute (or a
  block-supplied one) in <collaborative-field name tier>.
- yrby_forms:install generates a FormFieldsChannel (locate_signed scoped
  to Y::Forms.sgid_purpose, deny-by-default authorized?, storage through
  the record's document, refresh after every append) and invokes
  yrby:tables for the migration.

The npm package (packages/forms, same tsc dual-build + node --test
tooling as packages/client):
- <collaborative-form> builds the consumer (createConsumer, or
  setConsumer / a consumer property for AnyCable), a Y.Doc, and
  yrby-client's ActionCableProvider from its attributes, and reflects the
  provider status into its status attribute.
- <collaborative-field> binds the control inside it by tier: LWW is map
  entry <-> control value on input/change, remote-echo-safe via
  transaction origins; text is a minimal diff-splice binding between the
  control and the Y.Text, keeping the local caret across remote splices.
- Presence: awareness carries { name, color, field } set on focus/blur;
  a field a remote peer is in shows their color as an outline and their
  name as a chip (one injected stylesheet, override or pre-insert by id).

Demo + e2e: the actioncable-demo gains a /tickets/:id page running the
whole stack (Ticket model, FormFieldsChannel over Y::Document storage,
the helpers, the elements) and frontend/form_fields_e2e.mjs drives two
real browsers through it in CI: LWW last-write-wins convergence, text
merge of concurrent typing, presence on the peer, and materialized
columns matching the converged state. New CI job runs the package tests
on Node 20/22.

Both existing gemspecs exclude the forms files (packaging_test now pins
the three-way separation), and the demo Dockerfile builds the new
package's dist like yrby-client's.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant