Skip to content
Merged
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
63 changes: 39 additions & 24 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,34 +78,49 @@ 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 bracketMatches = Array.from(rawPart.matchAll(/\[([^\]]*)\]/g));
if (bracketMatches.length === 0) {
nameParts.push(rawPart);
continue;
}

let currentPart = "";
let cursor = 0;

for (const match of bracketMatches) {
const literalText = rawPart.slice(cursor, match.index ?? cursor);
if (literalText !== "") {
currentPart += literalText;
}

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

if (isArraySegment) {
if (currentPart !== "" && currentPart.endsWith("]")) {
nameParts.push(currentPart);
currentPart = "";
}

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);
currentPart = `${currentPart}[${bracketContent}]`;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} else {
if (currentPart !== "") {
nameParts.push(currentPart);
}

currentPart = bracketContent;
}
} else {
nameParts.push(...splitByRailsChunk);

cursor = (match.index ?? cursor) + match[0].length;
}

const trailingText = rawPart.slice(cursor);
if (trailingText !== "") {
currentPart += trailingText;
}

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

Expand Down
82 changes: 82 additions & 0 deletions packages/core/test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,88 @@ 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("supports consecutive indexed segments for nested arrays", () => {
const result = entriesToObject([{ key: "foo[0][1][bar]", value: "baz" }], {
skipEmpty: false
});

expect(result).toEqual({
foo: [
[
{
bar: "baz"
}
]
]
});
});

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
85 changes: 82 additions & 3 deletions packages/js2form/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type ArrayIndexesMap = Record<
{
lastIndex: number;
indexes: Record<string, number>;
emptyIndexGroup?: {
index: number;
seenSuffixes: Set<string>;
};
}
>;

Expand Down Expand Up @@ -89,8 +93,63 @@ 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 = "";
let cursor = 0;

for (const match of bracketMatches) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const literalText = rawChunk.slice(cursor, match.index ?? cursor);
if (literalText !== "") {
currentChunk += literalText;
}

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

if (isArraySegment) {
if (currentChunk !== "" && currentChunk.endsWith("]")) {
normalizedRawChunks.push(currentChunk);
currentChunk = "";
}

currentChunk = `${currentChunk}[${bracketContent}]`;
Comment on lines +117 to +125
Copy link
Copy Markdown

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
Copy Markdown
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.

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

currentChunk = bracketContent;
}

cursor = (match.index ?? cursor) + match[0].length;
}

const trailingText = rawChunk.slice(cursor);
if (trailingText !== "") {
currentChunk += trailingText;
}

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 All @@ -110,12 +169,32 @@ function normalizeName(name: string, delimiter: string, arrayIndexes: ArrayIndex
indexes: {}
});

if (currentIndex === "" || arrayIndexInfo.indexes[currentIndex] === undefined) {
if (currentIndex === "") {
const remainingPath = chunks.slice(chunkIndex + 1).join(delimiter);
const currentGroup = arrayIndexInfo.emptyIndexGroup;

if (
!currentGroup ||
remainingPath === "" ||
currentGroup.seenSuffixes.has(remainingPath)
) {
arrayIndexInfo.lastIndex += 1;
arrayIndexInfo.emptyIndexGroup = {
index: arrayIndexInfo.lastIndex,
seenSuffixes: new Set(remainingPath === "" ? [] : [remainingPath])
};
} else {
currentGroup.seenSuffixes.add(remainingPath);
}
} else if (arrayIndexInfo.indexes[currentIndex] === undefined) {
arrayIndexInfo.lastIndex += 1;
arrayIndexInfo.indexes[currentIndex] = arrayIndexInfo.lastIndex;
}

const newIndex = arrayIndexInfo.indexes[currentIndex];
const newIndex =
currentIndex === ""
? (arrayIndexInfo.emptyIndexGroup?.index ?? 0)
: arrayIndexInfo.indexes[currentIndex];
normalizedNameChunks[normalizedNameChunks.length - 1] = currentChunk.replace(
LAST_INDEXED_ARRAY_REGEXP,
`$1$2${newIndex}$4`
Expand Down
Loading
Loading