From b922fbb928f1774e7832eddfee95443bd93d9060 Mon Sep 17 00:00:00 2001 From: lby Date: Thu, 24 Oct 2024 10:04:21 +0800 Subject: [PATCH] feat(web): add indicator for active gql requests (#1190) --- .../beta/features/AccountSetting/index.tsx | 3 + web/src/beta/features/CursorStatus/index.tsx | 74 +++++++++++++++++++ web/src/beta/features/Dashboard/index.tsx | 3 + web/src/beta/features/Editor/index.tsx | 2 + .../beta/features/ProjectSettings/index.tsx | 3 + .../beta/features/WorkspaceSetting/index.tsx | 2 + web/src/services/gql/provider/index.tsx | 31 +++++++- web/src/services/gql/provider/links/index.ts | 1 + .../services/gql/provider/links/taskLink.ts | 45 +++++++++++ web/src/services/state/index.ts | 23 +++++- 10 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 web/src/beta/features/CursorStatus/index.tsx create mode 100644 web/src/services/gql/provider/links/taskLink.ts diff --git a/web/src/beta/features/AccountSetting/index.tsx b/web/src/beta/features/AccountSetting/index.tsx index af31246eba..3cd239cc58 100644 --- a/web/src/beta/features/AccountSetting/index.tsx +++ b/web/src/beta/features/AccountSetting/index.tsx @@ -12,6 +12,8 @@ import { useWorkspace } from "@reearth/services/state"; import { styled } from "@reearth/services/theme"; import { FC, useState } from "react"; +import CursorStatus from "../CursorStatus"; + import useHook from "./hooks"; import PasswordModal from "./PasswordModal"; @@ -105,6 +107,7 @@ const AccountSetting: FC = () => { handleUpdateUserPassword={handleUpdateUserPassword} /> + ); }; diff --git a/web/src/beta/features/CursorStatus/index.tsx b/web/src/beta/features/CursorStatus/index.tsx new file mode 100644 index 0000000000..2d4d934c18 --- /dev/null +++ b/web/src/beta/features/CursorStatus/index.tsx @@ -0,0 +1,74 @@ +import { useHasActiveGQLTasks } from "@reearth/services/state"; +import { keyframes, styled } from "@reearth/services/theme"; +import { FC, useCallback, useEffect, useState } from "react"; + +const offsetX = 16; +const offsetY = 16; + +const CursorStatus: FC = () => { + const [mousePosition, setMousePosition] = useState({ x: -100, y: -100 }); + const [inView, setInView] = useState(true); + const [enabled] = useHasActiveGQLTasks(); + + const handleMouseMove = useCallback((event: MouseEvent) => { + setMousePosition({ + x: event.clientX, + y: event.clientY + }); + }, []); + + const handleMouseEnter = useCallback(() => { + setInView(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setInView(false); + }, []); + + useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseenter", handleMouseEnter); + document.addEventListener("mouseleave", handleMouseLeave); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseenter", handleMouseEnter); + document.removeEventListener("mouseleave", handleMouseLeave); + }; + }, [handleMouseMove, handleMouseEnter, handleMouseLeave]); + + return ( + enabled && + inView && ( + + + + ) + ); +}; + +export default CursorStatus; + +const Wrapper = styled("div")<{ left: number; top: number }>( + ({ left, top, theme }) => ({ + position: "absolute", + left: `${left}px`, + top: `${top}px`, + pointerEvents: "none", + zIndex: theme.zIndexes.editor.loading + }) +); + +const loaderKeyframes = keyframes` + 100%{transform: rotate(1turn)} +`; + +const loaderColor = "#ccc"; + +const Loader = styled("div")(() => ({ + width: 24, + aspectRatio: 1, + borderRadius: "50%", + background: `radial-gradient(farthest-side,${loaderColor} 100%,#0000) top/6px 6px no-repeat, conic-gradient(#0000 30%,${loaderColor})`, + WebkitMask: "radial-gradient(farthest-side,#0000 calc(100% - 6px),#000 0)", + animation: `${loaderKeyframes} 1s infinite linear` +})); diff --git a/web/src/beta/features/Dashboard/index.tsx b/web/src/beta/features/Dashboard/index.tsx index 0956ce48a6..8a7726a6e3 100644 --- a/web/src/beta/features/Dashboard/index.tsx +++ b/web/src/beta/features/Dashboard/index.tsx @@ -2,6 +2,8 @@ import { DEFAULT_SIDEBAR_WIDTH } from "@reearth/beta/ui/components/Sidebar"; import { styled } from "@reearth/services/theme"; import { FC } from "react"; +import CursorStatus from "../CursorStatus"; + import ContentsContainer from "./ContentsContainer"; import useHooks from "./hooks"; import LeftSidePanel from "./LeftSidePanel"; @@ -61,6 +63,7 @@ const Dashboard: FC = ({ workspaceId }) => { workspaceId={workspaceId} currentWorkspace={currentWorkspace} /> + ); }; diff --git a/web/src/beta/features/Editor/index.tsx b/web/src/beta/features/Editor/index.tsx index 97e072f9ac..c73c18a380 100644 --- a/web/src/beta/features/Editor/index.tsx +++ b/web/src/beta/features/Editor/index.tsx @@ -2,6 +2,7 @@ import styled from "@emotion/styled"; import { Provider as DndProvider } from "@reearth/beta/utils/use-dnd"; import { FC } from "react"; +import CursorStatus from "../CursorStatus"; import Navbar, { Tab } from "../Navbar"; import useHooks from "./hooks"; @@ -144,6 +145,7 @@ const Editor: FC = ({ sceneId, projectId, workspaceId, tab }) => { onCustomPropertySchemaUpdate={handleCustomPropertySchemaUpdate} /> )} + ); diff --git a/web/src/beta/features/ProjectSettings/index.tsx b/web/src/beta/features/ProjectSettings/index.tsx index 3d55bcd2ac..81915ce610 100644 --- a/web/src/beta/features/ProjectSettings/index.tsx +++ b/web/src/beta/features/ProjectSettings/index.tsx @@ -10,6 +10,8 @@ import { useT } from "@reearth/services/i18n"; import { styled } from "@reearth/services/theme"; import { useMemo } from "react"; +import CursorStatus from "../CursorStatus"; + import useHooks from "./hooks"; import GeneralSettings from "./innerPages/GeneralSettings"; import PluginSettings from "./innerPages/PluginSettings"; @@ -136,6 +138,7 @@ const ProjectSettings: React.FC = ({ projectId, tab, subId }) => { )} + ); }; diff --git a/web/src/beta/features/WorkspaceSetting/index.tsx b/web/src/beta/features/WorkspaceSetting/index.tsx index 6436587b4e..799f69a2cb 100644 --- a/web/src/beta/features/WorkspaceSetting/index.tsx +++ b/web/src/beta/features/WorkspaceSetting/index.tsx @@ -2,6 +2,7 @@ import useAccountSettingsTabs from "@reearth/beta/hooks/useAccountSettingsTabs"; import SettingBase from "@reearth/beta/ui/components/SettingBase"; import { FC } from "react"; +import CursorStatus from "../CursorStatus"; import useProjectsHook from "../Dashboard/ContentsContainer/Projects/hooks"; import useWorkspaceHook from "./hooks"; @@ -33,6 +34,7 @@ const WorkspaceSetting: FC = ({ tab, workspaceId }) => { projectsCount={filtedProjects?.length} /> )} + ); }; diff --git a/web/src/services/gql/provider/index.tsx b/web/src/services/gql/provider/index.tsx index 7b609d4df2..4e865afda7 100644 --- a/web/src/services/gql/provider/index.tsx +++ b/web/src/services/gql/provider/index.tsx @@ -4,11 +4,16 @@ import { ApolloLink, InMemoryCache } from "@apollo/client"; -import type { ReactNode } from "react"; +import { + GQLTask, + useAddGQLTask, + useRemoveGQLTask +} from "@reearth/services/state"; +import { useCallback, type ReactNode } from "react"; import fragmentMatcher from "../__gen__/fragmentMatcher.json"; -import { authLink, sentryLink, errorLink, uploadLink } from "./links"; +import { authLink, sentryLink, errorLink, uploadLink, taskLink } from "./links"; import { paginationMerge } from "./pagination"; const Provider: React.FC<{ children?: ReactNode }> = ({ children }) => { @@ -57,9 +62,31 @@ const Provider: React.FC<{ children?: ReactNode }> = ({ children }) => { } }); + const addGQLTask = useAddGQLTask(); + const removeGQLTask = useRemoveGQLTask(); + + const addTask = useCallback( + (task: GQLTask) => { + requestAnimationFrame(() => { + addGQLTask(task); + }); + }, + [addGQLTask] + ); + + const removeTask = useCallback( + (task: GQLTask) => { + requestAnimationFrame(() => { + removeGQLTask(task); + }); + }, + [removeGQLTask] + ); + const client = new ApolloClient({ uri: endpoint, link: ApolloLink.from([ + taskLink(addTask, removeTask), errorLink(), sentryLink(endpoint), authLink(), diff --git a/web/src/services/gql/provider/links/index.ts b/web/src/services/gql/provider/links/index.ts index 73114f7761..fe493230ef 100644 --- a/web/src/services/gql/provider/links/index.ts +++ b/web/src/services/gql/provider/links/index.ts @@ -2,3 +2,4 @@ export { default as authLink } from "./authLink"; export { default as errorLink } from "./errorLink"; export { default as sentryLink } from "./sentryLink"; export { default as uploadLink } from "./uploadLink"; +export { default as taskLink } from "./taskLink"; diff --git a/web/src/services/gql/provider/links/taskLink.ts b/web/src/services/gql/provider/links/taskLink.ts new file mode 100644 index 0000000000..77021cd125 --- /dev/null +++ b/web/src/services/gql/provider/links/taskLink.ts @@ -0,0 +1,45 @@ +import { ApolloLink, Operation, NextLink, Observable } from "@apollo/client"; +import { GQLTask } from "@reearth/services/state"; +import { v4 as uuidv4 } from "uuid"; + +export default ( + addTask: (task: GQLTask) => void, + removeTask: (task: GQLTask) => void +): ApolloLink => + new ApolloLink((operation: Operation, forward: NextLink) => { + const taskId = uuidv4(); + addTask({ id: taskId }); + + return new Observable((observer) => { + const timeoutId = setTimeout(() => { + observer.error(new Error("Operation timeout")); + removeTask({ id: taskId }); + }, 10000); + + const sub = forward(operation).subscribe({ + next: (result) => { + if (result.errors) { + clearTimeout(timeoutId); + removeTask({ id: taskId }); + } + observer.next(result); + }, + error: (error) => { + clearTimeout(timeoutId); + observer.error(error); + removeTask({ id: taskId }); + }, + complete: () => { + clearTimeout(timeoutId); + observer.complete(); + removeTask({ id: taskId }); + } + }); + + return () => { + clearTimeout(timeoutId); + sub.unsubscribe(); + removeTask({ id: taskId }); + }; + }); + }); diff --git a/web/src/services/state/index.ts b/web/src/services/state/index.ts index 7cb27feb62..aea140a30a 100644 --- a/web/src/services/state/index.ts +++ b/web/src/services/state/index.ts @@ -1,4 +1,4 @@ -import { atom, useAtom } from "jotai"; +import { atom, useAtom, useSetAtom } from "jotai"; import { atomWithStorage } from "jotai/utils"; export * from "./devPlugins"; @@ -75,3 +75,24 @@ export const useWorkspace = () => useAtom(workspace); const userId = atomWithStorage("userId", undefined); export const useUserId = () => useAtom(userId); + +// Record active requests (queries & mutaions) +export type GQLTask = { + id: string; +}; + +const activeGQLTasksAtom = atom([]); + +const addGQLTaskAtom = atom(null, (_get, set, task: GQLTask) => { + set(activeGQLTasksAtom, (prev) => [...prev, task]); +}); + +const removeGQLTaskAtom = atom(null, (_get, set, task: GQLTask) => { + set(activeGQLTasksAtom, (prev) => prev.filter((t) => t.id !== task.id)); +}); + +const hasActiveGQLTasksAtom = atom((get) => get(activeGQLTasksAtom).length > 0); + +export const useAddGQLTask = () => useSetAtom(addGQLTaskAtom); +export const useRemoveGQLTask = () => useSetAtom(removeGQLTaskAtom); +export const useHasActiveGQLTasks = () => useAtom(hasActiveGQLTasksAtom);