From 28e0582a539281a16b28cd92f6951f53421bf800 Mon Sep 17 00:00:00 2001 From: Daniel Haselhan Date: Tue, 7 Jan 2025 15:02:31 -0800 Subject: [PATCH 1/2] Add Legacy Fuels * Migrate over legacy Fuel Types from TFRS and set is_legacy flag. --- .../versions/2025-01-06-22-09_fa98709e7952.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 backend/lcfs/db/migrations/versions/2025-01-06-22-09_fa98709e7952.py diff --git a/backend/lcfs/db/migrations/versions/2025-01-06-22-09_fa98709e7952.py b/backend/lcfs/db/migrations/versions/2025-01-06-22-09_fa98709e7952.py new file mode 100644 index 000000000..2b815de67 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2025-01-06-22-09_fa98709e7952.py @@ -0,0 +1,177 @@ +"""Add legacy fuel types + +Revision ID: fa98709e7952 +Revises: 94306eca5261 +Create Date: 2025-01-06 22:09:52.936619 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "fa98709e7952" +down_revision = "94306eca5261" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + INSERT INTO fuel_type ( + fuel_type, + fossil_derived, + other_uses_fossil_derived, + default_carbon_intensity, + units, + unrecognized, + create_user, + update_user, + is_legacy + ) + VALUES + -- 1) Natural gas-based gasoline + ( + 'Natural gas-based gasoline', + FALSE, + TRUE, + 90.07, + 'Litres', + FALSE, + 'no_user', + 'no_user', + TRUE + ), + -- 2) Petroleum-based diesel + ( + 'Petroleum-based diesel', + FALSE, + TRUE, + 94.76, + 'Litres', + FALSE, + 'no_user', + 'no_user', + TRUE + ), + -- 3) Petroleum-based gasoline + ( + 'Petroleum-based gasoline', + FALSE, + TRUE, + 88.14, + 'Litres', + FALSE, + 'no_user', + 'no_user', + TRUE + ); + """ + ) + + op.execute( + """ + INSERT INTO energy_density ( + fuel_type_id, + density, + uom_id, + create_user, + update_user + ) + SELECT + ft.fuel_type_id, + CASE + WHEN ft.fuel_type = 'Natural gas-based gasoline' THEN 34.69 + WHEN ft.fuel_type = 'Petroleum-based diesel' THEN 38.65 + WHEN ft.fuel_type = 'Petroleum-based gasoline' THEN 34.69 + END AS density, + 1 AS uom_id, + 'no_user' AS create_user, + 'no_user' AS update_user + FROM fuel_type ft + WHERE ft.fuel_type IN ( + 'Natural gas-based gasoline', + 'Petroleum-based diesel', + 'Petroleum-based gasoline' + ); + """ + ) + + op.execute( + """ + INSERT INTO energy_effectiveness_ratio ( + fuel_category_id, + fuel_type_id, + end_use_type_id, + ratio, + create_user, + update_user, + effective_date, + effective_status, + expiration_date + ) + SELECT + CASE + WHEN ft.fuel_type = 'Petroleum-based diesel' THEN 2 + ELSE 1 + END AS fuel_category_id, + ft.fuel_type_id, + NULL AS end_use_type_id, + 1.0 AS ratio, + 'no_user' AS create_user, + 'no_user' AS update_user, + CURRENT_DATE AS effective_date, + TRUE AS effective_status, + NULL AS expiration_date + FROM fuel_type ft + WHERE ft.fuel_type IN ( + 'Natural gas-based gasoline', + 'Petroleum-based diesel', + 'Petroleum-based gasoline' + ); + """ + ) + + +def downgrade(): + op.execute( + """ + DELETE FROM energy_effectiveness_ratio + WHERE fuel_type_id IN ( + SELECT fuel_type_id + FROM fuel_type + WHERE fuel_type IN ( + 'Natural gas-based gasoline', + 'Petroleum-based diesel', + 'Petroleum-based gasoline' + ) + ); + """ + ) + + op.execute( + """ + DELETE FROM energy_density + WHERE fuel_type_id IN ( + SELECT fuel_type_id + FROM fuel_type + WHERE fuel_type IN ( + 'Natural gas-based gasoline', + 'Petroleum-based diesel', + 'Petroleum-based gasoline' + ) + ); + """ + ) + + op.execute( + """ + DELETE FROM fuel_type + WHERE fuel_type IN ( + 'Natural gas-based gasoline', + 'Petroleum-based diesel', + 'Petroleum-based gasoline' + ); + """ + ) From 3c6861856a2f19c4055d9064e4f27f2d4ad47561 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Date: Thu, 9 Jan 2025 15:42:51 -0800 Subject: [PATCH 2/2] feat: add user settings widget to the dashboard --- frontend/src/assets/locales/en/dashboard.json | 12 ++ frontend/src/views/Dashboard/Dashboard.jsx | 19 ++- .../cards/bceid/OrgUserSettingsCard.jsx | 79 +++++++++ .../__tests__/OrgUserSettingsCard.test.jsx | 160 ++++++++++++++++++ .../cards/idir/UserSettingsCard.jsx | 79 +++++++++ .../idir/__tests__/UserSettingsCard.test.jsx | 160 ++++++++++++++++++ .../views/Dashboard/components/cards/index.js | 2 + 7 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 frontend/src/views/Dashboard/components/cards/bceid/OrgUserSettingsCard.jsx create mode 100644 frontend/src/views/Dashboard/components/cards/bceid/__tests__/OrgUserSettingsCard.test.jsx create mode 100644 frontend/src/views/Dashboard/components/cards/idir/UserSettingsCard.jsx create mode 100644 frontend/src/views/Dashboard/components/cards/idir/__tests__/UserSettingsCard.test.jsx diff --git a/frontend/src/assets/locales/en/dashboard.json b/frontend/src/assets/locales/en/dashboard.json index c88fa945b..7ec7521dc 100644 --- a/frontend/src/assets/locales/en/dashboard.json +++ b/frontend/src/assets/locales/en/dashboard.json @@ -65,5 +65,17 @@ "inProgress": "Compliance report(s) in progress", "awaitingGovReview": "Compliance report(s) awaiting government review", "noActionRequired": "There are no reports that require any action." + }, + "userSettings": { + "title": "User settings", + "notifications": "Notifications", + "configureNotifications": "Configure your notifications", + "help": "Help" + }, + "orgUserSettings": { + "title": "User settings", + "notifications": "Notifications", + "configureNotifications": "Configure your notifications", + "help": "Help" } } diff --git a/frontend/src/views/Dashboard/Dashboard.jsx b/frontend/src/views/Dashboard/Dashboard.jsx index 7d6a66052..2708d076e 100644 --- a/frontend/src/views/Dashboard/Dashboard.jsx +++ b/frontend/src/views/Dashboard/Dashboard.jsx @@ -3,15 +3,20 @@ import { Grid, Box } from '@mui/material' import { Role } from '@/components/Role' import { roles, govRoles, nonGovRoles } from '@/constants/roles' import { + // IDIR Cards AdminLinksCard, + DirectorReviewCard, + TransactionsCard, + UserSettingsCard, + + // BCeID Cards OrgDetailsCard, OrgBalanceCard, FeedbackCard, WebsiteCard, - DirectorReviewCard, - TransactionsCard, OrgTransactionsCard, - OrgComplianceReportsCard + OrgComplianceReportsCard, + OrgUserSettingsCard } from './components/cards' import OrganizationsSummaryCard from './components/cards/idir/OrganizationsSummaryCard' @@ -93,6 +98,14 @@ export const Dashboard = () => { + + {/* User settings */} + + + + + + diff --git a/frontend/src/views/Dashboard/components/cards/bceid/OrgUserSettingsCard.jsx b/frontend/src/views/Dashboard/components/cards/bceid/OrgUserSettingsCard.jsx new file mode 100644 index 000000000..18a45546d --- /dev/null +++ b/frontend/src/views/Dashboard/components/cards/bceid/OrgUserSettingsCard.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import { List, ListItemButton, Stack, Typography } from '@mui/material' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import BCWidgetCard from '@/components/BCWidgetCard/BCWidgetCard' +import BCTypography from '@/components/BCTypography' +import withRole from '@/utils/withRole' +import { nonGovRoles } from '@/constants/roles' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { ROUTES } from '@/constants/routes' + +const linkStyle = { + textDecoration: 'underline', + color: 'link.main', + '&:hover': { color: 'info.main' } +} + +const UserSettingsLink = ({ onClick, children }) => ( + + + {children} + + +) + +const OrgUserSettingsCard = () => { + const { t } = useTranslation(['dashboard']) + const { data: currentUser } = useCurrentUser() + const navigate = useNavigate() + + const { title, firstName, lastName } = currentUser || {} + const name = [firstName, lastName].filter(Boolean).join(' ') + const displayName = [name, title].filter(Boolean).join(', ') + + return ( + + + {displayName} + + + + navigate(ROUTES.NOTIFICATIONS)}> + {t('dashboard:orgUserSettings.notifications')} + + + navigate(ROUTES.NOTIFICATIONS_SETTINGS)} + > + {t('dashboard:orgUserSettings.configureNotifications')} + + + {/* TODO: Update the link to the help page */} + navigate()}> + {t('dashboard:orgUserSettings.help')} + + + + + } + /> + ) +} + +export default withRole(OrgUserSettingsCard, nonGovRoles) diff --git a/frontend/src/views/Dashboard/components/cards/bceid/__tests__/OrgUserSettingsCard.test.jsx b/frontend/src/views/Dashboard/components/cards/bceid/__tests__/OrgUserSettingsCard.test.jsx new file mode 100644 index 000000000..3a4aa4030 --- /dev/null +++ b/frontend/src/views/Dashboard/components/cards/bceid/__tests__/OrgUserSettingsCard.test.jsx @@ -0,0 +1,160 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import OrgUserSettingsCard from '../OrgUserSettingsCard' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { useNavigate } from 'react-router-dom' +import { ROUTES } from '@/constants/routes' +import { wrapper } from '@/tests/utils/wrapper' + +vi.mock('@/hooks/useCurrentUser') + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => `mock__${key}` + }) +})) + +vi.mock('react-router-dom', () => ({ + ...vi.importActual('react-router-dom'), + useNavigate: vi.fn() +})) + +vi.mock('@/utils/withRole', () => ({ + __esModule: true, + default: (Component) => + function MockWithRole(props) { + return + } +})) + +describe('OrgUserSettingsCard', () => { + const mockNavigate = vi.fn() + + beforeEach(() => { + mockNavigate.mockClear() + useNavigate.mockReturnValue(mockNavigate) + }) + + it('renders the user’s full name and title correctly', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Developer', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + expect(screen.getByText('Test User, Developer')).toBeInTheDocument() + + expect( + screen.getByText('mock__dashboard:orgUserSettings.notifications') + ).toBeInTheDocument() + expect( + screen.getByText('mock__dashboard:orgUserSettings.configureNotifications') + ).toBeInTheDocument() + expect( + screen.getByText('mock__dashboard:orgUserSettings.help') + ).toBeInTheDocument() + }) + + it('handles missing title gracefully', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: undefined, + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + // Should be "Test User" with no comma + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + it('handles missing firstName or lastName gracefully', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: '', + title: 'Developer', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + // Should show "Test, Developer" + expect(screen.getByText('Test, Developer')).toBeInTheDocument() + }) + + it('navigates to Notifications page when "mock__dashboard:orgUserSettings.notifications" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const notificationsLink = screen.getByText( + 'mock__dashboard:orgUserSettings.notifications' + ) + fireEvent.click(notificationsLink) + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.NOTIFICATIONS) + }) + + it('navigates to Notifications Settings page when "mock__dashboard:orgUserSettings.configureNotifications" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const configureLink = screen.getByText( + 'mock__dashboard:orgUserSettings.configureNotifications' + ) + fireEvent.click(configureLink) + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.NOTIFICATIONS_SETTINGS) + }) + + it('navigates to the help page (placeholder) when "mock__dashboard:orgUserSettings.help" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const helpLink = screen.getByText('mock__dashboard:orgUserSettings.help') + fireEvent.click(helpLink) + + // By default, it calls navigate() with no args + expect(mockNavigate).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/views/Dashboard/components/cards/idir/UserSettingsCard.jsx b/frontend/src/views/Dashboard/components/cards/idir/UserSettingsCard.jsx new file mode 100644 index 000000000..76331bb0d --- /dev/null +++ b/frontend/src/views/Dashboard/components/cards/idir/UserSettingsCard.jsx @@ -0,0 +1,79 @@ +import React from 'react' +import { List, ListItemButton, Stack, Typography } from '@mui/material' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import BCWidgetCard from '@/components/BCWidgetCard/BCWidgetCard' +import BCTypography from '@/components/BCTypography' +import withRole from '@/utils/withRole' +import { govRoles } from '@/constants/roles' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faArrowUpRightFromSquare } from '@fortawesome/free-solid-svg-icons' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { ROUTES } from '@/constants/routes' + +const linkStyle = { + textDecoration: 'underline', + color: 'link.main', + '&:hover': { color: 'info.main' } +} + +const UserSettingsLink = ({ onClick, children }) => ( + + + {children} + + +) + +const UserSettingsCard = () => { + const { t } = useTranslation(['dashboard']) + const { data: currentUser } = useCurrentUser() + const navigate = useNavigate() + + const { title, firstName, lastName } = currentUser || {} + const name = [firstName, lastName].filter(Boolean).join(' ') + const displayName = [name, title].filter(Boolean).join(', ') + + return ( + + + {displayName} + + + + navigate(ROUTES.NOTIFICATIONS)}> + {t('dashboard:userSettings.notifications')} + + + navigate(ROUTES.NOTIFICATIONS_SETTINGS)} + > + {t('dashboard:userSettings.configureNotifications')} + + + {/* TODO: Update the link to the help page */} + navigate()}> + {t('dashboard:userSettings.help')} + + + + + } + /> + ) +} + +export default withRole(UserSettingsCard, govRoles) diff --git a/frontend/src/views/Dashboard/components/cards/idir/__tests__/UserSettingsCard.test.jsx b/frontend/src/views/Dashboard/components/cards/idir/__tests__/UserSettingsCard.test.jsx new file mode 100644 index 000000000..cf0b93b21 --- /dev/null +++ b/frontend/src/views/Dashboard/components/cards/idir/__tests__/UserSettingsCard.test.jsx @@ -0,0 +1,160 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import UserSettingsCard from '../UserSettingsCard' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { useNavigate } from 'react-router-dom' +import { ROUTES } from '@/constants/routes' +import { wrapper } from '@/tests/utils/wrapper' + +vi.mock('@/hooks/useCurrentUser') + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => `mock__${key}` + }) +})) + +vi.mock('react-router-dom', () => ({ + ...vi.importActual('react-router-dom'), + useNavigate: vi.fn() +})) + +vi.mock('@/utils/withRole', () => ({ + __esModule: true, + default: (Component) => + function MockWithRole(props) { + return + } +})) + +describe('UserSettingsCard', () => { + const mockNavigate = vi.fn() + + beforeEach(() => { + mockNavigate.mockClear() + useNavigate.mockReturnValue(mockNavigate) + }) + + it('renders the user’s full name and title correctly', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Developer', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + expect(screen.getByText('Test User, Developer')).toBeInTheDocument() + + expect( + screen.getByText('mock__dashboard:userSettings.notifications') + ).toBeInTheDocument() + expect( + screen.getByText('mock__dashboard:userSettings.configureNotifications') + ).toBeInTheDocument() + expect( + screen.getByText('mock__dashboard:userSettings.help') + ).toBeInTheDocument() + }) + + it('handles missing title gracefully', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: undefined, + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + // Should be "Test User" with no comma + expect(screen.getByText('Test User')).toBeInTheDocument() + }) + + it('handles missing firstName or lastName gracefully', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: '', + title: 'Developer', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + // Should show "Test, Developer" + expect(screen.getByText('Test, Developer')).toBeInTheDocument() + }) + + it('navigates to Notifications page when "mock__dashboard:userSettings.notifications" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const notificationsLink = screen.getByText( + 'mock__dashboard:userSettings.notifications' + ) + fireEvent.click(notificationsLink) + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.NOTIFICATIONS) + }) + + it('navigates to Notifications Settings page when "mock__dashboard:userSettings.configureNotifications" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const configureLink = screen.getByText( + 'mock__dashboard:userSettings.configureNotifications' + ) + fireEvent.click(configureLink) + + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.NOTIFICATIONS_SETTINGS) + }) + + it('navigates to the help page (placeholder) when "mock__dashboard:userSettings.help" is clicked', () => { + useCurrentUser.mockReturnValue({ + data: { + firstName: 'Test', + lastName: 'User', + title: 'Dev', + roles: [{ name: 'Government' }] + }, + isLoading: false + }) + + render(, { wrapper }) + + const helpLink = screen.getByText('mock__dashboard:userSettings.help') + fireEvent.click(helpLink) + + // By default, it calls navigate() with no args + expect(mockNavigate).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/views/Dashboard/components/cards/index.js b/frontend/src/views/Dashboard/components/cards/index.js index 58aefc825..95f57fb9e 100644 --- a/frontend/src/views/Dashboard/components/cards/index.js +++ b/frontend/src/views/Dashboard/components/cards/index.js @@ -2,6 +2,7 @@ export { default as AdminLinksCard } from './idir/AdminLinksCard' export { default as DirectorReviewCard } from './idir/DirectorReviewCard' export { default as TransactionsCard } from './idir/TransactionsCard' +export { default as UserSettingsCard } from './idir/UserSettingsCard' // BCeID Cards export { default as OrgDetailsCard } from './bceid/OrgDetailsCard' @@ -10,3 +11,4 @@ export { default as FeedbackCard } from './bceid/FeedbackCard' export { default as WebsiteCard } from './bceid/WebsiteCard' export { default as OrgTransactionsCard } from './bceid/OrgTransactionsCard' export { default as OrgComplianceReportsCard } from './bceid/OrgComplianceReportsCard' +export { default as OrgUserSettingsCard } from './bceid/OrgUserSettingsCard'