Skip to content

Commit

Permalink
feat(web): add indicator for active gql requests (#1190)
Browse files Browse the repository at this point in the history
  • Loading branch information
airslice authored Oct 24, 2024
1 parent 270a26d commit b922fbb
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 3 deletions.
3 changes: 3 additions & 0 deletions web/src/beta/features/AccountSetting/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -105,6 +107,7 @@ const AccountSetting: FC = () => {
handleUpdateUserPassword={handleUpdateUserPassword}
/>
</InnerPage>
<CursorStatus />
</SettingBase>
);
};
Expand Down
74 changes: 74 additions & 0 deletions web/src/beta/features/CursorStatus/index.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<Wrapper left={mousePosition.x + offsetX} top={mousePosition.y + offsetY}>
<Loader />
</Wrapper>
)
);
};

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`
}));
3 changes: 3 additions & 0 deletions web/src/beta/features/Dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +63,7 @@ const Dashboard: FC<DashboardProps> = ({ workspaceId }) => {
workspaceId={workspaceId}
currentWorkspace={currentWorkspace}
/>
<CursorStatus />
</Wrapper>
);
};
Expand Down
2 changes: 2 additions & 0 deletions web/src/beta/features/Editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -144,6 +145,7 @@ const Editor: FC<Props> = ({ sceneId, projectId, workspaceId, tab }) => {
onCustomPropertySchemaUpdate={handleCustomPropertySchemaUpdate}
/>
)}
<CursorStatus />
</Wrapper>
</DndProvider>
);
Expand Down
3 changes: 3 additions & 0 deletions web/src/beta/features/ProjectSettings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -136,6 +138,7 @@ const ProjectSettings: React.FC<Props> = ({ projectId, tab, subId }) => {
)}
</Content>
</MainSection>
<CursorStatus />
</Wrapper>
);
};
Expand Down
2 changes: 2 additions & 0 deletions web/src/beta/features/WorkspaceSetting/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -33,6 +34,7 @@ const WorkspaceSetting: FC<Props> = ({ tab, workspaceId }) => {
projectsCount={filtedProjects?.length}
/>
)}
<CursorStatus />
</SettingBase>
);
};
Expand Down
31 changes: 29 additions & 2 deletions web/src/services/gql/provider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions web/src/services/gql/provider/links/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
45 changes: 45 additions & 0 deletions web/src/services/gql/provider/links/taskLink.ts
Original file line number Diff line number Diff line change
@@ -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 });
};
});
});
23 changes: 22 additions & 1 deletion web/src/services/state/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { atom, useAtom } from "jotai";
import { atom, useAtom, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";

export * from "./devPlugins";
Expand Down Expand Up @@ -75,3 +75,24 @@ export const useWorkspace = () => useAtom(workspace);

const userId = atomWithStorage<string | undefined>("userId", undefined);
export const useUserId = () => useAtom(userId);

// Record active requests (queries & mutaions)
export type GQLTask = {
id: string;
};

const activeGQLTasksAtom = atom<GQLTask[]>([]);

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);

0 comments on commit b922fbb

Please sign in to comment.