Skip to content

Commit

Permalink
Add Click-To-Edit functionality (#250)
Browse files Browse the repository at this point in the history
* Make Extracted Data be sortable

Updates SortableTable to pass in index and entire row data into
formatters

Use SortableTable on Review page

Create custom formatter for adding exclamation icon and coloring rows
red

* Add click to edit functionality

Adds the click to edit functions for the review and edit page
Return will commit changes
Escape will revert changes since last commit
Clicking outside of the text box will commit changes

If there are errors Submit button on review page will be disabled

Hovering over content will display icon (transition of icon will respect
reduce motion preferences)

Minor tsconfig tweak to remove error when importing
syntheticDefaultImports

Fix bug with Sortable Table passing in wrong index to formatter
functions

* Update Tests
  • Loading branch information
schreiaj authored Sep 26, 2024
1 parent a8beaba commit 21d86a1
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 48 deletions.
12 changes: 9 additions & 3 deletions OCR/frontend/e2e/ReviewTemplate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { test, expect } from "@playwright/test";
test.describe("ReviewTemplate Page", () => {
test.beforeEach(async ({ page }) => {
// Navigate to the ReviewTemplate page
await page.goto("http://localhost:5173/extract/review");
await page.goto("/extract/review");
});

test("Document image scrollable", async ({ page }) => {
Expand All @@ -30,6 +30,12 @@ test.describe("ReviewTemplate Page", () => {
test("Submit button navigates correctly", async ({ page }) => {
const submitButton = page.getByRole("button", { name: "Submit" });
await expect(submitButton).toBeVisible();
await expect(submitButton).toBeDisabled();
const errorRows = await page.locator("tr *[data-testid='edit-fix-error']");
for (const row of await errorRows.elementHandles()) {
await row.click();
await page.keyboard.press('Enter');
}

await submitButton.click();
await expect(page).toHaveURL("/extract/submit");
Expand Down Expand Up @@ -67,7 +73,7 @@ test.describe("ReviewTemplate Page", () => {
test("should correctly identify and count errors (below threshold)", async ({
page,
}) => {
const errorCount = await page.locator("tr .usa-input--error").count();
const errorCount = await page.locator("tr .text-error").count();

await expect(errorCount).toBeGreaterThan(0);
});
Expand All @@ -76,6 +82,6 @@ test.describe("ReviewTemplate Page", () => {
page,
}) => {
const DrawLocation = page.locator("td >> text=BH_1Diamondd_LAB");
await expect(DrawLocation).toHaveClass(/usa-input--error/);
await expect(DrawLocation).toHaveClass(/text-error/);
});
});
19 changes: 19 additions & 0 deletions OCR/frontend/src/components/EditableText/EditableText.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.hover-display-icon {
svg {
opacity: 0;
@media (prefers-reduced-motion: no-preference) {
transition: opacity 0.2s ease-in-out;
}
}
}

.hover-display-icon:hover {

svg {
opacity: 1;
@media (prefers-reduced-motion: no-preference) {
transition: opacity 0.2s ease-in-out;
}
}
}

83 changes: 83 additions & 0 deletions OCR/frontend/src/components/EditableText/EditableText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React, {FC, ReactNode, useEffect, useRef, useState} from 'react';
import {Icon, TextInput} from "@trussworks/react-uswds";

import './EditableText.scss'

interface EditableTextProps {
text: string,
onChange: (text: string) => void,
onSave: (value: string) => void,
onCancel: () => void,
onEdit: () => void,
isEditing: boolean,
isError: boolean,
errorMessage: string,
onValidate: (text: string) => boolean | string[]
textFormatter: (text: string) => ReactNode | string
dataTestId?: string
}

export const EditableText: FC<EditableTextProps> = ({
text, dataTestId,
onChange = () => {
},
onSave = () => {
},
textFormatter = (text) => text
}: EditableTextProps) => {
const [isEditing, setIsEditing] = useState(false)
const [value, setValue] = useState(text)
const inputRef = useRef<HTMLInputElement | null>(null)
useEffect(() => {
if (isEditing) {
inputRef.current?.focus()
}
}, [isEditing])

useEffect(() => {
setValue(text)
}, [text])

const _onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
onChange(e.target.value)
}

const _onEdit = () => {
setIsEditing(true)
// onEdit()
}

const _onSave = () => {
setIsEditing(false)
onSave(value)
}

const _onCancel = () => {
setValue(text)
setIsEditing(false)
}
const _onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
_onSave()
}
if (e.key === 'Escape') {
_onCancel()
}
}

return (<>
{isEditing ?
<TextInput inputRef={inputRef} className="margin-0 padding-0" type="text" value={value} onChange={_onChange}
onBlur={_onSave} onKeyDown={_onKeyDown} id={value}/> :
<div data-testid={dataTestId} className="display-flex flex-align-center hover-display-icon"
onClick={_onEdit}>
<div className="font-sans-md font-weight-semibold">{textFormatter(value)}</div>
<div className="flex-1"></div>
<div className="padding-left-1 padding-right-1 custom-tooltip-container display-flex flex-align-end">
<Icon.Edit aria-hidden={true}/>
</div>
</div>}

</>)
}
4 changes: 2 additions & 2 deletions OCR/frontend/src/components/SortableTable/SortableTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface SortableTableProps {
defaultDescending: boolean | undefined,
columns: Array<string> | undefined,
columnNames: Map<string, string> | undefined,
formatters: Map<string, (any) => object> | undefined
formatters: Map<string, (any, number, any ) => object> | undefined
}

const SORTS = {
Expand Down Expand Up @@ -70,7 +70,7 @@ export const SortableTable: FC<SortableTableProps> = ({
return (<tr key={idx}>
{columns?.map((col, colIdx) => {
return <td
key={colIdx}>{formatters?.[col] ? formatters?.[col](t[col]) : t[col]?.toString()}</td>
key={colIdx}>{formatters?.[col] ? formatters?.[col](t[col], idx, t) : t[col]?.toString()}</td>
})}
</tr>)
})}
Expand Down
101 changes: 60 additions & 41 deletions OCR/frontend/src/pages/ReviewTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Divider } from "../components/Divider";
import documentImage from "./SyphForm.png"; //Please enter your file of choice here
import "./ReviewTemplate.scss";
import aiIconUrl from "../assets/ai_icon.svg";
import {SortableTable} from "../components/SortableTable/SortableTable.tsx";
import {EditableText} from "../components/EditableText/EditableText.tsx";

interface Result {
text: string;
Expand Down Expand Up @@ -59,6 +61,9 @@ const ReviewTemplate: React.FC = () => {
}
}, []);

const [editedValues, setEditedValues] = useState<Map<string, string>>(new Map());


const handleBack = () => {
navigate("/extract/upload");
};
Expand All @@ -71,14 +76,7 @@ const ReviewTemplate: React.FC = () => {
navigate("/");
};

const calculateOverallConfidence = () => {
if (!submissionData) return 0;
const results = Object.values(submissionData.results);
const totalConfidence = results.reduce((sum, result) => {
return sum + (result.edited ? 100 : result.confidence);
}, 0);
return (totalConfidence / results.length).toFixed(2);
};


//fallback if no valid template is available can edit if needed
if (!submissionData) {
Expand All @@ -88,19 +86,60 @@ const ReviewTemplate: React.FC = () => {
const { file_image, results } = submissionData;
const confidenceVal = 75;

const errorCount = Object.values(results).filter(
(r) => r.confidence <= confidenceVal
const resultsTable = Object.entries(results).map(([k, v], idx) => {
const overrideValue = editedValues[k]
return {
index: idx,
name: k,
value: overrideValue || v.text,
confidence: overrideValue ? 100 : v.confidence,
isError: overrideValue ? false : v.confidence <= confidenceVal,
isEdited: !!overrideValue
}
});

const calculateOverallConfidence = () => {
if (!submissionData) return 0;
const results = resultsTable;
const totalConfidence = results.reduce((sum, result) => {
return sum + result.confidence;
}, 0);
return (totalConfidence / results.length).toFixed(2);
};

const errorCount = resultsTable.filter(
(r) => r.isError
).length;
const hasErrors = errorCount > 0;
const overallConfidence = calculateOverallConfidence();



const isErrorFormatter = (d) => {
return d ? <Icon.Warning className="text-error" /> : "";
}

const labelConfidenceFormatter = (d, _idx, row) => {
return row.isEdited ? "Edited" : redTextOnErrorFormatter(d, row.index, row)
}

const redTextOnErrorFormatter = (d, _idx, row) => {
return row.isError ? <span className="text-error">{d}</span> : d;
}

const editCellFormatter = (d, _idx, row) => {
return <EditableText dataTestId={`${row.isError ? 'edit-fix-error' : null}`} text={d} onSave={(value) => {
setEditedValues({...editedValues, [row.name]: value})
}} textFormatter={(s) => row.isError ? <span className="text-error">{s}</span> : s}/>
}

return (
<div className="display-flex flex-column flex-justify-start width-full height-full padding-1 padding-top-2">
<div className="display-flex flex-column flex-justify-start width-full height-full padding-1 padding-top-2">
<ExtractDataHeader
onSubmit={handleSubmit}
onExit={handleClose}
onBack={handleBack}
isUploadComplete={true}
isUploadComplete={!hasErrors}
/>
<Divider margin="0px" />
<div className="display-flex flex-justify-center padding-top-4">
Expand Down Expand Up @@ -151,35 +190,15 @@ const ReviewTemplate: React.FC = () => {
</div>
</div>

<Table fullWidth striped>
<thead>
<tr>
<th>Label</th>
<th>Value</th>
<th></th>
<th>Label Confidence</th>
</tr>
</thead>
<tbody>
{Object.entries(results).map(([key, value]) => {
const isError = value.confidence <= confidenceVal;
return (
<tr key={key}>
<td>{key}</td>
<td className={`${isError ? "usa-input--error" : ""}`}>
{value.text}
</td>
<td>
{isError && <Icon.Warning className="text-error" />}
</td>
<td className={`${isError ? "usa-input--error" : ""}`}>
{`${value.confidence}%`}
</td>
</tr>
);
})}
</tbody>
</Table>
<SortableTable
columns={["name", "value", "isError", "confidence"]}
data={resultsTable}
sortableBy={["name", "confidence", "value"]}
defaultSort={"confidence"}
columnNames={{name: "Label", value: "Value", isError: " ", confidence: "Label Confidence"}}
formatters={{isError: isErrorFormatter, confidence: labelConfidenceFormatter, value: editCellFormatter}}
/>

</div>
<div className="width-50">
<div
Expand Down
8 changes: 7 additions & 1 deletion OCR/frontend/src/style/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
height: 100%;
width: 100%;

color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;

font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

body {
height: 100vh;
width: 100vw;
}
3 changes: 2 additions & 1 deletion OCR/frontend/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true
},
"include": ["src"]
}

0 comments on commit 21d86a1

Please sign in to comment.