mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 20:42:05 +00:00
* docs(work-orders): add pending BE contract checklist for design parity gaps * feat(work-orders): add mentions in work-order comments composer * docs(work-orders): align pr description with jira and qa residuals * refactor(workorders): split work order view page into view components * fix(work-orders): export comment assignment vendor input types for view hook * fix(work-orders): drop preferredContact from vendor update payload Align with WorkOrderVendorUpdatePayload on current dev so the mentions branch typechecks. * fix(work-orders): remove Preferred Contact from WO vendor editor (SH-180) EOF * fix(work-orders): harden comment mentions and clear draft on success * fix(work-orders): harden mention highlight boundary and submit catch [SH-217] * fix(work-orders): keep mention cursor sync on current value [SH-217] * fix(work-orders): keep mention menu open after typing @ Pass live text into syncCursor instead of a render-time valueRef. Add a composer interaction regression for @ autocomplete visibility. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Alexandre Brandizzi <alex_brandizzi@hotmail.com>
205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Box,
|
|
ClickAwayListener,
|
|
List,
|
|
ListItemButton,
|
|
Paper,
|
|
Popper,
|
|
TextField,
|
|
} from "@mui/material";
|
|
import {
|
|
filterMentionCandidates,
|
|
findActiveMentionQuery,
|
|
insertMentionToken,
|
|
type MentionLookup,
|
|
} from "@/domain/work-orders/utils/comment-mentions";
|
|
|
|
type CommentMentionComposerProps = {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
candidates: MentionLookup[];
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
rows?: number;
|
|
onSubmit?: () => void;
|
|
};
|
|
|
|
type MentionCandidateMenuProps = {
|
|
open: boolean;
|
|
anchorEl: HTMLElement | null;
|
|
candidates: MentionLookup[];
|
|
highlightIndex: number;
|
|
onSelect: (user: MentionLookup) => void;
|
|
};
|
|
|
|
function MentionCandidateMenu({
|
|
open,
|
|
anchorEl,
|
|
candidates,
|
|
highlightIndex,
|
|
onSelect,
|
|
}: MentionCandidateMenuProps) {
|
|
return (
|
|
<Popper open={open} anchorEl={anchorEl} placement="bottom-start" className="z-50">
|
|
<Paper elevation={4} className="mt-1 max-h-48 overflow-y-auto">
|
|
<List dense disablePadding>
|
|
{candidates.map((candidate, index) => (
|
|
<ListItemButton
|
|
key={String(candidate.id)}
|
|
selected={index === highlightIndex}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => onSelect(candidate)}
|
|
>
|
|
@{candidate.name}
|
|
</ListItemButton>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
</Popper>
|
|
);
|
|
}
|
|
|
|
export function CommentMentionComposer({
|
|
value,
|
|
onChange,
|
|
candidates,
|
|
placeholder = "Type a comment...",
|
|
disabled = false,
|
|
rows = 2,
|
|
onSubmit,
|
|
}: CommentMentionComposerProps) {
|
|
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
|
const [cursor, setCursor] = useState(0);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
|
|
const activeQuery = useMemo(() => findActiveMentionQuery(value, cursor), [value, cursor]);
|
|
|
|
const filteredCandidates = useMemo(
|
|
() => filterMentionCandidates(candidates, activeQuery?.query ?? ""),
|
|
[activeQuery?.query, candidates],
|
|
);
|
|
|
|
const showMenu = Boolean(menuOpen && activeQuery && filteredCandidates.length > 0);
|
|
|
|
useEffect(() => {
|
|
setHighlightIndex(0);
|
|
}, [activeQuery?.query, activeQuery?.start]);
|
|
|
|
const syncCursor = useCallback((textOverride?: string) => {
|
|
const element = inputRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
const text = textOverride ?? element.value;
|
|
const nextCursor = element.selectionStart ?? text.length;
|
|
setCursor(nextCursor);
|
|
setMenuOpen(Boolean(findActiveMentionQuery(text, nextCursor)));
|
|
}, []);
|
|
|
|
const handleChange = (nextValue: string) => {
|
|
onChange(nextValue);
|
|
requestAnimationFrame(() => {
|
|
syncCursor(nextValue);
|
|
});
|
|
};
|
|
|
|
const applyMention = useCallback(
|
|
(user: MentionLookup) => {
|
|
if (!activeQuery) {
|
|
return;
|
|
}
|
|
|
|
const { nextText, nextCursor } = insertMentionToken(value, activeQuery.start, cursor, user);
|
|
onChange(nextText);
|
|
setMenuOpen(false);
|
|
setHighlightIndex(0);
|
|
|
|
requestAnimationFrame(() => {
|
|
const element = inputRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
element.focus();
|
|
element.setSelectionRange(nextCursor, nextCursor);
|
|
setCursor(nextCursor);
|
|
});
|
|
},
|
|
[activeQuery, cursor, onChange, value],
|
|
);
|
|
|
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (showMenu) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
setHighlightIndex((current) => (current + 1) % filteredCandidates.length);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
setHighlightIndex(
|
|
(current) => (current - 1 + filteredCandidates.length) % filteredCandidates.length,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
const selected = filteredCandidates[highlightIndex];
|
|
if (selected) {
|
|
applyMention(selected);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setMenuOpen(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && onSubmit) {
|
|
event.preventDefault();
|
|
onSubmit();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ClickAwayListener onClickAway={() => setMenuOpen(false)}>
|
|
<Box
|
|
className="relative"
|
|
ref={(node: HTMLDivElement | null) => {
|
|
setAnchorEl(node);
|
|
}}
|
|
>
|
|
<TextField
|
|
inputRef={inputRef}
|
|
multiline
|
|
rows={rows}
|
|
fullWidth
|
|
value={value}
|
|
onChange={(event) => handleChange(event.target.value)}
|
|
onClick={() => syncCursor()}
|
|
onKeyUp={() => syncCursor()}
|
|
onSelect={() => syncCursor()}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
<MentionCandidateMenu
|
|
open={showMenu}
|
|
anchorEl={anchorEl}
|
|
candidates={filteredCandidates}
|
|
highlightIndex={highlightIndex}
|
|
onSelect={applyMention}
|
|
/>
|
|
</Box>
|
|
</ClickAwayListener>
|
|
);
|
|
}
|