From 9cdc0335ff925549e3177f78b50776737e0b8d5d Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Fri, 16 Feb 2024 18:20:15 +0100 Subject: [PATCH] Remix: fix loader data not updating (#4916) * fix loader data not updating * rename store * dark mode hook * split components, drop useEffects --- .../components/projectActionContextMenu.tsx | 4 +- utopia-remix/app/hooks/useIsDarkMode.tsx | 23 + utopia-remix/app/routes/projects.tsx | 650 +++++++++--------- utopia-remix/app/store.tsx | 8 +- 4 files changed, 372 insertions(+), 313 deletions(-) create mode 100644 utopia-remix/app/hooks/useIsDarkMode.tsx diff --git a/utopia-remix/app/components/projectActionContextMenu.tsx b/utopia-remix/app/components/projectActionContextMenu.tsx index d45292adc5fb..324a6690fd50 100644 --- a/utopia-remix/app/components/projectActionContextMenu.tsx +++ b/utopia-remix/app/components/projectActionContextMenu.tsx @@ -1,7 +1,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { useFetcher } from '@remix-run/react' import React from 'react' -import { useStore } from '../store' +import { useProjectsStore } from '../store' import { contextMenuItem } from '../styles/contextMenuItem.css' import { colors } from '../styles/sprinkles.css' import { ProjectWithoutContent } from '../types' @@ -17,7 +17,7 @@ type ContextMenuEntry = export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWithoutContent }) => { const fetcher = useFetcher() - const selectedCategory = useStore((store) => store.selectedCategory) + const selectedCategory = useProjectsStore((store) => store.selectedCategory) const deleteProject = React.useCallback( (projectId: string) => { diff --git a/utopia-remix/app/hooks/useIsDarkMode.tsx b/utopia-remix/app/hooks/useIsDarkMode.tsx new file mode 100644 index 000000000000..67a3ff3a85f9 --- /dev/null +++ b/utopia-remix/app/hooks/useIsDarkMode.tsx @@ -0,0 +1,23 @@ +import React from 'react' + +export function useIsDarkMode() { + const [isDarkMode, setIsDarkMode] = React.useState(false) + + React.useEffect(() => { + const handleColorSchemeChange = (event: { + matches: boolean | ((prevState: boolean) => boolean) + }) => { + setIsDarkMode(event.matches) + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + setIsDarkMode(mediaQuery.matches) + mediaQuery.addListener(handleColorSchemeChange) + + return () => { + mediaQuery.removeListener(handleColorSchemeChange) + } + }, []) + + return isDarkMode +} diff --git a/utopia-remix/app/routes/projects.tsx b/utopia-remix/app/routes/projects.tsx index 0fcc7b7ff828..2e4a3593fe05 100644 --- a/utopia-remix/app/routes/projects.tsx +++ b/utopia-remix/app/routes/projects.tsx @@ -3,10 +3,11 @@ import { LoaderFunctionArgs, json } from '@remix-run/node' import { useFetcher, useLoaderData } from '@remix-run/react' import moment from 'moment' import { UserDetails } from 'prisma-client' -import React, { useState } from 'react' +import React from 'react' import { ProjectContextMenu } from '../components/projectActionContextMenu' +import { useIsDarkMode } from '../hooks/useIsDarkMode' import { listDeletedProjects, listProjects } from '../models/project.server' -import { useStore } from '../store' +import { useProjectsStore } from '../store' import { button } from '../styles/button.css' import { newProjectButton } from '../styles/newProjectButton.css' import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' @@ -17,6 +18,22 @@ import { assertNever } from '../util/assertNever' import { projectEditorLink } from '../util/links' import { when } from '../util/react-conditionals' +const Categories = ['allProjects', 'trash'] as const + +function isCategory(category: unknown): category is Category { + return Categories.includes(category as Category) +} + +export type Category = (typeof Categories)[number] + +const categories: { [key in Category]: { name: string } } = { + allProjects: { name: 'All My Projects' }, + trash: { name: 'Trash' }, +} + +const MarginSize = 30 +const SidebarRowHeight = 30 + export async function loader(args: LoaderFunctionArgs) { const user = await requireUser(args.request) @@ -31,113 +48,181 @@ export async function loader(args: LoaderFunctionArgs) { return json({ projects, deletedProjects, user }) } -const Categories = ['allProjects', 'trash'] as const - -export type Category = (typeof Categories)[number] - -const categories: { [key in Category]: { name: string } } = { - allProjects: { name: 'All My Projects' }, - trash: { name: 'Trash' }, -} - const ProjectsPage = React.memo(() => { - const marginSize = 30 - const rowHeight = 30 - const data = useLoaderData() as unknown as { projects: ProjectWithoutContent[] user: UserDetails deletedProjects: ProjectWithoutContent[] } - const fetcher = useFetcher() - - const [projects, setProjects] = React.useState(data.projects) - const [searchQuery, setSearchQuery] = useState('') - const [isDarkMode, setIsDarkMode] = useState(false) + const selectedCategory = useProjectsStore((store) => store.selectedCategory) - const filteredProjects = React.useMemo(() => { - const sanitizedQuery = searchQuery.trim().toLowerCase() - if (sanitizedQuery.length === 0) { - return projects + const activeProjects = React.useMemo(() => { + switch (selectedCategory) { + case 'allProjects': + return data.projects + case 'trash': + return data.deletedProjects + default: + assertNever(selectedCategory) } - return projects.filter((project) => project.title.toLowerCase().includes(sanitizedQuery)) - }, [projects, searchQuery]) + }, [data.projects, data.deletedProjects, selectedCategory]) - const selectedProjectId = useStore((store) => store.selectedProjectId) - const setSelectedProjectId = useStore((store) => store.setSelectedProjectId) - const selectedCategory = useStore((store) => store.selectedCategory) - const setCategory = useStore((store) => store.setSelectedCategory) - - const updateProjects = React.useCallback( - (category: Category) => { - switch (category) { - case 'allProjects': - setProjects(data.projects) - break - case 'trash': - setProjects(data.deletedProjects) - break - default: - assertNever(category) - } - }, - [data.projects, data.deletedProjects], + return ( +
+ +
+ + + ) +
+
) +}) +ProjectsPage.displayName = 'ProjectsPage' - const handleEmptyTrash = React.useCallback(() => { - const ok = window.confirm( - 'Are you sure? ALL projects in the trash will be deleted permanently.', - ) - if (ok) { - fetcher.submit({}, { method: 'POST', action: `/internal/projects/destroy` }) - } - }, [fetcher]) +export default ProjectsPage - const handleProjectSelect = React.useCallback( - (project: ProjectWithoutContent) => { - if (project.deleted) { - return +const Sidebar = React.memo(({ user }: { user: UserDetails }) => { + const searchQuery = useProjectsStore((store) => store.searchQuery) + const setSearchQuery = useProjectsStore((store) => store.setSearchQuery) + const selectedCategory = useProjectsStore((store) => store.selectedCategory) + const setSelectedCategory = useProjectsStore((store) => store.setSelectedCategory) + const setSelectedProjectId = useProjectsStore((store) => store.setSelectedProjectId) + + const isDarkMode = useIsDarkMode() + + const logoPic = React.useMemo(() => { + return isDarkMode ? 'url(/assets/pyramid_dark.png)' : 'url(/assets/pyramid_light.png)' + }, [isDarkMode]) + + const handleSelectCategory = React.useCallback( + (category: string) => () => { + if (isCategory(category)) { + setSelectedCategory(category) + setSearchQuery('') + setSelectedProjectId(null) } - setSelectedProjectId(project.proj_id === selectedProjectId ? null : project.proj_id) }, - [selectedProjectId, setSelectedProjectId], + [setSelectedCategory, setSearchQuery, setSelectedProjectId], ) - const udpateCategory = React.useCallback( - (category: Category) => { - setCategory(category) - setSearchQuery('') - setSelectedProjectId(null) - updateProjects(category) - }, - [selectedCategory, setSelectedProjectId, updateProjects], - ) + return ( +
+
+
+ +
{user.name}
+
- const handleCategoryClick = React.useCallback( - (category: React.SetStateAction) => { - udpateCategory(category as Category) - }, - [udpateCategory], + { + setSearchQuery(e.target.value) + }} + style={{ + border: 'none', + background: 'transparent', + outline: 'none', + color: 'grey', + height: SidebarRowHeight, + borderBottom: '1px solid gray', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: '0 14px', + }} + placeholder='Search…' + /> +
+ {Object.entries(categories).map(([category, data]) => { + return ( + + ) + })} +
+
+
+
+ Utopia +
+
) +}) +Sidebar.displayName = 'Sidebar' - // when the media query changes, update the theme - React.useEffect(() => { - const handleColorSchemeChange = (event: { - matches: boolean | ((prevState: boolean) => boolean) - }) => { - setIsDarkMode(event.matches) - } - - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') - setIsDarkMode(mediaQuery.matches) - mediaQuery.addListener(handleColorSchemeChange) - - return () => { - mediaQuery.removeListener(handleColorSchemeChange) - } - }, []) - +const TopActionBar = React.memo(() => { const newProjectButtons = [ { id: 'createProject', @@ -167,253 +252,200 @@ const ProjectsPage = React.memo(() => { // }, ] as const - const logoPic = isDarkMode ? 'url(/assets/pyramid_dark.png)' : 'url(/assets/pyramid_light.png)' - return (
-
-
-
- -
{data.user.name}
-
- - { - setSearchQuery(e.target.value) - }} - style={{ - border: 'none', - background: 'transparent', - outline: 'none', - color: 'grey', - height: rowHeight, - borderBottom: '1px solid gray', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - padding: '0 14px', - }} - placeholder='Search…' - /> -
- {Object.entries(categories).map(([category, data]) => { - return ( - - ) - })} -
-
-
-
- Utopia -
-
-
-
- {newProjectButtons.map((p) => ( - - ))} -
-
-
-
- {when( - searchQuery !== '', - - - { - setSearchQuery('') - const inputElement = document.getElementById( - 'search-input', - ) as HTMLInputElement - if (inputElement) { - inputElement.value = '' - } - }} - style={{ cursor: 'pointer' }} - > - ←{' '} - {' '} - Search results for - - "{searchQuery}" - , - )} - {when( - searchQuery === '', -
{categories[selectedCategory].name}
, - )} -
- -
- {when( - selectedCategory === 'trash', - + ))} +
+ ) +}) +TopActionBar.displayName = 'TopActionBar' + +const CategoryHeader = React.memo(({ projects }: { projects: ProjectWithoutContent[] }) => { + const searchQuery = useProjectsStore((store) => store.searchQuery) + const setSearchQuery = useProjectsStore((store) => store.setSearchQuery) + const selectedCategory = useProjectsStore((store) => store.selectedCategory) + + return ( +
+
+
+ {when( + searchQuery !== '', + + + { + setSearchQuery('') + const inputElement = document.getElementById('search-input') as HTMLInputElement + if (inputElement) { + inputElement.value = '' + } + }} + style={{ cursor: 'pointer' }} > - Empty trash - , - )} -
-
+ ←{' '} + {' '} + Search results for + + "{searchQuery}" + , + )} + {when( + searchQuery === '', +
{categories[selectedCategory].name}
, + )}
-
- {filteredProjects.map((project) => ( - handleProjectSelect(project)} - /> - ))} -
+
) }) -ProjectsPage.displayName = 'ProjectsPage' +CategoryHeader.displayName = 'CategoryHeader' + +const CategoryActions = React.memo(({ projects }: { projects: ProjectWithoutContent[] }) => { + const selectedCategory = useProjectsStore((store) => store.selectedCategory) + + switch (selectedCategory) { + case 'allProjects': + return null + case 'trash': + return + default: + assertNever(selectedCategory) + } +}) +CategoryActions.displayName = 'CategoryActions' -export default ProjectsPage +const CategoryTrashActions = React.memo(({ projects }: { projects: ProjectWithoutContent[] }) => { + const fetcher = useFetcher() -type ProjectCardProps = { - project: ProjectWithoutContent - selected: boolean - onSelect: () => void -} + const handleEmptyTrash = React.useCallback(() => { + const ok = window.confirm( + 'Are you sure? ALL projects in the trash will be deleted permanently.', + ) + if (ok) { + fetcher.submit({}, { method: 'POST', action: `/internal/projects/destroy` }) + } + }, [fetcher]) -const ProjectCard: React.FC = ({ project, selected, onSelect }) => { - const openProject = React.useCallback(() => { - window.open(projectEditorLink(project.proj_id), '_blank') - }, [project.proj_id]) + return ( + <> + + + ) +}) +CategoryTrashActions.displayName = 'CategoryTrashActions' + +const ProjectCards = React.memo(({ projects }: { projects: ProjectWithoutContent[] }) => { + const searchQuery = useProjectsStore((store) => store.searchQuery) + const selectedProjectId = useProjectsStore((store) => store.selectedProjectId) + const setSelectedProjectId = useProjectsStore((store) => store.setSelectedProjectId) + + const handleProjectSelect = React.useCallback( + (project: ProjectWithoutContent) => + setSelectedProjectId(project.proj_id === selectedProjectId ? null : project.proj_id), + [setSelectedProjectId, selectedProjectId], + ) + + const filteredProjects = React.useMemo(() => { + const sanitizedQuery = searchQuery.trim().toLowerCase() + if (sanitizedQuery.length === 0) { + return projects + } + return projects.filter((project) => project.title.toLowerCase().includes(sanitizedQuery)) + }, [projects, searchQuery]) return (
+ {filteredProjects.map((project) => ( + handleProjectSelect(project)} + /> + ))} +
+ ) +}) +ProjectCards.displayName = 'CategoryAllProjects' + +const ProjectCard = React.memo( + ({ + project, + selected, + onSelect, + }: { + project: ProjectWithoutContent + selected: boolean + onSelect: () => void + }) => { + const openProject = React.useCallback(() => { + window.open(projectEditorLink(project.proj_id), '_blank') + }, [project.proj_id]) + + return (
- -
- ) -} + > +
+ +
+ ) + }, +) +ProjectCard.displayName = 'ProjectCard' -const ProjectActions = React.memo(({ project }: { project: ProjectWithoutContent }) => { +const ProjectCardActions = React.memo(({ project }: { project: ProjectWithoutContent }) => { return (
@@ -431,4 +463,4 @@ const ProjectActions = React.memo(({ project }: { project: ProjectWithoutContent
) }) -ProjectActions.displayName = 'ProjectActions' +ProjectCardActions.displayName = 'ProjectCardActions' diff --git a/utopia-remix/app/store.tsx b/utopia-remix/app/store.tsx index f17c2fb0d193..7f7ab2f212b1 100644 --- a/utopia-remix/app/store.tsx +++ b/utopia-remix/app/store.tsx @@ -2,14 +2,16 @@ import { create } from 'zustand' import { devtools, persist } from 'zustand/middleware' import { Category } from './routes/projects' -interface Store { +interface ProjectsStore { selectedProjectId: string | null setSelectedProjectId: (projectId: string | null) => void selectedCategory: Category setSelectedCategory: (category: Category) => void + searchQuery: string + setSearchQuery: (query: string) => void } -export const useStore = create()( +export const useProjectsStore = create()( devtools( persist( (set) => ({ @@ -18,6 +20,8 @@ export const useStore = create()( selectedProjectId: null, setSelectedProjectId: (projectId: string | null) => set(() => ({ selectedProjectId: projectId })), + searchQuery: '', + setSearchQuery: (query) => set(() => ({ searchQuery: query })), }), { name: 'store',