Skip to content

Commit b8f5674

Browse files
authored
feat: display form errors in footer (and count in header) (#4044)
* use tanstack integration for cell fields (intent danger on error when it is possible) Closes: #4026
1 parent 6ccb66a commit b8f5674

16 files changed

+508
-353
lines changed
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useStore } from '@tanstack/react-form';
2+
import type { Dispatch, ReactNode, SetStateAction } from 'react';
3+
import {
4+
createContext,
5+
useContext,
6+
useEffect,
7+
useMemo,
8+
useRef,
9+
useState,
10+
} from 'react';
11+
import { assert, withForm } from 'react-science/ui';
12+
13+
import {
14+
defaultGeneralSettingsFormValues,
15+
workspaceValidation,
16+
} from '../validation.ts';
17+
18+
interface GeneralSettingsErrors {
19+
isOpen: boolean;
20+
setIsOpen: Dispatch<SetStateAction<boolean>>;
21+
count: number;
22+
}
23+
24+
const GeneralSettingsErrorsContext =
25+
createContext<GeneralSettingsErrors | null>(null);
26+
27+
interface GeneralSettingsErrorsOpenProvider {
28+
children: ReactNode;
29+
}
30+
const defaultProps: GeneralSettingsErrorsOpenProvider = {
31+
children: null,
32+
};
33+
34+
export const GeneralSettingsErrorsOpenProvider = withForm({
35+
defaultValues: defaultGeneralSettingsFormValues,
36+
validators: { onDynamic: workspaceValidation },
37+
props: defaultProps,
38+
render: function GeneralSettingsErrorsOpenProvider({ form, children }) {
39+
const [isOpen, setIsOpen] = useState(false);
40+
41+
const submitCount = useStore(form.store, (s) => s.submissionAttempts);
42+
const previousSubmitCount = useRef(submitCount);
43+
const errorsCount = useStore(form.store, (s) => {
44+
const recordErrors = s.errorMap.onDynamic;
45+
if (!recordErrors) return 0;
46+
47+
let count = 0;
48+
for (const errors of Object.values(recordErrors)) {
49+
count += errors.length;
50+
}
51+
52+
return count;
53+
});
54+
55+
// open errors after submitting if there are errors
56+
useEffect(() => {
57+
if (previousSubmitCount.current === submitCount) return;
58+
previousSubmitCount.current = submitCount;
59+
60+
if (errorsCount === 0) return;
61+
62+
setIsOpen(true);
63+
}, [errorsCount, submitCount]);
64+
65+
// close errors if all fixed
66+
useEffect(() => {
67+
if (errorsCount !== 0) return;
68+
69+
setIsOpen(false);
70+
}, [errorsCount]);
71+
72+
const contextValue = useMemo(
73+
() => ({ isOpen, setIsOpen, count: errorsCount }),
74+
[errorsCount, isOpen],
75+
);
76+
77+
return (
78+
<GeneralSettingsErrorsContext.Provider value={contextValue}>
79+
{children}
80+
</GeneralSettingsErrorsContext.Provider>
81+
);
82+
},
83+
});
84+
85+
export function useErrors() {
86+
const context = useContext(GeneralSettingsErrorsContext);
87+
88+
assert(
89+
context,
90+
'useErrors must be used within a GeneralSettingsErrorsOpenProvider',
91+
);
92+
93+
return context;
94+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Callout, Collapse } from '@blueprintjs/core';
2+
import styled from '@emotion/styled';
3+
import { useStore } from '@tanstack/react-form';
4+
import { Button, withForm } from 'react-science/ui';
5+
6+
import {
7+
defaultGeneralSettingsFormValues,
8+
workspaceValidation,
9+
} from '../validation.ts';
10+
11+
import { useErrors } from './context.tsx';
12+
13+
export const GeneralSettingsErrorRenderer = withForm({
14+
defaultValues: defaultGeneralSettingsFormValues,
15+
validators: { onDynamic: workspaceValidation },
16+
render: function GeneralSettingsErrorRenderer({ form }) {
17+
const { isOpen, setIsOpen } = useErrors();
18+
const errors = useStore(form.store, (state) => {
19+
return Object.values(state.errorMap.onDynamic ?? {}).flat();
20+
});
21+
22+
return (
23+
<Collapse isOpen={isOpen} keepChildrenMounted>
24+
<Callout title="Errors in the form" intent="danger">
25+
<CloseButton
26+
icon="cross"
27+
variant="minimal"
28+
onClick={() => setIsOpen(false)}
29+
/>
30+
31+
<ul>
32+
{errors.map((error, index) => {
33+
const path = error.path
34+
?.map((path) =>
35+
typeof path === 'object' ? path.key : String(path),
36+
)
37+
.join('.');
38+
39+
return (
40+
<li key={`${path}-${index}`}>
41+
{path}: {error.message}
42+
</li>
43+
);
44+
})}
45+
</ul>
46+
</Callout>
47+
</Collapse>
48+
);
49+
},
50+
});
51+
52+
const CloseButton = styled(Button)`
53+
position: absolute;
54+
right: 0;
55+
top: 0;
56+
`;

