Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): add indicator for active gql requests #1190

Merged
merged 9 commits into from
Oct 24, 2024
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);
Loading