Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/jest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: jest

on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
Comment thread
smarcet marked this conversation as resolved.
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
Comment thread
smarcet marked this conversation as resolved.
- uses: actions/setup-node@v3
with:
node-version: 22
Comment thread
smarcet marked this conversation as resolved.
- run: yarn install
Comment thread
smarcet marked this conversation as resolved.
- run: yarn test
14 changes: 14 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Babel config used by jest (babel-jest). It is scoped to the "test" env so the
// webpack build is left untouched: webpack.common.js configures babel-loader
// with inline presets, and for non-test envs this file contributes nothing.
module.exports = {
env: {
test: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-react',
'@babel/preset-flow'
]
}
}
};
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/src/**/*.test.js']
};
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build-dev": "./node_modules/.bin/webpack --config webpack.dev.js",
"build": "./node_modules/.bin/webpack --config webpack.prod.js",
"serve": "webpack-dev-server --open --config webpack.dev.js",
"test": "jest",
"clean-branchs": "git branch | grep -v \"master\\|main\\|production\" | xargs git branch -D"
},
"author": "",
Expand Down Expand Up @@ -101,7 +102,9 @@
"webpack-dev-server": "^4.7.4",
"webpack-merge": "^5.8.0"
},
"devDependencies": {},
"devDependencies": {
"jest": "30.4.2"
},
"resolutions": {
"lodash": "4.17.21"
}
Expand Down
2 changes: 1 addition & 1 deletion src/actions/speaker-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export const saveSpeakerProfile = (entity) => async (dispatch, getState) => {
dispatch(getSpeakerInfo(null));
})
.then((payload) => {
const redirectUrl = summit ? `/app/${summit.slug}/${getSubmissionsPath()}` : '/app/start';
const redirectUrl = summit ? `/app/${summit.slug}/${getSubmissionsPath(summit)}` : '/app/start';
success_message.html = T.translate("edit_profile.profile_saved");
dispatch(showMessage(success_message, () => history.push(redirectUrl)));
});
Expand Down
6 changes: 4 additions & 2 deletions src/components/nav-menu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import MenuItem from './menu-item'
import MenuItemsDefinitions from './menu-items-definition'
import '../../styles/menu.less';
import {connect} from "react-redux";
import {getCurrentSelectionPlanId, getLandingSelectionPlanId} from "../../utils/methods";
import {getCurrentSelectionPlanId, getAllowedLandingSelectionPlanId} from "../../utils/methods";
import DocList from "./doc-list";


