Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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}]`;
} 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
57 changes: 56 additions & 1 deletion packages/js2form/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,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) {
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}]`;
} 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 Down
30 changes: 30 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 Expand Up @@ -324,4 +343,15 @@ describe("low-level helpers", () => {
expect(Object.keys(fields)).not.toContain("person.colors[1]");
expect(Object.keys(fields)).not.toContain("person.colors[2]");
});

it("preserves literal suffixes around indexed bracket groups", () => {
document.body.innerHTML = `
<form id="testForm">
<input name="items[5]_id" value="alpha" />
</form>
`;

const fields = mapFieldsByName("testForm", { shouldClean: false });
expect(Object.keys(fields)).toContain("items[5]_id");
});
});
Loading