#329 Add a meeting search box to the schedule toolbar
Lets users find a meeting in the day's schedule by typing part of its
title, an attendee name, or other text — instead of scrolling.
artifacts/tx-os/src/pages/executive-meetings.tsx
- Added two top-level helpers near translateSaveError:
- normalizeForSearch(input): strips HTML tags + entities, drops
Arabic diacritics + tatweel, folds common letter variants
(آأإٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي), lowercases.
- meetingMatchesSearch(meeting, needle): tests title (AR/EN),
attendee names + titles, location, notes, and merge text via
normalized substring containment.
- ScheduleSection: added searchQuery state, useEffect that resets it
whenever the active `date` changes, normalizedSearch memo, and a
filteredMeetings memo derived from orderedMeetings.
- Switched the row render path to filteredMeetings: SortableContext
items, the .map render, and the select-all overlay's "anything to
select" check now read from the filtered list. The
setAllMeetingsSelected and allSelectedState helpers also operate
on filteredMeetings so the header checkbox + select-all reflect
what's actually visible.
- Added a new "no matches" empty-state row that appears only when
the day has meetings but the active search filters them all out
(the original "no meetings today" row is preserved for empty days).
- New search Input + clear-X button placed in the schedule toolbar,
next to the existing date picker. Hidden in print so PDFs export
the full schedule.
- MeetingRow gained an optional `displayNumber` prop. The "#" cell
now renders displayNumber when provided, falling back to the
server's dailyNumber. The schedule passes idx+1, so visible rows
always show 1, 2, 3… without gaps when the list is filtered.
artifacts/tx-os/src/locales/ar.json + en.json
- New keys under executiveMeetings.schedule: searchPlaceholder,
searchAria, searchClear, searchNoMatches.
- Bulk delete now intersects the selection with filteredMeetings at
action time. The selection set is intentionally NOT auto-pruned
when the search narrows (so clearing the search restores the
user's earlier picks), but destructive actions only ever target
rows the user can currently see.
Out of scope: cross-day search, prev/next-day chevron buttons,
search history, fuzzy matching.
Verified: `tsc --noEmit` clean.
This commit is contained in:
@@ -1203,7 +1203,11 @@
|
||||
"bulkDeleteUndo": "تراجع",
|
||||
"bulkDeleteUndone": "تمت استعادة {{n}} اجتماعاً",
|
||||
"bulkDeleteUndoPartial": "تمت استعادة {{ok}} من {{total}} اجتماعاً",
|
||||
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات"
|
||||
"bulkDeleteUndoFailed": "تعذرت استعادة الاجتماعات",
|
||||
"searchPlaceholder": "ابحث عن اجتماع…",
|
||||
"searchAria": "ابحث في اجتماعات اليوم",
|
||||
"searchClear": "مسح البحث",
|
||||
"searchNoMatches": "لا توجد اجتماعات تطابق البحث."
|
||||
},
|
||||
"rowColor": {
|
||||
"label": "لون الصف"
|
||||
|
||||
@@ -1086,7 +1086,11 @@
|
||||
"bulkDeleteUndo": "Undo",
|
||||
"bulkDeleteUndone": "Restored {{n}} meetings",
|
||||
"bulkDeleteUndoPartial": "Restored {{ok}} of {{total}} meetings",
|
||||
"bulkDeleteUndoFailed": "Could not restore meetings"
|
||||
"bulkDeleteUndoFailed": "Could not restore meetings",
|
||||
"searchPlaceholder": "Search a meeting…",
|
||||
"searchAria": "Search today's meetings",
|
||||
"searchClear": "Clear search",
|
||||
"searchNoMatches": "No meetings match your search."
|
||||
},
|
||||
"nav": {
|
||||
"schedule": "Daily Schedule",
|
||||
|
||||
@@ -433,6 +433,51 @@ function translateSaveError(rawMsg: string, t: (k: string) => string): string {
|
||||
return rawMsg;
|
||||
}
|
||||
|
||||
// #329: Normalize a free-text string so the schedule's search box can match
|
||||
// across HTML markup (titles + attendee names are sanitized Tiptap rich text)
|
||||
// and across the most common Arabic spelling variants. Strips:
|
||||
// • HTML tags + common entities ( / U+00A0)
|
||||
// • Arabic diacritics (\u064B–\u065F) and tatweel (\u0640)
|
||||
// • Folds alef variants (آ أ إ ٱ → ا), ya (ى → ي), ta marbouta (ة → ه),
|
||||
// hamza on waw/ya (ؤ ئ → و / ي)
|
||||
// Then lowercases for case-insensitive English matching. Empty/whitespace
|
||||
// input becomes "" so the caller can short-circuit.
|
||||
function normalizeForSearch(input: string | null | undefined): string {
|
||||
if (!input) return "";
|
||||
let s = String(input);
|
||||
// Strip tags + common HTML entities first so we don't index markup.
|
||||
s = s.replace(/<[^>]*>/g, " ").replace(/ /gi, " ").replace(/\u00a0/g, " ");
|
||||
// Drop Arabic diacritics + tatweel.
|
||||
s = s.replace(/[\u064B-\u065F\u0640]/g, "");
|
||||
// Fold common Arabic letter variants.
|
||||
s = s
|
||||
.replace(/[\u0622\u0623\u0625\u0671]/g, "\u0627") // alef variants → ا
|
||||
.replace(/\u0649/g, "\u064A") // ى → ي
|
||||
.replace(/\u0629/g, "\u0647") // ة → ه
|
||||
.replace(/\u0624/g, "\u0648") // ؤ → و
|
||||
.replace(/\u0626/g, "\u064A"); // ئ → ي
|
||||
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function meetingMatchesSearch(m: Meeting, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const haystacks: string[] = [
|
||||
normalizeForSearch(m.titleAr),
|
||||
normalizeForSearch(m.titleEn),
|
||||
normalizeForSearch(m.location),
|
||||
normalizeForSearch(m.notes),
|
||||
normalizeForSearch(m.mergeText ?? ""),
|
||||
];
|
||||
for (const a of m.attendees) {
|
||||
haystacks.push(normalizeForSearch(a.name));
|
||||
haystacks.push(normalizeForSearch(a.title));
|
||||
}
|
||||
for (const h of haystacks) {
|
||||
if (h && h.includes(needle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tWithFallback(
|
||||
t: (key: string) => string,
|
||||
key: string,
|
||||
@@ -893,6 +938,27 @@ function ScheduleSection({
|
||||
return out;
|
||||
}, [meetings, optimisticOrder]);
|
||||
|
||||
// #329: in-day search. Free-text needle is normalized once (Arabic
|
||||
// diacritics + letter variants folded, lowercased, HTML stripped) and
|
||||
// matched against title, attendee names/titles, location, notes, and
|
||||
// any merge text on the row. Empty needle → pass-through.
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Reset whenever the active day changes — a search is "scoped to the
|
||||
// currently visible date" per the spec, so jumping days clears it.
|
||||
useEffect(() => {
|
||||
setSearchQuery("");
|
||||
}, [date]);
|
||||
const normalizedSearch = useMemo(
|
||||
() => normalizeForSearch(searchQuery),
|
||||
[searchQuery],
|
||||
);
|
||||
const filteredMeetings = useMemo(() => {
|
||||
if (!normalizedSearch) return orderedMeetings;
|
||||
return orderedMeetings.filter((m) =>
|
||||
meetingMatchesSearch(m, normalizedSearch),
|
||||
);
|
||||
}, [orderedMeetings, normalizedSearch]);
|
||||
|
||||
const refreshDay = useCallback(() => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["/api/executive-meetings", date],
|
||||
@@ -1412,23 +1478,32 @@ function ScheduleSection({
|
||||
);
|
||||
const setAllMeetingsSelected = useCallback(
|
||||
(on: boolean) => {
|
||||
setSelectedMeetingIds(
|
||||
on ? new Set(orderedMeetings.map((m) => m.id)) : new Set(),
|
||||
);
|
||||
// #329: "select all" operates on the currently visible (filtered)
|
||||
// rows so users can't accidentally bulk-act on rows they hid via
|
||||
// the search box. Turning it off always clears the entire selection
|
||||
// (including any rows that may be hidden by an active search).
|
||||
setSelectedMeetingIds((prev) => {
|
||||
if (!on) return new Set();
|
||||
const next = new Set(prev);
|
||||
for (const m of filteredMeetings) next.add(m.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[orderedMeetings],
|
||||
[filteredMeetings],
|
||||
);
|
||||
// Tri-state hint for the "select all" checkbox in the toolbar.
|
||||
// "all" → fully checked, "some" → indeterminate, "none" → unchecked.
|
||||
// Computed against the visible (filtered) row set so the header
|
||||
// checkbox reflects what the user can actually see.
|
||||
const allSelectedState: "all" | "some" | "none" = useMemo(() => {
|
||||
const total = orderedMeetings.length;
|
||||
const total = filteredMeetings.length;
|
||||
if (total === 0) return "none";
|
||||
let count = 0;
|
||||
for (const m of orderedMeetings) if (selectedMeetingIds.has(m.id)) count++;
|
||||
for (const m of filteredMeetings) if (selectedMeetingIds.has(m.id)) count++;
|
||||
if (count === 0) return "none";
|
||||
if (count === total) return "all";
|
||||
return "some";
|
||||
}, [orderedMeetings, selectedMeetingIds]);
|
||||
}, [filteredMeetings, selectedMeetingIds]);
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
// #199: undo via recreate. dailyNumber is omitted so a stolen slot
|
||||
// doesn't 409 the restore — the row gets the next available number.
|
||||
@@ -1474,14 +1549,23 @@ function ScheduleSection({
|
||||
);
|
||||
const deleteSelectedMeetings = useCallback(async () => {
|
||||
if (bulkDeleting) return;
|
||||
const ids = Array.from(selectedMeetingIds);
|
||||
// #329: only act on rows that are currently visible. The selection
|
||||
// set may legitimately contain ids that an active search filter is
|
||||
// hiding (we don't auto-prune so that clearing the search restores
|
||||
// the user's selection). Intersecting at action time prevents an
|
||||
// accidental destructive op against rows the user can't even see.
|
||||
const visibleIds = new Set(filteredMeetings.map((m) => m.id));
|
||||
const ids = Array.from(selectedMeetingIds).filter((id) =>
|
||||
visibleIds.has(id),
|
||||
);
|
||||
if (ids.length === 0) return;
|
||||
const message = t(
|
||||
"executiveMeetings.schedule.bulkDeleteConfirm",
|
||||
).replace("{{n}}", String(ids.length));
|
||||
if (!window.confirm(message)) return;
|
||||
// Snapshot before deletion so undo can reconstruct the rows.
|
||||
const snapshots = meetings.filter((m) => selectedMeetingIds.has(m.id));
|
||||
const idsSet = new Set(ids);
|
||||
const snapshots = meetings.filter((m) => idsSet.has(m.id));
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
// Deliberately allSettled, not all: a single failed DELETE shouldn't
|
||||
@@ -1575,6 +1659,7 @@ function ScheduleSection({
|
||||
}, [
|
||||
bulkDeleting,
|
||||
selectedMeetingIds,
|
||||
filteredMeetings,
|
||||
meetings,
|
||||
t,
|
||||
toast,
|
||||
@@ -2082,6 +2167,36 @@ function ScheduleSection({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* #329: in-day search box. Sits next to the date picker so
|
||||
the user finds a meeting in the currently visible day
|
||||
without having to scroll. Hidden in print so PDFs still
|
||||
show the full schedule. */}
|
||||
<div className="relative print:hidden">
|
||||
<Input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("executiveMeetings.schedule.searchPlaceholder")}
|
||||
aria-label={t("executiveMeetings.schedule.searchAria")}
|
||||
data-testid="em-schedule-search"
|
||||
className="h-8 w-44 sm:w-56 text-sm bg-white"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
aria-label={t("executiveMeetings.schedule.searchClear")}
|
||||
title={t("executiveMeetings.schedule.searchClear")}
|
||||
data-testid="em-schedule-search-clear"
|
||||
className={
|
||||
"absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-[#0B1E3F] " +
|
||||
(isRtl ? "left-1.5" : "right-1.5")
|
||||
}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
|
||||
<input
|
||||
@@ -2209,7 +2324,7 @@ function ScheduleSection({
|
||||
selectAllOverlay={
|
||||
idx === 0 &&
|
||||
effectiveCanMutate &&
|
||||
orderedMeetings.length > 0 ? (
|
||||
filteredMeetings.length > 0 ? (
|
||||
<div
|
||||
className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -2313,14 +2428,27 @@ function ScheduleSection({
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading &&
|
||||
orderedMeetings.length > 0 &&
|
||||
filteredMeetings.length === 0 && (
|
||||
<tr data-testid="em-search-no-matches">
|
||||
<td
|
||||
colSpan={visibleColumns.length}
|
||||
className="py-10 text-center text-muted-foreground"
|
||||
>
|
||||
{t("executiveMeetings.schedule.searchNoMatches")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<SortableContext
|
||||
items={orderedMeetings.map((m) => m.id)}
|
||||
items={filteredMeetings.map((m) => m.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{orderedMeetings.map((m) => (
|
||||
{filteredMeetings.map((m, idx) => (
|
||||
<MeetingRow
|
||||
key={m.id}
|
||||
meeting={m}
|
||||
displayNumber={idx + 1}
|
||||
isRtl={isRtl}
|
||||
t={t}
|
||||
visibleColumns={visibleColumns}
|
||||
@@ -2983,8 +3111,10 @@ function MeetingRow({
|
||||
bulkSelectable,
|
||||
bulkSelected,
|
||||
onBulkSelectChange,
|
||||
displayNumber,
|
||||
}: {
|
||||
meeting: Meeting;
|
||||
displayNumber?: number;
|
||||
isRtl: boolean;
|
||||
t: (k: string) => string;
|
||||
visibleColumns: ColumnSetting[];
|
||||
@@ -3251,7 +3381,7 @@ function MeetingRow({
|
||||
<GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
|
||||
</button>
|
||||
)}
|
||||
<span>{meeting.dailyNumber}</span>
|
||||
<span>{displayNumber ?? meeting.dailyNumber}</span>
|
||||
</div>
|
||||
{cellBulkOverlay}
|
||||
{actionsOverlay}
|
||||
|
||||
Reference in New Issue
Block a user