From 292b39c373bc13cb6e72aa1a99ef0b800fe69439 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 6 Jul 2026 14:14:13 -0400 Subject: [PATCH 1/3] #4286 moved rule status logic and added to general view --- src/frontend/src/hooks/rules.hooks.ts | 1 + .../ProjectRules/ProjectRulesTab.tsx | 109 ++---------------- .../ProjectRules/UpdateStatusPopover.tsx | 10 +- .../src/pages/RulesPage/RulesetViewPage.tsx | 2 +- .../RulesPage/components/RuleStatusTag.tsx | 86 ++++++++++++++ .../components/RulesetGeneralView.tsx | 53 ++++++++- src/frontend/src/utils/rules.utils.ts | 42 ++++++- 7 files changed, 195 insertions(+), 108 deletions(-) create mode 100644 src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 22a3ea7df3..d24c97cfb0 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -360,6 +360,7 @@ export const useSetRuleCompletion = (rulesetId: string, projectId: string) => { onSuccess: () => { queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); queryClient.invalidateQueries(['rules', 'unassigned']); + queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); } } ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index a80bc77c2e..20006e7bec 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -34,21 +34,15 @@ import { useCreateProjectRule } from '../../../../hooks/rules.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; -import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; +import { InfoOutlined } from '@mui/icons-material'; import { useHistory } from 'react-router-dom'; import { routes } from '../../../../utils/routes'; +import RuleStatusTag from '../../../RulesPage/components/RuleStatusTag'; interface ProjectRulesTabProps { project: Project; } -/** - * Get the status chip configuration - */ -const getStatusConfig = (isComplete: boolean) => { - return isComplete ? { label: 'Complete', color: '#4caf50' } : { label: 'Incomplete', color: '#f44336' }; -}; - export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const toast = useToast(); const theme = useTheme(); @@ -100,27 +94,6 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { 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 completion from leaf rules. - // A parent is complete only if all of its descendant leaf rules are complete. - const getAggregatedStatus = (rule: Rule): boolean => { - const leafRules = getDescendantLeafRules(rule); - if (leafRules.length === 0) { - return false; - } - return leafRules.every((leafRule) => leafRule.isComplete); - }; - // Handle completion update const handleStatusUpdate = async (ruleId: string, isComplete: boolean) => { try { @@ -193,77 +166,19 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { // Check if we have no active ruleset const hasNoActiveRuleset = !activeRulesetLoading && !activeRuleset; - // Right content for rule rows - status badge + // Right content for rule rows - status badge. Leaf rules are clickable to open + // the completion popover; parents show an aggregated, read-only status. const renderRightContent = (rule: Rule) => { - const hasChildren = allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); - const isLeafRule = !hasChildren; - - // Completion - for leaf rules use their own, for parents aggregate from children - const isComplete = isLeafRule ? rule.isComplete : getAggregatedStatus(rule); - const statusConfig = getStatusConfig(isComplete); - - const completedByName = rule.completedBy && `${rule.completedBy.firstName} ${rule.completedBy.lastName}`; - const completionMessage = completedByName - ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` - : ''; - - // Whether the status popover is currently open for this rule + const isLeafRule = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); const isPopoverOpenForRule = Boolean(statusPopoverAnchor) && selectedProjectRule?.rule.ruleId === rule.ruleId; return ( - - {isLeafRule && isComplete && completionMessage && ( - - e.stopPropagation()} - sx={{ - padding: '2px', - color: 'text.secondary' - }} - > - - - - )} - ) => { - e.stopPropagation(); - handleStatusClick(e, rule); - } - : undefined - } - sx={{ - backgroundColor: statusConfig.color, - color: 'white', - fontSize: '11px', - fontWeight: 600, - pl: isLeafRule ? 0.25 : 0.75, - pr: 0.75, - py: 0.25, - borderRadius: '3px', - cursor: isLeafRule ? 'pointer' : 'default', - display: 'inline-flex', - alignItems: 'center', - whiteSpace: 'nowrap', - '&:hover': isLeafRule - ? { - opacity: 0.85 - } - : {} - }} - > - {isLeafRule && - (isPopoverOpenForRule ? ( - - ) : ( - - ))} - {statusConfig.label} - - + handleStatusClick(e, rule) : undefined} + /> ); }; @@ -432,7 +347,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { )} diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx index 66287f4303..1437603c9a 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover.tsx @@ -4,20 +4,20 @@ */ import { Box, Checkbox, FormControlLabel, Popover, Typography } from '@mui/material'; -import { ProjectRule } from 'shared'; +import { Rule } from 'shared'; interface UpdateStatusPopoverProps { anchorEl: HTMLElement | null; onClose: () => void; - projectRule: ProjectRule; + rule: Rule; onStatusChange: (ruleId: string, isComplete: boolean) => void; } -const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: UpdateStatusPopoverProps) => { +const UpdateStatusPopover = ({ anchorEl, onClose, rule, onStatusChange }: UpdateStatusPopoverProps) => { const open = Boolean(anchorEl); const handleStatusChange = (isComplete: boolean) => { - onStatusChange(projectRule.rule.ruleId, isComplete); + onStatusChange(rule.ruleId, isComplete); onClose(); }; @@ -54,7 +54,7 @@ const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }: key={option.label} control={ handleStatusChange(option.value)} sx={{ color: 'white', diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index 4e7ece0ba1..904e5809a4 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -118,7 +118,7 @@ const RulesetViewPage = () => { {tabIndex === 0 ? ( ) : ( - + )} diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx new file mode 100644 index 0000000000..beec41bac5 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -0,0 +1,86 @@ +/* + * 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, IconButton, Tooltip } from '@mui/material'; +import { InfoOutlined, KeyboardArrowRight, KeyboardArrowDown } from '@mui/icons-material'; +import { Rule } from 'shared'; +import { getRuleStatusConfig, isRuleComplete } from '../../../utils/rules.utils'; + +interface RuleStatusTagProps { + rule: Rule; + allRules: Rule[]; + // ability to update completion status + onClick?: (event: React.MouseEvent) => void; + // controls chevron direction when completion is interactive + popoverOpen?: boolean; +} + +/** + * Completion status chip for a rule. + * A leaf shows its own completion, while a parent is only complete if all + * of its descendant leaf rules are complete. Completed leafs also show an + * info tooltip with who completed it and in which project. + */ +const RuleStatusTag: React.FC = ({ rule, allRules, onClick, popoverOpen = false }) => { + const isComplete = isRuleComplete(rule, allRules); + const { label, color } = getRuleStatusConfig(isComplete); + + const isLeaf = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId); + // Note: Info tooltip only says "Completed by {User}" if completed in general view + const completedByName = rule.completedBy && `${rule.completedBy.firstName} ${rule.completedBy.lastName}`; + const completionMessage = completedByName + ? `Completed by ${completedByName}${rule.completedInProject ? ` in ${rule.completedInProject.projectName}` : ''}` + : ''; + + // only leafs are interactive + const isInteractive = isLeaf && Boolean(onClick); + + return ( + + {isLeaf && isComplete && completionMessage && ( + + e.stopPropagation()} sx={{ padding: '2px', color: 'text.secondary' }}> + + + + )} + ) => { + e.stopPropagation(); + onClick!(e); + } + : undefined + } + sx={{ + backgroundColor: color, + color: 'white', + fontSize: '11px', + fontWeight: 600, + pl: isInteractive ? 0.25 : 0.75, + pr: 0.75, + py: 0.25, + borderRadius: '3px', + cursor: isInteractive ? 'pointer' : 'default', + display: 'inline-flex', + alignItems: 'center', + whiteSpace: 'nowrap', + '&:hover': isInteractive ? { opacity: 0.85 } : {} + }} + > + {isInteractive && + (popoverOpen ? ( + + ) : ( + + ))} + {label} + + + ); +}; + +export default RuleStatusTag; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index 56f556f019..e5c5cde05d 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -1,18 +1,46 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; import { Rule } from 'shared'; import RuleRow from '../RuleRow'; +import RuleStatusTag from './RuleStatusTag'; +import UpdateStatusPopover from '../../ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover'; +import { useSetRuleCompletion } from '../../../hooks/rules.hooks'; +import { useToast } from '../../../hooks/toasts.hooks'; interface RulesetGeneralViewProps { allRules: Rule[]; + rulesetId: string; } /** * general view for displaying all top-level rules as dropdowns */ -const RulesetGeneralView: React.FC = ({ allRules }) => { +const RulesetGeneralView: React.FC = ({ allRules, rulesetId }) => { + const toast = useToast(); + const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); + const [selectedRule, setSelectedRule] = useState(null); + + // Completion here is ruleset-wide (no project context), so no projectId is passed + const { mutateAsync: setCompletion } = useSetRuleCompletion(rulesetId, ''); + const topLevelRules = allRules.filter((rule) => !rule.parentRule); + const handleStatusClose = () => { + setStatusPopoverAnchor(null); + setSelectedRule(null); + }; + + const handleStatusChange = async (ruleId: string, isComplete: boolean) => { + try { + await setCompletion({ ruleId, isComplete }); + toast.success('Rule completion updated successfully'); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + return ( @@ -23,7 +51,17 @@ const RulesetGeneralView: React.FC = ({ allRules }) => key={rule.ruleId} rule={rule} allRules={allRules} - rightContent={() => null} + rightContent={(r) => ( + { + setSelectedRule(r); + setStatusPopoverAnchor(e.currentTarget); + }} + /> + )} backgroundColor="#9d9d9d" textColor="#000000" hoverColor="#5e5e5e" @@ -34,6 +72,15 @@ const RulesetGeneralView: React.FC = ({ allRules }) => + + {selectedRule && ( + + )} ); }; diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index c0804c7158..37ea526737 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -6,8 +6,8 @@ import { Rule } from 'shared'; /** - * Counts the total number of rules that will be deleted when deleting a rule - * (including the rule itself and all its descendants) + * Counts the total number of rules that will be deleted when deleting a rule, including + * the rule itself and all its descendants. Does not include parents or siblings. * @param rule - The rule to delete * @param allRules - All rules in the ruleset * @returns The total number of rules that will be deleted @@ -20,3 +20,41 @@ export const countRulesToDelete = (rule: Rule, allRules: Rule[]): number => { } return count; }; + +/** + * Gets all leaf rules of a given rule (the rule itself if it has no children). + * Does not include the rule's parent or siblings. + * + * @param rule - rule to start from + * @param allRules - all rules in scope + * @returns The leaf rules under the given rule, or rule if it is already a leaf + */ +export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + return [rule]; + } + return children.flatMap((child) => getDescendantLeafRules(child, allRules)); +}; + +/** + * Whether a rule is complete. A leaf uses its own completion; a parent is + * complete only if all of its descendant leaf rules are complete. + * @param rule - The rule to check + * @param allRules - All rules in scope + * @returns True if the rule (or all its leaves) are complete + */ +export const isRuleComplete = (rule: Rule, allRules: Rule[]): boolean => { + const leafRules = getDescendantLeafRules(rule, allRules); + if (leafRules.length === 0) { + return false; + } + return leafRules.every((leafRule) => leafRule.isComplete); +}; + +/** + * Status chip label and color for a completion state. + */ +export const getRuleStatusConfig = (isComplete: boolean): { label: string; color: string } => { + return isComplete ? { label: 'Complete', color: '#4caf50' } : { label: 'Incomplete', color: '#f44336' }; +}; From cf3d880a931f4dc0bbc5effd3f2368ae2ab7f451 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 6 Jul 2026 14:44:38 -0400 Subject: [PATCH 2/3] #4286 team view formatting --- .../components/RulesetGeneralView.tsx | 25 +++++++++++------- .../RulesPage/components/RulesetTeamView.tsx | 26 ++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index e5c5cde05d..5a2e7bcce8 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; +import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; import { Rule } from 'shared'; import RuleRow from '../RuleRow'; import RuleStatusTag from './RuleStatusTag'; @@ -16,11 +16,17 @@ interface RulesetGeneralViewProps { * general view for displaying all top-level rules as dropdowns */ const RulesetGeneralView: React.FC = ({ allRules, rulesetId }) => { + const theme = useTheme(); const toast = useToast(); const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); const [selectedRule, setSelectedRule] = useState(null); - // Completion here is ruleset-wide (no project context), so no projectId is passed + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + // Completion in general view is for the whole ruleset, so no projectId is passed in const { mutateAsync: setCompletion } = useSetRuleCompletion(rulesetId, ''); const topLevelRules = allRules.filter((rule) => !rule.parentRule); @@ -43,8 +49,8 @@ const RulesetGeneralView: React.FC = ({ allRules, rules return ( - - + +
{topLevelRules.map((rule) => ( = ({ allRules, rules }} /> )} - backgroundColor="#9d9d9d" - textColor="#000000" - hoverColor="#5e5e5e" - rowHeight="10px" - verticalPadding="5px" + backgroundColor={tableBackgroundColor} + textColor={tableTextColor} + hoverColor={tableHoverColor} + rowHeight="40px" + verticalPadding="8px" + indentRow /> ))} diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx index 7e89317248..e5f7a047af 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material'; +import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; import { Rule } from 'shared'; import RuleRow from '../RuleRow'; @@ -27,6 +27,13 @@ interface RulesetTeamViewProps { * Teams and projects are rendered as RuleRows for consistent formatting */ const RulesetTeamView: React.FC = ({ allRules, teamRules, unassignedToTeam }) => { + const theme = useTheme(); + + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + // Convert teams to mock rules for rendering with RuleRow const teamRulesAsRules: Rule[] = teamRules.map((team) => ({ ruleId: `team-${team.teamId}`, @@ -105,20 +112,21 @@ const RulesetTeamView: React.FC = ({ allRules, teamRules, return ( - -
- + +
+ {topLevelItems.map((item) => ( null} - backgroundColor="#9d9d9d" - textColor="#000000" - hoverColor="#5e5e5e" - rowHeight="10px" - verticalPadding="5px" + backgroundColor={tableBackgroundColor} + textColor={tableTextColor} + hoverColor={tableHoverColor} + rowHeight="40px" + verticalPadding="8px" + indentRow initiallyExpanded={item.ruleId.startsWith('team-')} /> ))} From f86a25e6550255c2fdc58de3b15b5f83514af85a Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 6 Jul 2026 14:52:27 -0400 Subject: [PATCH 3/3] #4286 small fix --- src/frontend/src/utils/rules.utils.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index 37ea526737..ef3c6f0d72 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -46,9 +46,6 @@ export const getDescendantLeafRules = (rule: Rule, allRules: Rule[]): Rule[] => */ export const isRuleComplete = (rule: Rule, allRules: Rule[]): boolean => { const leafRules = getDescendantLeafRules(rule, allRules); - if (leafRules.length === 0) { - return false; - } return leafRules.every((leafRule) => leafRule.isComplete); };