Improve admin form workspace UX

This commit is contained in:
2026-07-27 19:11:27 +07:00 Unverified
parent f988f60504
commit bc856402e9
33 changed files with 2194 additions and 596 deletions
+133
View File
@@ -0,0 +1,133 @@
import { db } from "@/db";
import { submissions } from "@/db/schema";
import {
and,
asc,
count,
desc,
eq,
gte,
ilike,
lt,
or,
sql,
type SQL,
} from "drizzle-orm";
export const RESULTS_PAGE_SIZE = 25;
export type ResultFilters = {
q: string;
page: number;
sort: "submittedAt" | "respondent";
direction: "asc" | "desc";
edited: "all" | "edited" | "unedited";
dateFrom: string;
dateTo: string;
};
export function parseResultFilters(
params: Record<string, string | string[] | undefined>
): ResultFilters {
const value = (key: string) => {
const entry = params[key];
return Array.isArray(entry) ? entry[0] ?? "" : entry ?? "";
};
const page = Number.parseInt(value("page"), 10);
const sort = value("sort");
const direction = value("direction");
const edited = value("edited");
return {
q: value("q").trim().slice(0, 100),
page: Number.isFinite(page) && page > 0 ? page : 1,
sort: sort === "respondent" ? "respondent" : "submittedAt",
direction: direction === "asc" ? "asc" : "desc",
edited:
edited === "edited" || edited === "unedited" ? edited : "all",
dateFrom: /^\d{4}-\d{2}-\d{2}$/.test(value("dateFrom"))
? value("dateFrom")
: "",
dateTo: /^\d{4}-\d{2}-\d{2}$/.test(value("dateTo"))
? value("dateTo")
: "",
};
}
function bangkokBoundary(date: string, end: boolean) {
if (!date) return null;
const value = new Date(`${date}T00:00:00+07:00`);
if (end) value.setUTCDate(value.getUTCDate() + 1);
return value;
}
function buildWhere(formId: string, filters: ResultFilters) {
const conditions: SQL[] = [eq(submissions.formId, formId)];
if (filters.q) {
const query = `%${filters.q}%`;
conditions.push(
or(
ilike(submissions.userName, query),
ilike(submissions.userDiscordId, query)
)!
);
}
if (filters.edited === "edited") {
conditions.push(sql`jsonb_array_length(coalesce(${submissions.editHistory}, '[]'::jsonb)) > 0`);
}
if (filters.edited === "unedited") {
conditions.push(sql`jsonb_array_length(coalesce(${submissions.editHistory}, '[]'::jsonb)) = 0`);
}
const from = bangkokBoundary(filters.dateFrom, false);
const to = bangkokBoundary(filters.dateTo, true);
if (from) conditions.push(gte(submissions.submittedAt, from));
if (to) conditions.push(lt(submissions.submittedAt, to));
return and(...conditions)!;
}
export async function getFilteredSubmissions(
formId: string,
filters: ResultFilters,
options: { paginate?: boolean } = {}
) {
const where = buildWhere(formId, filters);
const orderDirection = filters.direction === "asc" ? asc : desc;
const order =
filters.sort === "respondent"
? orderDirection(submissions.userName)
: orderDirection(submissions.submittedAt);
const [{ total }] = await db
.select({ total: count() })
.from(submissions)
.where(where);
const baseQuery = db
.select()
.from(submissions)
.where(where)
.orderBy(order, orderDirection(submissions.id));
const rows =
options.paginate === false
? await baseQuery
: await baseQuery
.limit(RESULTS_PAGE_SIZE)
.offset((filters.page - 1) * RESULTS_PAGE_SIZE);
return { rows, total };
}
export function resultFiltersToSearchParams(
filters: ResultFilters,
overrides: Partial<ResultFilters> = {}
) {
const next = { ...filters, ...overrides };
const params = new URLSearchParams();
if (next.q) params.set("q", next.q);
if (next.page > 1) params.set("page", String(next.page));
if (next.sort !== "submittedAt") params.set("sort", next.sort);
if (next.direction !== "desc") params.set("direction", next.direction);
if (next.edited !== "all") params.set("edited", next.edited);
if (next.dateFrom) params.set("dateFrom", next.dateFrom);
if (next.dateTo) params.set("dateTo", next.dateTo);
return params.toString();
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
type FlushSave = () => Promise<void>;
const pendingSaves = new Set<FlushSave>();
export function registerPendingSave(flush: FlushSave) {
pendingSaves.add(flush);
return () => {
pendingSaves.delete(flush);
};
}
export async function flushPendingSaves() {
await Promise.all([...pendingSaves].map((flush) => flush()));
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import {
isValidDiscordWebhookUrl,
previewWebhookTemplate,
} from "@/lib/discord-webhook-settings";
import {
parseResultFilters,
resultFiltersToSearchParams,
} from "@/lib/admin-form-results";
describe("Discord webhook settings", () => {
test("accepts only Discord webhook endpoints", () => {
expect(
isValidDiscordWebhookUrl(
"https://discord.com/api/webhooks/123/secret-token"
)
).toBe(true);
expect(isValidDiscordWebhookUrl("https://example.com/api/webhooks/1/2")).toBe(
false
);
expect(isValidDiscordWebhookUrl("not a URL")).toBe(false);
});
test("previews known variables without removing unknown variables", () => {
expect(
previewWebhookTemplate("{username}: {1} {unknown}")
).toBe("Example User: Example answer 1 {unknown}");
expect(previewWebhookTemplate("{update}", { update: true })).toContain(
"Previous answer"
);
});
});
describe("response filters", () => {
test("normalizes unsupported and unsafe query values", () => {
expect(
parseResultFilters({
page: "-5",
sort: "other",
direction: "sideways",
edited: "maybe",
dateFrom: "27/07/2026",
})
).toEqual({
q: "",
page: 1,
sort: "submittedAt",
direction: "desc",
edited: "all",
dateFrom: "",
dateTo: "",
});
});
test("preserves active filters when changing page", () => {
const filters = parseResultFilters({
q: "Erika",
edited: "edited",
dateFrom: "2026-07-01",
});
const params = new URLSearchParams(
resultFiltersToSearchParams(filters, { page: 2 })
);
expect(params.get("q")).toBe("Erika");
expect(params.get("edited")).toBe("edited");
expect(params.get("page")).toBe("2");
});
});
+37
View File
@@ -0,0 +1,37 @@
const DISCORD_WEBHOOK_HOSTS = new Set([
"discord.com",
"canary.discord.com",
"ptb.discord.com",
]);
export function isValidDiscordWebhookUrl(value: string) {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
DISCORD_WEBHOOK_HOSTS.has(url.hostname) &&
/^\/api\/webhooks\/[^/]+\/[^/]+\/?$/.test(url.pathname)
);
} catch {
return false;
}
}
export function previewWebhookTemplate(
template: string,
options: { update?: boolean } = {}
) {
const source =
template.trim() ||
(options.update
? "Submission updated by {username} ({id}).\n{update}"
: "New submission from {username} ({id}).");
return source.replace(/\{([^}]+)\}/g, (_match, variable: string) => {
const key = variable.toLowerCase();
if (key === "username" || key === "displayname") return "Example User";
if (key === "id") return "123456789";
if (key === "update") return "Question 1: Previous answer → Example answer";
if (/^\d+$/.test(key)) return `Example answer ${key}`;
return `{${variable}}`;
});
}