diff --git a/superset-frontend/src/SqlLab/actions/Validator.js b/superset-frontend/src/SqlLab/actions/Validator.js new file mode 100644 index 000000000000..b7eb534c9c63 --- /dev/null +++ b/superset-frontend/src/SqlLab/actions/Validator.js @@ -0,0 +1,40 @@ +const strategies = { + isNonEmpty(value, errMsg) { + if (value === '') { + return errMsg; + } + }, + minLenth(value, length, errMsg) { + if (value.length < length) { + return errMsg; + } + }, + isMobile(value, errMsg) { + if (!/^1[3|5|8][0-9]{9}$/.test(value)) { + return errMsg; + } + }, +}; + +export default class Validator { + constructor() { + this.cache = []; + } + + add(value, rule, errMsg) { + const arr = rule.split(':'); + this.cache.push(() => { + const strategy = arr.shift(); + arr.unshift(value); + arr.push(errMsg); + return strategies[strategy](value, errMsg); + }); + } + + start() { + for (let i = 0; i < this.cache.length; i++) { + const msg = this.cache[i](); + if (msg) return msg; + } + } +} diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx index ad1dcc815834..6cf403211bb5 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx @@ -101,11 +101,9 @@ const StyledToolbar = styled.div` justify-content: space-between; border: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; border-top: 0; - form { margin-block-end: 0; } - .leftItems, .rightItems { display: flex; @@ -113,13 +111,11 @@ const StyledToolbar = styled.div` & > span { margin-right: ${({ theme }) => theme.gridUnit * 2}px; display: inline-block; - &:last-child { margin-right: 0; } } } - .limitDropdown { white-space: nowrap; } diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index 06a31711db4a..0f9d8d0e3673 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -30,7 +30,7 @@ import Button from 'src/components/Button'; import { t, styled, css, SupersetTheme } from '@superset-ui/core'; import Collapse from 'src/components/Collapse'; import Icons from 'src/components/Icons'; -import { TableSelectorMultiple } from 'src/components/TableSelector'; +import { TableSelectorMultiple } from 'src/SqlLab/components/TableSelector'; import { IconTooltip } from 'src/components/IconTooltip'; import { QueryEditor, SchemaOption } from 'src/SqlLab/types'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; diff --git a/superset-frontend/src/SqlLab/components/TableSelector/index.tsx b/superset-frontend/src/SqlLab/components/TableSelector/index.tsx new file mode 100644 index 000000000000..9e44216b049d --- /dev/null +++ b/superset-frontend/src/SqlLab/components/TableSelector/index.tsx @@ -0,0 +1,340 @@ +/** + * 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 React, { + FunctionComponent, + useState, + ReactNode, + useEffect, +} from 'react'; +import { Modal } from 'antd'; +import { styled, t, SupersetClient } from '@superset-ui/core'; +import { FormLabel } from 'src/components/Form'; +import Icons from 'src/components/Icons'; +import DatabaseSelector, { + DatabaseObject, +} from 'src/components/DatabaseSelector'; +import TemplateSelector from 'src/components/TemplateSelector'; +import Button from 'src/components/Button'; +import { Input } from 'src/components/Input'; +import CertifiedBadge from 'src/components/CertifiedBadge'; +import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { SchemaOption } from 'src/SqlLab/types'; +import { useTables, Table } from 'src/hooks/apiResources'; +import Validator from 'src/SqlLab/actions/Validator'; + +const REFRESH_WIDTH = 30; + +const TableSelectorWrapper = styled.div` + ${({ theme }) => ` + .refresh { + display: flex; + align-items: center; + width: ${REFRESH_WIDTH}px; + margin-left: ${theme.gridUnit}px; + margin-top: ${theme.gridUnit * 5}px; + } + + .section { + display: flex; + flex-direction: row; + align-items: center; + } + + .input { + width: calc(100% - 30px - ${theme.gridUnit}px); + flex: 1; + } + .divider { + border-bottom: 1px solid ${theme.colors.secondary.light5}; + margin: 15px 0; + } + .table-length { + color: ${theme.colors.grayscale.light1}; + } + .select { + flex: 1; + max-width: calc(100% - ${theme.gridUnit + REFRESH_WIDTH}px) + } + & > div { + margin-bottom: ${theme.gridUnit * 4}px; + } + `} +`; + +const TableLabel = styled.span` + align-items: center; + display: flex; + white-space: nowrap; + + svg, + small { + margin-right: ${({ theme }) => theme.gridUnit}px; + } +`; + +interface TableSelectorProps { + clearable?: boolean; + database?: DatabaseObject | null; + emptyState?: ReactNode; + formMode?: boolean; + getDbList?: (arg0: any) => {}; + handleError: (msg: string) => void; + isDatabaseSelectEnabled?: boolean; + onDbChange?: (db: DatabaseObject) => void; + onSchemaChange?: (schema?: string) => void; + onSchemasLoad?: (schemaOptions: SchemaOption[]) => void; + onTablesLoad?: (options: Array) => void; + readOnly?: boolean; + schema?: string; + onEmptyResults?: (searchText?: string) => void; + sqlLabMode?: boolean; + tableValue?: string | string[]; + onTableSelectChange?: (value?: string | string[], schema?: string) => void; + tableSelectMode?: 'single' | 'multiple'; +} + +export interface TableOption { + label: JSX.Element; + text: string; + value: string; +} + +export const TableOption = ({ table }: { table: Table }) => { + const { value, type, extra } = table; + return ( + + {type === 'view' ? ( + + ) : ( + + )} + {extra?.certification && ( + + )} + {extra?.warning_markdown && ( + + )} + {value} + + ); +}; + +const TableSelector: FunctionComponent = ({ + database, + emptyState, + formMode = false, + getDbList, + handleError, + isDatabaseSelectEnabled = true, + onDbChange, + onSchemaChange, + onSchemasLoad, + readOnly = false, + onEmptyResults, + schema, + sqlLabMode = true, +}) => { + const { addSuccessToast } = useToasts(); + const [currentSchema, setCurrentSchema] = useState( + schema, + ); + + useEffect(() => { + if (database === undefined) { + setCurrentSchema(undefined); + } + }, [database]); + + const internalDbChange = (db: DatabaseObject) => { + if (onDbChange) { + onDbChange(db); + } + }; + + const internalSchemaChange = (schema?: string) => { + setCurrentSchema(schema); + if (onSchemaChange) { + onSchemaChange(schema); + } + }; + + function renderDatabaseSelector() { + return ( + + ); + } + + const [buttonLoading, setButtonLoading] = useState(false); + const [params, setParams] = useState({}); + const [template_id, setTemplateId] = useState(''); + const [dataset_name, setDatasetName] = useState(''); + const [datasetId, setDatasetId] = useState(null); + + function postTemplateParamsData(payload: object) { + const modal = Modal.info({ + content: 'Generating dataset ... ...', + okButtonProps: { + disabled: true, + loading: true, + }, + okText: 'chart', + }); + return SupersetClient.post({ + url: 'http://192.168.8.69:5000/api/dataset', + body: JSON.stringify(payload), + headers: { 'Content-Type': 'application/json' }, + }) + .then(({ json }) => { + setButtonLoading(false); + const { dataset_id: datasetId } = json; + setDatasetId(datasetId); + modal.update({ + content: 'Dataset created successfully', + okButtonProps: { + disabled: false, + loading: false, + }, + okText: 'chart', + onOk: () => { + handleOk(); + }, + }); + }) + .catch(e => { + setButtonLoading(false); + modal.update({ + content: 'Failed to create dataset.', + okButtonProps: { + disabled: false, + loading: false, + }, + okText: 'cancel', + }); + }); + } + function handleOk() { + window.open( + `/explore/?datasource_id=${datasetId}&dataset_type=table&dataset_id=${datasetId}&datasource_type=table`, + '_blank', + 'noreferrer', + ); + } + + function createDataset() { + setButtonLoading(true); + const validator = new Validator(); + validator.add(dataset_name, 'isNonEmpty', 'dataset name 不能为空'); + validator.add(currentSchema, 'isNonEmpty', '请选择Schema'); + validator.add(database?.id, 'isNonEmpty', '请选择数据库'); + const errMsg = validator.start(); + if (errMsg) { + Modal.confirm({ + content: errMsg, + }); + return; + } + postTemplateParamsData({ + database: database?.id, + schema: currentSchema, + params, + template_id, + dataset_name, + }); + } + function onParamsChange(params: Object) { + setParams(params); + } + function onTemplateChange(id: string) { + setTemplateId(id); + } + + function renderInputRow(input: ReactNode, label: string) { + return ( + <> + {label} +
+ {input} + +
+ + ); + } + function DatasetNameChange(value: string) { + if (value) { + setDatasetName(value); + } + } + + return ( + + {renderDatabaseSelector()} + {sqlLabMode && !formMode &&
} + + {renderInputRow( + { + DatasetNameChange(e.target.value); + }} + />, + 'dataset name', + )} + + + ); +}; + +export const TableSelectorMultiple: FunctionComponent< + TableSelectorProps +> = props => ; + +export default TableSelector; diff --git a/superset-frontend/src/components/AddLabel/index.tsx b/superset-frontend/src/components/AddLabel/index.tsx new file mode 100644 index 000000000000..9e91aa1f13a7 --- /dev/null +++ b/superset-frontend/src/components/AddLabel/index.tsx @@ -0,0 +1,51 @@ +/** + * 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 React, { MouseEventHandler, forwardRef } from 'react'; +import { SupersetTheme } from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; +import Icons, { IconType } from 'src/components/Icons'; + +export interface AddLabelProps { + onClick: MouseEventHandler; + tooltipContent: string; +} + +const AddLabel = ({ onClick, tooltipContent }: AddLabelProps) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + + const IconWithoutRef = forwardRef((props: IconType, ref: any) => ( + + )); + + return ( + + ({ + cursor: 'pointer', + color: theme.colors.grayscale.base, + '&:hover': { color: theme.colors.primary.base }, + })} + /> + + ); +}; + +export default AddLabel; diff --git a/superset-frontend/src/components/DeleteLabel/index.tsx b/superset-frontend/src/components/DeleteLabel/index.tsx new file mode 100644 index 000000000000..253990ab5340 --- /dev/null +++ b/superset-frontend/src/components/DeleteLabel/index.tsx @@ -0,0 +1,50 @@ +/** + * 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 React, { MouseEventHandler, forwardRef } from 'react'; +import { SupersetTheme } from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; +import Icons, { IconType } from 'src/components/Icons'; + +export interface DeleteLabelProps { + onClick: MouseEventHandler; + tooltipContent: string; +} + +const DeleteLabel = ({ onClick, tooltipContent }: DeleteLabelProps) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const IconWithoutRef = forwardRef((props: IconType, ref: any) => ( + + )); + + return ( + + ({ + cursor: 'pointer', + color: theme.colors.grayscale.base, + '&:hover': { color: theme.colors.primary.base }, + })} + /> + + ); +}; + +export default DeleteLabel; diff --git a/superset-frontend/src/components/MultipleInput/index.jsx b/superset-frontend/src/components/MultipleInput/index.jsx new file mode 100644 index 000000000000..1296c9fffce4 --- /dev/null +++ b/superset-frontend/src/components/MultipleInput/index.jsx @@ -0,0 +1,135 @@ +/** + * 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 React, { useState, useEffect } from 'react'; +import { styled, t } from '@superset-ui/core'; +import DeleteLabel from 'src/components/DeleteLabel'; +import AddLabel from 'src/components/AddLabel'; +import { Input } from 'src/components/Input'; +import { FormLabel } from 'src/components/Form'; + +const FormInputWrapper = styled.div` + ${({ theme }) => ` + .add-label, .del-label{ + display: flex; + align-items: center; + width: 30px; + margin-left: ${theme.gridUnit}px; + } + .section { + display: flex; + flex-direction: row; + align-items: center; + } + + .select { + width: calc(100% - 30px - ${theme.gridUnit}px); + flex: 1; + } + + .input { + width: calc(100% - 30px - ${theme.gridUnit}px); + flex: 1; + } + + & > div { + margin-bottom: ${theme.gridUnit * 4}px; + } + `} +`; + +export default function MultipleInput(props) { + const [list, setList] = useState([]); + const [id, setId] = useState(0); + + useEffect(() => { + setList([ + ...list, + { + index: id, + value: '', + show: true, + }, + ]); + setId(id + 1); + }, []); + + function onChange(id, value) { + const newList = list; + newList[id].value = value; + setList(newList); + props.onChange( + props.template.name, + JSON.stringify(newList.filter(el => el.value !== '').map(el => el.value)), + ); + } + + function AddInputBox() { + setList([ + ...list, + { + index: id, + value: '', + show: true, + }, + ]); + setId(id + 1); + } + function deleteInput(index) { + const li = list.filter(el => el.index !== index); + setList(li); + } + function renderInputRow(input, index) { + return ( +
+ {input} + + {list.length > 1 ? ( + deleteInput(index)} /> + ) : ( + '' + )} + +
+ ); + } + + return ( + + + {props.template.name}{' '} + AddInputBox()} + tooltipContent={t('点击以添加更多元素')} + /> + + + {list.map(el => + renderInputRow( + onChange(el.index, e.target.value)} + placeholder={props.template.description} + id={el.index} + key={el.index} + />, + el.index, + ), + )} + + ); +} diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index a0f6e5366bf3..29bd7668457d 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -50,22 +50,18 @@ const TableSelectorWrapper = styled.div` margin-left: ${theme.gridUnit}px; margin-top: ${theme.gridUnit * 5}px; } - .section { display: flex; flex-direction: row; align-items: center; } - .divider { border-bottom: 1px solid ${theme.colors.secondary.light5}; margin: 15px 0; } - .table-length { color: ${theme.colors.grayscale.light1}; } - .select { flex: 1; max-width: calc(100% - ${theme.gridUnit + REFRESH_WIDTH}px) @@ -77,7 +73,6 @@ const TableLabel = styled.span` align-items: center; display: flex; white-space: nowrap; - svg, small { margin-right: ${({ theme }) => theme.gridUnit}px; @@ -194,10 +189,10 @@ const TableSelector: FunctionComponent = ({ () => data ? data.options.map(table => ({ - value: table.value, - label: , - text: table.value, - })) + value: table.value, + label: , + text: table.value, + })) : [], [data], ); @@ -336,4 +331,4 @@ const TableSelector: FunctionComponent = ({ export const TableSelectorMultiple: FunctionComponent = props => ; -export default TableSelector; +export default TableSelector; \ No newline at end of file diff --git a/superset-frontend/src/components/TemplateSelector/index.jsx b/superset-frontend/src/components/TemplateSelector/index.jsx new file mode 100644 index 000000000000..6840f5115991 --- /dev/null +++ b/superset-frontend/src/components/TemplateSelector/index.jsx @@ -0,0 +1,189 @@ +/** + * 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 React, { useState, useEffect } from 'react'; +import { styled, t, SupersetClient } from '@superset-ui/core'; +import { Select } from 'src/components'; +import RefreshLabel from 'src/components/RefreshLabel'; +import MultipleInput from 'src/components/MultipleInput'; +import { Input } from 'src/components/Input'; +import { FormLabel } from 'src/components/Form'; + +const TemplateSelectorWrapper = styled.div` + ${({ theme }) => ` + .add-label, .refresh { + display: flex; + align-items: center; + width: 30px; + margin-left: ${theme.gridUnit}px; + margin-top: ${theme.gridUnit * 5}px; + } + .add-label{ + transform: translateY(${theme.gridUnit * 5}px); + } + .section { + display: flex; + flex-direction: row; + align-items: center; + } + + .select { + width: calc(100% - 30px - ${theme.gridUnit}px); + flex: 1; + } + + .input { + width: calc(100% - 30px - ${theme.gridUnit}px); + flex: 1; + } + + & > div { + margin-bottom: ${theme.gridUnit * 4}px; + } + `} +`; + +export default function TemplateSelector(props) { + const [templatesInfo, setTemplatesInfo] = useState([]); + const [templateOptions, setTemplateOptions] = useState([]); + const [currentTemplate, setCurrentTemplate] = useState(null); + const [params, setParams] = useState(null); + const [loadingTemplates, setLoadingTemplates] = useState(true); + function getTemplates() { + SupersetClient.get({ + url: 'http://192.168.8.60:5000/api/templates', + }) + .then(({ json }) => { + setLoadingTemplates(false); + const templatesInfo = json; + setTemplatesInfo(templatesInfo); + const templateOptions = templatesInfo.map((item, index) => ({ + label: item.label, + value: index, + })); + setTemplateOptions(templateOptions); + }) + .catch(() => { + setLoadingTemplates(false); + props.handleError(t('There was an error loading the templates')); + }); + } + + useEffect(() => { + getTemplates(); + }, []); + + useEffect(() => { + if (currentTemplate) { + const par = {}; + templatesInfo[currentTemplate.value].params.forEach(item => { + par[item.name] = null; + }); + setParams(par); + props.onTemplateChange(templatesInfo[currentTemplate.value].template_id); + } + setParams({}); + props.onParamsChange({}); + }, [currentTemplate]); + + function changeTemplate(template) { + if (template) { + setCurrentTemplate(template); + } + } + + function renderSelectRow(select) { + return ( +
+ {select} + + getTemplates()} + tooltipContent={t('Force refresh table list')} + /> + +
+ ); + } + + function renderInputRow(input, label) { + return ( + <> + {label} +
+ {input} + +
+ + ); + } + function renderMultipleInputRow(template, func, key) { + return ; + } + + function renderTemplateSelect() { + return renderSelectRow( + , + templateParam.name, + ) + : renderMultipleInputRow( + templateParam, + changeParam, + currentTemplate.label + templateParam.name, + ); + } + + return ( + <> + + {renderTemplateSelect()} + {currentTemplate && + templatesInfo[currentTemplate.value].params.map(item => + renderParamsInput(item), + )} + + + ); +}