Skip to content

Commit 1ae6aed

Browse files
authored
Validate custom column names (#1013)
1 parent 8a8d04e commit 1ae6aed

6 files changed

Lines changed: 412 additions & 117 deletions

File tree

packages/iris-grid/src/IrisGrid.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2979,7 +2979,11 @@ export class IrisGrid extends Component<IrisGridProps, IrisGridState> {
29792979
}
29802980

29812981
this.setState({ customColumns });
2982-
this.startLoading('Applying custom columns...');
2982+
if (customColumns.length > 0) {
2983+
// If there are no custom columns, the change handler never fires
2984+
// This causes the loader to stay until canceled by the user
2985+
this.startLoading('Applying custom columns...');
2986+
}
29832987
}
29842988

29852989
handleCustomColumnsChanged(): void {
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { EventShimCustomEvent } from '@deephaven/utils';
5+
import CustomColumnBuilder, {
6+
CustomColumnBuilderProps,
7+
} from './CustomColumnBuilder';
8+
import IrisGridTestUtils from '../IrisGridTestUtils';
9+
import IrisGridModel from '../IrisGridModel';
10+
11+
function Builder({
12+
model = IrisGridTestUtils.makeModel(),
13+
customColumns = [],
14+
onSave = jest.fn(),
15+
onCancel = jest.fn(),
16+
}: Partial<CustomColumnBuilderProps>) {
17+
return (
18+
<CustomColumnBuilder
19+
model={model}
20+
customColumns={customColumns}
21+
onSave={onSave}
22+
onCancel={onCancel}
23+
/>
24+
);
25+
}
26+
27+
test('Renders the default state', async () => {
28+
render(<Builder />);
29+
expect(screen.getByPlaceholderText('Column Name')).toBeInTheDocument();
30+
expect(screen.getByText('Column Formula')).toBeInTheDocument();
31+
});
32+
33+
test('Calls on save', async () => {
34+
const user = userEvent.setup();
35+
const customColumns = ['abc=def', 'foo=bar'];
36+
const mockSave = jest.fn();
37+
render(<Builder onSave={mockSave} customColumns={customColumns} />);
38+
39+
await user.type(screen.getByDisplayValue('abc'), 'cba');
40+
await user.click(screen.getByText(/Save/));
41+
expect(mockSave).toHaveBeenLastCalledWith(['abccba=def', 'foo=bar']);
42+
});
43+
44+
test('Switches to loader button while saving', async () => {
45+
jest.useFakeTimers();
46+
const user = userEvent.setup({ delay: null });
47+
const model = IrisGridTestUtils.makeModel();
48+
const mockSave = jest.fn(() =>
49+
setTimeout(() => {
50+
model.dispatchEvent(
51+
new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED)
52+
);
53+
}, 50)
54+
);
55+
56+
render(
57+
<Builder model={model} onSave={mockSave} customColumns={['foo=bar']} />
58+
);
59+
60+
await user.click(screen.getByText(/Save/));
61+
expect(screen.getByText('Applying')).toBeInTheDocument();
62+
jest.advanceTimersByTime(50);
63+
expect(screen.getByText('Success')).toBeInTheDocument();
64+
jest.advanceTimersByTime(CustomColumnBuilder.SUCCESS_SHOW_DURATION);
65+
expect(screen.getByText(/Save/)).toBeInTheDocument();
66+
67+
// Component should ignore this event and not change the save button
68+
model.dispatchEvent(
69+
new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED)
70+
);
71+
expect(screen.getByText(/Save/)).toBeInTheDocument();
72+
jest.useRealTimers();
73+
});
74+
75+
test('Adds a column', async () => {
76+
const user = userEvent.setup();
77+
render(<Builder />);
78+
79+
await user.click(screen.getByText('Add Another Column'));
80+
expect(screen.getAllByPlaceholderText('Column Name').length).toBe(2);
81+
expect(screen.getAllByText('Column Formula').length).toBe(2);
82+
});
83+
84+
test('Ignores empty names or formulas on save', async () => {
85+
const user = userEvent.setup();
86+
const customColumns = ['foo=bar'];
87+
const mockSave = jest.fn();
88+
render(<Builder customColumns={customColumns} onSave={mockSave} />);
89+
90+
await user.click(screen.getByText('Add Another Column'));
91+
await user.type(screen.getAllByPlaceholderText('Column Name')[1], 'test');
92+
await user.click(screen.getByText(/Save/));
93+
expect(mockSave).toBeCalledWith(customColumns);
94+
});
95+
96+
test('Ignores deleted formulas on save', async () => {
97+
// There is an issue with populating the custom columns and then editing the existing column
98+
// RTL/monaco aren't playing nicely and it won't edit the existing text
99+
// This test instead creates the new text, saves, then removes it to test the same behavior
100+
jest.useFakeTimers();
101+
const user = userEvent.setup({ delay: null });
102+
const model = IrisGridTestUtils.makeModel();
103+
const mockSave = jest.fn(() =>
104+
setTimeout(() => {
105+
model.dispatchEvent(
106+
new EventShimCustomEvent(IrisGridModel.EVENT.COLUMNS_CHANGED)
107+
);
108+
}, 50)
109+
);
110+
111+
const { container } = render(<Builder model={model} onSave={mockSave} />);
112+
113+
await user.type(screen.getByPlaceholderText('Column Name'), 'foo');
114+
await user.click(container.querySelectorAll('.input-editor-wrapper')[0]);
115+
await user.keyboard('bar');
116+
117+
await user.click(screen.getByText(/Save/));
118+
jest.advanceTimersByTime(50); // Applying duration
119+
jest.advanceTimersByTime(CustomColumnBuilder.SUCCESS_SHOW_DURATION);
120+
121+
expect(mockSave).toBeCalledWith(['foo=bar']);
122+
123+
mockSave.mockClear();
124+
125+
await user.click(container.querySelectorAll('.input-editor-wrapper')[0]);
126+
await user.keyboard('{Control>}a{/Control}{Backspace}');
127+
await user.click(screen.getByText(/Save/));
128+
expect(mockSave).toBeCalledWith([]);
129+
130+
jest.useRealTimers();
131+
});
132+
133+
test('Deletes columns', async () => {
134+
const user = userEvent.setup();
135+
const customColumns = ['abc=def', 'foo=bar'];
136+
render(<Builder customColumns={customColumns} />);
137+
138+
await user.click(screen.getAllByLabelText(/Delete/)[0]);
139+
expect(screen.queryByDisplayValue('abc')).toBeNull();
140+
expect(screen.queryByDisplayValue('def')).toBeNull();
141+
expect(screen.getByDisplayValue('foo')).toBeInTheDocument();
142+
expect(screen.getByDisplayValue('bar')).toBeInTheDocument();
143+
144+
await user.click(screen.getByLabelText(/Delete/));
145+
expect(screen.queryByDisplayValue('foo')).toBeNull();
146+
expect(screen.queryByDisplayValue('bar')).toBeNull();
147+
expect(screen.getByPlaceholderText('Column Name')).toBeInTheDocument();
148+
expect(screen.getByText('Column Formula')).toBeInTheDocument();
149+
});
150+
151+
test('Displays request failure message', async () => {
152+
const user = userEvent.setup();
153+
const model = IrisGridTestUtils.makeModel();
154+
render(<Builder model={model} customColumns={['foo=bar']} />);
155+
156+
// Should ignore this since not in saving state
157+
model.dispatchEvent(
158+
new EventShimCustomEvent(IrisGridModel.EVENT.REQUEST_FAILED, {
159+
detail: { errorMessage: 'Error message' },
160+
})
161+
);
162+
expect(screen.queryByText(/Error message/)).toBeNull();
163+
164+
await user.click(screen.getByText(/Save/));
165+
model.dispatchEvent(
166+
new EventShimCustomEvent(IrisGridModel.EVENT.REQUEST_FAILED, {
167+
detail: { errorMessage: 'Error message' },
168+
})
169+
);
170+
171+
expect(screen.getByText(/Error message/)).toBeInTheDocument();
172+
173+
const input = screen.getByDisplayValue('foo');
174+
await user.click(input);
175+
expect(input).not.toHaveClass('is-invalid');
176+
});
177+
178+
test('Handles focus changes via keyboard', async () => {
179+
const user = userEvent.setup();
180+
const { container } = render(
181+
<Builder customColumns={['abc=bar', 'foo=bar']} />
182+
);
183+
184+
const nameInputs = screen.getAllByPlaceholderText('Column Name');
185+
const formulaInputs = container.querySelectorAll(
186+
'.input-editor-wrapper textarea'
187+
);
188+
const deleteButtons = screen.getAllByLabelText(/Delete/);
189+
const dragHandles = screen.getAllByLabelText(/Drag/);
190+
await user.click(nameInputs[0]);
191+
192+
await user.keyboard('{Tab}');
193+
expect(deleteButtons[0]).toHaveFocus();
194+
await user.keyboard('{Tab}');
195+
expect(dragHandles[0]).toHaveFocus();
196+
await user.keyboard('{Tab}');
197+
expect(formulaInputs[0]).toHaveFocus();
198+
199+
await user.keyboard('{Tab}');
200+
expect(nameInputs[1]).toHaveFocus();
201+
await user.keyboard('{Tab}');
202+
expect(deleteButtons[1]).toHaveFocus();
203+
await user.keyboard('{Tab}');
204+
expect(dragHandles[1]).toHaveFocus();
205+
await user.keyboard('{Tab}');
206+
expect(formulaInputs[1]).toHaveFocus();
207+
208+
await user.keyboard('{Tab}');
209+
expect(screen.getByText('Add Another Column')).toHaveFocus();
210+
211+
await user.keyboard('{Shift>}{Tab}{/Shift}');
212+
expect(formulaInputs[1]).toHaveFocus();
213+
await user.keyboard('{Shift>}{Tab}{/Shift}');
214+
expect(dragHandles[1]).toHaveFocus();
215+
});

packages/iris-grid/src/sidebar/CustomColumnBuilder.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
66
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
77
import { Button, DragUtils, LoadingSpinner } from '@deephaven/components';
88
import { dhNewCircleLargeFilled, vsWarning, vsPass } from '@deephaven/icons';
9+
import { DbNameValidator } from '@deephaven/utils';
910
import CustomColumnInput from './CustomColumnInput';
1011
import './CustomColumnBuilder.scss';
1112
import IrisGridModel from '../IrisGridModel';
@@ -17,7 +18,7 @@ type Input = {
1718
name: string;
1819
formula: string;
1920
};
20-
interface CustomColumnBuilderProps {
21+
export interface CustomColumnBuilderProps {
2122
model: IrisGridModel;
2223
customColumns: string[];
2324
onSave: (columns: string[]) => void;
@@ -37,12 +38,6 @@ class CustomColumnBuilder extends Component<
3738
> {
3839
static SUCCESS_SHOW_DURATION = 750;
3940

40-
static defaultProps = {
41-
customColumns: [],
42-
onSave: (): void => undefined,
43-
onCancel: (): void => undefined,
44-
};
45-
4641
static makeCustomColumnInputEventKey(): string {
4742
return shortid.generate();
4843
}
@@ -265,15 +260,15 @@ class CustomColumnBuilder extends Component<
265260
const { inputs } = this.state;
266261
// focus on drag handle
267262
if (shiftKey) {
268-
(this.container?.querySelectorAll(`.btn-drag-handle`)[
263+
(this.container?.querySelectorAll('.btn-drag-handle')[
269264
focusEditorIndex
270265
] as HTMLButtonElement).focus();
271266
return;
272267
}
273268
if (focusEditorIndex === inputs.length - 1) {
274-
(this.container?.querySelectorAll(`.btn-add-column`)[
275-
focusEditorIndex
276-
] as HTMLButtonElement).focus();
269+
(this.container?.querySelector(
270+
'.btn-add-column'
271+
) as HTMLButtonElement)?.focus();
277272
} else {
278273
// focus on next column name input
279274
const nextFocusIndex = focusEditorIndex + 1;
@@ -284,7 +279,7 @@ class CustomColumnBuilder extends Component<
284279
}
285280

286281
handleSaveClick(): void {
287-
const { onSave } = this.props;
282+
const { onSave, customColumns: originalCustomColumns } = this.props;
288283
const { inputs, isCustomColumnApplying } = this.state;
289284
if (isCustomColumnApplying) {
290285
return;
@@ -297,7 +292,11 @@ class CustomColumnBuilder extends Component<
297292
}
298293
});
299294
this.resetErrorMessage();
300-
this.setState({ isCustomColumnApplying: true });
295+
this.setState({
296+
// If both are 0, then moving from no custom to no custom. The parent won't re-render to cancel the loading state
297+
isCustomColumnApplying:
298+
customColumns.length > 0 || originalCustomColumns.length > 0,
299+
});
301300
onSave(customColumns);
302301
}
303302

@@ -313,8 +312,14 @@ class CustomColumnBuilder extends Component<
313312
renderInputs(): ReactElement[] {
314313
const { inputs, hasRequestFailed } = this.state;
315314

315+
const nameCount = new Map();
316+
inputs.forEach(({ name }) =>
317+
nameCount.set(name, (nameCount.get(name) ?? 0) + 1)
318+
);
319+
316320
return inputs.map((input, index) => {
317321
const { eventKey, name, formula } = input;
322+
const isDuplicate = (nameCount.get(name) ?? 0) > 1;
318323
return (
319324
<CustomColumnInput
320325
key={eventKey}
@@ -326,6 +331,7 @@ class CustomColumnBuilder extends Component<
326331
onDeleteColumn={this.handleDeleteColumn}
327332
onTabInEditor={this.handleEditorTabNavigation}
328333
invalid={hasRequestFailed}
334+
isDuplicate={isDuplicate}
329335
/>
330336
);
331337
});
@@ -334,21 +340,32 @@ class CustomColumnBuilder extends Component<
334340
renderSaveButton(): ReactElement {
335341
const { inputs, isCustomColumnApplying, isSuccessShowing } = this.state;
336342
const saveText = inputs.length > 1 ? 'Save Columns' : 'Save Column';
343+
const areNamesValid = inputs.every(
344+
({ name }) => name === '' || DbNameValidator.isValidColumnName(name)
345+
);
346+
const filteredNames = inputs
347+
.filter(({ name }) => name !== '')
348+
.map(({ name }) => name);
349+
const areNamesUnique = new Set(filteredNames).size === filteredNames.length;
337350

338351
return (
339352
<Button
340353
kind={isSuccessShowing ? 'success' : 'primary'}
341354
className={classNames('btn-apply', {
342355
'btn-spinner': isCustomColumnApplying,
343356
})}
344-
disabled={isSuccessShowing || isCustomColumnApplying}
357+
disabled={
358+
isSuccessShowing ||
359+
isCustomColumnApplying ||
360+
!areNamesValid ||
361+
!areNamesUnique
362+
}
345363
onClick={this.handleSaveClick}
346364
>
347365
{isCustomColumnApplying && (
348366
<span>
349367
<LoadingSpinner />
350368
<span className="btn-normal-content">Applying</span>
351-
<span className="btn-hover-content">Applying</span>
352369
</span>
353370
)}
354371
{!isSuccessShowing && !isCustomColumnApplying && saveText}
@@ -361,7 +378,7 @@ class CustomColumnBuilder extends Component<
361378
);
362379
}
363380

364-
render(): ReactElement {
381+
render(): JSX.Element {
365382
const { onCancel } = this.props;
366383
const { errorMessage } = this.state;
367384
return (

0 commit comments

Comments
 (0)