From 934ad94c76ea8d254094ee8cb974d7f72e6563d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=98JoinTyang=E2=80=99?= Date: Fri, 5 Jul 2024 11:55:50 +0800 Subject: [PATCH] can add and modify property --- .../column/column-name.js | 22 ++ .../column/index.css | 7 + .../column/index.js | 37 +++ .../editor/ctime-formatter.js | 22 ++ .../editor/date-editor.js | 28 ++ .../editor/formula-formatter.js | 31 +++ .../editor/index.js | 20 ++ .../editor/number-editor.js | 90 +++++++ .../editor/search-input.js | 108 ++++++++ .../editor/simple-text.js | 92 +++++++ .../editor/single-select/index.css | 102 ++++++++ .../editor/single-select/index.js | 83 ++++++ .../single-select/single-select-editor.js | 141 ++++++++++ .../index.css | 17 ++ .../extra-metadata-attributes-dialog/index.js | 244 ++++++++++++++++++ .../dirent-detail/detail-list-view.js | 37 ++- frontend/src/metadata/api.js | 35 ++- seahub/api2/endpoints/metadata_manage.py | 207 ++++++++++++++- seahub/repo_metadata/metadata_server_api.py | 8 + seahub/repo_metadata/utils.py | 16 ++ seahub/urls.py | 6 +- 21 files changed, 1337 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/column/column-name.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.css create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/column/index.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/ctime-formatter.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/date-editor.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/formula-formatter.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/index.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/number-editor.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/search-input.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/simple-text.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.css create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/index.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/editor/single-select/single-select-editor.js create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/index.css create mode 100644 frontend/src/components/dialog/extra-metadata-attributes-dialog/index.js 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'), + ]