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:
@@ -33,6 +33,7 @@ import {
|
||||
getEffectiveRoleIds,
|
||||
} from "../middlewares/auth";
|
||||
import { sanitizeRichText } from "../lib/sanitize";
|
||||
import { ExecutiveMeetingsReorderBody } from "@workspace/api-zod";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -234,13 +235,9 @@ const duplicateSchema = z.object({
|
||||
targetDate: dateSchema,
|
||||
});
|
||||
|
||||
const reorderSchema = z.object({
|
||||
meetingDate: dateSchema,
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(2, "Need at least 2 meetings to reorder")
|
||||
.max(200),
|
||||
});
|
||||
// Reorder schema lives in @workspace/api-zod (lib/api-zod/src/manual.ts) so
|
||||
// frontend and server share the same contract for POST /reorder.
|
||||
const reorderSchema = ExecutiveMeetingsReorderBody;
|
||||
|
||||
const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const timeHm = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/);
|
||||
@@ -934,42 +931,36 @@ router.post(
|
||||
}
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
// Load *all* meetings on this date — full-day reorder is required.
|
||||
const allRows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
|
||||
inArray(executiveMeetingsTable.id, data.orderedIds),
|
||||
),
|
||||
);
|
||||
if (rows.length !== data.orderedIds.length) {
|
||||
throw Object.assign(new Error("missing_or_cross_day"), {
|
||||
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
|
||||
if (allRows.length !== data.orderedIds.length) {
|
||||
throw Object.assign(new Error("incomplete_day_reorder"), {
|
||||
httpStatus: 400,
|
||||
code: "invalid_ids",
|
||||
code: "incomplete_day",
|
||||
});
|
||||
}
|
||||
// Snapshot the current (start_time, end_time, daily_number) ordered
|
||||
// by current daily_number — these are the slots we redistribute as
|
||||
// a permutation among the affected rows. We deliberately reuse
|
||||
// the existing daily_numbers instead of renumbering 1..N, because
|
||||
// the day may contain meetings outside `orderedIds` (partial
|
||||
// reorder) and renumbering would collide with the
|
||||
// (meeting_date, daily_number) UNIQUE constraint. Reusing the
|
||||
// affected rows' own slot values is a true permutation, so it is
|
||||
// both contiguous within the selection and conflict-free against
|
||||
// the rest of the day.
|
||||
const slots = rows
|
||||
const dayIds = new Set(allRows.map((r) => r.id));
|
||||
for (const id of data.orderedIds) {
|
||||
if (!dayIds.has(id)) {
|
||||
throw Object.assign(new Error("invalid_or_cross_day_id"), {
|
||||
httpStatus: 400,
|
||||
code: "invalid_ids",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Snapshot the day's existing slots (start_time, end_time) sorted by
|
||||
// current daily_number. We redistribute them in the new order so the
|
||||
// first row of the new order takes the earliest existing slot, etc.
|
||||
const slots = allRows
|
||||
.slice()
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
||||
.map((r) => ({
|
||||
startTime: r.startTime,
|
||||
endTime: r.endTime,
|
||||
dailyNumber: r.dailyNumber,
|
||||
}));
|
||||
.map((r) => ({ startTime: r.startTime, endTime: r.endTime }));
|
||||
// Two-phase update to avoid (date, daily_number) unique conflicts
|
||||
// *during* the swap.
|
||||
// Phase 1: park into negative numbers.
|
||||
// *during* the renumber.
|
||||
// Phase 1: park into negative daily_numbers.
|
||||
for (let i = 0; i < data.orderedIds.length; i++) {
|
||||
const id = data.orderedIds[i]!;
|
||||
await tx
|
||||
@@ -977,7 +968,8 @@ router.post(
|
||||
.set({ dailyNumber: -(i + 1), updatedBy: userId })
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
}
|
||||
// Phase 2: assign final slot from the snapshot above.
|
||||
// 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++) {
|
||||
const id = data.orderedIds[i]!;
|
||||
const slot = slots[i]!;
|
||||
@@ -986,7 +978,7 @@ router.post(
|
||||
.set({
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
dailyNumber: slot.dailyNumber,
|
||||
dailyNumber: i + 1,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
@@ -997,7 +989,7 @@ router.post(
|
||||
entityId: data.orderedIds[0]!,
|
||||
oldValue: {
|
||||
meetingDate: data.meetingDate,
|
||||
order: rows
|
||||
order: allRows
|
||||
.slice()
|
||||
.sort((a, b) => a.dailyNumber - b.dailyNumber)
|
||||
.map((r) => r.id),
|
||||
@@ -1008,16 +1000,11 @@ router.post(
|
||||
},
|
||||
performedBy: userId,
|
||||
});
|
||||
// Defensive post-check: dailyNumbers must be exactly 1..N.
|
||||
const updated = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(executiveMeetingsTable.meetingDate, data.meetingDate),
|
||||
inArray(executiveMeetingsTable.id, data.orderedIds),
|
||||
),
|
||||
);
|
||||
// Validate no two share dailyNumber post-update (defensive).
|
||||
.where(eq(executiveMeetingsTable.meetingDate, data.meetingDate));
|
||||
const seen = new Set<number>();
|
||||
for (const u of updated) {
|
||||
if (seen.has(u.dailyNumber)) {
|
||||
@@ -1025,7 +1012,6 @@ router.post(
|
||||
}
|
||||
seen.add(u.dailyNumber);
|
||||
}
|
||||
return { updated };
|
||||
});
|
||||
// Return the updated meetings in the new order, with attendees.
|
||||
const idToFetch = data.orderedIds;
|
||||
|
||||
@@ -580,17 +580,19 @@ test("Sanitization: rich text strips disallowed tags but keeps inline formatting
|
||||
assert.ok(/<em[^>]*>One<\/em>/i.test(att.name), "em must survive in attendee");
|
||||
});
|
||||
|
||||
test("Reorder: POST /reorder swaps daily numbers + start times within a day", 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 a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أول", titleEn: "First", meetingDate: today,
|
||||
titleAr: "أول", titleEn: "First", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثاني", titleEn: "Second", meetingDate: today,
|
||||
titleAr: "ثاني", titleEn: "Second", meetingDate: reorderDate,
|
||||
startTime: "10:00", endTime: "10:30",
|
||||
});
|
||||
const c = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ثالث", titleEn: "Third", meetingDate: today,
|
||||
titleAr: "ثالث", titleEn: "Third", meetingDate: reorderDate,
|
||||
startTime: "11:00", endTime: "11:30",
|
||||
});
|
||||
assert.equal(a.status, 201);
|
||||
@@ -603,29 +605,48 @@ test("Reorder: POST /reorder swaps daily numbers + start times within a day", as
|
||||
// reverse the order: C, B, A
|
||||
const reorder = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: today, orderedIds: [C.id, B.id, A.id] });
|
||||
{ meetingDate: reorderDate, orderedIds: [C.id, B.id, A.id] });
|
||||
assert.equal(reorder.status, 200);
|
||||
|
||||
const day = await api(adminCookie, "GET",
|
||||
`/api/executive-meetings?date=${today}`);
|
||||
`/api/executive-meetings?date=${reorderDate}`);
|
||||
const body = await day.json();
|
||||
const byId = new Map(body.meetings.map((m) => [m.id, m]));
|
||||
const cAfter = byId.get(C.id);
|
||||
const bAfter = byId.get(B.id);
|
||||
const aAfter = byId.get(A.id);
|
||||
// The reorder is a *permutation* of the affected rows' existing slots —
|
||||
// C now occupies A's old slot, B keeps its slot, and A occupies C's old
|
||||
// slot. dailyNumber values are reused exactly so that the (date, daily)
|
||||
// UNIQUE constraint stays valid even when the day contains other
|
||||
// meetings outside `orderedIds`.
|
||||
assert.equal(cAfter.dailyNumber, A.dailyNumber);
|
||||
assert.equal(bAfter.dailyNumber, B.dailyNumber);
|
||||
assert.equal(aAfter.dailyNumber, C.dailyNumber);
|
||||
// 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(bAfter.dailyNumber, 2);
|
||||
assert.equal(aAfter.dailyNumber, 3);
|
||||
assert.equal(cAfter.startTime.slice(0, 5), "09:00");
|
||||
assert.equal(bAfter.startTime.slice(0, 5), "10:00");
|
||||
assert.equal(aAfter.startTime.slice(0, 5), "11:00");
|
||||
});
|
||||
|
||||
test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => {
|
||||
const reorderDate = "2050-02-20";
|
||||
const a = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "أ", titleEn: "A", meetingDate: reorderDate,
|
||||
startTime: "08:00", endTime: "08:30",
|
||||
});
|
||||
const b = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "ب", titleEn: "B", meetingDate: reorderDate,
|
||||
startTime: "09:00", endTime: "09:30",
|
||||
});
|
||||
const A = await a.json(); created.meetingIds.push(A.id);
|
||||
const B = await b.json(); created.meetingIds.push(B.id);
|
||||
|
||||
// Send only one of the two — server must refuse so 1..N renumber stays safe.
|
||||
const r = await api(adminCookie, "POST",
|
||||
"/api/executive-meetings/reorder",
|
||||
{ meetingDate: reorderDate, orderedIds: [A.id] });
|
||||
assert.equal(r.status, 400);
|
||||
const body = await r.json();
|
||||
assert.equal(body.code, "incomplete_day");
|
||||
});
|
||||
|
||||
test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
|
||||
const m = await api(adminCookie, "POST", "/api/executive-meetings", {
|
||||
titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
|
||||
|
||||
@@ -91,10 +91,12 @@
|
||||
"@tiptap/extension-underline": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@uppy/aws-s3": "^5.1.0",
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.1",
|
||||
"@uppy/react": "^5.2.0",
|
||||
"@workspace/object-storage-web": "workspace:*"
|
||||
"@workspace/object-storage-web": "workspace:*",
|
||||
"dompurify": "^3.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { FontFamily } from "@tiptap/extension-font-family";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { safeHtml } from "@/lib/safe-html";
|
||||
import {
|
||||
Bold as BoldIcon,
|
||||
Italic as ItalicIcon,
|
||||
@@ -59,6 +60,18 @@ const FONT_SIZE_OPTIONS = [
|
||||
// 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" {
|
||||
interface Commands<ReturnType> {
|
||||
fontSize: {
|
||||
setFontSize: (size: string) => ReturnType;
|
||||
unsetFontSize: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FontSize = Extension.create({
|
||||
name: "fontSize",
|
||||
addOptions() {
|
||||
@@ -86,13 +99,16 @@ const FontSize = Extension.create({
|
||||
return {
|
||||
setFontSize:
|
||||
(size: string) =>
|
||||
({ chain }: { chain: () => ReturnType<Editor["chain"]> }) =>
|
||||
({ chain }) =>
|
||||
chain().setMark("textStyle", { fontSize: size }).run(),
|
||||
unsetFontSize:
|
||||
() =>
|
||||
({ chain }: { chain: () => ReturnType<Editor["chain"]> }) =>
|
||||
chain().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run(),
|
||||
} as never;
|
||||
({ chain }) =>
|
||||
chain()
|
||||
.setMark("textStyle", { fontSize: null })
|
||||
.removeEmptyTextStyle()
|
||||
.run(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -257,15 +273,18 @@ export function EditableCell({
|
||||
}
|
||||
};
|
||||
|
||||
// Static (read-only) rendering: an HTML wrapper. We don't sanitize on the
|
||||
// client because the server already sanitized. Plain-text legacy values
|
||||
// contain no tags.
|
||||
// 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) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={fontStyle}
|
||||
dangerouslySetInnerHTML={{ __html: value || (placeholder ?? "") }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: value ? safeHtml(value) : (placeholder ?? ""),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -293,7 +312,7 @@ export function EditableCell({
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: value }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: safeHtml(value) }} />
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">
|
||||
{placeholder ?? ""}
|
||||
@@ -415,7 +434,7 @@ function FormattingToolbar({
|
||||
<span className="w-px h-4 bg-gray-200 mx-0.5" />
|
||||
<button
|
||||
type="button"
|
||||
className={btn(isActive({ textAlign: "right" } as never))}
|
||||
className={btn(isActive("paragraph", { textAlign: "right" }))}
|
||||
onClick={() => editor.chain().focus().setTextAlign("right").run()}
|
||||
aria-label="Align right"
|
||||
data-testid="em-edit-align-right"
|
||||
@@ -424,7 +443,7 @@ function FormattingToolbar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn(isActive({ textAlign: "center" } as never))}
|
||||
className={btn(isActive("paragraph", { textAlign: "center" }))}
|
||||
onClick={() => editor.chain().focus().setTextAlign("center").run()}
|
||||
aria-label="Align center"
|
||||
data-testid="em-edit-align-center"
|
||||
@@ -433,7 +452,7 @@ function FormattingToolbar({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn(isActive({ textAlign: "left" } as never))}
|
||||
className={btn(isActive("paragraph", { textAlign: "left" }))}
|
||||
onClick={() => editor.chain().focus().setTextAlign("left").run()}
|
||||
aria-label="Align left"
|
||||
data-testid="em-edit-align-left"
|
||||
@@ -463,12 +482,11 @@ function FormattingToolbar({
|
||||
value={(editor.getAttributes("textStyle").fontSize as string) || ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const chain = editor.chain().focus();
|
||||
// setFontSize / unsetFontSize are added by the FontSize extension
|
||||
// above. They are typed as `never` because Tiptap's command map is
|
||||
// generated from registered extensions and we don't augment it.
|
||||
if (v) (chain as unknown as { setFontSize: (s: string) => { run: () => void } }).setFontSize(v).run();
|
||||
else (chain as unknown as { unsetFontSize: () => { run: () => void } }).unsetFontSize().run();
|
||||
// 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();
|
||||
else editor.chain().focus().unsetFontSize().run();
|
||||
}}
|
||||
aria-label="Font size"
|
||||
data-testid="em-edit-font-size"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const ALLOWED_TAGS = [
|
||||
"span",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"br",
|
||||
"p",
|
||||
];
|
||||
const ALLOWED_ATTR = ["style", "class"];
|
||||
|
||||
export function safeHtml(input: string | null | undefined): string {
|
||||
if (!input) return "";
|
||||
return DOMPurify.sanitize(input, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
KEEP_CONTENT: true,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { safeHtml } from "@/lib/safe-html";
|
||||
|
||||
type Attendee = {
|
||||
id: number;
|
||||
@@ -228,7 +229,7 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
into the printed page. */}
|
||||
<div
|
||||
style={{ fontWeight: 600 }}
|
||||
dangerouslySetInnerHTML={{ __html: title }}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml(title) }}
|
||||
/>
|
||||
{m.location ? (
|
||||
<div style={{ fontSize: 12, color: "#4b5563" }}>
|
||||
@@ -257,7 +258,9 @@ export default function ExecutiveMeetingsPrintPage() {
|
||||
cannot escape into <script> tags on
|
||||
the print page. */}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: a.name }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: safeHtml(a.name),
|
||||
}}
|
||||
/>
|
||||
{a.title ? <span> ({a.title})</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { EditableCell } from "@/components/editable-cell";
|
||||
import { safeHtml } from "@/lib/safe-html";
|
||||
|
||||
type Attendee = {
|
||||
id?: number;
|
||||
@@ -1851,7 +1852,7 @@ function AttendeeFlow({
|
||||
testId={`em-edit-attendee-${i}`}
|
||||
/>
|
||||
) : (
|
||||
<span dangerouslySetInnerHTML={{ __html: a.name }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './generated/api';
|
||||
export * from './manual';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ExecutiveMeetingsReorderBody = z.object({
|
||||
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
orderedIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
});
|
||||
|
||||
export type ExecutiveMeetingsReorderBodyT = z.infer<
|
||||
typeof ExecutiveMeetingsReorderBody
|
||||
>;
|
||||
Generated
+27
@@ -385,6 +385,9 @@ importers:
|
||||
'@tiptap/starter-kit':
|
||||
specifier: ^3.22.4
|
||||
version: 3.22.4
|
||||
'@types/dompurify':
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
'@uppy/aws-s3':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(@uppy/core@5.2.0)
|
||||
@@ -400,6 +403,9 @@ importers:
|
||||
'@workspace/object-storage-web':
|
||||
specifier: workspace:*
|
||||
version: link:../../lib/object-storage-web
|
||||
dompurify:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
devDependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.10.0
|
||||
@@ -2594,6 +2600,10 @@ packages:
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -2650,6 +2660,9 @@ packages:
|
||||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
@@ -3064,6 +3077,9 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.1:
|
||||
resolution: {integrity: sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
@@ -6577,6 +6593,10 @@ snapshots:
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/dompurify@3.2.0':
|
||||
dependencies:
|
||||
dompurify: 3.4.1
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
@@ -6648,6 +6668,9 @@ snapshots:
|
||||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
@@ -7033,6 +7056,10 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.1:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
|
||||
Reference in New Issue
Block a user