diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/column-name.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/column-name.js
new file mode 100644
index 00000000000..2252e283c29
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/column-name.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Col } from 'reactstrap';
+
+function ColumnName(props) {
+ const { column } = props;
+ const { name } = column;
+
+ return (
+
+
+ {name || ''}
+
+
+ );
+}
+
+ColumnName.propTypes = {
+ column: PropTypes.object.isRequired,
+};
+
+export default ColumnName;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.css b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.css
new file mode 100644
index 00000000000..fe3e8a1d61d
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.css
@@ -0,0 +1,7 @@
+.extra-attributes-dialog .column-name {
+ padding-top: 9px;
+}
+
+.extra-attributes-dialog .column-item {
+ min-height: 56px;
+}
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.js
new file mode 100644
index 00000000000..164a7a2a439
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.js
@@ -0,0 +1,37 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Col } from 'reactstrap';
+import ColumnName from './column-name';
+import CONFIG from '../editor';
+
+import './index.css';
+
+class Column extends Component {
+ render() {
+ const { column, row, columns } = this.props;
+ const Editor = CONFIG[column.type] || CONFIG['text'];
+
+ return (
+
+
+
+
+
+
+ );
+ }
+}
+
+Column.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ columns: PropTypes.array,
+ onCommit: PropTypes.func,
+};
+
+export default Column;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/ctime-formatter.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/ctime-formatter.js
new file mode 100644
index 00000000000..43334ade82d
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/ctime-formatter.js
@@ -0,0 +1,22 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { getDateDisplayString } from '../../../../utils/extra-attributes';
+
+class CtimeFormatter extends Component {
+ render() {
+ const { column, row } = this.props;
+ const { key } = column;
+ const value = getDateDisplayString(row[key], 'YYYY-MM-DD HH:mm:ss') || '';
+
+ return (
+ {value}
+ );
+ }
+}
+
+CtimeFormatter.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+};
+
+export default CtimeFormatter;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/date-editor.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/date-editor.js
new file mode 100644
index 00000000000..e72abd3e0ed
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/date-editor.js
@@ -0,0 +1,28 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { getDateDisplayString } from '../../../../utils/extra-attributes';
+
+
+class DateEditor extends Component {
+ render() {
+ const { column, row } = this.props;
+ const { data, key } = column;
+ const value = getDateDisplayString(row[key], data ? data.format : '');
+
+ return (
+
+ );
+ }
+}
+
+DateEditor.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+};
+
+export default DateEditor;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/formula-formatter.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/formula-formatter.js
new file mode 100644
index 00000000000..d901a6648cf
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/formula-formatter.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FORMULA_RESULT_TYPE } from '../../../../constants';
+import { getDateDisplayString } from '../../../../utils/extra-attributes';
+
+function FormulaFormatter(props) {
+ const { column, row } = props;
+ const value = row[column.key];
+
+ const { data } = column;
+ const { result_type, format } = data || {};
+ if (result_type === FORMULA_RESULT_TYPE.DATE) {
+ return (
+ {getDateDisplayString(value, format)}
+ );
+ }
+ if (result_type === FORMULA_RESULT_TYPE.STRING) {
+ return value;
+ }
+ if (typeof value === 'object') {
+ return null;
+ }
+ return <>>;
+}
+
+FormulaFormatter.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+};
+
+export default FormulaFormatter;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/index.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/index.js
new file mode 100644
index 00000000000..63cd1f2dd4f
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/index.js
@@ -0,0 +1,20 @@
+import SimpleText from './simple-text';
+import FormulaFormatter from './formula-formatter';
+import SingleSelect from './single-select';
+import NumberEditor from './number-editor';
+import DateEditor from './date-editor';
+import CtimeFormatter from './ctime-formatter';
+import { EXTRA_ATTRIBUTES_COLUMN_TYPE } from '../../../../constants';
+
+
+const CONFIG = {
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.TEXT]: SimpleText,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.FORMULA]: FormulaFormatter,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.SINGLE_SELECT]: SingleSelect,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.NUMBER]: NumberEditor,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.DATE]: DateEditor,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.CTIME]: CtimeFormatter,
+ [EXTRA_ATTRIBUTES_COLUMN_TYPE.MTIME]: CtimeFormatter,
+};
+
+export default CONFIG;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/number-editor.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/number-editor.js
new file mode 100644
index 00000000000..a5202f3e5ac
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/number-editor.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getNumberDisplayString, replaceNumberNotAllowInput, formatStringToNumber, isMac } from '../../../../utils/extra-attributes';
+import { KeyCodes, DEFAULT_NUMBER_FORMAT } from '../../../../constants';
+
+class NumberEditor extends React.Component {
+
+ constructor(props) {
+ super(props);
+ const { row, column } = props;
+ const value = row[column.key];
+ this.state = {
+ value: getNumberDisplayString(value, column.data),
+ };
+ }
+
+ onChange = (event) => {
+ const { data } = this.props.column; // data maybe 'null'
+ const format = (data && data.format) ? data.format : DEFAULT_NUMBER_FORMAT;
+ let currency_symbol = null;
+ if (data && data.format === 'custom_currency') {
+ currency_symbol = data['currency_symbol'];
+ }
+ const initValue = event.target.value.trim();
+
+ //Prevent the repetition of periods bug in the Chinese input method of the Windows system
+ if (!isMac() && initValue.indexOf('.ă') > -1) return;
+ let value = replaceNumberNotAllowInput(initValue, format, currency_symbol);
+ if (value === this.state.value) return;
+ this.setState({ value });
+ };
+
+ onKeyDown = (event) => {
+ let { selectionStart, selectionEnd, value } = event.currentTarget;
+ if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Esc) {
+ event.preventDefault();
+ this.input.blur();
+ } else if ((event.keyCode === KeyCodes.LeftArrow && selectionStart === 0) ||
+ (event.keyCode === KeyCodes.RightArrow && selectionEnd === value.length)
+ ) {
+ event.stopPropagation();
+ }
+ };
+
+ onBlur = () => {
+ const { value } = this.state;
+ const { column } = this.props;
+ this.props.onCommit({ [column.key]: formatStringToNumber(value, column.data) }, column);
+ };
+
+ setInputRef = (input) => {
+ this.input = input;
+ return this.input;
+ };
+
+ onPaste = (e) => {
+ e.stopPropagation();
+ };
+
+ onCut = (e) => {
+ e.stopPropagation();
+ };
+
+ render() {
+ const { column } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+NumberEditor.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ onCommit: PropTypes.func,
+};
+
+export default NumberEditor;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/search-input.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/search-input.js
new file mode 100644
index 00000000000..6aad4b1bc50
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/search-input.js
@@ -0,0 +1,108 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+
+class SearchInput extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ searchValue: props.value,
+ };
+ this.isInputtingChinese = false;
+ this.timer = null;
+ this.inputRef = null;
+ }
+
+ componentDidMount() {
+ if (this.props.autoFocus && this.inputRef && this.inputRef !== document.activeElement) {
+ setTimeout(() => {
+ this.inputRef.focus();
+ }, 0);
+ }
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (nextProps.value !== this.props.value) {
+ this.setState({searchValue: nextProps.value});
+ }
+ }
+
+ componentWillUnmount() {
+ this.timer && clearTimeout(this.timer);
+ this.timer = null;
+ this.inputRef = null;
+ }
+
+ onCompositionStart = () => {
+ this.isInputtingChinese = true;
+ };
+
+ onChange = (e) => {
+ this.timer && clearTimeout(this.timer);
+ const { onChange, wait } = this.props;
+ let text = e.target.value;
+ this.setState({searchValue: text || ''}, () => {
+ if (this.isInputtingChinese) return;
+ this.timer = setTimeout(() => {
+ onChange && onChange(this.state.searchValue.trim());
+ }, wait);
+ });
+ };
+
+ onCompositionEnd = (e) => {
+ this.isInputtingChinese = false;
+ this.onChange(e);
+ };
+
+ setFocus = (isSelectAllText) => {
+ if (this.inputRef === document.activeElement) return;
+ this.inputRef.focus();
+ if (isSelectAllText) {
+ const txtLength = this.state.searchValue.length;
+ this.inputRef.setSelectionRange(0, txtLength);
+ }
+ };
+
+ render() {
+ const { placeholder, autoFocus, className, onKeyDown, disabled, style } = this.props;
+ const { searchValue } = this.state;
+
+ return (
+ this.inputRef = ref}
+ />
+ );
+ }
+}
+
+SearchInput.propTypes = {
+ placeholder: PropTypes.string,
+ autoFocus: PropTypes.bool,
+ className: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onKeyDown: PropTypes.func,
+ wait: PropTypes.number,
+ disabled: PropTypes.bool,
+ style: PropTypes.object,
+ value: PropTypes.string,
+};
+
+SearchInput.defaultProps = {
+ wait: 100,
+ disabled: false,
+ value: '',
+};
+
+export default SearchInput;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/simple-text.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/simple-text.js
new file mode 100644
index 00000000000..8e69a30e15a
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/simple-text.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { KeyCodes } from '../../../../constants';
+
+class SimpleText extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ value: props.row[props.column.key] || '',
+ };
+ this.inputRef = React.createRef();
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const nextValue = nextProps.row[nextProps.column.key];
+ if (nextValue !== this.state.value) {
+ this.setState({ value: nextValue });
+ }
+ }
+
+ blurInput = () => {
+ setTimeout(() => {
+ this.inputRef.current && this.inputRef.current.blur();
+ }, 1);
+ };
+
+ onBlur = () => {
+ let { column, onCommit } = this.props;
+ const updated = {};
+ updated[column.key] = this.state.value.trim();
+ onCommit(updated, column);
+ };
+
+ onChange = (e) => {
+ let value = e.target.value;
+ if (value === this.state.value) return;
+ this.setState({value});
+ };
+
+ onCut = (e) => {
+ e.stopPropagation();
+ };
+
+ onPaste = (e) => {
+ e.stopPropagation();
+ };
+
+ onKeyDown = (e) => {
+ if (e.keyCode === KeyCodes.Esc) {
+ e.stopPropagation();
+ this.blurInput();
+ return;
+ }
+ let { selectionStart, selectionEnd, value } = e.currentTarget;
+ if (
+ (e.keyCode === KeyCodes.ChineseInputMethod) ||
+ (e.keyCode === KeyCodes.LeftArrow && selectionStart === 0) ||
+ (e.keyCode === KeyCodes.RightArrow && selectionEnd === value.length)
+ ) {
+ e.stopPropagation();
+ }
+ };
+
+ render() {
+ const { column } = this.props;
+ const { value } = this.state;
+
+ return (
+
+ );
+ }
+}
+
+SimpleText.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ onCommit: PropTypes.func.isRequired,
+};
+
+export default SimpleText;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.css b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.css
new file mode 100644
index 00000000000..cee008435ee
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.css
@@ -0,0 +1,102 @@
+.extra-attributes-dialog .selected-single-select-container {
+ height: 38px;
+ width: 100%;
+ padding: 0 10px;
+ border-radius: 3px;
+ user-select: none;
+ border: 1px solid rgba(0, 40, 100, .12);
+ appearance: none;
+ background: #fff;
+}
+
+.extra-attributes-dialog .selected-single-select-container.disable {
+ background-color: #f8f9fa;
+}
+
+.extra-attributes-dialog .selected-single-select-container.focus {
+ border-color: #1991eb!important;
+ box-shadow: 0 0 0 2px rgba(70, 127, 207, .25);
+}
+
+.extra-attributes-dialog .selected-single-select-container:not(.disable):hover {
+ cursor: pointer;
+}
+
+.extra-attributes-dialog .selected-single-select-container .single-select-option {
+ text-align: center;
+ width: min-content;
+ max-width: 250px;
+ line-height: 20px;
+ border-radius: 10px;
+ padding: 0 10px;
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* editor */
+.single-select-editor-popover .popover,
+.single-select-editor-popover .popover-inner {
+ width: fit-content;
+ max-width: fit-content;
+}
+
+.single-select-editor-container {
+ min-height: 160px;
+ width: 320px;
+ overflow: hidden;
+ background-color: #fff;
+}
+
+.single-select-editor-container .search-single-selects {
+ padding: 10px 10px 0;
+}
+
+.single-select-editor-container .search-single-selects input {
+ max-height: 30px;
+ font-size: 14px;
+}
+
+.single-select-editor-container .single-select-editor-content {
+ max-height: 200px;
+ min-height: 100px;
+ padding: 10px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+}
+
+.single-select-editor-container .single-select-editor-content .single-select-option-container {
+ width: 100%;
+ height: 30px;
+ border-radius: 2px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 13px;
+ color: #212529;
+ padding-left: 12px;
+}
+
+.single-select-editor-container .single-select-editor-content .single-select-option-container:hover {
+ background-color: #f5f5f5;
+ cursor: pointer;
+}
+
+.single-select-editor-container .single-select-editor-content .single-select-option {
+ padding: 0 10px;
+ height: 20px;
+ line-height: 20px;
+ text-align: center;
+ border-radius: 10px;
+ margin-right: 10px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.single-select-editor-container .single-select-editor-content .single-select-option-selected {
+ width: 20px;
+ text-align: center;
+}
+
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.js
new file mode 100644
index 00000000000..018a0500b3e
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.js
@@ -0,0 +1,83 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { DELETED_OPTION_BACKGROUND_COLOR, DELETED_OPTION_TIPS } from '../../../../../constants';
+import { gettext } from '../../../../../utils/constants';
+import SingleSelectEditor from './single-select-editor';
+import { getSelectColumnOptions } from '../../../../../utils/extra-attributes';
+
+import './index.css';
+
+class SingleSelect extends Component {
+
+ constructor(props) {
+ super(props);
+ const { column } = props;
+ this.options = getSelectColumnOptions(column);
+ this.state = {
+ isShowSingleSelect: false,
+ };
+ this.editorKey = `single-select-editor-${column.key}`;
+ }
+
+ updateState = () => {
+ this.setState({ isShowSingleSelect: !this.state.isShowSingleSelect });
+ };
+
+ onCommit = (value, column) => {
+ this.props.onCommit(value, column);
+ };
+
+ render() {
+ const { isShowSingleSelect } = this.state;
+ const { column, row } = this.props;
+ const currentOptionID = row[column.key];
+ const option = this.options.find(option => option.id === currentOptionID);
+ const optionStyle = option ?
+ { backgroundColor: option.color, color: option.textColor || null } :
+ { backgroundColor: DELETED_OPTION_BACKGROUND_COLOR };
+ const optionName = option ? option.name : gettext(DELETED_OPTION_TIPS);
+
+ return (
+ <>
+
+
+
+ {currentOptionID && (
+
{optionName}
+ )}
+
+ {column.editable && (
+
+ )}
+
+
+ {column.editable && (
+
+ )}
+ >
+ );
+ }
+}
+
+SingleSelect.propTypes = {
+ column: PropTypes.object,
+ row: PropTypes.object,
+ columns: PropTypes.array,
+ onCommit: PropTypes.func,
+};
+
+export default SingleSelect;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/single-select-editor.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/single-select-editor.js
new file mode 100644
index 00000000000..cffc4f3ab47
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/single-select-editor.js
@@ -0,0 +1,141 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { UncontrolledPopover } from 'reactstrap';
+import { gettext } from '../../../../../utils/constants';
+import SearchInput from '../search-input';
+import { getSelectColumnOptions } from '../../../../../utils/extra-attributes';
+
+class SingleSelectEditor extends Component {
+
+ constructor(props) {
+ super(props);
+ const options = this.getSelectColumnOptions(props);
+ this.state = {
+ value: props.row[props.column.key],
+ searchVal: '',
+ highlightIndex: -1,
+ maxItemNum: 0,
+ itemHeight: 0,
+ filteredOptions: options,
+ };
+ this.options = options;
+ this.timer = null;
+ this.editorKey = `single-select-editor-${props.column.key}`;
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const currentCascadeColumnValue = this.getCascadeColumnValue(this.props);
+ const nextCascadeColumnValue = this.getCascadeColumnValue(nextProps);
+ if (currentCascadeColumnValue !== nextCascadeColumnValue) {
+ this.options = this.getSelectColumnOptions(nextProps);
+ this.setState({ filteredOptions: this.options });
+ }
+ }
+
+ getCascadeColumnValue = (props) => {
+ const { column, row, columns } = props;
+ const { data } = column;
+ const { cascade_column_key } = data || {};
+ if (!cascade_column_key) return '';
+ const cascadeColumn = columns.find(item => item.key === cascade_column_key);
+ if (!cascadeColumn) return '';
+ return row[cascade_column_key];
+ };
+
+ getSelectColumnOptions = (props) => {
+ const { column, row, columns } = props;
+ let options = getSelectColumnOptions(column);
+ const { data } = column;
+ const { cascade_column_key, cascade_settings } = data || {};
+ if (cascade_column_key) {
+ const cascadeColumn = columns.find(item => item.key === cascade_column_key);
+ if (cascadeColumn) {
+ const cascadeColumnValue = row[cascade_column_key];
+ if (!cascadeColumnValue) return [];
+ const cascadeSetting = cascade_settings[cascadeColumnValue];
+ if (!cascadeSetting || !Array.isArray(cascadeSetting) || cascadeSetting.length === 0) return [];
+ return options.filter(option => cascadeSetting.includes(option.id));
+ }
+ }
+ return options;
+ };
+
+ toggle = () => {
+ this.ref.toggle();
+ this.props.onUpdateState();
+ };
+
+ onChangeSearch = (searchVal) => {
+ const { searchVal: oldSearchVal } = this.state;
+ if (oldSearchVal === searchVal) return;
+ const val = searchVal.toLowerCase();
+ const filteredOptions = val ?
+ this.options.filter((item) => item.name && item.name.toLowerCase().indexOf(val) > -1) : this.options;
+ this.setState({ searchVal, filteredOptions });
+ };
+
+ onSelectOption = (optionID) => {
+ const { column } = this.props;
+ this.setState({ value: optionID }, () => {
+ this.props.onCommit({ [column.key]: optionID }, column);
+ this.toggle();
+ });
+ };
+
+ render() {
+ const { value, filteredOptions } = this.state;
+ const { column } = this.props;
+
+ return (
+ this.ref = ref}
+ >
+
+
+
+
+
+ {filteredOptions.map(option => {
+ const isSelected = value === option.id;
+ const style = {
+ backgroundColor: option.color,
+ color: option.textColor || null,
+ maxWidth: Math.max(200 - 62, column.width ? column.width -62 : 0)
+ };
+ return (
+
+
{option.name}
+
+ {isSelected && ()}
+
+
+ );
+ })}
+
+
+
+ );
+ }
+}
+
+SingleSelectEditor.propTypes = {
+ value: PropTypes.string,
+ row: PropTypes.object,
+ column: PropTypes.object,
+ columns: PropTypes.array,
+ onUpdateState: PropTypes.func,
+ onCommit: PropTypes.func,
+};
+
+export default SingleSelectEditor;
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.css b/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.css
new file mode 100644
index 00000000000..f96ae21d00e
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.css
@@ -0,0 +1,17 @@
+.extra-attributes-dialog {
+ margin: 28px 0 0 0;
+}
+
+.extra-attributes-dialog .extra-attributes-content-container {
+ height: 100%;
+ overflow: hidden;
+}
+
+.extra-attributes-dialog .modal-body {
+ overflow-y: scroll;
+ padding: 30px;
+}
+
+.extra-attributes-dialog .modal-body .form-control.disabled {
+ background-color: #f8f9fa;
+}
diff --git a/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.js b/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.js
new file mode 100644
index 00000000000..f4ce5abd5d6
--- /dev/null
+++ b/frontend/src/components/dialog/extra-metadata-attributes-dialog/index.js
@@ -0,0 +1,244 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import isHotkey from 'is-hotkey';
+import { zIndexes, DIALOG_MAX_HEIGHT } from '../../../constants';
+import { gettext } from '../../../utils/constants';
+import { seafileAPI } from '../../../utils/seafile-api';
+import { Utils } from '../../../utils/utils';
+import { getValidColumns } from '../../../utils/extra-attributes';
+import Column from './column';
+import Loading from '../../loading';
+import toaster from '../../toast';
+import metadataAPI from '../../../metadata/api';
+
+import './index.css';
+
+
+class ExtraMetadataAttributesDialog extends Component {
+
+ constructor(props) {
+ super(props);
+ const { direntDetail, direntType } = props;
+ this.state = {
+ animationEnd: false,
+ isLoading: true,
+ update: {},
+ row: {},
+ columns: [],
+ errorMsg: '',
+ };
+ if (direntType === 'dir') {
+ this.isEmptyFile = false;
+ } else {
+ const direntDetailId = direntDetail?.id || '';
+ this.isEmptyFile = direntDetailId === '0'.repeat(direntDetailId.length);
+ }
+ this.isExist = false;
+ this.modalRef = React.createRef();
+ }
+
+ componentDidMount() {
+ this.startAnimation(this.getData);
+ window.addEventListener('keydown', this.onHotKey);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('keydown', this.onHotKey);
+ }
+
+ startAnimation = (callback) => {
+ if (this.state.animationEnd === true) {
+ callback && callback();
+ }
+
+ // use setTimeout to make sure real dom rendered
+ setTimeout(() => {
+ let dom = this.modalRef.current.firstChild;
+ const { width, maxWidth, marginLeft, height } = this.getDialogStyle();
+ dom.style.width = `${width}px`;
+ dom.style.maxWidth = `${maxWidth}px`;
+ dom.style.marginLeft = `${marginLeft}px`;
+ dom.style.height = `${height}px`;
+ dom.style.marginRight = 'unset';
+ dom.style.marginTop = '28px';
+
+ // after animation, change style and run callback
+ setTimeout(() => {
+ this.setState({ animationEnd: true }, () => {
+ dom.style.transition = 'none';
+ callback && callback();
+ });
+ }, 280);
+ }, 1);
+ };
+
+ getData = () => {
+ const { repoID, filePath, direntType } = this.props;
+
+ let dirName = Utils.getDirName(filePath);
+ let fileName = Utils.getFileName(filePath);
+ let parentDir = direntType === 'file' ? dirName : dirName.slice(0, dirName.length - fileName.length - 1);
+
+ if (!parentDir.startsWith('/')) {
+ parentDir = '/' + parentDir;
+ }
+
+ // console.log('dirName')
+ // console.log(dirName)
+ // console.log(fileName)
+ // console.log(parentDir)
+ //
+ let column_name = 'čšć'
+ metadataAPI.addMetadataColumn(repoID, column_name)
+
+ metadataAPI.getMetadataRecordInfo(repoID, parentDir, fileName).then(res => {
+ const { row, metadata, editable_columns } = res.data;
+ this.isExist = Boolean(row._id);
+ this.setState({ row: row, columns: getValidColumns(metadata, editable_columns, this.isEmptyFile), isLoading: false, errorMsg: '' });
+ }).catch(error => {
+ const errorMsg = Utils.getErrorMsg(error);
+ this.setState({ isLoading: false, errorMsg });
+ });
+ };
+
+ updateData = (update, column) => {
+ const newRow = { ...this.state.row, ...update };
+ this.setState({ row: newRow }, () => {
+ const { repoID, filePath } = this.props;
+
+ let newValue = update[column.key];
+ let recordID = this.state.row._id;
+ if (this.isExist) {
+ metadataAPI.updateMetadataRecord(repoID, recordID, column.name, newValue).then(res => {
+ this.setState({ update: {}, row: res.data.row });
+ }).catch(error => {
+ const errorMsg = Utils.getErrorMsg(error);
+ toaster.danger(gettext(errorMsg));
+ });
+ } else {
+ // this.createData(data);
+ }
+ });
+ };
+
+ onHotKey = (event) => {
+ if (isHotkey('esc', event)) {
+ this.onToggle();
+ return;
+ }
+ };
+
+ onToggle = () => {
+ this.props.onToggle();
+ };
+
+ getDialogStyle = () => {
+ const width = 800;
+ return {
+ width,
+ maxWidth: width,
+ marginLeft: (window.innerWidth - width) / 2,
+ height: DIALOG_MAX_HEIGHT,
+ };
+ };
+
+ getInitStyle = () => {
+ const transition = 'all .3s';
+ const defaultMargin = 80; // sequence cell width
+ const defaultHeight = 100;
+ const marginTop = '30%';
+ const width = window.innerWidth;
+ return {
+ width: `${width - defaultMargin}px`,
+ maxWidth: `${width - defaultMargin}px`,
+ marginLeft: `${defaultMargin}px`,
+ height: `${defaultHeight}px`,
+ marginRight: `${defaultMargin}px`,
+ marginTop,
+ transition,
+ };
+ };
+
+ renderColumns = () => {
+ const { isLoading, errorMsg, columns, row, update } = this.state;
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (errorMsg) {
+ return (
+
+ {gettext(errorMsg)}
+
+ );
+ }
+
+ const newRow = { ...row, ...update };
+
+ return (
+ <>
+ {columns.map(column => {
+ return (
+
+ );
+ })}
+ >
+ );
+
+ };
+
+ renderContent = () => {
+ if (!this.state.animationEnd) return null;
+
+ return (
+ <>
+ {gettext('Edit extra properties')}
+
+ {this.renderColumns()}
+
+ >
+ );
+ };
+
+ render() {
+ const { animationEnd } = this.state;
+
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+ExtraMetadataAttributesDialog.propTypes = {
+ repoID: PropTypes.string,
+ filePath: PropTypes.string,
+ direntType: PropTypes.string,
+ direntDetail: PropTypes.object,
+ onToggle: PropTypes.func,
+};
+
+export default ExtraMetadataAttributesDialog;
diff --git a/frontend/src/components/dirent-detail/detail-list-view.js b/frontend/src/components/dirent-detail/detail-list-view.js
index 1991b37552f..6537a5375f6 100644
--- a/frontend/src/components/dirent-detail/detail-list-view.js
+++ b/frontend/src/components/dirent-detail/detail-list-view.js
@@ -9,6 +9,7 @@ import EditFileTagPopover from '../popover/edit-filetag-popover';
import ExtraAttributesDialog from '../dialog/extra-attributes-dialog';
import FileTagList from '../file-tag-list';
import ConfirmApplyFolderPropertiesDialog from '../dialog/confirm-apply-folder-properties-dialog';
+import ExtraMetadataAttributesDialog from '../dialog/extra-metadata-attributes-dialog';
const propTypes = {
repoInfo: PropTypes.object.isRequired,
@@ -29,7 +30,8 @@ class DetailListView extends React.Component {
this.state = {
isEditFileTagShow: false,
isShowExtraProperties: false,
- isShowApplyProperties: false
+ isShowApplyProperties: false,
+ isShowMetadataExtraProperties: false,
};
this.tagListTitleID = `detail-list-view-tags-${uuidv4()}`;
}
@@ -69,6 +71,10 @@ class DetailListView extends React.Component {
this.setState({ isShowExtraProperties: !this.state.isShowExtraProperties });
};
+ toggleExtraMetadataPropertiesDialog = () => {
+ this.setState({ isShowMetadataExtraProperties: !this.state.isShowMetadataExtraProperties });
+ };
+
toggleApplyPropertiesDialog = () => {
this.setState({ isShowApplyProperties: !this.state.isShowApplyProperties });
};
@@ -107,6 +113,17 @@ class DetailListView extends React.Component {
)}
+ {direntDetail.permission === 'rw' && window.app.pageOptions.enableMetadataManagement && (
+
+
+
+
+ {gettext('Edit metadata properties')}
+
+ |
+
+
+ )}
);
@@ -136,6 +153,15 @@ class DetailListView extends React.Component {
)}
+ {direntDetail.permission === 'rw' && window.app.pageOptions.enableMetadataManagement && (
+
+
+
+ {gettext('Edit metadata properties')}
+
+ |
+
+ )}
);
@@ -176,6 +202,15 @@ class DetailListView extends React.Component {
path={direntPath}
/>
)}
+ {this.state.isShowMetadataExtraProperties && (
+
+ )}
);
}
diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js
index a43f7da4eca..f6985fd450f 100644
--- a/frontend/src/metadata/api.js
+++ b/frontend/src/metadata/api.js
@@ -72,24 +72,35 @@ class MetadataManagerAPI {
return this.req.post(url, data);
}
- updateMetadataRecord = (repoID, recordID, creator, createTime, modifier, modifyTime, parentDir, name) => {
+ addMetadataColumn(repoID, column_name) {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/columns/';
+ let data = {
+ 'column_name': column_name
+ };
+ return this.req.post(url, data);
+ }
+
+ getMetadataRecordInfo(repoID, parentDir, name) {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/record/';
+ let params = {};
+ if (parentDir) {
+ params['parent_dir'] = parentDir;
+ }
+ if (name) {
+ params['name'] = name;
+ }
+ return this.req.get(url, {params: params});
+ }
+
+ updateMetadataRecord = (repoID, recordID, columnName, newValue) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/' + recordID + '/';
const data = {
- 'creator': creator,
- 'create_time': createTime,
- 'modifier': modifier,
- 'modify_time': modifyTime,
- 'current_dir': parentDir,
- 'name': name,
+ 'column_name': columnName,
+ 'value': newValue,
};
return this.req.put(url, data);
};
- deleteMetadataRecord = (repoID, recordID) => {
- const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/' + recordID + '/';
- return this.req.delete(url);
- };
-
listUserInfo = (userIds) => {
const url = this.server + '/api/v2.1/user-list/';
const params = { user_id_list: userIds };
diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py
index 6eb05af553d..8f7955f631e 100644
--- a/seahub/api2/endpoints/metadata_manage.py
+++ b/seahub/api2/endpoints/metadata_manage.py
@@ -9,7 +9,7 @@
from seahub.api2.authentication import TokenAuthentication
from seahub.repo_metadata.models import RepoMetadata
from seahub.views import check_folder_permission
-from seahub.repo_metadata.utils import add_init_metadata_task
+from seahub.repo_metadata.utils import add_init_metadata_task, gen_unique_id
from seahub.repo_metadata.metadata_server_api import MetadataServerAPI, list_metadata_records
from seaserv import seafile_api
@@ -132,7 +132,7 @@ def delete(self, request, repo_id):
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
return Response({'success': True})
-
+
class MetadataRecords(APIView):
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAuthenticated, )
@@ -210,3 +210,206 @@ def get(self, request, repo_id):
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
return Response(results)
+
+
+class MetadataRecordInfo(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id):
+ parent_dir = request.GET.get('parent_dir')
+ name = request.GET.get('name')
+ if not parent_dir:
+ error_msg = 'parent_dir invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not name:
+ error_msg = 'name invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ record = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not record or not record.enabled:
+ error_msg = f'The metadata module is disabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ permission = check_folder_permission(request, repo_id, '/')
+ if permission != 'rw':
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+
+ from seafevents.repo_metadata.metadata_server_api import METADATA_TABLE
+
+ sql = f'SELECT * FROM `{METADATA_TABLE.name}` WHERE \
+ `{METADATA_TABLE.columns.parent_dir.name}`=? AND `{METADATA_TABLE.columns.file_name.name}`=?;'
+ parameters = [parent_dir, name]
+
+ try:
+ query_result = metadata_server_api.query_rows(sql, parameters)
+ except Exception as e:
+ logger.error(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ sys_columns = [
+ METADATA_TABLE.columns.id.key,
+ METADATA_TABLE.columns.file_creator.key,
+ METADATA_TABLE.columns.file_ctime.key,
+ METADATA_TABLE.columns.file_modifier.key,
+ METADATA_TABLE.columns.file_mtime.key,
+ METADATA_TABLE.columns.parent_dir.key,
+ METADATA_TABLE.columns.file_name.key,
+ METADATA_TABLE.columns.is_dir.key,
+ ]
+
+ rows = query_result.get('results')
+
+ if not rows:
+ error_msg = 'Record not found'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ metadata = query_result.get('metadata')
+ editable_columns = []
+ name_to_key = {}
+ for col in metadata:
+ col_key = col.get('key')
+ col_name = col.get('name')
+ name_to_key[col_name] = col_key
+ if col_key in sys_columns:
+ continue
+ editable_columns.append(col.get('name'))
+
+ row = {name_to_key[name]: value for name, value in rows[0].items()}
+ query_result['row'] = row
+ query_result['editable_columns'] = editable_columns
+
+ query_result.pop('results', None)
+
+ return Response(query_result)
+
+
+class MetadataRecord(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def put(self, request, repo_id, record_id):
+ column_name = request.data.get('column_name')
+ new_value = request.data.get('value')
+
+ if not column_name:
+ error_msg = 'column_name invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ if not new_value:
+ error_msg = 'value invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ record = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not record or not record.enabled:
+ error_msg = f'The metadata module is disabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ permission = check_folder_permission(request, repo_id, '/')
+ if permission != 'rw':
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+
+ from seafevents.repo_metadata.metadata_server_api import METADATA_TABLE
+
+ sys_column_names = [
+ METADATA_TABLE.columns.id.name,
+ METADATA_TABLE.columns.file_creator.name,
+ METADATA_TABLE.columns.file_ctime.name,
+ METADATA_TABLE.columns.file_modifier.name,
+ METADATA_TABLE.columns.file_mtime.name,
+ METADATA_TABLE.columns.parent_dir.name,
+ METADATA_TABLE.columns.file_name.name,
+ METADATA_TABLE.columns.is_dir.name,
+ ]
+
+ if column_name in sys_column_names:
+ error_msg = 'column_name invalid'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ row = {
+ METADATA_TABLE.columns.id.name: record_id,
+ column_name: new_value
+ }
+
+ try:
+ metadata_server_api.update_rows(METADATA_TABLE.id, [row])
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
+
+
+class MetadataColumns(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def post(self, request, repo_id):
+ column_name = request.data.get('column_name')
+ if not column_name:
+ error_msg = 'column_name invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ record = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not record or not record.enabled:
+ error_msg = f'The metadata module is disabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ permission = check_folder_permission(request, repo_id, '/')
+ if permission != 'rw':
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+
+ from seafevents.repo_metadata.metadata_server_api import METADATA_TABLE, MetadataColumn
+ columns = metadata_server_api.list_columns(METADATA_TABLE.id).get('columns')
+ column_keys = set()
+ column_names = set()
+
+ for column in columns:
+ column_keys.add(column.get('key'))
+ column_names.add(column.get('name'))
+
+ if column_name in column_names:
+ error_msg = 'column_name duplicated.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ column_key = gen_unique_id(column_keys)
+ column = MetadataColumn(column_key, column_name, 'text')
+
+ try:
+ metadata_server_api.add_column(METADATA_TABLE.id, column.to_dict())
+ except Exception as e:
+ logger.exception(e)
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ return Response({'success': True})
diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py
index 303baff5783..376b67b1807 100644
--- a/seahub/repo_metadata/metadata_server_api.py
+++ b/seahub/repo_metadata/metadata_server_api.py
@@ -139,3 +139,11 @@ def query_rows(self, sql, params=[]):
url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/query'
response = requests.post(url, json=post_data, headers=self.headers, timeout=self.timeout)
return parse_response(response)
+
+ def list_columns(self, table_id):
+ url = f'{METADATA_SERVER_URL}/api/v1/base/{self.base_id}/columns'
+ data = {
+ 'table_id': table_id
+ }
+ response = requests.get(url, json=data, headers=self.headers, timeout=self.timeout)
+ return parse_response(response)
diff --git a/seahub/repo_metadata/utils.py b/seahub/repo_metadata/utils.py
index 4dc2cdc4aee..7cae3b97a03 100644
--- a/seahub/repo_metadata/utils.py
+++ b/seahub/repo_metadata/utils.py
@@ -2,6 +2,7 @@
import time
import requests
import json
+import random
from urllib.parse import urljoin
from seahub.settings import SECRET_KEY, SEAFEVENTS_SERVER_URL
@@ -14,3 +15,18 @@ def add_init_metadata_task(params):
url = urljoin(SEAFEVENTS_SERVER_URL, '/add-init-metadata-task')
resp = requests.get(url, params=params, headers=headers)
return json.loads(resp.content)['task_id']
+
+
+def generator_base64_code(length=4):
+ possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789'
+ ids = random.sample(possible, length)
+ return ''.join(ids)
+
+
+def gen_unique_id(id_set, length=4):
+ _id = generator_base64_code(length)
+
+ while True:
+ if _id not in id_set:
+ return _id
+ _id = generator_base64_code(length)
diff --git a/seahub/urls.py b/seahub/urls.py
index 4b95d598d44..490acf2dd82 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -206,7 +206,7 @@
from seahub.wiki2.views import wiki_view
from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView, Wiki2DuplicatePageView
from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView
-from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage
+from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage, MetadataRecord, MetadataColumns, MetadataRecordInfo
from seahub.api2.endpoints.user_list import UserListView
@@ -1036,4 +1036,8 @@
urlpatterns += [
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/$', MetadataManage.as_view(), name='api-v2.1-metadata'),
re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/records/$', MetadataRecords.as_view(), name='api-v2.1-metadata-records'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/records/(?P[A-Za-z0-9_-]+)/$', MetadataRecord.as_view(), name='api-v2.1-metadata-record'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/record/$', MetadataRecordInfo.as_view(), name='api-v2.1-metadata-record-info'),
+ re_path(r'^api/v2.1/repos/(?P[-0-9a-f]{36})/metadata/columns/$', MetadataColumns.as_view(), name='api-v2.1-metadata-columns'),
+
]