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