From 86d77df2c9d58db3b3e637b5479ff31807cd599b Mon Sep 17 00:00:00 2001 From: Nuwan Date: Tue, 16 Jul 2024 17:26:48 +0530 Subject: [PATCH] app features on/off add ability to control the visibility of beta site menu items. the visibility state is stored in the back end api and the front end menu items are been shown accordingly. --- jam-ui/.env.development | 3 +- jam-ui/.env.development.example | 3 +- jam-ui/.env.production | 3 +- jam-ui/.env.staging | 3 +- jam-ui/cypress/e2e/layout/navigation.cy.js | 258 ++++++++++++------ jam-ui/cypress/support/e2e.js | 10 + .../src/components/navbar/JKNavbarVertical.js | 15 +- .../components/navbar/NavbarVerticalMenu.js | 2 +- jam-ui/src/context/AppRoutesContext.js | 46 ++++ jam-ui/src/helpers/rest.js | 9 + jam-ui/src/layouts/JKDashboardLayout.js | 18 +- .../20240713160254_create_app_features.rb | 17 ++ ruby/lib/jam_ruby.rb | 1 + ruby/lib/jam_ruby/models/app_feature.rb | 8 + .../api_app_features_controller.rb | 10 + web/app/views/api_app_features/index.rabl | 2 + web/app/views/api_app_features/show.rabl | 3 + web/config/routes.rb | 4 + .../api_app_features_controller_spec.rb | 22 ++ web/spec/factories.rb | 8 + 20 files changed, 341 insertions(+), 104 deletions(-) create mode 100644 jam-ui/src/context/AppRoutesContext.js create mode 100644 ruby/db/migrate/20240713160254_create_app_features.rb create mode 100644 ruby/lib/jam_ruby/models/app_feature.rb create mode 100644 web/app/controllers/api_app_features_controller.rb create mode 100644 web/app/views/api_app_features/index.rabl create mode 100644 web/app/views/api_app_features/show.rabl create mode 100644 web/spec/controllers/api_app_features_controller_spec.rb diff --git a/jam-ui/.env.development b/jam-ui/.env.development index cc2e6087d..2ae774922 100644 --- a/jam-ui/.env.development +++ b/jam-ui/.env.development @@ -4,4 +4,5 @@ REACT_APP_ORIGIN=jamkazam.local REACT_APP_CLIENT_BASE_URL=http://www.jamkazam.local:3000 REACT_APP_API_BASE_URL=http://www.jamkazam.local:3000/api REACT_APP_BITBUCKET_BUILD_NUMBER=dev -REACT_APP_BITBUCKET_COMMIT=dev \ No newline at end of file +REACT_APP_BITBUCKET_COMMIT=dev +REACT_APP_ENV=development \ No newline at end of file diff --git a/jam-ui/.env.development.example b/jam-ui/.env.development.example index cc2e6087d..2ae774922 100644 --- a/jam-ui/.env.development.example +++ b/jam-ui/.env.development.example @@ -4,4 +4,5 @@ REACT_APP_ORIGIN=jamkazam.local REACT_APP_CLIENT_BASE_URL=http://www.jamkazam.local:3000 REACT_APP_API_BASE_URL=http://www.jamkazam.local:3000/api REACT_APP_BITBUCKET_BUILD_NUMBER=dev -REACT_APP_BITBUCKET_COMMIT=dev \ No newline at end of file +REACT_APP_BITBUCKET_COMMIT=dev +REACT_APP_ENV=development \ No newline at end of file diff --git a/jam-ui/.env.production b/jam-ui/.env.production index 4b10dd841..0c627702a 100644 --- a/jam-ui/.env.production +++ b/jam-ui/.env.production @@ -2,4 +2,5 @@ HOST=beta.jamkazam.com PORT=4000 REACT_APP_ORIGIN=jamkazam.com REACT_APP_CLIENT_BASE_URL=https://www.jamkazam.com -REACT_APP_API_BASE_URL=https://www.jamkazam.com/api \ No newline at end of file +REACT_APP_API_BASE_URL=https://www.jamkazam.com/api +REACT_APP_ENV=production \ No newline at end of file diff --git a/jam-ui/.env.staging b/jam-ui/.env.staging index 6d976fa8b..5e7d4b10b 100644 --- a/jam-ui/.env.staging +++ b/jam-ui/.env.staging @@ -2,4 +2,5 @@ HOST=beta.staging.jamkazam.com PORT=4000 REACT_APP_ORIGIN=staging.jamkazam.com REACT_APP_CLIENT_BASE_URL=https://staging.jamkazam.com -REACT_APP_API_BASE_URL=https://staging.jamkazam.com/api \ No newline at end of file +REACT_APP_API_BASE_URL=https://staging.jamkazam.com/api +REACT_APP_ENV=staging \ No newline at end of file diff --git a/jam-ui/cypress/e2e/layout/navigation.cy.js b/jam-ui/cypress/e2e/layout/navigation.cy.js index 6f13ff760..9650bf074 100644 --- a/jam-ui/cypress/e2e/layout/navigation.cy.js +++ b/jam-ui/cypress/e2e/layout/navigation.cy.js @@ -1,120 +1,198 @@ /// -describe("Top Navigation", () => { - +describe('Top Navigation', () => { const showSubscribeToUpdates = () => { - cy.contains('Keep JamKazam Improving').should('exist') - cy.contains('Subscribe').should('exist') - } + cy.contains('Keep JamKazam Improving').should('exist'); + cy.contains('Subscribe').should('exist'); + }; const showProfileDropdown = () => { - cy.get('[data-testid=navbarTopProfileDropdown]').should('exist') - cy.contains("Peter Pan").should('exist') + cy.get('[data-testid=navbarTopProfileDropdown]').should('exist'); + cy.contains('Peter Pan').should('exist'); //cy.contains("My Profile").should('exist') - cy.contains("Sign Out").should('exist') - } + cy.contains('Sign Out').should('exist'); + }; - describe("when user has not logged in", () => { + describe('when user has not logged in', () => { beforeEach(() => { - cy.stubUnauthenticate() + cy.stubUnauthenticate(); }); it('shows homepage', () => { - cy.visit('/') - cy.contains('Home').should('exist') - showSubscribeToUpdates() - }) - - it("not allowed to protected page", () => { - cy.visit('/friends') - cy.url().should('include', '/authentication/basic/login') - cy.contains("Sign in") - cy.get('button').should('have.text', 'Sign in') - cy.get('[data-testid=navbarTopProfileDropdown]').should('not.exist') + cy.visit('/'); + cy.wait('@getAppFeatures'); + cy.contains('Home').should('exist'); + showSubscribeToUpdates(); }); - - }) - describe("when user has logged in", () => { - + it('not allowed to protected page', () => { + cy.visit('/friends'); + cy.wait('@getAppFeatures'); + cy.url().should('include', '/authentication/basic/login'); + cy.contains('Sign in'); + cy.get('button').should('have.text', 'Sign in'); + cy.get('[data-testid=navbarTopProfileDropdown]').should('not.exist'); + }); + }); + + describe('when user has logged in', () => { beforeEach(() => { - cy.stubAuthenticate() - cy.visit('/') + cy.stubAuthenticate(); + cy.visit('/'); + cy.wait('@getAppFeatures'); }); - it("shows user dropdown", () => { - showSubscribeToUpdates() - showProfileDropdown() - }) + it('shows user dropdown', () => { + showSubscribeToUpdates(); + showProfileDropdown(); + }); it('sign out', () => { - cy.get('[data-testid=navbarTopProfileDropdown]').contains('Peter Pan').click() - cy.stubUnauthenticate() - cy.get('[data-testid=navbarTopProfileDropdown]').contains('Sign Out').click() - cy.get('[data-testid=navbarTopProfileDropdown]').should('not.exist') - cy.contains("Home") - }) - }) + cy.get('[data-testid=navbarTopProfileDropdown]') + .contains('Peter Pan') + .click(); + cy.stubUnauthenticate(); + cy.get('[data-testid=navbarTopProfileDropdown]') + .contains('Sign Out') + .click(); + cy.get('[data-testid=navbarTopProfileDropdown]').should('not.exist'); + cy.contains('Home'); + }); + }); describe('header notifications', () => { beforeEach(() => { - cy.stubAuthenticate() - cy.intercept('GET', /\S+\/notifications/, { fixture: 'notifications'} ) + cy.stubAuthenticate(); + cy.intercept('GET', /\S+\/notifications/, { fixture: 'notifications' }); cy.intercept('GET', /\S+\/profile\S+/, { fixture: 'person' }); - cy.visit('/') - }) + cy.visit('/'); + cy.wait('@getAppFeatures'); + }); it('shows notifications', () => { - cy.get('[data-testid=notificationDropdown]').should('not.be.visible') - cy.get('.notification-indicator').click() - cy.get('[data-testid=notificationDropdown]').should('be.visible') - cy.get('[data-testid=notificationDropdown] .list-group-item').should('have.length', 3) - cy.get('[data-testid=notificationDropdown]').contains('View all').click() //view all notifications - cy.url().should('include', '/notifications') - }) - }) + cy.get('[data-testid=notificationDropdown]').should('not.be.visible'); + cy.get('.notification-indicator').click(); + cy.get('[data-testid=notificationDropdown]').should('be.visible'); + cy.get('[data-testid=notificationDropdown] .list-group-item').should('have.length', 3); + cy.get('[data-testid=notificationDropdown]') + .contains('View all') + .click(); //view all notifications + cy.url().should('include', '/notifications'); + }); + }); describe('locale switch', () => { beforeEach(() => { - cy.stubAuthenticate() - cy.visit('/') - }) - - it("translate", () => { - cy.get('.card-header').contains('Home') - cy.get('[data-testid=langSwitch]').contains('ES').click() - cy.get('.card-header').contains('Página de inicio') - cy.get('.card-header').should('not.contain', 'Home') - cy.get('[data-testid=langSwitch]').contains('EN').click() - cy.get('.card-header').contains('Home') - cy.get('.card-header').should('not.contain', 'Página de inicio') - }) - }) - - describe('left side navigation', () => { - beforeEach(() => { - cy.viewport('macbook-13'); - cy.stubAuthenticate() - cy.visit('/') - }) - - it('shows all main and sub menu items opened by default', () => { - cy.get('[data-testid=verticalNavigation]' ).contains('Sessions') - cy.get('[data-testid=verticalNavigation]' ).contains('Create Session') - cy.get('[data-testid=verticalNavigation]' ).contains('Browse Current Sessions') - cy.get('[data-testid=verticalNavigation]' ).contains('View Session History') - }) - - it.only('toggles only one menu on click', () => { - cy.get('[data-testid=verticalNavigation]' ).contains('Sessions').click() - cy.get('[data-testid=verticalNavigation]' ).contains('Create Session').should('not.visible') - cy.get('[data-testid=verticalNavigation]' ).contains('Browse Current Sessions').should('not.visible') - cy.get('[data-testid=verticalNavigation]' ).contains('View Session History').should('not.visible') - //the Friends menu is not toggled - cy.get('[data-testid=verticalNavigation]' ).contains('Friends') - cy.get('[data-testid=verticalNavigation]' ).contains('My Friends') - }) - }) + cy.stubAuthenticate(); + cy.visit('/'); + cy.wait('@getAppFeatures'); + }); + it('translate', () => { + cy.get('.card-header').contains('Home'); + cy.get('[data-testid=langSwitch]') + .contains('ES') + .click(); + cy.get('.card-header').contains('Página de inicio'); + cy.get('.card-header').should('not.contain', 'Home'); + cy.get('[data-testid=langSwitch]') + .contains('EN') + .click(); + cy.get('.card-header').contains('Home'); + cy.get('.card-header').should('not.contain', 'Página de inicio'); + }); + }); + }); +describe('Side Navigation', () => { + describe('backend returns empty set of app features', () => { + beforeEach(() => { + cy.stubAuthenticate(); + cy.viewport('macbook-13'); + }); + + it('shows all menu items', () => { + cy.visit('/'); + cy.wait('@getAppFeatures'); + cy.get('[data-testid=verticalNavigation]').contains('Home'); + cy.get('[data-testid=verticalNavigation]').contains('Sessions'); + cy.get('[data-testid=verticalNavigation]').contains('Create Session'); + cy.get('[data-testid=verticalNavigation]').contains('Friends'); + }); + + it('toggles only one menu on click', () => { + cy.visit('/'); + cy.wait('@getAppFeatures'); + cy.get('[data-testid=verticalNavigation]') + .contains('Sessions') + .click(); + cy.get('[data-testid=verticalNavigation]') + .contains('Create Session') + .should('not.visible'); + cy.get('[data-testid=verticalNavigation]') + .contains('Browse Sessions') + .should('not.visible'); + cy.get('[data-testid=verticalNavigation]') + .contains('Session History') + .should('not.visible'); + //the Friends menu is not toggled + cy.get('[data-testid=verticalNavigation]').contains('Friends'); + cy.get('[data-testid=verticalNavigation]').contains('My Friends'); + }); + + }); + + describe('backend returns app features', () => { + beforeEach(() => { + cy.stubAuthenticate(); + cy.viewport('macbook-13'); + cy.intercept('GET', /\S+\/app_features/, { + statusCode: 200, + body: [ + { + handle: '/sessions', + is_enabled: true, + feature_type: 'page', + env: 'development' + }, + { + handle: '/sessions/new', + is_enabled: false, + feature_type: 'page', + env: 'development' + }, + { + handle: '/sessions/history', + is_enabled: true, + feature_type: 'page', + env: 'production' + }, + { + handle: '/friends', + is_enabled: true, + feature_type: 'page', + env: 'development' + }, + { + handle: '/friends/my', + is_enabled: true, + feature_type: 'page', + env: 'development' + } + ] + }).as('getAppFeatures'); + }); + + it('shows only enabled menu items', () => { + cy.visit('/'); + cy.wait('@getAppFeatures'); + cy.get('[data-testid=verticalNavigation]').contains('Friends'); + cy.get('[data-testid=verticalNavigation]').contains('Sessions'); + cy.get('[data-testid=verticalNavigation]').should('not.include.text', 'Create Session') + cy.get('[data-testid=verticalNavigation]').should('not.include.text', 'Session History') + }); + + + }) +}); diff --git a/jam-ui/cypress/support/e2e.js b/jam-ui/cypress/support/e2e.js index 4c0cb055a..3a28a93ed 100644 --- a/jam-ui/cypress/support/e2e.js +++ b/jam-ui/cypress/support/e2e.js @@ -20,4 +20,14 @@ import './commands' // require('./commands') +beforeEach(() => { + // Intercept the GET /app_features request and return an empty array + // to simulate the backend returning an empty set of app features + cy.intercept('GET', /\S+\/app_features/, { + statusCode: 200, + body: [], + }).as('getAppFeatures'); +}); + + diff --git a/jam-ui/src/components/navbar/JKNavbarVertical.js b/jam-ui/src/components/navbar/JKNavbarVertical.js index e29b1d9a1..3af5f4565 100644 --- a/jam-ui/src/components/navbar/JKNavbarVertical.js +++ b/jam-ui/src/components/navbar/JKNavbarVertical.js @@ -1,20 +1,31 @@ import classNames from 'classnames'; import is from 'is_js'; import PropTypes from 'prop-types'; -import React, { useContext, useEffect, useRef } from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import { Collapse, Nav, Navbar } from 'reactstrap'; import bgNavbarImg from '../../assets/img/generic/bg-navbar.png'; import { navbarBreakPoint, topNavbarBreakpoint } from '../../config'; import AppContext from '../../context/Context'; -import routes from '../../routes'; +//import routes from '../../routes'; import Flex from '../common/Flex'; import Logo from './Logo'; import NavbarTopDropDownMenus from './NavbarTopDropDownMenus'; import NavbarVerticalMenu from './NavbarVerticalMenu'; //import ToggleButton from './ToggleButton'; +import { useAppRoutes } from '../../context/AppRoutesContext'; + const JKNavbarVertical = ({ navbarStyle }) => { + + const { appRoutes } = useAppRoutes(); + const [routes, setRoutes] = useState([]); + + useEffect(() => { + if(appRoutes.length > 0){ + setRoutes(appRoutes); + } + }, [appRoutes]); const navBarRef = useRef(null); const { diff --git a/jam-ui/src/components/navbar/NavbarVerticalMenu.js b/jam-ui/src/components/navbar/NavbarVerticalMenu.js index c064a1676..32305ead6 100644 --- a/jam-ui/src/components/navbar/NavbarVerticalMenu.js +++ b/jam-ui/src/components/navbar/NavbarVerticalMenu.js @@ -16,7 +16,7 @@ const NavbarVerticalMenu = ({ routes, location }) => { navs.push({ ...route, isOpened: true, index }) }); setNavRoutes(navs) - }, []); + }, [routes]); const toggleOpened = (e, route) => { e.preventDefault(); diff --git a/jam-ui/src/context/AppRoutesContext.js b/jam-ui/src/context/AppRoutesContext.js new file mode 100644 index 000000000..14feedf52 --- /dev/null +++ b/jam-ui/src/context/AppRoutesContext.js @@ -0,0 +1,46 @@ +import React from 'react'; +import routes from '../routes'; +import { getAppFeatures } from '../helpers/rest'; + +const AppRoutesContext = React.createContext(null); + +export const AppRoutesProvider = ({ children }) => { + const [appRoutes, setAppRoutes] = React.useState([]); + + React.useEffect(() => { + if (appRoutes.length === 0) { + const env = process.env.REACT_APP_ENV; + getAppFeatures(env) + .then(response => { + if (response) { + return response.json(); + } + }) + .then(features => { + if (features.length === 0) { + setAppRoutes(routes); + } else { + const _routes = routes.filter(route => { + if(route.children && route.children.length > 0) { + route.children = route.children.filter(child => { + return presentInNav(features, child); + }); + } + return presentInNav(features, route); + }); + setAppRoutes(_routes); + } + }); + } + }, []); + + const presentInNav = (features, route) => { + return features.find( + feature => route.to === feature.handle && feature.is_enabled && feature.feature_type === 'page' && feature.env === process.env.REACT_APP_ENV + ); + }; + + return {children}; +}; + +export const useAppRoutes = () => React.useContext(AppRoutesContext); diff --git a/jam-ui/src/helpers/rest.js b/jam-ui/src/helpers/rest.js index 374eec789..6a16362eb 100644 --- a/jam-ui/src/helpers/rest.js +++ b/jam-ui/src/helpers/rest.js @@ -415,4 +415,13 @@ export const addJamtrackToShoppingCart = (options = {}) => { .then(response => resolve(response)) .catch(error => reject(error)); }); +} + + +export const getAppFeatures = (env) => { + return new Promise((resolve, reject) => { + apiFetch(`/app_features?env=${env}`) + .then(response => resolve(response)) + .catch(error => reject(error)); + }); } \ No newline at end of file diff --git a/jam-ui/src/layouts/JKDashboardLayout.js b/jam-ui/src/layouts/JKDashboardLayout.js index 94bb2b65c..3df4cce0e 100644 --- a/jam-ui/src/layouts/JKDashboardLayout.js +++ b/jam-ui/src/layouts/JKDashboardLayout.js @@ -9,6 +9,8 @@ import { NativeAppProvider } from '../context/NativeAppContext'; import { JKLobbyChatProvider } from '../components/sessions/JKLobbyChatContext'; +import { AppRoutesProvider } from '../context/AppRoutesContext'; + const DashboardLayout = ({ location }) => { useEffect(() => { window.scrollTo(0, 0); @@ -16,13 +18,15 @@ const DashboardLayout = ({ location }) => { return ( - - - - - - - + + + + + + + + + ); }; diff --git a/ruby/db/migrate/20240713160254_create_app_features.rb b/ruby/db/migrate/20240713160254_create_app_features.rb new file mode 100644 index 000000000..1bbf914c8 --- /dev/null +++ b/ruby/db/migrate/20240713160254_create_app_features.rb @@ -0,0 +1,17 @@ + class CreateAppFeatures < ActiveRecord::Migration + def self.up + execute(<<-SQL + CREATE TABLE public.app_features ( + id character varying(64) DEFAULT public.uuid_generate_v4() PRIMARY KEY NOT NULL, + feature_type character varying(64) NOT NULL, + handle character varying(1024) NOT NULL, + is_enabled boolean DEFAULT false NOT NULL, + env character varying(16) DEFAULT 'development' NOT NULL + ); + SQL + ) + end + def self.down + execute("DROP TABLE public.app_features") + end + end diff --git a/ruby/lib/jam_ruby.rb b/ruby/lib/jam_ruby.rb index 430d7435e..789a5e6cb 100755 --- a/ruby/lib/jam_ruby.rb +++ b/ruby/lib/jam_ruby.rb @@ -345,6 +345,7 @@ require "jam_ruby/models/ad_campaign" require "jam_ruby/models/user_asset" require "jam_ruby/models/user_match_email_sending" require "jam_ruby/models/app_interaction" +require "jam_ruby/models/app_feature" include Jampb diff --git a/ruby/lib/jam_ruby/models/app_feature.rb b/ruby/lib/jam_ruby/models/app_feature.rb new file mode 100644 index 000000000..1422d8476 --- /dev/null +++ b/ruby/lib/jam_ruby/models/app_feature.rb @@ -0,0 +1,8 @@ +module JamRuby + class AppFeature < ActiveRecord::Base + self.primary_key = 'id' + self.table_name = 'app_features' + + attr_accessible :feature_type, :handle, :is_enabled, :env + end +end \ No newline at end of file diff --git a/web/app/controllers/api_app_features_controller.rb b/web/app/controllers/api_app_features_controller.rb new file mode 100644 index 000000000..85c4cf29c --- /dev/null +++ b/web/app/controllers/api_app_features_controller.rb @@ -0,0 +1,10 @@ +class ApiAppFeaturesController < ApiController + before_filter :api_signed_in_user + + respond_to :json + + def index + env = params[:env] || 'development' + @app_features = AppFeature.where(env: env) + end +end \ No newline at end of file diff --git a/web/app/views/api_app_features/index.rabl b/web/app/views/api_app_features/index.rabl new file mode 100644 index 000000000..9ed33faa8 --- /dev/null +++ b/web/app/views/api_app_features/index.rabl @@ -0,0 +1,2 @@ +object @app_features +extends "api_app_features/show" \ No newline at end of file diff --git a/web/app/views/api_app_features/show.rabl b/web/app/views/api_app_features/show.rabl new file mode 100644 index 000000000..7e2dc190b --- /dev/null +++ b/web/app/views/api_app_features/show.rabl @@ -0,0 +1,3 @@ +object @app_feature + +attributes :id, :feature_type, :handle, :is_enabled, :env \ No newline at end of file diff --git a/web/config/routes.rb b/web/config/routes.rb index ff2f89a41..e2a54add7 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -859,5 +859,9 @@ Rails.application.routes.draw do match 'jamblasters/pairing/login' => 'api_jamblasters#login', :via => :post match 'jamblasters/pairing/store' => 'api_jamblasters#store_token', :via => :post match 'jamblasters/pairing/pair' => 'api_jamblasters#pair', :via => :post + + + #app features + match '/app_features' => 'api_app_features#index', :via => :get end end diff --git a/web/spec/controllers/api_app_features_controller_spec.rb b/web/spec/controllers/api_app_features_controller_spec.rb new file mode 100644 index 000000000..e5df9150b --- /dev/null +++ b/web/spec/controllers/api_app_features_controller_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe ApiAppFeaturesController, type: :controller do + render_views + + let(:user) { FactoryGirl.create(:user) } + + before(:each) do + controller.current_user = user + end + + describe "index" do + it "returns app features of env" do + FactoryGirl.create(:app_feature, env: 'production') + get :index, env: 'production' + response.should be_success + json = JSON.parse(response.body, :symbolize_names => true) + json.should have(1).item + end + end + +end \ No newline at end of file diff --git a/web/spec/factories.rb b/web/spec/factories.rb index fc04aafc4..1efd4844d 100644 --- a/web/spec/factories.rb +++ b/web/spec/factories.rb @@ -1125,4 +1125,12 @@ FactoryGirl.define do asset_type "image" filename "image.jpg" end + + factory :app_feature, class: "JamRuby::AppFeature" do + feature_type "page" + handle "/" + env "development" + is_enabled true + end + end