diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..a55d90b78 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,88 @@ +name: e2e + +# Runs E2E tests on every push and pull request. +# +# Auth is synthetic (no real IDP call) and all API calls are intercepted by +# page.route() mocks, so no GitHub secrets or repository variables are needed. +# The app is built and served at localhost:8080 - PR code is always tested. + +on: [push, pull_request] + +jobs: + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v3 + with: + node-version: 22 + + - name: Install dependencies + run: yarn install + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Create .env for webpack build + run: | + cat > .env << 'DOTENV' + OAUTH2_CLIENT_ID=e2e-test-client + IDP_BASE_URL=https://idp.dev.fnopen.com + API_BASE_URL=https://api.fnvirtual.app + REPORT_API_BASE_URL=https://summit-reports-api.dev.fnopen.com + MARKETING_API_BASE_URL=https://marketing-api.dev.fnopen.com + EMAIL_API_BASE_URL=https://mailing-api.dev.fnopen.com + PRINT_APP_URL=https://badge-print-app.dev.fnopen.com + FILE_UPLOAD_API_BASE_URL=https://file-upload-api.dev.fnopen.com + INVENTORY_API_BASE_URL=https://inventory-api.dev.fnopen.com + CHAT_API_BASE_URL=https://chat-api.dev.fnopen.com + PUB_API_BASE_URL=https://pub-api.dev.fnopen.com + SIGNAGE_BASE_URL=https://signage.dev.fnopen.com + PERSIST_FILTER_CRITERIA_API=https://filter-criteria-api.dev.fnopen.com + CFP_APP_BASE_URL=https://speakermgmt.dev.fnopen.com + PURCHASES_API_URL=https://purchases-api.dev.fnopen.com + SPONSOR_USERS_API_URL=https://sponsor-users-api.dev.fnopen.com + SPONSOR_PAGES_API_URL=https://sponsor-pages-api.dev.fnopen.com + AUDIT_LOG_API_BASE_URL=https://audit-logs-api.dev.fnopen.com + S3_MEDIA_UPLOADS_ENDPOINT_URL=https://fntech.sfo2.digitaloceanspaces.com + S3_MEDIA_UPLOADS_BUCKET_NAME=PresentationMediaUploads + ALLOWED_USER_GROUPS="super-admins administrators summit-front-end-administrators summit-room-administrators summit-registration-administrators track-chairs-admins sponsors" + APP_CLIENT_NAME="fntech" + OAUTH2_FLOW=code + SCOPES=profile openid offline_access + DOTENV + + - name: Start dev server + run: npx webpack-dev-server --port 8080 --config webpack.dev.js > /tmp/webpack.log 2>&1 & + + - name: Wait for dev server + run: | + echo "Waiting for dev server (up to 4 min)..." + for i in $(seq 1 80); do + if curl -sk --max-time 3 https://localhost:8080/ -o /dev/null 2>&1; then + echo "Server ready after $((i * 3))s" + exit 0 + fi + sleep 3 + done + echo "Dev server did not start in time" + cat /tmp/webpack.log + exit 1 + + - name: Run E2E tests + run: yarn test:e2e + + - name: Upload Playwright traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + if-no-files-found: ignore + + - name: Show dev server logs on failure + if: failure() + run: cat /tmp/webpack.log || true diff --git a/.gitignore b/.gitignore index 2e0ab863c..00f3b4354 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ package-lock.json .claude .codegraph .worktrees -docs/ \ No newline at end of file +docs/ +# Playwright auth state +e2e/.auth/ diff --git a/e2e/auth.setup.js b/e2e/auth.setup.js new file mode 100644 index 000000000..a41330aad --- /dev/null +++ b/e2e/auth.setup.js @@ -0,0 +1,147 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const { test: setup } = require("@playwright/test"); +const path = require("path"); +const fs = require("fs"); + +const AUTH_FILE = path.join(__dirname, ".auth/user.json"); +const MIN_AUTH_FILE_BYTES = 10; +const MS_PER_SECOND = 1000; +const JSON_INDENT = 2; +const TOKEN_LIFETIME_SECONDS = 3600; +const SKEW_SECONDS = 60; + +fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + +// Build a syntactically valid JWT with no real signature. +// The app calls IdTokenVerifier.decode() (not .verify()), so the signature +// is never checked against a real key. +function buildFakeJwt(payload) { + const header = Buffer.from( + JSON.stringify({ alg: "none", typ: "JWT" }) + ).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.fakesig`; +} + +// Write a Playwright storage-state JSON that puts the app in a fully +// authenticated session without touching any real IDP endpoint. +// +// All API calls in the tests are intercepted via page.route(), so the +// access token never reaches a real server and its value does not matter. +// +// No GitHub secrets or repository variables are required. +function writesyntheticAuth() { + const now = Math.floor(Date.now() / MS_PER_SECOND); + + const groups = [ + { + id: 1, + title: "super admins", + description: "super admins", + code: "super-admins" + }, + { id: 2, title: "sponsors", description: "sponsors", code: "sponsors" } + ]; + + const member = { + id: 1, + first_name: "CI", + last_name: "User", + email: "ci@e2e.test", + groups + }; + + const idTokenPayload = { + sub: "1", + email: member.email, + picture: "", + groups: groups.map((g) => ({ id: g.id, name: g.title, slug: g.code })), + exp: now + TOKEN_LIFETIME_SECONDS, + iat: now + }; + + const fakeAccessToken = buildFakeJwt({ + sub: "1", + exp: now + TOKEN_LIFETIME_SECONDS, + iat: now + }); + const fakeIdToken = buildFakeJwt(idTokenPayload); + + const authInfo = JSON.stringify({ + accessToken: fakeAccessToken, + expiresIn: TOKEN_LIFETIME_SECONDS, + // Set 60 s in the past so getAccessTokenSafely() considers it fresh + // and never tries to call the IDP to refresh. + accessTokenUpdatedAt: now - SKEW_SECONDS, + refreshToken: fakeAccessToken, + idToken: fakeIdToken + }); + + // redux-persist reads "persist:root" to rehydrate the Redux store. + // isLoggedUser must be true (AuthorizedRoute gate) and member must have + // groups that overlap ALLOWED_USER_GROUPS (Restrict HOC gate). + const persistRoot = JSON.stringify({ + loggedUserState: JSON.stringify({ + isLoggedUser: true, + member, + sessionState: null, + backUrl: null, + sessionStateStatus: "unchanged" + }), + _persist: JSON.stringify({ version: -1, rehydrated: true }) + }); + + const baseURL = process.env.PLAYWRIGHT_BASE_URL || "https://localhost:8080"; + const { origin, hostname } = new URL(baseURL); + + const storageState = { + cookies: [ + { + name: "idToken", + value: fakeIdToken, + domain: hostname, + path: "/", + expires: -1, + httpOnly: false, + secure: true, + sameSite: "Lax" + } + ], + origins: [ + { + origin, + localStorage: [ + { name: "authInfo", value: authInfo }, + { name: "persist:root", value: persistRoot } + ] + } + ] + }; + + fs.writeFileSync(AUTH_FILE, JSON.stringify(storageState, null, JSON_INDENT)); + process.stdout.write( + "[auth.setup] wrote synthetic auth state (no IDP call)\n" + ); +} + +setup("authenticate", async ({ page }) => { + // Always write synthetic auth. All API calls are mocked via page.route() + // so a real access token is never needed. + writesyntheticAuth(); + + // In local dev, if you need an interactive session instead (e.g. to run + // tests against a staging URL), delete e2e/.auth/user.json and set + // PLAYWRIGHT_BASE_URL to the staging URL. The file will be missing so + // the interactive path below fires. + if ( + fs.existsSync(AUTH_FILE) && + fs.statSync(AUTH_FILE).size > MIN_AUTH_FILE_BYTES + ) { + return; + } + + // Fallback: interactive OAuth for local dev when the auth file is missing. + await page.goto("/"); + await page.waitForURL(/\/app\//, { timeout: 120_000 }); + await page.context().storageState({ path: AUTH_FILE }); +}); diff --git a/e2e/import-allowed-members.spec.js b/e2e/import-allowed-members.spec.js new file mode 100644 index 000000000..dd741e3b6 --- /dev/null +++ b/e2e/import-allowed-members.spec.js @@ -0,0 +1,242 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const { test, expect } = require("@playwright/test"); + +const SELECTION_PLANS_URL = "/app/summits/13/selection-plans"; +const DATA_TIMEOUT = 30_000; +const DIALOG_COUNT_BOTH = 2; +const DIALOG_COUNT_ONE = 1; + +// Minimal summit fixture. Fields must satisfy the SummitIdLayout condition: +// !currentSummit.id || summitId !== currentSummit.id || loading || !hasLoaded +// i.e. id:13 must be present and loading must become false after RECEIVE_SUMMIT. +const SUMMIT_FIXTURE = { + id: 13, + name: "E2E Test Summit", + status: "Published", + active: false, + start_date: 1700000000, + end_date: 1700100000, + time_zone_id: "America/Chicago", + time_zone: {}, + event_types: [], + tracks: [], + track_groups: [], + locations: [], + meeting_booking_room_allowed_attributes: [], + presentation_action_types: [], + selection_plans: [], + ticket_types: [], + badge_types: [], + badge_features: [], + badge_features_types: [], + badge_access_level_types: [], + badge_view_types: [], + tax_types: [], + mux_allowed_domains: [], + supported_currencies: ["USD"] +}; + +// One selection plan so the table renders a row with an Edit button. +const SELECTION_PLANS_FIXTURE = { + data: [ + { + id: 1, + name: "Main Selection Plan", + type: "presentations", + is_enabled: true, + is_hidden: false, + allow_new_presentations: true, + submission_begin_date: null, + submission_end_date: null, + voting_begin_date: null, + voting_end_date: null, + selection_begin_date: null, + selection_end_date: null + } + ], + total: 1, + per_page: 10, + current_page: 1, + last_page: 1 +}; + +// Single plan response returned when the edit modal fetches plan details. +const SELECTION_PLAN_DETAIL_FIXTURE = { + id: 1, + name: "Main Selection Plan", + type: "presentations", + is_enabled: true, + is_hidden: false, + allow_new_presentations: true, + submission_begin_date: null, + submission_end_date: null, + voting_begin_date: null, + voting_end_date: null, + selection_begin_date: null, + selection_end_date: null, + summit_id: 13, + track_groups: [], + extra_questions: [], + event_types: [], + track_chair_rating_types: [] +}; + +const EMPTY_PAGINATED = { + data: [], + total: 0, + per_page: 100, + current_page: 1, + last_page: 1 +}; + +// Use URL predicate functions instead of glob patterns to avoid glob-matching +// ambiguity. Playwright evaluates routes LIFO - register the catch-all first +// so specific mocks (registered after) take priority. +async function setupApiMocks(page) { + // Catch-all: abort any unmatched request to the main API host so tests + // fail fast instead of hanging for 60 s on a superagent timeout. + // Only blocks api.fnvirtual.app - IDP and other services pass through. + await page.route( + (url) => url.hostname === "api.fnvirtual.app", + async (route) => route.abort("failed") + ); + + // Public timezones - called by App.componentDidMount + await page.route( + (url) => url.pathname.includes("/api/public/v1/timezones"), + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]) + }) + ); + + // getSummitById(13) - required before SummitIdLayout renders children. + // Only matches the root summit endpoint (no trailing slash after the ID) + // so sub-resource mocks registered later (and checked first in LIFO order) + // are not shadowed by this broader predicate. + await page.route( + (url) => + url.pathname === "/api/v2/summits/13" || + url.pathname === "/api/v1/summits/13", + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SUMMIT_FIXTURE) + }) + ); + + // getSelectionPlans() list - provides table rows (NOT the single-plan endpoint) + await page.route( + (url) => + url.pathname.includes("/api/v1/summits/13/selection-plans") && + !url.pathname.match(/\/selection-plans\/\d+/), + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SELECTION_PLANS_FIXTURE) + }) + ); + + // getSelectionPlan(1) - fetched when the edit modal opens + await page.route( + (url) => + url.pathname.match(/\/api\/v1\/summits\/13\/selection-plans\/1($|\?)/), + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(SELECTION_PLAN_DETAIL_FIXTURE) + }) + ); + + // Marketing settings - called by getMarketingSettingsForPrintApp / RegLite + await page.route( + (url) => + url.hostname.includes("marketing") || url.pathname.includes("/marketing"), + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(EMPTY_PAGINATED) + }) + ); +} + +test.describe("Import Allowed Members popup", () => { + test.beforeEach(async ({ page }) => { + await setupApiMocks(page); + await page.goto(SELECTION_PLANS_URL); + // Wait for the first Edit button - proves summit loaded AND plans loaded. + await page + .getByRole("button", { name: /edit/i }) + .first() + .waitFor({ timeout: DATA_TIMEOUT }); + }); + + test("opens above the Edit Selection Plan dialog with correct z-index", async ({ + page + }) => { + const editButtons = page.getByRole("button", { name: /edit/i }); + await editButtons.first().click(); + + // The Edit Selection Plan MUI dialog should be visible. + const editDialog = page.locator("[role=\"dialog\"]").first(); + await expect(editDialog).toBeVisible(); + + // Navigate to the Allowed Members tab. + await page.getByRole("tab", { name: "Allowed Members" }).click(); + + // Click the Import button. + await page.getByRole("button", { name: "Import" }).click(); + + // Both MUI dialogs should now be mounted. + const dialogs = page.locator("[role=\"dialog\"]"); + await expect(dialogs).toHaveCount(DIALOG_COUNT_BOTH); + + // The Import dialog title must be visible (proves it is on top). + await expect( + page.getByRole("heading", { name: /import allowed members/i }) + ).toBeVisible(); + + // The Ingest button must be visible and disabled until a file is selected. + const ingestBtn = page.getByRole("button", { name: /ingest/i }); + await expect(ingestBtn).toBeVisible(); + await expect(ingestBtn).toBeDisabled(); + + // Verify z-index: the import dialog's backdrop must stack above the edit dialog's. + const zIndexes = await page.evaluate(() => { + const backdrops = Array.from( + document.querySelectorAll(".MuiBackdrop-root") + ); + return backdrops.map((el) => + parseInt(window.getComputedStyle(el).zIndex, 10) + ); + }); + expect(zIndexes.length).toBe(DIALOG_COUNT_BOTH); + expect(zIndexes[1]).toBeGreaterThan(zIndexes[0]); + + // Close the Import popup via the X button. + await page.getByRole("button", { name: "Close" }).last().click(); + + // Import dialog gone, edit dialog still open. + await expect(dialogs).toHaveCount(DIALOG_COUNT_ONE); + await expect(editDialog).toBeVisible(); + }); + + test("does not render import dialog before Import is clicked", async ({ + page + }) => { + const editButtons = page.getByRole("button", { name: /edit/i }); + await editButtons.first().click(); + + await expect(page.locator("[role=\"dialog\"]").first()).toBeVisible(); + await page.getByRole("tab", { name: "Allowed Members" }).click(); + + // Only the edit dialog should exist - no import dialog yet. + await expect(page.locator("[role=\"dialog\"]")).toHaveCount(DIALOG_COUNT_ONE); + }); +}); diff --git a/package.json b/package.json index 1058855e5..6f2bfcde1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "test": "yarn jest", "prepare": "husky", "lint": "eslint src", - "precommit": "yarn lint-staged --verbose" + "precommit": "yarn lint-staged --verbose", + "test:e2e": "playwright test", + "test:e2e:auth": "playwright test --project=setup", + "test:e2e:ui": "playwright test --ui" }, "resolutions": { "segmented-control": "0.1.12" @@ -146,6 +149,7 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.24.7", + "@playwright/test": "^1.61.1", "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "12.1.5", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..e28b0e1a4 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,33 @@ +/* eslint-disable import/no-extraneous-dependencies */ +// @ts-check +const { defineConfig, devices } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./e2e", + fullyParallel: false, + retries: 0, + timeout: 60_000, + reporter: "list", + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "https://localhost:8080", + ignoreHTTPSErrors: true, + trace: "on", + screenshot: "only-on-failure", + video: "off" + }, + projects: [ + { + name: "setup", + testMatch: "**/auth.setup.js" + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + ignoreHTTPSErrors: true, + storageState: "e2e/.auth/user.json" + }, + dependencies: ["setup"] + } + ] +}); diff --git a/src/actions/__tests__/selection-plan-actions.test.js b/src/actions/__tests__/selection-plan-actions.test.js new file mode 100644 index 000000000..a67e63e66 --- /dev/null +++ b/src/actions/__tests__/selection-plan-actions.test.js @@ -0,0 +1,107 @@ +/** + * @jest-environment jsdom + */ +import configureStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import flushPromises from "flush-promises"; +import { + postRequest, + putRequest +} from "openstack-uicore-foundation/lib/utils/actions"; +import { saveSelectionPlan } from "../selection-plan-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"), + postRequest: jest.fn(), + putRequest: jest.fn() +})); + +jest.mock("../marketing-actions", () => ({ + saveMarketingSetting: jest.fn() +})); + +const requestMock = + (requestActionCreator, receiveActionCreator) => () => (dispatch) => { + if (requestActionCreator && typeof requestActionCreator === "function") { + dispatch(requestActionCreator({})); + } + return new Promise((resolve) => { + if (typeof receiveActionCreator === "function") { + dispatch(receiveActionCreator({ response: { id: 1 } })); + } else { + dispatch(receiveActionCreator); + } + resolve({ response: { id: 1 } }); + }); + }; + +const storeState = { + currentSummitState: { currentSummit: { id: 1 } } +}; + +describe("saveSelectionPlan", () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + + beforeEach(() => { + jest.spyOn(methods, "getAccessTokenSafely").mockResolvedValue("TOKEN"); + postRequest.mockImplementation(requestMock); + putRequest.mockImplementation(requestMock); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("create path (entity has no id)", () => { + it("returns a Promise that resolves with the response payload", async () => { + const store = mockStore(storeState); + const result = store.dispatch( + saveSelectionPlan({ name: "CFP 2026", is_enabled: true }) + ); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ id: 1 }); + }); + + it("dispatches SELECTION_PLAN_ADDED then STOP_LOADING on success", async () => { + const store = mockStore(storeState); + store.dispatch(saveSelectionPlan({ name: "CFP 2026", is_enabled: true })); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("SELECTION_PLAN_ADDED"); + expect(actionTypes).toContain("STOP_LOADING"); + expect(actionTypes.indexOf("STOP_LOADING")).toBeGreaterThan( + actionTypes.indexOf("SELECTION_PLAN_ADDED") + ); + }); + }); + + describe("update path (entity has id)", () => { + it("returns a Promise that resolves with the response payload", async () => { + const store = mockStore(storeState); + const result = store.dispatch( + saveSelectionPlan({ id: 1, name: "CFP 2026", is_enabled: true }) + ); + expect(result).toBeInstanceOf(Promise); + await expect(result).resolves.toEqual({ id: 1 }); + }); + + it("dispatches SELECTION_PLAN_UPDATED then STOP_LOADING on success", async () => { + const store = mockStore(storeState); + store.dispatch( + saveSelectionPlan({ id: 1, name: "CFP 2026", is_enabled: true }) + ); + await flushPromises(); + + const actionTypes = store.getActions().map((a) => a.type); + expect(actionTypes).toContain("SELECTION_PLAN_UPDATED"); + expect(actionTypes).toContain("STOP_LOADING"); + expect(actionTypes.indexOf("STOP_LOADING")).toBeGreaterThan( + actionTypes.indexOf("SELECTION_PLAN_UPDATED") + ); + }); + }); +}); diff --git a/src/actions/selection-plan-actions.js b/src/actions/selection-plan-actions.js index e0e48ae77..f41aeb1ad 100644 --- a/src/actions/selection-plan-actions.js +++ b/src/actions/selection-plan-actions.js @@ -12,7 +12,7 @@ * */ import T from "i18n-react/dist/i18n-react"; -import debounce from "lodash/debounce" +import debounce from "lodash/debounce"; import { getRequest, putRequest, @@ -22,8 +22,8 @@ import { stopLoading, startLoading, showMessage, - showSuccessMessage, authErrorHandler, + snackbarErrorHandler, postFile } from "openstack-uicore-foundation/lib/utils/actions"; import URI from "urijs"; @@ -35,7 +35,13 @@ import { fetchErrorHandler } from "../utils/methods"; import { saveMarketingSetting } from "./marketing-actions"; -import { DEBOUNCE_WAIT, DEFAULT_PER_PAGE } from "../utils/constants"; +import { snackbarSuccessHandler } from "./base-actions"; +import { + DEBOUNCE_WAIT, + DEFAULT_CURRENT_PAGE, + DEFAULT_ORDER_DIR, + DEFAULT_PER_PAGE +} from "../utils/constants"; URI.escapeQuerySpace = false; @@ -66,7 +72,13 @@ export const SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED = "SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED"; export const getSelectionPlans = - (term = "", page = 1, order = "id", orderDir = 1) => + ( + term = "", + page = DEFAULT_CURRENT_PAGE, + perPage = DEFAULT_PER_PAGE, + order = "id", + orderDir = DEFAULT_ORDER_DIR + ) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -84,7 +96,7 @@ export const getSelectionPlans = access_token: accessToken, relations: "none", page, - per_page: DEFAULT_PER_PAGE, + per_page: perPage, order: `${orderDir === 1 ? "" : "-"}${order}` }; @@ -93,10 +105,11 @@ export const getSelectionPlans = } return getRequest( - null, + createAction(REQUEST_SELECTION_PLANS), createAction(RECEIVE_SELECTION_PLANS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans`, - authErrorHandler + authErrorHandler, + { order, orderDir, page, perPage, term } )(params)(dispatch).then(async () => { dispatch(stopLoading()); }); @@ -120,7 +133,7 @@ export const getSelectionPlan = null, createAction(RECEIVE_SELECTION_PLAN), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(async () => { await dispatch(getAllowedMembers(selectionPlanId)); await dispatch( @@ -149,34 +162,42 @@ export const saveSelectionPlan = (entity) => async (dispatch, getState) => { createAction(SELECTION_PLAN_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${entity.id}?access_token=${accessToken}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity - )({})(dispatch).then((payload) => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) - ); - return payload.response; - }); + )({})(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) + ); + return payload.response; + }) + .finally(() => { + dispatch(stopLoading()); + }); } return postRequest( createAction(UPDATE_SELECTION_PLAN), createAction(SELECTION_PLAN_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans?access_token=${accessToken}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity - )({})(dispatch).then((payload) => { - dispatch(stopLoading()); - dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_created") - ) - ); - return payload.response; - }); + )({})(dispatch) + .then((payload) => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_created") + }) + ); + return payload.response; + }) + .finally(() => { + dispatch(stopLoading()); + }); }; export const deleteSelectionPlan = @@ -194,7 +215,7 @@ export const deleteSelectionPlan = createAction(SELECTION_PLAN_DELETED)({ selectionPlanId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -240,7 +261,7 @@ export const removeTrackGroupFromSelectionPlan = createAction(TRACK_GROUP_REMOVED)({ trackGroupId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-groups/${trackGroupId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -404,7 +425,7 @@ export const saveSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${entity.id}`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then((payload) => { dispatch(stopLoading()); @@ -440,7 +461,7 @@ export const saveSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions`, normalizedEntity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then((payload) => { dispatch(stopLoading()); @@ -469,7 +490,7 @@ export const deleteSelectionPlanExtraQuestion = createAction(SELECTION_PLAN_EXTRA_QUESTION_DELETED)({ questionId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -491,7 +512,7 @@ export const updateSelectionPlanExtraQuestionOrder = createAction(SELECTION_PLAN_EXTRA_QUESTION_ORDER_UPDATED)(questions), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}`, { order: newOrder }, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -515,7 +536,7 @@ export const saveSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values/${entity.id}`, entity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -527,7 +548,7 @@ export const saveSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values`, entity, - authErrorHandler, + snackbarErrorHandler, entity )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -559,7 +580,7 @@ export const updateSelectionPlanExtraQuestionValueOrder = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_UPDATED), `${window.API_BASE_URL}/api/v1/summits/${summit_id}/selection-plans/${selection_plan_id}/extra-questions/${id}/values/${valueId}`, { order: newOrder }, - authErrorHandler, + snackbarErrorHandler, { order: newOrder, id: valueId } )(params)(dispatch).then(() => { dispatch(stopLoading()); @@ -581,7 +602,7 @@ export const deleteSelectionPlanExtraQuestionValue = createAction(SELECTION_PLAN_EXTRA_QUESTION_VALUE_DELETED)({ valueId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/extra-questions/${questionId}/values/${valueId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -611,7 +632,7 @@ export const addEventTypeSelectionPlan = createAction(SELECTION_PLAN_EVENT_TYPE_ADDED)({ eventType }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventType.id}`, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -633,7 +654,7 @@ export const deleteEventTypeSelectionPlan = createAction(SELECTION_PLAN_EVENT_TYPE_REMOVED)({ eventTypeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/event-types/${eventTypeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -671,7 +692,7 @@ export const updateRatingTypeOrder = createAction(SELECTION_PLAN_RATING_TYPE_ORDER_UPDATED)(ratingTypes), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-chair-rating-types/${ratingTypeId}`, ratingType, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -692,7 +713,7 @@ export const deleteRatingType = createAction(SELECTION_PLAN_RATING_TYPE_REMOVED)({ ratingTypeId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/track-chair-rating-types/${ratingTypeId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -724,18 +745,19 @@ export const assignExtraQuestion2SelectionPlan = (summitId, selectionPlanId, questionId) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); - postRequest( + return postRequest( null, createAction(SELECTION_PLAN_ASSIGNED_EXTRA_QUESTION), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/extra-questions/${questionId}?access_token=${accessToken}`, {}, - authErrorHandler + snackbarErrorHandler )({})(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) ); }); }; @@ -763,7 +785,7 @@ export const getAllowedMembers = createAction(REQUEST_ALLOWED_MEMBERS), createAction(RECEIVE_ALLOWED_MEMBERS), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -786,7 +808,7 @@ export const addAllowedMemberToSelectionPlan = createAction(ALLOWED_MEMBER_ADDED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members`, { email }, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -809,7 +831,7 @@ export const removeAllowedMemberFromSelectionPlan = createAction(ALLOWED_MEMBER_REMOVED)({ emailId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members/${emailId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -827,19 +849,22 @@ export const importAllowedMembersCSV = dispatch(startLoading()); - postFile( + return postFile( null, createAction(ALLOWED_MEMBERS_IMPORTED), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-members/csv`, file, {}, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.import_allowed_members_success") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate( + "edit_selection_plan.import_allowed_members_success" + ) + }) ); }); }; @@ -863,7 +888,7 @@ export const getSelectionPlanProgressFlags = null, createAction(RECEIVE_SELECTION_PLAN_PROGRESS_FLAGS), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/allowed-presentation-action-types`, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -873,18 +898,19 @@ export const assignProgressFlag2SelectionPlan = (summitId, selectionPlanId, progressFlagId) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); - postRequest( + return postRequest( null, createAction(SELECTION_PLAN_ASSIGNED_PROGRESS_FLAG), `${window.API_BASE_URL}/api/v1/summits/${summitId}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}?access_token=${accessToken}`, {}, - authErrorHandler + snackbarErrorHandler )({})(dispatch).then(() => { dispatch(stopLoading()); dispatch( - showSuccessMessage( - T.translate("edit_selection_plan.selection_plan_saved") - ) + snackbarSuccessHandler({ + title: T.translate("general.done"), + html: T.translate("edit_selection_plan.selection_plan_saved") + }) ); }); }; @@ -911,7 +937,7 @@ export const updateProgressFlagOrder = createAction(SELECTION_PLAN_PROGRESS_FLAG_ORDER_UPDATED)(progressFlags), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}`, progressFlag, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); @@ -932,7 +958,7 @@ export const unassignProgressFlagFromSelectionPlan = createAction(SELECTION_PLAN_PROGRESS_FLAG_REMOVED)({ progressFlagId }), `${window.API_BASE_URL}/api/v1/summits/${currentSummit.id}/selection-plans/${selectionPlanId}/allowed-presentation-action-types/${progressFlagId}`, null, - authErrorHandler + snackbarErrorHandler )(params)(dispatch).then(() => { dispatch(stopLoading()); }); diff --git a/src/components/forms/__tests__/selection-plan-form.test.js b/src/components/forms/__tests__/selection-plan-form.test.js new file mode 100644 index 000000000..35bce9f58 --- /dev/null +++ b/src/components/forms/__tests__/selection-plan-form.test.js @@ -0,0 +1,603 @@ +import React from "react"; +import { render, screen, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import SelectionPlanForm from "../selection-plan-form"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/inputs/editor-input-v3", + () => ({ __esModule: true, default: () => null }) +); + +jest.mock("openstack-uicore-foundation/lib/components/mui/table", () => ({ + __esModule: true, + default: ({ data, onDelete }) => ( + + ) +})); + +jest.mock( + "openstack-uicore-foundation/lib/components/mui/sortable-table", + () => ({ + __esModule: true, + default: ({ data, onEdit, onDelete }) => ( + + ) + }) +); + +jest.mock("openstack-uicore-foundation/lib/utils/query-actions", () => ({ + queryTrackGroups: jest.fn(), + queryEventTypes: jest.fn() +})); + +jest.mock("../../mui/formik-inputs/mui-formik-datetimepicker", () => ({ + __esModule: true, + default: ({ name }) =>
+})); + +jest.mock("../../inputs/email-template-input", () => ({ + __esModule: true, + default: ({ id }) => +})); + +jest.mock("../../inputs/import-modal", () => ({ + __esModule: true, + default: ({ show, onIngest }) => + show ? ( +
+ +
+ ) : null +})); + +jest.mock("../../inputs/many-2-many-dropdown", () => ({ + __esModule: true, + default: () => null +})); + +jest.mock("../../../actions/selection-plan-actions", () => ({ + querySelectionPlanExtraQuestions: jest.fn() +})); + +jest.mock("../../../actions/track-chair-actions", () => ({ + querySummitProgressFlags: jest.fn() +})); + +jest.mock("../../../reducers/selection_plans/selection-plan-reducer", () => ({ + DEFAULT_ALLOWED_EDITABLE_QUESTIONS: [], + DEFAULT_ALLOWED_QUESTIONS: [], + DEFAULT_CFP_PRESENTATION_EDITION_TABS: [] +})); + +// Base entity for a new plan (no tabs shown) +const newEntity = { + id: 0, + name: "", + is_enabled: false, + is_hidden: false, + allow_proposed_schedules: false, + allow_new_presentations: false, + submission_begin_date: null, + submission_end_date: null, + submission_lock_down_presentation_status_date: null, + voting_begin_date: null, + voting_end_date: null, + selection_begin_date: null, + selection_end_date: null, + submission_period_disclaimer: "", + max_submission_allowed_per_user: 0, + presentation_creator_notification_email_template: "", + presentation_moderator_notification_email_template: "", + presentation_speaker_notification_email_template: "", + track_chair_rating_types: [], + allow_track_change_requests: true, + track_groups: [], + event_types: [], + extra_questions: [], + allowed_presentation_action_types: [], + allowed_presentation_questions: [], + allowed_presentation_editable_questions: [], + marketing_settings: {} +}; + +// Existing plan entity (tabs shown) +const existingEntity = { ...newEntity, id: 42, name: "Spring CFP" }; + +const baseProps = { + entity: newEntity, + errors: {}, + currentSummit: { id: 1, time_zone_id: "UTC", slug: "test-summit" }, + extraQuestionsOrder: "id", + extraQuestionsOrderDir: 1, + actionTypesOrder: "id", + actionTypesOrderDir: 1, + allowedMembers: { data: [], currentPage: 1, lastPage: 1 }, + onSave: jest.fn(() => Promise.resolve()), + onTrackGroupLink: jest.fn(), + onTrackGroupUnLink: jest.fn(), + onAddEventType: jest.fn(), + onDeleteEventType: jest.fn(), + onAddRatingType: jest.fn(), + onEditRatingType: jest.fn(), + onDeleteRatingType: jest.fn(), + onEditExtraQuestion: jest.fn(), + onDeleteExtraQuestion: jest.fn(), + onAddNewExtraQuestion: jest.fn(), + onAssignExtraQuestion2SelectionPlan: jest.fn(), + onAssignProgressFlag2SelectionPlan: jest.fn(), + onUnassignProgressFlag: jest.fn(), + onUpdateProgressFlagOrder: jest.fn(), + onUpdateRatingTypeOrder: jest.fn(), + updateExtraQuestionOrder: jest.fn(), + onImportAllowedMembers: jest.fn(), + onAllowedMemberAdd: jest.fn(), + onAllowedMemberDelete: jest.fn(), + onAllowedMembersPageChange: jest.fn() +}; + +// Mirrors the popup - external submit button via form attribute +const FormWithButton = (props) => ( + <> + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + +); + +const renderForm = (overrides = {}) => { + const merged = { ...baseProps, ...overrides }; + // eslint-disable-next-line react/jsx-props-no-spreading + return render(); +}; + +const renderExistingForm = (overrides = {}) => + renderForm({ entity: existingEntity, ...overrides }); + +const clickTab = async (label) => { + await userEvent.click(screen.getByRole("tab", { name: label })); +}; + +beforeEach(() => jest.clearAllMocks()); + +// --------------------------------------------------------------------------- +// Save behaviour +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - save behaviour", () => { + it("calls onSave when the form is submitted", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderForm({ onSave }); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + }); + + it("normalizes null dates to 0 before calling onSave", async () => { + const onSave = jest.fn(() => Promise.resolve()); + renderForm({ onSave }); + await userEvent.click(screen.getByRole("button", { name: "general.save" })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + const [payload] = onSave.mock.calls[0]; + expect(payload.submission_begin_date).toBe(0); + expect(payload.voting_begin_date).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tab navigation +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - tab navigation", () => { + it("hides tabs for a new plan (id=0)", () => { + renderForm(); + expect(screen.queryByRole("tab")).toBeNull(); + }); + + it("shows tab bar for an existing plan (id>0)", () => { + renderExistingForm(); + expect(screen.getByRole("tab", { name: "Main" })).toBeInTheDocument(); + expect( + screen.getByRole("tab", { name: "edit_selection_plan.track_groups" }) + ).toBeInTheDocument(); + }); + + it("hides the allowed_members tab when is_hidden is true", () => { + renderExistingForm({ entity: { ...existingEntity, is_hidden: true } }); + expect( + screen.queryByRole("tab", { name: "edit_selection_plan.allowed_members" }) + ).toBeNull(); + }); + + it("shows the allowed_members tab when is_hidden is false", () => { + renderExistingForm({ entity: { ...existingEntity, is_hidden: false } }); + expect( + screen.getByRole("tab", { name: "edit_selection_plan.allowed_members" }) + ).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Main tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - main tab", () => { + it("renders the name field", () => { + renderForm(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("renders enabled and hidden checkboxes", () => { + renderForm(); + expect( + screen.getByLabelText("edit_selection_plan.enabled") + ).toBeInTheDocument(); + expect( + screen.getByLabelText("edit_selection_plan.hidden") + ).toBeInTheDocument(); + }); + + it("renders all date pickers", () => { + renderForm(); + expect(screen.getByTestId("submission_begin_date")).toBeInTheDocument(); + expect(screen.getByTestId("submission_end_date")).toBeInTheDocument(); + expect(screen.getByTestId("voting_begin_date")).toBeInTheDocument(); + expect(screen.getByTestId("selection_begin_date")).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Track groups tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - track_groups tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.track_groups"); + }; + + it("shows empty state when no track groups", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-track_groups"); + expect( + within(panel).getByText("edit_selection_plan.no_track_groups") + ).toBeInTheDocument(); + }); + + it("renders linked track groups in table", async () => { + renderExistingForm({ + entity: { + ...existingEntity, + track_groups: [{ id: 1, name: "Group A", description: "" }] + } + }); + await clickTab("edit_selection_plan.track_groups"); + const panel = document.getElementById("tabpanel-track_groups"); + expect(within(panel).getByText("Group A")).toBeInTheDocument(); + }); + + it("calls onTrackGroupUnLink when delete is clicked", async () => { + const onTrackGroupUnLink = jest.fn(); + renderExistingForm({ + entity: { + ...existingEntity, + track_groups: [{ id: 7, name: "G", description: "" }] + }, + onTrackGroupUnLink + }); + await clickTab("edit_selection_plan.track_groups"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onTrackGroupUnLink).toHaveBeenCalledWith(existingEntity.id, 7); + }); +}); + +// --------------------------------------------------------------------------- +// Event types tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - event_types tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.event_types"); + }; + + it("shows empty state when no event types", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-event_types"); + expect( + within(panel).getByText("edit_selection_plan.no_event_types") + ).toBeInTheDocument(); + }); + + it("renders linked event types in table", async () => { + renderExistingForm({ + entity: { + ...existingEntity, + event_types: [{ id: 3, name: "Presentation" }] + } + }); + await clickTab("edit_selection_plan.event_types"); + const panel = document.getElementById("tabpanel-event_types"); + expect(within(panel).getByText("Presentation")).toBeInTheDocument(); + }); + + it("calls onDeleteEventType when delete is clicked", async () => { + const onDeleteEventType = jest.fn(); + renderExistingForm({ + entity: { ...existingEntity, event_types: [{ id: 5, name: "Talk" }] }, + onDeleteEventType + }); + await clickTab("edit_selection_plan.event_types"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onDeleteEventType).toHaveBeenCalledWith(existingEntity.id, 5); + }); +}); + +// --------------------------------------------------------------------------- +// Extra questions tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - extra_questions tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.extra_questions"); + }; + + it("shows empty state when no extra questions", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-extra_questions"); + expect( + within(panel).getByText("edit_selection_plan.no_extra_questions") + ).toBeInTheDocument(); + }); + + it("calls onAddNewExtraQuestion when Add button is clicked", async () => { + const onAddNewExtraQuestion = jest.fn(); + renderExistingForm({ onAddNewExtraQuestion }); + await clickTab("edit_selection_plan.extra_questions"); + await userEvent.click( + screen.getByRole("button", { + name: "edit_selection_plan.add_extra_questions" + }) + ); + expect(onAddNewExtraQuestion).toHaveBeenCalledTimes(1); + }); + + it("renders extra questions and calls onEditExtraQuestion on edit", async () => { + const onEditExtraQuestion = jest.fn(); + renderExistingForm({ + entity: { + ...existingEntity, + extra_questions: [{ id: 10, name: "q1", label: "Q One", type: "text" }] + }, + onEditExtraQuestion + }); + await clickTab("edit_selection_plan.extra_questions"); + await userEvent.click(screen.getByRole("button", { name: "edit" })); + expect(onEditExtraQuestion).toHaveBeenCalledWith(10); + }); +}); + +// --------------------------------------------------------------------------- +// Email templates tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - email_templates tab", () => { + it("renders the three email template inputs", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.email_templates"); + const panel = document.getElementById("tabpanel-email_templates"); + expect( + within(panel).getByTestId( + "presentation_creator_notification_email_template" + ) + ).toBeInTheDocument(); + expect( + within(panel).getByTestId( + "presentation_moderator_notification_email_template" + ) + ).toBeInTheDocument(); + expect( + within(panel).getByTestId( + "presentation_speaker_notification_email_template" + ) + ).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Track chair settings tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - track_chair_settings tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("track_chair_settings.title"); + }; + + it("renders the allow track change requests checkbox", async () => { + await goToTab(); + expect( + screen.getByLabelText("track_chair_settings.allow_change_requests") + ).toBeInTheDocument(); + }); + + it("calls onAddRatingType when Add Rating Type is clicked", async () => { + const onAddRatingType = jest.fn(); + renderExistingForm({ onAddRatingType }); + await clickTab("track_chair_settings.title"); + await userEvent.click( + screen.getByRole("button", { + name: "track_chair_settings.add_rating_type" + }) + ); + expect(onAddRatingType).toHaveBeenCalledTimes(1); + }); + + it("renders rating types and calls onEditRatingType on edit", async () => { + const onEditRatingType = jest.fn(); + renderExistingForm({ + entity: { + ...existingEntity, + track_chair_rating_types: [{ id: 20, name: "Excellent", weight: 10 }] + }, + onEditRatingType + }); + await clickTab("track_chair_settings.title"); + await userEvent.click(screen.getByRole("button", { name: "edit" })); + expect(onEditRatingType).toHaveBeenCalledWith(20); + }); +}); + +// --------------------------------------------------------------------------- +// Presentation action types tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - presentation_action_types tab", () => { + const goToTab = async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.presentation_action_types"); + }; + + it("shows empty state when no action types", async () => { + await goToTab(); + const panel = document.getElementById("tabpanel-presentation_action_types"); + expect( + within(panel).getByText( + "edit_selection_plan.no_presentation_action_types" + ) + ).toBeInTheDocument(); + }); + + it("renders action types and calls onUnassignProgressFlag on delete", async () => { + const onUnassignProgressFlag = jest.fn(); + renderExistingForm({ + entity: { + ...existingEntity, + allowed_presentation_action_types: [{ id: 30, label: "Approve" }] + }, + onUnassignProgressFlag + }); + await clickTab("edit_selection_plan.presentation_action_types"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onUnassignProgressFlag).toHaveBeenCalledWith(30); + }); +}); + +// --------------------------------------------------------------------------- +// Allowed members tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - allowed_members tab", () => { + const membersProps = { + entity: { ...existingEntity, is_hidden: false }, + allowedMembers: { + data: [{ id: 1, email: "user@example.com" }], + currentPage: 1, + lastPage: 2 + } + }; + + it("renders members and calls onAllowedMemberDelete on delete", async () => { + const onAllowedMemberDelete = jest.fn(); + renderExistingForm({ ...membersProps, onAllowedMemberDelete }); + await clickTab("edit_selection_plan.allowed_members"); + await userEvent.click(screen.getByRole("button", { name: "delete" })); + expect(onAllowedMemberDelete).toHaveBeenCalledWith(existingEntity.id, 1); + }); + + it("calls onAllowedMemberAdd when Add is clicked with an email", async () => { + const onAllowedMemberAdd = jest.fn(); + renderExistingForm({ ...membersProps, onAllowedMemberAdd }); + await clickTab("edit_selection_plan.allowed_members"); + const panel = document.getElementById("tabpanel-allowed_members"); + const emailInput = within(panel).getByRole("textbox"); + await userEvent.type(emailInput, "new@test.com"); + await userEvent.click( + within(panel).getByRole("button", { name: "general.add" }) + ); + expect(onAllowedMemberAdd).toHaveBeenCalledWith( + existingEntity.id, + "new@test.com" + ); + }); + + it("calls onImportAllowedMembers when import modal is confirmed", async () => { + const onImportAllowedMembers = jest.fn(); + renderExistingForm({ ...membersProps, onImportAllowedMembers }); + await clickTab("edit_selection_plan.allowed_members"); + const panel = document.getElementById("tabpanel-allowed_members"); + await userEvent.click( + within(panel).getByRole("button", { name: "edit_selection_plan.import" }) + ); + await userEvent.click(screen.getByRole("button", { name: "ingest" })); + expect(onImportAllowedMembers).toHaveBeenCalledWith( + existingEntity.id, + expect.any(File) + ); + }); +}); + +// --------------------------------------------------------------------------- +// CFP settings tab +// --------------------------------------------------------------------------- + +describe("SelectionPlanForm - cfp_settings tab", () => { + it("renders the allowed_presentation_questions autocomplete", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.cfp_settings"); + const panel = document.getElementById("tabpanel-cfp_settings"); + expect( + within(panel).getByPlaceholderText( + "edit_selection_plan.placeholders.allowed_presentation_questions" + ) + ).toBeInTheDocument(); + }); + + it("renders the allowed_presentation_editable_questions autocomplete", async () => { + renderExistingForm(); + await clickTab("edit_selection_plan.cfp_settings"); + const panel = document.getElementById("tabpanel-cfp_settings"); + expect( + within(panel).getByPlaceholderText( + "edit_selection_plan.placeholders.allowed_presentation_editable_questions" + ) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/selection-plan-form.js b/src/components/forms/selection-plan-form.js index eae94c098..416062d9e 100644 --- a/src/components/forms/selection-plan-form.js +++ b/src/components/forms/selection-plan-form.js @@ -11,1466 +11,340 @@ * limitations under the License. * */ -import React from "react"; +import React, { useState, useEffect } from "react"; +import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; -import "awesome-bootstrap-checkbox/awesome-bootstrap-checkbox.css"; +import { useFormik, FormikProvider } from "formik"; +import moment from "moment-timezone"; import { epochToMomentTimeZone } from "openstack-uicore-foundation/lib/utils/methods"; -import { - queryTrackGroups, - queryEventTypes, - queryMembers -} from "openstack-uicore-foundation/lib/utils/query-actions"; -import { - Input, - DateTimePicker, - SimpleLinkList, - SortableTable, - Panel, - Table, - Dropdown -} from "openstack-uicore-foundation/lib/components"; -import TextEditorV3 from "openstack-uicore-foundation/lib/components/inputs/editor-input-v3"; -import Switch from "react-switch"; -import { Pagination } from "react-bootstrap"; -import { - isEmpty, - scrollToError, - shallowEqual, - stripTags -} from "../../utils/methods"; -import EmailTemplateInput from "../inputs/email-template-input"; -import ImportModal from "../inputs/import-modal"; -import { - MILLISECONDS_TO_SECONDS, - PresentationTypeClassName -} from "../../utils/constants"; -import Many2ManyDropDown from "../inputs/many-2-many-dropdown"; -import { querySelectionPlanExtraQuestions } from "../../actions/selection-plan-actions"; -import { querySummitProgressFlags } from "../../actions/track-chair-actions"; -import { - DEFAULT_ALLOWED_EDITABLE_QUESTIONS, - DEFAULT_ALLOWED_QUESTIONS, - DEFAULT_CFP_PRESENTATION_EDITION_TABS -} from "../../reducers/selection_plans/selection-plan-reducer"; -import history from "../../history"; - -class SelectionPlanForm extends React.Component { - constructor(props) { - super(props); - - this.state = { - entity: { ...props.entity }, - errors: props.errors, - showSection: "main", - newMemberEmail: "", - showImportModal: false, - importFile: null - }; - - this.handleTrackGroupLink = this.handleTrackGroupLink.bind(this); - this.handleTrackGroupUnLink = this.handleTrackGroupUnLink.bind(this); - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleEditExtraQuestion = this.handleEditExtraQuestion.bind(this); - this.handleDeleteExtraQuestion = this.handleDeleteExtraQuestion.bind(this); - this.handleNewExtraQuestion = this.handleNewExtraQuestion.bind(this); - this.handleDeleteEventType = this.handleDeleteEventType.bind(this); - this.handleAddEventType = this.handleAddEventType.bind(this); - this.handleAddRatingType = this.handleAddRatingType.bind(this); - this.handleDeleteRatingType = this.handleDeleteRatingType.bind(this); - this.handleEditRatingType = this.handleEditRatingType.bind(this); - this.handleRemoveProgressFlag = this.handleRemoveProgressFlag.bind(this); - this.toggleSection = this.toggleSection.bind(this); - this.handleNotificationEmailTemplateChange = - this.handleNotificationEmailTemplateChange.bind(this); - this.fetchSummitSelectionPlanExtraQuestions = - this.fetchSummitSelectionPlanExtraQuestions.bind(this); - this.fetchMembers = this.fetchMembers.bind(this); - this.linkSummitSelectionPlanExtraQuestion = - this.linkSummitSelectionPlanExtraQuestion.bind(this); - this.fetchSummitPresentationActionTypes = - this.fetchSummitPresentationActionTypes.bind(this); - this.linkSummitProgressFlag = this.linkSummitProgressFlag.bind(this); - this.handleAddAllowedMember = this.handleAddAllowedMember.bind(this); - this.handleImportAllowedMembers = - this.handleImportAllowedMembers.bind(this); - this.handleDeleteAllowedMember = this.handleDeleteAllowedMember.bind(this); - this.handleAllowedMembersPageChange = - this.handleAllowedMembersPageChange.bind(this); - this.handleOnSwitchChange = this.handleOnSwitchChange.bind(this); - } - - fetchSummitSelectionPlanExtraQuestions(input, callback) { - const { currentSummit } = this.props; - - if (!input) { - return Promise.resolve({ options: [] }); - } - querySelectionPlanExtraQuestions(currentSummit.id, input, callback); - } - - fetchMembers(input, callback) { - if (!input) { - return Promise.resolve({ options: [] }); - } - queryMembers(input, callback); - } - - linkSummitSelectionPlanExtraQuestion(question) { - const { currentSummit } = this.props; - this.props.onAssignExtraQuestion2SelectionPlan( - currentSummit.id, - this.state.entity.id, - question.id - ); - } - - handleEditExtraQuestion(questionId) { - this.props.onEditExtraQuestion(questionId); - } - - handleDeleteExtraQuestion(questionId) { - this.props.onDeleteExtraQuestion(questionId); - } - - handleNewExtraQuestion() { - this.props.onAddNewExtraQuestion(); - } - - componentDidUpdate(prevProps) { - const state = {}; - scrollToError(this.props.errors); - - if (!shallowEqual(prevProps.entity, this.props.entity)) { - state.entity = { ...this.props.entity }; - state.errors = {}; - } - - if (!shallowEqual(prevProps.errors, this.props.errors)) { - state.errors = { ...this.props.errors }; - } - - if (!isEmpty(state)) { - this.setState({ ...this.state, ...state }); - } - } - - handleChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - let { value, id } = ev.target; - - if (ev.target.type === "checkbox") { - value = ev.target.checked; - } - - if (ev.target.type === "datetime") { - value = value.valueOf() / MILLISECONDS_TO_SECONDS; - } - - if (id.startsWith("cfp_")) { - if (!newEntity.marketing_settings.hasOwnProperty(id)) { - newEntity.marketing_settings[id] = { value: "" }; - } - newEntity.marketing_settings[id].value = value; - } else { - newErrors[id] = ""; - newEntity[id] = value; - } - - this.setState({ entity: newEntity, errors: newErrors }); - } - - handleNotificationEmailTemplateChange(ev) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - const { value, id } = ev.target; - - newErrors[id] = ""; - newEntity[id] = value; - this.setState({ ...this.state, entity: newEntity, errors: newErrors }); - } - - handleSubmit(ev) { - ev.preventDefault(); - - const entity = { ...this.state.entity }; - const { currentSummit } = this.props; - - this.props.onSubmit(this.state.entity).then((e) => { - this.props - .saveSelectionPlanSettings(entity.marketing_settings, e.id) - .then(() => { - if (!entity.id) - history.push( - `/app/summits/${currentSummit.id}/selection-plans/${e.id}` - ); - }); +import Box from "@mui/material/Box"; +import Tab from "@mui/material/Tab"; +import Tabs from "@mui/material/Tabs"; +import { scrollToError } from "../../utils/methods"; +import MainTab from "./selection-plan-form/main-tab"; +import TrackGroupsTab from "./selection-plan-form/track-groups-tab"; +import EventTypesTab from "./selection-plan-form/event-types-tab"; +import ExtraQuestionsTab from "./selection-plan-form/extra-questions-tab"; +import EmailTemplatesTab from "./selection-plan-form/email-templates-tab"; +import TrackChairSettingsTab from "./selection-plan-form/track-chair-settings-tab"; +import PresentationActionTypesTab from "./selection-plan-form/presentation-action-types-tab"; +import AllowedMembersTab from "./selection-plan-form/allowed-members-tab"; +import CfpSettingsTab from "./selection-plan-form/cfp-settings-tab"; + +const DATE_FIELDS = [ + "submission_begin_date", + "submission_end_date", + "submission_lock_down_presentation_status_date", + "voting_begin_date", + "voting_end_date", + "selection_begin_date", + "selection_end_date" +]; + +const buildInitialValues = (entity, timezone) => { + const values = { ...entity }; + DATE_FIELDS.forEach((field) => { + values[field] = entity[field] + ? epochToMomentTimeZone(entity[field], timezone) + : null; + }); + return values; +}; + +const TAB_SX = { + fontSize: "1.4rem", + lineHeight: "1.8rem", + minHeight: "36px", + px: 2, + py: 1 +}; + +const SelectionPlanForm = (props) => { + const { + entity: propsEntity, + errors: propsErrors, + currentSummit, + extraQuestionsOrderDir, + extraQuestionsOrder, + actionTypesOrderDir, + actionTypesOrder, + allowedMembers, + onSave, + onTrackGroupLink, + onTrackGroupUnLink, + onAddEventType, + onDeleteEventType, + onAddRatingType, + onEditRatingType, + onDeleteRatingType, + onEditExtraQuestion, + onDeleteExtraQuestion, + onAddNewExtraQuestion, + onAssignExtraQuestion2SelectionPlan, + onAssignProgressFlag2SelectionPlan, + onUnassignProgressFlag, + onUpdateProgressFlagOrder, + onUpdateRatingTypeOrder, + updateExtraQuestionOrder, + onImportAllowedMembers, + onAllowedMemberAdd, + onAllowedMemberDelete, + onAllowedMembersPageChange + } = props; + + const [activeTab, setActiveTab] = useState("main"); + + const handleFormikSubmit = (values) => { + const normalized = { ...values }; + DATE_FIELDS.forEach((field) => { + normalized[field] = values[field] + ? moment.tz(values[field], currentSummit.time_zone_id).unix() + : 0; }); - } - - hasErrors(field) { - const { errors } = this.state; - if (field in errors) { - return errors[field]; - } - - return ""; - } - - handleTrackGroupLink(value) { - const { entity } = this.state; - this.props.onTrackGroupLink(entity.id, value); - } - - handleTrackGroupUnLink(valueId) { - const { entity } = this.state; - this.props.onTrackGroupUnLink(entity.id, valueId); - } - - handleAddEventType(value) { - const { entity } = this.state; - this.props.onAddEventType(entity.id, value); - } - - handleDeleteEventType(valueId) { - const { entity } = this.state; - this.props.onDeleteEventType(entity.id, valueId); - } - - handleAddRatingType() { - this.props.onAddRatingType(); - } - - handleEditRatingType(ratingTypeId) { - this.props.onEditRatingType(ratingTypeId); - } - - handleDeleteRatingType(ratingTypeId) { - this.props.onDeleteRatingType(ratingTypeId); - } - - fetchSummitPresentationActionTypes(input, callback) { - const { currentSummit } = this.props; - - if (!input) { - return Promise.resolve({ options: [] }); - } - querySummitProgressFlags(currentSummit.id, input, callback); - } - - linkSummitProgressFlag(progressFlag) { - const { currentSummit } = this.props; - this.props.onAssignProgressFlag2SelectionPlan( - currentSummit.id, - this.state.entity.id, - progressFlag.id + return onSave(normalized); + }; + + const formik = useFormik({ + initialValues: buildInitialValues(propsEntity, currentSummit.time_zone_id), + onSubmit: handleFormikSubmit, + validateOnChange: false + }); + + useEffect(() => { + scrollToError(propsErrors); + formik.setErrors( + propsErrors && Object.keys(propsErrors).length > 0 ? propsErrors : {} ); - } - - handleRemoveProgressFlag(progressFlagId) { - this.props.onUnassignProgressFlag(progressFlagId); - } - - handleImportAllowedMembers(importFile) { - if (importFile) { - this.props.onImportAllowedMembers(this.state.entity.id, importFile); + }, [propsErrors]); + + // Sync sub-resource arrays from Redux without resetting user-editable main tab fields + useEffect(() => { + formik.setValues((current) => ({ + ...current, + track_groups: propsEntity.track_groups ?? [], + event_types: propsEntity.event_types ?? [], + extra_questions: propsEntity.extra_questions ?? [], + track_chair_rating_types: propsEntity.track_chair_rating_types ?? [], + allowed_presentation_action_types: + propsEntity.allowed_presentation_action_types ?? [] + })); + }, [ + propsEntity.track_groups, + propsEntity.event_types, + propsEntity.extra_questions, + propsEntity.track_chair_rating_types, + propsEntity.allowed_presentation_action_types + ]); + + // Reset tab if allowed_members becomes unavailable + useEffect(() => { + if (formik.values.is_hidden && activeTab === "allowed_members") { + setActiveTab("main"); } - this.setState({ ...this.state, showImportModal: false }); - } - - handleAddAllowedMember() { - const { entity, newMemberEmail } = this.state; - this.props.onAllowedMemberAdd(entity.id, newMemberEmail); - } - - handleDeleteAllowedMember(valueId) { - const { entity } = this.state; - this.props.onAllowedMemberDelete(entity.id, valueId); - } - - handleAllowedMembersPageChange(page) { - const { entity } = this.state; - this.props.onAllowedMembersPageChange(entity.id, page); - } - - toggleSection(section) { - const { showSection } = this.state; - const newShowSection = showSection === section ? "main" : section; - this.setState({ showSection: newShowSection }); - } - - handleOnSwitchChange(setting, value) { - const newEntity = { ...this.state.entity }; - const newErrors = { ...this.state.errors }; - - if (!newEntity.marketing_settings.hasOwnProperty(setting)) { - newEntity.marketing_settings[setting] = { value: "" }; + }, [formik.values.is_hidden]); + + const isNewPlan = formik.values.id === 0; + + const tabs = [ + { value: "main", label: "Main" }, + { + value: "track_groups", + label: T.translate("edit_selection_plan.track_groups") + }, + { + value: "event_types", + label: T.translate("edit_selection_plan.event_types") + }, + { + value: "extra_questions", + label: T.translate("edit_selection_plan.extra_questions") + }, + { + value: "email_templates", + label: T.translate("edit_selection_plan.email_templates") + }, + { + value: "track_chair_settings", + label: T.translate("track_chair_settings.title") + }, + { + value: "presentation_action_types", + label: T.translate("edit_selection_plan.presentation_action_types") + }, + ...(!formik.values.is_hidden + ? [ + { + value: "allowed_members", + label: T.translate("edit_selection_plan.allowed_members") + } + ] + : []), + { + value: "cfp_settings", + label: T.translate("edit_selection_plan.cfp_settings") } + ]; + + return ( + + + + + {!isNewPlan && ( + + setActiveTab(val)} + variant="scrollable" + scrollButtons="auto" + sx={{ + "& .MuiTabScrollButton-root.Mui-disabled": { display: "none" } + }} + > + {tabs.map((tab) => ( + + ))} + + + )} - newEntity.marketing_settings[setting].value = value; - - this.setState({ entity: newEntity, errors: newErrors }); - } - - render() { - const { entity, showSection, newMemberEmail, showImportModal } = this.state; - const { - currentSummit, - extraQuestionsOrderDir, - extraQuestionsOrder, - actionTypesOrderDir, - actionTypesOrder, - allowedMembers - } = this.props; - - const trackGroupsColumns = [ - { columnKey: "name", value: T.translate("edit_selection_plan.name") }, - { - columnKey: "description", - value: T.translate("edit_selection_plan.description") - } - ]; - - const trackGroupsOptions = { - valueKey: "name", - labelKey: "name", - defaultOptions: true, - actions: { - search: (input, callback) => { - queryTrackGroups(currentSummit.id, input, callback); - }, - delete: { onClick: this.handleTrackGroupUnLink }, - add: { onClick: this.handleTrackGroupLink } - } - }; - - const eventTypesColumns = [ - { columnKey: "name", value: T.translate("edit_selection_plan.name") } - ]; - - const eventTypesOptions = { - valueKey: "name", - labelKey: "name", - defaultOptions: true, - actions: { - search: (input, callback) => { - queryEventTypes( - currentSummit.id, - input, - callback, - PresentationTypeClassName - ); - }, - delete: { onClick: this.handleDeleteEventType }, - add: { onClick: this.handleAddEventType } - } - }; - - const extraQuestionColumns = [ - { - columnKey: "type", - value: T.translate("order_extra_question_list.question_type") - }, - { - columnKey: "label", - value: T.translate("order_extra_question_list.visible_question") - }, - { - columnKey: "name", - value: T.translate("order_extra_question_list.question_id") - } - ]; - - const extraQuestionsOptions = { - sortCol: extraQuestionsOrder, - sortDir: extraQuestionsOrderDir, - actions: { - edit: { onClick: this.handleEditExtraQuestion }, - delete: { onClick: this.handleDeleteExtraQuestion } - } - }; - - const ratingTypesColumns = [ - { columnKey: "name", value: T.translate("rating_type_list.name") }, - { columnKey: "weight", value: T.translate("rating_type_list.weight") } - ]; - - const ratingTypesOptions = { - actions: { - edit: { onClick: this.handleEditRatingType }, - delete: { onClick: this.handleDeleteRatingType } - } - }; - - const actionTypesColumns = [ - { columnKey: "label", value: T.translate("progress_flags.label") } - ]; - - const actionTypesOptions = { - sortCol: actionTypesOrder, - sortDir: actionTypesOrderDir, - actions: { - delete: { onClick: this.handleRemoveProgressFlag } - } - }; - - const allowedMembersColumns = [ - { columnKey: "id", value: T.translate("edit_selection_plan.id") }, - { columnKey: "email", value: T.translate("edit_selection_plan.email") } - ]; - - const allowedMembersOptions = { - sortCol: "email", - sortDir: 1, - actions: { - delete: { onClick: this.handleDeleteAllowedMember } - } - }; - - console.log("CHECK...", entity, currentSummit); - - return ( -
- -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
+ {/* Main tab always in DOM - TextEditorV3 must not remount on tab switch */} +