From a53f620a6d658c01c26c59e007d3d80034e83e81 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Thu, 15 Feb 2024 12:00:43 +0100 Subject: [PATCH] Remix: state management (#4904) * add zustand * use store for category --- .../components/projectActionContextMenu.tsx | 258 +++++++++--------- utopia-remix/app/routes/projects.tsx | 64 ++--- utopia-remix/app/store.tsx | 22 ++ utopia-remix/package.json | 3 +- utopia-remix/pnpm-lock.yaml | 30 ++ 5 files changed, 204 insertions(+), 173 deletions(-) create mode 100644 utopia-remix/app/store.tsx diff --git a/utopia-remix/app/components/projectActionContextMenu.tsx b/utopia-remix/app/components/projectActionContextMenu.tsx index 53d3f248367d..483771f76452 100644 --- a/utopia-remix/app/components/projectActionContextMenu.tsx +++ b/utopia-remix/app/components/projectActionContextMenu.tsx @@ -1,13 +1,12 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' - import { useFetcher } from '@remix-run/react' import React from 'react' -import { Category } from '../routes/projects' +import { useStore } from '../store' +import { contextMenuItem } from '../styles/contextMenuItem.css' +import { colors } from '../styles/sprinkles.css' import { ProjectWithoutContent } from '../types' import { assertNever } from '../util/assertNever' import { projectEditorLink } from '../util/links' -import { contextMenuItem } from '../styles/contextMenuItem.css' -import { colors } from '../styles/sprinkles.css' type ContextMenuEntry = | { @@ -16,148 +15,141 @@ type ContextMenuEntry = } | 'separator' -export const ProjectContextMenu = React.memo( - ({ - selectedCategory, - project, - }: { - selectedCategory: Category - project: ProjectWithoutContent - }) => { - const fetcher = useFetcher() +export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWithoutContent }) => { + const fetcher = useFetcher() + const selectedCategory = useStore((store) => store.selectedCategory) - const deleteProject = React.useCallback( - (projectId: string) => { - fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/delete` }) - }, - [fetcher], - ) + const deleteProject = React.useCallback( + (projectId: string) => { + fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/delete` }) + }, + [fetcher], + ) - const destroyProject = React.useCallback( - (projectId: string) => { - const ok = window.confirm('Are you sure? The project contents will be deleted permanently.') - if (ok) { - fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/destroy` }) - } - }, - [fetcher], - ) + const destroyProject = React.useCallback( + (projectId: string) => { + const ok = window.confirm('Are you sure? The project contents will be deleted permanently.') + if (ok) { + fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/destroy` }) + } + }, + [fetcher], + ) - const restoreProject = React.useCallback( - (projectId: string) => { - fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/restore` }) - }, - [fetcher], - ) + const restoreProject = React.useCallback( + (projectId: string) => { + fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/restore` }) + }, + [fetcher], + ) - const renameProject = React.useCallback( - (projectId: string, newTitle: string) => { - fetcher.submit( - { title: newTitle }, - { method: 'POST', action: `/internal/projects/${projectId}/rename` }, - ) - }, - [fetcher], - ) + const renameProject = React.useCallback( + (projectId: string, newTitle: string) => { + fetcher.submit( + { title: newTitle }, + { method: 'POST', action: `/internal/projects/${projectId}/rename` }, + ) + }, + [fetcher], + ) - const menuEntries = React.useMemo((): ContextMenuEntry[] => { - switch (selectedCategory) { - case 'allProjects': - return [ - { - text: 'Open', - onClick: (project) => { - window.open(projectEditorLink(project.proj_id), '_blank') - }, + const menuEntries = React.useMemo((): ContextMenuEntry[] => { + switch (selectedCategory) { + case 'allProjects': + return [ + { + text: 'Open', + onClick: (project) => { + window.open(projectEditorLink(project.proj_id), '_blank') }, - 'separator', - { - text: 'Copy Link', - onClick: (project) => { - navigator.clipboard.writeText(projectEditorLink(project.proj_id)) - // TODO notification toast - }, + }, + 'separator', + { + text: 'Copy Link', + onClick: (project) => { + navigator.clipboard.writeText(projectEditorLink(project.proj_id)) + // TODO notification toast }, - 'separator', - { - text: 'Rename', - onClick: (project) => { - const newTitle = window.prompt('New title:', project.title) - if (newTitle != null) { - renameProject(project.proj_id, newTitle) - } - }, + }, + 'separator', + { + text: 'Rename', + onClick: (project) => { + const newTitle = window.prompt('New title:', project.title) + if (newTitle != null) { + renameProject(project.proj_id, newTitle) + } }, - { - text: 'Delete', - onClick: (project) => { - deleteProject(project.proj_id) - }, + }, + { + text: 'Delete', + onClick: (project) => { + deleteProject(project.proj_id) }, - ] - case 'trash': - return [ - { - text: 'Restore', - onClick: (project) => { - restoreProject(project.proj_id) - }, + }, + ] + case 'trash': + return [ + { + text: 'Restore', + onClick: (project) => { + restoreProject(project.proj_id) }, - 'separator', - { - text: 'Delete permanently', - onClick: (project) => { - destroyProject(project.proj_id) - }, + }, + 'separator', + { + text: 'Delete permanently', + onClick: (project) => { + destroyProject(project.proj_id) }, - ] - default: - assertNever(selectedCategory) - } - }, [selectedCategory]) + }, + ] + default: + assertNever(selectedCategory) + } + }, [selectedCategory]) - return ( - <> - - - {menuEntries.map((entry, index) => { - if (entry === 'separator') { - return ( - - ) - } + return ( + <> + + + {menuEntries.map((entry, index) => { + if (entry === 'separator') { return ( - entry.onClick(project)} - className={contextMenuItem()} - > - {entry.text} - + ) - })} - - + } + return ( + entry.onClick(project)} + className={contextMenuItem()} + > + {entry.text} + + ) + })} + + - - - ) - }, -) + + + ) +}) ProjectContextMenu.displayName = 'ProjectContextMenu' diff --git a/utopia-remix/app/routes/projects.tsx b/utopia-remix/app/routes/projects.tsx index bac3f705e060..5244831c7bbf 100644 --- a/utopia-remix/app/routes/projects.tsx +++ b/utopia-remix/app/routes/projects.tsx @@ -1,3 +1,4 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { LoaderFunctionArgs, json } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' import moment from 'moment' @@ -5,6 +6,8 @@ import { UserDetails } from 'prisma-client' import React, { useEffect, useState } from 'react' import { ProjectContextMenu } from '../components/projectActionContextMenu' import { listDeletedProjects, listProjects } from '../models/project.server' +import { useStore } from '../store' +import { button } from '../styles/button.css' import { newProjectButton } from '../styles/newProjectButton.css' import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' import { sprinkles } from '../styles/sprinkles.css' @@ -13,9 +16,6 @@ import { requireUser } from '../util/api.server' import { assertNever } from '../util/assertNever' import { projectEditorLink } from '../util/links' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import { button } from '../styles/button.css' - export async function loader(args: LoaderFunctionArgs) { const user = await requireUser(args.request) @@ -56,10 +56,11 @@ const ProjectsPage = React.memo(() => { } const clearSelectedProject = () => setSelectedProject({ selectedProjectId: null }) - const [selectedCategory, setSelectedCategory] = useState('allProjects') + const selectedCategory = useStore((store) => store.selectedCategory) + const setCategory = useStore((store) => store.setSelectedCategory) const handleCategoryClick = (category: React.SetStateAction) => { - setSelectedCategory(category as Category) + setCategory(category as Category) } const data = useLoaderData() as unknown as { @@ -340,7 +341,6 @@ const ProjectsPage = React.memo(() => { project={project} selected={project.proj_id === selectedProject.selectedProjectId} onSelect={() => handleProjectSelect(project.proj_id)} - selectedCategory={selectedCategory} /> ))} @@ -356,15 +356,9 @@ type ProjectCardProps = { project: ProjectWithoutContent selected: boolean onSelect: () => void - selectedCategory: Category } -const ProjectCard: React.FC = ({ - project, - selected, - onSelect, - selectedCategory, -}) => { +const ProjectCard: React.FC = ({ project, selected, onSelect }) => { const openProject = React.useCallback(() => { window.open(projectEditorLink(project.proj_id), '_blank') }, [project.proj_id]) @@ -393,35 +387,27 @@ const ProjectCard: React.FC = ({ onMouseDown={onSelect} onDoubleClick={openProject} /> - + ) } -const ProjectActions = React.memo( - ({ - project, - selectedCategory, - }: { - project: ProjectWithoutContent - selectedCategory: Category - }) => { - return ( -
-
-
{project.title}
-
{moment(project.modified_at).fromNow()}
-
-
- - - - - - -
+const ProjectActions = React.memo(({ project }: { project: ProjectWithoutContent }) => { + return ( +
+
+
{project.title}
+
{moment(project.modified_at).fromNow()}
+
+
+ + + + + +
- ) - }, -) +
+ ) +}) ProjectActions.displayName = 'ProjectActions' diff --git a/utopia-remix/app/store.tsx b/utopia-remix/app/store.tsx new file mode 100644 index 000000000000..1981bd011fb3 --- /dev/null +++ b/utopia-remix/app/store.tsx @@ -0,0 +1,22 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { Category } from './routes/projects' + +interface Store { + selectedCategory: Category + setSelectedCategory: (category: Category) => void +} + +export const useStore = create()( + devtools( + persist( + (set) => ({ + selectedCategory: 'allProjects', + setSelectedCategory: (category: Category) => set(() => ({ selectedCategory: category })), + }), + { + name: 'store', + }, + ), + ), +) diff --git a/utopia-remix/package.json b/utopia-remix/package.json index 9988d3eb7fdd..eef05f99e6c7 100644 --- a/utopia-remix/package.json +++ b/utopia-remix/package.json @@ -29,7 +29,8 @@ "react-dom": "18.2.0", "slugify": "1.6.6", "tiny-invariant": "1.3.1", - "url-join": "5.0.0" + "url-join": "5.0.0", + "zustand": "4.5.0" }, "devDependencies": { "@babel/core": "7.23.9", diff --git a/utopia-remix/pnpm-lock.yaml b/utopia-remix/pnpm-lock.yaml index 850b68497856..70fa5f8b49f7 100644 --- a/utopia-remix/pnpm-lock.yaml +++ b/utopia-remix/pnpm-lock.yaml @@ -42,6 +42,7 @@ specifiers: ts-node: 10.9.2 typescript: 5.1.6 url-join: 5.0.0 + zustand: 4.5.0 dependencies: '@prisma/client': 5.9.0_prisma@5.9.0 @@ -60,6 +61,7 @@ dependencies: slugify: 1.6.6 tiny-invariant: 1.3.1 url-join: 5.0.0 + zustand: 4.5.0_j3ahe22lw6ac2w6qvqp4kjqnqy devDependencies: '@babel/core': 7.23.9 @@ -8979,6 +8981,14 @@ packages: tslib: 2.6.2 dev: false + /use-sync-external-store/1.2.0_react@18.2.0: + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /util-deprecate/1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -9291,6 +9301,26 @@ packages: engines: {node: '>=10'} dev: true + /zustand/4.5.0_j3ahe22lw6ac2w6qvqp4kjqnqy: + resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.20 + react: 18.2.0 + use-sync-external-store: 1.2.0_react@18.2.0 + dev: false + /zwitch/2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: true