Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@
"object-hash": "^3.0.0"
},
"peerDependencies": {
"mobx": ">=6",
"@legendapp/state": ">=3",
"react": ">=16"
},
"devDependencies": {
"@homebound/rtl-utils": "2.66.2",
"@legendapp/state": "3.0.0-beta.46",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@storybook/addon-essentials": "^8.3.5",
Expand Down Expand Up @@ -69,8 +70,6 @@
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.2.10",
"mobx": "^6.13.5",
"mobx-react": "^9.1.1",
"prettier": "3.3.3",
"prettier-plugin-organize-imports": "^4.1.0",
"react": "^18.3.1",
Expand Down
242 changes: 116 additions & 126 deletions src/FormStateApp.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { Observer } from "mobx-react";
import { observer } from "@legendapp/state/react";
import { AuthorInput, BookInput } from "src/formStateDomain";
import { FieldState, f, useFormState } from "src/index";

// Configure the fields/behavior for AuthorInput's fields
const formConfig = f.config<AuthorInput>({
firstName: f.value().req(),
lastName: f.value().readOnly(),
books: f
.list<BookInput>({
title: f.value().req(),
})
.rule(({ value }) => (value.length === 0 ? "Empty" : undefined)),
});

// TODO: Legend-State observer HOC causes infinite re-render loop with useFormState
// This needs investigation — likely related to Legend-State v3 beta observer tracking
// observables during render that are then set in useEffect
export function FormStateApp() {
const formState = useFormState({
config: formConfig,
// Simulate getting the initial form state back from a server call
init: {
input: {
firstName: "a1",
Expand All @@ -26,149 +39,126 @@ export function FormStateApp() {
});

return (
<Observer>
{() => (
<div className="App">
<header className="App-header">
<div>
<b>Author</b>
<TextField field={formState.firstName} />
<TextField field={formState.lastName} />
</div>

<div>
<strong>
Books <button onClick={() => formState.books.add({})}>Add book</button>
</strong>
{formState.books.rows?.map((row, i) => {
return (
<div key={i}>
Book {i}
<button onClick={() => formState.books.remove(row.value)}>X</button>
<TextField field={row.title} />
</div>
);
})}
</div>

<div>
<strong>Rows</strong>
<table cellPadding="4px">
<thead>
<tr>
<th>touched</th>
<th>valid</th>
<th>dirty</th>
<th>errors</th>
</tr>
</thead>
<tbody>
<tr>
<td>{formState.books.touched.toString()}</td>
<td>{formState.books.valid.toString()}</td>
<td>{formState.books.dirty.toString()}</td>
<td>{formState.books.errors}</td>
</tr>
</tbody>
</table>
</div>

<div>
<strong>Form</strong>
<table cellPadding="4px">
<thead>
<tr>
<th>touched</th>
<th>valid</th>
<th>dirty</th>
</tr>
</thead>
<tbody>
<tr>
<td>{formState.touched.toString()}</td>
<td>{formState.valid.toString()}</td>
<td>{formState.dirty.toString()}</td>
</tr>
</tbody>
</table>
<div className="App">
<header className="App-header">
<div>
<b>Author</b>
<TextField field={formState.firstName} />
<TextField field={formState.lastName} />
</div>

<div>
<button data-testid="touch" onClick={() => (formState.touched = !formState.touched)}>
touch
</button>
<button data-testid="revertChanges" onClick={() => formState.revertChanges()}>
revert
</button>
<button data-testid="commitChanges" onClick={() => formState.commitChanges()}>
commit
</button>
<button data-testid="set" onClick={() => formState.set({ firstName: "a2" })}>
set
</button>
</div>
<div>
<strong>
Books <button onClick={() => formState.books.add({})}>Add book</button>
</strong>
{formState.books.rows?.map((row, i) => (
<div key={i}>
Book {i}
<button onClick={() => formState.books.remove(row.value)}>X</button>
<TextField field={row.title} />
</div>
</header>
))}
</div>
)}
</Observer>
);
}

// Configure the fields/behavior for AuthorInput's fields
const formConfig = f.config<AuthorInput>({
firstName: f.value().req(),
lastName: f.value().readOnly(),
books: f
.list<BookInput>({
title: f.value().req(),
})
.rule(({ value }) => (value.length === 0 ? "Empty" : undefined)),
});

export function TextField(props: { field: FieldState<string | null | undefined> }) {
const { field } = props;
// Somewhat odd: input won't update unless we use <Observer>, even though our
// parent uses `<Observer>`
return (
<Observer>
{() => (
<div>
<span>{field.key}:</span>
<div>
<input
data-testid={field.key}
value={field.value || ""}
onBlur={() => field.blur()}
readOnly={field.readOnly}
onChange={(e) => {
field.set(e.target.value);
}}
/>
</div>
<strong>Rows</strong>
<table cellPadding="4px">
<thead>
<tr>
<th>touched</th>
<th>valid</th>
<th>dirty</th>
<th>readOnly</th>
<th>errors</th>
<th>original value</th>
</tr>
</thead>
<tbody>
<tr>
<td data-testid={`${field.key}_touched`}>{field.touched.toString()}</td>
<td data-testid={`${field.key}_valid`}>{field.valid.toString()}</td>
<td data-testid={`${field.key}_dirty`}>{field.dirty.toString()}</td>
<td data-testid={`${field.key}_readOnly`}>{field.readOnly.toString()}</td>
<td data-testid={`${field.key}_errors`}>{field.errors}</td>
<td data-testid={`${field.key}_original`}>{field.originalValue}</td>
<td>{formState.books.touched.toString()}</td>
<td>{formState.books.valid.toString()}</td>
<td>{formState.books.dirty.toString()}</td>
<td>{formState.books.errors}</td>
</tr>
</tbody>
</table>
</div>

<div>
<strong>Form</strong>
<table cellPadding="4px">
<thead>
<tr>
<th>touched</th>
<th>valid</th>
<th>dirty</th>
</tr>
</thead>
<tbody>
<tr>
<td>{formState.touched.toString()}</td>
<td>{formState.valid.toString()}</td>
<td>{formState.dirty.toString()}</td>
</tr>
</tbody>
</table>

<div>
<button data-testid="touch" onClick={() => (formState.touched = !formState.touched)}>
touch
</button>
<button data-testid="revertChanges" onClick={() => formState.revertChanges()}>
revert
</button>
<button data-testid="commitChanges" onClick={() => formState.commitChanges()}>
commit
</button>
<button data-testid="set" onClick={() => formState.set({ firstName: "a2" })}>
set
</button>
</div>
</div>
)}
</Observer>
</header>
</div>
);
}

export const TextField = observer(function TextField(props: { field: FieldState<string | null | undefined> }) {
const { field } = props;
return (
<div>
<span>{field.key}:</span>
<div>
<input
data-testid={field.key}
value={field.value || ""}
onBlur={() => field.blur()}
readOnly={field.readOnly}
onChange={(e) => {
field.set(e.target.value);
}}
/>
</div>
<table cellPadding="4px">
<thead>
<tr>
<th>touched</th>
<th>valid</th>
<th>dirty</th>
<th>readOnly</th>
<th>errors</th>
<th>original value</th>
</tr>
</thead>
<tbody>
<tr>
<td data-testid={`${field.key}_touched`}>{field.touched.toString()}</td>
<td data-testid={`${field.key}_valid`}>{field.valid.toString()}</td>
<td data-testid={`${field.key}_dirty`}>{field.dirty.toString()}</td>
<td data-testid={`${field.key}_readOnly`}>{field.readOnly.toString()}</td>
<td data-testid={`${field.key}_errors`}>{field.errors}</td>
<td data-testid={`${field.key}_original`}>{field.originalValue}</td>
</tr>
</tbody>
</table>
</div>
);
});
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type ValueFieldConfig<V> = {
/** If true, we ignore field in dirty checks. */
isLocalOnly?: boolean;
/**
* Marks a field as being backed by a mobx class computed field.
* Marks a field as computed/derived from other fields.
*
* Note that it might still be settable (some computed have setters), but we do
* exclude from the `reset` operation, i.e. we assume resetting other non-computed fields
Expand Down
15 changes: 7 additions & 8 deletions src/fields/fragmentField.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { makeAutoObservable, observable } from "mobx";
import { observable, Observable } from "@legendapp/state";
import { FieldState, FieldStateInternal, ValueAdapter } from "src/fields/valueField";
import { fail } from "src/utils";
import { V } from "vite/dist/node/types.d-aGj9QkWt";

export interface FragmentField<V> {
value: V;
Expand All @@ -13,7 +12,7 @@ export function newFragmentField<T extends object, K extends keyof T & string>(
): FragmentField<T[K]> {
// We always return the same `instance` field from our `value` method, but
// we want to pretend that it's observable, so use a tick to force it.
const _tick = observable({ value: 1 });
const _tick = observable(1);

// We steal the fragment from our parent, so that it doesn't
// accidentally end up on the wire
Expand Down Expand Up @@ -50,23 +49,23 @@ export function newFragmentField<T extends object, K extends keyof T & string>(
value = parentInstance[key];
delete parentInstance[key];
}
_tick.value > 0 || fail();
_tick.get() > 0 || fail();
return value;
},

set value(v: T[K]) {
value = v;
_tick.value++;
_tick.set((t) => t + 1);
},

set(value) {
set(value: any) {
this.value = value;
},

adapt(value) {
adapt(value: any) {
throw new Error("FragmentField does not support adapt");
},
} satisfies FieldStateInternal<T, any>;

return makeAutoObservable(obj, { value: false });
return obj;
}
Loading
Loading