Skip to content

Commit

Permalink
feat: Add front end feature flags
Browse files Browse the repository at this point in the history
* Update config with new feature flags
* Add new helper function for checking if one is enabled
* Follow the withRole pattern to add a withFeatureFlag HOC
  • Loading branch information
dhaselhan committed Dec 4, 2024
1 parent 3e83e7c commit d57a70a
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 29 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ __pycache__/
*.py[cod]
*$py.class
docs/
.DS_Store

# C extensions
*.so
Expand Down
4 changes: 4 additions & 0 deletions frontend/public/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export const config = {
POST_LOGOUT_URL: 'http://localhost:3000/',
SM_LOGOUT_URL:
'https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl='
},
feature_flags: {
supplementalReporting: true,
notifications: false
}
}

Expand Down
14 changes: 14 additions & 0 deletions frontend/src/constants/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export function getApiBaseUrl() {
return window.lcfs_config.api_base ?? baseUrl
}

export const isFeatureEnabled = (featureFlag) => {
return CONFIG.feature_flags[featureFlag]
}

export const FEATURE_FLAGS = {
SUPPLEMENTAL_REPORTING: 'supplementalReporting',
NOTIFICATIONS: 'notifications'
}

export const CONFIG = {
API_BASE: getApiBaseUrl(),
KEYCLOAK: {
Expand All @@ -42,5 +51,10 @@ export const CONFIG = {
SM_LOGOUT_URL:
window.lcfs_config.keycloak.SM_LOGOUT_URL ??
'https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi?retnow=1&returl='
},
feature_flags: {
supplementalReporting:
window.lcfs_config.feature_flags.supplementalReporting ?? true,
notifications: window.lcfs_config.feature_flags.notifications ?? false
}
}
109 changes: 109 additions & 0 deletions frontend/src/utils/__tests__/withFeatureFlag.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import withFeatureFlag from '../withFeatureFlag.jsx' // Adjust the import path as necessary
import { isFeatureEnabled } from '@/constants/config.js'

// Mock the isFeatureEnabled function
vi.mock('@/constants/config.js', () => ({
isFeatureEnabled: vi.fn()
}))

// Mock Navigate component
vi.mock('react-router-dom', () => ({
...vi.importActual('react-router-dom'),
Navigate: ({ to }) => <div data-test="navigate">Navigate to {to}</div>
}))

// Define a mock component to be wrapped
const MockComponent = () => <div>Feature Enabled Content</div>

describe('withFeatureFlag HOC', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('renders the wrapped component when the feature flag is enabled', () => {
isFeatureEnabled.mockReturnValue(true)

const WrappedComponent = withFeatureFlag(
MockComponent,
'new-feature',
'/fallback'
)

render(<WrappedComponent />)

expect(screen.getByText('Feature Enabled Content')).toBeInTheDocument()
})

it('redirects to the specified path when the feature flag is disabled and redirect is provided', () => {
isFeatureEnabled.mockReturnValue(false)

const WrappedComponent = withFeatureFlag(
MockComponent,
'new-feature',
'/fallback'
)

render(<WrappedComponent />)

const navigateElement = screen.getByTestId('navigate')
expect(navigateElement).toBeInTheDocument()
expect(navigateElement).toHaveTextContent('Navigate to /fallback')
})

it('renders null when the feature flag is disabled and no redirect is provided', () => {
isFeatureEnabled.mockReturnValue(false)

const WrappedComponent = withFeatureFlag(MockComponent, 'new-feature')

const { container } = render(<WrappedComponent />)

expect(container.firstChild).toBeNull()
})

it('sets the correct display name for the wrapped component', () => {
isFeatureEnabled.mockReturnValue(true)

const WrappedComponent = withFeatureFlag(
MockComponent,
'new-feature',
'/fallback'
)

render(<WrappedComponent />)

expect(WrappedComponent.displayName).toBe('WithFeatureFlag(MockComponent)')
})

it('handles undefined featureFlag gracefully by rendering the wrapped component', () => {
isFeatureEnabled.mockReturnValue(false)

const WrappedComponent = withFeatureFlag(
MockComponent,
undefined,
'/fallback'
)

render(<WrappedComponent />)

const navigateElement = screen.getByTestId('navigate')
expect(navigateElement).toBeInTheDocument()
expect(navigateElement).toHaveTextContent('Navigate to /fallback')
})

it('handles null props correctly by passing them to the wrapped component', () => {
isFeatureEnabled.mockReturnValue(true)

const WrappedComponent = withFeatureFlag(
MockComponent,
'new-feature',
'/fallback'
)

render(<WrappedComponent prop1={null} />)

expect(screen.getByText('Feature Enabled Content')).toBeInTheDocument()
})
})
138 changes: 137 additions & 1 deletion frontend/src/utils/__tests__/withRole.test.jsx
Original file line number Diff line number Diff line change
@@ -1 +1,137 @@
describe.todo()
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import withRole from '../withRole.jsx'
import { useCurrentUser } from '@/hooks/useCurrentUser'

// Mock the useCurrentUser hook
vi.mock('@/hooks/useCurrentUser')

// Mock Navigate component
vi.mock('react-router-dom', () => ({
...vi.importActual('react-router-dom'),
Navigate: ({ to }) => <div data-test="navigate">Navigate to {to}</div>
}))

// Define a mock component to be wrapped
const MockComponent = () => <div>Protected Content</div>

