Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
52 changes: 25 additions & 27 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,34 +78,32 @@ function splitNameIntoParts(name: string, delimiter: string): string[] {
const nameParts: string[] = [];

for (const rawPart of rawParts) {
const splitByRailsChunk = rawPart.split("][");

if (splitByRailsChunk.length > 1) {
for (let chunkIndex = 0; chunkIndex < splitByRailsChunk.length; chunkIndex += 1) {
let chunk = splitByRailsChunk[chunkIndex] ?? "";

if (chunkIndex === 0) {
chunk = `${chunk}]`;
} else if (chunkIndex === splitByRailsChunk.length - 1) {
chunk = `[${chunk}`;
} else {
chunk = `[${chunk}]`;
}

const railsMatch = chunk.match(/([a-z_]+)?\[([a-z_][a-z0-9_]+?)\]/i);
if (railsMatch) {
for (let matchIndex = 1; matchIndex < railsMatch.length; matchIndex += 1) {
const matchPart = railsMatch[matchIndex];
if (matchPart) {
nameParts.push(matchPart);
}
}
} else {
nameParts.push(chunk);
}
const bracketMatches = Array.from(rawPart.matchAll(/\[([^\]]*)\]/g));
if (bracketMatches.length === 0) {
nameParts.push(rawPart);
continue;
}

let currentPart = rawPart.slice(0, bracketMatches[0]?.index ?? 0);

for (const match of bracketMatches) {
const bracketContent = match[1] ?? "";
const isArraySegment = bracketContent === "" || /^\d+$/.test(bracketContent);

if (isArraySegment) {
currentPart = `${currentPart}[${bracketContent}]`;
continue;
}
} else {
nameParts.push(...splitByRailsChunk);

if (currentPart !== "") {
nameParts.push(currentPart);
}

currentPart = bracketContent;
}

if (currentPart !== "") {
nameParts.push(currentPart);
}
}

Expand Down
66 changes: 66 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,72 @@ describe("entriesToObject", () => {
});
});

it("supports rails style keys with underscores and one-character names", () => {
const result = entriesToObject(
[
{ key: "data[Topic][topic_id]", value: "1" },
{ key: "person.ruby[field2][f]", value: "baz" }
],
{ skipEmpty: false }
);

expect(result).toEqual({
data: {
Topic: {
topic_id: "1"
}
},
person: {
ruby: {
field2: {
f: "baz"
}
}
}
});
});

it("supports single-bracket rails object segments at the root", () => {
const result = entriesToObject([{ key: "testitem[test_property]", value: "ok" }], {
skipEmpty: false
});

expect(result).toEqual({
testitem: {
test_property: "ok"
}
});
});

it("supports mixed indexed rails arrays and nested object traversal", () => {
const result = entriesToObject(
[
{ key: "tables[1][features][0][title]", value: "Feature A" },
{ key: "something[something][title]", value: "Nested" },
{ key: "something[description]", value: "Test" }
],
{ skipEmpty: false }
);

expect(result).toEqual({
tables: [
{
features: [
{
title: "Feature A"
}
]
}
],
something: {
something: {
title: "Nested"
},
description: "Test"
}
});
});

