Skip to content

Commit 7ca72a3

Browse files
authored
refactor(web): improve import project [VIZ-1774] (#1669)
1 parent fa0e5c0 commit 7ca72a3

File tree

10 files changed

+233
-61
lines changed

10 files changed

+233
-61
lines changed

web/src/beta/features/Dashboard/ContentsContainer/Projects/hooks.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
SortDirection,
99
Visualizer
1010
} from "@reearth/services/gql";
11+
import { useT } from "@reearth/services/i18n";
12+
import { useNotification } from "@reearth/services/state";
1113
import {
1214
useCallback,
1315
useMemo,
@@ -19,7 +21,7 @@ import {
1921
} from "react";
2022
import { useNavigate } from "react-router-dom";
2123

22-
import { Project } from "../../type";
24+
import { getImportStatus, ImportStatus, Project } from "../../type";
2325

2426
const PROJECTS_VIEW_STATE_STORAGE_KEY_PREFIX = `reearth-visualizer-dashboard-project-view-state`;
2527

@@ -40,7 +42,8 @@ export default (workspaceId?: string) => {
4042
useStarredProjectsQuery,
4143
useUpdateProjectRemove,
4244
usePublishProject,
43-
useImportProject
45+
useImportProject,
46+
useProjectQuery
4447
} = useProjectFetcher();
4548
const navigate = useNavigate();
4649
const client = useApolloClient();
@@ -67,6 +70,9 @@ export default (workspaceId?: string) => {
6770
keyword: searchTerm
6871
});
6972

