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 };