it("skips empty and null values by default", () => {
const result = entriesToObject([
{ key: "a", value: "" },
Expand Down
12 changes: 11 additions & 1 deletion packages/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,17 @@ function getFieldValue(fieldNode: Node, getDisabled: boolean): unknown {
if (fieldNode.checked && fieldNode.value === "false") {
return false;
}
// eslint-disable-next-line no-fallthrough

if (fieldNode.checked && fieldNode.value === "true") {
return true;
}

if (fieldNode.checked) {
return fieldNode.value;
}

return null;

case "checkbox":
if (fieldNode.checked && fieldNode.value === "true") {
return true;
Expand Down
34 changes: 34 additions & 0 deletions packages/dom/test/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ describe("extractPairs", () => {
expect(result).toContainEqual({ key: "person.colors", value: ["red", "green"] });
});

it("extracts nested form controls inside arbitrary container markup", () => {
document.body.innerHTML = `
<form id="testForm">
<div class="wrapper">
<section>
<input type="text" name="person.name.first" value="John" />
</section>
</div>
</form>
`;

const form = document.getElementById("testForm") as HTMLFormElement;
const result = extractPairs(form);

expect(result).toEqual([{ key: "person.name.first", value: "John" }]);
});

it("supports callback extraction", () => {
document.body.innerHTML = `
<form id="testForm">
Expand Down Expand Up @@ -71,6 +88,23 @@ describe("formToObject", () => {
});
});

it("does not coerce an empty checked radio option to false when true and false siblings exist", () => {
document.body.innerHTML = `
<form id="testForm">
<input type="radio" name="state" value="" checked />
<input type="radio" name="state" value="true" />
<input type="radio" name="state" value="false" />
</form>
`;

const form = document.getElementById("testForm") as HTMLFormElement;
const result = formToObject(form, { skipEmpty: false });

expect(result).toEqual({
state: ""
});
});

it("supports id fallback and disabled field extraction", () => {
document.body.innerHTML = `
<form id="testForm">
Expand Down
40 changes: 39 additions & 1 deletion packages/js2form/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,46 @@ function shouldSkipNodeAssignment(node: Node, nodeCallback: ObjectToFormNodeCall
}

function normalizeName(name: string, delimiter: string, arrayIndexes: ArrayIndexesMap): string {
let nameToNormalize = name;
const rawChunks = name.split(delimiter);
const normalizedRawChunks: string[] = [];

for (const rawChunk of rawChunks) {
const bracketMatches = Array.from(rawChunk.matchAll(/\[([^\]]*)\]/g));
if (bracketMatches.length === 0) {
normalizedRawChunks.push(rawChunk);
continue;
}

let currentChunk = rawChunk.slice(0, bracketMatches[0]?.index ?? 0);

for (const match of bracketMatches) {
const bracketContent = match[1] ?? "";
const isArraySegment = bracketContent === "" || /^\d+$/.test(bracketContent);

if (isArraySegment) {
currentChunk = `${currentChunk}[${bracketContent}]`;
Comment on lines +117 to +125

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep sibling [] Rails fields on the same array item

normalizeName() now routes Rails names like items[][title] and items[][description] through the empty-index array path, but each occurrence gets a fresh synthetic index. In mapFieldsByName() this produces items[0].title and items[1].description, so objectToForm() only fills one field for { items: [{ title, description }] } and leaves the other blank. This breaks a common Rails nested-form shape whenever array object fields omit explicit numeric indexes.

Useful? React with 👍 / 👎.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid issue. Fixed in 95e4d1a by keeping sibling Rails empty-index fields on the same synthetic array item until a sibling path repeats, so items[][title] and items[][description] map to the same object slot. Added a regression test for that shape in packages/js2form/test/js2form.test.ts and re-ran npm test.

continue;
}

if (currentChunk !== "") {
normalizedRawChunks.push(currentChunk);
}

currentChunk = bracketContent;
}

if (currentChunk !== "") {
normalizedRawChunks.push(currentChunk);
}
}

if (normalizedRawChunks.length > 0) {
nameToNormalize = normalizedRawChunks.join(delimiter);
}

const normalizedNameChunks: string[] = [];
const chunks = name.replace(ARRAY_OF_ARRAYS_REGEXP, "[$1].[$2]").split(delimiter);
const chunks = nameToNormalize.replace(ARRAY_OF_ARRAYS_REGEXP, "[$1].[$2]").split(delimiter);

for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
const currentChunk = chunks[chunkIndex] ?? "";
Expand Down
19 changes: 19 additions & 0 deletions packages/js2form/test/js2form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ describe("objectToForm", () => {
const input = customDocument.getElementById("person.name.first") as HTMLInputElement;
expect(input.value).toBe("Neo");
});

it("populates rails-style field names", () => {
document.body.innerHTML = `
<form id="testForm">
<input name="a[b][c]" />
</form>
`;

objectToForm("testForm", {
a: {
b: {
c: "value"
}
}
});

const input = document.querySelector("input[name='a[b][c]']") as HTMLInputElement;
expect(input.value).toBe("value");
});
});

describe("low-level helpers", () => {
Expand Down
Loading