Skip to content
Open
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
37 changes: 36 additions & 1 deletion src/frontend/src/apis/rules.api.ts
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
// write api functions below here!
/*
* 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 axios from '../utils/axios';
import { Rule } from 'shared';
import { apiUrls } from '../utils/urls';

/**
* Gets all top-level rules (rules with no parent) for a ruleset
*/
export const getTopLevelRules = (rulesetId: string) => {
return axios.get<Rule[]>(apiUrls.rulesTopLevel(rulesetId));
};

/**
* Gets all child rules of a specific rule
*/
export const getChildRules = (ruleId: string) => {
return axios.get<Rule[]>(apiUrls.rulesChildRules(ruleId));
};

/**
* Toggles team assignment for a rule
*/
export const toggleRuleTeam = (ruleId: string, teamId: string) => {
return axios.post<Rule>(apiUrls.rulesToggleTeam(ruleId), { teamId });
};

/**
* Gets all rules assigned to a team for a specific ruleset type
*/
export const getTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) => {
return axios.get<Rule[]>(apiUrls.rulesTeamRulesInRulesetType(rulesetTypeId, teamId));
};
46 changes: 45 additions & 1 deletion src/frontend/src/hooks/rules.hooks.ts
Original file line number Diff line number Diff line change
@@ -1 +1,45 @@
// write hooks below here!
/*
* 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 { useQuery, useMutation, useQueryClient } from 'react-query';
import { Rule } from 'shared';
import { getTopLevelRules, getChildRules, toggleRuleTeam, getTeamRulesInRulesetType } from '../apis/rules.api';

export const useGetTopLevelRules = (rulesetId: string) => {
return useQuery<Rule[], Error>(['rules', 'top-level', rulesetId], async () => {
const { data } = await getTopLevelRules(rulesetId);
return data;
});
};

export const useGetChildRules = (ruleId: string) => {
return useQuery<Rule[], Error>(['rules', 'children', ruleId], async () => {
const { data } = await getChildRules(ruleId);
return data;
});
};

export const useToggleRuleTeam = () => {
const queryClient = useQueryClient();
return useMutation<Rule, Error, { ruleId: string; teamId: string }>(
['rules', 'toggle-team'],
async ({ ruleId, teamId }) => {
const { data } = await toggleRuleTeam(ruleId, teamId);
return data;
},
{
onSuccess: () => {
queryClient.invalidateQueries(['rules']);
}
}
);
};

export const useGetTeamRulesInRulesetType = (rulesetTypeId: string, teamId: string) => {
return useQuery<Rule[], Error>(['rules', 'team-rules', rulesetTypeId, teamId], async () => {
const { data } = await getTeamRulesInRulesetType(rulesetTypeId, teamId);
return data;
});
};
278 changes: 278 additions & 0 deletions src/frontend/src/pages/RulesPage/AssignRulesTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/*
* 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,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Typography,
useTheme
} from '@mui/material';
import { useState } from 'react';
import { Rule, TeamPreview } from 'shared';
import { useAllTeams } from '../../hooks/teams.hooks';
import LoadingIndicator from '../../components/LoadingIndicator';
import ErrorPage from '../ErrorPage';
import { useHistory } from 'react-router-dom';
import { routes } from '../../utils/routes';
import { useToast } from '../../hooks/toasts.hooks';
import { NERButton } from '../../components/NERButton';
import RuleRow from './RuleRow';

/*
* Props for the assign rules tab.
*/
interface AssignRulesTabProps {
rules: Rule[];
}

/*
* Props for the team row.
*/
interface TeamRowProps {
team: TeamPreview;
isSelected: boolean;
onClick: () => void;
}

/**
* Row component for displaying a team in the teams table.
*/
const TeamRow: React.FC<TeamRowProps> = ({ team, isSelected, onClick }) => {
return (
<TableRow
onClick={onClick}
sx={{
borderBottom: '1px solid #7d7d7d',
backgroundColor: isSelected ? '#b36b6b' : '#CECECE',
'&:hover': { backgroundColor: isSelected ? '#a05858' : '#5e5e5e' },
cursor: 'pointer',
'&:last-child': { borderBottom: 'none' }
}}
>
<TableCell
sx={{
fontSize: '16px',
padding: '8px 16px',
backgroundColor: 'inherit',
borderBottom: 'none',
color: '#000000'
}}
>
{team.teamName}
</TableCell>
</TableRow>
);
};

