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..5a2e7bcce8 100644
--- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx
+++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx
@@ -1,39 +1,93 @@
-import React from 'react';
-import { Box, Paper, Table, TableBody, TableContainer } from '@mui/material';
+import React, { useState } from 'react';
+import { Box, Paper, Table, TableBody, TableContainer, useTheme } 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 theme = useTheme();
+ const toast = useToast();
+ const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null);
+ const [selectedRule, setSelectedRule] = useState(null);
+
+ 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);
+ 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 (
-
-
+
+
{topLevelRules.map((rule) => (
null}
- backgroundColor="#9d9d9d"
- textColor="#000000"
- hoverColor="#5e5e5e"
- rowHeight="10px"
- verticalPadding="5px"
+ rightContent={(r) => (
+ {
+ setSelectedRule(r);
+ setStatusPopoverAnchor(e.currentTarget);
+ }}
+ />
+ )}
+ backgroundColor={tableBackgroundColor}
+ textColor={tableTextColor}
+ hoverColor={tableHoverColor}
+ rowHeight="40px"
+ verticalPadding="8px"
+ indentRow
/>
))}
+
+ {selectedRule && (
+
+ )}
);
};
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-')}
/>
))}
diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts
index c0804c7158..ef3c6f0d72 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,38 @@ 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);
+ 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' };
+};