describe('withRole HOC', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('renders Loading... when currentUser is undefined', () => {
useCurrentUser.mockReturnValue({
data: undefined
})

const WrappedComponent = withRole(
MockComponent,
['admin', 'user'],
'/login'
)

render(<WrappedComponent />)

expect(screen.getByText('Loading...')).toBeInTheDocument()
})

it('renders the wrapped component when user has an allowed role', () => {
useCurrentUser.mockReturnValue({
data: {
roles: [{ name: 'user' }, { name: 'editor' }]
}
})

const WrappedComponent = withRole(
MockComponent,
['admin', 'user'],
'/login'
)

render(<WrappedComponent />)

expect(screen.getByText('Protected Content')).toBeInTheDocument()
})

it('redirects to the specified path when user does not have an allowed role and redirect is provided', () => {
useCurrentUser.mockReturnValue({
data: {
roles: [{ name: 'guest' }]
}
})

const WrappedComponent = withRole(
MockComponent,
['admin', 'user'],
'/login'
)

render(<WrappedComponent />)

const navigateElement = screen.getByTestId('navigate')
expect(navigateElement).toBeInTheDocument()
expect(navigateElement).toHaveTextContent('Navigate to /login')
})

it('renders null when user does not have an allowed role and no redirect is provided', () => {
useCurrentUser.mockReturnValue({
data: {
roles: [{ name: 'guest' }]
}
})

const WrappedComponent = withRole(MockComponent, ['admin', 'user'])

const { container } = render(<WrappedComponent />)

expect(container.firstChild).toBeNull()
})

it('sets the correct display name for the wrapped component', () => {
useCurrentUser.mockReturnValue({
data: {
roles: [{ name: 'admin' }]
}
})

const WrappedComponent = withRole(MockComponent, ['admin'], '/login')

render(<WrappedComponent />)

expect(WrappedComponent.displayName).toBe('WithRole(MockComponent)')
})

it('handles currentUser with no roles gracefully', () => {
useCurrentUser.mockReturnValue({
data: {
roles: []
}
})

const WrappedComponent = withRole(MockComponent, ['admin'], '/login')

render(<WrappedComponent />)

const navigateElement = screen.getByTestId('navigate')
expect(navigateElement).toBeInTheDocument()
expect(navigateElement).toHaveTextContent('Navigate to /login')
})

it('handles currentUser.roles being undefined gracefully', () => {
useCurrentUser.mockReturnValue({
data: {
// roles is undefined
}
})

const WrappedComponent = withRole(MockComponent, ['admin'], '/login')

render(<WrappedComponent />)

const navigateElement = screen.getByTestId('navigate')
expect(navigateElement).toBeInTheDocument()
expect(navigateElement).toHaveTextContent('Navigate to /login')
})
})
26 changes: 26 additions & 0 deletions frontend/src/utils/withFeatureFlag.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Navigate } from 'react-router-dom'
import { isFeatureEnabled } from '@/constants/config.js'

export const withFeatureFlag = (WrappedComponent, featureFlag, redirect) => {
const WithFeatureFlag = (props) => {
const isEnabled = isFeatureEnabled(featureFlag)

if (!isEnabled && redirect) {
return <Navigate to={redirect} />
}
if (!isEnabled && !redirect) {
return null
}

return <WrappedComponent {...props} />
}

// Display name for the wrapped component
WithFeatureFlag.displayName = `WithFeatureFlag(${
WrappedComponent.displayName || WrappedComponent.name || 'Component'
})`

return WithFeatureFlag
}

export default withFeatureFlag
56 changes: 29 additions & 27 deletions frontend/src/views/ComplianceReports/components/AssessmentCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useNavigate } from 'react-router-dom'
import { StyledListItem } from '@/components/StyledListItem'
import { roles } from '@/constants/roles'
import { Role } from '@/components/Role'
import { FEATURE_FLAGS, isFeatureEnabled } from '@/constants/config.js'

export const AssessmentCard = ({
orgData,
Expand Down Expand Up @@ -173,7 +174,7 @@ export const AssessmentCard = ({
variant="h6"
color="primary"
>
{t(`report:reportHistory`)}
{t('report:reportHistory')}
</Typography>
<List sx={{ padding: 0 }}>
{filteredHistory.map((item, index) => (
Expand Down Expand Up @@ -202,33 +203,34 @@ export const AssessmentCard = ({
</>
)}
<Role roles={[roles.supplier]}>
{currentStatus === COMPLIANCE_REPORT_STATUSES.ASSESSED && (
<>
<Typography
sx={{ paddingTop: '16px' }}
component="div"
variant="body4"
>
{t('report:supplementalWarning')}
</Typography>
<Box>
<BCButton
data-test="create-supplemental"
size="large"
variant="contained"
color="primary"
onClick={() => {
createSupplementalReport()
}}
startIcon={<AssignmentIcon />}
sx={{ mt: 2 }}
disabled={isLoading}
{isFeatureEnabled(FEATURE_FLAGS.SUPPLEMENTAL_REPORTING) &&
currentStatus === COMPLIANCE_REPORT_STATUSES.ASSESSED && (
<>
<Typography
sx={{ paddingTop: '16px' }}
component="div"
variant="body4"
>
{t('report:createSupplementalRptBtn')}
</BCButton>
</Box>
</>
)}
{t('report:supplementalWarning')}
</Typography>
<Box>
<BCButton
data-test="create-supplemental"
size="large"
variant="contained"
color="primary"
onClick={() => {
createSupplementalReport()
}}
startIcon={<AssignmentIcon />}
sx={{ mt: 2 }}
disabled={isLoading}
>
{t('report:createSupplementalRptBtn')}
</BCButton>
</Box>
</>
)}
</Role>
<Role roles={[roles.analyst]}>
<Box>
Expand Down
Loading

0 comments on commit d57a70a

Please sign in to comment.