Convert insights from sidebar to modal dialog (#9892)

* Convert insights from sidebar to modal dialog

- Remove insights routing and sidebar layout
- Update DocumentMeta to use modal action instead of navigation
- Convert Insights component to modal-ready format
- Clean up route helpers and authenticated layout
- Remove insights route from authenticated routes

Co-authored-by: Tom Moor <tom@getoutline.com>

* Applied automatic fixes

* refactor

* singular

* refactor

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-08-11 18:57:27 -04:00
committed by GitHub
parent 4fc6ac1f15
commit b1dffc3486
13 changed files with 152 additions and 248 deletions

View File

@@ -26,7 +26,6 @@ import {
PublishIcon,
CommentIcon,
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
@@ -70,7 +69,6 @@ import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
@@ -84,6 +82,7 @@ import {
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -1329,7 +1328,7 @@ export const openDocumentHistory = createInternalLinkActionV2({
},
});
export const openDocumentInsights = createInternalLinkActionV2({
export const openDocumentInsights = createActionV2({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1347,51 +1346,17 @@ export const openDocumentInsights = createInternalLinkActionV2({
!document?.isDeleted
);
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
perform: ({ activeDocumentId, stores, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
const [pathname, search] = documentInsightsPath(document).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const toggleViewerInsights = createActionV2({
name: ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return document?.insightsEnabled
? t("Disable viewer insights")
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: ActiveDocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return can.updateInsights;
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.save({
insightsEnabled: !document.insightsEnabled,
stores.dialogs.openModal({
title: t("Insights"),
content: <Insights document={document} />,
});
},
});

View File

@@ -27,7 +27,6 @@ import {
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -39,9 +38,7 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
@@ -98,12 +95,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showInsights =
!!matchPath(location.pathname, {
path: matchDocumentInsights,
}) && can.listViews;
const showComments =
!showInsights &&
!showHistory &&
can.comment &&
ui.activeDocumentId &&
@@ -115,12 +107,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showInsights || showComments) && (
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>

View File

@@ -148,6 +148,10 @@ function Collaborators(props: Props) {
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
return null;
}
return (
<Popover>
<PopoverTrigger>

View File

@@ -0,0 +1,16 @@
import { formatNumber } from "~/utils/language";
import useUserLocale from "./useUserLocale";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
/**
* Hook that returns a function to format numbers based on the user's locale.
*
* @returns A function that formats numbers
*/
export function useFormatNumber() {
const language = useUserLocale();
return (input: number) =>
language
? formatNumber(input, unicodeCLDRtoBCP47(language))
: input.toString();
}

View File

@@ -148,6 +148,13 @@ function DocumentMenu({
[user, document]
);
const handleInsightsToggle = React.useCallback(
(checked: boolean) => {
void document.save({ insightsEnabled: checked });
},
[document]
);
const templateMenuActions = useTemplateMenuActions({
document,
onSelectTemplate,
@@ -231,6 +238,18 @@ function DocumentMenu({
<>
<MenuSeparator />
<DisplayOptions>
{can.updateInsights && (
<Style>
<ToggleMenuItem
width={26}
height={14}
label={t("Enable viewer insights")}
labelPosition="left"
checked={document.insightsEnabled}
onChange={handleInsightsToggle}
/>
</Style>
)}
{showToggleEmbeds && (
<Style>
<ToggleMenuItem
@@ -263,6 +282,7 @@ function DocumentMenu({
can.update,
document.embedsDisabled,
document.fullWidth,
document.insightsEnabled,
isMobile,
showDisplayOptions,
showToggleEmbeds,

View File

@@ -1,36 +0,0 @@
import { t } from "i18next";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import NudeButton from "~/components/NudeButton";
import { toggleViewerInsights } from "~/actions/definitions/documents";
import { useMemo } from "react";
import { useMenuAction } from "~/hooks/useMenuAction";
const InsightsMenu: React.FC = () => {
const actions = useMemo(() => [toggleViewerInsights], []);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} align="end" ariaLabel={t("Insights")}>
<Button>
<MoreIcon />
</Button>
</DropdownMenu>
);
};
const Button = styled(NudeButton)`
color: ${s("textSecondary")};
&:${hover},
&:active,
&[data-state="open"] {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
export default InsightsMenu;

View File

@@ -93,11 +93,7 @@ function AuthenticatedRoutes() {
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route
exact
path={`/doc/${slug}/insights`}
component={Document}
/>
<Route exact path={`/doc/${slug}/edit`} component={Document} />
<Route path={`/doc/${slug}`} component={Document} />
<Route

View File

@@ -8,13 +8,15 @@ import styled from "styled-components";
import { TeamPreference } from "@shared/types";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import { openDocumentInsights } from "~/actions/definitions/documents";
import DocumentMeta from "~/components/DocumentMeta";
import Fade from "~/components/Fade";
import useActionContext from "~/hooks/useActionContext";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
/* The document to display meta data for */
@@ -35,10 +37,12 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
const onlyYou = totalViewers === 1 && documentViews[0].userId;
const viewsLoadedOnMount = useRef(totalViewers > 0);
const can = usePolicy(document);
const actionContext = useActionContext({
activeDocumentId: document.id,
});
const Wrapper = viewsLoadedOnMount.current ? Fragment : Fade;
const insightsPath = documentInsightsPath(document);
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
const commentingEnabled = !!team.getPreference(TeamPreference.Commenting);
@@ -67,14 +71,8 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
!document.isTemplate ? (
<Wrapper>
&nbsp;&nbsp;
<Link
to={{
pathname:
match.url === insightsPath
? documentPath(document)
: insightsPath,
state: { sidebarContext },
}}
<InsightsButton
onClick={() => openDocumentInsights.perform(actionContext)}
>
{t("Viewed by")}{" "}
{onlyYou
@@ -82,7 +80,7 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</Link>
</InsightsButton>
</Wrapper>
) : null}
</Meta>
@@ -94,6 +92,20 @@ const CommentLink = styled(Link)`
align-items: center;
`;
const InsightsButton = styled.button`
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
text-decoration: none;
cursor: var(--pointer);
&:hover {
text-decoration: underline;
}
`;
export const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;

View File

@@ -1,55 +1,33 @@
import { observer } from "mobx-react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { stringToColor } from "@shared/utils/color";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { useDocumentContext } from "~/components/DocumentContext";
import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useKeyDown from "~/hooks/useKeyDown";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import InsightsMenu from "~/menus/InsightsMenu";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
import type Document from "~/models/Document";
import { useFormatNumber } from "~/hooks/useFormatNumber";
function Insights() {
const { views, documents } = useStores();
type Props = {
document: Document;
};
function Insights({ document }: Props) {
const { t } = useTranslation();
const match = useRouteMatch<{ documentSlug: string }>();
const history = useHistory();
const sidebarContext = useLocationSidebarContext();
const selectedText = useTextSelection();
const document = documents.getByUrl(match.params.documentSlug);
const { editor } = useDocumentContext();
const text = editor?.getPlainText();
const text = document.toPlainText();
const stats = useTextStats(text ?? "", selectedText);
const can = usePolicy(document);
const documentViews = document ? views.inDocument(document.id) : [];
const onCloseInsights = () => {
if (document) {
history.push({
pathname: documentPath(document),
state: { sidebarContext },
});
}
};
useKeyDown("Escape", onCloseInsights);
const formatNumber = useFormatNumber();
return (
<Sidebar title={t("Insights")} onClose={onCloseInsights}>
<div>
{document ? (
<Flex
column
@@ -58,69 +36,86 @@ function Insights() {
justify="space-between"
>
<div>
<Content column>
{document.sourceMetadata && (
<>
<Heading>{t("Source")}</Heading>
{
<Text as="p" type="secondary" size="small">
<Flex column>
<Text as="h2" size="large">
{t("Source")}
</Text>
<Text as="p" type="secondary" size="small">
<List>
<li>
{t("Created")}{" "}
<Time dateTime={document.createdAt} addSuffix />
</li>
<li>
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />
</li>
{document.sourceMetadata && (
<li>
{t("Imported from {{ source }}", {
source:
document.sourceName ??
`${document.sourceMetadata.fileName}`,
})}
</Text>
}
</>
)}
<Heading>{t("Stats")}</Heading>
</li>
)}
</List>
</Text>
<Text as="h2" size="large">
{t("Stats")}
</Text>
<Text as="p" type="secondary" size="small">
<List>
{stats.total.words > 0 && (
<li>
{t(`{{ count }} minute read`, {
count: stats.total.readingTime,
{t(`{{ number }} minute read`, {
number: formatNumber(stats.total.readingTime),
})}
</li>
)}
<li>
{t(`{{ count }} words`, { count: stats.total.words })}
</li>
<li>
{t(`{{ count }} characters`, {
count: stats.total.characters,
{t(`{{ number }} words`, {
count: stats.total.words,
number: formatNumber(stats.total.words),
})}
</li>
<li>
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
{t(`{{ number }} characters`, {
count: stats.total.characters,
number: formatNumber(stats.total.characters),
})}
</li>
<li>
{t(`{{ number }} emoji`, {
number: formatNumber(stats.total.emoji),
})}
</li>
{stats.selected.characters === 0 ? (
<li>{t("No text selected")}</li>
) : (
<>
<li>
{t(`{{ count }} words selected`, {
{t(`{{ number }} words selected`, {
count: stats.selected.words,
number: formatNumber(stats.selected.words),
})}
</li>
<li>
{t(`{{ count }} characters selected`, {
{t(`{{ number }} characters selected`, {
count: stats.selected.characters,
number: formatNumber(stats.selected.characters),
})}
</li>
</>
)}
</List>
</Text>
</Content>
</Flex>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text as="p" type="secondary" size="small">
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
<Flex column>
<Text as="h2" size="large">
{t("Contributors")}
</Text>
<ListSpacing>
{document.sourceMetadata?.createdByName && (
@@ -166,49 +161,11 @@ function Insights() {
)}
/>
</ListSpacing>
</Content>
{(document.insightsEnabled || can.updateInsights) && (
<Content column>
<Heading>
<Flex justify="space-between">
{t("Viewed by")}
{can.updateInsights && <InsightsMenu />}
</Flex>
</Heading>
{document.insightsEnabled ? (
<>
<Text as="p" type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(
`Viewed {{ count }} times by {{ teamMembers }} people`,
{
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
}
)}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} />
</ListSpacing>
)}
</>
) : (
<Text as="p" type="secondary" size="small">
{t("Viewer insights are disabled.")}
</Text>
)}
</Content>
)}
</Flex>
</div>
</Flex>
) : null}
</Sidebar>
</div>
);
}
@@ -218,7 +175,7 @@ const ListSpacing = styled("div")`
`;
const List = styled("ul")`
margin: 0;
margin: 0 0 1em;
padding: 0;
list-style: none;
@@ -231,13 +188,4 @@ const List = styled("ul")`
}
`;
const Content = styled(Flex)`
padding: 0 16px;
user-select: none;
`;
const Heading = styled("h3")`
font-size: 15px;
`;
export default observer(Insights);

View File

@@ -13,9 +13,8 @@ import {
} from "~/components/SortableTable";
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useUserLocale from "~/hooks/useUserLocale";
import ShareMenu from "~/menus/ShareMenu";
import { formatNumber } from "~/utils/language";
import { useFormatNumber } from "~/hooks/useFormatNumber";
const ROW_HEIGHT = 50;
@@ -25,7 +24,7 @@ type Props = Omit<TableProps<Share>, "columns" | "rowHeight"> & {
export function SharesTable({ data, canManage, ...rest }: Props) {
const { t } = useTranslation();
const language = useUserLocale();
const formatNumber = useFormatNumber();
const hasDomain = data.some((share) => share.domain);
const columns = useMemo<TableColumn<Share>[]>(
@@ -101,13 +100,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
id: "views",
header: t("Views"),
accessor: (share) => share.views,
component: (share) => (
<>
{language
? formatNumber(share.views, unicodeCLDRtoBCP47(language))
: share.views}
</>
),
component: (share) => formatNumber(share.views),
width: "150px",
},
canManage
@@ -123,7 +116,7 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
}
: undefined,
]),
[t, language, hasDomain, canManage]
[t, hasDomain, canManage]
);
return (

View File

@@ -1,9 +1,12 @@
import { i18n } from "i18next";
import { locales, unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Desktop from "./Desktop";
import User from "~/models/User";
import useUserLocale from "~/hooks/useUserLocale";
/**
* Formats a number using the user's locale where possible.
* Formats a number using the user's locale where possible. Use `useFormatNumber` hook
* instead of this function in React components, to automatically use the user's locale.
*
* @param number The number to format
* @param locale The locale to use for formatting (BCP47 format)

View File

@@ -63,9 +63,7 @@ export function documentEditPath(doc: Document): string {
return `${documentPath(doc)}/edit`;
}
export function documentInsightsPath(doc: Document): string {
return `${documentPath(doc)}/insights`;
}
export function documentHistoryPath(
doc: Document,
@@ -155,4 +153,4 @@ export const matchDocumentEdit = `/doc/${matchDocumentSlug}/edit`;
export const matchDocumentHistory = `/doc/${matchDocumentSlug}/history/:revisionId?`;
export const matchDocumentInsights = `/doc/${matchDocumentSlug}/insights`;

View File

@@ -105,8 +105,6 @@
"Comments": "Comments",
"History": "History",
"Insights": "Insights",
"Disable viewer insights": "Disable viewer insights",
"Enable viewer insights": "Enable viewer insights",
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
@@ -567,6 +565,7 @@
"Rename": "Rename",
"Collection menu": "Collection menu",
"Comment options": "Comment options",
"Enable viewer insights": "Enable viewer insights",
"Enable embeds": "Enable embeds",
"Document options": "Document options",
"File": "File",
@@ -688,29 +687,24 @@
"Restore version": "Restore version",
"No history yet": "No history yet",
"Source": "Source",
"Created": "Created",
"Imported from {{ source }}": "Imported from {{ source }}",
"Stats": "Stats",
"{{ count }} minute read": "{{ count }} minute read",
"{{ count }} minute read_plural": "{{ count }} minute read",
"{{ count }} words": "{{ count }} word",
"{{ count }} words_plural": "{{ count }} words",
"{{ count }} characters": "{{ count }} character",
"{{ count }} characters_plural": "{{ count }} characters",
"{{ number }} minute read": "{{ number }} minute read",
"{{ number }} words": "{{ number }} word",
"{{ number }} words_plural": "{{ number }} words",
"{{ number }} characters": "{{ number }} character",
"{{ number }} characters_plural": "{{ number }} characters",
"{{ number }} emoji": "{{ number }} emoji",
"No text selected": "No text selected",
"{{ count }} words selected": "{{ count }} word selected",
"{{ count }} words selected_plural": "{{ count }} words selected",
"{{ count }} characters selected": "{{ count }} character selected",
"{{ count }} characters selected_plural": "{{ count }} characters selected",
"{{ number }} words selected": "{{ number }} word selected",
"{{ number }} words selected_plural": "{{ number }} words selected",
"{{ number }} characters selected": "{{ number }} character selected",
"{{ number }} characters selected_plural": "{{ number }} characters selected",
"Contributors": "Contributors",
"Created": "Created",
"Creator": "Creator",
"Last edited": "Last edited",
"Previously edited": "Previously edited",
"No one else has viewed yet": "No one else has viewed yet",
"Viewed {{ count }} times by {{ teamMembers }} people": "Viewed {{ count }} time by {{ teamMembers }} people",
"Viewed {{ count }} times by {{ teamMembers }} people_plural": "Viewed {{ count }} times by {{ teamMembers }} people",
"Viewer insights are disabled.": "Viewer insights are disabled.",
"Sorry, the last change could not be persisted please reload the page": "Sorry, the last change could not be persisted please reload the page",
"{{ count }} days": "{{ count }} day",
"{{ count }} days_plural": "{{ count }} days",