/**
* Tab component for assigning rules to teams.
* Displays teams and rules side-by-side for selection.
*/
const AssignRulesTab: React.FC<AssignRulesTabProps> = ({ rules }) => {
const theme = useTheme();
const history = useHistory();
const toast = useToast();
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [assignments, setAssignments] = useState<Set<string>>(new Set());
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [originalAssignments, setOriginalAssignments] = useState<Set<string>>(new Set());

const { data: teams, isLoading: teamsLoading, isError: teamsError, error: teamsErrorData } = useAllTeams();

// TODO: Fetch all team assignments on mount and populate originalAssignments and assignments
// Not implemented yet since we do not use an actual ruleset yet

const handleTeamSelect = (teamId: string) => setSelectedTeamId(teamId);

const isRuleAssigned = (ruleId: string) => {
if (!selectedTeamId) return false;
return assignments.has(`${selectedTeamId}:${ruleId}`);
};

const getAssignedTeamNames = (ruleId: string): string[] => {
if (!teams) return [];
const assignedTeamIds = [...assignments].filter((key) => key.endsWith(`:${ruleId}`)).map((key) => key.split(':')[0]);
return teams.filter((t) => assignedTeamIds.includes(t.teamId)).map((t) => t.teamName);
};

const renderTeamTags = (ruleId: string) => {
const teamNames = getAssignedTeamNames(ruleId);
if (teamNames.length === 0) return null;
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
{teamNames.map((name) => (
<Chip
key={name}
label={name}
size="small"
sx={{
height: '18px',
fontSize: '11px',
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary
}}
/>
))}
</Box>
);
};

const handleRuleToggle = (ruleId: string) => {
if (!selectedTeamId) {
toast.error('Please select a team first');
return;
}

const key = `${selectedTeamId}:${ruleId}`;
const newAssignments = new Set(assignments);
if (assignments.has(key)) {
newAssignments.delete(key);
} else {
newAssignments.add(key);
}
setAssignments(newAssignments);
};

const handleSaveAndExit = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like if there were no changes there should be like a toast message saying 'No rules selected to assign' instead of still jumping back to the other page - but up to you

const toAdd = [...assignments].filter((key) => !originalAssignments.has(key));
const toRemove = [...originalAssignments].filter((key) => !assignments.has(key));

// TODO: Save changes via backend
if (toAdd.length > 0 || toRemove.length > 0) {
toast.success(`Placeholder: Would save ${toAdd.length} additions and ${toRemove.length} removals`);
}

history.push(routes.RULES);
Copy link
Contributor

Choose a reason for hiding this comment

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

could we go back to ruleset page instead of the ruleset types view

};

if (teamsLoading) {
return <LoadingIndicator />;
}

if (teamsError) {
return <ErrorPage message={teamsErrorData?.message} error={teamsErrorData} />;
}

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

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' }}>
Teams:
</Typography>
<TableContainer
component={Paper}
sx={{
borderRadius: '8px',
overflow: 'hidden',
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto'
}}
>
<Table sx={{ borderCollapse: 'collapse' }}>
<TableBody sx={{ backgroundColor: '#CECECE' }}>
{teams?.map((team) => (
<TeamRow
key={team.teamId}
team={team}
isSelected={selectedTeamId === team.teamId}
onClick={() => handleTeamSelect(team.teamId)}
/>
))}
</TableBody>
</Table>
</TableContainer>
</Box>

{/* Rules Column */}
<Box sx={{ flex: 1 }}>
<Typography variant="h4" sx={{ mb: 2, color: '#ffffff' }}>
Rules:
</Typography>
<TableContainer
component={Paper}
sx={{
borderRadius: '8px',
overflow: 'hidden',
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto'
}}
>
<Table sx={{ borderCollapse: 'collapse' }}>
<TableBody sx={{ backgroundColor: '#CECECE' }}>
{topLevelRules.map((rule) => (
<RuleRow
key={rule.ruleId}
rule={rule}
allRules={rules}
backgroundColor={(r) => {
const isLeaf = r.subRuleIds.length === 0;
return isLeaf && isRuleAssigned(r.ruleId) ? '#b36b6b' : '#CECECE';
}}
hoverColor={(r) => {
const isLeaf = r.subRuleIds.length === 0;
return isLeaf && isRuleAssigned(r.ruleId) ? '#a05858' : '#5e5e5e';
}}
textColor="#000000"
onRowClick={(r) => handleRuleToggle(r.ruleId)}
Copy link
Contributor

Choose a reason for hiding this comment

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

its a bit odd that we can't click the right side of the ruleRow to select but not high priority to fix

middleContent={() => null}
rightContent={(r) => renderTeamTags(r.ruleId)}
verticalPadding="8px"
leftWidth="70%"
middleWidth="0%"
rightWidth="30%"
/>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>

{/* Save & Exit Button */}
<Box
sx={{
backgroundColor: '#121313',
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
width: '100%'
}}
>
<Box
sx={{
borderBottom: '2px solid white',
mb: 2,
ml: '30px'
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pr: '30px', pb: 1 }}>
<NERButton
variant="contained"
onClick={handleSaveAndExit}
sx={{
backgroundColor: '#dd514c',
'&:hover': { backgroundColor: '#c74340' }
}}
>
Save & Exit
</NERButton>
</Box>
</Box>
</Box>
);
};

export default AssignRulesTab;
Loading