diff --git a/databuilder/databuilder/extractor/mssql_metadata_extractor.py b/databuilder/databuilder/extractor/mssql_metadata_extractor.py index 404ed9b3d9..beced79250 100644 --- a/databuilder/databuilder/extractor/mssql_metadata_extractor.py +++ b/databuilder/databuilder/extractor/mssql_metadata_extractor.py @@ -10,7 +10,7 @@ from pyhocon import ConfigFactory, ConfigTree -from sqlalchemy import create_engine +from sqlalchemy import create_engine, text from sqlglot import parse_one, exp from databuilder import Scoped @@ -206,7 +206,9 @@ def _get_extract_iter(self) -> Iterator[TableMetadata]: last_row = row if key_cols is None: - results = self.connection.execute(self.get_key_sql_statement(schema_name=last_row['schema_name'], table_name=last_row['name'])) + results = self.connection.execute(text(self.get_key_sql_statement(schema_name=last_row['schema_name'], table_name=last_row['name']))) + results = [dict(row._mapping) for row in results] + LOGGER.info(f"results={results}") if results: key_cols = {} diff --git a/frontend/amundsen_application/static/js/config/config-default.ts b/frontend/amundsen_application/static/js/config/config-default.ts index 631e78a0be..a18185653c 100644 --- a/frontend/amundsen_application/static/js/config/config-default.ts +++ b/frontend/amundsen_application/static/js/config/config-default.ts @@ -420,10 +420,10 @@ const configDefault: AppConfig = { type: FilterType.INPUT_SELECT, }, { - categoryId: 'column', - displayName: 'Column', + categoryId: 'cluster', + displayName: 'Cluster', helpText: - 'Enter one or more comma separated values with exact column names or regex wildcard patterns', + 'Enter one or more comma separated values with exact cluster names or regex wildcard patterns', type: FilterType.INPUT_SELECT, }, { @@ -440,6 +440,13 @@ const configDefault: AppConfig = { 'Enter one or more comma separated values with exact table names or regex wildcard patterns', type: FilterType.INPUT_SELECT, }, + { + categoryId: 'column', + displayName: 'Column', + helpText: + 'Enter one or more comma separated values with exact column names or regex wildcard patterns', + type: FilterType.INPUT_SELECT, + }, { categoryId: 'tag', displayName: 'Tag', diff --git a/frontend/amundsen_application/static/js/features/Badges/BadgeInput/index.tsx b/frontend/amundsen_application/static/js/features/Badges/BadgeInput/index.tsx new file mode 100644 index 0000000000..421211395f --- /dev/null +++ b/frontend/amundsen_application/static/js/features/Badges/BadgeInput/index.tsx @@ -0,0 +1,408 @@ +// Copyright Contributors to the Amundsen project. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { Modal } from 'react-bootstrap'; +import { components } from 'react-select'; +import CreatableSelect from 'react-select/lib/Creatable'; + +import { GlobalState } from 'ducks/rootReducer'; +import { getAllBadges } from 'ducks/badges/reducer'; +import { + GetAllTags, + GetAllTagsRequest, + UpdateTags, + UpdateTagsRequest, +} from 'ducks/tags/types'; + +import { EditableSectionChildProps } from 'components/EditableSection'; +import { ResourceType, Tag, UpdateMethod, UpdateTagData } from 'interfaces'; +import TagInfo from '../TagInfo'; + +import './styles.scss'; + +const VALID_TAG_REGEXP = new RegExp(/^([a-z0-9_]+)$/); +const BATCH_EDIT_TAG_OPTION = 'amundsen_batch_edit'; + +const FILTER_COMMON_TAGS = (otherArray) => (current) => + otherArray.filter((other) => other.tag_name === current.tag_name).length === + 0; + +enum BatchEditState { + CURRENT = 'CURRENT', + DELETE = 'DELETE', + PUT = 'PUT', +} + +export interface StateFromProps { + allTags: Tag[]; + isLoading: boolean; + tags: Tag[]; +} + +export interface OwnProps { + resourceType: ResourceType; + uriKey: string; +} + +export interface DispatchFromProps { + updateTags: (tagArray: UpdateTagData[]) => UpdateTagsRequest; + getAllTags: () => GetAllTagsRequest; +} + +export type TagInputProps = StateFromProps & + OwnProps & + DispatchFromProps & + EditableSectionChildProps; + +interface TagInputState { + showModal: boolean; +} + +export class TagInput extends React.Component { + private batchEditSet: Map | {}; + + public static defaultProps: TagInputProps = { + allTags: [], + getAllTags: () => ({ + type: GetAllTags.REQUEST, + }), + isLoading: false, + resourceType: ResourceType.table, + tags: [], + updateTags: () => ({ + type: UpdateTags.REQUEST, + payload: { + tagArray: [], + resourceType: ResourceType.table, + uriKey: '', + }, + }), + uriKey: '', + }; + + constructor(props) { + super(props); + this.state = { + showModal: false, + }; + } + + componentDidMount() { + const { getAllTags } = this.props; + + getAllTags(); + } + + handleClose = () => { + this.batchEditSet = {}; + this.setState({ showModal: false }); + }; + + handleShow = () => { + const { tags } = this.props; + + this.batchEditSet = {}; + tags.map((tag) => { + this.batchEditSet[tag.tag_name] = BatchEditState.CURRENT; + }); + this.setState({ showModal: true }); + }; + + handleSaveModalEdit = () => { + const { updateTags } = this.props; + const tagArray = Object.keys(this.batchEditSet).reduce( + (previousValue: UpdateTagData[], tagName) => { + const action = this.batchEditSet[tagName]; + + if (action === BatchEditState.DELETE) { + previousValue.push({ methodName: UpdateMethod.DELETE, tagName }); + } else if (action === BatchEditState.PUT) { + previousValue.push({ methodName: UpdateMethod.PUT, tagName }); + } + + return previousValue; + }, + [] + ); + + updateTags(tagArray); + this.handleClose(); + }; + + generateCustomOptionStyle(provided, state) { + // https://react-select.com/props#api + const isSeeAll = state.value === BATCH_EDIT_TAG_OPTION; + + return { + ...provided, + color: isSeeAll ? 'grey' : 'inherit', + fontStyle: isSeeAll ? 'italic' : 'inherit', + }; + } + + isValidNewOption(inputValue) { + // https://react-select.com/props#api + return VALID_TAG_REGEXP.test(inputValue); + } + + mapTagsToReactSelectAPI(tagArray) { + return tagArray.map((tag) => ({ + value: tag.tag_name, + label: tag.tag_name, + })); + } + + mapOptionsToReactSelectAPI(tagArray) { + return [ + { value: BATCH_EDIT_TAG_OPTION, label: 'Select From All Tags...' }, + ].concat(this.mapTagsToReactSelectAPI(tagArray)); + } + + noOptionsMessage(inputValue) { + // https://react-select.com/props#api + if (VALID_TAG_REGEXP.test(inputValue.inputValue)) { + return 'Tag already exists.'; + } + + return "Valid characters include a-z, 0-9, and '_'."; + } + + onChange = (currentTags, actionPayload) => { + // https://react-select.com/props#api + const actionType = actionPayload.action; + let tag; + + if (actionType === 'select-option' || actionType === 'create-option') { + tag = + actionType === 'select-option' + ? actionPayload.option.value + : currentTags[currentTags.length - 1].value; + if (tag === BATCH_EDIT_TAG_OPTION) { + currentTags.pop(); + this.handleShow(); + } else { + this.props.updateTags([{ methodName: UpdateMethod.PUT, tagName: tag }]); + } + } else if (actionType === 'remove-value' || actionType === 'pop-value') { + tag = actionPayload.removedValue.value; + this.props.updateTags([ + { methodName: UpdateMethod.DELETE, tagName: tag }, + ]); + } + }; + + onKeyDown = (event) => { + if (event.key === 8 && event.target.value.length === 0) { + event.preventDefault(); + } + if (event.key === 'Escape') { + this.stopEditing(); + } + }; + + toggleTag = (event, tagName) => { + const element = event.currentTarget; + + if (element.classList.contains('selected')) { + element.classList.remove('selected'); + } else { + element.classList.add('selected'); + } + + if (!this.batchEditSet.hasOwnProperty(tagName)) { + this.batchEditSet[tagName] = BatchEditState.PUT; + } else if (this.batchEditSet[tagName] === BatchEditState.PUT) { + delete this.batchEditSet[tagName]; + } else if (this.batchEditSet[tagName] === BatchEditState.CURRENT) { + this.batchEditSet[tagName] = BatchEditState.DELETE; + } else if (this.batchEditSet[tagName] === BatchEditState.DELETE) { + this.batchEditSet[tagName] = BatchEditState.CURRENT; + } + }; + + renderTagBlob(tagArray, keyPrefix, className) { + return tagArray.map((tag) => { + const tagName = tag.tag_name; + const labelProps = { + children: tagName, + data: { value: tagName, label: tagName }, + innerProps: { className: 'multi-value-label' }, + }; + const updateTag = (event) => { + this.toggleTag(event, tagName); + }; + + return ( +
+ + + +
+ ); + }); + } + + renderModalBody() { + const { tags, allTags } = this.props; + + return ( +
+

Click on a tag to add/remove

+
+ {this.renderTagBlob( + tags, + 'current', + 'multi-value-container selected' + )} + {this.renderTagBlob( + allTags.filter(FILTER_COMMON_TAGS(tags)), + 'existing', + 'multi-value-container' + )} +
+
+ ); + } + + startEditing = () => { + const { setEditMode } = this.props; + + if (setEditMode) { + setEditMode(true); + } + }; + + stopEditing = () => { + const { setEditMode } = this.props; + + if (setEditMode) { + setEditMode(false); + } + }; + + render() { + const { isEditing, tags, isLoading, allTags } = this.props; + const { showModal } = this.state; + + // https://react-select.com/props#api + const componentOverides = !isEditing + ? { + DropdownIndicator: () => null, + IndicatorSeparator: () => null, + MultiValueRemove: () => null, + } + : { + DropdownIndicator: () => null, + IndicatorSeparator: () => null, + }; + + let tagBody; + + if (!isEditing) { + if (tags.length === 0) { + tagBody = ( + + ); + } else { + tagBody = tags.map((tag, index) => ); + } + } else { + tagBody = ( + ({ + ...provided, + fontSize: '14px', + height: '30px', + lineHeight: '24px', + width: '100%', + }), + option: this.generateCustomOptionStyle, + }} + value={this.mapTagsToReactSelectAPI(tags)} + /> + ); + } + + return ( +
+ {tagBody} + + + Add/Remove Tags + + {this.renderModalBody()} + + + + + +
+ ); + } +} + +export const mapStateToProps = (state: GlobalState) => ({ + allTags: state.tags.allTags.tags, + isLoading: state.tags.allTags.isLoading || state.tags.resourceTags.isLoading, + tags: state.tags.resourceTags.tags, +}); + +export const mapDispatchToProps = (dispatch: any, ownProps: OwnProps) => + bindActionCreators( + { + getAllTags, + updateTags: (tags: UpdateTagData[]) => + updateTags(tags, ownProps.resourceType, ownProps.uriKey), + }, + dispatch + ); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TagInput); diff --git a/frontend/amundsen_application/static/js/features/Badges/BadgeInput/styles.scss b/frontend/amundsen_application/static/js/features/Badges/BadgeInput/styles.scss new file mode 100644 index 0000000000..155434167b --- /dev/null +++ b/frontend/amundsen_application/static/js/features/Badges/BadgeInput/styles.scss @@ -0,0 +1,125 @@ +// Copyright Contributors to the Amundsen project. +// SPDX-License-Identifier: Apache-2.0 + +@import 'variables'; + +.badge-input { + /* + override default react-select style classes. + using !important as a temporary workaround in places where a compiled class + is taking precendence, + */ + .basic-multi-select { + .amundsen__control { + min-height: 32px; + + &, + .amundsen__control--is-focused, + .amundsen__control--is-focused:hover { + border: none !important; + box-shadow: none !important; + } + + .amundsen__multi-value { + background-color: $badge-bg !important; + border-radius: 4px; + margin: 8px 8px 0 0; + + .amundsen__multi-value__label { + border-radius: 4px 0 0 4px; + color: $text-primary; + line-height: 14px; + padding: 8px; + } + + .amundsen__multi-value__remove { + border-radius: 0 4px 4px 0; + cursor: pointer; + + &:hover, + &:focus { + background-color: $badge-bg-hover !important; + color: $text-primary; + } + } + } + } + + .amundsen__control--is-disabled { + background-color: transparent; + border-style: none; + } + } + + .amundsen__value-container { + padding: 0 !important; + } + + .amundsen__option--is-focused { + background-color: #eee !important; + } + + .amundsen__multi-value--is-disabled .amundsen__multi-value__label { + padding-right: 6px; + } + + .amundsen__indicators { + width: 48px; + } +} + +.badge-input-modal { + .modal-body { + height: calc(100% - 150px); + overflow-y: scroll; + } + + .modal-dialog, + .modal-content { + height: 85%; + } + + .modal-dialog { + overflow-y: scroll; + width: 50% !important; + + p { + margin-left: 4px; + text-align: start; + } + } + + .badge-blob { + display: flex; + flex-wrap: wrap; + } + + .multi-value-container { + border: 1px solid hsl(0, 0%, 90%); + border-radius: 4px; + box-sizing: border-box; + display: flex; + margin: 4px; + min-width: 0; + } + + .multi-value-container.selected { + background-color: hsl(0, 0%, 90%); + } + + .multi-value-label { + box-sizing: border-box; + color: hsl(0, 0%, 20%); + overflow: hidden; + padding: 3px; + padding-left: 6px; + padding-right: 6px; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + } + + .multi-value-label:hover { + cursor: pointer; + } +}