Improve admin form workspace UX
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}}`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user