diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84152ca62c5..ff4fee2fae1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,7 @@ "@seafile/sdoc-editor": "1.0.50", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.109", - "@seafile/sf-metadata-ui-component": "0.0.21", + "@seafile/sf-metadata-ui-component": "0.0.22", "@uiw/codemirror-extensions-langs": "^4.19.4", "@uiw/react-codemirror": "^4.19.4", "axios": "^1.7.3", @@ -5093,9 +5093,9 @@ } }, "node_modules/@seafile/sf-metadata-ui-component": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@seafile/sf-metadata-ui-component/-/sf-metadata-ui-component-0.0.21.tgz", - "integrity": "sha512-bskuoVgMXDY5sD++MlMx9864+J3BdJ69pXZKifu40op4ebpC6qtJLAdZQV/j/ZqadyzGp1r0T340ClzhUfBQWw==", + "version": "0.0.22", + "resolved": "https://registry.npmjs.org/@seafile/sf-metadata-ui-component/-/sf-metadata-ui-component-0.0.22.tgz", + "integrity": "sha512-sRFGl3JoD4m5+Hdmvxt9b6yer8HxT4mI5hHBPqF+AvKsyaWMQ2Dq108vKUUlADbZOLvFlx0YuVfYfOcxYJQeJQ==", "dependencies": { "@seafile/seafile-calendar": "0.0.24", "@seafile/seafile-editor": "~1.0.102", diff --git a/frontend/package.json b/frontend/package.json index 1ad2da4d87c..07174538527 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "@seafile/sdoc-editor": "1.0.50", "@seafile/seafile-calendar": "0.0.12", "@seafile/seafile-editor": "1.0.109", - "@seafile/sf-metadata-ui-component": "0.0.21", + "@seafile/sf-metadata-ui-component": "0.0.22", "@uiw/codemirror-extensions-langs": "^4.19.4", "@uiw/react-codemirror": "^4.19.4", "axios": "^1.7.3", diff --git a/frontend/src/assets/icons/multiple-select.svg b/frontend/src/assets/icons/multiple-select.svg new file mode 100644 index 00000000000..c902a7dcfdd --- /dev/null +++ b/frontend/src/assets/icons/multiple-select.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/dir-view-mode/dir-views.js b/frontend/src/components/dir-view-mode/dir-views.js index 686ddbac546..d1d24bae5ac 100644 --- a/frontend/src/components/dir-view-mode/dir-views.js +++ b/frontend/src/components/dir-view-mode/dir-views.js @@ -17,7 +17,7 @@ const DirViews = ({ userPerm, repoID, currentPath, currentRepoInfo }) => { return [ { key: 'extended-properties', value: gettext('Extended properties') } ]; - }, [enableMetadataManagement, userPerm]); + }, [enableMetadataManagement, currentRepoInfo]); const moreOperationClick = useCallback((operationKey) => { if (operationKey === 'extended-properties') { diff --git a/frontend/src/metadata/metadata-details/index.js b/frontend/src/metadata/metadata-details/index.js index 400bec9bf5b..71a4b0b37f8 100644 --- a/frontend/src/metadata/metadata-details/index.js +++ b/frontend/src/metadata/metadata-details/index.js @@ -9,7 +9,7 @@ import toaster from '../../components/toast'; import { gettext } from '../../utils/constants'; import { DetailEditor, CellFormatter } from '../metadata-view'; import { getColumnOriginName } from '../metadata-view/utils/column-utils'; -import { CellType, getColumnOptions, getOptionName, PREDEFINED_COLUMN_KEYS } from '../metadata-view/_basic'; +import { CellType, getColumnOptions, getOptionName, PREDEFINED_COLUMN_KEYS, getColumnOptionNamesByIds } from '../metadata-view/_basic'; import './index.css'; @@ -47,6 +47,8 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType, emptyTip }) = if (!PREDEFINED_COLUMN_KEYS.includes(field.key) && field.type === CellType.SINGLE_SELECT) { const options = getColumnOptions(field); update = { [fileName]: getOptionName(options, newValue) }; + } else if (field.type === CellType.MULTIPLE_SELECT) { + update = { [fileName]: newValue ? getColumnOptionNamesByIds(field, newValue) : [] }; } metadataAPI.modifyRecord(repoID, record._id, update, record._obj_id).then(res => { const newMetadata = { ...metadata, record: { ...record, ...update } }; @@ -73,6 +75,9 @@ const MetadataDetails = ({ repoID, filePath, repoInfo, direntType, emptyTip }) = update = { [fileName]: newOption.id }; if (!PREDEFINED_COLUMN_KEYS.includes(fieldKey) && newField.type === CellType.SINGLE_SELECT) { update = { [fileName]: getOptionName(options, newOption.id) }; + } else if (newField.type === CellType.MULTIPLE_SELECT) { + const oldValue = getCellValueByColumn(record, newField) || []; + update = { [fileName]: [...oldValue, newOption.name] }; } return metadataAPI.modifyRecord(repoID, record._id, update, record._obj_id); }).then(res => { diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/format.js b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js index d1bbbdd5089..cf41325264c 100644 --- a/frontend/src/metadata/metadata-view/_basic/constants/column/format.js +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js @@ -53,6 +53,7 @@ const NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP = { const MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP = { [CellType.COLLABORATOR]: true, + [CellType.MULTIPLE_SELECT]: true, }; const SINGLE_CELL_VALUE_COLUMN_TYPE_MAP = { [CellType.TEXT]: true, diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js index a1d9b33935d..4715316b194 100644 --- a/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js @@ -13,6 +13,7 @@ const COLUMNS_ICON_CONFIG = { [CellType.DATE]: 'date', [CellType.LONG_TEXT]: 'long-text', [CellType.SINGLE_SELECT]: 'single-select', + [CellType.MULTIPLE_SELECT]: 'multiple-select', [CellType.NUMBER]: 'number', [CellType.GEOLOCATION]: 'location', }; @@ -30,6 +31,7 @@ const COLUMNS_ICON_NAME = { [CellType.DATE]: 'Date', [CellType.LONG_TEXT]: 'Long text', [CellType.SINGLE_SELECT]: 'Single select', + [CellType.MULTIPLE_SELECT]: 'Multiple select', [CellType.NUMBER]: 'Number', [CellType.GEOLOCATION]: 'Geolocation', }; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/type.js b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js index 51cae4d2098..9861052e198 100644 --- a/frontend/src/metadata/metadata-view/_basic/constants/column/type.js +++ b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js @@ -11,6 +11,7 @@ const CellType = { DATE: 'date', LONG_TEXT: 'long-text', SINGLE_SELECT: 'single-select', + MULTIPLE_SELECT: 'multiple-select', NUMBER: 'number', GEOLOCATION: 'geolocation', }; diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js index 121b042272d..f0736414b67 100644 --- a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js +++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js @@ -72,6 +72,16 @@ const FILTER_COLUMN_OPTIONS = { FILTER_PREDICATE_TYPE.NOT_EMPTY, ], }, + [CellType.MULTIPLE_SELECT]: { + filterPredicateList: [ + FILTER_PREDICATE_TYPE.HAS_ANY_OF, + FILTER_PREDICATE_TYPE.HAS_ALL_OF, + FILTER_PREDICATE_TYPE.HAS_NONE_OF, + FILTER_PREDICATE_TYPE.IS_EXACTLY, + FILTER_PREDICATE_TYPE.EMPTY, + FILTER_PREDICATE_TYPE.NOT_EMPTY, + ], + }, [CellType.CTIME]: { filterPredicateList: datePredicates, filterTermModifierList: dateTermModifiers, diff --git a/frontend/src/metadata/metadata-view/_basic/constants/sort.js b/frontend/src/metadata/metadata-view/_basic/constants/sort.js index 9fd5e79dcbc..3a98721bbcc 100644 --- a/frontend/src/metadata/metadata-view/_basic/constants/sort.js +++ b/frontend/src/metadata/metadata-view/_basic/constants/sort.js @@ -12,6 +12,7 @@ const SORT_COLUMN_OPTIONS = [ CellType.TEXT, CellType.DATE, CellType.SINGLE_SELECT, + CellType.MULTIPLE_SELECT, CellType.COLLABORATOR, CellType.CHECKBOX, CellType.NUMBER, diff --git a/frontend/src/metadata/metadata-view/_basic/index.js b/frontend/src/metadata/metadata-view/_basic/index.js index f8a2a668e54..79e4c2298c8 100644 --- a/frontend/src/metadata/metadata-view/_basic/index.js +++ b/frontend/src/metadata/metadata-view/_basic/index.js @@ -154,4 +154,6 @@ export { isNumber, getCellValueDisplayString, getCellValueStringResult, + getColumnOptionNamesByIds, + getColumnOptionIdsByNames, } from './utils'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/cell/column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/cell/column/index.js index a246a4784f6..d5d65b166b6 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/cell/column/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/cell/column/index.js @@ -11,6 +11,8 @@ export { export { getOption, getColumnOptionNameById, + getColumnOptionNamesByIds, + getColumnOptionIdsByNames, getOptionName, getMultipleOptionName, } from './option'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/cell/column/option.js b/frontend/src/metadata/metadata-view/_basic/utils/cell/column/option.js index 53b2a4eed0b..f17fc93bc60 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/cell/column/option.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/cell/column/option.js @@ -36,13 +36,46 @@ const getColumnOptionNameById = (column, optionId) => { return getOptionName(options, optionId); }; +/** + * Get column option name by id + * @param {object} column e.g. { data: { options, ... }, ... } + * @param {array} optionIds + * @returns options name, array + */ +const getColumnOptionNamesByIds = (column, optionIds) => { + if (PRIVATE_COLUMN_KEYS.includes(column.key)) return optionIds; + if (!Array.isArray(optionIds) || optionIds.length === 0) return []; + const options = getColumnOptions(column); + if (!Array.isArray(options) || options.length === 0) return []; + return optionIds.map(optionId => getOptionName(options, optionId)).filter(name => name); +}; + +/** + * Get column option name by id + * @param {object} column e.g. { data: { options, ... }, ... } + * @param {array} option names + * @returns options id, array + */ +const getColumnOptionIdsByNames = (column, names) => { + if (PRIVATE_COLUMN_KEYS.includes(column.key)) return names; + if (!Array.isArray(names) || names.length === 0) return []; + const options = getColumnOptions(column); + if (!Array.isArray(options) || options.length === 0) return []; + return names.map(name => { + const option = getOption(options, name); + if (option) return option.id; + return null; + }).filter(name => name); +}; + /** * Get concatenated options names of given ids. * @param {array} options e.g. [ { id, color, name, ... }, ... ] * @param {array} targetOptionsIds e.g. [ option.id, ... ] * @returns concatenated options names, string. e.g. 'name1, name2' */ -const getMultipleOptionName = (options, targetOptionsIds) => { +const getMultipleOptionName = (column, targetOptionsIds) => { + const options = getColumnOptions(column); if (!Array.isArray(targetOptionsIds) || !Array.isArray(options)) return ''; const selectedOptions = options.filter((option) => targetOptionsIds.includes(option.id)); if (selectedOptions.length === 0) return ''; @@ -53,5 +86,7 @@ export { getOption, getOptionName, getColumnOptionNameById, + getColumnOptionNamesByIds, + getColumnOptionIdsByNames, getMultipleOptionName, }; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/cell/index.js b/frontend/src/metadata/metadata-view/_basic/utils/cell/index.js index 3c864e66d8d..4ce9b8b72d0 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/cell/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/cell/index.js @@ -28,4 +28,6 @@ export { getGeolocationByGranularity, getFloatNumber, isNumber, + getColumnOptionNamesByIds, + getColumnOptionIdsByNames, } from './column'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js index 31232cfdde9..0410da434dd 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js @@ -5,3 +5,4 @@ export { checkboxFilter } from './checkbox'; export { singleSelectFilter } from './single-select'; export { collaboratorFilter } from './collaborator'; export { numberFilter } from './number'; +export { multipleSelectFilter } from './multiple-select'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/multiple-select.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/multiple-select.js new file mode 100644 index 00000000000..61e3578df55 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/multiple-select.js @@ -0,0 +1,54 @@ +import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate'; + +/** + * Filter multiple-select + * @param {array} optionIds e.g. [ option.id, ... ] + * @param {string} filter_predicate + * @param {array} filter_term option ids + * @returns bool + */ +const multipleSelectFilter = (optionIds, { filter_predicate, filter_term }) => { + switch (filter_predicate) { + case FILTER_PREDICATE_TYPE.HAS_ANY_OF: { + return ( + filter_term.length === 0 + || (Array.isArray(optionIds) && optionIds.some((optionId) => filter_term.includes(optionId))) + ); + } + case FILTER_PREDICATE_TYPE.HAS_ALL_OF: { + return ( + filter_term.length === 0 + || (Array.isArray(optionIds) && filter_term.every((optionId) => optionIds.includes(optionId))) + ); + } + case FILTER_PREDICATE_TYPE.HAS_NONE_OF: { + if (filter_term.length === 0 || !Array.isArray(optionIds) || optionIds.length === 0) { + return true; + } + return filter_term.every((optionId) => optionIds.indexOf(optionId) < 0); + } + case FILTER_PREDICATE_TYPE.IS_EXACTLY: { + if (filter_term.length === 0) { + return true; + } + if (!Array.isArray(optionIds)) { + return false; + } + const uniqueArr = (arr) => [...new Set(arr)].sort(); + return uniqueArr(optionIds).toString() === uniqueArr(filter_term).toString(); + } + case FILTER_PREDICATE_TYPE.EMPTY: { + return !Array.isArray(optionIds) || optionIds.length === 0; + } + case FILTER_PREDICATE_TYPE.NOT_EMPTY: { + return Array.isArray(optionIds) && optionIds.length > 0; + } + default: { + return false; + } + } +}; + +export { + multipleSelectFilter, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js index 013bf58cb8b..20d1005f2ae 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js @@ -10,6 +10,7 @@ import { singleSelectFilter, collaboratorFilter, numberFilter, + multipleSelectFilter, } from './filter-column'; import { FILTER_CONJUNCTION_TYPE, @@ -42,6 +43,9 @@ const getFilterResult = (row, filter, { username, userId }) => { case CellType.SINGLE_SELECT: { return singleSelectFilter(cellValue, filter); } + case CellType.MULTIPLE_SELECT: { + return multipleSelectFilter(cellValue, filter); + } case CellType.NUMBER: { return numberFilter(cellValue, filter); } diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js b/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js index 7fedaca88fd..2988a419c90 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js @@ -5,6 +5,8 @@ import { sortNumber, sortCheckbox, sortCollaborator, + sortSingleSelect, + sortMultipleSelect, } from '../sort/sort-column'; import { MAX_GROUP_LEVEL } from '../../constants/group'; import { @@ -45,6 +47,9 @@ const _getFormattedCellValue = (cellValue, groupby) => { case CellType.SINGLE_SELECT: { return cellValue || null; } + case CellType.MULTIPLE_SELECT: { + return Array.isArray(cellValue) ? cellValue : []; + } case CellType.COLLABORATOR: { return Array.isArray(cellValue) ? cellValue : []; } @@ -98,8 +103,18 @@ const _findGroupIndex = (sCellValue, cellValue2GroupIndexMap, groupsLength) => { const getSortedGroups = (groups, groupbys, level, collaborators = []) => { const sortFlag = 0; const { column, sort_type } = groupbys[level]; - const { type: columnType } = column; + const { type: columnType, data: columnData } = column; const normalizedSortType = sort_type || SORT_TYPE.UP; + let option_id_index_map = {}; + if (columnType === CellType.SINGLE_SELECT || columnType === CellType.MULTIPLE_SELECT) { + const { options } = columnData || {}; + if (Array.isArray(options)) { + options.forEach((option, index) => { + option_id_index_map[option.id] = index; + }); + } + } + groups.sort((currGroupRow, nextGroupRow) => { let { cell_value: currCellVal } = currGroupRow; let { cell_value: nextCellVal } = nextGroupRow; @@ -121,6 +136,10 @@ const getSortedGroups = (groups, groupbys, level, collaborators = []) => { nextCollaborators = getCollaboratorsNames(nextCollaborators, collaborators); } sortResult = sortCollaborator(currCollaborators, nextCollaborators, normalizedSortType); + } else if (columnType === CellType.SINGLE_SELECT) { + sortResult = sortSingleSelect(currCellVal, nextCellVal, { sort_type: normalizedSortType, option_id_index_map }); + } else if (columnType === CellType.MULTIPLE_SELECT) { + sortResult = sortMultipleSelect(currCellVal, nextCellVal, { sort_type: normalizedSortType, option_id_index_map }); } return sortFlag || sortResult; } diff --git a/frontend/src/metadata/metadata-view/_basic/utils/index.js b/frontend/src/metadata/metadata-view/_basic/utils/index.js index 448782034e6..5fad636016e 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/index.js @@ -25,6 +25,8 @@ export { isNumber, getCellValueDisplayString, getCellValueStringResult, + getColumnOptionNamesByIds, + getColumnOptionIdsByNames, } from './cell'; export { getColumnType, diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js index 43df31159d8..011b2df6ae4 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js @@ -61,7 +61,8 @@ const deleteInvalidSort = (sorts, columns) => { let newSort = { ...sort, column: sortColumn }; const { type: columnType } = sortColumn; switch (columnType) { - case CellType.SINGLE_SELECT: { + case CellType.SINGLE_SELECT: + case CellType.MULTIPLE_SELECT: { const options = getColumnOptions(sortColumn); let option_id_index_map = {}; options.forEach((option, index) => { diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js index 95ac7f56f75..7848961ac4c 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js @@ -13,6 +13,7 @@ export { sortCollaborator, sortNumber, sortSingleSelect, + sortMultipleSelect, } from './sort-column'; export { diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js index d1c39f5a99d..e1549b1f210 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js @@ -8,3 +8,4 @@ export { sortCheckbox } from './checkbox'; export { sortCollaborator } from './collaborator'; export { sortNumber } from './number'; export { sortSingleSelect } from './single-select'; +export { sortMultipleSelect } from './multiple-select'; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/multiple-select.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/multiple-select.js new file mode 100644 index 00000000000..8b411493c63 --- /dev/null +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/multiple-select.js @@ -0,0 +1,50 @@ +import { getMultipleIndexesOrderbyOptions } from '../core'; +import { SORT_TYPE } from '../../../constants/sort'; + +/** + * Sort multiple-select + * @param {array} leftOptionIds the ids of options + * @param {array} rightOptionIds + * @param {string} sort_type e.g. 'up' | 'down' + * @param {object} option_id_index_map e.g. { [option.id]: 0, ... } + * @returns number + */ +const sortMultipleSelect = (leftOptionIds, rightOptionIds, { sort_type, option_id_index_map }) => { + const emptyLeftOptionIds = !leftOptionIds || leftOptionIds.length === 0; + const emptyRightOptionIds = !rightOptionIds || rightOptionIds.length === 0; + if (emptyLeftOptionIds && emptyRightOptionIds) return 0; + if (emptyLeftOptionIds) return 1; + if (emptyRightOptionIds) return -1; + + const leftOptionIndexes = getMultipleIndexesOrderbyOptions(leftOptionIds, option_id_index_map); + const rightOptionIndexes = getMultipleIndexesOrderbyOptions(rightOptionIds, option_id_index_map); + const leftOptionsLen = leftOptionIndexes.length; + const rightOptionsLen = rightOptionIndexes.length; + + // current multiple select equal to next multiple select. + if ( + leftOptionsLen === rightOptionsLen + && (leftOptionsLen === 0 || leftOptionIndexes.join('') === rightOptionIndexes.join('')) + ) { + return 0; + } + + const len = Math.min(leftOptionsLen, rightOptionsLen); + for (let i = 0; i < len; i++) { + if (leftOptionIndexes[i] > rightOptionIndexes[i]) { + return sort_type === SORT_TYPE.UP ? 1 : -1; + } + if (leftOptionIndexes[i] < rightOptionIndexes[i]) { + return sort_type === SORT_TYPE.UP ? -1 : 1; + } + } + if (leftOptionsLen > rightOptionsLen) { + return sort_type === SORT_TYPE.UP ? 1 : -1; + } + + return sort_type === SORT_TYPE.UP ? -1 : 1; +}; + +export { + sortMultipleSelect, +}; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js index 66b31c3fe5a..efea0b1aea0 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js @@ -6,6 +6,7 @@ import { sortNumber, sortCollaborator, sortCheckbox, + sortMultipleSelect, } from './sort-column'; import { CellType, DATE_COLUMN_OPTIONS } from '../../constants/column'; import { getCellValueByColumn, getCollaboratorsNames } from '../cell'; @@ -31,6 +32,8 @@ const sortRowsWithMultiSorts = (tableRows, sorts, { collaborators }) => { initValue = initValue || sortSingleSelect(currCellVal, nextCellVal, sort); } else if (NUMBER_SORTER_COLUMN_TYPES.includes(columnType)) { initValue = initValue || sortNumber(currCellVal, nextCellVal, sort_type); + } else if (columnType === CellType.MULTIPLE_SELECT) { + initValue = initValue || sortMultipleSelect(currCellVal, nextCellVal, sort); } else if (columnType === CellType.COLLABORATOR) { let currValidCollaborators = currCellVal; let nextValidCollaborators = nextCellVal; diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js index 46c556814eb..af310fbbb38 100644 --- a/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js +++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js @@ -145,10 +145,9 @@ class ValidateFilter { } // Filter predicate should support: is_empty/is_not_empty(excludes checkbox and bool) - if (CHECK_EMPTY_PREDICATES.includes(predicate)) { - return true; - } - if (array_type === CellType.SINGLE_SELECT) { + if (CHECK_EMPTY_PREDICATES.includes(predicate)) return true; + + if (array_type === CellType.SINGLE_SELECT || array_type === CellType.DEPARTMENT_SINGLE_SELECT) { return this.validatePredicate(predicate, { type: CellType.MULTIPLE_SELECT }); } if (COLLABORATOR_COLUMN_TYPES.includes(array_type)) { @@ -272,6 +271,15 @@ class ValidateFilter { // invalid filter_term if selected option is deleted return !!options.find((option) => term === option.id); } + case CellType.MULTIPLE_SELECT: { + if (!this.isValidTermType(term, TERM_TYPE_MAP.ARRAY)) { + return false; + } + + // contains deleted option(s) + const options = getColumnOptions(filterColumn); + return this.isValidSelectedOptions(term, options); + } default: { return false; } @@ -299,9 +307,6 @@ class ValidateFilter { type: CellType.MULTIPLE_SELECT, data: array_data, }); } - if (array_type === CellType.DEPARTMENT_SINGLE_SELECT) { - return this.isValidTermType(term, TERM_TYPE_MAP.ARRAY); - } if (COLLABORATOR_COLUMN_TYPES.includes(array_type)) { return this.isValidTerm(term, predicate, modifier, { type: CellType.COLLABORATOR }); } diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/collaborator-editor/delete-collaborator/index.css b/frontend/src/metadata/metadata-view/components/cell-editor/collaborator-editor/delete-collaborator/index.css index ec9e9304ddf..e366ec798e0 100644 --- a/frontend/src/metadata/metadata-view/components/cell-editor/collaborator-editor/delete-collaborator/index.css +++ b/frontend/src/metadata/metadata-view/components/cell-editor/collaborator-editor/delete-collaborator/index.css @@ -2,8 +2,9 @@ background-color: #f6f6f6; border-bottom: 1px solid #dde2ea; border-radius: 3px 3px 0 0; - min-height: 34px; + min-height: 35px; padding: 5px 10px; + line-height: 1; } .collaborator { @@ -46,6 +47,11 @@ font-size: 12px; } +.sf-metadata-delete-collaborator .collaborator { + margin-top: 2px; + margin-bottom: 2px; +} + .sf-metadata-delete-collaborator .collaborator .collaborator-remove { height: 14px; width: 14px; diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/index.js b/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/index.js index 2ca13495168..ccf9167a9e5 100644 --- a/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/index.js +++ b/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/index.js @@ -9,6 +9,7 @@ const POPUP_EDITOR_COLUMN_TYPES = [ CellType.DATE, CellType.COLLABORATOR, CellType.SINGLE_SELECT, + CellType.MULTIPLE_SELECT, CellType.LONG_TEXT, ]; diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/popup-editor-container.js b/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/popup-editor-container.js index 9a5ef665eda..80d6d00675d 100644 --- a/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/popup-editor-container.js +++ b/frontend/src/metadata/metadata-view/components/cell-editor/editor-container/popup-editor-container.js @@ -2,15 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { ClickOutside } from '@seafile/sf-metadata-ui-component'; -import { CellType, isFunction, Z_INDEX, getCellValueByColumn, getColumnOptionNameById, PRIVATE_COLUMN_KEYS } from '../../../_basic'; +import { CellType, isFunction, Z_INDEX, getCellValueByColumn, getColumnOptionNameById, PRIVATE_COLUMN_KEYS, + getColumnOptionNamesByIds, +} from '../../../_basic'; import { isCellValueChanged } from '../../../utils/cell-comparer'; import { EVENT_BUS_TYPE } from '../../../constants'; import Editor from '../editor'; import { canEditCell } from '../../../utils/column-utils'; const NOT_SUPPORT_EDITOR_COLUMN_TYPES = [ - CellType.CTIME, CellType.MTIME, CellType.CREATOR, CellType.LAST_MODIFIER, - CellType.FILE_NAME, CellType.COLLABORATOR, CellType.LONG_TEXT, CellType.SINGLE_SELECT, + CellType.CTIME, CellType.MTIME, CellType.CREATOR, CellType.LAST_MODIFIER, CellType.FILE_NAME ]; class PopupEditorContainer extends React.Component { @@ -146,6 +147,8 @@ class PopupEditorContainer extends React.Component { let updated = columnType === CellType.DATE ? { [columnKey]: newValue } : newValue; if (columnType === CellType.SINGLE_SELECT) { updated[columnKey] = newValue[columnKey] ? getColumnOptionNameById(column, newValue[columnKey]) : ''; + } else if (columnType === CellType.MULTIPLE_SELECT) { + updated[columnKey] = newValue[columnKey] ? getColumnOptionNamesByIds(column, newValue[columnKey]) : []; } this.commitData(updated, true); diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/editor.js b/frontend/src/metadata/metadata-view/components/cell-editor/editor.js index db30b1e1ea4..506413d00e3 100644 --- a/frontend/src/metadata/metadata-view/components/cell-editor/editor.js +++ b/frontend/src/metadata/metadata-view/components/cell-editor/editor.js @@ -6,6 +6,7 @@ import FileNameEditor from './file-name-editor'; import TextEditor from './text-editor'; import NumberEditor from './number-editor'; import SingleSelectEditor from './single-select-editor'; +import MultipleSelectEditor from './multiple-select-editor'; import CollaboratorEditor from './collaborator-editor'; // eslint-disable-next-line react/display-name @@ -28,6 +29,9 @@ const Editor = React.forwardRef((props, ref) => { case CellType.SINGLE_SELECT: { return (); } + case CellType.MULTIPLE_SELECT: { + return (); + } case CellType.COLLABORATOR: { return (); } diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.css b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.css new file mode 100644 index 00000000000..2f766cefb07 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.css @@ -0,0 +1,25 @@ +.sf-metadata-delete-select-options { + background-color: #f6f6f6; + border-bottom: 1px solid #dde2ea; + border-radius: 3px 3px 0 0; + min-height: 35px; + padding: 2px 10px; + line-height: 1; +} + +.sf-metadata-delete-select-options .sf-metadata-delete-select-option { + margin-top: 5px; + margin-bottom: 5px; + align-items: center; +} + +.sf-metadata-delete-select-options .sf-metadata-delete-select-option .sf-metadata-delete-select-remove { + height: 14px; + width: 14px; + margin-left: 2px; +} + +.sf-metadata-delete-select-options .sf-metadata-delete-select-option .sf-metadata-icon-x-01 { + fill: inherit; + font-size: 12px; +} diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.js b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.js new file mode 100644 index 00000000000..a0d92308126 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/delete-options/index.js @@ -0,0 +1,59 @@ +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { IconBtn } from '@seafile/sf-metadata-ui-component'; +import { gettext } from '../../../../utils'; +import { DELETED_OPTION_TIPS, DELETED_OPTION_BACKGROUND_COLOR } from '../../../../constants'; + +import './index.css'; + +const DeleteOption = ({ value, options, onDelete }) => { + + const displayOptions = useMemo(() => { + if (!Array.isArray(value) || value.length === 0) return []; + const selectedOptions = options.filter((option) => value.includes(option.id) || value.includes(option.name)); + const invalidOptionIds = value.filter(optionId => optionId && !options.find(o => o.id === optionId || o.name === optionId)); + const invalidOptions = invalidOptionIds.map(optionId => ({ + id: optionId, + name: gettext(DELETED_OPTION_TIPS), + color: DELETED_OPTION_BACKGROUND_COLOR, + })); + return [...selectedOptions, ...invalidOptions]; + }, [options, value]); + + if (displayOptions.length === 0) return null; + + return ( +
+ {displayOptions.map(option => { + if (!option) return null; + const { id, name } = option; + const style = { + display: 'inline-flex', + padding: '0px 10px', + height: '20px', + lineHeight: '20px', + textAlign: 'center', + borderRadius: '10px', + maxWidth: '250px', + fontSize: 13, + backgroundColor: option.color, + color: option.textColor || null, + fill: option.textColor || '#666', + }; + return ( +
+ {name} + onDelete(id, event)} iconName="x-01" /> +
+ ); + })} +
+ ); +}; + +DeleteOption.propTypes = { + value: PropTypes.array.isRequired, + onDelete: PropTypes.func.isRequired +}; + +export default DeleteOption; diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/index.css b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/index.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/index.js b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/index.js new file mode 100644 index 00000000000..d1c6fb6ce5f --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/cell-editor/multiple-select-editor/index.js @@ -0,0 +1,278 @@ +import React, { forwardRef, useMemo, useImperativeHandle, useCallback, useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { SearchInput, CustomizeAddTool, Icon } from '@seafile/sf-metadata-ui-component'; +import { isFunction, getColumnOptions, getColumnOptionIdsByNames } from '../../../_basic'; +import { generateNewOption } from '../../../utils/select-utils'; +import { KeyCodes } from '../../../../../constants'; +import { gettext } from '../../../../../utils/constants'; +import DeleteOption from './delete-options'; + +import './index.css'; + +const MultipleSelectEditor = forwardRef(({ + saveImmediately, + column, + value: oldValue, + onCommit, + onPressTab, + modifyColumnData, +}, ref) => { + const [value, setValue] = useState(getColumnOptionIdsByNames(column, oldValue)); + const [searchValue, setSearchValue] = useState(''); + const [highlightIndex, setHighlightIndex] = useState(-1); + const [maxItemNum, setMaxItemNum] = useState(0); + const itemHeight = 30; + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + const selectItemRef = useRef(null); + const canEditData = window.sfMetadataContext.canModifyColumnData(column); + + const options = useMemo(() => { + return getColumnOptions(column); + }, [column]); + + const displayOptions = useMemo(() => { + if (!searchValue) return options; + const value = searchValue.toLowerCase().trim(); + if (!value) return options; + return options.filter((item) => item.name && item.name.toLowerCase().indexOf(value) > -1); + }, [searchValue, options]); + + const isShowCreateBtn = useMemo(() => { + if (!canEditData || !searchValue) return false; + return displayOptions.findIndex(option => option.name === searchValue) === -1 ? true : false; + }, [canEditData, displayOptions, searchValue]); + + const style = useMemo(() => { + return { width: column.width }; + }, [column]); + + const blur = useCallback(() => { + onCommit && onCommit(value); + }, [value, onCommit]); + + const onChangeSearch = useCallback((newSearchValue) => { + if (searchValue === newSearchValue) return; + setSearchValue(newSearchValue); + }, [searchValue]); + + const onSelectOption = useCallback((optionId) => { + const newValue = value.slice(0); + let optionIdx = value.indexOf(optionId); + if (optionIdx > -1) { + newValue.splice(optionIdx, 1); + } else { + newValue.push(optionId); + } + setValue(newValue); + if (saveImmediately) { + onCommit && onCommit(newValue); + } + }, [saveImmediately, value, onCommit]); + + const onMenuMouseEnter = useCallback((highlightIndex) => { + setHighlightIndex(highlightIndex); + }, []); + + const onMenuMouseLeave = useCallback((index) => { + setHighlightIndex(-1); + }, []); + + const createOption = useCallback((event) => { + event && event.stopPropagation(); + event && event.nativeEvent.stopImmediatePropagation(); + const newOption = generateNewOption(options, searchValue?.trim() || ''); + let newOptions = options.slice(0); + newOptions.push(newOption); + modifyColumnData(column.key, { options: newOptions }, { options: column.data.options || [] }); + onSelectOption(newOption.id); + }, [column, searchValue, options, onSelectOption, modifyColumnData]); + + const onDeleteOption = useCallback((optionId) => { + const newValue = value.slice(0); + const index = newValue.indexOf(optionId); + if (index > -1) { + newValue.splice(index, 1); + } + setValue(newValue); + if (saveImmediately) { + onCommit && onCommit(newValue); + } + }, [saveImmediately, value, onCommit]); + + const getMaxItemNum = useCallback(() => { + let selectContainerStyle = getComputedStyle(editorContainerRef.current, null); + let selectItemStyle = getComputedStyle(selectItemRef.current, null); + let maxSelectItemNum = Math.floor(parseInt(selectContainerStyle.maxHeight) / parseInt(selectItemStyle.height)); + return maxSelectItemNum - 1; + }, [editorContainerRef, selectItemRef]); + + const onEnter = useCallback((event) => { + event.preventDefault(); + let option; + if (displayOptions.length === 1) { + option = displayOptions[0]; + } else if (highlightIndex > -1) { + option = displayOptions[highlightIndex]; + } + if (option) { + let newOptionId = option.id; + if (value === option.id) newOptionId = null; + onSelectOption(newOptionId); + return; + } + let isShowCreateBtn = false; + if (searchValue) { + isShowCreateBtn = canEditData && displayOptions.findIndex(option => option.name === searchValue) === -1 ? true : false; + } + if (!isShowCreateBtn || displayOptions.length === 0) return; + createOption(); + }, [canEditData, displayOptions, highlightIndex, value, searchValue, onSelectOption, createOption]); + + const onUpArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === 0) return; + setHighlightIndex(highlightIndex - 1); + if (highlightIndex > displayOptions.length - maxItemNum) { + editorContainerRef.current.scrollTop -= itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayOptions, itemHeight]); + + const onDownArrow = useCallback((event) => { + event.preventDefault(); + event.stopPropagation(); + if (highlightIndex === displayOptions.length - 1) return; + setHighlightIndex(highlightIndex + 1); + if (highlightIndex >= maxItemNum) { + editorContainerRef.current.scrollTop += itemHeight; + } + }, [editorContainerRef, highlightIndex, maxItemNum, displayOptions, itemHeight]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Enter) { + onEnter(event); + } else if (event.keyCode === KeyCodes.UpArrow) { + onUpArrow(event); + } else if (event.keyCode === KeyCodes.DownArrow) { + onDownArrow(event); + } else if (event.keyCode === KeyCodes.Tab) { + if (isFunction(onPressTab)) { + onPressTab(event); + } + } + }, [onEnter, onUpArrow, onDownArrow, onPressTab]); + + const onKeyDown = useCallback((event) => { + if ( + event.keyCode === KeyCodes.ChineseInputMethod || + event.keyCode === KeyCodes.Enter || + event.keyCode === KeyCodes.LeftArrow || + event.keyCode === KeyCodes.RightArrow + ) { + event.stopPropagation(); + } + }, []); + + useEffect(() => { + if (editorRef.current) { + const { bottom } = editorRef.current.getBoundingClientRect(); + if (bottom > window.innerHeight) { + editorRef.current.style.top = (parseInt(editorRef.current.style.top) - bottom + window.innerHeight) + 'px'; + } + } + if (editorContainerRef.current && selectItemRef.current) { + setMaxItemNum(getMaxItemNum()); + } + document.addEventListener('keydown', onHotKey, true); + return () => { + document.removeEventListener('keydown', onHotKey, true); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onHotKey]); + + useEffect(() => { + const highlightIndex = displayOptions.length === 0 ? -1 : 0; + setHighlightIndex(highlightIndex); + }, [displayOptions]); + + useImperativeHandle(ref, () => ({ + getValue: () => { + const { key } = column; + return { [key]: value }; + }, + onBlur: () => blur(), + + }), [column, value, blur]); + + const renderOptions = useCallback(() => { + if (displayOptions.length === 0) { + const noOptionsTip = searchValue ? gettext('No options available') : gettext('No option'); + return ({noOptionsTip}); + } + + return displayOptions.map((option, i) => { + const isSelected = value.includes(option.id); + return ( +
+
onSelectOption(option.id)} + onMouseEnter={() => onMenuMouseEnter(i)} + onMouseLeave={() => onMenuMouseLeave(i)} + > +
+ + {option.name} + +
+
+ {isSelected && ()} +
+
+
+ ); + }); + + }, [displayOptions, searchValue, value, highlightIndex, onMenuMouseEnter, onMenuMouseLeave, onSelectOption]); + + return ( +
+ +
+ +
+
+ {renderOptions()} +
+ {isShowCreateBtn && ( + + )} +
+ ); +}); + +MultipleSelectEditor.propTypes = { + column: PropTypes.object, + value: PropTypes.array, + onCommit: PropTypes.func, + onPressTab: PropTypes.func, +}; + +export default MultipleSelectEditor; diff --git a/frontend/src/metadata/metadata-view/components/detail-editor/index.js b/frontend/src/metadata/metadata-view/components/detail-editor/index.js index b24c4f6b601..0adbf09e1fc 100644 --- a/frontend/src/metadata/metadata-view/components/detail-editor/index.js +++ b/frontend/src/metadata/metadata-view/components/detail-editor/index.js @@ -5,6 +5,7 @@ import CheckboxEditor from './checkbox-editor'; import TextEditor from './text-editor'; import NumberEditor from './number-editor'; import SingleSelectEditor from './single-select-editor'; +import MultipleSelectEditor from './multiple-select-editor'; import CollaboratorEditor from './collaborator-editor'; import DateEditor from './date-editor'; import { lang } from '../../../../utils/constants'; @@ -16,7 +17,6 @@ const DetailEditor = ({ field, onChange: onChangeAPI, ...props }) => { onChangeAPI(field.key, newValue); }, [field, onChangeAPI]); - switch (field.type) { case CellType.CHECKBOX: { return (); @@ -33,6 +33,9 @@ const DetailEditor = ({ field, onChange: onChangeAPI, ...props }) => { case CellType.SINGLE_SELECT: { return (); } + case CellType.MULTIPLE_SELECT: { + return (); + } case CellType.COLLABORATOR: { return (); } diff --git a/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.css b/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.css new file mode 100644 index 00000000000..deb896d0cf0 --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.css @@ -0,0 +1,21 @@ +.sf-metadata-multiple-select-property-detail-editor { + min-height: 34px; + width: 100%; + height: auto; +} + +.sf-metadata-multiple-select-property-detail-editor .sf-metadata-delete-select-options { + min-height: 34px; + border-bottom: none; + background-color: inherit; + border-radius: unset; + padding: 2px 6px; +} + +.sf-metadata-multiple-select-property-detail-editor .sf-metadata-delete-select-options .sf-metadata-delete-select-option { + margin-right: 10px; +} + +.sf-metadata-multiple-select-property-editor-popover .sf-metadata-delete-select-options { + display: none; +} diff --git a/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.js b/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.js new file mode 100644 index 00000000000..5361a8aa17a --- /dev/null +++ b/frontend/src/metadata/metadata-view/components/detail-editor/multiple-select-editor/index.js @@ -0,0 +1,106 @@ +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Popover } from 'reactstrap'; +import { getColumnOptionIdsByNames, getColumnOptions, KeyCodes } from '../../../_basic'; +import { getEventClassName, gettext } from '../../../utils'; +import Editor from '../../cell-editor/multiple-select-editor'; +import DeleteOptions from '../../cell-editor/multiple-select-editor/delete-options'; + +import './index.css'; + +const MultipleSelectEditor = ({ field, value, record, fields, onChange, modifyColumnData }) => { + const ref = useRef(null); + const [showEditor, setShowEditor] = useState(false); + const options = useMemo(() => getColumnOptions(field), [field]); + + const onClick = useCallback((event) => { + if (!event.target) return; + const className = getEventClassName(event); + if (className.indexOf('sf-metadata-search-options') > -1) return; + const dom = document.querySelector('.sf-metadata-multiple-select-editor'); + if (!dom) return; + if (dom.contains(event.target)) return; + if (ref.current && !ref.current.contains(event.target) && showEditor) { + setShowEditor(false); + } + }, [showEditor]); + + const onHotKey = useCallback((event) => { + if (event.keyCode === KeyCodes.Esc) { + if (showEditor) { + setShowEditor(false); + } + } + }, [showEditor]); + + useEffect(() => { + document.addEventListener('mousedown', onClick); + document.addEventListener('keydown', onHotKey, true); + return () => { + document.removeEventListener('mousedown', onClick); + document.removeEventListener('keydown', onHotKey, true); + }; + }, [onClick, onHotKey]); + + const openEditor = useCallback(() => { + setShowEditor(true); + }, []); + + const deleteOption = useCallback((id, event) => { + event && event.stopPropagation(); + event && event.nativeEvent && event.nativeEvent.stopImmediatePropagation(); + const oldValue = getColumnOptionIdsByNames(field, value); + const newValue = oldValue.filter(c => c !== id); + onChange(newValue); + }, [field, value, onChange]); + + const onCommit = useCallback((newValue) => { + onChange(newValue); + }, [onChange]); + + const renderEditor = useCallback(() => { + if (!showEditor) return null; + const { width } = ref.current.getBoundingClientRect(); + return ( + + + + ); + }, [showEditor, onCommit, record, value, modifyColumnData, fields, field]); + + return ( +
+ + {renderEditor()} +
+ ); +}; + +MultipleSelectEditor.propTypes = { + field: PropTypes.object.isRequired, + value: PropTypes.array, + onChange: PropTypes.func.isRequired, +}; + +export default MultipleSelectEditor; diff --git a/frontend/src/metadata/metadata-view/components/popover/column-popover/index.js b/frontend/src/metadata/metadata-view/components/popover/column-popover/index.js index 00f8b417211..f2771448254 100644 --- a/frontend/src/metadata/metadata-view/components/popover/column-popover/index.js +++ b/frontend/src/metadata/metadata-view/components/popover/column-popover/index.js @@ -69,7 +69,7 @@ const ColumnPopover = ({ target, onChange }) => { if (Object.keys(data).length === 0) { data = null; if (!column.unique) { - if (column.type === CellType.SINGLE_SELECT) { + if (column.type === CellType.SINGLE_SELECT || column.type === CellType.MULTIPLE_SELECT) { data = { options: [] }; } else if (column.type === CellType.DATE) { data = { format: DEFAULT_DATE_FORMAT }; diff --git a/frontend/src/metadata/metadata-view/components/popover/column-popover/type/index.js b/frontend/src/metadata/metadata-view/components/popover/column-popover/type/index.js index cd8b3a05d0d..beca8463a28 100644 --- a/frontend/src/metadata/metadata-view/components/popover/column-popover/type/index.js +++ b/frontend/src/metadata/metadata-view/components/popover/column-popover/type/index.js @@ -18,12 +18,13 @@ const COLUMNS = [ { icon: COLUMNS_ICON_CONFIG[CellType.CHECKBOX], type: CellType.CHECKBOX, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.FILE_EXPIRED), unique: true, key: PRIVATE_COLUMN_KEY.FILE_EXPIRED, canChangeName: false, groupby: 'predefined' }, { icon: COLUMNS_ICON_CONFIG[CellType.SINGLE_SELECT], type: CellType.SINGLE_SELECT, name: getColumnDisplayName(PRIVATE_COLUMN_KEY.FILE_STATUS), unique: true, key: PRIVATE_COLUMN_KEY.FILE_STATUS, canChangeName: false, groupby: 'predefined' }, { icon: COLUMNS_ICON_CONFIG[CellType.TEXT], type: CellType.TEXT, name: gettext(COLUMNS_ICON_NAME[CellType.TEXT]), canChangeName: true, key: CellType.TEXT, groupby: 'basics' }, - { icon: COLUMNS_ICON_CONFIG[CellType.CHECKBOX], type: CellType.CHECKBOX, name: gettext(COLUMNS_ICON_NAME[CellType.CHECKBOX]), canChangeName: true, key: CellType.CHECKBOX, groupby: 'basics' }, + { icon: COLUMNS_ICON_CONFIG[CellType.LONG_TEXT], type: CellType.LONG_TEXT, name: gettext(COLUMNS_ICON_NAME[CellType.LONG_TEXT]), canChangeName: true, key: CellType.LONG_TEXT, groupby: 'basics' }, + { icon: COLUMNS_ICON_CONFIG[CellType.NUMBER], type: CellType.NUMBER, name: gettext(COLUMNS_ICON_NAME[CellType.NUMBER]), canChangeName: true, key: CellType.NUMBER, groupby: 'basics' }, { icon: COLUMNS_ICON_CONFIG[CellType.COLLABORATOR], type: CellType.COLLABORATOR, name: gettext(COLUMNS_ICON_NAME[CellType.COLLABORATOR]), canChangeName: true, key: CellType.COLLABORATOR, groupby: 'basics' }, + { icon: COLUMNS_ICON_CONFIG[CellType.CHECKBOX], type: CellType.CHECKBOX, name: gettext(COLUMNS_ICON_NAME[CellType.CHECKBOX]), canChangeName: true, key: CellType.CHECKBOX, groupby: 'basics' }, { icon: COLUMNS_ICON_CONFIG[CellType.DATE], type: CellType.DATE, name: gettext(COLUMNS_ICON_NAME[CellType.DATE]), canChangeName: true, key: CellType.DATE, groupby: 'basics' }, - { icon: COLUMNS_ICON_CONFIG[CellType.LONG_TEXT], type: CellType.LONG_TEXT, name: gettext(COLUMNS_ICON_NAME[CellType.LONG_TEXT]), canChangeName: true, key: CellType.LONG_TEXT, groupby: 'basics' }, { icon: COLUMNS_ICON_CONFIG[CellType.SINGLE_SELECT], type: CellType.SINGLE_SELECT, name: gettext(COLUMNS_ICON_NAME[CellType.SINGLE_SELECT]), canChangeName: true, key: CellType.SINGLE_SELECT, groupby: 'basics' }, - { icon: COLUMNS_ICON_CONFIG[CellType.NUMBER], type: CellType.NUMBER, name: gettext(COLUMNS_ICON_NAME[CellType.NUMBER]), canChangeName: true, key: CellType.NUMBER, groupby: 'basics' }, + { icon: COLUMNS_ICON_CONFIG[CellType.MULTIPLE_SELECT], type: CellType.MULTIPLE_SELECT, name: gettext(COLUMNS_ICON_NAME[CellType.MULTIPLE_SELECT]), canChangeName: true, key: CellType.MULTIPLE_SELECT, groupby: 'basics' }, ]; // eslint-disable-next-line react/display-name diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item/index.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item/index.js index f6be555b8de..3a8734e6a12 100644 --- a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item/index.js +++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item/index.js @@ -481,6 +481,10 @@ class FilterItem extends React.Component { /> ); } + case CellType.MULTIPLE_SELECT: { + let { options = [] } = filterColumn.data || {}; + return this.renderMultipleSelectOption(options, filter_term, readOnly); + } default: { return null; } @@ -523,9 +527,9 @@ class FilterItem extends React.Component { } else if (isCheckboxColumn(filterColumn)) { _isCheckboxColumn = true; } - const isContainPredicate = [FILTER_PREDICATE_TYPE.CONTAINS, FILTER_PREDICATE_TYPE.NOT_CONTAIN].includes(filter_predicate); - const isRenderErrorTips = this.isRenderErrorTips(); - const showToolTip = isContainPredicate && !isRenderErrorTips; + // const isContainPredicate = [].includes(filterColumn.type) && [FILTER_PREDICATE_TYPE.CONTAINS, FILTER_PREDICATE_TYPE.NOT_CONTAIN].includes(filter_predicate); + // const isRenderErrorTips = this.isRenderErrorTips(); + // const showToolTip = isContainPredicate && !isRenderErrorTips; // current predicate is not empty const isNeedShowTermModifier = !EMPTY_PREDICATE.includes(filter_predicate); @@ -574,12 +578,14 @@ class FilterItem extends React.Component {
{this.renderFilterTerm(filterColumn)}
- {showToolTip && -
- - {/* */} -
- } + {/* {showToolTip && ( +
+ + + {gettext('If there are multiple items in the cell, a random one will be chosen and be compared with the filter value.')} + +
+ )} */} {this.renderErrorMessage()} diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/index.css index 721c8fdef60..ba878d733a9 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/index.css +++ b/frontend/src/metadata/metadata-view/components/table/table-main/index.css @@ -1,5 +1,6 @@ /* basic css */ -.sf-metadata-single-select-option { +.sf-metadata-single-select-option, +.sf-metadata-multiple-select-option { border-radius: 10px; font-size: 13px; line-height: 20px; @@ -12,5 +13,3 @@ white-space: nowrap; width: min-content; } - - diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/index.js index 92cd0895a17..df9b03a7648 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/index.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/index.js @@ -7,11 +7,11 @@ import GridUtils from '../../../utils/grid-utils'; import './index.css'; -const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, recordGetterByIndex, recordGetterById, ...params }) => { +const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, recordGetterByIndex, recordGetterById, modifyColumnData, ...params }) => { const gridUtils = useMemo(() => { - return new GridUtils(metadata, { modifyRecord, modifyRecords, recordGetterByIndex, recordGetterById }); - }, [metadata, modifyRecord, modifyRecords, recordGetterByIndex, recordGetterById]); + return new GridUtils(metadata, { modifyRecord, modifyRecords, recordGetterByIndex, recordGetterById, modifyColumnData }); + }, [metadata, modifyRecord, modifyRecords, recordGetterByIndex, recordGetterById, modifyColumnData]); const groupbysCount = useMemo(() => { const groupbys = metadata?.view?.groupbys || []; @@ -63,6 +63,7 @@ const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, s getCopiedRecordsAndColumnsFromRange={getCopiedRecordsAndColumnsFromRange} recordGetterById={recordGetterById} recordGetterByIndex={recordGetterByIndex} + modifyColumnData={modifyColumnData} {...params} /> diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/group-title.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/group-title.js index f3ddb6e9b47..ba35aa42280 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/group-title.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/group-title.js @@ -45,6 +45,29 @@ const GroupTitle = ({ column, cellValue, originalCellValue }) => { const optionName = selectedOption ? selectedOption.name : deletedOptionTip; return (
{optionName}
); } + case CellType.MULTIPLE_SELECT: { + const options = getColumnOptions(column); + if (options.length === 0 || !Array.isArray(originalCellValue) || originalCellValue.length === 0) return emptyTip; + const selectedOptions = options.filter((option) => originalCellValue.includes(option.id) || originalCellValue.includes(option.name)); + const invalidOptionIds = originalCellValue.filter(optionId => optionId && !options.find(o => o.id === optionId || o.name === optionId)); + const invalidOptions = invalidOptionIds.map(optionId => ({ + id: optionId, + name: deletedOptionTip, + color: DELETED_OPTION_BACKGROUND_COLOR, + })); + return ( + <> + {selectedOptions.map(option => { + const style = { backgroundColor: option.color, color: option.textColor }; + return (
{option.name}
); + })} + {invalidOptions.map(option => { + const style = { backgroundColor: option.color }; + return (
{option.name}
); + })} + + ); + } default: { return cellValue || emptyTip; } diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/index.css index c81e11e4ac8..01bef350590 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/index.css +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-body/group-container/index.css @@ -213,6 +213,14 @@ margin-right: 0; } +.canvas-groups-rows .group-title .sf-metadata-multiple-select-option { + margin: 0 10px 0 0; +} + +.canvas-groups-rows .group-title .sf-metadata-multiple-select-option:last-child { + margin-right: 0; +} + .canvas-groups-rows .collaborator-avatar, .canvas-groups-rows .sf-metadata-ui.collaborator-item .collaborator-avatar { height: 16px; diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header/cell/dropdown-menu/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header/cell/dropdown-menu/index.js index 90ee651f075..d6a57cf16b7 100644 --- a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header/cell/dropdown-menu/index.js +++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header/cell/dropdown-menu/index.js @@ -181,6 +181,16 @@ const HeaderDropdownMenu = ({ column, renameColumn, modifyColumnData, deleteColu /> */} )} + {type === CellType.MULTIPLE_SELECT && ( + + )} {type === CellType.NUMBER && ( {renderDateFormat(canModifyColumnData)} )} - {[CellType.DATE, CellType.SINGLE_SELECT, CellType.NUMBER].includes(column.type) && ( + {[CellType.DATE, CellType.SINGLE_SELECT, CellType.NUMBER, CellType.MULTIPLE_SELECT].includes(column.type) && ( )} { + const { type: copiedColumnType } = copiedColumn; + if (!copiedCellVal || + (Array.isArray(copiedCellVal) && copiedCellVal.length === 0) || + !SUPPORT_PASTE_FROM_COLUMN[CellType.MULTIPLE_SELECT].includes(copiedColumnType) + ) { + return { selectedOptionIds: pasteCellVal }; + } + let copiedOptionNames = []; + if (copiedColumnType === CellType.MULTIPLE_SELECT) { + const copiedOptions = getColumnOptions(copiedColumn); + copiedOptionNames = copiedOptions.filter((option) => copiedCellVal.includes(option.id) || copiedCellVal.includes(option.name)) + .map((option) => option.name); + } else if (copiedColumnType === CellType.TEXT) { + const sCopiedCellVal = String(copiedCellVal); + + // Pass excel test, wps test failed + copiedOptionNames = sCopiedCellVal.split('\n'); + + // get option names from string like 'a, b, c' + if (copiedOptionNames.length === 1) { + copiedOptionNames = sCopiedCellVal.split(','); + } + copiedOptionNames = copiedOptionNames.map(name => name.trim()) + .filter(name => name !== ''); + } else if (copiedColumnType === CellType.SINGLE_SELECT) { + const copiedOptions = getColumnOptions(copiedColumn); + copiedOptionNames = copiedOptions.filter((option) => option.id === copiedCellVal) + .map((option) => option.name); + } + if (copiedOptionNames.length === 0) { + return { selectedOptionIds: pasteCellVal }; + } + + const pasteOptions = getColumnOptions(pasteColumn); + const { cellOptions: newCellOptions, selectedOptionIds } = generatorCellOptions(pasteOptions, copiedOptionNames); + return { pasteOptions, newCellOptions, selectedOptionIds }; +}; + +const convert2MultipleSelect = (copiedCellVal, pasteCellVal, copiedColumn, pasteColumn, api) => { + const { newCellOptions, pasteOptions, selectedOptionIds } = _getPasteMultipleSelect(copiedCellVal, pasteCellVal, copiedColumn, pasteColumn); + let newColumn = pasteColumn; + + // the target column have no options with the same name + if (newCellOptions) { + if (!window.sfMetadataContext.canModifyColumnData(pasteColumn)) return null; + const updatedPasteOptions = [...pasteOptions, ...newCellOptions]; + if (!newColumn.data) { + newColumn.data = {}; + } + newColumn.data.options = updatedPasteOptions; + api.modifyColumnData(pasteColumn.key, { options: updatedPasteOptions }, pasteColumn.data); + } + return getColumnOptionNamesByIds(newColumn, selectedOptionIds); +}; + +function convertCellValue(cellValue, oldCellValue, targetColumn, fromColumn, api) { + const { type: fromColumnType, data: fromColumnData } = fromColumn; + const { type: targetColumnType, data: targetColumnData } = targetColumn; + switch (targetColumnType) { + case CellType.CHECKBOX: { + return convert2Checkbox(cellValue, oldCellValue, fromColumnType); + } + case CellType.NUMBER: { + return convert2Number(cellValue, oldCellValue, fromColumnType, targetColumnData); + } + case CellType.DATE: { + return convert2Date(cellValue, oldCellValue, fromColumnType, fromColumnData, targetColumnData); + } + case CellType.SINGLE_SELECT: { + return convert2SingleSelect(cellValue, oldCellValue, fromColumn, targetColumn); + } + case CellType.MULTIPLE_SELECT: { + return convert2MultipleSelect(cellValue, oldCellValue, fromColumn, targetColumn, api); + } + case CellType.LONG_TEXT: { + return convert2LongText(cellValue, oldCellValue, fromColumn); + } + case CellType.TEXT: { + return convert2Text(cellValue, oldCellValue, fromColumn); + } + case CellType.COLLABORATOR: { + return convert2Collaborator(cellValue, oldCellValue, fromColumnType); + } + default: { + return oldCellValue; + } + } +} + export { convertCellValue }; diff --git a/frontend/src/metadata/metadata-view/utils/grid-utils.js b/frontend/src/metadata/metadata-view/utils/grid-utils.js index a98977d374d..6cc93f9747f 100644 --- a/frontend/src/metadata/metadata-view/utils/grid-utils.js +++ b/frontend/src/metadata/metadata-view/utils/grid-utils.js @@ -107,7 +107,7 @@ class GridUtils { const copiedColumnName = getColumnOriginName(copiedColumn); const pasteCellValue = Object.prototype.hasOwnProperty.call(pasteRecord, pasteColumnName) ? getCellValueByColumn(pasteRecord, pasteColumn) : null; const copiedCellValue = Object.prototype.hasOwnProperty.call(copiedRecord, copiedColumnName) ? getCellValueByColumn(copiedRecord, copiedColumn) : null; - const update = convertCellValue(copiedCellValue, pasteCellValue, pasteColumn, copiedColumn); + const update = convertCellValue(copiedCellValue, pasteCellValue, pasteColumn, copiedColumn, this.api); if (update === pasteCellValue) { continue; }