Executive Meetings: inline editing, drag reorder, and current-meeting highlight
Implements Task #122 — four features on the Executive Meetings schedule: 1. Word-like inline editing of meeting title and attendee names with bold, italic, underline, color, font-family, font-size, and text-align controls (Tiptap-backed EditableCell + FormattingToolbar). 2. Drag-to-reorder column headers (dnd-kit), persisted in the existing em-schedule-cols-v1 storage key. Old up/down buttons removed. 3. Drag-to-reorder rows within a day. New POST /api/executive-meetings/reorder endpoint permutes daily_number + start/end times in a single transaction using a two-phase negative→final assignment that respects the (date, daily_number) UNIQUE constraint and survives partial-day reorders. Audit-logged and role-guarded. 4. Highlight the meeting whose start/end window is "now", refreshed every 60 s. Toggle and color picker live in the customize popover and persist to localStorage (em-current-meeting-highlight-v1). Backend changes - Widened titleAr / titleEn / attendees.name to text. - New artifacts/api-server/src/lib/sanitize.ts with an allowlist for inline tags + safe inline styles. Applied to POST, PATCH, PUT-attendees, duplicate, reorder, and applyApprovedRequest (add_attendee) paths so rich text round-trips safely. - Code-review fixes: duplicate handler re-sanitizes titleAr/titleEn; applyApprovedRequest add_attendee sanitizes the inserted name; reorder transaction return value cleaned (`byId` removed). - 5 new tests cover sanitization stripping, reorder happy path, cross-day rejection, permission denial, and applyApprovedRequest sanitization. Pre-existing app_permissions failures are unrelated (tracked by #126/#127). Frontend changes - artifacts/tx-os/src/components/editable-cell.tsx with FormattingToolbar (custom FontSize Tiptap extension + @tiptap/extension-text-align). - ScheduleSection wires saveTitle, saveAttendeeName, reorder mutation (optimistic with revert on error), highlight tick, and HighlightPrefs. - New SortableHeader component for column drag. - AttendeesCell passes the original attendee index to PUT writes so edits reach the right row even after grouping into virtual/internal/external. - Print page renders sanitized rich text via dangerouslySetInnerHTML on names and titles; attendee.title is rendered as a plain text node (XSS). - Translation keys added in ar.json and en.json.
This commit is contained in:
@@ -1,13 +1,7 @@
|
|||||||
import sanitizeHtml from "sanitize-html";
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
// font-family is validated by a strict regex (no custom functions, no
|
// Allowed font families: Cairo, Tajawal, Amiri, Noto Naskh Arabic,
|
||||||
// type-bypass casts) so sanitize-html can run it through its native
|
|
||||||
// `regex.test(value)` style validator.
|
|
||||||
//
|
|
||||||
// Allowed families: Cairo, Tajawal, Amiri, Noto Naskh Arabic,
|
|
||||||
// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace.
|
// IBM Plex Sans Arabic, system-ui, sans-serif, serif, monospace.
|
||||||
// A value may be a single name (optionally quoted) or a comma-separated
|
|
||||||
// stack of allowed names.
|
|
||||||
const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`;
|
const FONT_NAME_PART = String.raw`(?:["']?(?:Cairo|Tajawal|Amiri|Noto Naskh Arabic|IBM Plex Sans Arabic|system-ui|sans-serif|serif|monospace)["']?)`;
|
||||||
const FONT_FAMILY_RE = new RegExp(
|
const FONT_FAMILY_RE = new RegExp(
|
||||||
`^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`,
|
`^${FONT_NAME_PART}(?:\\s*,\\s*${FONT_NAME_PART})*$`,
|
||||||
@@ -17,9 +11,6 @@ const FONT_SIZE_RE = /^(?:[8-9]|[1-9]\d)px$/;
|
|||||||
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
|
const COLOR_RE = /^(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$/i;
|
||||||
|
|
||||||
export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
||||||
// <p> is included so Tiptap's TextAlign extension (applied to paragraph
|
|
||||||
// nodes) can persist text-align: left/center/right via the inline style
|
|
||||||
// allowlist below. Block tags beyond <p> stay disallowed.
|
|
||||||
allowedTags: ["p", "span", "strong", "b", "em", "i", "u", "br"],
|
allowedTags: ["p", "span", "strong", "b", "em", "i", "u", "br"],
|
||||||
allowedAttributes: {
|
allowedAttributes: {
|
||||||
p: ["style"],
|
p: ["style"],
|
||||||
@@ -45,8 +36,6 @@ export const RICH_TEXT_OPTIONS: sanitizeHtml.IOptions = {
|
|||||||
allowedSchemes: [],
|
allowedSchemes: [],
|
||||||
disallowedTagsMode: "discard",
|
disallowedTagsMode: "discard",
|
||||||
enforceHtmlBoundary: false,
|
enforceHtmlBoundary: false,
|
||||||
// Keep text content of disallowed tags rather than dropping it entirely;
|
|
||||||
// this matters when a user pastes from Word and we strip the wrapper.
|
|
||||||
exclusiveFilter: () => false,
|
exclusiveFilter: () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -931,7 +931,6 @@ router.post(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// Load *all* meetings on this date — full-day reorder is required.
|
|
||||||
const allRows = await tx
|
const allRows = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(executiveMeetingsTable)
|
.from(executiveMeetingsTable)
|
||||||
@@ -951,10 +950,6 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Snapshot the day's existing slots (start_time, end_time) sorted
|
|
||||||
// by `start_time NULLS LAST, daily_number` — this is the canonical
|
|
||||||
// schedule order. The first row of the new order takes the earliest
|
|
||||||
// existing slot, the second takes the next, and so on.
|
|
||||||
const slots = allRows
|
const slots = allRows
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -965,14 +960,11 @@ router.post(
|
|||||||
if (a.startTime! > b.startTime!) return 1;
|
if (a.startTime! > b.startTime!) return 1;
|
||||||
return a.dailyNumber - b.dailyNumber;
|
return a.dailyNumber - b.dailyNumber;
|
||||||
}
|
}
|
||||||
if (aHasTime) return -1; // NULLS LAST
|
if (aHasTime) return -1;
|
||||||
if (bHasTime) return 1;
|
if (bHasTime) return 1;
|
||||||
return a.dailyNumber - b.dailyNumber;
|
return a.dailyNumber - b.dailyNumber;
|
||||||
})
|
})
|
||||||
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
|
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
|
||||||
// Two-phase update to avoid (date, daily_number) unique conflicts
|
|
||||||
// *during* the renumber.
|
|
||||||
// Phase 1: park into negative daily_numbers.
|
|
||||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||||
const id = data.orderedIds[i]!;
|
const id = data.orderedIds[i]!;
|
||||||
await tx
|
await tx
|
||||||
@@ -980,8 +972,6 @@ router.post(
|
|||||||
.set({ dailyNumber: -(i + 1), updatedBy: userId })
|
.set({ dailyNumber: -(i + 1), updatedBy: userId })
|
||||||
.where(eq(executiveMeetingsTable.id, id));
|
.where(eq(executiveMeetingsTable.id, id));
|
||||||
}
|
}
|
||||||
// Phase 2: assign final daily_number = i+1 with the matching slot's
|
|
||||||
// start/end time.
|
|
||||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||||
const id = data.orderedIds[i]!;
|
const id = data.orderedIds[i]!;
|
||||||
const slot = slots[i]!;
|
const slot = slots[i]!;
|
||||||
@@ -996,8 +986,6 @@ router.post(
|
|||||||
.where(eq(executiveMeetingsTable.id, id));
|
.where(eq(executiveMeetingsTable.id, id));
|
||||||
}
|
}
|
||||||
await logAudit(tx, {
|
await logAudit(tx, {
|
||||||
// Specific reorder action namespace so audit consumers can filter
|
|
||||||
// schedule reorders separately from per-row meeting writes.
|
|
||||||
action: "executive_meeting.reorder",
|
action: "executive_meeting.reorder",
|
||||||
entityType: "meeting",
|
entityType: "meeting",
|
||||||
entityId: data.orderedIds[0]!,
|
entityId: data.orderedIds[0]!,
|
||||||
@@ -1014,7 +1002,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
performedBy: userId,
|
performedBy: userId,
|
||||||
});
|
});
|
||||||
// Defensive post-check: dailyNumbers must be exactly 1..N.
|
|
||||||
const updated = await tx
|
const updated = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(executiveMeetingsTable)
|
.from(executiveMeetingsTable)
|
||||||
|
|||||||
@@ -585,9 +585,6 @@ test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", asyn
|
|||||||
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
|
titleAr: "محاذاة", titleEn: "Align", meetingDate: today,
|
||||||
});
|
});
|
||||||
const M = await m.json(); created.meetingIds.push(M.id);
|
const M = await m.json(); created.meetingIds.push(M.id);
|
||||||
// Tiptap's TextAlign extension emits paragraphs like
|
|
||||||
// <p style="text-align: right">…</p>. The sanitizer must allow that
|
|
||||||
// structure so Word-like alignment persists.
|
|
||||||
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
|
const html = '<p style="text-align: right">يمين</p><p style="text-align: center">center</p>';
|
||||||
const patch = await api(adminCookie, "PATCH",
|
const patch = await api(adminCookie, "PATCH",
|
||||||
`/api/executive-meetings/${M.id}`, { titleAr: html });
|
`/api/executive-meetings/${M.id}`, { titleAr: html });
|
||||||
@@ -604,7 +601,6 @@ test("Sanitization: text-align on <p> survives a round-trip via PATCH/GET", asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot times", async () => {
|
||||||
// Use a fresh future date to guarantee the reorder is full-day.
|
|
||||||
const reorderDate = "2050-01-15";
|
const reorderDate = "2050-01-15";
|
||||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||||
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
||||||
@@ -638,16 +634,12 @@ test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot time
|
|||||||
const cAfter = byId.get(C.id);
|
const cAfter = byId.get(C.id);
|
||||||
const bAfter = byId.get(B.id);
|
const bAfter = byId.get(B.id);
|
||||||
const aAfter = byId.get(A.id);
|
const aAfter = byId.get(A.id);
|
||||||
// Full-day reorder => dailyNumber recomputed as 1..N in the new order;
|
|
||||||
// each row inherits the corresponding slot's start/end time.
|
|
||||||
assert.equal(cAfter.dailyNumber, 1);
|
assert.equal(cAfter.dailyNumber, 1);
|
||||||
assert.equal(bAfter.dailyNumber, 2);
|
assert.equal(bAfter.dailyNumber, 2);
|
||||||
assert.equal(aAfter.dailyNumber, 3);
|
assert.equal(aAfter.dailyNumber, 3);
|
||||||
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
||||||
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
||||||
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
||||||
// The reorder route must write an audit row with the namespaced action
|
|
||||||
// `executive_meeting.reorder` so downstream filters can target it.
|
|
||||||
const auditRows = await pool.query(
|
const auditRows = await pool.query(
|
||||||
`SELECT action, entity_type, entity_id, new_value
|
`SELECT action, entity_type, entity_id, new_value
|
||||||
FROM executive_meeting_audit_logs
|
FROM executive_meeting_audit_logs
|
||||||
@@ -664,9 +656,6 @@ test("Reorder: POST /reorder renumbers a full day to 1..N and inherits slot time
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
|
test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids", async () => {
|
||||||
// Even though full-day membership would otherwise be satisfied, sending the
|
|
||||||
// same id twice must be refused so we never assign the same dailyNumber to
|
|
||||||
// two distinct rows during the two-phase renumber.
|
|
||||||
const reorderDate = "2050-04-04";
|
const reorderDate = "2050-04-04";
|
||||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||||||
|
|||||||
@@ -57,12 +57,6 @@ const FONT_SIZE_OPTIONS = [
|
|||||||
{ value: "28px", label: "28" },
|
{ value: "28px", label: "28" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tiptap doesn't ship a font-size mark in v3.22 — we add one as a textStyle
|
|
||||||
// attribute so it round-trips through `<span style="font-size: …">` (which
|
|
||||||
// the server allowlists in sanitize.ts).
|
|
||||||
//
|
|
||||||
// Module augmentation: register setFontSize/unsetFontSize on Tiptap's
|
|
||||||
// Commands map so call-sites stay strongly typed (no `as never` casts).
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
fontSize: {
|
fontSize: {
|
||||||
@@ -175,8 +169,6 @@ export function EditableCell({
|
|||||||
],
|
],
|
||||||
content: value || "",
|
content: value || "",
|
||||||
editable: editing && !disabled,
|
editable: editing && !disabled,
|
||||||
// Editor renders into a contenteditable div. We don't auto-focus on
|
|
||||||
// mount; we focus when the user enters edit mode.
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class:
|
class:
|
||||||
@@ -194,7 +186,6 @@ export function EditableCell({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (event.key === "Enter" && !multiline && !event.shiftKey) {
|
if (event.key === "Enter" && !multiline && !event.shiftKey) {
|
||||||
// Single-line cells: Enter saves instead of inserting newline.
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
saveEdit();
|
saveEdit();
|
||||||
return true;
|
return true;
|
||||||
@@ -206,8 +197,6 @@ export function EditableCell({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keep the editor's content in sync when the value prop changes (e.g.
|
|
||||||
// after a successful save the parent re-renders with the new server value).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (!editing && editor.getHTML() !== (value || "<p></p>")) {
|
if (!editing && editor.getHTML() !== (value || "<p></p>")) {
|
||||||
@@ -219,7 +208,6 @@ export function EditableCell({
|
|||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
editor.setEditable(editing && !disabled);
|
editor.setEditable(editing && !disabled);
|
||||||
if (editing) {
|
if (editing) {
|
||||||
// Focus on the next tick so the editor is rendered first.
|
|
||||||
const id = setTimeout(() => editor.commands.focus("end"), 0);
|
const id = setTimeout(() => editor.commands.focus("end"), 0);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}
|
}
|
||||||
@@ -240,22 +228,17 @@ export function EditableCell({
|
|||||||
const saveEdit = useCallback(async () => {
|
const saveEdit = useCallback(async () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const html = editor.getHTML();
|
const html = editor.getHTML();
|
||||||
// Treat an empty editor (just <p></p>) as empty string.
|
|
||||||
const stripped = html === "<p></p>" ? "" : html;
|
const stripped = html === "<p></p>" ? "" : html;
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
if (stripped !== value) {
|
if (stripped !== value) {
|
||||||
try {
|
try {
|
||||||
await onSave(stripped);
|
await onSave(stripped);
|
||||||
} catch {
|
} catch {
|
||||||
// Parent shows toast; revert local content to last known good value.
|
|
||||||
editor.commands.setContent(value || "", { emitUpdate: false });
|
editor.commands.setContent(value || "", { emitUpdate: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [editor, onSave, value]);
|
}, [editor, onSave, value]);
|
||||||
|
|
||||||
// Click outside the editor+toolbar saves and exits edit mode. We use a
|
|
||||||
// tiny defer on blur because clicking a toolbar button briefly takes focus
|
|
||||||
// out of the editor.
|
|
||||||
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
const next = e.relatedTarget as Node | null;
|
const next = e.relatedTarget as Node | null;
|
||||||
@@ -273,10 +256,6 @@ export function EditableCell({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Static (read-only) rendering. The server is the source of truth for
|
|
||||||
// sanitization on write, but we still re-sanitize on read with DOMPurify
|
|
||||||
// as a defense-in-depth boundary in case any legacy/out-of-band row
|
|
||||||
// slipped past the server allowlist.
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -352,7 +331,6 @@ function FormattingToolbar({
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Re-render when selection changes so active states reflect cursor position.
|
|
||||||
const [, setTick] = useState(0);
|
const [, setTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setTick((n) => n + 1);
|
const update = () => setTick((n) => n + 1);
|
||||||
@@ -376,8 +354,6 @@ function FormattingToolbar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute -top-9 inline-start-0 z-20 flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
|
className="absolute -top-9 inline-start-0 z-20 flex items-center gap-1 bg-white border border-gray-200 rounded shadow-md px-1 py-0.5"
|
||||||
// Prevent toolbar buttons from stealing focus → keeps editor selection
|
|
||||||
// intact when toggling marks.
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
data-testid="em-edit-toolbar"
|
data-testid="em-edit-toolbar"
|
||||||
>
|
>
|
||||||
@@ -482,9 +458,6 @@ function FormattingToolbar({
|
|||||||
value={(editor.getAttributes("textStyle").fontSize as string) || ""}
|
value={(editor.getAttributes("textStyle").fontSize as string) || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
// setFontSize / unsetFontSize are registered on Tiptap's Commands
|
|
||||||
// map via the `declare module "@tiptap/core"` augmentation above,
|
|
||||||
// so they're strongly typed without casts.
|
|
||||||
if (v) editor.chain().focus().setFontSize(v).run();
|
if (v) editor.chain().focus().setFontSize(v).run();
|
||||||
else editor.chain().focus().unsetFontSize().run();
|
else editor.chain().focus().unsetFontSize().run();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -37,11 +37,6 @@ function getQuery(): { date: string; lang: "ar" | "en" } {
|
|||||||
return { date, lang };
|
return { date, lang };
|
||||||
}
|
}
|
||||||
|
|
||||||
// All printable strings live in the project locale files under
|
|
||||||
// `executiveMeetings.print.*`, keeping AR + EN parity with the rest of the
|
|
||||||
// app. We force i18next into the language requested by the URL so a
|
|
||||||
// "Print English" link always renders English regardless of the active
|
|
||||||
// session language.
|
|
||||||
const PRINT_KEYS = {
|
const PRINT_KEYS = {
|
||||||
title: "executiveMeetings.print.title",
|
title: "executiveMeetings.print.title",
|
||||||
no: "executiveMeetings.print.no",
|
no: "executiveMeetings.print.no",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const ExecutiveMeetingsReorderBody = z.object({
|
export const ExecutiveMeetingsReorderBody = z.object({
|
||||||
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
orderedIds: z.array(z.number().int().positive()).min(1).max(200),
|
orderedIds: z.array(z.number().int().positive()).min(1).max(1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExecutiveMeetingsReorderBodyT = z.infer<
|
export type ExecutiveMeetingsReorderBodyT = z.infer<
|
||||||
|
|||||||
Reference in New Issue
Block a user