Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/frontend/src/hooks/rules.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved into util functions

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 {
Expand Down Expand Up @@ -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 (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved into RuleStatusTag

{isLeafRule && isComplete && completionMessage && (
<Tooltip title={completionMessage} arrow>
<IconButton
size="small"
onClick={(e) => e.stopPropagation()}
sx={{
padding: '2px',
color: 'text.secondary'
}}
>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
)}
<Box
onClick={
isLeafRule
? (e: React.MouseEvent<HTMLElement>) => {
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 ? (
<KeyboardArrowDown sx={{ fontSize: '16px', mr: 0.25 }} />
) : (
<KeyboardArrowRight sx={{ fontSize: '16px', mr: 0.25 }} />
))}
{statusConfig.label}
</Box>
</Box>
<RuleStatusTag
rule={rule}
allRules={allRules}
popoverOpen={isPopoverOpenForRule}
onClick={isLeafRule ? (e) => handleStatusClick(e, rule) : undefined}
/>
);
};

Expand Down Expand Up @@ -432,7 +347,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
<UpdateStatusPopover
anchorEl={statusPopoverAnchor}
onClose={handleStatusPopoverClose}
projectRule={selectedProjectRule}
rule={selectedProjectRule.rule}
onStatusChange={handleStatusUpdate}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down Expand Up @@ -54,7 +54,7 @@ const UpdateStatusPopover = ({ anchorEl, onClose, projectRule, onStatusChange }:
key={option.label}
control={
<Checkbox
checked={projectRule.rule.isComplete === option.value}
checked={rule.isComplete === option.value}
onChange={() => handleStatusChange(option.value)}
sx={{
color: 'white',
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/pages/RulesPage/RulesetViewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ const RulesetViewPage = () => {
{tabIndex === 0 ? (
<RulesetTeamView allRules={allRules} teamRules={teamRules} unassignedToTeam={unassignedToTeam} />
) : (
<RulesetGeneralView allRules={allRules} />
<RulesetGeneralView allRules={allRules} rulesetId={rulesetId!} />
)}
</Box>
</PageLayout>
Expand Down
86 changes: 86 additions & 0 deletions src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => 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<RuleStatusTagProps> = ({ 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 (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}>
{isLeaf && isComplete && completionMessage && (
<Tooltip title={completionMessage} arrow>
<IconButton size="small" onClick={(e) => e.stopPropagation()} sx={{ padding: '2px', color: 'text.secondary' }}>
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
)}
<Box
onClick={
isInteractive
? (e: React.MouseEvent<HTMLElement>) => {
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 ? (
<KeyboardArrowDown sx={{ fontSize: '16px', mr: 0.25 }} />
) : (
<KeyboardArrowRight sx={{ fontSize: '16px', mr: 0.25 }} />
))}
{label}
</Box>
</Box>
);
};

export default RuleStatusTag;
76 changes: 65 additions & 11 deletions src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx
Original file line number Diff line number Diff line change
@@ -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<RulesetGeneralViewProps> = ({ allRules }) => {
const RulesetGeneralView: React.FC<RulesetGeneralViewProps> = ({ allRules, rulesetId }) => {
const theme = useTheme();
const toast = useToast();
const [statusPopoverAnchor, setStatusPopoverAnchor] = useState<HTMLElement | null>(null);
const [selectedRule, setSelectedRule] = useState<Rule | null>(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 (
<Box>
<TableContainer component={Paper} sx={{ borderRadius: '8px', overflow: 'hidden' }}>
<Table sx={{ borderCollapse: 'collapse' }}>
<TableContainer component={Paper} elevation={0} sx={{ borderRadius: '8px', overflow: 'hidden', backgroundColor }}>
<Table sx={{ borderCollapse: 'separate', borderSpacing: '0 8px', backgroundColor }}>
<TableBody>
{topLevelRules.map((rule) => (
<RuleRow
key={rule.ruleId}
rule={rule}
allRules={allRules}
rightContent={() => null}
backgroundColor="#9d9d9d"
textColor="#000000"
hoverColor="#5e5e5e"
rowHeight="10px"
verticalPadding="5px"
rightContent={(r) => (
<RuleStatusTag
rule={r}
allRules={allRules}
popoverOpen={selectedRule?.ruleId === r.ruleId && Boolean(statusPopoverAnchor)}
onClick={(e) => {
setSelectedRule(r);
setStatusPopoverAnchor(e.currentTarget);
}}
/>
)}
backgroundColor={tableBackgroundColor}
textColor={tableTextColor}
hoverColor={tableHoverColor}
rowHeight="40px"
verticalPadding="8px"
indentRow
/>
))}
</TableBody>
</Table>
</TableContainer>

{selectedRule && (
<UpdateStatusPopover
anchorEl={statusPopoverAnchor}
onClose={handleStatusClose}
rule={selectedRule}
onStatusChange={handleStatusChange}
/>
)}
</Box>
);
};
Expand Down
Loading
Loading