From 24580c52342448ea6425c2d379f3f91b75c7ec3d Mon Sep 17 00:00:00 2001 From: Aryan0102 Date: Sat, 27 Dec 2025 21:21:52 -0500 Subject: [PATCH 1/4] finsihed project rules page --- .../src/transformers/rules.transformer.ts | 2 +- src/frontend/src/apis/rules.api.ts | 110 ++++- .../apis/transformers/rules.transformers.ts | 66 ++- src/frontend/src/hooks/rules.hooks.ts | 170 +++++++- .../ProjectRules/AddRuleModal.tsx | 325 ++++++++++++++ .../ProjectRules/ProjectRulesTab.tsx | 408 +++++++++++++++++- .../ProjectRules/UpdateStatusPopover.tsx | 81 ++++ src/frontend/src/utils/urls.ts | 23 +- 8 files changed, 1171 insertions(+), 14 deletions(-) create mode 100644 src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx create mode 100644 src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts index 4c1b0af6be..464cc5252f 100644 --- a/src/backend/src/transformers/rules.transformer.ts +++ b/src/backend/src/transformers/rules.transformer.ts @@ -22,7 +22,7 @@ export const ruleTransformer = (rule: Prisma.RuleGetPayload { return { projectRuleId: projectRule.projectRuleId, - rule: projectRule.rule, + rule: ruleTransformer(projectRule.rule), projectId: projectRule.projectId, currentStatus: projectRule.currentStatus, statusHistory: projectRule.statusHistory diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index c0f743b58a..5b4885751a 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -1,13 +1,119 @@ -import { RulesetType } from 'shared'; +/* + * 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 axios from '../utils/axios'; +import { ProjectRule, Rule, RuleCompletion, Ruleset, RulesetType } from 'shared'; import { apiUrls } from '../utils/urls'; +import { + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer, + ruleTransformer +} from './transformers/rules.transformers'; /** * Creates a new ruleset type * - * @param payload the data for creating the ruleset type + * @param payload the data for creating the ruleset type. * @returns the created ruleset type */ export const createRulesetType = (payload: { name: string }) => { return axios.post(apiUrls.rulesetTypeCreate(), payload); }; + +/** + * Fetches all ruleset types for the organization. + */ +export const getAllRulesetTypes = () => { + return axios.get(apiUrls.rulesGetAllRulesetTypes(), { + transformResponse: (data) => JSON.parse(data).map(rulesetTypeTransformer) + }); +}; + +/** + * Gets the active ruleset for a given ruleset type. + * + * @param rulesetTypeId The ID of the ruleset type. + */ +export const getActiveRuleset = (rulesetTypeId: string) => { + return axios.get(apiUrls.rulesGetActiveRuleset(rulesetTypeId), { + transformResponse: (data) => rulesetTransformer(JSON.parse(data)) + }); +}; + +/** + * Gets all project rules for a given ruleset and project. + * + * @param rulesetId The ID of the ruleset. + * @param projectId The ID of the project. + */ +export const getProjectRules = (rulesetId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetProjectRules(rulesetId, projectId), { + transformResponse: (data) => JSON.parse(data).map(projectRuleTransformer) + }); +}; + +/** + * Gets unassigned rules for a ruleset and team. + * + * @param rulesetId The ID of the ruleset. + * @param teamId The ID of the team. + */ +export const getUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, teamId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Creates a project rule (assigns a rule to a project). + * + * @param ruleId The ID of the rule to assign. + * @param projectId The ID of the project. + */ +export const createProjectRule = (ruleId: string, projectId: string) => { + return axios.post(apiUrls.rulesCreateProjectRule(), { ruleId, projectId }); +}; + +/** + * Deletes a project rule. + * + * @param projectRuleId The ID of the project rule to delete. + */ +export const deleteProjectRule = (projectRuleId: string) => { + return axios.post(apiUrls.rulesDeleteProjectRule(projectRuleId)); +}; + +/** + * Updates the status of a project rule. + * + * @param projectRuleId The ID of the project rule. + * @param newStatus The new status to set. + */ +export const editProjectRuleStatus = (projectRuleId: string, newStatus: RuleCompletion) => { + return axios.post(apiUrls.rulesEditProjectRuleStatus(projectRuleId), { newStatus }); +}; + +/** + * Gets all child rules of a given rule. + * + * @param ruleId The ID of the parent rule. + */ +export const getChildRules = (ruleId: string) => { + return axios.get(apiUrls.rulesGetChildRules(ruleId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Gets all top-level rules (rules with no parent) for a ruleset. + * + * @param rulesetId The ID of the ruleset. + */ +export const getTopLevelRules = (rulesetId: string) => { + return axios.get(apiUrls.rulesGetTopLevelRules(rulesetId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; diff --git a/src/frontend/src/apis/transformers/rules.transformers.ts b/src/frontend/src/apis/transformers/rules.transformers.ts index cf7faadfac..7d187ed7b4 100644 --- a/src/frontend/src/apis/transformers/rules.transformers.ts +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -1 +1,65 @@ -// write transformer functions below here! +/* + * 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 { ProjectRule, Rule, RulesetType, Ruleset } from 'shared'; + +/** + * Transforms a rule to proper field types. + * + * @param rule Incoming rule object + * @returns Properly transformed rule object. + */ +export const ruleTransformer = (rule: Rule): Rule => { + return { + ...rule, + subRuleIds: rule.subRuleIds || [], + referencedRuleIds: rule.referencedRuleIds || [] + }; +}; + +/** + * Transforms a project rule (support Date objects) + * + * @param projectRule Incoming project rule object + * @returns Properly transformed project rule object. + */ +export const projectRuleTransformer = (projectRule: ProjectRule): ProjectRule => { + return { + ...projectRule, + rule: ruleTransformer(projectRule.rule), + statusHistory: (projectRule.statusHistory || []).map((history) => ({ + ...history, + dateCreated: new Date(history.dateCreated) + })) + }; +}; + +/** + * Transforms a ruleset type (support Date objects) + * + * @param rulesetType Incoming ruleset type object + * @returns Properly transformed ruleset type object. + */ +export const rulesetTypeTransformer = (rulesetType: RulesetType): RulesetType => { + return { + ...rulesetType, + lastUpdated: new Date(rulesetType.lastUpdated), + revisionFiles: rulesetType.revisionFiles || [] + }; +}; + +/** + * Transforms a ruleset (support Date objects) + * + * @param ruleset Incoming ruleset object + * @returns Properly transformed ruleset object. + */ +export const rulesetTransformer = (ruleset: Ruleset): Ruleset => { + return { + ...ruleset, + dateCreated: new Date(ruleset.dateCreated), + rulesetType: rulesetTypeTransformer(ruleset.rulesetType) + }; +}; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 3619b60d4d..ea45c3ad01 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -1,13 +1,114 @@ -import { useMutation, useQueryClient } from 'react-query'; -import { RulesetType } from 'shared'; -import { createRulesetType } from '../apis/rules.api'; +/* + * 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 { useMutation, useQuery, useQueryClient } from 'react-query'; +import { ProjectRule, Rule, RuleCompletion, Ruleset, RulesetType } from 'shared'; +import { + createRulesetType, + getAllRulesetTypes, + getActiveRuleset, + getProjectRules, + getUnassignedRulesForRuleset, + createProjectRule, + deleteProjectRule, + editProjectRuleStatus, + getChildRules, + getTopLevelRules +} from '../apis/rules.api'; + +/** + * Hook to supply all ruleset types. + */ +export const useAllRulesetTypes = () => { + return useQuery(['rules', 'rulesetTypes'], async () => { + const { data } = await getAllRulesetTypes(); + return data; + }); +}; + +/** + * Hook to get the active ruleset for a given ruleset type. + */ +export const useActiveRuleset = (rulesetTypeId: string) => { + return useQuery( + ['rules', 'activeRuleset', rulesetTypeId], + async () => { + try { + const { data } = await getActiveRuleset(rulesetTypeId); + return data; + } catch { + // Return undefined if no active ruleset exists + return undefined; + } + }, + { enabled: !!rulesetTypeId } + ); +}; + +/** + * Hook to get all project rules for a given ruleset and project. + */ +export const useProjectRules = (rulesetId: string, projectId: string) => { + return useQuery( + ['rules', 'projectRules', rulesetId, projectId], + async () => { + const { data } = await getProjectRules(rulesetId, projectId); + return data; + }, + { enabled: !!rulesetId && !!projectId } + ); +}; + +/** + * Hook to get unassigned rules for a ruleset and team. + */ +export const useUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => { + return useQuery( + ['rules', 'unassigned', rulesetId, teamId], + async () => { + const { data } = await getUnassignedRulesForRuleset(rulesetId, teamId); + return data; + }, + { enabled: !!rulesetId && !!teamId } + ); +}; + +/** + * Hook to get child rules of a rule. + */ +export const useChildRules = (ruleId: string) => { + return useQuery( + ['rules', 'children', ruleId], + async () => { + const { data } = await getChildRules(ruleId); + return data; + }, + { enabled: !!ruleId } + ); +}; + +/** + * Hook to get top-level rules for a ruleset. + */ +export const useTopLevelRules = (rulesetId: string) => { + return useQuery( + ['rules', 'topLevel', rulesetId], + async () => { + const { data } = await getTopLevelRules(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; interface CreateRulesetTypePayload { name: string; } /** - * Custom React Hook to create a new ruleset type + * Hook to create a new ruleset type. */ export const useCreateRulesetType = () => { const queryClient = useQueryClient(); @@ -19,7 +120,66 @@ export const useCreateRulesetType = () => { }, { onSuccess: () => { - queryClient.invalidateQueries(['rulesetTypes']); + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * Hook to create a project rule (assign a rule to a project). + */ +export const useCreateProjectRule = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'create'], + async ({ ruleId, projectId: pId }) => { + const { data } = await createProjectRule(ruleId, pId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to delete a project rule. + */ +export const useDeleteProjectRule = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'delete'], + async (projectRuleId: string) => { + const { data } = await deleteProjectRule(projectRuleId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); + } + } + ); +}; + +/** + * Hook to update project rule status. + */ +export const useEditProjectRuleStatus = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['rules', 'projectRules', 'editStatus'], + async ({ projectRuleId, newStatus }) => { + const { data } = await editProjectRuleStatus(projectRuleId, newStatus); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); } } ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx new file mode 100644 index 0000000000..b7e46badfb --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -0,0 +1,325 @@ +/* + * 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 { useState, useMemo } from 'react'; +import { + Box, + Typography, + CircularProgress, + Alert, + FormControl, + Select, + MenuItem, + SelectChangeEvent, + IconButton, + useTheme +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { Rule } from 'shared'; +import NERModal from '../../../../components/NERModal'; +import { useUnassignedRulesForRuleset } from '../../../../hooks/rules.hooks'; + +interface AddRuleModalProps { + open: boolean; + onHide: () => void; + rulesetId: string; + teamId: string; + onSubmit: (ruleIds: string[]) => void; +} + +const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModalProps) => { + const theme = useTheme(); + const [selectedSection, setSelectedSection] = useState(''); + const [selectedSubSection1, setSelectedSubSection1] = useState(''); + const [selectedSubSection2, setSelectedSubSection2] = useState(''); + const [selectedRuleIds, setSelectedRuleIds] = useState([]); + + const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId); + + // Get top-level sections (rules without parent) + const sections = useMemo(() => { + if (!unassignedRules) return []; + return unassignedRules.filter((rule: Rule) => !rule.parentRule); + }, [unassignedRules]); + + // Get sub-sections based on selected section + const subSections1 = useMemo(() => { + if (!unassignedRules || !selectedSection) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedSection); + }, [unassignedRules, selectedSection]); + + // Get sub-sub-sections based on selected sub-section + const subSections2 = useMemo(() => { + if (!unassignedRules || !selectedSubSection1) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedSubSection1); + }, [unassignedRules, selectedSubSection1]); + + // Get leaf rules based on selected sub-sub-section (or sub-section if no sub-sub-sections) + const leafRules = useMemo(() => { + if (!unassignedRules) return []; + const parentId = selectedSubSection2 || selectedSubSection1 || selectedSection; + if (!parentId) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === parentId && rule.subRuleIds.length === 0); + }, [unassignedRules, selectedSection, selectedSubSection1, selectedSubSection2]); + + const handleSectionChange = (event: SelectChangeEvent) => { + setSelectedSection(event.target.value); + setSelectedSubSection1(''); + setSelectedSubSection2(''); + setSelectedRuleIds([]); + }; + + const handleSubSection1Change = (event: SelectChangeEvent) => { + setSelectedSubSection1(event.target.value); + setSelectedSubSection2(''); + setSelectedRuleIds([]); + }; + + const handleSubSection2Change = (event: SelectChangeEvent) => { + setSelectedSubSection2(event.target.value); + setSelectedRuleIds([]); + }; + + const handleRuleSelect = (event: SelectChangeEvent) => { + const ruleId = event.target.value; + if (ruleId && !selectedRuleIds.includes(ruleId)) { + setSelectedRuleIds((prev) => [...prev, ruleId]); + } + }; + + const handleRemoveRule = (ruleId: string) => { + setSelectedRuleIds((prev) => prev.filter((id) => id !== ruleId)); + }; + + const handleSubmit = () => { + onSubmit(selectedRuleIds); + resetForm(); + onHide(); + }; + + const handleClose = () => { + resetForm(); + onHide(); + }; + + const resetForm = () => { + setSelectedSection(''); + setSelectedSubSection1(''); + setSelectedSubSection2(''); + setSelectedRuleIds([]); + }; + + // Get rule display name + const getRuleName = (ruleId: string): string => { + const rule = unassignedRules?.find((r: Rule) => r.ruleId === ruleId); + return rule ? rule.ruleCode : ruleId; + }; + + // Dropdown styling + const selectStyles = { + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + color: theme.palette.text.primary, + '& .MuiSelect-select': { + py: 1.5, + px: 2.5 + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none' + }, + '& .MuiSvgIcon-root': { + color: theme.palette.text.primary + } + }; + + const labelStyles = { + color: '#ef4345', + fontWeight: 700, + textDecoration: 'underline', + fontSize: '20px', + mb: 1.5 + }; + + // Selected rule row styling + const selectedRuleStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + px: 2.5, + py: 1.5, + mb: 1.5 + }; + + return ( + + + {isLoading ? ( + + + + ) : isError ? ( + Failed to load rules + ) : !unassignedRules || unassignedRules.length === 0 ? ( + + No unassigned rules available for this team. + + ) : ( + + {/* Select Section */} + + Select Section + + + + + + {/* Select Sub-Section 1 */} + + Select Sub-Section + + + + + + {/* Select Sub-Section 2 */} + + Select Sub-Section + + + + + + {/* Select Rules */} + + Select Rules + + {/* Selected Rules */} + {selectedRuleIds.map((ruleId) => ( + + + handleRemoveRule(ruleId)} + sx={{ color: theme.palette.text.primary, p: 0.5, mr: 1 }} + > + + + {getRuleName(ruleId)} + + + + ))} + + {/* Add Subtask dropdown */} + + + + + + )} + + + ); +}; + +export default AddRuleModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index ae8577fb37..87167c788b 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -3,9 +3,409 @@ * See the LICENSE file in the repository root folder for details. */ -import { Box } from '@mui/material'; -import { Project } from 'shared'; +import { useState, useMemo } from 'react'; +import { + Box, + Button, + Typography, + CircularProgress, + Alert, + Tab, + Tabs as MuiTabs, + Table, + TableBody, + TableContainer, + Paper, + useTheme +} from '@mui/material'; +import { Project, ProjectRule, Rule, RuleCompletion } from 'shared'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import RuleRow from '../../../RulesPage/RuleRow'; +import UpdateStatusPopover from './UpdateStatusPopover'; +import AddRuleModal from './AddRuleModal'; +import { + useAllRulesetTypes, + useActiveRuleset, + useProjectRules, + useEditProjectRuleStatus, + useCreateProjectRule +} from '../../../../hooks/rules.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; -export const ProjectRulesTab = ({ project: _project }: { project: Project }) => { - return ; +interface ProjectRulesTabProps { + project: Project; +} + +/** + * Get the status chip configuration + */ +const getStatusConfig = (status: RuleCompletion) => { + switch (status) { + case RuleCompletion.COMPLETED: + return { label: 'Complete', color: '#4caf50' }; + case RuleCompletion.INCOMPLETE: + return { label: 'Incomplete', color: '#f44336' }; + case RuleCompletion.REVIEW: + default: + return { label: 'Review', color: '#ff9800' }; + } +}; + +export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { + const toast = useToast(); + const theme = useTheme(); + + // State for modals and popovers + const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); + const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); + const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); + const [selectedProjectRule, setSelectedProjectRule] = useState(null); + + // Fetch all ruleset types + const { data: rulesetTypes, isLoading: rulesetTypesLoading, isError: rulesetTypesError } = useAllRulesetTypes(); + + // Get the currently selected ruleset type + const selectedRulesetType = rulesetTypes?.[selectedRulesetTypeIndex]; + + // Fetch the active ruleset for the selected ruleset type + const { data: activeRuleset, isLoading: activeRulesetLoading } = useActiveRuleset( + selectedRulesetType?.rulesetTypeId || '' + ); + + // Fetch project rules for the active ruleset + const { + data: projectRules, + isLoading: projectRulesLoading, + isError: projectRulesError + } = useProjectRules(activeRuleset?.rulesetId || '', project.id); + + // Mutations + const { mutateAsync: editStatusMutation, isLoading: isUpdatingStatus } = useEditProjectRuleStatus( + activeRuleset?.rulesetId || '', + project.id + ); + + const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule( + activeRuleset?.rulesetId || '', + project.id + ); + + // Get the first team's ID for fetching unassigned rules + const teamId = project.teams[0]?.teamId || ''; + + // Convert project rules to rules + const allRules = useMemo(() => { + if (!projectRules) return []; + return projectRules.map((pr) => pr.rule); + }, [projectRules]); + + // Get top-level rules (rules without a parent) + const topLevelRules = useMemo(() => { + return allRules.filter((rule) => !rule.parentRule); + }, [allRules]); + + // Helper function to get all descendant leaf rules for a given rule + const getDescendantLeafRules = (rule: Rule): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + // This is a leaf rule + return [rule]; + } + // Recursively get leaf rules from all children + return children.flatMap((child) => getDescendantLeafRules(child)); + }; + + // Helper function to calculate aggregated status from leaf rules + const getAggregatedStatus = (rule: Rule): RuleCompletion => { + const leafRules = getDescendantLeafRules(rule); + if (leafRules.length === 0) { + return RuleCompletion.REVIEW; + } + + const leafStatuses = leafRules.map((leafRule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === leafRule.ruleId); + return projectRule?.currentStatus || RuleCompletion.REVIEW; + }); + + if (leafStatuses.every((s) => s === RuleCompletion.COMPLETED)) { + return RuleCompletion.COMPLETED; + } + + if (leafStatuses.some((s) => s === RuleCompletion.INCOMPLETE)) { + return RuleCompletion.INCOMPLETE; + } + + return RuleCompletion.REVIEW; + }; + + // Handle status update + const handleStatusUpdate = async (projectRuleId: string, newStatus: RuleCompletion) => { + try { + await editStatusMutation({ projectRuleId, newStatus }); + toast.success('Rule status updated successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle add rules + const handleAddRules = async (ruleIds: string[]) => { + try { + for (const ruleId of ruleIds) { + await createProjectRuleMutation({ ruleId, projectId: project.id }); + } + toast.success(`${ruleIds.length} rule${ruleIds.length !== 1 ? 's' : ''} added successfully`); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + // Handle opening status popover + const handleStatusClick = (event: React.MouseEvent, rule: Rule) => { + const projectRule = projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId); + if (projectRule) { + // Only allow status updates for leaf rules + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + if (!hasChildren) { + setSelectedProjectRule(projectRule); + setStatusPopoverAnchor(event.currentTarget); + } + } + }; + + // Handle closing status popover + const handleStatusPopoverClose = () => { + setStatusPopoverAnchor(null); + setSelectedProjectRule(null); + }; + + // Handle tab change + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setSelectedRulesetTypeIndex(newValue); + }; + + // Loading state + if (rulesetTypesLoading) { + return ; + } + + // Error state + if (rulesetTypesError) { + return ; + } + + // No ruleset types + if (!rulesetTypes || rulesetTypes.length === 0) { + return ( + + + No ruleset types configured for this organization. + + + ); + } + + // Check if we have no active ruleset + const hasNoActiveRuleset = !activeRulesetLoading && !activeRuleset; + + // Right content for rule rows - status badge + const renderRightContent = (rule: Rule) => { + const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + const isLeafRule = !hasChildren; + + // Get status - for leaf rules use their own status, for parents calculate from children + const status = isLeafRule + ? projectRules?.find((pr) => pr.rule.ruleId === rule.ruleId)?.currentStatus || RuleCompletion.REVIEW + : getAggregatedStatus(rule); + const statusConfig = getStatusConfig(status); + + return ( + ) => { + e.stopPropagation(); + handleStatusClick(e, rule); + } + : undefined + } + sx={{ + backgroundColor: statusConfig.color, + color: 'white', + fontSize: '11px', + fontWeight: 600, + px: 0.75, + py: 0.25, + borderRadius: '3px', + cursor: isLeafRule ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + whiteSpace: 'nowrap', + '&:hover': isLeafRule + ? { + opacity: 0.85 + } + : {} + }} + > + {statusConfig.label} + + ); + }; + + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + return ( + + {/* Ruleset Type Tabs */} + + + {rulesetTypes.map((rulesetType, idx) => ( + + ))} + + + + {/* Rules Content */} + {activeRulesetLoading || projectRulesLoading ? ( + + + + ) : hasNoActiveRuleset ? ( + + + No active ruleset configured for this ruleset type. + + + ) : projectRulesError ? ( + Failed to load rules + ) : topLevelRules.length === 0 ? ( + + + No rules assigned to this project yet. + + + ) : ( + + + + + {topLevelRules.map((rule) => ( + + ))} + +
+
+
+ )} + + {/* Add Rule Button */} + + + + + + + + {/* Update Status Popover */} + {selectedProjectRule && ( + + )} + + {/* Add Rule Modal */} + {activeRuleset && teamId && ( + setAddRuleModalOpen(false)} + rulesetId={activeRuleset.rulesetId} + teamId={teamId} + onSubmit={handleAddRules} + /> + )} + + {/* Loading overlay */} + {(isUpdatingStatus || isCreating) && ( + + + + )} + + ); }; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx new file mode 100644 index 0000000000..ff5f877ed6 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -0,0 +1,81 @@ +/* + * 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 { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; +import { ProjectRule, RuleCompletion } from 'shared'; + +interface UpdateStatusPopoverProps { + anchorEl: HTMLElement | null; + onClose: () => void; + projectRule: ProjectRule; + onStatusChange: (projectRuleId: string, newStatus: RuleCompletion) => void; +} + +const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: UpdateStatusPopoverProps) => { + const open = Boolean(anchorEl); + + const handleStatusChange = (status: RuleCompletion) => { + onStatusChange(projectRule.projectRuleId, status); + onClose(); + }; + + const statusOptions = [ + { value: RuleCompletion.COMPLETED, label: 'Completed' }, + { value: RuleCompletion.INCOMPLETE, label: 'Incomplete' } + ]; + + return ( + + + {statusOptions.map((option) => ( + handleStatusChange(option.value)} + sx={{ + color: 'white', + '&.Mui-checked': { + color: 'white' + }, + p: 0.5 + }} + /> + } + label={{option.label}} + sx={{ + display: 'flex', + m: 0, + py: 0.5 + }} + /> + ))} + + + ); +}; + +export default UpdateStatusPopover; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 36785ec59b..29bc8852a0 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -436,11 +436,22 @@ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => (endDate ? `end=${encodeURIComponent(endDate.toISOString())}` : ''); const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; -/************** Rule Endpoints ***************/ +/**************** Rules Endpoints ****************/ const rules = () => `${API_URL}/rules`; const ruleset = () => `${rules()}/ruleset`; const rulesetTypeCreate = () => `${rules()}/rulesetType/create`; const rulesetsCreate = () => `${ruleset()}/create`; +const rulesGetAllRulesetTypes = () => `${rules()}/rulesetTypes`; +const rulesGetActiveRuleset = (rulesetTypeId: string) => `${rules()}/rulesetType/${rulesetTypeId}/active`; +const rulesGetProjectRules = (rulesetId: string, projectId: string) => + `${rules()}/ruleset/${rulesetId}/project/${projectId}/rules`; +const rulesGetUnassignedRulesForRuleset = (rulesetId: string, teamId: string) => + `${rules()}/ruleset/${rulesetId}/team/${teamId}/rules/unassigned`; +const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; +const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; +const rulesEditProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/editStatus`; +const rulesGetChildRules = (ruleId: string) => `${rules()}/${ruleId}/subrules`; +const rulesGetTopLevelRules = (rulesetId: string) => `${rules()}/${rulesetId}/parentRules`; /**************** Other Endpoints ****************/ const version = () => `https://api.github.com/repos/Northeastern-Electric-Racing/FinishLine/releases/latest`; @@ -744,9 +755,19 @@ export const apiUrls = { retrospectiveTimelines, retrospectiveBudgets, + rules, ruleset, rulesetTypeCreate, rulesetsCreate, + rulesGetAllRulesetTypes, + rulesGetActiveRuleset, + rulesGetProjectRules, + rulesGetUnassignedRulesForRuleset, + rulesCreateProjectRule, + rulesDeleteProjectRule, + rulesEditProjectRuleStatus, + rulesGetChildRules, + rulesGetTopLevelRules, version }; From 2195bf9a45376879421eb51063768cd9e8bf61e9 Mon Sep 17 00:00:00 2001 From: Zachary Wen Date: Sat, 3 Jan 2026 22:16:44 -0500 Subject: [PATCH 2/4] #3827 test fixes --- src/frontend/src/apis/rules.api.ts | 17 ++--------------- src/frontend/src/hooks/rules.hooks.ts | 13 ------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index b12c95ec2f..ac84d23a02 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -13,20 +13,6 @@ import { ruleTransformer } from './transformers/rules.transformers'; -/** - * Gets all top-level rules (rules with no parent) for a ruleset - */ -export const getTopLevelRules = (rulesetId: string) => { - return axios.get(apiUrls.rulesTopLevel(rulesetId)); -}; - -/** - * Gets all child rules of a specific rule - */ -export const getChildRules = (ruleId: string) => { - return axios.get(apiUrls.rulesChildRules(ruleId)); -}; - /** * Toggles team assignment for a rule */ @@ -130,7 +116,7 @@ export const editProjectRuleStatus = (projectRuleId: string, newStatus: RuleComp * @param ruleId The ID of the parent rule. */ export const getChildRules = (ruleId: string) => { - return axios.get(apiUrls.rulesGetChildRules(ruleId), { + return axios.get(apiUrls.rulesChildRules(ruleId), { transformResponse: (data) => JSON.parse(data).map(ruleTransformer) }); }; @@ -144,6 +130,7 @@ export const getTopLevelRules = (rulesetId: string) => { return axios.get(apiUrls.rulesGetTopLevelRules(rulesetId), { transformResponse: (data) => JSON.parse(data).map(ruleTransformer) }); +}; /** * Fetches all Rulesets for a specific Ruleset Type. diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index c0a295ca99..f28ceffa6e 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -18,7 +18,6 @@ import { getTopLevelRules, toggleRuleTeam, getTeamRulesInRulesetType, - createRulesetType, getRulesetsByRulesetType } from '../apis/rules.api'; @@ -226,18 +225,6 @@ export const useEditProjectRuleStatus = (rulesetId: string, projectId: string) = ); }; -/** - * React Query hook to fetch all Ruleset Types. - * - * @returns Query result containing Ruleset Types data, loading state, and error state. - */ -export const useAllRulesetTypes = () => { - return useQuery(['rulesetTypes'], async () => { - const { data } = await getAllRulesetTypes(); - return data; - }); -}; - /** * React Query hook to fetch all Rulesets for a specific Ruleset Type. * From f46ccc1534354138f0583a729b6d08b78e542d7b Mon Sep 17 00:00:00 2001 From: Zachary Wen Date: Sun, 4 Jan 2026 17:10:41 -0500 Subject: [PATCH 3/4] #3827 test fixes --- src/frontend/src/apis/rules.api.ts | 4 ++-- src/frontend/src/hooks/rules.hooks.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index 952428f7da..aeac616fd6 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -41,7 +41,7 @@ export const createRulesetType = (payload: { name: string }) => { * Fetches all ruleset types for the organization. */ export const getAllRulesetTypes = () => { - return axios.get(apiUrls.rulesGetAllRulesetTypes(), { + return axios.get(apiUrls.rulesetTypes(), { transformResponse: (data) => JSON.parse(data).map(rulesetTypeTransformer) }); }; @@ -127,7 +127,7 @@ export const getChildRules = (ruleId: string) => { * @param rulesetId The ID of the ruleset. */ export const getTopLevelRules = (rulesetId: string) => { - return axios.get(apiUrls.rulesGetTopLevelRules(rulesetId), { + return axios.get(apiUrls.rulesTopLevel(rulesetId), { transformResponse: (data) => JSON.parse(data).map(ruleTransformer) }); }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 5ef28e8c22..e6908362e8 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -18,8 +18,6 @@ import { getTopLevelRules, toggleRuleTeam, getTeamRulesInRulesetType, - createRulesetType, - getAllRulesetTypes, getRulesetsByRulesetType, updateRuleset, deleteRuleset, From ce4bf055c4218d1f85bc6ad84b51cd7ed112e18c Mon Sep 17 00:00:00 2001 From: Aryan0102 Date: Mon, 5 Jan 2026 20:37:15 -0800 Subject: [PATCH 4/4] fixed the add rule modal --- src/frontend/src/hooks/rules.hooks.ts | 4 +- .../ProjectRules/AddRuleModal.tsx | 131 ++++-------------- .../ProjectRules/ProjectRulesTab.tsx | 5 +- 3 files changed, 29 insertions(+), 111 deletions(-) diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index e6908362e8..2bb8b0f2af 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -172,7 +172,7 @@ export const useCreateRulesetType = () => { /** * Hook to create a project rule (assign a rule to a project). */ -export const useCreateProjectRule = (rulesetId: string, projectId: string) => { +export const useCreateProjectRule = () => { const queryClient = useQueryClient(); return useMutation( ['rules', 'projectRules', 'create'], @@ -182,7 +182,7 @@ export const useCreateProjectRule = (rulesetId: string, projectId: string) => { }, { onSuccess: () => { - queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'projectRules']); queryClient.invalidateQueries(['rules', 'unassigned']); } } diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx index b7e46badfb..3afcff73e5 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddRuleModal.tsx @@ -32,54 +32,32 @@ interface AddRuleModalProps { const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModalProps) => { const theme = useTheme(); - const [selectedSection, setSelectedSection] = useState(''); - const [selectedSubSection1, setSelectedSubSection1] = useState(''); - const [selectedSubSection2, setSelectedSubSection2] = useState(''); const [selectedRuleIds, setSelectedRuleIds] = useState([]); const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, teamId); - // Get top-level sections (rules without parent) - const sections = useMemo(() => { - if (!unassignedRules) return []; - return unassignedRules.filter((rule: Rule) => !rule.parentRule); - }, [unassignedRules]); - - // Get sub-sections based on selected section - const subSections1 = useMemo(() => { - if (!unassignedRules || !selectedSection) return []; - return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedSection); - }, [unassignedRules, selectedSection]); + type ParentInfo = { ruleId: string; ruleCode: string }; - // Get sub-sub-sections based on selected sub-section - const subSections2 = useMemo(() => { - if (!unassignedRules || !selectedSubSection1) return []; - return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedSubSection1); - }, [unassignedRules, selectedSubSection1]); - - // Get leaf rules based on selected sub-sub-section (or sub-section if no sub-sub-sections) - const leafRules = useMemo(() => { + const uniqueParents = useMemo(() => { if (!unassignedRules) return []; - const parentId = selectedSubSection2 || selectedSubSection1 || selectedSection; - if (!parentId) return []; - return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === parentId && rule.subRuleIds.length === 0); - }, [unassignedRules, selectedSection, selectedSubSection1, selectedSubSection2]); + const parentMap = new Map(); + unassignedRules.forEach((rule: Rule) => { + if (rule.parentRule) { + parentMap.set(rule.parentRule.ruleId, rule.parentRule); + } + }); + return Array.from(parentMap.values()).sort((a, b) => a.ruleCode.localeCompare(b.ruleCode)); + }, [unassignedRules]); - const handleSectionChange = (event: SelectChangeEvent) => { - setSelectedSection(event.target.value); - setSelectedSubSection1(''); - setSelectedSubSection2(''); - setSelectedRuleIds([]); - }; + const [selectedParentId, setSelectedParentId] = useState(''); - const handleSubSection1Change = (event: SelectChangeEvent) => { - setSelectedSubSection1(event.target.value); - setSelectedSubSection2(''); - setSelectedRuleIds([]); - }; + const availableRules = useMemo(() => { + if (!unassignedRules || !selectedParentId) return []; + return unassignedRules.filter((rule: Rule) => rule.parentRule?.ruleId === selectedParentId); + }, [unassignedRules, selectedParentId]); - const handleSubSection2Change = (event: SelectChangeEvent) => { - setSelectedSubSection2(event.target.value); + const handleParentChange = (event: SelectChangeEvent) => { + setSelectedParentId(event.target.value); setSelectedRuleIds([]); }; @@ -106,10 +84,8 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModa }; const resetForm = () => { - setSelectedSection(''); - setSelectedSubSection1(''); - setSelectedSubSection2(''); setSelectedRuleIds([]); + setSelectedParentId(''); }; // Get rule display name @@ -182,9 +158,10 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModa Select Section - -
- - {/* Select Sub-Section 1 */} - - Select Sub-Section - - - - - - {/* Select Sub-Section 2 */} - - Select Sub-Section - - @@ -287,7 +208,7 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModa value="" onChange={handleRuleSelect} displayEmpty - disabled={!selectedSection} + disabled={!selectedParentId} sx={selectStyles} MenuProps={{ PaperProps: { @@ -298,7 +219,7 @@ const AddRuleModal = ({ open, onHide, rulesetId, teamId, onSubmit }: AddRuleModa Add Subtask - {leafRules + {availableRules .filter((rule: Rule) => !selectedRuleIds.includes(rule.ruleId)) .map((rule: Rule) => ( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 87167c788b..29cd678e33 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -86,10 +86,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { project.id ); - const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule( - activeRuleset?.rulesetId || '', - project.id - ); + const { mutateAsync: createProjectRuleMutation, isLoading: isCreating } = useCreateProjectRule(); // Get the first team's ID for fetching unassigned rules const teamId = project.teams[0]?.teamId || '';