-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
WIP: Formatic 3000 #124
base: master
Are you sure you want to change the base?
WIP: Formatic 3000 #124
Conversation
This is more than a sketch but not quite fully baked spec for "Formatic 3000," which is a complete rewrite of Formatic's internals and external API.
Add a hidden demo page for Formatic 3000 components. Add one example for a boiler-plate React form that doesn't use Formatic at all.
- Added the first example and test to match the spec. - Added code to make the test pass. - Yay!
No optimization yet, but meeting that part of the spec.
Support render callbacks with FieldContainer to optionally avoid hook wiring.
Not styling support yet, just working through the spec.
Make sure cleanup is called after each test. Some other minor test code cleanup.
Per the spec, can pass in renderTag to FormContainer to override rendering of any tags. Internally, this requires a wrapper component that reads from context for every DOM element. Not sure about the performance implications there, but it shouldn't be any worse than emotion or styled-components, which do something similar for themes. Will revisit later, but for now, just matching the spec.
const renderForm = () => ( | ||
<FormContainer defaultValue={defaultValue} onChange={onChangeSpy}> | ||
<div data-testid="firstNameWrapper"> | ||
<TextField fieldKey="firstName" label="First Name" /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this approach a lot - you can specify your fields with JSX and control where it goes, add wrapper stuff, etc.
I also really like the useField
hook. I think that's a great way to write custom components or extend things.
Just my first impressions, but this is all feeling very nice!
In implementing symbols for render keys, it seemed like overkill. Support will be retaining for userland symbols, but it seems fine to have Formatic own some string keys.
Per the spec, you can pass in `renderComponent` to `FormContainer` in order to override the rendering of a built-in component. For these simple components, this seems like overkill, but this will probably be useful for more complex components? Hmm, not sure, will have to revisit this.
The monolithic index with all the things was getting a little unwieldy, so breaking it up into multiple modules.
Supported nested values by reusing ControlledFormContainer to bubble up nested objects.
Just delegate to ControlledContainer for now, so it's easier to implement other features in the spec. Will have to rethink this later for optiimzation since this does too much rerendering, but just working on the API surface right now.
Whoops, didn't actually mean to export these.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Echoing @stevemolitor, I'm definitely liking this. I'll try to go through building some components and checking out demo tomorrow. Code looks great 🙌
A few questions around style and syntax.
|
||
import { ExampleForm, defaultValue } from '@/demo/future/BuiltInField'; | ||
|
||
afterEach(cleanup); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using the setupTestFrameworkScriptFile
option in jest.config
would call setup cleanup
for all tests. Then we wouldn't need to explicitly call it in every test file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess that seems reasonable. The only downside of that is you can't opt out of that behavior then? For example, if you wanted to do some iterative tests on the same DOM, you could use afterAll
, but with the global setting, your DOM would be wiped out each time.
expect( | ||
getByLabelText('FIRST NAME') | ||
.getAttribute('class') | ||
.indexOf('css-') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super minor—another way to get this might be:
expect(
getByLabelText('FIRST NAME').className.includes('css-')
).toBe(true);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agree, that's a little nicer!
); | ||
} | ||
|
||
export function ExampleForm({ defaultValue, onChange }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One question regarding syntax: does Formatic keep the same style guidelines we try to use company-wide? We say to always use arrow functions unless we need to keep context of this
through the prototype chain, so as to document this
's usage in a function.
I'm all for function ExampleForm
, but just curious.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does use Zapier's linting, but I don't think it's stuck with all of our internal style rules.
This was just something I was trying though. I've found myself liking function
more than arrow functions in a lot of cases. Using function
makes it easier to export as a default for example.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally agree
|
||
function TextField({ fieldKey, label }) { | ||
const { value, onChangeTargetValue } = useField(fieldKey); | ||
return ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the recommended pattern to wrap each form element in a div
rather than:
<div>
<label>
<input>
</div>
// or
<>
<label>
<input>
</>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, good question. This is an example, so it doesn't matter here. But for built-in fields that might be exported, we should decide. I think built-in fields will probably mostly be for just getting something rendered quickly and probably won't be used in practice that much, so whatever renders the best out of the box?
@@ -0,0 +1,9 @@ | |||
export function createRenderWith(_meta) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we really need this function if it only applies three fields to Tag
(or whichever input)? I don't know if I see a lot of value for the abstraction over simply defining the fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably not. The repeating _meta={meta}
bugged me a bit and seemed like something that would easily get missed. And I didn't like the sort order rule of the linter changing from _tag="div" _key="Field" _meta={meta}
to _key="div" _meta={meta} _tag="div"
. But yeah, it's not that big of deal:tm:.
src/future/index.js
Outdated
function FieldComponent(props) { | ||
return ( | ||
<Component | ||
_component={Field} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the intent for _component
and other underscored props to imply do not mutate? Why wouldn't fieldType
be _fieldType
?
I've only really used _foo
with object fields and methods—not as much with function args.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The underscored props were just to signify that they would not be passed on to the component/tag. If a component had its own component
prop, then you could still use it like:
<Component _component={MyComponent} component={PassThruComponent}/>
Also, the underscore pushes it the front in the alphabetized props, rather than have it arbitrarily mixed in.
...props | ||
}) { | ||
const [savedDefaultValue] = useState(defaultValue); | ||
const [isControlled] = useState(savedDefaultValue === undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to put this in state? I could see caching the initial value of props.defaultValue
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's effectively what I'm doing here. When you use useState
without changing the value, it's effectively like using a constructor in a class-based component. I'm saving the value for later rather than mutating it every time. The value passed to useState
is the initial value and will never change here.
ref | ||
) { | ||
const renderContext = useContext(RenderContext); | ||
if (!renderContext.renderTag) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice. Hooray for Context 🎉 Hooray for Hooks 🎉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Glad you like it. 😄 This is actually the part I'm most nervous about, wondering if we should just keep Formatic out of the business of rendering components entirely. Where I'm at now: I think it's nice to have Formatic work out of the box, even if most consumers will tend to use hooks and provide their own presentational components. It's also good to have "reference components" to exercise the implementation.
It's really hard to come up with a good model for overriding the rendering of components though, and this is about as good as good as I think it gets. There's some extraneous wrapping here, but trying to avoid the wrapping starts making things a little weirder, like with the current version where you have to worry about ...children
.
Probably not very useful in practice, but it's not essential to the inner working of an uncontrolled component, and it was useful for a test.
This will require somewhat significant code changes to pass, but getting the test added before beginning to work on it.
@@ -0,0 +1,635 @@ | |||
# Formatic 3000 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jdeal I took a very quick glance at this spec, but not the code. None of this seems problematic with how the design system is building components since largely this new version of formatic allows customizing how any form component renders 🎉
Overall this spec looks great to me. Here are some questions / feedback that I have:
- There are a lot of examples and the API surface area still seems very large and somewhat ambiguous to me. Details below on individual points.
- Is there a benefit of providing
FieldContainer
and hooks, as in does each solve unique problems that the other cannot? If they provide equivalent functionality then IMO there should only be one. If there's only one option then there's no choice to make, which I think is a good thing. - I don't follow the difference between
renderTag
andrenderComponent
. Does there need to be an actual difference between the two or could there only be one? I would expect onlyrenderComponent
to be necessary because I would think the rendered components would control their own DOM, so I don't follow whererenderTag
fits in and when it would be called. - I don't understand
renderKey
and am curious if it's necessary. I would assume that formatic would choose which component to render for me given the type of the field. How it compares tofield.type
is also confusing to me. If it is necessary, are there a finite number of values thatrenderKey
could be? IMO it would make sense to provide those as constants rather than strings. PerhapsSymbol
s so you must import them and can't shoot yourself in the foot by having a lot of strings throughout the codebase. - Do nested values require nesting components or does/could
fieldKey
support something likemy.nested.value
or even['my', 'nested', 'value']
? - The
AutoFields
examples are a little hard for me to understand at a glance but seem to provide the functionality I would expect based on my above points. For instance mappingString: MyStringField
andInteger: MyIntegerField
makes a lot of sense to me. However I don't understand howAutoFields
either complements or competes withrenderTag
andrenderComponent
orAutoFieldsContainer
.
Overall it seems like there's a lot of API flexibility that is provided, but I think it makes it ambiguous which API to choose and how they compete with each other. If it comes down to personal preference rather than actual tradeoffs of one API vs another, I think formatic should choose one to minimize its API. I definitely think less is more here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevenhauser thanks for taking a look!
- Hmm, I like to think the API surface is a lot simpler than the current version of Formatic, where the plugins have a lot of hidden surface area. But I'll answer specific points...
- I see what you're saying, but I think that's a userland decision. Sometimes, it might make sense to inline a render callback because you have a bunch of variables in scope and you want to dynamically render a field based on those. Being forced to use
useField
would mean you have to pass all those variables as props in a case where it's not very ergonomic. Also, this makes it easy to make dependent fields where a field only shows up based on the value of another field. I'll likely create something higher level for that, but right now, I'm focusing on the primitives that you can stitch together. I appreciate wanting to keep the API small, and I know there's some overlap, but I think it's worth keeping the render callback for now. After I build more examples, I can revisit though. - I originally tried to do a single callback, but it gets pretty weird in practice.
renderTag
is specifically meant for rendering DOM nodes. Although it will probably be recommended that userland do all the DOM rendering, I think it's nice to have reference components to quickly build a form and see if Formatic works vs having to build custom components right away.renderTag
exists mainly as a way to style the DOM elements of those reference components.renderComponent
is used to completely take over the rendering of a component. If you take over all component rendering, you don't need to worry aboutrenderTag
at all. - I'm building the automatic form generation from other primitives. So you can have component/tag overriding independent of automatic form generation. I started out with symbols actually, but it felt kind of overkill. I'd be fine putting it back in though.
- I imagine I'll add some sugar, but I wanted to focus on primitives first. I'd definitely want to avoid having to always parse the keys, so I'd want to make that a different prop like
keyPath
or something. AutoFields
mainly exists for you to map automatic form generation to your components. If you use built-in Formatic components, you'll be able to userenderComponent
andrenderTag
on those, but if you use your own components, you would not (since you wouldn't have built your components to be overridable unless Formatic exports a utility for that).
Hopefully that helps explain some things @stevenhauser ! I kind of got lazy with the spec at the end, so as I flesh out more examples, it may make more sense. Or, maybe it will become more obvious what can be cut. Let me know if something I've said here still doesn't make sense though! (Happy to chat sync too.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's some more nuance worth knowing about renderComponent
, renderTag
, AutoFields
, and AutoFieldsContainer
:
renderComponent
is meant for intercepting or overriding the rendering of components that would be rendered anyway if you did nothing. This means that you've already absorbed the cost of those components in the build, so you should either be intercepting to modify/add props or lightly overriding small components. You would not want to use renderComponent
to completely replace a bunch of components, since you'd be including all those components in the build and then just replacing them anyway. This is a problem today with Formatic. All components are part of the default config, so even if you override the rendering of every single component, you're still paying the tax for all those default components.
That's one reason it's convenient to separate renderTag
: with renderTag
, you're rendering DOM nodes, so you want to render every single bit of the component. You just want to modify/add DOM-related props. Without that separation, you'd have to have a conditional inside your renderComponent
function to determine what you're rendering. A data-*
attribute makes sense in the case of a DOM-specific component, but not a DOM-independent component, for example.
AutoFields
is a kitchen-sink component. It likely will exist simply to take Formatic for a test-drive or for consumers who don't care too much about presentation (like for admin pages) and just want to quickly dump a form to the page. It's going to absorb all of Formatic's built-in components into your build (like with today's Formatic). You don't want to use AutoFields
and then override everything with renderComponent
(although you totally could do that).
AutoFieldsContainer
on the other hand takes a components
prop so you can pass in your own components. Or you could pick and choose to import only those components you want from Formatic's built-in components vs importing the kitchen-sink with AutoFields
. We can't have a single AutoFields
component that does both these things, because once we've imported everything, we can't unimport everything. Tree-shaking has no idea which types you're going to pick at runtime.
Now, it does feel like the components
prop of AutoFieldsContainer
and renderComponent
are doing similar jobs. But look at the signature of renderComponent
:
renderComponent(renderKey, Component, props)
Right now, it's meant for overriding Component
. But in the case of AutoFieldsContainer
, we don't actually have a component to override. That's the point: we want to decide what to render in userland. If we did that, we'd be calling renderComponent
with something like this:
renderComponent('Text', undefined, {...});
Maybe that's fine? It's a little weird though because it means that function is doing double-duty and like with the problem of combining renderTag
, you'd have to have a conditional in there in some cases. And the documentation would have to explain that subtle/strange thing.
We could also have a third function like renderAutoFieldComponent
or whatever, which would just be called like:
renderAutoFieldComponent('Text', {...});
That's clean and is a little more flexible than the components
property since you can do some dynamic wrapping if you need to. However, that case is atypical and didn't seem to warrant the extra complexity vs just having an object pointing to components. If you really need to inject something dynamic, you can pretty easily do that with context and a hook. The reverse is true as well. You could use an object mapping instead of renderComponent
, but there, we're talking about potentially many more layers of wrapping if you needed to do something dynamic, which might run into perf problems. (And there's already some wrapping cost to make the components overridable. I tried to avoid that, but that's another story of many traps.) So I tried to choose pragmatically for each use case.
Sorry about the wall of text! This is as much me thinking out loud as anything. So feel free to ignore. But happy to hear if you have any alternate design ideas.
This allows us to create something similar to React's context but with an important difference: you can react to changes to specific properties of an object rather than only being able to react to changes to the object as a whole. This makes it efficient to pass in a context value and make changes to parts of the tree without having it rerender all parts of the tree. Later commits will hopefully make use of this in `FormContainer` and `useField`.
With ReactiveValue now available, most of the implementation of FieldContainer and useField just composes over that. The previous AvoidRerendering test had a bug in it, but after fixing that, we now verify we have a performant useField. (Which basically was already known because ReactiveValue has its own tests.)
|
||
## Schemas | ||
|
||
You can also provide a set of fields as a schema to properly coerce types. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevelikesmusic I started implementing this, and it immediately feels unnecessary to do any kind of automatic coercion when you're specifying the field components anyway. It seems like the component's job to do this. In other words, better to just use an IntegerField
component that does the proper coercion from text to integer if necessary. Formatic can provide a reference component for that, but even if it didn't, it would be trivial to build that from other primitives.
Once I get to the auto form generation which will require a schema, I still don't think it's really necessary: it just needs to pick the appropriate component based on the type to do the coercion vs doing any side-channel coercion.
If I get further and realize it is necessary for some reason, I'll pick this back up, but for now, I think I'm going to delete this section unless you can think of any reason to keep it?
Even if we do think we need it, I think this can be accomplished via a prop like shouldAutoCoerce
or a special <AutoCoerceInput/>
/<AutoCoerceField>
component.
This is just a simple example of a field that coerces its output value. This should probably be coupled with validation at some point so that what you're typing isn't blocked, but rather it becomes a validation error. That's for later though. The spec currently describes using a schema for coercion, but I'm unsure if that's necessary: #124 (comment) So this is just an example of what I was describing there.
This was a leftover from the coercion idea I bailed on
Add AutoFields. Move field-components to their own folder.
Rename `useReativeValue` to `useReactiveValueAt` and add a new `useReactiveValue` which allows you to get and set the whole value.
Not sure what's up with the formatting changes, maybe @stevelikesmusic has different prettier settings?
Now, ReactiveValue wrapper instances are shared across hooks with the same property key. This means nested reactive values should perform even better. Also added a way to subscribe to changes to property types of values. This is more performant than Object.keys(value) since the value would change every time a proprty changes, but the property types only change when the type of a property changes or a property is added.
Use `useReactiveValueMeta` to generate AutoFields, rather than packing `initialValue` into `RenderContext`. This means that properties can be added, and new auto fields will be added.
}); | ||
|
||
export default function createAutoFields(defaultFieldComponents) { | ||
return function AutoFields(props) { | ||
const { initialValue: formValues } = useContext(RenderContext); | ||
const schema = Object.entries(formValues).map(inferSchema); | ||
const meta = useReactiveValueMeta(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stevelikesmusic I made an even more performant ReactiveValue
. (Unfortunately, it did get a bit more complex, but it's still nicely isolated behind hooks.) And as part of that, I added a useReactiveValueMeta
that I swapped out here. It gives you access to property types and only updates when those types change. This is also dynamic, so new properties that are added will generate new auto fields.
I think this may be roughly how validation errors will work once we get to that. Effectively, validation errors will just be extra metadata this sits alongside the values.
src/future/ReactiveValue.test.js
Outdated
firstName: 'Joe', | ||
lastName: '', | ||
}); | ||
expect(propertyTypesRenderSpy.mock.calls.length).toBe(1); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can simplify this assert with expect(propertyTypesRenderSpy).toHaveBeenCalledTimes(1)
Cleanup in ReactiveValue
Notify meta on value
This is just the seed of a work in progress of a big potential project to rewrite Formatic. It could end up being scrapped, completed, or having bits of it worked into smaller PRs. For now though, I'm exercising a spec for a future version of Formatic by trying to implement it from scratch.
Tasks: