Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

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

[Feature] Add decision reason to recent decision view as well as csv download (#772)

* [Feature] Add decision reason to recent decision view as well as csv download

* code review fixes

* wrap around policies

+231 -39
+229 -39
client/src/webpages/dashboard/mrt/ManualReviewRecentDecisions.tsx
··· 1 1 import ChevronLeft from '@/icons/lni/Direction/chevron-left.svg?react'; 2 2 import ChevronRight from '@/icons/lni/Direction/chevron-right.svg?react'; 3 3 import CrossCircle from '@/icons/lni/Interface and Sign/cross-circle.svg?react'; 4 + import GridAlt from '@/icons/lnif/Design/grid-alt.svg?react'; 4 5 import { HOST_URL } from '@/lib/config'; 6 + import { filterNullOrUndefined } from '@/utils/collections'; 5 7 import { RedoOutlined } from '@ant-design/icons'; 6 8 import { gql } from '@apollo/client'; 7 - import { Button, Input } from 'antd'; 8 - import { useCallback, useEffect, useMemo, useState } from 'react'; 9 + import { Button, Checkbox, Input, Tooltip } from 'antd'; 10 + import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 9 11 import { Helmet } from 'react-helmet-async'; 10 12 import { Link, useNavigate, useSearchParams } from 'react-router-dom'; 11 13 ··· 14 16 import FormHeader from '../components/FormHeader'; 15 17 import { stringSort } from '../components/table/sort'; 16 18 import Table from '../components/table/Table'; 17 - import TruncatedListTableCell from '../components/table/TruncatedListTableCell'; 18 19 import UserWithAvatar from '../components/UserWithAvatar'; 19 20 20 21 import { ··· 140 141 type RecentDecision = 141 142 GQLGetRecentDecisionsQuery['getRecentDecisions'][number]['decisions'][number]; 142 143 144 + // Column visibility configuration 145 + type ColumnId = 146 + | 'decisionTime' 147 + | 'decisions' 148 + | 'policies' 149 + | 'reviewer' 150 + | 'queue' 151 + | 'decisionReason'; 152 + 153 + const COLUMN_VISIBILITY_STORAGE_KEY = 'mrt-recent-decisions-column-visibility'; 154 + 155 + const defaultColumnVisibility: Record<ColumnId, boolean> = { 156 + decisionTime: true, 157 + decisions: true, 158 + decisionReason: true, 159 + policies: true, 160 + reviewer: true, 161 + queue: true, 162 + }; 163 + 164 + const columnLabels: Record<ColumnId, string> = { 165 + decisionTime: 'Decision Time', 166 + decisions: 'Decisions', 167 + decisionReason: 'Decision Reason', 168 + policies: 'Policies', 169 + reviewer: 'Reviewer', 170 + queue: 'Queue', 171 + }; 172 + 173 + const DECISION_REASON_PREVIEW_LENGTH = 50; 174 + 175 + // Escape a value for a CSV field per RFC 4180: wrap in double quotes and double 176 + // any embedded quotes so free-form text (e.g. decision reasons containing 177 + // quotes or newlines) doesn't corrupt the output. Also neutralize spreadsheet 178 + // formula prefixes (=, +, -, @) so a cell can't be interpreted as a formula. 179 + const toCsvField = (value: unknown): string => { 180 + let str = String(value ?? ''); 181 + if (/^[=+\-@]/.test(str)) { 182 + str = `'${str}`; 183 + } 184 + return `"${str.replace(/"/g, '""')}"`; 185 + }; 186 + 143 187 export default function ManualReviewRecentDecisions() { 144 188 const [searchParams] = useSearchParams(); 145 189 const [decisionId] = [searchParams.get('decisionId') ?? undefined]; ··· 154 198 RecentDecisionsFilterInput | undefined 155 199 >(undefined); 156 200 201 + // Column visibility state 202 + const [columnVisibility, setColumnVisibility] = useState< 203 + Record<ColumnId, boolean> 204 + >(() => { 205 + try { 206 + const stored = localStorage.getItem(COLUMN_VISIBILITY_STORAGE_KEY); 207 + if (stored) { 208 + return { ...defaultColumnVisibility, ...JSON.parse(stored) }; 209 + } 210 + } catch (e) { 211 + // Failed to load from localStorage, use defaults 212 + } 213 + return defaultColumnVisibility; 214 + }); 215 + 216 + // Save column visibility to localStorage whenever it changes 217 + useEffect(() => { 218 + try { 219 + localStorage.setItem( 220 + COLUMN_VISIBILITY_STORAGE_KEY, 221 + JSON.stringify(columnVisibility), 222 + ); 223 + } catch (e) { 224 + // Failed to save to localStorage 225 + } 226 + }, [columnVisibility]); 227 + 228 + const toggleColumnVisibility = useCallback((columnId: ColumnId) => { 229 + setColumnVisibility((prev) => { 230 + const next = { ...prev, [columnId]: !prev[columnId] }; 231 + // Keep at least one column visible to avoid a blank, unusable table. 232 + if (!Object.values(next).some(Boolean)) { 233 + return prev; 234 + } 235 + return next; 236 + }); 237 + }, []); 238 + 239 + const [columnsMenuVisible, setColumnsMenuVisible] = useState(false); 240 + const columnsMenuRef = useRef<HTMLDivElement>(null); 241 + 242 + // Close columns menu when clicking outside 243 + useEffect(() => { 244 + const handleClickOutside = (event: MouseEvent) => { 245 + if ( 246 + columnsMenuRef.current && 247 + !columnsMenuRef.current.contains(event.target as Node) 248 + ) { 249 + setColumnsMenuVisible(false); 250 + } 251 + }; 252 + 253 + if (columnsMenuVisible) { 254 + document.addEventListener('mousedown', handleClickOutside); 255 + return () => { 256 + document.removeEventListener('mousedown', handleClickOutside); 257 + }; 258 + } 259 + }, [columnsMenuVisible]); 260 + 157 261 const { data: orgLookupData } = useGQLOrgLookupDataQuery({ 158 262 fetchPolicy: 'cache-and-network', 159 263 }); ··· 257 361 ]); 258 362 259 363 const columns = useMemo( 260 - () => [ 261 - { 262 - Header: 'Decision Time', 263 - accessor: 'decisionTime', 264 - sortDescFirst: true, 265 - sortType: stringSort, 266 - }, 267 - { 268 - Header: 'Decisions', 269 - accessor: 'decisions', 270 - canSort: false, 271 - }, 272 - { 273 - Header: 'Policies', 274 - accessor: 'policies', 275 - canSort: false, 276 - }, 277 - { 278 - Header: 'Reviewer', 279 - accessor: 'reviewer', 280 - canSort: false, 281 - }, 282 - { 283 - Header: 'Queue', 284 - accessor: 'queue', 285 - canSort: true, 286 - }, 287 - ], 288 - [], 364 + () => 365 + filterNullOrUndefined([ 366 + columnVisibility.decisionTime 367 + ? { 368 + Header: 'Decision Time', 369 + accessor: 'decisionTime', 370 + sortDescFirst: true, 371 + sortType: stringSort, 372 + } 373 + : undefined, 374 + columnVisibility.decisions 375 + ? { 376 + Header: 'Decisions', 377 + accessor: 'decisions', 378 + canSort: false, 379 + } 380 + : undefined, 381 + columnVisibility.decisionReason 382 + ? { 383 + Header: 'Decision Reason', 384 + accessor: 'decisionReason', 385 + canSort: false, 386 + } 387 + : undefined, 388 + columnVisibility.policies 389 + ? { 390 + Header: 'Policies', 391 + accessor: 'policies', 392 + canSort: false, 393 + } 394 + : undefined, 395 + columnVisibility.reviewer 396 + ? { 397 + Header: 'Reviewer', 398 + accessor: 'reviewer', 399 + canSort: false, 400 + } 401 + : undefined, 402 + columnVisibility.queue 403 + ? { 404 + Header: 'Queue', 405 + accessor: 'queue', 406 + canSort: true, 407 + } 408 + : undefined, 409 + ]), 410 + [columnVisibility], 289 411 ); 290 412 291 413 const getReviewerName = useCallback( ··· 480 602 </div> 481 603 ), 482 604 policies: ( 483 - <div className="flex flex-wrap gap-1"> 484 - <TruncatedListTableCell list={value.policies} /> 605 + <div className="max-w-[220px] whitespace-normal break-words"> 606 + {value.policies.join(', ')} 485 607 </div> 486 608 ), 487 609 reviewer: <UserWithAvatar name={value.reviewer} />, ··· 493 615 )} 494 616 </div> 495 617 ), 618 + decisionReason: value.decisionReason ? ( 619 + <Tooltip title={value.decisionReason}> 620 + <div className="max-w-xs truncate"> 621 + {value.decisionReason.length > DECISION_REASON_PREVIEW_LENGTH 622 + ? `${value.decisionReason.slice( 623 + 0, 624 + DECISION_REASON_PREVIEW_LENGTH, 625 + )}…` 626 + : value.decisionReason} 627 + </div> 628 + </Tooltip> 629 + ) : ( 630 + <div className="text-slate-400">—</div> 631 + ), 496 632 values: value, 497 633 }; 498 634 }) ··· 563 699 new Date(decision.createdAt), 564 700 ), 565 701 policies, 702 + decisionReason: decision.decisionReason ?? '', 566 703 }; 567 704 }); 568 705 // Define the CSV headers ··· 572 709 'Reviewer', 573 710 'Queue', 574 711 'Decision Time', 712 + 'Decision Reason', 575 713 'Link', 576 714 ]; 577 715 ··· 582 720 item.reviewer, 583 721 item.queue, 584 722 item.createdAt, 723 + item.decisionReason, 585 724 `${HOST_URL}/dashboard/manual_review/recent?jobId=${item.jobId}`, 586 725 ]); 587 726 588 727 // Combine the headers and rows into a CSV string 589 728 const csvContent = [headers, ...rows] 590 - .map((row) => row.map((field) => `"${field}"`).join(',')) // Ensure each field is enclosed in double quotes 729 + .map((row) => row.map(toCsvField).join(',')) // RFC 4180: quote fields and escape embedded quotes 591 730 .join('\n'); 592 731 593 732 // Create a Blob from the CSV content ··· 637 776 638 777 // Combine the headers and rows into a CSV string 639 778 const csvContent = [headers, ...rows] 640 - .map((row) => row.map((field) => `"${field}"`).join(',')) // Ensure each field is enclosed in double quotes 779 + .map((row) => row.map(toCsvField).join(',')) // RFC 4180: quote fields and escape embedded quotes 641 780 .join('\n'); 642 781 643 782 // Create a Blob from the CSV content ··· 751 890 }; 752 891 753 892 const userSearchInput = ( 754 - <div className="flex items-start gap-2 pb-6"> 893 + <div className="flex items-start gap-2 pb-1"> 755 894 <Input 756 895 className="rounded-lg w-[300px]" 757 896 placeholder="Input a user's ID or username" ··· 778 917 </div> 779 918 ); 780 919 920 + const visibleColumnsCount = 921 + Object.values(columnVisibility).filter(Boolean).length; 922 + 923 + const columnsButton = ( 924 + <div ref={columnsMenuRef} className="relative inline-block text-start"> 925 + <Button 926 + className={`font-semibold text-base rounded ${ 927 + visibleColumnsCount === Object.keys(columnLabels).length 928 + ? 'bg-white text-gray-600 hover:bg-white hover:text-gray-600' 929 + : 'bg-gray-600 text-white border-none hover:bg-gray-500' 930 + }`} 931 + icon={ 932 + <GridAlt className="inline-block w-4 h-4 mr-2" fill="currentColor" /> 933 + } 934 + onClick={() => setColumnsMenuVisible(!columnsMenuVisible)} 935 + > 936 + Columns 937 + </Button> 938 + {columnsMenuVisible && ( 939 + <div className="absolute left-0 z-20 flex flex-col mt-1 bg-white border border-solid border-gray-300 rounded shadow-md min-w-[240px]"> 940 + <div className="px-4 py-4 text-base font-semibold">Show Columns</div> 941 + <div className="!p-0 !m-0 divider" /> 942 + <div className="flex flex-col px-4 py-2"> 943 + {(Object.keys(columnLabels) as ColumnId[]).map((columnId) => ( 944 + <div key={columnId} className="py-2"> 945 + <Checkbox 946 + checked={columnVisibility[columnId]} 947 + disabled={ 948 + columnVisibility[columnId] && visibleColumnsCount === 1 949 + } 950 + onChange={() => toggleColumnVisibility(columnId)} 951 + > 952 + {columnLabels[columnId]} 953 + </Checkbox> 954 + </div> 955 + ))} 956 + </div> 957 + </div> 958 + )} 959 + </div> 960 + ); 961 + 781 962 const userSearchAndRefresh = ( 782 963 <div className="flex gap-8"> 783 964 {userSearchInput} 784 965 {refreshButton} 785 966 {downloadButton} 786 967 {downloadSkips} 968 + </div> 969 + ); 970 + 971 + const tableControls = ( 972 + <div className="flex flex-col gap-2"> 973 + {userSearchAndRefresh} 974 + {columnsButton} 787 975 </div> 788 976 ); 789 977 ··· 822 1010 <ComponentLoading /> 823 1011 ) : ( 824 1012 <div className="flex w-full"> 825 - <div> 1013 + <div className={selectedDecision ? undefined : 'w-full min-w-0'}> 826 1014 <Table 827 1015 columns={columns} 828 1016 // @ts-ignore 829 1017 data={tableData} 1018 + alwaysShowScrollbar 1019 + containerClassName={selectedDecision ? undefined : 'w-full'} 830 1020 onSelectRow={(rowData) => 831 1021 setSelectedDecision( 832 1022 rowData.original.values.originalDecisionData, 833 1023 ) 834 1024 } 835 - topLeftComponent={selectedDecision ? null : userSearchAndRefresh} 1025 + topLeftComponent={selectedDecision ? null : tableControls} 836 1026 topRightComponent={<div className="pb-8">{filter}</div>} 837 1027 isCollapsed={selectedDecision != null} 838 1028 collapsedColumnTitle="Decisions"
+2
server/services/manualReviewToolService/modules/DecisionAnalytics.ts
··· 464 464 'decision_components', 465 465 'related_actions', 466 466 'created_at', 467 + 'decision_reason', 467 468 sql<string>`((job_payload->'payload'::text)->'item'::text) -> 'itemId'::text`.as( 468 469 'item_id', 469 470 ), ··· 518 519 type: 'RELATED_ACTION' as const, 519 520 })), 520 521 createdAt: decisionWithPayload.created_at, 522 + decisionReason: decisionWithPayload.decision_reason, 521 523 jobId: decisionWithPayload.job_id, 522 524 }, 523 525 };