diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index d730765..5c4276e 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ const MenuItemNames = makeEnumLike([ 'NCMEC Reports', 'Policies', 'Matching Banks', + 'User Strikes', 'Log Out', 'Account', 'Settings', diff --git a/client/src/webpages/dashboard/Dashboard.tsx b/client/src/webpages/dashboard/Dashboard.tsx index be0d092..75745da 100644 --- a/client/src/webpages/dashboard/Dashboard.tsx +++ b/client/src/webpages/dashboard/Dashboard.tsx @@ -357,9 +357,9 @@ export function DashboardRoutes() { handle: { isUsingLegacyCSS: true }, lazy: lazyRoute(async () => import('./policies/PolicyForm')), }, - // TODO: uncomment this when final UI is finished { - path: 'user_strikes', + path: 'rules/user_strikes', + handle: { isUsingLegacyCSS: true }, lazy: lazyRoute( async () => import('./userStrikes/UserStrikeDashboard'), ), @@ -558,12 +558,11 @@ export default function Dashboard() { urlPath: 'banks', requiredPermissions: [GQLUserPermission.MutateNonLiveRules], }, - //TODO: uncomment this when final UI is finished - // { - // title: 'User Strikes', - // urlPath: 'userStrikes', - // requiredPermissions: [GQLUserPermission.ManageOrg], - // }, + { + title: 'User Strikes' as const, + urlPath: 'user_strikes', + requiredPermissions: [GQLUserPermission.ManageOrg], + }, ]), }, { diff --git a/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx b/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx index b9a2854..2bf5444 100644 --- a/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx @@ -9,6 +9,7 @@ import { Tree, treeFromList, TreeNode } from '@/utils/tree'; import omit from 'lodash/omit'; import { Check, ChevronDown, ChevronUp, Pencil, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import CoopModal from '../components/CoopModal'; import Table from '../components/table/Table'; @@ -24,6 +25,7 @@ export type Policy = { }; export default function PolicyScoresTab() { + const navigate = useNavigate(); const { loading, error, @@ -383,6 +385,17 @@ export default function PolicyScoresTab() { return ; } + if (!policyList || policyList.length === 0) { + return ( +
+
No policies configured
+ +
+ ); + } + return (
@@ -458,12 +471,12 @@ function ChildPoliciesTable(props: { // will need to account for the whole parent tree editingDisabled ? policy?.parent?.value.id - ? updatedPolicyScores[policy?.parent?.value.id] + ? (updatedPolicyScores[policy?.parent?.value.id] ?.userStrikeCount ?? - policy?.parent?.value?.userStrikeCount + policy?.parent?.value?.userStrikeCount) : undefined - : updatedPolicyScores[policy.value.id]?.userStrikeCount ?? - policy.value.userStrikeCount + : (updatedPolicyScores[policy.value.id]?.userStrikeCount ?? + policy.value.userStrikeCount) } placeholder="1" onChange={(value) => { diff --git a/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx b/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx index ee1ff84..ab36d02 100644 --- a/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx @@ -68,20 +68,24 @@ function UserStrikeDistributionChart() { if (loading || thresholdsLoading) { return ; } + const chartHeight = 400; + return (
Distribution of User Strikes
- - + {/* Pin the tooltip to a fixed Y in the middle of the plot area so + it doesn't follow the cursor up and down inside a single bar. + The +20 nudges it below the title without overlapping the x-axis + label. The cursor fill replaces the invisible default so the + user can see which column is selected. */} + + {/* Top alignment keeps the legend out of the x-axis label's space. + Custom payload so the dashed red threshold lines are explained; + ReferenceLine doesn't participate in the legend on its own. */} + 0 + ? ([ + { + value: 'Strike Threshold', + type: 'plainline', + color: 'red', + payload: { strokeDasharray: '3 3' }, + }, + ] as const) + : []), + ]} + /> {thresholds?.map((threshold) => ( { - return recentUserStrikeActions - ?.slice() - ?.sort((a, b) => { - return a.time > b.time ? -1 : 1; - }) - .map((values) => { - return { - user: ( - - {values.itemId} - - ), - action: actionsById - ? actionsById[values.actionId] || 'Unknown' - : 'Unknown', - date: format(new Date(values.time), 'MM/dd/yy hh:mm'), - }; - }); - }, - - [recentUserStrikeActions, actionsById], - ); + const tableData = useMemo(() => { + return recentUserStrikeActions + ?.slice() + ?.sort((a, b) => (a.time > b.time ? -1 : a.time < b.time ? 1 : 0)) + .map((values) => { + return { + user: ( + + {values.itemId} + + ), + action: actionsById + ? (actionsById[values.actionId] ?? 'Unknown') + : 'Unknown', + date: format(new Date(values.time), 'MM/dd/yy hh:mm'), + }; + }); + }, [recentUserStrikeActions, actionsById]); if (error || actionsError) { throw new Error(error?.message ?? actionsError?.message); @@ -215,9 +243,11 @@ function RecentUserStrikeActionsTable() {
Recent Actions Taken By Your Strike System
-
- - +
); } diff --git a/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx b/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx index 368556a..459e236 100644 --- a/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx +++ b/client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx @@ -66,7 +66,11 @@ export default function ThresholdsTab() { }, }); - const [setUserStrikeTTL] = useGQLUpdateUserStrikeTtlMutation(); + const [setUserStrikeTTL] = useGQLUpdateUserStrikeTtlMutation({ + onCompleted: async () => { + await refetchThresholds(); + }, + }); const thresholds = data?.myOrg?.userStrikeThresholds; @@ -111,6 +115,9 @@ export default function ThresholdsTab() { }} /> { await setUserStrikeTTL({ @@ -127,7 +134,9 @@ export default function ThresholdsTab() { } function StrikeTTLForm(props: { orgTTL: number; - setTTL: (ttl: number) => void; + // Async — the caller awaits inside the Save handler so the form can wait for + // the mutation to round-trip before toggling out of edit mode. + setTTL: (ttl: number) => Promise; }) { const { orgTTL, setTTL } = props; @@ -180,7 +189,7 @@ function StrikeTTLForm(props: { className="!fill-none" startIcon={Check} onClick={async () => { - setTTL(ttlFormState); + await setTTL(ttlFormState); toggleEditing(); }} > @@ -196,17 +205,21 @@ function StrikeTTLForm(props: {
User strikes stay on record for
-
+
{ if (value.target.value === '') { setTTLFormState(0); @@ -219,6 +232,7 @@ function StrikeTTLForm(props: { } }} /> + days
diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 825056e..df5d241 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -18,7 +18,11 @@ const eslintInPackage = (pkg) => (files) => { .filter((f) => f.startsWith(pkgRoot + path.sep)) .map((f) => path.relative(pkgRoot, f)); if (rels.length === 0) return []; - const args = rels.map((f) => JSON.stringify(f)).join(' '); + // Single-quote each path so it survives the outer double-quoted `bash -c`. + // The previous JSON.stringify produced double-quoted args, which collapsed + // against the outer `"..."` and left eslint with no file args — silently + // making it lint the whole package directory instead. + const args = rels.map((f) => `'${f.replace(/'/g, `'\\''`)}'`).join(' '); return `bash -c "cd ${pkg} && ./node_modules/.bin/eslint --fix ${args}"`; };