Skip to content

Commit fe3c197

Browse files
authored
Remix projects pending operations (#4970)
* add motion * fetcher with operation * operation * use the new fetchers * split storage * active operation divs * cleanup operations * types * op with key * use v3_fetcherPersist * no need to check null * separate operation component * update naming * flip sorting
1 parent 13edb1a commit fe3c197

8 files changed

+299
-38
lines changed

utopia-remix/app/components/projectActionContextMenu.tsx

+21-11
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
2-
import { useFetcher } from '@remix-run/react'
32
import React from 'react'
43
import { useProjectsStore } from '../store'
54
import { contextMenuDropdown, contextMenuItem } from '../styles/contextMenu.css'
65
import { sprinkles } from '../styles/sprinkles.css'
7-
import { ProjectWithoutContent } from '../types'
6+
import { ProjectWithoutContent, operation } from '../types'
87
import { assertNever } from '../util/assertNever'
98
import { projectEditorLink } from '../util/links'
9+
import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation'
1010

1111
type ContextMenuEntry =
1212
| {
@@ -16,41 +16,51 @@ type ContextMenuEntry =
1616
| 'separator'
1717

1818
export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWithoutContent }) => {
19-
const fetcher = useFetcher()
19+
const deleteFetcher = useFetcherWithOperation(operation(project, 'delete'))
20+
const destroyFetcher = useFetcherWithOperation(operation(project, 'destroy'))
21+
const restoreFetcher = useFetcherWithOperation(operation(project, 'restore'))
22+
const renameFetcher = useFetcherWithOperation(operation(project, 'rename'))
23+
2024
const selectedCategory = useProjectsStore((store) => store.selectedCategory)
2125

2226
const deleteProject = React.useCallback(
2327
(projectId: string) => {
24-
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/delete` })
28+
deleteFetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/delete` })
2529
},
26-
[fetcher],
30+
[deleteFetcher],
2731
)
2832

