Merge pull request 'fix(qa): resolve QA-pass issues #122–#134' (#135) from fix/qa-issues-122-134 into develop
Reviewed-on: #135
This commit was merged in pull request #135.
This commit is contained in:
@@ -1,4 +1,22 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/**
|
||||
* Persisted floating AI chat window geometry (position + size). Held in
|
||||
* localStorage so a drag/resize survives a full page reload. `null` means
|
||||
* "never placed yet" — the window then computes an initial top-right placement.
|
||||
* On restore the value is clamped to the current viewport (see AiChatWindow).
|
||||
*/
|
||||
export type AiChatWindowGeom = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
|
||||
"ai-chat-window-geom",
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
aiChatWindowGeomAtom,
|
||||
aiChatDraftAtom,
|
||||
selectedAiRoleIdAtom,
|
||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
@@ -123,15 +124,13 @@ export default function AiChatWindow() {
|
||||
minimizedRef.current = minimized;
|
||||
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
// Live window geometry (position + size); initialized lazily on first open so
|
||||
// it is anchored to the current viewport (top-right corner). Kept in state so
|
||||
// a user resize survives close/reopen and can be re-clamped to the viewport.
|
||||
const [geom, setGeom] = useState<{
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
// Live window geometry (position + size); persisted to localStorage so a
|
||||
// drag/resize survives a full page reload (and close/reopen). `null` means
|
||||
// "never placed yet" — the layout effect below then computes an initial
|
||||
// top-right placement anchored to the current viewport, and on restore it is
|
||||
// re-clamped to the viewport (so a placement saved on a larger screen is not
|
||||
// left partly off-screen).
|
||||
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||
|
||||
// Track whether we are awaiting the id of a just-created (new) chat, so we
|
||||
// can adopt it once the chat list refreshes after the first turn finishes.
|
||||
@@ -416,6 +415,10 @@ export default function AiChatWindow() {
|
||||
useEffect(() => {
|
||||
if (!windowOpen || minimized) return;
|
||||
const el = winRef.current;
|
||||
// `geom` is in the deps so this re-runs once geometry is settled and the
|
||||
// window is actually rendered (on the first open `geom` is still null on the
|
||||
// render that flips windowOpen, so winRef.current is null then — without the
|
||||
// geom dep the observer would never attach and resizes would not persist).
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const width = el.offsetWidth;
|
||||
@@ -427,7 +430,7 @@ export default function AiChatWindow() {
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [windowOpen, minimized]);
|
||||
}, [windowOpen, minimized, geom !== null]);
|
||||
|
||||
const startDrag = useCallback((e: React.MouseEvent): void => {
|
||||
// Ignore drags that originate on a button (minimize/close/new chat).
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
PasswordInput,
|
||||
Box,
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import APP_ROUTE from "@/lib/app-route";
|
||||
import useAuth from "@/features/auth/hooks/use-auth";
|
||||
import classes from "@/features/auth/components/auth.module.css";
|
||||
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
|
||||
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>{t("invalid invitation link")}</div>;
|
||||
// Styled error with a CTA to login, mirroring the password-reset
|
||||
// error page and the 404 page (issue #133)
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Container my={40}>
|
||||
<Text size="lg" ta="center">
|
||||
{t("Invalid invitation link")}
|
||||
</Text>
|
||||
<Group justify="center">
|
||||
<Button
|
||||
component={Link}
|
||||
to={APP_ROUTE.AUTH.LOGIN}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
>
|
||||
{t("Go to login page")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invitation) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getSuggestionItems } from "./menu-items";
|
||||
|
||||
// The slash-command `allow` callback (slash-command.ts) keeps the popup active
|
||||
// only while at least one item matches the current query:
|
||||
// const groups = getSuggestionItems({ query });
|
||||
// const hasMatches = Object.values(groups).some((items) => items.length > 0);
|
||||
// return hasMatches;
|
||||
// With `allowSpaces: true`, a non-empty query that matches nothing must collapse
|
||||
// to an empty result so `allow` returns false and the menu closes (instead of
|
||||
// leaving literal "/todo abc" text behind). These tests pin that contract at the
|
||||
// `getSuggestionItems` boundary, which is the unit-testable half of `allow`.
|
||||
|
||||
const KEY = "currentUser";
|
||||
|
||||
function hasMatches(query: string): boolean {
|
||||
// Mirror the exact predicate used by slash-command.ts `allow`.
|
||||
const groups = getSuggestionItems({ query });
|
||||
return Object.values(groups).some((items) => items.length > 0);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Default workspace state: HTML-embed feature OFF (matches production default).
|
||||
localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => {
|
||||
it("keeps the menu allowed for a query that matches items", () => {
|
||||
expect(hasMatches("h1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the menu allowed for a multi-word matching query", () => {
|
||||
// "Heading 1" is a multi-word title kept alive by allowSpaces.
|
||||
expect(hasMatches("Heading 1")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the menu (no matches) for a non-empty query that matches nothing", () => {
|
||||
expect(hasMatches("zzzznomatch")).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the menu for a space-bearing non-matching query", () => {
|
||||
// The exact case the allowSpaces fix targets: "/todo abc" matches nothing.
|
||||
expect(hasMatches("todo abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns an empty result object for a no-match query", () => {
|
||||
expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({});
|
||||
});
|
||||
|
||||
it("returns a non-empty result for the 'Heading 1' query", () => {
|
||||
const groups = getSuggestionItems({ query: "Heading 1" });
|
||||
const titles = Object.values(groups)
|
||||
.flat()
|
||||
.map((item) => item.title);
|
||||
expect(titles).toContain("Heading 1");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ const Command = Extension.create({
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
// Keep the query alive through spaces so multi-word item labels
|
||||
// (e.g. "Heading 1", "Math block") match instead of terminating the
|
||||
// query and leaving literal "/Heading 1" text in the document.
|
||||
allowSpaces: true,
|
||||
command: ({ editor, range, props }) => {
|
||||
props.command({ editor, range, props });
|
||||
},
|
||||
@@ -23,7 +27,22 @@ const Command = Extension.create({
|
||||
if ($from.parent.type.name === 'codeBlock') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// With `allowSpaces: true` a query that contains a space no longer
|
||||
// terminates the suggestion on its own, so a space-bearing query that
|
||||
// matches nothing (e.g. "/todo abc") would otherwise keep an empty
|
||||
// popup logically active and leave the literal "/todo abc" text in the
|
||||
// document, only dismissable via Escape. Deactivate the suggestion when
|
||||
// no item matches the current query: returning false here removes the
|
||||
// decoration, fires the popup's `onExit`, and lets subsequent keystrokes
|
||||
// pass through normally — restoring the pre-`allowSpaces` behavior for
|
||||
// non-matching queries while keeping multi-word matches (e.g.
|
||||
// "/Heading 1") working.
|
||||
const query = state.doc.textBetween(range.from + 1, range.to);
|
||||
const groups = getSuggestionItems({ query });
|
||||
const hasMatches = Object.values(groups).some(
|
||||
(items) => items.length > 0,
|
||||
);
|
||||
return hasMatches;
|
||||
},
|
||||
} as Partial<SuggestionOptions>,
|
||||
};
|
||||
|
||||
@@ -113,6 +113,13 @@ export default function PageEditor({
|
||||
);
|
||||
const menuContainerRef = useRef(null);
|
||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||
// Always holds the latest collab token. The provider effect below runs once
|
||||
// per pageId, so a handler created inside it would otherwise close over a
|
||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
collabTokenRef.current = collabQuery?.token;
|
||||
}, [collabQuery?.token]);
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -167,20 +174,33 @@ export default function PageEditor({
|
||||
}
|
||||
};
|
||||
const onAuthenticationFailedHandler = () => {
|
||||
const payload = jwtDecode(collabQuery?.token);
|
||||
const now = Date.now().valueOf() / 1000;
|
||||
const isTokenExpired = now >= payload.exp;
|
||||
if (isTokenExpired) {
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
||||
// may be stale). Guard the decode: a missing or unparseable token must
|
||||
// not throw "Invalid token specified" and should trigger a refresh so
|
||||
// the editor reconnects even when the initial token fetch failed.
|
||||
const token = collabTokenRef.current;
|
||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
||||
if (token) {
|
||||
try {
|
||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
||||
} catch {
|
||||
needsRefresh = true;
|
||||
}
|
||||
}
|
||||
if (!needsRefresh) return;
|
||||
refetchCollabToken().then((result) => {
|
||||
if (result.data?.token) {
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
remote.configuration.token = result.data.token;
|
||||
socket.connect();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
const remote = new HocuspocusProvider({
|
||||
websocketProvider: socket,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ToggleFavoriteParams,
|
||||
} from "../services/favorite-service";
|
||||
import { FavoriteType } from "../types/favorite.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||
return useInfiniteQuery({
|
||||
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
|
||||
|
||||
export function useAddFavoriteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||
mutationFn: (data) => addFavorite(data),
|
||||
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorites", variables.type],
|
||||
});
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Added to favorites") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveFavoriteMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, ToggleFavoriteParams>({
|
||||
mutationFn: (data) => removeFavorite(data),
|
||||
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["favorites", variables.type],
|
||||
});
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Removed from favorites") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { zod4Resolver } from 'mantine-form-zod-resolver';
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().trim().min(2).max(100),
|
||||
description: z.string().max(500),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FormValues = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export function CreateGroupForm() {
|
||||
const { t } = useTranslation();
|
||||
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
|
||||
const [userIds, setUserIds] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Build the schema with friendly, translated validation messages (issue #130)
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, t("Group name must be at least 2 characters"))
|
||||
.max(100, t("Group name must be 100 characters or fewer")),
|
||||
description: z
|
||||
.string()
|
||||
.max(500, t("Description must be 500 characters or fewer")),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function GroupMembersList() {
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => onRemove(userId),
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) {
|
||||
<Badge
|
||||
color={getInitialsColor(page?.space.name)}
|
||||
variant="light"
|
||||
tt="none"
|
||||
component={Link}
|
||||
to={getSpaceUrl(page?.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function FavoritesPages({ spaceId }: Props) {
|
||||
<Badge
|
||||
color={getInitialsColor(fav.space.name)}
|
||||
variant="light"
|
||||
tt="none"
|
||||
component={Link}
|
||||
to={getSpaceUrl(fav.space.slug)}
|
||||
style={{ cursor: "pointer" }}
|
||||
|
||||
@@ -78,6 +78,8 @@ export function useAddLabelsMutation(pageId: string | undefined) {
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Label added") });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
notifications.show({
|
||||
@@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) {
|
||||
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-pages"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["label-info"] });
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Label removed") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
|
||||
@@ -102,7 +102,12 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
|
||||
{!readOnly && <PageEditModeToggle size="xs" />}
|
||||
|
||||
{!workspaceSharingDisabled && <ShareModal readOnly={readOnly ?? false} />}
|
||||
{/* Hide the Share entry point for readers; the toggle inside is inert
|
||||
without edit permission, so gate it like other edit-only actions
|
||||
(issue #133) */}
|
||||
{!readOnly && !workspaceSharingDisabled && (
|
||||
<ShareModal readOnly={false} />
|
||||
)}
|
||||
|
||||
<Tooltip label={t("Comments")} openDelay={250} withArrow>
|
||||
<ActionIcon
|
||||
@@ -286,7 +291,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={openMovePageModal}
|
||||
>
|
||||
{t("Move")}
|
||||
{t("Move to space")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })}
|
||||
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
openExportModal();
|
||||
}}
|
||||
>
|
||||
{t("Export page")}
|
||||
{t("Export")}
|
||||
</Menu.Item>
|
||||
|
||||
{canEdit && (
|
||||
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
openMovePageModal();
|
||||
}}
|
||||
>
|
||||
{t("Move")}
|
||||
{t("Move to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
|
||||
@@ -170,7 +170,7 @@ export function SpaceTreeRow({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={classes.text}>{node.name || t("untitled")}</span>
|
||||
<span className={classes.text}>{node.name || t("Untitled")}</span>
|
||||
|
||||
{node.isTemplate === true && (
|
||||
<Tooltip label={t("Template")} withArrow>
|
||||
@@ -297,7 +297,7 @@ function CreateNode({
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
className={classes.actionIcon}
|
||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })}
|
||||
aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -282,7 +282,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
[],
|
||||
);
|
||||
const getDragLabel = useCallback(
|
||||
(n: SpaceTreeNode) => n.name || t("untitled"),
|
||||
(n: SpaceTreeNode) => n.name || t("Untitled"),
|
||||
[t],
|
||||
);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function findBreadcrumbPath(
|
||||
): SpaceTreeNode[] | null {
|
||||
for (const node of tree) {
|
||||
if (!node.name || node.name.trim() === "") {
|
||||
node.name = "untitled";
|
||||
node.name = "Untitled";
|
||||
}
|
||||
|
||||
if (node.id === pageId) {
|
||||
|
||||
@@ -107,48 +107,55 @@ export function SearchSpotlightFilters({
|
||||
</Button>
|
||||
</SpaceFilterMenu>
|
||||
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={220}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconFileDescription size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
{contentType
|
||||
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
|
||||
: t("Type")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
component={RadioMenuItem}
|
||||
aria-checked={contentType === option.value}
|
||||
onClick={() =>
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
{/* Only render the content-type dropdown when there is more than one
|
||||
option to choose from. With a single option ("Pages") it is a no-op
|
||||
control, so we hide it instead of showing a dead filter. */}
|
||||
{contentTypeOptions.length > 1 && (
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={220}
|
||||
position="bottom-start"
|
||||
zIndex={getDefaultZIndex("max")}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
rightSection={<IconChevronDown size={14} />}
|
||||
leftSection={<IconFileDescription size={16} />}
|
||||
className={classes.filterButton}
|
||||
fw={500}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
</div>
|
||||
{contentType === option.value && <IconCheck size={20} aria-hidden />}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
{contentType
|
||||
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
|
||||
: t("Type")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{contentTypeOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
component={RadioMenuItem}
|
||||
aria-checked={contentType === option.value}
|
||||
onClick={() =>
|
||||
contentType !== option.value &&
|
||||
handleFilterChange("contentType", option.value)
|
||||
}
|
||||
>
|
||||
<Group flex="1" gap="xs">
|
||||
<div>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
</div>
|
||||
{contentType === option.value && (
|
||||
<IconCheck size={20} aria-hidden />
|
||||
)}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
|
||||
{query.length > 0 && !isLoading
|
||||
? resultItems.length === 0
|
||||
? t("No results found")
|
||||
: t("{{count}} results found", { count: resultItems.length })
|
||||
: // Singular/plural handling so 1 result is not announced as
|
||||
// "1 results found".
|
||||
t("{{count}} result found", { count: resultItems.length })
|
||||
: ""}
|
||||
</VisuallyHidden>
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
{getPageIcon(share.sharedPage.icon)}
|
||||
<div className={classes.shareLinkText}>
|
||||
<Text fz="sm" fw={500} lineClamp={1}>
|
||||
{share.sharedPage.title || t("untitled")}
|
||||
{share.sharedPage.title || t("Untitled")}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
// React Query forbids `undefined` as resolved query data ("Query data cannot be
|
||||
// undefined"). The backend resolves to `undefined` when a page has no share, so
|
||||
// `useShareForPageQuery` normalizes that absence to `null`:
|
||||
// queryFn: async () => (await getShareForPage(pageId)) ?? null
|
||||
// These tests pin that contract: the hook must resolve to `null` (never
|
||||
// `undefined`) when there is no share, and pass a real share through untouched.
|
||||
|
||||
// Mock the service module so the queryFn calls our stub instead of the network.
|
||||
vi.mock("@/features/share/services/share-service.ts", () => ({
|
||||
getShareForPage: vi.fn(),
|
||||
// Other named exports referenced by share-query.ts must exist on the mock so
|
||||
// the module import resolves; they are unused by these tests.
|
||||
createShare: vi.fn(),
|
||||
deleteShare: vi.fn(),
|
||||
getSharedPageTree: vi.fn(),
|
||||
getShareInfo: vi.fn(),
|
||||
getSharePageInfo: vi.fn(),
|
||||
getShares: vi.fn(),
|
||||
updateShare: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getShareForPage } from "@/features/share/services/share-service.ts";
|
||||
import { useShareForPageQuery } from "@/features/share/queries/share-query.ts";
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe("useShareForPageQuery — null normalization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("normalizes an absent share (undefined) to null", async () => {
|
||||
vi.mocked(getShareForPage).mockResolvedValue(undefined as any);
|
||||
|
||||
const { result } = renderHook(() => useShareForPageQuery("page-1"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
// The key assertion: null, never undefined.
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(result.current.data).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes an absent share (null) to null", async () => {
|
||||
vi.mocked(getShareForPage).mockResolvedValue(null as any);
|
||||
|
||||
const { result } = renderHook(() => useShareForPageQuery("page-2"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it("passes an existing share through unchanged", async () => {
|
||||
const share = { id: "share-1", pageId: "page-3" } as any;
|
||||
vi.mocked(getShareForPage).mockResolvedValue(share);
|
||||
|
||||
const { result } = renderHook(() => useShareForPageQuery("page-3"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toEqual(share);
|
||||
});
|
||||
});
|
||||
@@ -65,10 +65,13 @@ export function useSharePageQuery(
|
||||
|
||||
export function useShareForPageQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<IShareForPage, Error> {
|
||||
): UseQueryResult<IShareForPage | null, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["share-for-page", pageId],
|
||||
queryFn: () => getShareForPage(pageId),
|
||||
// React Query forbids `undefined` as resolved data ("Query data cannot be
|
||||
// undefined"). When no share exists for the page the endpoint resolves to
|
||||
// undefined, so normalize the absence to `null`.
|
||||
queryFn: async () => (await getShareForPage(pageId)) ?? null,
|
||||
enabled: !!pageId,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
|
||||
@@ -10,17 +10,23 @@ import { TextInput, Button } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(40),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FormValues = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function AccountNameForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
|
||||
// Build the schema with friendly, translated validation messages (issue #130)
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, t("Name is required"))
|
||||
.max(40, t("Name must be 40 characters or fewer")),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
@@ -34,6 +40,9 @@ export default function AccountNameForm() {
|
||||
try {
|
||||
const updatedUser = await updateUser(data);
|
||||
setUser(updatedUser);
|
||||
// Reset the dirty baseline so the Save button disables again on a clean
|
||||
// form right after a successful save.
|
||||
form.resetDirty(data as FormValues);
|
||||
notifications.show({
|
||||
message: t("Updated successfully"),
|
||||
});
|
||||
@@ -57,7 +66,12 @@ export default function AccountNameForm() {
|
||||
variant="filled"
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
mt="sm"
|
||||
disabled={isLoading || !form.isDirty()}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -41,14 +41,10 @@ export default function ChangePassword() {
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
oldPassword: z
|
||||
.string({ error: "your current password is required" })
|
||||
.min(8),
|
||||
newPassword: z.string({ error: "New password is required" }).min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FormValues = {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
};
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
onClose?: () => void;
|
||||
@@ -57,6 +53,16 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Build the schema with friendly, translated validation messages (issue #130)
|
||||
const formSchema = z.object({
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(8, t("Password must be at least 8 characters")),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, t("Password must be at least 8 characters")),
|
||||
});
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function InviteActionMenu({ invitationId }: Props) {
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Revoke"), cancel: t("Don't") },
|
||||
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: onRevoke,
|
||||
});
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function MemberActionMenu({
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
||||
labels: { confirm: t("Delete"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: onRevoke,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
|
||||
"Enter valid email addresses separated by comma or space max_50",
|
||||
)}
|
||||
label={t("Invite by email")}
|
||||
placeholder={t("enter valid emails addresses")}
|
||||
placeholder={t("enter valid email addresses")}
|
||||
variant="filled"
|
||||
splitChars={[",", " "]}
|
||||
maxDropdownHeight={200}
|
||||
|
||||
@@ -203,8 +203,47 @@ export default function AiProviderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
// Reindexing runs as an async background job: the endpoint returns the
|
||||
// PRE-job counts immediately, so the only way the "Indexed X of Y" counter
|
||||
// visibly climbs is to keep polling the settings query while the job runs.
|
||||
// `reindexDeadline` is the timestamp until which we poll (set on reindex
|
||||
// success); polling stops early once indexed === total. Bounded so a stuck
|
||||
// job can never poll forever.
|
||||
const REINDEX_POLL_INTERVAL = 3000; // ms between refetches while indexing
|
||||
const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap
|
||||
const [reindexDeadline, setReindexDeadline] = useState<number | null>(null);
|
||||
|
||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin);
|
||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => {
|
||||
if (reindexDeadline === null) return false;
|
||||
// Past the cap → stop polling (cleared via the effect below too).
|
||||
if (Date.now() > reindexDeadline) return false;
|
||||
const data = query.state.data;
|
||||
// Stop once everything is indexed; otherwise keep polling.
|
||||
if (data && data.indexedPages >= data.totalPages) return false;
|
||||
return REINDEX_POLL_INTERVAL;
|
||||
});
|
||||
|
||||
// Stop polling once the work is done or the cap is reached. Also clears on
|
||||
// unmount because the deadline state goes away with the component.
|
||||
useEffect(() => {
|
||||
if (reindexDeadline === null) return;
|
||||
// "Done" matches the refetchInterval stop condition (indexed >= total),
|
||||
// including an empty workspace (0 >= 0), so the deadline clears promptly
|
||||
// instead of waiting out the cap.
|
||||
if (settings && settings.indexedPages >= settings.totalPages) {
|
||||
setReindexDeadline(null);
|
||||
return;
|
||||
}
|
||||
const msLeft = reindexDeadline - Date.now();
|
||||
if (msLeft <= 0) {
|
||||
setReindexDeadline(null);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setReindexDeadline(null), msLeft);
|
||||
return () => clearTimeout(timer);
|
||||
}, [reindexDeadline, settings]);
|
||||
|
||||
const updateMutation = useUpdateAiSettingsMutation();
|
||||
const reindexMutation = useReindexAiEmbeddingsMutation();
|
||||
|
||||
@@ -877,7 +916,14 @@ export default function AiProviderSettings() {
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
loading={reindexMutation.isPending}
|
||||
onClick={() => reindexMutation.mutate()}
|
||||
onClick={() =>
|
||||
reindexMutation.mutate(undefined, {
|
||||
// Begin bounded polling so the counter climbs as the async
|
||||
// background job indexes (it does not update on its own).
|
||||
onSuccess: () =>
|
||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("Reindex now")}
|
||||
</Button>
|
||||
|
||||
@@ -21,11 +21,20 @@ const aiSettingsKey = ["ai-settings"];
|
||||
|
||||
export function useAiSettingsQuery(
|
||||
enabled: boolean = true,
|
||||
// While reindexing runs as an async background job, the counter only climbs
|
||||
// if the client keeps refetching. The component passes a refetchInterval
|
||||
// function that polls until indexed === total or a bounded deadline, then
|
||||
// returns false to stop. See AiProviderSettings.
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((query: { state: { data?: IAiSettings } }) => number | false),
|
||||
): UseQueryResult<IAiSettings, Error> {
|
||||
return useQuery({
|
||||
queryKey: aiSettingsKey,
|
||||
queryFn: () => getAiSettings(),
|
||||
enabled,
|
||||
refetchInterval: refetchInterval as any,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export function useDeleteWorkspaceMemberMutation() {
|
||||
}
|
||||
|
||||
export function useDeactivateWorkspaceMemberMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
@@ -98,6 +99,8 @@ export function useDeactivateWorkspaceMemberMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaceMembers"],
|
||||
});
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Member deactivated") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
@@ -107,6 +110,7 @@ export function useDeactivateWorkspaceMemberMutation() {
|
||||
}
|
||||
|
||||
export function useActivateWorkspaceMemberMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<
|
||||
@@ -121,6 +125,8 @@ export function useActivateWorkspaceMemberMutation() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workspaceMembers"],
|
||||
});
|
||||
// Notify on success so the action gives visible feedback (issue #128)
|
||||
notifications.show({ message: t("Member activated") });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
|
||||
Reference in New Issue
Block a user