Merge branch 'main' into confluence-importer

This commit is contained in:
Philipinho
2026-07-01 23:05:52 +01:00
42 changed files with 1237 additions and 199 deletions
@@ -94,21 +94,21 @@ export const GridCell = memo(function GridCell({
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property || e.button !== 0) return;
if (!property || e.button !== 0 || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
},
[property, rowId, setFocusedCell],
[property, rowId, setFocusedCell, isEditing],
);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!property) return;
if (!property || isEditing) return;
setFocusedCell({ rowId, propertyId: property.id });
(e.currentTarget.closest('[role="grid"]') as HTMLElement | null)?.focus({
preventScroll: true,
});
},
[property, rowId, setFocusedCell],
[property, rowId, setFocusedCell, isEditing],
);
const cellReadOnly = property
@@ -47,7 +47,6 @@ interface MfaSetupModalProps {
}
interface SetupData {
secret: string;
qrCode: string;
manualKey: string;
}
@@ -101,7 +100,6 @@ export function MfaSetupModal({
const enableMutation = useMutation({
mutationFn: (verificationCode: string) =>
enableMfa({
secret: setupData!.secret,
verificationCode,
}),
onSuccess: (data) => {
@@ -31,12 +31,10 @@ export interface MfaSetupRequest {
export interface MfaSetupResponse {
method: string;
qrCode: string;
secret: string;
manualKey: string;
}
export interface MfaEnableRequest {
secret: string;
verificationCode: string;
}
@@ -38,6 +38,10 @@ import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-t
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import { TableHandlesLayer } from "@/features/editor/components/table/handle/table-handles-layer";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import classes from "./template-editor.module.css";
@@ -85,6 +89,8 @@ export default function TemplateEditor() {
extensions: templateExtensions,
content: "",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
handleDOMEvents: {
keydown: (_view, event) => {
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
@@ -398,9 +404,18 @@ export default function TemplateEditor() {
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} templateMode />
<EditorLinkMenu editor={editor} />
<TableMenu editor={editor} />
<TableHandlesLayer editor={editor} />
<CalloutMenu editor={editor} />
<ColumnsMenu editor={editor} />
</>
)}
<div style={{ paddingBottom: "20vh" }} />
<div
onClick={() => {
if (editor && !editor.isDestroyed) editor.commands.focus("end");
}}
style={{ paddingBottom: "20vh" }}
/>
</Container>
</>
);
@@ -29,11 +29,17 @@ import UseTemplateModal from "@/ee/template/components/use-template-modal";
import TemplatePreviewModal from "@/ee/template/components/template-preview-modal";
import useUserRole from "@/hooks/use-user-role";
import CreateTemplateModal from "@/ee/template/components/create-template-modal";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export default function TemplateList() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isAdmin: isWorkspaceAdmin } = useUserRole();
const workspace = useAtomValue(workspaceAtom);
const canCreateTemplate =
isWorkspaceAdmin ||
workspace?.settings?.templates?.allowMemberTemplates === true;
const [spaceFilter, setSpaceFilter] = useState<string | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ITemplate | null>(
null,
@@ -105,7 +111,7 @@ export default function TemplateList() {
<Container size="900" pt="xl">
<Group justify="space-between" mb="xl">
<Title order={3}>{t("Templates")}</Title>
{isWorkspaceAdmin && (
{canCreateTemplate && (
<Button
leftSection={<IconPlus size={16} />}
onClick={openCreateModal}
@@ -76,7 +76,7 @@ export function useCreateTemplateMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to create template"),
message: errorMessage ? t(errorMessage) : t("Failed to create template"),
color: "red",
});
},
@@ -117,7 +117,7 @@ export function useUpdateTemplateMutation() {
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Failed to update template"),
message: errorMessage ? t(errorMessage) : t("Failed to update template"),
color: "red",
});
},
@@ -333,6 +333,22 @@ export function useDeletedPagesQuery(
});
}
function getChildrenCacheKeys(
parentPageId: string | null,
spaceId: string,
): QueryKey[] {
if (parentPageId === null) {
return [["root-sidebar-pages", spaceId]];
}
return queryClient
.getQueriesData({
predicate: (query) =>
query.queryKey[0] === "sidebar-pages" &&
(query.queryKey[1] as { pageId?: string })?.pageId === parentPageId,
})
.map(([key]) => key);
}
export function invalidateOnCreatePage(data: Partial<IPage>) {
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
@@ -346,35 +362,27 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
title: data.title,
};
let queryKey: QueryKey = null;
if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId];
} else {
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, newPage],
};
}
return page;
}),
};
},
);
getChildrenCacheKeys(data.parentPageId, data.spaceId).forEach((queryKey) => {
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, newPage],
};
}
return page;
}),
};
},
);
});
//update sidebar haschildren
if (data.parentPageId !== null) {
@@ -438,34 +446,30 @@ export function invalidateOnUpdatePage(
title: string,
icon: string,
) {
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
} else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id
? {
...sidebarPage,
...(title !== undefined ? { title } : {}),
...(icon !== undefined ? { icon } : {}),
}
: sidebarPage,
),
})),
};
},
);
getChildrenCacheKeys(parentPageId, spaceId).forEach((queryKey) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
queryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id
? {
...sidebarPage,
...(title !== undefined ? { title } : {}),
...(icon !== undefined ? { icon } : {}),
}
: sidebarPage,
),
})),
};
},
);
});
//update recent changes
queryClient.invalidateQueries({
@@ -481,24 +485,21 @@ export function updateCacheOnMovePage(
pageData: Partial<IPage>,
) {
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== pageId),
})),
};
},
);
getChildrenCacheKeys(oldParentId, spaceId).forEach((oldQueryKey) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
(old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((item) => item.id !== pageId),
})),
};
},
);
});
// Update old parent's hasChildren flag if it has no more children
if (oldParentId !== null) {
@@ -540,36 +541,33 @@ export function updateCacheOnMovePage(
}
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
getChildrenCacheKeys(newParentId, spaceId).forEach((newQueryKey) => {
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
(old) => {
if (!old) return old;
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
(old) => {
if (!old) return old;
// Check if page already exists in new location
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === pageId),
);
if (exists) return old;
// Check if page already exists in new location
const exists = old.pages.some((page) =>
page.items.some((item) => item.id === pageId),
);
if (exists) return old;
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, pageData],
};
}
return page;
}),
};
},
);
return {
...old,
pages: old.pages.map((page, index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, pageData],
};
}
return page;
}),
};
},
);
});
// Update new parent's hasChildren flag
if (newParentId !== null) {
@@ -203,7 +203,14 @@ export function mergeRootTrees(
prevRoots: SpaceTreeNode[],
incomingRoots: SpaceTreeNode[],
): SpaceTreeNode[] {
const seen = new Set(prevRoots.map((r) => r.id));
const seen = new Set<string>();
const collect = (nodes: SpaceTreeNode[]) => {
for (const node of nodes) {
seen.add(node.id);
if (node.children?.length) collect(node.children);
}
};
collect(prevRoots);
// add new roots that were not present before
const merged = [...prevRoots];
@@ -0,0 +1,71 @@
import { Group, Text, SegmentedControl } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { getApiErrorMessage } from "@/lib/api-error.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
export default function WorkspaceDefaultPageEditMode() {
const { t } = useTranslation();
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Default page edit mode")}</Text>
<Text size="sm" c="dimmed">
{t(
"Choose the page edit mode new members start with. Existing members are not affected.",
)}
</Text>
</div>
<DefaultPageEditModeControl />
</Group>
);
}
function DefaultPageEditModeControl() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const defaultPageEditMode =
workspace?.settings?.defaultPageEditMode ?? PageEditMode.Edit;
const [value, setValue] = useState<string>(defaultPageEditMode);
const handleChange = async (newValue: string) => {
const prevValue = value;
setValue(newValue);
try {
const updatedWorkspace = await updateWorkspace({
defaultPageEditMode: newValue,
});
setWorkspace(updatedWorkspace);
} catch (err) {
setValue(prevValue);
notifications.show({
message: getApiErrorMessage(err, t("Failed to update setting")),
color: "red",
});
}
};
useEffect(() => {
if (defaultPageEditMode !== value) {
setValue(defaultPageEditMode);
}
}, [defaultPageEditMode, value]);
return (
<SegmentedControl
aria-label={t("Default page edit mode")}
value={value}
onChange={handleChange}
data={[
{ label: t("Edit"), value: PageEditMode.Edit },
{ label: t("Read"), value: PageEditMode.Read },
]}
/>
);
}
@@ -29,6 +29,7 @@ export interface IWorkspace {
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
allowPersonalSpaces?: boolean;
defaultPageEditMode?: string;
isScimEnabled?: boolean;
}
@@ -38,6 +39,7 @@ export interface IWorkspaceSettings {
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
spaces?: IWorkspaceSpaceSettings;
defaultPageEditMode?: string;
}
export interface IWorkspaceApiSettings {
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
import WorkspaceDefaultPageEditMode from "@/features/workspace/components/settings/components/workspace-default-page-edit-mode.tsx";
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
export default function WorkspaceSettings() {
@@ -32,6 +33,9 @@ export default function WorkspaceSettings() {
<ManageHostname />
</>
)}
<Divider my="md" />
<WorkspaceDefaultPageEditMode />
</>
);
}
+7
View File
@@ -4,6 +4,7 @@ import {
CSSVariablesResolver,
MantineColorsTuple,
Tabs,
Tooltip,
v8CssVariablesResolver,
} from "@mantine/core";
@@ -38,7 +39,13 @@ export const theme = createTheme({
blue,
red,
},
defaultRadius: 'sm',
components: {
Tooltip: Tooltip.extend({
defaultProps: {
events: { hover: true, focus: true, touch: false },
},
}),
// Size badges to their content; fit-content collapses inside table cells.
Badge: Badge.extend({
styles: (_theme, props) => ({