diff --git a/.eslintrc.json b/.eslintrc.json index 6243cb38..ca63bfc1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -67,6 +67,8 @@ "functional/functional-parameters": [ "error", { + // want to allow currying with arbitrary arguments e.g. for zustand middleware + "allowRestParameter": true, "enforceParameterCount": false } ], diff --git a/e2e/tests/smoke.spec.ts-snapshots/playground-with-solution-chromium-linux.png b/e2e/tests/smoke.spec.ts-snapshots/playground-with-solution-chromium-linux.png index 3b0b891a..1924df40 100644 Binary files a/e2e/tests/smoke.spec.ts-snapshots/playground-with-solution-chromium-linux.png and b/e2e/tests/smoke.spec.ts-snapshots/playground-with-solution-chromium-linux.png differ diff --git a/src/pages/[username].page.tsx b/src/pages/[username].page.tsx index 9c1679a6..c539444e 100644 --- a/src/pages/[username].page.tsx +++ b/src/pages/[username].page.tsx @@ -1,5 +1,5 @@ import { AutoStories, Settings } from "@mui/icons-material"; -import { Box, Button, IconButton } from "@mui/material"; +import { Box, Button, Dialog, IconButton } from "@mui/material"; import { type Topic } from "@prisma/client"; import { type MRT_ColumnDef, @@ -9,14 +9,15 @@ import { } from "material-react-table"; import { NextPage } from "next"; import Head from "next/head"; -import NextLink from "next/link"; import { useRouter } from "next/router"; +import { useState } from "react"; import { NotFoundError, QueryError } from "@/web/common/components/Error/Error"; import { Link } from "@/web/common/components/Link"; import { Loading } from "@/web/common/components/Loading/Loading"; import { useSessionUser } from "@/web/common/hooks"; import { trpc } from "@/web/common/trpc"; +import { CreateTopicForm, EditTopicForm } from "@/web/topic/components/TopicForm/TopicForm"; type RowData = Topic; @@ -35,6 +36,9 @@ const User: NextPage = () => { { enabled: !!username }, ); + const [editingTopicId, setEditingTopicId] = useState(null); + const [creatingTopic, setCreatingTopic] = useState(false); + // TODO: use suspense to better handle loading & error if (!router.isReady || !username || findUser.isLoading || sessionUserIsLoading) return ; @@ -113,14 +117,22 @@ const User: NextPage = () => { layoutMode="grid" enableRowActions={hasEditAccess} renderRowActions={({ row }) => ( - { - e.stopPropagation(); // prevent row click - void router.push(`/${foundUser.username}/${row.original.title}/settings`); - }} - > - - + <> + setEditingTopicId(row.original.id)} + > + + + setEditingTopicId(null)} + aria-label="Topic Settings" + > + + + )} positionActionsColumn="last" renderToolbarInternalActions={({ table }) => { @@ -130,14 +142,22 @@ const User: NextPage = () => { {hasEditAccess && ( - + <> + + setCreatingTopic(false)} + aria-label="New Topic" + > + + + )} ); diff --git a/src/pages/[username]/[topicTitle]/settings.page.tsx b/src/pages/[username]/[topicTitle]/settings.page.tsx deleted file mode 100644 index debcb457..00000000 --- a/src/pages/[username]/[topicTitle]/settings.page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { NextPage } from "next"; -import Head from "next/head"; -import { useRouter } from "next/router"; - -import { NotFoundError, NotLoggedInError, QueryError } from "@/web/common/components/Error/Error"; -import { Loading } from "@/web/common/components/Loading/Loading"; -import { useSessionUser } from "@/web/common/hooks"; -import { trpc } from "@/web/common/trpc"; -import { EditTopicForm } from "@/web/topic/components/TopicForm/TopicForm"; - -const TopicSettings: NextPage = () => { - const router = useRouter(); - // Router only loads query params after hydration, so we can get undefined username here. - // Value can't be string[] because not using catch-all "[...slug]". - const username = router.query.username as string | undefined; - const topicTitle = router.query.topicTitle as string | undefined; - - const { sessionUser, isLoading: sessionUserIsLoading } = useSessionUser(); - const findTopic = trpc.topic.findByUsernameAndTitle.useQuery( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `enabled` guarantees non-null before query is run - { username: username!, title: topicTitle! }, - { enabled: !!username && !!topicTitle }, - ); - - // TODO: use suspense to better handle loading & error - if (!router.isReady || !username || !topicTitle || findTopic.isLoading || sessionUserIsLoading) - return ; - // TODO: require loggedIn - add method similar to withPageAuthRequired, like withPageUserRequired - if (!sessionUser) return ; - // not authorized - if (username !== sessionUser.username) return ; - // server error - if (findTopic.error) return ; - // no topic to view - if (!findTopic.data) return ; - - return ( - <> - - Topic settings | Ameliorate - - - - - - ); -}; - -export default TopicSettings; diff --git a/src/pages/new.page.tsx b/src/pages/new.page.tsx index 06cd6a1c..f91ceea0 100644 --- a/src/pages/new.page.tsx +++ b/src/pages/new.page.tsx @@ -23,7 +23,7 @@ const NewTopic: NextPage = () => { - + ); }; diff --git a/src/web/comment/store/apiSyncerMiddleware.ts b/src/web/comment/store/apiSyncerMiddleware.ts index 29d752b1..dea1c4d3 100644 --- a/src/web/comment/store/apiSyncerMiddleware.ts +++ b/src/web/comment/store/apiSyncerMiddleware.ts @@ -151,9 +151,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { // relies on `setState`, but we might as well override `set` too in case we ever want to use that. // Tried extracting the code into a reusable function, but that seemed hard to read, so we're just // duplicating it here. - const apiSyncerSet: typeof set = (args) => { + const apiSyncerSet: typeof set = (...args) => { const storeBefore = get(); - set(args); + set(...args); const storeAfter = get(); if (isPlaygroundTopic(storeAfter.topic)) return; @@ -164,9 +164,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { const origSetState = api.setState; // eslint-disable-next-line functional/immutable-data, no-param-reassign -- mutation required https://github.com/pmndrs/zustand/issues/881#issuecomment-1076957006 - api.setState = (args) => { + api.setState = (...args) => { const storeBefore = api.getState(); - origSetState(args); + origSetState(...args); const storeAfter = api.getState(); if (isPlaygroundTopic(storeAfter.topic)) return; diff --git a/src/web/comment/store/commentStore.ts b/src/web/comment/store/commentStore.ts index 4e70ac93..8354c954 100644 --- a/src/web/comment/store/commentStore.ts +++ b/src/web/comment/store/commentStore.ts @@ -1,3 +1,4 @@ +import { Topic as ApiTopic } from "@prisma/client"; import shortUUID from "short-uuid"; import { devtools, persist } from "zustand/middleware"; import { createWithEqualityFn } from "zustand/traditional"; @@ -8,7 +9,7 @@ import { withDefaults } from "@/common/object"; import { apiSyncer } from "@/web/comment/store/apiSyncerMiddleware"; import { emitter } from "@/web/common/event"; import { storageWithDates } from "@/web/common/store/utils"; -import { StoreTopic, UserTopic } from "@/web/topic/store/store"; +import { StoreTopic } from "@/web/topic/store/store"; import { setSelected } from "@/web/view/currentViewStore/store"; import { toggleShowResolvedComments } from "@/web/view/miscTopicConfigStore"; @@ -172,7 +173,7 @@ export const resolveComment = (commentId: string, resolved: boolean) => { ); }; -export const loadCommentsFromApi = (topic: UserTopic, comments: StoreComment[]) => { +export const loadCommentsFromApi = (topic: ApiTopic, comments: StoreComment[]) => { const builtPersistedName = `${persistedNameBase}-user`; useCommentStore.persist.setOptions({ name: builtPersistedName }); @@ -180,12 +181,16 @@ export const loadCommentsFromApi = (topic: UserTopic, comments: StoreComment[]) useCommentStore.setState( { - // specify each field because we don't need to store extra data like createdAt etc. + // specify each field because we don't need to store extra data like topic's relations if they're passed in topic: { id: topic.id, - creatorName: topic.creatorName, title: topic.title, + creatorName: topic.creatorName, description: topic.description, + visibility: topic.visibility, + allowAnyoneToEdit: topic.allowAnyoneToEdit, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt, }, // specify each field because we don't need to store extra data like topicId etc. comments: comments.map((comment) => ({ diff --git a/src/web/topic/components/TopicForm/TopicForm.tsx b/src/web/topic/components/TopicForm/TopicForm.tsx index 66657074..0f91b8d0 100644 --- a/src/web/topic/components/TopicForm/TopicForm.tsx +++ b/src/web/topic/components/TopicForm/TopicForm.tsx @@ -17,7 +17,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import { Topic, User } from "@prisma/client"; +import { Topic } from "@prisma/client"; import Router from "next/router"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -25,24 +25,25 @@ import { z } from "zod"; import { topicSchema, visibilityTypes } from "@/common/topic"; import { trpc } from "@/web/common/trpc"; +import { setTopic } from "@/web/topic/store/topicActions"; import { generateBasicViews } from "@/web/view/quickViewStore/store"; -export const CreateTopicForm = ({ user }: { user: User }) => { +export const CreateTopicForm = ({ creatorName }: { creatorName: string }) => { const utils = trpc.useContext(); const createTopic = trpc.topic.create.useMutation({ onSuccess: async (newTopic, variables) => { utils.topic.findByUsernameAndTitle.setData( - { username: user.username, title: variables.topic.title }, + { username: creatorName, title: variables.topic.title }, newTopic, ); - utils.user.findByUsername.setData({ username: user.username }, (oldUser) => { + utils.user.findByUsername.setData({ username: creatorName }, (oldUser) => { if (oldUser) return { ...oldUser, topics: [...oldUser.topics, newTopic] }; return oldUser; }); - await Router.push(`/${user.username}/${variables.topic.title}`); + await Router.push(`/${creatorName}/${variables.topic.title}`); }, }); @@ -59,19 +60,34 @@ export const CreateTopicForm = ({ user }: { user: User }) => { }); }; - return ; + return ; }; -export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => { +export const EditTopicForm = ({ topic, creatorName }: { topic: Topic; creatorName: string }) => { const utils = trpc.useContext(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const updateTopic = trpc.topic.update.useMutation({ - onSuccess: async (updatedTopic, variables) => { - await Router.push(`/${user.username}/${variables.title}/settings`); + onSuccess: (updatedTopic) => { + // need to update the URL if we're changing from the topic's page in the app + const url = new URL(window.location.href); + const oldPath = `/${creatorName}/${topic.title}`; + const newPath = `/${creatorName}/${updatedTopic.title}`; + if (oldPath != newPath && url.pathname === oldPath) { + // eslint-disable-next-line functional/immutable-data + url.pathname = newPath; + const newUrl = url.toString(); + // Tried updating URL without reloading page (via Router `shallow` and also tried `window.history.replaceState` https://github.com/vercel/next.js/discussions/18072#discussioncomment-109059), + // but after doing that, when later updating query params e.g. by selecting a view, the page would reload. + // So we're just accepting having to reload right away. + void Router.push(newUrl); + } + + // update topic in store e.g. if changing description and viewing topic details in pane + setTopic(updatedTopic); // this endpoint returns all topics - utils.user.findByUsername.setData({ username: user.username }, (oldUser) => { + utils.user.findByUsername.setData({ username: creatorName }, (oldUser) => { if (oldUser) { return { ...oldUser, @@ -86,13 +102,13 @@ export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => // update old title query utils.topic.findByUsernameAndTitle.setData( - { username: user.username, title: topic.title }, + { username: creatorName, title: topic.title }, null, ); // update new title query utils.topic.findByUsernameAndTitle.setData( - { username: user.username, title: updatedTopic.title }, + { username: creatorName, title: updatedTopic.title }, updatedTopic, ); }, @@ -100,10 +116,10 @@ export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => const deleteTopic = trpc.topic.delete.useMutation({ onSuccess: async () => { - await Router.push(`/${user.username}`); + await Router.push(`/${creatorName}`); // this endpoint returns all topics - utils.user.findByUsername.setData({ username: user.username }, (oldUser) => { + utils.user.findByUsername.setData({ username: creatorName }, (oldUser) => { if (oldUser) { return { ...oldUser, @@ -114,7 +130,7 @@ export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => }); utils.topic.findByUsernameAndTitle.setData( - { username: user.username, title: topic.title }, + { username: creatorName, title: topic.title }, null, ); }, @@ -149,7 +165,7 @@ export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => aria-labelledby="alert-dialog-title" > - Delete topic {user.username}/{topic.title}? + Delete topic {creatorName}/{topic.title}? This action cannot be undone. @@ -175,12 +191,19 @@ export const EditTopicForm = ({ topic, user }: { topic: Topic; user: User }) => ); - return ; + return ( + + ); }; // not sure if extracting the form schema here is a good pattern, but it was // really annoying to have so much code to read through in the component -const formSchema = (utils: ReturnType, user: User, topic?: Topic) => { +const formSchema = (utils: ReturnType, username: string, topic?: Topic) => { return z.object({ title: topicSchema.shape.title.refine( async (title) => { @@ -189,7 +212,7 @@ const formSchema = (utils: ReturnType, user: User, topic if (!topicSchema.shape.title.safeParse(title).success) return true; const existingTopic = await utils.topic.findByUsernameAndTitle.fetch({ - username: user.username, + username, title, }); return !existingTopic; @@ -205,12 +228,12 @@ type FormData = z.infer>; interface Props { topic?: Topic; - user: User; + creatorName: string; onSubmit: (data: FormData) => void; DeleteSection?: JSX.Element; } -const TopicForm = ({ topic, user, onSubmit, DeleteSection }: Props) => { +const TopicForm = ({ topic, creatorName, onSubmit, DeleteSection }: Props) => { const utils = trpc.useContext(); const { @@ -224,7 +247,7 @@ const TopicForm = ({ topic, user, onSubmit, DeleteSection }: Props) => { } = useForm({ mode: "onBlur", // onChange seems better but probably would want to debounce api calls, which is annoying reValidateMode: "onBlur", - resolver: zodResolver(formSchema(utils, user, topic)), + resolver: zodResolver(formSchema(utils, creatorName, topic)), defaultValues: { title: topic?.title, description: topic?.description, @@ -259,7 +282,7 @@ const TopicForm = ({ topic, user, onSubmit, DeleteSection }: Props) => { @@ -353,7 +376,7 @@ const TopicForm = ({ topic, user, onSubmit, DeleteSection }: Props) => { /> - View your topic at: ameliorate.app/{user.username}/{topicTitle || "{title}"} + View your topic at: ameliorate.app/{creatorName}/{topicTitle || "{title}"} diff --git a/src/web/topic/components/TopicPane/GraphPartDetails.tsx b/src/web/topic/components/TopicPane/GraphPartDetails.tsx index cd2b936e..c9942e94 100644 --- a/src/web/topic/components/TopicPane/GraphPartDetails.tsx +++ b/src/web/topic/components/TopicPane/GraphPartDetails.tsx @@ -17,6 +17,7 @@ import { ListItemText, Tab, Typography, + styled, } from "@mui/material"; import { useCommentCount } from "@/web/comment/store/commentStore"; @@ -67,7 +68,8 @@ export const GraphPartDetails = ({ graphPart, selectedTab, setSelectedTab }: Pro const indicateComments = commentCount > 0; return ( - + // flex & max-h to ensure content takes up no more than full height, allowing inner containers to control scrolling +
{partIsNode ? ( // z-index to ensure hanging node indicators don't fall behind the next section's empty background @@ -78,126 +80,130 @@ export const GraphPartDetails = ({ graphPart, selectedTab, setSelectedTab }: Pro
{/* mt-2 to match distance from Tabs look to graph part */} - - - {!expandDetailsTabs ? ( - <> - - setSelectedTab(value)} - centered - className="px-2" - > - : } - value="Basics" - title="Basics" - aria-label="Basics" - /> - : } - value="Justification" - title="Justification" - aria-label="Justification" - /> - {partIsNode && ( + + + + {!expandDetailsTabs ? ( + <> + + setSelectedTab(value)} + centered + className="px-2" + > + : } + value="Basics" + title="Basics" + aria-label="Basics" + /> + : } + value="Justification" + title="Justification" + aria-label="Justification" + /> + {partIsNode && ( + : } + value="Research" + title="Research" + aria-label="Research" + /> + )} : } - value="Research" - title="Research" - aria-label="Research" + icon={indicateComments ? : } + value="Comments" + title="Comments" + aria-label="Comments" /> + + + + + + Basics + + + + + + + + Justification + + + + + {partIsNode && ( + + + + Research + + + + )} - : } - value="Comments" - title="Comments" - aria-label="Comments" - /> - - - - - - Basics - - - - - - - - Justification - - - - - {partIsNode && ( - + - Research + Comments - + + + + ) : ( + <> + + +
+ + + + + + + + + + + + + + + + {/* prevent adding research nodes to edges; not 100% sure that we want to restrict this, but if it continues to seem good, this section can accept node instead of graphPart */} + {partIsNode && ( + <> + + + + + + + + + + )} - - - - Comments - - - - - - - ) : ( - <> - - -
- - - - - - - - - - - - - - - - {/* prevent adding research nodes to edges; not 100% sure that we want to restrict this, but if it continues to seem good, this section can accept node instead of graphPart */} - {partIsNode && ( - <> - - - - - - - - - - - )} - - - - - - - - - - - - )} + + + + + + + + + + + + )} + ); }; + +const ContentDiv = styled("div")``; diff --git a/src/web/topic/components/TopicPane/TopicDetails.tsx b/src/web/topic/components/TopicPane/TopicDetails.tsx index 64ae3093..3c51fc0e 100644 --- a/src/web/topic/components/TopicPane/TopicDetails.tsx +++ b/src/web/topic/components/TopicPane/TopicDetails.tsx @@ -1,3 +1,4 @@ +import styled from "@emotion/styled"; import { zodResolver } from "@hookform/resolvers/zod"; import { Article, @@ -9,6 +10,7 @@ import { } from "@mui/icons-material"; import { TabContext, TabList, TabPanel } from "@mui/lab"; import { + Dialog, Divider, IconButton, List, @@ -21,8 +23,7 @@ import { Tooltip, Typography, } from "@mui/material"; -import NextLink from "next/link"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -32,6 +33,7 @@ import { useCommentCount } from "@/web/comment/store/commentStore"; import { Link } from "@/web/common/components/Link"; import { useSessionUser } from "@/web/common/hooks"; import { trpc } from "@/web/common/trpc"; +import { EditTopicForm } from "@/web/topic/components/TopicForm/TopicForm"; import { CommentSection } from "@/web/topic/components/TopicPane/CommentSection"; import { StoreTopic } from "@/web/topic/store/store"; import { setTopicDetails } from "@/web/topic/store/topicActions"; @@ -118,6 +120,8 @@ export const TopicDetails = ({ selectedTab, setSelectedTab }: Props) => { const isPlaygroundTopic = topic.id === undefined; const expandDetailsTabs = useExpandDetailsTabs(); + const [topicFormOpen, setTopicFormOpen] = useState(false); + // Ideally we could exactly reuse the indicator logic here, rather than duplicating, but not sure // a good way to do that, so we're just duplicating the logic for now. // Don't want to use the exact indicators, because pane indication seems to look better with Icon @@ -141,7 +145,8 @@ export const TopicDetails = ({ selectedTab, setSelectedTab }: Props) => { const showWatch = willShowWatch && findWatch.isSuccess; return ( - + // flex & max-h to ensure content takes up no more than full height, allowing inner containers to control scrolling + {/* whitespace-normal to wrap long topic titles */}
{isPlaygroundTopic ? ( @@ -154,144 +159,156 @@ export const TopicDetails = ({ selectedTab, setSelectedTab }: Props) => { )} - {!isPlaygroundTopic && userIsCreator && ( - - - + {!isPlaygroundTopic && sessionUser && userIsCreator && ( + <> + setTopicFormOpen(true)} + > + + + setTopicFormOpen(false)} + aria-label="Topic Settings" + > + + + )}
- + - {showWatch && ( - <> - - { - setWatch.mutate({ topicId: topic.id, type: event.target.value as WatchType }); - }} - > - {watchTypes.map((type) => ( - - {type} - - ))} - - - You will receive notifications if you're subscribed to a thread. -
-
- Your watch determines in which cases you automatically become subscribed to a - thread. -
-
- "participatingOrMentions" will subscribe you when you participate (comment) in a - thread (the "mentions" half is not implemented yet). -
-
- "all" will subscribe you to all new threads. -
-
- "ignore" will not subscribe you to any new threads. - - } - enterTouchDelay={0} // allow touch to immediately trigger - leaveTouchDelay={Infinity} // touch-away to close on mobile, since message is long - > - + {showWatch && ( + <> + + { + setWatch.mutate({ topicId: topic.id, type: event.target.value as WatchType }); }} > - - -
-
+ {watchTypes.map((type) => ( + + {type} + + ))} + + + You will receive notifications if you're subscribed to a thread. +
+
+ Your watch determines in which cases you automatically become subscribed to a + thread. +
+
+ "participatingOrMentions" will subscribe you when you participate (comment) in a + thread (the "mentions" half is not implemented yet). +
+
+ "all" will subscribe you to all new threads. +
+
+ "ignore" will not subscribe you to any new threads. + + } + enterTouchDelay={0} // allow touch to immediately trigger + leaveTouchDelay={Infinity} // touch-away to close on mobile, since message is long + > + + + +
+ - - - )} + + + )} - {!expandDetailsTabs ? ( - <> - - setSelectedTab(value)} - centered - className="px-2" - > - : } - value="Basics" - title="Basics" - aria-label="Basics" - /> - : } - value="Comments" - title="Comments" - aria-label="Comments" - /> - + {!expandDetailsTabs ? ( + <> + + setSelectedTab(value)} + centered + className="px-2" + > + : } + value="Basics" + title="Basics" + aria-label="Basics" + /> + : } + value="Comments" + title="Comments" + aria-label="Comments" + /> + - - - - Basics - - - - - - - - Comments - - - - - - - ) : ( - <> - - -
- - - - + + + + Basics + + + + + + + + Comments + + + + + + + ) : ( + <> + + +
+ + + + - + - - - - - - - - - )} + + + + + + + + + )} + ); }; + +const ContentDiv = styled.div``; diff --git a/src/web/topic/components/TopicPane/TopicPane.styles.tsx b/src/web/topic/components/TopicPane/TopicPane.styles.tsx index f23aded6..e7045ca8 100644 --- a/src/web/topic/components/TopicPane/TopicPane.styles.tsx +++ b/src/web/topic/components/TopicPane/TopicPane.styles.tsx @@ -1,47 +1,10 @@ import styled from "@emotion/styled"; -import { Drawer, IconButton, css } from "@mui/material"; +import { Drawer, css } from "@mui/material"; import { nodeWidthRem } from "@/web/topic/components/Node/EditableNode.styles"; -export type Anchor = "top" | "left" | "bottom" | "right"; - -interface ButtonProps { - anchor: Anchor; -} - -const buttonOptions = { - shouldForwardProp: (prop: string) => !["anchor"].includes(prop), -}; - -export const TogglePaneButton = styled(IconButton, buttonOptions)` - position: absolute; - z-index: ${({ theme }) => theme.zIndex.appBar - 1}; - - ${({ anchor }) => { - switch (anchor) { - case "left": - return css` - right: 0; - transform: translateX(100%); - `; - case "right": - return css` - left: 0; - transform: translateX(-100%); - `; - default: - return css` - right: 0; - top: 0; - transform: translateY(-100%); - `; - } - }} -`; - interface DrawerProps { open: boolean; - anchor: Anchor; } const options = { @@ -51,30 +14,24 @@ const options = { const drawerPaddingRem = 0.5; const drawerScrollbarWidthRem = 1; // make container big enough to hold both nodes even with scrollbar showing -const drawerMinWidthRem = nodeWidthRem * 2 + drawerPaddingRem + drawerScrollbarWidthRem; +export const drawerMinWidthRem = nodeWidthRem * 2 + drawerPaddingRem + drawerScrollbarWidthRem; // paper controls what the drawer actually looks like, but it's `position: fixed` so it // doesn't affect surrounding elements. // So there's a parent div non-fixed position in order to allow affecting surrounding elements (e.g. menu button), // and this needs to match transition and size of Paper; this is why there's css on both elements. export const StyledDrawer = styled(Drawer, options)` - ${({ open, anchor }) => { - const isLandscape = anchor !== "bottom"; - const lengthIfOpen = isLandscape - ? `${drawerMinWidthRem}rem` - : `min(30vh, ${drawerMinWidthRem}rem)`; - const length = open ? lengthIfOpen : "0"; - const width = isLandscape ? length : "100%"; - const height = isLandscape ? "100%" : length; + ${({ open }) => { + const length = open ? `${drawerMinWidthRem}rem` : "0"; const borderStyle = open ? "" : "border: none;"; // drawer is given a 1px border which takes up more space than the 0 width return css` - width: ${width}; - height: ${height}; + width: ${length}; + height: 100%; & .MuiDrawer-paper { - width: ${width}; - height: ${height}; + width: ${length}; + height: 100%; ${borderStyle}; } `; diff --git a/src/web/topic/components/TopicPane/TopicPane.tsx b/src/web/topic/components/TopicPane/TopicPane.tsx index c59a5432..0ca3a13f 100644 --- a/src/web/topic/components/TopicPane/TopicPane.tsx +++ b/src/web/topic/components/TopicPane/TopicPane.tsx @@ -1,12 +1,6 @@ -import { - AutoStories, - ChevronLeft, - ChevronRight, - KeyboardArrowDown, - KeyboardArrowUp, -} from "@mui/icons-material"; +import { AutoStories, ChevronLeft, ChevronRight } from "@mui/icons-material"; import { TabContext, TabList, TabPanel } from "@mui/lab"; -import { Tab } from "@mui/material"; +import { IconButton, Modal, Tab } from "@mui/material"; import { memo, useEffect, useState } from "react"; import { deepIsEqual } from "@/common/utils"; @@ -19,29 +13,19 @@ import { TopicDetails, DetailsTab as TopicDetailsTab, } from "@/web/topic/components/TopicPane/TopicDetails"; -import { - Anchor, - StyledDrawer, - TogglePaneButton, -} from "@/web/topic/components/TopicPane/TopicPane.styles"; +import { StyledDrawer, drawerMinWidthRem } from "@/web/topic/components/TopicPane/TopicPane.styles"; import { TopicViews } from "@/web/topic/components/TopicPane/TopicViews"; import { useSelectedGraphPart } from "@/web/view/currentViewStore/store"; -const ANCHOR_ICON_MAP = { - top: KeyboardArrowUp, - left: ChevronLeft, - bottom: KeyboardArrowDown, - right: ChevronRight, -} as const; - type TopicTab = "Details" | "Views"; interface Props { - anchor: Anchor; + anchor: "left" | "right" | "modal"; tabs: [TopicTab, ...TopicTab[]]; } const TopicPaneBase = ({ anchor, tabs }: Props) => { - const [isOpen, setIsOpen] = useState(tabs.includes("Details") ? true : false); // Views is expected to be used less, so close it by default + const defaultOpen = anchor !== "modal" && tabs.includes("Details"); // Views is expected to be used less, so close it by default + const [isOpen, setIsOpen] = useState(defaultOpen); const [selectedTab, setSelectedTab] = useState(tabs[0]); const [selectedTopicDetailsTab, setSelectedTopicDetailsTab] = useState("Basics"); @@ -97,43 +81,61 @@ const TopicPaneBase = ({ anchor, tabs }: Props) => { setSelectedTab(value); }; - const ToggleIcon = isOpen ? ANCHOR_ICON_MAP[anchor] : AutoStories; - - const TabPanelContent = { - Details: - selectedGraphPart !== null ? ( - - ) : ( - - ), - Views: , - }; + const paneContent = ( + + + {tabs.map((tab) => ( + + ))} + + + {/* overflow-auto to allow tab content to scroll */} + {/* p-0 to allow tab content to manage its own padding, since e.g. dividers prefer not to be padded */} + + {selectedGraphPart !== null ? ( + + ) : ( + + )} + + + + + + + ); - return ( + return anchor !== "modal" ? (
- - - + + {isOpen ? anchor === "left" ? : : } + { // z-auto because Pane should share space with other elements, so doesn't need to be in front PaperProps={{ className: "bg-inherit border-x-0 lg:border-t z-auto" }} > - - - {tabs.map((tab) => ( - - ))} - - {tabs.map((tab) => ( - - {TabPanelContent[tab]} - - ))} - + {paneContent}
+ ) : ( +
+ + + + setIsOpen(false)} + aria-label="Topic pane" + className="flex items-center justify-center" + > +
+ {paneContent} +
+
+
); }; diff --git a/src/web/topic/components/TopicWorkspace/AppHeader.tsx b/src/web/topic/components/TopicWorkspace/AppHeader.tsx index 729e114b..dd6082e9 100644 --- a/src/web/topic/components/TopicWorkspace/AppHeader.tsx +++ b/src/web/topic/components/TopicWorkspace/AppHeader.tsx @@ -15,9 +15,10 @@ import { useTemporalHooks } from "@/web/topic/store/utilHooks"; import { goBack, goForward, useCanGoBackForward } from "@/web/view/currentViewStore/store"; // TODO: check if need overflow-x-auto to deal with increased html font size -// h-[49px] to match SiteHeader's 48px + 1px border +// h-[calc(3rem + 1 px)] to match SiteHeader's 48px + 1px border // border stuff to allow corners to have their own border on large screens, but use the parent's shared border for smaller screens -const headerCornerClasses = "h-[49px] bg-paperShaded-main flex items-center border-0 lg:border-b"; +const headerCornerClasses = + "h-[calc(3rem_+_1px)] bg-paperShaded-main flex items-center border-0 lg:border-b"; export const AppHeader = () => { const theme = useTheme(); diff --git a/src/web/topic/components/TopicWorkspace/ContentFooter.tsx b/src/web/topic/components/TopicWorkspace/ContentFooter.tsx index d5aecbc1..7be144fc 100644 --- a/src/web/topic/components/TopicWorkspace/ContentFooter.tsx +++ b/src/web/topic/components/TopicWorkspace/ContentFooter.tsx @@ -1,4 +1,5 @@ import { + ArrowDropDown, Build, Delete, EditOff, @@ -6,11 +7,13 @@ import { Group, Highlight, QuestionMark, + SelfImprovement, TabUnselected, } from "@mui/icons-material"; import { Divider, IconButton, ToggleButton, Tooltip } from "@mui/material"; -import { useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { Menu } from "@/web/common/components/Menu/Menu"; import { emitter } from "@/web/common/event"; import { useSessionUser } from "@/web/common/hooks"; import { HelpMenu } from "@/web/topic/components/TopicWorkspace/HelpMenu"; @@ -27,13 +30,37 @@ import { useFlashlightMode, useReadonlyMode, } from "@/web/view/actionConfigStore"; +import { Perspectives } from "@/web/view/components/Perspectives/Perspectives"; import { useSelectedGraphPart } from "@/web/view/currentViewStore/store"; import { comparePerspectives, resetPerspectives, useIsComparingPerspectives, } from "@/web/view/perspectiveStore"; -import { toggleShowIndicators, useShowIndicators } from "@/web/view/userConfigStore"; +import { toggleShowIndicators, toggleZenMode, useShowIndicators } from "@/web/view/userConfigStore"; + +interface PerspectivesMenuProps { + anchorEl: HTMLElement | null; + setAnchorEl: Dispatch>; +} + +const PerspectivesMenu = ({ anchorEl, setAnchorEl }: PerspectivesMenuProps) => { + const menuOpen = Boolean(anchorEl); + if (!menuOpen) return; + + return ( + setAnchorEl(null)} + openDirection="top" + // match the ~300px width of drawer + className="w-[18.75rem] px-2" + > + + + ); +}; interface Props { /** @@ -61,6 +88,9 @@ export const ContentFooter = ({ overlay }: Props) => { const selectedGraphPart = useSelectedGraphPart(); const partIsTableEdge = useIsTableEdge(selectedGraphPart?.id ?? ""); + const [perspectivesMenuAnchorEl, setPerspectivesMenuAnchorEl] = useState( + null, + ); const [isMoreActionsDrawerOpen, setIsMoreActionsDrawerOpen] = useState(false); const [helpAnchorEl, setHelpAnchorEl] = useState(null); @@ -86,11 +116,26 @@ export const ContentFooter = ({ overlay }: Props) => { > {/* show this in content footer when screens are small and it doesn't fit between AppHeader corners, otherwise put in header */}
- +
{/* Toolbar */} + {/* Toolbar buttons have square-rounding to fit more snuggly into the toolbar; potentially could make */} + {/* all icon buttons match this but they seem ok as the default full-rounded. */}
+ toggleZenMode()} + className="rounded border-none" + > + + + { size="small" selected={showIndicators} onClick={() => toggleShowIndicators()} - className="rounded-full border-none" + className="rounded border-none" > @@ -116,10 +161,27 @@ export const ContentFooter = ({ overlay }: Props) => { onClick={() => isComparingPerspectives ? resetPerspectives() : comparePerspectives() } - className="rounded-full border-none" + className="rounded border-none" > + + setPerspectivesMenuAnchorEl(event.currentTarget)} + // small width to keep the menu button narrow + // extra right padding because otherwise icon is too close to right-divider + // extra y padding to match other icon buttons with default fontSize="medium" + className="w-3 rounded py-2.5 pl-2 pr-2.5" + > + + + )} @@ -134,7 +196,7 @@ export const ContentFooter = ({ overlay }: Props) => { size="small" selected={flashlightMode} onClick={() => toggleFlashlightMode(!flashlightMode)} - className="rounded-full border-none" + className="rounded border-none" > @@ -149,7 +211,7 @@ export const ContentFooter = ({ overlay }: Props) => { size="small" selected={readonlyMode} onClick={() => toggleReadonlyMode()} - className="rounded-full border-none" + className="rounded border-none" > @@ -170,6 +232,7 @@ export const ContentFooter = ({ overlay }: Props) => { }} // don't allow modifying edges that are part of the table, because they should always exist as long as their nodes do disabled={!selectedGraphPart || partIsTableEdge} + className="rounded" > @@ -183,6 +246,7 @@ export const ContentFooter = ({ overlay }: Props) => { title="More actions" aria-label="More actions" onClick={() => setIsMoreActionsDrawerOpen(true)} + className="rounded" > @@ -198,6 +262,7 @@ export const ContentFooter = ({ overlay }: Props) => { title="Help" aria-label="Help" onClick={(event) => setHelpAnchorEl(event.currentTarget)} + className="rounded" > @@ -223,7 +288,7 @@ export const ContentFooter = ({ overlay }: Props) => { // Don't make it look like clicking will do something, since it won't. // Using a button here is an attempt to make it accessible, since the tooltip will show // on focus. - className="cursor-default" + className="cursor-default rounded" > diff --git a/src/web/topic/components/TopicWorkspace/ContentHeader.tsx b/src/web/topic/components/TopicWorkspace/ContentHeader.tsx index ba0239d3..f1ce52b1 100644 --- a/src/web/topic/components/TopicWorkspace/ContentHeader.tsx +++ b/src/web/topic/components/TopicWorkspace/ContentHeader.tsx @@ -1,9 +1,10 @@ import { Settings } from "@mui/icons-material"; -import { IconButton } from "@mui/material"; -import NextLink from "next/link"; +import { Dialog, IconButton } from "@mui/material"; +import { useState } from "react"; import { Link } from "@/web/common/components/Link"; import { useSessionUser } from "@/web/common/hooks"; +import { EditTopicForm } from "@/web/topic/components/TopicForm/TopicForm"; import { QuickViewSelect } from "@/web/topic/components/TopicWorkspace/QuickViewSelect"; import { useTopic } from "@/web/topic/store/topicHooks"; import { useUserIsCreator } from "@/web/topic/store/userHooks"; @@ -24,6 +25,9 @@ export const ContentHeader = ({ overlay }: Props) => { const topic = useTopic(); const onPlayground = topic.id === undefined; + const showSettings = !onPlayground && sessionUser && userIsCreator; + + const [topicFormOpen, setTopicFormOpen] = useState(false); return (
{ {/* Max-w on individual children so that topic title can take up more space than creator's name, */} {/* since it usually will be longer, yet keep creator name from being scrunched if it's already short but topic title is really long. */} {/* Classes like text-nowrap are for keeping children on one line, because we don't want them taking much vertical space */} -
+
{onPlayground ? ( "Playground Topic" ) : ( @@ -61,23 +71,31 @@ export const ContentHeader = ({ overlay }: Props) => { )} - {!onPlayground && userIsCreator && ( - - - + {showSettings && ( + <> + setTopicFormOpen(true)} + > + + + setTopicFormOpen(false)} + aria-label="Topic Settings" + > + + + )}
{/* show this in content footer when screens are small and it doesn't fit between AppHeader corners, otherwise put in header */}
- +
); diff --git a/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx b/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx index e3bd46d5..1b4fadf4 100644 --- a/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx +++ b/src/web/topic/components/TopicWorkspace/MoreActionsDrawer.tsx @@ -39,7 +39,6 @@ import { getRectOfNodes, getTransformForBounds } from "reactflow"; import { getDisplayNodes } from "@/web/topic/components/Diagram/externalFlowStore"; import { downloadTopic, uploadTopic } from "@/web/topic/loadStores"; -import { useOnPlayground } from "@/web/topic/store/topicHooks"; import { resetTopicData } from "@/web/topic/store/utilActions"; import { hotkeys } from "@/web/topic/utils/hotkeys"; import { @@ -50,7 +49,6 @@ import { useReadonlyMode, useUnrestrictedEditing, } from "@/web/view/actionConfigStore"; -import { Perspectives } from "@/web/view/components/Perspectives/Perspectives"; import { toggleShowImpliedEdges, toggleShowProblemCriterionSolutionEdges, @@ -127,7 +125,6 @@ export const MoreActionsDrawer = ({ sessionUser, userCanEditTopicData, }: Props) => { - const onPlayground = useOnPlayground(); const format = useFormat(); const isTableActive = format === "table"; @@ -409,16 +406,6 @@ export const MoreActionsDrawer = ({ )} - {!onPlayground && ( - <> - Perspectives - - - - - - )} - User Config diff --git a/src/web/topic/components/TopicWorkspace/QuickViewSelect.tsx b/src/web/topic/components/TopicWorkspace/QuickViewSelect.tsx index 4f07549f..b0d9cc91 100644 --- a/src/web/topic/components/TopicWorkspace/QuickViewSelect.tsx +++ b/src/web/topic/components/TopicWorkspace/QuickViewSelect.tsx @@ -1,12 +1,8 @@ -import { MenuItem, TextField } from "@mui/material"; +import { TextField } from "@mui/material"; import { selectView, useQuickViews, useSelectedViewId } from "@/web/view/quickViewStore/store"; -interface Props { - openDirection: "top" | "bottom"; -} - -export const QuickViewSelect = ({ openDirection }: Props) => { +export const QuickViewSelect = () => { const quickViews = useQuickViews(); const selectedViewId = useSelectedViewId(); @@ -19,31 +15,22 @@ export const QuickViewSelect = ({ openDirection }: Props) => { // jank to manually specify these, but the label should be reduced in size since the Select's text is reduced InputLabelProps={{ className: "text-sm translate-x-[1.0625rem] -translate-y-2 scale-75" }} SelectProps={{ + // native select dropdown maybe looks less stylish but it comes with space-to-open/close, up/down-to-change-value, esc to blur, which is really nice + // TODO: native select also doesn't size based on selected option, would be nice if it did... unfortunately it seems like right now this requires annoying js https://stackoverflow.com/questions/20091481/auto-resizing-the-select-element-according-to-selected-options-width + native: true, // override to be smaller than MUI allows - SelectDisplayProps: { className: "text-sm py-1" }, - MenuProps: { - anchorOrigin: { - vertical: openDirection === "top" ? "top" : "bottom", - horizontal: "left", - }, - transformOrigin: { - vertical: openDirection === "top" ? "bottom" : "top", - horizontal: "left", - }, - }, + className: "text-sm [&_>_select]:py-1 [&_*]:text-ellipsis", }} - // ensure no overflow if view titles are really long - className="max-w-full" > {/* e.g. if user manually changes a filter */} - + {quickViews.map((view, index) => ( - + + ))} ); diff --git a/src/web/topic/components/TopicWorkspace/TopicWorkspace.tsx b/src/web/topic/components/TopicWorkspace/TopicWorkspace.tsx index b2266c75..42d17270 100644 --- a/src/web/topic/components/TopicWorkspace/TopicWorkspace.tsx +++ b/src/web/topic/components/TopicWorkspace/TopicWorkspace.tsx @@ -1,6 +1,7 @@ -import { Global, useTheme } from "@emotion/react"; +import { useTheme } from "@emotion/react"; import styled from "@emotion/styled"; -import { useMediaQuery } from "@mui/material"; +import { SelfImprovement } from "@mui/icons-material"; +import { ToggleButton, useMediaQuery } from "@mui/material"; import { useHotkeys } from "react-hotkeys-hook"; import { ContextMenu } from "@/web/common/components/ContextMenu/ContextMenu"; @@ -24,7 +25,7 @@ import { userCanEditScores } from "@/web/topic/utils/score"; import { getReadonlyMode, toggleReadonlyMode } from "@/web/view/actionConfigStore"; import { getSelectedGraphPart, setSelected, useFormat } from "@/web/view/currentViewStore/store"; import { getPerspectives } from "@/web/view/perspectiveStore"; -import { toggleShowIndicators } from "@/web/view/userConfigStore"; +import { toggleShowIndicators, toggleZenMode, useZenMode } from "@/web/view/userConfigStore"; const useWorkspaceHotkeys = (user: { username: string } | null | undefined) => { useHotkeys([hotkeys.deselectPart], () => setSelected(null)); @@ -44,6 +45,23 @@ const useWorkspaceHotkeys = (user: { username: string } | null | undefined) => { }); }; +const ZenModeButton = () => { + return ( + toggleZenMode()} + className="absolute bottom-0 left-0 z-10 rounded border-none" + > + + + ); +}; + export const TopicWorkspace = () => { const { sessionUser } = useSessionUser(); @@ -51,27 +69,28 @@ export const TopicWorkspace = () => { const format = useFormat(); const theme = useTheme(); - const isLandscape = useMediaQuery("(orientation: landscape)"); - const usingBigScreen = useMediaQuery(theme.breakpoints.up("2xl")); - const useSplitPanes = isLandscape && usingBigScreen; + const using2xlScreen = useMediaQuery(theme.breakpoints.up("2xl")); + const useSplitPanes = using2xlScreen; + const usingLgScreen = useMediaQuery(theme.breakpoints.up("lg")); + + const zenMode = useZenMode(); return ( // h-svh to force workspace to take up full height of screen
- + {!zenMode && } -
+
- + {!zenMode && } + {zenMode && } {format === "table" && ( @@ -85,7 +104,7 @@ export const TopicWorkspace = () => { )} - + {!zenMode && } {useSplitPanes && ( @@ -93,9 +112,6 @@ export const TopicWorkspace = () => { )} - - {/* prevents body scrolling when workspace is rendered*/} -
diff --git a/src/web/topic/store/apiSyncerMiddleware.ts b/src/web/topic/store/apiSyncerMiddleware.ts index 1a9e8f88..e0642f7a 100644 --- a/src/web/topic/store/apiSyncerMiddleware.ts +++ b/src/web/topic/store/apiSyncerMiddleware.ts @@ -154,9 +154,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { // relies on `setState`, but we might as well override `set` too in case we ever want to use that. // Tried extracting the code into a reusable function, but that seemed hard to read, so we're just // duplicating it here. - const apiSyncerSet: typeof set = (args) => { + const apiSyncerSet: typeof set = (...args) => { const storeBefore = get(); - set(args); + set(...args); const storeAfter = get(); if (isPlaygroundTopic(storeAfter.topic)) return; @@ -167,9 +167,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { const origSetState = api.setState; // eslint-disable-next-line functional/immutable-data, no-param-reassign -- mutation required https://github.com/pmndrs/zustand/issues/881#issuecomment-1076957006 - api.setState = (args) => { + api.setState = (...args) => { const storeBefore = api.getState(); - origSetState(args); + origSetState(...args); const storeAfter = api.getState(); if (isPlaygroundTopic(storeAfter.topic)) return; diff --git a/src/web/topic/store/loadActions.ts b/src/web/topic/store/loadActions.ts index ac69e3bb..2a630e49 100644 --- a/src/web/topic/store/loadActions.ts +++ b/src/web/topic/store/loadActions.ts @@ -25,6 +25,10 @@ export const populateDiagramFromApi = (topicData: TopicData) => { title: topicData.title, creatorName: topicData.creatorName, description: topicData.description, + visibility: topicData.visibility, + allowAnyoneToEdit: topicData.allowAnyoneToEdit, + createdAt: topicData.createdAt, + updatedAt: topicData.updatedAt, }, nodes: topicGraphNodes, edges: topicGraphEdges, diff --git a/src/web/topic/store/store.ts b/src/web/topic/store/store.ts index c2e03463..3139a86d 100644 --- a/src/web/topic/store/store.ts +++ b/src/web/topic/store/store.ts @@ -1,3 +1,4 @@ +import { Topic as ApiTopic } from "@prisma/client"; import sortBy from "lodash/sortBy"; import uniqBy from "lodash/uniqBy"; import { temporal } from "zundo"; @@ -35,13 +36,7 @@ export interface PlaygroundTopic { description: string; } -export type UserTopic = Omit & { - id: number; - creatorName: string; - title: string; -}; - -export type StoreTopic = UserTopic | PlaygroundTopic; +export type StoreTopic = ApiTopic | PlaygroundTopic; // TODO: probably better to put userScores into a separate store (it doesn't seem necessary to // couple scores with the nodes/edges, and we'd be able to avoid triggering score comparators by diff --git a/src/web/topic/store/topicActions.ts b/src/web/topic/store/topicActions.ts index c087eb0e..1599349c 100644 --- a/src/web/topic/store/topicActions.ts +++ b/src/web/topic/store/topicActions.ts @@ -1,3 +1,4 @@ +import { Topic as ApiTopic } from "@prisma/client"; import { createDraft, finishDraft } from "immer"; import { useTopicStore } from "@/web/topic/store/store"; @@ -10,3 +11,7 @@ export const setTopicDetails = (description: string) => { useTopicStore.setState(finishDraft(state), false, "setTopicDetails"); }; + +export const setTopic = (topic: ApiTopic) => { + useTopicStore.setState({ topic }, false, "setTopic"); +}; diff --git a/src/web/topic/store/userHooks.ts b/src/web/topic/store/userHooks.ts index a0cc4c7e..38ad6e36 100644 --- a/src/web/topic/store/userHooks.ts +++ b/src/web/topic/store/userHooks.ts @@ -1,7 +1,6 @@ import { shallow } from "zustand/shallow"; -import { trpc } from "@/web/common/trpc"; -import { UserTopic, useTopicStore } from "@/web/topic/store/store"; +import { useTopicStore } from "@/web/topic/store/store"; import { isPlaygroundTopic } from "@/web/topic/store/utils"; import { useReadonlyMode } from "@/web/view/actionConfigStore"; @@ -10,19 +9,12 @@ export const useUserCanEditTopicData = (username?: string) => { const storeTopic = useTopicStore((state) => state.topic, shallow); const readonlyMode = useReadonlyMode(); - const findTopic = trpc.topic.findByUsernameAndTitle.useQuery( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `enabled` guarantees non-null id, and if there's an id, it's a UserTopic - { username: (storeTopic as UserTopic).creatorName, title: (storeTopic as UserTopic).title }, - { enabled: storeTopic.id !== undefined }, - ); - if (readonlyMode) return false; if (isPlaygroundTopic(storeTopic)) return true; if (!username) return false; if (storeTopic.creatorName === username) return true; - if (findTopic.isLoading || findTopic.isError || !findTopic.data) return false; - return findTopic.data.allowAnyoneToEdit; + return storeTopic.allowAnyoneToEdit; }; export const useUserIsCreator = (username?: string) => { diff --git a/src/web/view/components/Perspectives/Perspectives.tsx b/src/web/view/components/Perspectives/Perspectives.tsx index 31535368..2de02b45 100644 --- a/src/web/view/components/Perspectives/Perspectives.tsx +++ b/src/web/view/components/Perspectives/Perspectives.tsx @@ -18,7 +18,7 @@ export const Perspectives = () => { setPerspectives([...value])} // hmm need to spread because value is readonly and our params would have to be readonly all the way up the chain for ts to accept it diff --git a/src/web/view/currentViewStore/store.ts b/src/web/view/currentViewStore/store.ts index c88e278e..512f5e6d 100644 --- a/src/web/view/currentViewStore/store.ts +++ b/src/web/view/currentViewStore/store.ts @@ -178,7 +178,7 @@ export const setFormat = (format: Format) => { * E.g. so that a new non-null value in initialState is non-null in the persisted state, * removing the need to write a migration for every new field. */ -const withViewDefaults = (viewState?: Partial) => { +export const withViewDefaults = (viewState?: Partial) => { if (!viewState) return initialViewState; return withDefaults(viewState, initialViewState); }; diff --git a/src/web/view/currentViewStore/triggerEventMiddleware.ts b/src/web/view/currentViewStore/triggerEventMiddleware.ts index 180e1ccc..4f340c72 100644 --- a/src/web/view/currentViewStore/triggerEventMiddleware.ts +++ b/src/web/view/currentViewStore/triggerEventMiddleware.ts @@ -20,8 +20,8 @@ const triggerEventImpl: TriggerEventImpl = (create) => (set, get, api) => { // relies on `setState`, but we might as well override `set` too in case we ever want to use that. // Tried extracting the code into a reusable function, but that seemed hard to read, so we're just // duplicating it here. - const triggerEventSet: typeof set = (args) => { - set(args); + const triggerEventSet: typeof set = (...args) => { + set(...args); const newView = get(); emitter.emit("changedView", newView); @@ -29,8 +29,8 @@ const triggerEventImpl: TriggerEventImpl = (create) => (set, get, api) => { const origSetState = api.setState; // eslint-disable-next-line functional/immutable-data, no-param-reassign -- mutation required https://github.com/pmndrs/zustand/issues/881#issuecomment-1076957006 - api.setState = (args) => { - origSetState(args); + api.setState = (...args) => { + origSetState(...args); const newView = get(); emitter.emit("changedView", newView); diff --git a/src/web/view/quickViewStore/apiSyncerMiddleware.ts b/src/web/view/quickViewStore/apiSyncerMiddleware.ts index ecadabf6..05e00fc3 100644 --- a/src/web/view/quickViewStore/apiSyncerMiddleware.ts +++ b/src/web/view/quickViewStore/apiSyncerMiddleware.ts @@ -154,9 +154,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { // relies on `setState`, but we might as well override `set` too in case we ever want to use that. // Tried extracting the code into a reusable function, but that seemed hard to read, so we're just // duplicating it here. - const apiSyncerSet: typeof set = (args) => { + const apiSyncerSet: typeof set = (...args) => { const storeBefore = get(); - set(args); + set(...args); const storeAfter = get(); if (isPlaygroundTopic(storeAfter.topic)) return; @@ -167,9 +167,9 @@ const apiSyncerImpl: ApiSyncerImpl = (create) => (set, get, api) => { const origSetState = api.setState; // eslint-disable-next-line functional/immutable-data, no-param-reassign -- mutation required https://github.com/pmndrs/zustand/issues/881#issuecomment-1076957006 - api.setState = (args) => { + api.setState = (...args) => { const storeBefore = api.getState(); - origSetState(args); + origSetState(...args); const storeAfter = api.getState(); if (isPlaygroundTopic(storeAfter.topic)) return; diff --git a/src/web/view/quickViewStore/store.ts b/src/web/view/quickViewStore/store.ts index 59a6e8db..da7f7536 100644 --- a/src/web/view/quickViewStore/store.ts +++ b/src/web/view/quickViewStore/store.ts @@ -1,3 +1,4 @@ +import { Topic as ApiTopic } from "@prisma/client"; import Router from "next/router"; import shortUUID from "short-uuid"; import { temporal } from "zundo"; @@ -9,8 +10,14 @@ import { errorWithData } from "@/common/errorHandling"; import { withDefaults } from "@/common/object"; import { deepIsEqual } from "@/common/utils"; import { emitter } from "@/web/common/event"; -import { StoreTopic, UserTopic } from "@/web/topic/store/store"; -import { ViewState, getView, initialViewState, setView } from "@/web/view/currentViewStore/store"; +import { StoreTopic } from "@/web/topic/store/store"; +import { + ViewState, + getView, + initialViewState, + setView, + withViewDefaults, +} from "@/web/view/currentViewStore/store"; import { apiSyncer } from "@/web/view/quickViewStore/apiSyncerMiddleware"; import { migrate } from "@/web/view/quickViewStore/migrate"; @@ -242,7 +249,7 @@ export const selectView = (viewId: string | null) => { export const selectViewFromState = (viewState: ViewState) => { const { views } = useQuickViewStore.getState(); - const view = views.find((view) => deepIsEqual(view.viewState, viewState)); + const view = views.find((view) => deepIsEqual(withViewDefaults(view.viewState), viewState)); if (view === undefined) return; useQuickViewStore.temporal.getState().pause(); @@ -266,7 +273,7 @@ export const resetQuickViews = () => { useQuickViewStore.setState({ ...initialStateWithBasicViews(), topic }, true, "reset"); }; -export const loadQuickViewsFromApi = (topic: UserTopic, views: QuickView[]) => { +export const loadQuickViewsFromApi = (topic: ApiTopic, views: QuickView[]) => { const builtPersistedName = `${persistedNameBase}-user`; useQuickViewStore.persist.setOptions({ name: builtPersistedName }); @@ -274,12 +281,16 @@ export const loadQuickViewsFromApi = (topic: UserTopic, views: QuickView[]) => { useQuickViewStore.setState( { - // specify each field because we don't need to store extra data like createdAt etc. + // specify each field because we don't need to store extra data like topic's relations if they're passed in topic: { id: topic.id, - creatorName: topic.creatorName, title: topic.title, + creatorName: topic.creatorName, description: topic.description, + visibility: topic.visibility, + allowAnyoneToEdit: topic.allowAnyoneToEdit, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt, }, // specify each field because we don't need to store extra data like createdAt etc. views: views.map((view) => ({ @@ -347,12 +358,23 @@ export const getPersistState = () => { }; // misc -// if a quick view is selected and the view changes from that, deselect it -emitter.on("changedView", (_newView) => { - if (selectingView) return; // don't deselect the view if this event was triggered by selection +// ensure selected quick view is updated when the current view changes +emitter.on("changedView", (newView) => { + if (selectingView) return; // don't change selected view if this event was triggered by selection const state = useQuickViewStore.getState(); - if (state.selectedViewId === null) return; - selectView(null); + // If the view changed, and the state matches a Quick View, set that Quick View as selected, + // otherwise deselect the selected view. + // Doing a deep comparison for each quick view on every current view change is probably a + // bit unperformant, but it's nice to have when merely selecting a node results in deselecting the + // current view. + // TODO: consider removing `selectedGraphPartId` from this store so that doesn't trigger this event, + // then maybe it'd be less painful to remove this deep comparison. + const match = state.views.find((view) => deepIsEqual(withViewDefaults(view.viewState), newView)); + if (match) { + if (state.selectedViewId !== match.id) selectView(match.id); + } else { + if (state.selectedViewId !== null) selectView(null); + } }); diff --git a/src/web/view/userConfigStore.ts b/src/web/view/userConfigStore.ts index 249e39b6..7b6c7c25 100644 --- a/src/web/view/userConfigStore.ts +++ b/src/web/view/userConfigStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; interface UserConfigStoreState { + zenMode: boolean; showIndicators: boolean; fillNodesWithColor: boolean; indicateWhenNodeForcedToShow: boolean; @@ -9,6 +10,7 @@ interface UserConfigStoreState { } const initialState: UserConfigStoreState = { + zenMode: false, showIndicators: false, fillNodesWithColor: false, indicateWhenNodeForcedToShow: false, @@ -22,6 +24,10 @@ const useUserConfigStore = create()( ); // hooks +export const useZenMode = () => { + return useUserConfigStore((state) => state.zenMode); +}; + export const useShowIndicators = () => { return useUserConfigStore((state) => state.showIndicators); }; @@ -39,6 +45,10 @@ export const useExpandDetailsTabs = () => { }; // actions +export const toggleZenMode = () => { + useUserConfigStore.setState((state) => ({ zenMode: !state.zenMode })); +}; + export const toggleShowIndicators = () => { useUserConfigStore.setState((state) => ({ showIndicators: !state.showIndicators })); };