src/component/modal/setting/tanstack_general_settings/general_settings.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Form, assert, assertUnreachable, useForm } from 'react-science/ui';
88
import { usePreferences } from '../../../context/PreferencesContext.js';
99
import ErrorOverlay from '../../../main/ErrorOverlay.tsx';
1010

11+
import { GeneralSettingsErrorsOpenProvider } from './errors/context.tsx';
1112
import { GeneralSettingsDialogBody } from './general_settings_dialog_body.js';
1213
import { GeneralSettingsDialogFooter } from './general_settings_dialog_footer.js';
1314
import { GeneralSettingsDialogHeader } from './general_settings_dialog_header.js';
@@ -94,26 +95,30 @@ function GeneralSettings(props: GeneralSettingsProps) {
9495
});
9596

9697
return (
97-
<Form
98-
layout="inline"
99-
noValidate
100-
onSubmit={(event) => {
101-
event.preventDefault();
102-
103-
const nativeEvent = event.nativeEvent as SubmitEvent;
104-
const submitter = nativeEvent.submitter as HTMLButtonElement | null;
105-
assert(submitter, 'form event should have a submitter');
106-
const action = submitter.dataset.action;
107-
108-
void form.handleSubmit(action as FormMeta);
109-
}}
110-
>
111-
<PreventImplicitSubmit />
112-
113-
<GeneralSettingsDialogHeader form={form} />
114-
<GeneralSettingsDialogBody form={form} height={height} />
115-
<GeneralSettingsDialogFooter form={form} onCancel={close} />
116-
</Form>
98+
<form.AppForm>
99+
<Form
100+
layout="inline"
101+
noValidate
102+
onSubmit={(event) => {
103+
event.preventDefault();
104+
105+
const nativeEvent = event.nativeEvent as SubmitEvent;
106+
const submitter = nativeEvent.submitter as HTMLButtonElement | null;
107+
assert(submitter, 'form event should have a submitter');
108+
const action = submitter.dataset.action;
109+
110+
void form.handleSubmit(action as FormMeta);
111+
}}
112+
>
113+
<PreventImplicitSubmit />
114+
115+
<GeneralSettingsErrorsOpenProvider form={form}>
116+
<GeneralSettingsDialogHeader form={form} />
117+
<GeneralSettingsDialogBody form={form} height={height} />
118+
<GeneralSettingsDialogFooter form={form} onCancel={close} />
119+
</GeneralSettingsErrorsOpenProvider>
120+
</Form>
121+
</form.AppForm>
117122
);
118123
}
119124

src/component/modal/setting/tanstack_general_settings/general_settings_dialog_body.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { withForm } from 'react-science/ui';
55

66
import { StyledDialogBody } from '../../../elements/StyledDialogBody.js';
77

8+
import { GeneralSettingsErrorRenderer } from './errors/renderer.tsx';
89
import { AutoProcessingTab } from './tabs/auto_processing_tab.tsx';
910
import { AxisTab } from './tabs/axis_tab.tsx';
1011
import { DatabaseTab } from './tabs/database_tab.js';
@@ -17,7 +18,10 @@ import { PanelsTab } from './tabs/panels_tab.js';
1718
import { SpectraColorsTab } from './tabs/spectra_colors_tab.tsx';
1819
import { TitleBlockTab } from './tabs/title_block_tab.js';
1920
import { ToolsTab } from './tabs/tools_tab.tsx';
20-
import { defaultGeneralSettingsFormValues } from './validation.js';
21+
import {
22+
defaultGeneralSettingsFormValues,
23+
workspaceValidation,
24+
} from './validation.js';
2125

2226
const Tabs = styled(BPTabs)`
2327
height: 100%;
@@ -44,6 +48,7 @@ const Div = styled.div<{ height?: number }>`
4448