const NavMenu = ({summit, active, user, exclusiveSections, presentation}) => {
const [activeItem, setActiveItem] = useState(active);
const landingSP = getLandingSelectionPlanId();
// a landing plan the user cannot submit to would build menu links that the allowed-plan
// guard bounces back to /all-plans, so fall back to the global routes instead
const landingSP = getAllowedLandingSelectionPlanId(summit);
const currentSP = getCurrentSelectionPlanId();

const globalSummitDocs = summit.summit_documents.filter(sd => sd.selection_plan_id === 0);
Expand Down
6 changes: 4 additions & 2 deletions src/layouts/summit-layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import AllPlansLayout from "./all-plans-layout";
import PlanSelectionPage from "../pages/plan-selection-page";
import ProfilePage from "../pages/profile-page";
import ClockComponent from '../components/clock';
import {getLandingSelectionPlanId} from "../utils/methods";
import {getAllowedLandingSelectionPlanId} from "../utils/methods";

const SummitLayout = ({summit, loading, match, speaker, location, baseLoaded, ...props}) => {
const urlSummitSlug = match.params.summit_slug;
Expand All @@ -39,7 +39,9 @@ const SummitLayout = ({summit, loading, match, speaker, location, baseLoaded, ..

// check if speaker profile exists, if not redirect
if ((!speaker || !speaker.id) && !location.pathname.includes('/profile') && !loading) {
const spLanding = getLandingSelectionPlanId();
// only keep the landing plan in the URL if the user can actually submit to it, otherwise
// SelectionPlanLayout's guard bounces us back here and we redirect again, forever
const spLanding = getAllowedLandingSelectionPlanId(summit);

return (
<Redirect exact to={{pathname: `/app/${summit.slug}/${spLanding ? `all-plans/${spLanding}/profile` : 'all-plans/profile'}`}}/>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/preview-presentation-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class PreviewPresentationPage extends React.Component {
ev.preventDefault();

const {history, summit} = this.props;
const submissionsPath = getSubmissionsPath();
const submissionsPath = getSubmissionsPath(summit);

history.push(`/app/${summit.slug}/${submissionsPath}`);
}
Expand Down
1 change: 0 additions & 1 deletion src/pages/profile-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {saveSpeakerProfile, getOrganizationalRoles} from "../actions/speaker-act
import {getSpeakerInfo} from "../actions/auth-actions";

import '../styles/profile-page.less';
import {getSubmissionsPath} from "../utils/methods";

const ProfilePage = ({entity, speaker, orgRoles, loggedMember, errors, loading, summit, selectionPlanId, history, selectionPlansSettings, ...props}) => {
const selectionPlanSettings = selectionPlansSettings?.[selectionPlanId];
Expand Down
2 changes: 1 addition & 1 deletion src/pages/thankyou-presentation-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ThankYouPresentationPage extends React.Component {
ev.preventDefault();

const {history, summit} = this.props;
const submissionsPath = getSubmissionsPath();
const submissionsPath = getSubmissionsPath(summit);

history.push(`/app/${summit.slug}/${submissionsPath}`);
}
Expand Down
27 changes: 23 additions & 4 deletions src/utils/methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,13 +259,32 @@ export const setDefaultColors = () => {
setDocumentColors(defaultColors);
};

export const getSubmissionsPath = () => {
const selectionPlanLandingId = localStorage.getItem(SP_LANDING);
export const getSubmissionsPath = (summit) => {
const selectionPlanLandingId = getAllowedLandingSelectionPlanId(summit);
return selectionPlanLandingId ? `all-plans/${selectionPlanLandingId}` : 'all-plans';
};

export const getLandingSelectionPlanId = () => {
return localStorage.getItem(SP_LANDING);
/**
* SP_LANDING is stored globally, so it can outlive the summit it was set on and point to a
* selection plan id that isn't among the member's allowed plans for this summit. Callers that
* build URLs from it must use this instead, or they send the user into a route the allowed-plan
* guard bounces back. This mirrors the guard in selection-plan-layout.js:SelectionPlanLayout
* (allowedSelectionPlans.some(sp => sp.id === selectionPlanId)) on purpose: it's what closes the
* redirect loop. If either check ever gains a condition the other lacks, the loop comes back.
*
* @param summit
* @returns {number|null} the landing selection plan id, or null when it isn't among the
* member's allowed plans for this summit
*/
export const getAllowedLandingSelectionPlanId = (summit) => {
const storedId = localStorage.getItem(SP_LANDING);
if (!storedId) return null;

const selectionPlanLandingId = parseInt(storedId);
if (Number.isNaN(selectionPlanLandingId) || selectionPlanLandingId <= 0) return null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const isAllowed = summit?.selection_plans?.some(sp => sp.id === selectionPlanLandingId);
Comment thread
smarcet marked this conversation as resolved.
return isAllowed ? selectionPlanLandingId : null;
Comment thread
smarcet marked this conversation as resolved.
}

/**
Expand Down
92 changes: 92 additions & 0 deletions src/utils/methods.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Copyright 2018 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 { getAllowedLandingSelectionPlanId, getSubmissionsPath } from './methods';
import { SP_LANDING } from './constants';

const originalLocalStorage = global.localStorage;

afterEach(() => {
global.localStorage = originalLocalStorage;
});

const setLanding = (value) => {
global.localStorage = {
Comment thread
smarcet marked this conversation as resolved.
getItem: (key) => (key === SP_LANDING && value !== null ? String(value) : null)
};
};

const summitWithPlans = (...ids) => ({selection_plans: ids.map(id => ({id}))});

describe('getAllowedLandingSelectionPlanId', () => {
it('returns the landing plan id when the user is allowed to submit to it', () => {
setLanding(123);

expect(getAllowedLandingSelectionPlanId(summitWithPlans(45, 123))).toBe(123);
});

it('returns null when the landing plan is not among the allowed plans', () => {
// stale SP_LANDING: id kept from another summit or a closed plan.
// this is the precondition of the /all-plans/{id}/profile redirect loop
setLanding(999);

expect(getAllowedLandingSelectionPlanId(summitWithPlans(45, 123))).toBeNull();
});

it('returns null when no landing plan was ever stored', () => {
setLanding(null);

expect(getAllowedLandingSelectionPlanId(summitWithPlans(45, 123))).toBeNull();
});

it('returns null when the stored value is not a usable plan id', () => {
setLanding('');
expect(getAllowedLandingSelectionPlanId(summitWithPlans(45, 123))).toBeNull();

setLanding('not-a-plan');
expect(getAllowedLandingSelectionPlanId(summitWithPlans(45, 123))).toBeNull();
});

it('returns null for a non-positive id even if a plan matches it', () => {
setLanding(0);

expect(getAllowedLandingSelectionPlanId(summitWithPlans(0, 123))).toBeNull();
});

it('returns null when the allowed plans are not loaded yet', () => {
setLanding(123);

expect(getAllowedLandingSelectionPlanId(undefined)).toBeNull();
expect(getAllowedLandingSelectionPlanId({})).toBeNull();
});
});

describe('getSubmissionsPath', () => {
it('keeps the landing plan in the path when the user can submit to it', () => {
setLanding(123);

expect(getSubmissionsPath(summitWithPlans(45, 123))).toBe('all-plans/123');
});

it('falls back to the global submissions path when the landing plan is stale', () => {
setLanding(999);

expect(getSubmissionsPath(summitWithPlans(45, 123))).toBe('all-plans');
});

it('falls back to the global submissions path when no landing plan was stored', () => {
setLanding(null);

expect(getSubmissionsPath(summitWithPlans(45, 123))).toBe('all-plans');
});
});
Loading
Loading