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