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;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function fileInputToBase64({
|
||||
return { fileName: inputFile.name };
|
||||
}
|
||||
|
||||
let fileName = inputFile.name?.replace(/\..*/, "");
|
||||
let fileName = inputFile.name?.replace(/\.[^.]*$/, "");
|
||||
const file = inputFile;
|
||||
|
||||
try {
|
||||
@@ -34,8 +34,8 @@ export default async function fileInputToBase64({
|
||||
reader.onload = function () {
|
||||
resolve(reader.result?.toString());
|
||||
};
|
||||
reader.onerror = function (/** @type {*} */ error: any) {
|
||||
console.log("File Input to Base64 Error: ", error.message);
|
||||
reader.onerror = function () {
|
||||
reject(reader.error);
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export default async function imageInputToBase64({
|
||||
throw new Error("No Files found");
|
||||
}
|
||||
|
||||
let imageName = finalFile.name.replace(/\..*/, "");
|
||||
let imageName = finalFile.name.replace(/\.[^.]*$/, "");
|
||||
|
||||
const MIME_TYPE = mimeType ? mimeType : finalFile.type;
|
||||
const QUALITY = 0.95;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import twuiLinkMatchesPath from "./link-matches-path";
|
||||
|
||||
describe("twuiLinkMatchesPath", () => {
|
||||
test("matches the exact path", () => {
|
||||
expect(twuiLinkMatchesPath({ href: "/blog", pathname: "/blog" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("matches sub-paths when not strict", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({ href: "/blog", pathname: "/blog/post-1" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match sub-paths when strict", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({
|
||||
href: "/blog",
|
||||
pathname: "/blog/post-1",
|
||||
strict: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not match a path that only shares a prefix", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({ href: "/blog", pathname: "/blogging" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("root link only matches the root path", () => {
|
||||
expect(twuiLinkMatchesPath({ href: "/", pathname: "/" })).toBe(true);
|
||||
expect(twuiLinkMatchesPath({ href: "/", pathname: "/about" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores trailing slashes, query strings and hashes", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({ href: "/docs/?tab=1#top", pathname: "/docs" }),
|
||||
).toBe(true);
|
||||
expect(twuiLinkMatchesPath({ href: "/docs", pathname: "/docs/" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("never matches links to other origins", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({
|
||||
href: "https://example.com/blog",
|
||||
pathname: "/blog",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not match hash-only links", () => {
|
||||
expect(
|
||||
twuiLinkMatchesPath({ href: "#section", pathname: "/blog" }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
type Params = {
|
||||
href: string;
|
||||
pathname: string;
|
||||
/** Only match the exact path, not its sub-paths */
|
||||
strict?: boolean;
|
||||
};
|
||||
|
||||
function normalizePath(path: string) {
|
||||
const trimmed = path.replace(/\/+$/, "");
|
||||
return trimmed || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* # Check whether a link points to the current page
|
||||
* A non-strict link matches its own path and any sub-path, segment-wise:
|
||||
* `/blog` matches `/blog` and `/blog/post`, but not `/blogging`. The root
|
||||
* link `/` only ever matches `/`. Links to other origins never match.
|
||||
*/
|
||||
export default function twuiLinkMatchesPath({
|
||||
href,
|
||||
pathname,
|
||||
strict,
|
||||
}: Params): boolean {
|
||||
if (href.startsWith("#")) return false;
|
||||
|
||||
let linkPath: string;
|
||||
|
||||
try {
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "http://twui.local";
|
||||
const url = new URL(href, origin);
|
||||
if (url.origin !== origin) return false;
|
||||
linkPath = normalizePath(url.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPath = normalizePath(pathname);
|
||||
|
||||
if (strict || linkPath === "/") return linkPath === currentPath;
|
||||
|
||||
return currentPath === linkPath || currentPath.startsWith(`${linkPath}/`);
|
||||
}
|
||||
@@ -57,4 +57,9 @@ describe("twuiSerializeQuery", () => {
|
||||
const result = twuiSerializeQuery({ active: true as any });
|
||||
expect(result).toContain("active=true");
|
||||
});
|
||||
|
||||
test("keeps zero values", () => {
|
||||
const result = twuiSerializeQuery({ page: 0, foo: "bar" });
|
||||
expect(result).toContain("page=0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function twuiSerializeQuery(query: any): string {
|
||||
const queryArr: string[] = [];
|
||||
|
||||
keys.forEach((key) => {
|
||||
if (!key || !query[key]) return;
|
||||
const value = query[key];
|
||||
if (!key || (!value && value !== 0)) return;
|
||||
|
||||
if (typeof value === "object") {
|
||||
const jsonStr = TWUIEJSON.stringify(value);
|
||||
|
||||
@@ -69,4 +69,8 @@ describe("twuiSlugify", () => {
|
||||
test("handles number-only input", () => {
|
||||
expect(twuiSlugify("123")).toBe("123");
|
||||
});
|
||||
|
||||
test("trims leading and trailing underscore dividers", () => {
|
||||
expect(twuiSlugify("!Hello World!", "_")).toBe("hello_world");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,13 +23,13 @@ export default function twuiSlugify(
|
||||
.replace(/ /g, finalSlugDivider)
|
||||
.replace(/[^a-z0-9]/g, finalSlugDivider)
|
||||
.replace(/-{2,}|_{2,}/g, finalSlugDivider)
|
||||
.replace(/^-/, "");
|
||||
.replace(/^[-_]/, "");
|
||||
|
||||
if (allowTrailingDash) {
|
||||
return finalStr;
|
||||
}
|
||||
|
||||
return finalStr.replace(/-$/, "");
|
||||
return finalStr.replace(/[-_]$/, "");
|
||||
} catch (error: any) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user