DEV: adds support for nested collections and objects (#30265)

Collections were an existing concept in FormKit but didn't allow nesting. You can now do infinite nesting:

```gjs
<Form
  @data={{hash
    foo=(array
      (hash bar=(array (hash baz=1))) (hash bar=(array (hash baz=2)))
    )
  }}
  as |form|
>
  <form.Collection @name="foo" as |parent parentIndex|>
    <parent.Collection @name="bar" as |child childIndex|>
      <child.Field @name="baz" @title="Baz" as |field|>
        <field.Input />
      </child.Field>
    </parent.Collection>
  </form.Collection>
</Form>
```

On top of this a new component has been added: `Object`. It allows you to represent objects in your form data. Collections are basically handling arrays, and Objects are objects.

This is useful if you form data has this shape for example:

```javascript
{ foo: { bar: 1, baz: 2 } }
```

This can now be mapped in your form using this syntax:

```gjs
<Form @data={{hash foo=(hash bar=1 baz=2)}} as |form|>
  <form.Object @name="foo" as |object name|>
    <object.Field @name={{name}} @title={{name}} as |field|>
      <field.Input />
    </object.Field>
  </form.Object>
</Form>
```

Objects accept nested collections and nested objects. Just like Collections.

A small addition has also been made to `Collection`, they now support a custom `@tagName`, it's useful if each item of your collection is the row of a table for example.
This commit is contained in:
Joffrey JAFFEUX 2024-12-13 15:43:32 +01:00 committed by GitHub
parent b191d63c1b
commit f6a4de4805
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 338 additions and 37 deletions

View File

@ -1,40 +1,78 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { concat, hash } from "@ember/helper";
import { action, get } from "@ember/object";
import FKField from "discourse/form-kit/components/fk/field";
import FKObject from "discourse/form-kit/components/fk/object";
import element from "discourse/helpers/element";
export default class FKCollection extends Component {
@action
remove(index) {
this.args.remove(this.args.name, index);
this.args.remove(this.name, index);
}
get collectionValue() {
return this.args.data.get(this.args.name);
get collectionData() {
return this.args.data.get(this.name);
}
get name() {
return this.args.parentName
? `${this.args.parentName}.${this.args.name}`
: this.args.name;
}
get tagName() {
return this.args.tagName || "div";
}
<template>
<div class="form-kit__collection">
{{#each this.collectionValue key="index" as |data index|}}
{{yield
(hash
Field=(component
FKField
errors=@errors
collectionName=@name
collectionIndex=index
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
{{#let (element this.tagName) as |Wrapper|}}
{{#each this.collectionData key="index" as |data index|}}
<Wrapper class="form-kit__collection">
{{yield
(hash
Field=(component
FKField
errors=@errors
collectionIndex=index
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=(concat this.name "." index)
)
Object=(component
FKObject
errors=@errors
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=(concat this.name "." index)
)
Collection=(component
FKCollection
errors=@errors
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=(concat this.name "." index)
remove=@remove
)
remove=this.remove
)
remove=this.remove
)
index
}}
index
(get this.collectionData index)
}}
</Wrapper>
{{/each}}
</div>
{{/let}}
</template>
}

View File

@ -146,13 +146,11 @@ export default class FKFieldData extends Component {
throw new Error("@name can't include `.` or `-`.");
}
return (
(this.args.collectionName ? `${this.args.collectionName}.` : "") +
(this.args.collectionIndex !== undefined
? `${this.args.collectionIndex}.`
: "") +
this.args.name
);
if (this.args.parentName) {
return `${this.args.parentName}.${this.args.name}`;
}
return this.args.name;
}
/**

View File

@ -57,7 +57,7 @@ export default class FKField extends Component {
@registerField={{@registerField}}
@format={{@format}}
@disabled={{@disabled}}
@collectionName={{@collectionName}}
@parentName={{@parentName}}
as |field|
>
<this.wrapper @size={{@size}}>

View File

@ -14,6 +14,7 @@ import FKErrorsSummary from "discourse/form-kit/components/fk/errors-summary";
import FKField from "discourse/form-kit/components/fk/field";
import FKFieldset from "discourse/form-kit/components/fk/fieldset";
import FKInputGroup from "discourse/form-kit/components/fk/input-group";
import FKObject from "discourse/form-kit/components/fk/object";
import Row from "discourse/form-kit/components/fk/row";
import FKSection from "discourse/form-kit/components/fk/section";
import FKSubmit from "discourse/form-kit/components/fk/submit";
@ -289,6 +290,17 @@ class FKForm extends Component {
unregisterField=this.unregisterField
triggerRevalidationFor=this.triggerRevalidationFor
)
Object=(component
FKObject
errors=this.formData.errors
addError=this.addError
data=this.formData
set=this.set
registerField=this.registerField
unregisterField=this.unregisterField
triggerRevalidationFor=this.triggerRevalidationFor
remove=this.remove
)
InputGroup=(component
FKInputGroup
errors=this.formData.errors

View File

@ -0,0 +1,71 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import FKCollection from "discourse/form-kit/components/fk/collection";
import FKField from "discourse/form-kit/components/fk/field";
export default class FKObject extends Component {
get objectData() {
return this.args.data.get(this.name);
}
get name() {
return this.args.parentName
? `${this.args.parentName}.${this.args.name}`
: this.args.name;
}
get keys() {
return Object.keys(this.objectData);
}
entryData(name) {
return this.objectData[name];
}
<template>
<div class="form-kit__object">
{{#each this.keys key="index" as |name|}}
{{yield
(hash
Field=(component
FKField
errors=@errors
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=this.name
)
Object=(component
FKObject
errors=@errors
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=this.name
)
Collection=(component
FKCollection
errors=@errors
addError=@addError
data=@data
set=@set
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
parentName=this.name
remove=@remove
)
)
name
(this.entryData name)
}}
{{/each}}
</div>
</template>
}

View File

@ -3,11 +3,22 @@ import { click, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Form from "discourse/components/form";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import formKit from "discourse/tests/helpers/form-kit-helper";
module("Integration | Component | FormKit | Collection", function (hooks) {
setupRenderingTest(hooks);
test("default", async function (assert) {
test("@tagName", async function (assert) {
await render(<template>
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" @tagName="tr" />
</Form>
</template>);
assert.dom("tr.form-kit__collection").exists();
});
test("field", async function (assert) {
await render(<template>
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" as |collection|>
@ -45,4 +56,85 @@ module("Integration | Component | FormKit | Collection", function (hooks) {
assert.form().field("foo.0.bar").hasValue("1");
assert.form().field("foo.1.bar").doesNotExist();
});
test("nested object", async function (assert) {
await render(<template>
<Form
@data={{hash
foo=(array (hash bar=(hash baz=1)) (hash bar=(hash baz=2)))
}}
as |form|
>
<form.Collection @name="foo" as |collection index|>
<collection.Object @name="bar" as |object|>
<object.Field @name="baz" @title="Baz" as |field|>
<field.Input />
<form.Button
class={{concat "remove-" index}}
@action={{fn collection.remove index}}
>Remove</form.Button>
</object.Field>
</collection.Object>
</form.Collection>
</Form>
</template>);
assert.form().field("foo.0.bar.baz").hasValue("1");
assert.form().field("foo.1.bar.baz").hasValue("2");
await click(".remove-1");
assert.form().field("foo.0.bar.baz").hasValue("1");
assert.form().field("foo.1.bar.baz").doesNotExist();
await formKit().field("foo.0.bar.baz").fillIn("2");
assert.form().field("foo.0.bar.baz").hasValue("2");
});
test("nested collection", async function (assert) {
await render(<template>
<Form
@data={{hash
one=(array
(hash two=(array (hash three=(array (hash foo=1) (hash foo=2)))))
)
}}
as |form|
>
<form.Collection @name="one" as |first firstIndex|>
<first.Collection @name="two" as |second secondIndex|>
<second.Collection @name="three" as |third thirdIndex|>
<third.Field @name="foo" @title="Foo" as |field|>
<field.Input />
<form.Button
class={{concat
"remove-"
firstIndex
"-"
secondIndex
"-"
thirdIndex
}}
@action={{fn third.remove thirdIndex}}
>Remove</form.Button>
</third.Field>
</second.Collection>
</first.Collection>
</form.Collection>
</Form>
</template>);
assert.form().field("one.0.two.0.three.0.foo").hasValue("1");
assert.form().field("one.0.two.0.three.1.foo").hasValue("2");
await click(".remove-0-0-1");
assert.form().field("one.0.two.0.three.0.foo").hasValue("1");
assert.form().field("one.0.two.0.three.1.foo").doesNotExist();
await formKit().field("one.0.two.0.three.0.foo").fillIn("2");
assert.form().field("one.0.two.0.three.0.foo").hasValue("2");
});
});

View File

@ -0,0 +1,84 @@
import { array, concat, fn, hash } from "@ember/helper";
import { click, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Form from "discourse/components/form";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import formKit from "discourse/tests/helpers/form-kit-helper";
module("Integration | Component | FormKit | Object", function (hooks) {
setupRenderingTest(hooks);
test("field", async function (assert) {
await render(<template>
<Form @data={{hash foo=(hash bar=1 baz=2)}} as |form|>
<form.Object @name="foo" as |object name|>
<object.Field @name={{name}} @title={{name}} as |field|>
<field.Input />
</object.Field>
</form.Object>
</Form>
</template>);
assert.form().field("foo.bar").hasValue("1");
assert.form().field("foo.baz").hasValue("2");
});
test("nested object", async function (assert) {
await render(<template>
<Form
@data={{hash one=(hash two=(hash three=(hash foo=1 bar=2)))}}
as |form|
>
<form.Object @name="one" as |one|>
<one.Object @name="two" as |two|>
<two.Object @name="three" as |three name|>
<three.Field @name={{name}} @title={{name}} as |field|>
<field.Input />
</three.Field>
</two.Object>
</one.Object>
</form.Object>
</Form>
</template>);
assert.form().field("one.two.three.foo").hasValue("1");
assert.form().field("one.two.three.bar").hasValue("2");
await formKit().field("one.two.three.foo").fillIn("2");
assert.form().field("one.two.three.foo").hasValue("2");
});
test("nested collection", async function (assert) {
await render(<template>
<Form
@data={{hash one=(hash two=(array (hash foo=1) (hash foo=2)))}}
as |form|
>
<form.Object @name="one" as |one|>
<one.Collection @name="two" as |two twoIndex|>
<two.Field @name="foo" @title="foo" as |field|>
<field.Input />
</two.Field>
<form.Button
class={{concat "remove-" twoIndex}}
@action={{fn two.remove twoIndex}}
>Remove</form.Button>
</one.Collection>
</form.Object>
</Form>
</template>);
assert.form().field("one.two.0.foo").hasValue("1");
assert.form().field("one.two.1.foo").hasValue("2");
await click(".remove-1");
assert.form().field("one.two.0.foo").hasValue("1");
assert.form().field("one.two.1.foo").doesNotExist();
await formKit().field("one.two.0.foo").fillIn("2");
assert.form().field("one.two.0.foo").hasValue("2");
});
});

View File

@ -1,5 +1,7 @@
.form-kit__collection {
display: flex;
flex-direction: column;
gap: var(--form-kit-gutter-y);
&div {
display: flex;
flex-direction: column;
gap: var(--form-kit-gutter-y);
}
}

View File

@ -3,6 +3,7 @@
@import "_alert";
@import "_char-counter";
@import "_col";
@import "_object";
@import "_collection";
@import "_conditional-display";
@import "_container";

View File

@ -0,0 +1,3 @@
.form-kit__object {
display: contents;
}