diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js
index 1df140caa..3ae49a47d 100644
--- a/src/actions/__tests__/dropbox-sync-actions.test.js
+++ b/src/actions/__tests__/dropbox-sync-actions.test.js
@@ -8,10 +8,12 @@ import flushPromises from "flush-promises";
import {
getRequest,
putRequest,
- postRequest
+ postRequest,
+ authErrorHandler
} from "openstack-uicore-foundation/lib/utils/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,
@@ -195,3 +197,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("every getRequest call is constructed with authErrorHandler; a 401 page still ends with the ERROR action dispatched", 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(authErrorHandler);
+ });
+
+ 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..2dcd65466 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,7 +23,8 @@ import {
showSuccessMessage,
authErrorHandler
} from "openstack-uicore-foundation/lib/utils/actions";
-import { getAccessTokenSafely } from "../utils/methods";
+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";
@@ -30,8 +32,128 @@ export const SYNC_CONFIG_UPDATED = "SYNC_CONFIG_UPDATED";
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 authErrorHandler's
+// 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
+ // (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,
+ authErrorHandler
+ )({ ...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,
+ authErrorHandler
+ )(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());
+ });
+ };
+
export const getSyncConfig = () => async (dispatch, getState) => {
const { currentSummitState } = getState();
const baseUrl = getBaseUrl();
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4861975f0..0717fc761 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -4251,7 +4251,23 @@
"config_saved": "Sync configuration saved successfully.",
"resync_helper": "Use the sync icon to re-sync individual room folders to Dropbox.",
"resync_tooltip": "Re-sync Dropbox folder",
- "resync_dispatched": "Room re-sync task has been dispatched."
+ "resync_dispatched": "Room re-sync task has been dispatched.",
+ "allowlist_label": "Materialized media upload types",
+ "allowlist_helper": "Only uploads of the selected types are copied to Dropbox. At least one type is required. To stop file sync entirely for this summit, use the Synchronization toggle above.",
+ "allowlist_save": "Save Allowlist",
+ "allowlist_error": "Couldn't load media upload types",
+ "allowlist_retry": "Retry",
+ "allowlist_badge_invalid": "invalid stored entry — remove required",
+ "allowlist_badge_renamed": "renamed: was '{stored}', now '{current}'",
+ "allowlist_badge_not_dropbox": "no longer DropBox — uploads of this type will still be matched by name; consider removing",
+ "allowlist_badge_renamed_not_dropbox": "renamed AND no longer DropBox — was '{stored}', now '{current}'; consider removing",
+ "allowlist_badge_recreated": "re-created with new id — was id {storedId}, now id {currentId}",
+ "allowlist_badge_recreated_not_dropbox": "re-created with new id AND no longer DropBox — was id {storedId}, now id {currentId}; consider removing",
+ "allowlist_badge_ambiguous": "ambiguous: {count} current types share this name — pick one manually or remove",
+ "allowlist_badge_missing": "missing — type may have been deleted",
+ "banner_active": "Materializer Active — Syncing: {types}",
+ "banner_active_empty": "Materializer Active — no types selected (nothing will sync)",
+ "banner_inactive": "Materializer Inactive"
},
"refund_form": {
"reason": "Reason for Refund",
diff --git a/src/pages/locations/__tests__/location-list-page-allowlist.test.js b/src/pages/locations/__tests__/location-list-page-allowlist.test.js
new file mode 100644
index 000000000..815bf422e
--- /dev/null
+++ b/src/pages/locations/__tests__/location-list-page-allowlist.test.js
@@ -0,0 +1,1016 @@
+import React from "react";
+import { screen, fireEvent, act } from "@testing-library/react";
+import LocationListPage, {
+ normalizeName,
+ isValidEntry,
+ RECONCILE_CASE,
+ reconcileAllowlist
+} from "../location-list-page";
+import { renderWithRedux } from "../../../utils/test-utils";
+import {
+ getSyncConfig,
+ updateSyncConfig,
+ getAllMediaUploadTypesForAllowlist
+} from "../../../actions/dropbox-sync-actions";
+
+// i18n mock echoes the key, and appends the interpolation params so badge
+// substitutions (stored/current names, ids, counts) are assertable.
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ translate: (key, params) =>
+ params && Object.keys(params).length
+ ? `${key} ${JSON.stringify(params)}`
+ : key
+}));
+
+jest.mock("../../../components/summit-dropdown", () => () => null);
+
+jest.mock("../../../actions/summit-actions", () => ({
+ getSummitById: jest.fn(() => () => Promise.resolve())
+}));
+
+jest.mock("../../../actions/location-actions", () => ({
+ getLocations: jest.fn(() => () => Promise.resolve()),
+ deleteLocation: jest.fn(() => () => Promise.resolve()),
+ exportLocations: jest.fn(() => () => Promise.resolve()),
+ updateLocationOrder: jest.fn(() => () => Promise.resolve()),
+ copyLocations: jest.fn(() => () => Promise.resolve())
+}));
+
+jest.mock("../../../actions/dropbox-sync-actions", () => ({
+ getSyncConfig: jest.fn(() => () => Promise.resolve()),
+ updateSyncConfig: jest.fn(() => () => Promise.resolve()),
+ rebuildSync: jest.fn(() => () => Promise.resolve()),
+ resyncRoom: jest.fn(() => () => Promise.resolve()),
+ getAllMediaUploadTypesForAllowlist: jest.fn(() => () => Promise.resolve())
+}));
+
+// ---- fixtures -------------------------------------------------------------
+
+const dropboxType = (id, name) => ({
+ id,
+ name,
+ private_storage_type: "DropBox"
+});
+const localType = (id, name) => ({ id, name, private_storage_type: "Local" });
+
+const buildState = ({ syncConfig = {}, allowlistOptions = {} } = {}) => ({
+ currentSummitState: {
+ currentSummit: { id: 1, name: "Test Summit" }
+ },
+ currentLocationListState: { locations: [], totalLocations: 0 },
+ dropboxSyncState: {
+ loading: false,
+ syncConfig: {
+ summit_id: 1,
+ dropbox_sync_enabled: true,
+ preflight_alert_email: null,
+ materialized_media_upload_types: [],
+ ...syncConfig
+ },
+ allowlistOptions: { options: [], error: null, ...allowlistOptions }
+ }
+});
+
+const mountPanel = (opts) =>
+ renderWithRedux(, {
+ initialState: buildState(opts)
+ });
+
+const badgeTexts = () =>
+ screen.queryAllByTestId("allowlist-badge").map((n) => n.textContent);
+
+const saveButton = () => screen.getByTestId("allowlist-save");
+
+const clickSave = () => {
+ act(() => {
+ fireEvent.click(saveButton());
+ });
+};
+
+const lastSavePayload = () => {
+ const call =
+ updateSyncConfig.mock.calls[updateSyncConfig.mock.calls.length - 1];
+ return call[0].materialized_media_upload_types;
+};
+
+beforeEach(() => {
+ window.DROPBOX_MATERIALIZER_API_BASE_URL = "https://materializer.test";
+ jest.clearAllMocks();
+});
+
+afterEach(() => {
+ delete window.DROPBOX_MATERIALIZER_API_BASE_URL;
+});
+
+// ===========================================================================
+// 3a. Reconciliation named exports (pure unit tests)
+// ===========================================================================
+
+describe("normalizeName", () => {
+ test("worker parity: trims then casefolds", () => {
+ expect(normalizeName(" Final Poster ")).toBe("final poster");
+ });
+
+ test("non-ASCII parity: Straße folds equal to STRASSE", () => {
+ expect(normalizeName("Straße")).toBe(normalizeName("STRASSE"));
+ expect(normalizeName("Straße")).toBe("strasse");
+ });
+
+ // KNOWN DIVERGENCE from Python str.casefold(): JS toUpperCase().toLowerCase()
+ // merges the dotless "ı" with "i", while Python casefold keeps them distinct.
+ // This is an accepted, documented decision (see plan note). If this pin fails
+ // someone changed the normalization strategy and must re-read that note before
+ // "fixing" it -- the consequence is a silent Case-1 no-badge + skipped worker
+ // repair for ı/i name collisions.
+ test("known-divergence pin: dotless ı folds to i (JS-only, NOT Python parity)", () => {
+ expect(normalizeName("ı")).toBe("i");
+ });
+
+ test("non-string input yields empty string", () => {
+ expect(normalizeName(123)).toBe("");
+ expect(normalizeName(null)).toBe("");
+ expect(normalizeName(undefined)).toBe("");
+ });
+});
+
+describe("isValidEntry", () => {
+ test.each([
+ ["non-positive (zero) id", { id: 0, name: "X" }],
+ ["negative id", { id: -5, name: "X" }],
+ ["bool id", { id: true, name: "X" }],
+ ["string id", { id: "5", name: "X" }],
+ ["null id", { id: null, name: "X" }],
+ ["missing id key", { name: "X" }],
+ ["empty name", { id: 5, name: "" }],
+ ["whitespace-only name", { id: 5, name: " " }],
+ ["numeric name", { id: 5, name: 123 }],
+ ["null name", { id: 5, name: null }],
+ ["missing name key", { id: 5 }],
+ ["non-object entry", "not-an-object"]
+ ])("rejects %s", (_label, entry) => {
+ expect(isValidEntry(entry)).toBe(false);
+ });
+
+ test("accepts a positive-int id + non-empty string name", () => {
+ expect(isValidEntry({ id: 5, name: "X" })).toBe(true);
+ });
+});
+
+describe("reconcileAllowlist decision table", () => {
+ test("row 1: id+name match, DropBox -> OK, no badge, stored payload", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(1, "Final Poster")]
+ );
+ expect(rows).toHaveLength(1);
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.OK);
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" });
+ expect(rows[0].selectable).toBe(true);
+ });
+
+ test("row 2: id match, name differs, DropBox -> RENAMED, current name in payload", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Old Name" }],
+ [dropboxType(1, "New Name")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.RENAMED);
+ expect(rows[0].badgeParams).toEqual({
+ stored: "Old Name",
+ current: "New Name"
+ });
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "New Name" });
+ });
+
+ test("row 3: id+name match, storage Local -> NOT_DROPBOX, stored payload", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [localType(1, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX);
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" });
+ });
+
+ test("row 3: id+name match, storage null -> NOT_DROPBOX", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [{ id: 1, name: "Final Poster", private_storage_type: null }]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX);
+ });
+
+ test("row 4: id match, name differs, non-DropBox -> RENAMED_NOT_DROPBOX", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Old Name" }],
+ [localType(1, "New Name")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.RENAMED_NOT_DROPBOX);
+ expect(rows[0].badgeParams).toEqual({
+ stored: "Old Name",
+ current: "New Name"
+ });
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "New Name" });
+ });
+
+ test("row 5: id gone, exactly 1 DropBox name-match -> RECREATED auto-relink", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(99, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED);
+ expect(rows[0].badgeParams).toEqual({ storedId: 1, currentId: 99 });
+ expect(rows[0].savePayload).toEqual({ id: 99, name: "Final Poster" });
+ });
+
+ test("row 5: case-insensitive name match relinks (stored 'Final Poster' vs 'FINAL POSTER ')", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(99, "FINAL POSTER ")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED);
+ expect(rows[0].savePayload).toEqual({ id: 99, name: "FINAL POSTER " });
+ });
+
+ test("row 6: id gone, exactly 1 non-DropBox name-match -> RECREATED_NOT_DROPBOX auto-relink", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [localType(99, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.RECREATED_NOT_DROPBOX);
+ expect(rows[0].badgeParams).toEqual({ storedId: 1, currentId: 99 });
+ expect(rows[0].savePayload).toEqual({ id: 99, name: "Final Poster" });
+ });
+
+ test("row 7: 2 DropBox name-matches -> AMBIGUOUS(2), stored payload, non-selectable", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(99, "Final Poster"), dropboxType(100, "FINAL POSTER")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS);
+ expect(rows[0].badgeParams).toEqual({ count: 2 });
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" });
+ expect(rows[0].selectable).toBe(false);
+ });
+
+ test("row 7: DropBox + Local name-matches -> AMBIGUOUS(2)", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(99, "Final Poster"), localType(100, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS);
+ expect(rows[0].badgeParams).toEqual({ count: 2 });
+ });
+
+ test("row 7: 3 name-matches -> AMBIGUOUS(3) (locks >= 2, not === 2)", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [
+ dropboxType(99, "Final Poster"),
+ localType(100, "Final Poster"),
+ dropboxType(101, "final poster")
+ ]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.AMBIGUOUS);
+ expect(rows[0].badgeParams).toEqual({ count: 3 });
+ });
+
+ test("row 8: id gone, 0 name-matches -> MISSING, stored payload", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [dropboxType(99, "Something Else")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.MISSING);
+ expect(rows[0].savePayload).toEqual({ id: 1, name: "Final Poster" });
+ });
+
+ test("reconciliation uses the FULL list: stored type now Local -> Case 3, not Case 8", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 1, name: "Final Poster" }],
+ [localType(1, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.NOT_DROPBOX);
+ expect(rows[0].caseId).not.toBe(RECONCILE_CASE.MISSING);
+ });
+});
+
+describe("reconcileAllowlist Case 0 + dedup pipeline", () => {
+ const CASE0 = [
+ { id: 0, name: "X" },
+ { id: -5, name: "X" },
+ { id: true, name: "X" },
+ { id: "5", name: "X" },
+ { id: null, name: "X" },
+ { name: "X" },
+ { id: 5, name: "" },
+ { id: 5, name: " " },
+ { id: 5, name: 123 },
+ { id: 5, name: null },
+ { id: 5 },
+ "not-an-object"
+ ];
+
+ test.each(CASE0.map((e, i) => [i, e]))(
+ "invalid stored entry #%i -> INVALID case, null payload, non-selectable",
+ (_i, entry) => {
+ const rows = reconcileAllowlist([entry], [dropboxType(5, "X")]);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID);
+ expect(rows[0].savePayload).toBeNull();
+ expect(rows[0].selectable).toBe(false);
+ }
+ );
+
+ test("valid dup ids: first occurrence wins, later duplicate produces NO row", () => {
+ const rows = reconcileAllowlist(
+ [
+ { id: 5, name: "A" },
+ { id: 5, name: "B" },
+ { id: 7, name: "C" }
+ ],
+ [dropboxType(5, "A"), dropboxType(7, "C")]
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows[0].savePayload).toEqual({ id: 5, name: "A" });
+ expect(rows[1].savePayload).toEqual({ id: 7, name: "C" });
+ });
+
+ test("Order A [valid, invalid-same-id]: valid survives, invalid renders Case 0", () => {
+ const rows = reconcileAllowlist(
+ [
+ { id: 5, name: "X" },
+ { id: 5, name: "" }
+ ],
+ [dropboxType(5, "X")]
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.OK);
+ expect(rows[1].caseId).toBe(RECONCILE_CASE.INVALID);
+ });
+
+ test("Order B [invalid, valid-same-id]: valid ALWAYS survives", () => {
+ const rows = reconcileAllowlist(
+ [
+ { id: 5, name: "" },
+ { id: 5, name: "X" }
+ ],
+ [dropboxType(5, "X")]
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID);
+ expect(rows[1].caseId).toBe(RECONCILE_CASE.OK);
+ expect(rows[1].savePayload).toEqual({ id: 5, name: "X" });
+ });
+
+ test("all-invalid duplicates -> two independent Case-0 rows", () => {
+ const rows = reconcileAllowlist(
+ [
+ { id: true, name: "X" },
+ { id: true, name: "Y" }
+ ],
+ []
+ );
+ expect(rows).toHaveLength(2);
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID);
+ expect(rows[1].caseId).toBe(RECONCILE_CASE.INVALID);
+ });
+
+ test("mutual exclusivity: every row carries exactly one caseId", () => {
+ const rows = reconcileAllowlist(
+ [
+ { id: 1, name: "Final Poster" },
+ { id: 2, name: "Old Deck" },
+ { id: 0, name: "bad" }
+ ],
+ [dropboxType(1, "Final Poster"), dropboxType(2, "New Deck")]
+ );
+ rows.forEach((r) => {
+ expect(Object.values(RECONCILE_CASE)).toContain(r.caseId);
+ });
+ expect(rows.map((r) => r.caseId)).toEqual([
+ RECONCILE_CASE.OK,
+ RECONCILE_CASE.RENAMED,
+ RECONCILE_CASE.INVALID
+ ]);
+ });
+
+ test("Case-0 precedence: {id:0,name:'Final Poster'} with live DropBox match -> INVALID not RECREATED", () => {
+ const rows = reconcileAllowlist(
+ [{ id: 0, name: "Final Poster" }],
+ [dropboxType(99, "Final Poster")]
+ );
+ expect(rows[0].caseId).toBe(RECONCILE_CASE.INVALID);
+ expect(rows[0].caseId).not.toBe(RECONCILE_CASE.RECREATED);
+ });
+});
+
+// ===========================================================================
+// 3c. Panel component tests (render / wiring; classification unit-tested above)
+// ===========================================================================
+
+describe("Allowlist panel - mount + options filtering", () => {
+ test("dispatches the aggregator on mount inside the materializer gate", () => {
+ mountPanel();
+ expect(getAllMediaUploadTypesForAllowlist).toHaveBeenCalledTimes(1);
+ expect(getSyncConfig).toHaveBeenCalledTimes(1);
+ });
+
+ test("does NOT dispatch the aggregator when the materializer flag is absent", () => {
+ delete window.DROPBOX_MATERIALIZER_API_BASE_URL;
+ mountPanel();
+ expect(getAllMediaUploadTypesForAllowlist).not.toHaveBeenCalled();
+ });
+
+ test("options are DropBox-filtered; reconciliation uses full list (Case 3 not missing)", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: {
+ options: [localType(1, "Poster"), dropboxType(2, "Deck")]
+ }
+ });
+ // reconciliation on the full list -> Case 3 badge (not "missing")
+ expect(badgeTexts()).toEqual([
+ expect.stringContaining("allowlist_badge_not_dropbox")
+ ]);
+ // only the DropBox type is offered as a new selection option
+ expect(screen.queryByTestId("allowlist-option-2")).toBeInTheDocument();
+ expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument();
+ });
+});
+
+describe("Allowlist panel - per-row rendering + save payload", () => {
+ test("Case 1: no badge; save payload is the stored entry", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Final Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Final Poster")] }
+ });
+ expect(badgeTexts()).toHaveLength(0);
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]);
+ });
+
+ test("Case 2: renamed badge shows stored+current; payload uses current name", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Old Name" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "New Name")] }
+ });
+ const [badge] = badgeTexts();
+ expect(badge).toContain("allowlist_badge_renamed");
+ expect(badge).toContain("Old Name");
+ expect(badge).toContain("New Name");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "New Name" }]);
+ });
+
+ test.each([
+ ["Local", localType(1, "Poster")],
+ ["null", { id: 1, name: "Poster", private_storage_type: null }]
+ ])(
+ "Case 3 (%s storage): not-DropBox badge; stored payload",
+ (_label, type) => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: { options: [type] }
+ });
+ expect(badgeTexts()[0]).toContain("allowlist_badge_not_dropbox");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Poster" }]);
+ }
+ );
+
+ test("Case 4: renamed + not-DropBox badge; payload uses current name", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Old Name" }]
+ },
+ allowlistOptions: { options: [localType(1, "New Name")] }
+ });
+ const [badge] = badgeTexts();
+ expect(badge).toContain("allowlist_badge_renamed_not_dropbox");
+ expect(badge).toContain("Old Name");
+ expect(badge).toContain("New Name");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "New Name" }]);
+ });
+
+ test("Case 5: re-created badge shows ids; payload relinks to current id+name", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Final Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(99, "Final Poster")] }
+ });
+ const [badge] = badgeTexts();
+ expect(badge).toContain("allowlist_badge_recreated");
+ expect(badge).toContain("\"storedId\":1");
+ expect(badge).toContain("\"currentId\":99");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]);
+ });
+
+ test("Case 6: re-created + not-DropBox badge; payload relinks", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Final Poster" }]
+ },
+ allowlistOptions: { options: [localType(99, "Final Poster")] }
+ });
+ expect(badgeTexts()[0]).toContain("allowlist_badge_recreated_not_dropbox");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]);
+ });
+
+ test("Case 7: ambiguous badge with count; stored payload; Save enabled via other rows", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Final Poster" }]
+ },
+ allowlistOptions: {
+ options: [
+ dropboxType(99, "Final Poster"),
+ dropboxType(100, "FINAL POSTER")
+ ]
+ }
+ });
+ const [badge] = badgeTexts();
+ expect(badge).toContain("allowlist_badge_ambiguous");
+ expect(badge).toContain("\"count\":2");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]);
+ });
+
+ test("Case 8: missing badge; stored payload", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Final Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(99, "Other")] }
+ });
+ expect(badgeTexts()[0]).toContain("allowlist_badge_missing");
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]);
+ });
+});
+
+describe("Allowlist panel - Case 0 mounted sub-fixtures", () => {
+ const CASE0 = [
+ ["zero id", { id: 0, name: "X" }],
+ ["negative id", { id: -5, name: "X" }],
+ ["bool id", { id: true, name: "X" }],
+ ["string id", { id: "5", name: "X" }],
+ ["null id", { id: null, name: "X" }],
+ ["missing id", { name: "X" }],
+ ["empty name", { id: 5, name: "" }],
+ ["whitespace name", { id: 5, name: " " }],
+ ["numeric name", { id: 5, name: 123 }],
+ ["null name", { id: 5, name: null }],
+ ["missing name", { id: 5 }],
+ ["non-object", "not-an-object"]
+ ];
+
+ test.each(CASE0)(
+ "%s: invalid badge renders + Save disabled (empty effective selection)",
+ (_label, entry) => {
+ mountPanel({
+ syncConfig: { materialized_media_upload_types: [entry] },
+ allowlistOptions: { options: [] }
+ });
+ expect(badgeTexts()[0]).toContain("allowlist_badge_invalid");
+ expect(saveButton()).toBeDisabled();
+ }
+ );
+
+ test("granular omission: one valid + one Case-0 -> payload has only valid; Save enabled", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 1, name: "Final Poster" },
+ { id: 0, name: "bad" }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Final Poster")] }
+ });
+ expect(saveButton()).not.toBeDisabled();
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Final Poster" }]);
+ });
+});
+
+describe("Allowlist panel - pre-table pipeline (mounted)", () => {
+ test("pure valid dup: two rows, no badge on survivor, payload has both deduped", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 5, name: "A" },
+ { id: 5, name: "B" },
+ { id: 7, name: "C" }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(5, "A"), dropboxType(7, "C")] }
+ });
+ expect(badgeTexts()).toHaveLength(0);
+ clickSave();
+ expect(lastSavePayload()).toEqual([
+ { id: 5, name: "A" },
+ { id: 7, name: "C" }
+ ]);
+ });
+
+ test("Order A [valid, invalid]: invalid badge on the invalid; payload only valid", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 5, name: "X" },
+ { id: 5, name: "" }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(5, "X")] }
+ });
+ expect(badgeTexts()).toEqual([
+ expect.stringContaining("allowlist_badge_invalid")
+ ]);
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 5, name: "X" }]);
+ });
+
+ test("Order B [invalid, valid]: valid not lost; payload only valid", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 5, name: "" },
+ { id: 5, name: "X" }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(5, "X")] }
+ });
+ expect(badgeTexts()).toEqual([
+ expect.stringContaining("allowlist_badge_invalid")
+ ]);
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 5, name: "X" }]);
+ });
+
+ test("all-invalid dup: two invalid badges; Save disabled", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: true, name: "X" },
+ { id: true, name: "Y" }
+ ]
+ },
+ allowlistOptions: { options: [] }
+ });
+ expect(badgeTexts()).toHaveLength(2);
+ badgeTexts().forEach((t) => expect(t).toContain("allowlist_badge_invalid"));
+ expect(saveButton()).toBeDisabled();
+ });
+
+ test("mounted Case-0 precedence: invalid badge wins over re-created", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 0, name: "Final Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(99, "Final Poster")] }
+ });
+ expect(badgeTexts()[0]).toContain("allowlist_badge_invalid");
+ expect(badgeTexts()[0]).not.toContain("allowlist_badge_recreated");
+ });
+});
+
+describe("Allowlist panel - payload dedupe under double relink", () => {
+ test("two stale ids both relinking to one live type -> id appears once in payload", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 1, name: "Final Poster" },
+ { id: 2, name: "final poster" }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(99, "Final Poster")] }
+ });
+ // both rows render a re-created badge
+ expect(badgeTexts()).toHaveLength(2);
+ badgeTexts().forEach((t) =>
+ expect(t).toContain("allowlist_badge_recreated")
+ );
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 99, name: "Final Poster" }]);
+ });
+});
+
+describe("Allowlist panel - remove vs uncheck (two-set model)", () => {
+ test("uncheck: row stays, option does NOT resurface, selection empties (Save disabled)", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Poster")] }
+ });
+ // suppressed while checked
+ expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument();
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-check-stored-0"));
+ });
+ // still visible, still suppressing its option, selection now empty
+ expect(screen.getByTestId("allowlist-check-stored-0")).toBeInTheDocument();
+ expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument();
+ expect(saveButton()).toBeDisabled();
+ // re-check restores
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-check-stored-0"));
+ });
+ expect(saveButton()).not.toBeDisabled();
+ });
+
+ test("remove: row disappears, option resurfaces, re-pick puts live {id,name} back", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Poster")] }
+ });
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-remove-stored-0"));
+ });
+ // row gone, option resurfaced
+ expect(
+ screen.queryByTestId("allowlist-check-stored-0")
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("allowlist-option-1")).toBeInTheDocument();
+ // pick the resurfaced option
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-option-1"));
+ });
+ expect(saveButton()).not.toBeDisabled();
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Poster" }]);
+ });
+});
+
+describe("Allowlist panel - Case 7 remove-and-repick", () => {
+ test("ambiguous row removable; payload drops it; re-pick adds live record", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [
+ { id: 1, name: "Final Poster" },
+ { id: 2, name: "Keynote Deck" }
+ ]
+ },
+ allowlistOptions: {
+ options: [
+ dropboxType(99, "Final Poster"),
+ dropboxType(100, "FINAL POSTER"),
+ dropboxType(2, "Keynote Deck")
+ ]
+ }
+ });
+ // ambiguous badge present, stored entry present in payload while visible
+ expect(badgeTexts()[0]).toContain("allowlist_badge_ambiguous");
+ clickSave();
+ expect(lastSavePayload()).toEqual([
+ { id: 1, name: "Final Poster" },
+ { id: 2, name: "Keynote Deck" }
+ ]);
+ // remove the ambiguous row
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-remove-stored-0"));
+ });
+ expect(saveButton()).not.toBeDisabled();
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 2, name: "Keynote Deck" }]);
+ // re-pick one of the ambiguous live types
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-option-99"));
+ });
+ clickSave();
+ expect(lastSavePayload()).toEqual([
+ { id: 2, name: "Keynote Deck" },
+ { id: 99, name: "Final Poster" }
+ ]);
+ });
+});
+
+describe("Allowlist panel - error + empty gating", () => {
+ test("error state: options hidden, Alert + Retry rendered, Save disabled", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: { options: [], error: "fetch failed" }
+ });
+ expect(screen.getByTestId("allowlist-error")).toBeInTheDocument();
+ expect(screen.queryByTestId("allowlist-option-1")).not.toBeInTheDocument();
+ expect(saveButton()).toBeDisabled();
+ // Retry re-dispatches the aggregator (already called once on mount)
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-retry"));
+ });
+ expect(getAllMediaUploadTypesForAllowlist).toHaveBeenCalledTimes(2);
+ });
+
+ test("recovery: fresh mount with options + selection re-enables Save", () => {
+ mountPanel({
+ syncConfig: {
+ materialized_media_upload_types: [{ id: 1, name: "Poster" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Poster")], error: null }
+ });
+ expect(saveButton()).not.toBeDisabled();
+ });
+
+ test("empty selection disables Save; picking one enables; deselecting re-disables", () => {
+ mountPanel({
+ syncConfig: { materialized_media_upload_types: [] },
+ allowlistOptions: { options: [dropboxType(1, "Poster")] }
+ });
+ expect(saveButton()).toBeDisabled();
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-option-1"));
+ });
+ expect(saveButton()).not.toBeDisabled();
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-option-1"));
+ });
+ expect(saveButton()).toBeDisabled();
+ });
+});
+
+// ===========================================================================
+// Stable empty-array fallbacks — regression guard
+// ===========================================================================
+// Before the fix: `syncConfig.materialized_media_upload_types || []` and
+// `allowlistOptions?.options || []` mint a NEW array identity on every render
+// when the field is absent/undefined. Both arrays are deps of the reset
+// useEffect, so the effect fires every render → 4× setState → re-render loop
+// → "Maximum update depth exceeded".
+// After the fix: module-scope EMPTY_ARRAY + Array.isArray guard gives a stable
+// identity, breaking the loop.
+describe("Allowlist panel - stable empty-array fallbacks (regression)", () => {
+ test("renders without throwing when syncConfig lacks materialized_media_upload_types", () => {
+ // Simulate an out-of-contract RECEIVE_SYNC_CONFIG that omits the key entirely
+ const state = {
+ currentSummitState: {
+ currentSummit: { id: 1, name: "Test Summit" }
+ },
+ currentLocationListState: { locations: [], totalLocations: 0 },
+ dropboxSyncState: {
+ loading: false,
+ syncConfig: {
+ summit_id: 1,
+ dropbox_sync_enabled: true,
+ preflight_alert_email: null
+ // materialized_media_upload_types intentionally omitted
+ },
+ allowlistOptions: { options: [], error: null }
+ }
+ };
+ renderWithRedux(, {
+ initialState: state
+ });
+ // Page must render — banner is the first always-visible element in the panel
+ expect(screen.getByTestId("allowlist-banner")).toBeInTheDocument();
+ });
+
+ test("renders without throwing when allowlistOptions.options is undefined", () => {
+ const state = {
+ currentSummitState: {
+ currentSummit: { id: 1, name: "Test Summit" }
+ },
+ currentLocationListState: { locations: [], totalLocations: 0 },
+ dropboxSyncState: {
+ loading: false,
+ syncConfig: {
+ summit_id: 1,
+ dropbox_sync_enabled: true,
+ preflight_alert_email: null,
+ materialized_media_upload_types: []
+ },
+ allowlistOptions: { options: undefined, error: null }
+ }
+ };
+ renderWithRedux(, {
+ initialState: state
+ });
+ expect(screen.getByTestId("allowlist-banner")).toBeInTheDocument();
+ });
+});
+
+describe("Allowlist panel - saved-state banner", () => {
+ test("active + non-empty: names in stored order", () => {
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: [
+ { id: 1, name: "Alpha" },
+ { id: 2, name: "Bravo" }
+ ]
+ },
+ allowlistOptions: {
+ options: [dropboxType(1, "Alpha"), dropboxType(2, "Bravo")]
+ }
+ });
+ const banner = screen.getByTestId("allowlist-banner").textContent;
+ expect(banner).toContain("banner_active");
+ expect(banner).toContain("Alpha, Bravo");
+ });
+
+ test("active + stored-empty: nothing-will-sync banner", () => {
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: []
+ }
+ });
+ expect(screen.getByTestId("allowlist-banner").textContent).toContain(
+ "banner_active_empty"
+ );
+ });
+
+ test("active + only-invalid stored entries: nothing-will-sync banner, no crash", () => {
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: [null, "oops", { id: 1 }]
+ }
+ });
+ expect(screen.getByTestId("allowlist-banner").textContent).toContain(
+ "banner_active_empty"
+ );
+ });
+
+ test("active + mixed valid/invalid stored entries: only valid names shown", () => {
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: [
+ null,
+ { id: 1, name: "Alpha" },
+ { id: 2 }
+ ]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Alpha")] }
+ });
+ const banner = screen.getByTestId("allowlist-banner").textContent;
+ expect(banner).toContain("Alpha");
+ expect(banner).not.toContain("undefined");
+ });
+
+ test("inactive: inactive banner regardless of stored allowlist", () => {
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: false,
+ materialized_media_upload_types: [{ id: 1, name: "Alpha" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Alpha")] }
+ });
+ expect(screen.getByTestId("allowlist-banner").textContent).toContain(
+ "banner_inactive"
+ );
+ });
+
+ test("transition proof: unsaved edit does not move banner; Save PUTs new payload; fresh mount shows new names", () => {
+ // (a) pre-save mount
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: [
+ { id: 1, name: "Alpha" },
+ { id: 2, name: "Bravo" }
+ ]
+ },
+ allowlistOptions: {
+ options: [dropboxType(1, "Alpha"), dropboxType(2, "Bravo")]
+ }
+ });
+ // unsaved picker edit: uncheck Bravo
+ act(() => {
+ fireEvent.click(screen.getByTestId("allowlist-check-stored-1"));
+ });
+ // banner reads SAVED config -> unchanged
+ expect(screen.getByTestId("allowlist-banner").textContent).toContain(
+ "Alpha, Bravo"
+ );
+ // (b) Save dispatches new payload
+ clickSave();
+ expect(lastSavePayload()).toEqual([{ id: 1, name: "Alpha" }]);
+
+ // (c) fresh mount from the post-save syncConfig -> banner shows only Alpha
+ mountPanel({
+ syncConfig: {
+ dropbox_sync_enabled: true,
+ materialized_media_upload_types: [{ id: 1, name: "Alpha" }]
+ },
+ allowlistOptions: { options: [dropboxType(1, "Alpha")] }
+ });
+ const banners = screen.getAllByTestId("allowlist-banner");
+ const fresh = banners[banners.length - 1].textContent;
+ expect(fresh).toContain("Alpha");
+ expect(fresh).not.toContain("Bravo");
+ });
+});
diff --git a/src/pages/locations/location-list-page.js b/src/pages/locations/location-list-page.js
index b80d35451..08b5d600c 100644
--- a/src/pages/locations/location-list-page.js
+++ b/src/pages/locations/location-list-page.js
@@ -11,11 +11,21 @@
* limitations under the License.
* */
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import T from "i18n-react/dist/i18n-react";
import Swal from "sweetalert2";
import Switch from "react-switch";
+import {
+ Alert,
+ Box,
+ Button,
+ Checkbox,
+ Chip,
+ FormControlLabel,
+ FormGroup,
+ Typography
+} from "@mui/material";
import SortableTable from "openstack-uicore-foundation/lib/components/table-sortable";
import SummitDropdown from "../../components/summit-dropdown";
@@ -30,8 +40,191 @@ import {
import {
getSyncConfig,
updateSyncConfig,
- rebuildSync
+ rebuildSync,
+ getAllMediaUploadTypesForAllowlist
} from "../../actions/dropbox-sync-actions";
+import { TWO } from "../../utils/constants";
+
+// Stable sentinel used as a fallback when redux fields are absent/non-array.
+// A module-scope constant has a fixed identity, so useEffect 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 = [];
+
+const DROPBOX_STORAGE = "DropBox";
+
+/**
+ * JS approximation of Python str.casefold() — the worker's key-space
+ * (build_allowlist_set uses strip().casefold()). toUpperCase() first expands
+ * full case foldings (ß→SS, fi→FI) that toLowerCase() alone would miss.
+ * KNOWN DIVERGENCE (accepted, see SDS plan note): JS merges dotless ı with i
+ * while Python casefold keeps them distinct. Pinned by the divergence fixture.
+ * This is the ONLY normalization site in this file.
+ */
+export const normalizeName = (name) =>
+ typeof name === "string" ? name.trim().toUpperCase().toLowerCase() : "";
+
+export const isValidEntry = (entry) =>
+ entry !== null &&
+ typeof entry === "object" &&
+ !Array.isArray(entry) &&
+ typeof entry.id === "number" &&
+ Number.isInteger(entry.id) &&
+ entry.id > 0 &&
+ typeof entry.name === "string" &&
+ entry.name.trim().length > 0;
+
+export const RECONCILE_CASE = {
+ INVALID: 0,
+ OK: 1,
+ RENAMED: 2,
+ NOT_DROPBOX: 3,
+ RENAMED_NOT_DROPBOX: 4,
+ RECREATED: 5,
+ RECREATED_NOT_DROPBOX: 6,
+ AMBIGUOUS: 7,
+ MISSING: 8
+};
+
+// 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"
+};
+
+const reconcileValidEntry = (entry, types) => {
+ const storedNorm = normalizeName(entry.name);
+ const byId = types.find((t) => t.id === entry.id);
+
+ if (byId) {
+ const isDropbox = byId.private_storage_type === DROPBOX_STORAGE;
+ const nameAgrees = normalizeName(byId.name) === storedNorm;
+ if (nameAgrees && isDropbox) {
+ return {
+ entry,
+ caseId: RECONCILE_CASE.OK,
+ badgeParams: {},
+ savePayload: { id: entry.id, name: entry.name },
+ selectable: true
+ };
+ }
+ if (!nameAgrees && isDropbox) {
+ return {
+ entry,
+ caseId: RECONCILE_CASE.RENAMED,
+ badgeParams: { stored: entry.name, current: byId.name },
+ savePayload: { id: entry.id, name: byId.name },
+ selectable: true
+ };
+ }
+ if (nameAgrees && !isDropbox) {
+ return {
+ entry,
+ caseId: RECONCILE_CASE.NOT_DROPBOX,
+ badgeParams: {},
+ savePayload: { id: entry.id, name: entry.name },
+ selectable: true
+ };
+ }
+ return {
+ entry,
+ caseId: RECONCILE_CASE.RENAMED_NOT_DROPBOX,
+ badgeParams: { stored: entry.name, current: byId.name },
+ savePayload: { id: entry.id, name: byId.name },
+ selectable: true
+ };
+ }
+
+ const nameMatches = types.filter((t) => normalizeName(t.name) === storedNorm);
+
+ if (nameMatches.length === 0) {
+ return {
+ entry,
+ caseId: RECONCILE_CASE.MISSING,
+ badgeParams: {},
+ savePayload: { id: entry.id, name: entry.name },
+ selectable: true
+ };
+ }
+
+ if (nameMatches.length >= TWO) {
+ return {
+ entry,
+ caseId: RECONCILE_CASE.AMBIGUOUS,
+ badgeParams: { count: nameMatches.length },
+ savePayload: { id: entry.id, name: entry.name },
+ selectable: false
+ };
+ }
+
+ const [match] = nameMatches;
+ const isDropbox = match.private_storage_type === DROPBOX_STORAGE;
+ return {
+ entry,
+ caseId: isDropbox
+ ? RECONCILE_CASE.RECREATED
+ : RECONCILE_CASE.RECREATED_NOT_DROPBOX,
+ badgeParams: { storedId: entry.id, currentId: match.id },
+ savePayload: { id: match.id, name: match.name },
+ selectable: true
+ };
+};
+
+/**
+ * Reconcile the materializer-stored allowlist against the live MediaUploadType
+ * list (the FULL, unfiltered list — the DropBox filter only gates NEW picks).
+ * Returns one row per surviving stored entry:
+ * { entry, caseId, badgeParams, savePayload|null, selectable }
+ * Pre-table pipeline: invalid entries take Case 0 (null payload, non-selectable)
+ * and never participate in dedup; valid entries dedup by id, first occurrence
+ * wins (later valid duplicates produce no row) so valid data always survives.
+ */
+export const reconcileAllowlist = (stored, allTypes) => {
+ const list = Array.isArray(stored) ? stored : [];
+ const types = Array.isArray(allTypes) ? allTypes : [];
+ const seenValidIds = new Set();
+ const rows = [];
+
+ list.forEach((entry) => {
+ if (!isValidEntry(entry)) {
+ rows.push({
+ entry,
+ caseId: RECONCILE_CASE.INVALID,
+ badgeParams: {},
+ savePayload: null,
+ selectable: false
+ });
+ return;
+ }
+ if (seenValidIds.has(entry.id)) return; // silent first-occurrence dedup
+ seenValidIds.add(entry.id);
+ rows.push(reconcileValidEntry(entry, types));
+ });
+
+ return rows;
+};
function LocationListPage({
currentSummit,
@@ -41,15 +234,48 @@ function LocationListPage({
dropboxSyncState,
...props
}) {
+ const {
+ syncConfig,
+ loading: syncLoading,
+ allowlistOptions
+ } = dropboxSyncState;
+ 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;
+
+ // Selection model — minimal local state, derived rendering (see SDS § State).
+ const [storedRows, setStoredRows] = useState([]);
+ const [uncheckedKeys, setUncheckedKeys] = useState(() => new Set());
+ const [removedKeys, setRemovedKeys] = useState(() => new Set());
+ const [pickedIds, setPickedIds] = useState(() => new Set());
+
useEffect(() => {
if (currentSummit) {
props.getLocations();
if (window.DROPBOX_MATERIALIZER_API_BASE_URL) {
props.getSyncConfig();
+ props.getAllMediaUploadTypesForAllowlist();
}
}
}, [currentSummit?.id]);
+ // Recompute rows + reset selection whenever the stored allowlist or the
+ // options list changes.
+ useEffect(() => {
+ const rows = reconcileAllowlist(storedTypes, options).map((row, index) => ({
+ ...row,
+ uiKey: `stored-${index}`
+ }));
+ setStoredRows(rows);
+ setUncheckedKeys(new Set());
+ setRemovedKeys(new Set());
+ setPickedIds(new Set());
+ }, [storedTypes, options]);
+
if (!currentSummit.id) return
;
const handleEdit = (locationId) => {
@@ -102,6 +328,86 @@ function LocationListPage({
});
};
+ // ---- 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;
+ const saveDisabled = optionsError !== null || selectedCount === 0;
+
+ 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 = () =>
+ props.updateSyncConfig({
+ materialized_media_upload_types: buildSavePayload()
+ });
+ const handleRetryOptions = () => props.getAllMediaUploadTypesForAllowlist();
+
+ 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(", ")
+ });
+ };
+
const columns = [
{ columnKey: "name", value: T.translate("location_list.name") },
{ columnKey: "class_name", value: T.translate("location_list.class_name") }
@@ -116,7 +422,6 @@ function LocationListPage({
const sortedLocations = locations.sort((a, b) => a.order - b.order);
- const { syncConfig, loading: syncLoading } = dropboxSyncState;
const showSyncPanel = !!window.DROPBOX_MATERIALIZER_API_BASE_URL;
return (
@@ -156,6 +461,127 @@ function LocationListPage({
+
+
+ {T.translate("dropbox_sync.allowlist_label")}
+
+
+ {bannerText()}
+
+
+ {T.translate("dropbox_sync.allowlist_helper")}
+
+
+ {optionsError !== null ? (
+
+ {T.translate("dropbox_sync.allowlist_retry")}
+
+ }
+ >
+ {T.translate("dropbox_sync.allowlist_error")}
+
+ ) : (
+
+ {visibleStoredRows.map((row) => {
+ const badgeKey = BADGE_KEY_BY_CASE[row.caseId];
+ const name = row.entry?.name ?? "";
+ // Interactive controls (the Remove button) render as
+ // siblings of the FormControlLabel, never inside its
+ //
+ )}
+
+
+
{T.translate("dropbox_sync.rebuild_title")}
@@ -229,5 +655,6 @@ export default connect(mapStateToProps, {
copyLocations,
getSyncConfig,
updateSyncConfig,
- rebuildSync
+ rebuildSync,
+ getAllMediaUploadTypesForAllowlist
})(LocationListPage);
diff --git a/src/reducers/__tests__/dropbox-sync-reducer.test.js b/src/reducers/__tests__/dropbox-sync-reducer.test.js
index cbffa305c..8e6db0990 100644
--- a/src/reducers/__tests__/dropbox-sync-reducer.test.js
+++ b/src/reducers/__tests__/dropbox-sync-reducer.test.js
@@ -4,15 +4,20 @@ import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
import {
REQUEST_SYNC_CONFIG,
RECEIVE_SYNC_CONFIG,
- SYNC_CONFIG_UPDATED
+ SYNC_CONFIG_UPDATED,
+ REQUEST_ALLOWLIST_OPTIONS,
+ RECEIVE_ALLOWLIST_OPTIONS,
+ ALLOWLIST_OPTIONS_ERROR
} from "../../actions/dropbox-sync-actions";
const DEFAULT_STATE = {
syncConfig: {
summit_id: null,
dropbox_sync_enabled: false,
- preflight_alert_email: null
+ preflight_alert_email: null,
+ materialized_media_upload_types: []
},
+ allowlistOptions: { options: [], error: null },
loading: false
};
@@ -102,4 +107,103 @@ describe("dropboxSyncReducer", () => {
expect(state).toEqual(DEFAULT_STATE);
});
+
+ // regression pins — must pass before AND after implementation
+ test("RECEIVE_SYNC_CONFIG populates materialized_media_upload_types when present in response", () => {
+ const payload = {
+ response: {
+ summit_id: 1,
+ dropbox_sync_enabled: true,
+ preflight_alert_email: "test@example.com",
+ materialized_media_upload_types: [
+ { id: 1, name: "Video", private_storage_type: "dropbox" }
+ ]
+ }
+ };
+
+ const state = dropboxSyncReducer(DEFAULT_STATE, {
+ type: RECEIVE_SYNC_CONFIG,
+ payload
+ });
+
+ expect(state.syncConfig.materialized_media_upload_types).toEqual([
+ { id: 1, name: "Video", private_storage_type: "dropbox" }
+ ]);
+ });
+
+ test("SYNC_CONFIG_UPDATED populates materialized_media_upload_types when present in response", () => {
+ const payload = {
+ response: {
+ summit_id: 1,
+ dropbox_sync_enabled: false,
+ preflight_alert_email: "test@example.com",
+ materialized_media_upload_types: [
+ { id: 2, name: "Image", private_storage_type: "local" }
+ ]
+ }
+ };
+
+ const state = dropboxSyncReducer(DEFAULT_STATE, {
+ type: SYNC_CONFIG_UPDATED,
+ payload
+ });
+
+ expect(state.syncConfig.materialized_media_upload_types).toEqual([
+ { id: 2, name: "Image", private_storage_type: "local" }
+ ]);
+ });
+
+ // allowlist action tests
+ test("REQUEST_ALLOWLIST_OPTIONS resets allowlistOptions and leaves syncConfig untouched", () => {
+ const prevState = {
+ ...DEFAULT_STATE,
+ syncConfig: { ...DEFAULT_STATE.syncConfig, dropbox_sync_enabled: true },
+ allowlistOptions: {
+ options: [{ id: 1, name: "Video", private_storage_type: "dropbox" }],
+ error: null
+ }
+ };
+
+ const state = dropboxSyncReducer(prevState, {
+ type: REQUEST_ALLOWLIST_OPTIONS,
+ payload: {}
+ });
+
+ expect(state.allowlistOptions).toEqual({ options: [], error: null });
+ expect(state.syncConfig.dropbox_sync_enabled).toBe(true);
+ });
+
+ test("RECEIVE_ALLOWLIST_OPTIONS sets options array and clears error", () => {
+ const options = [
+ { id: 1, name: "Video", private_storage_type: "dropbox" },
+ { id: 2, name: "Image", private_storage_type: "local" }
+ ];
+
+ const state = dropboxSyncReducer(DEFAULT_STATE, {
+ type: RECEIVE_ALLOWLIST_OPTIONS,
+ payload: options
+ });
+
+ expect(state.allowlistOptions).toEqual({ options, error: null });
+ });
+
+ test("ALLOWLIST_OPTIONS_ERROR clears options and sets error message", () => {
+ const prevState = {
+ ...DEFAULT_STATE,
+ allowlistOptions: {
+ options: [{ id: 1, name: "Video", private_storage_type: "dropbox" }],
+ error: null
+ }
+ };
+
+ const state = dropboxSyncReducer(prevState, {
+ type: ALLOWLIST_OPTIONS_ERROR,
+ payload: "fetch failed"
+ });
+
+ expect(state.allowlistOptions).toEqual({
+ options: [],
+ error: "fetch failed"
+ });
+ });
});
diff --git a/src/reducers/locations/dropbox-sync-reducer.js b/src/reducers/locations/dropbox-sync-reducer.js
index ca670af43..39c5f2abe 100644
--- a/src/reducers/locations/dropbox-sync-reducer.js
+++ b/src/reducers/locations/dropbox-sync-reducer.js
@@ -16,15 +16,20 @@ import { SET_CURRENT_SUMMIT } from "../../actions/summit-actions";
import {
REQUEST_SYNC_CONFIG,
RECEIVE_SYNC_CONFIG,
- SYNC_CONFIG_UPDATED
+ SYNC_CONFIG_UPDATED,
+ REQUEST_ALLOWLIST_OPTIONS,
+ RECEIVE_ALLOWLIST_OPTIONS,
+ ALLOWLIST_OPTIONS_ERROR
} from "../../actions/dropbox-sync-actions";
const DEFAULT_STATE = {
syncConfig: {
summit_id: null,
dropbox_sync_enabled: false,
- preflight_alert_email: null
+ preflight_alert_email: null,
+ materialized_media_upload_types: []
},
+ allowlistOptions: { options: [], error: null },
loading: false
};
@@ -46,6 +51,12 @@ const dropboxSyncReducer = (state = DEFAULT_STATE, action) => {
syncConfig: payload.response ?? DEFAULT_STATE.syncConfig,
loading: false
};
+ case REQUEST_ALLOWLIST_OPTIONS:
+ return { ...state, allowlistOptions: { options: [], error: null } };
+ case RECEIVE_ALLOWLIST_OPTIONS:
+ return { ...state, allowlistOptions: { options: payload, error: null } };
+ case ALLOWLIST_OPTIONS_ERROR:
+ return { ...state, allowlistOptions: { options: [], error: payload } };
case SET_CURRENT_SUMMIT:
case LOGOUT_USER:
return { ...DEFAULT_STATE };