Skip to content

Commit eba06b0

Browse files
Show if collab editor has unuploaded changes
Should help the user know whether or not its safe to exit. Also count edited-but-not-saved plain input fields as "dirty" so that users know to save them.
1 parent d82ea41 commit eba06b0

11 files changed

Lines changed: 57 additions & 21 deletions

File tree

javascript/src/frontend/collab_forms/connection.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import * as Y from "yjs";
55
export type ConnectionStatus =
66
| "disconnected"
77
| "connecting"
8-
| "initialSyncing"
98
| "syncing"
109
| "error"
10+
| "dirty"
1111
| "idle";
1212

1313
/// Gets a YJS connection from the information embedded by the `collab_editing/update.html` view.
@@ -22,12 +22,17 @@ export function usePageConnection(settings: {
2222
status: ConnectionStatus;
2323
// A simple yes-no for whether or not controls should be enabled. Derived from `status` but provided here for convenience.
2424
connected: boolean;
25+
// A setter that can be set to `true` to return the `"dirty"` status even if nothing is actually dirty.
26+
// Use if a field is being edited but not saved to the document yet.
27+
setEditing: (editing: boolean) => void;
2528
} {
2629
const [status, setStatus] = useState<
2730
"disconnected" | "connecting" | "connected"
2831
>("disconnected");
2932
const [synced, setSynced] = useState<boolean>(false);
30-
const [initialSyncDone, setInitialSyncDone] = useState<boolean>(false);
33+
const [hasUnsyncedChanges, setHasUnsyncedChanges] =
34+
useState<boolean>(false);
35+
const [editing, setEditing] = useState<boolean>(false);
3136
const savedInstanceID = useRef<string | null>(null);
3237

3338
// Type as `HocuspocusProvider` only, cuz it's only going to be null for a slight bit.
@@ -60,12 +65,10 @@ export function usePageConnection(settings: {
6065
},
6166
onStatus(event) {
6267
setStatus(event.status);
63-
if (event.status !== "connected") setInitialSyncDone(false);
6468
},
6569
onSynced(event) {
6670
const state = event.state;
6771
setSynced(state);
68-
if (state) setInitialSyncDone(true);
6972

7073
if (savedInstanceID.current === null) {
7174
const doc = provider.current.document;
@@ -76,6 +79,12 @@ export function usePageConnection(settings: {
7679
});
7780
}
7881
},
82+
onDisconnect() {
83+
setSynced(false);
84+
},
85+
});
86+
provider.current.on("unsyncedChanges", () => {
87+
setHasUnsyncedChanges(provider.current!.hasUnsyncedChanges);
7988
});
8089

8190
provider.current.awareness!.setLocalStateField("user", {
@@ -113,9 +122,9 @@ export function usePageConnection(settings: {
113122

114123
let outStatus: ConnectionStatus;
115124
if (status === "connected") {
116-
if (!initialSyncDone) outStatus = "initialSyncing";
117-
else if (!synced) outStatus = "syncing";
125+
if (!synced) outStatus = "syncing";
118126
else if (hasSaveError) outStatus = "error";
127+
else if (hasUnsyncedChanges || editing) outStatus = "dirty";
119128
else outStatus = "idle";
120129
} else {
121130
outStatus = status;
@@ -124,10 +133,8 @@ export function usePageConnection(settings: {
124133
return {
125134
provider: provider.current,
126135
status: outStatus,
127-
connected:
128-
outStatus === "idle" ||
129-
outStatus === "syncing" ||
130-
outStatus === "error",
136+
connected: outStatus === "idle" || outStatus === "dirty",
137+
setEditing,
131138
};
132139
}
133140

@@ -171,9 +178,9 @@ function hsv_to_rgb(h: number, s: number, v: number) {
171178
const STATUS_LOOKUP: { [key in ConnectionStatus]: [string, string] } = {
172179
disconnected: ["Disconnected", "alert-danger"],
173180
connecting: ["Connecting...", "alert-warning"],
174-
initialSyncing: ["Synchronizing...", "alert-warning"],
175181
syncing: ["Synchronizing...", "alert-warning"],
176182
idle: ["Connected, changes saved automatically", "alert-success"],
183+
dirty: ["Connected, unsaved changes", "alert-warning"],
177184
error: ["Could not save data - refresh page and try again", "alert-danger"],
178185
};
179186

javascript/src/frontend/collab_forms/extra_fields.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default function ExtraFieldsSection(props: {
4040
provider: HocuspocusProvider;
4141
header?: React.ReactNode;
4242
toolbarExtra?: (editor: Editor) => React.ReactNode;
43+
setEditing?: (editing: boolean) => void;
4344
}) {
4445
const specs = useExtraFieldSpecs();
4546

@@ -66,6 +67,7 @@ export function ExtraFieldInput(props: {
6667
provider: HocuspocusProvider;
6768
spec: ExtraFieldSpec;
6869
toolbarExtra?: (editor: Editor) => React.ReactNode;
70+
setEditing?: (editing: boolean) => void;
6971
}) {
7072
const map = useMemo(
7173
() => props.provider.document.getMap("extra_fields"),
@@ -91,6 +93,7 @@ export function ExtraFieldInput(props: {
9193
mapKey={props.spec.internal_name}
9294
inputProps={{ className: "form-control mb-3" }}
9395
defaultValue={0}
96+
setEditing={props.setEditing}
9497
/>
9598
);
9699
case "integer":
@@ -102,6 +105,7 @@ export function ExtraFieldInput(props: {
102105
mapKey={props.spec.internal_name}
103106
inputProps={{ className: "form-control mb-3" }}
104107
defaultValue={0}
108+
setEditing={props.setEditing}
105109
/>
106110
);
107111
case "single_line_text":
@@ -112,6 +116,7 @@ export function ExtraFieldInput(props: {
112116
map={map}
113117
mapKey={props.spec.internal_name}
114118
inputProps={{ className: "form-control mb-3" }}
119+
setEditing={props.setEditing}
115120
/>
116121
);
117122
case "rich_text":

javascript/src/frontend/collab_forms/forms/finding.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { FindingFormFields } from "../forms_common/finding";
77
import RichTextEditor from "../rich_text_editor";
88

99
function FindingForm() {
10-
const { provider, status, connected } = usePageConnection({
10+
const { provider, status, connected, setEditing } = usePageConnection({
1111
model: "finding",
1212
});
1313

@@ -16,6 +16,7 @@ function FindingForm() {
1616
provider={provider}
1717
status={status}
1818
connected={connected}
19+
setEditing={setEditing}
1920
extraBottom={
2021
<>
2122
<div className="form-group col-md-12">

javascript/src/frontend/collab_forms/forms/observation.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import ExtraFieldsSection from "../extra_fields";
88
import ReactModal from "react-modal";
99

1010
function ObservationForm() {
11-
const { provider, status, connected } = usePageConnection({
11+
const { provider, status, connected, setEditing } = usePageConnection({
1212
model: "observation",
1313
});
1414

@@ -29,6 +29,7 @@ function ObservationForm() {
2929
connected={connected}
3030
provider={provider}
3131
mapKey="title"
32+
setEditing={setEditing}
3233
/>
3334
</div>
3435
</div>
@@ -64,7 +65,11 @@ function ObservationForm() {
6465
</div>
6566
</div>
6667

67-
<ExtraFieldsSection connected={connected} provider={provider} />
68+
<ExtraFieldsSection
69+
connected={connected}
70+
provider={provider}
71+
setEditing={setEditing}
72+
/>
6873

6974
<ConnectionStatus status={status} />
7075
</div>

javascript/src/frontend/collab_forms/forms/report_field.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const renderToolbarExtra = (editor: Editor) => (
1212
);
1313

1414
function ReportExtraFieldForm(props: { field: string }) {
15-
const { provider, status, connected } = usePageConnection({
15+
const { provider, status, connected, setEditing } = usePageConnection({
1616
model: "report",
1717
});
1818

@@ -32,6 +32,7 @@ function ReportExtraFieldForm(props: { field: string }) {
3232
provider={provider}
3333
spec={extraField}
3434
toolbarExtra={renderToolbarExtra}
35+
setEditing={setEditing}
3536
/>
3637
{extraField.description && (
3738
<small className="form-text text-muted">

javascript/src/frontend/collab_forms/forms/reportfindinglink.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const renderToolbarExtra = (editor: Editor) => (
1414
);
1515

1616
function ReportFindingLinkForm() {
17-
const { provider, status, connected } = usePageConnection({
17+
const { provider, status, connected, setEditing } = usePageConnection({
1818
model: "report_finding_link",
1919
});
2020

@@ -24,6 +24,7 @@ function ReportFindingLinkForm() {
2424
status={status}
2525
connected={connected}
2626
toolbarExtra={renderToolbarExtra}
27+
setEditing={setEditing}
2728
extraTop={
2829
<>
2930
<h4 className="icon list-icon">Affected Entities</h4>

javascript/src/frontend/collab_forms/forms/reportobservationlink.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const renderToolbarExtra = (editor: Editor) => (
1616
);
1717

1818
function ReportObservationLinkForm() {
19-
const { provider, status, connected } = usePageConnection({
19+
const { provider, status, connected, setEditing } = usePageConnection({
2020
model: "report_observation_link",
2121
});
2222

@@ -37,6 +37,7 @@ function ReportObservationLinkForm() {
3737
connected={connected}
3838
provider={provider}
3939
mapKey="title"
40+
setEditing={setEditing}
4041
/>
4142
</div>
4243
</div>
@@ -77,6 +78,7 @@ function ReportObservationLinkForm() {
7778
connected={connected}
7879
provider={provider}
7980
toolbarExtra={renderToolbarExtra}
81+
setEditing={setEditing}
8082
/>
8183
</div>
8284
</ProvidePageEvidence>

javascript/src/frontend/collab_forms/forms_common/finding.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,15 @@ export function FindingFormFields({
4545
toolbarExtra,
4646
extraTop,
4747
extraBottom,
48+
setEditing,
4849
}: {
4950
provider: HocuspocusProvider;
5051
status: ConnectionStatus;
5152
connected: boolean;
5253
toolbarExtra?: (editor: Editor) => React.ReactNode;
5354
extraTop?: React.ReactNode;
5455
extraBottom?: React.ReactNode;
56+
setEditing?: (editing: boolean) => void;
5557
}) {
5658
return (
5759
<>
@@ -73,6 +75,7 @@ export function FindingFormFields({
7375
connected={connected}
7476
provider={provider}
7577
mapKey="title"
78+
setEditing={setEditing}
7679
/>
7780
</div>
7881
</div>
@@ -156,6 +159,7 @@ export function FindingFormFields({
156159
mapKey="cvssScore"
157160
connected={connected}
158161
defaultValue={null}
162+
setEditing={setEditing}
159163
/>
160164
<small className="form-text text-muted">
161165
Set the CVSS score for this finding
@@ -175,6 +179,7 @@ export function FindingFormFields({
175179
connected={connected}
176180
provider={provider}
177181
mapKey="cvssVector"
182+
setEditing={setEditing}
178183
/>
179184
<small className="form-text text-muted">
180185
Set the CVSS vector for this finding
@@ -310,6 +315,7 @@ export function FindingFormFields({
310315
</>
311316
}
312317
toolbarExtra={toolbarExtra}
318+
setEditing={setEditing}
313319
/>
314320
</>
315321
);

javascript/src/frontend/collab_forms/plain_editors/field.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { useEffect, useMemo, useReducer, useRef } from "react";
55

66
/**
77
* Gets and observes a YJS map key.
8-
* @param map The YJS map to observe
9-
* @param key The key of the YJS map to get and observe
10-
* @param defaultValue The value to return if the key is missing
8+
* @param map The YJS map to observe.
9+
* @param key The key of the YJS map to get and observe.
10+
* @param defaultValue The value to return if the key is missing.
11+
* @param onExternalChange Callback to call when a change to the field happens that wasn't caused by this client.
1112
* @returns The current value and a setter that sets the value on the YJS map.
1213
*/
1314
export function usePlainField<T>(

javascript/src/frontend/collab_forms/plain_editors/input.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function BaseInput<T>(props: {
1414
toString: (v: T) => string;
1515
parse: (v: string) => T | undefined;
1616
defaultValue: T;
17+
setEditing?: (editing: boolean) => void;
1718
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
1819
}) {
1920
const map = useMemo(
@@ -40,13 +41,15 @@ export function BaseInput<T>(props: {
4041
if (formValue === null) return;
4142
let parsed = props.parse(formValue);
4243
if (parsed === undefined || parsed === docValue) {
44+
if (props.setEditing) props.setEditing(false);
4345
setFormValue(null);
4446
return;
4547
} else if (parsed === null) {
4648
parsed = props.defaultValue;
4749
}
4850
setDocValue(parsed);
4951
setFormValue(null);
52+
if (props.setEditing) props.setEditing(false);
5053
};
5154

5255
return (
@@ -60,6 +63,7 @@ export function BaseInput<T>(props: {
6063
}
6164
onInput={(ev) => {
6265
setFormValue((ev.target as HTMLInputElement).value);
66+
if (props.setEditing) props.setEditing(true);
6367
}}
6468
onFocus={onFocus}
6569
onBlur={() => {
@@ -84,6 +88,7 @@ export function PlainTextInput(props: {
8488
mapKey: string;
8589
connected: boolean;
8690
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
91+
setEditing?: (editing: boolean) => void;
8792
}) {
8893
const inputProps = {
8994
type: "text",
@@ -115,6 +120,7 @@ export function NumberInput(props: {
115120
connected: boolean;
116121
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
117122
defaultValue: number | null;
123+
setEditing?: (editing: boolean) => void;
118124
}) {
119125
const inputProps = {
120126
type: "number",
@@ -146,6 +152,7 @@ export function IntegerInput(props: {
146152
connected: boolean;
147153
inputProps?: React.HTMLAttributes<HTMLInputElement>;
148154
defaultValue: number;
155+
setEditing?: (editing: boolean) => void;
149156
}) {
150157
const inputProps = {
151158
type: "number",

0 commit comments

Comments
 (0)