Skip to content

Commit 22789c3

Browse files
feat: Explicit buckets- update measurement schemas (#2836)
Closes #2509 Closes #2918 adds ability to update existing measurement schemas * lets you undo the update * uses same exact column validation as when the measurement schema is created; still doesn't check for everything but does a first pass * notifications are shown upon success or failure * validates it; 'save changes' is active but shows errors after pressing it if invalid (that way, errors don't show immediately when the user adds a new, empty line when adding a new schema) * truncates schema names that are too long, with a 'title' tooltip of the full name Co-authored-by: Chitlange Sahas <chitlangesahas@gmail.com>
1 parent 2e6c91c commit 22789c3

File tree

18 files changed

+824
-108
lines changed

18 files changed

+824
-108
lines changed

cypress/e2e/cloud/buckets.test.ts

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const setupData = (cy: Cypress.Chainable, enableMeasurementSchema = false) =>
1818
describe('Explicit Buckets', () => {
1919
beforeEach(() => {
2020
setupData(cy, true)
21+
22+
// remove the downloaded files
23+
cy.exec('rm cypress/downloads/*', {
24+
log: true,
25+
failOnNonZeroExit: false,
26+
})
2127
})
2228

2329
it('can create a bucket with an explicit schema', () => {
@@ -139,6 +145,8 @@ describe('Explicit Buckets', () => {
139145

140146
cy.getByTestID('bucket-form-submit').click()
141147

148+
// give it some time for the submit to happen/the bucket list to show up
149+
cy.wait(500)
142150
cy.getByTestID(`bucket-card explicit_bucket`)
143151
.should('exist')
144152
.within(() => {
@@ -150,7 +158,7 @@ describe('Explicit Buckets', () => {
150158
.should('exist')
151159
.within(() => {
152160
cy.getByTestID('measurement-schema-name-0')
153-
.contains('first schema file')
161+
.contains('first schem...')
154162
.should('exist')
155163
cy.getByTestID('measurement-schema-download-button').click()
156164
cy.readFile(`cypress/downloads/first_schema_file.json`)
@@ -213,7 +221,7 @@ describe('Explicit Buckets', () => {
213221
.should('exist')
214222
.within(() => {
215223
cy.getByTestID('measurement-schema-name-0')
216-
.contains('first schema file')
224+
.contains('first schem...')
217225
.should('exist')
218226

219227
cy.getByTestID('measurement-schema-download-button').click()
@@ -229,6 +237,164 @@ describe('Explicit Buckets', () => {
229237
})
230238
})
231239
})
240+
241+
it('should be able to create an explicit bucket and update the existing schema file during editing', function() {
242+
cy.getByTestID('Create Bucket').click()
243+
cy.getByTestID('bucket-form-name').type('explicit_bucket')
244+
cy.getByTestID('accordion-header').click()
245+
cy.getByTestID('explicit-bucket-schema-choice-ID').click()
246+
247+
cy.getByTestID('bucket-form-submit').click()
248+
249+
cy.getByTestID(`bucket-card explicit_bucket`)
250+
.should('exist')
251+
.within(() => {
252+
cy.getByTestID('bucket-settings').click({force: true})
253+
})
254+
cy.getByTestID('accordion-header').click()
255+
const schemaName = 'one schema'
256+
const fileName = 'one_schema.json'
257+
258+
cy.getByTestID('measurement-schema-add-file-button').click()
259+
cy.getByTestID('input-field').type(schemaName)
260+
261+
const schemaFile = 'valid.json'
262+
const type = 'application/json'
263+
const testFile = new File(
264+
[
265+
`[{"name":"time","type":"timestamp"},
266+
{"name":"fsWrite","type":"field","dataType":"float"} ]`,
267+
],
268+
schemaFile,
269+
{type}
270+
)
271+
272+
const event = {dataTransfer: {files: [testFile]}, force: true}
273+
cy.getByTestID('dndContainer')
274+
.trigger('dragover', event)
275+
.trigger('drop', event)
276+
277+
cy.getByTestID('bucket-form-submit').click()
278+
279+
cy.getByTestID(`bucket-card explicit_bucket`)
280+
.should('exist')
281+
.within(() => {
282+
cy.getByTestID('bucket-settings').click({force: true})
283+
})
284+
cy.getByTestID('accordion-header').click()
285+
286+
cy.getByTestID('measurement-schema-readOnly-panel-0')
287+
.should('exist')
288+
.within(() => {
289+
cy.getByTestID('measurement-schema-name-0')
290+
.should('exist')
291+
.contains(schemaName)
292+
.should('exist')
293+
294+
cy.getByTestID('measurement-schema-download-button').click()
295+
cy.readFile(`cypress/downloads/${fileName}`)
296+
.should('exist')
297+
.then(fileContent => {
298+
expect(fileContent[0].name).to.be.equal('time')
299+
expect(fileContent[0].type).to.be.equal('timestamp')
300+
301+
expect(fileContent[1].name).to.be.equal('fsWrite')
302+
expect(fileContent[1].type).to.be.equal('field')
303+
expect(fileContent[1].dataType).to.be.equal('float')
304+
})
305+
306+
const schemaFile = 'updated_valid.json'
307+
const type = 'application/json'
308+
const validTestFile = new File(
309+
[
310+
`[{"name":"time","type":"timestamp"},
311+
{"name":"fsWrite","type":"field","dataType":"float"},
312+
{"name": "hello there", "type": "field" , "dataType": "string"}]`,
313+
],
314+
schemaFile,
315+
{type}
316+
)
317+
318+
const invalidTestFile = new File(
319+
[
320+
`[{"name":"time","type":"timestamp"},
321+
{"name":"fsWrite","type":"field","dataType":"float"},
322+
{"name": "hello there"}]`,
323+
],
324+
schemaFile,
325+
{type}
326+
)
327+
328+
// cancel button should not be showing yet
329+
cy.getByTestID('dndContainer-cancel-update').should('not.exist')
330+
331+
// use the invalid file first to test the error handling
332+
const invalidFileEvent = {
333+
dataTransfer: {files: [invalidTestFile]},
334+
force: true,
335+
}
336+
cy.getByTestID('dndContainer')
337+
.trigger('dragover', invalidFileEvent)
338+
.trigger('drop', invalidFileEvent)
339+
340+
// should show error
341+
cy.getByTestID('form--element-error').should('exist')
342+
343+
// cancel it
344+
cy.getByTestID('dndContainer-cancel-update').click()
345+
346+
// error should be gone
347+
cy.getByTestID('form--element-error').should('not.exist')
348+
349+
// add the right one
350+
const validFileEvent = {
351+
dataTransfer: {files: [validTestFile]},
352+
force: true,
353+
}
354+
cy.getByTestID('dndContainer')
355+
.trigger('dragover', validFileEvent)
356+
.trigger('drop', validFileEvent)
357+
})
358+
cy.getByTestID('bucket-form-submit').click()
359+
360+
cy.getByTestID(`bucket-card explicit_bucket`)
361+
.should('exist')
362+
.within(() => {
363+
cy.getByTestID('bucket-settings').click({force: true})
364+
})
365+
cy.getByTestID('accordion-header').click()
366+
367+
cy.getByTestID('measurement-schema-readOnly-panel-0')
368+
.should('exist')
369+
.within(() => {
370+
cy.getByTestID('measurement-schema-name-0')
371+
.should('exist')
372+
.contains(schemaName)
373+
.should('exist')
374+
375+
// remove the downloaded files
376+
cy.exec('rm cypress/downloads/*', {
377+
log: true,
378+
failOnNonZeroExit: false,
379+
})
380+
381+
cy.getByTestID('measurement-schema-download-button').click()
382+
cy.readFile(`cypress/downloads/${fileName}`)
383+
.should('exist')
384+
.then(fileContent => {
385+
expect(fileContent[0].name).to.be.equal('time')
386+
expect(fileContent[0].type).to.be.equal('timestamp')
387+
388+
expect(fileContent[1].name).to.be.equal('fsWrite')
389+
expect(fileContent[1].type).to.be.equal('field')
390+
expect(fileContent[1].dataType).to.be.equal('float')
391+
392+
expect(fileContent[2].name).to.be.equal('hello there')
393+
expect(fileContent[2].type).to.be.equal('field')
394+
expect(fileContent[2].dataType).to.be.equal('string')
395+
})
396+
})
397+
})
232398
})
233399
describe('Buckets', () => {
234400
beforeEach(() => {

src/buckets/actions/thunks.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,29 @@ import {
5454
removeBucketLabelFailed,
5555
measurementSchemaAdditionSuccessful,
5656
measurementSchemaAdditionFailed,
57+
measurementSchemaUpdateFailed,
58+
measurementSchemaUpdateSuccessful,
5759
} from 'src/shared/copy/notifications'
5860

5961
type Action = BucketAction | NotifyAction
6062

6163
let getBucketsSchemaMeasurements = null,
6264
MeasurementSchemaCreateRequest = null,
63-
postBucketsSchemaMeasurement = null
65+
MeasurementSchemaUpdateRequest = null,
66+
postBucketsSchemaMeasurement = null,
67+
patchBucketsSchemaMeasurement = null
6468

6569
if (CLOUD) {
6670
getBucketsSchemaMeasurements = require('src/client/generatedRoutes')
6771
.getBucketsSchemaMeasurements
6872
MeasurementSchemaCreateRequest = require('src/client/generatedRoutes')
6973
.MeasurementSchemaCreateRequest
74+
MeasurementSchemaUpdateRequest = require('src/client/generatedRoutes')
75+
.MeasurementSchemaUpdateRequest
7076
postBucketsSchemaMeasurement = require('src/client/generatedRoutes')
7177
.postBucketsSchemaMeasurement
78+
patchBucketsSchemaMeasurement = require('src/client/generatedRoutes')
79+
.patchBucketsSchemaMeasurement
7280
}
7381

7482
export const getBuckets = () => async (
@@ -375,6 +383,33 @@ export const addSchemaToBucket = (
375383
)
376384
}
377385

386+
export const updateMeasurementSchema = (
387+
bucketID: string,
388+
measurementID: string,
389+
measurementName: string,
390+
schema: typeof MeasurementSchemaUpdateRequest,
391+
orgID: string
392+
) => async (dispatch: Dispatch<Action>) => {
393+
const params = {
394+
bucketID,
395+
measurementID,
396+
data: schema,
397+
query: {orgID},
398+
}
399+
400+
try {
401+
const resp = await patchBucketsSchemaMeasurement(params)
402+
if (resp.status !== 200) {
403+
const msg = resp?.data?.message
404+
throw new Error(msg)
405+
}
406+
dispatch(notify(measurementSchemaUpdateSuccessful(measurementName)))
407+
} catch (error) {
408+
const message = getErrorMessage(error)
409+
dispatch(notify(measurementSchemaUpdateFailed(measurementName, message)))
410+
}
411+
}
412+
378413
const denormalizeBucket = (state: AppState, bucket: OwnBucket): GenBucket => {
379414
const labels = getLabels(state, bucket.labels)
380415
return {

src/buckets/components/context/lineProtocol.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,25 @@ export const LineProtocolProvider: FC<Props> = React.memo(({children}) => {
6969
}
7070

7171
/**
72-
* change in newest api: error 429 (too many requests) is in CLOUD and not in OSS
72+
* change in newest api (since the hash was last updated in 9/2021):
73+
* * error 429 (too many requests) is in CLOUD and not in OSS
74+
* * error 403 was removed, added error 404 (not found)
7375
*
74-
* doing the 'as any' cast away from the type because: safest way; this error code will never happen in OSS;
75-
* so the clause will never be activated, and the user still gets the proper error.
76+
* for error 429 which exists in only CLOUD:
77+
* doing the 'as any' cast away from the type because: safest way; this error code will never happen in OSS;
78+
* so the clause will never be activated, and the user still gets the proper error when in CLOUD.
7679
*
77-
* other strategies not implement here, with reasoning:
80+
* other strategies not implemented here, with reasoning:
7881
*
7982
* 1) not removing the clause and drop down to the generic error
8083
* because then the user doesn't get a good error message
8184
* 2) bad code smell: add it to oss for code purposes, knowing it will never be called
8285
* 3) can't do an IF CLOUD b/c the code just ISN'T THERE; the type (PostWriteResult) exists in both
8386
* cloud and oss and is different in each environment
87+
*
88+
* for local testing, need to check out the open api repo, make sure it is named "openapi" (the default name) and
89+
* is present in the same directory as the ui repo,
90+
* and then run "yarn generate-local" in the ui repo to generate the OSS (not the cloud) files.
8491
*/
8592
const writeLineProtocol = useCallback(
8693
async (bucket: string) => {

src/buckets/components/createBucketForm/BucketOverlayForm.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ import {RuleType} from 'src/buckets/reducers/createBucket'
2222
import {isFlagEnabled} from 'src/shared/utils/featureFlag'
2323
import {CLOUD} from 'src/shared/constants'
2424

25-
import {MeasurementSchemaSection} from 'src/buckets/components/createBucketForm/MeasurementSchemaSection'
25+
import {
26+
MeasurementSchemaSection,
27+
SchemaUpdateInfo,
28+
} from 'src/buckets/components/createBucketForm/MeasurementSchemaSection'
2629

2730
let MeasurementSchemaList = null,
2831
MeasurementSchemaCreateRequest = null,
@@ -48,10 +51,11 @@ interface Props {
4851
onChangeRetentionRule: (seconds: number) => void
4952
onChangeRuleType: (t: RuleType) => void
5053
onChangeInput: (e: ChangeEvent<HTMLInputElement>) => void
51-
onUpdateNewMeasurementSchemas?: (
54+
onAddNewMeasurementSchemas?: (
5255
schemas: any[],
5356
resetValidation?: boolean
5457
) => void
58+
onUpdateMeasurementSchemas?: (schemaInfo: SchemaUpdateInfo[]) => void
5559
isEditing: boolean
5660
buttonText: string
5761
onClickRename?: () => void
@@ -66,25 +70,32 @@ interface State {
6670
showAdvanced: boolean
6771
schemaType: 'implicit' | 'explicit'
6872
newMeasurementSchemas: typeof MeasurementSchemaCreateRequest[]
73+
measurementSchemaUpdates: SchemaUpdateInfo[]
6974
}
7075

7176
export default class BucketOverlayForm extends PureComponent<Props> {
7277
public state: State = {
7378
showAdvanced: false,
7479
schemaType: 'implicit',
7580
newMeasurementSchemas: [],
81+
measurementSchemaUpdates: [],
7682
}
7783

7884
onChangeSchemaTypeInternal = (newSchemaType: typeof SchemaType) => {
7985
this.setState({schemaType: newSchemaType})
8086
this.props.onChangeSchemaType(newSchemaType)
8187
}
8288

83-
onUpdateSchemasInternal = (schemas, resetValidation) => {
84-
this.props.onUpdateNewMeasurementSchemas(schemas, resetValidation)
89+
onAddSchemasInternal = (schemas, resetValidation) => {
90+
this.props.onAddNewMeasurementSchemas(schemas, resetValidation)
8591
this.setState({newMeasurementSchemas: schemas})
8692
}
8793

94+
onUpdateSchemasInternal = (schemas: SchemaUpdateInfo[]) => {
95+
this.props.onUpdateMeasurementSchemas(schemas)
96+
this.setState({measurementSchemaUpdates: schemas})
97+
}
98+
8899
public render() {
89100
const {
90101
name,
@@ -112,8 +123,9 @@ export default class BucketOverlayForm extends PureComponent<Props> {
112123
<MeasurementSchemaSection
113124
measurementSchemaList={measurementSchemaList}
114125
key="measurementSchemaSection"
115-
onUpdateSchemas={this.onUpdateSchemasInternal}
126+
onAddSchemas={this.onAddSchemasInternal}
116127
showSchemaValidation={showSchemaValidation}
128+
onUpdateSchemas={this.onUpdateSchemasInternal}
117129
/>
118130
)
119131

src/buckets/components/createBucketForm/CreateBucketForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export const CreateBucketForm: FC<CreateBucketFormProps> = props => {
175175
onChangeRetentionRule={handleChangeRetentionRule}
176176
testID={testID}
177177
onChangeSchemaType={handleChangeSchemaType}
178-
onUpdateNewMeasurementSchemas={handleNewMeasurementSchemas}
178+
onAddNewMeasurementSchemas={handleNewMeasurementSchemas}
179179
showSchemaValidation={showSchemaValidation}
180180
/>
181181
)

src/buckets/components/createBucketForm/CreateBucketOverlay.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {CreateBucketForm} from 'src/buckets/components/createBucketForm/CreateBu
99
import {OverlayContext} from 'src/overlays/components/OverlayController'
1010

1111
// Constants
12-
import {BUCKET_OVERLAY_WIDTH} from 'src/buckets/constants'
12+
import {getBucketOverlayWidth} from 'src/buckets/constants'
1313

1414
const CreateBucketOverlay: FC = () => {
1515
const {onClose} = useContext(OverlayContext)
1616
return (
17-
<Overlay.Container maxWidth={BUCKET_OVERLAY_WIDTH}>
17+
<Overlay.Container maxWidth={getBucketOverlayWidth()}>
1818
<Overlay.Header title="Create Bucket" onDismiss={onClose} />
1919
<Overlay.Body>
2020
<CreateBucketForm onClose={onClose} />

0 commit comments

Comments
 (0)