Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop

This commit is contained in:
claude_code
2026-06-22 21:14:05 +03:00
45 changed files with 661 additions and 127 deletions

View File

@@ -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:

View File

@@ -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).

View File

@@ -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) {

View File

@@ -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");
});
});

View File

@@ -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>,
};

View File

@@ -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,

View File

@@ -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") });
},
});
}

View File

@@ -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: {

View File

@@ -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),
});

View File

@@ -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" }}

View File

@@ -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" }}

View File

@@ -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({

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -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();

View File

@@ -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],
);

View File

@@ -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) {

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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}

View File

@@ -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>

View File

@@ -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,
});
}

View File

@@ -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;