diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 500c400625..e1e15624fc 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -101,7 +101,7 @@ export default class RulesController { static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) { try { - const rulesetTypeId = req.body; + const { rulesetTypeId } = req.params; const rulesets = await RulesService.getRulesetsByRulesetType(rulesetTypeId, req.organization.organizationId); res.status(200).json(rulesets); } catch (error: unknown) { diff --git a/src/backend/src/prisma-query-args/rules.query-args.ts b/src/backend/src/prisma-query-args/rules.query-args.ts index acb098e426..aa3cf262bd 100644 --- a/src/backend/src/prisma-query-args/rules.query-args.ts +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -1,5 +1,4 @@ import { Prisma } from '@prisma/client'; -import { getUserQueryArgs } from './user.query-args'; export type RulePreviewQueryArgs = ReturnType; @@ -50,7 +49,7 @@ export const getProjectRuleQueryArgs = () => export type RulesetQueryArgs = ReturnType; -export const getRulesetQueryArgs = (organizationId: string) => +export const getRulesetQueryArgs = () => Prisma.validator()({ include: { rules: { @@ -65,20 +64,8 @@ export const getRulesetQueryArgs = (organizationId: string) => } }, rulesetType: true, - car: true, - createdBy: getUserQueryArgs(organizationId) - } - }); - -export const getRulesetPreviewQueryArgs = () => - Prisma.validator()({ - select: { - name: true, - dateCreated: true, - rulesetType: true, - active: true, car: { - select: { + include: { wbsElement: true } } diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index 124eaf24bb..3b1b4a8db1 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -34,8 +34,6 @@ rulesRouter.post('/rule/:ruleId/delete', RulesController.deleteRule); rulesRouter.post('/rulesetType/create', nonEmptyString(body('name')), validateInputs, RulesController.createRulesetType); -rulesRouter.post('/rule/:ruleId/delete', RulesController.deleteRule); - rulesRouter.post( '/projectRule/create', nonEmptyString(body('ruleId')), diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index bab0c0aa89..4d9df5342b 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -5,10 +5,10 @@ import { ProjectRule, RulesetType, notGuest, - RulesetPreview, User, Rule as SharedRule, - isHead + isHead, + Ruleset } from 'shared'; import prisma from '../prisma/prisma'; import { @@ -24,15 +24,13 @@ import { userHasPermission } from '../utils/users.utils'; import { getProjectRuleQueryArgs, getRulesetQueryArgs, - getRulesetPreviewQueryArgs, getRulePreviewQueryArgs } from '../prisma-query-args/rules.query-args'; import { ruleTransformer, projectRuleTransformer, rulesetTransformer, - rulesetTypeTransformer, - rulesetPreviewTransformer + rulesetTypeTransformer } from '../transformers/rules.transformer'; export default class RulesService { @@ -61,7 +59,7 @@ export default class RulesService { const activeRuleset = await prisma.ruleset.findFirst({ where: { rulesetTypeId, deletedByUserId: null, active: true }, - ...getRulesetQueryArgs(organization.organizationId) + ...getRulesetQueryArgs() }); if (!activeRuleset) { @@ -461,10 +459,10 @@ export default class RulesService { * @param organizationId the id of the organization the ruleset is being deleted in * @returns the ruleset with query args */ - static async getRulesetWithQueryArgs(rulesetId: string, organizationId: string) { + static async getRulesetWithQueryArgs(rulesetId: string) { const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, - ...getRulesetQueryArgs(organizationId) + ...getRulesetQueryArgs() }); if (!ruleset) throw new NotFoundException('Ruleset', rulesetId); @@ -481,17 +479,21 @@ export default class RulesService { * @returns the deleted Ruleset */ static async deleteRuleset(rulesetId: string, deleterId: string, organizationId: string) { - const ruleset = await RulesService.getRulesetWithQueryArgs(rulesetId, organizationId); + const ruleset = await RulesService.getRulesetWithQueryArgs(rulesetId); const hasPermission = (await userHasPermission(deleterId, organizationId, isAdmin)) || deleterId === ruleset.createdByUserId; if (!hasPermission) throw new AccessDeniedException('Only admins can delete a ruleset.'); + if (ruleset.active) { + throw new HttpException(400, 'Cannot delete an active ruleset. Please deactivate it first.'); + } + const deletedRuleset = await prisma.ruleset.update({ where: { rulesetId }, - data: { deletedBy: { connect: { userId: deleterId } } }, - ...getRulesetQueryArgs(organizationId) + data: { deletedBy: { connect: { userId: deleterId } }, active: false }, + ...getRulesetQueryArgs() }); return rulesetTransformer(deletedRuleset); @@ -516,7 +518,7 @@ export default class RulesService { * @param organizationId id of organization * @returns rulesets associated with provided ruleset type */ - static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string): Promise { + static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string): Promise { const rulesets = await prisma.ruleset.findMany({ where: { rulesetTypeId, @@ -525,10 +527,13 @@ export default class RulesService { organizationId } }, - ...getRulesetPreviewQueryArgs() + orderBy: { + dateCreated: 'desc' + }, + ...getRulesetQueryArgs() }); - return rulesets.map(rulesetPreviewTransformer); + return rulesets.map(rulesetTransformer); } /** @@ -573,7 +578,7 @@ export default class RulesService { active: true, deletedBy: null }, - ...getRulesetQueryArgs(organization.organizationId) + ...getRulesetQueryArgs() }); if (!activeRuleset) { @@ -784,7 +789,7 @@ export default class RulesService { active, createdByUserId: submitter.userId }, - ...getRulesetQueryArgs(organization.organizationId) + ...getRulesetQueryArgs() }); return rulesetTransformer(ruleset); @@ -942,7 +947,7 @@ export default class RulesService { rulesetTypeId: rulesetExists.rulesetTypeId, organizationId }, - dateDeleted: null + deletedByUserId: null } }); @@ -961,7 +966,7 @@ export default class RulesService { name, active: isActive }, - ...getRulesetQueryArgs(organizationId) + ...getRulesetQueryArgs() }); return rulesetTransformer(ruleset); diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 4c1b0af6be..51959bdb48 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -1,5 +1,5 @@ import { Prisma } from '@prisma/client'; -import { Rule, ProjectRule, Ruleset, RulesetType, RulesetPreview } from 'shared'; +import { Rule, ProjectRule, Ruleset, RulesetType } from 'shared'; import { RulesetQueryArgs, RulePreviewQueryArgs } from '../prisma-query-args/rules.query-args'; export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { @@ -35,6 +35,8 @@ export const rulesetTypeTransformer = (rulesetType: any): RulesetType => { name: rulesetType.name, lastUpdated: rulesetType.lastUpdated, revisionFiles: rulesetType.revisionFiles + ? rulesetType.revisionFiles.filter((ruleset: any) => ruleset.deletedByUserId === null) + : [] }; }; @@ -53,22 +55,7 @@ export const rulesetTransformer = (ruleset: Prisma.RulesetGetPayload { - const teamsPercentage = 0; - - return { - name: ruleset.name, - dateCreated: ruleset.dateCreated, - active: ruleset.active, - assignedPercentage: teamsPercentage, - car: { - carId: ruleset.car.carId, - name: ruleset.car.wbsElementId + name: ruleset.car.wbsElement.name } }; }; diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 290733874f..b8dbced304 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -377,6 +377,12 @@ describe('Create Rules Tests', () => { }); it('Successful get rulesets by ruleset types after deleting ruleset', async () => { + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); expect(rulesets.length).toBe(0); @@ -395,8 +401,8 @@ describe('Create Rules Tests', () => { }); const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); expect(rulesets.length).toBe(2); - expect(rulesets[0].name).toBe('2025 FSAE Rules'); - expect(rulesets[1].name).toBe('2025 FSAE Rules2'); + expect(rulesets[0].name).toBe('2025 FSAE Rules2'); + expect(rulesets[1].name).toBe('2025 FSAE Rules'); }); }); @@ -524,6 +530,11 @@ describe('Create Rules Tests', () => { expect(ruleset2.name).toBe('name2'); }); it('update ruleset status on deleted ruleset fails', async () => { + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId }, + data: { active: false } + }); await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); await expect(async () => await RulesService.updateRuleset(batman, orgId, rulesetId, 'name', false)).rejects.toThrow( new NotFoundException('Ruleset', rulesetId) @@ -973,6 +984,13 @@ describe('Rule Tests', () => { it('Deletes a ruleset successfully and returns the correct information', async () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); + + // deactivate before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + const totalRules = await prisma.rule.count({ where: { rulesetId: ruleset1.rulesetId } }); @@ -989,6 +1007,20 @@ describe('Rule Tests', () => { expect(deleted.rulesetId).toBe(ruleset1.rulesetId); expect(deleted.assignedPercentage).toBeCloseTo(expectedPercentage, 2); }); + it('Throws error when trying to delete an active ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // Ensure the ruleset is active + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: true } + }); + + await expect( + RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId) + ).rejects.toThrow('Cannot delete an active ruleset. Please deactivate it first.'); + }); it('Delete ruleset fails if user does not have permission', async () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); @@ -1001,6 +1033,12 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); await expect( async () => await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId) @@ -1455,6 +1493,12 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); await expect(RulesService.getUnassignedRules(ruleset1.rulesetId, organization)).rejects.toThrow( diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 8f90276370..962facde88 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -67,3 +67,24 @@ export const getRulesetsByRulesetType = (rulesetTypeId: string) => { transformResponse: (data) => JSON.parse(data) }); }; + +/** + * Updates a rulesets active status + */ +export const updateRuleset = (rulesetId: string, name: string, isActive: boolean) => { + return axios.post(apiUrls.rulesetUpdate(rulesetId), { name, isActive }); +}; + +/** + * Deletes a ruleset given its ID + */ +export const deleteRuleset = (rulesetId: string) => { + return axios.post(apiUrls.rulesetDelete(rulesetId)); +}; + +/** + * Deletes a ruleset type given its ID + */ +export const deleteRulesetType = (rulesetTypeId: string) => { + return axios.post(apiUrls.rulesetTypeDelete(rulesetTypeId)); +}; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 1183fe2384..7ba3ef0a7a 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -12,7 +12,10 @@ import { getTeamRulesInRulesetType, createRulesetType, getAllRulesetTypes, - getRulesetsByRulesetType + getRulesetsByRulesetType, + updateRuleset, + deleteRuleset, + deleteRulesetType } from '../apis/rules.api'; interface CreateRulesetTypePayload { @@ -99,3 +102,46 @@ export const useRulesetsByType = (rulesetTypeId: string) => { return data; }); }; + +export const useUpdateRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + async ({ rulesetId, name, isActive }) => { + const { data } = await updateRuleset(rulesetId, name, isActive); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +export const useDeleteRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetId: string) => { + await deleteRuleset(rulesetId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + } + } + ); +}; + +export const useDeleteRulesetType = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetTypeId: string) => { + await deleteRulesetType(rulesetTypeId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesetTypes']); + } + } + ); +}; diff --git a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx index fa80b96bec..a3d3972464 100644 --- a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -185,7 +185,7 @@ const AssignRulesTab: React.FC = ({ rules }) => { toast.success(`Placeholder: Would save ${toAdd.length} additions and ${toRemove.length} removals`); } - history.push(`${routes.RULES}/${rulesetId}`); + history.push(routes.RULESET_EDIT.replace(':rulesetId', rulesetId)); }; if (teamsLoading) { diff --git a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx index 5ae6b638cf..2988a5e494 100644 --- a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx @@ -240,7 +240,7 @@ const RulesetEditPage: React.FC = () => { diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx index 84cd954c5a..123f9a765c 100644 --- a/src/frontend/src/pages/RulesPage/RulesetPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -2,8 +2,6 @@ * This file is part of NER's FinishLine and licensed under GNU AGPLv3. * See the LICENSE file in the repository root folder for details. */ -import { useHistory } from 'react-router-dom'; -import { routes } from '../../utils/routes'; import React from 'react'; import { NERButton } from '../../components/NERButton'; import AddNewFileModal from './components/AddNewFileModal'; @@ -16,7 +14,6 @@ import RulesetTable from './components/RulesetTable'; * Supports editing and assigning rules to projects and teams. */ const RulesetPage: React.FC = () => { - const history = useHistory(); const [AddFileModalShow, setAddFileModalShow] = React.useState(false); const handleFileConfirm = async (data: { file: File; name: string; car: string; isActive: boolean }) => { @@ -67,9 +64,6 @@ const RulesetPage: React.FC = () => { onConfirm={handleFileConfirm} carOptions={['1', '2']} /> - history.push(`${routes.RULES}/placeholder_ruleset_id/edit`)}> - MOCK edit/assign rules - diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index 85c9baf809..65b54d1bf6 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -17,7 +17,6 @@ const RulesetViewPage = () => { ]; const { rulesetId } = useParams<{ rulesetId: string }>(); - const { data: ruleset, isError, error, isLoading } = useSingleRuleset(rulesetId); if (isError) { @@ -38,7 +37,7 @@ const RulesetViewPage = () => { noUnderline setTab={setTabIndex} tabsLabels={tabs} - baseUrl={`${routes.RULES}/${rulesetId}/view`} + baseUrl={routes.RULESET_VIEW.replace(':rulesetId', rulesetId)} defaultTab={'teamView'} id="rules-view-tabs" /> diff --git a/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx new file mode 100644 index 0000000000..911d4c8d00 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface RulesetDeleteModalProps { + rulesetName: string; + onDelete: () => void; + onHide: () => void; +} + +const RulesetDeleteModal: React.FC = ({ rulesetName, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this ruleset? + {rulesetName} + + ); +}; + +export default RulesetDeleteModal; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx index db99b85dc3..63ede00b2c 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Paper, @@ -14,27 +14,45 @@ import { CardContent, Typography, Stack, - Checkbox + Checkbox, + IconButton } from '@mui/material'; import { datePipe } from '../../../utils/pipes'; import { NERButton } from '../../../components/NERButton'; +import { useHistory, useParams } from 'react-router-dom'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useDeleteRuleset, useRulesetsByType, useUpdateRuleset } from '../../../hooks/rules.hooks'; +import { Ruleset } from 'shared'; +import { routes } from '../../../utils/routes'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { Delete } from '@mui/icons-material'; +import RulesetDeleteModal from './RulesetDeleteModal'; -interface RulesetRow { - id: string; - fileName: string; - dateUploaded: Date; - percentRulesAssigned: number; - car: number; - isActive: boolean; +interface RulesetParams { + rulesetTypeId: string; +} + +interface RulesetDeleteButtonProps { + rulesetId: string; + name: string; + onDelete: (rulesetId: string, name: string) => void; } const RulesetTable: React.FC = () => { + const { rulesetTypeId } = useParams(); + const toast = useToast(); + const history = useHistory(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // Add file upload logic // const [AddFileModalShow, setAddFileModalShow] = React.useState(false); + const { data: rulesets = [], isLoading, error } = useRulesetsByType(rulesetTypeId); + const updateRuleset = useUpdateRuleset(); + const { mutateAsync: deleteRuleset } = useDeleteRuleset(); + // Table header configuration const headCells = [ { id: 'fileName', label: 'File Name' }, @@ -42,188 +60,84 @@ const RulesetTable: React.FC = () => { { id: 'percentRulesAssigned', label: '% of Rules Assigned' }, { id: 'car', label: 'Car' }, { id: 'isActive', label: 'Active?' }, - { id: 'actions', label: 'Actions' } + { id: 'actions', label: 'Actions' }, + { id: 'delete', label: '' } ]; - // Mock data for now - will be replaced with ruleset data - const mockRulesets: RulesetRow[] = [ - { - id: '1', - fileName: 'FSAE Original Version', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '2', - fileName: 'FSAE Revision 1', - dateUploaded: new Date('2025-02-25'), - percentRulesAssigned: 10, - car: 1, - isActive: true - }, - { - id: '3', - fileName: 'Hi', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false - }, - { - id: '3', - fileName: 'Hi ', - dateUploaded: new Date('2025-02-24'), - percentRulesAssigned: 80, - car: 1, - isActive: false + const handleToggleActive = (ruleset: Ruleset) => { + updateRuleset.mutate( + { + rulesetId: ruleset.rulesetId, + name: ruleset.name, + isActive: !ruleset.active + }, + { + onSuccess: () => { + toast.success(ruleset.active ? 'Ruleset deactivated' : 'Ruleset activated'); + }, + onError: (error: any) => { + const message = error.response?.data?.message || error.message; + toast.error(message); + } + } + ); + }; + + const handleEditRuleset = (rulesetId: string) => { + history.push(routes.RULESET_EDIT.replace(':rulesetId', rulesetId)); + }; + + const handleViewRuleset = (rulesetId: string) => { + history.push(routes.RULESET_VIEW.replace(':rulesetId', rulesetId)); + }; + + const handleDeleteRuleset = async (rulesetId: string, name: string) => { + const ruleset = rulesets.find((r) => r.rulesetId === rulesetId); + + if (ruleset && ruleset.active) { + toast.error('Cannot delete an active ruleset. Please deactivate it first.'); + return; } - ]; + + try { + await deleteRuleset(rulesetId); + toast.success(`Ruleset: ${name} deleted successfully!`); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const RulesetDeleteButton: React.FC = ({ rulesetId, name, onDelete }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => { + onDelete(rulesetId, name); + setShowDeleteModal(false); + }; + return ( + <> + setShowDeleteModal(true)}> + + + {showDeleteModal && ( + setShowDeleteModal(false)} /> + )} + + ); + }; + + if (isLoading) return ; + if (error) return ; return ( {isMobile ? ( - {mockRulesets.map((ruleset) => ( + {rulesets.map((ruleset: Ruleset) => ( { > - {ruleset.fileName} + {ruleset.name} @@ -240,7 +154,7 @@ const RulesetTable: React.FC = () => { Date Uploaded: - {datePipe(ruleset.dateUploaded)} + {datePipe(ruleset.dateCreated)} @@ -248,7 +162,7 @@ const RulesetTable: React.FC = () => { % of Rules Assigned: - {ruleset.percentRulesAssigned} + {ruleset.assignedPercentage} @@ -256,7 +170,7 @@ const RulesetTable: React.FC = () => { Car: - {ruleset.car} + {ruleset.car.name} @@ -264,11 +178,12 @@ const RulesetTable: React.FC = () => { Active: - {ruleset.isActive} + {ruleset.active} handleToggleActive(ruleset)} + disabled={updateRuleset.isLoading} sx={{ color: '#fff', '&.Mui-checked': { color: '#dd514c' } @@ -277,6 +192,7 @@ const RulesetTable: React.FC = () => { handleEditRuleset(ruleset.rulesetId)} sx={{ backgroundColor: theme.palette.grey[800], color: theme.palette.getContrastText(theme.palette.grey[600]), @@ -292,6 +208,7 @@ const RulesetTable: React.FC = () => { Edit/Assign Rules handleViewRuleset(ruleset.rulesetId)} sx={{ backgroundColor: theme.palette.grey[800], color: theme.palette.getContrastText(theme.palette.grey[600]), @@ -305,6 +222,7 @@ const RulesetTable: React.FC = () => { > View Rules + @@ -330,30 +248,31 @@ const RulesetTable: React.FC = () => { {/* Table rows with ruleset data */} - {mockRulesets.length === 0 ? ( + {rulesets.length === 0 ? ( - + No Rulesets Found ) : ( - mockRulesets.map((ruleset) => ( + rulesets.map((ruleset: Ruleset) => ( - {ruleset.fileName} + {ruleset.name} - {datePipe(ruleset.dateUploaded)} - {ruleset.percentRulesAssigned}% - {ruleset.car} + {datePipe(ruleset.dateCreated)} + {ruleset.assignedPercentage}% + {ruleset.car.name} handleToggleActive(ruleset)} + disabled={updateRuleset.isLoading} sx={{ color: '#fff', '&.Mui-checked': { color: '#dd514c' } @@ -362,6 +281,7 @@ const RulesetTable: React.FC = () => { handleEditRuleset(ruleset.rulesetId)} sx={{ backgroundColor: theme.palette.grey[800], color: theme.palette.getContrastText(theme.palette.grey[600]), @@ -377,6 +297,7 @@ const RulesetTable: React.FC = () => { Edit/Assign Rules handleViewRuleset(ruleset.rulesetId)} sx={{ backgroundColor: theme.palette.grey[800], color: theme.palette.getContrastText(theme.palette.grey[600]), @@ -391,6 +312,13 @@ const RulesetTable: React.FC = () => { View Rules + + + )) )} diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx new file mode 100644 index 0000000000..ad48f500d2 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeDeleteModal.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface RulesetTypeDeleteModalProps { + rulesetTypeName: string; + onDelete: () => void; + onHide: () => void; +} + +const RulesetTypeDeleteModal: React.FC = ({ rulesetTypeName, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this ruleset type? + {rulesetTypeName} + + ); +}; + +export default RulesetTypeDeleteModal; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx index 97d7a74550..4ef2c1c2eb 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx @@ -13,30 +13,42 @@ import { CardContent, Typography, Stack, - Link + IconButton } from '@mui/material'; -import { Link as RouterLink, useHistory } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { datePipe } from '../../../utils/pipes'; import { routes } from '../../../utils/routes'; -import { useAllRulesetTypes } from '../../../hooks/rules.hooks'; +import { useAllRulesetTypes, useDeleteRulesetType } from '../../../hooks/rules.hooks'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { RulesetType } from 'shared'; import { NERButton } from '../../../components/NERButton'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useState } from 'react'; +import RulesetTypeDeleteModal from './RulesetTypeDeleteModal'; +import { Delete } from '@mui/icons-material'; -type RulesetTypeColumnId = 'id' | 'name' | 'lastUpdated' | 'revisions' | 'actions'; +type RulesetTypeColumnId = 'id' | 'name' | 'lastUpdated' | 'revisions' | 'actions' | 'delete'; interface RulesetTypeHeadCell { id: RulesetTypeColumnId; label: string; } +interface RulesetTypeDeleteButtonProps { + rulesetTypeId: string; + name: string; + onDelete: (rulesetTypeId: string, name: string) => void; +} + const RulesetTypeTable: React.FC = () => { const history = useHistory(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const toast = useToast(); const { data: rulesetTypes = [], isLoading, error } = useAllRulesetTypes(); + const { mutateAsync: deleteRulesetType } = useDeleteRulesetType(); const headCells: readonly RulesetTypeHeadCell[] = [ { @@ -54,11 +66,56 @@ const RulesetTypeTable: React.FC = () => { { id: 'actions', label: 'Actions' + }, + { + id: 'delete', + label: '' } ]; - const handleViewRuleset = (rulesetTypeId: string) => { - history.push(routes.RULESET_BY_ID.replace(':rulesetId', rulesetTypeId)); + const handleViewRulesetType = (rulesetTypeId: string) => { + history.push(routes.RULESET_BY_ID.replace(':rulesetTypeId', rulesetTypeId)); + }; + + const handleDeleteRulesetType = async (rulesetTypeId: string, name: string) => { + const rulesetType = rulesetTypes.find((rt) => rt.rulesetTypeId === rulesetTypeId); + if (rulesetType && rulesetType.revisionFiles.length > 0) { + toast.error('Cannot delete ruleset type with existing revisions'); + return; + } + + try { + await deleteRulesetType(rulesetTypeId); + toast.success(`Ruleset Type: ${name} deleted successfully!`); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const RulesetTypeDeleteButton: React.FC = ({ rulesetTypeId, name, onDelete }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => { + onDelete(rulesetTypeId, name); + setShowDeleteModal(false); + }; + + return ( + <> + setShowDeleteModal(true)}> + + + {showDeleteModal && ( + setShowDeleteModal(false)} + /> + )} + + ); }; if (isLoading) return ; @@ -111,10 +168,15 @@ const RulesetTypeTable: React.FC = () => { lineHeight: 1, borderRadius: '6px' }} - onClick={() => handleViewRuleset(rulesetType.rulesetTypeId)} + onClick={() => handleViewRulesetType(rulesetType.rulesetTypeId)} > - View Ruleset + View Rulesets + @@ -154,32 +216,10 @@ const RulesetTypeTable: React.FC = () => { }} > - - {rulesetType.name} - - - - - {datePipe(rulesetType.lastUpdated)} - - - - - {rulesetType.revisionFiles.length} - + {rulesetType.name} + {datePipe(rulesetType.lastUpdated)} + {rulesetType.revisionFiles.length} { lineHeight: 1, borderRadius: '6px' }} - onClick={() => handleViewRuleset(rulesetType.rulesetTypeId)} + onClick={() => handleViewRulesetType(rulesetType.rulesetTypeId)} > - View Ruleset + View Rulesets + + + )) )} diff --git a/src/frontend/src/utils/routes.ts b/src/frontend/src/utils/routes.ts index aa6ce0de87..72dcfcab3d 100644 --- a/src/frontend/src/utils/routes.ts +++ b/src/frontend/src/utils/routes.ts @@ -82,9 +82,9 @@ const RETROSPECTIVE = `/retrospective`; /**************** Rules ****************/ const RULES = `/rules`; -const RULESET_BY_ID = RULES + `/:rulesetId`; -const RULESET_VIEW = RULESET_BY_ID + `/view`; -const RULESET_EDIT = RULESET_BY_ID + `/edit`; +const RULESET_BY_ID = RULES + `/:rulesetTypeId`; +const RULESET_VIEW = RULES + `/ruleset/:rulesetId/view`; +const RULESET_EDIT = RULES + `/ruleset/:rulesetId/edit`; export const routes = { BASE, diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 6cfd301a4f..3a3373c227 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -447,6 +447,9 @@ const rulesetsByType = (rulesetTypeId: string) => `${rules()}/rulesets/${ruleset const ruleset = () => `${rules()}/ruleset`; const rulesetTypeCreate = () => `${rules()}/rulesetType/create`; const rulesetsCreate = () => `${ruleset()}/create`; +const rulesetUpdate = (rulesetId: string) => `${ruleset()}/${rulesetId}/update`; +const rulesetDelete = (rulesetId: string) => `${ruleset()}/${rulesetId}/delete`; +const rulesetTypeDelete = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/delete`; /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -760,6 +763,9 @@ export const apiUrls = { rulesetsByType, rulesetTypeCreate, rulesetsCreate, + rulesetUpdate, + rulesetDelete, + rulesetTypeDelete, version };