4549
export const GeneralSettingsDialogBody = withForm({
4650
defaultValues: defaultGeneralSettingsFormValues,
51+
validators: { onDynamic: workspaceValidation },
4752
props: {
4853
height: undefined as number | undefined,
4954
},
@@ -113,6 +118,8 @@ export const GeneralSettingsDialogBody = withForm({
113118
<Tab id="export" title="Export" panel={<ExportTab form={form} />} />
114119
</Tabs>
115120
</Div>
121+
122+
<GeneralSettingsErrorRenderer form={form} />
116123
</StyledDialogBody>
117124
);
118125
},

src/component/modal/setting/tanstack_general_settings/general_settings_dialog_footer.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
import { DialogFooter } from '@blueprintjs/core';
2-
import styled from '@emotion/styled';
32
import { Button, withForm } from 'react-science/ui';
43

54
import { defaultGeneralSettingsFormValues } from './validation.js';
65

7-
const Footer = styled.div`
8-
display: flex;
9-
justify-content: flex-end;
10-
gap: 10px;
11-
`;
12-
136
export const GeneralSettingsDialogFooter = withForm({
147
props: {
158
onCancel: () => {
@@ -20,19 +13,21 @@ export const GeneralSettingsDialogFooter = withForm({
2013
render: ({ form, onCancel }) => {
2114
return (
2215
<form.AppForm>
23-
<DialogFooter>
24-
<Footer>
25-
<Button variant="outlined" intent="danger" onClick={onCancel}>
26-
Cancel
27-
</Button>
28-
<form.SubmitButton intent="success" data-action="save">
29-
Apply and Save
30-
</form.SubmitButton>
31-
<form.SubmitButton intent="primary" data-action="apply">
32-
Apply
33-
</form.SubmitButton>
34-
</Footer>
35-
</DialogFooter>
16+
<DialogFooter
17+
actions={
18+
<>
19+
<Button variant="outlined" intent="danger" onClick={onCancel}>
20+
Cancel
21+
</Button>
22+
<form.SubmitButton intent="success" data-action="save">
23+
Apply and Save
24+
</form.SubmitButton>
25+
<form.SubmitButton intent="primary" data-action="apply">
26+
Apply
27+
</form.SubmitButton>
28+
</>
29+
}
30+
/>
3631
</form.AppForm>
3732
);
3833
},

src/component/modal/setting/tanstack_general_settings/general_settings_dialog_header.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Classes } from '@blueprintjs/core';
22
import styled from '@emotion/styled';
33
import { useStore } from '@tanstack/react-form';
44
import { useMemo } from 'react';
5-
import { withForm } from 'react-science/ui';
5+
import { Button, withForm } from 'react-science/ui';
66

77
import type { ExtendedWorkspace } from '../../../context/PreferencesContext.js';
88
import {
@@ -16,6 +16,7 @@ import { useWorkspaceAction } from '../../../hooks/useWorkspaceAction.js';
1616
import { workspaceDefaultProperties } from '../../../workspaces/workspaceDefaultProperties.ts';
1717
import WorkspaceItem from '../WorkspaceItem.js';
1818

19+
import { useErrors } from './errors/context.tsx';
1920
import { GeneralSettingsDialogHeaderActionsButtons } from './general_settings_dialog_header_actions_buttons.js';
2021
import {
2122
formValueToWorkspace,
@@ -90,16 +91,48 @@ export const GeneralSettingsDialogHeader = withForm({
9091
onSelect={handleChangeWorkspace}
9192
/>
9293
</Label>
93-
9494
<GeneralSettingsDialogHeaderActionsButtons form={form} />
95+
96+
<FlexSeparator />
97+
98+
<ErrorsIndicator />
9599
</DialogHeader>
96100
);
97101
},
98102
});
99103

104+
function ErrorsIndicator() {
105+
const { setIsOpen, count } = useErrors();
106+
107+
if (count === 0) return null;
108+
109+
return (
110+
<ErrorButton
111+
variant="outlined"
112+
intent="danger"
113+
size="small"
114+
tooltipProps={{
115+
content: `There is ${count} errors in the form. Click to show them.`,
116+
}}
117+
onClick={() => setIsOpen(true)}
118+
>
119+
{count}
120+
</ErrorButton>
121+
);
122+
}
123+
100124
const DialogHeader = styled.div`
101125
cursor: default;
102126
padding-top: 10px;
103127
box-shadow: none;
104128
background-color: #f8f8f8;
105129
`;
130+
131+
const FlexSeparator = styled.div`
132+
flex: 1;
133+
`;
134+
135+
const ErrorButton = styled(Button)`
136+
border-radius: 100%;
137+
font-size: 0.75em;
138+
`;

0 commit comments

Comments
 (0)