diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 110014799c..5a37a07dfb 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1146,6 +1146,7 @@ export default class RulesService { parentRuleId: ruleId, dateDeleted: null }, + orderBy: { ruleCode: 'asc' }, ...getRulePreviewQueryArgs() }); return subRules.map((rule) => ruleTransformer(rule)); @@ -1332,6 +1333,7 @@ export default class RulesService { }, dateDeleted: null }, + orderBy: { rule: { ruleCode: 'asc' } }, ...getProjectRuleQueryArgs() }); @@ -1374,6 +1376,7 @@ export default class RulesService { dateDeleted: null, parentRuleId: null }, + orderBy: { ruleCode: 'asc' }, ...getRulePreviewQueryArgs() }); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index ef6759be0c..15a5b424e7 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -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; @@ -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) => { @@ -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); @@ -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 ( handleStatusClick(e, rule) : undefined} /> @@ -300,7 +303,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { { }} > - - + diff --git a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx index 24559b19c3..3ab681c2a5 100644 --- a/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -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. @@ -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 = ({ team, isSelected, onClick }) => { +const TeamRow: React.FC = ({ team, backgroundColor, hoverColor, onClick }) => { + const theme = useTheme(); return ( = ({ team, isSelected, onClick }) => { padding: '8px 16px', backgroundColor: 'inherit', borderBottom: 'none', - color: '#000000' + color: theme.palette.common.black }} > {team.teamName} @@ -130,6 +133,16 @@ const AssignRulesTab: React.FC = ({ 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]); @@ -281,14 +294,14 @@ const AssignRulesTab: React.FC = ({ rules }) => { return ; } - const topLevelRules = rules.filter((rule) => !rule.parentRule); + const topLevelRules = rules.filter((rule) => !rule.parentRule).sort(compareRuleCodes); return ( {/* Teams Column */} - + Teams: = ({ rules }) => { }} > - + {teams?.map((team) => ( handleTeamSelect(team.teamId)} /> ))} @@ -317,7 +331,7 @@ const AssignRulesTab: React.FC = ({ rules }) => { {/* Rules Column */} - + Rules: = ({ rules }) => { }} >
- + {topLevelRules.map((rule) => ( (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)} @@ -367,21 +381,13 @@ const AssignRulesTab: React.FC = ({ rules }) => { > - - + + {isSaving ? 'Saving...' : 'Save & Exit'} diff --git a/src/frontend/src/pages/RulesPage/RuleRow.tsx b/src/frontend/src/pages/RulesPage/RuleRow.tsx index 58799b7b75..1fd93fe2f0 100644 --- a/src/frontend/src/pages/RulesPage/RuleRow.tsx +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import { Rule } from 'shared'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useGetChildRules } from '../../hooks/rules.hooks'; +import { compareRuleCodes } from '../../utils/rules.utils'; interface RuleRowProps { rule: Rule; @@ -31,6 +32,8 @@ interface RuleRowProps { indentRow?: boolean; // Amount of indentation per child depth when indentRow is enabled indentWidth?: number; + // If a rule's code/name should span the entire row - used for team view header rows + fullWidthCode?: (rule: Rule) => boolean; } /** @@ -56,7 +59,8 @@ const RuleRow: React.FC = ({ rightWidth = '10%', initiallyExpanded = false, indentRow = false, - indentWidth = 10 + indentWidth = 10, + fullWidthCode }) => { const [isExpanded, setIsExpanded] = useState(initiallyExpanded); @@ -68,8 +72,9 @@ const RuleRow: React.FC = ({ // Lazy load if allRules not provided const { data: fetchedSubRules = [] } = useGetChildRules(rule.ruleId, !allRules && isExpanded && hasSubRules); - // Use allRules if provided, otherwise use fetched - const subRules = presentSubRules ?? fetchedSubRules; + // Use allRules if provided, otherwise use fetched. + // Sorted by rule code so children render in a stable numeric order (e.g. F.2 before F.10) + const subRules = [...(presentSubRules ?? fetchedSubRules)].sort(compareRuleCodes); const bgColor = typeof backgroundColor === 'function' ? backgroundColor(rule) : backgroundColor; const color = typeof textColor === 'function' ? textColor(rule) : textColor; @@ -85,9 +90,13 @@ const RuleRow: React.FC = ({ const handleRowClick = () => { if (onRowClick) { onRowClick(rule); + } else if (hasSubRules) { + toggleExpand(); } }; + const rowIsClickable = Boolean(onRowClick) || hasSubRules; + const commonCellStyles = { fontSize: '16px', padding: `${verticalPadding} ${horizontalPadding}`, @@ -144,9 +153,11 @@ const RuleRow: React.FC = ({ return ( <> = ({ height: rowHeight }} > - - {leftContent ? leftContent(rule, level, isExpanded, hasSubRules) : defaultLeftContent} - - - {middleContent - ? middleContent(rule, level) - : rule.ruleContent && {rule.ruleContent}} - - - {rightContent(rule, level)} - + {fullWidthCode && fullWidthCode(rule) ? ( + + {leftContent ? leftContent(rule, level, isExpanded, hasSubRules) : defaultLeftContent} + + ) : ( + <> + + {leftContent ? leftContent(rule, level, isExpanded, hasSubRules) : defaultLeftContent} + + e.stopPropagation()} + sx={{ + ...commonCellStyles, + ...cardCellBg, + width: middleWidth, + maxWidth: '700px', + wordWrap: 'break-word', + overflowWrap: 'break-word', + whiteSpace: 'normal' + }} + > + {middleContent + ? middleContent(rule, level) + : rule.ruleContent && {rule.ruleContent}} + + e.stopPropagation()} + sx={{ + ...commonCellStyles, + ...cardCellBg, + ...rightCellRadius, + cursor: 'default', + width: rightWidth + }} + > + {rightContent(rule, level)} + + + )} {isExpanded && hasSubRules && @@ -228,6 +266,7 @@ const RuleRow: React.FC = ({ rightWidth={rightWidth} indentRow={indentRow} indentWidth={indentWidth} + fullWidthCode={fullWidthCode} /> ))} diff --git a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx index bd7992f551..d5a46f24bc 100644 --- a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx @@ -17,9 +17,10 @@ import AddRuleSectionModal from './components/AddRuleSectionModal'; import AddRuleModal from './components/AddRuleModal'; import { AddRuleBox } from './components/AddRuleBox'; import AssignRulesTab from './AssignRulesTab'; +import { NERButton } from '../../components/NERButton'; import DeleteRuleModal from './components/DeleteRuleModal'; import { useDeleteRule, useEditRule, useSingleRuleset, useAllRulesForRuleset } from '../../hooks/rules.hooks'; -import { countRulesToDelete } from '../../utils/rules.utils'; +import { countRulesToDelete, compareRuleCodes } from '../../utils/rules.utils'; import { Rule } from 'shared'; /** @@ -158,8 +159,8 @@ const RulesetEditPage: React.FC = () => { const totalRulesToDelete = ruleToDelete ? countRulesToDelete(ruleToDelete, allRules) : 0; - // Filter to only show top-level rules - const topLevelRules = allRules.filter((rule) => !rule.parentRule); + // Filter to only show top-level rules, sorted by rule code for stable numeric order + const topLevelRules = allRules.filter((rule) => !rule.parentRule).sort(compareRuleCodes); return ( {
- + {topLevelRules.map((rule) => ( { sx={{ backgroundColor: theme.palette.grey[100], '& .MuiOutlinedInput-root': { - color: '#000000', + color: theme.palette.common.black, '& fieldset': { borderColor: '#dd514c' }, @@ -225,7 +226,9 @@ const RulesetEditPage: React.FC = () => { ); } return ( - currentRule.ruleContent && {currentRule.ruleContent} + currentRule.ruleContent && ( + {currentRule.ruleContent} + ) ); }} rightContent={(currentRule) => ( @@ -234,12 +237,14 @@ const RulesetEditPage: React.FC = () => { onAdd={handleOpenAddMenu} onRemove={handleRemoveRule} onEdit={handleEditRule} - iconColor="#000000" + iconColor={theme.palette.common.black} /> )} - backgroundColor={(currentRule) => (editingRuleId === currentRule.ruleId ? '#c0c0c0' : '#9d9d9d')} - textColor="#000000" - hoverColor="#5e5e5e" + backgroundColor={(currentRule) => + editingRuleId === currentRule.ruleId ? theme.palette.grey[400] : theme.palette.grey[500] + } + textColor={theme.palette.common.black} + hoverColor={theme.palette.grey[700]} rowHeight="10px" verticalPadding="5px" /> @@ -290,12 +295,12 @@ const RulesetEditPage: React.FC = () => { > - + {editingRuleId ? ( <> - + ) : ( - + )} diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx index b63fceb5ff..64775f3c7a 100644 --- a/src/frontend/src/pages/RulesPage/RulesetPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -82,34 +82,43 @@ const RulesetPage: React.FC = () => { title={rulesetType?.name ? `${rulesetType.name} Rulesets` : 'Rulesets'} previousPages={[{ name: 'Rules', route: routes.RULES }]} > - + ({ + borderBottom: `2px solid ${theme.palette.divider}`, + mb: 2, + ml: '20px' + })} /> {/* Add New File Button */} - setAddFileModalShow(!AddFileModalShow)}> + setAddFileModalShow(!AddFileModalShow)} + > Add New File { return ( <> - + ({ + borderBottom: `2px solid ${theme.palette.divider}`, + mb: 2, + ml: '20px' + })} /> - setAddRulesetTypeModalShow(!addRulesetTypeModalShow)}> + setAddRulesetTypeModalShow(!addRulesetTypeModalShow)} + > Add Ruleset Type = ({ allRules, rules // 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); + // Sort once by rule code so both top-level rows and their children render in a stable numeric order. + const sortedRules = useMemo(() => [...allRules].sort(compareRuleCodes), [allRules]); + const topLevelRules = useMemo(() => sortedRules.filter((rule) => !rule.parentRule), [sortedRules]); const handleStatusClose = () => { setStatusPopoverAnchor(null); @@ -56,11 +59,11 @@ const RulesetGeneralView: React.FC = ({ allRules, rules ( { setSelectedRule(r); diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx index 34d79660dd..adb1f7cd38 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -184,6 +184,9 @@ const RulesetTeamView: React.FC = ({ allRules }) => { return { topLevelItems, rowsById: [...sectionRows, ...ruleRows] }; }, [allRules]); + // Everything that isn't an actual rule (e.g., unassigned headers) should span the full row width + const actualRuleIds = new Set(allRules.map((r) => r.ruleId)); + return ( @@ -202,6 +205,7 @@ const RulesetTeamView: React.FC = ({ allRules }) => { verticalPadding="8px" indentRow initiallyExpanded={item.ruleId.startsWith('team-')} + fullWidthCode={(rule) => !actualRuleIds.has(rule.ruleId)} /> ))} diff --git a/src/frontend/src/utils/rules.utils.ts b/src/frontend/src/utils/rules.utils.ts index 81ac7d92a9..5bd446632a 100644 --- a/src/frontend/src/utils/rules.utils.ts +++ b/src/frontend/src/utils/rules.utils.ts @@ -83,3 +83,10 @@ export const getAncestorIds = (ruleId: string, allRules: Rule[]): string[] => { return ancestorIds; }; + +/** + * Comparator that orders rules by their rule code numerically, so codes sort + * as F.2 before F.10 rather than alphabetically + */ +export const compareRuleCodes = (a: Rule, b: Rule): number => + a.ruleCode.localeCompare(b.ruleCode, undefined, { numeric: true });