Fix component bugs across hooks, elements, form, layout, editors and utils
- Hooks: per-instance timers in useReady; cleanup for useIntersectionObserver; useWebSocket no longer reconnects after unmount/refresh, drains the queue and dispatches non-JSON messages - Modal/Popover/Dropdown: onClose only on close, Escape to close, no stray DOM props, hooks before early return, scrollbar-aware positioning, top-left/top-right dropdown positions - Elements: Toast/Search/StarRating per-instance timers, Pagination labels and empty state, Breadcrumbs titles, LinkList dividers/props, segment-wise active-link matching (new utils/link-matches-path), Table object cells - Form: Select with a default option is changeable, Input clear notifies controlled parents and copy works on textareas, Checkbox random label, NumberInputButtons runaway interval, upload prop/spread fixes - Layout: Button disabled while loading, Stack gap and Span truncate via inline styles, Img fallbackImageSrc, className/JSDoc fixes - Editors/MDX/AI: TinyMCE id/cleanup/stale handler/duplicate scripts, AceEditor debounce, markdown heights and frontmatter regex, last assistant message, AIPromptBlock prompt ref - Utils: fetchApi headers/FormData/fetchOptions/no mutation, base64 file names and read errors, slugify underscore trim, serializeQuery zero values - Tests for fetchApi, link matching, slugify and serializeQuery - AGENTS.md updated for the package layout Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import fetchApi from "./fetchApi";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls: { url: string; init?: RequestInit }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
globalThis.fetch = (async (url: string, init?: RequestInit) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(JSON.stringify({ success: true }));
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchApi", () => {
|
||||
test("JSON-encodes object bodies without mutating the options", async () => {
|
||||
const options = { method: "POST" as const, body: { a: 1 } };
|
||||
await fetchApi("/api", options);
|
||||
|
||||
expect(calls[0]?.init?.body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect(options.body).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("caller headers override the default Content-Type", async () => {
|
||||
await fetchApi("/api", {
|
||||
method: "POST",
|
||||
body: "raw",
|
||||
headers: { "content-type": "text/plain" } as any,
|
||||
});
|
||||
|
||||
const headers = calls[0]?.init?.headers as Record<string, string>;
|
||||
expect(headers["content-type"]).toBe("text/plain");
|
||||
expect(headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sends FormData as-is and lets the browser set Content-Type", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", "content");
|
||||
|
||||
await fetchApi("/api", { method: "POST", body: formData });
|
||||
|
||||
const headers = calls[0]?.init?.headers as Record<string, string>;
|
||||
expect(calls[0]?.init?.body).toBe(formData);
|
||||
expect(headers["Content-Type"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("uppercases the method and applies fetchOptions", async () => {
|
||||
await fetchApi("/api", {
|
||||
method: "patch",
|
||||
fetchOptions: { credentials: "include" },
|
||||
});
|
||||
|
||||
expect(calls[0]?.init?.method).toBe("PATCH");
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
});
|
||||
|
||||
test("adds the CSRF header and serializes the query", async () => {
|
||||
await fetchApi("/api", {
|
||||
method: "GET",
|
||||
csrfKey: "x-csrf",
|
||||
csrfValue: "token",
|
||||
query: { page: 2 },
|
||||
});
|
||||
|
||||
const headers = calls[0]?.init?.headers as Record<string, string>;
|
||||
expect(calls[0]?.url).toBe("/api?page=2");
|
||||
expect(headers["x-csrf"]).toBe("token");
|
||||
});
|
||||
|
||||
test("returns an error object when the request fails", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("Network down");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const res = await fetchApi("/api", { method: "GET" });
|
||||
expect(res).toEqual({ success: false, msg: "Network down" });
|
||||
});
|
||||
});
|
||||
+147
-123
@@ -1,123 +1,147 @@
|
||||
import _ from "lodash";
|
||||
import twuiSerializeQuery from "../serialize-query";
|
||||
|
||||
export const FetchAPIMethods = [
|
||||
"POST",
|
||||
"GET",
|
||||
"DELETE",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"post",
|
||||
"get",
|
||||
"delete",
|
||||
"put",
|
||||
"patch",
|
||||
] as const;
|
||||
|
||||
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
|
||||
method: (typeof FetchAPIMethods)[number];
|
||||
body?: T | string;
|
||||
headers?: FetchHeader;
|
||||
query?: T;
|
||||
csrfValue?: string;
|
||||
csrfKey?: string;
|
||||
fetchOptions?: RequestInit;
|
||||
};
|
||||
|
||||
type FetchHeader = HeadersInit & {
|
||||
[key: string]: string | null;
|
||||
};
|
||||
|
||||
export type FetchApiReturn = {
|
||||
success: boolean;
|
||||
payload: any;
|
||||
msg?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* # Fetch API
|
||||
*/
|
||||
export default async function fetchApi<
|
||||
T extends { [k: string]: any } = { [k: string]: any },
|
||||
R extends any = any,
|
||||
>(url: string, options?: FetchApiOptions<T>): Promise<R> {
|
||||
let data;
|
||||
|
||||
let finalHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
} as FetchHeader;
|
||||
|
||||
if (options?.csrfKey && options.csrfValue) {
|
||||
finalHeaders[options.csrfKey] = options.csrfValue;
|
||||
}
|
||||
|
||||
let finalURL = url;
|
||||
|
||||
if (options?.query) {
|
||||
finalURL += twuiSerializeQuery(options.query);
|
||||
}
|
||||
|
||||
if (typeof options === "string") {
|
||||
try {
|
||||
let fetchData;
|
||||
|
||||
switch (options) {
|
||||
case "post":
|
||||
fetchData = await fetch(finalURL, {
|
||||
method: options,
|
||||
headers: finalHeaders,
|
||||
} as RequestInit);
|
||||
data = await fetchData.json();
|
||||
break;
|
||||
|
||||
default:
|
||||
fetchData = await fetch(finalURL);
|
||||
data = await fetchData.json();
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #1:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
} else if (typeof options === "object") {
|
||||
try {
|
||||
let fetchData;
|
||||
|
||||
if (options.body && typeof options.body === "object") {
|
||||
let oldOptionsBody = _.cloneDeep(options.body);
|
||||
options.body = JSON.stringify(oldOptionsBody);
|
||||
}
|
||||
|
||||
if (options.headers) {
|
||||
options.headers = _.merge(options.headers, finalHeaders);
|
||||
|
||||
const finalOptions: any = { ...options };
|
||||
|
||||
fetchData = await fetch(finalURL, finalOptions);
|
||||
} else {
|
||||
const finalOptions = {
|
||||
...options,
|
||||
headers: finalHeaders,
|
||||
} as RequestInit;
|
||||
|
||||
fetchData = await fetch(finalURL, finalOptions);
|
||||
}
|
||||
|
||||
data = await fetchData.json();
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #2:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
let fetchData = await fetch(finalURL);
|
||||
data = await fetchData.json();
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #3:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
import twuiSerializeQuery from "../serialize-query";
|
||||
|
||||
export const FetchAPIMethods = [
|
||||
"POST",
|
||||
"GET",
|
||||
"DELETE",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"post",
|
||||
"get",
|
||||
"delete",
|
||||
"put",
|
||||
"patch",
|
||||
] as const;
|
||||
|
||||
type FetchApiOptions<T extends { [k: string]: any } = { [k: string]: any }> = {
|
||||
method: (typeof FetchAPIMethods)[number];
|
||||
body?: T | string | BodyInit;
|
||||
headers?: FetchHeader;
|
||||
query?: T;
|
||||
csrfValue?: string;
|
||||
csrfKey?: string;
|
||||
fetchOptions?: RequestInit;
|
||||
};
|
||||
|
||||
type FetchHeader = HeadersInit & {
|
||||
[key: string]: string | null;
|
||||
};
|
||||
|
||||
export type FetchApiReturn = {
|
||||
success: boolean;
|
||||
payload: any;
|
||||
msg?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
function isNativeBody(body: unknown): body is BodyInit {
|
||||
return (
|
||||
(typeof FormData !== "undefined" && body instanceof FormData) ||
|
||||
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
||||
(typeof URLSearchParams !== "undefined" &&
|
||||
body instanceof URLSearchParams) ||
|
||||
body instanceof ArrayBuffer ||
|
||||
ArrayBuffer.isView(body)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* # Fetch API
|
||||
*/
|
||||
export default async function fetchApi<
|
||||
T extends { [k: string]: any } = { [k: string]: any },
|
||||
R extends any = any,
|
||||
>(url: string, options?: FetchApiOptions<T>): Promise<R> {
|
||||
let data;
|
||||
|
||||
let finalHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
} as FetchHeader;
|
||||
|
||||
if (options?.csrfKey && options.csrfValue) {
|
||||
finalHeaders[options.csrfKey] = options.csrfValue;
|
||||
}
|
||||
|
||||
if (options?.headers) {
|
||||
for (const [key, value] of Object.entries(options.headers)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
// Header names are case-insensitive; caller values win
|
||||
for (const existingKey of Object.keys(finalHeaders)) {
|
||||
if (existingKey.toLowerCase() === key.toLowerCase()) {
|
||||
delete finalHeaders[existingKey];
|
||||
}
|
||||
}
|
||||
finalHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let finalURL = url;
|
||||
|
||||
if (options?.query) {
|
||||
finalURL += twuiSerializeQuery(options.query);
|
||||
}
|
||||
|
||||
if (typeof options === "string") {
|
||||
try {
|
||||
let fetchData;
|
||||
|
||||
switch (options) {
|
||||
case "post":
|
||||
fetchData = await fetch(finalURL, {
|
||||
method: options,
|
||||
headers: finalHeaders,
|
||||
} as RequestInit);
|
||||
data = await fetchData.json();
|
||||
break;
|
||||
|
||||
default:
|
||||
fetchData = await fetch(finalURL);
|
||||
data = await fetchData.json();
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #1:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
} else if (typeof options === "object") {
|
||||
try {
|
||||
let body: BodyInit | undefined;
|
||||
|
||||
if (isNativeBody(options.body)) {
|
||||
body = options.body;
|
||||
// Let the browser set the multipart boundary etc.
|
||||
if (finalHeaders["Content-Type"] === "application/json") {
|
||||
delete finalHeaders["Content-Type"];
|
||||
}
|
||||
} else if (options.body && typeof options.body === "object") {
|
||||
body = JSON.stringify(options.body);
|
||||
} else {
|
||||
body = options.body as BodyInit | undefined;
|
||||
}
|
||||
|
||||
const finalOptions: RequestInit = {
|
||||
...options.fetchOptions,
|
||||
method: options.method?.toUpperCase(),
|
||||
headers: finalHeaders as HeadersInit,
|
||||
body,
|
||||
};
|
||||
|
||||
const fetchData = await fetch(finalURL, finalOptions);
|
||||
|
||||
data = await fetchData.json();
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #2:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
let fetchData = await fetch(finalURL);
|
||||
data = await fetchData.json();
|
||||
} catch (error: any) {
|
||||
console.log("FetchAPI error #3:", error.message);
|
||||
data = { success: false, msg: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user