-
Notifications
You must be signed in to change notification settings - Fork 13.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Revert "chore(fe): remove unused codes"
This reverts commit 996a671.
- Loading branch information
Showing
2 changed files
with
362 additions
and
0 deletions.
There are no files selected for viewing
142 changes: 142 additions & 0 deletions
142
superset-frontend/src/explore/components/controls/withAsyncVerification.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
/** | ||
* Licensed to the Apache Software Foundation (ASF) under one | ||
* or more contributor license agreements. See the NOTICE file | ||
* distributed with this work for additional information | ||
* regarding copyright ownership. The ASF licenses this file | ||
* to you under the Apache License, Version 2.0 (the | ||
* "License"); you may not use this file except in compliance | ||
* with the License. You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, | ||
* software distributed under the License is distributed on an | ||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
* KIND, either express or implied. See the License for the | ||
* specific language governing permissions and limitations | ||
* under the License. | ||
*/ | ||
import { ReactWrapper } from 'enzyme'; | ||
import { styledMount as mount } from 'spec/helpers/theming'; | ||
import { act } from 'react-dom/test-utils'; | ||
|
||
import withAsyncVerification, { | ||
ControlPropsWithExtras, | ||
WithAsyncVerificationOptions, | ||
} from 'src/explore/components/controls/withAsyncVerification'; | ||
import { ExtraControlProps } from '@superset-ui/chart-controls'; | ||
import MetricsControl from 'src/explore/components/controls/MetricControl/MetricsControl'; | ||
|
||
const VALID_METRIC = { | ||
metric_name: 'sum__value', | ||
expression: 'SUM(energy_usage.value)', | ||
}; | ||
|
||
const mockSetControlValue = jest.fn(); | ||
|
||
const defaultProps = { | ||
name: 'metrics', | ||
label: 'Metrics', | ||
value: undefined, | ||
multi: true, | ||
needAsyncVerification: true, | ||
actions: { setControlValue: mockSetControlValue }, | ||
onChange: () => {}, | ||
columns: [ | ||
{ type: 'VARCHAR(255)', column_name: 'source' }, | ||
{ type: 'VARCHAR(255)', column_name: 'target' }, | ||
{ type: 'DOUBLE', column_name: 'value' }, | ||
], | ||
savedMetrics: [ | ||
VALID_METRIC, | ||
{ metric_name: 'avg__value', expression: 'AVG(energy_usage.value)' }, | ||
], | ||
datasourceType: 'sqla', | ||
}; | ||
|
||
function verify(sourceProp: string) { | ||
const mock = jest.fn(); | ||
mock.mockImplementation(async (props: ControlPropsWithExtras) => ({ | ||
[sourceProp]: props.validMetrics || [VALID_METRIC], | ||
})); | ||
return mock; | ||
} | ||
|
||
async function setup({ | ||
extraProps, | ||
baseControl = MetricsControl as WithAsyncVerificationOptions['baseControl'], | ||
onChange, | ||
}: Partial<WithAsyncVerificationOptions> & { | ||
extraProps?: ExtraControlProps; | ||
} = {}) { | ||
const props = { | ||
...defaultProps, | ||
...extraProps, | ||
}; | ||
const verifier = verify('savedMetrics'); | ||
const VerifiedControl = withAsyncVerification({ | ||
baseControl, | ||
verify: verifier, | ||
onChange, | ||
}); | ||
type Wrapper = ReactWrapper<typeof props & ExtraControlProps>; | ||
let wrapper: Wrapper | undefined; | ||
await act(async () => { | ||
wrapper = mount(<VerifiedControl {...props} />); | ||
}); | ||
return { props, wrapper: wrapper as Wrapper, onChange, verifier }; | ||
} | ||
|
||
describe('VerifiedMetricsControl', () => { | ||
it('should calls verify correctly', async () => { | ||
expect.assertions(5); | ||
const { wrapper, verifier, props } = await setup(); | ||
|
||
expect(wrapper.find(MetricsControl).length).toBe(1); | ||
|
||
expect(verifier).toBeCalledTimes(1); | ||
expect(verifier).toBeCalledWith( | ||
expect.objectContaining({ savedMetrics: props.savedMetrics }), | ||
); | ||
|
||
// should call verifier with new props when props are updated. | ||
await act(async () => { | ||
wrapper.setProps({ validMetric: ['abc'] }); | ||
}); | ||
|
||
expect(verifier).toBeCalledTimes(2); | ||
expect(verifier).toBeCalledWith( | ||
expect.objectContaining({ validMetric: ['abc'] }), | ||
); | ||
}); | ||
|
||
it('should trigger onChange event', async () => { | ||
expect.assertions(3); | ||
const mockOnChange = jest.fn(); | ||
const { wrapper } = await setup({ | ||
// should allow specify baseControl with control component name | ||
baseControl: 'MetricsControl', | ||
onChange: mockOnChange, | ||
}); | ||
|
||
const child = wrapper.find(MetricsControl); | ||
child.props().onChange?.(['abc']); | ||
|
||
expect(child.length).toBe(1); | ||
expect(mockOnChange).toBeCalledTimes(1); | ||
expect(mockOnChange).toBeCalledWith(['abc'], { | ||
actions: defaultProps.actions, | ||
columns: defaultProps.columns, | ||
datasourceType: defaultProps.datasourceType, | ||
label: defaultProps.label, | ||
multi: defaultProps.multi, | ||
name: defaultProps.name, | ||
// in real life, `onChange` should have been called with the updated | ||
// props (both savedMetrics and value should have been updated), but | ||
// because of the limitation of enzyme (it cannot get props updated from | ||
// useEffect hooks), we are not able to check that here. | ||
savedMetrics: defaultProps.savedMetrics, | ||
value: undefined, | ||
}); | ||
}); | ||
}); |
220 changes: 220 additions & 0 deletions
220
superset-frontend/src/explore/components/controls/withAsyncVerification.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
/** | ||
* Licensed to the Apache Software Foundation (ASF) under one | ||
* or more contributor license agreements. See the NOTICE file | ||
* distributed with this work for additional information | ||
* regarding copyright ownership. The ASF licenses this file | ||
* to you under the Apache License, Version 2.0 (the | ||
* "License"); you may not use this file except in compliance | ||
* with the License. You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, | ||
* software distributed under the License is distributed on an | ||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | ||
* KIND, either express or implied. See the License for the | ||
* specific language governing permissions and limitations | ||
* under the License. | ||
*/ | ||
import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'; | ||
import { | ||
ExtraControlProps, | ||
sharedControlComponents, | ||
} from '@superset-ui/chart-controls'; | ||
import { JsonArray, JsonValue, t } from '@superset-ui/core'; | ||
import { ControlProps } from 'src/explore/components/Control'; | ||
import builtInControlComponents from 'src/explore/components/controls'; | ||
|
||
/** | ||
* Full control component map. | ||
*/ | ||
const controlComponentMap = { | ||
...builtInControlComponents, | ||
...sharedControlComponents, | ||
}; | ||
|
||
export type SharedControlComponent = keyof typeof controlComponentMap; | ||
|
||
/** | ||
* The actual props passed to the control component itself | ||
* (not src/explore/components/Control.tsx). | ||
*/ | ||
export type ControlPropsWithExtras = Omit<ControlProps, 'type'> & | ||
ExtraControlProps; | ||
|
||
/** | ||
* The full props passed to control component. Including withAsyncVerification | ||
* related props and `onChange` event + `hovered` state from Control.tsx. | ||
*/ | ||
export type FullControlProps = ControlPropsWithExtras & { | ||
onChange?: (value: JsonValue) => void; | ||
hovered?: boolean; | ||
/** | ||
* An extra flag for triggering async verification. Set it in mapStateToProps. | ||
*/ | ||
needAsyncVerification?: boolean; | ||
/** | ||
* Whether to show loading state when verification is still loading. | ||
*/ | ||
showLoadingState?: boolean; | ||
verify?: AsyncVerify; | ||
}; | ||
|
||
/** | ||
* The async verification function that accepts control props and returns a | ||
* promise resolving to extra props if overrides are needed. | ||
*/ | ||
export type AsyncVerify = ( | ||
props: ControlPropsWithExtras, | ||
) => Promise<ExtraControlProps | undefined | null>; | ||
|
||
/** | ||
* Whether the extra props will update the original props. | ||
*/ | ||
function hasUpdates( | ||
props: ControlPropsWithExtras, | ||
newProps: ExtraControlProps, | ||
) { | ||
return ( | ||
props !== newProps && | ||
Object.entries(newProps).some(([key, value]) => { | ||
if (Array.isArray(props[key]) && Array.isArray(value)) { | ||
const sourceValue: JsonArray = props[key]; | ||
return ( | ||
sourceValue.length !== value.length || | ||
sourceValue.some((x, i) => x !== value[i]) | ||
); | ||
} | ||
if (key === 'formData') { | ||
return JSON.stringify(props[key]) !== JSON.stringify(value); | ||
} | ||
return props[key] !== value; | ||
}) | ||
); | ||
} | ||
|
||
export type WithAsyncVerificationOptions = { | ||
baseControl: | ||
| SharedControlComponent | ||
// allows custom `baseControl` to not handle some of the <Control /> | ||
// component props. | ||
| ComponentType<Partial<FullControlProps>>; | ||
showLoadingState?: boolean; | ||
quiet?: boolean; | ||
verify?: AsyncVerify; | ||
onChange?: (value: JsonValue, props: ControlPropsWithExtras) => void; | ||
}; | ||
|
||
/** | ||
* Wrap Control with additional async verification. The <Control /> component | ||
* will render twice, once with the original props, then later with the updated | ||
* props after the async verification is finished. | ||
* | ||
* @param baseControl - The base control component. | ||
* @param verify - An async function that returns a Promise which resolves with | ||
* the updated and verified props. You should handle error within | ||
* the promise itself. If the Promise returns nothing or null, then | ||
* the control will not rerender. | ||
* @param onChange - Additional event handler when values are changed by users. | ||
* @param quiet - Whether to show a warning toast when verification failed. | ||
*/ | ||
export default function withAsyncVerification({ | ||
baseControl, | ||
onChange, | ||
verify: defaultVerify, | ||
quiet = false, | ||
showLoadingState: defaultShowLoadingState = true, | ||
}: WithAsyncVerificationOptions) { | ||
const ControlComponent: ComponentType<FullControlProps> = | ||
typeof baseControl === 'string' | ||
? controlComponentMap[baseControl] | ||
: baseControl; | ||
|
||
return function ControlWithVerification(props: FullControlProps) { | ||
const { | ||
hovered, | ||
onChange: basicOnChange, | ||
needAsyncVerification = false, | ||
isLoading: initialIsLoading = false, | ||
showLoadingState = defaultShowLoadingState, | ||
verify = defaultVerify, | ||
...restProps | ||
} = props; | ||
const otherPropsRef = useRef(restProps); | ||
const [verifiedProps, setVerifiedProps] = useState({}); | ||
const [isLoading, setIsLoading] = useState<boolean>(initialIsLoading); | ||
const { addWarningToast } = restProps.actions; | ||
|
||
// memoize `restProps`, so that verification only triggers when material | ||
// props are actually updated. | ||
let otherProps = otherPropsRef.current; | ||
if (hasUpdates(otherProps, restProps)) { | ||
otherProps = otherPropsRef.current = restProps; | ||
} | ||
|
||
const handleChange = useCallback( | ||
(value: JsonValue) => { | ||
// the default onChange handler, triggers the `setControlValue` action | ||
if (basicOnChange) { | ||
basicOnChange(value); | ||
} | ||
if (onChange) { | ||
onChange(value, { ...otherProps, ...verifiedProps }); | ||
} | ||
}, | ||
[basicOnChange, otherProps, verifiedProps], | ||
); | ||
|
||
useEffect(() => { | ||
if (needAsyncVerification && verify) { | ||
if (showLoadingState) { | ||
setIsLoading(true); | ||
} | ||
verify(otherProps) | ||
.then(updatedProps => { | ||
if (showLoadingState) { | ||
setIsLoading(false); | ||
} | ||
if (updatedProps && hasUpdates(otherProps, updatedProps)) { | ||
setVerifiedProps({ | ||
// save isLoading in combination with other props to avoid | ||
// rendering twice. | ||
...updatedProps, | ||
}); | ||
} | ||
}) | ||
.catch((err: Error | string) => { | ||
if (showLoadingState) { | ||
setIsLoading(false); | ||
} | ||
if (!quiet && addWarningToast) { | ||
addWarningToast( | ||
t( | ||
'Failed to verify select options: %s', | ||
(typeof err === 'string' ? err : err.message) || | ||
t('[unknown error]'), | ||
), | ||
{ noDuplicate: true }, | ||
); | ||
} | ||
}); | ||
} | ||
}, [ | ||
needAsyncVerification, | ||
showLoadingState, | ||
verify, | ||
otherProps, | ||
addWarningToast, | ||
]); | ||
|
||
return ( | ||
<ControlComponent | ||
isLoading={isLoading} | ||
hovered={hovered} | ||
onChange={handleChange} | ||
{...otherProps} | ||
{...verifiedProps} | ||
/> | ||
); | ||
}; | ||
} |