This is an automated email from the ASF dual-hosted git repository. xxyu pushed a commit to branch kylin5 in repository https://gitbox.apache.org/repos/asf/kylin.git
commit 424bb2450e0a942df00c95b89c29a8783d7029c1 Author: Qian Xia <lauraxiaq...@gmail.com> AuthorDate: Fri Jun 2 17:39:33 2023 +0800 KYLIN-5541 support aggregate index text recognize --- kystudio/build/webpack.base.conf.js | 2 +- kystudio/package.json | 1 + kystudio/src/assets/styles/index.less | 1 + kystudio/src/components/common/Modal/Modal.vue | 5 +- .../RecognizeAggregateModal.vue | 575 +++++++++++++++++++++ .../common/RecognizeAggregateModal/error.svg | 3 + .../common/RecognizeAggregateModal/handler.js | 115 +++++ .../common/RecognizeAggregateModal/locales.js | 28 + .../common/RecognizeAggregateModal/store.js | 120 +++++ .../common/RecognizeAggregateModal/warning.svg | 3 + .../StudioModel/ModelList/AggregateModal/index.vue | 47 ++ .../ModelList/AggregateModal/locales.js | 3 +- kystudio/src/config/index.js | 7 + 13 files changed, 907 insertions(+), 3 deletions(-) diff --git a/kystudio/build/webpack.base.conf.js b/kystudio/build/webpack.base.conf.js index a1532d7b9b..712ff5c1a3 100644 --- a/kystudio/build/webpack.base.conf.js +++ b/kystudio/build/webpack.base.conf.js @@ -55,7 +55,7 @@ module.exports = { { test: /\.js$/, loader: 'babel-loader', - include: [resolve('src'), resolve('test'), resolve('node_modules/vue-awesome')], + include: [resolve('src'), resolve('test'), resolve('node_modules/vue-awesome'), resolve('node_modules/vue-virtual-scroller')], options: { presets: ['@babel/preset-env'] } diff --git a/kystudio/package.json b/kystudio/package.json index 98802c940d..d7ed33f2e8 100644 --- a/kystudio/package.json +++ b/kystudio/package.json @@ -42,6 +42,7 @@ "vue-property-decorator": "7.0.0", "vue-resource": "1.5.1", "vue-router": "2.8.1", + "vue-virtual-scroller": "^1.0.10", "vue2-ace-editor": "0.0.3", "vuex": "2.5.0" }, diff --git a/kystudio/src/assets/styles/index.less b/kystudio/src/assets/styles/index.less index 5b1f4c7b0b..1ed2b24a57 100644 --- a/kystudio/src/assets/styles/index.less +++ b/kystudio/src/assets/styles/index.less @@ -2,6 +2,7 @@ @import '~kyligence-kylin-ui/lib/theme-chalk/index.css'; @import '~smooth-scrollbar/dist/smooth-scrollbar.css'; @import '~nprogress/nprogress.css'; +@import '~vue-virtual-scroller/dist/vue-virtual-scroller.css'; // custom stylesheet @import './icons.less'; diff --git a/kystudio/src/components/common/Modal/Modal.vue b/kystudio/src/components/common/Modal/Modal.vue index a59d797a6a..cbc9978a48 100644 --- a/kystudio/src/components/common/Modal/Modal.vue +++ b/kystudio/src/components/common/Modal/Modal.vue @@ -8,6 +8,7 @@ <ModelsImportModal /> <ModelERDiagramModal /> <EditProjectConfigDialog /> + <RecognizeAggregateModal /> </div> </template> @@ -23,6 +24,7 @@ import ModelsExportModal from '../ModelsExportModal/ModelsExportModal.vue' import ModelsImportModal from '../ModelsImportModal/ModelsImportModal.vue' import ModelERDiagramModal from '../ModelERDiagramModal/ModelERDiagramModal' import EditProjectConfigDialog from '../EditProjectConfigDialog' +import RecognizeAggregateModal from '../RecognizeAggregateModal/RecognizeAggregateModal' @Component({ computed: { @@ -38,7 +40,8 @@ import EditProjectConfigDialog from '../EditProjectConfigDialog' ModelsImportModal, ProjectEditModal, ModelERDiagramModal, - EditProjectConfigDialog + EditProjectConfigDialog, + RecognizeAggregateModal } }) export default class Modal extends Vue { diff --git a/kystudio/src/components/common/RecognizeAggregateModal/RecognizeAggregateModal.vue b/kystudio/src/components/common/RecognizeAggregateModal/RecognizeAggregateModal.vue new file mode 100644 index 0000000000..c3ab6ac753 --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/RecognizeAggregateModal.vue @@ -0,0 +1,575 @@ +<template> + <el-dialog class="recognize-aggregate-modal" width="960px" + append-to-body + :title="$t('title')" + :visible="isShow" + :close-on-press-escape="false" + :close-on-click-modal="false" + :before-close="handleCancel" + @closed="handleClosed" + > + <div class="dialog-content"> + <div class="recognize-area"> + <div class="recognize-header" v-if="errorLines.length"> + <div class="result-counter"> + <span class="error">{{$tc('errorCount', errorCount, { count: errorCount })}}</span> + <el-tooltip :content="$t('repeatTip')" placement="top"> + <span class="warning">{{$tc('repeatCount', repeatCount, { count: repeatCount })}}</span> + </el-tooltip> + </div> + <div class="result-actions"> + <el-button icon-button text type="primary" size="mini" icon="el-ksd-n-icon-arrow-up-outlined" @click="handlePrevious" /><!-- + --><el-button icon-button text type="primary" size="mini" icon="el-ksd-n-icon-arrow-down-outlined" @click="handleNext" /> + </div> + </div> + <AceEditor :key="isShow" :placeholder="'123'" class="text-input" ref="editorRef" :value="form.text" @input="handleInputText" /> + <div class="actions"> + <el-tooltip :content="$t('dexecute')" placement="left"> + <el-button icon-button class="recognize" size="small" type="primary" icon="el-icon-caret-right" :disabled="!form.text" @click="handleRecognize" /> + </el-tooltip> + </div> + </div> + <div class="recognize-results"> + <template v-if="form.dimensions.length"> + <div class="results-header"> + {{$tc('selectedDimensionCount', selectedDimensionCount, { count: selectedDimensionCount })}} + </div> + <div class="list-actions"> + <el-checkbox :key="isSelectAll" :indeterminate="isIndeterminate" :checked="isSelectAll" @change="handleSelectAll" /> + <div class="header-dimension-name">{{$t('dimensionName')}}</div> + <div class="header-data-type">{{$t('dataType')}}</div> + </div> + <RecycleScroller + class="dimension-list" + :items="form.dimensions" + :item-size="37" + key-field="value" + > + <template slot-scope="{ item }"> + <div class="dimension" @click="handleCheckDimension(item)"> + <el-checkbox :key="item.isChecked" :checked="item.isChecked" @change="handleCheckDimension(item)" /> + <span class="name">{{ item.label }}</span> + <span class="data-type">{{ item.dataType }}</span> + <div v-if="item.isDisabled" class="current-used-mask" /> + </div> + </template> + </RecycleScroller> + </template> + <div class="all-dimension-error" v-else-if="isAllDimensionError"> + <i class="el-ksd-n-icon-error-circle-filled" /> + <span>{{$t('recognizeFailed')}}</span> + </div> + <EmptyData v-else :showImage="false" :content="$t('emptyText')" /> + </div> + </div> + <div slot="footer" class="dialog-footer ky-no-br-space"> + <el-button size="medium" @click="handleCancel"> + {{$t('kylinLang.common.cancel')}} + </el-button> + <el-button type="primary" size="medium" :disabled="!selectedDimensionCount" @click="handleSubmit"> + {{$t('kylinLang.common.submit')}} + </el-button> + </div> + </el-dialog> + </template> + + <script> + import Vue from 'vue' + import AceEditor from 'vue2-ace-editor' + import { Component, Watch } from 'vue-property-decorator' + import { RecycleScroller } from 'vue-virtual-scroller' + import { mapState, mapMutations, mapGetters } from 'vuex' + import locales from './locales' + import vuex from '../../../store' + import EmptyData from '../../common/EmptyData/EmptyData' + import store, { types, getInitialErrors, ALERT_STATUS } from './store' + import { collectErrorsInEditor, refreshEditor, scrollToLineAndHighlight, updatePlaceHolder, ERROR_TYPE } from './handler' + import { AGGREGATE_TYPE } from '../../../config' + vuex.registerModule(['modals', 'RecognizeAggregateModal'], store) + @Component({ + components: { + AceEditor, + EmptyData, + RecycleScroller + }, + computed: { + ...mapState('RecognizeAggregateModal', { + isShow: state => state.isShow, + type: state => state.type, + status: state => state.status, + form: state => state.form, + errors: state => state.errors, + errorLines: state => state.errorLines, + errorInEditor: state => state.errorInEditor, + errorCursor: state => state.errorCursor, + callback: state => state.callback + }), + ...mapGetters('RecognizeAggregateModal', [ + 'modelDimensions', + 'includes', + 'mandatories', + 'hierarchies', + 'hierarchyItems', + 'joints', + 'jointItems' + ]) + }, + methods: { + ...mapMutations('RecognizeAggregateModal', { + setModal: types.SET_MODAL, + hideModal: types.HIDE_MODAL, + resetModal: types.RESET_MODAL, + setModalForm: types.SET_MODAL_FORM + }) + }, + locales + }) + export default class RecognizeAggregateModal extends Vue { + ALERT_STATUS = ALERT_STATUS + @Watch('$lang') + onLocaleChanged () { + this.updatePlaceHolder() + } + @Watch('isShow') + onIsShowChanged (newVal, oldVal) { + this.updatePlaceHolder() + this.updateRecognizeShortcut(newVal, oldVal) + } + get errorCount () { + const { errorInEditor } = this + return errorInEditor.filter(line => [ERROR_TYPE.COLUMN_NOT_IN_MODEL, ERROR_TYPE.COLUMN_NOT_IN_INCLUDES].includes(line.type)).length + } + get repeatCount () { + const { errorInEditor } = this + return errorInEditor.filter(line => [ERROR_TYPE.COLUMN_DUPLICATE].includes(line.type)).length + } + get selectedDimensionCount () { + const { form: { dimensions } } = this + return dimensions.filter(dimension => dimension.isChecked).length + } + get isSelectAll () { + const { form: { dimensions } } = this + return !dimensions.some(dimension => !dimension.isChecked && !dimension.isDisabled) + } + get isAllDimensionError () { + const { selectedDimensionCount, errorCount, repeatCount } = this + return !selectedDimensionCount && !!(errorCount + repeatCount) + } + get isIndeterminate () { + const { selectedDimensionCount, isSelectAll } = this + return selectedDimensionCount && !isSelectAll + } + isColumnUsedInCurrent (column) { + const { type, includes, mandatories, hierarchyItems, jointItems } = this + switch (type) { + case AGGREGATE_TYPE.INCLUDE: return includes.includes(column) + case AGGREGATE_TYPE.MANDATORY: return mandatories.includes(column) + case AGGREGATE_TYPE.HIERARCHY: return hierarchyItems.includes(column) + case AGGREGATE_TYPE.JOINT: return jointItems.includes(column) + default: return false + } + } + isColumnUsedInOther (column) { + const { type, groupIdx, mandatories, hierarchies, joints } = this + switch (type) { + // 层级和联合中有此维度 + case AGGREGATE_TYPE.MANDATORY: + return hierarchies.some(hierarchy => hierarchy.items.some(item => item === column)) || + joints.some(joint => joint.items.some(item => item === column)) + // 必需、其他层级和联合中有此维度 + case AGGREGATE_TYPE.HIERARCHY: + return mandatories.some(mandatory => mandatory === column) || + hierarchies.some(hierarchy => hierarchy.items.some((item, idx) => idx !== groupIdx && item === column)) || + joints.some(joint => joint.items.some(item => item === column)) + // 必需、层级和其他联合中有此维度 + case AGGREGATE_TYPE.JOINT: + return mandatories.some(mandatory => mandatory === column) || + hierarchies.some(hierarchy => hierarchy.items.some(item => item === column)) || + joints.some(joint => joint.items.some((item, idx) => idx !== groupIdx && item === column)) + // 包含维度不做判断 + case AGGREGATE_TYPE.INCLUDE: + default: return false + } + } + isColumnInModel (column) { + const { modelDimensions } = this + return modelDimensions.some(d => d.column === column) + } + isColumnInIncludes (column) { + const { includes } = this + return includes.includes(column) + } + getColumnErrorMessage (errorType, column) { + switch (errorType) { + case ERROR_TYPE.COLUMN_NOT_IN_MODEL: + return this.$t('columnNotInModel', { column }) + case ERROR_TYPE.COLUMN_NOT_IN_INCLUDES: + return this.$t('columnNotInIncludes', { column }) + case ERROR_TYPE.COLUMN_DUPLICATE: + return this.$t('columnDuplicate', { column }) + default: return 'Unknow Error' + } + } + setNotInModelError (column) { + const { errors } = this + if (!errors.notInModel.includes(column)) this.setModal({ errors: { ...errors, notInModel: [...errors.notInModel, column] } }) + } + setNotInIncludesError (column) { + const { errors } = this + if (!errors.notInIncludes.includes(column)) this.setModal({ errors: { ...errors, notInIncludes: [...errors.notInIncludes, column] } }) + } + setDuplicateError (column) { + const { errors } = this + if (!errors.duplicate.includes(column)) this.setModal({ errors: { ...errors, duplicate: [...errors.duplicate, column] } }) + } + setUsedInOthersError (column) { + const { errors } = this + if (!errors.usedInOthers.includes(column)) this.setModal({ errors: { ...errors, usedInOthers: [...errors.usedInOthers, column] } }) + } + clearupErrors () { + this.setModal({ errors: getInitialErrors() }) + } + updateRecognizeShortcut (newVal, oldVal) { + if (!oldVal && newVal) { + document.addEventListener('keydown', this.handleDexecute) + } else if (!newVal && oldVal) { + document.removeEventListener('keydown', this.handleDexecute) + } + } + updatePlaceHolder () { + this.$nextTick(() => { + const { editorRef } = this.$refs + const { editor } = editorRef || {} + updatePlaceHolder(editor, (h) => ( + <div class="ace_placeholder"> + <div> + {this.$t('inputPlaceholder1')} + <el-tooltip + popperClass="recognize-aggregate-placeholder-tooltip" + content={( + <ul> + <li>{this.$t('inputPlaceholderTooltip1')}</li> + <li>{this.$t('inputPlaceholderTooltip2')}</li> + </ul> + )} + placement="top" + > + <span class="how-to-use">{this.$t('inputPlaceholderTooltipTrigger')}</span> + </el-tooltip> + </div> + <div> + {this.$t('inputPlaceholder2')} + </div> + </div> + )) + }) + } + showErrors () { + const { errors } = this + const { editorRef: { editor } } = this.$refs + const session = editor.getSession() + const { errorInEditor, errorLines } = collectErrorsInEditor(errors, editor) + session.setAnnotations(errorInEditor.map(error => ({ + row: error.row, + column: 0, + text: this.getColumnErrorMessage(error.type, error.column), + type: [ERROR_TYPE.COLUMN_DUPLICATE].includes(error.type) ? 'warning' : 'error' + }))) + this.setModal({ errorLines, errorInEditor }) + this.$nextTick(() => refreshEditor(editor)) + } + handleDexecute (event) { + const { metaKey, key, keyCode } = event + if (metaKey && (keyCode === 13 || key === 'Enter')) { + this.handleRecognize() + } + } + handleInputText (text) { + this.setModalForm({ text }) + } + handleCheckDimension (dimension) { + const { form } = this + if (!dimension.isDisabled) { + const dimensions = form.dimensions.map((d) => ( + d.value === dimension.value ? { ...d, isChecked: !d.isChecked } : d + )) + this.setModalForm({ dimensions: [...dimensions] }) + } + } + handleRecognize () { + const { type, form, modelDimensions } = this + const dimensions = [] + this.clearupErrors() + let formattedText = '' + for (const text of form.text.replace(/^\n|\n$/g, '').split(/,\n*/g)) { + const columnText = text.trim() + if (columnText) { + const dimension = modelDimensions.find(d => d.column === columnText) + if (dimension) { + if (type !== AGGREGATE_TYPE.INCLUDE && !this.isColumnInIncludes(dimension.column)) { + this.setNotInIncludesError(dimension.column) + } else if (!this.isColumnUsedInOther(dimension.column)) { + const duplicate = dimensions.some(d => d.value === dimension.column) + if (!duplicate) { + const isFormChecked = form.dimensions.find(d => d.value === columnText)?.isChecked + const isChecked = isFormChecked ?? true + const isDisabled = this.isColumnUsedInCurrent(dimension.column) + const dataType = dimension.type + dimensions.push({ value: dimension.column, label: dimension.column, isChecked, isDisabled, dataType }) + } else { + this.setDuplicateError(dimension.column) + } + } else { + this.setUsedInOthersError(dimension.column) + } + } else { + this.setNotInModelError(columnText) + } + formattedText += `${columnText},\n` + } + } + this.setModalForm({ text: formattedText, dimensions }) + this.$nextTick(() => { + this.showErrors() + }) + } + handlePrevious () { + const { editorRef: { editor } } = this.$refs + const { errorLines, errorCursor } = this + const lineIdx = errorLines.indexOf(errorCursor) + const nextCursor = errorLines[lineIdx - 1] ?? errorLines[errorLines.length - 1] + this.setModal({ errorCursor: nextCursor }) + scrollToLineAndHighlight(editor, nextCursor) + } + handleNext () { + const { editorRef: { editor } } = this.$refs + const { errorLines, errorCursor } = this + const lineIdx = errorLines.indexOf(errorCursor) + const nextCursor = errorLines[lineIdx + 1] ?? errorLines[0] + this.setModal({ errorCursor: nextCursor }) + scrollToLineAndHighlight(editor, nextCursor) + } + handleSelectAll () { + const { form, isSelectAll } = this + const dimensions = isSelectAll + ? form.dimensions.map((d) => ( + !d.isDisabled ? { ...d, isChecked: false } : d + )) + : form.dimensions.map((d) => ( + !d.isDisabled ? { ...d, isChecked: true } : d + )) + this.setModalForm({ dimensions }) + } + handleClosed () { + this.resetModal() + } + handleCancel (done) { + if (typeof done === 'function') done() + this.hideModal() + } + handleSubmit () { + const { form, callback } = this + callback(form.dimensions.filter(d => !d.isDisabled && d.isChecked).map(d => d.value)) + this.hideModal() + } + } + </script> + <style lang="less"> + @import '../../../assets/styles/variables.less'; + .recognize-aggregate-modal { + .el-dialog { + width: 800px !important; + } + .el-dialog__header { + padding: 24px 24px 16px 24px; + } + .el-dialog__body { + padding: 0; + border-top: 1px solid @ke-border-divider-color; + border-bottom: 1px solid @ke-border-divider-color; + } + .dialog-content { + display: flex; + height: 424px; + } + .recognize-area { + display: flex; + flex-direction: column; + width: 30%; + position: relative; + border-right: 1px solid @ke-border-divider-color; + } + .recognize-header { + height: 38px; + flex: none; + display: flex; + align-items: center; + position: relative; + padding: 0px 8px; + border-bottom: 1px solid @ke-border-divider-color; + } + .result-counter { + font-weight: 400; + font-size: 12px; + line-height: 16px; + .error { + color: @ke-color-danger-hover; + } + .warning { + color: @ke-color-warning-hover; + } + * + * { + margin-left: 7px; + } + } + .result-actions { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + .el-button + .el-button { + margin-left: 4px; + } + .is-text:focus:not(:hover) { + background: transparent; + border-color: transparent; + } + } + .text-input { + background-color: @ke-background-color-secondary; + .ace_gutter { + background-color: @ke-background-color-secondary; + } + .ace_placeholder { + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: @text-normal-color; + padding: 0 6px; + white-space: pre-wrap; + } + } + .recognize-results { + width: 70%; + display: flex; + flex-direction: column; + position: relative; + overflow-x: hidden; + overflow-y: auto; + } + .results-header { + height: 38px; + flex: none; + display: flex; + align-items: center; + position: relative; + padding: 0px 8px; + border-bottom: 1px solid @ke-border-divider-color; + font-weight: 400; + font-size: 12px; + line-height: 16px; + } + .actions { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 1; + } + .empty-data { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .list-actions { + display: flex; + border-bottom: 1px solid @ke-border-divider-color; + align-items: center; + font-weight: 500; + font-size: 12px; + line-height: 16px; + padding: 10px; + .el-checkbox__inner { + display: block; + } + .el-checkbox { + margin-right: 16px; + } + } + .dimension-list { + border-radius: 3px; + height: 100%; + box-sizing: border-box; + } + .dimension { + position: relative; + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + border-bottom: 1px solid @ke-border-divider-color; + user-select: none; + .el-checkbox { + margin-right: 16px; + } + &:hover { + background-color: @ke-color-info-secondary-bg; + } + } + .dimension > * { + vertical-align: middle; + } + .header-data-type, + .data-type { + position: absolute; + right: 10px; + width: 100px; + } + .current-used-mask { + cursor: not-allowed; + background: white; + opacity: 0.5; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + } + .ace_gutter-cell.ace_error { + background-image: url('./error.svg'); + background-repeat: no-repeat; + background-position: 4px center; + } + .ace_gutter-cell.ace_warning { + background-image: url('./warning.svg'); + background-repeat: no-repeat; + background-position: 4px center; + } + .all-dimension-error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: @ke-color-danger-hover; + } + .how-to-use { + color: @ke-color-primary; + cursor: pointer; + &:hover { + color: @ke-color-primary-hover; + } + } + } + .recognize-aggregate-placeholder-tooltip { + font-weight: 400; + font-size: 12px; + line-height: 16px; + ul, li { + list-style: disc; + } + ul { + margin-left: 15px; + } + } + </style> + \ No newline at end of file diff --git a/kystudio/src/components/common/RecognizeAggregateModal/error.svg b/kystudio/src/components/common/RecognizeAggregateModal/error.svg new file mode 100644 index 0000000000..b36e44207e --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/error.svg @@ -0,0 +1,3 @@ +<svg width="5" height="6" viewBox="0 0 5 6" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="2.5" cy="3" r="2.5" fill="#E03B3B"/> +</svg> diff --git a/kystudio/src/components/common/RecognizeAggregateModal/handler.js b/kystudio/src/components/common/RecognizeAggregateModal/handler.js new file mode 100644 index 0000000000..cf4930ca14 --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/handler.js @@ -0,0 +1,115 @@ +import { acequire } from 'brace' +import Vue from 'vue' + +const { Range } = acequire('ace/range') + +const dom = acequire('ace/lib/dom') + +export const ERROR_TYPE = { + COLUMN_NOT_IN_MODEL: 'columnNotInModel', + COLUMN_NOT_IN_INCLUDES: 'columnNotInIncludes', + COLUMN_DUPLICATE: 'columnDuplicate' +} + +export function $updatePlaceholder (editor, renderPlaceholder) { + const value = editor.renderer.$composition || editor.getValue() + if (value && editor.renderer.placeholderNode) { + editor.renderer.off('afterRender', editor.$updatePlaceholder) + dom.removeCssClass(editor.container, 'ace_hasPlaceholder') + editor.renderer.placeholderNode.remove() + editor.renderer.placeholderNode = null + } else if (!value && !editor.renderer.placeholderNode) { + editor.renderer.on('afterRender', editor.$updatePlaceholder) + dom.addCssClass(editor.container, 'ace_hasPlaceholder') + var el = dom.createElement('div') + editor.renderer.placeholderNode = el + editor.renderer.content.appendChild(editor.renderer.placeholderNode) + + var vmEl = dom.createElement('div') + el.appendChild(vmEl) + editor.renderer.placeholderVm = new Vue({ el: vmEl, render: renderPlaceholder }) + } +} + +export function updatePlaceHolder (editor, renderPlaceholder) { + if (editor) { + if (!editor.$updatePlaceholder) { + editor.$updatePlaceholder = $updatePlaceholder.bind(this, editor, renderPlaceholder) + editor.on('input', editor.$updatePlaceholder) + } + editor.$updatePlaceholder(editor, renderPlaceholder) + } +} + +export function refreshEditor (editor) { + if (editor) { + editor.resize(true) + } +} + +export function clearupMarkers (editor) { + const session = editor.getSession() + for (const marker of Object.values(session.getMarkers())) { + if (marker.type === 'fullLine') { + session.removeMarker(marker.id) + } + } +} + +export function scrollToLineAndHighlight (editor, line) { + const session = editor.getSession() + if (line !== undefined) { + clearupMarkers(editor) + editor.scrollToLine(line, true) + const range = new Range(line, 0, line, 1) + session.addMarker(range, 'ace_active-line', 'fullLine') + } +} + +export function searchColumnInEditor (editor, column) { + const { $search: editorSearch } = editor + const session = editor.getSession() + + editorSearch.setOptions({ + needle: `^${column},\n`, + caseSensitive: true, + wholeWord: false, + regExp: true + }) + return editorSearch.findAll(session) +} + +export function collectErrorsInEditor (errors, editor) { + const { notInModel, duplicate, notInIncludes } = errors + + let errorInEditor = [] + let errorLines = [] + + for (const column of notInModel) { + const notInModelRanges = searchColumnInEditor(editor, column) + errorInEditor = [...errorInEditor, ...notInModelRanges.map(r => { + errorLines.push(r.start.row) + return { row: r.start.row, column, type: ERROR_TYPE.COLUMN_NOT_IN_MODEL } + })] + } + + for (const column of notInIncludes) { + const notInIncludesRanges = searchColumnInEditor(editor, column) + errorInEditor = [...errorInEditor, ...notInIncludesRanges.map(r => { + errorLines.push(r.start.row) + return { row: r.start.row, column, type: ERROR_TYPE.COLUMN_NOT_IN_INCLUDES } + })] + } + + for (const column of duplicate) { + const [, ...duplicateRanges] = searchColumnInEditor(editor, column) + errorInEditor = [...errorInEditor, ...duplicateRanges.map(r => { + errorLines.push(r.start.row) + return { row: r.start.row, column, type: ERROR_TYPE.COLUMN_DUPLICATE } + })] + } + + errorLines = errorLines.sort() + + return { errorInEditor, errorLines } +} diff --git a/kystudio/src/components/common/RecognizeAggregateModal/locales.js b/kystudio/src/components/common/RecognizeAggregateModal/locales.js new file mode 100644 index 0000000000..31ebef97e2 --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/locales.js @@ -0,0 +1,28 @@ +export default { + 'en': { + title: 'Text Recognition', + emptyText: 'The recognized columns will be displayed here', + previous: 'Prev', + next: 'Next', + recognize: 'Recognize', + errorCount: ' | {count} error | {count} errors', + repeatCount: ' | {count} duplicate | {count} duplicates', + selectedDimensionCount: 'Select {count} results | Select {count} result | Select {count} results', + usedDimensionCount: '{count} already exists', + inputPlaceholder1: 'Please paste the text, separated by "," to identify the selected column.', + inputPlaceholder2: 'Example: CUSTOMER.C_CUSTKEY,CUSTOMER.C_CUSTKEY', + inputPlaceholderTooltip1: 'Method 1: Enter the formula A1 & "," on a new column in Excel, enter and drag the bottom right corner of the cell to add in bulk;', + inputPlaceholderTooltip2: 'Method 2: Select all the cells that need to added in bulk, right-click and select the cell format (shortcut cmd & ctrl + 1). Select "Custom", enter English format General "," or @ "," in the type, confirm and add in bulk.', + inputPlaceholderTooltipTrigger: 'Not sure how to batch add characters?', + recognizeFailed: 'Recognize failed. No result, please check and try again', + columnDuplicate: 'Duplicate with {column}', + columnNotInModel: 'Column {column} does not exist in the current model', + columnNotInIncludes: 'Column {column} does not exist in include dimension', + columnUsedInOther: 'Column {column} is used in other dimension', + dimensionName: 'Dimension Name', + dataType: 'Data Type', + dexecute: 'Dexecute', + acceleratorKey: ' ⌃/⌘ enter', + repeatTip: 'The selectable options have been automatically deduplicated' + } +} diff --git a/kystudio/src/components/common/RecognizeAggregateModal/store.js b/kystudio/src/components/common/RecognizeAggregateModal/store.js new file mode 100644 index 0000000000..47ab670b4b --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/store.js @@ -0,0 +1,120 @@ +import { AGGREGATE_TYPE } from '../../../config' + +const types = { + SHOW_MODAL: 'SHOW_MODAL', + HIDE_MODAL: 'HIDE_MODAL', + SET_MODAL: 'SET_MODAL', + RESET_MODAL: 'RESET_MODAL', + SET_MODAL_FORM: 'SET_MODAL_FORM', + CALL_MODAL: 'CALL_MODAL' +} + +export const ALERT_STATUS = { + INIT: 'INIT', + SUCCESS: 'SUCCESS', + WARNING: 'WARNING', + ERROR: 'ERROR' +} + +export function getInitialErrors () { + return { + notInModel: [], + notInIncludes: [], + duplicate: [], + usedInOthers: [] + } +} + +function getInitialState () { + return { + isShow: false, + model: null, + aggregate: null, + type: AGGREGATE_TYPE.INCLUDE, + status: ALERT_STATUS.INIT, + groupIdx: null, + errors: getInitialErrors(), + errorLines: [], + errorInEditor: [], + errorCursor: 0, + form: { + text: '', + // { label, value, isChecked, type } + dimensions: [] + }, + callback: null + } +} + +export default { + state: getInitialState(), + mutations: { + [types.SHOW_MODAL] (state) { + state.isShow = true + }, + [types.HIDE_MODAL] (state) { + state.isShow = false + }, + [types.SET_MODAL] (state, payload) { + for (const [key, value] of Object.entries(payload)) { + state[key] = value + } + }, + [types.RESET_MODAL] (state) { + for (const [key, value] of Object.entries(getInitialState())) { + state[key] = value + } + }, + [types.SET_MODAL_FORM] (state, payload) { + for (const [key, value] of Object.entries(payload)) { + state.form[key] = value + } + } + }, + getters: { + includes (state) { + const { aggregate } = state + return aggregate?.includes ?? [] + }, + mandatories (state) { + const { aggregate } = state + return aggregate?.mandatory ?? [] + }, + hierarchies (state) { + const { aggregate } = state + return aggregate?.hierarchyArray ?? [] + }, + joints (state) { + const { aggregate } = state + return aggregate?.jointArray ?? [] + }, + tableIndexCols (state) { + const { allColumns } = state + return allColumns.filter(c => c.isUsed).map(c => c.fullName) + }, + hierarchyItems (state) { + const { aggregate, groupIdx } = state + return aggregate?.hierarchyArray[groupIdx]?.items ?? [] + }, + jointItems (state) { + const { aggregate, groupIdx } = state + return aggregate?.jointArray[groupIdx]?.items ?? [] + }, + modelDimensions (state) { + const { model } = state + return model?.simplified_dimensions?.filter(c => c.status === 'DIMENSION') ?? [] + } + }, + actions: { + [types.CALL_MODAL] ({ commit }, args) { + const { aggregate, type, model, allColumns = [], groupIdx = null } = args + return new Promise(resolve => { + commit(types.SET_MODAL, { aggregate, model, type, groupIdx, allColumns, callback: resolve }) + commit(types.SHOW_MODAL) + }) + } + }, + namespaced: true +} + +export { types } diff --git a/kystudio/src/components/common/RecognizeAggregateModal/warning.svg b/kystudio/src/components/common/RecognizeAggregateModal/warning.svg new file mode 100644 index 0000000000..7d416355bc --- /dev/null +++ b/kystudio/src/components/common/RecognizeAggregateModal/warning.svg @@ -0,0 +1,3 @@ +<svg width="5" height="6" viewBox="0 0 5 6" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="2.5" cy="3" r="2.5" fill="#F29D41"/> +</svg> diff --git a/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/index.vue b/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/index.vue index e8fc98a331..7ad21f77b9 100644 --- a/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/index.vue +++ b/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/index.vue @@ -94,6 +94,18 @@ <div class="ksd-mb-10"> <span class="title font-medium include-title"><span class="is-required">*</span> {{$t('include')}}</span> <div class="row ksd-fright ky-no-br-space"> + <common-tip placement="top" :content="(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) ? $t('disableAddDim') : $t('refuseAddIndexTip')" + :disabled="!(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) || (!indexUpdateEnabled && ['HYBRID', 'STREAMING'].includes(aggregate.index_range))"> + <el-button + plain + class="ksd-ml-10" + size="mini" + :disabled="(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) || (!indexUpdateEnabled && ['HYBRID', 'STREAMING'].includes(aggregate.index_range))" + @click="handleIncludesRecognize(AGGREGATE_TYPE.INCLUDE, aggregateIdx)" + > + {{$t('textRecognition')}} + </el-button> + </common-tip> <common-tip placement="top" :content="$t('refuseAddIndexTip')" :disabled="!(!indexUpdateEnabled && ['HYBRID', 'STREAMING'].includes(aggregate.index_range))"> <el-button @@ -140,6 +152,20 @@ <common-tip placement="right" :content="$t('mandatoryDesc')"> <i class="el-ksd-icon-more_info_16"></i> </common-tip> + <span class="row ksd-fright ky-no-br-space"> + <common-tip placement="top" v-if="form.aggregateArray[aggregateIdx].includes.length" :content="(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) ? $t('disableAddDim') : $t('refuseAddIndexTip')" + :disabled="!(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) || (!indexUpdateEnabled && ['HYBRID', 'STREAMING'].includes(aggregate.index_range))"> + <el-button + plain + class="ksd-ml-10" + size="mini" + :disabled="(model.model_type === 'HYBRID' && !form.aggregateArray[aggregateIdx].index_range) || (!indexUpdateEnabled && ['HYBRID', 'STREAMING'].includes(aggregate.index_range))" + @click="handleMandatoryRecognize(AGGREGATE_TYPE.MANDATORY, aggregateIdx)" + > + {{$t('textRecognition')}} + </el-button> + </common-tip> + </span> </h2> <el-select multiple @@ -541,6 +567,7 @@ import { mapState, mapGetters, mapMutations, mapActions } from 'vuex' import vuex from 'store' import locales from './locales' +import { AGGREGATE_TYPE } from 'config' import { BuildIndexStatus } from 'config/model' import store, { types, initialAggregateData } from './store' import { titleMaps, getPlaintDimensions, findIncludeDimension } from './handler' @@ -591,6 +618,9 @@ vuex.registerModule(['modals', 'AggregateModal'], store) ...mapMutations({ setChangedForm: 'SET_CHANGED_FORM', setProject: 'SET_PROJECT' + }), + ...mapActions('RecognizeAggregateModal', { + callRecognizeAggregateModal: types.CALL_MODAL }) }, locales @@ -666,6 +696,7 @@ export default class AggregateModal extends Vue { width: [50, 1000] } } + AGGREGATE_TYPE = AGGREGATE_TYPE @Watch('$lang') changeCurrentLang (newVal, oldVal) { @@ -1095,6 +1126,22 @@ export default class AggregateModal extends Vue { } }) } + async handleIncludesRecognize (type, aggregateIdx, groupIdx = 0) { + const { model, form } = this + const { aggregateArray = [] } = form + const aggregate = aggregateArray[aggregateIdx] + const selectedColumns = await this.callRecognizeAggregateModal({ type, model, aggregate, groupIdx }) + const value = [...aggregate.includes, ...selectedColumns] + this.handleInput(`aggregateArray.${aggregateIdx}.includes`, value, aggregate.id) + } + async handleMandatoryRecognize (type, aggregateIdx, groupIdx = 0) { + const { model, form } = this + const { aggregateArray = [] } = form + const aggregate = aggregateArray[aggregateIdx] + const selectedColumns = await this.callRecognizeAggregateModal({ type, model, aggregate, groupIdx }) + const value = [...aggregate.mandatory, ...selectedColumns] + this.handleInput(`aggregateArray.${aggregateIdx}.mandatory`, value, aggregate.id) + } handleRemoveAllIncludes (aggregateIdx, titleId, id) { kylinConfirm(this.$t('clearAllAggregateTip', {aggId: titleId}), {type: 'warning'}, this.$t('clearAggregateTitle')).then(() => { const { aggregateArray = [] } = this.form diff --git a/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/locales.js b/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/locales.js index c48786c10b..af73fae4c2 100644 --- a/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/locales.js +++ b/kystudio/src/components/studio/StudioModel/ModelList/AggregateModal/locales.js @@ -114,6 +114,7 @@ export default { manyToManyAntiTableTip: 'For the tables excluded from recommendations, if the join relationship of a table is One-to-Many or Many-to-Many, dimensions from this table can\'t be used in indexes. ', indexTimeRangeTips: 'The data range that the indexes will be built in. With “Batch and Streaming“ selected, there will be generated batch indexes and streaming indexes with same content respectively. ', refuseAddIndexTip: 'Can\'t add streaming indexes. Please stop the streaming job and then delete all the streaming segments.', - disableAddDim: 'Select index\'s data range' + disableAddDim: 'Select index\'s data range', + textRecognition: 'Text Recognition' } } diff --git a/kystudio/src/config/index.js b/kystudio/src/config/index.js index 9b51bf0819..1c9e1da646 100644 --- a/kystudio/src/config/index.js +++ b/kystudio/src/config/index.js @@ -730,3 +730,10 @@ export const formatSQLConfig = { } export { projectCfgs } from './projectCfgs' + +export const AGGREGATE_TYPE = { + INCLUDE: 'includes', + MANDATORY: 'mandatory', + HIERARCHY: 'hierarchyArray', + JOINT: 'jointArray' +}