diff --git a/.gitignore b/.gitignore index 81b02a92e..e437ac444 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ *.py[cod] *$py.class docs/ +.DS_Store # C extensions *.so diff --git a/frontend/public/config/config.js b/frontend/public/config/config.js index 01cbed624..a53256ce3 100644 --- a/frontend/public/config/config.js +++ b/frontend/public/config/config.js @@ -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 } } diff --git a/frontend/src/constants/config.js b/frontend/src/constants/config.js index 2763bbcf2..8dfec5e6c 100644 --- a/frontend/src/constants/config.js +++ b/frontend/src/constants/config.js @@ -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: { @@ -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 } } diff --git a/frontend/src/utils/__tests__/withFeatureFlag.test.jsx b/frontend/src/utils/__tests__/withFeatureFlag.test.jsx new file mode 100644 index 000000000..b619a1f25 --- /dev/null +++ b/frontend/src/utils/__tests__/withFeatureFlag.test.jsx @@ -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 }) =>
Navigate to {to}
+})) + +// Define a mock component to be wrapped +const MockComponent = () =>
Feature Enabled Content
+ +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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getByText('Feature Enabled Content')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/utils/__tests__/withRole.test.jsx b/frontend/src/utils/__tests__/withRole.test.jsx index 1d74ce903..350efd3ca 100644 --- a/frontend/src/utils/__tests__/withRole.test.jsx +++ b/frontend/src/utils/__tests__/withRole.test.jsx @@ -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 }) =>
Navigate to {to}
+})) + +// Define a mock component to be wrapped +const MockComponent = () =>
Protected Content
+ +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() + + 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() + + 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() + + 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() + + 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() + + expect(WrappedComponent.displayName).toBe('WithRole(MockComponent)') + }) + + it('handles currentUser with no roles gracefully', () => { + useCurrentUser.mockReturnValue({ + data: { + roles: [] + } + }) + + const WrappedComponent = withRole(MockComponent, ['admin'], '/login') + + render() + + 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() + + const navigateElement = screen.getByTestId('navigate') + expect(navigateElement).toBeInTheDocument() + expect(navigateElement).toHaveTextContent('Navigate to /login') + }) +}) diff --git a/frontend/src/utils/withFeatureFlag.jsx b/frontend/src/utils/withFeatureFlag.jsx new file mode 100644 index 000000000..55b536f17 --- /dev/null +++ b/frontend/src/utils/withFeatureFlag.jsx @@ -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 + } + if (!isEnabled && !redirect) { + return null + } + + return + } + + // Display name for the wrapped component + WithFeatureFlag.displayName = `WithFeatureFlag(${ + WrappedComponent.displayName || WrappedComponent.name || 'Component' + })` + + return WithFeatureFlag +} + +export default withFeatureFlag diff --git a/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx b/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx index 65be99cc1..339b22133 100644 --- a/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx +++ b/frontend/src/views/ComplianceReports/components/AssessmentCard.jsx @@ -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, @@ -173,7 +174,7 @@ export const AssessmentCard = ({ variant="h6" color="primary" > - {t(`report:reportHistory`)} + {t('report:reportHistory')} {filteredHistory.map((item, index) => ( @@ -202,33 +203,34 @@ export const AssessmentCard = ({ )} - {currentStatus === COMPLIANCE_REPORT_STATUSES.ASSESSED && ( - <> - - {t('report:supplementalWarning')} - - - { - createSupplementalReport() - }} - startIcon={} - sx={{ mt: 2 }} - disabled={isLoading} + {isFeatureEnabled(FEATURE_FLAGS.SUPPLEMENTAL_REPORTING) && + currentStatus === COMPLIANCE_REPORT_STATUSES.ASSESSED && ( + <> + - {t('report:createSupplementalRptBtn')} - - - - )} + {t('report:supplementalWarning')} + + + { + createSupplementalReport() + }} + startIcon={} + sx={{ mt: 2 }} + disabled={isLoading} + > + {t('report:createSupplementalRptBtn')} + + + + )} diff --git a/frontend/src/views/Notifications/Notifications.jsx b/frontend/src/views/Notifications/Notifications.jsx index 5841ee3c7..da555141f 100644 --- a/frontend/src/views/Notifications/Notifications.jsx +++ b/frontend/src/views/Notifications/Notifications.jsx @@ -1,3 +1,14 @@ -export const Notifications = () => { +import * as ROUTES from '@/constants/routes/routes.js' +import withFeatureFlag from '@/utils/withFeatureFlag.jsx' +import { FEATURE_FLAGS } from '@/constants/config.js' + +export const NotificationsBase = () => { return
Notifications
} + +export const Notifications = withFeatureFlag( + NotificationsBase, + FEATURE_FLAGS.NOTIFICATIONS, + ROUTES.DASHBOARD +) +Notifications.displayName = 'Notifications'