73+
const t = useT();
74+
const [, setNotification] = useNotification();
75+
7076
const filtedProjects = useMemo(() => {
7177
return (projects ?? [])
7278
.map<Project | undefined>((project) =>
@@ -75,6 +81,7 @@ export default (workspaceId?: string) => {
7581
id: project.id,
7682
description: project.description,
7783
name: project.name,
84+
teamId: project.teamId,
7885
imageUrl: project.imageUrl,
7986
isArchived: project.isArchived,
8087
status: toPublishmentStatus(project.publishmentStatus),
@@ -86,7 +93,8 @@ export default (workspaceId?: string) => {
8693
isDeleted: project.isDeleted,
8794
isPublished:
8895
project.publishmentStatus === "PUBLIC" ||
89-
project.publishmentStatus === "LIMITED"
96+
project.publishmentStatus === "LIMITED",
97+
metadata: project?.metadata
9098
}
9199
: undefined
92100
)
@@ -242,19 +250,59 @@ export default (workspaceId?: string) => {
242250
};
243251
}, [wrapperRef, contentRef]);
244252

253+
//import project
254+
const [importedProjectId, setImportedProjectId] = useState<
255+
string | undefined
256+
>();
257+
const [importStatus, setImportStatus] = useState<ImportStatus>();
258+
259+
const { refetch: refetchProject } = useProjectQuery(importedProjectId);
260+
245261
const handleProjectImport = useCallback(
246262
async (event: ChangeEvent<HTMLInputElement>) => {
247263
const file = event.target.files?.[0];
248264
if (file && workspaceId) {
265+
setImportStatus("processing");
249266
const result = await useImportProject(file, workspaceId);
250-
if (result.status === "chunk_received") {
251-
await refetch();
252-
}
267+
if (!result?.project_id) return;
268+
setImportedProjectId(result.project_id);
253269
}
254270
},
255-
[refetch, useImportProject, workspaceId]
271+
[workspaceId, useImportProject]
256272
);
257273

274+
useEffect(() => {
275+
if (!importedProjectId) return;
276+
277+
let retries = 0;
278+
const MAX_RETRIES = 100;
279+
280+
const interval = setInterval(() => {
281+
if (++retries > MAX_RETRIES) {
282+
clearInterval(interval);
283+
return;
284+
}
285+
refetchProject().then((result) => {
286+
const status =
287+
result.data?.node?.__typename === "Project"
288+
? getImportStatus(result.data.node.metadata?.importStatus)
289+
: undefined;
290+
291+
setImportStatus(status);
292+
if (status === "success") {
293+
setNotification({
294+
type: "success",
295+
text: t("Successfully imported project!")
296+
});
297+
refetch();
298+
}
299+
if (status !== "processing") clearInterval(interval);
300+
});
301+
}, 3000);
302+
303+
return () => clearInterval(interval);
304+
}, [importedProjectId, refetch, refetchProject, setNotification, t]);
305+
258306
// project remove
259307
const handleProjectRemove = useCallback(
260308
async (project: Project) => {
@@ -292,6 +340,7 @@ export default (workspaceId?: string) => {
292340
sortValue,
293341
contentWidth,
294342
starredProjects,
343+
importStatus,
295344
showProjectCreator,
296345
closeProjectCreator,
297346
handleGetMoreProjects,

web/src/beta/features/Dashboard/ContentsContainer/Projects/index.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const Projects: FC<{ workspaceId?: string }> = ({ workspaceId }) => {
2929
layout,
3030
searchTerm,
3131
sortValue,
32+
importStatus,
3233
showProjectCreator,
3334
closeProjectCreator,
3435
handleProjectUpdate,
@@ -174,6 +175,16 @@ const Projects: FC<{ workspaceId?: string }> = ({ workspaceId }) => {
174175
layout={layout}
175176
data-testid={`projects-group-${layout}`}
176177
>
178+
{importStatus === "processing" &&
179+
(layout === "grid" ? (
180+
<ImportingCardContainer>
181+
<ImportCardPlaceholder />
182+
<ImportingCardFotter />
183+
</ImportingCardContainer>
184+
) : (
185+
<ImportingListContainer />
186+
))}
187+
177188
{filtedProjects.map((project) =>
178189
layout === "grid" ? (
179190
<ProjectGridViewItem
@@ -326,3 +337,48 @@ const LoadingWrapper = styled("div")(() => ({
326337
const HiddenFileInput = styled("input")({
327338
display: "none"
328339
});
340+
341+
const ImportingCardContainer = styled("div")(({ theme }) => ({
342+
display: "flex",
343+
flexDirection: "column",
344+
gap: theme.spacing.small,
345+
height: "218px",
346+
"@media (max-width: 567px)": {
347+
height: "171px"
348+
}
349+
}));
350+
351+
352+
353+
const shimmerEffect = {
354+
background:
355+
"linear-gradient(90deg, #292929 0%, #474747 33.04%, #474747 58.56%, #292929 100.09%)",
356+
backgroundSize: "400% 100%",
357+
animation: "shimmer 1.2s infinite ease-in-out",
358+
"@keyframes shimmer": {
359+
"0%": { backgroundPosition: "-400px 0" },
360+
"100%": { backgroundPosition: "400px 0" }
361+
}
362+
};
363+
364+
const ImportingListContainer = styled("div")(({ theme }) => ({
365+
display: "flex",
366+
flexDirection: "column",
367+
gap: theme.spacing.small,
368+
height: "25px",
369+
borderRadius: theme.radius.normal,
370+
...shimmerEffect
371+
}));
372+
373+
const ImportCardPlaceholder = styled("div")(({ theme }) => ({
374+
flex: 1,
375+
borderRadius: theme.radius.normal,
376+
...shimmerEffect
377+
}));
378+
379+
const ImportingCardFotter = styled("div")(({ theme }) => ({
380+
height: "20px",
381+
borderRadius: theme.radius.normal,
382+
flexShrink: 0,
383+
...shimmerEffect
384+
}));

web/src/beta/features/Dashboard/type.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { IconName } from "@reearth/beta/lib/reearth-ui";
22
import { PublishStatus } from "@reearth/services/api/publishTypes";
3-
import { TeamMember } from "@reearth/services/gql";
3+
import { ProjectImportStatus, TeamMember } from "@reearth/services/gql";
44
import { ProjectType } from "@reearth/types";
55
import { ReactNode } from "react";
66

7+
export type ImportStatus = "failed" | "none" | "processing" | "success";
8+
9+
export type ProjectMetadata = {
10+
id: string;
11+
project: string;
12+
workspace: string;
13+
readme?: string | null;
14+
license?: string | null;
15+
importStatus?: ProjectImportStatus | null;
16+
createdAt?: Date | null;
17+
updatedAt?: Date | null;
18+
};
19+
720
export type Project = {
821
id: string;
922
name: string;
23+
teamId: string;
1024
imageUrl?: string | null;
1125
status?: PublishStatus;
1226
isArchived?: boolean;
@@ -18,6 +32,7 @@ export type Project = {
1832
starred?: boolean;
1933
isDeleted?: boolean;
2034
isPublished?: boolean;
35+
metadata?: ProjectMetadata | null;
2136
};
2237

2338
export type DeletedProject = {
@@ -57,3 +72,16 @@ export type Workspace = {
5772
policy?: { id: string; name: string } | null;
5873
personal?: boolean;
5974
};
75+
76+
export const getImportStatus = (s?: ProjectImportStatus | null) => {
77+
switch (s) {
78+
case ProjectImportStatus.Failed:
79+
return "failed";
80+
case ProjectImportStatus.Success:
81+
return "success";
82+
case ProjectImportStatus.Processing:
83+
return "processing";
84+
default:
85+
return "none";
86+
}
87+
};

web/src/services/api/projectApi/index.test.tsx

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,6 @@ describe("useProjectApi - useImportProject", () => {
6262
});
6363

6464
expect(mockAxiosPost).toHaveBeenCalledTimes(1);
65-
expect(mockNotification).toHaveBeenCalledWith({
66-
type: "success",
67-
text: "Successfully imported project!"
68-
});
6965
expect(res).toEqual({ status: "chunk_received" });
7066
});
7167

@@ -90,10 +86,6 @@ describe("useProjectApi - useImportProject", () => {
9086
});
9187

9288
expect(mockAxiosPost).toHaveBeenCalledTimes(3);
93-
expect(mockNotification).toHaveBeenCalledWith({
94-
type: "success",
95-
text: "Successfully imported project!"
96-
});
9789
expect(res).toEqual({ status: "chunk_received" });
9890
});
9991

@@ -144,10 +136,13 @@ describe("useProjectApi - useImportProject", () => {
144136
res = await result.current.useImportProject(file, teamId);
145137
});
146138

147-
expect(mockNotification).toHaveBeenCalledWith({
148-
type: "error",
149-
text: "Failed to import project."
150-
});
139+
expect(mockNotification).toHaveBeenCalledWith(
140+
expect.objectContaining({
141+
type: "error",
142+
text: expect.stringContaining("Failed")
143+
})
144+
);
145+
151146
expect(res).toEqual({ status: "error" });
152147
});
153148

@@ -163,11 +158,6 @@ describe("useProjectApi - useImportProject", () => {
163158
await act(async () => {
164159
res = await result.current.useImportProject(file, teamId);
165160
});
166-
167-
expect(mockNotification).toHaveBeenCalledWith({
168-
type: "success",
169-
text: "Successfully imported project!"
170-
});
171161
expect(res).toEqual({ status: "chunk_received" });
172162
});
173163
});

web/src/services/api/projectApi/index.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -605,8 +605,25 @@ export default () => {
605605

606606
const parallelUpload = async (indices: number[]): Promise<any[]> => {
607607
const results = [];
608-
for (let i = 0; i < indices.length; i += CHUNK_CONCURRENCY) {
609-
const batch = indices.slice(i, i + CHUNK_CONCURRENCY);
608+
609+
// 1. Always upload chunk 0 first
610+
try {
611+
const firstChunkResponse = await uploadChunk(0);
612+
results.push(firstChunkResponse);
613+
} catch (error) {
614+
setNotification({
615+
type: "error",
616+
text: t("Failed to upload chunk 0.")
617+
});
618+
console.error("Failed chunk 0:", error);
619+
return [{ status: "error" }];
620+
}
621+
622+
// 2. Upload remaining chunks in parallel (excluding 0)
623+
const remaining = indices.slice(1);
624+
625+
for (let i = 0; i < remaining.length; i += CHUNK_CONCURRENCY) {
626+
const batch = remaining.slice(i, i + CHUNK_CONCURRENCY);
610627
try {
611628
const responses = await Promise.all(batch.map(uploadChunk));
612629
results.push(...responses);
@@ -619,16 +636,13 @@ export default () => {
619636
return [{ status: "error" }];
620637
}
621638
}
639+
622640
return results;
623641
};
624642

625643
const responses = await parallelUpload(chunkIndices);
626-
lastResponse = responses[responses.length - 1];
627-
628-
setNotification({
629-
type: "success",
630-
text: t("Successfully imported project!")
631-
});
644+
lastResponse = responses.at(-1);
645+
632646
return lastResponse || { status: "chunk_received" };
633647
},
634648
[axios, setNotification, t]

web/src/services/gql/__gen__/gql.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ const documents = {
1818
"\n fragment NLSLayerCommon on NLSLayer {\n id\n index\n layerType\n sceneId\n config\n title\n visible\n infobox {\n sceneId\n layerId\n propertyId\n property {\n id\n ...PropertyFragment\n }\n blocks {\n id\n pluginId\n extensionId\n propertyId\n property {\n id\n ...PropertyFragment\n }\n }\n }\n photoOverlay {\n layerId\n propertyId\n property {\n id\n ...PropertyFragment\n }\n }\n isSketch\n sketch {\n customPropertySchema\n featureCollection {\n type\n features {\n ...FeatureFragment\n }\n }\n }\n ... on NLSLayerGroup {\n children {\n id\n }\n }\n }\n": types.NlsLayerCommonFragmentDoc,
1919
"\n fragment NLSLayerStyle on Style {\n id\n name\n value\n }\n": types.NlsLayerStyleFragmentDoc,
2020
"\n fragment PluginFragment on Plugin {\n id\n version\n author\n name\n description\n translatedName(lang: $lang)\n translatedDescription(lang: $lang)\n extensions {\n extensionId\n description\n name\n translatedDescription(lang: $lang)\n translatedName(lang: $lang)\n icon\n singleOnly\n type\n widgetLayout {\n extendable {\n vertically\n horizontally\n }\n extended\n floating\n defaultLocation {\n zone\n section\n area\n }\n }\n }\n }\n": types.PluginFragmentFragmentDoc,
21-
"\n fragment ProjectFragment on Project {\n id\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n }\n": types.ProjectFragmentFragmentDoc,
21+
"\n fragment ProjectFragment on Project {\n id\n teamId\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n publishedAt\n publicNoIndex\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n metadata {\n id\n ...ProjectMetadataFragment\n }\n visualizer\n visibility\n }\n": types.ProjectFragmentFragmentDoc,
22+
"\n fragment ProjectMetadataFragment on ProjectMetadata {\n project\n workspace\n readme\n license\n importStatus\n createdAt\n updatedAt\n}\n": types.ProjectMetadataFragmentFragmentDoc,
2223
"\n fragment PropertySchemaFieldFragment on PropertySchemaField {\n fieldId\n title\n description\n placeholder\n translatedTitle(lang: $lang)\n translatedDescription(lang: $lang)\n translatedPlaceholder(lang: $lang)\n prefix\n suffix\n type\n defaultValue\n ui\n min\n max\n choices {\n key\n icon\n title\n translatedTitle(lang: $lang)\n }\n isAvailableIf {\n fieldId\n type\n value\n }\n }\n\n fragment PropertySchemaGroupFragment on PropertySchemaGroup {\n schemaGroupId\n title\n collection\n translatedTitle(lang: $lang)\n isList\n representativeFieldId\n isAvailableIf {\n fieldId\n type\n value\n }\n fields {\n ...PropertySchemaFieldFragment\n }\n }\n\n fragment PropertyFieldFragment on PropertyField {\n id\n fieldId\n type\n value\n }\n\n fragment PropertyGroupFragment on PropertyGroup {\n id\n schemaGroupId\n fields {\n ...PropertyFieldFragment\n }\n }\n\n fragment PropertyItemFragment on PropertyItem {\n ... on PropertyGroupList {\n id\n schemaGroupId\n groups {\n ...PropertyGroupFragment\n }\n }\n ... on PropertyGroup {\n ...PropertyGroupFragment\n }\n }\n\n fragment PropertyFragmentWithoutSchema on Property {\n id\n items {\n ...PropertyItemFragment\n }\n }\n\n fragment PropertyFragment on Property {\n id\n ...PropertyFragmentWithoutSchema\n schema {\n id\n groups {\n ...PropertySchemaGroupFragment\n }\n }\n }\n\n fragment MergedPropertyGroupCommonFragment on MergedPropertyGroup {\n schemaGroupId\n fields {\n fieldId\n type\n overridden\n }\n }\n\n fragment MergedPropertyGroupFragment on MergedPropertyGroup {\n ...MergedPropertyGroupCommonFragment\n groups {\n ...MergedPropertyGroupCommonFragment\n }\n }\n\n fragment MergedPropertyFragmentWithoutSchema on MergedProperty {\n originalId\n parentId\n groups {\n ...MergedPropertyGroupFragment\n }\n }\n\n fragment MergedPropertyFragment on MergedProperty {\n ...MergedPropertyFragmentWithoutSchema\n schema {\n id\n }\n }\n": types.PropertySchemaFieldFragmentFragmentDoc,
2324
"\n fragment StoryFragment on Story {\n id\n title\n panelPosition\n bgColor\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n alias\n publicTitle\n publicDescription\n publishmentStatus\n publicImage\n publicNoIndex\n enableGa\n trackingId\n pages {\n ...StoryPageFragment\n }\n }\n": types.StoryFragmentFragmentDoc,
2425
"\n fragment StoryPageFragment on StoryPage {\n id\n title\n swipeable\n propertyId\n property {\n id\n ...PropertyFragment\n }\n layersIds\n blocks {\n id\n pluginId\n extensionId\n property {\n id\n ...PropertyFragment\n }\n }\n }\n": types.StoryPageFragmentFragmentDoc,
@@ -133,7 +134,11 @@ export function gql(source: "\n fragment PluginFragment on Plugin {\n id\n
133134
/**
134135
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
135136
*/
136-
export function gql(source: "\n fragment ProjectFragment on Project {\n id\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n }\n"): (typeof documents)["\n fragment ProjectFragment on Project {\n id\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n }\n"];
137+
export function gql(source: "\n fragment ProjectFragment on Project {\n id\n teamId\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n publishedAt\n publicNoIndex\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n metadata {\n id\n ...ProjectMetadataFragment\n }\n visualizer\n visibility\n }\n"): (typeof documents)["\n fragment ProjectFragment on Project {\n id\n teamId\n name\n description\n imageUrl\n isArchived\n isBasicAuthActive\n basicAuthUsername\n basicAuthPassword\n publicTitle\n publicDescription\n publicImage\n publishedAt\n publicNoIndex\n alias\n enableGa\n trackingId\n publishmentStatus\n updatedAt\n createdAt\n coreSupport\n starred\n isDeleted\n metadata {\n id\n ...ProjectMetadataFragment\n }\n visualizer\n visibility\n }\n"];
138+
/**
139+
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
140+
*/
141+
export function gql(source: "\n fragment ProjectMetadataFragment on ProjectMetadata {\n project\n workspace\n readme\n license\n importStatus\n createdAt\n updatedAt\n}\n"): (typeof documents)["\n fragment ProjectMetadataFragment on ProjectMetadata {\n project\n workspace\n readme\n license\n importStatus\n createdAt\n updatedAt\n}\n"];
137142
/**
138143
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
139144
*/

0 commit comments

Comments
 (0)