2933
const destroyProject = React.useCallback(
3034
(projectId: string) => {
3135
const ok = window.confirm('Are you sure? The project contents will be deleted permanently.')
3236
if (ok) {
33-
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/destroy` })
37+
destroyFetcher.submit(
38+
{},
39+
{ method: 'POST', action: `/internal/projects/${projectId}/destroy` },
40+
)
3441
}
3542
},
36-
[fetcher],
43+
[destroyFetcher],
3744
)
3845

3946
const restoreProject = React.useCallback(
4047
(projectId: string) => {
41-
fetcher.submit({}, { method: 'POST', action: `/internal/projects/${projectId}/restore` })
48+
restoreFetcher.submit(
49+
{},
50+
{ method: 'POST', action: `/internal/projects/${projectId}/restore` },
51+
)
4252
},
43-
[fetcher],
53+
[restoreFetcher],
4454
)
4555

4656
const renameProject = React.useCallback(
4757
(projectId: string, newTitle: string) => {
48-
fetcher.submit(
58+
renameFetcher.submit(
4959
{ title: newTitle },
5060
{ method: 'POST', action: `/internal/projects/${projectId}/rename` },
5161
)
5262
},
53-
[fetcher],
63+
[renameFetcher],
5464
)
5565

5666
const menuEntries = React.useMemo((): ContextMenuEntry[] => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useFetcher, useFetchers } from '@remix-run/react'
2+
import React from 'react'
3+
import { useProjectsStore } from '../store'
4+
import { Operation } from '../types'
5+
6+
const operationFetcherKeyPrefix = 'operation-'
7+
8+
/**
9+
* This is a specialized that returns a fetcher that also updates a given project operation.
10+
*/
11+
export function useFetcherWithOperation(operation: Operation) {
12+
const key = `operation-${operation.projectId}-${operation.type}`
13+
14+
const fetcher = useFetcher({ key: key })
15+
const addOperation = useProjectsStore((store) => store.addOperation)
16+
17+
const submit = React.useCallback(
18+
(data: any, options: { method: 'GET' | 'PUT' | 'POST' | 'DELETE'; action: string }) => {
19+
addOperation(operation, key)
20+
fetcher.submit(data, options)
21+
},
22+
[fetcher, addOperation],
23+
)
24+
25+
return {
26+
...fetcher,
27+
submit: submit,
28+
}
29+
}
30+
31+
export function useCleanupOperations() {
32+
const fetchers = useFetchers()
33+
const removeOperation = useProjectsStore((store) => store.removeOperation)
34+
35+
React.useEffect(() => {
36+
for (const fetcher of fetchers) {
37+
const isOperationFetcher = fetcher.key.startsWith(operationFetcherKeyPrefix)
38+
const isNotSubmitting = fetcher.data != null && fetcher.state !== 'submitting'
39+
if (isOperationFetcher && isNotSubmitting) {
40+
removeOperation(fetcher.key)
41+
}
42+
}
43+
}, [fetchers])
44+
}

utopia-remix/app/routes/projects.tsx

+85-6
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,22 @@ import {
33
Trigger as DropdownMenuTrigger,
44
} from '@radix-ui/react-dropdown-menu'
55
import {
6+
CubeIcon,
67
DashboardIcon,
78
DotsHorizontalIcon,
89
HamburgerMenuIcon,
910
MagnifyingGlassIcon,
1011
TrashIcon,
11-
CubeIcon,
1212
} from '@radix-ui/react-icons'
13-
import React from 'react'
1413
import { LoaderFunctionArgs, json } from '@remix-run/node'
1514
import { useFetcher, useLoaderData } from '@remix-run/react'
15+
import { motion } from 'framer-motion'
1616
import moment from 'moment'
1717
import { UserDetails } from 'prisma-client'
18+
import React from 'react'
1819
import { ProjectContextMenu } from '../components/projectActionContextMenu'
1920
import { SortingContextMenu } from '../components/sortProjectsContextMenu'
21+
import { useCleanupOperations } from '../hooks/useFetcherWithOperation'
2022
import { useIsDarkMode } from '../hooks/useIsDarkMode'
2123
import { listDeletedProjects, listProjects } from '../models/project.server'
2224
import { getCollaborators } from '../models/projectCollaborators.server'
@@ -25,14 +27,14 @@ import { button } from '../styles/button.css'
2527
import { newProjectButton } from '../styles/newProjectButton.css'
2628
import { projectCategoryButton, userName } from '../styles/sidebarComponents.css'
2729
import { sprinkles } from '../styles/sprinkles.css'
28-
import { Collaborator, CollaboratorsByProject, ProjectWithoutContent } from '../types'
30+
import { Collaborator, CollaboratorsByProject, Operation, ProjectWithoutContent } from '../types'
2931
import { requireUser } from '../util/api.server'
3032
import { assertNever } from '../util/assertNever'
33+
import { auth0LoginURL } from '../util/auth0.server'
3134
import { projectEditorLink } from '../util/links'
3235
import { when } from '../util/react-conditionals'
3336
import { UnknownPlayerName, multiplayerInitialsFromName } from '../util/strings'
3437
import { useProjectMatchesQuery, useSortCompareProject } from '../util/use-sort-compare-project'
35-
import { auth0LoginURL } from '../util/auth0.server'
3638

3739
const SortOptions = ['title', 'dateCreated', 'dateModified'] as const
3840
export type SortCriteria = (typeof SortOptions)[number]
@@ -74,6 +76,8 @@ export async function loader(args: LoaderFunctionArgs) {
7476
}
7577

7678
const ProjectsPage = React.memo(() => {
79+
useCleanupOperations()
80+
7781
const data = useLoaderData() as unknown as {
7882
projects: ProjectWithoutContent[]
7983
user: UserDetails
@@ -134,6 +138,7 @@ const ProjectsPage = React.memo(() => {
134138
<TopActionBar />
135139
<ProjectsHeader projects={filteredProjects} />
136140
<Projects projects={filteredProjects} collaborators={data.collaborators} />
141+
<ActiveOperations />
137142
</div>
138143
</div>
139144
)
@@ -205,12 +210,11 @@ const Sidebar = React.memo(({ user }: { user: UserDetails }) => {
205210
flexDirection: 'row',
206211
alignItems: 'center',
207212
border: `1px solid ${isSearchFocused ? '#0075F9' : 'transparent'}`,
208-
borderBottomColor: isSearchFocused ? '#0075F9' : 'gray',
213+
borderBottom: `1px solid ${isSearchFocused ? '#0075F9' : 'gray'}`,
209214
borderRadius: isSearchFocused ? 3 : undefined,
210215
overflow: 'visible',
211216
padding: '0px 14px',
212217
gap: 10,
213-
borderBottom: '1px solid gray',
214218
}}
215219
>
216220
<MagnifyingGlassIcon />
@@ -819,3 +823,78 @@ const ProjectCardActions = React.memo(({ project }: { project: ProjectWithoutCon
819823
)
820824
})
821825
ProjectCardActions.displayName = 'ProjectCardActions'
826+
827+
const ActiveOperations = React.memo(() => {
828+
const operations = useProjectsStore((store) =>
829+
store.operations.sort((a, b) => b.startedAt - a.startedAt),
830+
)
831+
832+
return (
833+
<div
834+
style={{
835+
position: 'fixed',
836+
bottom: 0,
837+
right: 0,
838+
margin: 10,
839+
display: 'flex',
840+
flexDirection: 'column',
841+
gap: 10,
842+
}}
843+
>
844+
{operations.map((operation) => {
845+
return <ActiveOperation operation={operation} key={operation.key} />
846+
})}
847+
</div>
848+
)
849+
})
850+
ActiveOperations.displayName = 'ActiveOperations'
851+
852+
const ActiveOperation = React.memo(({ operation }: { operation: Operation }) => {
853+
function getOperationVerb(op: Operation) {
854+
switch (op.type) {
855+
case 'delete':
856+
return 'Deleting'
857+
case 'destroy':
858+
return 'Destroying'
859+
case 'rename':
860+
return 'Renaming'
861+
case 'restore':
862+
return 'Restoring'
863+
default:
864+
assertNever(op.type)
865+
}
866+
}
867+
868+
return (
869+
<div
870+
style={{
871+
padding: 10,
872+
display: 'flex',
873+
gap: 10,
874+
alignItems: 'center',
875+
animation: 'spin 2s linear infinite',
876+
}}
877+
className={sprinkles({
878+
boxShadow: 'shadow',
879+
borderRadius: 'small',
880+
backgroundColor: 'primary',
881+
color: 'white',
882+
})}
883+
>
884+
<motion.div
885+
style={{
886+
width: 8,
887+
height: 8,
888+
}}
889+
className={sprinkles({ backgroundColor: 'white' })}
890+
initial={{ rotate: 0 }}
891+
animate={{ rotate: 100 }}
892+
transition={{ ease: 'linear', repeatType: 'loop', repeat: Infinity }}
893+
/>
894+
<div>
895+
{getOperationVerb(operation)} project {operation.projectName}
896+
</div>
897+
</div>
898+
)
899+
})
900+
ActiveOperation.displayName = 'ActiveOperation'

0 commit comments

Comments
 (0)