Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

Enable User Strikes dashboard + fix UX issues blocking v1 (#600)

* Expose the existing User Strikes dashboard in the sidebar

The User Strikes dashboard was already fully built — four tabs covering
per-policy strike counts, per-action strike toggles, org-level
thresholds + TTL, and analytics, with real GraphQL mutations across
~1400 lines — but was unreachable from navigation. The route at
`/user_strikes` existed but was commented "uncomment this when final UI
is finished," and the sidebar entry was also commented out.

This is what issue #156 ("Finish / enable user strike and score system")
is actually asking for: the strikes configuration UX exists, it just
isn't wired into the navigation. Two changes:

- Uncomment the User Strikes nav entry in Dashboard.tsx, with
`urlPath: 'user_strikes'` (the commented-out version had `userStrikes`
which didn't match the route).
- Add 'User Strikes' to the closed MenuItemName union in Sidebar.tsx
(otherwise the title literal fails to typecheck).

Note: the deprecated User Quality Score signal (`USER_SCORE`) remains
disabled in the rule builder with its existing tooltip. Removing it
entirely (plus the dead `actions.penalty` / `policies.penalty` schema)
will be a follow-up issue — too much scope for a 1.5-week v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix User Strikes route + Strike Window save flow

Two bugs that surfaced once the User Strikes nav link was uncommented:

1. Route path: registered at 'user_strikes' but the sidebar generates
URLs by prepending the parent urlPath ('rules' for Automated
Enforcement → /dashboard/rules/user_strikes), so the page 404'd.
Renamed the route to 'rules/user_strikes' to match the sibling
sub-items (rules/proactive, rules/banks, rules/report).

2. Strike Window save flow had three compounding bugs that made it
look like saves were silently failing:
- Display: `value={ttlFormState + ' days'}` on a `type="number"`
input is invalid — browser rejected the string and rendered the
placeholder "90" instead of the saved value. Moved the " days"
suffix to a sibling <span>; the input now just holds the number.
- Refetch: `useGQLUpdateUserStrikeTtlMutation` had no `onCompleted`
handler (unlike the sibling thresholds mutation), so the cached
`userStrikeTTL` stayed stale after a successful write.
- State sync: `useState(orgTTL)` only takes the initial value; even
with refetch, the form's local state wouldn't pick up the new
value. Added a `key={\`strikeTTLForm-${orgTTL}\`}` on the parent
so the form remounts when the refetched value changes (mirrors
the pattern used on the threshold form).
- Bonus: `setTTL()` is now awaited so the editing-mode flip happens
after the mutation completes, not in parallel.

Width was also bumped from 3.5em to 6em (cutting off 3-digit values
plus the number-input spinner controls).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix Analytics tab UX issues on User Strikes dashboard

Three problems surfaced once the page was reachable with real data:

1. Chart legend and x-axis label collided at the bottom of the
BarChart — both default to bottom positioning. Moved the legend to
`verticalAlign="top"` so it sits above the chart instead.

2. The "Strikes Applied" x-axis label was getting clipped below the
tick numbers. Bumped the chart's bottom margin from 15 → 40 to make
room for both.

3. The dashed red `ReferenceLine`s marking org strike thresholds had
no explanation — users couldn't tell what they meant. Added them
to the legend via a custom `payload` (ReferenceLine doesn't
participate in the legend on its own).

4. Tooltip floated around with the cursor inside a single bar, which
felt buggy. Pinned its Y position to mid-chart (220) so it only
tracks horizontally, and replaced the invisible default cursor
highlight with a subtle column fill so the user can see which bar
is selected.

5. The "Recent Actions Taken By Your Strike System" table was using
the Table component's default `w-fit` (shrink-to-content) container
class, so a sparse table rendered very narrow. Passed
`containerClassName="w-full"` so it fills its page-width parent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix lint-staged shell quoting so eslint runs on staged files only

`JSON.stringify(filename)` produced double-quoted args that collapsed
against the outer `bash -c "..."` wrapper, so the shell parsed the
command as `cd <pkg> && .../eslint --fix` with no file args. eslint
then linted the entire package directory and surfaced every pre-existing
warning/error in the repo, blocking any commit whose staged files were
clean but whose tree wasn't.

Switch to single-quoted args (with the standard `'\''` escape for any
embedded single quote) so the file paths survive the outer
double-quoted `bash -c` intact.

Confirmed locally: with this change, `npx lint-staged` on the User
Strikes review fixes runs eslint against exactly the two staged files
instead of all 126 files in `client/src/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review feedback on User Strikes dashboard

Four fixes from PR #600 review threads:

- StrikeAnalyticsTab: lift the BarChart height to a `chartHeight` const
and derive the tooltip y from `chartHeight / 2 + 20`. The +20 still
nudges the tooltip below the title; changing the chart height no
longer silently desyncs the tooltip from the plot area.

- StrikeAnalyticsTab: collapse the table sort comparator to
`a.time > b.time ? -1 : a.time < b.time ? 1 : 0`. The old form never
returned `0`, so equal timestamps were treated as strictly ordered
and could reorder nondeterministically across renders.

- StrikeAnalyticsTab: switch `actionsById[values.actionId] || 'Unknown'`
to `??`. An action name that's an empty string (falsy) shouldn't get
replaced with `'Unknown'` — only `null`/`undefined` should fall
through. This also matches the repo's lint preference of `??` over
`||` for fallbacks.

- ThresholdsAndSettingsTab: type `StrikeTTLForm.setTTL` as
`(ttl: number) => Promise<void>`. The Save handler does
`await setTTL(ttlFormState); toggleEditing();`, so awaiting a
void-typed prop both fails `await-thenable` lint and lets the toggle
fire before the mutation round-trips on a slow link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: add empty state for Policy Scores tab

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Caleb McQuaid <calebmcquaid@gmail.com>

+116 -55
+1
client/src/components/Sidebar.tsx
··· 34 34 'NCMEC Reports', 35 35 'Policies', 36 36 'Matching Banks', 37 + 'User Strikes', 37 38 'Log Out', 38 39 'Account', 39 40 'Settings',
+7 -8
client/src/webpages/dashboard/Dashboard.tsx
··· 357 357 handle: { isUsingLegacyCSS: true }, 358 358 lazy: lazyRoute(async () => import('./policies/PolicyForm')), 359 359 }, 360 - // TODO: uncomment this when final UI is finished 361 360 { 362 - path: 'user_strikes', 361 + path: 'rules/user_strikes', 362 + handle: { isUsingLegacyCSS: true }, 363 363 lazy: lazyRoute( 364 364 async () => import('./userStrikes/UserStrikeDashboard'), 365 365 ), ··· 558 558 urlPath: 'banks', 559 559 requiredPermissions: [GQLUserPermission.MutateNonLiveRules], 560 560 }, 561 - //TODO: uncomment this when final UI is finished 562 - // { 563 - // title: 'User Strikes', 564 - // urlPath: 'userStrikes', 565 - // requiredPermissions: [GQLUserPermission.ManageOrg], 566 - // }, 561 + { 562 + title: 'User Strikes' as const, 563 + urlPath: 'user_strikes', 564 + requiredPermissions: [GQLUserPermission.ManageOrg], 565 + }, 567 566 ]), 568 567 }, 569 568 {
+17 -4
client/src/webpages/dashboard/userStrikes/PolicyScoresTab.tsx
··· 9 9 import omit from 'lodash/omit'; 10 10 import { Check, ChevronDown, ChevronUp, Pencil, Trash2 } from 'lucide-react'; 11 11 import { useCallback, useEffect, useMemo, useState } from 'react'; 12 + import { useNavigate } from 'react-router-dom'; 12 13 13 14 import CoopModal from '../components/CoopModal'; 14 15 import Table from '../components/table/Table'; ··· 24 25 }; 25 26 26 27 export default function PolicyScoresTab() { 28 + const navigate = useNavigate(); 27 29 const { 28 30 loading, 29 31 error, ··· 383 385 return <FullScreenLoading />; 384 386 } 385 387 388 + if (!policyList || policyList.length === 0) { 389 + return ( 390 + <div className="flex flex-col items-center justify-center w-full gap-4 py-16"> 391 + <div className="text-slate-400">No policies configured</div> 392 + <Button onClick={() => navigate('/dashboard/policies/form')}> 393 + Create Policy 394 + </Button> 395 + </div> 396 + ); 397 + } 398 + 386 399 return ( 387 400 <div className="flex flex-col items-start"> 388 401 <div className="flex flex-row items-stretch w-full mt-6"> ··· 458 471 // will need to account for the whole parent tree 459 472 editingDisabled 460 473 ? policy?.parent?.value.id 461 - ? updatedPolicyScores[policy?.parent?.value.id] 474 + ? (updatedPolicyScores[policy?.parent?.value.id] 462 475 ?.userStrikeCount ?? 463 - policy?.parent?.value?.userStrikeCount 476 + policy?.parent?.value?.userStrikeCount) 464 477 : undefined 465 - : updatedPolicyScores[policy.value.id]?.userStrikeCount ?? 466 - policy.value.userStrikeCount 478 + : (updatedPolicyScores[policy.value.id]?.userStrikeCount ?? 479 + policy.value.userStrikeCount) 467 480 } 468 481 placeholder="1" 469 482 onChange={(value) => {
+65 -35
client/src/webpages/dashboard/userStrikes/StrikeAnalyticsTab.tsx
··· 68 68 if (loading || thresholdsLoading) { 69 69 return <FullScreenLoading />; 70 70 } 71 + const chartHeight = 400; 72 + 71 73 return ( 72 74 <div className="w-full"> 73 75 <div className="font-bold">Distribution of User Strikes</div> 74 76 <BarChart 75 77 title="Distribution of User Strikes" 76 78 width={900} 77 - height={400} 79 + height={chartHeight} 78 80 data={counts} 79 81 layout="horizontal" 80 82 margin={{ 81 83 top: 25, 82 84 right: 30, 83 85 left: 20, 84 - bottom: 15, 86 + // Room for both the tick values and the "Strikes Applied" axis label 87 + // below them. 88 + bottom: 40, 85 89 }} 86 90 > 87 91 <YAxis ··· 108 112 name="Strike Count" 109 113 label={{ value: 'Strikes Applied', position: 'bottom' }} 110 114 /> 111 - <Tooltip cursor={{ fill: 'transparent' }} /> 112 - <Legend /> 115 + {/* Pin the tooltip to a fixed Y in the middle of the plot area so 116 + it doesn't follow the cursor up and down inside a single bar. 117 + The +20 nudges it below the title without overlapping the x-axis 118 + label. The cursor fill replaces the invisible default so the 119 + user can see which column is selected. */} 120 + <Tooltip 121 + cursor={{ fill: 'rgba(0, 0, 0, 0.04)' }} 122 + position={{ y: chartHeight / 2 + 20 }} 123 + /> 124 + {/* Top alignment keeps the legend out of the x-axis label's space. 125 + Custom payload so the dashed red threshold lines are explained; 126 + ReferenceLine doesn't participate in the legend on its own. */} 127 + <Legend 128 + verticalAlign="top" 129 + payload={[ 130 + { 131 + value: 'Number of Users', 132 + type: 'square', 133 + color: '#6aa9f6', 134 + }, 135 + ...((thresholds?.length ?? 0) > 0 136 + ? ([ 137 + { 138 + value: 'Strike Threshold', 139 + type: 'plainline', 140 + color: 'red', 141 + payload: { strokeDasharray: '3 3' }, 142 + }, 143 + ] as const) 144 + : []), 145 + ]} 146 + /> 113 147 {thresholds?.map((threshold) => ( 114 148 <ReferenceLine 115 149 key={threshold.threshold} ··· 174 208 ); 175 209 const recentUserStrikeActions = data?.recentUserStrikeActions; 176 210 177 - const tableData = useMemo( 178 - () => { 179 - return recentUserStrikeActions 180 - ?.slice() 181 - ?.sort((a, b) => { 182 - return a.time > b.time ? -1 : 1; 183 - }) 184 - .map((values) => { 185 - return { 186 - user: ( 187 - <Link 188 - className="cursor-pointer shrink-0" 189 - to={`/dashboard/investigation?id=${values.itemId}&typeId=${values.itemTypeId}`} 190 - target="_blank" 191 - > 192 - {values.itemId} 193 - </Link> 194 - ), 195 - action: actionsById 196 - ? actionsById[values.actionId] || 'Unknown' 197 - : 'Unknown', 198 - date: format(new Date(values.time), 'MM/dd/yy hh:mm'), 199 - }; 200 - }); 201 - }, 202 - 203 - [recentUserStrikeActions, actionsById], 204 - ); 211 + const tableData = useMemo(() => { 212 + return recentUserStrikeActions 213 + ?.slice() 214 + ?.sort((a, b) => (a.time > b.time ? -1 : a.time < b.time ? 1 : 0)) 215 + .map((values) => { 216 + return { 217 + user: ( 218 + <Link 219 + className="cursor-pointer shrink-0" 220 + to={`/dashboard/investigation?id=${values.itemId}&typeId=${values.itemTypeId}`} 221 + target="_blank" 222 + > 223 + {values.itemId} 224 + </Link> 225 + ), 226 + action: actionsById 227 + ? (actionsById[values.actionId] ?? 'Unknown') 228 + : 'Unknown', 229 + date: format(new Date(values.time), 'MM/dd/yy hh:mm'), 230 + }; 231 + }); 232 + }, [recentUserStrikeActions, actionsById]); 205 233 206 234 if (error || actionsError) { 207 235 throw new Error(error?.message ?? actionsError?.message); ··· 215 243 <div className="font-bold"> 216 244 Recent Actions Taken By Your Strike System 217 245 </div> 218 - <div className="items-center w-full"> 219 - <Table columns={columns} data={tableData ?? []} /> 220 - </div> 246 + <Table 247 + columns={columns} 248 + data={tableData ?? []} 249 + containerClassName="w-full" 250 + /> 221 251 </div> 222 252 ); 223 253 }
+21 -7
client/src/webpages/dashboard/userStrikes/ThresholdsAndSettingsTab.tsx
··· 66 66 }, 67 67 }); 68 68 69 - const [setUserStrikeTTL] = useGQLUpdateUserStrikeTtlMutation(); 69 + const [setUserStrikeTTL] = useGQLUpdateUserStrikeTtlMutation({ 70 + onCompleted: async () => { 71 + await refetchThresholds(); 72 + }, 73 + }); 70 74 71 75 const thresholds = data?.myOrg?.userStrikeThresholds; 72 76 ··· 111 115 }} 112 116 /> 113 117 <StrikeTTLForm 118 + // Force remount when orgTTL changes (after a successful save) so the 119 + // form's local ttlFormState picks up the refetched value. 120 + key={`strikeTTLForm-${data?.myOrg?.userStrikeTTL ?? 90}`} 114 121 orgTTL={data?.myOrg?.userStrikeTTL ?? 90} 115 122 setTTL={async (ttl) => { 116 123 await setUserStrikeTTL({ ··· 127 134 } 128 135 function StrikeTTLForm(props: { 129 136 orgTTL: number; 130 - setTTL: (ttl: number) => void; 137 + // Async — the caller awaits inside the Save handler so the form can wait for 138 + // the mutation to round-trip before toggling out of edit mode. 139 + setTTL: (ttl: number) => Promise<void>; 131 140 }) { 132 141 const { orgTTL, setTTL } = props; 133 142 ··· 180 189 className="!fill-none" 181 190 startIcon={Check} 182 191 onClick={async () => { 183 - setTTL(ttlFormState); 192 + await setTTL(ttlFormState); 184 193 toggleEditing(); 185 194 }} 186 195 > ··· 196 205 <div className="flex flex-row items-start mt-4 space-x-12 text-slate-700 text-start"> 197 206 <div className="flex flex-col mr-12 gap-3"> 198 207 <div className="text-sm">User strikes stay on record for</div> 199 - <div className="flex flex-row"> 208 + <div className="flex flex-row items-center gap-2"> 200 209 <Input 201 210 type="number" 202 211 disabled={!editingTTL} 203 - style={editingTTL ? { width: '3.5em' } : { width: '5.5em' }} 212 + // Wide enough for 3 digits (max 365) plus the browser's 213 + // number-input spinner controls in the editing state. 214 + style={{ width: '6em' }} 204 215 min={0} 205 216 max={365} 206 217 maxLength={3} 207 218 placeholder="90" 208 - defaultValue={orgTTL} 209 - value={ttlFormState + `${!editingTTL ? ' days' : ''}`} 219 + // value must be a plain number string for type="number" — 220 + // any trailing non-numeric chars (like " days") cause the 221 + // browser to silently render the placeholder instead. 222 + value={ttlFormState} 210 223 onChange={(value) => { 211 224 if (value.target.value === '') { 212 225 setTTLFormState(0); ··· 219 232 } 220 233 }} 221 234 /> 235 + <span className="text-sm">days</span> 222 236 </div> 223 237 </div> 224 238 </div>
+5 -1
lint-staged.config.mjs
··· 18 18 .filter((f) => f.startsWith(pkgRoot + path.sep)) 19 19 .map((f) => path.relative(pkgRoot, f)); 20 20 if (rels.length === 0) return []; 21 - const args = rels.map((f) => JSON.stringify(f)).join(' '); 21 + // Single-quote each path so it survives the outer double-quoted `bash -c`. 22 + // The previous JSON.stringify produced double-quoted args, which collapsed 23 + // against the outer `"..."` and left eslint with no file args — silently 24 + // making it lint the whole package directory instead. 25 + const args = rels.map((f) => `'${f.replace(/'/g, `'\\''`)}'`).join(' '); 22 26 return `bash -c "cd ${pkg} && ./node_modules/.bin/eslint --fix ${args}"`; 23 27 }; 24 28