Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 89 additions & 44 deletions ui/src/Components/Project.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from "@fluentui/react-components";
import { useParams } from "react-router-dom";
import { useState, useEffect } from "react";
import { apiGet } from "../util/api";
import { apiGet, apiGetResponse } from "../util/api";
import { useNavigate } from "react-router-dom";

import LayerRow from "./ProjectManagement/LayerRow";
Expand All @@ -37,7 +37,9 @@ import { updateUserSettings } from "../AppHelper";
import {
collectProjectJobStates,
findJobStatusTransitions,
hasActiveProjectJobs,
} from "../util/jobNotifications";
import { createSingleFlight } from "../util/singleFlight";

import PropType from "prop-types";

Expand All @@ -64,6 +66,7 @@ const GROUP_OPTIONS = [
];

const PAGE_SIZE_OPTIONS = [5, 8, 10, 20, 50];
const EMPTY_LAYERS = [];

/** Resolve the group bucket label for an image layer given the grouping. */
function getLayerGroupLabel(item, mode) {
Expand Down Expand Up @@ -133,13 +136,11 @@ const Project = ({ setModalComponent }) => {

const defaultProjectDetailsRef = useRef(null);
const projectJobStatesRef = useRef(null);
const projectLoadRef = useRef(createSingleFlight());
const projectEtagRef = useRef(null);
const projectLifecycleRef = useRef(0);
const projectMountedRef = useRef(false);
const { dispatchToast } = useToastController("job-completion-toaster");


useEffect(() => {
setCurrentPage(1);
}, [appParams.userSettings.itemsPerPageLayers]);

const DEFAULT_COMPONENT_STATE = {
project: null,
visibleModelId: imageLayerId || "-1",
Expand All @@ -148,41 +149,45 @@ const Project = ({ setModalComponent }) => {

const projectCurrentTouruseRef = useRef(DEFAULT_COMPONENT_STATE.visibleModelId === "-1" ? "singleProjectGuide" : "singleProjectModelGuide");

const [moreInfoVisibleId, setMoreInfoVisibleId] = useState(null);
const [, setMoreInfoVisibleId] = useState(null);
const [componentState, setComponentState] = useState(DEFAULT_COMPONENT_STATE);
const navigate = useNavigate();

useEffect(() => {
const fetchData = async () => {
await fetchProjectDetails();
};
fetchData();
fetchProjectDetails(true, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageLayerId]);
}, [projectId, imageLayerId]);

useEffect(() => {
const fetchData = async () => {
await fetchProjectDetails();

initGuidedTourState(projectCurrentTouruseRef.current, appParams.guidedTourProperties);
initCurrentTour(projectCurrentTouruseRef.current);

setAppHeaderRightButtons([
{
iconName: "help",
title: "Help",
id: "helpButton",
onClick: () =>
setGuidedTourState(false, initCurrentTour, projectCurrentTouruseRef.current, appParams.guidedTourProperties),
},
]);
};
fetchData();
const lifecycle = ++projectLifecycleRef.current;
const projectLoad = projectLoadRef.current;
const initialTour = projectCurrentTouruseRef.current;
projectMountedRef.current = true;
initGuidedTourState(initialTour, appParams.guidedTourProperties);
initCurrentTour(initialTour);

setAppHeaderRightButtons([
{
iconName: "help",
title: "Help",
id: "helpButton",
onClick: () =>
setGuidedTourState(false, initCurrentTour, projectCurrentTouruseRef.current, appParams.guidedTourProperties),
},
]);

//On component dismount
return () => {
projectMountedRef.current = false;
queueMicrotask(() => {
// StrictMode immediately advances this generation before the microtask.
// eslint-disable-next-line react-hooks/exhaustive-deps
if (projectLifecycleRef.current === lifecycle) {
projectLoad.abort();
}
});
initCurrentTour(null);
initGuidedTourState(projectCurrentTouruseRef.current, appParams.guidedTourProperties);
initGuidedTourState(initialTour, appParams.guidedTourProperties);
setAppHeaderRightButtons([]);
setModalComponent(null);
};
Expand All @@ -198,12 +203,38 @@ const Project = ({ setModalComponent }) => {
}
}, [componentState.visibleModelId]);

async function fetchProjectDetails(showLoading = true) {
if (showLoading) {
async function fetchProjectDetails(
showLoading = true,
forceRefresh = showLoading
) {
if (projectEtagRef.current?.projectId !== projectId) {
projectEtagRef.current = null;
}

const projectLoad = projectLoadRef.current;
if (forceRefresh && projectLoad.isRunning(projectId)) {
projectLoad.abort();
}
const startsRequest = !projectLoad.isRunning(projectId);
if (showLoading && startsRequest) {
setIsLoading(true);
}
await apiGet("GetProjectDetails?projectId=" + projectId + "&includeModels=True")
.then((response) => {
const headers = {};
if (forceRefresh) {
headers["Cache-Control"] = "no-cache";
}
if (projectEtagRef.current?.etag) {
headers["If-None-Match"] = projectEtagRef.current.etag;
}

const requestPromise = projectLoad.run(projectId, async (signal) => {
try {
const { data: response, etag, status } = await apiGetResponse(
"GetProjectDetails?projectId=" + projectId + "&includeModels=True",
{ signal, headers, cache: forceRefresh ? "no-cache" : "default" }
);
if (etag) projectEtagRef.current = { projectId, etag };
if (status === 304) return defaultProjectDetailsRef.current;
defaultProjectDetailsRef.current = response;
const currentJobStates = collectProjectJobStates(response);
const previousJobState = projectJobStatesRef.current;
Expand Down Expand Up @@ -250,18 +281,33 @@ const Project = ({ setModalComponent }) => {
filter: false,
},
}));
})
.catch((error) => {
return response;
} catch (error) {
if (error.name === "AbortError") return null;
console.error("Error fetching projects:", error);
return null;
}
});
if (showLoading && startsRequest) {
requestPromise.finally(() => {
if (!projectLoad.isRunning() && projectMountedRef.current) {
setIsLoading(false);
}
});
if (showLoading) {
setIsLoading(false);
}
return requestPromise;
}

useEffect(() => {
const intervalId = setInterval(async () => {
fetchProjectDetails(false);
const intervalId = setInterval(() => {
const jobs = projectJobStatesRef.current?.jobs;
if (
document.visibilityState === "visible" &&
!projectLoadRef.current.isRunning() &&
hasActiveProjectJobs(jobs)
) {
fetchProjectDetails(false);
}
}, 20000);

return () => clearInterval(intervalId);
Expand Down Expand Up @@ -307,7 +353,7 @@ const Project = ({ setModalComponent }) => {
}

// Filter + sort + group the image layers (memoised so pagination is cheap).
const imageLayers = componentState.project?.imageLayer || [];
const imageLayers = componentState.project?.imageLayer || EMPTY_LAYERS;
const processed = useMemo(() => {
const search = searchText.toLowerCase();
const filtered = imageLayers.filter(
Expand All @@ -332,7 +378,6 @@ const Project = ({ setModalComponent }) => {
return String(av ?? "").localeCompare(String(bv ?? "")) * dir;
});
return sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imageLayers, searchText, sort, effectiveGroupBy]);

if (!componentState.project) {
Expand Down Expand Up @@ -416,7 +461,7 @@ const Project = ({ setModalComponent }) => {
<CreateEditProjectModal
onClose={() => {
setModalComponent(null);
fetchProjectDetails(false);
fetchProjectDetails(false, true);
}}
projectId={projectId}
/>
Expand Down
23 changes: 10 additions & 13 deletions ui/src/util/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ const APIUrl = import.meta.env.VITE_API_URL;
const APIMSubscriptionKey = import.meta.env.VITE_APIM_SUBSCRIPTION_KEY;
import { upsertUser } from "../AppHelper.js";
import { sanitizeRedirectPath } from "./validation.js";

function resolveVarConcatChar(text) {
if (text === "") return "";
return text.includes("?") ? "&" : "?";
}
import { fetchJsonResponse } from "./http.js";

export function buildUrl(endpoint) {
const base = APIUrl + endpoint;
Expand Down Expand Up @@ -68,14 +64,15 @@ export async function apiLogout(redirectPath = "/") {
}

export async function apiGet(endpoint) {
try {
const response = await fetch(buildUrl(endpoint));
const response = await apiGetResponse(endpoint);
return response.data;
}

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
export async function apiGetResponse(endpoint, options = {}) {
try {
return await fetchJsonResponse(buildUrl(endpoint), options);
} catch (error) {
if (error.name === "AbortError") throw error;
console.error("Error fetching.:", error);
throw new Error("Error fetching.");
}
Expand Down Expand Up @@ -137,7 +134,7 @@ export async function apiPost(endpoint, data, isFormData = false) {
throw new Error(message.error || `HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
} catch {
throw new Error("Error uploading chunk.");
}
}
Expand All @@ -151,7 +148,7 @@ export async function apiDelete(endpoint) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
} catch {
throw new Error("Error deleting element.");
}
}
17 changes: 17 additions & 0 deletions ui/src/util/http.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

export async function fetchJsonResponse(url, options = {}, fetchImpl = fetch) {
const response = await fetchImpl(url, options);
const etag = response.headers?.get?.("etag") ?? null;

if (response.status === 304) {
return { data: null, etag, status: response.status };
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = response.status === 204 ? null : await response.json();
return { data, etag, status: response.status };
}
78 changes: 78 additions & 0 deletions ui/src/util/http.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";

import { fetchJsonResponse } from "./http.js";

function response({ status = 200, data = null, etag = null } = {}) {
return {
status,
ok: status >= 200 && status < 300,
headers: { get: (name) => (name === "etag" ? etag : null) },
json: async () => data,
};
}

test("returns parsed JSON and ETag", async () => {
const fetchImpl = async () =>
response({ data: { projectId: "project-1" }, etag: '"etag"' });

const result = await fetchJsonResponse("/project", {}, fetchImpl);

assert.deepEqual(result, {
data: { projectId: "project-1" },
etag: '"etag"',
status: 200,
});
});

test("returns an empty successful result for 304", async () => {
let jsonCalled = false;
const fetchImpl = async () => ({
...response({ status: 304, etag: '"etag"' }),
json: async () => {
jsonCalled = true;
},
});

const result = await fetchJsonResponse("/project", {}, fetchImpl);

assert.deepEqual(result, { data: null, etag: '"etag"', status: 304 });
assert.equal(jsonCalled, false);
});

test("returns null for a successful empty response", async () => {
const result = await fetchJsonResponse(
"/project",
{},
async () => response({ status: 204 })
);

assert.deepEqual(result, { data: null, etag: null, status: 204 });
});

test("rejects unsuccessful responses", async () => {
await assert.rejects(
fetchJsonResponse(
"/project",
{},
async () => response({ status: 503 })
),
/status: 503/
);
});

test("passes request options to fetch", async () => {
const controller = new AbortController();
const options = {
signal: controller.signal,
headers: { "If-None-Match": '"etag"' },
};
let receivedOptions;

await fetchJsonResponse("/project", options, async (_url, received) => {
receivedOptions = received;
return response({ data: {} });
});

assert.equal(receivedOptions, options);
});
Loading