Skip to content
Open
Show file tree
Hide file tree
Changes from 14 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
14 changes: 14 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { WSOToken } from "../lib/types";
// More component imports
const Scheduler = lazy(() => import("./views/CourseScheduler/Scheduler"));
const About = lazy(() => import("./views/Misc/About"));
const Dashboard = lazy(() => import("./views/AdminTools/dashboard"));

const FAQ = lazy(() => import("./views/Misc/FAQ"));
const Team = lazy(() => import("./views/Misc/Team"));
const MobilePrivacyPolicy = lazy(
Expand Down Expand Up @@ -194,15 +196,27 @@ const App = () => {
</RequireScope>
}
/>

<Route path="schedulecourses" element={<Scheduler />} />
{/* Static Content Pages */}

<Route path="about" element={<About />} />
<Route path="faq" element={<FAQ />} />
<Route path="team" element={<Team />} />
<Route
path="admin"
element={
<RequireScope token={apiToken} name="admin.dashboard">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route name should be just admin (since the path name is also just /admin)

<Dashboard />
</RequireScope>
}
/>

<Route
path="mobile-privacy-policy"
element={<MobilePrivacyPolicy />}
/>

<Route path="login" element={<Login />} />
{/* Error-handling Pages */}
<Route path="403" element={<Error403 />} />
Expand Down
70 changes: 61 additions & 9 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// React Imports
import React, { useEffect, useState } from "react";

import "./stylesheets/Nav.css";

// Redux imports
import { getWSO, getCurrUser } from "../lib/authSlice";
import { removeCredentials } from "../lib/authSlice";
Expand All @@ -11,6 +13,32 @@ import { Link } from "react-router-dom";
import history from "../lib/history";
import { userTypeStudent } from "../constants/general";

// Feature flag imports
import { RootState } from "../lib/store";

interface FeatureFlagElementProps {
element: React.ReactElement;
flag: keyof RootState["featureFlagState"];
}

const FeatureFlagElement: React.FC<FeatureFlagElementProps> = ({
element,
flag,
}) => {
const enabled = useAppSelector(
(state: RootState) => state.featureFlagState[flag]
);

return (
<div className="nav_feature_div">
{console.log(enabled)}
{enabled === "Enabled" ? (
<li className="nav_feature_item">{element}</li>
) : null}
</div>
);
};

const Nav = () => {
const dispatch = useAppDispatch();
const currUser = useAppSelector(getCurrUser);
Expand Down Expand Up @@ -111,18 +139,42 @@ const Nav = () => {
</>
)}

<li>
<Link to="faq">FAQ</Link>
</li>
<li>
<a href="/wiki/">Wiki</a>
</li>
<li>
<Link to="about">About</Link>
</li>
<FeatureFlagElement
element={<Link to="faq">FAQ</Link>}
flag="enableFAQ"
/>
<FeatureFlagElement
element={<a href="/wiki/">Wiki</a>}
flag="enableWiki"
/>
<FeatureFlagElement
element={<Link to="about">About</Link>}
flag="enableAbout"
/>

{/* {featureFlagElement(
<a href="/wiki/">Wiki</a>,
"enableWiki",


// getFeatureFlag(currentState, "enableWiki")
)}

{featureFlagElement(
<Link to="about">About</Link>,
"enableAbout",


// getFeatureFlag(currentState, "enableAbout")
)} */}
<li>
<Link to="schedulecourses">Course Scheduler</Link>
</li>
{currUser?.admin && (
<li>
<Link to="admin">Admin Dashboard</Link>
</li>
)}
{ephmatchVisibility > 0 && (
<li>
<Link
Expand Down
7 changes: 7 additions & 0 deletions src/components/stylesheets/Nav.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.nav_feature_div {
display: inline-block;
}

.nav_feature_div:not(:empty) {
padding-right: 2em;
}
41 changes: 41 additions & 0 deletions src/components/views/AdminTools/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// React imports
import React from "react";
// import { useDispatch, useSelector } from "react-redux";
import { FFState, toggleFeatureFlag } from "../../../lib/featureFlagSlice";
import { RootState } from "../../../lib/store";
import { useAppDispatch, useAppSelector } from "../../../lib/store";

const Dashboard = () => {
const dispatch = useAppDispatch();
const featureFlagState = useAppSelector(
(state: RootState) => state.featureFlagState
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleToggle = (myflag: any, newState: FFState) => {
dispatch(toggleFeatureFlag({ flag: myflag, value: newState }));
};

return (
<div>
<p>Dashboard</p>
{Object.entries(featureFlagState).map(
([key, value]) =>
!(key[0] === "_") && (
<div key={key}>
{Object.entries(FFState).map(([stateKey, stateValue]) => (
<button
key={key + stateKey}
onClick={() => handleToggle(key, stateKey as FFState)}
>
{"Set " + key.slice(6, key.length) + " to " + stateKey}
</button>
))}
</div>
)
)}
</div>
);
};

export default Dashboard;
18 changes: 17 additions & 1 deletion src/components/views/Misc/About.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import React from "react";
import DoughtyBanner from "../../../assets/images/banners/Doughty.jpg";
import { Link } from "react-router-dom";
import { useAppSelector } from "../../../lib/store";

const About = () => {
const AboutPreRelease = () => {
return (
<div className="article">
<div className="about-banner">
Expand Down Expand Up @@ -118,4 +119,19 @@ const About = () => {
);
};

const About = () => {
const enableAbout = useAppSelector(
(state) => state.featureFlagState["enableAbout"]
);

return (
<div>
{enableAbout === "Enabled" ? (
<AboutPreRelease />
) : (
<p>Feature {enableAbout}</p>
)}
</div>
);
};
export default About;
15 changes: 14 additions & 1 deletion src/components/views/Misc/FAQ.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// React imports
import React from "react";
import { useAppSelector } from "../../../lib/store";

const FAQ = () => {
const FAQPreRelease = () => {
return (
<div className="article">
<section>
Expand Down Expand Up @@ -127,4 +128,16 @@ const FAQ = () => {
);
};

const FAQ = () => {
const enableFAQ = useAppSelector(
(state) => state.featureFlagState["enableFAQ"]
);

return (
<div>
{enableFAQ === "Enabled" ? <FAQPreRelease /> : <p>Feature {enableFAQ}</p>}
</div>
);
};

export default FAQ;
45 changes: 45 additions & 0 deletions src/lib/featureFlagSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

// const API_ADDRESS = "http://localhost:8080";

export enum FFState {
Enabled = "Enabled",
Disabled = "Disabled",
Pending = "Pending",
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding typing here, I think we need some more discussions.

@AndrewMuh, do you think it's a better idea if we give each flag its own type for granular state mangament?
If so, should we implement it in union types? (given that enum cannot be extended) Or totally separate types?

Also, do you think the rendering logic like senior-only should be handled at backend or front-end?

This question is important, because it will affect how FeatureFlagElement should be implemented (in Nav.tsx)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly thinking about it now, I think all flags should be either Enabled or Disabled (I still think enums, but only these two). Things like senior only or student only should be done through a "featureIsSeniorOnly" feature flag. This simplifies implementation in the frontend/db: just check ephmatchEnabled and ephmatchIsSeniorOnly for example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for rendering logic, the frontend should restrict the rendering of a component using the FF, and the backend should reject invalid requests based on the FF.

interface FeatureFlagsState {
enableAbout: FFState;
enableWiki: FFState;
enableFAQ: FFState;

// Add more feature flags as needed
}

export const initialState: FeatureFlagsState = {
enableAbout: FFState.Enabled,
enableWiki: FFState.Enabled,
enableFAQ: FFState.Enabled,
};

const featureFlagSlice = createSlice({
name: "featureFlags",
initialState,
reducers: {
toggleFeatureFlag: (
state,
action: PayloadAction<{ flag: keyof FeatureFlagsState; value: FFState }>
) => {
const { flag, value } = action.payload;
if (flag in state) {
state[flag] = value;
}
},
},
});

export const { toggleFeatureFlag } = featureFlagSlice.actions;

// export const getFeatureFlag = (state: FeatureFlagsState, flag: keyof FeatureFlagsState) => featureFlagsState[flag];

export default featureFlagSlice.reducer;
13 changes: 12 additions & 1 deletion src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
} from "redux-persist";
import storage from "redux-persist/lib/storage";

import { courseReducer, schedulerUtilReducer, authReducer } from "../reducers";
import {
courseReducer,
schedulerUtilReducer,
authReducer,
featureFlagReducer,
} from "../reducers";

const persistedAuthReducer = persistReducer(
{ key: "auth", storage, whitelist: ["identityToken"] },
Expand All @@ -24,11 +29,17 @@ const persistedCourseSchedulerReducer = persistReducer(
schedulerUtilReducer
);

const persistedFeatureFlagReducer = persistReducer(
{ key: "featureFlags", storage },
featureFlagReducer
);

const store = configureStore({
reducer: {
courseState: courseReducer,
schedulerUtilState: persistedCourseSchedulerReducer,
authState: persistedAuthReducer,
featureFlagState: persistedFeatureFlagReducer,
},
// this is to disable React Toolkit's error message "A non-serializable value was detected in the state"
// TODO: stop using non-serializable object `authState.wso` and `schedulerUtilState.gapi`
Expand Down
11 changes: 10 additions & 1 deletion src/reducers/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import courseReducer from "./course";
import schedulerUtilReducer from "./schedulerUtils";
import authReducer from "../lib/authSlice";
import featureFlagReducer from "../lib/featureFlagSlice";

import goodrichReducer from "./goodrich";

const rootReducer = {
courseState: courseReducer,
schedulerUtilState: schedulerUtilReducer,
authState: authReducer,
goodrichState: goodrichReducer,
featureFlagState: featureFlagReducer,
};

export default rootReducer;
export { courseReducer, schedulerUtilReducer, authReducer, goodrichReducer };
export {
courseReducer,
schedulerUtilReducer,
authReducer,
goodrichReducer,
featureFlagReducer,
};
4 changes: 4 additions & 0 deletions src/router-permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ const routePermissions = {
tokenLevel: 3,
scopes: [scopes.ScopeGoodrich, scopes.ScopeGoodrichManager],
},
"admin.dashboard": {
tokenLevel: 3,
scopes: [scopes.ScopeAdminAll],
},
};

/**
Expand Down