diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js
index 1df140caa..d15395d1b 100644
--- a/src/actions/__tests__/dropbox-sync-actions.test.js
+++ b/src/actions/__tests__/dropbox-sync-actions.test.js
@@ -10,8 +10,10 @@ import {
putRequest,
postRequest
} from "openstack-uicore-foundation/lib/utils/actions";
+import { snackbarErrorHandler } from "../base-actions";
import * as DropboxSyncActions from "../dropbox-sync-actions";
import * as methods from "../../utils/methods";
+import * as MediaUploadActions from "../media-upload-actions";
jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({
__esModule: true,
@@ -76,6 +78,9 @@ describe("dropbox sync actions", () => {
const actions = store.getActions();
expect(actions).toEqual([
{ payload: {}, type: "REQUEST_SYNC_CONFIG" },
+ // uicore's internal receive is a DUMMY; the real commit happens through
+ // the summit-guarded commitDispatch below it.
+ { payload: { response: {} }, type: "DUMMY" },
{ payload: { response: {} }, type: "RECEIVE_SYNC_CONFIG" },
{ payload: undefined, type: "STOP_LOADING" }
]);
@@ -106,7 +111,7 @@ describe("dropbox sync actions", () => {
expect(methods.getAccessTokenSafely).not.toHaveBeenCalled();
});
- test("updateSyncConfig dispatches START_LOADING, SYNC_CONFIG_UPDATED, STOP_LOADING", async () => {
+ test("updateSyncConfig dispatches START_LOADING, REQUEST_SYNC_CONFIG, SYNC_CONFIG_UPDATED, STOP_LOADING", async () => {
store.dispatch(
DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true })
);
@@ -114,14 +119,253 @@ describe("dropbox sync actions", () => {
const actions = store.getActions();
expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" });
- expect(actions[1]).toEqual({
+ // REQUEST_SYNC_CONFIG is dispatched by the thunk itself (pre-token, before
+ // putRequest) so dropboxSyncState.loading covers the full save duration
+ // and the Save/toggle/Rebuild disabled guards block overlapping saves.
+ expect(actions[1]).toEqual({ payload: {}, type: "REQUEST_SYNC_CONFIG" });
+ // uicore's internal receive is a DUMMY; the commit is summit-guarded.
+ expect(actions[2]).toEqual({ payload: { response: {} }, type: "DUMMY" });
+ expect(actions[3]).toEqual({
payload: { response: {} },
type: "SYNC_CONFIG_UPDATED"
});
- expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" });
+ expect(actions[4]).toEqual({ payload: undefined, type: "STOP_LOADING" });
expect(putRequest).toHaveBeenCalledTimes(1);
});
+ test("getSyncConfig gates BEFORE the token await: REQUEST_SYNC_CONFIG is dispatched synchronously", () => {
+ // The mount GET must flip syncLoading immediately so the toggle/Save/
+ // Rebuild guards disable mutations for the GET's whole duration — a PUT
+ // started during a slow token refresh could otherwise interleave with
+ // the GET (early flag clear, or a stale GET overwriting the PUT's config).
+ store.dispatch(DropboxSyncActions.getSyncConfig());
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain("REQUEST_SYNC_CONFIG");
+ expect(getRequest).not.toHaveBeenCalled();
+ });
+
+ test("updateSyncConfig gates BEFORE the token await: START_LOADING + REQUEST_SYNC_CONFIG are dispatched synchronously", () => {
+ // No flushPromises: a slow token refresh must not leave a window where
+ // Save/toggle are still enabled (dropboxSyncState.loading false) and a
+ // second click can start an overlapping PUT.
+ store.dispatch(
+ DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true })
+ );
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain("START_LOADING");
+ expect(types).toContain("REQUEST_SYNC_CONFIG");
+ // The PUT itself has not started yet (token still pending).
+ expect(putRequest).not.toHaveBeenCalled();
+ });
+
+ // Request-identity guard: getSyncConfig/updateSyncConfig share a sequence;
+ // only the newest invocation for the still-current summit may commit.
+ describe("sync-config request-identity guard", () => {
+ // Deferred mock that emulates the real uicore contract: on resolution it
+ // dispatches the receive action creator through the dispatch it was
+ // handed, THEN resolves. Without that emulation these tests would pass
+ // vacuously against pre-guard code, which relied on uicore dispatching
+ // the receive creator internally.
+ const deferredRequestImpl =
+ (register) =>
+ (requestActionCreator, receiveActionCreator) =>
+ () =>
+ (dispatch) =>
+ new Promise((resolve, reject) => {
+ if (
+ requestActionCreator &&
+ typeof requestActionCreator === "function"
+ )
+ dispatch(requestActionCreator({}));
+ register({
+ resolve: (payload) => {
+ if (typeof receiveActionCreator === "function")
+ dispatch(receiveActionCreator(payload));
+ resolve(payload);
+ },
+ reject
+ });
+ });
+
+ test("stale GET response for a previous summit is NOT committed after a summit switch", async () => {
+ let currentState = { currentSummitState: { currentSummit: { id: 1 } } };
+ const gStore = mockStore(() => currentState);
+ let inFlight;
+ getRequest.mockImplementation(
+ deferredRequestImpl((handle) => {
+ inFlight = handle;
+ })
+ );
+
+ gStore.dispatch(DropboxSyncActions.getSyncConfig());
+ await flushPromises();
+ expect(getRequest).toHaveBeenCalledTimes(1);
+
+ // Summit switch while the response is in flight.
+ currentState = { currentSummitState: { currentSummit: { id: 2 } } };
+ inFlight.resolve({
+ response: { summit_id: 1, dropbox_sync_enabled: true }
+ });
+ await flushPromises();
+
+ // Summit 1's config must not repopulate summit 2's slice.
+ const types = gStore.getActions().map((a) => a.type);
+ expect(types).not.toContain("RECEIVE_SYNC_CONFIG");
+ });
+
+ test("superseded GET's failure does not clear the newer GET's loading flag (identical-key abort)", async () => {
+ let rejectA;
+ let resolveB;
+ getRequest
+ .mockImplementationOnce(
+ () => () => () =>
+ new Promise((_resolve, reject) => {
+ rejectA = reject;
+ })
+ )
+ .mockImplementationOnce(
+ () => () => () =>
+ new Promise((resolve) => {
+ resolveB = resolve;
+ })
+ );
+
+ store.dispatch(DropboxSyncActions.getSyncConfig()); // A
+ await flushPromises();
+ store.dispatch(DropboxSyncActions.getSyncConfig()); // B supersedes A
+ await flushPromises();
+
+ // uicore aborts A when B fires with the identical token-stripped key;
+ // A's catch must NOT dispatch RECEIVE_SYNC_CONFIG({}) — that would
+ // clear loading (and wipe the config) while B is still in flight.
+ rejectA(new Error("aborted"));
+ await flushPromises();
+ let types = store.getActions().map((a) => a.type);
+ expect(types.filter((t) => t === "RECEIVE_SYNC_CONFIG")).toHaveLength(0);
+
+ resolveB({ response: { summit_id: 1 } });
+ await flushPromises();
+ types = store.getActions().map((a) => a.type);
+ expect(types.filter((t) => t === "RECEIVE_SYNC_CONFIG")).toHaveLength(1);
+ });
+
+ test("PUT superseded by a GET that bails on summit switch: the inherited overlay is still cleared", async () => {
+ // The superseded PUT's terminal dispatches are sequence-suppressed and
+ // the superseding GET never started the overlay — the GET's bail path
+ // is the only live party that can clear it.
+ let currentState = { currentSummitState: { currentSummit: { id: 1 } } };
+ const gStore = mockStore(() => currentState);
+ putRequest.mockImplementation(deferredRequestImpl(() => {}));
+ let resolveTokenB;
+ methods.getAccessTokenSafely
+ .mockReturnValueOnce("TOKEN") // PUT A
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveTokenB = resolve;
+ })
+ ); // GET B
+
+ gStore.dispatch(
+ DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true })
+ );
+ await flushPromises(); // A past its token; PUT in flight; overlay started
+ gStore.dispatch(DropboxSyncActions.getSyncConfig()); // B supersedes A, token pending
+ // Summit switches while B awaits its token.
+ currentState = { currentSummitState: { currentSummit: { id: 2 } } };
+ const stopsBefore = gStore
+ .getActions()
+ .filter((a) => a.type === "STOP_LOADING").length;
+
+ resolveTokenB("TOKEN");
+ await flushPromises(); // B bails (newest, summit switched)
+
+ const stopsAfter = gStore
+ .getActions()
+ .filter((a) => a.type === "STOP_LOADING").length;
+ expect(stopsAfter).toBe(stopsBefore + 1);
+ expect(getRequest).not.toHaveBeenCalled(); // B really bailed pre-HTTP
+ });
+
+ test("superseded PUT that succeeded triggers a same-summit refetch so redux converges", async () => {
+ // A GET issued after the PUT was sent can be answered with the PRE-save
+ // snapshot; with the PUT's own commit suppressed, redux would hold
+ // stale config indefinitely without the convergence refetch.
+ const getHandles = [];
+ const putHandles = [];
+ getRequest.mockImplementation(
+ deferredRequestImpl((h) => getHandles.push(h))
+ );
+ putRequest.mockImplementation(
+ deferredRequestImpl((h) => putHandles.push(h))
+ );
+
+ store.dispatch(
+ DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true })
+ ); // PUT A
+ await flushPromises();
+ store.dispatch(DropboxSyncActions.getSyncConfig()); // GET B supersedes A
+ await flushPromises();
+ expect(getRequest).toHaveBeenCalledTimes(1);
+
+ putHandles[0].resolve({
+ response: { summit_id: 1, dropbox_sync_enabled: true }
+ }); // A succeeds server-side; its commit is suppressed
+ await flushPromises();
+ const types = store.getActions().map((a) => a.type);
+ expect(types).not.toContain("SYNC_CONFIG_UPDATED");
+ // The convergence refetch (GET C) fired.
+ expect(getRequest).toHaveBeenCalledTimes(2);
+
+ // B answers with the PRE-save snapshot — superseded by C, not committed.
+ getHandles[0].resolve({
+ response: { summit_id: 1, dropbox_sync_enabled: false }
+ });
+ await flushPromises();
+ expect(
+ store.getActions().filter((a) => a.type === "RECEIVE_SYNC_CONFIG")
+ ).toHaveLength(0);
+
+ // C answers with the post-save state — the one commit that lands.
+ getHandles[1].resolve({
+ response: { summit_id: 1, dropbox_sync_enabled: true }
+ });
+ await flushPromises();
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === "RECEIVE_SYNC_CONFIG");
+ expect(receives).toHaveLength(1);
+ expect(receives[0].payload.response.dropbox_sync_enabled).toBe(true);
+ });
+
+ test("stale PUT response is NOT committed after a summit switch (but its overlay is still cleared)", async () => {
+ let currentState = { currentSummitState: { currentSummit: { id: 1 } } };
+ const gStore = mockStore(() => currentState);
+ let inFlight;
+ putRequest.mockImplementation(
+ deferredRequestImpl((handle) => {
+ inFlight = handle;
+ })
+ );
+
+ gStore.dispatch(
+ DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true })
+ );
+ await flushPromises();
+ currentState = { currentSummitState: { currentSummit: { id: 2 } } };
+ inFlight.resolve({
+ response: { summit_id: 1, dropbox_sync_enabled: true }
+ });
+ await flushPromises();
+
+ const types = gStore.getActions().map((a) => a.type);
+ expect(types).not.toContain("SYNC_CONFIG_UPDATED");
+ // Still the newest invocation, so the overlay it started is cleared.
+ expect(types).toContain("STOP_LOADING");
+ });
+ });
+
test("rebuildSync dispatches START_LOADING, REBUILD_SYNC_DISPATCHED, STOP_LOADING", async () => {
store.dispatch(DropboxSyncActions.rebuildSync());
await flushPromises();
@@ -157,10 +401,15 @@ describe("dropbox sync actions", () => {
await flushPromises();
const actions = store.getActions();
- expect(actions).toEqual([{ payload: {}, type: "RECEIVE_SYNC_CONFIG" }]);
+ // REQUEST (pre-token) then RECEIVE({}) from the catch: loading is set and
+ // then cleared even when the GET fails — no stranded flag.
+ expect(actions).toEqual([
+ { payload: {}, type: "REQUEST_SYNC_CONFIG" },
+ { payload: {}, type: "RECEIVE_SYNC_CONFIG" }
+ ]);
});
- test("updateSyncConfig dispatches STOP_LOADING on failure", async () => {
+ test("updateSyncConfig on failure: loading is set (REQUEST) and then cleared (SYNC_CONFIG_ERROR), no deadlock", async () => {
putRequest.mockImplementation(() => mockRequestImplReject());
store.dispatch(
@@ -169,8 +418,14 @@ describe("dropbox sync actions", () => {
await flushPromises();
const actions = store.getActions();
+ // REQUEST_SYNC_CONFIG comes from the thunk's own pre-token dispatch, so
+ // it fires even though the reject mock bypasses uicore's request action —
+ // this pins that a failed save cannot strand dropboxSyncState.loading.
expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" });
- expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" });
+ expect(actions[1]).toEqual({ payload: {}, type: "REQUEST_SYNC_CONFIG" });
+ expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" });
+ // Clears the loading flag without resetting the stored syncConfig.
+ expect(actions[3]).toEqual({ payload: {}, type: "SYNC_CONFIG_ERROR" });
});
test("rebuildSync dispatches STOP_LOADING on failure", async () => {
@@ -195,3 +450,477 @@ describe("dropbox sync actions", () => {
expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" });
});
});
+
+// ─── getAllMediaUploadTypesForAllowlist ───────────────────────────────────────
+describe("getAllMediaUploadTypesForAllowlist", () => {
+ const middlewares = [thunk];
+ const mockStore = configureStore(middlewares);
+ let store;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ store = mockStore({
+ currentSummitState: { currentSummit: { id: 1 } }
+ });
+ jest.spyOn(methods, "getAccessTokenSafely").mockReturnValue("TOKEN");
+ window.API_BASE_URL = "https://summit-api.example.com";
+ window.DROPBOX_MATERIALIZER_API_BASE_URL = "https://test-api.example.com";
+ // Default mock — individual tests override as needed
+ getRequest.mockImplementation(
+ () => () => () =>
+ Promise.resolve({ response: { data: [], last_page: 1 } })
+ );
+ putRequest.mockImplementation(
+ (requestActionCreator, receiveActionCreator) => () => (dispatch) => {
+ if (requestActionCreator && typeof requestActionCreator === "function")
+ dispatch(requestActionCreator({}));
+ return new Promise((resolve) => {
+ if (typeof receiveActionCreator === "function") {
+ dispatch(receiveActionCreator({ response: {} }));
+ resolve({ response: {} });
+ return;
+ }
+ resolve({ response: {} });
+ });
+ }
+ );
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ delete window.API_BASE_URL;
+ delete window.DROPBOX_MATERIALIZER_API_BASE_URL;
+ });
+
+ it("single page: one HTTP call (page=1, per_page=MAX_PER_PAGE, fields=id,name,private_storage_type), one RECEIVE dispatch with the raw array (SDS:918)", async () => {
+ const capturedCalls = [];
+ getRequest.mockImplementation((req, rec, url) => (params) => {
+ capturedCalls.push({ url, params });
+ return () =>
+ Promise.resolve({
+ response: { data: [{ id: 1 }, { id: 2 }], last_page: 1 }
+ });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ expect(capturedCalls).toHaveLength(1);
+ expect(capturedCalls[0].url).toBe(
+ "https://summit-api.example.com/api/v1/summits/1/media-upload-types"
+ );
+ expect(capturedCalls[0].params).toMatchObject({
+ page: 1,
+ per_page: 100,
+ fields: "id,name,private_storage_type"
+ });
+
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS");
+ expect(receives).toHaveLength(1);
+ expect(receives[0].payload).toEqual([{ id: 1 }, { id: 2 }]);
+ });
+
+ it("multi page (last_page=3, page 2 delayed to resolve AFTER page 3): exactly 3 calls, ONE RECEIVE dispatch, entries concatenated in PAGE order (SDS:919)", async () => {
+ let page2Resolve;
+ const page2Promise = new Promise((resolve) => {
+ page2Resolve = resolve;
+ });
+
+ getRequest.mockImplementation(() => (params) => () => {
+ if (params.page === 1)
+ return Promise.resolve({
+ response: { data: [{ id: 1 }], last_page: 3 }
+ });
+ if (params.page === 2) return page2Promise;
+ return Promise.resolve({
+ response: { data: [{ id: 3 }], last_page: 3 }
+ });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises(); // page 1 and page 3 resolve; page 2 still pending
+
+ expect(
+ store.getActions().filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS")
+ ).toHaveLength(0);
+
+ page2Resolve({ response: { data: [{ id: 2 }], last_page: 3 } });
+ await flushPromises();
+
+ expect(getRequest).toHaveBeenCalledTimes(3);
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS");
+ expect(receives).toHaveLength(1);
+ expect(receives[0].payload).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
+ });
+
+ it("brackets the run with global startLoading/stopLoading (happy path)", async () => {
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types[0]).toBe("START_LOADING");
+ expect(types[types.length - 1]).toBe("STOP_LOADING");
+ });
+
+ it("missing/zero summit id: returns Promise.resolve() WITHOUT dispatching anything — currentSummit defaults to a truthy {id: 0} entity (current-summit-reducer DEFAULT_ENTITY), so a bare truthy check would fetch summit 0", async () => {
+ store = mockStore({
+ currentSummitState: { currentSummit: { id: 0 } }
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ expect(store.getActions()).toEqual([]);
+ expect(getRequest).not.toHaveBeenCalled();
+ expect(methods.getAccessTokenSafely).not.toHaveBeenCalled();
+ });
+
+ it("summit switched mid-flight (success path, single-page): page resolves AFTER the summit id changed → RECEIVE not dispatched", async () => {
+ let summitId = 1;
+ const dispatched = [];
+ const mockGetState = () => ({
+ currentSummitState: { currentSummit: { id: summitId } }
+ });
+ const mockDispatch = jest.fn((action) =>
+ typeof action === "function"
+ ? action(mockDispatch, mockGetState)
+ : dispatched.push(action)
+ );
+
+ let pageResolve;
+ getRequest.mockImplementation(
+ () => () => () =>
+ new Promise((resolve) => {
+ pageResolve = resolve;
+ })
+ );
+
+ const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist();
+ thunkFn(mockDispatch, mockGetState);
+ await flushPromises(); // token resolves, page 1 called but pending
+
+ summitId = 2; // switch summit
+
+ pageResolve({ response: { data: [{ id: 1 }], last_page: 1 } });
+ await flushPromises();
+
+ const types = dispatched.map((a) => a.type);
+ expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS");
+ expect(types).toContain("STOP_LOADING"); // isNewest=true → stopLoading fires
+ });
+
+ it("summit switched mid-flight (success path, multi-page): fan-out resolves after the switch → RECEIVE not dispatched with the stale accumulation", async () => {
+ let summitId = 1;
+ const dispatched = [];
+ const mockGetState = () => ({
+ currentSummitState: { currentSummit: { id: summitId } }
+ });
+ const mockDispatch = jest.fn((action) =>
+ typeof action === "function"
+ ? action(mockDispatch, mockGetState)
+ : dispatched.push(action)
+ );
+
+ const fanOutResolvers = [];
+ getRequest.mockImplementation(() => (params) => () => {
+ if (params.page === 1)
+ return Promise.resolve({
+ response: { data: [{ id: 1 }], last_page: 3 }
+ });
+ return new Promise((resolve) => {
+ fanOutResolvers.push(resolve);
+ });
+ });
+
+ const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist();
+ thunkFn(mockDispatch, mockGetState);
+ await flushPromises(); // page 1 resolves, fan-out starts (pages 2+3 pending)
+
+ expect(fanOutResolvers).toHaveLength(2);
+ summitId = 2; // switch summit before fan-out resolves
+
+ fanOutResolvers.forEach((r) =>
+ r({ response: { data: [{ id: 99 }], last_page: 3 } })
+ );
+ await flushPromises();
+
+ const types = dispatched.map((a) => a.type);
+ expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS");
+ expect(types).toContain("STOP_LOADING");
+ });
+
+ it("summit switched mid-flight (REJECTION path): a page REJECTS after the switch → ERROR not dispatched either (the reset slice must not gain a stale error) — the fixture must actually reject or the assertion is vacuous", async () => {
+ let summitId = 1;
+ const dispatched = [];
+ const mockGetState = () => ({
+ currentSummitState: { currentSummit: { id: summitId } }
+ });
+ const mockDispatch = jest.fn((action) =>
+ typeof action === "function"
+ ? action(mockDispatch, mockGetState)
+ : dispatched.push(action)
+ );
+
+ let pageReject;
+ getRequest.mockImplementation(
+ () => () => () =>
+ new Promise((_, reject) => {
+ pageReject = reject;
+ })
+ );
+
+ const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist();
+ thunkFn(mockDispatch, mockGetState);
+ await flushPromises(); // token resolves, page 1 called but pending
+
+ summitId = 2; // switch summit
+
+ pageReject(new Error("network error")); // page rejects AFTER switch
+ await flushPromises();
+
+ const types = dispatched.map((a) => a.type);
+ expect(types).not.toContain("ALLOWLIST_OPTIONS_ERROR");
+ expect(types).toContain("STOP_LOADING"); // isNewest=true
+ });
+
+ it("A→B→A superseded invocation: hold invocation 1's pages, start invocation 2 for the SAME summit, resolve invocation 1 → its RECEIVE and stopLoading are suppressed by the sequence token even though the summit id matches; only invocation 2's dispatches land", async () => {
+ let callCount = 0;
+ let inv1Resolve;
+
+ getRequest.mockImplementation(() => () => () => {
+ callCount++;
+ if (callCount === 1) {
+ return new Promise((resolve) => {
+ inv1Resolve = resolve;
+ });
+ }
+ return Promise.resolve({
+ response: { data: [{ id: "inv2" }], last_page: 1 }
+ });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises(); // inv 1 awaiting page 1
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises(); // inv 2 completes
+
+ const actionsAfterInv2 = store.getActions().length;
+
+ inv1Resolve({ response: { data: [{ id: "inv1" }], last_page: 1 } });
+ await flushPromises();
+
+ // Inv 1 dispatched nothing new — both RECEIVE and stopLoading suppressed
+ expect(store.getActions()).toHaveLength(actionsAfterInv2);
+
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS");
+ expect(receives).toHaveLength(1);
+ expect(receives[0].payload).toEqual([{ id: "inv2" }]);
+ });
+
+ it("summit switched with NO newer invocation: the stale invocation's RECEIVE/ERROR are suppressed BUT its stopLoading still fires (seq-only loading tier) — the overlay is not stranded", async () => {
+ let summitId = 1;
+ const dispatched = [];
+ const mockGetState = () => ({
+ currentSummitState: { currentSummit: { id: summitId } }
+ });
+ const mockDispatch = jest.fn((action) =>
+ typeof action === "function"
+ ? action(mockDispatch, mockGetState)
+ : dispatched.push(action)
+ );
+
+ let pageResolve;
+ getRequest.mockImplementation(
+ () => () => () =>
+ new Promise((resolve) => {
+ pageResolve = resolve;
+ })
+ );
+
+ const thunkFn = DropboxSyncActions.getAllMediaUploadTypesForAllowlist();
+ thunkFn(mockDispatch, mockGetState);
+ await flushPromises(); // token resolves, page 1 pending
+
+ summitId = 2; // switch summit — NO new invocation
+
+ pageResolve({ response: { data: [{ id: 1 }], last_page: 1 } });
+ await flushPromises();
+
+ const types = dispatched.map((a) => a.type);
+ expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS");
+ expect(types).toContain("STOP_LOADING"); // isNewest=true → still fires
+ });
+
+ it("superseded invocation's QUEUED fan-out pages never fire an HTTP call (count getRequest invocations) — a stale identical-key request would abort the fresh invocation's in-flight page", async () => {
+ // last_page=12: range(2,12,1) = 11 fan-out items
+ // pLimit(TEN): 10 start immediately (pages 2..11), page 12 queued
+ const LAST_PAGE = 12;
+ const holdResolves = [];
+ let callCount = 0;
+
+ getRequest.mockImplementation(() => () => () => {
+ callCount++;
+ const currentCall = callCount;
+ if (currentCall === 1) {
+ return Promise.resolve({
+ response: { data: [], last_page: LAST_PAGE }
+ });
+ }
+ if (currentCall >= 2 && currentCall <= 11) {
+ return new Promise((resolve) => {
+ holdResolves.push(resolve);
+ });
+ }
+ // currentCall === 12: inv 2's page 1 — never resolved in this test
+ if (currentCall === 12) return new Promise(() => {});
+ // Should NOT be reached — inv 1's queued page 12 must be suppressed
+ return Promise.resolve({ response: { data: [], last_page: 1 } });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+ // Page 1 resolved, 10 pooled pages started (calls 2..11), page 12 queued
+ expect(callCount).toBe(11);
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+ // Inv 2's page 1 called (call 12)
+ expect(callCount).toBe(12);
+
+ // Release one pooled page → pLimit dequeues page 12 callback
+ // But stillCurrent()=false → returns empty placeholder, no getRequest call
+ holdResolves[0]({ response: { data: [], last_page: LAST_PAGE } });
+ await flushPromises();
+
+ expect(callCount).toBe(12); // page 12 of inv 1 did NOT fire
+ });
+
+ it("a superseded invocation dispatches NO stopLoading after being superseded (newest-clears invariant; getRequest receives the seq-guarded dispatch, so authErrorHandler's unconditional stopLoading is also suppressed)", async () => {
+ let callCount = 0;
+ let inv1Resolve;
+
+ getRequest.mockImplementation(() => () => () => {
+ callCount++;
+ if (callCount === 1) {
+ return new Promise((resolve) => {
+ inv1Resolve = resolve;
+ });
+ }
+ return Promise.resolve({ response: { data: [], last_page: 1 } });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises(); // inv 1 awaiting page 1
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises(); // inv 2 completes
+
+ const actionsAfterInv2 = store.getActions().length;
+
+ inv1Resolve({ response: { data: [], last_page: 1 } });
+ await flushPromises();
+
+ // Inv 1 dispatched no STOP_LOADING (isNewest=false)
+ const newActions = store.getActions().slice(actionsAfterInv2);
+ expect(newActions.map((a) => a.type)).not.toContain("STOP_LOADING");
+ });
+
+ it("handler identity pin: every getRequest call is constructed with snackbarErrorHandler, and a rejected page still ends with the ERROR action (does NOT exercise snackbar delivery — the mock rejects before the handler runs)", async () => {
+ getRequest.mockImplementation(
+ () => () => () => Promise.reject(new Error("Unauthorized"))
+ );
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ expect(getRequest.mock.calls.length).toBeGreaterThan(0);
+ getRequest.mock.calls.forEach((call) => {
+ expect(call[3]).toBe(snackbarErrorHandler);
+ });
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain("ALLOWLIST_OPTIONS_ERROR");
+ });
+
+ it("page-1 failure: REQUEST then ERROR, RECEIVE never dispatched", async () => {
+ getRequest.mockImplementation(
+ () => () => () => Promise.reject(new Error("network error"))
+ );
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).toContain("REQUEST_ALLOWLIST_OPTIONS");
+ expect(types).toContain("ALLOWLIST_OPTIONS_ERROR");
+ expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS");
+ });
+
+ it("mid-fan-out failure (page 2 rejects, pages 1+3 succeed): ERROR dispatched, RECEIVE never dispatched with any partial list", async () => {
+ getRequest.mockImplementation(() => (params) => () => {
+ if (params.page === 1)
+ return Promise.resolve({
+ response: { data: [{ id: 1 }], last_page: 3 }
+ });
+ if (params.page === 2) return Promise.reject(new Error("page 2 error"));
+ return Promise.resolve({
+ response: { data: [{ id: 3 }], last_page: 3 }
+ });
+ });
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ const types = store.getActions().map((a) => a.type);
+ expect(types).not.toContain("RECEIVE_ALLOWLIST_OPTIONS");
+ expect(types).toContain("ALLOWLIST_OPTIONS_ERROR");
+ });
+
+ it("a rejected page never produces a RECEIVE whose payload is undefined or non-array", async () => {
+ getRequest.mockImplementation(
+ () => () => () => Promise.reject(new Error("error"))
+ );
+
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ const receives = store
+ .getActions()
+ .filter((a) => a.type === "RECEIVE_ALLOWLIST_OPTIONS");
+ expect(receives).toHaveLength(0);
+ // Belt-and-suspenders: any accidentally-slipped-through RECEIVE must carry an array
+ receives.forEach((action) => {
+ expect(Array.isArray(action.payload)).toBe(true);
+ });
+ });
+
+ it("dispatches NO media-upload-list action types (assert the dispatched type set is disjoint from media-upload-actions' constants)", async () => {
+ store.dispatch(DropboxSyncActions.getAllMediaUploadTypesForAllowlist());
+ await flushPromises();
+
+ const dispatchedTypes = new Set(store.getActions().map((a) => a.type));
+ const mediaUploadTypes = new Set(
+ Object.values(MediaUploadActions).filter((v) => typeof v === "string")
+ );
+
+ dispatchedTypes.forEach((type) => {
+ expect(mediaUploadTypes.has(type)).toBe(false);
+ });
+ });
+
+ it("PUT-body pin (regression, passes before AND after): updateSyncConfig({materialized_media_upload_types: [...]}) forwards the key verbatim in the PUT body", async () => {
+ const data = { materialized_media_upload_types: ["type-a", "type-b"] };
+ store.dispatch(DropboxSyncActions.updateSyncConfig(data));
+ await flushPromises();
+
+ expect(putRequest).toHaveBeenCalledTimes(1);
+ expect(putRequest.mock.calls[0][3]).toEqual(data);
+ });
+});
diff --git a/src/actions/dropbox-sync-actions.js b/src/actions/dropbox-sync-actions.js
index a16339563..bba4db31a 100644
--- a/src/actions/dropbox-sync-actions.js
+++ b/src/actions/dropbox-sync-actions.js
@@ -12,6 +12,7 @@
* */
import T from "i18n-react/dist/i18n-react";
+import pLimit from "p-limit";
import {
getRequest,
putRequest,
@@ -22,16 +23,154 @@ import {
showSuccessMessage,
authErrorHandler
} from "openstack-uicore-foundation/lib/utils/actions";
-import { getAccessTokenSafely } from "../utils/methods";
+import { snackbarErrorHandler } from "./base-actions";
+import { getAccessTokenSafely, range } from "../utils/methods";
+import { MAX_PER_PAGE, TEN, TWO } from "../utils/constants";
export const REQUEST_SYNC_CONFIG = "REQUEST_SYNC_CONFIG";
export const RECEIVE_SYNC_CONFIG = "RECEIVE_SYNC_CONFIG";
export const SYNC_CONFIG_UPDATED = "SYNC_CONFIG_UPDATED";
+export const SYNC_CONFIG_ERROR = "SYNC_CONFIG_ERROR";
export const REBUILD_SYNC_DISPATCHED = "REBUILD_SYNC_DISPATCHED";
export const RESYNC_ROOM_DISPATCHED = "RESYNC_ROOM_DISPATCHED";
+export const REQUEST_ALLOWLIST_OPTIONS = "REQUEST_ALLOWLIST_OPTIONS";
+export const RECEIVE_ALLOWLIST_OPTIONS = "RECEIVE_ALLOWLIST_OPTIONS";
+export const ALLOWLIST_OPTIONS_ERROR = "ALLOWLIST_OPTIONS_ERROR";
+
const getBaseUrl = () => window.DROPBOX_MATERIALIZER_API_BASE_URL;
+// TWO-TIER sequence guard:
+// isNewest() — seq only. Gates loading side effects (our stopLoading and
+// the dispatch handed to getRequest so snackbarErrorHandler's
+// (→ authErrorHandler) unconditional stopLoading is also
+// suppressed). Seq-only
+// preserves the newest-clears invariant: when the summit
+// switches but NO newer fetch starts, isNewest=true lets the
+// stale invocation's stopLoading clear the overlay.
+// stillCurrent() — seq + summit id. Gates state commits (REQUEST/RECEIVE/ERROR):
+// a stale landing must not repopulate the slice that
+// SET_CURRENT_SUMMIT just reset.
+// Fan-out pages re-check stillCurrent() inside the pLimit callback: a superseded
+// invocation's queued identical-key requests would abort the fresh invocation's
+// in-flight pages (getRequest cancels by URL+params, stripping only access_token).
+let allowlistOptionsSeq = 0;
+
+export const getAllMediaUploadTypesForAllowlist =
+ () => async (dispatch, getState) => {
+ const summitId = getState().currentSummitState?.currentSummit?.id;
+ // currentSummit defaults to a truthy {id: 0} entity — guard the id, not
+ // the object (sibling precedent: getSyncConfig). Promise.resolve() so
+ // every branch returns a thenable.
+ if (!summitId) return Promise.resolve();
+
+ allowlistOptionsSeq += 1;
+ const mySeq = allowlistOptionsSeq;
+ const isNewest = () => mySeq === allowlistOptionsSeq;
+ const stillCurrent = () =>
+ isNewest() &&
+ getState().currentSummitState?.currentSummit?.id === summitId;
+ // Loading-tier dispatch: passed to getRequest so its internal error handler
+ // (snackbarErrorHandler → authErrorHandler → stopLoading) fires only while
+ // this invocation is newest.
+ const loadingDispatch = (action) => {
+ if (isNewest()) dispatch(action);
+ };
+ // Commit-tier dispatch: state-bearing actions only.
+ const commitDispatch = (action) => {
+ if (stillCurrent()) dispatch(action);
+ };
+
+ dispatch(startLoading()); // newest by construction at entry
+ commitDispatch(createAction(REQUEST_ALLOWLIST_OPTIONS)({}));
+
+ const accessToken = await getAccessTokenSafely();
+ // Superseded (or summit switched) while awaiting the token → fire nothing:
+ // an identical-key request from this stale invocation would abort the
+ // fresh invocation's page 1.
+ if (!stillCurrent()) {
+ if (isNewest()) dispatch(stopLoading());
+ return Promise.resolve();
+ }
+
+ const endpoint = `${window.API_BASE_URL}/api/v1/summits/${summitId}/media-upload-types`;
+ const baseParams = {
+ access_token: accessToken,
+ per_page: MAX_PER_PAGE,
+ fields: "id,name,private_storage_type"
+ };
+ const limit = pLimit(TEN);
+ const EMPTY_PAGE = { response: { data: [] } };
+
+ return getRequest(
+ createAction("DUMMY"),
+ createAction("DUMMY"),
+ endpoint,
+ snackbarErrorHandler
+ )({ ...baseParams, page: 1 })(loadingDispatch)
+ .then(({ response }) => {
+ const { last_page: lastPage, data: firstPageData } = response;
+ if (lastPage <= 1) {
+ commitDispatch(
+ createAction(RECEIVE_ALLOWLIST_OPTIONS)(firstPageData)
+ );
+ return firstPageData;
+ }
+ // local range() is stop-INCLUSIVE: range(TWO, lastPage, 1) === [2..lastPage]
+ const pageParams = range(TWO, lastPage, 1).map((page) => ({
+ ...baseParams,
+ page
+ }));
+ return Promise.all(
+ pageParams.map((p) =>
+ limit(() =>
+ // Re-check INSIDE the pool callback: a superseded invocation's
+ // still-queued jobs must not fire (identical-key abort hazard).
+ stillCurrent()
+ ? getRequest(
+ createAction("DUMMY"),
+ createAction("DUMMY"),
+ endpoint,
+ snackbarErrorHandler
+ )(p)(loadingDispatch)
+ : Promise.resolve(EMPTY_PAGE)
+ )
+ )
+ ).then((responses) => {
+ // Promise.all preserves input order → page-order accumulation.
+ const accumulated = [...firstPageData];
+ responses.forEach(({ response: pageResponse }) => {
+ accumulated.push(...pageResponse.data);
+ });
+ commitDispatch(createAction(RECEIVE_ALLOWLIST_OPTIONS)(accumulated));
+ return accumulated;
+ });
+ })
+ .catch((err) => {
+ commitDispatch(
+ createAction(ALLOWLIST_OPTIONS_ERROR)(
+ err && err.message ? err.message : "fetch failed"
+ )
+ );
+ })
+ .finally(() => {
+ loadingDispatch(stopLoading());
+ });
+ };
+
+// Request-identity guard for the sync-config slice, shared by getSyncConfig
+// and updateSyncConfig (both write syncConfig + loading, so they form one
+// ownership domain — newest invocation wins). Same two-tier scheme as the
+// allowlist aggregator above: isNewest() gates loading side effects (incl.
+// the dispatch handed to uicore, whose authErrorHandler dispatches an
+// unconditional stopLoading), stillCurrent() additionally requires the summit
+// unchanged and gates state commits. Without it: a response captured for
+// summit A lands after a switch to B and repopulates B's view with A's
+// config; and uicore's identical-key abort (a second GET from another
+// Locations page mount cancels the first) makes the stale GET's catch clear
+// the loading flag while the fresh GET is still in flight.
+let syncConfigSeq = 0;
+
export const getSyncConfig = () => async (dispatch, getState) => {
const { currentSummitState } = getState();
const baseUrl = getBaseUrl();
@@ -39,23 +178,59 @@ export const getSyncConfig = () => async (dispatch, getState) => {
if (!baseUrl || !summitId) return;
+ syncConfigSeq += 1;
+ const mySeq = syncConfigSeq;
+ const isNewest = () => mySeq === syncConfigSeq;
+ const stillCurrent = () =>
+ isNewest() && getState().currentSummitState?.currentSummit?.id === summitId;
+ const loadingDispatch = (action) => {
+ if (isNewest()) dispatch(action);
+ };
+ const commitDispatch = (action) => {
+ if (stillCurrent()) dispatch(action);
+ };
+
+ // Pre-token, same as updateSyncConfig: syncLoading goes true the instant
+ // the GET starts, so the toggle/Save/Rebuild guards disable mutations for
+ // the GET's whole duration (including a slow token refresh). Otherwise a
+ // PUT started during that window could interleave with the GET — whichever
+ // response landed first would clear the shared flag early, and a stale GET
+ // response could overwrite a newer PUT's config in redux.
+ dispatch(createAction(REQUEST_SYNC_CONFIG)({})); // newest by construction
+
const accessToken = await getAccessTokenSafely();
+ // Superseded (or summit switched) while awaiting the token: fire nothing.
+ // The newer invocation — or the SET_CURRENT_SUMMIT slice reset — owns the
+ // loading flag now. If we are still the NEWEST owner (summit switched, no
+ // newer sibling), we may have inherited the global overlay from a PUT this
+ // GET superseded — that PUT's own terminal dispatches are now
+ // sequence-suppressed, so clearing here is the only live path (mirrors the
+ // PUT bail below).
+ if (!stillCurrent()) {
+ if (isNewest()) dispatch(stopLoading());
+ return;
+ }
const params = {
access_token: accessToken
};
return getRequest(
- createAction(REQUEST_SYNC_CONFIG),
- createAction(RECEIVE_SYNC_CONFIG),
+ // null request action: REQUEST_SYNC_CONFIG already fired above (pre-token).
+ null,
+ createAction("DUMMY"),
`${baseUrl}/api/v1/sync/config/${summitId}/`,
- authErrorHandler
- )(params)(dispatch)
- .then(() => {
- dispatch(stopLoading());
+ snackbarErrorHandler
+ )(params)(loadingDispatch)
+ .then(({ response }) => {
+ commitDispatch(createAction(RECEIVE_SYNC_CONFIG)({ response }));
+ loadingDispatch(stopLoading());
})
.catch(() => {
- dispatch(createAction(RECEIVE_SYNC_CONFIG)({}));
+ // Genuine failure of the current request: clear the flag (and reset the
+ // config) as before. A superseded request's failure — including the
+ // identical-key abort — is suppressed: the newer request owns the flag.
+ commitDispatch(createAction(RECEIVE_SYNC_CONFIG)({}));
});
};
@@ -66,27 +241,81 @@ export const updateSyncConfig = (data) => async (dispatch, getState) => {
if (!baseUrl || !summitId) return;
- const accessToken = await getAccessTokenSafely();
+ syncConfigSeq += 1;
+ const mySeq = syncConfigSeq;
+ const isNewest = () => mySeq === syncConfigSeq;
+ const stillCurrent = () =>
+ isNewest() && getState().currentSummitState?.currentSummit?.id === summitId;
+ const loadingDispatch = (action) => {
+ if (isNewest()) dispatch(action);
+ };
+ const commitDispatch = (action) => {
+ if (stillCurrent()) dispatch(action);
+ };
+ // Dispatched BEFORE the token await: REQUEST_SYNC_CONFIG flips
+ // dropboxSyncState.loading for the full thunk duration, so the
+ // Save/toggle/Rebuild disabled={syncLoading} guards are mutually exclusive
+ // with an in-flight save (not just with the config GET) — including during
+ // a slow token refresh, when neither loading flag would otherwise be set
+ // and a second click could start an overlapping PUT.
dispatch(startLoading());
+ dispatch(createAction(REQUEST_SYNC_CONFIG)({})); // newest by construction
+
+ const accessToken = await getAccessTokenSafely();
+ // Superseded (or summit switched) while awaiting the token: don't PUT.
+ // We started the global overlay, so clear it if no newer invocation has
+ // taken ownership; the slice flag belongs to the newer invocation (or was
+ // reset by SET_CURRENT_SUMMIT).
+ if (!stillCurrent()) {
+ if (isNewest()) dispatch(stopLoading());
+ return;
+ }
const params = {
access_token: accessToken
};
return putRequest(
+ // null request action: REQUEST_SYNC_CONFIG already fired above (pre-token),
+ // so the PUT itself must not re-dispatch it.
null,
- createAction(SYNC_CONFIG_UPDATED),
+ createAction("DUMMY"),
`${baseUrl}/api/v1/sync/config/${summitId}/`,
data,
- authErrorHandler
- )(params)(dispatch)
- .then(() => {
- dispatch(stopLoading());
- dispatch(showSuccessMessage(T.translate("dropbox_sync.config_saved")));
+ snackbarErrorHandler
+ )(params)(loadingDispatch)
+ .then(({ response }) => {
+ // Commit only for the summit the save was issued against — a stale
+ // response must not repopulate another summit's slice.
+ commitDispatch(createAction(SYNC_CONFIG_UPDATED)({ response }));
+ loadingDispatch(stopLoading());
+ // The save itself succeeded server-side, so the toast is truthful even
+ // after a summit switch; suppress it only when superseded.
+ loadingDispatch(
+ showSuccessMessage(T.translate("dropbox_sync.config_saved"))
+ );
+ // Superseded on the SAME summit: our commit was suppressed, and the
+ // superseding GET may have been answered with a pre-save snapshot (the
+ // server can serve the GET before finishing this write) — redux would
+ // then hold stale config indefinitely, since nothing else refetches.
+ // Issue a fresh GET to converge on the saved server state. GETs never
+ // trigger refetches themselves, so this cannot loop. Summit-switched:
+ // skip — returning to this summit remounts and refetches anyway.
+ if (
+ !isNewest() &&
+ getState().currentSummitState?.currentSummit?.id === summitId
+ ) {
+ dispatch(getSyncConfig());
+ }
})
.catch(() => {
- dispatch(stopLoading());
+ loadingDispatch(stopLoading());
+ // Clear the REQUEST_SYNC_CONFIG loading flag without touching the
+ // stored syncConfig (unlike getSyncConfig's catch, which resets it).
+ // Suppressed when superseded/switched: the newer invocation (or the
+ // SET_CURRENT_SUMMIT reset) owns the flag.
+ commitDispatch(createAction(SYNC_CONFIG_ERROR)({}));
});
};
diff --git a/src/components/locations/allowlist-panel.js b/src/components/locations/allowlist-panel.js
new file mode 100644
index 000000000..697784862
--- /dev/null
+++ b/src/components/locations/allowlist-panel.js
@@ -0,0 +1,324 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+import React, { useEffect, useMemo, useState } from "react";
+import T from "i18n-react/dist/i18n-react";
+import {
+ Alert,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ FormControlLabel,
+ FormGroup,
+ Typography
+} from "@mui/material";
+import {
+ DROPBOX_STORAGE,
+ RECONCILE_CASE,
+ isValidEntry,
+ reconcileAllowlist
+} from "../../models/materializer-allowlist";
+
+// Stable sentinel used as a fallback when redux fields are absent/non-array.
+// A module-scope constant has a fixed identity, so memo/effect deps that
+// reference it don't change on every render (preventing "Maximum update depth
+// exceeded" when RECEIVE_SYNC_CONFIG omits materialized_media_upload_types).
+const EMPTY_ARRAY = [];
+
+// Badge i18n keys resolved ONLY through this static map keyed by RECONCILE_CASE
+// (the "constrained key map" i18n rule). OK renders no badge.
+const BADGE_KEY_BY_CASE = {
+ [RECONCILE_CASE.INVALID]: "dropbox_sync.allowlist_badge_invalid",
+ [RECONCILE_CASE.OK]: null,
+ [RECONCILE_CASE.RENAMED]: "dropbox_sync.allowlist_badge_renamed",
+ [RECONCILE_CASE.NOT_DROPBOX]: "dropbox_sync.allowlist_badge_not_dropbox",
+ [RECONCILE_CASE.RENAMED_NOT_DROPBOX]:
+ "dropbox_sync.allowlist_badge_renamed_not_dropbox",
+ [RECONCILE_CASE.RECREATED]: "dropbox_sync.allowlist_badge_recreated",
+ [RECONCILE_CASE.RECREATED_NOT_DROPBOX]:
+ "dropbox_sync.allowlist_badge_recreated_not_dropbox",
+ [RECONCILE_CASE.AMBIGUOUS]: "dropbox_sync.allowlist_badge_ambiguous",
+ [RECONCILE_CASE.MISSING]: "dropbox_sync.allowlist_badge_missing"
+};
+
+const BADGE_COLOR_BY_CASE = {
+ [RECONCILE_CASE.INVALID]: "error",
+ [RECONCILE_CASE.OK]: "default",
+ [RECONCILE_CASE.RENAMED]: "warning",
+ [RECONCILE_CASE.NOT_DROPBOX]: "warning",
+ [RECONCILE_CASE.RENAMED_NOT_DROPBOX]: "warning",
+ [RECONCILE_CASE.RECREATED]: "info",
+ [RECONCILE_CASE.RECREATED_NOT_DROPBOX]: "warning",
+ [RECONCILE_CASE.AMBIGUOUS]: "error",
+ [RECONCILE_CASE.MISSING]: "error"
+};
+
+function AllowlistPanel({
+ syncConfig,
+ allowlistOptions,
+ syncLoading,
+ onSave,
+ onRetryOptions
+}) {
+ const storedTypes = Array.isArray(syncConfig.materialized_media_upload_types)
+ ? syncConfig.materialized_media_upload_types
+ : EMPTY_ARRAY;
+ const options = Array.isArray(allowlistOptions?.options)
+ ? allowlistOptions.options
+ : EMPTY_ARRAY;
+ const optionsError = allowlistOptions?.error ?? null;
+ // Until the options fetch has committed once, reconciling stored rows would
+ // badge everything Case 8 ("missing") against a list that merely hasn't
+ // arrived. The global loading overlay normally hides this, but it is a
+ // shared non-ref-counted boolean — any parallel fetch (getLocations) that
+ // finishes first drops it mid-flight. Gate on data, not on the overlay.
+ const optionsLoaded = allowlistOptions?.loaded === true;
+
+ // Derived state, not duplicate state: rows are a pure function of
+ // storedTypes + options, so they recompute in-render — no commit ever pairs
+ // fresh inputs with stale rows.
+ const storedRows = useMemo(
+ () =>
+ reconcileAllowlist(storedTypes, options).map((row, index) => ({
+ ...row,
+ uiKey: `stored-${index}`
+ })),
+ [storedTypes, options]
+ );
+
+ // Selection model — minimal local state, derived rendering (see SDS § State).
+ const [uncheckedKeys, setUncheckedKeys] = useState(() => new Set());
+ const [removedKeys, setRemovedKeys] = useState(() => new Set());
+ const [pickedIds, setPickedIds] = useState(() => new Set());
+
+ // Reset the selection whenever the stored allowlist or the options list
+ // changes (the rows themselves recompute in-render via the memo above).
+ useEffect(() => {
+ setUncheckedKeys(new Set());
+ setRemovedKeys(new Set());
+ setPickedIds(new Set());
+ }, [storedTypes, options]);
+
+ // ---- allowlist selection derivation -------------------------------------
+
+ const effectiveId = (row) => row.savePayload?.id ?? row.entry?.id;
+
+ const isRowSelected = (row) =>
+ !removedKeys.has(row.uiKey) &&
+ !uncheckedKeys.has(row.uiKey) &&
+ row.savePayload !== null;
+
+ const visibleStoredRows = storedRows.filter(
+ (row) => !removedKeys.has(row.uiKey)
+ );
+
+ const suppressedIds = new Set(
+ visibleStoredRows
+ .map(effectiveId)
+ .filter((id) => id !== undefined && id !== null)
+ );
+
+ const availableOptions = options.filter(
+ (opt) =>
+ opt.private_storage_type === DROPBOX_STORAGE && !suppressedIds.has(opt.id)
+ );
+
+ const selectedStoredRows = storedRows.filter(isRowSelected);
+ const selectedCount = selectedStoredRows.length + pickedIds.size;
+ // syncLoading matches the Sync toggle / Rebuild guards: no Save while a
+ // config GET or PUT is in flight (updateSyncConfig dispatches
+ // REQUEST_SYNC_CONFIG pre-token, so overlapping saves are mutually
+ // exclusive too). !optionsLoaded: never save rows reconciled against an
+ // options list that hasn't arrived.
+ const saveDisabled =
+ optionsError !== null ||
+ !optionsLoaded ||
+ selectedCount === 0 ||
+ syncLoading;
+
+ const buildSavePayload = () => {
+ const fromStored = selectedStoredRows.map((row) => row.savePayload);
+ // Defensive: a picked id normally always resolves in `options` (the
+ // [storedTypes, options] reset effect clears pickedIds whenever options
+ // change). Guard the find() anyway so a future re-fetch trigger that clears
+ // options while picks are staged can't dereference undefined here.
+ const fromPicked = [...pickedIds]
+ .map((id) => options.find((o) => o.id === id))
+ .filter(Boolean)
+ .map((opt) => ({ id: opt.id, name: opt.name }));
+ const seen = new Set();
+ const deduped = [];
+ [...fromStored, ...fromPicked].forEach((item) => {
+ if (seen.has(item.id)) return; // materializer PUT 400s on duplicate ids
+ seen.add(item.id);
+ deduped.push(item);
+ });
+ return deduped;
+ };
+
+ const toggleKey = (setState, key) => {
+ setState((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ };
+
+ const handleToggleStored = (uiKey) => toggleKey(setUncheckedKeys, uiKey);
+ const handleRemoveStored = (uiKey) =>
+ setRemovedKeys((prev) => new Set(prev).add(uiKey));
+ const handleTogglePicked = (id) => toggleKey(setPickedIds, id);
+ const handleSaveAllowlist = () => onSave(buildSavePayload());
+
+ const bannerText = () => {
+ if (!syncConfig.dropbox_sync_enabled) {
+ return T.translate("dropbox_sync.banner_inactive");
+ }
+ const validNames = storedTypes.filter(isValidEntry).map((t) => t.name);
+ if (validNames.length === 0) {
+ return T.translate("dropbox_sync.banner_active_empty");
+ }
+ return T.translate("dropbox_sync.banner_active", {
+ types: validNames.join(", ")
+ });
+ };
+
+ return (
+