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
3 changes: 3 additions & 0 deletions src/backend/src/services/rules.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,7 @@ export default class RulesService {
parentRuleId: ruleId,
dateDeleted: null
},
orderBy: { ruleCode: 'asc' },
...getRulePreviewQueryArgs()
});
return subRules.map((rule) => ruleTransformer(rule));
Expand Down Expand Up @@ -1332,6 +1333,7 @@ export default class RulesService {
},
dateDeleted: null
},
orderBy: { rule: { ruleCode: 'asc' } },
...getProjectRuleQueryArgs()
});

Expand Down Expand Up @@ -1374,6 +1376,7 @@ export default class RulesService {
dateDeleted: null,
parentRuleId: null
},
orderBy: { ruleCode: 'asc' },
...getRulePreviewQueryArgs()
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import { InfoOutlined } from '@mui/icons-material';
import { useHistory } from 'react-router-dom';
import { routes } from '../../../../utils/routes';
import RuleStatusTag from '../../../RulesPage/components/RuleStatusTag';
import { NERButton } from '../../../../components/NERButton';
import { compareRuleCodes } from '../../../../utils/rules.utils';

interface ProjectRulesTabProps {
project: Project;
Expand Down Expand Up @@ -84,16 +86,17 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
const teamId = project.teams[0]?.teamId || '';
const teamNames = project.teams.map((team) => team.teamName);

// Convert project rules to rules
const allRules = useMemo(() => {
// Convert project rules to rules for display
// Sorted by rule code so both top-level rows and their children render in stable numeric order
const projectRuleList = useMemo(() => {
if (!projectRules) return [];
return projectRules.map((pr) => pr.rule);
return projectRules.map((pr) => pr.rule).sort(compareRuleCodes);
}, [projectRules]);

// Get top-level rules (rules without a parent)
const topLevelRules = useMemo(() => {
return allRules.filter((rule) => !rule.parentRule);
}, [allRules]);
return projectRuleList.filter((rule) => !rule.parentRule);
}, [projectRuleList]);

// Handle completion update
const handleStatusUpdate = async (ruleId: string, isComplete: boolean) => {
Expand Down Expand Up @@ -126,7 +129,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
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);
const hasChildren = projectRuleList.some((r) => r.parentRule?.ruleId === rule.ruleId);
if (!hasChildren) {
setSelectedProjectRule(projectRule);
setStatusPopoverAnchor(event.currentTarget);
Expand Down Expand Up @@ -170,13 +173,13 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
// 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 isLeafRule = !allRules.some((r) => r.parentRule?.ruleId === rule.ruleId);
const isLeafRule = !projectRuleList.some((r) => r.parentRule?.ruleId === rule.ruleId);
const isPopoverOpenForRule = Boolean(statusPopoverAnchor) && selectedProjectRule?.rule.ruleId === rule.ruleId;

return (
<RuleStatusTag
rule={rule}
allRules={allRules}
allRules={projectRuleList}
popoverOpen={isPopoverOpenForRule}
onClick={isLeafRule ? (e) => handleStatusClick(e, rule) : undefined}
/>
Expand Down Expand Up @@ -300,7 +303,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
<RuleRow
key={rule.ruleId}
rule={rule}
allRules={allRules}
allRules={projectRuleList}
rightContent={renderRightContent}
backgroundColor={tableBackgroundColor}
textColor={tableTextColor}
Expand Down Expand Up @@ -329,24 +332,15 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => {
}}
>
<Box sx={{ borderBottom: `2px solid ${theme.palette.divider}`, mb: 2, ml: '30px' }} />
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pr: '30px', pb: 1 }}>
<Button
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pr: '30px', pb: 2 }}>
<NERButton
variant="contained"
sx={{ color: '#ededed' }}
onClick={() => setAddRuleModalOpen(true)}
disabled={teamNames.length === 0 || hasNoActiveRuleset}
sx={{
borderRadius: '8px',
backgroundColor: '#ef4345',
padding: '2px 15px',
fontSize: '16px',
fontWeight: 700,
textTransform: 'none',
'&:hover': { backgroundColor: '#b0191a' },
'&.Mui-disabled': { backgroundColor: theme.palette.action.disabled, color: theme.palette.text.disabled }
}}
>
Add Rule
</Button>
</NERButton>
</Box>
</Box>

Expand Down
60 changes: 33 additions & 27 deletions src/frontend/src/pages/RulesPage/AssignRulesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import NERModal from '../../components/NERModal';
import WarningIcon from '@mui/icons-material/Warning';
import RuleRow from './RuleRow';
import { useBulkToggleRuleTeam } from '../../hooks/rules.hooks';
import { getAncestorIds, getRuleAndDescendantIds } from '../../utils/rules.utils';
import { compareRuleCodes } from '../../utils/rules.utils';
import { getAncestorIds, getRuleAndDescendantIds, getDescendantLeafRules } from '../../utils/rules.utils';

/*
* Props for the assign rules tab.
Expand All @@ -42,21 +43,23 @@ interface AssignRulesTabProps {
*/
interface TeamRowProps {
team: TeamPreview;
isSelected: boolean;
backgroundColor: string;
hoverColor: string;
onClick: () => void;
}

