diff --git a/app/components/Sidebar/components/DocumentLink.tsx b/app/components/Sidebar/components/DocumentLink.tsx index f76d563ea..34502f7f8 100644 --- a/app/components/Sidebar/components/DocumentLink.tsx +++ b/app/components/Sidebar/components/DocumentLink.tsx @@ -69,7 +69,7 @@ function InnerDocumentLink( if (isActiveDocument && hasChildDocuments) { void fetchChildDocuments(node.id); } - }, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]); + }, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]); const pathToNode = React.useMemo( () => collection?.pathToDocument(node.id).map((entry) => entry.id), diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx index c8f2c6d64..ea7afae0d 100644 --- a/app/components/Switch.tsx +++ b/app/components/Switch.tsx @@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>` display: inline-block; width: ${(props) => props.width}px; height: ${(props) => props.height}px; - margin-right: 8px; flex-shrink: 0; + + &:not(:last-child) { + margin-right: 8px; + } `; const Slider = styled.span<{ width: number; height: number }>` diff --git a/app/components/Text.ts b/app/components/Text.ts index 3d6c2dafb..bd69efccc 100644 --- a/app/components/Text.ts +++ b/app/components/Text.ts @@ -14,7 +14,7 @@ type Props = { */ const Text = styled.p` margin-top: 0; - text-align: ${(props) => (props.dir ? props.dir : "auto")}; + text-align: ${(props) => (props.dir ? props.dir : "initial")}; color: ${(props) => props.type === "secondary" ? props.theme.textSecondary diff --git a/app/models/Document.ts b/app/models/Document.ts index af1d9814f..ebd0c2503 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -44,44 +44,77 @@ export default class Document extends ParanoidModel { store: DocumentsStore; - @Field - @observable - collectionId?: string | null; - @Field @observable id: string; + /** + * The id of the collection that this document belongs to, if any. + */ + @Field + @observable + collectionId?: string | null; + + /** + * The text content of the document as Markdown. + */ @observable text: string; + /** + * The title of the document. + */ @Field @observable title: string; + /** + * Whether this is a template. + */ @observable template: boolean; + /** + * Whether the document layout is displayed full page width. + */ @Field @observable fullWidth: boolean; + /** + * Whether team members can see who has viewed this document. + */ + @Field + @observable + insightsEnabled: boolean; + + /** + * A reference to the template that this document was created from. + */ @Field @observable templateId: string | undefined; + /** + * The id of the parent document that this is a child of, if any. + */ @Field @observable parentDocumentId: string | undefined; + @observable collaboratorIds: string[]; + @observable createdBy: User; + @observable updatedBy: User; + @observable publishedAt: string | undefined; + @observable archivedAt: string; url: string; diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx index 641c204d6..714a9c999 100644 --- a/app/scenes/Document/components/Insights.tsx +++ b/app/scenes/Document/components/Insights.tsx @@ -12,9 +12,11 @@ import DocumentViews from "~/components/DocumentViews"; import Flex from "~/components/Flex"; import ListItem from "~/components/List/Item"; import PaginatedList from "~/components/PaginatedList"; +import Switch from "~/components/Switch"; import Text from "~/components/Text"; import Time from "~/components/Time"; import useKeyDown from "~/hooks/useKeyDown"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useTextSelection from "~/hooks/useTextSelection"; import { documentPath } from "~/utils/routeHelpers"; @@ -30,6 +32,7 @@ function Insights() { const { editor } = useDocumentContext(); const text = editor?.getPlainText(); const stats = useTextStats(text ?? "", selectedText); + const can = usePolicy(document); const documentViews = document ? views.inDocument(document.id) : []; const onCloseInsights = () => { @@ -83,57 +86,86 @@ function Insights() { - - {t("Contributors")} - - {t(`Created`)} - - ( - } - subtitle={ - model.id === document.createdBy.id - ? t("Creator") - : model.id === document.updatedBy.id - ? t("Last edited") - : t("Previously edited") - } - border={false} - small + {document.insightsEnabled && ( + <> + + {t("Contributors")} + + {t(`Created`)}{" "} + + + ( + } + subtitle={ + model.id === document.createdBy.id + ? t("Creator") + : model.id === document.updatedBy.id + ? t("Last edited") + : t("Previously edited") + } + border={false} + small + /> + )} /> + + + + {t("Views")} + + {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, + } + )} + . + + {documentViews.length > 1 && ( + + + )} + + + )} + {can.updateInsights && ( + + + + {t("Viewer insights")} + + + {t( + "As an admin you can manage if team members can see who has viewed this document" + )} + + + { + document.insightsEnabled = ev.currentTarget.checked; + await document.save(); + }} /> - - - - {t("Views")} - - {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, - })} - . - - {documentViews.length > 1 && ( - - - - )} - + + )} ) : null} @@ -166,6 +198,17 @@ function countWords(text: string): number { return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0; } +const Manage = styled(Flex)` + border: 1px solid ${s("inputBorder")}; + border-bottom-width: 2px; + border-radius: 8px; + margin: 16px; + padding: 16px 16px 0; + + position: absolute; + bottom: 0; +`; + const ListSpacing = styled("div")` margin-top: -0.5em; margin-bottom: 0.5em; diff --git a/server/commands/documentUpdater.ts b/server/commands/documentUpdater.ts index d06813ed8..0be8722df 100644 --- a/server/commands/documentUpdater.ts +++ b/server/commands/documentUpdater.ts @@ -19,6 +19,8 @@ type Props = { templateId?: string | null; /** If the document should be displayed full-width on the screen */ fullWidth?: boolean; + /** Whether insights should be visible on the document */ + insightsEnabled?: boolean; /** Whether the text be appended to the end instead of replace */ append?: boolean; /** Whether the document should be published to the collection */ @@ -46,6 +48,7 @@ export default async function documentUpdater({ editorVersion, templateId, fullWidth, + insightsEnabled, append, publish, collectionId, @@ -68,6 +71,9 @@ export default async function documentUpdater({ if (fullWidth !== undefined) { document.fullWidth = fullWidth; } + if (insightsEnabled !== undefined) { + document.insightsEnabled = insightsEnabled; + } if (text !== undefined) { document = DocumentHelper.applyMarkdownToDocument(document, text, append); } diff --git a/server/migrations/20230720002422-add-insights-control.js b/server/migrations/20230720002422-add-insights-control.js new file mode 100644 index 000000000..a112ba1c7 --- /dev/null +++ b/server/migrations/20230720002422-add-insights-control.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("documents", "insightsEnabled", { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }); + }, + + async down (queryInterface, Sequelize) { + await queryInterface.removeColumn("documents", "insightsEnabled"); + } +}; diff --git a/server/models/Document.ts b/server/models/Document.ts index db2898cd1..6cb961a0c 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -196,6 +196,9 @@ class Document extends ParanoidModel { @Column fullWidth: boolean; + @Column + insightsEnabled: boolean; + @SimpleLength({ max: 255, msg: `editorVersion must be 255 characters or less`, diff --git a/server/policies/document.ts b/server/policies/document.ts index 0cfc8a7e6..8ef45d804 100644 --- a/server/policies/document.ts +++ b/server/policies/document.ts @@ -277,6 +277,20 @@ allow(User, "archive", Document, (user, document) => { return user.teamId === document.teamId; }); +allow(User, "updateInsights", Document, (user, document) => { + if (!document || !document.isActive || document.isDraft) { + return false; + } + invariant( + document.collection, + "collection is missing, did you forget to include in the query scope?" + ); + if (cannot(user, "update", document.collection)) { + return false; + } + return user.teamId === document.teamId; +}); + allow(User, "unarchive", Document, (user, document) => { if (!document) { return false; diff --git a/server/presenters/document.ts b/server/presenters/document.ts index 4286434c2..49f2c9abb 100644 --- a/server/presenters/document.ts +++ b/server/presenters/document.ts @@ -41,6 +41,7 @@ async function presentDocument( templateId: document.templateId, collaboratorIds: [], revision: document.revisionCount, + insightsEnabled: document.insightsEnabled, fullWidth: document.fullWidth, collectionId: undefined, parentDocumentId: undefined, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 5cd88fae8..dc6b42f71 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -892,18 +892,8 @@ router.post( auth(), validate(T.DocumentsUpdateSchema), async (ctx: APIContext) => { - const { - id, - title, - text, - fullWidth, - publish, - templateId, - collectionId, - append, - apiVersion, - done, - } = ctx.input.body; + const { id, apiVersion, insightsEnabled, publish, collectionId, ...input } = + ctx.input.body; const editorVersion = ctx.headers["x-editor-version"] as string | undefined; const { user } = ctx.state.auth; let collection: Collection | null | undefined; @@ -915,6 +905,10 @@ router.post( collection = document?.collection; authorize(user, "update", document); + if (collection && insightsEnabled !== undefined) { + authorize(user, "updateInsights", document); + } + if (publish) { if (!document.collectionId) { assertPresent( @@ -932,16 +926,12 @@ router.post( await documentUpdater({ document, user, - title, - text, - fullWidth, + ...input, publish, collectionId, - append, - templateId, + insightsEnabled, editorVersion, transaction, - done, ip: ctx.request.ip, }); diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index 26b387214..ef3411eab 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -189,6 +189,9 @@ export const DocumentsUpdateSchema = BaseSchema.extend({ /** Boolean to denote if the doc should occupy full width */ fullWidth: z.boolean().optional(), + /** Boolean to denote if insights should be visible on the doc */ + insightsEnabled: z.boolean().optional(), + /** Boolean to denote if the doc should be published */ publish: z.boolean().optional(), diff --git a/server/routes/api/views/views.ts b/server/routes/api/views/views.ts index abf6e2ec1..37be7b26a 100644 --- a/server/routes/api/views/views.ts +++ b/server/routes/api/views/views.ts @@ -1,4 +1,5 @@ import Router from "koa-router"; +import { ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import validate from "@server/middlewares/validate"; @@ -23,6 +24,11 @@ router.post( userId: user.id, }); authorize(user, "read", document); + + if (!document.insightsEnabled) { + throw ValidationError("Insights are not enabled for this document"); + } + const views = await View.findByDocument(documentId, { includeSuspended }); ctx.body = { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 7611565b6..e203233ed 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -511,6 +511,8 @@ "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": "Viewer insights", + "As an admin you can manage if team members can see who has viewed this document": "As an admin you can manage if team members can see who has viewed this document", "Sorry, it looks like you don’t have permission to access the document": "Sorry, it looks like you don’t have permission to access the document", "Sorry, this document is too large - edits will no longer be persisted.": "Sorry, this document is too large - edits will no longer be persisted.", "Sorry, the last change could not be persisted – please reload the page": "Sorry, the last change could not be persisted – please reload the page",