diff --git a/.env.example b/.env.example index d1a92df06..adbe8e81f 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ EMAIL_SCOPES="clients/read templates/read templates/write emails/read" FILE_UPLOAD_SCOPES="files/upload" SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com SPONSOR_PAGES_SCOPES="page-template/read page-template/write show-page/read show-page/write media-upload/read" -SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" +SCOPES="profile openid offline_access reports/all ${EMAIL_SCOPES} ${INVENTORY_API_SCOPES} ${FILE_UPLOAD_SCOPES} ${PURCHASES_API_SCOPES} ${SPONSOR_USERS_SCOPES} ${SPONSOR_PAGES_SCOPES} ${DROPBOX_MATERIALIZER_API_SCOPES} ${SCOPES_BASE_REALM}/summits/delete-event ${SCOPES_BASE_REALM}/companies/read ${SCOPES_BASE_REALM}/companies/write ${SCOPES_BASE_REALM}/summits/write ${SCOPES_BASE_REALM}/summits/write-event ${SCOPES_BASE_REALM}/summits/read/all ${SCOPES_BASE_REALM}/summits/read ${SCOPES_BASE_REALM}/summits/publish-event ${SCOPES_BASE_REALM}/members/read ${SCOPES_BASE_REALM}/members/read/me ${SCOPES_BASE_REALM}/speakers/write ${SCOPES_BASE_REALM}/attendees/write ${SCOPES_BASE_REALM}/members/write ${SCOPES_BASE_REALM}/organizations/write ${SCOPES_BASE_REALM}/organizations/read ${SCOPES_BASE_REALM}/summits/write-presentation-materials ${SCOPES_BASE_REALM}/summits/registration-orders/update ${SCOPES_BASE_REALM}/summits/registration-orders/delete ${SCOPES_BASE_REALM}/summits/registration-orders/create/offline ${SCOPES_BASE_REALM}/summits/badge-scans/read config-values/write ${SCOPES_BASE_REALM}/summit-administrator-groups/read ${SCOPES_BASE_REALM}/summit-administrator-groups/write ${SCOPES_BASE_REALM}/summit-media-file-types/read ${SCOPES_BASE_REALM}/summit-media-file-types/write user-roles/write entity-updates/publish ${SCOPES_BASE_REALM}/audit-logs/read filter-criteria/read filter-criteria/write" GOOGLE_API_KEY= ALLOWED_USER_GROUPS="super-admins administrators summit-front-end-administrators summit-room-administrators track-chairs-admins sponsors" APP_CLIENT_NAME="openstack" @@ -37,5 +37,7 @@ SENTRY_PROJECT= SENTRY_TRACE_SAMPLE_RATE= SENTRY_TRACE_PROPAGATION_TARGETS= CFP_APP_BASE_URL= +DROPBOX_MATERIALIZER_API_BASE_URL= +DROPBOX_MATERIALIZER_API_SCOPES="dropbox-materializer/read dropbox-materializer/write" S3_MEDIA_UPLOADS_ENDPOINT_URL=https://fntech.sfo2.digitaloceanspaces.com S3_MEDIA_UPLOADS_BUCKET_NAME=PresentationMediaUploads_DEV \ No newline at end of file diff --git a/src/actions/__tests__/dropbox-sync-actions.test.js b/src/actions/__tests__/dropbox-sync-actions.test.js new file mode 100644 index 000000000..1bf4c5718 --- /dev/null +++ b/src/actions/__tests__/dropbox-sync-actions.test.js @@ -0,0 +1,197 @@ +/** + * @jest-environment jsdom + */ + +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + getRequest, + putRequest, + postRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import * as DropboxSyncActions from "../dropbox-sync-actions"; +import * as methods from "../../utils/methods"; + +jest.mock("openstack-uicore-foundation/lib/utils/actions", () => ({ + __esModule: true, + ...jest.requireActual("openstack-uicore-foundation/lib/utils/actions"), + getRequest: jest.fn(), + putRequest: jest.fn(), + postRequest: jest.fn() +})); + +const mockRequestImpl = + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (requestActionCreator && typeof requestActionCreator === "function") + dispatch(requestActionCreator({})); + + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: {} })); + resolve({ response: {} }); + return; + } + dispatch(receiveActionCreator); + resolve({ response: {} }); + }); + }; + +const mockRequestImplReject = () => () => () => + Promise.reject(new Error("network error")); + +describe("dropbox sync actions", () => { + 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.DROPBOX_MATERIALIZER_API_BASE_URL = "https://test-api.example.com"; + + getRequest.mockImplementation(mockRequestImpl); + putRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + mockRequestImpl(requestActionCreator, receiveActionCreator) + ); + postRequest.mockImplementation( + (requestActionCreator, receiveActionCreator) => + mockRequestImpl(requestActionCreator, receiveActionCreator) + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + delete window.DROPBOX_MATERIALIZER_API_BASE_URL; + }); + + test("getSyncConfig dispatches REQUEST, RECEIVE, then STOP_LOADING", async () => { + store.dispatch(DropboxSyncActions.getSyncConfig()); + await flushPromises(); + + const actions = store.getActions(); + expect(actions).toEqual([ + { payload: {}, type: "REQUEST_SYNC_CONFIG" }, + { payload: { response: {} }, type: "RECEIVE_SYNC_CONFIG" }, + { payload: undefined, type: "STOP_LOADING" } + ]); + expect(getRequest).toBeCalledTimes(1); + }); + + test("getSyncConfig early returns when getBaseUrl() is falsy", async () => { + window.DROPBOX_MATERIALIZER_API_BASE_URL = ""; + + store.dispatch(DropboxSyncActions.getSyncConfig()); + await flushPromises(); + + expect(store.getActions()).toEqual([]); + expect(getRequest).not.toBeCalled(); + expect(methods.getAccessTokenSafely).not.toBeCalled(); + }); + + test("getSyncConfig early returns when summitId is missing", async () => { + store = configureStore([thunk])({ + currentSummitState: { currentSummit: {} } + }); + + store.dispatch(DropboxSyncActions.getSyncConfig()); + await flushPromises(); + + expect(store.getActions()).toEqual([]); + expect(getRequest).not.toBeCalled(); + expect(methods.getAccessTokenSafely).not.toBeCalled(); + }); + + test("updateSyncConfig dispatches START_LOADING, SYNC_CONFIG_UPDATED, STOP_LOADING", async () => { + store.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ + payload: { response: {} }, + type: "SYNC_CONFIG_UPDATED" + }); + expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(putRequest).toBeCalledTimes(1); + }); + + test("rebuildSync dispatches START_LOADING, REBUILD_SYNC_DISPATCHED, STOP_LOADING", async () => { + store.dispatch(DropboxSyncActions.rebuildSync()); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ + payload: { response: {} }, + type: "REBUILD_SYNC_DISPATCHED" + }); + expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(postRequest).toBeCalledTimes(1); + }); + + test("resyncRoom dispatches START_LOADING, RESYNC_ROOM_DISPATCHED, STOP_LOADING", async () => { + store.dispatch(DropboxSyncActions.resyncRoom("Main Venue", "Room A")); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ + payload: { response: {} }, + type: "RESYNC_ROOM_DISPATCHED" + }); + expect(actions[2]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + expect(postRequest).toBeCalled(); + }); + + test("getSyncConfig dispatches RECEIVE_SYNC_CONFIG with empty payload on failure", async () => { + getRequest.mockImplementation(mockRequestImplReject); + + store.dispatch(DropboxSyncActions.getSyncConfig()); + await flushPromises(); + + const actions = store.getActions(); + expect(actions).toEqual([{ payload: {}, type: "RECEIVE_SYNC_CONFIG" }]); + }); + + test("updateSyncConfig dispatches STOP_LOADING on failure", async () => { + putRequest.mockImplementation(() => mockRequestImplReject()); + + store.dispatch( + DropboxSyncActions.updateSyncConfig({ dropbox_sync_enabled: true }) + ); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + }); + + test("rebuildSync dispatches STOP_LOADING on failure", async () => { + postRequest.mockImplementation(() => mockRequestImplReject()); + + store.dispatch(DropboxSyncActions.rebuildSync()); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + }); + + test("resyncRoom dispatches STOP_LOADING on failure", async () => { + postRequest.mockImplementation(() => mockRequestImplReject()); + + store.dispatch(DropboxSyncActions.resyncRoom("Main Venue", "Room A")); + await flushPromises(); + + const actions = store.getActions(); + expect(actions[0]).toEqual({ payload: undefined, type: "START_LOADING" }); + expect(actions[1]).toEqual({ payload: undefined, type: "STOP_LOADING" }); + }); +}); diff --git a/src/actions/dropbox-sync-actions.js b/src/actions/dropbox-sync-actions.js new file mode 100644 index 000000000..a16339563 --- /dev/null +++ b/src/actions/dropbox-sync-actions.js @@ -0,0 +1,160 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import T from "i18n-react/dist/i18n-react"; +import { + getRequest, + putRequest, + postRequest, + createAction, + stopLoading, + startLoading, + showSuccessMessage, + authErrorHandler +} from "openstack-uicore-foundation/lib/utils/actions"; +import { getAccessTokenSafely } from "../utils/methods"; + +export const REQUEST_SYNC_CONFIG = "REQUEST_SYNC_CONFIG"; +export const RECEIVE_SYNC_CONFIG = "RECEIVE_SYNC_CONFIG"; +export const SYNC_CONFIG_UPDATED = "SYNC_CONFIG_UPDATED"; +export const REBUILD_SYNC_DISPATCHED = "REBUILD_SYNC_DISPATCHED"; +export const RESYNC_ROOM_DISPATCHED = "RESYNC_ROOM_DISPATCHED"; + +const getBaseUrl = () => window.DROPBOX_MATERIALIZER_API_BASE_URL; + +export const getSyncConfig = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const baseUrl = getBaseUrl(); + const summitId = currentSummitState?.currentSummit?.id; + + if (!baseUrl || !summitId) return; + + const accessToken = await getAccessTokenSafely(); + + const params = { + access_token: accessToken + }; + + return getRequest( + createAction(REQUEST_SYNC_CONFIG), + createAction(RECEIVE_SYNC_CONFIG), + `${baseUrl}/api/v1/sync/config/${summitId}/`, + authErrorHandler + )(params)(dispatch) + .then(() => { + dispatch(stopLoading()); + }) + .catch(() => { + dispatch(createAction(RECEIVE_SYNC_CONFIG)({})); + }); +}; + +export const updateSyncConfig = (data) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const baseUrl = getBaseUrl(); + const summitId = currentSummitState?.currentSummit?.id; + + if (!baseUrl || !summitId) return; + + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + return putRequest( + null, + createAction(SYNC_CONFIG_UPDATED), + `${baseUrl}/api/v1/sync/config/${summitId}/`, + data, + authErrorHandler + )(params)(dispatch) + .then(() => { + dispatch(stopLoading()); + dispatch(showSuccessMessage(T.translate("dropbox_sync.config_saved"))); + }) + .catch(() => { + dispatch(stopLoading()); + }); +}; + +export const rebuildSync = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const baseUrl = getBaseUrl(); + const summitId = currentSummitState?.currentSummit?.id; + + if (!baseUrl || !summitId) return; + + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + return postRequest( + null, + createAction(REBUILD_SYNC_DISPATCHED), + `${baseUrl}/api/v1/sync/rebuild/${summitId}/`, + null, + authErrorHandler + )(params)(dispatch) + .then(() => { + dispatch(stopLoading()); + dispatch( + showSuccessMessage(T.translate("dropbox_sync.rebuild_dispatched")) + ); + }) + .catch(() => { + dispatch(stopLoading()); + }); +}; + +export const resyncRoom = + (venueName, roomName) => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const baseUrl = getBaseUrl(); + const summitId = currentSummitState?.currentSummit?.id; + + if (!baseUrl || !summitId) return; + + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + return postRequest( + null, + createAction(RESYNC_ROOM_DISPATCHED), + `${baseUrl}/api/v1/sync/materialize/${summitId}/${encodeURIComponent( + venueName + )}/${encodeURIComponent(roomName)}/`, + null, + authErrorHandler + )(params)(dispatch) + .then(() => { + dispatch(stopLoading()); + dispatch( + showSuccessMessage(T.translate("dropbox_sync.resync_dispatched")) + ); + }) + .catch(() => { + dispatch(stopLoading()); + }); + }; diff --git a/src/app.js b/src/app.js index cb99477c3..90bb88fc1 100644 --- a/src/app.js +++ b/src/app.js @@ -100,6 +100,8 @@ window.SENTRY_TRACE_SAMPLE_RATE = process.env.SENTRY_TRACE_SAMPLE_RATE; window.SENTRY_TRACE_PROPAGATION_TARGETS = process.env.SENTRY_TRACE_PROPAGATION_TARGETS; window.CFP_APP_BASE_URL = process.env.CFP_APP_BASE_URL; +window.DROPBOX_MATERIALIZER_API_BASE_URL = + process.env.DROPBOX_MATERIALIZER_API_BASE_URL; if (exclusiveSections.hasOwnProperty(process.env.APP_CLIENT_NAME)) { window.EXCLUSIVE_SECTIONS = exclusiveSections[process.env.APP_CLIENT_NAME]; diff --git a/src/components/forms/location-form.js b/src/components/forms/location-form.js index 73402dd46..ce2b45eb8 100644 --- a/src/components/forms/location-form.js +++ b/src/components/forms/location-form.js @@ -52,6 +52,7 @@ class LocationForm extends React.Component { this.handleMapUpdate = this.handleMapUpdate.bind(this); this.handleMapClick = this.handleMapClick.bind(this); this.handleClearHours = this.handleClearHours.bind(this); + this.handleRoomResync = this.handleRoomResync.bind(this); } componentDidUpdate(prevProps) { @@ -212,6 +213,15 @@ class LocationForm extends React.Component { this.props.onMarkerDragged(entity); } + handleRoomResync(roomId) { + const { entity } = this.state; + const persistedVenueName = this.props.entity.name; + const room = entity.rooms.find((r) => r.id === roomId); + if (room && this.props.onRoomResync) { + this.props.onRoomResync(persistedVenueName, room.name); + } + } + render() { const { entity, showSection } = this.state; const { currentSummit, allClasses } = this.props; @@ -264,7 +274,17 @@ class LocationForm extends React.Component { const room_options = { actions: { edit: { onClick: this.handleRoomEdit }, - delete: { onClick: this.props.onRoomDelete } + delete: { onClick: this.props.onRoomDelete }, + custom: this.props.syncEnabled + ? [ + { + name: "resync_dropbox", + tooltip: T.translate("dropbox_sync.resync_tooltip"), + icon: , + onClick: this.handleRoomResync + } + ] + : [] } }; @@ -637,6 +657,12 @@ class LocationForm extends React.Component { title={T.translate("edit_location.rooms")} handleClick={this.toggleSection.bind(this, "rooms")} > + {this.props.syncEnabled && ( +
+ {" "} + {T.translate("dropbox_sync.resync_helper")} +
+ )} + + + + + )} +