Skip to content

Commit cf1cb06

Browse files
feat: add nuclei tab on general settings (#3978)
1 parent d3dba78 commit cf1cb06

4 files changed

Lines changed: 341 additions & 0 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { StyledDialogBody } from '../../../elements/StyledDialogBody.tsx';
66

77
import { ExportTab } from './tabs/export_tab.tsx';
88
import { GeneralTab } from './tabs/general_tab.tsx';
9+
import { NucleiTab } from './tabs/nuclei_tab.tsx';
910
import { defaultGeneralSettingsFormValues } from './validation.ts';
1011

1112
const Tabs = styled(BPTabs)`
@@ -43,6 +44,8 @@ export const GeneralSettingsDialogBody = withForm({
4344
panel={<GeneralTab form={form} />}
4445
/>
4546

47+
<Tab title="Nuclei" id="nuclei" panel={<NucleiTab form={form} />} />
48+
4649
<Tab id="export" title="Export" panel={<ExportTab form={form} />} />
4750
</Tabs>
4851
</Div>
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import { Classes, NumericInput } from '@blueprintjs/core';
2+
import styled from '@emotion/styled';
3+
import { useStore } from '@tanstack/react-form';
4+
import { useCallback, useMemo } from 'react';
5+
import { FaPlus, FaRegTrashAlt } from 'react-icons/fa';
6+
import { Button, withForm } from 'react-science/ui';
7+
import type { CellProps } from 'react-table';
8+
9+
import { GroupPane } from '../../../../elements/GroupPane.tsx';
10+
import { Input2 } from '../../../../elements/Input2.tsx';
11+
import type { Column } from '../../../../elements/ReactTable/ReactTable.tsx';
12+
import ReactTable from '../../../../elements/ReactTable/ReactTable.tsx';
13+
import { Section } from '../../general_settings.tsx';
14+
import { defaultGeneralSettingsFormValues } from '../validation.ts';
15+
16+
interface NucleiFormElement {
17+
nucleus: string;
18+
ppmFormat: string;
19+
hzFormat: string;
20+
}
21+
22+
const emptyNucleiFormElement: NucleiFormElement = {
23+
nucleus: '',
24+
ppmFormat: '0.00',
25+
hzFormat: '0.00',
26+
};
27+
28+
export const NucleiTab = withForm({
29+
defaultValues: defaultGeneralSettingsFormValues,
30+
render: function Render({ form }) {
31+
const fields = useStore(form.store, (state) => state.values.nuclei);
32+
33+
const handleAdd = useCallback(
34+
(data: readonly NucleiFormElement[], index: number) => {
35+
let columns: NucleiFormElement[] = [];
36+
37+
if (data) {
38+
columns = [
39+
...data.slice(0, index),
40+
emptyNucleiFormElement,
41+
...data.slice(index),
42+
];
43+
} else {
44+
columns.push(emptyNucleiFormElement);
45+
}
46+
47+
form.setFieldValue('nuclei', columns);
48+
},
49+
[form],
50+
);
51+
52+
const handleDelete = useCallback(
53+
(data: readonly NucleiFormElement[], formElement: NucleiFormElement) => {
54+
const fields = data.filter((element) => {
55+
return element.nucleus !== formElement.nucleus;
56+
});
57+
58+
form.setFieldValue('nuclei', fields);
59+
},
60+
[form],
61+
);
62+
63+
const COLUMNS = useMemo<Array<Column<NucleiFormElement>>>(
64+
() => [
65+
{
66+
Header: 'Nucleus',
67+
style: { padding: 0 },
68+
Cell: ({ row: { index } }: CellProps<NucleiFormElement>) => {
69+
return (
70+
<form.Field key={index} name={`nuclei[${index}].nucleus`}>
71+
{(subField) => (
72+
<Input2
73+
style={{
74+
backgroundColor: 'transparent',
75+
boxShadow: 'none',
76+
}}
77+
value={subField.state.value}
78+
onChange={subField.handleChange}
79+
/>
80+
)}
81+
</form.Field>
82+
);
83+
},
84+
},
85+
{
86+
Header: 'δ (ppm)',
87+
style: { padding: 0 },
88+
Cell: ({ row: { index } }: CellProps<NucleiFormElement>) => {
89+
return (
90+
<form.Field key={index} name={`nuclei[${index}].ppmFormat`}>
91+
{(subField) => (
92+
<Input2
93+
style={{
94+
backgroundColor: 'transparent',
95+
boxShadow: 'none',
96+
}}
97+
value={subField.state.value}
98+
onChange={subField.handleChange}
99+
/>
100+
)}
101+
</form.Field>
102+
);
103+
},
104+
},
105+
{
106+
Header: 'Coupling (Hz)',
107+
style: { padding: 0 },
108+
Cell: ({ row: { index } }: CellProps<NucleiFormElement>) => {
109+
return (
110+
<form.Field key={index} name={`nuclei[${index}].hzFormat`}>
111+
{(subField) => (
112+
<Input2
113+
style={{
114+
backgroundColor: 'transparent',
115+
boxShadow: 'none',
116+
}}
117+
value={subField.state.value}
118+
onChange={subField.handleChange}
119+
/>
120+
)}
121+
</form.Field>
122+
);
123+
},
124+
},
125+
{
126+
Header: 'Axis from',
127+
style: { padding: 0 },
128+
Cell: ({ row: { index } }: CellProps<NucleiFormElement>) => {
129+
return (
130+
<form.Field key={index} name={`nuclei[${index}].axisFrom`}>
131+
{(subField) => (
132+
<NumericInput
133+
style={{
134+
backgroundColor: 'transparent',
135+
boxShadow: 'none',
136+
}}
137+
value={Number(subField.state.value) || undefined}
138+
fill
139+
onValueChange={(valueAsNumber) =>
140+
subField.handleChange(valueAsNumber)
141+
}
142+
/>
143+
)}
144+
</form.Field>
145+
);
146+
},
147+
},
148+
{
149+
Header: 'Axis to',
150+
style: { padding: 0 },
151+
Cell: ({ row: { index } }: CellProps<NucleiFormElement>) => {
152+
return (
153+
<form.Field key={index} name={`nuclei[${index}].axisTo`}>
154+
{(subField) => (
155+
<NumericInput
156+
style={{
157+
backgroundColor: 'transparent',
158+
boxShadow: 'none',
159+
}}
160+
value={Number(subField.state.value) || undefined}
161+
fill
162+
onValueChange={(valueAsNumber) =>
163+
subField.handleChange(valueAsNumber)
164+
}
165+
/>
166+
)}
167+
</form.Field>
168+
);
169+
},
170+
},
171+
{
172+
Header: '',
173+
style: {
174+
width: 60,
175+
},
176+
id: 'op-buttons',
177+
Cell: ({
178+
row: { original, index: rowIndex },
179+
data,
180+
}: CellProps<NucleiFormElement>) => {
181+
return (
182+
<Buttons>
183+
<Button
184+
size="small"
185+
variant="outlined"
186+
intent="success"
187+
onClick={() => handleAdd(data, rowIndex + 1)}
188+
>
189+
<FaPlus className={Classes.ICON} />
190+
</Button>
191+
<Button
192+
size="small"
193+
variant="outlined"
194+
intent="danger"
195+
onClick={() => handleDelete(data, original)}
196+
>
197+
<FaRegTrashAlt className={Classes.ICON} />
198+
</Button>
199+
</Buttons>
200+
);
201+
},
202+
},
203+
],
204+
[form, handleAdd, handleDelete],
205+
);
206+
207+
return (
208+
<GroupPane
209+
text="Number formatting for crosshair and info line and axis domain"
210+
renderHeader={(text) => {
211+
return (
212+
<FieldsBlockHeader text={text} onAdd={() => handleAdd(fields, 0)} />
213+
);
214+
}}
215+
>
216+
<ReactTable
217+
style={{
218+
'thead tr th': { zIndex: 1 },
219+
td: { padding: 0 },
220+
}}
221+
rowStyle={{
222+
hover: { backgroundColor: '#f7f7f7' },
223+
active: { backgroundColor: '#f5f5f5' },
224+
}}
225+
data={fields}
226+
columns={COLUMNS}
227+
emptyDataRowText="No Nucleus"
228+
/>
229+
</GroupPane>
230+
);
231+
},
232+
});
233+
234+
interface FieldsBlockHeaderProps {
235+
onAdd: () => void;
236+
text: string;
237+
}
238+
239+
function FieldsBlockHeader(props: FieldsBlockHeaderProps) {
240+
const { onAdd, text } = props;
241+
return (
242+
<Section>
243+
<p style={{ flex: 1 }}>{text}</p>
244+
245+
<Button
246+
size="small"
247+
variant="outlined"
248+
intent="success"
249+
tooltipProps={{ content: '', disabled: true }}
250+
onClick={onAdd}
251+
>
252+
Add nuclei preferences
253+
</Button>
254+
</Section>
255+
);
256+
}
257+
258+
const Buttons = styled.div`
259+
display: flex;
260+
justify-content: space-evenly;
261+
`;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { RefinementCtx } from 'zod';
2+
3+
interface CheckUniqueByKeyOptions<T> {
4+
data?: T[];
5+
checkKey: keyof T;
6+
basePath?: string;
7+
context: RefinementCtx;
8+
}
9+
10+
export function checkUniqueByKey<T>(options: CheckUniqueByKeyOptions<T>) {
11+
const { data, checkKey, context, basePath = '' } = options;
12+
13+
if (!data) return true;
14+
15+
const matchesFrequencies: Record<
16+
string,
17+
{ value: number; fieldsIndexes: number[] }
18+
> = {};
19+
let index = 0;
20+
for (const datum of data) {
21+
let key;
22+
const matchValue = datum[checkKey];
23+
24+
if (typeof matchValue === 'string') {
25+
key = matchValue.toLowerCase();
26+
}
27+
28+
if (key) {
29+
if (matchesFrequencies[key]) {
30+
++matchesFrequencies[key].value;
31+
matchesFrequencies[key].fieldsIndexes.push(index);
32+
} else {
33+
matchesFrequencies[key] = { value: 1, fieldsIndexes: [index] };
34+
}
35+
}
36+
index++;
37+
}
38+
39+
for (const key in matchesFrequencies) {
40+
const { value, fieldsIndexes } = matchesFrequencies[key];
41+
if (value > 1) {
42+
for (const index of fieldsIndexes) {
43+
context.addIssue({
44+
code: 'custom',
45+
message: `${key} must be unique`,
46+
path: basePath
47+
? [basePath, index, String(checkKey)]
48+
: [index, String(checkKey)],
49+
});
50+
}
51+
}
52+
}
53+
54+
return true;
55+
}

src/component/modal/setting/tanstack_general_settings/validation.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { z } from 'zod/v4';
55
import type { LoggerType } from '../../../context/LoggerContext.tsx';
66
import { workspaceDefaultProperties } from '../../../workspaces/workspaceDefaultProperties.ts';
77

8+
import { checkUniqueByKey } from './utils/checkUniqueByKey.ts';
9+
810
const loggingLevel: LoggerType[] = [
911
'fatal',
1012
'error',
@@ -102,7 +104,26 @@ const exportPreferencesValidation = z.object({
102104
clipboard: exportSettingsValidation,
103105
});
104106

107+
const nucleiValidation = z
108+
.array(
109+
z.object({
110+
nucleus: z.string({ error: 'Nucleus is a required field' }),
111+
ppmFormat: z.string({ error: 'PPM format is a required field' }),
112+
hzFormat: z.string({ error: 'Hz format is a required field' }),
113+
axisFrom: z.coerce.number().optional(),
114+
axisTo: z.coerce.number().optional(),
115+
}),
116+
)
117+
.superRefine((nuclei, ctx) => {
118+
checkUniqueByKey({
119+
data: nuclei,
120+
checkKey: 'nucleus',
121+
context: ctx,
122+
});
123+
});
124+
105125
export const workspaceValidation = z.object({
126+
nuclei: nucleiValidation,
106127
peaksLabel: peaksLabelValidation,
107128
general: generalValidation,
108129
display: displayValidation,
@@ -113,6 +134,7 @@ export const workspaceValidation = z.object({
113134
export const defaultGeneralSettingsFormValues: z.input<
114135
typeof workspaceValidation
115136
> = {
137+
nuclei: [{ nucleus: '', ppmFormat: '', hzFormat: '' }],
116138
peaksLabel: {
117139
marginTop: 0,
118140
},

0 commit comments

Comments
 (0)