/**
* Row component for displaying a team in the teams table.
*/
const TeamRow: React.FC<TeamRowProps> = ({ team, isSelected, onClick }) => {
const TeamRow: React.FC<TeamRowProps> = ({ team, backgroundColor, hoverColor, onClick }) => {
const theme = useTheme();
return (
<TableRow
onClick={onClick}
sx={{
borderBottom: '1px solid #7d7d7d',
backgroundColor: isSelected ? '#b36b6b' : '#CECECE',
'&:hover': { backgroundColor: isSelected ? '#a05858' : '#5e5e5e' },
backgroundColor,
'&:hover': { backgroundColor: hoverColor },
cursor: 'pointer',
'&:last-child': { borderBottom: 'none' }
}}
Expand All @@ -67,7 +70,7 @@ const TeamRow: React.FC<TeamRowProps> = ({ team, isSelected, onClick }) => {
padding: '8px 16px',
backgroundColor: 'inherit',
borderBottom: 'none',
color: '#000000'
color: theme.palette.common.black
}}
>
{team.teamName}
Expand Down Expand Up @@ -130,6 +133,16 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
return assignments.has(`${selectedTeamId}:${ruleId}`);
};

// A rule is considered selected when all of its leaf rules are assigned to the current team.
const isRuleSelected = (rule: Rule) => {
const leafIds = getDescendantLeafRules(rule, rules).map((leaf) => leaf.ruleId);
return leafIds.length > 0 && leafIds.every((id) => isRuleAssigned(id));
};

// Shared highlight logic for both the team rows and rule rows.
const rowBackgroundColor = (isSelected: boolean) => (isSelected ? '#b36b6b' : theme.palette.grey[500]);
const rowHoverColor = (isSelected: boolean) => (isSelected ? '#a05858' : theme.palette.grey[700]);

const getAssignedTeamNames = (ruleId: string): string[] => {
if (!teams) return [];
const assignedTeamIds = [...assignments].filter((key) => key.endsWith(`:${ruleId}`)).map((key) => key.split(':')[0]);
Expand Down Expand Up @@ -281,14 +294,14 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
return <LoadingIndicator />;
}

const topLevelRules = rules.filter((rule) => !rule.parentRule);
const topLevelRules = rules.filter((rule) => !rule.parentRule).sort(compareRuleCodes);

return (
<Box sx={{ paddingBottom: '100px' }}>
<Box sx={{ display: 'flex', gap: 4 }}>
{/* Teams Column */}
<Box sx={{ flex: 1 }}>
<Typography variant="h4" sx={{ mb: 2, color: '#ffffff' }}>
<Typography variant="h4" sx={{ mb: 2, color: theme.palette.text.primary }}>
Teams:
</Typography>
<TableContainer
Expand All @@ -301,12 +314,13 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
}}
>
<Table sx={{ borderCollapse: 'collapse' }}>
<TableBody sx={{ backgroundColor: '#CECECE' }}>
<TableBody sx={{ backgroundColor: theme.palette.grey[500] }}>
{teams?.map((team) => (
<TeamRow
key={team.teamId}
team={team}
isSelected={selectedTeamId === team.teamId}
backgroundColor={rowBackgroundColor(selectedTeamId === team.teamId)}
hoverColor={rowHoverColor(selectedTeamId === team.teamId)}
onClick={() => handleTeamSelect(team.teamId)}
/>
))}
Expand All @@ -317,7 +331,7 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {

{/* Rules Column */}
<Box sx={{ flex: 1 }}>
<Typography variant="h4" sx={{ mb: 2, color: '#ffffff' }}>
<Typography variant="h4" sx={{ mb: 2, color: theme.palette.text.primary }}>
Rules:
</Typography>
<TableContainer
Expand All @@ -330,15 +344,15 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
}}
>
<Table sx={{ borderCollapse: 'collapse' }}>
<TableBody sx={{ backgroundColor: '#CECECE' }}>
<TableBody sx={{ backgroundColor: theme.palette.grey[500] }}>
{topLevelRules.map((rule) => (
<RuleRow
key={rule.ruleId}
rule={rule}
allRules={rules}
backgroundColor={(r) => (isRuleAssigned(r.ruleId) ? '#b36b6b' : '#CECECE')}
hoverColor={(r) => (isRuleAssigned(r.ruleId) ? '#a05858' : '#5e5e5e')}
textColor="#000000"
backgroundColor={(r) => rowBackgroundColor(isRuleSelected(r))}
hoverColor={(r) => rowHoverColor(isRuleSelected(r))}
textColor={theme.palette.common.black}
onRowClick={(r) => handleRuleToggle(r.ruleId)}
middleContent={() => null}
rightContent={(r) => renderTeamTags(r.ruleId)}
Expand Down Expand Up @@ -367,21 +381,13 @@ const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
>
<Box
sx={{
borderBottom: '2px solid white',
borderBottom: `2px solid ${theme.palette.divider}`,
mb: 2,
ml: '30px'
ml: '20px'
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pr: '30px', pb: 1 }}>
<NERButton
variant="contained"
onClick={handleSaveAndExit}
disabled={isSaving}
sx={{
backgroundColor: '#dd514c',
'&:hover': { backgroundColor: '#c74340' }
}}
>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pr: '30px', pb: 2 }}>
<NERButton variant="contained" sx={{ color: '#ededed' }} onClick={handleSaveAndExit} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save & Exit'}
</NERButton>
</Box>
Expand Down
Loading
Loading