>
);
};
diff --git a/frontend/src/components/dir-view-mode/dir-views/index.js b/frontend/src/components/dir-view-mode/dir-views/index.js
index 2c35ad62c1..f2e7b8a5b9 100644
--- a/frontend/src/components/dir-view-mode/dir-views/index.js
+++ b/frontend/src/components/dir-view-mode/dir-views/index.js
@@ -3,14 +3,13 @@ import PropTypes from 'prop-types';
import { gettext } from '../../../utils/constants';
import { Utils } from '../../../utils/utils';
import TreeSection from '../../tree-section';
-import MetadataStatusManagementDialog from '../../metadata-manage/metadata-status-manage-dialog';
-import metadataManagerAPI from '../../metadata-manage/api';
+import { MetadataStatusManagementDialog, MetadataTreeView } from '../../../metadata';
+import metadataAPI from '../../../metadata/api';
import toaster from '../../toast';
-import MetadataViews from '../../metadata-manage/metadata-views';
import './index.css';
-const DirViews = ({ userPerm, repoID }) => {
+const DirViews = ({ userPerm, repoID, currentPath, onNodeClick }) => {
const enableMetadataManagement = useMemo(() => {
return window.app.pageOptions.enableMetadataManagement;
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -33,7 +32,7 @@ const DirViews = ({ userPerm, repoID }) => {
return;
}
- const repoMetadataManagementEnabledStatusRes = metadataManagerAPI.getRepoMetadataManagementEnabledStatus(repoID);
+ const repoMetadataManagementEnabledStatusRes = metadataAPI.getMetadataStatus(repoID);
Promise.all([repoMetadataManagementEnabledStatusRes]).then(results => {
const [repoMetadataManagementEnabledStatusRes] = results;
setMetadataStatus(repoMetadataManagementEnabledStatusRes.data.enabled);
@@ -65,7 +64,7 @@ const DirViews = ({ userPerm, repoID }) => {
return (
<>
- {!loading && metadataStatus && ()}
+ {!loading && metadataStatus && ()}
{showMetadataStatusManagementDialog && (
@@ -77,6 +76,8 @@ const DirViews = ({ userPerm, repoID }) => {
DirViews.propTypes = {
userPerm: PropTypes.string,
repoID: PropTypes.string,
+ currentPath: PropTypes.string,
+ onNodeClick: PropTypes.func,
};
export default DirViews;
diff --git a/frontend/src/components/metadata-manage/metadata-views/index.js b/frontend/src/components/metadata-manage/metadata-views/index.js
deleted file mode 100644
index 653db268cf..0000000000
--- a/frontend/src/components/metadata-manage/metadata-views/index.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import PropTypes from 'prop-types';
-import { gettext } from '../../../utils/constants';
-import { siteRoot } from '../../../utils/constants';
-import Icon from '../../icon';
-
-import './index.css';
-
-const MetadataViews = ({ repoID }) => {
- const [highlight, setHighlight] = useState(false);
-
- const onMouseEnter = useCallback(() => {
- setHighlight(true);
- }, []);
-
- const onMouseOver = useCallback(() => {
- setHighlight(true);
- }, []);
-
- const onMouseLeave = useCallback(() => {
- setHighlight(false);
- }, []);
-
- const openView = useCallback(() => {
- const server = siteRoot.substring(0, siteRoot.length-1);
- window.open(server + '/repos/' + repoID + '/metadata/table-view/', '_blank');
- }, [repoID]);
-
- return (
-
-
-
-
-
{gettext('File extended properties')}
-
-
-
-
-
- );
-};
-
-MetadataViews.propTypes = {
- repoID: PropTypes.string.isRequired,
-};
-
-export default MetadataViews;
diff --git a/frontend/src/constants/index.js b/frontend/src/constants/index.js
index 62960ffb5b..12c5633eee 100644
--- a/frontend/src/constants/index.js
+++ b/frontend/src/constants/index.js
@@ -106,6 +106,10 @@ export const DURATION_DECIMAL_DIGITS = {
[DURATION_FORMATS_MAP.H_MM_SS_SSS]: 3,
};
+export const PRIVATE_FILE_TYPE = {
+ FILE_EXTENDED_PROPERTIES: '__file_extended_properties'
+};
+
const TAG_COLORS = ['#FBD44A', '#EAA775', '#F4667C', '#DC82D2', '#9860E5', '#9F8CF1', '#59CB74', '#ADDF84',
'#89D2EA', '#4ECCCB', '#46A1FD', '#C2C2C2'];
diff --git a/frontend/src/css/lib-content-view.css b/frontend/src/css/lib-content-view.css
index 5f798ac980..eb6acd4b44 100644
--- a/frontend/src/css/lib-content-view.css
+++ b/frontend/src/css/lib-content-view.css
@@ -11,6 +11,7 @@
width: 100%;
height: calc(100% - 48px);
border-top: 1px solid #e8e8e8;
+ transform: translateZ(10px);
}
.view-mode-container {
diff --git a/frontend/src/components/metadata-manage/api.js b/frontend/src/metadata/api.js
similarity index 76%
rename from frontend/src/components/metadata-manage/api.js
rename to frontend/src/metadata/api.js
index 50394363c6..95efb72242 100644
--- a/frontend/src/components/metadata-manage/api.js
+++ b/frontend/src/metadata/api.js
@@ -1,6 +1,6 @@
import axios from 'axios';
import cookie from 'react-cookies';
-import { siteRoot } from '../../utils/constants';
+import { siteRoot } from '../utils/constants';
class MetadataManagerAPI {
init({ server, username, password, token }) {
@@ -43,22 +43,22 @@ class MetadataManagerAPI {
}
}
- getRepoMetadataManagementEnabledStatus(repoID) {
+ getMetadataStatus(repoID) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/';
return this.req.get(url);
}
- openRepoMetadataManagement(repoID) {
+ createMetadata(repoID) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/';
return this.req.put(url);
}
- closeRepoMetadataManagement(repoID) {
+ deleteMetadata(repoID) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/';
return this.req.delete(url);
}
- getMetadataRecords(repoID, params) {
+ getMetadata(repoID, params) {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/';
return this.req.get(url, {params: params});
}
@@ -72,7 +72,7 @@ class MetadataManagerAPI {
return this.req.post(url, data);
}
- updateMetadataRecord(repoID, recordID, creator, createTime, modifier, modifyTime, parentDir, name) {
+ updateMetadataRecord = (repoID, recordID, creator, createTime, modifier, modifyTime, parentDir, name) => {
const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/records/' + recordID + '/';
const data = {
'creator': creator,
@@ -83,16 +83,23 @@ class MetadataManagerAPI {
'name': name,
};
return this.req.put(url, data);
- }
+ };
- deleteMetadataRecord(repoID, recordID) {
+ 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 };
+ return this._sendPostRequest(url, params, { headers: { 'Content-type': 'application/json' } });
+ };
+
}
-const metadataManagerAPI = new MetadataManagerAPI();
+const metadataAPI = new MetadataManagerAPI();
const xcsrfHeaders = cookie.load('sfcsrftoken');
-metadataManagerAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders });
+metadataAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders });
-export default metadataManagerAPI;
+export default metadataAPI;
diff --git a/frontend/src/metadata/index.js b/frontend/src/metadata/index.js
new file mode 100644
index 0000000000..321bae7f08
--- /dev/null
+++ b/frontend/src/metadata/index.js
@@ -0,0 +1,9 @@
+import SeafileMetadata from './metadata-view';
+import MetadataStatusManagementDialog from './metadata-status-manage-dialog';
+import MetadataTreeView from './metadata-tree-view';
+
+export {
+ SeafileMetadata,
+ MetadataStatusManagementDialog,
+ MetadataTreeView,
+};
diff --git a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.css b/frontend/src/metadata/metadata-status-manage-dialog/index.css
similarity index 100%
rename from frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.css
rename to frontend/src/metadata/metadata-status-manage-dialog/index.css
diff --git a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js b/frontend/src/metadata/metadata-status-manage-dialog/index.js
similarity index 86%
rename from frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js
rename to frontend/src/metadata/metadata-status-manage-dialog/index.js
index 5f4f3e3b1d..19381d5a49 100644
--- a/frontend/src/components/metadata-manage/metadata-status-manage-dialog/index.js
+++ b/frontend/src/metadata/metadata-status-manage-dialog/index.js
@@ -1,11 +1,11 @@
import React, { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap';
-import { gettext } from '../../../utils/constants';
-import Switch from '../../common/switch';
-import metadataManagerAPI from '../api';
-import { Utils } from '../../../utils/utils';
-import toaster from '../../toast';
+import { gettext } from '../../utils/constants';
+import Switch from '../../components/common/switch';
+import metadataAPI from '../api';
+import { Utils } from '../../utils/utils';
+import toaster from '../../components/toast';
import './index.css';
@@ -20,8 +20,8 @@ const MetadataStatusManagementDialog = ({ value: oldValue, repoID, toggle, submi
const onSubmit = useCallback(() => {
setSubmitting(true);
- const apiName = value ? 'openRepoMetadataManagement' : 'closeRepoMetadataManagement';
- metadataManagerAPI[apiName](repoID).then(res => {
+ const apiName = value ? 'createMetadata' : 'deleteMetadata';
+ metadataAPI[apiName](repoID).then(res => {
submit(value);
toggle();
}).catch(error => {
diff --git a/frontend/src/components/metadata-manage/metadata-views/index.css b/frontend/src/metadata/metadata-tree-view/index.css
similarity index 100%
rename from frontend/src/components/metadata-manage/metadata-views/index.css
rename to frontend/src/metadata/metadata-tree-view/index.css
diff --git a/frontend/src/metadata/metadata-tree-view/index.js b/frontend/src/metadata/metadata-tree-view/index.js
new file mode 100644
index 0000000000..2258ebf5d1
--- /dev/null
+++ b/frontend/src/metadata/metadata-tree-view/index.js
@@ -0,0 +1,74 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { gettext } from '../../utils/constants';
+import Icon from '../../components/icon';
+import { PRIVATE_FILE_TYPE } from '../../constants';
+
+import './index.css';
+
+const MetadataTreeView = ({ repoID, currentPath, onNodeClick }) => {
+ const node = useMemo(() => {
+ return {
+ children: [],
+ path: '/' + PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES,
+ isExpanded: false,
+ isLoaded: true,
+ isPreload: true,
+ object: {
+ file_tags: [],
+ id: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES,
+ name: gettext('File extended properties'),
+ type: PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES,
+ isDir: () => false,
+ },
+ parentNode: {},
+ key: repoID,
+ };
+ }, [repoID]);
+ const [highlight, setHighlight] = useState(false);
+
+ const onMouseEnter = useCallback(() => {
+ setHighlight(true);
+ }, []);
+
+ const onMouseOver = useCallback(() => {
+ setHighlight(true);
+ }, []);
+
+ const onMouseLeave = useCallback(() => {
+ setHighlight(false);
+ }, []);
+
+ return (
+
+
+
+
onNodeClick(node)}
+ >
+
{gettext('File extended properties')}
+
+
+
+
+
+ );
+};
+
+MetadataTreeView.propTypes = {
+ repoID: PropTypes.string.isRequired,
+ currentPath: PropTypes.string,
+ onNodeClick: PropTypes.func,
+};
+
+export default MetadataTreeView;
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/format.js b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js
new file mode 100644
index 0000000000..f5331cd69a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/column/format.js
@@ -0,0 +1,82 @@
+import CellType from './type';
+
+const DATE_COLUMN_OPTIONS = [
+ CellType.CTIME, CellType.MTIME,
+];
+const NUMERIC_COLUMNS_TYPES = [
+
+];
+const COLLABORATOR_COLUMN_TYPES = [
+ CellType.CREATOR, CellType.LAST_MODIFIER,
+];
+
+// date
+const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
+const UTC_FORMAT_DEFAULT = 'YYYY-MM-DDTHH:mm:ss.SSSZ';
+const DATE_UNIT = {
+ YEAR: 'year',
+ MONTH: 'month',
+ WEEK: 'week',
+ DAY: 'day',
+ HOUR: 'hour',
+ HOURS: 'hours',
+ MINUTE: 'minute',
+ MINUTES: 'minutes',
+ SECOND: 'second',
+};
+const DATE_FORMAT_MAP = {
+ YYYY_MM_DD: 'YYYY-MM-DD',
+ YYYY_MM_DD_HH_MM: 'YYYY-MM-DD HH:mm',
+ YYYY_MM_DD_HH_MM_SS: 'YYYY-MM-DD HH:mm:ss',
+};
+
+// number
+const DEFAULT_NUMBER_FORMAT = 'number';
+
+const NOT_SUPPORT_EDIT_COLUMN_TYPE = [
+ CellType.CTIME,
+ CellType.MTIME,
+ CellType.CREATOR,
+ CellType.LAST_MODIFIER,
+];
+
+const NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP = {
+ [CellType.CTIME]: true,
+ [CellType.MTIME]: true,
+ [CellType.CREATOR]: true,
+ [CellType.LAST_MODIFIER]: true,
+};
+
+const MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP = {
+
+};
+const SINGLE_CELL_VALUE_COLUMN_TYPE_MAP = {
+ [CellType.TEXT]: true,
+ [CellType.CTIME]: true,
+ [CellType.MTIME]: true,
+ [CellType.CREATOR]: true,
+ [CellType.LAST_MODIFIER]: true,
+};
+
+const DATE_DEFAULT_TYPES = {
+ SPECIFIC_DATE: 'specific_date',
+ CURRENT_DATE: 'current_date',
+ DAYS_BEFORE: 'days_before',
+ DAYS_AFTER: 'days_after',
+};
+
+export {
+ COLLABORATOR_COLUMN_TYPES,
+ DATE_COLUMN_OPTIONS,
+ NUMERIC_COLUMNS_TYPES,
+ DEFAULT_DATE_FORMAT,
+ UTC_FORMAT_DEFAULT,
+ DATE_UNIT,
+ DATE_FORMAT_MAP,
+ DEFAULT_NUMBER_FORMAT,
+ DATE_DEFAULT_TYPES,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+ MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ SINGLE_CELL_VALUE_COLUMN_TYPE_MAP,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js
new file mode 100644
index 0000000000..99b1c55da7
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/column/icon.js
@@ -0,0 +1,24 @@
+import CellType from './type';
+
+const COLUMNS_ICON_CONFIG = {
+ [CellType.CREATOR]: 'creator',
+ [CellType.LAST_MODIFIER]: 'creator',
+ [CellType.CTIME]: 'creation-time',
+ [CellType.MTIME]: 'creation-time',
+ [CellType.DEFAULT]: 'text',
+ [CellType.TEXT]: 'text',
+};
+
+const COLUMNS_ICON_NAME = {
+ [CellType.CREATOR]: 'Creator',
+ [CellType.LAST_MODIFIER]: 'Last_modifier',
+ [CellType.CTIME]: 'CTime',
+ [CellType.MTIME]: 'Last_modified_time',
+ [CellType.DEFAULT]: 'Text',
+ [CellType.TEXT]: 'Text',
+};
+
+export {
+ COLUMNS_ICON_CONFIG,
+ COLUMNS_ICON_NAME,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/index.js b/frontend/src/metadata/metadata-view/_basic/constants/column/index.js
new file mode 100644
index 0000000000..51a8e8d493
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/column/index.js
@@ -0,0 +1,27 @@
+import CellType from './type';
+import {
+ COLUMNS_ICON_CONFIG,
+ COLUMNS_ICON_NAME,
+} from './icon';
+
+export {
+ COLLABORATOR_COLUMN_TYPES,
+ DATE_COLUMN_OPTIONS,
+ NUMERIC_COLUMNS_TYPES,
+ DEFAULT_DATE_FORMAT,
+ UTC_FORMAT_DEFAULT,
+ DATE_UNIT,
+ DATE_FORMAT_MAP,
+ DEFAULT_NUMBER_FORMAT,
+ DATE_DEFAULT_TYPES,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+ MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ SINGLE_CELL_VALUE_COLUMN_TYPE_MAP,
+} from './format';
+
+export {
+ CellType,
+ COLUMNS_ICON_CONFIG,
+ COLUMNS_ICON_NAME,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/column/type.js b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js
new file mode 100644
index 0000000000..6b04be5e40
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/column/type.js
@@ -0,0 +1,10 @@
+const CellType = {
+ DEFAULT: 'default',
+ TEXT: 'text',
+ CREATOR: 'creator',
+ CTIME: 'ctime',
+ LAST_MODIFIER: 'last-modifier',
+ MTIME: 'mtime',
+};
+
+export default CellType;
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
new file mode 100644
index 0000000000..3b967fb2d9
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-column-options.js
@@ -0,0 +1,84 @@
+import { CellType } from '../column';
+import { FILTER_TERM_MODIFIER_TYPE } from './filter-modifier';
+import { FILTER_PREDICATE_TYPE } from './filter-predicate';
+
+const textPredicates = [
+ FILTER_PREDICATE_TYPE.CONTAINS,
+ FILTER_PREDICATE_TYPE.NOT_CONTAIN,
+ FILTER_PREDICATE_TYPE.IS,
+ FILTER_PREDICATE_TYPE.IS_NOT,
+ FILTER_PREDICATE_TYPE.EMPTY,
+ FILTER_PREDICATE_TYPE.NOT_EMPTY,
+ FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID,
+];
+
+const datePredicates = [
+ FILTER_PREDICATE_TYPE.IS,
+ FILTER_PREDICATE_TYPE.IS_WITHIN,
+ FILTER_PREDICATE_TYPE.IS_BEFORE,
+ FILTER_PREDICATE_TYPE.IS_AFTER,
+ FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE,
+ FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER,
+ FILTER_PREDICATE_TYPE.IS_NOT,
+ FILTER_PREDICATE_TYPE.EMPTY,
+ FILTER_PREDICATE_TYPE.NOT_EMPTY,
+];
+
+const dateTermModifiers = [
+ FILTER_TERM_MODIFIER_TYPE.TODAY,
+ FILTER_TERM_MODIFIER_TYPE.TOMORROW,
+ FILTER_TERM_MODIFIER_TYPE.YESTERDAY,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+];
+
+const FILTER_COLUMN_OPTIONS = {
+ [CellType.TEXT]: {
+ filterPredicateList: textPredicates,
+ },
+ [CellType.CTIME]: {
+ filterPredicateList: datePredicates,
+ filterTermModifierList: dateTermModifiers,
+ },
+ [CellType.MTIME]: {
+ filterPredicateList: datePredicates,
+ filterTermModifierList: dateTermModifiers,
+ },
+ [CellType.CREATOR]: {
+ filterPredicateList: [
+ FILTER_PREDICATE_TYPE.CONTAINS,
+ FILTER_PREDICATE_TYPE.NOT_CONTAIN,
+ FILTER_PREDICATE_TYPE.INCLUDE_ME,
+ FILTER_PREDICATE_TYPE.IS,
+ FILTER_PREDICATE_TYPE.IS_NOT,
+ ],
+ },
+ [CellType.LAST_MODIFIER]: {
+ filterPredicateList: [
+ FILTER_PREDICATE_TYPE.CONTAINS,
+ FILTER_PREDICATE_TYPE.NOT_CONTAIN,
+ FILTER_PREDICATE_TYPE.INCLUDE_ME,
+ FILTER_PREDICATE_TYPE.IS,
+ FILTER_PREDICATE_TYPE.IS_NOT,
+ ],
+ },
+ [CellType.URL]: {
+ filterPredicateList: [
+ FILTER_PREDICATE_TYPE.CONTAINS,
+ FILTER_PREDICATE_TYPE.NOT_CONTAIN,
+ FILTER_PREDICATE_TYPE.IS,
+ FILTER_PREDICATE_TYPE.IS_NOT,
+ FILTER_PREDICATE_TYPE.EMPTY,
+ FILTER_PREDICATE_TYPE.NOT_EMPTY,
+ ],
+ },
+};
+
+export {
+ FILTER_COLUMN_OPTIONS,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js
new file mode 100644
index 0000000000..cb2f89f2b9
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-is-within.js
@@ -0,0 +1,30 @@
+import { FILTER_TERM_MODIFIER_TYPE } from './filter-modifier';
+
+const filterTermModifierNotWithin = [
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+ FILTER_TERM_MODIFIER_TYPE.TODAY,
+ FILTER_TERM_MODIFIER_TYPE.TOMORROW,
+ FILTER_TERM_MODIFIER_TYPE.YESTERDAY,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+];
+
+const filterTermModifierIsWithin = [
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR,
+ FILTER_TERM_MODIFIER_TYPE.THIS_WEEK,
+ FILTER_TERM_MODIFIER_TYPE.THIS_MONTH,
+ FILTER_TERM_MODIFIER_TYPE.THIS_YEAR,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS,
+];
+
+export { filterTermModifierNotWithin, filterTermModifierIsWithin };
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js
new file mode 100644
index 0000000000..0e4d5f0248
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-modifier.js
@@ -0,0 +1,54 @@
+import { gettext } from '../../../../../utils/constants';
+
+const FILTER_TERM_MODIFIER_TYPE = {
+ TODAY: 'today',
+ TOMORROW: 'tomorrow',
+ YESTERDAY: 'yesterday',
+ ONE_WEEK_AGO: 'one_week_ago',
+ ONE_WEEK_FROM_NOW: 'one_week_from_now',
+ ONE_MONTH_AGO: 'one_month_ago',
+ ONE_MONTH_FROM_NOW: 'one_month_from_now',
+ NUMBER_OF_DAYS_AGO: 'number_of_days_ago',
+ NUMBER_OF_DAYS_FROM_NOW: 'number_of_days_from_now',
+ EXACT_DATE: 'exact_date',
+ THE_PAST_WEEK: 'the_past_week',
+ THE_PAST_MONTH: 'the_past_month',
+ THE_PAST_YEAR: 'the_past_year',
+ THE_NEXT_WEEK: 'the_next_week',
+ THE_NEXT_MONTH: 'the_next_month',
+ THE_NEXT_YEAR: 'the_next_year',
+ THE_NEXT_NUMBERS_OF_DAYS: 'the_next_numbers_of_days',
+ THE_PAST_NUMBERS_OF_DAYS: 'the_past_numbers_of_days',
+ THIS_WEEK: 'this_week',
+ THIS_MONTH: 'this_month',
+ THIS_YEAR: 'this_year',
+};
+
+const FILTER_TERM_MODIFIER_SHOW = {
+ [FILTER_TERM_MODIFIER_TYPE.TODAY]: gettext('Today'),
+ [FILTER_TERM_MODIFIER_TYPE.TOMORROW]: gettext('Tomorrow'),
+ [FILTER_TERM_MODIFIER_TYPE.YESTERDAY]: gettext('Yesterday'),
+ [FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO]: gettext('One_week_ago'),
+ [FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW]: gettext('One_week_from_now'),
+ [FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO]: gettext('One_month_ago'),
+ [FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW]: gettext('One_month_from_now'),
+ [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO]: gettext('Number_of_days_ago'),
+ [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW]: gettext('Number_of_days_from_now'),
+ [FILTER_TERM_MODIFIER_TYPE.EXACT_DATE]: gettext('Exact_date'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK]: gettext('The_past_week'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH]: gettext('The_past_month'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR]: gettext('The_past_year'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK]: gettext('The_next_week'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH]: gettext('The_next_month'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR]: gettext('The_next_year'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS]: gettext('The_next_numbers_of_days'),
+ [FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS]: gettext('The_past_numbers_of_days'),
+ [FILTER_TERM_MODIFIER_TYPE.THIS_WEEK]: gettext('This_week'),
+ [FILTER_TERM_MODIFIER_TYPE.THIS_MONTH]: gettext('This_month'),
+ [FILTER_TERM_MODIFIER_TYPE.THIS_YEAR]: gettext('This_year'),
+};
+
+export {
+ FILTER_TERM_MODIFIER_TYPE,
+ FILTER_TERM_MODIFIER_SHOW,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js
new file mode 100644
index 0000000000..b999ca1063
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/filter-predicate.js
@@ -0,0 +1,57 @@
+const FILTER_PREDICATE_TYPE = {
+ CONTAINS: 'contains',
+ NOT_CONTAIN: 'does_not_contain',
+ IS: 'is',
+ IS_NOT: 'is_not',
+ EQUAL: 'equal',
+ NOT_EQUAL: 'not_equal',
+ LESS: 'less',
+ GREATER: 'greater',
+ LESS_OR_EQUAL: 'less_or_equal',
+ GREATER_OR_EQUAL: 'greater_or_equal',
+ EMPTY: 'is_empty',
+ NOT_EMPTY: 'is_not_empty',
+ IS_WITHIN: 'is_within',
+ IS_BEFORE: 'is_before',
+ IS_AFTER: 'is_after',
+ IS_ON_OR_BEFORE: 'is_on_or_before',
+ IS_ON_OR_AFTER: 'is_on_or_after',
+ HAS_ANY_OF: 'has_any_of',
+ HAS_ALL_OF: 'has_all_of',
+ HAS_NONE_OF: 'has_none_of',
+ IS_EXACTLY: 'is_exactly',
+ INCLUDE_ME: 'include_me',
+ IS_CURRENT_USER_ID: 'is_current_user_ID',
+ IS_ANY_OF: 'is_any_of',
+ IS_NONE_OF: 'is_none_of',
+};
+
+const FILTER_PREDICATE_SHOW = {
+ [FILTER_PREDICATE_TYPE.CONTAINS]: 'contains',
+ [FILTER_PREDICATE_TYPE.NOT_CONTAIN]: 'does not contain',
+ [FILTER_PREDICATE_TYPE.IS]: 'is',
+ [FILTER_PREDICATE_TYPE.IS_NOT]: 'is not',
+ [FILTER_PREDICATE_TYPE.EQUAL]: '\u003d',
+ [FILTER_PREDICATE_TYPE.NOT_EQUAL]: '\u2260',
+ [FILTER_PREDICATE_TYPE.LESS]: '\u003C',
+ [FILTER_PREDICATE_TYPE.GREATER]: '\u003E',
+ [FILTER_PREDICATE_TYPE.LESS_OR_EQUAL]: '\u2264',
+ [FILTER_PREDICATE_TYPE.GREATER_OR_EQUAL]: '\u2265',
+ [FILTER_PREDICATE_TYPE.EMPTY]: 'is empty',
+ [FILTER_PREDICATE_TYPE.NOT_EMPTY]: 'is not empty',
+ [FILTER_PREDICATE_TYPE.IS_WITHIN]: 'is within...',
+ [FILTER_PREDICATE_TYPE.IS_BEFORE]: 'is before...',
+ [FILTER_PREDICATE_TYPE.IS_AFTER]: 'is after...',
+ [FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE]: 'is on or before...',
+ [FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER]: 'is on or after...',
+ [FILTER_PREDICATE_TYPE.HAS_ANY_OF]: 'has any of...',
+ [FILTER_PREDICATE_TYPE.HAS_ALL_OF]: 'has all of...',
+ [FILTER_PREDICATE_TYPE.HAS_NONE_OF]: 'has none of...',
+ [FILTER_PREDICATE_TYPE.IS_EXACTLY]: 'is exactly...',
+ [FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID]: 'is current user\'s ID',
+};
+
+export {
+ FILTER_PREDICATE_TYPE,
+ FILTER_PREDICATE_SHOW,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js b/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js
new file mode 100644
index 0000000000..4d68d723ae
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/filter/index.js
@@ -0,0 +1,36 @@
+const FILTER_CONJUNCTION_TYPE = {
+ AND: 'And',
+ OR: 'Or',
+};
+
+const FILTER_ERR_MSG = {
+ INVALID_FILTER: 'invalid filter',
+ INCOMPLETE_FILTER: 'incomplete filter',
+ COLUMN_MISSING: 'the column to filter does not exist',
+ COLUMN_NOT_SUPPORTED: 'the column to filter is not supported',
+ UNMATCHED_PREDICATE: 'unmatched filter predicate',
+ UNMATCHED_MODIFIER: 'unmatched filter modifier',
+ INVALID_TERM: 'invalid filter term',
+};
+
+export {
+ FILTER_CONJUNCTION_TYPE,
+ FILTER_ERR_MSG,
+};
+
+export { FILTER_COLUMN_OPTIONS } from './filter-column-options';
+
+export {
+ FILTER_TERM_MODIFIER_TYPE,
+ FILTER_TERM_MODIFIER_SHOW,
+} from './filter-modifier';
+
+export {
+ FILTER_PREDICATE_TYPE,
+ FILTER_PREDICATE_SHOW,
+} from './filter-predicate';
+
+export {
+ filterTermModifierIsWithin,
+ filterTermModifierNotWithin,
+} from './filter-is-within';
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js b/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js
new file mode 100644
index 0000000000..a107585872
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/grid-header.js
@@ -0,0 +1,8 @@
+const HEADER_HEIGHT_TYPE = {
+ DEFAULT: 'default',
+ DOUBLE: 'double',
+};
+
+export {
+ HEADER_HEIGHT_TYPE,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/group.js b/frontend/src/metadata/metadata-view/_basic/constants/group.js
new file mode 100644
index 0000000000..f0ed73d6ad
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/group.js
@@ -0,0 +1,58 @@
+import { CellType } from './column';
+
+const MAX_GROUP_LEVEL = 3;
+
+const GROUP_DATE_GRANULARITY = {
+ DAY: 'day',
+ WEEK: 'week',
+ MONTH: 'month',
+ QUARTAR: 'quartar',
+ YEAR: 'year',
+};
+
+const DISPLAY_GROUP_DATE_GRANULARITY = {
+ [GROUP_DATE_GRANULARITY.DAY]: 'By_day',
+ [GROUP_DATE_GRANULARITY.WEEK]: 'By_week',
+ [GROUP_DATE_GRANULARITY.MONTH]: 'By_month',
+ [GROUP_DATE_GRANULARITY.QUARTAR]: 'By_quarter',
+ [GROUP_DATE_GRANULARITY.YEAR]: 'By_year',
+};
+
+const GROUP_GEOLOCATION_GRANULARITY = {
+ PROVINCE: 'province',
+ CITY: 'city',
+ DISTRICT: 'district',
+ COUNTRY: 'country',
+};
+
+const DISPLAY_GROUP_GEOLOCATION_GRANULARITY = {
+ [GROUP_GEOLOCATION_GRANULARITY.PROVINCE]: 'By_province',
+ [GROUP_GEOLOCATION_GRANULARITY.CITY]: 'By_city',
+ [GROUP_GEOLOCATION_GRANULARITY.DISTRICT]: 'By_district',
+};
+
+const SUPPORT_GROUP_COLUMN_TYPES = [
+ CellType.TEXT,
+ CellType.CTIME,
+ CellType.MTIME,
+ CellType.CREATOR,
+ CellType.LAST_MODIFIER,
+];
+
+const GROUPBY_DATE_GRANULARITY_LIST = [
+ GROUP_DATE_GRANULARITY.DAY,
+ GROUP_DATE_GRANULARITY.WEEK,
+ GROUP_DATE_GRANULARITY.MONTH,
+ GROUP_DATE_GRANULARITY.QUARTAR,
+ GROUP_DATE_GRANULARITY.YEAR,
+];
+
+export {
+ MAX_GROUP_LEVEL,
+ GROUP_DATE_GRANULARITY,
+ DISPLAY_GROUP_DATE_GRANULARITY,
+ GROUP_GEOLOCATION_GRANULARITY,
+ DISPLAY_GROUP_GEOLOCATION_GRANULARITY,
+ SUPPORT_GROUP_COLUMN_TYPES,
+ GROUPBY_DATE_GRANULARITY_LIST,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/index.js b/frontend/src/metadata/metadata-view/_basic/constants/index.js
new file mode 100644
index 0000000000..040dfff621
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/index.js
@@ -0,0 +1,63 @@
+import KeyCodes from './key-codes';
+import * as Z_INDEX from './z-index';
+
+export {
+ CellType,
+ COLUMNS_ICON_CONFIG,
+ COLUMNS_ICON_NAME,
+ COLLABORATOR_COLUMN_TYPES,
+ DATE_COLUMN_OPTIONS,
+ NUMERIC_COLUMNS_TYPES,
+ DEFAULT_DATE_FORMAT,
+ UTC_FORMAT_DEFAULT,
+ DATE_UNIT,
+ DATE_FORMAT_MAP,
+ DEFAULT_NUMBER_FORMAT,
+ DATE_DEFAULT_TYPES,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+ MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ SINGLE_CELL_VALUE_COLUMN_TYPE_MAP,
+} from './column';
+export {
+ FILTER_CONJUNCTION_TYPE,
+ FILTER_ERR_MSG,
+ FILTER_COLUMN_OPTIONS,
+ FILTER_TERM_MODIFIER_TYPE,
+ FILTER_TERM_MODIFIER_SHOW,
+ FILTER_PREDICATE_TYPE,
+ FILTER_PREDICATE_SHOW,
+ filterTermModifierIsWithin,
+ filterTermModifierNotWithin,
+} from './filter';
+export {
+ MAX_GROUP_LEVEL,
+ GROUP_DATE_GRANULARITY,
+ DISPLAY_GROUP_DATE_GRANULARITY,
+ GROUP_GEOLOCATION_GRANULARITY,
+ DISPLAY_GROUP_GEOLOCATION_GRANULARITY,
+ SUPPORT_GROUP_COLUMN_TYPES,
+ GROUPBY_DATE_GRANULARITY_LIST,
+} from './group';
+export {
+ HEADER_HEIGHT_TYPE
+} from './grid-header';
+export {
+ REG_STRING_NUMBER_PARTS,
+ REG_NUMBER_DIGIT,
+} from './reg';
+export {
+ SELECT_OPTION_COLORS,
+ HIGHLIGHT_COLORS,
+} from './select-option';
+export {
+ SORT_TYPE,
+ SORT_COLUMN_OPTIONS,
+ TEXT_SORTER_COLUMN_TYPES,
+ NUMBER_SORTER_COLUMN_TYPES,
+} from './sort';
+
+export {
+ KeyCodes,
+ Z_INDEX,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js b/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js
new file mode 100644
index 0000000000..5aed77093a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/key-codes.js
@@ -0,0 +1,102 @@
+module.exports = {
+ Backspace: 8,
+ Tab: 9,
+ Enter: 13,
+ Shift: 16,
+ Ctrl: 17,
+ Alt: 18,
+ PauseBreak: 19,
+ CapsLock: 20,
+ Escape: 27,
+ Esc: 27,
+ Space: 32,
+ PageUp: 33,
+ PageDown: 34,
+ End: 35,
+ Home: 36,
+ LeftArrow: 37,
+ UpArrow: 38,
+ RightArrow: 39,
+ DownArrow: 40,
+ Insert: 45,
+ Delete: 46,
+ 0: 48,
+ 1: 49,
+ 2: 50,
+ 3: 51,
+ 4: 52,
+ 5: 53,
+ 6: 54,
+ 7: 55,
+ 8: 56,
+ 9: 57,
+ a: 65,
+ b: 66,
+ c: 67,
+ d: 68,
+ e: 69,
+ f: 70,
+ g: 71,
+ h: 72,
+ i: 73,
+ j: 74,
+ k: 75,
+ l: 76,
+ m: 77,
+ n: 78,
+ o: 79,
+ p: 80,
+ q: 81,
+ r: 82,
+ s: 83,
+ t: 84,
+ u: 85,
+ v: 86,
+ w: 87,
+ x: 88,
+ y: 89,
+ z: 90,
+ LeftWindowKey: 91,
+ RightWindowKey: 92,
+ SelectKey: 93,
+ NumPad0: 96,
+ NumPad1: 97,
+ NumPad2: 98,
+ NumPad3: 99,
+ NumPad4: 100,
+ NumPad5: 101,
+ NumPad6: 102,
+ NumPad7: 103,
+ NumPad8: 104,
+ NumPad9: 105,
+ Multiply: 106,
+ Add: 107,
+ Subtract: 109,
+ DecimalPoint: 110,
+ Divide: 111,
+ F1: 112,
+ F2: 113,
+ F3: 114,
+ F4: 115,
+ F5: 116,
+ F6: 117,
+ F7: 118,
+ F8: 119,
+ F9: 120,
+ F10: 121,
+ F12: 123,
+ NumLock: 144,
+ ScrollLock: 145,
+ SemiColon: 186,
+ EqualSign: 187,
+ Comma: 188,
+ Dash: 189,
+ Period: 190,
+ ForwardSlash: 191,
+ GraveAccent: 192,
+ OpenBracket: 219,
+ BackSlash: 220,
+ CloseBracket: 221,
+ SingleQuote: 222,
+ ChineseInputMethod: 229,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/reg.js b/frontend/src/metadata/metadata-view/_basic/constants/reg.js
new file mode 100644
index 0000000000..f63ea51536
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/reg.js
@@ -0,0 +1,7 @@
+const REG_STRING_NUMBER_PARTS = /\d+|\D+/g;
+const REG_NUMBER_DIGIT = /\d/;
+
+export {
+ REG_STRING_NUMBER_PARTS,
+ REG_NUMBER_DIGIT,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/select-option.js b/frontend/src/metadata/metadata-view/_basic/constants/select-option.js
new file mode 100644
index 0000000000..aebae56e03
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/select-option.js
@@ -0,0 +1,62 @@
+const SELECT_OPTION_COLORS = [
+ { COLOR: '#FFFCB5', BORDER_COLOR: '#E8E79D', TEXT_COLOR: '#212529' },
+ { COLOR: '#FFEAB6', BORDER_COLOR: '#ECD084', TEXT_COLOR: '#212529' },
+ { COLOR: '#FFD9C8', BORDER_COLOR: '#EFBAA3', TEXT_COLOR: '#212529' },
+ { COLOR: '#FFDDE5', BORDER_COLOR: '#EDC4C1', TEXT_COLOR: '#212529' },
+ { COLOR: '#FFD4FF', BORDER_COLOR: '#E6B6E6', TEXT_COLOR: '#212529' },
+ { COLOR: '#DAD7FF', BORDER_COLOR: '#C3BEEF', TEXT_COLOR: '#212529' },
+ { COLOR: '#DDFFE6', BORDER_COLOR: '#BBEBCD', TEXT_COLOR: '#212529' },
+ { COLOR: '#DEF7C4', BORDER_COLOR: '#C5EB9E', TEXT_COLOR: '#212529' },
+ { COLOR: '#D8FAFF', BORDER_COLOR: '#B4E4E9', TEXT_COLOR: '#212529' },
+ { COLOR: '#D7E8FF', BORDER_COLOR: '#BAD1E9', TEXT_COLOR: '#212529' },
+ { COLOR: '#B7CEF9', BORDER_COLOR: '#96B2E1', TEXT_COLOR: '#212529' },
+ { COLOR: '#E9E9E9', BORDER_COLOR: '#DADADA', TEXT_COLOR: '#212529' },
+ { COLOR: '#FBD44A', BORDER_COLOR: '#E5C142', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#EAA775', BORDER_COLOR: '#D59361', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#F4667C', BORDER_COLOR: '#DC556A', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#DC82D2', BORDER_COLOR: '#D166C5', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#9860E5', BORDER_COLOR: '#844BD2', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#9F8CF1', BORDER_COLOR: '#8F75E2', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#59CB74', BORDER_COLOR: '#4EB867', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#ADDF84', BORDER_COLOR: '#9CCF72', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#89D2EA', BORDER_COLOR: '#7BC0D6', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#4ECCCB', BORDER_COLOR: '#45BAB9', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#46A1FD', BORDER_COLOR: '#3C8FE4', TEXT_COLOR: '#FFFFFF' },
+ { COLOR: '#C2C2C2', BORDER_COLOR: '#ADADAD', TEXT_COLOR: '#FFFFFF' },
+];
+
+const HIGHLIGHT_COLORS = {
+ '#FFE8E6': '#FF6052',
+ '#FFDED5': '#FF714A',
+ '#FFE7D1': '#FF851A',
+ '#EED5FF': '#B64DFD',
+ '#DAD7FF': '#5F4CFF',
+ '#D7E8FF': '#3C8FFF',
+ '#D8FAFF': '#41E7FF',
+ '#DDFFE6': '#16BA51',
+ '#E9E9E9': '#999999',
+ '#FBD44A': '#E5C142',
+ '#EAA775': '#D59361',
+ '#F4667C': '#DC556A',
+ '#DC82D2': '#D166C5',
+ '#9860E5': '#844BD2',
+ '#9F8CF1': '#8F75E2',
+ '#59CB74': '#4EB867',
+ '#ADDF84': '#9CCF72',
+ '#89D2EA': '#7BC0D6',
+ '#4ECCCB': '#45BAB9',
+ '#46A1FD': '#3C8FE4',
+ '#C2C2C2': '#ADADAD',
+ '#FFFCB5': '#E8E79D',
+ '#FFEAB6': '#ECD084',
+ '#FFD9C8': '#EFBAA3',
+ '#FFDDE5': '#EDC4C1',
+ '#FFD4FF': '#E6B6E6',
+ '#DEF7C4': '#C5EB9E',
+ '#B7CEF9': '#96B2E1',
+};
+
+export {
+ SELECT_OPTION_COLORS,
+ HIGHLIGHT_COLORS,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/sort.js b/frontend/src/metadata/metadata-view/_basic/constants/sort.js
new file mode 100644
index 0000000000..e20d62245f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/sort.js
@@ -0,0 +1,22 @@
+import { CellType } from './column';
+
+const SORT_TYPE = {
+ UP: 'up',
+ DOWN: 'down',
+};
+
+const SORT_COLUMN_OPTIONS = [
+ CellType.CTIME,
+ CellType.MTIME,
+ CellType.TEXT,
+];
+
+const TEXT_SORTER_COLUMN_TYPES = [CellType.TEXT];
+const NUMBER_SORTER_COLUMN_TYPES = [];
+
+export {
+ SORT_TYPE,
+ SORT_COLUMN_OPTIONS,
+ TEXT_SORTER_COLUMN_TYPES,
+ NUMBER_SORTER_COLUMN_TYPES,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/constants/z-index.js b/frontend/src/metadata/metadata-view/_basic/constants/z-index.js
new file mode 100644
index 0000000000..6f82782770
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/constants/z-index.js
@@ -0,0 +1,135 @@
+// copy from sf-metadata
+// Drop Target of top row's z-index is -1.
+export const DEFAULT_DROP_TARGET = -1;
+
+// CellMasks should render in front of the cells
+// Unfrozen cells do not have a zIndex specifed
+export const CELL_MASK = 1;
+export const TABLE_MAIN_INTERVAL = 1;
+export const RESIZE_HANDLE = 1;
+
+export const SEQUENCE_COLUMN = 1;
+
+// higher than unfrozen header cell(0), RESIZE_HANDLE
+export const FROZEN_HEADER_CELL = 2;
+
+export const GROUP_FROZEN_HEADER = 2;
+
+export const SCROLL_BAR = 2;
+
+// In front of CELL_MASK/non-frozen cell(1)、back of the frozen cells (2)
+export const GROUP_BACKDROP = 2;
+
+export const MOBILE_RECORDS_COLUMN_NAMES = 2;
+
+export const FROZEN_GROUP_CELL = 2;
+
+// Frozen cells have a zIndex value of 2 so CELL_MASK should have a higher value
+export const FROZEN_CELL_MASK = 3;
+
+// GALLERY_MAIN_HEADER, TABLE_MAIN_INTERVAL is 3 to hide first freeze column
+export const GALLERY_MAIN_HEADER = 3;
+
+// APP_HEADER is 8 than TABLE_HEADER because Logout Popover need to be at the top
+export const APP_HEADER = 8;
+
+// higher than frozen cells(2)
+export const GRID_HORIZONTAL_SCROLLBAR = 3;
+
+// In mobile list mode, row name fixed, so upper components z-index is 3
+export const SEARCH_ALL_TABLES = 3;
+
+export const MOBILE_TABLES_TABS_CONTAINER = 3;
+
+export const MOBILE_TABLE_TOOLBAR = 3;
+
+export const MOBILE_HEADER = 3;
+
+// need higher than the doms(etc. cell, cell_mask) which behind of the grid header
+export const GRID_HEADER = 4;
+
+export const GRID_FOOTER = 4;
+
+export const UPLOAD_PROGRESS = 4;
+
+// frozen column header z-index is 3,row drop target horizontal line shoule appear so z-index is 4
+export const ROW_DROP_TARGET = 4;
+
+export const VIEW_SIDEBAR_RESIZE_HANDLER = 4;
+
+// need higher or equal to GRID_HEADER(frozen): 4
+export const TABLE_SETTING_PANEL = 4;
+
+// higher than PANE_DIVIDER(4)
+export const TABLE_TOOLBAR = 5;
+
+export const TABLE_RIGHT_PANEL = 5;
+
+// higher than TABLE_TOOLBAR(5)
+export const TABLES_TABS_CONTAINER = 6;
+
+// higher than TABLES_TABS_CONTAINER(6)
+export const TABLE_HEADER = 7;
+
+export const TABLE_COMMENT_CONTAINER = 7;
+
+// higher than TABLE_HEADER(7)
+export const PANE_DIVIDER = 8;
+
+// EditorContainer is rendered outside the grid and it higher FROZEN_GROUP_CELL(2) and PANE_DIVIDER(8)
+export const EDITOR_CONTAINER = 9;
+
+// APP_LEFT_BAR_COLLAPSE z-index should taller than the APP_HEADER and the PANE_DIVIDER(8)
+export const APP_LEFT_BAR_COLLAPSE = 9;
+
+export const EXPAND_ROW_ICON = 99;
+
+export const MOBILE_MASK = 100;
+
+export const ROW_EXPAND_VIEW = 100;
+
+export const APP_NAV_SLIDER = 100;
+
+// LINK_RECORDS z-index should higher than EXPAND_ROW_ICON
+export const LINK_RECORDS = 100;
+
+// APP_LEFT_BAR is higher than APP_NAV_SLIDER
+export const APP_LEFT_BAR = 101;
+
+export const MOBILE_APP_NAV = 101;
+
+export const STATISTIC_DIALOG_MODAL = 800;
+
+export const STATISTIC_ENLARGE_DIALOG_MODAL = 900;
+
+export const STATISTIC_RECORDS_DIALOG_MODAL = 1000;
+
+export const SEARCH_TABLES_DIALOG_MODAL = 1000;
+
+export const DATE_EDITOR = 1001;
+
+export const NOTIFICATION_LIST_MODAL = 1046;
+
+export const TRIGGER_ROWS_MODAL = 1047;
+
+export const TRIGGER_ROWS_VIEW = 1047;
+
+export const RECORD_DETAILS_DIALOG = 1048;
+
+export const CALENDAR_DIALOG_MODAL = 1048;
+
+export const PRINT_ROW_TYPE_MODAL = 1049;
+
+export const IMAGE_PREVIEW_LIGHTBOX = 1051;
+
+export const DROPDOWN_MENU = 1051;
+
+export const RC_CALENDAR = 1053;
+
+export const EDIT_COLUMN_POPOVER = 1060;
+
+export const LARGE_MAP_EDITOR_DIALOG_MODAL = 1061;
+
+export const TOAST_MANAGER = 999999;
+
+export const LINK_PICKER = 10;
diff --git a/frontend/src/metadata/metadata-view/_basic/index.js b/frontend/src/metadata/metadata-view/_basic/index.js
new file mode 100644
index 0000000000..6773942b37
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/index.js
@@ -0,0 +1,114 @@
+export {
+ UserService
+} from './services';
+export {
+ CellType,
+ COLUMNS_ICON_CONFIG,
+ COLUMNS_ICON_NAME,
+ COLLABORATOR_COLUMN_TYPES,
+ DATE_COLUMN_OPTIONS,
+ NUMERIC_COLUMNS_TYPES,
+ DEFAULT_DATE_FORMAT,
+ UTC_FORMAT_DEFAULT,
+ DATE_UNIT,
+ DATE_FORMAT_MAP,
+ DEFAULT_NUMBER_FORMAT,
+ DATE_DEFAULT_TYPES,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+ MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ SINGLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ FILTER_CONJUNCTION_TYPE,
+ FILTER_ERR_MSG,
+ FILTER_COLUMN_OPTIONS,
+ FILTER_TERM_MODIFIER_TYPE,
+ FILTER_TERM_MODIFIER_SHOW,
+ FILTER_PREDICATE_TYPE,
+ FILTER_PREDICATE_SHOW,
+ filterTermModifierIsWithin,
+ filterTermModifierNotWithin,
+ MAX_GROUP_LEVEL,
+ GROUP_DATE_GRANULARITY,
+ DISPLAY_GROUP_DATE_GRANULARITY,
+ GROUP_GEOLOCATION_GRANULARITY,
+ DISPLAY_GROUP_GEOLOCATION_GRANULARITY,
+ SUPPORT_GROUP_COLUMN_TYPES,
+ REG_STRING_NUMBER_PARTS,
+ REG_NUMBER_DIGIT,
+ SELECT_OPTION_COLORS,
+ HIGHLIGHT_COLORS,
+ SORT_TYPE,
+ SORT_COLUMN_OPTIONS,
+ TEXT_SORTER_COLUMN_TYPES,
+ NUMBER_SORTER_COLUMN_TYPES,
+ KeyCodes,
+ Z_INDEX,
+ GROUPBY_DATE_GRANULARITY_LIST,
+ HEADER_HEIGHT_TYPE,
+} from './constants';
+
+export {
+ getColumnType,
+ getColumnsByType,
+ isDateColumn,
+ isSupportDateColumnFormat,
+ getValidFilters,
+ getValidFiltersWithoutError,
+ deleteInvalidFilter,
+ otherDate,
+ getFormattedFilterOtherDate,
+ getFormattedFilter,
+ getFormattedFilters,
+ creatorFilter,
+ dateFilter,
+ textFilter,
+ filterRow,
+ filterRows,
+ deleteInvalidGroupby,
+ isValidGroupby,
+ getValidGroupbys,
+ groupTableRows,
+ groupViewRows,
+ isTableRows,
+ updateTableRowsWithRowsData,
+ isValidSort,
+ getValidSorts,
+ deleteInvalidSort,
+ getMultipleIndexesOrderbyOptions,
+ sortDate,
+ sortText,
+ sortRowsWithMultiSorts,
+ sortTableRows,
+ getTableById,
+ getTableByName,
+ getTableByIndex,
+ getTableColumnByKey,
+ getTableColumnByName,
+ getRowById,
+ getRowsByIds,
+ isValidEmail,
+ ValidateFilter,
+ DATE_MODIFIERS_REQUIRE_TERM,
+ getViewById,
+ getViewByName,
+ isDefaultView,
+ isFilterView,
+ isGroupView,
+ isSortView,
+ isHiddenColumnsView,
+ getViewShownColumns,
+ getGroupByPath,
+ getType,
+ isMac,
+ base64ToFile,
+ bytesToSize,
+ getErrorMsg,
+ DateUtils,
+ CommonlyUsedHotkey,
+ LocalStorage,
+ isFunction,
+ isEmpty,
+ isEmptyObject,
+ debounce,
+ throttle,
+} from './utils';
diff --git a/frontend/src/metadata/metadata-view/_basic/services/index.js b/frontend/src/metadata/metadata-view/_basic/services/index.js
new file mode 100644
index 0000000000..b3945491d1
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/services/index.js
@@ -0,0 +1,5 @@
+import UserService from './user-service';
+
+export {
+ UserService,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/services/user-service.js b/frontend/src/metadata/metadata-view/_basic/services/user-service.js
new file mode 100644
index 0000000000..b5b64c511d
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/services/user-service.js
@@ -0,0 +1,68 @@
+const PENDING_INTERVAL = 1000; // 1s
+
+class UserService {
+
+ constructor({ api, mediaUrl = '' }) {
+ this.api = api;
+ this.defaultAvatarUrl = `${mediaUrl}/avatars/default.png`;
+ this.waitingQueryEmails = [];
+ this.waitingExecCallbacks = [];
+ this.emailUserMap = {};
+ }
+
+ queryUser = (email, callback) => {
+ if (!email) return;
+ this.waitingExecCallbacks.push(callback);
+ if (this.emailUserMap[email] || this.waitingQueryEmails.includes(email)) return;
+ this.waitingQueryEmails.push(email);
+ this.startQueryUsers();
+ };
+
+ queryUsers = (emails, callback) => {
+ if (!Array.isArray(emails) || emails.length === 0) return;
+ let validEmails = [];
+ emails.forEach(email => {
+ this.waitingExecCallbacks.push(callback);
+ if (this.emailUserMap[email] || this.waitingQueryEmails.includes(email)) return;
+ validEmails.push(email);
+ });
+ if (validEmails.length === 0) return;
+ this.waitingQueryEmails.push(...validEmails);
+ this.startQueryUsers();
+ };
+
+ startQueryUsers = () => {
+ if (this.pendingTimer || this.waitingQueryEmails.length === 0) return;
+ this.pendingTimer = setTimeout(() => {
+ this.api(this.waitingQueryEmails).then(res => {
+ const { user_list } = res.data;
+ user_list.forEach(user => {
+ this.emailUserMap[user.email] = user;
+ });
+ this.queryUserCallback();
+ }).catch(() => {
+ this.waitingQueryEmails.forEach(email => {
+ this.emailUserMap[email] = {
+ email: email,
+ name: email,
+ avatar_url: this.defaultAvatarUrl,
+ };
+ });
+ this.queryUserCallback();
+ });
+ clearTimeout(this.pendingTimer);
+ this.pendingTimer = null;
+ }, PENDING_INTERVAL);
+ };
+
+ queryUserCallback = () => {
+ this.waitingExecCallbacks.forEach(callback => {
+ callback(this.emailUserMap);
+ });
+ this.waitingQueryEmails = [];
+ this.waitingExecCallbacks = [];
+ };
+
+}
+
+export default UserService;
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/core.js b/frontend/src/metadata/metadata-view/_basic/utils/column/core.js
new file mode 100644
index 0000000000..db50c776b8
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/column/core.js
@@ -0,0 +1,27 @@
+/**
+ * Get column type.
+ * @param {object} column { type, ... }
+ * @returns column type
+ */
+const getColumnType = (column) => {
+ const { type } = column;
+ return type;
+};
+
+/**
+ * Get columns by type.
+ * @param {array} columns
+ * @param {string} columnType
+ * @returns the target type columns, array
+ */
+const getColumnsByType = (columns, columnType) => {
+ if (!Array.isArray(columns) || !columnType) {
+ return [];
+ }
+ return columns.filter((column) => column.type === columnType);
+};
+
+export {
+ getColumnType,
+ getColumnsByType,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/column/date.js
new file mode 100644
index 0000000000..7aa1217726
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/column/date.js
@@ -0,0 +1,30 @@
+import { getColumnType } from './core';
+import { DATE_COLUMN_OPTIONS, DATE_FORMAT_MAP } from '../../constants/column';
+
+/**
+ * Check whether is date column:
+ * - column type is date, ctime or mtime etc.
+ * - column type is formula and result_type is date
+ * - column type is link/link_fromula and array_type is date, ctime or mtime etc.
+ * @param {object} column e.g. { type, data }
+ * @returns true/false, bool
+ */
+const isDateColumn = (column) => DATE_COLUMN_OPTIONS.includes(getColumnType(column));
+
+/**
+ * Check whether the format is supported in date column
+ * @param {string} format
+ * @returns bool
+ */
+const isSupportDateColumnFormat = (format) => {
+ if (!format) {
+ return false;
+ }
+ return (
+ format === DATE_FORMAT_MAP.YYYY_MM_DD
+ || format === DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM
+ || format === DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM_SS
+ );
+};
+
+export { isDateColumn, isSupportDateColumnFormat };
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/column/index.js b/frontend/src/metadata/metadata-view/_basic/utils/column/index.js
new file mode 100644
index 0000000000..f58880095f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/column/index.js
@@ -0,0 +1,8 @@
+export {
+ getColumnType,
+ getColumnsByType,
+} from './core';
+export {
+ isDateColumn,
+ isSupportDateColumnFormat,
+} from './date';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/common.js b/frontend/src/metadata/metadata-view/_basic/utils/common.js
new file mode 100644
index 0000000000..7c845c0add
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/common.js
@@ -0,0 +1,113 @@
+export const getType = (value) => {
+ return Object.prototype.toString.call(value).slice(8, -1);
+};
+
+export const isMac = () => {
+ const platform = navigator.platform;
+ return (platform === 'Mac68K') || (platform === 'MacPPC') || (platform === 'Macintosh') || (platform === 'MacIntel');
+};
+
+export const base64ToFile = (data, fileName) => {
+ const parts = data.split(';base64,');
+ const contentType = parts[0].split(':')[1];
+ const raw = window.atob(parts[1]);
+ const rawLength = raw.length;
+ const uInt8Array = new Uint8Array(rawLength);
+
+ for (let i = 0; i < rawLength; ++i) {
+ uInt8Array[i] = raw.charCodeAt(i);
+ }
+
+ const blob = new Blob([uInt8Array], { type: contentType });
+ const file = new File([blob], fileName, { type: contentType });
+ return file;
+};
+
+export const bytesToSize = (bytes) => {
+ if (typeof(bytes) == 'undefined') return ' ';
+
+ if (bytes < 0) return '--';
+ const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
+
+ if (bytes === 0) return bytes + ' ' + sizes[0];
+
+ const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1000)), 10);
+ if (i === 0) return bytes + ' ' + sizes[i];
+ return (bytes / (1000 ** i)).toFixed(1) + ' ' + sizes[i];
+};
+
+export const getErrorMsg = (error) => {
+ let errorMsg = '';
+ if (error.response) {
+ if (error.response.status === 403) {
+ errorMsg = 'Permission_denied';
+ } else if (error.response.data &&
+ error.response.data['error_msg']) {
+ errorMsg = error.response.data['error_msg'];
+ } else {
+ errorMsg = 'Error';
+ }
+ } else {
+ errorMsg = 'Please_check_the_network';
+ }
+ return errorMsg;
+};
+
+export const isFunction = (functionToCheck) => {
+ const getType = {};
+ return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
+};
+
+/**
+ * Check whether the given value is empty
+ * @param {any} val
+ * @returns bool
+ */
+export const isEmpty = (val) => {
+ if (val === null || val === undefined) return true;
+ if (val.length !== undefined) return val.length === 0;
+ if (val instanceof Date) return false;
+ if (typeof val === 'object') return Object.keys(val).length === 0;
+ return false;
+};
+
+/**
+ * Check whether the object is empty.
+ * The true will be returned if the "obj" is invalid.
+ * @param {object} obj
+ * @returns bool
+ */
+export const isEmptyObject = (obj) => {
+ let name;
+ // eslint-disable-next-line
+ for (name in obj) {
+ return false;
+ }
+ return true;
+};
+
+export const debounce = (fn, wait) => {
+ let timeout = null;
+ return function () {
+ if (timeout !== null) clearTimeout(timeout);
+ timeout = setTimeout(fn, wait);
+ };
+};
+
+export const throttle = (func, delay) => {
+ let timer = null;
+ let startTime = Date.now();
+ return function () {
+ let curTime = Date.now();
+ let remaining = delay - (curTime - startTime);
+ let context = this;
+ let args = arguments;
+ clearTimeout(timer);
+ if (remaining <= 0) {
+ func.apply(context, args);
+ startTime = Date.now();
+ } else {
+ timer = setTimeout(func, remaining);
+ }
+ };
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/date.js b/frontend/src/metadata/metadata-view/_basic/utils/date.js
new file mode 100644
index 0000000000..e1f809b09c
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/date.js
@@ -0,0 +1,207 @@
+import {
+ DEFAULT_DATE_FORMAT,
+ DATE_UNIT,
+} from '../constants';
+
+const MONTH_QUARTERS = [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4];
+const FORMATTING_TOKENS = /(\[[^[]*\])|([-:/.()\s]+)|(A|a|YYYY|YY?|MM?M?M?|Do|DD?|hh?|HH?|mm?|ss?|S{1,3}|z|ZZ?)/g;
+const MATCH_1_2 = /\d\d?/; // 0 - 99
+const MATCH2 = /\d\d/; // 00 - 99
+const MATCH4 = /\d{4}/; // 0000 - 9999
+
+const MATCHER_EXPRESSIONS = {
+ mm: [MATCH_1_2, DATE_UNIT.MINUTES],
+ HH: [MATCH_1_2, DATE_UNIT.HOURS],
+ D: [MATCH_1_2, DATE_UNIT.DAY],
+ DD: [MATCH2, DATE_UNIT.DAY],
+ M: [MATCH_1_2, DATE_UNIT.MONTH],
+ MM: [MATCH2, DATE_UNIT.MONTH],
+ YYYY: [MATCH4, DATE_UNIT.YEAR],
+};
+
+const MATCHER_DATE_PARTS = ['YYYY', 'MM', 'M', 'DD', 'D'];
+
+class DateUtils {
+ /**
+ * return the formatted date with target format.
+ * @param {string|date object} date
+ * @param {string} format
+ * @returns formatted date
+ */
+ static format(date, format) {
+ const dateObject = this.getValidDate(date);
+ if (!dateObject) {
+ return '';
+ }
+ const upperCaseFormat = format && format.toUpperCase();
+ const year = dateObject.getFullYear();
+ const month = dateObject.getMonth() + 1;
+ const day = dateObject.getDate();
+ const displayMonth = month < 10 ? `0${month}` : month;
+ const displayDay = day < 10 ? `0${day}` : day;
+ switch (upperCaseFormat) {
+ case 'YYYY-MM-DD HH:MM:SS': {
+ const hours = dateObject.getHours();
+ const minutes = dateObject.getMinutes();
+ const seconds = dateObject.getSeconds();
+ const disPlayHours = hours < 10 ? `0${hours}` : hours;
+ const disPlayMinutes = minutes < 10 ? `0${minutes}` : minutes;
+ const disPlaySeconds = seconds < 10 ? `0${seconds}` : seconds;
+ return `${year}-${displayMonth}-${displayDay} ${disPlayHours}:${disPlayMinutes}:${disPlaySeconds}`;
+ }
+ case 'YYYY-MM-DD HH:MM': {
+ const hours = dateObject.getHours();
+ const minutes = dateObject.getMinutes();
+ const disPlayHours = hours < 10 ? `0${hours}` : hours;
+ const disPlayMinutes = minutes < 10 ? `0${minutes}` : minutes;
+ return `${year}-${displayMonth}-${displayDay} ${disPlayHours}:${disPlayMinutes}`;
+ }
+ default: {
+ return `${year}-${displayMonth}-${displayDay}`;
+ }
+ }
+ }
+
+ /**
+ * returns the formatted date with granularity.
+ * @param {string|date object} date
+ * @param {string} granularity
+ * @returns formatted date
+ */
+ static getDateByGranularity(date, granularity) {
+ const dateObject = this.getValidDate(date);
+ if (!dateObject) {
+ return '';
+ }
+ const upperCaseGranularity = granularity && granularity.toUpperCase();
+ const year = dateObject.getFullYear();
+ switch (upperCaseGranularity) {
+ case 'YEAR': {
+ return `${year}`;
+ }
+ case 'QUARTAR': {
+ const month = dateObject.getMonth();
+ const quarter = MONTH_QUARTERS[month];
+ return `${year}-Q${quarter}`;
+ }
+ case 'MONTH': {
+ const month = dateObject.getMonth() + 1;
+ const displayMonth = month < 10 ? `0${month}` : month;
+ return `${year}-${displayMonth}`;
+ }
+ case 'WEEK': {
+ const weekNum = dateObject.getDay();
+ const startOfWeekDay = dateObject.getDate() + (weekNum === 0 ? -6 : 1 - weekNum);
+ const startOfWeekDate = new Date(year, dateObject.getMonth(), startOfWeekDay);
+ const month = startOfWeekDate.getMonth() + 1;
+ const day = startOfWeekDate.getDate();
+ const displayMonth = month < 10 ? `0${month}` : month;
+ const displayDay = day < 10 ? `0${day}` : day;
+ return `${startOfWeekDate.getFullYear()}-${displayMonth}-${displayDay}`;
+ }
+ case 'DAY': {
+ const month = dateObject.getMonth() + 1;
+ const day = dateObject.getDate();
+ const displayMonth = month < 10 ? `0${month}` : month;
+ const displayDay = day < 10 ? `0${day}` : day;
+ return `${year}-${displayMonth}-${displayDay}`;
+ }
+ default: {
+ return '';
+ }
+ }
+ }
+
+ static isValidDateObject(dateObject) {
+ return dateObject instanceof Date && !isNaN(dateObject.getTime());
+ }
+
+ static getValidDate(date) {
+ if (!date) {
+ return null;
+ }
+ const isDateTypeString = typeof date === 'string';
+ let dateString = date;
+ let dateObject = date;
+ if (isDateTypeString) {
+ if (dateString.split(' ').length > 1 || dateString.includes('T')) {
+ dateObject = new Date(date);
+ } else {
+ // given date is without time precision
+ dateString = `${date} 00:00:00`;
+ dateObject = new Date(dateString);
+ }
+ }
+ if (this.isValidDateObject(dateObject)) return dateObject;
+ if (!isDateTypeString) return null;
+
+ // ios phone and safari browser not support use '2021-09-10 12:30', support '2021/09/10 12:30'
+ dateObject = new Date(dateString.replace(/-/g, '/'));
+ if (this.isValidDateObject(dateObject)) return dateObject;
+ return null;
+ }
+
+ /**
+ * @param {string} dateString
+ * @param {string} format
+ * @returns Date Object
+ */
+ static parseDateWithFormat(dateString, format) {
+ if (dateString.includes('T')) {
+ // ISO 8601 format with "T" separator directly using Date object
+ const dateObj = new Date(dateString);
+ return this.isValidDateObject(dateObj) ? dateObj : this.getValidDate(dateString);
+ }
+ try {
+ const parser = this.makeParser(format);
+ let {
+ year, month, day, hours, minutes,
+ } = parser(dateString);
+ if (!year) {
+ const nowDate = new Date();
+ year = nowDate.getFullYear();
+ }
+ let dateObj = new Date(`${year}-${month}-${day} ${hours || '00'}:${minutes || '00'}`);
+ if (!this.isValidDateObject(dateObj)) {
+ return this.getValidDate(dateString);
+ }
+ return dateObj;
+ } catch (err) {
+ return this.getValidDate(dateString);
+ }
+ }
+
+ static makeParser(format) {
+ // 'YYYY-MM-DD HH:mm'.match(formattingTokens):
+ // ['YYYY', '-', 'MM', '-', 'DD', ' ', 'HH', ':', 'mm']
+ const tokens = (format || DEFAULT_DATE_FORMAT).match(FORMATTING_TOKENS);
+ const { length: formatPartsLength } = tokens;
+ return (dateString) => {
+ const dateParts = dateString.split(' ');
+ let datePart = dateParts[0] || '';
+ let timePart = dateParts[1] || '';
+ let time = {};
+ for (let i = 0; i < formatPartsLength; i++) {
+ const token = tokens[i];
+ const parseTo = MATCHER_EXPRESSIONS[token];
+ if (!parseTo) continue;
+ const regex = parseTo[0];
+ const parserType = parseTo[1];
+ if (!parserType) continue;
+ const isDatePart = MATCHER_DATE_PARTS.includes(token);
+ let match = isDatePart ? regex.exec(datePart) : regex.exec(timePart);
+ if (!match) continue;
+ const value = match[0];
+ time[parserType] = value;
+ if (isDatePart) {
+ datePart = datePart.replace(value, '');
+ } else {
+ timePart = timePart.replace(value, '');
+ }
+ }
+ return time;
+ };
+ }
+}
+
+export { DateUtils };
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js
new file mode 100644
index 0000000000..b62dccda37
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/core.js
@@ -0,0 +1,273 @@
+import { ValidateFilter } from '../validate/filter';
+import { DateUtils } from '../date';
+import {
+ FILTER_ERR_MSG,
+ FILTER_TERM_MODIFIER_TYPE,
+} from '../../constants/filter';
+import { CellType } from '../../constants/column';
+
+const EXACT_DATE_TERM_MODIFIER_TYPES = [
+ FILTER_TERM_MODIFIER_TYPE.TODAY,
+ FILTER_TERM_MODIFIER_TYPE.TOMORROW,
+ FILTER_TERM_MODIFIER_TYPE.YESTERDAY,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO,
+ FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+];
+
+/**
+ * Get filters which excludes incomplete
+ * @param {array} filters e.g. [{ column_key, filter_predicate, ... }]
+ * @param {array} columns
+ * @returns valid filters, array
+ */
+const getValidFilters = (filters, columns) => {
+ if (!Array.isArray(filters) || !Array.isArray(columns)) {
+ return [];
+ }
+
+ return filters.filter((filter) => {
+ const { error_message } = ValidateFilter.validate(filter, columns);
+ return !error_message || error_message !== FILTER_ERR_MSG.INCOMPLETE_FILTER;
+ });
+};
+
+/**
+ * Get filters without error messages
+ * @param {array} filters e.g. [{ column_key, filter_predicate, ... }]
+ * @param {array} columns
+ * @returns valid filters, array
+ */
+const getValidFiltersWithoutError = (filters, columns) => {
+ if (!Array.isArray(filters) || !Array.isArray(columns)) {
+ return [];
+ }
+
+ return filters.filter((filter) => !ValidateFilter.validate(filter, columns).error_message);
+};
+
+/**
+ * Generate date for filter
+ * @param {string} filterTermModifier
+ * @param {any} filterTerm
+ * @returns date | date range, object
+ */
+const otherDate = (filterTermModifier, filterTerm) => {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = today.getMonth(); // use js month representation: 0 - 11
+ const day = today.getDate();
+
+ // 0 1 2 3 4 5 6 7 8 9 10 11 days in every month
+ let days = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ days[1] = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; // is leap year
+ switch (filterTermModifier) {
+ case FILTER_TERM_MODIFIER_TYPE.TODAY: {
+ // today, should start at 0:00 and end at 24:00
+ return new Date(year, month, day, 0, 0, 0);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.TOMORROW: {
+ return new Date(year, month, day + 1);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.YESTERDAY: {
+ return new Date(year, month, day - 1);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_AGO: {
+ return new Date(year, month, day - 7);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.ONE_WEEK_FROM_NOW: {
+ return new Date(year, month, day + 7);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_AGO: {
+ const pastMonth = month - 1;
+ const monthDaysIndex = month === 0 ? 11 : pastMonth;
+ const currentDay = day > days[monthDaysIndex] ? days[monthDaysIndex] : day;
+ return new Date(year, pastMonth, currentDay);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.ONE_MONTH_FROM_NOW: {
+ const nextMonth = month + 1;
+ const monthDaysIndex = month === 11 ? 0 : nextMonth;
+ const currentDay = day > days[monthDaysIndex] ? days[monthDaysIndex] : day;
+ return new Date(year, nextMonth, currentDay);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO: {
+ return new Date(year, month, day - Number(filterTerm));
+ }
+ case FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW: {
+ return new Date(year, month, day + Number(filterTerm));
+ }
+ case FILTER_TERM_MODIFIER_TYPE.EXACT_DATE: {
+ return new Date(filterTerm);
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_PAST_WEEK: {
+ const weekDay = today.getDay() !== 0 ? today.getDay() : 7;
+ return {
+ startDate: new Date(year, month, day - weekDay - 6),
+ endDate: new Date(year, month, day - weekDay),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THIS_WEEK: {
+ const weekDay = today.getDay() !== 0 ? today.getDay() : 7;
+ return {
+ startDate: new Date(year, month, day - weekDay + 1),
+ endDate: new Date(year, month, day - weekDay + 7),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_WEEK: {
+ const weekDay = today.getDay() !== 0 ? today.getDay() : 7;
+ return {
+ startDate: new Date(year, month, day - weekDay + 8),
+ endDate: new Date(year, month, day - weekDay + 14),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_PAST_MONTH: {
+ const pastMonth = month - 1;
+ return {
+ startDate: new Date(year, pastMonth, 1),
+ endDate: new Date(year, pastMonth, days[month === 0 ? 11 : pastMonth]),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THIS_MONTH: {
+ return {
+ startDate: new Date(year, month, 1),
+ endDate: new Date(year, month, days[month]),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_MONTH: {
+ const nextMonth = month + 1;
+ return {
+ startDate: new Date(year, nextMonth, 1),
+ endDate: new Date(year, nextMonth, month === 11 ? days[0] : days[nextMonth]),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_PAST_YEAR: {
+ const pastYear = year - 1;
+ return {
+ startDate: new Date(pastYear, 0, 1), // The computer's month starts at 0.
+ endDate: new Date(pastYear, 11, 31),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THIS_YEAR: {
+ return {
+ startDate: new Date(year, 0, 1),
+ endDate: new Date(year, 11, 31),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_YEAR: {
+ const nextYear = year + 1;
+ return {
+ startDate: new Date(nextYear, 0, 1),
+ endDate: new Date(nextYear, 11, 31),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS: {
+ return {
+ startDate: new Date(year, month, day + 1, 0, 0, 0),
+ endDate: new Date(year, month, day + Number(filterTerm)),
+ };
+ }
+ case FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS: {
+ return {
+ startDate: new Date(year, month, day - Number(filterTerm)),
+ endDate: new Date(year, month, day, 0, 0, 0),
+ };
+ }
+ default: {
+ return {};
+ }
+ }
+};
+
+/**
+ * Generate formatted date for filter
+ * @param {string} filterTermModifier
+ * @param {any} filterTerm
+ * @returns formatted date | date range, object
+ */
+const getFormattedFilterOtherDate = (filterTermModifier, filterTerm) => {
+ const _otherDate = otherDate(filterTermModifier, filterTerm);
+ if (EXACT_DATE_TERM_MODIFIER_TYPES.includes(filterTermModifier)) {
+ return DateUtils.format(_otherDate);
+ }
+
+ const { startDate, endDate } = _otherDate;
+ return {
+ startDate: startDate ? DateUtils.format(startDate) : '',
+ endDate: endDate ? DateUtils.format(endDate) : '',
+ };
+};
+
+/**
+ * Format filter with other_date, linked_column etc.
+ * @param {object} filter e.g. { filter_term, filter_term_modifier, ... }
+ * @param {object} column
+ * @returns formatted filter
+ */
+const getFormattedFilter = (filter, column) => {
+ const { filter_term, filter_term_modifier } = filter;
+ let { type: columnType } = column;
+ let formattedFilter = filter;
+ switch (columnType) {
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ formattedFilter.other_date = getFormattedFilterOtherDate(filter_term_modifier, filter_term);
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ return formattedFilter;
+};
+
+/**
+ * Get formatted filters with other_date, linked_column etc.
+ * @param {array} filters [{ filter_term, filter_term_modifier, column, ... }]
+ * @returns formatted filters, array
+ */
+const getFormattedFilters = (filters) => (
+ filters.map((filter) => (
+ getFormattedFilter(filter, filter.column)
+ ))
+);
+
+/**
+ * Get filters without error messages and formatted with filter column
+ * @param {array} filters e.g. [{ column_key, filter_predicate, ... }]
+ * @param {array} columns
+ * @returns filters, array
+ */
+const deleteInvalidFilter = (filters, columns) => {
+ if (!Array.isArray(filters) || filters.length === 0) {
+ return [];
+ }
+ let cleanFilters = [];
+ filters.forEach((filter) => {
+ const { column_key } = filter;
+ const { error_message } = ValidateFilter.validate(filter, columns);
+ if (error_message) {
+ if (error_message !== FILTER_ERR_MSG.INCOMPLETE_FILTER) {
+ throw new Error(error_message);
+ }
+ } else {
+ const filterColumn = columns.find((column) => column.key === column_key);
+ const newFilter = { ...filter, column: filterColumn };
+ cleanFilters.push(newFilter);
+ }
+ });
+ return cleanFilters;
+};
+
+export {
+ getValidFilters,
+ getValidFiltersWithoutError,
+ deleteInvalidFilter,
+ otherDate,
+ getFormattedFilterOtherDate,
+ getFormattedFilter,
+ getFormattedFilters,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js
new file mode 100644
index 0000000000..b47891e803
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/creator.js
@@ -0,0 +1,49 @@
+import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate';
+
+/**
+ * Filter creator
+ * @param {string} email
+ * @param {string} filter_predicate
+ * @param {array} filter_term e.g. [ collaborator.email, ... ]
+ * @param {string} username
+ * @returns bool
+ */
+const creatorFilter = (email, { filter_predicate, filter_term }, username) => {
+ switch (filter_predicate) {
+ case FILTER_PREDICATE_TYPE.CONTAINS: {
+ if (!Array.isArray(filter_term)) {
+ return true;
+ }
+ if (!email) {
+ return false;
+ }
+ return filter_term.findIndex((filterEmail) => filterEmail === email) > -1;
+ }
+ case FILTER_PREDICATE_TYPE.NOT_CONTAIN: {
+ if (!Array.isArray(filter_term) || !email) {
+ return true;
+ }
+ return filter_term.findIndex((filterEmail) => filterEmail === email) < 0;
+ }
+ case FILTER_PREDICATE_TYPE.INCLUDE_ME: {
+ return email === username;
+ }
+ case FILTER_PREDICATE_TYPE.IS: {
+ if (!filter_term) return true;
+ if (!Array.isArray(filter_term)) return email === filter_term;
+ return email === filter_term[0];
+ }
+ case FILTER_PREDICATE_TYPE.IS_NOT: {
+ if (!filter_term) return true;
+ if (!Array.isArray(filter_term)) return email !== filter_term;
+ return email !== filter_term[0];
+ }
+ default: {
+ return false;
+ }
+ }
+};
+
+export {
+ creatorFilter,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js
new file mode 100644
index 0000000000..ae852a0c9a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/date.js
@@ -0,0 +1,95 @@
+import { DateUtils } from '../../date';
+import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate';
+import { FILTER_TERM_MODIFIER_TYPE } from '../../../constants/filter/filter-modifier';
+
+/**
+ * Filter date
+ * @param {string} date
+ * @param {string} filter_predicate
+ * @param {string} filter_term_modifier
+ * @param {any} filter_term date string or number etc.
+ * @param {string|object} other_date date string or { startDate, endDate }
+ * @returns bool
+ */
+const dateFilter = (date, {
+ filter_predicate, filter_term_modifier, filter_term, other_date,
+}) => {
+ switch (filter_predicate) {
+ case FILTER_PREDICATE_TYPE.IS: {
+ return (
+ (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term)
+ || DateUtils.format(date) === other_date
+ );
+ }
+ case FILTER_PREDICATE_TYPE.IS_WITHIN: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date) {
+ return false;
+ }
+ const { startDate, endDate } = other_date;
+ const currentDate = DateUtils.format(date);
+ return currentDate >= startDate && currentDate <= endDate;
+ }
+ case FILTER_PREDICATE_TYPE.IS_BEFORE: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date || !DateUtils.getValidDate(date)) {
+ return false;
+ }
+
+ return DateUtils.format(date) < other_date;
+ }
+ case FILTER_PREDICATE_TYPE.IS_AFTER: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date || !DateUtils.getValidDate(date)) {
+ return false;
+ }
+ return DateUtils.format(date) > other_date;
+ }
+ case FILTER_PREDICATE_TYPE.IS_ON_OR_BEFORE: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date || !DateUtils.getValidDate(date)) {
+ return false;
+ }
+ return DateUtils.format(date) <= other_date;
+ }
+ case FILTER_PREDICATE_TYPE.IS_ON_OR_AFTER: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date || !DateUtils.getValidDate(date)) {
+ return false;
+ }
+ return DateUtils.format(date) >= other_date;
+ }
+ case FILTER_PREDICATE_TYPE.IS_NOT: {
+ if (filter_term_modifier === FILTER_TERM_MODIFIER_TYPE.EXACT_DATE && !filter_term) {
+ return true;
+ }
+ if (!date || !DateUtils.getValidDate(date)) {
+ return false;
+ }
+ return DateUtils.format(date) !== other_date;
+ }
+ case FILTER_PREDICATE_TYPE.EMPTY: {
+ return !(date && DateUtils.getValidDate(date));
+ }
+ case FILTER_PREDICATE_TYPE.NOT_EMPTY: {
+ return !!(date && DateUtils.getValidDate(date));
+ }
+ default: {
+ return false;
+ }
+ }
+};
+
+export {
+ dateFilter,
+};
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
new file mode 100644
index 0000000000..e0ec6fa562
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/index.js
@@ -0,0 +1,3 @@
+export { creatorFilter } from './creator';
+export { dateFilter } from './date';
+export { textFilter } from './text';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js
new file mode 100644
index 0000000000..a02b25528e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-column/text.js
@@ -0,0 +1,52 @@
+import { FILTER_PREDICATE_TYPE } from '../../../constants/filter/filter-predicate';
+
+/**
+ * Filter text
+ * @param {string} text
+ * @param {string} filter_predicate
+ * @param {string} filter_term
+ * @param {string} userId
+ * @returns bool
+ */
+const textFilter = (text, { filter_predicate, filter_term }, userId) => {
+ switch (filter_predicate) {
+ case FILTER_PREDICATE_TYPE.CONTAINS: {
+ if (!filter_term) {
+ return true;
+ }
+ if (!text) {
+ return false;
+ }
+ return text.toString().toLowerCase().indexOf(filter_term.toLowerCase()) > -1;
+ }
+ case FILTER_PREDICATE_TYPE.NOT_CONTAIN: {
+ if (!filter_term || !text) {
+ return true;
+ }
+ return text.toString().toLowerCase().indexOf(filter_term.toLowerCase()) < 0;
+ }
+ case FILTER_PREDICATE_TYPE.IS: {
+ return !filter_term || text === filter_term;
+ }
+ case FILTER_PREDICATE_TYPE.IS_NOT: {
+ return !filter_term || text !== filter_term;
+ }
+ case FILTER_PREDICATE_TYPE.EMPTY: {
+ return !text;
+ }
+ case FILTER_PREDICATE_TYPE.NOT_EMPTY: {
+ return !!text;
+ }
+ case FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID: {
+ if (!userId) return false;
+ return text === userId;
+ }
+ default: {
+ return false;
+ }
+ }
+};
+
+export {
+ textFilter,
+};
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
new file mode 100644
index 0000000000..c5bfd6bf94
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/filter-row.js
@@ -0,0 +1,86 @@
+import {
+ getFormattedFilters,
+} from './core';
+import {
+ creatorFilter,
+ dateFilter,
+ textFilter,
+} from './filter-column';
+import {
+ FILTER_CONJUNCTION_TYPE,
+} from '../../constants/filter';
+import { DateUtils } from '../date';
+import { CellType, DATE_FORMAT_MAP } from '../../constants/column';
+
+const getFilterResult = (row, filter, { username, userId }) => {
+ const { column_key, column } = filter;
+ let cellValue = row[column_key];
+ switch (column.type) {
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ cellValue = DateUtils.format(cellValue, DATE_FORMAT_MAP.YYYY_MM_DD_HH_MM_SS);
+ return dateFilter(cellValue, filter);
+ }
+ case CellType.TEXT: {
+ return textFilter(cellValue, filter, userId);
+ }
+ case CellType.LAST_MODIFIER:
+ case CellType.CREATOR: {
+ return creatorFilter(cellValue, filter, username);
+ }
+ default: {
+ return false;
+ }
+ }
+};
+
+/**
+ * Filter row
+ * @param {object} row e.g. { _id, .... }
+ * @param {string} filterConjunction e.g. 'And' | 'Or'
+ * @param {array} filters e.g. [{ column_key, filter_predicate, ... }, ...]
+ * @param {object} formulaRow
+ * @param {string} username
+ * @param {string} userId
+ * @param {object} userDepartmentIdsMap e.g. { current_user_department_ids: [8, 10], current_user_department_and_sub_ids: [8, 10, 12, 34] }
+ * @returns filter result, bool
+ */
+const filterRow = (row, filterConjunction, filters, { username = '', userId } = {}) => {
+ if (filterConjunction === FILTER_CONJUNCTION_TYPE.AND) {
+ return filters.every((filter) => (
+ getFilterResult(row, filter, { username, userId })
+ ));
+ }
+ if (filterConjunction === FILTER_CONJUNCTION_TYPE.OR) {
+ return filters.some((filter) => (
+ getFilterResult(row, filter, { username, userId })
+ ));
+ }
+ return false;
+};
+
+/**
+ * Filter rows
+ * @param {string} filterConjunction e.g. 'And' | 'Or'
+ * @param {array} filters e.g. [{ column_key, filter_predicate, ... }, ...]
+ * @param {array} rows e.g. [{ _id, .... }, ...]
+ * @param {string} username
+ * @param {string} userId
+ * @returns filtered rows ids, array
+ */
+const filterRows = (filterConjunction, filters, rows, { username, userId }) => {
+ let filteredRows = [];
+ const formattedFilters = getFormattedFilters(filters);
+ rows.forEach((row) => {
+ const rowId = row._id;
+ if (filterRow(row, filterConjunction, formattedFilters, { username, userId })) {
+ filteredRows.push(rowId);
+ }
+ });
+ return filteredRows;
+};
+
+export {
+ filterRow,
+ filterRows,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js b/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js
new file mode 100644
index 0000000000..4badc1bde5
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/filter/index.js
@@ -0,0 +1,20 @@
+export {
+ getValidFilters,
+ getValidFiltersWithoutError,
+ deleteInvalidFilter,
+ otherDate,
+ getFormattedFilterOtherDate,
+ getFormattedFilter,
+ getFormattedFilters,
+} from './core';
+
+export {
+ creatorFilter,
+ dateFilter,
+ textFilter,
+} from './filter-column';
+
+export {
+ filterRow,
+ filterRows,
+} from './filter-row';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/core.js b/frontend/src/metadata/metadata-view/_basic/utils/group/core.js
new file mode 100644
index 0000000000..925bc68fb2
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/group/core.js
@@ -0,0 +1,74 @@
+import { CellType } from '../../constants/column';
+import {
+ GROUP_DATE_GRANULARITY,
+ SUPPORT_GROUP_COLUMN_TYPES,
+} from '../../constants/group';
+
+/**
+ * Check is valid groupby
+ * @param {object} groupby e.g. { column_key, count_type, sort_type, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isValidGroupby = (groupby, columns) => {
+ if (!groupby || !Array.isArray(columns)) return false;
+
+ const { column_key } = groupby;
+ const groupbyColumn = columns.find((column) => column.key === column_key);
+ if (!groupbyColumn) {
+ return false;
+ }
+
+ return SUPPORT_GROUP_COLUMN_TYPES.includes(groupbyColumn.type);
+};
+
+/**
+ * Get valid groupbys
+ * @param {array} groupbys e.g. [{ column_key, count_type, ... }, ...]
+ * @param {array} columns
+ * @returns valid groupbys, array
+ */
+const getValidGroupbys = (groupbys, columns) => {
+ if (!Array.isArray(groupbys) || !Array.isArray(columns)) {
+ return [];
+ }
+
+ return groupbys.filter((groupby) => isValidGroupby(groupby, columns));
+};
+
+/**
+ * Get valid and formatted groupbys
+ * @param {array} groupbys e.g. [{ column_key, count_type, ... }, ...]
+ * @param {array} columns
+ * @param {object} currentTable e.g. { _id, ... }
+ * @param {object} value e.g. { tables, collaborators }
+ * @returns valid and formatted groupbys
+ */
+const deleteInvalidGroupby = (groupbys, columns) => {
+ const validGroupbys = getValidGroupbys(groupbys, columns);
+ let cleanGroupbys = [];
+ validGroupbys.forEach((groupby) => {
+ const { column_key: groupbyColumnKey, count_type } = groupby;
+ const groupbyColumn = columns.find((column) => groupbyColumnKey === column.key);
+ const { type: columnType } = groupbyColumn;
+ let newGroupby = { ...groupby, column: groupbyColumn };
+ switch (columnType) {
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ newGroupby.count_type = count_type || GROUP_DATE_GRANULARITY.MONTH;
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ cleanGroupbys.push(newGroupby);
+ });
+ return cleanGroupbys;
+};
+
+export {
+ deleteInvalidGroupby,
+ isValidGroupby,
+ getValidGroupbys,
+};
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
new file mode 100644
index 0000000000..9ea0dd913e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/group/group-row.js
@@ -0,0 +1,250 @@
+import { getRowsByIds } from '../table/row';
+import { DateUtils } from '../date';
+import {
+ sortDate,
+ sortText,
+} from '../sort/sort-column';
+import { MAX_GROUP_LEVEL } from '../../constants/group';
+import {
+ CellType,
+ DATE_COLUMN_OPTIONS,
+ MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP,
+ SINGLE_CELL_VALUE_COLUMN_TYPE_MAP,
+} from '../../constants/column';
+import {
+ SORT_COLUMN_OPTIONS,
+ SORT_TYPE,
+ TEXT_SORTER_COLUMN_TYPES,
+} from '../../constants/sort';
+
+const _getCellValue = (row, groupby) => {
+ const { column_key } = groupby;
+ let cellValue = row[column_key];
+ return cellValue;
+};
+
+const _getFormattedCellValue = (cellValue, groupby) => {
+ const { column, count_type: countType } = groupby;
+ const { type: columnType } = column;
+ switch (columnType) {
+ case CellType.TEXT:
+ case CellType.LAST_MODIFIER:
+ case CellType.CREATOR: {
+ return cellValue || null;
+ }
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ return DateUtils.getDateByGranularity(cellValue, countType) || null;
+ }
+ default: {
+ return null;
+ }
+ }
+};
+
+const _getStrCellValue = (cellValue, columnType) => {
+ let sCellValue = null;
+ if (SINGLE_CELL_VALUE_COLUMN_TYPE_MAP[columnType]) {
+ sCellValue = typeof cellValue === 'string' ? cellValue : String(cellValue);
+ } else if (MULTIPLE_CELL_VALUE_COLUMN_TYPE_MAP[columnType]) {
+ sCellValue = [...cellValue].sort().toString();
+ }
+ return sCellValue;
+};
+
+const _findGroupIndexWithMultipleGroupbys = (sCellValue, cellValue2GroupIndexMap, groupsLength) => {
+ const target = cellValue2GroupIndexMap[sCellValue];
+ if (target && target.index > -1) {
+ return target.index;
+ }
+
+ // eslint-disable-next-line
+ cellValue2GroupIndexMap[sCellValue] = {};
+
+ // eslint-disable-next-line
+ cellValue2GroupIndexMap[sCellValue].subgroups = {};
+
+ // eslint-disable-next-line
+ cellValue2GroupIndexMap[sCellValue].index = groupsLength;
+ return -1;
+};
+
+const _findGroupIndex = (sCellValue, cellValue2GroupIndexMap, groupsLength) => {
+ const index = cellValue2GroupIndexMap[sCellValue];
+ if (index > -1) {
+ return index;
+ }
+
+ // eslint-disable-next-line
+ cellValue2GroupIndexMap[sCellValue] = groupsLength;
+ return -1;
+};
+
+const getSortedGroups = (groups, groupbys, level) => {
+ const sortFlag = 0;
+ const { column, sort_type } = groupbys[level];
+ const { type: columnType } = column;
+ const normalizedSortType = sort_type || SORT_TYPE.UP;
+ groups.sort((currGroupRow, nextGroupRow) => {
+ let { cell_value: currCellVal } = currGroupRow;
+ let { cell_value: nextCellVal } = nextGroupRow;
+ if (SORT_COLUMN_OPTIONS.includes(columnType)) {
+ let sortResult;
+ if (TEXT_SORTER_COLUMN_TYPES.includes(columnType)) {
+ sortResult = sortText(currCellVal, nextCellVal, normalizedSortType);
+ } else if (DATE_COLUMN_OPTIONS.includes(columnType)) {
+ sortResult = sortDate(currCellVal, nextCellVal, normalizedSortType);
+ }
+ return sortFlag || sortResult;
+ }
+ if (currCellVal === '') return 1;
+ if (nextCellVal === '') return -1;
+ return 0;
+ });
+
+ // for nested group.
+ const isNestedGroup = Array.isArray(groups[0].subgroups) && groups[0].subgroups.length > 0;
+ if (isNestedGroup) {
+ const nextLevel = level + 1;
+
+ // eslint-disable-next-line
+ groups = groups.map((group) => {
+ const sortedSubgroups = getSortedGroups(group.subgroups, groupbys, nextLevel);
+ return {
+ ...group,
+ subgroups: sortedSubgroups,
+ };
+ });
+ }
+ return groups;
+};
+
+const groupRowsWithMultipleGroupbys = (groupbys, rows, value) => {
+ const validGroupbys = groupbys.length > MAX_GROUP_LEVEL
+ ? groupbys.slice(0, MAX_GROUP_LEVEL)
+ : [...groupbys];
+ let groups = [];
+ let cellValue2GroupIndexMap = {};
+ rows.forEach((row) => {
+ const rowId = row._id;
+ let updatedGroup;
+ let updateCellValue2GroupIndexMap;
+ for (let level = 0; level < validGroupbys.length; level++) {
+ const currentGroupby = validGroupbys[level];
+ const { column, column_key } = currentGroupby;
+ const { type: columnType } = column;
+ const cellValue = _getCellValue(row, currentGroupby);
+ const formattedValue = _getFormattedCellValue(cellValue, currentGroupby);
+ const sCellValue = _getStrCellValue(formattedValue, columnType);
+ const group = {
+ cell_value: formattedValue,
+ original_cell_value: cellValue,
+ row_ids: null,
+ column_key,
+ subgroups: [],
+ summaries: {},
+ };
+ if (level === 0) {
+ let groupedRowIndex = _findGroupIndexWithMultipleGroupbys(sCellValue, cellValue2GroupIndexMap, groups.length);
+ updateCellValue2GroupIndexMap = cellValue2GroupIndexMap[sCellValue].subgroups;
+ if (groupedRowIndex < 0) {
+ groups.push(group);
+ updatedGroup = groups[groups.length - 1];
+ } else {
+ updatedGroup = groups[groupedRowIndex];
+ }
+ } else {
+ let groupedRowIndex = _findGroupIndexWithMultipleGroupbys(sCellValue, updateCellValue2GroupIndexMap, updatedGroup.subgroups.length);
+ updateCellValue2GroupIndexMap = updateCellValue2GroupIndexMap[sCellValue].subgroups;
+ if (groupedRowIndex < 0) {
+ updatedGroup.subgroups.push(group);
+ updatedGroup = updatedGroup.subgroups[updatedGroup.subgroups.length - 1];
+ } else {
+ updatedGroup = updatedGroup.subgroups[groupedRowIndex];
+ }
+
+ // update row_ids in the deepest group.
+ if (level === validGroupbys.length - 1) {
+ if (!updatedGroup.row_ids) {
+ updatedGroup.row_ids = [rowId];
+ } else {
+ updatedGroup.row_ids.push(rowId);
+ }
+ }
+ }
+ }
+ });
+
+ groups = getSortedGroups(groups, validGroupbys, value, 0);
+
+ return groups;
+};
+
+/**
+ * Group table rows
+ * @param {array} groupbys e.g. [{ column_key, count_type, column, ... }, ...]
+ * @param {array} rows e.g. [{ _id, ... }, ...]
+ * @param {object} value e.g. { collaborators, ... }
+ * @returns groups: [{
+ * cell_value, original_cell_value, column_key,
+ row_ids, subgroups, summaries, ...}, ...], array
+ */
+const groupTableRows = (groupbys, rows) => {
+ if (groupbys.length === 0) {
+ return [];
+ }
+ if (groupbys.length > 1) {
+ return groupRowsWithMultipleGroupbys(groupbys, rows);
+ }
+ const groupby = groupbys[0];
+ const { column_key, column } = groupby;
+ const { type: columnType } = column;
+ let groups = [];
+ let cellValue2GroupIndexMap = {};
+ rows.forEach((r) => {
+ const cellValue = _getCellValue(r, groupby);
+ const formattedValue = _getFormattedCellValue(cellValue, groupby);
+ const sCellValue = _getStrCellValue(formattedValue, columnType);
+ let groupedRowIndex = _findGroupIndex(sCellValue, cellValue2GroupIndexMap, groups.length);
+ if (groupedRowIndex > -1) {
+ groups[groupedRowIndex].row_ids.push(r._id);
+ } else {
+ groups.push({
+ cell_value: formattedValue,
+ original_cell_value: cellValue,
+ column_key,
+ row_ids: [r._id],
+ subgroups: null,
+ summaries: {},
+ });
+ }
+ });
+
+ // sort groups
+ groups = getSortedGroups(groups, groupbys, 0);
+
+ return groups;
+};
+
+/**
+ * Group view rows
+ * @param {array} groupbys e.g. [{ column_key, count_type, column, ... }, ...]
+ * @param {object} table e.g. { id_row_map, ... }
+ * @param {array} rowsIds e.g. [ row._id, ...]
+ * @param {object} value e.g. { collaborators, ... }
+ * @returns groups: [{
+ * cell_value, original_cell_value, column_key,
+ row_ids, subgroups, summaries, ...}, ...], array
+ */
+const groupViewRows = (groupbys, table, rowsIds) => {
+ if (rowsIds.length === 0) {
+ return [];
+ }
+ let rowsData = getRowsByIds(table, rowsIds);
+ return groupTableRows(groupbys, rowsData);
+};
+
+export {
+ groupTableRows,
+ groupViewRows,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/group/index.js b/frontend/src/metadata/metadata-view/_basic/utils/group/index.js
new file mode 100644
index 0000000000..d0b85a63e3
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/group/index.js
@@ -0,0 +1,10 @@
+export {
+ deleteInvalidGroupby,
+ isValidGroupby,
+ getValidGroupbys,
+} from './core';
+
+export {
+ groupTableRows,
+ groupViewRows,
+} from './group-row';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js b/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js
new file mode 100644
index 0000000000..f6d483bac5
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/hotkey.js
@@ -0,0 +1,27 @@
+import isHotkey from 'is-hotkey';
+
+export const isModS = isHotkey('mod+s');
+export const isModZ = isHotkey('mod+z');
+export const isModL = isHotkey('mod+l');
+export const isModF = isHotkey('mod+f');
+export const isModP = isHotkey('mod+p');
+export const isModG = isHotkey('mod+g');
+export const isModDot = isHotkey('mod+.');
+export const isModComma = isHotkey('mod+,');
+export const isModSlash = isHotkey('mod+/');
+export const isModBackslash = isHotkey('mod+\'');
+export const isModSemicolon = isHotkey('mod+;');
+export const isModUp = isHotkey('mod+up');
+export const isModDown = isHotkey('mod+down');
+export const isModLeft = isHotkey('mod+left');
+export const isModRight = isHotkey('mod+right');
+export const isModShiftZ = isHotkey('mod+shift+z');
+export const isModShiftG = isHotkey('mod+shift+g');
+export const isModShiftDot = isHotkey('mod+shift+.');
+export const isModShiftComma = isHotkey('mod+shift+,');
+export const isShiftEnter = isHotkey('shift+enter');
+export const isShiftModEnter = isHotkey('shift+mod+enter');
+export const isOptPageUp = isHotkey('opt+pageup');
+export const isOptPageDown = isHotkey('opt+pagedown');
+export const isSpace = isHotkey('space');
+export const isEnter = isHotkey('enter');
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/index.js b/frontend/src/metadata/metadata-view/_basic/utils/index.js
new file mode 100644
index 0000000000..533340f0cb
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/index.js
@@ -0,0 +1,88 @@
+import * as CommonlyUsedHotkey from './hotkey';
+import LocalStorage from './local-storage';
+
+export {
+ getColumnType,
+ getColumnsByType,
+ isDateColumn,
+ isSupportDateColumnFormat,
+} from './column';
+export {
+ getValidFilters,
+ getValidFiltersWithoutError,
+ deleteInvalidFilter,
+ otherDate,
+ getFormattedFilterOtherDate,
+ getFormattedFilter,
+ getFormattedFilters,
+ creatorFilter,
+ dateFilter,
+ textFilter,
+ filterRow,
+ filterRows,
+} from './filter';
+export {
+ deleteInvalidGroupby,
+ isValidGroupby,
+ getValidGroupbys,
+ groupTableRows,
+ groupViewRows,
+} from './group';
+export {
+ isTableRows,
+ updateTableRowsWithRowsData,
+} from './row';
+export {
+ isValidSort,
+ getValidSorts,
+ deleteInvalidSort,
+ getMultipleIndexesOrderbyOptions,
+ sortDate,
+ sortText,
+ sortRowsWithMultiSorts,
+ sortTableRows,
+} from './sort';
+export {
+ getTableById,
+ getTableByName,
+ getTableByIndex,
+ getTableColumnByKey,
+ getTableColumnByName,
+ getRowById,
+ getRowsByIds,
+} from './table';
+export {
+ isValidEmail,
+ ValidateFilter,
+ DATE_MODIFIERS_REQUIRE_TERM,
+} from './validate';
+export {
+ getViewById,
+ getViewByName,
+ isDefaultView,
+ isFilterView,
+ isGroupView,
+ isSortView,
+ isHiddenColumnsView,
+ getViewShownColumns,
+ getGroupByPath,
+} from './view';
+export {
+ getType,
+ isMac,
+ base64ToFile,
+ bytesToSize,
+ getErrorMsg,
+ isFunction,
+ isEmpty,
+ isEmptyObject,
+ debounce,
+ throttle,
+} from './common';
+export {
+ DateUtils
+} from './date';
+export {
+ CommonlyUsedHotkey,
+ LocalStorage,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js b/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js
new file mode 100644
index 0000000000..33e1cccd9f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/local-storage.js
@@ -0,0 +1,28 @@
+class LocalStorage {
+
+ constructor(baseName) {
+ this.baseName = baseName || 'sf-metadata';
+ }
+
+ getStorage() {
+ try {
+ return JSON.parse(window.localStorage.getItem(this.baseName) || '{}');
+ } catch (error) {
+ return '';
+ }
+ }
+
+ setItem(key, value) {
+ const storage = this.getStorage();
+ const newValue = { ...storage, [key]: value };
+ return window.localStorage.setItem(this.baseName, JSON.stringify(newValue));
+ }
+
+ getItem(key) {
+ const storage = this.getStorage();
+ return storage[key];
+ }
+
+}
+
+export default LocalStorage;
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/row/core.js b/frontend/src/metadata/metadata-view/_basic/utils/row/core.js
new file mode 100644
index 0000000000..a93aff0905
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/row/core.js
@@ -0,0 +1,31 @@
+import { getTableById } from '../table/core';
+
+/**
+ * Check is table rows
+ * @param {array} rows e.g. table rows: [{ _id, xxx }, ...] | view rows: [ row._id, ... ]
+ * @returns bool
+ */
+const isTableRows = (rows) => (
+ Array.isArray(rows) && typeof rows[0] === 'object'
+);
+
+const updateTableRowsWithRowsData = (tables, tableId, rowsData = []) => {
+ let table = getTableById(tables, tableId);
+ let idRowDataMap = {};
+ rowsData.forEach((rowData) => idRowDataMap[rowData._id] = rowData);
+ table.rows.forEach((row, index) => {
+ const rowId = row._id;
+ const newRowData = idRowDataMap[rowId];
+ if (!newRowData) {
+ return;
+ }
+ const newRow = Object.assign({}, row, newRowData);
+ table.rows[index] = newRow;
+ table.id_row_map[rowId] = newRow;
+ });
+};
+
+export {
+ isTableRows,
+ updateTableRowsWithRowsData,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/row/index.js b/frontend/src/metadata/metadata-view/_basic/utils/row/index.js
new file mode 100644
index 0000000000..cd8b4b64b9
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/row/index.js
@@ -0,0 +1,4 @@
+export {
+ isTableRows,
+ updateTableRowsWithRowsData,
+} from './core';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js
new file mode 100644
index 0000000000..02bc4a5c29
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/core.js
@@ -0,0 +1,70 @@
+import { SORT_COLUMN_OPTIONS } from '../../constants/sort';
+
+/**
+ * Check is valid sort
+ * @param {object} sort e.g. { column_key, sort_type, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isValidSort = (sort, columns) => {
+ const sortByColumn = sort && columns.find((column) => column.key === sort.column_key);
+ if (!sortByColumn) return false;
+
+ return SORT_COLUMN_OPTIONS.includes(sortByColumn.type);
+};
+
+/**
+ * Get valid sorts
+ * 1. sort column is exist or not
+ * 2. valid sort type
+ * @param {array} sorts e.g. [{ column_key, sort_type, ... }, ...]
+ * @param {array} columns
+ * @returns valid sorts, array
+ */
+const getValidSorts = (sorts, columns) => {
+ if (!Array.isArray(sorts) || !Array.isArray(columns)) return [];
+
+ return sorts.filter((sort) => isValidSort(sort, columns));
+};
+
+/**
+ * Get sorted option index of the "optionIds"
+ * @param {array} optionIds
+ * @param {object} option_id_index_map e.g. {[option.id]: 0, ...}
+ * @returns sorted options index, array
+ */
+const getMultipleIndexesOrderbyOptions = (optionIds, option_id_index_map) => {
+ let indexArr = [];
+ optionIds.forEach((optionId) => {
+ const index = option_id_index_map[optionId];
+ if (index > -1) {
+ indexArr.push(index);
+ }
+ });
+ return indexArr.sort();
+};
+
+/**
+ * Get valid and formatted sorts
+ * @param {array} sorts e.g. [{ column_key, sort_type, ... }, ...]
+ * @param {array} columns
+ * @returns valid and formatted sorts, array
+ */
+const deleteInvalidSort = (sorts, columns) => {
+ const validSorts = getValidSorts(sorts, columns);
+ let cleanSorts = [];
+ validSorts.forEach((sort) => {
+ const { column_key } = sort;
+ const sortColumn = columns.find((column) => column.key === column_key);
+ let newSort = { ...sort, column: sortColumn };
+ cleanSorts.push(newSort);
+ });
+ return cleanSorts;
+};
+
+export {
+ isValidSort,
+ getValidSorts,
+ deleteInvalidSort,
+ getMultipleIndexesOrderbyOptions,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js
new file mode 100644
index 0000000000..c10224914a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/index.js
@@ -0,0 +1,16 @@
+export {
+ isValidSort,
+ getValidSorts,
+ deleteInvalidSort,
+ getMultipleIndexesOrderbyOptions,
+} from './core';
+
+export {
+ sortDate,
+ sortText,
+} from './sort-column';
+
+export {
+ sortRowsWithMultiSorts,
+ sortTableRows,
+} from './sort-row';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js
new file mode 100644
index 0000000000..17adc2fb76
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/date.js
@@ -0,0 +1,30 @@
+import { SORT_TYPE } from '../../../constants/sort';
+
+/**
+ * Sort date
+ * @param {string} leftDate e.g. '2023-07-31'
+ * @param {string} nextDate
+ * @param {string} sortType e.g. 'up' | 'down'
+ * @returns number
+ */
+const sortDate = (leftDate, rightDate, sortType) => {
+ const emptyLeftDate = !leftDate;
+ const emptyRightDate = !rightDate;
+ if (emptyLeftDate && emptyRightDate) return 0;
+ if (emptyLeftDate) return 1;
+ if (emptyRightDate) return -1;
+
+ if (leftDate > rightDate) {
+ return sortType === SORT_TYPE.UP ? 1 : -1;
+ }
+
+ if (leftDate < rightDate) {
+ return sortType === SORT_TYPE.UP ? -1 : 1;
+ }
+
+ return 0;
+};
+
+export {
+ sortDate,
+};
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
new file mode 100644
index 0000000000..c310d38dd5
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/index.js
@@ -0,0 +1,6 @@
+
+export { sortDate } from './date';
+export {
+ compareString,
+ sortText,
+} from './text';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js
new file mode 100644
index 0000000000..aecebf2f6a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-column/text.js
@@ -0,0 +1,77 @@
+import {
+ REG_NUMBER_DIGIT,
+ REG_STRING_NUMBER_PARTS,
+} from '../../../constants/reg';
+import { SORT_TYPE } from '../../../constants/sort';
+
+/**
+ * Compare strings
+ * @param {string} leftString
+ * @param {string} rightString
+ * @returns number
+ */
+const compareString = (leftString, rightString) => {
+ if (!leftString && !rightString) return 0;
+ if (!leftString) return -1;
+ if (!rightString) return 1;
+ if (typeof leftString !== 'string' || typeof rightString !== 'string') return 0;
+
+ let leftStringParts = leftString.match(REG_STRING_NUMBER_PARTS);
+ let rightStringParts = rightString.match(REG_STRING_NUMBER_PARTS);
+ let len = Math.min(leftStringParts.length, rightStringParts.length);
+ let isDigitPart;
+ let leftStringPart;
+ let rightStringPart;
+
+ // Loop through each substring part to canCompare the overall strings.
+ for (let i = 0; i < len; i++) {
+ leftStringPart = leftStringParts[i];
+ rightStringPart = rightStringParts[i];
+ isDigitPart = REG_NUMBER_DIGIT.test(leftStringPart) && REG_NUMBER_DIGIT.test(rightStringPart);
+
+ if (isDigitPart) {
+ leftStringPart = parseInt(leftStringPart);
+ rightStringPart = parseInt(rightStringPart);
+ if (leftStringPart > rightStringPart) {
+ return 1;
+ }
+ if (leftStringPart < rightStringPart) {
+ return -1;
+ }
+ }
+ if (leftStringPart !== rightStringPart) {
+ return leftString.localeCompare(rightString);
+ }
+ }
+ return leftString.localeCompare(rightString);
+};
+
+/**
+ * Sort text
+ * @param {string} leftText
+ * @param {string} rightText
+ * @param {string} sortType e.g. 'up' | 'down
+ * @returns number
+ */
+const sortText = (leftText, rightText, sortType) => {
+ const emptyLeftText = !leftText;
+ const emptyRightText = !rightText;
+ if (emptyLeftText && emptyRightText) {
+ return 0;
+ }
+ if (emptyLeftText) {
+ return 1;
+ }
+ if (emptyRightText) {
+ return -1;
+ }
+ if (rightText === leftText) {
+ return 0;
+ }
+ return sortType === SORT_TYPE.UP ? compareString(leftText, rightText) : -1 * compareString(leftText, rightText);
+};
+
+export {
+ compareString,
+ sortText,
+};
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
new file mode 100644
index 0000000000..aab69321ff
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/sort/sort-row.js
@@ -0,0 +1,51 @@
+import { deleteInvalidSort } from './core';
+import {
+ sortDate,
+ sortText,
+} from './sort-column';
+import { DATE_COLUMN_OPTIONS } from '../../constants/column';
+
+/**
+ * Sort rows with multiple sorts
+ * @param {array} tableRows e.g. [{ _id, [column.key]: '', ...}, ...]
+ * @param {array} sorts e.g. [{ column_key, sort_type, column, ... }, ...]
+ * @param {object} value e.g. { collaborators, ... }
+ */
+const sortRowsWithMultiSorts = (tableRows, sorts) => {
+ tableRows.sort((currentRow, nextRow) => {
+ let initValue = 0;
+ sorts.forEach((sort) => {
+ const { column_key, sort_type, column } = sort;
+ const { type: columnType } = column;
+ let currCellVal = currentRow[column_key];
+ let nextCellVal = nextRow[column_key];
+ if (DATE_COLUMN_OPTIONS.includes(columnType)) {
+ initValue = initValue || sortDate(currCellVal, nextCellVal, sort_type);
+ } else {
+ initValue = initValue || sortText(currCellVal, nextCellVal, sort_type);
+ }
+ });
+ return initValue;
+ });
+};
+
+/**
+ * Get sorted rows ids from table rows with multiple sorts
+ * @param {array} sorts e.g. [{ column_key, sort_type, column, ... }, ...]
+ * @param {array} rows e.g. [{ _id, [column.key]: '', ...}, ...]
+ * @param {array} columns e.g. [{ key, type, ... }, ...]
+ * @param {object} value e.g. { collaborators, ... }
+ * @returns sorted rows ids, array
+ */
+const sortTableRows = (sorts, rows, columns) => {
+ if (!Array.isArray(rows) || rows.length === 0) return [];
+ const sortRows = rows.slice(0);
+ const validSorts = deleteInvalidSort(sorts, columns);
+ sortRowsWithMultiSorts(sortRows, validSorts);
+ return sortRows.map((row) => row._id);
+};
+
+export {
+ sortRowsWithMultiSorts,
+ sortTableRows,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/column.js b/frontend/src/metadata/metadata-view/_basic/utils/table/column.js
new file mode 100644
index 0000000000..1d9393b542
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/table/column.js
@@ -0,0 +1,26 @@
+/**
+ * Get column by key from table
+ * @param {object} table
+ * @param {string} columnKey
+ * @returns column, object
+ */
+const getTableColumnByKey = (table, columnKey) => {
+ if (!table || !Array.isArray(table.columns) || !columnKey) return null;
+ return table.columns.find((column) => column.key === columnKey);
+};
+
+/**
+ * Get table column by name
+ * @param {object} table
+ * @param {string} columnName
+ * @returns column, object
+ */
+const getTableColumnByName = (table, columnName) => {
+ if (!table || !Array.isArray(table.columns) || !columnName) return null;
+ return table.columns.find((column) => column.name === columnName);
+};
+
+export {
+ getTableColumnByKey,
+ getTableColumnByName,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/core.js b/frontend/src/metadata/metadata-view/_basic/utils/table/core.js
new file mode 100644
index 0000000000..fbe44db1de
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/table/core.js
@@ -0,0 +1,32 @@
+/**
+ * Get table by id
+ * @param {array} tables
+ * @param {string} tableId
+ * @returns table, object
+ */
+const getTableById = (tables, tableId) => {
+ if (!Array.isArray(tables) || !tableId) return null;
+ return tables.find((table) => table._id === tableId);
+};
+
+/**
+ * Get table by name
+ * @param {array} tables
+ * @param {string} tableName
+ * @returns table, object
+ */
+const getTableByName = (tables, tableName) => {
+ if (!Array.isArray(tables) || !tableName) return null;
+ return tables.find((table) => table.name === tableName);
+};
+
+const getTableByIndex = (tables, tableIndex) => {
+ if (!Array.isArray(tables) || tableIndex < 0) return null;
+ return tables[tableIndex];
+};
+
+export {
+ getTableById,
+ getTableByName,
+ getTableByIndex,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/index.js b/frontend/src/metadata/metadata-view/_basic/utils/table/index.js
new file mode 100644
index 0000000000..413e4923f2
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/table/index.js
@@ -0,0 +1,5 @@
+export {
+ getTableById, getTableByName, getTableByIndex,
+} from './core';
+export { getTableColumnByKey, getTableColumnByName } from './column';
+export { getRowById, getRowsByIds } from './row';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/table/row.js b/frontend/src/metadata/metadata-view/_basic/utils/table/row.js
new file mode 100644
index 0000000000..a20131ee72
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/table/row.js
@@ -0,0 +1,26 @@
+/**
+ * Get table row by id
+ * @param {object} table
+ * @param {string} rowId the id of row
+ * @returns row, object
+ */
+const getRowById = (table, rowId) => {
+ if (!table || !table.id_row_map || !rowId) return null;
+ return table.id_row_map[rowId];
+};
+
+/**
+ * Get table rows by ids
+ * @param {object} table { id_row_map, ... }
+ * @param {array} rowsIds [ row._id, ... ]
+ * @returns rows, array
+ */
+const getRowsByIds = (table, rowsIds) => {
+ if (!table || !table.id_row_map || !Array.isArray(rowsIds)) return [];
+ return rowsIds.map((rowId) => table.id_row_map[rowId]).filter(Boolean);
+};
+
+export {
+ getRowById,
+ getRowsByIds,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js
new file mode 100644
index 0000000000..602f879511
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/email.js
@@ -0,0 +1,10 @@
+/**
+ * Check email format is valid.
+ * @param {string} email
+ * @returns true/false, bool
+ */
+const isValidEmail = (email) => (
+ /^[A-Za-z0-9]+([-_.][A-Za-z0-9]+)*@([A-Za-z0-9]+[-.])+[A-Za-z0-9]{2,20}$/.test(email)
+);
+
+export { isValidEmail };
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js
new file mode 100644
index 0000000000..1144551227
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/filter.js
@@ -0,0 +1,297 @@
+import { CellType, COLLABORATOR_COLUMN_TYPES } from '../../constants/column';
+import {
+ FILTER_COLUMN_OPTIONS,
+ FILTER_TERM_MODIFIER_TYPE,
+ FILTER_PREDICATE_TYPE,
+ filterTermModifierIsWithin,
+ filterTermModifierNotWithin,
+ FILTER_ERR_MSG,
+} from '../../constants/filter';
+import { isDateColumn } from '../column/date';
+
+const TERM_TYPE_MAP = {
+ NUMBER: 'number',
+ STRING: 'string',
+ BOOLEAN: 'boolean',
+ ARRAY: 'array',
+};
+
+const TEXT_COLUMN_TYPES = [CellType.TEXT, CellType.STRING];
+
+const CHECK_EMPTY_PREDICATES = [FILTER_PREDICATE_TYPE.EMPTY, FILTER_PREDICATE_TYPE.NOT_EMPTY];
+
+const DATE_MODIFIERS_REQUIRE_TERM = [
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+];
+
+const MODIFIERS_REQUIRE_NUMERIC_TERM = [
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS,
+];
+
+class ValidateFilter {
+ /**
+ * Check filter is valid. The error_message from returns will be null if the filter is valid.
+ * 1.incomplete filter which should be ignored
+ * - column_key: required
+ * - filter_predicate: required
+ * - filter_term_modifier: determined by the column to filter with
+ * - filter_term: determined by filter_predicate / the column to filter with
+ * 2.illegal filter
+ * - column missing: cannot find the column to filter
+ * - column not support: the column to filter is not support
+ * - mismatch: filter_predicate, filter_term_modifier mismatch
+ * - wrong data type: filter_term with wrong data type
+ * @param {object} filter e.g. { column_key, filter_term, ... }
+ * @param {array} columns e.g. [{ key, name, ... }, ...]
+ * @param {bool} isValidTerm No longer to validate filter term if false. default as false
+ * @returns { error_message }, object
+ */
+ static validate(filter, columns, isValidTerm = true) {
+ const {
+ column_key, filter_predicate, filter_term_modifier, filter_term,
+ } = filter;
+ const { error_message: column_error_message } = this.validateColumn(column_key, columns);
+ if (column_error_message) {
+ return { error_message: column_error_message };
+ }
+
+ const filterColumn = columns.find((column) => column.key === column_key);
+ const {
+ error_message: predicate_error_message,
+ } = this.validatePredicate(filter_predicate, filterColumn);
+ if (predicate_error_message) {
+ return { error_message: predicate_error_message };
+ }
+
+ if (this.isFilterOnlyWithPredicate(filter_predicate, filterColumn)) {
+ return { error_message: null };
+ }
+
+ const {
+ error_message: modifier_error_message,
+ } = this.validateModifier(filter_term_modifier, filter_predicate, filterColumn);
+ if (modifier_error_message) {
+ return { error_message: modifier_error_message };
+ }
+
+ if (this.isFilterOnlyWithModifier(filter_term_modifier, filterColumn)) {
+ return { error_message: null };
+ }
+
+ if (isValidTerm) {
+ const {
+ error_message: term_error_message,
+ } = this.validateTerm(filter_term, filter_predicate, filter_term_modifier, filterColumn);
+ if (term_error_message) {
+ return { error_message: term_error_message };
+ }
+ }
+
+ return { error_message: null };
+ }
+
+ static validateColumn(column_key, columns) {
+ if (!column_key) {
+ return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER };
+ }
+ const filterColumn = columns.find((column) => column.key === column_key);
+
+ if (!filterColumn) {
+ return { error_message: FILTER_ERR_MSG.COLUMN_MISSING };
+ }
+
+ if (!this.isValidColumnType(filterColumn)) {
+ return { error_message: FILTER_ERR_MSG.COLUMN_NOT_SUPPORTED };
+ }
+ return { error_message: null };
+ }
+
+ /**
+ * the column to filter must be available
+ */
+ static validatePredicate(predicate, filterColumn) {
+ if (!predicate) {
+ return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER };
+ }
+ const { type: columnType } = filterColumn;
+ const filterConfigs = FILTER_COLUMN_OPTIONS[columnType];
+ const { filterPredicateList: predicateList } = filterConfigs;
+ if (!predicateList.includes(predicate)) {
+ return { error_message: FILTER_ERR_MSG.UNMATCHED_PREDICATE };
+ }
+ return { error_message: null };
+ }
+
+ static validatePredicateWithArrayType(predicate, filterColumn) {
+ const { data } = filterColumn;
+ const { array_type } = data;
+
+ // Only support: is
+ if (array_type === CellType.CHECKBOX || array_type === CellType.BOOL) {
+ return this.validatePredicate(predicate, { type: CellType.CHECKBOX });
+ }
+
+ // 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 || array_type === CellType.DEPARTMENT_SINGLE_SELECT) {
+ return this.validatePredicate(predicate, { type: CellType.MULTIPLE_SELECT });
+ }
+ if (COLLABORATOR_COLUMN_TYPES.includes(array_type)) {
+ return this.validatePredicate(predicate, { type: CellType.COLLABORATOR });
+ }
+ return this.validatePredicate(predicate, { type: array_type });
+ }
+
+ /**
+ * filter predicate must be available.
+ * filterColumn the column to filter must be available
+ */
+ static isFilterOnlyWithPredicate(predicate, filterColumn) {
+ if (CHECK_EMPTY_PREDICATES.includes(predicate)) {
+ return true;
+ }
+
+ const { type: columnType } = filterColumn;
+ const { IS_CURRENT_USER_ID, INCLUDE_ME } = FILTER_PREDICATE_TYPE;
+ if (predicate === IS_CURRENT_USER_ID && TEXT_COLUMN_TYPES.includes(columnType)) {
+ return true;
+ }
+ if (predicate === INCLUDE_ME && COLLABORATOR_COLUMN_TYPES.includes(columnType)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * filter predicate must be available.
+ * the column to filter must be available
+ */
+ static validateModifier(modifier, predicate, filterColumn) {
+ if (!isDateColumn(filterColumn)) {
+ return { error_message: null };
+ }
+ if (!modifier) {
+ return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER };
+ }
+ if (predicate === FILTER_PREDICATE_TYPE.IS_WITHIN) {
+ if (filterTermModifierIsWithin.includes(modifier)) {
+ return { error_message: null };
+ }
+ } else if (filterTermModifierNotWithin.includes(modifier)) {
+ return { error_message: null };
+ }
+ return { error_message: FILTER_ERR_MSG.UNMATCHED_MODIFIER };
+ }
+
+ /**
+ * filter predicate must be available.
+ * filter modifier must be available.
+ * the column to filter must be available
+ */
+ static isFilterOnlyWithModifier(modifier, filterColumn) {
+ if (isDateColumn(filterColumn)) {
+ return !DATE_MODIFIERS_REQUIRE_TERM.includes(modifier);
+ }
+ return false;
+ }
+
+ static validateTerm(term, predicate, modifier, filterColumn) {
+ if (this.isTermMissing(term)) {
+ return { error_message: FILTER_ERR_MSG.INCOMPLETE_FILTER };
+ }
+
+ if (!this.isValidTerm(term, predicate, modifier, filterColumn)) {
+ return { error_message: FILTER_ERR_MSG.INVALID_TERM };
+ }
+ return { error_message: null };
+ }
+
+ static isTermMissing(term) {
+ return (!term && term !== 0 && term !== false)
+ || (Array.isArray(term) && term.length === 0);
+ }
+
+ static isValidTerm(term, predicate, modifier, filterColumn) {
+ switch (filterColumn.type) {
+ case CellType.TEXT: {
+ return this.isValidTermType(term, TERM_TYPE_MAP.STRING);
+ }
+
+ case CellType.CHECKBOX:
+ case CellType.BOOL: {
+ return this.isValidTermType(term, TERM_TYPE_MAP.BOOLEAN);
+ }
+ case CellType.COLLABORATOR:
+ case CellType.CREATOR:
+ case CellType.LAST_MODIFIER: {
+ return this.isValidTermType(term, TERM_TYPE_MAP.ARRAY);
+ }
+ case CellType.DATE:
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ if (MODIFIERS_REQUIRE_NUMERIC_TERM.includes(modifier)) {
+ return this.isValidTermType(term, TERM_TYPE_MAP.NUMBER);
+ }
+ return this.isValidTermType(term, TERM_TYPE_MAP.STRING);
+ }
+ default: {
+ return false;
+ }
+ }
+ }
+
+ static isValidTermType(term, type) {
+ if (type === TERM_TYPE_MAP.ARRAY) {
+ return Array.isArray(term) && term.length > 0;
+ }
+ if (type === CellType.NUMBER) {
+ // is a number or a number string
+ // eslint-disable-next-line
+ return typeof term === type || !isNaN(Number(term));
+ }
+ // eslint-disable-next-line
+ return typeof term === type;
+ }
+
+ static isValidTermWithArrayType(term, predicate, modifier, filterColumn) {
+ const { data } = filterColumn;
+ const { array_type, array_data } = data;
+ if (array_type === CellType.SINGLE_SELECT) {
+ return this.isValidTerm(term, predicate, modifier, {
+ 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 });
+ }
+ return this.isValidTerm(term, predicate, modifier, { type: array_type, data: array_data });
+ }
+
+ static isValidColumnType(filterColumn) {
+ const { type: columnType } = filterColumn;
+ // eslint-disable-next-line
+ return FILTER_COLUMN_OPTIONS.hasOwnProperty(columnType);
+ }
+
+ static isValidSelectedOptions(selectedOptionIds, options) {
+ const validSelectedOptions = options.filter((option) => selectedOptionIds.includes(option.id));
+ return selectedOptionIds.length === validSelectedOptions.length;
+ }
+}
+
+export {
+ ValidateFilter,
+ DATE_MODIFIERS_REQUIRE_TERM,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js b/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js
new file mode 100644
index 0000000000..45cd4da56f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/validate/index.js
@@ -0,0 +1,5 @@
+export { isValidEmail } from './email';
+export {
+ ValidateFilter,
+ DATE_MODIFIERS_REQUIRE_TERM,
+} from './filter';
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/core.js b/frontend/src/metadata/metadata-view/_basic/utils/view/core.js
new file mode 100644
index 0000000000..6293f2fe6a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/view/core.js
@@ -0,0 +1,98 @@
+import {
+ getValidFilters,
+} from '../filter/core';
+import { getValidGroupbys } from '../group/core';
+import { getValidSorts } from '../sort/core';
+
+/**
+ * Get view by id
+ * @param {array} views e.g. [{ _id, ... }, ...]
+ * @param {string} viewId
+ * @returns view, object
+ */
+const getViewById = (views, viewId) => {
+ if (!Array.isArray(views) || !viewId) return null;
+ return views.find((view) => view._id === viewId);
+};
+
+/**
+ * Get view by name
+ * @param {array} views
+ * @param {string} viewName
+ * @returns view, object
+ */
+const getViewByName = (views, viewName) => {
+ if (!Array.isArray(views) || !viewName) return null;
+ return views.find((view) => view.name === viewName);
+};
+
+/**
+ * Check whether the view contains filters
+ * @param {object} view e.g. { filters, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isFilterView = (view, columns) => {
+ const validFilters = getValidFilters(view.filters, columns);
+ return validFilters.length > 0;
+};
+
+/**
+ * Check whether the view contains groupbys
+ * @param {object} view e.g. { groupbys, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isGroupView = (view, columns) => {
+ const validGroupbys = getValidGroupbys(view.groupbys, columns);
+ return validGroupbys.length > 0;
+};
+
+/**
+ * Check whether the view contains sorts
+ * @param {object} view e.g. { sorts, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isSortView = (view, columns) => {
+ const validSorts = getValidSorts(view.sorts, columns);
+ return validSorts.length > 0;
+};
+
+/**
+ * Check whether the view has hidden columns
+ * @param {object} view e.g. { hidden_columns, ... }
+ * @returns bool
+ */
+const isHiddenColumnsView = (view) => {
+ const { hidden_columns } = view || {};
+ return Array.isArray(hidden_columns) && hidden_columns.length > 0;
+};
+
+/**
+ * Check is default view which no contains filters, sorts, groupbys etc.
+ * @param {object} view e.g. { filters, groupbys, sorts, ... }
+ * @param {array} columns
+ * @returns bool
+ */
+const isDefaultView = (view, columns) => (
+ !isFilterView(view, columns) && !isSortView(view, columns) && !isGroupView(view, columns)
+);
+
+const getViewShownColumns = (view, columns) => {
+ if (!Array.isArray(columns)) return [];
+ if (!isHiddenColumnsView(view)) return columns;
+ const { hidden_columns } = view;
+ return columns.filter((column) => !hidden_columns.includes(column.key));
+};
+
+export {
+ getViewById,
+ getViewByName,
+ isDefaultView,
+ isFilterView,
+ isGroupView,
+ isSortView,
+ isHiddenColumnsView,
+ getViewShownColumns,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/group.js b/frontend/src/metadata/metadata-view/_basic/utils/view/group.js
new file mode 100644
index 0000000000..28a0de0577
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/view/group.js
@@ -0,0 +1,39 @@
+/**
+ * Get group by paths
+ * @param {array} paths e.g. [ 0, 1, 2 ]
+ * @param {array} groups grouped rows
+ * @returns group, object
+ */
+const getGroupByPath = (paths, groups) => {
+ if (!Array.isArray(paths) || !Array.isArray(groups)) {
+ return null;
+ }
+
+ const level0GroupIndex = paths[0];
+ if (level0GroupIndex < 0 || level0GroupIndex >= groups.length) {
+ return null;
+ }
+
+ let level = 1;
+ let foundGroup = groups[level0GroupIndex];
+ while (level < paths.length) {
+ if (!foundGroup) {
+ break;
+ }
+ const subGroups = foundGroup.subgroups;
+ const currentLevelGroupIndex = paths[level];
+ if (
+ !Array.isArray(subGroups)
+ || (currentLevelGroupIndex < 0 || currentLevelGroupIndex >= subGroups.length)
+ ) {
+ break;
+ }
+ foundGroup = subGroups[currentLevelGroupIndex];
+ level += 1;
+ }
+ return foundGroup;
+};
+
+export {
+ getGroupByPath,
+};
diff --git a/frontend/src/metadata/metadata-view/_basic/utils/view/index.js b/frontend/src/metadata/metadata-view/_basic/utils/view/index.js
new file mode 100644
index 0000000000..194b522f70
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/_basic/utils/view/index.js
@@ -0,0 +1,14 @@
+export {
+ getViewById,
+ getViewByName,
+ isDefaultView,
+ isFilterView,
+ isGroupView,
+ isSortView,
+ isHiddenColumnsView,
+ getViewShownColumns,
+} from './core';
+
+export {
+ getGroupByPath,
+} from './group';
diff --git a/frontend/src/metadata/metadata-view/components/cell-formatter/index.js b/frontend/src/metadata/metadata-view/components/cell-formatter/index.js
new file mode 100644
index 0000000000..3c230c2f03
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/cell-formatter/index.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Formatter } from '@seafile/sf-metadata-ui-component';
+import { useCollaborators } from '../../hooks';
+
+const CellFormatter = ({ readonly, value, field, }) => {
+ const { collaborators, collaboratorsCache, updateCollaboratorsCache } = useCollaborators();
+ return (
+
+ );
+};
+
+CellFormatter.propTypes = {
+ readonly: PropTypes.bool,
+ value: PropTypes.any,
+ field: PropTypes.object.isRequired,
+};
+
+export default CellFormatter;
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx
new file mode 100644
index 0000000000..30b5a3114f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/filter-setter.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import deepCopy from 'deep-copy';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { getValidFilters, CommonlyUsedHotkey } from '../../_basic';
+import { gettext } from '../../../../utils/constants';
+
+const propTypes = {
+ wrapperClass: PropTypes.string,
+ filtersClassName: PropTypes.string,
+ target: PropTypes.string,
+ isNeedSubmit: PropTypes.bool,
+ filterConjunction: PropTypes.string,
+ filters: PropTypes.array,
+ columns: PropTypes.array,
+ onFiltersChange: PropTypes.func,
+ collaborators: PropTypes.array,
+ isPre: PropTypes.bool,
+};
+
+class FilterSetter extends React.Component {
+
+ static defaultProps = {
+ target: 'sf-metadata-filter-popover',
+ isNeedSubmit: false,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isShowFilterSetter: false,
+ };
+ }
+
+ onKeyDown = (e) => {
+ e.stopPropagation();
+ if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onFilterSetterToggle();
+ };
+
+ onFilterSetterToggle = () => {
+ this.setState({ isShowFilterSetter: !this.state.isShowFilterSetter });
+ };
+
+ update = (update) => {
+ const { filters, filter_conjunction } = update || {};
+ const { columns } = this.props;
+ const valid_filters = getValidFilters(filters, columns);
+
+ this.props.onFiltersChange(valid_filters, filter_conjunction);
+ };
+
+ render() {
+ const {
+ wrapperClass, filters, columns, isNeedSubmit,
+ // collaborators, filtersClassName, filterConjunction,
+ } = this.props;
+ if (!columns) return null;
+ // const { isShowFilterSetter } = this.state;
+ const validFilters = deepCopy(getValidFilters(filters || [], columns));
+ const filtersLength = validFilters ? validFilters.length : 0;
+ let filterMessage = isNeedSubmit ? gettext('Preset filter') : gettext('Filter');
+ if (filtersLength === 1) {
+ filterMessage = isNeedSubmit ? gettext('1 preset filter') : gettext('1 filter');
+ } else if (filtersLength > 1) {
+ filterMessage = isNeedSubmit ? gettext('Preset filters') : gettext('Filters');
+ filterMessage = filtersLength + ' ' + filterMessage;
+ }
+ let labelClass = wrapperClass || '';
+ labelClass = (labelClass && filtersLength > 0) ? labelClass + ' active' : labelClass;
+ return (
+ <>
+
+
+
+ {filterMessage}
+
+
+ >
+ );
+ }
+}
+
+FilterSetter.propTypes = propTypes;
+
+export default FilterSetter;
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx
new file mode 100644
index 0000000000..46fc8c7d9b
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/groupby-setter.jsx
@@ -0,0 +1,76 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { CommonlyUsedHotkey } from '../../_basic';
+import { gettext } from '../../utils';
+
+class GroupbySetter extends Component {
+
+ static defaultProps = {
+ target: 'sf-metadata-groupby-popover',
+ isNeedSubmit: false,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isShowGroupbySetter: false,
+ };
+ }
+
+ onKeyDown = (e) => {
+ if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onGroupbySetterToggle();
+ };
+
+ onGroupbySetterToggle = () => {
+ this.setState({ isShowGroupbySetter: !this.state.isShowGroupbySetter });
+ };
+
+ render() {
+ const { columns, groupbys, wrapperClass } = this.props;
+ if (!columns) return null;
+
+ const groupbysLength = groupbys ? groupbys.length : 0;
+ const activated = groupbysLength > 0;
+ let groupbyMessage = gettext('Group');
+ if (groupbysLength === 1) {
+ groupbyMessage = gettext('Grouped by 1 column');
+ } else if (groupbysLength > 1) {
+ groupbyMessage = gettext('Grouped by xxx columns').replace('xxx', groupbysLength);
+ }
+ let labelClass = wrapperClass || '';
+ labelClass = (labelClass && activated) ? labelClass + ' active' : labelClass;
+
+ return (
+ <>
+
+
+
+ {groupbyMessage}
+
+
+ >
+ );
+ }
+}
+
+GroupbySetter.propTypes = {
+ wrapperClass: PropTypes.string,
+ columns: PropTypes.array,
+ groupbys: PropTypes.array, // valid groupbys
+ modifyGroupbys: PropTypes.func,
+ target: PropTypes.string,
+ isNeedSubmit: PropTypes.bool,
+};
+
+export default GroupbySetter;
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx
new file mode 100644
index 0000000000..ffe2203e25
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/hide-column-setter.jsx
@@ -0,0 +1,70 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { CommonlyUsedHotkey } from '../../_basic';
+import { gettext } from '../../utils';
+
+class HideColumnSetter extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isHideColumnSetterShow: false,
+ };
+ }
+
+ onKeyDown = (e) => {
+ if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onHideColumnToggle();
+ };
+
+ onHideColumnToggle = () => {
+ this.setState({ isHideColumnSetterShow: !this.state.isHideColumnSetterShow });
+ };
+
+ render() {
+ const { columns, wrapperClass, target, localShownColumnKeys } = this.props;
+ if (!columns) return null;
+ let message = gettext('Hide columns');
+ const hiddenColumns = columns.filter((column) => !localShownColumnKeys.includes(column.key));
+ const hiddenColumnsLength = hiddenColumns.length;
+ if (hiddenColumnsLength === 1) {
+ message = gettext('1 hidden column');
+ } else if (hiddenColumnsLength > 1) {
+ message = gettext('xxx hidden columns').replace('xxx', hiddenColumnsLength);
+ }
+ let labelClass = wrapperClass || '';
+ labelClass = (labelClass && hiddenColumnsLength > 0) ? labelClass + ' active' : labelClass;
+
+ return (
+ <>
+
+ >
+ );
+ }
+}
+
+HideColumnSetter.propTypes = {
+ wrapperClass: PropTypes.string,
+ target: PropTypes.string,
+ page: PropTypes.object,
+ shownColumnKeys: PropTypes.array,
+ localShownColumnKeys: PropTypes.array,
+ columns: PropTypes.array,
+ modifyHiddenColumns: PropTypes.func,
+};
+
+export default HideColumnSetter;
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/index.js b/frontend/src/metadata/metadata-view/components/data-process-setter/index.js
new file mode 100644
index 0000000000..89cbfa66d3
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/index.js
@@ -0,0 +1,13 @@
+import FilterSetter from './filter-setter';
+import SortSetter from './sort-setter';
+import GroupbySetter from './groupby-setter';
+import PreHideColumnSetter from './pre-hide-column-setter';
+import HideColumnSetter from './hide-column-setter';
+
+export {
+ FilterSetter,
+ SortSetter,
+ GroupbySetter,
+ PreHideColumnSetter,
+ HideColumnSetter,
+};
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx
new file mode 100644
index 0000000000..347b565d72
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/pre-hide-column-setter.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { gettext } from '../../utils';
+
+class PreHideColumnSetter extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isShowHideColumnSetter: false,
+ shownColumnKeys: props.shownColumnKeys || [],
+ };
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { shownColumnKeys } = nextProps;
+ if (shownColumnKeys !== this.props.shownColumnKeys) {
+ this.setState({
+ isShowHideColumnSetter: false,
+ shownColumnKeys,
+ });
+ }
+ }
+
+ onHideColumnToggle = () => {
+ const { isShowHideColumnSetter } = this.state;
+ if (isShowHideColumnSetter) {
+ const { shownColumnKeys } = this.state;
+ this.props.onSettingUpdate(shownColumnKeys);
+ }
+ this.setState({ isShowHideColumnSetter: !isShowHideColumnSetter });
+ };
+
+ modifyHiddenColumns = (shownColumnKeys) => {
+ this.setState({ shownColumnKeys });
+ };
+
+ render() {
+ const { columns, wrapperClass } = this.props;
+ if (!columns) return null;
+ const { shownColumnKeys } = this.state;
+ const shown_column_keys = shownColumnKeys || [];
+ const hiddenColumns = columns.filter((column) => !shown_column_keys.includes(column.key));
+ const hiddenColumnsLength = hiddenColumns.length;
+ let message = gettext('Preset hide columns');
+ if (hiddenColumnsLength === 1) {
+ message = gettext('1 preset hidden column');
+ } else if (hiddenColumnsLength > 1) {
+ message = gettext('xxx preset hidden columns').replace('xxx', hiddenColumnsLength);
+ }
+ let settingClass = wrapperClass || '';
+ settingClass = (settingClass && hiddenColumnsLength > 0) ? settingClass + ' active' : settingClass;
+ return (
+
+ );
+ }
+}
+
+PreHideColumnSetter.propTypes = {
+ shownColumnKeys: PropTypes.array,
+ columns: PropTypes.array,
+ onSettingUpdate: PropTypes.func.isRequired,
+ wrapperClass: PropTypes.string,
+};
+
+export default PreHideColumnSetter;
diff --git a/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx b/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx
new file mode 100644
index 0000000000..11327dcd46
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/data-process-setter/sort-setter.jsx
@@ -0,0 +1,84 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { getValidSorts, CommonlyUsedHotkey } from '../../_basic';
+import { gettext } from '../../utils';
+
+const propTypes = {
+ wrapperClass: PropTypes.string,
+ target: PropTypes.string,
+ isNeedSubmit: PropTypes.bool,
+ sorts: PropTypes.array,
+ columns: PropTypes.array,
+ onSortsChange: PropTypes.func,
+};
+
+class SortSetter extends Component {
+
+ static defaultProps = {
+ target: 'sf-metadata-sort-popover',
+ isNeedSubmit: false,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isSortPopoverShow: false,
+ };
+ }
+
+ onSortToggle = () => {
+ this.setState({ isSortPopoverShow: !this.state.isSortPopoverShow });
+ };
+
+ onKeyDown = (e) => {
+ e.stopPropagation();
+ if (CommonlyUsedHotkey.isEnter(e) || CommonlyUsedHotkey.isSpace(e)) this.onSortToggle();
+ };
+
+ update = (update) => {
+ const { sorts } = update || {};
+ this.props.onSortsChange(sorts);
+ };
+
+ render() {
+ const { sorts, columns, isNeedSubmit, wrapperClass } = this.props;
+ if (!columns) return null;
+ const validSorts = getValidSorts(sorts || [], columns);
+ const sortsLength = validSorts ? validSorts.length : 0;
+
+ let sortMessage = isNeedSubmit ? gettext('Preset sort') : gettext('Sort');
+ if (sortsLength === 1) {
+ sortMessage = isNeedSubmit ? gettext('1 preset sort') : gettext('1 sort');
+ } else if (sortsLength > 1) {
+ sortMessage = isNeedSubmit ? gettext('xxx preset sorts') : gettext('xxx sorts');
+ sortMessage = sortMessage.replace('xxx', sortsLength);
+ }
+ let labelClass = wrapperClass || '';
+ labelClass = (labelClass && sortsLength > 0) ? labelClass + ' active' : labelClass;
+
+ return (
+ <>
+
+ >
+ );
+ }
+}
+
+SortSetter.propTypes = propTypes;
+
+export default SortSetter;
diff --git a/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js b/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js
new file mode 100644
index 0000000000..464908b9bc
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/delete-confirm-dialog/index.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
+import { gettext } from '../../utils';
+
+const DeleteConfirmDialog = ({ title, content, onToggle, onSubmit }) => {
+ return (
+
+ {title}
+
+ {gettext('Are you sure to delete ') + content}
+
+
+
+
+
+
+ );
+};
+
+DeleteConfirmDialog.propTypes = {
+ title: PropTypes.string.isRequired,
+ content: PropTypes.string,
+ onToggle: PropTypes.func.isRequired,
+ onSubmit: PropTypes.func.isRequired,
+};
+
+export default DeleteConfirmDialog;
diff --git a/frontend/src/metadata/metadata-view/components/index.js b/frontend/src/metadata/metadata-view/components/index.js
new file mode 100644
index 0000000000..f4e738015a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/index.js
@@ -0,0 +1,9 @@
+import DeleteConfirmDialog from './delete-confirm-dialog';
+import RecordDetailsDialog from './record-details-dialog';
+import Table from './table';
+
+export {
+ DeleteConfirmDialog,
+ RecordDetailsDialog,
+ Table,
+};
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css
new file mode 100644
index 0000000000..afcfce1e9d
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.css
@@ -0,0 +1,30 @@
+.filter-popover .popover {
+ max-width: none;
+ min-width: 300px;
+}
+
+.filter-popover .popover-add-tool {
+ border-top: none;
+ color: #666666;
+}
+
+.filter-popover .popover-add-tool.disabled {
+ color: #c2c2c2;
+}
+
+.filter-popover .popover-add-tool.disabled:hover {
+ cursor: not-allowed;
+ background: #fff;
+}
+
+.filter-popover .popover-add-tool .popover-add-icon {
+ margin-right: 14px;
+}
+
+.filter-popover .filter-popover-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 1rem;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js
new file mode 100644
index 0000000000..600cfdc6f4
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/index.js
@@ -0,0 +1,211 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import isHotkey from 'is-hotkey';
+import { Button, UncontrolledPopover } from 'reactstrap';
+import { CustomizeAddTool } from '@seafile/sf-metadata-ui-component';
+import {
+ FILTER_COLUMN_OPTIONS,
+ getValidFilters,
+} from '../../../_basic';
+import { getEventClassName } from '../../../utils';
+import { getFilterByColumn } from '../../../utils/filters-utils';
+import FiltersList from './widgets';
+import { EVENT_BUS_TYPE } from '../../../constants';
+import { gettext } from '../../../utils';
+
+import './index.css';
+
+/**
+ * filter = {
+ * column_key: '',
+ * filter_predicate: '',
+ * filter_term: '',
+ * filter_term_modifier: '',
+ * }
+ */
+class FilterPopover extends Component {
+
+ static defaultProps = {
+ filtersClassName: '',
+ placement: 'auto-start',
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filters: getValidFilters(props.filters, props.columns),
+ filterConjunction: props.filterConjunction || 'And',
+ };
+ this.isSelectOpen = false;
+ }
+
+ componentDidMount() {
+ document.addEventListener('mousedown', this.hideDTablePopover, true);
+ document.addEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('mousedown', this.hideDTablePopover, true);
+ document.removeEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect();
+ }
+
+ onHotKey = (e) => {
+ if (isHotkey('esc', e) && !this.isSelectOpen) {
+ e.preventDefault();
+ this.props.hidePopover();
+ }
+ };
+
+ setSelectStatus = (status) => {
+ this.isSelectOpen = status;
+ };
+
+ hideDTablePopover = (e) => {
+ if (this.dtablePopoverRef && !getEventClassName(e).includes('popover') && !this.dtablePopoverRef.contains(e.target)) {
+ this.props.hidePopover(e);
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ };
+
+ isNeedSubmit = () => {
+ return this.props.isNeedSubmit;
+ };
+
+ update = (filters) => {
+ if (this.isNeedSubmit()) {
+ const isSubmitDisabled = false;
+ this.setState({ filters, isSubmitDisabled });
+ return;
+ }
+ this.setState({ filters }, () => {
+ const update = { filters, filter_conjunction: this.state.filterConjunction };
+ this.props.update(update);
+ });
+ };
+
+ deleteFilter = (filterIndex, scheduleUpdate) => {
+ const filters = this.state.filters.slice(0);
+ filters.splice(filterIndex, 1);
+ if (filters.length === 0) {
+ scheduleUpdate();
+ }
+ this.update(filters);
+ };
+
+ updateFilter = (filterIndex, updated) => {
+ const filters = this.state.filters.slice(0);
+ filters[filterIndex] = updated;
+ this.update(filters);
+ };
+
+ updateFilterConjunction = (conjunction) => {
+ if (this.isNeedSubmit()) {
+ const isSubmitDisabled = false;
+ this.setState({ filterConjunction: conjunction, isSubmitDisabled });
+ return;
+ }
+ this.setState({ filterConjunction: conjunction }, () => {
+ const update = { filters: this.state.filters, filter_conjunction: conjunction };
+ this.props.update(update);
+ });
+ };
+
+ addFilter = (scheduleUpdate) => {
+ let { columns } = this.props;
+ let defaultColumn = columns[0];
+ if (!FILTER_COLUMN_OPTIONS[defaultColumn.type]) {
+ defaultColumn = columns.find((c) => FILTER_COLUMN_OPTIONS[c.type]);
+ }
+ if (!defaultColumn) return;
+ let filter = getFilterByColumn(defaultColumn);
+ const filters = this.state.filters.slice(0);
+ if (filters.length === 0) {
+ scheduleUpdate();
+ }
+ filters.push(filter);
+ this.update(filters);
+ };
+
+ onClosePopover = () => {
+ this.props.hidePopover();
+ };
+
+ onSubmitFilters = () => {
+ const { filters, filterConjunction } = this.state;
+ const update = { filters, filter_conjunction: filterConjunction };
+ this.props.update(update);
+ this.props.hidePopover();
+ };
+
+ onPopoverInsideClick = (e) => {
+ e.stopPropagation();
+ };
+
+ render() {
+ const { target, columns, placement } = this.props;
+ const { filters, filterConjunction } = this.state;
+ const canAddFilter = columns.length > 0;
+ return (
+
+ {({ scheduleUpdate }) => (
+ this.dtablePopoverRef = ref} onClick={this.onPopoverInsideClick} className={this.props.filtersClassName}>
+
+
this.addFilter(scheduleUpdate) : () => {}}
+ footerName={gettext('Add filter')}
+ addIconClassName="popover-add-icon"
+ />
+ {this.isNeedSubmit() && (
+
+
+
+
+ )}
+
+ )}
+
+ );
+ }
+}
+
+FilterPopover.propTypes = {
+ placement: PropTypes.string,
+ filtersClassName: PropTypes.string,
+ target: PropTypes.string.isRequired,
+ isNeedSubmit: PropTypes.bool,
+ isLocked: PropTypes.bool,
+ columns: PropTypes.array.isRequired,
+ filterConjunction: PropTypes.string,
+ filters: PropTypes.array,
+ collaborators: PropTypes.array,
+ isPre: PropTypes.bool,
+ hidePopover: PropTypes.func,
+ update: PropTypes.func,
+};
+
+export default FilterPopover;
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js
new file mode 100644
index 0000000000..fb62649d72
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/collaborator-filter.js
@@ -0,0 +1,114 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import intl from 'react-intl-universal';
+import { CustomizeSelect } from '@seafile/sf-metadata-ui-component';
+import { FILTER_PREDICATE_TYPE } from '../../../../_basic';
+
+const propTypes = {
+ filterIndex: PropTypes.number,
+ filterTerm: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), // Make the current bug execution the correct code, this can restore in this Component
+ filter_predicate: PropTypes.string,
+ collaborators: PropTypes.array,
+ onSelectCollaborator: PropTypes.func,
+ isLocked: PropTypes.bool,
+ placeholder: PropTypes.string,
+};
+
+class CollaboratorFilter extends Component {
+
+ constructor(props) {
+ super(props);
+ this.supportMultipleSelectOptions = [
+ FILTER_PREDICATE_TYPE.HAS_ANY_OF,
+ FILTER_PREDICATE_TYPE.HAS_ALL_OF,
+ FILTER_PREDICATE_TYPE.HAS_NONE_OF,
+ FILTER_PREDICATE_TYPE.IS_EXACTLY,
+ ];
+ }
+
+ createCollaboratorOptions = (filterIndex, collaborators, filterTerm) => {
+ return collaborators.map((collaborator) => {
+ let isSelected = filterTerm.findIndex(item => item === collaborator.email) > -1;
+ return {
+ value: { filterIndex, columnOption: collaborator },
+ label: (
+
+
+
+
+
+
+
+
{collaborator.name}
+
+
+
+
+ {isSelected && }
+
+
+
+ )
+ };
+ });
+ };
+
+ onClick = (e, collaborator) => {
+ e.stopPropagation();
+ this.props.onSelectCollaborator({ columnOption: collaborator });
+ };
+
+ render() {
+ let { filterIndex, filterTerm, collaborators, placeholder, filter_predicate } = this.props;
+ let isSupportMultipleSelect = this.supportMultipleSelectOptions.indexOf(filter_predicate) > -1 ? true : false;
+ let selectedCollaborators = Array.isArray(filterTerm) && filterTerm.length > 0 && filterTerm.map((item) => {
+ let collaborator = collaborators.find(c => c.email === item);
+ if (!collaborator) return null;
+ return (
+
+
+
+
+
{collaborator.name}
+
+
+ {
+ this.onClick(e, collaborator);
+ }}>
+
+
+
+
+ );
+ });
+ let value = selectedCollaborators ? { label: (<>{selectedCollaborators}>) } : {};
+ let options = Array.isArray(filterTerm) ? this.createCollaboratorOptions(filterIndex, collaborators, filterTerm) : [];
+ return (
+
+ );
+ }
+}
+
+CollaboratorFilter.propTypes = propTypes;
+
+export default CollaboratorFilter;
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js
new file mode 100644
index 0000000000..24d36c4d0f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-calendar.js
@@ -0,0 +1,184 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import Calendar from '@seafile/seafile-calendar';
+import DatePicker from '@seafile/seafile-calendar/lib/Picker';
+import { translateCalendar } from '../../../../utils/date-translate';
+import { getDateColumnFormat } from '../../../../utils/column-utils';
+import dayjs from '../../../../utils/dayjs';
+import 'dayjs/locale/zh-cn';
+import 'dayjs/locale/en-gb';
+
+import '@seafile/seafile-calendar/assets/index.css';
+
+let now = dayjs();
+
+const propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ filterColumn: PropTypes.object.isRequired,
+ isReadOnly: PropTypes.bool,
+};
+
+class FilterCalendar extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ open: false,
+ value: null
+ };
+ const DataFormat = getDateColumnFormat(props.filterColumn).trim();
+ // Minutes and seconds are not supported at present
+ this.columnDataFormat = DataFormat.split(' ')[0];
+ this.calendarContainerRef = React.createRef();
+ this.defaultCalendarValue = null;
+ }
+
+ componentDidMount() {
+ const iszhcn = (window.app && window.app.config && window.app.config.lang === 'zh-cn');
+ if (iszhcn) {
+ now = now.locale('zh-cn');
+ } else {
+ now = now.locale('en-gb');
+ }
+ this.defaultCalendarValue = now.clone();
+ const { value } = this.props;
+ if (value && dayjs(value).isValid()) {
+ let validValue = dayjs(value).isValid() ? dayjs(value) : dayjs(this.defaultCalendarValue);
+ this.setState({
+ value: iszhcn ? dayjs(validValue).locale('zh-cn') : dayjs(validValue).locale('en-gb')
+ });
+ }
+ }
+
+ handleMouseDown = (e) => {
+ e.preventDefault();
+ };
+
+ onChange = (value) => {
+ const { onChange } = this.props;
+ const searchFormat = 'YYYY-MM-DD';
+ this.setState({
+ value
+ }, () => {
+ if (this.state.value) {
+ onChange(this.state.value.format(searchFormat));
+ }
+ });
+ };
+
+ onClear = () => {
+ this.setState({
+ value: null
+ }, () => {
+ this.setState({
+ open: true
+ });
+ });
+ };
+
+ onOpenChange = (open) => {
+ this.setState({
+ open,
+ });
+ };
+
+ onReadOnlyFocus = () => {
+ if (!this.state.open && this.state.isMouseDown) {
+ this.setState({
+ isMouseDown: false,
+ });
+ } else {
+ this.setState({
+ open: true,
+ });
+ }
+ };
+
+ getCalendarContainer = () => {
+ return this.calendarContainerRef.current;
+ };
+
+ getCalendarFormat = () => {
+ let calendarFormat = [];
+ if (this.columnDataFormat.indexOf('YYYY-MM-DD') > -1) {
+ let newColumnDataFormat = this.columnDataFormat.replace('YYYY-MM-DD', 'YYYY-M-D');
+ calendarFormat = [this.columnDataFormat, newColumnDataFormat];
+ } else if (this.columnDataFormat.indexOf('DD/MM/YYYY') > -1) {
+ let newColumnDataFormat = this.columnDataFormat.replace('DD/MM/YYYY', 'D/M/YYYY');
+ calendarFormat = [this.columnDataFormat, newColumnDataFormat];
+ } else {
+ calendarFormat = [this.columnDataFormat];
+ }
+ return calendarFormat;
+ };
+
+ render() {
+ const { isReadOnly } = this.props;
+ const state = this.state;
+ if (isReadOnly) return (
+
+ );
+ const calendarFormat = this.getCalendarFormat();
+ const clearStyle = {
+ position: 'absolute',
+ top: '15px',
+ left: '225px',
+ color: 'gray',
+ fontSize: '12px'
+ };
+ const clearIcon = React.createElement('i', { className: 'item-icon sf-metadata-font sf-metadata-icon-x', style: clearStyle });
+ const calendar = (
+
+ );
+ return (
+
+
+ {
+ ({ value }) => {
+ return (
+
+
+
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+FilterCalendar.propTypes = propTypes;
+
+export default FilterCalendar;
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js
new file mode 100644
index 0000000000..4d22c32501
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item-utils.js
@@ -0,0 +1,91 @@
+import React, { Fragment } from 'react';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { COLUMNS_ICON_CONFIG, FILTER_TERM_MODIFIER_SHOW } from '../../../../_basic';
+import { gettext } from '../../../../utils';
+
+class FilterItemUtils {
+
+ static generatorColumnOption(column) {
+ if (!column) return null;
+ const { type, name } = column;
+ return {
+ value: { column },
+ label: (
+
+
+ {name}
+
+ )
+ };
+ }
+
+ static generatorPredicateOption(filterPredicate) {
+ return {
+ value: { filterPredicate },
+ label: {gettext(filterPredicate)}
+ };
+ }
+
+ static generatorTermModifierOption(filterTermModifier) {
+ return {
+ value: { filterTermModifier },
+ label: {FILTER_TERM_MODIFIER_SHOW[filterTermModifier]}
+ };
+ }
+
+ static generatorSingleSelectOption(option, selectedOption) {
+ return {
+ value: { columnOption: option },
+ label: (
+
+
{option.name}
+
+ {selectedOption?.id === option.id && }
+
+
+ )
+ };
+ }
+
+ static generatorMultipleSelectOption(option, filterTerm) {
+ return {
+ value: { columnOption: option },
+ label: (
+
+
{option.name}
+
+ {filterTerm.indexOf(option.id) > -1 && }
+
+
+ )
+ };
+ }
+
+ static generatorConjunctionOptions() {
+ return [
+ {
+ value: { filterConjunction: 'And' },
+ label: ({gettext('And')})
+ },
+ {
+ value: { filterConjunction: 'Or' },
+ label: ({gettext('Or')})
+ }
+ ];
+ }
+
+ static getActiveConjunctionOption(conjunction) {
+ if (conjunction === 'And') {
+ return {
+ value: { filterConjunction: 'And' },
+ label: ({gettext('And')})
+ };
+ }
+ return {
+ value: { filterConjunction: 'Or' },
+ label: ({gettext('Or')})
+ };
+ }
+}
+
+export default FilterItemUtils;
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js
new file mode 100644
index 0000000000..0077bc2f01
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/filter-item.js
@@ -0,0 +1,512 @@
+import React, { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { UncontrolledTooltip } from 'reactstrap';
+import { CustomizeSelect, IconBtn, SearchInput } from '@seafile/sf-metadata-ui-component';
+import {
+ CellType,
+ FILTER_PREDICATE_TYPE,
+ FILTER_TERM_MODIFIER_TYPE,
+ filterTermModifierIsWithin,
+ isDateColumn,
+ FILTER_ERR_MSG,
+} from '../../../../_basic';
+import CollaboratorFilter from './collaborator-filter';
+import FilterCalendar from './filter-calendar';
+import FilterItemUtils from './filter-item-utils';
+import {
+ getFilterByColumn, getUpdatedFilterBySelectSingle, getUpdatedFilterBySelectMultiple,
+ getUpdatedFilterByCreator, getUpdatedFilterByCollaborator, getColumnOptions, getUpdatedFilterByPredicate,
+} from '../../../../utils/filters-utils';
+import { isCheckboxColumn } from '../../../../utils/column-utils';
+import { gettext } from '../../../../utils';
+import { DELETED_OPTION_BACKGROUND_COLOR, DELETED_OPTION_TIPS } from '../../../../constants';
+
+const propTypes = {
+ index: PropTypes.number.isRequired,
+ filter: PropTypes.object.isRequired,
+ filterColumn: PropTypes.object.isRequired,
+ filterConjunction: PropTypes.string.isRequired,
+ conjunctionOptions: PropTypes.array.isRequired,
+ filterColumnOptions: PropTypes.array.isRequired,
+ value: PropTypes.object,
+ deleteFilter: PropTypes.func.isRequired,
+ updateFilter: PropTypes.func.isRequired,
+ updateConjunction: PropTypes.func.isRequired,
+ collaborators: PropTypes.array,
+ errMsg: PropTypes.string,
+};
+
+const EMPTY_PREDICATE = [FILTER_PREDICATE_TYPE.EMPTY, FILTER_PREDICATE_TYPE.NOT_EMPTY];
+
+class FilterItem extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filterTerm: props.filter.filter_term,
+ enterRateItemIndex: 0,
+ };
+ this.filterPredicateOptions = null;
+ this.filterTermModifierOptions = null;
+
+ this.filterToolTip = React.createRef();
+ this.invalidFilterTip = React.createRef();
+
+ this.initSelectOptions(props);
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { filter } = this.props;
+ if (nextProps.filter !== filter) {
+ this.initSelectOptions(nextProps);
+ this.setState({
+ filterTerm: nextProps.filter.filter_term,
+ });
+ }
+ }
+
+ shouldComponentUpdate(nextProps) {
+ const currentProps = this.props;
+ const shouldUpdated = (
+ nextProps.index !== currentProps.index ||
+ nextProps.filter !== currentProps.filter ||
+ nextProps.filterColumn !== currentProps.filterColumn ||
+ nextProps.filterConjunction !== currentProps.filterConjunction ||
+ nextProps.conjunctionOptions !== currentProps.conjunctionOptions ||
+ nextProps.filterColumnOptions !== currentProps.filterColumnOptions
+ );
+ return shouldUpdated;
+ }
+
+ initSelectOptions = (props) => {
+ const { filter, filterColumn, value } = props;
+ let { filterPredicateList, filterTermModifierList } = getColumnOptions(filterColumn, value);
+ // The value of the calculation formula column does not exist in the shared view
+ this.filterPredicateOptions = filterPredicateList ? filterPredicateList.map(predicate => {
+ return FilterItemUtils.generatorPredicateOption(predicate);
+ }).filter(item => item) : [];
+
+ const { filter_predicate } = filter;
+ if (isDateColumn(filterColumn)) {
+ if (filter_predicate === FILTER_PREDICATE_TYPE.IS_WITHIN) {
+ filterTermModifierList = filterTermModifierIsWithin;
+ }
+ this.filterTermModifierOptions = filterTermModifierList.map(termModifier => {
+ return FilterItemUtils.generatorTermModifierOption(termModifier);
+ });
+ }
+ };
+
+ onDeleteFilter = (event) => {
+ event.nativeEvent.stopImmediatePropagation();
+ const { index } = this.props;
+ this.props.deleteFilter(index);
+ };
+
+ resetState = (filter) => {
+ this.setState({ filterTerm: filter.filter_term });
+ };
+
+ onSelectConjunction = (value) => {
+ const { filterConjunction } = this.props;
+ if (filterConjunction === value.filterConjunction) {
+ return;
+ }
+ this.props.updateConjunction(value.filterConjunction);
+ };
+
+ onSelectColumn = (value) => {
+ const { index, filter } = this.props;
+ const { column } = value;
+ if (column.key === filter.column_key) return;
+
+ let newFilter = getFilterByColumn(column, filter);
+ if (!newFilter) return;
+
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectPredicate = (value) => {
+ const { index, filter, filterColumn } = this.props;
+ const { filterPredicate } = value;
+ if (filter.filter_predicate === filterPredicate) {
+ return;
+ }
+ let newFilter = getUpdatedFilterByPredicate(filter, filterColumn, filterPredicate);
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectTermModifier = (value) => {
+ const { index, filter } = this.props;
+ const { filterTermModifier } = value;
+ const inputRangeLabel = [
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS
+ ];
+ if (filter.filter_term_modifier === filterTermModifier) {
+ return;
+ }
+ let filter_term = filter.filter_term;
+ if (inputRangeLabel.indexOf(filter.filter_term_modifier) > -1) {
+ filter_term = '';
+ }
+ let newFilter = Object.assign({}, filter, { filter_term_modifier: filterTermModifier, filter_term });
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectSingle = (value) => {
+ const { index, filter } = this.props;
+ const { columnOption: option } = value;
+ if (filter.filter_term === option.id) {
+ return;
+ }
+
+ let newFilter = getUpdatedFilterBySelectSingle(filter, option);
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectMultiple = (value) => {
+ const { index, filter } = this.props;
+ const { columnOption: option } = value;
+
+ let newFilter = getUpdatedFilterBySelectMultiple(filter, option);
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectCollaborator = (value) => {
+ const { index, filter } = this.props;
+ const { columnOption: collaborator } = value;
+ let newFilter = getUpdatedFilterByCollaborator(filter, collaborator);
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+ };
+
+ onSelectCreator = (value) => {
+ const { index, filter } = this.props;
+ const { columnOption: collaborator } = value;
+ let newFilter = getUpdatedFilterByCreator(filter, collaborator);
+ // the predicate is 'is' or 'is not'
+ if (!newFilter) {
+ return;
+ }
+ this.resetState(newFilter);
+ this.props.updateFilter(index, newFilter);
+
+ };
+
+ onFilterTermCheckboxChanged = (e) => {
+ this.onFilterTermChanged(e.target.checked);
+ };
+
+ onFilterTermTextChanged = (value) => {
+ this.onFilterTermChanged(value);
+ };
+
+ onFilterTermNumberChanged = () => {
+ const value = this.numberEditor.getValue();
+ this.onFilterTermChanged(Object.values(value)[0]);
+ };
+
+ onFilterTermChanged = (newFilterTerm) => {
+ const { index, filter } = this.props;
+ const { filterTerm } = this.state;
+ if (newFilterTerm !== filterTerm) {
+ this.setState({ filterTerm: newFilterTerm });
+ let newFilter = Object.assign({}, filter, { filter_term: newFilterTerm });
+ this.props.updateFilter(index, newFilter);
+ }
+ };
+
+ onMouseEnterRateItem = (index) => {
+ this.setState({ enterRateItemIndex: index });
+ };
+
+ onMouseLeaveRateItem = () => {
+ this.setState({ enterRateItemIndex: 0 });
+ };
+
+ onChangeRateNumber = (index) => {
+ this.onFilterTermChanged(index);
+ };
+
+ getInputComponent = (type) => {
+ const { filterTerm } = this.state;
+ if (type === 'text') {
+ return (
+
+ );
+ } else if (type === 'checkbox') {
+ return (
+
+ );
+ }
+ };
+
+ renderConjunction = () => {
+ const { index, filterConjunction, conjunctionOptions } = this.props;
+ switch (index) {
+ case 0: {
+ return null;
+ }
+ case 1: {
+ const activeConjunction = FilterItemUtils.getActiveConjunctionOption(filterConjunction);
+ return (
+
+ );
+ }
+ default: {
+ return (
+ {gettext(filterConjunction)}
+ );
+ }
+ }
+
+ };
+
+ renderMultipleSelectOption = (options = [], filterTerm) => {
+ const { filter } = this.props;
+ const { filter_predicate } = filter;
+ let isSupportMultipleSelect = false;
+ // The first two options are used for single selection, and the last four options are used for multiple selection
+ const supportMultipleSelectOptions = [
+ FILTER_PREDICATE_TYPE.IS_ANY_OF,
+ FILTER_PREDICATE_TYPE.IS_NONE_OF,
+ FILTER_PREDICATE_TYPE.HAS_ANY_OF,
+ FILTER_PREDICATE_TYPE.HAS_ALL_OF,
+ FILTER_PREDICATE_TYPE.HAS_NONE_OF,
+ FILTER_PREDICATE_TYPE.IS_EXACTLY
+ ];
+ if (supportMultipleSelectOptions.includes(filter_predicate)) {
+ isSupportMultipleSelect = true;
+ }
+ const className = 'select-option-name multiple-select-option';
+ let labelArray = [];
+ if (Array.isArray(options) && Array.isArray(filterTerm)) {
+ filterTerm.forEach((item) => {
+ let inOption = options.find(option => option.id === item);
+ let optionStyle = { margin: '0 10px 0 0' };
+ let optionName = null;
+ if (inOption) {
+ optionName = inOption.name;
+ optionStyle.background = inOption.color;
+ optionStyle.color = inOption.textColor || null;
+ } else {
+ optionStyle.background = DELETED_OPTION_BACKGROUND_COLOR;
+ optionName = gettext(DELETED_OPTION_TIPS);
+ }
+ labelArray.push(
+
+ {optionName}
+
+ );
+ });
+ }
+ const selectedOptionNames = labelArray.length > 0 ? { label: ({labelArray}) } : {};
+
+ const dataOptions = options.map(option => {
+ return FilterItemUtils.generatorMultipleSelectOption(option, filterTerm);
+ });
+ return (
+
+ );
+ };
+
+ renderFilterTerm = (filterColumn) => {
+ const { index, filter, collaborators } = this.props;
+ const { type } = filterColumn;
+ const { filter_term, filter_predicate, filter_term_modifier } = filter;
+ // predicate is empty or not empty
+ if (EMPTY_PREDICATE.includes(filter_predicate)) {
+ return null;
+ }
+
+ // the cell value will be date
+ // 1. DATE
+ // 2. CTIME: create-time
+ // 3. MTIME: modify-time
+ // 4. FORMULA: result_type is date
+ if (isDateColumn(filterColumn)) {
+ const inputRangeLabel = [
+ FILTER_TERM_MODIFIER_TYPE.EXACT_DATE,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO,
+ FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW,
+ FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS,
+ FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS
+ ];
+ if (inputRangeLabel.indexOf(filter_term_modifier) > -1) {
+ if (filter_term_modifier === 'exact_date') {
+ return (
+
+ );
+ }
+ return this.getInputComponent('text');
+ }
+ return null;
+ }
+
+ switch (type) {
+ case CellType.TEXT:
+ case CellType.URL: { // The data in the formula column is a date type that has been excluded
+ if (filter_predicate === FILTER_PREDICATE_TYPE.IS_CURRENT_USER_ID) {
+ return null;
+ }
+ return this.getInputComponent('text');
+ }
+ case CellType.CREATOR:
+ case CellType.LAST_MODIFIER: {
+ if (filter_predicate === FILTER_PREDICATE_TYPE.INCLUDE_ME) {
+ return null;
+ }
+ const creators = collaborators;
+ return (
+
+ );
+ }
+ default: {
+ return null;
+ }
+ }
+ };
+
+ isRenderErrorTips = () => {
+ const { errMsg } = this.props;
+ return errMsg && errMsg !== FILTER_ERR_MSG.INCOMPLETE_FILTER;
+ };
+
+ renderErrorMessage = () => {
+ if (!this.isRenderErrorTips()) {
+ return null;
+ }
+ return (
+
+
+
+ {gettext('Invalid filter')}
+
+
+ );
+ };
+
+ render() {
+ const { filterPredicateOptions, filterTermModifierOptions } = this;
+ const { filter, filterColumn, filterColumnOptions } = this.props;
+ const { filter_predicate, filter_term_modifier } = filter;
+ const activeColumn = FilterItemUtils.generatorColumnOption(filterColumn);
+ const activePredicate = FilterItemUtils.generatorPredicateOption(filter_predicate);
+ let activeTermModifier = null;
+ let _isCheckboxColumn = false;
+ if (isDateColumn(filterColumn)) {
+ activeTermModifier = FilterItemUtils.generatorTermModifierOption(filter_term_modifier);
+ } 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;
+
+ // current predicate is not empty
+ const isNeedShowTermModifier = !EMPTY_PREDICATE.includes(filter_predicate);
+
+ return (
+
+
+
+
+
+
+ {this.renderConjunction()}
+
+
+
+
+
+
+
+
+ {isDateColumn(filterColumn) && isNeedShowTermModifier && (
+
+
+
+ )}
+
+ {this.renderFilterTerm(filterColumn)}
+
+ {showToolTip &&
+
+
+
+ {gettext('Filter tip message')}
+
+
+ }
+ {this.renderErrorMessage()}
+
+
+
+ );
+ }
+}
+
+FilterItem.propTypes = propTypes;
+
+export default FilterItem;
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css
new file mode 100644
index 0000000000..0591e2031a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.css
@@ -0,0 +1,321 @@
+.filters-list {
+ min-height: 120px;
+ max-height: 100%;
+ padding: 15px;
+}
+
+.filters-list.empty-filters-container {
+ min-height: 80px;
+ padding: 16px;
+}
+
+.filters-list.empty-filters-container .empty-filters-list {
+ padding-left: 0;
+}
+
+.filters-list .empty-filters-list {
+ padding: 0.25rem;
+ font-size: 14px;
+ color: #666666;
+}
+
+.filters-list .filter-item {
+ display: flex;
+ align-items: center;
+ padding: 0.25rem 0;
+}
+
+.filters-list .filter-item .condition {
+ display: flex;
+ flex: 1;
+}
+
+.filters-list .filter-item .condition > div {
+ height: 38px;
+ line-height: 38px;
+ margin-left: 0.5rem;
+}
+
+.filters-list .filter-item .condition > div:first-child {
+ margin-left: 0;
+}
+
+.filters-list .filter-item .filter-term {
+ max-width: 300px;
+}
+
+.filters-list .filter-item .filter-term .option-group-content .option.option-active .sf-metadata-font {
+ color: #798d99;
+}
+
+.filters-list .filter-conjunction {
+ width: 72px;
+}
+
+.filters-list .filter-conjunction-readonly {
+ width: 52px;
+}
+
+.filters-list .filter-container {
+ width: calc(100% - 72px);
+ display: flex;
+}
+
+.filters-list .sf-metadata-select .selected-option-show {
+ width: calc(100% - 20px);
+ height: 20px;
+}
+
+.filters-list .sf-metadata-select .selected-option {
+ width: auto;
+ overflow-x: auto;
+}
+
+.filters-list .sf-metadata-select .sf-metadata-icon-drop-down {
+ margin-left: 0.5rem;
+}
+
+.filters-list .selected-conjunction-show {
+ padding: 0 10px;
+ color: #666666;
+}
+
+.filters-list .filter-column {
+ max-width: 150px;
+}
+
+.filter-term .selector-multiple-select .option,
+.filter-term .selector-single-select .option {
+ height: 30px;
+ padding: 0 10px;
+}
+
+.filter-term .selector-multiple-select .select-option-name,
+.filter-term .selector-single-select .select-option-name {
+ margin-top: 5px;
+}
+
+.filter-term .selector-single-select .option:hover {
+ color: #212529;
+ background-color: #f7f7f7;
+}
+
+.filter-term .selector-single-select .option:hover .select-option-name,
+.filter-term .selector-multiple-select .option:hover .select-option-name,
+.filter-term .selector-collaborator .option:hover .select-option-name {
+ color: unset;
+}
+
+.filter-term .selector-collaborator .sf-metadata-icon-drop-down {
+ padding-left: 5px;
+}
+
+.filters-list .selector-collaborator .selected-option-show {
+ text-overflow: unset;
+}
+
+.filters-list .selector-multiple-select .option:hover,
+.filters-list .selector-multiple-select .option.option-active,
+.filters-list .selector-collaborator .option:hover,
+.filters-list .selector-collaborator .option.option-active {
+ color: #212529;
+ background-color: #f7f7f7;
+}
+
+.filters-list .selector-multiple-select .option.option-active .select-option-name,
+.filters-list .selector-collaborator .option.option-active .select-option-name {
+ color: #212529;
+}
+
+.filters-list .selected-option .multiple-select-option,
+.filters-list .selected-option .single-select-option {
+ margin: 0;
+ display: inline-block;
+}
+
+.filters-list .filter-term input {
+ display: flex;
+ width: 100%;
+ height: 38px;
+ background-color: #ffffff;
+ padding-left: 8px;
+ padding-right: 8px;
+ outline: none;
+ border-radius: 3px;
+ font-size: 0.875rem;
+}
+
+.filters-list .filter-term input:hover {
+ border-color: rgb(179, 179, 179);
+}
+
+.filters-list .filter-term input.disabled:hover {
+ border-color: rgba(0, 40, 100, 0.12);
+}
+
+.filters-list .filter-term input:hover:focus {
+ border-color: #1991eb;
+}
+
+.filters-list .filter-term input:focus {
+ color: #495057;
+ background-color: #fff;
+ border-color: #1991eb;
+ outline: 0;
+ box-shadow: 0 0 0 2px rgba(70, 127, 207, 0.25);
+}
+
+.filters-list .filter-term .date-picker-container input:focus {
+ color: #495057;
+ background-color: #fff;
+ border: 1px solid #fff;
+ outline: 0;
+}
+
+.filters-list .filter-term .date-picker-container table tr {
+ height: 30px;
+}
+
+.filters-list .filter-term input[type='checkbox'] {
+ width: inherit;
+}
+
+.filters-list .filter-term input[type='checkbox']:focus {
+ border: 0;
+ box-shadow: none;
+}
+
+.filter-term .filter-rate-list {
+ display: flex;
+ padding: 0 5px;
+ border: 1px solid rgba(0, 40, 100, 0.12);
+}
+
+.filters-list .delete-filter {
+ width: 12px;
+ height: 20px;
+ margin-right: 14px;
+ text-align: center;
+}
+
+.filters-list .delete-filter:hover {
+ cursor: pointer;
+}
+
+.filters-list .delete-filter .sf-metadata-icon-fork-number {
+ display: inline-block;
+ font-size: 12px;
+ color: #999;
+}
+
+.filters-list .multiple-option-name {
+ display: flex;
+ align-items: center;
+}
+
+.filters-list .multiple-check-icon,
+.filters-list .collaborator-check-icon {
+ display: inline-flex;
+ width: 20px;
+ height: 20px;
+ align-items: center;
+ text-align: center;
+}
+
+.filters-list .multiple-check-icon .sf-metadata-icon-check-mark,
+.filters-list .collaborator-check-icon .sf-metadata-icon-check-mark {
+ font-size: 12px;
+ color: #798d99;
+}
+
+.user-select-item,
+.collaborator {
+ display: inline-flex;
+ align-items: center;
+ height: 20px;
+ margin-right: 10px;
+ padding: 0 8px 0 2px;
+ font-size: 13px;
+ border-radius: 10px;
+ background: #eaeaea;
+}
+
+.filters-list .collaborator-show {
+ flex: 1;
+}
+
+.filters-list .collaborator-avatar-container {
+ width: 16px;
+}
+
+.filters-list .collaborator-avatar {
+ width: 16px;
+ height: 16px;
+ transform: translateY(-1px);
+ border-radius: 50%;
+}
+
+.filters-list .option .collaborator-avatar {
+ transform: translateY(-2px);
+}
+
+.filters-list .collaborator-name {
+ margin-left: 5px;
+ max-width: 200px;
+}
+
+.filters-list .option-collaborator {
+ display: flex;
+}
+
+.filters-list .collaborator-container {
+ flex: 1;
+}
+
+.filters-list .popover-add-tool {
+ border-top: none;
+ color: #666666;
+}
+
+.filters-list .popover-add-tool .popover-add-icon {
+ margin-right: 14px;
+}
+
+.filters-list .option-group {
+ max-height: 360px;
+ overflow: auto;
+}
+
+.filters-list .filter-item .sf-metadata-icon-fork-number:hover {
+ color: #666666;
+}
+
+.filters-list .filter-container-readonly .sf-metadata-select .selected-option-show,
+.filters-list .filter-conjunction-readonly .sf-metadata-select .selected-option-show {
+ width: 100%;
+}
+
+.filters-list .filter-checkbox-predicate .sf-metadata-select .selected-option-show {
+ width: 100%;
+}
+
+.dropdown-item .collaborator,
+.filters-list .option-group .option-group-content .collaborator {
+ background-color: unset;
+}
+
+.filters-list .sf-metadata-select .selected-option-show .remove-container {
+ display: none;
+}
+
+.filter-header-icon {
+ display: inline-block;
+ padding: 0 0.3125rem;
+ margin-left: -0.3125rem;
+}
+
+.filter-header-icon .sf-metadata-font {
+ font-size: 14px;
+ color: #aaa;
+ cursor: default;
+}
diff --git a/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js
new file mode 100644
index 0000000000..83cc771601
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/filter-popover/widgets/index.js
@@ -0,0 +1,129 @@
+import React, { Component } from 'react';
+import classnames from 'classnames';
+import PropTypes from 'prop-types';
+import {
+ FILTER_COLUMN_OPTIONS,
+ ValidateFilter,
+} from '../../../../_basic';
+import FilterItemUtils from './filter-item-utils';
+import FilterItem from './filter-item';
+import { getColumnByKey } from '../../../../utils/column-utils';
+
+import './index.css';
+
+const propTypes = {
+ isLocked: PropTypes.bool,
+ className: PropTypes.string,
+ filters: PropTypes.array,
+ columns: PropTypes.array.isRequired,
+ filterConjunction: PropTypes.string.isRequired,
+ updateFilter: PropTypes.func.isRequired,
+ deleteFilter: PropTypes.func.isRequired,
+ updateFilterConjunction: PropTypes.func,
+ emptyPlaceholder: PropTypes.string,
+ value: PropTypes.object,
+ collaborators: PropTypes.array,
+ scheduleUpdate: PropTypes.func,
+ isPre: PropTypes.bool,
+};
+
+class FiltersList extends Component {
+
+ constructor(props) {
+ super(props);
+ this.conjunctionOptions = null;
+ this.columnOptions = null;
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (nextProps.columns !== this.props.columns) {
+ this.columnOptions = null;
+ }
+ }
+
+ updateFilter = (filterIndex, updatedFilter) => {
+ if (!updatedFilter) return;
+ this.props.updateFilter(filterIndex, updatedFilter);
+ };
+
+ deleteFilter = (index) => {
+ const { scheduleUpdate } = this.props;
+ this.props.deleteFilter(index, scheduleUpdate);
+ };
+
+ updateConjunction = (filterConjunction) => {
+ this.props.updateFilterConjunction(filterConjunction);
+ };
+
+ getConjunctionOptions = () => {
+ if (!this.conjunctionOptions) {
+ this.conjunctionOptions = FilterItemUtils.generatorConjunctionOptions();
+ }
+ return this.conjunctionOptions;
+ };
+
+ getFilterColumns = () => {
+ const { columns } = this.props;
+ return columns.filter(column => {
+ let { type } = column;
+ return Object.prototype.hasOwnProperty.call(FILTER_COLUMN_OPTIONS, type);
+ });
+ };
+
+ getColumnOptions = () => {
+ if (!this.columnOptions) {
+ const filterColumns = this.getFilterColumns();
+ this.columnOptions = filterColumns.map(column => {
+ return FilterItemUtils.generatorColumnOption(column);
+ });
+ }
+ return this.columnOptions;
+ };
+
+ renderFilterItem = (filter, index, errMsg, filterColumn) => {
+ const { filterConjunction, value } = this.props;
+ const conjunctionOptions = this.getConjunctionOptions();
+ const columnOptions = this.getColumnOptions();
+ return (
+
+ );
+ };
+
+ render() {
+ let { filters, className, emptyPlaceholder, columns } = this.props;
+ const isEmpty = filters.length === 0;
+ return (
+
+ {isEmpty &&
{emptyPlaceholder}
}
+ {!isEmpty &&
+ filters.map((filter, index) => {
+ const { column_key } = filter;
+ const { error_message } = ValidateFilter.validate(filter, columns);
+ const filterColumn = getColumnByKey(column_key, columns) || {};
+ return this.renderFilterItem(filter, index, error_message, filterColumn);
+ })
+ }
+
+ );
+ }
+}
+
+FiltersList.propTypes = propTypes;
+
+export default FiltersList;
diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css
new file mode 100644
index 0000000000..2edda2c52e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.css
@@ -0,0 +1,110 @@
+.groupby-popover .popover {
+ max-width: none;
+ min-width: 400px;
+}
+
+.groupby-popover .groupbys {
+ min-height: 80px;
+ max-height: 300px;
+ padding: 15px;
+}
+
+.groupby-popover .empty-groupbys-container {
+ min-height: 80px;
+ padding: 16px;
+}
+
+.groupby-popover .groupbys .groupby-item {
+ display: flex;
+ align-items: center;
+ padding: 0.25rem 0;
+}
+
+.groupby-popover .groupby-item .option-group {
+ overflow: auto;
+ max-height: 360px;
+}
+
+.groupby-popover .groupby-item .condition {
+ display: flex;
+ flex: 1 1;
+}
+
+.groupby-popover .groupby-item .condition > div {
+ height: 38px;
+ line-height: 38px;
+}
+
+.groupby-popover .groupby-item .groupby-column {
+ width: 150px;
+}
+
+.groupby-popover .groupby-item .groupby-count-type,
+.groupby-popover .groupby-item .groupby-predicate {
+ width: 130px;
+}
+
+.groupby-popover .groupby-item .sf-metadata-icon-exclamation-triangle {
+ color: rgb(205, 32, 31);
+}
+
+.groupby-popover .column-icon {
+ display: inline-block;
+ padding: 0 0.3125rem;
+ margin-left: -0.3125rem;
+}
+
+.groupby-popover .column-icon .sf-metadata-font {
+ font-size: 14px;
+ color: #aaa;
+ cursor: default;
+}
+
+.groupby-popover .delete-groupby {
+ width: 12px;
+ height: 20px;
+ margin-right: 14px;
+ text-align: center;
+}
+
+.groupby-popover .empty-groupbys {
+ color: #666666;
+}
+
+.groupby-popover .delete-groupby .sf-metadata-icon-fork-number {
+ display: inline-block;
+ font-size: 12px;
+ color: #999;
+ cursor: pointer;
+}
+
+.groupby-popover .delete-groupby .sf-metadata-icon-fork-number:hover {
+ color: #666666;
+}
+
+.groupby-popover .popover-add-tool {
+ border-top: none;
+ color: #666666;
+}
+
+.groupby-popover .popover-add-tool .popover-add-icon {
+ margin-right: 14px;
+}
+
+.groupby-popover .groupbys-tools {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+ padding: 0 15px 15px;
+ font-size: 14px;
+ color: #666666;
+}
+
+.groupby-popover .groupbys-tools .groupbys-tool-item:first-child {
+ margin-right: 20px;
+}
+
+.groupby-popover .groupbys-tools .groupbys-tool-item:hover {
+ cursor: pointer;
+ color: #666666;
+}
diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx
new file mode 100644
index 0000000000..20c3c722ca
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/index.jsx
@@ -0,0 +1,317 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import intl from 'react-intl-universal';
+import { UncontrolledPopover, Button } from 'reactstrap';
+import {
+ COLUMNS_ICON_CONFIG,
+ DISPLAY_GROUP_DATE_GRANULARITY,
+ DISPLAY_GROUP_GEOLOCATION_GRANULARITY,
+ MAX_GROUP_LEVEL,
+ SORT_TYPE,
+} from 'sf-metadata-utils';
+import CommonAddTool from '../../common/common-add-tool';
+import GroupbyItem from '../groupby-popover-widgets/groupby-item';
+import GroupbyService from '../../services/groupby-service';
+import { isEsc } from '../../utils/hotkey';
+import { getColumnByKey } from '../../../utils/column-utils';
+import { getEventClassName } from '../../utils/utils';
+import { generateDefaultGroupby, getDefaultCountType, getGroupbyColumns } from '../../../utils/groupby-utils';
+import eventBus from '../../../utils/event-bus';
+import { GROUPBY_ACTION_TYPE, GROUPBY_DATE_GRANULARITY_LIST, GROUPBY_GEOLOCATION_GRANULARITY_LIST } from '../../constants/groupby';
+import { EVENT_BUS_TYPE } from '../../../constants';
+
+import './index.css';
+
+class GroupbyPopover extends Component {
+
+ constructor(props) {
+ super(props);
+ const { groupbys, columns } = this.props;
+ this.groupbyService = new GroupbyService({ groupbys });
+ this.columnsOptions = this.createColumnsOptions(columns);
+ this.geoCountTypeOptions = this.createGeoCountTypeOptions();
+ this.dateCountTypeOptions = this.createDateCountTypeOptions();
+ this.sortTypeOptions = this.createSortTypeOptions();
+ this.state = {
+ groupbys: this.groupbyService.getGroupbys(),
+ };
+ this.isSelectOpen = false;
+ }
+
+ componentDidMount() {
+ document.addEventListener('click', this.hideDTablePopover, true);
+ document.addEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { columns } = this.props;
+ if (columns !== prevProps.columns) {
+ this.columnsOptions = this.createColumnsOptions(columns);
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.hideDTablePopover, true);
+ document.removeEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect();
+ }
+
+ hideDTablePopover = (e) => {
+ if (this.groupbysWrapper && !getEventClassName(e).includes('popover') && !this.groupbysWrapper.contains(e.target)) {
+ this.props.onGroupbyPopoverToggle();
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ };
+
+ onHotKey = (e) => {
+ if (isEsc(e) && !this.isSelectOpen) {
+ e.preventDefault();
+ this.props.onGroupbyPopoverToggle();
+ }
+ };
+
+ setSelectStatus = (status) => {
+ this.isSelectOpen = status;
+ };
+
+ createColumnsOptions = (columns = []) => {
+ const validColumns = getGroupbyColumns(columns);
+ return validColumns.map((column) => {
+ const { type, name } = column;
+ return {
+ value: { column },
+ label: (
+
+
+ {name}
+
+ )
+ };
+ });
+ };
+
+ createGeoCountTypeOptions = () => {
+ return GROUPBY_GEOLOCATION_GRANULARITY_LIST.map(granularity => {
+ const displayGranularity = DISPLAY_GROUP_GEOLOCATION_GRANULARITY[granularity];
+ if (!displayGranularity) {
+ return null;
+ }
+ return {
+ value: { countType: granularity },
+ label: {intl.get(displayGranularity)},
+ };
+ }).filter(Boolean);
+ };
+
+ createDateCountTypeOptions = () => {
+ return GROUPBY_DATE_GRANULARITY_LIST.map(granularity => {
+ const displayGranularity = DISPLAY_GROUP_DATE_GRANULARITY[granularity];
+ if (!displayGranularity) {
+ return null;
+ }
+ return {
+ value: { countType: granularity },
+ label: {intl.get(DISPLAY_GROUP_DATE_GRANULARITY[granularity])},
+ };
+ }).filter(Boolean);
+ };
+
+ createSortTypeOptions = () => {
+ return [
+ {
+ value: { sortType: SORT_TYPE.UP },
+ label: {intl.get(SORT_TYPE.UP)}
+ },
+ {
+ value: { sortType: SORT_TYPE.DOWN },
+ label: {intl.get(SORT_TYPE.DOWN)}
+ },
+ ];
+ };
+
+ addGroupby = (scheduleUpdate) => {
+ const { groupbys } = this.state;
+ // When the size of the popover is changed, need use scheduleUpdate to reposition the popover
+ if (groupbys.length === 0) {
+ scheduleUpdate();
+ }
+ const groupbyColumns = this.columnsOptions.map(option => option.value.column);
+ const groupby = generateDefaultGroupby(groupbyColumns);
+ this.groupbyService.update(GROUPBY_ACTION_TYPE.ADD, { groupby });
+ this.updateGroups();
+ };
+
+ deleteGroupby = (index, event, scheduleUpdate) => {
+ event.nativeEvent.stopImmediatePropagation();
+ this.groupbyService.update(GROUPBY_ACTION_TYPE.DELETE, { index });
+ // use scheduleUpdate to reposition the popover
+ scheduleUpdate();
+ this.updateGroups();
+ };
+
+ isNeedSubmit = () => {
+ return this.props.isNeedSubmit;
+ };
+
+ selectColumn = ({ column }, index) => {
+ const { groupbys } = this.state;
+ const updatedGroupby = groupbys[index];
+ const newColumnKey = column.key;
+ if (newColumnKey === updatedGroupby.column_key) {
+ return;
+ }
+ const newGroupby = {
+ ...updatedGroupby,
+ column_key: newColumnKey,
+ sort_type: SORT_TYPE.UP,
+ count_type: getDefaultCountType(column),
+ };
+ this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby });
+ this.updateGroups();
+ };
+
+ selectCountType = ({ countType }, index) => {
+ const { groupbys } = this.state;
+ const updatedGroupby = groupbys[index];
+ if (countType === updatedGroupby.count_type) {
+ return;
+ }
+ const newGroupby = {
+ ...updatedGroupby,
+ count_type: countType,
+ };
+ this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby });
+ this.updateGroups();
+ };
+
+ selectSortType = ({ sortType }, index) => {
+ const { groupbys } = this.state;
+ const updatedGroupby = groupbys[index];
+ if (sortType === updatedGroupby.sort_type) {
+ return;
+ }
+ const newGroupby = {
+ ...updatedGroupby,
+ sort_type: sortType,
+ };
+ this.groupbyService.update(GROUPBY_ACTION_TYPE.UPDATE, { index, groupby: newGroupby });
+ this.updateGroups();
+ };
+
+ submitDefaultGroupbys = () => {
+ const { groupbys } = this.state;
+ this.props.modifyGroupbys(groupbys);
+ this.props.onGroupbyPopoverToggle();
+ };
+
+ updateGroups = () => {
+ const groupbys = this.groupbyService.getGroupbys();
+ this.setState({ groupbys }, () => {
+ if (this.isNeedSubmit()) return;
+ this.props.modifyGroupbys(groupbys);
+ });
+ };
+
+ onPopoverInsideClick = (e) => {
+ e.stopPropagation();
+ };
+
+ renderGroupbys = (scheduleUpdate) => {
+ const { columns } = this.props;
+ const { groupbys } = this.state;
+ return groupbys.map((groupby, index) => {
+ const column = getColumnByKey(groupby.column_key, columns) || {};
+ return (
+
+ );
+ });
+ };
+
+ onHideAllGroups = () => {
+ eventBus.dispatch(EVENT_BUS_TYPE.COLLAPSE_ALL_GROUPS);
+ };
+
+ onShowAllGroups = () => {
+ eventBus.dispatch(EVENT_BUS_TYPE.EXPAND_ALL_GROUPS);
+ };
+
+ render() {
+ const { target } = this.props;
+ const { groupbys } = this.state;
+ const groupbysLen = Array.isArray(groupbys) ? groupbys.length : 0;
+ const isEmpty = groupbysLen === 0;
+ return (
+
+ {({ scheduleUpdate }) => (
+ this.groupbysWrapper = ref}
+ onClick={this.onPopoverInsideClick}
+ >
+
+ {isEmpty ?
+
{intl.get('No_groupings')}
:
+ this.renderGroupbys(scheduleUpdate)
+ }
+
+ {groupbysLen < MAX_GROUP_LEVEL &&
+
this.addGroupby(scheduleUpdate)}
+ footerName={intl.get('Add_group')}
+ className='popover-add-tool'
+ addIconClassName='popover-add-icon'
+ />
+ }
+ {!isEmpty &&
+
+ {intl.get('Collapse_all')}
+ {intl.get('Expand_all')}
+
+ }
+ {this.isNeedSubmit() && (
+
+
+
+
+ )}
+
+ )}
+
+ );
+ }
+}
+
+GroupbyPopover.propTypes = {
+ target: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.node]),
+ groupbys: PropTypes.array,
+ columns: PropTypes.array,
+ onGroupbyPopoverToggle: PropTypes.func,
+ modifyGroupbys: PropTypes.func,
+ isNeedSubmit: PropTypes.bool,
+};
+
+export default GroupbyPopover;
diff --git a/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx
new file mode 100644
index 0000000000..e47acd31cc
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/groupby-popover/widgets/groupby-item.jsx
@@ -0,0 +1,125 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { Tooltip } from 'reactstrap';
+import { CustomizeSelect } from '@seafile/sf-metadata-ui-component';
+import {
+ COLUMNS_ICON_CONFIG,
+ SORT_COLUMN_OPTIONS,
+ isDateColumn,
+} from '../../../_basic';
+import { getSelectedCountType, isShowGroupCountType } from '../../../utils/groupby-utils';
+import { gettext } from '../../../utils';
+
+class GroupbyItem extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ tooltipOpen: false
+ };
+ this.filterToolTip = React.createRef();
+ }
+
+ toggleTipMessage = () => {
+ this.setState({ tooltipOpen: !this.state.tooltipOpen });
+ };
+
+ getCountTypeOptions = (column) => {
+ const { dateCountTypeOptions } = this.props;
+ if (isDateColumn(column)) {
+ return dateCountTypeOptions;
+ }
+ };
+
+ renderTipMessage = () => {
+ const { column } = this.props;
+ const { tooltipOpen } = this.state;
+ const page = window.app.getPage();
+ const { shown_column_keys } = page || {};
+
+ if (!shown_column_keys || !Array.isArray(shown_column_keys) || shown_column_keys.includes(column.key)) {
+ return null;
+ }
+
+ return (
+
+
+
+ {gettext('Group tip message')}
+
+
+ );
+ };
+
+ render() {
+ const { index, column, groupby, columnsOptions, sortTypeOptions, scheduleUpdate } = this.props;
+ const { name, type: columnType } = column;
+ const { sort_type } = groupby;
+ const selectedColumn = {
+ label: (
+
+
+ {name}
+
+ )
+ };
+ const countTypeOptions = this.getCountTypeOptions(column);
+ const selectedCountType = getSelectedCountType(column, groupby.count_type);
+ const selectedSortType = sort_type && sortTypeOptions.find(option => option.value.sortType === sort_type);
+ return (
+
+
this.props.onDeleteGroupby(index, e, scheduleUpdate)}>
+
+
+
+
+ this.props.onSelectColumn(value, index)}
+ options={columnsOptions}
+ searchable={true}
+ searchPlaceholder={gettext('Search column')}
+ noOptionsPlaceholder={gettext('No results')}
+ />
+
+ {isShowGroupCountType(column) && (
+
+ {gettext(selectedCountType)} } : ''}
+ onSelectOption={(value) => this.props.onSelectCountType(value, index)}
+ options={countTypeOptions}
+ />
+
+ )}
+
+ {(!column.key || SORT_COLUMN_OPTIONS.includes(columnType)) &&
+ this.props.onSelectSortType(value, index)}
+ options={sortTypeOptions}
+ />
+ }
+
+ {this.renderTipMessage()}
+
+
+ );
+ }
+}
+
+GroupbyItem.propTypes = {
+ index: PropTypes.number,
+ column: PropTypes.object,
+ groupby: PropTypes.object,
+ columnsOptions: PropTypes.array,
+ geoCountTypeOptions: PropTypes.array,
+ dateCountTypeOptions: PropTypes.array,
+ sortTypeOptions: PropTypes.array,
+ onDeleteGroupby: PropTypes.func,
+ onSelectColumn: PropTypes.func,
+ onSelectCountType: PropTypes.func,
+ onSelectSortType: PropTypes.func,
+ scheduleUpdate: PropTypes.func,
+};
+
+export default GroupbyItem;
diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js
new file mode 100644
index 0000000000..71ddff8c79
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/hide-column-item.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Icon, Switch } from '@seafile/sf-metadata-ui-component';
+
+class HideColumnItem extends React.PureComponent {
+
+ static defaultProps = {
+ readonly: false,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ setting: null
+ };
+ }
+
+ static getDerivedStateFromProps(nextProps, prevState) {
+ if (JSON.stringify(nextProps.setting) !== JSON.stringify(prevState.setting)) {
+ return { setting: nextProps.setting };
+ }
+ return null;
+ }
+
+ onUpdateFieldSetting = (event) => {
+ event.nativeEvent.stopImmediatePropagation();
+ const value = event.target.checked;
+ const { setting } = this.state;
+ if (setting.isChecked === value) {
+ return;
+ }
+ const newSetting = Object.assign({}, setting, { isChecked: value });
+ this.setState({ setting: newSetting }, () => {
+ this.props.onUpdateFieldSetting(newSetting);
+ });
+ };
+
+ render() {
+ const { setting } = this.state;
+ const { readonly } = this.props;
+ const placeholder = (
+ <>
+
+ {setting.columnName}
+ >
+ );
+ return (
+
+ );
+ }
+}
+
+HideColumnItem.propTypes = {
+ readonly: PropTypes.bool,
+ setting: PropTypes.object.isRequired,
+ onUpdateFieldSetting: PropTypes.func.isRequired,
+};
+
+export default HideColumnItem;
diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css
new file mode 100644
index 0000000000..98bbf0af0b
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.css
@@ -0,0 +1,3 @@
+.hidden-column-popover .custom-switch .custom-switch-description {
+ width: 192px;
+}
diff --git a/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx
new file mode 100644
index 0000000000..d90a119ae0
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/hidden-column-popover/index.jsx
@@ -0,0 +1,203 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import intl from 'react-intl-universal';
+import { UncontrolledPopover } from 'reactstrap';
+import isHotkey from 'is-hotkey';
+import { COLUMNS_ICON_CONFIG } from '../../../_basic';
+import HideColumnItem from '../hide-column-popover-widgets/hide-column-item';
+import { getEventClassName } from '../../../utils/utils';
+
+import './index.css';
+
+class HideColumnPopover extends React.Component {
+
+ static defaultProps = {
+ readonly: false,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ fieldSettings: [],
+ searchVal: '',
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('click', this.hidePopover, true);
+ document.addEventListener('keydown', this.onHotKey);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.hidePopover, true);
+ document.removeEventListener('keydown', this.onHotKey);
+ }
+
+ hidePopover = (e) => {
+ if (this.popoverRef && !getEventClassName(e).includes('popover') && !this.popoverRef.contains(e.target)) {
+ this.props.onPopoverToggle(e);
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ };
+
+ onHotKey = (e) => {
+ if (isHotkey('esc', e)) {
+ e.preventDefault();
+ this.props.onPopoverToggle();
+ }
+ };
+
+ static getDerivedStateFromProps(nextProps, preState) {
+ const { columns, shownColumnKeys } = nextProps;
+ let fieldSettings = columns.map(column => {
+ return {
+ key: column.key,
+ isChecked: shownColumnKeys.includes(column.key),
+ columnName: column.name,
+ columnIcon: COLUMNS_ICON_CONFIG[column.type],
+ };
+ });
+ // table page cannot hide first column
+ fieldSettings.shift();
+ return { fieldSettings: fieldSettings };
+ }
+
+ onChooseAllColumns = () => {
+ const { columns } = this.props;
+ let shownColumnKeys = [];
+ const { fieldSettings } = this.state;
+ const newFieldSettings = fieldSettings.map(setting => {
+ setting.isChecked = true;
+ shownColumnKeys.push(setting.key);
+ return setting;
+ });
+ shownColumnKeys.unshift(columns[0].key);
+ this.setState({ fieldSettings: newFieldSettings }, () => {
+ this.props.modifyHiddenColumns(shownColumnKeys);
+ });
+ };
+
+ onHideAllColumns = () => {
+ const { columns } = this.props;
+ const newFieldSettings = this.state.fieldSettings.map(setting => {
+ setting.isChecked = false;
+ return setting;
+ });
+ // table page cannot hide first column
+ const shownColumnKeys = [columns[0].key];
+ this.setState({ fieldSettings: newFieldSettings }, () => {
+ this.props.modifyHiddenColumns(shownColumnKeys);
+ });
+ };
+
+ onUpdateFieldSetting = (columnSetting) => {
+ const { columns } = this.props;
+ const { fieldSettings } = this.state;
+ let shownColumnKeys = [];
+ const newFieldSettings = fieldSettings.map(setting => {
+ if (setting.key === columnSetting.key) {
+ setting = columnSetting;
+ }
+ if (setting.isChecked) {
+ shownColumnKeys.push(setting.key);
+ }
+ return setting;
+ });
+ // table page cannot hide first column
+ if (!shownColumnKeys.includes(columns[0].key)) {
+ shownColumnKeys.unshift(columns[0].key);
+ }
+ this.setState({ fieldSettings: newFieldSettings }, () => {
+ this.props.modifyHiddenColumns(shownColumnKeys);
+ });
+ };
+
+ onPopoverInsideClick = (e) => {
+ e.stopPropagation();
+ };
+
+ onChangeSearch = (event) => {
+ let { searchVal } = this.state;
+ if (searchVal === event.target.value) {
+ return;
+ }
+ searchVal = event.target.value;
+ this.setState({ searchVal });
+ };
+
+ getFilteredColumns = () => {
+ let { searchVal, fieldSettings } = this.state;
+ searchVal = searchVal.toLowerCase();
+ if (!searchVal) {
+ return fieldSettings;
+ }
+ return fieldSettings.filter((setting) => {
+ return setting.columnName.toLowerCase().includes(searchVal);
+ });
+ };
+
+ render() {
+ const { target, readonly } = this.props;
+ const fieldSettings = this.getFilteredColumns();
+ const isEmpty = fieldSettings.length === 0 ? true : false;
+ return (
+
+ this.popoverRef = ref} onClick={this.onPopoverInsideClick}>
+
+
+
+
+ {isEmpty &&
+
+
{intl.get('No_columns_available_to_be_hidden')}
+
+ }
+ {!isEmpty &&
+ <>
+
+ {fieldSettings.map(setting => {
+ return (
+
+ );
+ })}
+
+ {(!this.state.searchVal && !readonly) &&
+
+
{intl.get('Hide_all')}
+
{intl.get('Show_all')}
+
+ }
+ >
+ }
+
+
+
+ );
+ }
+}
+
+HideColumnPopover.propTypes = {
+ target: PropTypes.string.isRequired,
+ shownColumnKeys: PropTypes.array.isRequired,
+ columns: PropTypes.array.isRequired,
+ modifyHiddenColumns: PropTypes.func.isRequired,
+ onPopoverToggle: PropTypes.func.isRequired,
+ readonly: PropTypes.bool,
+};
+
+export default HideColumnPopover;
diff --git a/frontend/src/metadata/metadata-view/components/popover/index.js b/frontend/src/metadata/metadata-view/components/popover/index.js
new file mode 100644
index 0000000000..7760756bfc
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/index.js
@@ -0,0 +1,13 @@
+import FilterPopover from './filter-popover';
+import SortPopover from './sort-popover';
+import GroupbyPopover from './groupby-popover';
+import HideColumnPopover from './hide-column-popover';
+import ColumnPermissionPopover from './column-permission-popover';
+
+export {
+ FilterPopover,
+ SortPopover,
+ GroupbyPopover,
+ HideColumnPopover,
+ ColumnPermissionPopover
+};
diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css
new file mode 100644
index 0000000000..27d10859bc
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.css
@@ -0,0 +1,83 @@
+.sort-popover .popover {
+ max-width: none;
+ min-width: 400px;
+}
+
+.sort-popover .sorts-list {
+ min-height: 120px;
+ max-height: 100%;
+ padding: 15px;
+}
+
+.sort-popover .sorts-list .option-group {
+ overflow: auto;
+ max-height: 360px;
+}
+
+.sort-popover .empty-sorts-container {
+ min-height: 80px;
+ padding: 16px;
+}
+
+.sorts-list .sort-item {
+ display: flex;
+ align-items: center;
+ padding: 0.25rem 0;
+}
+
+.sort-item .condition {
+ display: flex;
+ flex: 1 1;
+}
+
+.sort-item .condition > div {
+ height: 38px;
+ line-height: 38px;
+}
+
+.sort-item .sort-column {
+ width: 150px;
+}
+
+.sort-item .sort-predicate {
+ width: 130px;
+}
+
+.sorts-list .delete-sort {
+ width: 12px;
+ height: 20px;
+ margin-right: 14px;
+ text-align: center;
+}
+
+.sorts-list .empty-sorts-list {
+ color: #666666;
+}
+
+.delete-sort .sf-metadata-icon-fork-number {
+ display: inline-block;
+ font-size: 12px;
+ color: #999;
+ cursor: pointer;
+}
+
+.sorts-list .delete-sort .sf-metadata-icon-fork-number:hover {
+ color: #666666;
+}
+
+.sort-popover .popover-add-tool {
+ border-top: none;
+ color: #666666;
+}
+
+.sort-popover .popover-add-tool .popover-add-icon {
+ margin-right: 14px;
+}
+
+.sort-popover .sort-popover-footer {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 1rem;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx
new file mode 100644
index 0000000000..c22a326065
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/index.jsx
@@ -0,0 +1,283 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import intl from 'react-intl-universal';
+import isHotkey from 'is-hotkey';
+import { Button, UncontrolledPopover } from 'reactstrap';
+import {
+ COLUMNS_ICON_CONFIG,
+ SORT_COLUMN_OPTIONS,
+ SORT_TYPE,
+} from 'sf-metadata-utils';
+import { DTableCustomizeSelect } from 'sf-metadata-ui-component';
+import CommonAddTool from '../../common/common-add-tool';
+import { execSortsOperation, getDisplaySorts, isSortsEmpty, SORT_OPERATION } from '../sort-popover-widgets/sort-utils';
+import { getEventClassName } from '../../utils/utils';
+import { getColumnByKey } from '../../../utils/column-utils';
+import eventBus from '../../../utils/event-bus';
+import { EVENT_BUS_TYPE } from '../../../constants';
+
+import './index.css';
+
+const SORT_TYPES = [SORT_TYPE.UP, SORT_TYPE.DOWN];
+
+const propTypes = {
+ target: PropTypes.string.isRequired,
+ isNeedSubmit: PropTypes.bool,
+ sorts: PropTypes.array,
+ columns: PropTypes.array.isRequired,
+ onSortComponentToggle: PropTypes.func,
+ update: PropTypes.func,
+ readonly: PropTypes.bool,
+};
+
+class SortPopover extends Component {
+
+ static defaultProps = {
+ readonly: false,
+ };
+
+ constructor(props) {
+ super(props);
+ const { sorts, columns } = this.props;
+ this.sortTypeOptions = this.createSortTypeOptions();
+ this.columnsOptions = this.createColumnsOptions(columns);
+ this.state = {
+ sorts: getDisplaySorts(sorts, columns),
+ };
+ this.isSelectOpen = false;
+ }
+
+ componentDidMount() {
+ document.addEventListener('click', this.hideDTablePopover, true);
+ document.addEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect = eventBus.subscribe(EVENT_BUS_TYPE.OPEN_SELECT, this.setSelectStatus);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.hideDTablePopover, true);
+ document.removeEventListener('keydown', this.onHotKey);
+ this.unsubscribeOpenSelect();
+ }
+
+ hideDTablePopover = (e) => {
+ if (this.sortPopoverRef && !getEventClassName(e).includes('popover') && !this.sortPopoverRef.contains(e.target)) {
+ this.props.onSortComponentToggle(e);
+ e.preventDefault();
+ e.stopPropagation();
+ return false;
+ }
+ };
+
+ isNeedSubmit = () => {
+ return this.props.isNeedSubmit;
+ };
+
+ onHotKey = (e) => {
+ if (isHotkey('esc', e) && !this.isSelectOpen) {
+ e.preventDefault();
+ this.props.onSortComponentToggle();
+ }
+ };
+
+ setSelectStatus = (status) => {
+ this.isSelectOpen = status;
+ };
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const newColumns = nextProps.columns;
+ if (newColumns !== this.props.columns) {
+ this.columnsOptions = this.createColumnsOptions(newColumns);
+ }
+ }
+
+ addSort = () => {
+ const { sorts } = this.state;
+ const newSorts = execSortsOperation(SORT_OPERATION.ADD_SORT, { sorts });
+ this.updateSorts(newSorts);
+ };
+
+ deleteSort = (event, index) => {
+ event.nativeEvent.stopImmediatePropagation();
+ const sorts = this.state.sorts.slice(0);
+ const newSorts = execSortsOperation(SORT_OPERATION.DELETE_SORT, { sorts, index });
+ this.updateSorts(newSorts);
+ };
+
+ onSelectColumn = (value, index) => {
+ const sorts = this.state.sorts.slice(0);
+ const newColumnKey = value.column.key;
+ if (newColumnKey === sorts[index].column_key) {
+ return;
+ }
+ const newSorts = execSortsOperation(SORT_OPERATION.MODIFY_SORT_COLUMN, { sorts, index, column_key: newColumnKey });
+ this.updateSorts(newSorts);
+ };
+
+ onSelectSortType = (value, index) => {
+ const sorts = this.state.sorts.slice(0);
+ const newSortType = value.sortType;
+ if (newSortType === sorts[index].sort_type) {
+ return;
+ }
+ const newSorts = execSortsOperation(SORT_OPERATION.MODIFY_SORT_TYPE, { sorts, index, sort_type: newSortType });
+ this.updateSorts(newSorts);
+ };
+
+ updateSorts = (sorts) => {
+ if (this.isNeedSubmit()) {
+ const isSubmitDisabled = false;
+ this.setState({ sorts, isSubmitDisabled });
+ return;
+ }
+ this.setState({ sorts }, () => {
+ this.handleSortAnimation();
+ });
+ };
+
+ handleSortAnimation = () => {
+ const update = { sorts: this.state.sorts };
+ this.props.update(update);
+ };
+
+ onClosePopover = () => {
+ this.props.onSortComponentToggle();
+ };
+
+ onSubmitSorts = () => {
+ const { sorts } = this.state;
+ const update = { sorts: sorts };
+ this.props.update(update);
+ this.props.onSortComponentToggle();
+ };
+
+ createColumnsOptions = (columns = []) => {
+ const sortableColumns = columns.filter(column => SORT_COLUMN_OPTIONS.includes(column.type));
+ return sortableColumns.map((column) => {
+ const { type, name } = column;
+ return {
+ value: { column },
+ label: (
+
+
+ {name}
+
+ )
+ };
+ });
+ };
+
+ createSortTypeOptions = () => {
+ return SORT_TYPES.map(sortType => {
+ return {
+ value: { sortType },
+ label: {intl.get(sortType)}
+ };
+ });
+ };
+
+ renderSortsList = () => {
+ const { columns } = this.props;
+ const { sorts } = this.state;
+ return sorts.map((sort, index) => {
+ const column = getColumnByKey(sort.column_key, columns) || {};
+ return this.renderSortItem(column, sort, index);
+ });
+ };
+
+ renderSortItem = (column, sort, index) => {
+ let { name, type } = column;
+ const { readonly } = this.props;
+ let selectedColumn = {
+ label: (
+
+
+ {name}
+
+ )
+ };
+
+ let selectedTypeShow = sort.sort_type;
+ let selectedSortType = selectedTypeShow && {
+ label: {intl.get(selectedTypeShow)}
+ };
+
+ return (
+
+ {!readonly &&
+
this.deleteSort(event, index)}>
+
+
+ }
+
+
+ this.onSelectColumn(value, index)}
+ options={this.columnsOptions}
+ searchable={true}
+ searchPlaceholder={intl.get('Search_column')}
+ noOptionsPlaceholder={intl.get('No_results')}
+ />
+
+
+ this.onSelectSortType(value, index)}
+ options={this.sortTypeOptions}
+ />
+
+
+
+ );
+ };
+
+ onPopoverInsideClick = (e) => {
+ e.stopPropagation();
+ };
+
+ render() {
+ const { target, readonly } = this.props;
+ const { sorts } = this.state;
+ const isEmpty = isSortsEmpty(sorts);
+ return (
+
+ this.sortPopoverRef = ref} onClick={this.onPopoverInsideClick}>
+
+ {isEmpty ?
+
{intl.get('No_sorts')}
:
+ this.renderSortsList()
+ }
+
+ {!readonly &&
+
+ }
+ {(this.isNeedSubmit() && !readonly) && (
+
+
+
+
+ )}
+
+
+ );
+ }
+}
+
+SortPopover.propTypes = propTypes;
+
+export default SortPopover;
diff --git a/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js b/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js
new file mode 100644
index 0000000000..4caa68f1eb
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/popover/sort-popover/sort-utils.js
@@ -0,0 +1,63 @@
+import {
+ SORT_TYPE,
+ isValidSort,
+} from '../../../_basic';
+
+export const SORT_OPERATION = {
+ ADD_SORT: 'add_sort',
+ DELETE_SORT: 'delete_sort',
+ MODIFY_SORT_COLUMN: 'modify_sort_column',
+ MODIFY_SORT_TYPE: 'modify_sort_type',
+};
+
+export const getDisplaySorts = (sorts, columns) => {
+ if (!Array.isArray(sorts) || !Array.isArray(columns)) {
+ return [];
+ }
+ return sorts.filter((sort) => !sort.column_key || isValidSort(sort, columns));
+};
+
+export const isSortsEmpty = (sorts) => {
+ return !sorts || sorts.length === 0;
+};
+
+export const execSortsOperation = (action, payload) => {
+ const { sorts: updatedSorts } = payload;
+ switch (action) {
+ case SORT_OPERATION.ADD_SORT: {
+ const newSort = {
+ column_key: null,
+ sort_type: SORT_TYPE.UP,
+ };
+ updatedSorts.push(newSort);
+ return updatedSorts;
+ }
+ case SORT_OPERATION.DELETE_SORT: {
+ const { index } = payload;
+ updatedSorts.splice(index, 1);
+ return updatedSorts;
+ }
+ case SORT_OPERATION.MODIFY_SORT_COLUMN: {
+ const { index, column_key } = payload;
+ const newSort = {
+ column_key: column_key,
+ sort_type: SORT_TYPE.UP,
+ };
+ updatedSorts[index] = newSort;
+ return updatedSorts;
+ }
+ case SORT_OPERATION.MODIFY_SORT_TYPE: {
+ const { index, sort_type } = payload;
+ const updatedSort = updatedSorts[index];
+ const newSort = {
+ column_key: updatedSort.column_key,
+ sort_type: sort_type,
+ };
+ updatedSorts[index] = newSort;
+ return updatedSorts;
+ }
+ default: {
+ return updatedSorts;
+ }
+ }
+};
diff --git a/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js b/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js
new file mode 100644
index 0000000000..9c710b036f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/record-details-dialog/index.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { RecordDetails } from '@seafile/sf-metadata-ui-component';
+import { useCollaborators, useMetadata, useRecordDetails } from '../../hooks';
+import { COLUMNS_ICON_CONFIG } from '../../_basic';
+
+const RecordDetailsDialog = () => {
+ const { isShowRecordDetails, recordDetails, closeRecordDetails } = useRecordDetails();
+ const { collaborators, collaboratorsCache, updateCollaboratorsCache } = useCollaborators();
+ const { metadata } = useMetadata();
+ if (!isShowRecordDetails) return null;
+ const props = {
+ collaborators,
+ collaboratorsCache,
+ updateCollaboratorsCache,
+ queryUserAPI: window.sfMetadataContext.userService.queryUser,
+ record: recordDetails,
+ fields: metadata.columns,
+ fieldIconConfig: COLUMNS_ICON_CONFIG,
+ onToggle: closeRecordDetails,
+ };
+ return ();
+};
+
+export default RecordDetailsDialog;
diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js b/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js
new file mode 100644
index 0000000000..8792096770
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/scrollbar/horizontal-scrollbar.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Z_INDEX } from '../../_basic';
+
+const propTypes = {
+ innerWidth: PropTypes.number,
+ onScrollbarScroll: PropTypes.func.isRequired,
+ onScrollbarMouseUp: PropTypes.func.isRequired,
+};
+
+class HorizontalScrollbar extends React.Component {
+
+ isSelfScroll = true;
+
+ setScrollLeft = (scrollLeft) => {
+ this.isSelfScroll = false;
+ this.container.scrollLeft = scrollLeft;
+ };
+
+ onScroll = (event) => {
+ // only update grid's scrollLeft via scroll by itself.
+ // e.g. forbid to update grid's scrollLeft when the scrollbar's scrollLeft changed by other component
+ event.stopPropagation();
+ if (!this.isSelfScroll) {
+ this.isSelfScroll = true;
+ return;
+ }
+ const { scrollLeft } = event.target;
+ this.props.onScrollbarScroll(scrollLeft);
+ return;
+ };
+
+ getScrollbarStyle = () => {
+ return { width: this.props.innerWidth };
+ };
+
+ getContainerStyle = () => {
+ return { zIndex: Z_INDEX.SCROLL_BAR };
+ };
+
+ setScrollbarRef = (ref) => {
+ this.scrollbar = ref;
+ };
+
+ setContainerRef = (ref) => {
+ this.container = ref;
+ };
+
+ render() {
+ if (!this.props.innerWidth) {
+ return null;
+ }
+
+ const containerStyle = this.getContainerStyle();
+ const scrollbarStyle = this.getScrollbarStyle();
+
+ return (
+
+ );
+ }
+}
+
+HorizontalScrollbar.propTypes = propTypes;
+
+export default HorizontalScrollbar;
diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/index.js b/frontend/src/metadata/metadata-view/components/scrollbar/index.js
new file mode 100644
index 0000000000..b866e9c69e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/scrollbar/index.js
@@ -0,0 +1,8 @@
+import RightScrollbar from './right-scrollbar';
+import HorizontalScrollbar from './horizontal-scrollbar';
+import './scrollbar.css';
+
+export {
+ RightScrollbar,
+ HorizontalScrollbar,
+};
diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js b/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js
new file mode 100644
index 0000000000..668adc6bd2
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/scrollbar/right-scrollbar.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { HEADER_HEIGHT_TYPE, isEmptyObject, Z_INDEX } from '../../_basic';
+import { GRID_HEADER_DEFAULT_HEIGHT, GRID_HEADER_DOUBLE_HEIGHT } from '../../constants';
+
+const propTypes = {
+ table: PropTypes.object.isRequired,
+ onScrollbarScroll: PropTypes.func.isRequired,
+ onScrollbarMouseUp: PropTypes.func.isRequired,
+};
+
+class RightScrollbar extends React.Component {
+
+ isSelfScroll = true;
+
+ setScrollTop = (scrollTop) => {
+ this.isSelfScroll = false;
+ this.rightScrollContainer.scrollTop = scrollTop;
+ };
+
+ onScroll = (event) => {
+ event.stopPropagation();
+
+ // only update canvas's scrollTop via scroll by itself.
+ // e.g. forbid to update canvas's scrollTop when the scrollbar's scrollTop changed by other component
+ if (!this.isSelfScroll) {
+ this.isSelfScroll = true;
+ return;
+ }
+ const { scrollTop } = event.target;
+ this.props.onScrollbarScroll(scrollTop);
+ };
+
+ onMouseUp = (event) => {
+ if (this.props.onScrollbarMouseUp) {
+ this.props.onScrollbarMouseUp(event);
+ }
+ };
+
+ getScrollbarStyle = () => {
+ const component = window.sfMetadataBody;
+ if (component && component.resultRef) {
+ const resultRef = component.resultRef;
+ return { height: resultRef.scrollHeight };
+ }
+ return {};
+ };
+
+ getGridHeaderHeight = () => {
+ const headerSettings = this.props.table.header_settings || {};
+ const headerHeight = isEmptyObject(headerSettings) ? HEADER_HEIGHT_TYPE.DEFAULT : headerSettings.header_height;
+ const height = headerHeight === HEADER_HEIGHT_TYPE.DOUBLE ? GRID_HEADER_DOUBLE_HEIGHT : GRID_HEADER_DEFAULT_HEIGHT;
+ return height;
+ };
+
+ getContainerStyle = () => {
+ const style = {};
+ const component = window.sfMetadataBody;
+ if (component && component.resultContentRef) {
+ style.height = component.resultContentRef.clientHeight;
+ style.zIndex = Z_INDEX.SCROLL_BAR;
+ }
+ /* page-header + seatable-app-header + table-header-top + first row(grid-header) */
+ style.top = 50 + 10 + 48 + this.getGridHeaderHeight();
+ /* sf-metadata-wrapper have 10px margin */
+ style.right = '10px';
+ return style;
+ };
+
+ setScrollbarRef = (ref) => {
+ this.scrollbar = ref;
+ };
+
+ setContainerRef = (ref) => {
+ this.rightScrollContainer = ref;
+ };
+
+ render() {
+ const containerStyle = this.getContainerStyle();
+ const scrollbarStyle = this.getScrollbarStyle();
+
+ return (
+
+ );
+ }
+}
+
+RightScrollbar.propTypes = propTypes;
+
+export default RightScrollbar;
diff --git a/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css b/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css
new file mode 100644
index 0000000000..e46971423e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/scrollbar/scrollbar.css
@@ -0,0 +1,32 @@
+.sf-metadata-result-container.windows-browser::-webkit-scrollbar,
+.sf-metadata-result-table-content::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+.horizontal-scrollbar-container {
+ height: 20px;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 30px;
+ background-color: transparent;
+ overflow: auto;
+}
+
+.horizontal-scrollbar-inner {
+ height: 1px;
+}
+
+.right-scrollbar-container {
+ width: 20px;
+ position: fixed;
+ overflow: auto;
+ transition: right 0.25s ease-in-out 0s;
+}
+
+.right-scrollbar-container .right-scrollbar-inner {
+ width: 1px;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/container.js b/frontend/src/metadata/metadata-view/components/table/container.js
new file mode 100644
index 0000000000..bf4232c1f0
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/container.js
@@ -0,0 +1,187 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { toaster } from '@seafile/sf-metadata-ui-component';
+import { EVENT_BUS_TYPE } from '../../constants';
+import { CommonlyUsedHotkey, getErrorMsg } from '../../_basic';
+import { gettext } from '../../utils';
+import { useMetadata } from '../../hooks';
+import TableTool from './table-tool';
+import TableMain from './table-main';
+import RecordDetailsDialog from '../record-details-dialog';
+
+import './index.css';
+
+const Container = () => {
+ const [isLoadingMore, setLoadingMore] = useState(false);
+ const { metadata, errorMsg, extendMetadataRows } = useMetadata();
+ const containerRef = useRef(null);
+
+ const onKeyDown = useCallback((event) => {
+ if (event.target.className.includes('editor-main')) return;
+ if (CommonlyUsedHotkey.isModF(event)) {
+ event.preventDefault();
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SEARCH_CELLS);
+ return;
+ }
+ }, []);
+
+ const isGroupView = useCallback(() => {
+ // todo
+ return false;
+ }, []);
+
+ const onSelectCell = useCallback(() => {
+ // todo
+ }, []);
+
+ const tableChanged = useCallback(() => {
+ // todo
+ }, []);
+
+ const handleTableError = useCallback((error) => {
+ const errorMsg = getErrorMsg(error);
+ toaster.danger(gettext(errorMsg));
+ }, []);
+
+ const updateMetadata = useCallback(() => {
+ // todo
+ }, []);
+
+ const loadMore = useCallback(() => {
+ if (!metadata.hasMore) return;
+ setLoadingMore(true);
+ extendMetadataRows((flag) => {
+ setLoadingMore(false);
+ });
+ }, [metadata, extendMetadataRows]);
+
+ const modifyRecords = useCallback((rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData, isCopyPaste = false) => {
+ // todo: store op
+ }, []);
+
+ const modifyRecord = useCallback((rowId, updates, oldRowData, originalUpdates, originalOldRowData) => {
+ const rowIds = [rowId];
+ const idRowUpdates = { [rowId]: updates };
+ const idOriginalRowUpdates = { [rowId]: originalUpdates };
+ const idOldRowData = { [rowId]: oldRowData };
+ const idOriginalOldRowData = { [rowId]: originalOldRowData };
+ modifyRecords(rowIds, idRowUpdates, idOriginalRowUpdates, idOldRowData, idOriginalOldRowData);
+ }, [modifyRecords]);
+
+ const getAdjacentRowsIds = useCallback((rowIds) => {
+ const rowIdsLen = metadata.row_ids.length;
+ let rowIdsInOrder = [];
+ let upperRowIds = [];
+ let belowRowIds = [];
+ let rowIdMap = {};
+ rowIds.forEach(rowId => rowIdMap[rowId] = rowId);
+ metadata.row_ids.forEach((rowId, index) => {
+ if (!rowIdMap[rowId]) {
+ return;
+ }
+ const upperRowId = index === 0 ? null : metadata.row_ids[index - 1];
+ const belowRowId = index === rowIdsLen - 1 ? null : metadata.row_ids[index + 1];
+ rowIdsInOrder.push(rowId);
+ upperRowIds.push(upperRowId);
+ belowRowIds.push(belowRowId);
+ });
+ return { rowIdsInOrder, upperRowIds, belowRowIds };
+ }, [metadata]);
+
+ const modifyFilters = useCallback((filters, filterConjunction) => {
+ // modifyFilters
+ }, []);
+
+ const modifySorts = useCallback((sorts) => {
+ // modifySorts
+ }, []);
+
+ const modifyGroupbys = useCallback(() => {
+ // modifyGroupbys
+ }, []);
+
+ const modifyHiddenColumns = useCallback(() => {
+ // modifyHiddenColumns
+ }, []);
+
+ const recordGetterById = useCallback((recordId) => {
+ return metadata.id_row_map[recordId];
+ }, [metadata]);
+
+ const recordGetter = useCallback((recordIndex) => {
+ const recordId = metadata.row_ids[recordIndex];
+ return recordId && recordGetterById(recordId);
+ }, [metadata, recordGetterById]);
+
+ const groupRecordGetter = useCallback((groupRecordIndex) => {
+ if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupRecordByIndex) {
+ return null;
+ }
+ const groupRecord = window.sfMetadataBody.getGroupRecordByIndex(groupRecordIndex);
+ const recordId = groupRecord.rowId;
+ return recordId && recordGetterById(recordId);
+ }, [recordGetterById]);
+
+ const recordGetterByIndex = useCallback(({ isGroupView, groupRecordIndex, recordIndex }) => {
+ if (isGroupView) groupRecordGetter(groupRecordIndex);
+ return recordGetter(recordIndex);
+ }, [groupRecordGetter, recordGetter]);
+
+ const getTableContentWidth = useCallback(() => {
+ return containerRef?.current?.offsetWidth || 0;
+ }, [containerRef]);
+
+ const getTableContentLeft = useCallback(() => {
+ return containerRef?.current?.getBoundingClientRect()?.left || 0;
+ }, [containerRef]);
+
+ useEffect(() => {
+ document.addEventListener('keydown', onKeyDown);
+ const unsubscribeSelectCell = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_CELL, onSelectCell);
+ const unsubscribeServerTableChanged = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SERVER_TABLE_CHANGED, tableChanged);
+ const unsubscribeTableChanged = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.LOCAL_TABLE_CHANGED, tableChanged);
+ const unsubscribeHandleTableError = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.TABLE_ERROR, handleTableError);
+ const unsubscribeUpdateRows = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.UPDATE_TABLE_ROWS, updateMetadata);
+ return () => {
+ document.removeEventListener('keydown', onKeyDown);
+ unsubscribeSelectCell();
+ unsubscribeServerTableChanged();
+ unsubscribeTableChanged();
+ unsubscribeHandleTableError();
+ unsubscribeUpdateRows();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ <>
+
+
+
+ {errorMsg && (
{gettext(errorMsg)}
)}
+ {!errorMsg && (
+
+ )}
+
+
+
+ >
+
+ );
+
+};
+
+export default Container;
diff --git a/frontend/src/metadata/metadata-view/components/table/index.css b/frontend/src/metadata/metadata-view/components/table/index.css
new file mode 100644
index 0000000000..c0a16bddd3
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/index.css
@@ -0,0 +1,1048 @@
+.sf-metadata-wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ position: relative;
+ border: none;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.sf-metadata-wrapper .table-left-operations,
+.sf-metadata-wrapper .table-right-operations {
+ display: flex;
+ align-items: center;
+}
+
+.sf-metadata-wrapper .seatable-app-header.searcher-active .table-right-operations {
+ width: 100%;
+ margin-right: 10px;
+}
+
+.sf-metadata-wrapper .seatable-app-header .table-left-operations .setting-item {
+ margin-right: 0 !important;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-filter-label {
+ padding: 0 0.5rem;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-sort-label,
+.sf-metadata-wrapper .table-left-operations .custom-hide-column-label,
+.sf-metadata-wrapper .table-left-operations .custom-groupby-label {
+ padding: 0 0 0 0.5rem;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-filter-label.active {
+ background-color: #d1f7c4;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-sort-label.active {
+ background-color: #f5d9bc;
+}
+
+.sf-metadata-wrapper .table-left-operations .groupbys-setting-btn.active {
+ background-color: #dad7ff;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-hide-column-label.active {
+ background-color: #d7e8ff;
+}
+
+.sf-metadata-wrapper .table-left-operations .custom-filter-label.active:hover,
+.sf-metadata-wrapper .table-left-operations .custom-sort-label.active:hover,
+.sf-metadata-wrapper .table-left-operations .custom-hide-column-label.active:hover,
+.sf-metadata-wrapper .table-left-operations .groupbys-setting-btn.active:hover {
+ box-shadow: inset 0 0 0 2px rgb(0 0 0 / 10%);
+}
+
+.sf-metadata-wrapper .table-right-operations .new-record-btn button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 23px;
+ font-weight: 400;
+ border-color: rgba(0, 0, 0, 0.05);
+}
+
+.sf-metadata-wrapper .table-right-operations .more-operation-add-record {
+ padding: 0;
+}
+
+.sf-metadata-wrapper .table-right-operations .more-operation-add-record:not(:disabled):not(.disabled):active:focus {
+ box-shadow: none;
+}
+
+.sf-metadata-wrapper .table-right-operations .more-operation-add-record .dropdown {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+}
+
+.sf-metadata-wrapper .table-right-operations .add-record-dropdown-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+}
+
+.sf-metadata-dropdown-menu.add-record {
+ margin-top: 4px;
+}
+
+.sf-metadata-wrapper .table-right-operations .more-operation-add-record .dropdown .sf-metadata-dropdown-menu {
+ margin-top: 2px;
+}
+
+.sf-metadata-wrapper .table-right-operations .more-operation-add-record .toggle-icon {
+ display: inline-block;
+ font-size: 12px;
+ transform: scale(0.8);
+ margin-top: 1px;
+}
+
+.sf-metadata-wrapper .table-right-operations .new-record {
+ font-size: 14px;
+ line-height: 1.5rem;
+}
+
+.sf-metadata-wrapper .table-right-operations .table-search-box .input-icon-addon.search-poll-button {
+ display: flex;
+ right: 25px;
+ height: 30px;
+ line-height: 30px;
+ left: auto;
+ text-align: center;
+ font-size: 12px;
+ min-width: 35px;
+ pointer-events: all;
+}
+
+.sf-metadata-wrapper .table-right-operations .table-search-box .search-poll-button .search-description {
+ height: 30px;
+ line-height: 30px;
+ color: #666666;
+}
+
+.search-poll-button .sf-metadata-font {
+ font-size: 12px;
+ cursor: pointer;
+ color: #212529;
+}
+
+.mobile-search-exchange-btn {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ background-color: #e5e5e5;
+ color: #212529;
+ display: block;
+}
+
+.mobile-search-exchange-btn:hover {
+ background-color: #ededed;
+ color: #666666;
+}
+
+.mobile-search-exchange-btn.mobile-search-upward {
+ border-radius: 2px 0 0 2px;
+ transform: scale(0.8, 0.8) translateX(8px);
+}
+
+.mobile-search-exchange-btn.mobile-search-backward {
+ border-radius: 0 2px 2px 0;
+ transform: scale(0.8, 0.8);
+}
+
+.search-text-clear {
+ cursor: pointer;
+ min-width: 25px;
+ pointer-events: all;
+ font-style: normal;
+ font-size: 18px;
+ font-weight: 700;
+ text-align: center;
+ line-height: 30px;
+ height: 30px;
+ color: #999;
+}
+
+.search-text-clear:hover {
+ color: #212529;
+}
+
+.sf-metadata-result.success {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ overflow: hidden;
+ height: 100%;
+ width: fit-content;
+ max-width: 100%;
+}
+
+.sf-metadata-result-container {
+ flex: 1;
+ overflow-x: scroll;
+ overflow-y: hidden;
+}
+
+.sf-metadata-result-content {
+ height: 100%;
+ min-width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ position: relative;
+ overflow: hidden;
+ background: #f5f5f5;
+ user-select: none;
+}
+
+.group-level-2 .sf-metadata-result-content {
+ background-color: #ededed;
+}
+
+.group-level-3 .sf-metadata-result-content {
+ background-color: #e3e3e3;
+}
+
+.group-level-4 .sf-metadata-result-content {
+ background-color: #dedede;
+}
+
+.static-sf-metadata-result-content {
+ width: 100%;
+ position: relative;
+ background: #f9f9f9;
+ border-bottom: 1px solid #ddd;
+ z-index: 3;
+}
+
+.static-sf-metadata-result-content .sf-metadata-result-table-row {
+ position: relative;
+ width: fit-content;
+ background-color: #f9f9f9;
+}
+
+.record-HeaderCell__draggable {
+ position: absolute;
+ top: 0px;
+ right: -2px;
+ width: 5px;
+ border-radius: 3px;
+ margin: 3px 0px;
+ height: 80%;
+ cursor: col-resize;
+ z-index: 1;
+}
+
+.record-HeaderCell__draggable:hover {
+ background-color: #2d7ff9;
+}
+
+.record-header-cell .sf-metadata-result-table-cell {
+ overflow: unset;
+}
+
+.sf-metadata-result-table-row {
+ width: 100%;
+ border-bottom: 1px solid #ddd;
+ display: inline-flex;
+ margin-top: 0;
+ margin-bottom: 0;
+ transition: all 0.3s;
+}
+
+.static-sf-metadata-result-content.grid-header .sf-metadata-result-table-row {
+ border-bottom: none;
+}
+
+.table-btn-add-record-row {
+ border-bottom: none;
+}
+
+.table-btn-add-record-row .table-btn-add-record,
+.table-btn-add-record-row .table-btn-add-record-row-filler {
+ border-bottom: 1px solid #ddd;
+}
+
+.table-btn-add-record-row .canvas-groups-rows .sf-metadata-result-table-row:hover .sf-metadata-result-table-cell,
+.sf-metadata-result-table-row:hover .sf-metadata-result-table-cell,
+.sf-metadata-result-table-row:hover .column {
+ background-color: #f9f9f9;
+}
+
+.sf-metadata-result-table-row:hover .cell-selected {
+ background-color: #fff;
+}
+
+.sf-metadata-result-table:hover .sf-metadata-result-table-cell.cell-highlight {
+ background-color: rgb(239, 199, 151) !important;
+}
+
+.sf-metadata-result-table:hover .sf-metadata-result-table-cell.cell-current-highlight {
+ background-color: rgb(240, 159, 63) !important;
+}
+
+.sf-metadata-result-table-row.row-selected .sf-metadata-result-table-cell,
+.sf-metadata-result-table-row.row-selected .column {
+ background-color: #dbecfa !important;
+}
+
+.sf-metadata-result-table-content .sf-metadata-result-table-row {
+ background-color: #fff;
+}
+
+.sf-metadata-result-table-cell.index {
+ width: 90px;
+ height: 100%;
+ padding: 0;
+}
+
+.sf-metadata-result-table-cell.column {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 8px;
+}
+
+.sf-metadata-result-table-cell {
+ position: absolute;
+ height: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ border-right: 1px solid #eee;
+ padding: 6px 8px;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.sf-metadata-result-content .frozen-columns .sf-metadata-result-table-cell {
+ position: relative;
+}
+
+.sf-metadata-result-table-cell.table-cell-uneditable {
+ background-color: #f7f7f7;
+}
+
+.sf-metadata-result-column-content {
+ height: 100%;
+ width: 100%;
+ text-align: left;
+ line-height: 32px;
+}
+
+.sf-metadata-result-column-content.row-index {
+ text-align: center;
+}
+
+.sf-metadata-result-column-content .sf-metadata-font {
+ font-size: 14px;
+ color: #aaa;
+}
+
+.sf-metadata-result-column-content .header-name {
+ overflow: hidden;
+}
+
+.sf-metadata-result-column-content .header-name-text {
+ line-height: 1.5;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sf-metadata-result-column-content .header-name-text.double {
+ white-space: normal;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-height: 46px;
+ height: fit-content;
+}
+
+.sf-metadata-result-table-cell .select-cell-checkbox-container,
+.sf-metadata-result-table-cell .select-all-checkbox-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ flex: 1;
+}
+
+.sf-metadata-result-table-cell .select-cell-checkbox-container:hover,
+.sf-metadata-result-table-cell .select-all-checkbox-container:hover,
+.sf-metadata-result-table-cell .select-cell-checkbox-container input:hover,
+.sf-metadata-result-table-cell .select-all-checkbox-container input:hover {
+ cursor: pointer;
+}
+
+.sf-metadata-result-table-cell .select-cell-checkbox {
+ width: 12px;
+ height: 12px;
+}
+
+.sf-metadata-result-table-content {
+ flex: 1 1;
+ position: relative;
+ overflow-y: scroll;
+ padding-bottom: 150px;
+}
+
+.sf-metadata-result-table-content .sf-metadata-result-loading {
+ position: absolute;
+ left: 50vw;
+}
+
+.sf-metadata-result-table {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+}
+
+.sf-metadata-result-footer {
+ position: relative;
+ height: 30px;
+ width: 100%;
+ overflow: hidden;
+ line-height: 30px;
+ background-color: #f9f9f9;
+ border-top: 1px solid #ddd;
+ display: flex;
+ flex-shrink: 0;
+}
+
+.sf-metadata-result-table-cell .cell-file-add {
+ height: 31px;
+ line-height: 31px;
+ text-align: center;
+ color: #666666;
+ box-sizing: border-box;
+ position: relative;
+ top: -5px;
+ left: -8px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.draging-file-to-cell .cell-file-add {
+ border: 1px dashed #f09f3f;
+ background-color: #fdf5eb !important;
+}
+
+.sf-metadata-result-table-cell .file-cell-content {
+ flex: 1;
+ display: flex;
+ align-items: center;
+}
+
+.group-level-2 .sf-metadata-result-footer {
+ background-color: #fafafa;
+}
+
+.group-level-3 .sf-metadata-result-footer {
+ background-color: #f1f1f1;
+}
+
+.group-level-4 .sf-metadata-result-footer {
+ background-color: #eee;
+}
+
+.table-main-interval {
+ height: 20px;
+ position: relative;
+ width: 100%;
+ background-color: #f9f9f9;
+}
+
+/* cell comment num */
+.sf-metadata-result-table-cell.row-comment-cell {
+ align-items: center;
+}
+
+.sf-metadata-result-table-cell.row-comment-cell .sf-metadata-ui.cell-formatter-container {
+ padding-right: 26px;
+}
+
+.row-comment-cell .row-comment-content {
+ position: absolute;
+ right: 0;
+ color: #ccc;
+ cursor: pointer;
+}
+
+.row-comment-cell .row-comment-content .row-comment-icon {
+ margin-right: -9px;
+}
+
+.row-comment-cell .row-comment-content .row-comment-count {
+ color: #fff;
+ transform: scale(0.5) translateY(5px);
+ border-radius: 50%;
+ display: inline-block;
+ text-align: center;
+ min-width: 25px;
+ min-height: 25px;
+ background: #969696;
+ margin-left: -3px;
+ font-size: 16px;
+}
+
+/* cell formatter */
+.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container {
+ height: 100%;
+ line-height: 20px;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container.sf-metadata-file-formatter {
+ overflow: visible;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container.formula-formatter {
+ flex: 1;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-ui.cell-formatter-container .image-item {
+ height: auto;
+ width: auto;
+ max-height: 28px;
+}
+
+.sf-metadata-result-table-cell .links-formatter .sf-metadata-ui.cell-formatter-container {
+ width: auto;
+}
+
+.sf-metadata-result-table-cell .multiple-select-formatter,
+.sf-metadata-result-table-cell .single-select-formatter {
+ line-height: 1;
+}
+
+.row-detail-item .button-formatter.cell-formatter-container,
+.row-detail-item .sf-metadata-button-formatter.cell-formatter-container,
+.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container {
+ margin-top: 6px;
+ text-align: center;
+ height: 26px;
+ line-height: 14px;
+ min-width: 80px;
+ max-width: 100%;
+ width: fit-content;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ border-radius: 3px;
+ border-width: 2px;
+ font-weight: 500;
+ font-size: 14px;
+ font-family: inherit;
+ cursor: pointer;
+ letter-spacing: 0.03em;
+}
+
+.row-detail-item .sf-metadata-button-formatter.cell-formatter-container.disabled,
+.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container.disabled {
+ cursor: default;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-button-formatter.cell-formatter-container {
+ margin: -3px auto;
+}
+
+.row-detail-item .sf-metadata-button-formatter .loading-icon,
+.sf-metadata-result-table-cell .sf-metadata-button-formatter .loading-icon {
+ width: 16px;
+ height: 16px;
+}
+
+.row-detail-item .sf-metadata-button-formatter .button-formatter-btn-bg,
+.sf-metadata-result-table-cell .sf-metadata-button-formatter .button-formatter-btn-bg {
+ width: 11px;
+ height: 11px;
+ border-radius: 5px;
+ position: absolute;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-date-formatter,
+.sf-metadata-result-table-cell .sf-metadata-number-formatter,
+.sf-metadata-result-table-cell .sf-metadata-duration-formatter {
+ width: 100%;
+ text-align: right;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-long-text-formatter {
+ width: 100%;
+}
+
+.sf-metadata-result-table-cell .geolocation-formatter,
+.sf-metadata-result-table-cell .sf-metadata-text-formatter,
+.sf-metadata-result-table-cell .sf-metadata-url-formatter,
+.sf-metadata-result-table-cell .sf-metadata-email-formatter,
+.sf-metadata-result-table-cell .sf-metadata-long-text-formatter .long-text-content {
+ overflow: hidden !important;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-url-formatter {
+ text-decoration: none;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-ui.long-text-formatter {
+ height: 100%;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-checkbox-formatter {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.sf-metadata-file-formatter .file-item-icon {
+ margin-right: 4px;
+}
+
+.sf-metadata-result-table-cell .sf-metadata-collaborator-formatter,
+.sf-metadata-result-table-cell .sf-metadata-creator-formatter,
+.sf-metadata-result-table-cell .sf-metadata-last-modifier-formatter {
+ display: flex;
+ align-items: center;
+}
+
+.sf-metadata-result-table-cell.sf-metadata-result-table-digital-sign-cell {
+ padding: 0 8px;
+}
+
+.sf-metadata-result-table-cell .digital-sign-formatter .image-item {
+ height: 28px;
+}
+
+.sf-metadata-result-table-cell .collaborator-avatar,
+.sf-metadata-result-table-cell .sf-metadata-ui.collaborator-item .collaborator-avatar {
+ height: 16px;
+ width: 16px;
+ margin-left: 0;
+ transform: translateY(0px);
+}
+
+.sf-metadata-result-table-cell .select-all-checkbox-container .sf-metadata-icon-partially-selected {
+ cursor: pointer;
+ font-size: 12px;
+ color: #2b76f6;
+}
+
+.sf-metadata-result-table-cell .header-action-cell-placeholder {
+ /* same width as the button of record expand */
+ width: 20px;
+ height: 20px;
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container {
+ height: 14px;
+ width: 14px;
+ display: inline-flex;
+ margin-bottom: 0;
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox {
+ display: none;
+ z-index: 99999;
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container .select-all-checkbox-show {
+ height: 14px;
+ width: 14px;
+ border: 1px solid #aaa;
+ border-radius: 2px;
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox:checked + .select-all-checkbox-show {
+ border: unset;
+ background-color: #3b88fd;
+ position: relative;
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container .mobile-select-all-checkbox:checked + .select-all-checkbox-show::before {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 4px;
+ width: 5px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.sf-metadata-result-table-cell .mobile-select-all-container .sf-metadata-icon-partially-selected {
+ font-size: 14px;
+ line-height: 1;
+}
+
+.tooltip-inner {
+ max-width: 242px;
+ font-weight: lighter;
+ text-align: start;
+ background-color: #303133;
+}
+
+.sf-metadata-link-formatter {
+ display: flex;
+ flex-wrap: nowrap;
+}
+
+/* link */
+.row-detail-item .sf-metadata-link-formatter {
+ align-items: center;
+ overflow: hidden;
+ flex-wrap: nowrap;
+ width: 100%;
+ height: auto;
+}
+
+.sf-metadata-link-formatter .sf-metadata-link-item {
+ flex-shrink: 0;
+ height: 20px;
+ margin-right: 4px;
+ padding: 0 6px;
+ font-size: 13px;
+ max-width: 100%;
+ background: #eceff4;
+ border-radius: 3px;
+ align-items: center;
+ vertical-align: middle;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ line-height: 20px;
+ width: fit-content;
+}
+
+.sf-metadata-link-formatter .sf-metadata-checkbox-item {
+ width: fit-content;
+ margin-right: 10px;
+ line-height: 36px;
+}
+
+.sf-metadata-link-formatter .sf-metadata-long-text-item {
+ display: inline-flex;
+ margin-right: 10px;
+ font-size: 13px;
+ max-width: 100%;
+ flex-shrink: 0;
+}
+
+/* records count */
+.sf-metadata-result.success .sf-metadata-result-footer {
+ height: 32px;
+ width: 100%;
+ overflow: hidden;
+ line-height: 32px;
+ background-color: #f9f9f9;
+ border-top: 1px solid #ddd;
+ padding: 0 8px;
+}
+
+.sf-metadata-result.success .sf-metadata-result-footer .tip {
+ color: #666666;
+}
+
+.add-item-btn {
+ display: flex;
+ align-items: center;
+ height: 40px;
+ font-size: 14px;
+ font-weight: 500;
+ border-top: 1px solid #dedede;
+ background: #fff;
+ padding: 0 1rem;
+ border-bottom-left-radius: 3px;
+ border-bottom-right-radius: 3px;
+}
+
+.add-item-btn:hover {
+ cursor: pointer;
+ background: #f5f5f5;
+}
+
+.add-item-btn .sf-metadata-icon-add-table {
+ margin-right: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ transform: translateY(1px);
+}
+
+.formula-formatter-content-item {
+ margin-right: 10px;
+ font-size: 13px;
+ max-width: 100%;
+ display: inline-flex;
+}
+
+.formula-formatter-content-item.simple-cell-formatter {
+ height: 20px;
+ padding: 0 8px;
+ align-items: center;
+ background: #eceff4;
+ border-radius: 3px;
+ color: #212529;
+}
+
+.row-detail-item .formula-formatter-content-item.simple-cell-formatter .text-formatter {
+ max-width: 200px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.formula-formatter-content-item.simple-cell-text-formatter {
+ height: fit-content;
+ min-height: 20px;
+ padding: 0 8px;
+ align-items: center;
+ background: #eceff4;
+ border-radius: 3px;
+}
+
+.formula-ellipsis .sf-metadata-font {
+ font-size: 12px;
+}
+
+.formula-formatter-content-item .checkbox {
+ transform: unset;
+}
+
+.formula-formatter-content-item .collaborator {
+ margin-right: 0;
+}
+
+.checkbox-editor-rows-container .grid-checkbox-row-checkbox {
+ margin-top: 0;
+}
+
+/* common */
+.sf-metadata-result-container.horizontal-scroll .table-last--frozen {
+ box-shadow: 2px 0 5px -2px rgb(136 136 136 / 30%) !important;
+}
+
+.table-last--frozen {
+ border-right: 1px solid #cacaca !important;
+}
+
+.row-locked .actions-cell::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ content: '';
+ width: 0;
+ height: 0;
+ border-top: 10px solid #f25041;
+ border-left: 10px solid transparent;
+}
+
+/* table cell editor */
+.table-cell-editor .number-editor,
+.table-cell-editor .duration-editor {
+ text-align: right;
+}
+
+.table-cell-editor .checkbox-editor-rows-container {
+ align-items: center;
+}
+
+.table-cell-editor .rate-editor .rate-item {
+ padding-right: 0;
+}
+
+.rdg-editor-container .rate-editor {
+ width: 100%;
+ height: calc(100% - 1px);
+ border: 2px solid #66afe9;
+ border-right: none;
+ border-bottom: none;
+ /* background-color: #fff; */
+ box-sizing: border-box;
+ padding: 0 8px 2px 6px;
+ align-items: center;
+}
+
+.rdg-editor-container .checkbox-editor-rows-container {
+ justify-content: center;
+}
+
+.geolocation-editor-container {
+ background-color: #ffffff;
+ box-shadow: 0 0 5px #ccc;
+ border-radius: 4px;
+ position: relative;
+ display: inline-block;
+ min-width: max-content;
+ width: 400px;
+}
+
+.geolocation-editor-container .geolocation-selector-container {
+ position: absolute;
+ min-width: 400px;
+ top: 100%;
+ left: 0;
+ background-color: #ffffff;
+ box-shadow: 0 0 5px #ccc;
+ min-height: 165px;
+}
+
+.geolocation-editor-container .geolocation-selector-header {
+ height: 45px;
+ display: flex;
+ border-bottom: 1px solid #ccc;
+ padding: 5px 20px 0 20px;
+ align-items: flex-end;
+}
+
+.geolocation-editor-container .geolocation-selector-header-item {
+ border: 1px solid #ccc;
+ height: 35px;
+ margin-right: 10px;
+ display: flex;
+ margin-bottom: -1px;
+ border-radius: 3px 3px 0 0;
+ padding: 10px;
+ line-height: 15px;
+ cursor: pointer;
+ font-size: 14px;
+ word-break: keep-all;
+}
+
+.geolocation-editor-container .selected-geolocation-selector-header-item {
+ border-bottom: 1px solid #fff;
+}
+
+.geolocation-editor-container .geolocation-map-editor {
+ height: 384px;
+ width: 500px;
+ display: flex;
+ flex-direction: column;
+}
+
+@media screen and (max-width: 991.8px) {
+ .sf-metadata-result-table-cell .select-cell-checkbox {
+ width: 14px;
+ height: 14px;
+ }
+}
+
+/* btn-add-record */
+.table-btn-add-record-row .table-btn-add-record {
+ display: flex;
+ align-items: center;
+ padding-left: 22px;
+ cursor: pointer;
+ border-right: 1px solid #ddd;
+}
+
+.table-btn-add-record-row .table-btn-add-record-icon {
+ font-size: 24px;
+}
+
+.table-btn-add-record-row-filler {
+ flex: 1;
+ border-right: 1px solid #eee;
+ background-color: #fff;
+}
+
+/* custom table filters */
+.table-filters .filters-list .filter-item .filter-term {
+ max-width: unset;
+}
+
+.table-filters .filters-list .sf-metadata-select.custom-select {
+ max-width: 700px;
+}
+
+.table-filters .filters-list .single-select-option,
+.table-filters .filters-list .multiple-select-option {
+ max-width: 250px;
+}
+
+.sf-metadata-wrapper .sf-metadata-main,
+.sf-metadata-wrapper .seatable-app-header {
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+}
+
+.sf-metadata-wrapper .sf-metadata-main {
+ flex: 1;
+ overflow: hidden;
+}
+
+.sf-metadata-wrapper .sf-metadata-main .sf-metadata-container {
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+}
+
+.table-header-top {
+ z-index: 7;
+ transform: translateZ(1000px);
+ height: 10px;
+ background-color: #f5f5f5;
+}
+
+.sf-metadata-result-table .group-header-left .formatter-show {
+ display: inline-flex;
+ flex: 1;
+ width: calc(100% - 15px);
+ position: relative;
+}
+
+.sf-metadata-result-table .group-header-left .sf-metadata-link-formatter {
+ display: inline-flex;
+ flex-wrap: nowrap;
+}
+
+.sf-metadata-result-table .group-header-left .sf-metadata-link-formatter .sf-metadata-link-item {
+ max-width: 230px;
+}
+
+.record-header-cell .sf-metadata-dropdown-menu {
+ padding: 0 5px;
+ width: 20px;
+}
+
+.table-more-operations-dropdown-toggle {
+ display: inline-flex;
+ align-items: center;
+ height: 22px;
+ padding: 0 .5rem;
+ transition: all .1s ease-in;
+ border-radius: 3px;
+}
+
+.table-more-operations-dropdown-toggle:hover {
+ background-color: #f1f1f1;
+ cursor: pointer;
+}
+
+.table-more-operations-dropdown-toggle .sf-metadata-font {
+ font-size: 14px;
+ line-height: 22px;
+ color: #555;
+}
+
+.sf-metadata-wrapper .table-main-container {
+ display: flex;
+ flex: 1 1;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/index.js b/frontend/src/metadata/metadata-view/components/table/index.js
new file mode 100644
index 0000000000..4f90b3d94a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/index.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import { CenteredLoading } from '@seafile/sf-metadata-ui-component';
+import { useMetadata } from '../../hooks';
+import Container from './container';
+
+import './index.css';
+
+const Table = () => {
+ const { isLoading } = useMetadata();
+
+ if (isLoading) return ();
+ return ();
+};
+
+export default Table;
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
new file mode 100644
index 0000000000..e69de29bb2
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
new file mode 100644
index 0000000000..4830cbf688
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/index.js
@@ -0,0 +1,65 @@
+import React, { useCallback, useMemo } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import Records from './records';
+import { GROUP_VIEW_OFFSET } from '../../../constants';
+import GridUtils from '../../../utils/grid-utils';
+import { useRecordDetails } from '../../../hooks';
+
+const TableMain = ({ metadata, modifyRecord, modifyRecords, loadMore, loadAll, searchResult, ...params }) => {
+ const gridUtils = useMemo(() => {
+ return new GridUtils(metadata, { modifyRecord, modifyRecords });
+ }, [metadata, modifyRecord, modifyRecords]);
+ const { openRecordDetails } = useRecordDetails();
+
+ const groupbysCount = useMemo(() => {
+ const groupbys = metadata?.groupbys || [];
+ return groupbys.length;
+ }, [metadata]);
+
+ const groupOffset = useMemo(() => {
+ return groupbysCount * GROUP_VIEW_OFFSET;
+ }, [groupbysCount]);
+
+ const updateRecord = useCallback(({ rowId, updates, originalUpdates, oldRowData, originalOldRowData }) => {
+ modifyRecord && modifyRecord(rowId, updates, oldRowData, originalUpdates, originalOldRowData);
+ }, [modifyRecord]);
+
+ const updateRecords = useCallback(({ recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste = false }) => {
+ modifyRecords && modifyRecords(recordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste);
+ }, [modifyRecords]);
+
+ return (
+ 0 })}>
+
+
+ );
+
+};
+
+TableMain.propTypes = {
+ metadata: PropTypes.object.isRequired,
+ modifyRecord: PropTypes.func,
+ modifyRecords: PropTypes.func,
+ loadMore: PropTypes.func,
+ loadAll: PropTypes.func,
+ searchResult: PropTypes.object,
+};
+
+export default TableMain;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx
new file mode 100644
index 0000000000..fd6508e75e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/actions-cell.jsx
@@ -0,0 +1,100 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { Tooltip } from 'reactstrap';
+import { SEQUENCE_COLUMN_WIDTH } from '../../../../constants';
+import { isMobile, gettext } from '../../../../utils';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+
+class ActionsCell extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isLockedRowTooltipShow: false,
+ };
+ }
+
+ onCellMouseEnter = () => {
+ const { isLocked } = this.props;
+ if (!isLocked || isMobile) return;
+ this.timer = setTimeout(() => {
+ this.setState({ isLockedRowTooltipShow: true });
+ }, 500);
+ };
+
+ onCellMouseLeave = () => {
+ const { isLocked } = this.props;
+ if (!isLocked || isMobile) return;
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ this.setState({ isLockedRowTooltipShow: false });
+ };
+
+ getLockedRowTooltip = () => {
+ const { recordId } = this.props;
+ return (
+
+ {gettext('The row is locked and cannot be modified')}
+
+ );
+ };
+
+ render() {
+ const { isSelected, isLastFrozenCell, index, height, recordId } = this.props;
+ const cellStyle = {
+ height,
+ width: SEQUENCE_COLUMN_WIDTH,
+ minWidth: SEQUENCE_COLUMN_WIDTH,
+ };
+ return (
+
+ {!isSelected &&
{index + 1}
}
+
+
+
+
+ {/* {this.getLockedRowTooltip()} */}
+
+ );
+ }
+}
+
+ActionsCell.propTypes = {
+ isLocked: PropTypes.bool,
+ isSelected: PropTypes.bool,
+ isLastFrozenCell: PropTypes.bool,
+ recordId: PropTypes.string,
+ index: PropTypes.number,
+ height: PropTypes.number,
+ onSelectRecord: PropTypes.func,
+ onRowExpand: PropTypes.func,
+};
+
+export default ActionsCell;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx
new file mode 100644
index 0000000000..8ef42af51b
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/btn-add-record.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { UncontrolledTooltip } from 'reactstrap';
+import { SEQUENCE_COLUMN } from '../../../../_basic';
+import { gettext } from '../../../../utils';
+
+class BtnAddRecord extends React.Component {
+
+ componentDidMount() {
+ this.initFrozenColumnsStyle();
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return (
+ nextProps.isGroupView !== this.props.isGroupView ||
+ nextProps.groupPathString !== this.props.groupPathString ||
+ nextProps.height !== this.props.height ||
+ nextProps.width !== this.props.width ||
+ nextProps.top !== this.props.top ||
+ nextProps.left !== this.props.left ||
+ nextProps.iconWidth !== this.props.iconWidth ||
+ nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey ||
+ nextProps.scrollLeft !== this.props.scrollLeft
+ );
+ }
+
+ initFrozenColumnsStyle = () => {
+ const { isGroupView, lastFrozenColumnKey, scrollLeft } = this.props;
+ let style = {
+ position: 'absolute',
+ marginLeft: scrollLeft > 0 ? scrollLeft + 'px' : '0px',
+ };
+ if (isGroupView) {
+ if (!lastFrozenColumnKey) {
+ style.marginLeft = '0px';
+ }
+ }
+ this.frozenColumns.style.position = style.position;
+ this.frozenColumns.style.marginLeft = style.marginLeft;
+ };
+
+ renderBtn = () => {
+ const { height, iconWidth, lastFrozenColumnKey, isGroupView, groupPathString } = this.props;
+ let iconStyle = {
+ height,
+ width: iconWidth,
+ zIndex: SEQUENCE_COLUMN,
+ };
+ if (isGroupView) {
+ if (!lastFrozenColumnKey) {
+ iconStyle.marginLeft = '0px';
+ }
+ }
+ const btnClassName = classnames('table-btn-add-record', { 'table-last--frozen': isGroupView || !lastFrozenColumnKey });
+ let btnId = 'btn_table_add_record__frozen';
+ if (isGroupView) {
+ btnId += groupPathString;
+ }
+ return (
+ <>
+ this.frozenColumns = ref}
+ onClick={this.props.onAddRecord}
+ id={btnId}
+ >
+ +
+
+ {gettext('Add record')}
+
+
+
+ +
+
+ >
+ );
+ };
+
+ render() {
+ const { isGroupView, height, top, left, width } = this.props;
+ let btnStyle = {
+ height,
+ width,
+ };
+ if (isGroupView) {
+ btnStyle.top = top;
+ btnStyle.left = left;
+ }
+ return (
+
+ );
+ }
+}
+
+BtnAddRecord.propTypes = {
+ isGroupView: PropTypes.bool,
+ groupPathString: PropTypes.string,
+ height: PropTypes.number,
+ width: PropTypes.number,
+ iconWidth: PropTypes.number,
+ top: PropTypes.number,
+ left: PropTypes.number,
+ scrollLeft: PropTypes.number,
+ lastFrozenColumnKey: PropTypes.string,
+ onAddRecord: PropTypes.func,
+};
+
+export default BtnAddRecord;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx
new file mode 100644
index 0000000000..11ba4c6e36
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/column-dropdown-item.jsx
@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { UncontrolledTooltip, DropdownItem } from 'reactstrap';
+import { Icon } from 'seafile/sf-metadata-ui-component';
+
+export default class ColumnDropdownItem extends Component {
+
+ static propTypes = {
+ onClick: PropTypes.func.isRequired,
+ onMouseEnter: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
+ id: PropTypes.string.isRequired,
+ iconSvg: PropTypes.string,
+ iconClassName: PropTypes.string,
+ menuText: PropTypes.string.isRequired,
+ disabledText: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ };
+
+ static defaultProps = {
+ onClick: () => {},
+ onMouseEnter: () => {},
+ disabled: false,
+ className: '',
+ };
+
+ state = {
+ canToolTip: false,
+ };
+
+ componentDidMount() {
+ if (this.props.disabled) {
+ this.setState({ canToolTip: true });
+ }
+ }
+
+ onClick = (e) => {
+ e.preventDefault();
+ };
+
+ renderIcon = () => {
+ const { iconSvg, iconClassName } = this.props;
+ if (iconClassName) {
+ return ;
+ }
+ if (iconSvg) {
+ return ;
+ }
+ return null;
+ };
+
+ render() {
+ const { disabled, id, menuText, disabledText, className } = this.props;
+
+ if (!disabled) {
+ return (
+
+ {this.renderIcon()}
+ {menuText}
+
+ );
+ }
+
+ return (
+
+ {this.renderIcon()}
+ {menuText}
+ {this.state.canToolTip &&
+
+ {disabledText}
+
+ }
+
+ );
+ }
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js
new file mode 100644
index 0000000000..88fff7dbbe
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-left.js
@@ -0,0 +1,75 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import GroupHeaderLeft from './group-header-left';
+import { Z_INDEX } from '../../../../../_basic';
+
+class GroupContainerLeft extends Component {
+
+ fixedFrozenDOMs = (scrollLeft, scrollTop) => {
+ if (this.leftContainer) {
+ this.leftContainer.style.position = 'fixed';
+ this.leftContainer.style.marginLeft = '0px';
+ this.leftContainer.style.marginTop = (-scrollTop) + 'px';
+ }
+ };
+
+ setContainerRef = (ref) => {
+ this.leftContainer = ref;
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ if (this.leftContainer) {
+ this.leftContainer.style.position = 'absolute';
+ this.leftContainer.style.marginLeft = scrollLeft + 'px';
+ this.leftContainer.style.marginTop = '0px';
+ }
+ };
+
+ render() {
+ const {
+ isExpanded, maxLevel, group, formulaRow, leftPaneWidth, height,
+ firstColumnFrozen, lastColumnFrozen, firstColumnKey,
+ } = this.props;
+ let containerStyle = {
+ zIndex: firstColumnFrozen ? Z_INDEX.GROUP_FROZEN_HEADER : 0,
+ width: leftPaneWidth,
+ height,
+ };
+
+ return (
+
+ this.leftHeader = ref}
+ isExpanded={isExpanded}
+ firstColumnFrozen={firstColumnFrozen}
+ lastColumnFrozen={lastColumnFrozen}
+ firstColumnKey={firstColumnKey}
+ width={leftPaneWidth}
+ maxLevel={maxLevel}
+ group={group}
+ formulaRow={formulaRow}
+ onExpandGroupToggle={this.props.onExpandGroupToggle}
+ />
+
+ );
+ }
+}
+
+GroupContainerLeft.propTypes = {
+ isExpanded: PropTypes.bool,
+ firstColumnFrozen: PropTypes.bool,
+ lastColumnFrozen: PropTypes.bool,
+ firstColumnKey: PropTypes.string,
+ maxLevel: PropTypes.number,
+ group: PropTypes.object,
+ formulaRow: PropTypes.object,
+ leftPaneWidth: PropTypes.number,
+ height: PropTypes.number,
+ onExpandGroupToggle: PropTypes.func,
+};
+
+export default GroupContainerLeft;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js
new file mode 100644
index 0000000000..5f6a8fe052
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container-right.js
@@ -0,0 +1,56 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import GroupHeaderRight from './group-header-right';
+
+class GroupContainerRight extends Component {
+
+ fixedFrozenDOMs = (scrollLeft, scrollTop) => {
+ this.rightHeader && this.rightHeader.fixedFrozenDOMs(scrollLeft, scrollTop);
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ this.rightHeader && this.rightHeader.cancelFixFrozenDOMs(scrollLeft);
+ };
+
+ render() {
+ const {
+ group, isExpanded, columns, summaryConfigs, rightPaneWidth, leftPaneWidth, height,
+ groupOffsetLeft, lastFrozenColumnKey,
+ } = this.props;
+ const groupContainerRightStyle = {
+ left: leftPaneWidth,
+ width: rightPaneWidth,
+ height,
+ };
+
+ return (
+
+ this.rightHeader = ref}
+ groupOffsetLeft={groupOffsetLeft}
+ lastFrozenColumnKey={lastFrozenColumnKey}
+ group={group}
+ isExpanded={isExpanded}
+ columns={columns}
+ summaryConfigs={summaryConfigs}
+ getTableContentLeft={this.props.getTableContentLeft}
+ />
+
+ );
+ }
+}
+
+GroupContainerRight.propTypes = {
+ group: PropTypes.object,
+ isExpanded: PropTypes.bool,
+ columns: PropTypes.array,
+ summaryConfigs: PropTypes.object,
+ rightPaneWidth: PropTypes.number,
+ leftPaneWidth: PropTypes.number,
+ height: PropTypes.number,
+ groupOffsetLeft: PropTypes.number,
+ lastFrozenColumnKey: PropTypes.string,
+ getTableContentLeft: PropTypes.func,
+};
+
+export default GroupContainerRight;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css
new file mode 100644
index 0000000000..828f03d97b
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.css
@@ -0,0 +1,464 @@
+.canvas-groups-rows {
+ position: relative;
+ overflow: hidden;
+}
+
+.canvas-groups-rows .sf-metadata-result-table-cell {
+ background-color: #fff;
+}
+
+.canvas-groups-rows .group-item {
+ position: absolute;
+ overflow: hidden;
+}
+
+.canvas-groups-rows .group-container-left,
+.canvas-groups-rows .group-container-right {
+ position: absolute;
+ height: 100%;
+}
+
+/* border-radius of group container */
+.canvas-groups-rows .group-item .group-container-left {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+}
+
+.canvas-groups-rows .group-item .group-container-right {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+/* background of group container */
+.canvas-groups-rows .group-level-1 .group-container-left,
+.canvas-groups-rows .group-level-1 .group-container-right {
+ background-color: #f7f7f7;
+}
+
+.canvas-groups-rows .group-level-2 .group-container-left,
+.canvas-groups-rows .group-level-2 .group-container-right {
+ background-color: #ededed;
+}
+
+.canvas-groups-rows .group-level-3 .group-container-left,
+.canvas-groups-rows .group-level-3 .group-container-right {
+ background-color: #e3e3e3;
+}
+
+/* border-color of group container */
+.canvas-groups-rows .group-level-2 .group-container-left {
+ border-left: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .group-level-3 .group-container-left {
+ border-left: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .group-level-2 .group-container-right {
+ border-right: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .group-level-3 .group-container-right {
+ border-right: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .expanded-group.group-level-2 .group-container-left,
+.canvas-groups-rows .expanded-group.group-level-2 .group-container-right {
+ border-bottom: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .expanded-group.group-level-3 .group-container-left,
+.canvas-groups-rows .expanded-group.group-level-3 .group-container-right {
+ border-bottom: 1px solid #c1c1c1;
+}
+
+/* border-color of group header */
+.canvas-groups-rows .group-level-1 .group-header-left {
+ border-left: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .group-level-1 .group-header-right {
+ border-right: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .group-level-1 .group-header-left,
+.canvas-groups-rows .group-level-1 .group-header-right {
+ border-top: 1px solid #cacaca;
+ border-bottom: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .group-level-2 .group-header-left,
+.canvas-groups-rows .group-level-2 .group-header-right {
+ border-top: 1px solid #c1c1c1;
+ border-bottom: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .group-level-3 .group-header-left,
+.canvas-groups-rows .group-level-3 .group-header-right {
+ border-top: 1px solid #c1c1c1;
+ border-bottom: 1px solid #c1c1c1;
+}
+
+.canvas-groups-rows .expanded-group .group-header-left,
+.canvas-groups-rows .expanded-group .group-header-right {
+ border-bottom: none;
+}
+
+/* group backdrop */
+.canvas-groups-rows .group-item .group-backdrop {
+ position: absolute;
+ left: 0;
+}
+
+.group-level-2 .canvas-groups-rows .group-item .group-backdrop {
+ background-color: #ededed;
+}
+
+.group-level-3 .canvas-groups-rows .group-item .group-backdrop {
+ background-color: #e3e3e3;
+}
+
+.group-level-4 .canvas-groups-rows .group-item .group-backdrop {
+ background-color: #dedede;
+}
+
+/* group-header-left */
+.canvas-groups-rows .group-header-left {
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ display: flex;
+ align-items: center;
+ border-top-left-radius: 5px;
+ background-color: inherit;
+ overflow: hidden;
+}
+
+.canvas-groups-rows .folded-group .group-header-left {
+ border-bottom-left-radius: 5px;
+}
+
+.canvas-groups-rows.single-column .group-level-1 .group-container-left,
+.canvas-groups-rows.single-column .group-level-1 .group-header-cell {
+ border-top-right-radius: 5px;
+}
+
+.canvas-groups-rows.single-column .group-level-1 .group-header-cell {
+ border-right: 1px solid #cacaca !important;
+}
+
+.canvas-groups-rows.single-column .folded-group .group-container-left,
+.canvas-groups-rows.single-column .folded-group .group-header-cell,
+.canvas-groups-rows.single-column.frozen .table-btn-add-record {
+ border-bottom-right-radius: 5px;
+}
+
+/* group header cell */
+.canvas-groups-rows .group-level-1 .group-header-cell {
+ border-right: 1px solid #ededed;
+}
+
+.canvas-groups-rows .group-level-2 .group-header-cell {
+ border-right: 1px solid #e5e5e5;
+}
+
+.canvas-groups-rows .group-level-3 .group-header-cell {
+ border-right: 1px solid #dadada;
+}
+
+.canvas-groups-rows .group-container-right .group-header-cell:last-child {
+ border-right: none;
+}
+
+.canvas-groups-rows.all-columns-frozen .group-level-2 .table-last--frozen,
+.canvas-groups-rows.all-columns-frozen .group-level-3 .table-last--frozen {
+ border-right: none !important;
+}
+
+/* group expand */
+.canvas-groups-rows .group-expand {
+ padding: 0 0.5rem;
+}
+
+.canvas-groups-rows .group-expand span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+}
+
+.canvas-groups-rows .group-expand span:hover {
+ cursor: pointer;
+}
+
+.canvas-groups-rows .group-expand-icon {
+ font-size: 12px;
+ color: #666666;
+}
+
+/* group title */
+.canvas-groups-rows .group-title {
+ display: flex;
+ flex-direction: column;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.canvas-groups-rows .group-title .group-column-name {
+ font-size: 12px;
+ color: #666666;
+ font-weight: 500;
+}
+
+.canvas-groups-rows .group-title .group-cell-value {
+ display: flex;
+ font-weight: 500;
+ width: min-content;
+}
+
+.canvas-groups-rows .group-title .links-formatter .formatter-show {
+ min-height: unset;
+}
+
+.group-title .links-formatter .link {
+ max-width: unset;
+}
+
+.group-title .links-formatter .link-name {
+ padding-right: 0.25rem;
+}
+
+.canvas-groups-rows .group-title .multiple-select-option,
+.canvas-groups-rows .group-title .single-select-option {
+ margin: 0 10px 0 0;
+}
+
+.canvas-groups-rows .group-title .multiple-select-option:last-child,
+.canvas-groups-rows .group-title .single-select-option:last-child {
+ margin-right: 0;
+}
+
+.canvas-groups-rows .collaborator-avatar,
+.canvas-groups-rows .sf-metadata-ui.collaborator-item .collaborator-avatar {
+ height: 16px;
+ width: 16px;
+ margin-left: 0;
+ transform: translateY(0px);
+}
+
+.canvas-groups-rows .sf-metadata-ui.cell-formatter-container {
+ overflow: unset;
+}
+
+/* group rows count */
+.canvas-groups-rows .group-rows-count {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+}
+
+.group-rows-count-content {
+ display: inline-flex;
+ align-items: center;
+ margin: 0 15px 0 20px;
+ height: 100%;
+}
+
+.canvas-groups-rows .group-item.group-level-1 .group-rows-count {
+ background: linear-gradient(to right, hsla(0, 0%, 97%, 0), hsl(0, 0%, 97%) 18%);
+}
+
+.canvas-groups-rows .group-item.group-level-2 .group-rows-count {
+ background: linear-gradient(to right, hsla(0, 0%, 93%, 0), hsl(0, 0%, 93%) 18%);
+}
+
+.canvas-groups-rows .group-item.group-level-3 .group-rows-count {
+ background: linear-gradient(to right, hsla(0, 0%, 89%, 0), hsl(0, 0%, 89%) 18%);
+}
+
+.canvas-groups-rows .group-rows-count .count-title {
+ margin-right: 4px;
+ color: #666666;
+}
+
+/* group-header-right */
+.canvas-groups-rows .group-header-right {
+ width: 100%;
+ position: absolute;
+ display: inline-flex;
+ overflow: hidden;
+ border-top-right-radius: 5px;
+ background-color: inherit;
+ overflow: hidden;
+}
+
+.canvas-groups-rows .folded-group .group-header-right {
+ border-bottom-right-radius: 5px;
+}
+
+.canvas-groups-rows:not(.single-column) .group-level-2 .group-header-right {
+ border-left: 1px solid #e5e5e5;
+}
+
+.canvas-groups-rows:not(.single-column) .group-level-3 .group-header-right {
+ border-left: 1px solid #dadada;
+}
+
+/* group summary */
+.canvas-groups-rows .summary-item {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ height: 100%;
+ background-color: inherit;
+}
+
+/* group-row-cell */
+.canvas-groups-rows .sf-metadata-result-table-row {
+ position: absolute;
+ border-top: none;
+ border-bottom: none;
+}
+
+.sf-metadata-result-table-content .canvas-groups-rows .sf-metadata-result-table-row {
+ background-color: transparent;
+ overflow: hidden;
+}
+
+.canvas-groups-rows .sf-metadata-result-table-row .sf-metadata-result-table-cell {
+ border-top: 1px solid #ddd;
+}
+
+/* border color of last cell within group view */
+.canvas-groups-rows.disabled-add-record .sf-metadata-result-table-row.sf-metadata-last-table-row .sf-metadata-result-table-cell {
+ border-bottom: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record,
+.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record-row-filler {
+ border-top: 1px solid #ddd;
+ border-bottom: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .table-btn-add-record-row {
+ border-bottom-left-radius: 5px;
+ border-bottom-right-radius: 5px;
+ background-color: #fff;
+}
+
+.canvas-groups-rows .table-btn-add-record-row .table-btn-add-record {
+ border-left: 1px solid #cacaca;
+ border-bottom-left-radius: 5px;
+ background-color: #fff;
+}
+
+.canvas-groups-rows.frozen .frozen-btn-add-record-wrapper,
+.canvas-groups-rows.frozen .table-btn-add-record.frozen-columns,
+.canvas-groups-rows.frozen .table-result-table-cell.actions-cell {
+ z-index: 2 !important;
+}
+
+.canvas-groups-rows .table-btn-add-record-row-filler {
+ border-right: 1px solid #cacaca;
+ border-bottom-right-radius: 5px;
+}
+
+.frozen-columns.table-btn-add-record {
+ border-radius: 0;
+}
+
+.canvas-groups-rows .actions-cell {
+ border-left: 1px solid #cacaca;
+}
+
+.canvas-groups-rows .sf-metadata-result-table-row .last-cell {
+ border-right: 1px solid #cacaca;
+}
+
+.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row,
+.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row .actions-cell {
+ border-bottom-left-radius: 5px;
+}
+
+.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row,
+.canvas-groups-rows.disabled-add-record .sf-metadata-last-table-row .last-cell {
+ border-bottom-right-radius: 5px;
+}
+
+/* animation */
+.canvas-groups-rows.animation {
+ transition-property: height;
+ -webkit-transition-property: height;
+ -moz-transition-property: height;
+ transition-duration: 0.3s;
+ -webkit-transition-duration: 0.3s;
+ -moz-transition-duration: 0.3s;
+ transition-timing-function: ease-out;
+ -webkit-transition-timing-function: ease-out;
+ -moz-transition-timing-function: ease-out;
+ transition-delay: 0s;
+ -webkit-transition-delay: 0s;
+ -moz-transition-delay: 0s;
+}
+
+.canvas-groups-rows.animation .group-item,
+.canvas-groups-rows.animation .sf-metadata-result-table-row {
+ transition-property: top;
+ -webkit-transition-property: top;
+ -moz-transition-property: top;
+ transition-duration: 0.3s;
+ -webkit-transition-duration: 0.3s;
+ -moz-transition-duration: 0.3s;
+ transition-timing-function: ease-out;
+ -webkit-transition-timing-function: ease-out;
+ -moz-transition-timing-function: ease-out;
+ transition-delay: 0s;
+ -webkit-transition-delay: 0s;
+ -moz-transition-delay: 0s;
+}
+
+.canvas-groups-rows.animation .group-item,
+.canvas-groups-rows.animation .sf-metadata-result-table-row,
+.canvas-groups-rows.animation .sf-metadata-result-table-row .table-btn-add-record .canvas-groups-rows.animation .sf-metadata-result-table-row .table-btn-add-record-row-filler {
+ transition-property: height, top;
+ -webkit-transition-property: height, top;
+ -moz-transition-property: height, top;
+}
+
+.canvas-groups-rows .group-item.folding {
+ transition-property: none;
+ -webkit-transition-property: none;
+ -moz-transition-property: none;
+}
+
+.canvas-groups-rows.single-column .group-item {
+ border-radius: 5px;
+}
+
+.canvas-groups-rows.animation .group-item .group-backdrop,
+.canvas-groups-rows.animation .group-item .group-container-left,
+.canvas-groups-rows.animation .group-item .group-container-right {
+ transition-property: height;
+ -webkit-transition-property: height;
+ -moz-transition-property: height;
+ transition-duration: 0.3s;
+ -webkit-transition-duration: 0.3s;
+ -moz-transition-duration: 0.3s;
+ transition-timing-function: ease-out;
+ -webkit-transition-timing-function: ease-out;
+ -moz-transition-timing-function: ease-out;
+ transition-delay: 0s;
+ -webkit-transition-delay: 0s;
+ -moz-transition-delay: 0s;
+}
+
+.canvas-groups-rows .group-item.folding .group-container-left,
+.canvas-groups-rows .group-item.folding .group-container-right {
+ transition-property: none;
+ -webkit-transition-property: none;
+ -moz-transition-property: none;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js
new file mode 100644
index 0000000000..fb2d1f4cbe
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-container/index.js
@@ -0,0 +1,166 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import joinClasses from 'classnames';
+import GroupContainerLeft from '../group-container-left';
+import GroupContainerRight from '../group-container-right';
+import { isMobile } from '../../../../../../utils';
+import { isFrozen } from '../../../../../../utils/column-utils';
+import { GROUP_VIEW_OFFSET, SEQUENCE_COLUMN_WIDTH } from '../../../../../../constants';
+import { Z_INDEX } from '../../../../../../_basic';
+
+import './index.css';
+
+class GroupContainer extends Component {
+
+ shouldComponentUpdate(nextProps) {
+ return (
+ nextProps.groupPathString !== this.props.groupPathString ||
+ nextProps.group !== this.props.group ||
+ nextProps.width !== this.props.width ||
+ nextProps.height !== this.props.height ||
+ nextProps.top !== this.props.top ||
+ nextProps.columns !== this.props.columns ||
+ nextProps.rowHeight !== this.props.rowHeight ||
+ nextProps.isExpanded !== this.props.isExpanded ||
+ nextProps.scrollLeft !== this.props.scrollLeft ||
+ nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey ||
+ nextProps.summaryConfigs !== this.props.summaryConfigs
+ );
+ }
+
+ componentDidMount() {
+ if (this.props.lastFrozenColumnKey && !isMobile) {
+ this.checkScroll();
+ }
+ }
+
+ checkScroll() {
+ const { scrollLeft } = this.props;
+ this.cancelFixFrozenDOMs(scrollLeft);
+ }
+
+ fixedFrozenDOMs = (scrollLeft, scrollTop) => {
+ if (this.backDrop) {
+ const tableContentLeft = this.props.getTableContentLeft();
+ this.backDrop.style.position = 'fixed';
+ this.backDrop.style.marginLeft = tableContentLeft + 'px';
+ this.backDrop.style.marginTop = (-scrollTop) + 'px';
+ }
+
+ this.leftContainer && this.leftContainer.fixedFrozenDOMs(scrollLeft, scrollTop);
+ this.rightContainer && this.rightContainer.fixedFrozenDOMs(scrollLeft, scrollTop);
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ if (this.backDrop) {
+ this.backDrop.style.position = 'absolute';
+ this.backDrop.style.marginLeft = scrollLeft - GROUP_VIEW_OFFSET + 'px';
+ this.backDrop.style.marginTop = '0px';
+ }
+
+ this.leftContainer && this.leftContainer.cancelFixFrozenDOMs(scrollLeft);
+ this.rightContainer && this.rightContainer.cancelFixFrozenDOMs(scrollLeft);
+ };
+
+ setContainer = (node) => {
+ this.group = node;
+ };
+
+ setBackDrop = (node) => {
+ this.backDrop = node;
+ };
+
+ onExpandGroupToggle = () => {
+ const { groupPathString } = this.props;
+ this.props.onExpandGroupToggle(groupPathString);
+ };
+
+ render() {
+ const {
+ group, columns, width, isExpanded, folding, summaryConfigs, height, backdropHeight, top,
+ groupOffsetLeft, lastFrozenColumnKey, maxLevel,
+ } = this.props;
+ const { left, level } = group;
+ const firstLevelGroup = level === 1;
+ const groupClassName = joinClasses(
+ 'group-item',
+ `group-level-${level}`,
+ isExpanded ? 'expanded-group' : 'folded-group',
+ folding ? 'folding' : '',
+ );
+
+ const firstColumn = columns[0] || {};
+ const firstColumnFrozen = isFrozen(firstColumn);
+ const firstColumnWidth = firstColumn.width || 0;
+ const leftPaneWidth = SEQUENCE_COLUMN_WIDTH + firstColumnWidth + (firstLevelGroup ? 0 : ((level - 1) * GROUP_VIEW_OFFSET - 1));
+ const rightPaneWidth = width - leftPaneWidth;
+ const groupItemStyle = {
+ height,
+ width,
+ top,
+ left
+ };
+ let backDropStyle = {
+ height: backdropHeight,
+ width: leftPaneWidth + GROUP_VIEW_OFFSET,
+ zIndex: Z_INDEX.GROUP_BACKDROP
+ };
+
+ return (
+
+ {(level === maxLevel && firstColumnFrozen && !isMobile) &&
+
+ }
+
this.leftContainer = ref}
+ group={group}
+ firstColumnFrozen={firstColumnFrozen}
+ lastColumnFrozen={firstColumn.key === lastFrozenColumnKey}
+ leftPaneWidth={leftPaneWidth}
+ height={height}
+ isExpanded={isExpanded}
+ firstColumnKey={firstColumn.key}
+ maxLevel={maxLevel}
+ onExpandGroupToggle={this.onExpandGroupToggle}
+ />
+ this.rightContainer = ref}
+ group={group}
+ isExpanded={isExpanded}
+ leftPaneWidth={leftPaneWidth}
+ rightPaneWidth={rightPaneWidth}
+ height={height}
+ groupOffsetLeft={groupOffsetLeft}
+ lastFrozenColumnKey={lastFrozenColumnKey}
+ columns={columns}
+ summaryConfigs={summaryConfigs}
+ getTableContentLeft={this.props.getTableContentLeft}
+ />
+
+ );
+ }
+}
+
+GroupContainer.propTypes = {
+ group: PropTypes.object,
+ groupPathString: PropTypes.string,
+ folding: PropTypes.bool,
+ columns: PropTypes.array,
+ rowHeight: PropTypes.number,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ backdropHeight: PropTypes.number,
+ top: PropTypes.number,
+ groupOffsetLeft: PropTypes.number,
+ formulaRow: PropTypes.object,
+ lastFrozenColumnKey: PropTypes.string,
+ isExpanded: PropTypes.bool,
+ scrollLeft: PropTypes.number,
+ maxLevel: PropTypes.number,
+ summaryConfigs: PropTypes.object,
+ getTableContentLeft: PropTypes.func,
+ onExpandGroupToggle: PropTypes.func,
+ updateSummaryConfig: PropTypes.func,
+};
+
+export default GroupContainer;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js
new file mode 100644
index 0000000000..98b2237480
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-cell.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { isFrozen } from '../../../../../utils/column-utils';
+import { GROUP_HEADER_HEIGHT, SEQUENCE_COLUMN_WIDTH } from '../../../../../constants';
+import { Z_INDEX } from '../../../../../_basic';
+
+class GroupHeaderCell extends React.PureComponent {
+
+ fixedFrozenDOMs = (scrollLeft, scrollTop) => {
+ if (this.headerCell) {
+ const { firstColumnWidth, groupOffsetLeft } = this.props;
+ const tableContentLeft = this.props.getTableContentLeft();
+ this.headerCell.style.position = 'fixed';
+ this.headerCell.style.marginLeft = (SEQUENCE_COLUMN_WIDTH + firstColumnWidth + groupOffsetLeft + tableContentLeft) + 'px';
+ this.headerCell.style.marginTop = (-scrollTop) + 'px';
+ }
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ if (this.headerCell) {
+ this.headerCell.style.position = 'absolute';
+ this.headerCell.style.marginLeft = scrollLeft + 'px';
+ this.headerCell.style.marginTop = 0;
+ }
+ };
+
+ getStyle = () => {
+ let { offsetLeft, column, isExpanded } = this.props;
+ const style = {
+ position: 'absolute',
+ width: column.width,
+ height: GROUP_HEADER_HEIGHT - (isExpanded ? 1 : 2), // header height - border-top(1px) - !isExpanded && border-bottom(1px)
+ left: offsetLeft
+ };
+ if (isFrozen(column)) {
+ style.zIndex = Z_INDEX.GROUP_FROZEN_HEADER;
+ }
+ return style;
+ };
+
+ render() {
+ const { column, isLastFrozenColumn } = this.props;
+ return (
+ this.headerCell = ref}
+ className={classnames('summary-item group-header-cell', {
+ 'table-last--frozen': isLastFrozenColumn
+ })}
+ style={this.getStyle()}
+ data-column_key={column.key}
+ >
+
+ );
+ }
+}
+
+GroupHeaderCell.propTypes = {
+ column: PropTypes.object.isRequired,
+ isExpanded: PropTypes.bool,
+ isLastFrozenColumn: PropTypes.bool,
+ firstColumnWidth: PropTypes.number,
+ offsetLeft: PropTypes.number.isRequired,
+ groupOffsetLeft: PropTypes.number,
+ summary: PropTypes.object,
+ summaryMethod: PropTypes.string,
+ getTableContentLeft: PropTypes.func,
+};
+
+export default GroupHeaderCell;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js
new file mode 100644
index 0000000000..4141a4b18d
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-left.js
@@ -0,0 +1,66 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import GroupTitle from './group-title';
+import { Z_INDEX } from '../../../../../_basic';
+import { GROUP_HEADER_HEIGHT } from '../../../../../constants';
+import { gettext } from '../../../../../utils';
+
+class GroupHeaderLeft extends Component {
+
+ render() {
+ const {
+ isExpanded, firstColumnFrozen, lastColumnFrozen, firstColumnKey, maxLevel,
+ group, width,
+ } = this.props;
+ const { column, count, level, cell_value, original_cell_value } = group;
+ const expandIconClassName = classnames(
+ 'group-expand-icon',
+ 'sf-metadata-font',
+ isExpanded ? 'sf-metadata-icon-drop-down' : 'sf-metadata-icon-right-slide',
+ );
+ const groupHeaderLeftStyle = {
+ zIndex: firstColumnFrozen && Z_INDEX.GROUP_FROZEN_HEADER,
+ height: GROUP_HEADER_HEIGHT,
+ width,
+ };
+
+ return (
+ this.groupHeaderLeft = ref}
+ className={classnames('group-header-left group-header-cell', { 'table-last--frozen': lastColumnFrozen })}
+ style={groupHeaderLeftStyle}
+ data-column_key={firstColumnKey}
+ >
+
+
+
+
+
+
+ {level === maxLevel && {gettext('Count')}}
+ {count}
+
+
+
+ );
+ }
+}
+
+GroupHeaderLeft.propTypes = {
+ isExpanded: PropTypes.bool,
+ firstColumnFrozen: PropTypes.bool,
+ lastColumnFrozen: PropTypes.bool,
+ firstColumnKey: PropTypes.string,
+ maxLevel: PropTypes.number,
+ group: PropTypes.object,
+ formulaRow: PropTypes.object,
+ width: PropTypes.number,
+ onExpandGroupToggle: PropTypes.func,
+};
+
+export default GroupHeaderLeft;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js
new file mode 100644
index 0000000000..6bcd895ddd
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-header-right.js
@@ -0,0 +1,85 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import GroupHeaderCell from './group-header-cell';
+import { isFrozen } from '../../../../../utils/column-utils';
+import { GROUP_HEADER_HEIGHT } from '../../../../../constants';
+
+class GroupHeaderRight extends Component {
+
+ headerCells = {};
+
+ setHeaderCellRef = (key) => (node) => {
+ this.headerCells[key] = node;
+ };
+
+ fixedFrozenDOMs = (scrollLeft, scrollTop) => {
+ this.props.columns.forEach((column) => {
+ const headerCell = this.headerCells[column.key];
+ if (isFrozen(column) && headerCell) {
+ headerCell.fixedFrozenDOMs(scrollLeft, scrollTop);
+ }
+ });
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ this.props.columns.forEach((column) => {
+ const headerCell = this.headerCells[column.key];
+ if (isFrozen(column) && headerCell) {
+ headerCell.cancelFixFrozenDOMs(scrollLeft);
+ }
+ });
+ };
+
+ getGroupSummaries = () => {
+ const {
+ group, isExpanded, columns, groupOffsetLeft, lastFrozenColumnKey, summaryConfigs,
+ } = this.props;
+ const summaryColumns = columns.slice(1); // get column from 2 index
+ const firstColumnWidth = columns[0] ? columns[0].width : 0;
+ let offsetLeft = 0;
+ return summaryColumns.map((column, index) => {
+ const { key } = column;
+ const summaryMethod = summaryConfigs && summaryConfigs[key] ? summaryConfigs[key] : 'Sum';
+ const summary = group.summaries[key];
+ if (index !== 0) {
+ offsetLeft += summaryColumns[index - 1].width;
+ }
+
+ return (
+
+ );
+ });
+ };
+
+ render() {
+ return (
+
+ {this.getGroupSummaries()}
+
+ );
+ }
+}
+
+GroupHeaderRight.propTypes = {
+ group: PropTypes.object,
+ isExpanded: PropTypes.bool,
+ groupOffsetLeft: PropTypes.number,
+ lastFrozenColumnKey: PropTypes.string,
+ columns: PropTypes.array,
+ summaryConfigs: PropTypes.object,
+ getTableContentLeft: PropTypes.func,
+};
+
+export default GroupHeaderRight;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js
new file mode 100644
index 0000000000..0efba7ff60
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/group-widgets/group-title.js
@@ -0,0 +1,72 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+ CellType
+} from '../../../../../_basic';
+import { DELETED_OPTION_TIPS } from '../../../../../constants';
+import { gettext } from '../../../../../utils';
+import CellFormatter from '../../../../cell-formatter';
+
+class GroupTitle extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ creator: null,
+ };
+ this.EmptyDOM = `(${gettext('Empty')})`;
+ this.deletedOptionTips = gettext(DELETED_OPTION_TIPS);
+ this.collaborators = window.sfMetadataContext.getCollaboratorsFromCache();
+ }
+
+ getOptionColors = () => {
+ const { dtableUtils } = window.app;
+ return dtableUtils.getOptionColors();
+ };
+
+ renderCollaborator = (collaborator) => {
+ const { email, avatar_url, name } = collaborator || {};
+ return (
+
+
+
+
+
{name}
+
+ );
+ };
+
+ renderGroupCellVal = (column, cellValue, originalCellValue = null) => {
+ const { type } = column;
+ switch (type) {
+ case CellType.CREATOR:
+ case CellType.LAST_MODIFIER: {
+ if (!originalCellValue) return this.EmptyDOM;
+ return (
+
+ );
+ }
+ default: {
+ return cellValue || this.EmptyDOM;
+ }
+ }
+ };
+
+ render() {
+ const { column, originalCellValue, cellValue } = this.props;
+ return (
+
+
{column.name}
+
{this.renderGroupCellVal(column, cellValue, originalCellValue)}
+
+ );
+ }
+}
+
+GroupTitle.propTypes = {
+ originalCellValue: PropTypes.any,
+ cellValue: PropTypes.any,
+ column: PropTypes.object,
+};
+
+export default GroupTitle;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx
new file mode 100644
index 0000000000..1e5124b566
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-actions-cell.jsx
@@ -0,0 +1,48 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import SelectAll from './select-all';
+import { SEQUENCE_COLUMN_WIDTH } from '../../../../constants';
+
+class HeaderActionsCell extends Component {
+
+ render() {
+ const {
+ isMobile, hasSelectedRecord, isSelectedAll, isLastFrozenCell, groupOffsetLeft, height
+ } = this.props;
+ const columnCellClass = 'sf-metadata-result-table-cell column';
+ const columnCellStyle = {
+ height,
+ width: SEQUENCE_COLUMN_WIDTH + groupOffsetLeft,
+ minWidth: SEQUENCE_COLUMN_WIDTH + groupOffsetLeft,
+ };
+ return (
+
+ );
+ }
+}
+
+HeaderActionsCell.propTypes = {
+ isMobile: PropTypes.bool,
+ hasSelectedRecord: PropTypes.bool,
+ isSelectedAll: PropTypes.bool,
+ isLastFrozenCell: PropTypes.bool,
+ height: PropTypes.number,
+ groupOffsetLeft: PropTypes.number,
+ selectNoneRecords: PropTypes.func,
+ selectAllRecords: PropTypes.func,
+};
+
+export default HeaderActionsCell;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js
new file mode 100644
index 0000000000..59c55e4bcb
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-cell.js
@@ -0,0 +1,164 @@
+import React, { createRef, Component } from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { UncontrolledTooltip, Tooltip } from 'reactstrap';
+import { Icon } from '@seafile/sf-metadata-ui-component';
+import { COLUMNS_ICON_CONFIG, COLUMNS_ICON_NAME } from '../../../../_basic';
+import ResizeColumnHandle from './resize-column-handle';
+import { SUPPORT_BATCH_DOWNLOAD_TYPES, TABLE_SUPPORT_EDIT_TYPE_MAP, EVENT_BUS_TYPE } from '../../../../constants';
+import HeaderDropdownMenu from './header-dropdown-menu';
+import { gettext } from '../../../../utils';
+
+class HeaderCell extends Component {
+
+ static defaultProps = {
+ style: null,
+ };
+
+ static propTypes = {
+ groupOffsetLeft: PropTypes.number,
+ height: PropTypes.number,
+ column: PropTypes.object,
+ style: PropTypes.object,
+ frozen: PropTypes.bool,
+ isLastFrozenCell: PropTypes.bool,
+ isHideTriangle: PropTypes.bool,
+ resizeColumnWidth: PropTypes.func,
+ downloadColumnAllFiles: PropTypes.func,
+ };
+
+ constructor(props) {
+ super(props);
+ this.descriptionRef = createRef();
+ this.uneditableTip = createRef();
+ this.eventBus = window.sfMetadataContext.eventBus;
+ this.state = {
+ tooltipOpen: false,
+ isMenuShow: false,
+ };
+ }
+
+ headerCellRef = (node) => this.headerCell = node;
+
+ getWidthFromMouseEvent = (e) => {
+ let right = e.pageX || (e.touches && e.touches[0] && e.touches[0].pageX) || (e.changedTouches && e.changedTouches[e.changedTouches.length - 1].pageX);
+ if (e.pageX === 0) {
+ right = 0;
+ }
+ const left = ReactDOM.findDOMNode(this.headerCell).getBoundingClientRect().left;
+ return right - left;
+ };
+
+ onDrag = (e) => {
+ const width = this.getWidthFromMouseEvent(e);
+ if (width > 0) {
+ this.props.resizeColumnWidth(this.props.column, width);
+ }
+ };
+
+ onIconTooltipToggle = () => {
+ this.setState({ tooltipOpen: !this.state.tooltipOpen });
+ };
+
+ handleHeaderCellClick = (column) => {
+ this.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_COLUMN, column);
+ };
+
+ checkDropdownAvailable = () => {
+ const { isHideTriangle, column } = this.props;
+ if (isHideTriangle) {
+ return false;
+ }
+ if (SUPPORT_BATCH_DOWNLOAD_TYPES.includes(column.type)) {
+ return true;
+ }
+ return false;
+ };
+
+ toggleHeaderDropDownMenu = () => {
+ this.setState({ isMenuShow: !this.state.isMenuShow });
+ };
+
+ render() {
+ const { frozen, groupOffsetLeft, column, isLastFrozenCell, height } = this.props;
+ const { left, width, description, key, name, type } = column;
+ const canEditable = window.sfMetadataContext.canModifyCell(column);
+ const style = Object.assign({ width, maxWidth: width, minWidth: width, height }, this.props.style);
+ if (!frozen) {
+ style.left = left + groupOffsetLeft;
+ }
+ const headerIconTooltip = COLUMNS_ICON_NAME[type];
+
+ return (
+
+
this.handleHeaderCellClick(column, frozen)}
+ >
+
+
+
+ {gettext(headerIconTooltip)}
+
+
+ {name}
+
+
+ {(TABLE_SUPPORT_EDIT_TYPE_MAP[type] && !canEditable) &&
+
+
+ {gettext('No editing permission')}
+
+
+ }
+ {description &&
+ <>
+
+
+
+ {description}
+
+ >
+ }
+ {this.checkDropdownAvailable() &&
+
+ }
+
+
+
+ );
+ }
+}
+
+export default HeaderCell;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx
new file mode 100644
index 0000000000..98f56abe0c
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/header-dropdown-menu.jsx
@@ -0,0 +1,109 @@
+import React, { Fragment, createRef } from 'react';
+import PropTypes from 'prop-types';
+import { Dropdown, DropdownToggle, DropdownMenu } from 'reactstrap';
+import { ModalPortal } from '@seafile/sf-metadata-ui-component';
+import { isMobile } from '../../../../utils';
+import { isFrozen } from '../../../../utils/column-utils';
+import { gettext } from '../../../../../../utils/constants';
+
+class HeaderDropdownMenu extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ top: 0,
+ left: 0,
+ };
+ this.headerDropDownMenuRef = createRef();
+ }
+
+ toggle = (event) => {
+ event && event.preventDefault();
+ event && event.stopPropagation();
+
+ let { column, height, isMenuShow } = this.props;
+
+ let top = height - 5; // height - container padding - menu margin
+ let left = - (column.width - 30); // column width - container width - padding
+ this.setState({ top, left });
+ let targetDom = event.target;
+ if (isMenuShow && typeof targetDom.className === 'string' && targetDom.className.includes('disabled')) {
+ return;
+ }
+ this.props.toggleHeaderDropDownMenu();
+ };
+
+ hideSubMenu = () => {};
+
+ onDownloadAllFiles = () => {
+ const { column } = this.props;
+ this.props.downloadColumnAllFiles(column);
+ };
+
+ getMenuStyle = () => {
+ if (isFrozen(this.props.column)) {
+ return { transform: 'none' };
+ }
+ let { top, left } = this.state;
+
+ return {
+ top,
+ left,
+ transform: 'none',
+ };
+ };
+
+ renderUpperMenu = () => {
+ let upperMenu = [];
+ return upperMenu;
+ };
+
+ renderDropdownMenu = () => {
+ const menuStyle = this.getMenuStyle();
+
+ return (
+
+ this.dropdownDom = ref}>
+ {this.renderUpperMenu().map((item, index) => {
+ return {item};
+ })}
+
+
+ );
+ };
+
+ render() {
+ const { isMenuShow } = this.props;
+
+ return (
+
+
+
+
+ {isMenuShow && !isMobile &&
+
+ {this.renderDropdownMenu()}
+
+ }
+
+ );
+ }
+}
+
+HeaderDropdownMenu.propTypes = {
+ isMenuShow: PropTypes.bool,
+ column: PropTypes.object,
+ height: PropTypes.number,
+ toggleHeaderDropDownMenu: PropTypes.func,
+ downloadColumnAllFiles: PropTypes.func,
+};
+
+export default HeaderDropdownMenu;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js
new file mode 100644
index 0000000000..36d19dc2c1
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/index.js
@@ -0,0 +1,774 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import RecordsHeader from './records-header';
+import RecordsBody from './records-body';
+import RecordsGroupBody from './records-group-body';
+import RecordsFooter from './record-footer';
+import { HorizontalScrollbar } from '../../../scrollbar';
+import { recalculate } from '../../../../utils/column-utils';
+import { SEQUENCE_COLUMN_WIDTH, CANVAS_RIGHT_INTERVAL, GROUP_ROW_TYPE, EVENT_BUS_TYPE } from '../../../../constants';
+import {
+ isWindowsBrowser, isWebkitBrowser, isMobile, getEventClassName,
+ addClassName, removeClassName,
+} from '../../../../utils';
+import RecordMetrics from '../../../../utils/record-metrics';
+import { isShiftKeyDown } from '../../../../utils/keyboard-utils';
+import { getVisibleBoundaries } from '../../../../utils/viewport';
+import { getColOverScanEndIdx, getColOverScanStartIdx } from '../../../../utils/grid';
+import { setColumnOffsets } from '../../../../utils/column-utils';
+
+class Records extends Component {
+
+ constructor(props) {
+ super(props);
+ this.scrollTop = 0;
+ this.isScrollByScrollbar = false;
+ const scrollLeft = window.sfMetadataContext.localStorage.getItem('scroll_left');
+ this.scrollLeft = scrollLeft ? Number(scrollLeft) : 0;
+ this.lastScrollLeft = this.scrollLeft;
+ this.initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 };
+ const columnMetrics = this.createColumnMetrics(props);
+ const initHorizontalScrollState = this.getHorizontalScrollState({ gridWidth: props.tableContentWidth, columnMetrics, scrollLeft: 0 });
+ this.state = {
+ columnMetrics,
+ recordMetrics: this.createRowMetrics(),
+ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 },
+ touchStartPosition: {},
+ selectedRange: {
+ topLeft: this.initPosition,
+ bottomRight: this.initPosition,
+ },
+ ...initHorizontalScrollState,
+ };
+ this.isWindows = isWindowsBrowser();
+ this.isWebkit = isWebkitBrowser();
+ }
+
+ componentDidMount() {
+ window.addEventListener('popstate', this.onPopState);
+ document.addEventListener('copy', this.onCopyCells);
+ document.addEventListener('paste', this.onPasteCells);
+ if (window.isMobile) {
+ window.addEventListener('touchstart', this.onTouchStart);
+ window.addEventListener('touchend', this.onTouchEnd);
+ } else {
+ document.addEventListener('mousedown', this.onMouseDown);
+ }
+ this.unsubscribeSelectNone = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, this.selectNone);
+ this.getScrollPosition();
+ this.checkExpandRow();
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { columns, tableContentWidth } = nextProps;
+ if (
+ this.props.columns !== columns
+ ) {
+ const columnMetrics = this.createColumnMetrics(nextProps);
+ this.updateHorizontalScrollState({
+ columnMetrics,
+ scrollLeft: this.lastScrollLeft,
+ gridWidth: tableContentWidth,
+ });
+ this.setState({ columnMetrics });
+ } else if (this.props.tableContentWidth !== tableContentWidth) {
+ this.updateHorizontalScrollState({
+ columnMetrics: this.state.columnMetrics,
+ scrollLeft: this.lastScrollLeft,
+ gridWidth: tableContentWidth,
+ });
+ }
+
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('popstate', this.onPopState);
+ document.removeEventListener('copy', this.onCopyCells);
+ document.removeEventListener('paste', this.onPasteCells);
+ if (window.isMobile) {
+ window.removeEventListener('touchstart', this.onTouchStart);
+ window.removeEventListener('touchend', this.onTouchEnd);
+ } else {
+ document.removeEventListener('mousedown', this.onMouseDown);
+ }
+
+ this.clearSetAbsoluteTimer();
+ this.setState = (state, callback) => {
+ return;
+ };
+ }
+
+ createColumnMetrics = (props) => {
+ const { columns, table } = props;
+ return recalculate(columns, [], table._id);
+ };
+
+ createRowMetrics = (props = this.props) => {
+ return {
+ idSelectedRecordMap: {},
+ };
+ };
+
+ setScrollLeft = (scrollLeft) => {
+ this.resultContainerRef.scrollLeft = scrollLeft;
+ };
+
+ resizeColumnWidth = (column, width) => {
+ if (width < 50) return;
+ const { table, columns, } = this.props;
+ const newColumn = Object.assign({}, column, { width });
+ const index = columns.findIndex(item => item.key === column.key);
+ let updateColumns = columns.slice(0);
+ updateColumns[index] = newColumn;
+ updateColumns = setColumnOffsets(updateColumns);
+ const columnMetrics = recalculate(updateColumns, columns, table._id);
+ this.setState({ columnMetrics }, () => {
+ const oldValue = localStorage.getItem('pages_columns_width');
+ let pagesColumnsWidth = {};
+ if (oldValue) {
+ pagesColumnsWidth = JSON.parse(oldValue);
+ }
+ const page = window.app.getPage();
+ const { id: pageId } = page;
+ let pageColumnsWidth = pagesColumnsWidth[pageId] || {};
+ const key = `${table._id}-${column.key}`;
+ pageColumnsWidth[key] = width;
+ const updated = Object.assign({}, pagesColumnsWidth, { [pageId]: pageColumnsWidth });
+ localStorage.setItem('pages_columns_width', JSON.stringify(updated));
+ });
+ };
+
+ getScrollPosition = () => {
+ let scrollLeft = window.sfMetadataContext.localStorage.getItem('scroll_left') + '';
+ let scrollTop = window.sfMetadataContext.localStorage.getItem('scroll_top') + '';
+ if (scrollLeft && scrollTop) {
+ if (this.bodyRef) {
+ scrollLeft = Number(scrollLeft);
+ scrollTop = Number(scrollTop);
+ this.bodyRef.setScrollTop(scrollTop);
+ this.setScrollLeft(scrollLeft);
+ this.handleHorizontalScroll(scrollLeft, scrollTop);
+ }
+ }
+ };
+
+ checkExpandRow = async () => {
+ // todo
+ };
+
+ storeScrollPosition = () => {
+ const scrollTop = this.bodyRef.getScrollTop();
+ const scrollLeft = this.getScrollLeft();
+ window.sfMetadataContext.localStorage.setItem('scroll_left', scrollLeft);
+ this.storeScrollTop(scrollTop);
+ };
+
+ storeScrollTop = (scrollTop) => {
+ window.sfMetadataContext.localStorage.setItem('scroll_top', scrollTop);
+ };
+
+ onContentScroll = (e) => {
+ const { scrollLeft } = e.target;
+ const scrollTop = this.bodyRef.getScrollTop();
+ const deltaX = this.scrollLeft - scrollLeft;
+ const deltaY = this.scrollTop - scrollTop;
+ this.scrollLeft = scrollLeft;
+ if (deltaY !== 0) {
+ this.scrollTop = scrollTop;
+ }
+
+ // table horizontal scroll, set first column freeze
+ if (deltaY === 0 && (deltaX !== 0 || scrollLeft === 0)) {
+ this.handleHorizontalScroll(scrollLeft, scrollTop);
+ }
+ this.storeScrollPosition();
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.CLOSE_EDITOR);
+ };
+
+ handleHorizontalScroll = (scrollLeft, scrollTop) => {
+ if (isMobile) {
+ this.updateHorizontalScrollState({
+ scrollLeft,
+ columnMetrics: this.state.columnMetrics,
+ gridWidth: this.props.tableContentWidth,
+ });
+ return;
+ }
+
+ // update classnames after scroll
+ const originClassName = this.resultContainerRef ? this.resultContainerRef.className : '';
+ let newClassName;
+ if (scrollLeft > 0) {
+ newClassName = addClassName(originClassName, 'horizontal-scroll');
+ } else {
+ newClassName = removeClassName(originClassName, 'horizontal-scroll');
+ }
+ if (newClassName !== originClassName && this.resultContainerRef) {
+ this.resultContainerRef.className = newClassName;
+ }
+
+ this.lastScrollLeft = scrollLeft;
+
+ this.handleFrozenDOMsPosition(scrollLeft, scrollTop);
+
+ this.recordsFooterRef.setSummaryScrollLeft(scrollLeft);
+ if (!this.isScrollByScrollbar) {
+ this.handleScrollbarScroll(scrollLeft);
+ }
+ if (this.bodyRef && this.bodyRef.interactionMask) {
+ this.bodyRef.setScrollLeft(scrollLeft);
+ }
+
+ this.updateHorizontalScrollState({
+ scrollLeft,
+ columnMetrics: this.state.columnMetrics,
+ gridWidth: this.props.tableContentWidth,
+ });
+ };
+
+ handleFrozenDOMsPosition = (scrollLeft, scrollTop) => {
+ const { lastFrozenColumnKey } = this.state.columnMetrics;
+ if (this.props.isGroupView && !lastFrozenColumnKey) {
+ return; // none-frozen columns under group view
+ }
+
+ this.clearSetAbsoluteTimer();
+ this.setFixed(scrollLeft, scrollTop);
+ this.timer = setTimeout(() => {
+ this.setAbsolute(scrollLeft, scrollTop);
+ }, 100);
+ };
+
+ handleScrollbarScroll = (scrollLeft) => {
+ if (!this.horizontalScrollbar) return;
+ if (!this.isScrollByScrollbar) {
+ this.setHorizontalScrollbarScrollLeft(scrollLeft);
+ return;
+ }
+ this.isScrollByScrollbar = false;
+ };
+
+ onHorizontalScrollbarScroll = (scrollLeft) => {
+ this.isScrollByScrollbar = true;
+ this.setScrollLeft(scrollLeft);
+ };
+
+ onHorizontalScrollbarMouseUp = () => {
+ this.isScrollByScrollbar = false;
+ };
+
+ setHorizontalScrollbarScrollLeft = (scrollLeft) => {
+ this.horizontalScrollbar && this.horizontalScrollbar.setScrollLeft(scrollLeft);
+ };
+
+ setFixed = (left, top) => {
+ this.bodyRef.recordFrozenRefs.forEach(dom => {
+ if (!dom) return;
+ dom.frozenColumns.style.position = 'fixed';
+ dom.frozenColumns.style.marginLeft = '0px';
+ dom.frozenColumns.style.marginTop = '-' + top + 'px';
+ });
+
+ this.bodyRef.frozenBtnAddRecordRefs.forEach(dom => {
+ if (!dom) return;
+ dom.frozenColumns.style.position = 'fixed';
+ dom.frozenColumns.style.marginLeft = '0px';
+ dom.frozenColumns.style.marginTop = '-' + top + 'px';
+ });
+
+ if (this.bodyRef.fixFrozenDoms) {
+ this.bodyRef.fixFrozenDoms(left, top);
+ }
+ };
+
+ setAbsolute = (left) => {
+ const { isGroupView } = this.props;
+ const { lastFrozenColumnKey } = this.state.columnMetrics;
+ if (isGroupView && !lastFrozenColumnKey) {
+ return;
+ }
+
+ this.bodyRef.recordFrozenRefs.forEach(dom => {
+ if (!dom) return;
+ dom.frozenColumns.style.position = 'absolute';
+ dom.frozenColumns.style.marginLeft = left + 'px';
+ dom.frozenColumns.style.marginTop = '0px';
+ });
+
+ this.bodyRef.frozenBtnAddRecordRefs.forEach(dom => {
+ if (!dom) return;
+ dom.frozenColumns.style.position = 'absolute';
+ dom.frozenColumns.style.marginLeft = left + 'px';
+ dom.frozenColumns.style.marginTop = '0px';
+ });
+
+ if (this.bodyRef.cancelFixFrozenDOMs) {
+ this.bodyRef.cancelFixFrozenDOMs(left);
+ }
+
+ if (this.bodyRef && this.bodyRef.interactionMask) {
+ this.bodyRef.cancelSetScrollLeft();
+ }
+ };
+
+ clearSetAbsoluteTimer = () => {
+ if (!this.timer) {
+ return;
+ }
+ clearTimeout(this.timer);
+ this.timer = null;
+ };
+
+ getScrollLeft = () => {
+ if (isMobile) {
+ return 0;
+ }
+ return this.scrollLeft || 0;
+ };
+
+ getScrollTop = () => {
+ if (isMobile) {
+ return 0;
+ }
+ return this.scrollTop || 0;
+ };
+
+ setHorizontalScrollbarRef = (ref) => {
+ this.horizontalScrollbar = ref;
+ };
+
+ setResultContainerRef = (ref) => {
+ this.resultContainerRef = ref;
+ };
+
+ updateSelectedRange = (selectedRange) => {
+ this.setState({ selectedRange });
+ };
+
+ onClickContainer = (e) => {
+ let classNames = getEventClassName(e);
+ if (classNames.includes('sf-metadata-result-content') || classNames.includes('sf-metadata-result-table-content')) {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.CLOSE_EDITOR);
+ }
+ };
+
+ onCellClick = (cell) => {
+ if (cell) {
+ const currentPosition = { ...cell };
+ this.updateSelectedRange({
+ topLeft: currentPosition,
+ bottomRight: currentPosition,
+ });
+ }
+ this.onDeselectAllRecords();
+ };
+
+ onCellRangeSelectionUpdated = (selectedRange) => {
+ this.onCellClick();
+ this.updateSelectedRange(selectedRange);
+ };
+
+ onPopState = () => {
+ this.checkExpandRow();
+ };
+
+ onCopyCells = (e) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.COPY_CELLS, e);
+ };
+
+ onPasteCells = (e) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.PASTE_CELLS, e);
+ };
+
+ onTouchStart = (e) => {
+ const outsideDom = ['canvas', 'group-canvas'];
+ if (e.target && outsideDom.includes(e.target.id)) {
+ let touchStartPosition = {
+ startX: e.changedTouches[0].clientX,
+ startY: e.changedTouches[0].clientY,
+ };
+ this.setState({ touchStartPosition });
+ }
+ };
+
+ onTouchEnd = (e) => {
+ const outsideDom = ['canvas', 'group-canvas'];
+ if (e.target && outsideDom.includes(e.target.id)) {
+ let { clientX, clientY } = e.changedTouches[0];
+ let { touchStartPosition } = this.state;
+ if (Math.abs(touchStartPosition.startX - clientX) < 5 && Math.abs(touchStartPosition.startY - clientY) < 5) {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE);
+ }
+ }
+ };
+
+ onMouseDown = (e) => {
+ const validClassName = getEventClassName(e);
+ if (validClassName.indexOf('sf-metadata-result-table-cell') > -1) {
+ return;
+ }
+ const outsideDom = ['canvas', 'group-canvas'];
+ if (outsideDom.includes(e.target.id) || validClassName.includes('sf-metadata-result-content')) {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE);
+ }
+ };
+
+ selectNone = () => {
+ this.setState({
+ selectedRange: {
+ topLeft: this.initPosition,
+ bottomRight: this.initPosition
+ },
+ });
+
+ // clear selected records
+ this.onDeselectAllRecords();
+ };
+
+ onSelectRecord = ({ groupRecordIndex, recordIndex }, e) => {
+ e.stopPropagation();
+ if (isShiftKeyDown(e)) {
+ this.selectRecordWithShift({ groupRecordIndex, recordIndex });
+ return;
+ }
+ const { isGroupView } = this.props;
+ const { recordMetrics } = this.state;
+ const operateRecord = this.props.recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex });
+ if (!operateRecord) {
+ return;
+ }
+
+ const operateRecordId = operateRecord._id;
+ if (RecordMetrics.isRecordSelected(operateRecordId, recordMetrics)) {
+ this.deselectRecord(operateRecordId);
+ this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 } });
+ return;
+ }
+ this.selectRecord(operateRecordId);
+ this.setState({ lastRowIdxUiSelected: { groupRecordIndex, recordIndex } });
+ };
+
+ selectRecordWithShift = ({ groupRecordIndex, recordIndex }) => {
+ const { recordIds, isGroupView } = this.props;
+ const { lastRowIdxUiSelected, recordMetrics } = this.state;
+ let selectedRecordIds = [];
+ if (isGroupView) {
+ if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupMetrics) {
+ return;
+ }
+ const groupMetrics = window.sfMetadataBody.getGroupMetrics();
+ const { groupRows } = groupMetrics;
+ const groupRecordIndexes = [groupRecordIndex, lastRowIdxUiSelected.groupRecordIndex].sort((a, b) => a - b);
+ for (let i = groupRecordIndexes[0]; i <= groupRecordIndexes[1]; i++) {
+ const groupRow = groupRows[i];
+ const { type } = groupRow;
+ if (type !== GROUP_ROW_TYPE.ROW) {
+ continue;
+ }
+ selectedRecordIds.push(groupRow.rowId);
+ }
+ } else {
+ const operateRecordId = recordIds[recordIndex];
+ if (!operateRecordId) {
+ return;
+ }
+ const lastSelectedRecordIndex = lastRowIdxUiSelected.recordIndex;
+ if (lastSelectedRecordIndex < 0) {
+ this.selectRecord(operateRecordId);
+ this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex } });
+ return;
+ }
+ if (recordIndex === lastSelectedRecordIndex || RecordMetrics.isRecordSelected(operateRecordId, recordMetrics)) {
+ this.deselectRecord(operateRecordId);
+ this.setState({ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 } });
+ return;
+ }
+ selectedRecordIds = this.getRecordIdsBetweenRange({ start: lastSelectedRecordIndex, end: recordIndex });
+ }
+
+ if (selectedRecordIds.length === 0) {
+ return;
+ }
+ this.selectRecordsById(selectedRecordIds);
+ this.setState({ lastRowIdxUiSelected: { groupRecordIndex, recordIndex } });
+ };
+
+ getRecordIdsBetweenRange = ({ start, end }) => {
+ const { recordIds: propsRecordIds } = this.props;
+ const startIndex = Math.min(start, end);
+ const endIndex = Math.max(start, end);
+ let recordIds = [];
+ for (let i = startIndex; i <= endIndex; i++) {
+ const recordId = propsRecordIds[i];
+ if (recordId) {
+ recordIds.push(recordId);
+ }
+ }
+ return recordIds;
+ };
+
+ selectRecord = (recordId) => {
+ const { recordMetrics } = this.state;
+ if (RecordMetrics.isRecordSelected(recordId, recordMetrics)) {
+ return;
+ }
+ let updatedRecordMetrics = { ...recordMetrics };
+ RecordMetrics.selectRecord(recordId, updatedRecordMetrics);
+ this.setState({
+ recordMetrics: updatedRecordMetrics,
+ });
+ };
+
+ selectRecordsById = (recordIds) => {
+ const { recordMetrics } = this.state;
+ const unSelectedRecordIds = recordIds.filter(recordId => !RecordMetrics.isRecordSelected(recordId, recordMetrics));
+ if (unSelectedRecordIds.length === 0) {
+ return;
+ }
+ let updatedRecordMetrics = { ...recordMetrics };
+ RecordMetrics.selectRecordsById(recordIds, updatedRecordMetrics);
+ this.setState({
+ recordMetrics: updatedRecordMetrics,
+ });
+ };
+
+ deselectRecord = (recordId) => {
+ const { recordMetrics } = this.state;
+ if (!RecordMetrics.isRecordSelected(recordId, recordMetrics)) {
+ return;
+ }
+ let updatedRecordMetrics = { ...recordMetrics };
+ RecordMetrics.deselectRecord(recordId, updatedRecordMetrics);
+ this.setState({
+ recordMetrics: updatedRecordMetrics,
+ });
+ };
+
+ selectAllRecords = () => {
+ const { recordIds, isGroupView } = this.props;
+ const { recordMetrics } = this.state;
+ let updatedRecordMetrics = { ...recordMetrics };
+ let selectedRowIds = [];
+ if (isGroupView) {
+ if (!window.sfMetadataBody || !window.sfMetadataBody.getGroupMetrics) {
+ return;
+ }
+ const groupMetrics = window.sfMetadataBody.getGroupMetrics();
+ const { groupRows } = groupMetrics;
+ groupRows.forEach(groupRow => {
+ const { type } = groupRow;
+ if (type !== GROUP_ROW_TYPE.ROW) {
+ return;
+ }
+ selectedRowIds.push(groupRow.rowId);
+ });
+ } else {
+ selectedRowIds = recordIds;
+ }
+ RecordMetrics.selectRecordsById(selectedRowIds, updatedRecordMetrics);
+ this.setState({
+ recordMetrics: updatedRecordMetrics,
+ });
+ };
+
+ onDeselectAllRecords = () => {
+ const { recordMetrics } = this.state;
+ if (!RecordMetrics.hasSelectedRecords(recordMetrics)) {
+ return;
+ }
+ let updatedRecordMetrics = { ...recordMetrics };
+ RecordMetrics.deselectAllRecords(updatedRecordMetrics);
+ this.setState({
+ recordMetrics: updatedRecordMetrics,
+ lastRowIdxUiSelected: { groupRecordIndex: -1, recordIndex: -1 },
+ });
+ };
+
+ hasSelectedCell = ({ groupRecordIndex, recordIndex }, selectedPosition) => {
+ if (!selectedPosition) return false;
+ const { isGroupView } = this.props;
+ const { groupRecordIndex: selectedGroupRowIndex, rowIdx: selectedRecordIndex } = selectedPosition;
+ if (isGroupView) {
+ return groupRecordIndex === selectedGroupRowIndex;
+ }
+ return recordIndex === selectedRecordIndex;
+ };
+
+ hasSelectedRecord = () => {
+ const { recordMetrics } = this.state;
+ if (!RecordMetrics.hasSelectedRecords(recordMetrics)) {
+ return false;
+ }
+ const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics);
+ const selectedRecords = selectedRecordIds && selectedRecordIds.map(id => this.props.recordGetterById(id)).filter(Boolean);
+ return selectedRecords && selectedRecords.length > 0;
+ };
+
+ getHorizontalScrollState = ({ gridWidth, columnMetrics, scrollLeft }) => {
+ const { columns } = columnMetrics;
+ const columnsLength = columns.length;
+ const { colVisibleStartIdx, colVisibleEndIdx } = getVisibleBoundaries(columns, scrollLeft, gridWidth);
+ const colOverScanStartIdx = getColOverScanStartIdx(colVisibleStartIdx);
+ const colOverScanEndIdx = getColOverScanEndIdx(colVisibleEndIdx, columnsLength);
+ return {
+ colOverScanStartIdx,
+ colOverScanEndIdx,
+ };
+ };
+
+ updateHorizontalScrollState = ({ columnMetrics, gridWidth, scrollLeft }) => {
+ const scrollState = this.getHorizontalScrollState({ columnMetrics, gridWidth, scrollLeft });
+ this.setState(scrollState);
+ };
+
+ cacheDownloadFilesProps = (column, records) => {
+ // todo
+ };
+
+ downloadColumnAllFiles = (column) => {
+ // todo
+ };
+
+ openDownloadFilesDialog = () => {
+ // todo
+ };
+
+ closeDownloadFilesDialog = () => {
+ // todo
+ };
+
+ renderRecordsBody = ({ containerWidth }) => {
+ const { recordMetrics, columnMetrics, colOverScanStartIdx, colOverScanEndIdx } = this.state;
+ const {
+ columns, allColumns, totalWidth, lastFrozenColumnKey, frozenColumnsWidth,
+ } = columnMetrics;
+ const commonProps = {
+ ...this.props,
+ columns, allColumns, totalWidth, lastFrozenColumnKey, frozenColumnsWidth,
+ recordMetrics, colOverScanStartIdx, colOverScanEndIdx,
+ hasSelectedRecord: this.hasSelectedRecord(),
+ getScrollLeft: this.getScrollLeft,
+ getScrollTop: this.getScrollTop,
+ selectNone: this.selectNone,
+ onCellClick: this.onCellClick,
+ onCellRangeSelectionUpdated: this.onCellRangeSelectionUpdated,
+ onSelectRecord: this.onSelectRecord,
+ setRecordsScrollLeft: this.setScrollLeft,
+ hasSelectedCell: this.hasSelectedCell,
+ cacheScrollTop: this.storeScrollTop,
+ };
+ if (this.props.isGroupView) {
+ return (
+ {
+ this.bodyRef = ref;
+ }}
+ {...commonProps}
+ groups={this.props.groups}
+ groupbys={this.props.groupbys}
+ groupOffsetLeft={this.props.groupOffsetLeft}
+ />
+ );
+ }
+ return (
+ {
+ this.bodyRef = ref;
+ }}
+ {...commonProps}
+ recordIds={this.props.recordIds}
+ />
+ );
+ };
+
+ render() {
+ const { recordIds, recordsCount, table, isGroupView, groupOffsetLeft } = this.props;
+ const { recordMetrics, columnMetrics, selectedRange, colOverScanStartIdx, colOverScanEndIdx } = this.state;
+ const { columns, totalWidth, lastFrozenColumnKey } = columnMetrics;
+ const containerWidth = totalWidth + SEQUENCE_COLUMN_WIDTH + CANVAS_RIGHT_INTERVAL;
+ const hasSelectedRecord = this.hasSelectedRecord();
+ const isSelectedAll = RecordMetrics.isSelectedAll(recordIds, recordMetrics);
+
+ return (
+
+
+
+ {
+ this.headerFrozenRef = ref;
+ }}
+ containerWidth={containerWidth}
+ table={table}
+ columns={columns}
+ colOverScanStartIdx={colOverScanStartIdx}
+ colOverScanEndIdx={colOverScanEndIdx}
+ hasSelectedRecord={hasSelectedRecord}
+ isSelectedAll={isSelectedAll}
+ isGroupView={isGroupView}
+ groupOffsetLeft={groupOffsetLeft}
+ lastFrozenColumnKey={lastFrozenColumnKey}
+ resizeColumnWidth={this.resizeColumnWidth}
+ selectNoneRecords={this.selectNone}
+ selectAllRecords={this.selectAllRecords}
+ downloadColumnAllFiles={this.downloadColumnAllFiles}
+ />
+ {this.renderRecordsBody({ containerWidth })}
+
+
+ {this.isWindows && this.isWebkit && (
+
+ )}
+ this.recordsFooterRef = ref}
+ recordsCount={recordsCount}
+ hasMore={this.props.hasMore}
+ columns={columns}
+ groupOffsetLeft={groupOffsetLeft}
+ recordMetrics={recordMetrics}
+ selectedRange={selectedRange}
+ isGroupView={isGroupView}
+ hasSelectedRecord={hasSelectedRecord}
+ isLoadingMore={this.props.isLoadingMore}
+ recordGetterById={this.props.recordGetterById}
+ recordGetterByIndex={this.props.recordGetterByIndex}
+ getRecordsSummaries={() => {}}
+ clickToLoadMore={this.props.clickToLoadMore}
+ />
+
+ );
+ }
+}
+
+Records.propTypes = {
+ isGroupView: PropTypes.bool,
+ columns: PropTypes.array,
+ table: PropTypes.object,
+ hasMore: PropTypes.bool,
+ isLoadingMore: PropTypes.bool,
+ groupOffsetLeft: PropTypes.number,
+ gridUtils: PropTypes.object,
+ recordIds: PropTypes.array,
+ recordsCount: PropTypes.number,
+ groups: PropTypes.array,
+ groupbys: PropTypes.array,
+ searchResult: PropTypes.object,
+ tableContentWidth: PropTypes.number,
+ scrollToLoadMore: PropTypes.func,
+ updateRecord: PropTypes.func,
+ updateRecords: PropTypes.func,
+ recordGetterById: PropTypes.func,
+ recordGetterByIndex: PropTypes.func,
+ clickToLoadMore: PropTypes.func,
+};
+
+export default Records;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js
new file mode 100644
index 0000000000..d6d80ea69d
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/load-all-tip.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { toaster } from '@seafile/sf-metadata-ui-component';
+import { gettext } from '../../../../../../utils/constants';
+
+class LoadAllTip extends React.Component {
+
+ onClick = () => {
+ toaster.closeAll();
+ this.props.clickToLoadMore(100000);
+ };
+
+ render() {
+ return (
+
+
{gettext('Loaded 50,000 records.')}
+
{gettext('Click to load more')}
+
+ );
+ }
+}
+
+LoadAllTip.propTypes = {
+ clickToLoadMore: PropTypes.func
+};
+
+export default LoadAllTip;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js
new file mode 100644
index 0000000000..821890de82
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-cell.js
@@ -0,0 +1,204 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { toaster } from '@seafile/sf-metadata-ui-component';
+import { isFunction } from '../../../../_basic';
+import { isNameColumn } from '../../../../utils/column-utils';
+import { TABLE_SUPPORT_EDIT_TYPE_MAP } from '../../../../constants';
+import { isCellValueChanged } from '../../../../utils/cell-comparer';
+import CellFormatter from '../../../cell-formatter';
+
+class RecordCell extends React.Component {
+
+ static defaultProps = {
+ needBindEvents: true
+ };
+
+ state = {};
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const {
+ record: oldRecord, column, isCellSelected, isLastCell, highlightClassName,
+ height, bgColor,
+ } = this.props;
+ const { record: newRecord, highlightClassName: newHighlightClassName, height: newHeight, column: newColumn, bgColor: newBgColor } = nextProps;
+ // the modification of column is not currently supported, only the modification of cell data is considered
+ const oldValue = oldRecord[column.name] || oldRecord[column.key];
+ const newValue = newRecord[column.name] || newRecord[column.key];
+ const isShouldUpdated = (
+ isCellValueChanged(oldValue, newValue, column.type) ||
+ oldRecord._last_modifier !== newRecord._last_modifier ||
+ isCellSelected !== nextProps.isCellSelected ||
+ isLastCell !== nextProps.isLastCell ||
+ highlightClassName !== newHighlightClassName ||
+ height !== newHeight ||
+ column.left !== newColumn.left ||
+ column.width !== newColumn.width ||
+ bgColor !== newBgColor
+ );
+ return isShouldUpdated;
+ }
+
+ getCellClass = (hasComment) => {
+ const { column, highlightClassName, isLastCell, isLastFrozenCell, isCellSelected } = this.props;
+ const { isFileTipShow } = this.state;
+ const { type } = column;
+ let className = `sf-metadata-result-table-cell sf-metadata-result-table-${type}-cell `;
+ const canEditable = window.sfMetadataContext.canModifyCell(column);
+ className = `${className}${(canEditable || !TABLE_SUPPORT_EDIT_TYPE_MAP[type]) ? '' : 'table-cell-uneditable '}`;
+ if (highlightClassName) {
+ className += `${highlightClassName} `;
+ }
+ if (isLastCell) {
+ className += 'last-cell ';
+ }
+ if (isLastFrozenCell) {
+ className += 'table-last--frozen ';
+ }
+ if (isCellSelected) {
+ className += 'cell-selected ';
+ }
+ if (isFileTipShow) {
+ className += 'draging-file-to-cell ';
+ }
+ if (hasComment) {
+ className += 'row-comment-cell';
+ }
+ return className;
+ };
+
+ onCellClick = (e) => {
+ const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props;
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+
+ // select cell
+ if (isFunction(cellMetaData.onCellClick)) {
+ cellMetaData.onCellClick(cell, e);
+ }
+ };
+
+ onCellDoubleClick = (e) => {
+ const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props;
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+
+ if (isFunction(cellMetaData.onCellDoubleClick)) {
+ cellMetaData.onCellDoubleClick(cell, e);
+ }
+ };
+
+ onCellMouseDown = (e) => {
+ if (e.button === 2) {
+ return;
+ }
+ const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props;
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+
+ if (isFunction(cellMetaData.onCellMouseDown)) {
+ cellMetaData.onCellMouseDown(cell, e);
+ }
+ };
+
+ onCellMouseEnter = (e) => {
+ const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props;
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+ if (isFunction(cellMetaData.onCellMouseEnter)) {
+ const mousePosition = { x: e.clientX, y: e.clientY };
+ cellMetaData.onCellMouseEnter({ ...cell, mousePosition }, e);
+ }
+ };
+
+ onCellMouseMove = (e) => {
+ const { column, groupRecordIndex, recordIndex, cellMetaData } = this.props;
+ const cell = { idx: column.idx, groupRecordIndex, rowIdx: recordIndex };
+ if (isFunction(cellMetaData.onCellMouseMove)) {
+ const mousePosition = { x: e.clientX, y: e.clientY };
+ cellMetaData.onCellMouseMove({ ...cell, mousePosition }, e);
+ }
+ };
+
+ onCellMouseLeave = () => {
+ return;
+ };
+
+ getEvents = () => {
+ return {
+ onClick: this.onCellClick,
+ onDoubleClick: this.onCellDoubleClick,
+ onMouseDown: this.onCellMouseDown,
+ onMouseEnter: this.onCellMouseEnter,
+ onMouseMove: this.onCellMouseMove,
+ onMouseLeave: this.onCellMouseLeave,
+ onDragOver: this.onDragOver
+ };
+ };
+
+ onDragOver = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ };
+
+ updateParentTips = (isFileTipShow) => {
+ this.setState({ isFileTipShow: isFileTipShow });
+ };
+
+ onCellTipShow = (message) => {
+ toaster.warning(message);
+ };
+
+ render = () => {
+ const { frozen, record, column, needBindEvents, height, bgColor } = this.props;
+ const { key, name, left, width } = column;
+ const readonly = true;
+ const commentCount = isNameColumn(column) && this.getCommentCount();
+ const hasComment = !!commentCount;
+ const className = this.getCellClass(hasComment);
+ const cellStyle = {
+ width,
+ height,
+ };
+ if (!frozen) {
+ cellStyle.left = left;
+ }
+ if (bgColor) {
+ cellStyle['backgroundColor'] = bgColor;
+ }
+
+ let cellValue = record[name] || record[key];
+ const cellEvents = needBindEvents && this.getEvents();
+ const props = {
+ className,
+ style: cellStyle,
+ ...cellEvents,
+ };
+ const cellContent = (
+
+ );
+
+ return (
+
+ {cellContent}
+
+ );
+ };
+}
+
+RecordCell.propTypes = {
+ frozen: PropTypes.bool,
+ isCellSelected: PropTypes.bool,
+ isLastCell: PropTypes.bool,
+ isLastFrozenCell: PropTypes.bool,
+ cellMetaData: PropTypes.object,
+ record: PropTypes.object.isRequired,
+ groupRecordIndex: PropTypes.number,
+ recordIndex: PropTypes.number.isRequired,
+ column: PropTypes.object.isRequired,
+ height: PropTypes.number,
+ needBindEvents: PropTypes.bool,
+ modifyRecord: PropTypes.func,
+ lockRecordViaButton: PropTypes.func,
+ modifyRecordViaButton: PropTypes.func,
+ reloadCurrentRecord: PropTypes.func,
+ highlightClassName: PropTypes.string,
+ bgColor: PropTypes.string,
+};
+
+export default RecordCell;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css
new file mode 100644
index 0000000000..fba0063219
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.css
@@ -0,0 +1,88 @@
+.sf-metadata-result-footer .rows-record {
+ width: 80px;
+ padding-left: 8px;
+}
+
+.load-all-tip {
+ display: flex;
+ align-items: center;
+}
+
+.sf-metadata-result-footer .summaries-pane {
+ position: relative;
+ display: flex;
+ flex: 1 1;
+ overflow: hidden;
+}
+
+.sf-metadata-result-footer .summaries-scroll {
+ position: relative;
+ overflow: hidden;
+}
+
+.sf-metadata-result-footer .summaries-scroll > div {
+ display: inline-flex;
+}
+
+.sf-metadata-result-footer .summary-item,
+.canvas-groups-rows .summary-item {
+ display: flex;
+ justify-content: flex-end;
+ text-align: right;
+ padding: 0 8px;
+ height: 30px;
+}
+
+.sf-metadata-result-footer .summary-item .summary-value,
+.canvas-groups-rows .summary-item .summary-value {
+ display: flex;
+ justify-content: flex-end;
+ max-width: calc(100% - 18px);
+ overflow: hidden;
+}
+
+.sf-metadata-result-footer .summary-value .summary-value-title,
+.canvas-groups-rows .summary-value .summary-value-title {
+ color: #666666;
+ flex: none;
+}
+
+.sf-metadata-result-footer .summary-value .summary-value-text,
+.canvas-groups-rows .summary-value .summary-value-text {
+ max-width: 100%;
+ padding-left: 3px;
+ flex: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.load-all-tip .load-all,
+.sf-metadata-result-footer .load-all {
+ height: 16px;
+ line-height: 16px;
+ color: #666;
+ cursor: pointer;
+ border-bottom: 1px solid #666;
+}
+
+.sf-metadata-result-footer .load-all {
+ margin: auto;
+}
+
+.load-all-tip .load-all:hover,
+.sf-metadata-result-footer .load-all:hover {
+ color: #212529;
+ border-bottom: 1px solid #212529;
+}
+
+.sf-metadata-result-footer .loading-message {
+ display: inline-flex;
+ align-items: center;
+ color: #666;
+}
+
+.sf-metadata-result-footer .loading-message .loading-icon {
+ width: 16px;
+ height: 16px;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js
new file mode 100644
index 0000000000..80d140077e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record-footer/index.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Loading, toaster } from '@seafile/sf-metadata-ui-component';
+import { Z_INDEX } from '../../../../../_basic';
+import LoadAllTip from '../load-all-tip';
+import RecordMetrics from '../../../../../utils/record-metrics';
+import { SEQUENCE_COLUMN_WIDTH, CANVAS_RIGHT_INTERVAL } from '../../../../../constants';
+import { getRecordsFromSelectedRange } from '../../../../../utils/selected-cell-utils';
+import { gettext } from '../../../../../../../utils/constants';
+
+import './index.css';
+
+class RecordsFooter extends React.Component {
+
+ onClick = () => {
+ if (this.props.isLoadingMore) {
+ return;
+ }
+ const loadNumber = this.props.recordsCount < 50000 ? 50000 : 100000;
+ this.props.clickToLoadMore(loadNumber, (hasMore) => {
+ if (hasMore) {
+ toaster.success(, { duration: 5 });
+ } else {
+ toaster.success(gettext('All records loaded'));
+ }
+ });
+ };
+
+ setSummaryScrollLeft = (scrollLeft) => {
+ this.summaryItemsRef.scrollLeft = scrollLeft;
+ };
+
+ getSelectedCellsCount = (selectedRange) => {
+ const { topLeft, bottomRight } = selectedRange;
+
+ // if no cell selected topLeft.rowIdx is -1 , then return 0
+ if (topLeft.rowIdx === -1) {
+ return 0;
+ }
+
+ return (bottomRight.idx - topLeft.idx + 1) * (bottomRight.rowIdx - topLeft.rowIdx + 1);
+ };
+
+ getSummaries = () => {
+ const {
+ isGroupView, hasSelectedRecord, recordMetrics, selectedRange, summaries,
+ recordGetterByIndex,
+ } = this.props;
+ if (hasSelectedRecord) {
+ const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics);
+ const selectedRecords = selectedRecordIds && selectedRecordIds.map(id => this.props.recordGetterById(id)).filter(Boolean);
+ return this.props.getRecordsSummaries(selectedRecords);
+ }
+
+ const selectedCellsCount = this.getSelectedCellsCount(selectedRange);
+ if (selectedCellsCount > 1) {
+ const records = getRecordsFromSelectedRange({ selectedRange, isGroupView, recordGetterByIndex });
+ return this.props.getRecordsSummaries(records);
+ }
+
+ return summaries;
+ };
+
+ getSummaryItems = () => {
+ const { columns, hasMore, isLoadingMore } = this.props;
+ const displayColumns = isLoadingMore || hasMore ? columns.slice(1, columns.length) : columns;
+ let totalWidth = SEQUENCE_COLUMN_WIDTH;
+ let summaryItems = Array.isArray(displayColumns) && displayColumns.map((column, columnIndex) => {
+ let summaryItem;
+ let { width, key } = column;
+ totalWidth += width;
+ summaryItem = ;
+ return summaryItem;
+ });
+ return { summaryItems, totalWidth };
+ };
+
+ getRecord = () => {
+ const { hasMore, hasSelectedRecord, recordMetrics, selectedRange, recordsCount } = this.props;
+ if (hasSelectedRecord) {
+ const selectedRecordsCount = RecordMetrics.getSelectedIds(recordMetrics).length;
+ return selectedRecordsCount > 1 ? gettext('xxx_records_selected').replace('xxx', selectedRecordsCount) : gettext('1 record selected');
+ }
+ const selectedCellsCount = this.getSelectedCellsCount(selectedRange);
+ if (selectedCellsCount > 1) {
+ return gettext('xxx cells selected').replace('xxx', selectedCellsCount);
+ }
+
+ let recordsCountText = gettext('No record');
+ if (recordsCount > 1) {
+ recordsCountText = gettext('xxx records').replace('xxx', recordsCount);
+ } else if (recordsCount === 1) {
+ recordsCountText = gettext('1 record');
+ }
+ if (hasMore) {
+ recordsCountText += ' +';
+ }
+ return recordsCountText;
+ };
+
+ render() {
+ const { hasMore, isLoadingMore, columns, groupOffsetLeft } = this.props;
+ let { summaryItems, totalWidth } = this.getSummaryItems();
+ const recordWidth = (isLoadingMore || hasMore ? SEQUENCE_COLUMN_WIDTH + columns[0].width : SEQUENCE_COLUMN_WIDTH) + groupOffsetLeft;
+
+ return (
+
+
+ {this.getRecord()}
+ {!isLoadingMore && hasMore &&
+ {gettext('Load_all')}
+ }
+ {isLoadingMore &&
+
+ {gettext('Loading')}
+
+
+ }
+
+
+
this.summaryItemsRef = ref}>
+
+ {summaryItems || ''}
+
+
+
+
+ );
+ }
+}
+
+RecordsFooter.propTypes = {
+ hasMore: PropTypes.bool,
+ isLoadingMore: PropTypes.bool,
+ isGroupView: PropTypes.bool,
+ hasSelectedRecord: PropTypes.bool,
+ recordsCount: PropTypes.number,
+ summaries: PropTypes.object,
+ summaryConfigs: PropTypes.object,
+ columns: PropTypes.array,
+ groupOffsetLeft: PropTypes.number,
+ recordMetrics: PropTypes.object,
+ selectedRange: PropTypes.object,
+ recordGetterById: PropTypes.func,
+ recordGetterByIndex: PropTypes.func,
+ getRecordsSummaries: PropTypes.func,
+ clickToLoadMore: PropTypes.func,
+};
+
+export default RecordsFooter;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css
new file mode 100644
index 0000000000..487dc858a4
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.css
@@ -0,0 +1,72 @@
+.sf-metadata-result-table-row .rdg-row-expand-icon {
+ opacity: 0;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ text-align: center;
+ line-height: 20px;
+ flex-shrink: 0;
+ cursor: pointer;
+}
+
+.sf-metadata-result-table-row:hover .rdg-row-expand-icon {
+ opacity: 1;
+}
+
+.sf-metadata-result-table-row .sf-metadata-result-column-content.actions-checkbox {
+ text-align: center;
+ display: none;
+}
+
+.sf-metadata-result-table-row:hover .sf-metadata-result-column-content.actions-checkbox {
+ display: block;
+}
+
+.sf-metadata-result-table-row:hover .sf-metadata-result-column-content.row-index {
+ display: none;
+}
+
+.sf-metadata-result-table-row.row-selected .sf-metadata-result-column-content.actions-checkbox {
+ display: block !important;
+}
+
+.sf-metadata-result-table-row.row-selected .sf-metadata-result-column-content.row-index {
+ display: none !important;
+}
+
+.sf-metadata-result-table-row .rdg-row-expand-icon:hover {
+ background: #c2f5e9;
+}
+
+.sf-metadata-result-table-row .rdg-row-expand-icon .sf-metadata-icon-open {
+ color: #467fcf !important;
+ fill: #467fcf !important;
+ transform: scale(0.8);
+}
+
+.sf-metadata-result-table-row .cell-jump-link {
+ display: inline-block;
+ font-size: 14px;
+ height: 20px;
+ line-height: 20px;
+ margin-left: 8px;
+ border: 1px solid #eee;
+ padding: 0 2px;
+ color: #666666;
+ border-radius: 2px;
+ background: #fff;
+ cursor: pointer;
+ box-shadow: 0 0 1px;
+}
+
+.cell-highlight {
+ background-color: rgb(239, 199, 151) !important;
+}
+
+.cell-current-highlight {
+ background-color: #f09f3f !important;
+}
+
+.frozen-columns {
+ background-color: #fff;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js
new file mode 100644
index 0000000000..69850e8160
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/record/index.js
@@ -0,0 +1,306 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import RecordCell from '../record-cell';
+import ActionsCell from '../actions-cell';
+import { getFrozenColumns } from '../../../../../utils/table-utils';
+import { Z_INDEX } from '../../../../../_basic';
+
+import './index.css';
+
+class Record extends React.Component {
+
+ componentDidMount() {
+ this.checkScroll();
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return (
+ nextProps.isGroupView !== this.props.isGroupView ||
+ nextProps.hasSelectedCell !== this.props.hasSelectedCell ||
+ (nextProps.hasSelectedCell && this.props.selectedPosition.idx !== nextProps.selectedPosition.idx) || // selected cell in same row but different column
+ nextProps.isSelected !== this.props.isSelected ||
+ nextProps.groupRecordIndex !== this.props.groupRecordIndex ||
+ nextProps.index !== this.props.index ||
+ nextProps.isLastRecord !== this.props.isLastRecord ||
+ nextProps.lastFrozenColumnKey !== this.props.lastFrozenColumnKey ||
+ nextProps.columns !== this.props.columns ||
+ nextProps.colOverScanStartIdx !== this.props.colOverScanStartIdx ||
+ nextProps.colOverScanEndIdx !== this.props.colOverScanEndIdx ||
+ nextProps.record !== this.props.record ||
+ nextProps.top !== this.props.top ||
+ nextProps.left !== this.props.left ||
+ nextProps.height !== this.props.height ||
+ nextProps.searchResult !== this.props.searchResult ||
+ nextProps.columnColor !== this.props.columnColor
+ );
+ }
+
+ checkScroll = () => {
+ this.cancelFixFrozenDOMs(this.props.scrollLeft);
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ const { isGroupView } = this.props;
+ const frozenChildrenCount = this.frozenColumns.childElementCount;
+ if (!this.frozenColumns || frozenChildrenCount < 1 || (isGroupView && frozenChildrenCount < 2)) {
+ return;
+ }
+ this.frozenColumns.style.position = 'absolute';
+ this.frozenColumns.style.marginLeft = scrollLeft + 'px';
+ this.frozenColumns.style.marginTop = '0px';
+ };
+
+ onSelectRecord = (e) => {
+ const { groupRecordIndex, index } = this.props;
+ this.props.selectNoneCells();
+ this.props.onSelectRecord({ groupRecordIndex, recordIndex: index }, e);
+ };
+
+ onRowExpand = () => {
+ const { record } = this.props;
+ this.props.onRowExpand(record);
+ };
+
+ isCellSelected = (columnIdx) => {
+ const { hasSelectedCell, selectedPosition } = this.props;
+ if (!selectedPosition) return false;
+ return hasSelectedCell && selectedPosition.idx === columnIdx;
+ };
+
+ isLastCell(columns, columnKey) {
+ return columns[columns.length - 1].key === columnKey;
+ }
+
+ reloadCurrentRecord = () => {
+ this.props.reloadRecords([this.props.record._id]);
+ };
+
+ getFrozenCells = () => {
+ const {
+ columns, lastFrozenColumnKey, groupRecordIndex, index: recordIndex, record,
+ cellMetaData, isGroupView, height, columnColor
+ } = this.props;
+ const frozenColumns = getFrozenColumns(columns);
+ if (frozenColumns.length === 0) return null;
+ const recordId = record._id;
+ return frozenColumns.map((column, index) => {
+ const { key } = column;
+ const isCellHighlight = this.isCellHighlight(key, recordId);
+ const isCurrentCellHighlight = this.isCurrentCellHighlight(key, recordId);
+ const highlightClassName = isCurrentCellHighlight ? 'cell-current-highlight' : isCellHighlight ? 'cell-highlight' : null;
+ const isCellSelected = this.isCellSelected(index);
+ const isLastCell = this.isLastCell(columns, key);
+ const isLastFrozenCell = key === lastFrozenColumnKey;
+ const bgColor = columnColor && columnColor[key];
+ return (
+
+ );
+ });
+ };
+
+ isCellHighlight = (columnKey, rowId) => {
+ const { searchResult } = this.props;
+ if (searchResult) {
+ const matchedColumns = searchResult.matchedRows[rowId];
+ if (matchedColumns && matchedColumns.includes(columnKey)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ isCurrentCellHighlight = (columnKey, rowId) => {
+ const { searchResult } = this.props;
+ if (searchResult) {
+ const { currentSelectIndex } = searchResult;
+ if (typeof(currentSelectIndex) !== 'number') return false;
+ const currentSelectCell = searchResult.matchedCells[currentSelectIndex];
+ if (!currentSelectCell) return false;
+ if (currentSelectCell.row === rowId && currentSelectCell.column === columnKey) return true;
+ }
+ return false;
+ };
+
+ getColumnCells = () => {
+ const {
+ columns, colOverScanStartIdx, colOverScanEndIdx, groupRecordIndex, index: recordIndex,
+ record, cellMetaData, isGroupView, height, columnColor
+ } = this.props;
+ const recordId = record._id;
+ const rendererColumns = columns.slice(colOverScanStartIdx, colOverScanEndIdx);
+ return rendererColumns.map((column) => {
+ const { key, frozen } = column;
+ const needBindEvents = !frozen;
+ const isCellSelected = this.isCellSelected(columns.findIndex(col => col.key === column.key));
+ const isCellHighlight = this.isCellHighlight(key, recordId);
+ const isCurrentCellHighlight = this.isCurrentCellHighlight(key, recordId);
+ const highlightClassName = isCurrentCellHighlight ? 'cell-current-highlight' : isCellHighlight ? 'cell-highlight' : null;
+ const isLastCell = this.isLastCell(columns, key);
+ const bgColor = columnColor && columnColor[key];
+ return (
+
+ );
+ });
+ };
+
+ getRecordStyle = () => {
+ const { isGroupView, height } = this.props;
+ let style = {
+ height: height + 'px',
+ };
+ if (isGroupView) {
+ const { top, left } = this.props;
+ style.top = top + 'px';
+ style.left = left + 'px';
+ }
+ return style;
+ };
+
+ getFrozenColumnsStyle = () => {
+ const { isGroupView, lastFrozenColumnKey, height } = this.props;
+ let style = {
+ zIndex: Z_INDEX.SEQUENCE_COLUMN,
+ height: height - 1,
+ };
+ if (isGroupView) {
+ style.height = height;
+ style.zIndex = Z_INDEX.FROZEN_GROUP_CELL;
+ if (!lastFrozenColumnKey) {
+ style.marginLeft = '0px';
+ }
+ }
+ return style;
+ };
+
+ // handle drag copy
+ handleDragEnter = (e) => {
+ // Prevent default to allow drop
+ e.preventDefault();
+ const { index, groupRecordIndex, cellMetaData: { onDragEnter } } = this.props;
+ onDragEnter({ overRecordIdx: index, overGroupRecordIndex: groupRecordIndex });
+ };
+
+ handleDragOver = (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'copy';
+ };
+
+ handleDrop = (e) => {
+ // The default in Firefox is to treat data in dataTransfer as a URL and perform navigation on it, even if the data type used is 'text'
+ // To bypass this, we need to capture and prevent the drop event.
+ e.preventDefault();
+ };
+
+ render() {
+ const {
+ isSelected, isGroupView, index, isLastRecord, lastFrozenColumnKey, height, record
+ } = this.props;
+ const isLocked = record._locked ? true : false;
+ const cellHeight = isGroupView ? height : height - 1;
+
+ const frozenCells = this.getFrozenCells();
+ const columnCells = this.getColumnCells();
+
+ return (
+
+ {/* frozen */}
+
this.frozenColumns = ref}
+ >
+
+ {frozenCells}
+
+ {/* scroll */}
+ {columnCells}
+
+ );
+ }
+}
+
+Record.propTypes = {
+ hasSelectedCell: PropTypes.bool,
+ isGroupView: PropTypes.bool,
+ isSelected: PropTypes.bool,
+ groupRecordIndex: PropTypes.number,
+ index: PropTypes.number.isRequired,
+ isLastRecord: PropTypes.bool,
+ lastFrozenColumnKey: PropTypes.string,
+ cellMetaData: PropTypes.object,
+ selectedPosition: PropTypes.object,
+ record: PropTypes.object.isRequired,
+ columns: PropTypes.array.isRequired,
+ colOverScanStartIdx: PropTypes.number,
+ colOverScanEndIdx: PropTypes.number,
+ scrollLeft: PropTypes.number,
+ top: PropTypes.number,
+ left: PropTypes.number,
+ height: PropTypes.number,
+ selectNoneCells: PropTypes.func,
+ onSelectRecord: PropTypes.func,
+ onRowExpand: PropTypes.func,
+ modifyRecord: PropTypes.func,
+ lockRecordViaButton: PropTypes.func,
+ modifyRecordViaButton: PropTypes.func,
+ reloadRecords: PropTypes.func,
+ searchResult: PropTypes.object,
+ columnColor: PropTypes.object,
+};
+
+export default Record;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js
new file mode 100644
index 0000000000..9ef06140a3
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-body.js
@@ -0,0 +1,646 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { Loading } from '@seafile/sf-metadata-ui-component';
+import { RightScrollbar } from '../../../scrollbar';
+import Record from './record';
+import InteractionMasks from '../../table-masks/interaction-masks';
+import { EVENT_BUS_TYPE, SEQUENCE_COLUMN_WIDTH } from '../../../../constants';
+import { isShiftKeyDown } from '../../../../utils/keyboard-utils';
+import { isColumnSupportDirectEdit, isColumnSupportEdit } from '../../../../utils/column-utils';
+import { isSelectedCellSupportOpenEditor } from '../../../../utils/selected-cell-utils';
+import RecordMetrics from '../../../../utils/record-metrics';
+import { getColumnScrollPosition, getColVisibleStartIdx, getColVisibleEndIdx } from '../../../../utils/records-body-utils';
+
+const ROW_HEIGHT = 33;
+const RENDER_MORE_NUMBER = 10;
+const CONTENT_HEIGHT = window.innerHeight - 174;
+const { max, min, ceil, round } = Math;
+
+class RecordsBody extends Component {
+
+ static defaultProps = {
+ editorPortalTarget: document.body,
+ scrollToRowIndex: 0,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ startRenderIndex: 0,
+ endRenderIndex: this.getInitEndIndex(props),
+ isContextMenuShow: false,
+ activeRecords: [],
+ menuPosition: null,
+ selectedPosition: null,
+ isScrollingRightScrollbar: false,
+ };
+ this.resultContentRef = null;
+ this.resultRef = null;
+ this.recordFrozenRefs = [];
+ this.frozenBtnAddRecordRefs = [];
+ this.rowVisibleStart = 0;
+ this.rowVisibleEnd = this.setRecordVisibleEnd();
+ this.columnVisibleStart = 0;
+ this.columnVisibleEnd = this.setColumnVisibleEnd();
+ this.timer = null;
+ }
+
+ componentDidMount() {
+ this.props.onRef(this);
+ window.sfMetadataBody = this;
+ document.addEventListener('contextmenu', this.handleContextMenu);
+ }
+
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { recordsCount, recordIds } = nextProps;
+ if (recordsCount !== this.props.recordsCount || recordIds !== this.props.recordIds) {
+ this.recalculateRenderIndex(recordIds);
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('contextmenu', this.handleContextMenu);
+
+ this.clearHorizontalScroll();
+ this.clearScrollbarTimer();
+ this.setState = (state, callback) => {
+ return;
+ };
+ }
+
+ getVisibleIndex = () => {
+ return { rowVisibleStartIdx: this.rowVisibleStart, rowVisibleEndIdx: this.rowVisibleEnd };
+ };
+
+ getShownRecords = () => {
+ return this.getShownRecordIds().map((id) => this.props.recordGetterById(id));
+ };
+
+ setRecordVisibleEnd = () => {
+ return max(ceil(CONTENT_HEIGHT / ROW_HEIGHT), 0);
+ };
+
+ setColumnVisibleEnd = () => {
+ const { columns, getScrollLeft, tableContentWidth } = this.props;
+ let columnVisibleEnd = 0;
+ const contentScrollLeft = getScrollLeft();
+ let endColumnWidth = tableContentWidth + contentScrollLeft;
+ for (let i = 0; i < columns.length; i ++) {
+ const { width } = columns[i];
+ endColumnWidth = endColumnWidth - width;
+ if (endColumnWidth < 0) {
+ return columnVisibleEnd = i;
+ }
+ }
+ return columnVisibleEnd;
+ };
+
+ recalculateRenderIndex = (recordIds) => {
+ const { startRenderIndex, endRenderIndex } = this.state;
+ const contentScrollTop = this.resultContentRef.scrollTop;
+ const start = Math.max(0, Math.floor(contentScrollTop / ROW_HEIGHT) - RENDER_MORE_NUMBER);
+ const end = Math.min(Math.ceil((contentScrollTop + this.resultContentRef.offsetHeight) / ROW_HEIGHT) + RENDER_MORE_NUMBER, recordIds.length);
+ if (start !== startRenderIndex) {
+ this.setState({ startRenderIndex: start });
+ }
+ if (end !== endRenderIndex) {
+ this.setState({ endRenderIndex: end });
+ }
+ };
+
+ getInitEndIndex = (props) => {
+ return Math.min(Math.ceil(window.innerHeight / ROW_HEIGHT) + RENDER_MORE_NUMBER, props.recordsCount);
+ };
+
+ getShownRecordIds = () => {
+ const { recordIds } = this.props;
+ const { startRenderIndex, endRenderIndex } = this.state;
+ return recordIds.slice(startRenderIndex, endRenderIndex);
+ };
+
+ getRowTop = (rowIdx) => {
+ return ROW_HEIGHT * rowIdx;
+ };
+
+ getRowHeight = () => {
+ return ROW_HEIGHT;
+ };
+
+ jumpToRow = (scrollToRowIndex) => {
+ const { recordsCount } = this.props;
+ const rowHeight = this.getRowHeight();
+ const height = this.resultContentRef.offsetHeight;
+ const scrollTop = Math.min(scrollToRowIndex * rowHeight, recordsCount * rowHeight - height);
+ this.setScrollTop(scrollTop);
+ };
+
+ scrollToColumn = (idx) => {
+ const { columns, tableContentWidth } = this.props;
+ const newScrollLeft = getColumnScrollPosition(columns, idx, tableContentWidth);
+ if (newScrollLeft !== null) {
+ this.props.setRecordsScrollLeft(newScrollLeft);
+ }
+ this.updateColVisibleIndex(newScrollLeft);
+ };
+
+ updateColVisibleIndex = (scrollLeft) => {
+ const { columns } = this.props;
+ const columnVisibleStart = getColVisibleStartIdx(columns, scrollLeft);
+ const columnVisibleEnd = getColVisibleEndIdx(columns, window.innerWidth, scrollLeft);
+ this.columnVisibleStart = columnVisibleStart;
+ this.columnVisibleEnd = columnVisibleEnd;
+ };
+
+ setScrollTop = (scrollTop) => {
+ this.resultContentRef.scrollTop = scrollTop;
+ };
+
+ setScrollLeft = (scrollLeft) => {
+ const { interactionMask } = this;
+ interactionMask && interactionMask.setScrollLeft(scrollLeft);
+ };
+
+ cancelSetScrollLeft = () => {
+ const { interactionMask } = this;
+ interactionMask && interactionMask.cancelSetScrollLeft();
+ };
+
+ getClientScrollTopOffset = (node) => {
+ const rowHeight = this.getRowHeight();
+ const scrollVariation = node.scrollTop % rowHeight;
+ return scrollVariation > 0 ? rowHeight - scrollVariation : 0;
+ };
+
+ onHitBottomCanvas = () => {
+ const rowHeight = this.getRowHeight();
+ const node = this.resultContentRef;
+ node.scrollTop += rowHeight + this.getClientScrollTopOffset(node);
+ };
+
+ onHitTopCanvas = () => {
+ const rowHeight = this.getRowHeight();
+ const node = this.resultContentRef;
+ node.scrollTop -= (rowHeight - this.getClientScrollTopOffset(node));
+ };
+
+ getScrollTop = () => {
+ return this.resultContentRef ? this.resultContentRef.scrollTop : 0;
+ };
+
+ getRecordBodyHeight = () => {
+ return this.resultContentRef ? this.resultContentRef.offsetHeight : 0;
+ };
+
+ onScroll = () => {
+ const { recordsCount } = this.props;
+ const { startRenderIndex, endRenderIndex } = this.state;
+ const { offsetHeight, scrollTop: contentScrollTop } = this.resultContentRef;
+ // Calculate the start rendering row index, and end rendering row index
+ const start = Math.max(0, Math.floor(contentScrollTop / ROW_HEIGHT) - RENDER_MORE_NUMBER);
+ const end = Math.min(Math.ceil((contentScrollTop + this.resultContentRef.offsetHeight) / ROW_HEIGHT) + RENDER_MORE_NUMBER, recordsCount);
+
+ this.oldScrollTop = contentScrollTop;
+ const renderedRecordsCount = ceil(this.resultContentRef.offsetHeight / ROW_HEIGHT);
+ const newRecordVisibleStart = max(0, round(contentScrollTop / ROW_HEIGHT));
+ const newRecordVisibleEnd = min(newRecordVisibleStart + renderedRecordsCount, recordsCount);
+ this.rowVisibleStart = newRecordVisibleStart;
+ this.rowVisibleEnd = newRecordVisibleEnd;
+
+ this.props.cacheScrollTop(contentScrollTop);
+
+ if (Math.abs(start - startRenderIndex) > 5 || start < 5) {
+ this.setState({ startRenderIndex: start });
+ }
+ if (Math.abs(end - endRenderIndex) > 5 || end > recordsCount - 5) {
+ this.setState({ endRenderIndex: end });
+ }
+ // Scroll to the bottom of the page, load more records
+ if (offsetHeight + contentScrollTop >= this.resultContentRef.scrollHeight) {
+ this.props.scrollToLoadMore();
+ }
+
+ if (!this.isScrollingRightScrollbar) {
+ this.setRightScrollbarScrollTop(this.oldScrollTop);
+ }
+
+ // solve the bug that the scroll bar disappears when scrolling too fast
+ this.clearScrollbarTimer();
+ this.scrollbarTimer = setTimeout(() => {
+ this.setState({ isScrollingRightScrollbar: false });
+ }, 300);
+ };
+
+ onRowExpand = (row) => {
+ this.props.onRowExpand && this.props.onRowExpand(row);
+ };
+
+ onScrollbarScroll = (scrollTop) => {
+ // solve canvas&rightScrollbar circle scroll problem
+ if (this.oldScrollTop === scrollTop) {
+ return;
+ }
+ this.setState({ isScrollingRightScrollbar: true }, () => {
+ this.setScrollTop(scrollTop);
+ });
+ };
+
+ onScrollbarMouseUp = () => {
+ this.setState({ isScrollingRightScrollbar: false });
+ };
+
+ setRightScrollbarScrollTop = (scrollTop) => {
+ this.rightScrollbar && this.rightScrollbar.setScrollTop(scrollTop);
+ };
+
+ onDeleteRecords = () => {
+ this.interactionMask && this.interactionMask.selectNone();
+ this.props.selectNone();
+ this.props.onDeleteRecords(this.state.activeRecords);
+ };
+
+ onInsertRecords = ({ insertRecordsNumber }) => {
+ const activeRecord = this.state.activeRecords[0];
+ const upperRecordId = activeRecord && activeRecord._id;
+ if (!upperRecordId) return;
+ this.props.insertRecords({ upperRecordId, insertRecordsNumber });
+ };
+
+ addBlankRecord = () => {
+ const { recordsCount, recordIds } = this.props;
+ const lastRecordIndex = recordsCount - 1;
+ const lastRecordId = lastRecordIndex > -1 && recordIds[lastRecordIndex];
+ this.props.insertRecords({ upperRecordId: lastRecordId, insertRecordsNumber: 1 });
+ };
+
+ onDuplicateRecord = () => {
+ this.props.duplicateRecord(this.state.activeRecords[0]);
+ };
+
+ onDuplicateRecords = () => {
+ this.props.duplicateRecords(this.state.activeRecords);
+ };
+
+ selectNoneCells = () => {
+ this.interactionMask && this.interactionMask.selectNone();
+ const { selectedPosition } = this.state;
+ if (!selectedPosition || selectedPosition.idx < 0 || selectedPosition.rowIdx < 0) {
+ return;
+ }
+ this.selectNone();
+ };
+
+ selectNone = () => {
+ this.setState({ selectedPosition: { idx: -1, rowIdx: -1 } });
+ };
+
+ selectCell = (cell, openEditor) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_CELL, cell, openEditor);
+ };
+
+ selectStart = (cellPosition) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_START, cellPosition);
+ };
+
+ selectUpdate = (cellPosition, isFromKeyboard, callback) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_UPDATE, cellPosition, isFromKeyboard, callback);
+ };
+
+ selectEnd = () => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_END);
+ };
+
+ onCellClick = (cell, e) => {
+ const { selectedPosition } = this.state;
+ if (isShiftKeyDown(e)) {
+ if (!selectedPosition || selectedPosition.idx === -1) {
+ // need select cell first
+ this.selectCell(cell, false);
+ return;
+ }
+ const isFromKeyboard = true;
+ this.selectUpdate(cell, isFromKeyboard);
+ } else {
+ const { columns } = this.props;
+ const supportOpenEditor = isColumnSupportDirectEdit(cell, columns);
+ const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, false, this.props.recordGetterByIndex);
+ this.selectCell(cell, supportOpenEditor && hasOpenPermission);
+ }
+ this.props.onCellClick(cell);
+ this.setState({ selectedPosition: cell });
+ };
+
+ onCellDoubleClick = (cell, e) => {
+ const { columns } = this.props;
+ const supportOpenEditor = isColumnSupportEdit(cell, columns);
+ const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, false, this.props.recordGetterByIndex);
+ this.selectCell(cell, supportOpenEditor && hasOpenPermission);
+ };
+
+ // onRangeSelectStart
+ onCellMouseDown = (cellPosition, event) => {
+ if (!isShiftKeyDown(event)) {
+ this.selectCell(cellPosition);
+ this.selectStart(cellPosition);
+ window.addEventListener('mouseup', this.onWindowMouseUp);
+ }
+ };
+
+ // onRangeSelectUpdate
+ onCellMouseEnter = (cellPosition) => {
+ this.selectUpdate(cellPosition, false, this.updateViewableArea);
+ };
+
+ onCellMouseMove = (cellPosition) => {
+ this.selectUpdate(cellPosition, false, this.updateViewableArea);
+ };
+
+ onWindowMouseUp = (event) => {
+ window.removeEventListener('mouseup', this.onWindowMouseUp);
+ if (isShiftKeyDown(event)) return;
+ this.selectEnd();
+ this.clearHorizontalScroll();
+ };
+
+ onCellRangeSelectionUpdated = (selectedRange) => {
+ this.props.onCellRangeSelectionUpdated(selectedRange);
+ };
+
+ /**
+ * When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area
+ * @param {object} selectedRange
+ */
+ updateViewableArea = (selectedRange) => {
+ const { mousePosition } = selectedRange.cursorCell;
+ const { x: mouseX, y: mouseY } = mousePosition;
+ const tableHeaderHeight = 50 + 48 + 32;
+ const interval = 100;
+ const step = 8;
+
+ // cursor is at right boundary
+ if (mouseX + interval > window.innerWidth) {
+ this.scrollToRight();
+ } else if (mouseX - interval < SEQUENCE_COLUMN_WIDTH + this.props.frozenColumnsWidth) {
+ // cursor is at left boundary
+ this.scrollToLeft();
+ } else if (mouseY + interval > window.innerHeight - tableHeaderHeight) {
+ // cursor is at bottom boundary
+ const scrollTop = this.getScrollTop();
+ this.resultContentRef.scrollTop = scrollTop + step;
+ this.clearHorizontalScroll();
+ } else if (mouseY - interval < tableHeaderHeight) {
+ // cursor is at top boundary
+ const scrollTop = this.getScrollTop();
+ if (scrollTop - 16 >= 0) {
+ this.resultContentRef.scrollTop = scrollTop - step;
+ }
+ this.clearHorizontalScroll();
+ } else {
+ // cursor is at middle area
+ this.clearHorizontalScroll();
+ }
+ };
+
+ scrollToRight = () => {
+ if (this.timer) return;
+ this.timer = setInterval(() => {
+ const scrollLeft = this.props.getScrollLeft();
+ this.props.setRecordsScrollLeft(scrollLeft + 20);
+ }, 10);
+ };
+
+ scrollToLeft = () => {
+ if (this.timer) return;
+ this.timer = setInterval(() => {
+ const scrollLeft = this.props.getScrollLeft();
+ if (scrollLeft <= 0) {
+ this.clearHorizontalScroll();
+ return;
+ }
+ this.props.setRecordsScrollLeft(scrollLeft - 20);
+ }, 10);
+ };
+
+ clearHorizontalScroll = () => {
+ if (!this.timer) return;
+ clearInterval(this.timer);
+ this.timer = null;
+ };
+
+ clearScrollbarTimer = () => {
+ if (!this.scrollbarTimer) return;
+ clearTimeout(this.scrollbarTimer);
+ this.scrollbarTimer = null;
+ };
+
+ getCellMetaData = () => {
+ if (this.cellMetaData) {
+ return this.cellMetaData;
+ }
+ this.cellMetaData = {
+ onCellClick: this.onCellClick,
+ onCellDoubleClick: this.onCellDoubleClick,
+ onCellMouseDown: this.onCellMouseDown,
+ onCellMouseEnter: this.onCellMouseEnter,
+ onCellMouseMove: this.onCellMouseMove,
+ onDragEnter: this.handleDragEnter,
+ };
+ return this.cellMetaData;
+ };
+
+ handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DRAG_ENTER, { overRecordIdx, overGroupRecordIndex });
+ };
+
+ setRightScrollbar = (ref) => {
+ this.rightScrollbar = ref;
+ };
+
+ setInteractionMaskRef = (ref) => {
+ this.interactionMask = ref;
+ };
+
+ setResultRef = (ref) => {
+ this.resultRef = ref;
+ };
+
+ setResultContentRef = (ref) => {
+ this.resultContentRef = ref;
+ };
+
+ renderRecords = () => {
+ this.recordFrozenRefs = [];
+ this.frozenBtnAddRecordRefs = [];
+ const {
+ recordsCount, columns, colOverScanStartIdx, colOverScanEndIdx, lastFrozenColumnKey,
+ recordMetrics, showCellColoring, columnColors
+ } = this.props;
+ const { startRenderIndex, endRenderIndex, selectedPosition } = this.state;
+ const cellMetaData = this.getCellMetaData();
+ const lastRecordIndex = recordsCount - 1;
+ const shownRecordIds = this.getShownRecordIds();
+ const scrollLeft = this.props.getScrollLeft();
+ const rowHeight = this.getRowHeight();
+ let shownRecords = shownRecordIds.map((recordId, index) => {
+ const record = this.props.recordGetterById(recordId);
+ const isSelected = RecordMetrics.isRecordSelected(recordId, recordMetrics);
+ const recordIndex = startRenderIndex + index;
+ const isLastRecord = lastRecordIndex === recordIndex;
+ const hasSelectedCell = this.props.hasSelectedCell({ recordIndex }, selectedPosition);
+ const columnColor = showCellColoring ? columnColors[recordId] : {};
+ return (
+ {
+ this.recordFrozenRefs.push(ref);
+ }}
+ isSelected={isSelected}
+ index={recordIndex}
+ isLastRecord={isLastRecord}
+ record={record}
+ columns={columns}
+ colOverScanStartIdx={colOverScanStartIdx}
+ colOverScanEndIdx={colOverScanEndIdx}
+ lastFrozenColumnKey={lastFrozenColumnKey}
+ scrollLeft={scrollLeft}
+ height={rowHeight}
+ cellMetaData={cellMetaData}
+ hasSelectedCell={hasSelectedCell}
+ selectedPosition={this.state.selectedPosition}
+ selectNoneCells={this.selectNoneCells}
+ onSelectRecord={this.props.onSelectRecord}
+ onRowExpand={this.onRowExpand}
+ modifyRecord={this.props.modifyRecord}
+ searchResult={this.props.searchResult}
+ columnColor={columnColor}
+ />
+ );
+ });
+
+ const upperHeight = startRenderIndex * ROW_HEIGHT;
+ const belowHeight = (recordsCount - endRenderIndex) * ROW_HEIGHT;
+ // add top placeholder
+ if (upperHeight > 0) {
+ const style = { height: upperHeight, width: '100%' };
+ const upperRow =
;
+ shownRecords.unshift(upperRow);
+ }
+ // add bottom placeholder
+ if (belowHeight > 0) {
+ const style = { height: belowHeight, width: '100%' };
+ const belowRow =
;
+ shownRecords.push(belowRow);
+ }
+
+ return shownRecords;
+ };
+
+ render() {
+ // const { isContextMenuShow, menuPosition, activeRecords } = this.state;
+ return (
+
+
+
+
+ {this.renderRecords()}
+
+
+
+
+ );
+ }
+}
+
+RecordsBody.propTypes = {
+ onRef: PropTypes.func,
+ canAddRow: PropTypes.bool,
+ gridUtils: PropTypes.object,
+ table: PropTypes.object,
+ recordIds: PropTypes.array,
+ recordsCount: PropTypes.number,
+ columns: PropTypes.array.isRequired,
+ colOverScanStartIdx: PropTypes.number,
+ colOverScanEndIdx: PropTypes.number,
+ lastFrozenColumnKey: PropTypes.string,
+ hasSelectedRecord: PropTypes.bool,
+ recordMetrics: PropTypes.object,
+ totalWidth: PropTypes.number,
+ getScrollLeft: PropTypes.func,
+ setRecordsScrollLeft: PropTypes.func,
+ hasSelectedCell: PropTypes.func,
+ cacheScrollTop: PropTypes.func,
+ scrollToLoadMore: PropTypes.func,
+ getTableContentLeft: PropTypes.func,
+ getMobileFloatIconStyle: PropTypes.func,
+ onToggleMobileMoreOperations: PropTypes.func,
+ onToggleInsertRecordDialog: PropTypes.func,
+ onDeleteRecords: PropTypes.func,
+ duplicateRecord: PropTypes.func,
+ duplicateRecords: PropTypes.func,
+ lockRecordViaButton: PropTypes.func,
+ modifyRecordViaButton: PropTypes.func,
+ editorPortalTarget: PropTypes.instanceOf(Element),
+ recordGetterByIndex: PropTypes.func,
+ recordGetterById: PropTypes.func,
+ modifyRecord: PropTypes.func.isRequired,
+ selectNone: PropTypes.func,
+ onCellClick: PropTypes.func,
+ onCellRangeSelectionUpdated: PropTypes.func,
+ onSelectRecord: PropTypes.func,
+ updateRecords: PropTypes.func,
+ deleteRecordsLinks: PropTypes.func,
+ paste: PropTypes.func,
+ searchResult: PropTypes.object,
+ scrollToRowIndex: PropTypes.number,
+ tableContentWidth: PropTypes.number,
+ frozenColumnsWidth: PropTypes.number,
+ editMobileCell: PropTypes.func,
+ insertRecords: PropTypes.func,
+ reloadRecords: PropTypes.func,
+ appPage: PropTypes.object,
+ showCellColoring: PropTypes.bool,
+ columnColors: PropTypes.object,
+ onFillingDragRows: PropTypes.func,
+ getCopiedRecordsAndColumnsFromRange: PropTypes.func,
+ openDownloadFilesDialog: PropTypes.func,
+ cacheDownloadFilesProps: PropTypes.func,
+ onRowExpand: PropTypes.func,
+};
+
+export default RecordsBody;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx
new file mode 100644
index 0000000000..9f6ff01bae
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-group-body.jsx
@@ -0,0 +1,1040 @@
+import React, { Component, Fragment } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { CellType } from '../../../../_basic';
+import GroupContainer from './group-widgets/group-container';
+import InteractionMasks from '../../table-masks/interaction-masks';
+import { RightScrollbar } from '../../../scrollbar';
+import Record from './record';
+import { createGroupMetrics, getGroupRecordByIndex, isNestedGroupRow } from '../../../../utils/group-metrics';
+import RecordMetrics from '../../../../utils/record-metrics';
+import { isColumnSupportDirectEdit, isColumnSupportEdit } from '../../../../utils/column-utils';
+import { isShiftKeyDown } from '../../../../utils/keyboard-utils';
+import { isSelectedCellSupportOpenEditor } from '../../../../utils/selected-cell-utils';
+import { getColumnScrollPosition, getColVisibleEndIdx, getColVisibleStartIdx } from '../../../../utils/records-body-utils';
+import { isFrozen, isNameColumn } from '../../../../utils/column-utils';
+import { GROUP_HEADER_HEIGHT, GROUP_ROW_TYPE, GROUP_VIEW_OFFSET, SEQUENCE_COLUMN_WIDTH, EVENT_BUS_TYPE } from '../../../../constants';
+import { addClassName, removeClassName } from '../../../../utils';
+
+const ROW_HEIGHT = 32;
+const GROUP_OVERSCAN_ROWS = 10;
+const MAX_ANIMATION_ROWS = 50;
+const LOCAL_FOLDED_GROUP_KEY = 'path_folded_group';
+const { max, min } = Math;
+
+class RecordsGroupBody extends Component {
+
+ static defaultProps = {
+ editorPortalTarget: document.body,
+ scrollToRowIndex: 0,
+ };
+
+ constructor(props) {
+ super(props);
+ const { groups, groupbys, allColumns } = props;
+ const rowHeight = this.getRowHeight();
+ const pathFoldedGroupMap = this.getFoldedGroups();
+ const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false);
+ const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(window.innerHeight, 0, groupMetrics, rowHeight);
+ this.state = {
+ isContextMenuShow: false,
+ activeRecords: [],
+ menuPosition: null,
+ groupMetrics,
+ startRenderIndex,
+ endRenderIndex,
+ pathFoldedGroupMap,
+ isScrollingRightScrollbar: false,
+ selectedPosition: null,
+ };
+ this.groupsNode = {};
+ this.recordFrozenRefs = [];
+ this.frozenBtnAddRecordRefs = [];
+ this.rowVisibleStart = startRenderIndex;
+ this.rowVisibleEnd = endRenderIndex;
+ this.columnVisibleStart = 0;
+ this.columnVisibleEnd = this.setColumnVisibleEnd();
+ this.disabledAnimation = false;
+ this.nextPathFoldedGroupMap = null;
+ }
+
+ componentDidMount() {
+ window.sfMetadataBody = this;
+ window.addEventListener('resize', this.onResize);
+ this.props.onRef(this);
+ this.unSubscribeCollapseAllGroups = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.COLLAPSE_ALL_GROUPS, this.collapseAllGroups);
+ this.unSubscribeExpandAllGroups = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.EXPAND_ALL_GROUPS, this.expandAllGroups);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { groupbys, groups, allColumns, searchResult } = this.props;
+ const { scrollTop } = this.resultContentRef;
+ const rowHeight = this.getRowHeight();
+ if (
+ groupbys !== prevProps.groupbys ||
+ groups !== prevProps.groups ||
+ searchResult !== prevProps.searchResult
+ ) {
+ const gridHeight = window.innerHeight;
+ const { matchedCells } = searchResult || {};
+ const pathFoldedGroupMap = Array.isArray(matchedCells) && matchedCells.length > 0 ? {} : this.getFoldedGroups();
+ const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false);
+ this.updateScroll({ gridHeight, scrollTop, groupMetrics, rowHeight });
+ }
+ if (this.disabledAnimation) {
+ this.ableRecordsAnimation();
+ }
+ if (this.expandingGroupPathString) {
+ const groupMetrics = createGroupMetrics(groups, groupbys, this.nextPathFoldedGroupMap, allColumns, rowHeight, false);
+ this.updateScroll({ scrollTop, groupMetrics, pathFoldedGroupMap: this.nextPathFoldedGroupMap });
+ this.expandingGroupPathString = null;
+ this.nextPathFoldedGroupMap = null;
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.onResize);
+ this.unSubscribeCollapseAllGroups();
+ this.unSubscribeExpandAllGroups();
+
+ this.clearHorizontalScroll();
+ this.clearScrollbarTimer();
+ this.setState = (state, callback) => {
+ return;
+ };
+ }
+
+ getShownRecords = () => {
+ const { startRenderIndex, endRenderIndex, groupMetrics } = this.state;
+ const visibleGroupRows = this.getVisibleGroupRecords(startRenderIndex, endRenderIndex, groupMetrics.groupRows);
+ return visibleGroupRows.map(groupRow => this.props.recordGetterById(groupRow.rowId)).filter(row => !!row);
+ };
+
+ getGroupVisibleBoundaries = (gridHeight, scrollTop, groupMetrics, rowHeight) => {
+ const { groupRows, groupRowsHeight, maxLevel } = groupMetrics;
+ if (!Array.isArray(groupRows) || groupRows.length === 0) {
+ return { startRenderIndex: 0, endRenderIndex: 0 };
+ }
+ let startRenderIndex = 0;
+ let endRenderIndex = 0;
+ const GROUP_TOP_OFFSET = GROUP_HEADER_HEIGHT * maxLevel + GROUP_OVERSCAN_ROWS * rowHeight;
+ const GROUP_BOTTOM_OFFSET = GROUP_HEADER_HEIGHT * maxLevel + GROUP_OVERSCAN_ROWS * rowHeight;
+ const overScanStartTop = max(0, scrollTop - GROUP_TOP_OFFSET);
+ const overScanEndTop = min(groupRowsHeight, scrollTop + gridHeight + GROUP_BOTTOM_OFFSET);
+ const groupRowsLen = groupRows.length;
+ for (let i = 0; i < groupRowsLen; i++) {
+ const groupRow = groupRows[i];
+ const { top } = groupRow;
+ if (top <= overScanStartTop) {
+ startRenderIndex++;
+ }
+ if (top <= overScanEndTop) {
+ endRenderIndex++;
+ }
+ }
+ return { startRenderIndex, endRenderIndex };
+ };
+
+ setGroupNode = (groupPathString) => node => {
+ this.groupsNode[groupPathString] = node;
+ };
+
+ setResultContentRef = (ref) => {
+ this.resultContentRef = ref;
+ };
+
+ setInteractionMaskRef = (ref) => {
+ this.interactionMask = ref;
+ };
+
+ setResultRef = (ref) => {
+ this.resultRef = ref;
+ };
+
+ setScrollTop = (scrollTop) => {
+ this.resultContentRef.scrollTop = scrollTop;
+ };
+
+ setScrollLeft = (scrollLeft) => {
+ this.interactionMask && this.interactionMask.setScrollLeft(scrollLeft);
+ };
+
+ cancelSetScrollLeft = () => {
+ this.interactionMask && this.interactionMask.cancelSetScrollLeft();
+ };
+
+ setRightScrollbar = (ref) => {
+ this.rightScrollbar = ref;
+ };
+
+ setColumnVisibleEnd = () => {
+ const { columns, getScrollLeft, tableContentWidth } = this.props;
+ let columnVisibleEnd = 0;
+ const contentScrollLeft = getScrollLeft();
+ let endColumnWidth = tableContentWidth + contentScrollLeft;
+ for (let i = 0; i < columns.length; i ++) {
+ const { width } = columns[i];
+ endColumnWidth = endColumnWidth - width;
+ if (endColumnWidth < 0) {
+ return columnVisibleEnd = i;
+ }
+ }
+ return columnVisibleEnd;
+ };
+
+ getScrollTop = () => {
+ return this.resultContentRef ? this.resultContentRef.scrollTop : 0;
+ };
+
+ getRowHeight = () => {
+ return ROW_HEIGHT;
+ };
+
+ getRowTop = (groupRecordIndex) => {
+ const { groupMetrics } = this.state;
+ const groupRow = getGroupRecordByIndex(groupRecordIndex, groupMetrics);
+ if (!groupRow) {
+ return 0;
+ }
+ return groupRow.top || 0;
+ };
+
+ jumpToRow = (scrollToGroupRecordIndex) => {
+ const { groupMetrics } = this.state;
+ const height = this.resultContentRef.offsetHeight;
+ const groupRecordTop = this.getRowTop(scrollToGroupRecordIndex);
+ const scrollTop = Math.min(groupRecordTop, groupMetrics.groupRowsHeight - height);
+ this.setScrollTop(scrollTop);
+ };
+
+ scrollToColumn = (idx) => {
+ const { columns, tableContentWidth } = this.props;
+ const newScrollLeft = getColumnScrollPosition(columns, idx, tableContentWidth);
+ if (newScrollLeft !== null) {
+ this.props.setRecordsScrollLeft(newScrollLeft);
+ }
+ this.updateColVisibleIndex(newScrollLeft);
+ };
+
+ updateColVisibleIndex = (scrollLeft) => {
+ const { columns } = this.props;
+ const columnVisibleStart = getColVisibleStartIdx(columns, scrollLeft);
+ const columnVisibleEnd = getColVisibleEndIdx(columns, window.innerWidth, scrollLeft);
+ this.columnVisibleStart = columnVisibleStart;
+ this.columnVisibleEnd = columnVisibleEnd;
+ };
+
+ getRecordBodyHeight = () => {
+ return this.resultContentRef ? this.resultContentRef.offsetHeight : 0;
+ };
+
+ /**
+ * When updating the selection by moving the mouse, you need to automatically scroll to expand the visible area
+ * @param {object} selectedRange
+ */
+ updateViewableArea = (selectedRange) => {
+ const { mousePosition } = selectedRange.cursorCell;
+ const { x: mouseX, y: mouseY } = mousePosition;
+ const tableHeaderHeight = 50 + 48 + 32;
+ const interval = 100;
+ const step = 8;
+
+ // cursor is at right boundary
+ if (mouseX + interval > window.innerWidth) {
+ this.scrollToRight();
+ } else if (mouseX - interval < SEQUENCE_COLUMN_WIDTH + this.props.frozenColumnsWidth) {
+ // cursor is at left boundary
+ this.scrollToLeft();
+ } else if (mouseY + interval > window.innerHeight - tableHeaderHeight) {
+ // cursor is at bottom boundary
+ const scrollTop = this.getScrollTop();
+ this.resultContentRef.scrollTop = scrollTop + step;
+ this.clearHorizontalScroll();
+ } else if (mouseY - interval < tableHeaderHeight) {
+ // cursor is at top boundary
+ const scrollTop = this.getScrollTop();
+ if (scrollTop - 16 >= 0) {
+ this.resultContentRef.scrollTop = scrollTop - step;
+ }
+ this.clearHorizontalScroll();
+ } else {
+ // cursor is at middle area
+ this.clearHorizontalScroll();
+ }
+ };
+
+ scrollToRight = () => {
+ if (this.scrollTimer) return;
+ this.scrollTimer = setInterval(() => {
+ const scrollLeft = this.props.getScrollLeft();
+ this.props.setRecordsScrollLeft(scrollLeft + 20);
+ }, 10);
+ };
+
+ scrollToLeft = () => {
+ if (this.scrollTimer) return;
+ this.scrollTimer = setInterval(() => {
+ const scrollLeft = this.props.getScrollLeft();
+ if (scrollLeft <= 0) {
+ this.clearHorizontalScroll();
+ return;
+ }
+ this.props.setRecordsScrollLeft(scrollLeft - 20);
+ }, 10);
+ };
+
+ clearHorizontalScroll = () => {
+ if (!this.scrollTimer) return;
+ clearInterval(this.scrollTimer);
+ this.scrollTimer = null;
+ };
+
+ clearScrollbarTimer = () => {
+ if (!this.scrollbarTimer) return;
+ clearTimeout(this.scrollbarTimer);
+ this.scrollbarTimer = null;
+ };
+
+ getCellMetaData = () => {
+ if (this.cellMetaData) {
+ return this.cellMetaData;
+ }
+ this.cellMetaData = {
+ onCellClick: this.onCellClick,
+ onCellDoubleClick: this.onCellDoubleClick,
+ onCellMouseDown: this.onCellMouseDown,
+ onCellMouseEnter: this.onCellMouseEnter,
+ onCellMouseMove: this.onCellMouseMove,
+ onDragEnter: this.handleDragEnter,
+ };
+ return this.cellMetaData;
+ };
+
+ handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.DRAG_ENTER, { overRecordIdx, overGroupRecordIndex });
+ };
+
+ getGroupMetrics = () => {
+ return this.state.groupMetrics;
+ };
+
+ getGroupRecordByIndex = (groupRecordIndex) => {
+ const groupMetrics = this.getGroupMetrics();
+ return getGroupRecordByIndex(groupRecordIndex, groupMetrics);
+ };
+
+ fixFrozenDoms = (scrollLeft, scrollTop) => {
+ if (!isFrozen(this.props.columns[0]) && scrollLeft === 0) {
+ return;
+ }
+ Object.keys(this.groupsNode).forEach((groupIdx) => {
+ const groupNode = this.groupsNode[groupIdx];
+ if (!groupNode) {
+ return;
+ }
+ groupNode.fixedFrozenDOMs(scrollLeft, scrollTop);
+ });
+ };
+
+ cancelFixFrozenDOMs = (scrollLeft) => {
+ if (!isFrozen(this.props.columns[0]) && scrollLeft === 0) {
+ return;
+ }
+ if (this.groupsNode) {
+ Object.keys(this.groupsNode).forEach((groupPathString) => {
+ const groupNode = this.groupsNode[groupPathString];
+ if (!groupNode) {
+ return;
+ }
+ groupNode.cancelFixFrozenDOMs(scrollLeft);
+ });
+ }
+ };
+
+ onResize = () => {
+ const gridHeight = window.innerHeight;
+ if (!gridHeight) {
+ return;
+ }
+ const { scrollTop } = this.resultContentRef;
+ const rowHeight = this.getRowHeight();
+ this.updateScroll({ gridHeight, scrollTop, rowHeight });
+ };
+
+ onScroll = () => {
+ const { offsetHeight, scrollTop: contentScrollTop } = this.resultContentRef;
+ this.oldScrollTop = contentScrollTop;
+
+ this.props.cacheScrollTop(contentScrollTop);
+
+ this.updateScroll({ scrollTop: contentScrollTop });
+
+ // Scroll to the bottom of the page, load more records
+ if (offsetHeight + contentScrollTop >= this.resultContentRef.scrollHeight) {
+ this.props.scrollToLoadMore();
+ }
+
+ if (!this.isScrollingRightScrollbar) {
+ this.setRightScrollbarScrollTop(this.oldScrollTop);
+ }
+
+ // solve the bug that the scroll bar disappears when scrolling too fast
+ this.clearScrollbarTimer();
+ this.scrollbarTimer = setTimeout(() => {
+ this.setState({ isScrollingRightScrollbar: false });
+ }, 300);
+ };
+
+ onRowExpand = (record) => {
+ this.props.expandRow(this.props.table, this.props.columns, record);
+ };
+
+ setRightScrollbarScrollTop = (scrollTop) => {
+ this.rightScrollbar && this.rightScrollbar.setScrollTop(scrollTop);
+ };
+
+ onScrollbarScroll = (scrollTop) => {
+ // solve canvas&rightScrollbar circle scroll problem
+ if (this.oldScrollTop === scrollTop) {
+ return;
+ }
+ this.setState({ isScrollingRightScrollbar: true }, () => {
+ this.setScrollTop(scrollTop);
+ });
+ };
+
+ onScrollbarMouseUp = () => {
+ this.setState({ isScrollingRightScrollbar: false });
+ };
+
+ onCellClick = (cell, e) => {
+ const { selectedPosition } = this.state;
+ if (isShiftKeyDown(e)) {
+ if (!selectedPosition || selectedPosition.idx === -1) {
+ this.selectCell(cell, false);
+ return;
+ }
+ const isFromKeyboard = true;
+ this.selectUpdate(cell, isFromKeyboard);
+ } else {
+ const { columns } = this.props;
+ const supportOpenEditor = isColumnSupportDirectEdit(cell, columns);
+ const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, true, this.props.recordGetterByIndex);
+ this.selectCell(cell, supportOpenEditor && hasOpenPermission);
+ }
+ this.props.onCellClick(cell);
+ this.setState({ selectedPosition: cell });
+ };
+
+ onCellDoubleClick = (cell, e) => {
+ const { columns } = this.props;
+ const supportOpenEditor = isColumnSupportEdit(cell, columns);
+ const hasOpenPermission = isSelectedCellSupportOpenEditor(cell, columns, true, this.props.recordGetterByIndex);
+ this.selectCell(cell, supportOpenEditor && hasOpenPermission);
+ };
+
+ onCellMouseDown = (cellPosition, event) => {
+ if (!isShiftKeyDown(event)) {
+ this.selectCell(cellPosition);
+ this.selectStart(cellPosition);
+ window.addEventListener('mouseup', this.onWindowMouseUp);
+ }
+ };
+
+ // onRangeSelectUpdate
+ onCellMouseEnter = (cellPosition) => {
+ this.selectUpdate(cellPosition, false, this.updateViewableArea);
+ };
+
+ onCellMouseMove = (cellPosition) => {
+ this.selectUpdate(cellPosition, false, this.updateViewableArea);
+ };
+
+ onWindowMouseUp = (event) => {
+ window.removeEventListener('mouseup', this.onWindowMouseUp);
+ if (isShiftKeyDown(event)) return;
+ this.selectEnd();
+ this.clearHorizontalScroll();
+ };
+
+ onCellRangeSelectionUpdated = (selectedRange) => {
+ this.props.onCellRangeSelectionUpdated(selectedRange);
+ };
+
+ selectNoneCells = () => {
+ this.interactionMask && this.interactionMask.selectNone();
+ const { selectedPosition } = this.state;
+ if (!selectedPosition || selectedPosition.idx < 0 || selectedPosition.rowIdx < 0) {
+ return;
+ }
+ this.selectNone();
+ };
+
+ selectNone = () => {
+ this.setState({ selectedPosition: { idx: -1, rowIdx: -1, groupRecordIndex: -1 } });
+ };
+
+ selectCell = (cell, openEditor) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_CELL, cell, openEditor);
+ };
+
+ selectStart = (cellPosition) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_START, cellPosition);
+ };
+
+ selectUpdate = (cellPosition, isFromKeyboard, callback) => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_UPDATE, cellPosition, isFromKeyboard, callback);
+ };
+
+ selectEnd = () => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_END);
+ };
+
+ onCloseContextMenu = () => {
+ this.setState({
+ isContextMenuShow: false,
+ menuPosition: null,
+ activeRecords: [],
+ });
+ };
+
+ onDeleteRecords = () => {
+ this.interactionMask && this.interactionMask.selectNone();
+ this.props.selectNone();
+ this.props.onDeleteRecords(this.state.activeRecords);
+ };
+
+ onInsertRecords = ({ insertRecordsNumber }) => {
+ const activeRecord = this.state.activeRecords[0];
+ const upperRecordId = activeRecord && activeRecord._id;
+ if (!upperRecordId) return;
+ this.props.insertRecords({ upperRecordId, insertRecordsNumber });
+ };
+
+ addBlankRecord = (groupRecordIndex) => {
+ const { groupMetrics } = this.state;
+ const groupRecord = groupRecordIndex > 0 && getGroupRecordByIndex(groupRecordIndex - 1, groupMetrics);
+ this.props.insertRecords({
+ upperRecordId: groupRecord && groupRecord.rowId,
+ insertRecordsNumber: 1
+ });
+ };
+
+ onDuplicateRecord = () => {
+ this.props.duplicateRecord(this.state.activeRecords[0]);
+ };
+
+ onDuplicateRecords = () => {
+ this.props.duplicateRecords(this.state.activeRecords);
+ };
+
+ getNextScrollState = ({ gridHeight, scrollTop, rowHeight, groupMetrics, pathFoldedGroupMap }) => {
+ const _gridHeight = gridHeight || window.innerHeight;
+ const _rowHeight = rowHeight || this.getRowHeight();
+ const updatedGroupMetrics = groupMetrics || this.state.groupMetrics;
+ const updatedPathFoldedGroupMap = pathFoldedGroupMap || this.state.pathFoldedGroupMap;
+ const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(_gridHeight, scrollTop, updatedGroupMetrics, _rowHeight);
+ return {
+ startRenderIndex,
+ endRenderIndex,
+ groupMetrics: updatedGroupMetrics,
+ pathFoldedGroupMap: updatedPathFoldedGroupMap,
+ };
+ };
+
+ updateScroll = (scrollParams) => {
+ const { startRenderIndex, endRenderIndex, ...scrollArgs } = scrollParams;
+ let nextScrollState = this.getNextScrollState(scrollArgs);
+ if (startRenderIndex && endRenderIndex) {
+ nextScrollState.startRenderIndex = startRenderIndex;
+ nextScrollState.endRenderIndex = endRenderIndex;
+ }
+ this.setState(nextScrollState);
+ return nextScrollState;
+ };
+
+ isParentGroupContainer = (currentGroupRow, targetGroupRow) => {
+ const { groupPath: currentGroupPath, level: currentGroupLevel, type: currentGroupRowType } = currentGroupRow;
+ const { groupPath: targetGroupPath, level: targetGroupLevel } = targetGroupRow;
+ return currentGroupRowType === GROUP_ROW_TYPE.GROUP_CONTAINER &&
+ currentGroupLevel > targetGroupLevel && currentGroupPath[0] === targetGroupPath[0];
+ };
+
+ getPrevGroupContainers = (currentGroupRow, groupRows, maxLevel) => {
+ if (!currentGroupRow) {
+ return [];
+ }
+ const { level, groupRowIndex, type } = currentGroupRow;
+ if (groupRowIndex === 0 || (level === maxLevel && type === GROUP_ROW_TYPE.GROUP_CONTAINER)) {
+ return [];
+ }
+ let prevGroupContainers = [];
+ let prevGroupRowIndex = groupRowIndex - 1;
+ while (prevGroupRowIndex > -1) {
+ const prevGroupRow = groupRows[prevGroupRowIndex];
+ const { type: preGroupRowType, level: prevGroupRowLevel } = prevGroupRow;
+ if (preGroupRowType === GROUP_ROW_TYPE.GROUP_CONTAINER) {
+ // first level group.
+ if (level === maxLevel) {
+ prevGroupContainers.push(prevGroupRow);
+ break;
+ }
+
+ // multiple level group.
+ if (this.isParentGroupContainer(prevGroupRow, currentGroupRow)) {
+ prevGroupContainers.unshift(prevGroupRow);
+ }
+
+ if (prevGroupRowLevel === maxLevel) {
+ break;
+ }
+ }
+ prevGroupRowIndex--;
+ }
+ return prevGroupContainers;
+ };
+
+ getVisibleGroupRecords = (startRenderIndex, endRenderIndex, groupRows) => {
+ const visibleGroupRows = [];
+ const overScanStartGroupRow = groupRows[startRenderIndex];
+ const maxLevel = this.props.groupbys.length;
+
+ // If first visible group is nested in the previous group, then the previous group container also needs to be rendered.
+ const prevGroupContainers = this.getPrevGroupContainers(overScanStartGroupRow, groupRows, maxLevel);
+ visibleGroupRows.push(...prevGroupContainers);
+ let i = startRenderIndex;
+ let rows = [];
+ while (i <= endRenderIndex) {
+ let groupRow = groupRows[i];
+ if (groupRow && groupRow.visible) {
+ visibleGroupRows.push(groupRow);
+ if (groupRow.type === GROUP_ROW_TYPE.ROW) {
+ rows.push(groupRow);
+ }
+ }
+ i++;
+ }
+ return visibleGroupRows;
+ };
+
+ getFoldedGroups = () => {
+ const localPageConfigs = this.props.getLocalPageConfigs();
+ if (!localPageConfigs) {
+ return {};
+ }
+ return localPageConfigs[LOCAL_FOLDED_GROUP_KEY] || {};
+ };
+
+ getVisibleIndex = () => {
+ return { rowVisibleStartIdx: this.rowVisibleStart, rowVisibleEndIdx: this.rowVisibleEnd };
+ };
+
+ updateFoldedGroups = (pathFoldedGroupMap) => {
+ let localPageConfigs = this.props.getLocalPageConfigs();
+ localPageConfigs[LOCAL_FOLDED_GROUP_KEY] = pathFoldedGroupMap;
+ this.props.setLocalPageConfigs(localPageConfigs);
+ this.selectNoneCells();
+ };
+
+ collapseAllGroups = () => {
+ const { groupMetrics } = this.state;
+ const { groupRows } = groupMetrics;
+ let pathFoldedGroupMap = {};
+ groupRows.forEach(groupRow => {
+ const { type, groupPathString } = groupRow;
+ if (type !== GROUP_ROW_TYPE.GROUP_CONTAINER) {
+ return;
+ }
+ pathFoldedGroupMap[groupPathString] = true;
+ });
+ this.updateFoldedGroups(pathFoldedGroupMap);
+ const { groups, groupbys, allColumns } = this.props;
+ const rowHeight = this.getRowHeight();
+ const { scrollTop } = this.resultContentRef;
+ const nextGroupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false);
+ this.updateScroll({ scrollTop, rowHeight, groupMetrics: nextGroupMetrics });
+ };
+
+ expandAllGroups = () => {
+ const pathFoldedGroupMap = {};
+ this.updateFoldedGroups(pathFoldedGroupMap);
+ const { groups, groupbys, allColumns } = this.props;
+ const { scrollTop } = this.resultContentRef;
+ const rowHeight = this.getRowHeight();
+ const groupMetrics = createGroupMetrics(groups, groupbys, pathFoldedGroupMap, allColumns, rowHeight, false);
+ this.updateScroll({ scrollTop, rowHeight, groupMetrics });
+ };
+
+ onExpandGroupToggle = (groupPathString) => {
+ const { groupMetrics, pathFoldedGroupMap } = this.state;
+ const { groupRows, maxLevel } = groupMetrics;
+ const groupContainerRow = groupRows.find(groupRow => groupRow.groupPathString === groupPathString && groupRow.type === GROUP_ROW_TYPE.GROUP_CONTAINER);
+ if (!groupContainerRow) return;
+ const { groupRowIndex: operatedGroupRowIndex, groupPath: operatedGroupPath, height: operatedGroupRowHeight, isExpanded } = groupContainerRow;
+ let updatedPathFoldedGroupMap = { ...pathFoldedGroupMap };
+ if (isExpanded) {
+ updatedPathFoldedGroupMap[groupPathString] = true;
+ } else {
+ delete updatedPathFoldedGroupMap[groupPathString];
+ }
+
+ const { groups, groupbys, allColumns } = this.props;
+ const { scrollTop } = this.resultContentRef;
+ const rowHeight = this.getRowHeight();
+ const recalculatedGroupMetrics = createGroupMetrics(groups, groupbys, updatedPathFoldedGroupMap, allColumns, rowHeight, false);
+
+ // expand/fold group directly if the records exceed the maximum number of records supported.
+ if (groupContainerRow.count >= MAX_ANIMATION_ROWS) {
+ this.forbidRecordsAnimation();
+ this.updateFoldedGroups(updatedPathFoldedGroupMap);
+ this.updateScroll({ scrollTop, rowHeight, groupMetrics: recalculatedGroupMetrics, pathFoldedGroupMap: updatedPathFoldedGroupMap });
+ return;
+ }
+
+ const { startRenderIndex, endRenderIndex } = this.getGroupVisibleBoundaries(window.innerHeight, scrollTop, recalculatedGroupMetrics, rowHeight);
+ let newGroupMetrics;
+ if (isExpanded) {
+ newGroupMetrics = groupMetrics;
+ let newGroupRows = newGroupMetrics.groupRows;
+ if (maxLevel > 1) {
+ // update the parent group container.
+ const increment = -(operatedGroupRowHeight - GROUP_HEADER_HEIGHT);
+ for (let i = operatedGroupRowIndex - 1; i > -1; i--) {
+ let updatedGroupRow = newGroupRows[i];
+ const updatedGroupPath = updatedGroupRow.groupPath;
+ if (this.isParentGroupContainer(updatedGroupRow, groupContainerRow)) {
+ updatedGroupRow.height = updatedGroupRow.height + increment;
+ }
+ if (updatedGroupPath[0] !== operatedGroupPath[0]) {
+ break;
+ }
+ }
+ }
+
+ // update the group container/record which nested in the folding group.
+ for (let i = operatedGroupRowIndex + 1; i < newGroupRows.length; i++) {
+ let updatedGroupRow = newGroupRows[i];
+ const updatedGroupPath = updatedGroupRow.groupPath;
+ if (isNestedGroupRow(updatedGroupRow, groupContainerRow)) {
+ updatedGroupRow.visible = false;
+ }
+ if (updatedGroupPath[0] !== operatedGroupPath[0]) {
+ break;
+ }
+ }
+ newGroupRows[operatedGroupRowIndex] = { ...newGroupRows[operatedGroupRowIndex], isExpanded: false, height: GROUP_HEADER_HEIGHT };
+ } else {
+ newGroupMetrics = recalculatedGroupMetrics;
+ let newGroupRows = newGroupMetrics.groupRows;
+
+ // update the group container/record which nested in the expanding group.
+ const newTop = groupContainerRow.top + GROUP_HEADER_HEIGHT;
+ for (let i = operatedGroupRowIndex + 1; i < newGroupRows.length; i++) {
+ let updatedGroupRow = newGroupRows[i];
+ const updatedGroupPath = updatedGroupRow.groupPath;
+ if (isNestedGroupRow(updatedGroupRow, groupContainerRow)) {
+ updatedGroupRow.height = 0;
+ updatedGroupRow.top = newTop;
+ }
+ if (updatedGroupPath[0] !== operatedGroupPath[0]) {
+ break;
+ }
+ }
+ }
+ this.expandingGroupPathString = groupPathString;
+ this.nextPathFoldedGroupMap = updatedPathFoldedGroupMap;
+ this.setState({
+ groupMetrics: newGroupMetrics,
+ startRenderIndex,
+ endRenderIndex,
+ });
+ this.updateFoldedGroups(updatedPathFoldedGroupMap);
+ };
+
+ forbidRecordsAnimation = () => {
+ this.disabledAnimation = true;
+ const originClassName = this.groupRows.className;
+ const newClassName = removeClassName(originClassName, 'animation');
+ if (newClassName !== originClassName) {
+ this.groupRows.className = newClassName;
+ }
+ };
+
+ ableRecordsAnimation = () => {
+ this.disabledAnimation = false;
+ const originClassName = this.groupRows.className;
+ const newClassName = addClassName(originClassName, 'animation');
+ if (newClassName !== originClassName) {
+ this.groupRows.className = newClassName;
+ }
+ };
+
+ openDownloadFilesDialog = () => {
+ const { column, activeRecords } = this.state;
+ this.props.cacheDownloadFilesProps(column, activeRecords);
+ this.props.openDownloadFilesDialog();
+ };
+
+ checkSupportDownloadFiles = () => {
+ const { column } = this.state;
+ const { left, right } = this.interactionMask.getSelectedPosition();
+ const isSelectingMultiColumns = right > left;
+ return !isSelectingMultiColumns && (column.type === CellType.FILE || column.type === CellType.IMAGE);
+ };
+
+ renderGroups = () => {
+ const {
+ totalWidth: columnsWidth, containerWidth, appPage,
+ columns, colOverScanStartIdx, colOverScanEndIdx, groupOffsetLeft,
+ recordMetrics, summaryConfigs, lastFrozenColumnKey, showCellColoring, columnColors,
+ } = this.props;
+ this.recordFrozenRefs = [];
+ this.frozenBtnAddRecordRefs = [];
+ const totalColumnsWidth = columnsWidth + SEQUENCE_COLUMN_WIDTH;
+ const { startRenderIndex, endRenderIndex, groupMetrics, selectedPosition } = this.state;
+ const { groupRows, maxLevel } = groupMetrics;
+ const scrollLeft = this.props.getScrollLeft();
+ const cellMetaData = this.getCellMetaData();
+ let visibleGroupRows = this.getVisibleGroupRecords(startRenderIndex, endRenderIndex, groupRows);
+ const rendererGroups = [];
+ const columnsLen = columns.length;
+ const lastColumn = columns[columnsLen - 1];
+ let groupRowsHeight = groupMetrics.groupRowsHeight;
+ visibleGroupRows.forEach(groupRow => {
+ let {
+ type, level, key, left, top, isExpanded, height, groupPathString, groupRowIndex: groupRecordIndex,
+ } = groupRow;
+ if (type === GROUP_ROW_TYPE.GROUP_CONTAINER) {
+ const groupWidth = totalColumnsWidth + (level - 1) * 2 * GROUP_VIEW_OFFSET; // columns + group offset
+ const folding = this.expandingGroupPathString === groupPathString && !isExpanded;
+ const backdropHeight = height + GROUP_VIEW_OFFSET;
+
+ rendererGroups.push(
+
+ );
+ } else if (type === GROUP_ROW_TYPE.ROW) {
+ const { rowId, rowIdx, isLastRow } = groupRow;
+ const record = rowId && this.props.recordGetterById(rowId);
+ const isSelected = RecordMetrics.isRecordSelected(rowId, recordMetrics);
+ const hasSelectedCell = this.props.hasSelectedCell({ groupRecordIndex }, selectedPosition);
+ const columnColor = showCellColoring ? columnColors[rowId] : {};
+ if (!record) {
+ return;
+ }
+ rendererGroups.push(
+ {
+ this.recordFrozenRefs.push(ref);
+ }}
+ isSelected={isSelected}
+ groupRecordIndex={groupRecordIndex}
+ index={rowIdx}
+ isLastRecord={isLastRow}
+ lastFrozenColumnKey={lastFrozenColumnKey}
+ record={record}
+ columns={columns}
+ colOverScanStartIdx={colOverScanStartIdx}
+ colOverScanEndIdx={colOverScanEndIdx}
+ left={left}
+ top={top}
+ height={height}
+ scrollLeft={scrollLeft}
+ cellMetaData={cellMetaData}
+ searchResult={this.props.searchResult}
+ hasSelectedCell={hasSelectedCell}
+ selectedPosition={this.state.selectedPosition}
+ selectNoneCells={this.selectNoneCells}
+ onSelectRecord={this.props.onSelectRecord}
+ onRowExpand={this.onRowExpand}
+ modifyRecord={this.props.modifyRecord}
+ lockRecordViaButton={this.props.lockRecordViaButton}
+ modifyRecordViaButton={this.props.modifyRecordViaButton}
+ reloadRecords={this.props.reloadRecords}
+ appPage={appPage}
+ columnColor={columnColor}
+ />
+ );
+ }
+ });
+
+ const allColumnsFrozen = lastFrozenColumnKey === lastColumn.key;
+ const groupRowsClassName = classnames(
+ 'canvas-groups-rows', 'animation',
+ {
+ 'single-column': isNameColumn(lastColumn),
+ 'disabled-add-record': true,
+ 'all-columns-frozen': allColumnsFrozen,
+ 'frozen': allColumnsFrozen || !!lastFrozenColumnKey,
+ }
+ );
+ const groupRowsStyle = {
+ height: groupRowsHeight,
+ width: containerWidth + ((maxLevel - 1) * 2 + 1) * GROUP_VIEW_OFFSET, // columns width + groups offset
+ };
+ return (
+ this.groupRows = ref}>
+ {rendererGroups}
+
+ );
+ };
+
+ render() {
+ return (
+
+
+
+
+ {this.renderGroups()}
+
+
+
+ {/* {this.state.isContextMenuShow &&
+
+ } */}
+
+ );
+ }
+
+}
+
+RecordsGroupBody.propTypes = {
+ gridUtils: PropTypes.object,
+ table: PropTypes.object,
+ allColumns: PropTypes.array,
+ columns: PropTypes.array,
+ colOverScanStartIdx: PropTypes.number,
+ colOverScanEndIdx: PropTypes.number,
+ tableContentWidth: PropTypes.number,
+ totalWidth: PropTypes.number,
+ containerWidth: PropTypes.number,
+ groups: PropTypes.array,
+ groupbys: PropTypes.array,
+ recordsCount: PropTypes.number,
+ recordMetrics: PropTypes.object,
+ groupOffsetLeft: PropTypes.number,
+ frozenColumnsWidth: PropTypes.number,
+ summaryConfigs: PropTypes.object,
+ hasSelectedRecord: PropTypes.bool,
+ lastFrozenColumnKey: PropTypes.string,
+ searchResult: PropTypes.object,
+ editorPortalTarget: PropTypes.instanceOf(Element),
+ onRef: PropTypes.func,
+ getScrollLeft: PropTypes.func,
+ setRecordsScrollLeft: PropTypes.func,
+ hasSelectedCell: PropTypes.func,
+ cacheScrollTop: PropTypes.func,
+ scrollToLoadMore: PropTypes.func,
+ getTableContentLeft: PropTypes.func,
+ getMobileFloatIconStyle: PropTypes.func,
+ onToggleMobileMoreOperations: PropTypes.func,
+ onToggleInsertRecordDialog: PropTypes.func,
+ onCellClick: PropTypes.func,
+ onCellRangeSelectionUpdated: PropTypes.func,
+ modifyRecord: PropTypes.func,
+ recordGetterByIndex: PropTypes.func,
+ recordGetterById: PropTypes.func,
+ updateRecords: PropTypes.func,
+ deleteRecordsLinks: PropTypes.func,
+ paste: PropTypes.func,
+ selectNone: PropTypes.func,
+ onSelectRecord: PropTypes.func,
+ expandRow: PropTypes.func,
+ getLocalPageConfigs: PropTypes.func,
+ setLocalPageConfigs: PropTypes.func,
+ duplicateRecord: PropTypes.func,
+ duplicateRecords: PropTypes.func,
+ lockRecordViaButton: PropTypes.func,
+ modifyRecordViaButton: PropTypes.func,
+ onDeleteRecords: PropTypes.func,
+ editMobileCell: PropTypes.func,
+ insertRecords: PropTypes.func,
+ reloadRecords: PropTypes.func,
+ appPage: PropTypes.object,
+ showCellColoring: PropTypes.bool,
+ columnColors: PropTypes.object,
+ getCopiedRecordsAndColumnsFromRange: PropTypes.func,
+ openDownloadFilesDialog: PropTypes.func,
+ cacheDownloadFilesProps: PropTypes.func,
+};
+
+export default RecordsGroupBody;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js
new file mode 100644
index 0000000000..c2faadd8d0
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/records-header.js
@@ -0,0 +1,134 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+ HEADER_HEIGHT_TYPE,
+ isEmptyObject,
+ Z_INDEX,
+} from '../../../../_basic';
+import HeaderCell from './header-cell';
+import HeaderActionsCell from './header-actions-cell';
+import { isMobile } from '../../../../utils';
+import { getFrozenColumns } from '../../../../utils/table-utils';
+import { isFrozen } from '../../../../utils/column-utils';
+import { GRID_HEADER_DEFAULT_HEIGHT, GRID_HEADER_DOUBLE_HEIGHT } from '../../../../constants';
+
+class RecordsHeader extends Component {
+
+ static propTypes = {
+ containerWidth: PropTypes.number,
+ columns: PropTypes.array.isRequired,
+ colOverScanStartIdx: PropTypes.number,
+ colOverScanEndIdx: PropTypes.number,
+ table: PropTypes.object,
+ hasSelectedRecord: PropTypes.bool,
+ isSelectedAll: PropTypes.bool,
+ isGroupView: PropTypes.bool,
+ groupOffsetLeft: PropTypes.number,
+ lastFrozenColumnKey: PropTypes.string,
+ onRef: PropTypes.func,
+ resizeColumnWidth: PropTypes.func,
+ selectNoneRecords: PropTypes.func,
+ selectAllRecords: PropTypes.func,
+ downloadColumnAllFiles: PropTypes.func,
+ };
+
+ getFrozenCells = (height, isHideTriangle) => {
+ const { columns, lastFrozenColumnKey } = this.props;
+ const frozenColumns = getFrozenColumns(columns);
+ return frozenColumns.map(column => {
+ const { key } = column;
+ const style = { backgroundColor: '#f9f9f9' };
+ const isLastFrozenCell = key === lastFrozenColumnKey;
+ return (
+
+ );
+ });
+ };
+
+ getHeaderCells = (height, isHideTriangle) => {
+ const { columns, groupOffsetLeft, colOverScanStartIdx, colOverScanEndIdx } = this.props;
+ const rendererColumns = columns.slice(colOverScanStartIdx, colOverScanEndIdx);
+ return rendererColumns.map(column => {
+ return (
+
+ );
+ });
+ };
+
+ getFrozenWrapperStyle = (height) => {
+ const { isGroupView, columns } = this.props;
+ let style = {
+ position: (isMobile ? 'absolute' : 'fixed'),
+ marginLeft: '0px',
+ height,
+ zIndex: Z_INDEX.SEQUENCE_COLUMN,
+ };
+ if ((isGroupView && !isFrozen(columns[0])) || isMobile) {
+ style.position = 'absolute';
+ }
+ return style;
+ };
+
+ render() {
+ const {
+ containerWidth, hasSelectedRecord, isSelectedAll, lastFrozenColumnKey, groupOffsetLeft, table
+ } = this.props;
+ const headerSettings = table.header_settings || {};
+ const heightMode = isEmptyObject(headerSettings) ? HEADER_HEIGHT_TYPE.DEFAULT : headerSettings.header_height;
+ const isHideTriangle = headerSettings && headerSettings.is_hide_triangle;
+ const height = heightMode === HEADER_HEIGHT_TYPE.DOUBLE ? GRID_HEADER_DOUBLE_HEIGHT : GRID_HEADER_DEFAULT_HEIGHT;
+ const headerHeight = height + 1;
+ const frozenCells = this.getFrozenCells(height, isHideTriangle);
+ const headerCells = this.getHeaderCells(height, isHideTriangle);
+ const headerStyle = {
+ width: containerWidth,
+ minWidth: '100%',
+ zIndex: Z_INDEX.GRID_HEADER,
+ height
+ };
+ return (
+
+
+ {/* frozen */}
+
{
+ !isMobile && this.props.onRef(ref);
+ }}>
+
+ {frozenCells}
+
+ {/* scroll */}
+ {headerCells}
+
+
+ );
+ }
+}
+
+export default RecordsHeader;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js b/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js
new file mode 100644
index 0000000000..f96a5fc512
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/resize-column-handle.js
@@ -0,0 +1,57 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { debounce } from '../../../../_basic';
+
+class ResizeColumnHandle extends Component {
+
+ componentWillUnmount() {
+ this.cleanUp();
+ }
+
+ cleanUp = () => {
+ window.removeEventListener('mouseup', this.onMouseUp);
+ window.removeEventListener('mousemove', this.onMouseMove);
+ window.removeEventListener('touchend', this.onMouseUp);
+ window.removeEventListener('touchmove', this.onMouseMove);
+ };
+
+ onMouseDown = (e) => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+
+ window.addEventListener('mouseup', this.onMouseUp);
+ window.addEventListener('mousemove', this.onMouseMove);
+ window.addEventListener('touchend', this.onMouseUp);
+ window.addEventListener('touchmove', this.onMouseMove);
+ };
+
+ onMouseUp = (e) => {
+ this.cleanUp();
+ };
+
+ onMouseMove = (e) => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+
+ debounce(this.props.onDrag(e), 100);
+ };
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ResizeColumnHandle.propTypes = {
+ onDrag: PropTypes.func
+};
+
+export default ResizeColumnHandle;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx b/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx
new file mode 100644
index 0000000000..4ada966cd7
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-main/records/select-all.jsx
@@ -0,0 +1,89 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { gettext } from '../../../../../../utils/constants';
+
+class SelectAll extends Component {
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ isSelectedAll: props.isSelectedAll,
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const { isSelectedAll } = this.props;
+ if (isSelectedAll !== prevProps.isSelectedAll) {
+ this.setState({
+ isSelectedAll,
+ });
+ }
+ }
+
+ onToggleSelectAll = (e) => {
+ const { isMobile, hasSelectedRecord } = this.props;
+ const { isSelectedAll } = this.state;
+ if (isMobile) {
+ e.preventDefault();
+ }
+ if (hasSelectedRecord || isSelectedAll) {
+ this.setState({ isSelectedAll: false });
+ this.props.selectNoneRecords();
+ return;
+ }
+ this.setState({ isSelectedAll: true });
+ this.props.selectAllRecords();
+ };
+
+ render() {
+ const { isMobile, hasSelectedRecord } = this.props;
+ const { isSelectedAll } = this.state;
+ const isSelectedParts = hasSelectedRecord && !isSelectedAll;
+ return (
+
+ {isMobile ?
+
:
+ <>
+ {isSelectedParts ?
+
:
+
+ }
+ >
+ }
+
+
+ );
+ }
+}
+
+SelectAll.propTypes = {
+ isMobile: PropTypes.bool,
+ hasSelectedRecord: PropTypes.bool,
+ isSelectedAll: PropTypes.bool,
+ selectNoneRecords: PropTypes.func,
+ selectAllRecords: PropTypes.func,
+};
+
+export default SelectAll;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js
new file mode 100644
index 0000000000..22a90a0328
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/cell-mask.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import PropTypes from 'prop-types';
+
+class CellMask extends React.PureComponent {
+
+ componentDidUpdate() {
+ // Scrolling left and right causes the interface to re-render,
+ // and the style of CellMask is reset and needs to be fixed
+ const dom = ReactDOM.findDOMNode(this);
+ if (dom.style.position === 'fixed') {
+ dom.style.transform = 'none';
+ }
+ }
+
+ getMaskStyle = () => {
+ const { width, height, top, left, zIndex } = this.props;
+ // mask border needs to cover cell border, height and width are increased 1, left and top are decreased 1
+ return {
+ height: height - 1,
+ width: width,
+ zIndex,
+ position: 'absolute',
+ pointerEvents: 'none',
+ transform: `translate(${left}px, ${top}px)`,
+ outline: 0
+ };
+ };
+
+ render() {
+ const { width, height, top, left, zIndex, children, innerRef, ...rest } = this.props;
+ const style = this.getMaskStyle();
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+CellMask.propTypes = {
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ top: PropTypes.number.isRequired,
+ left: PropTypes.number.isRequired,
+ zIndex: PropTypes.number.isRequired,
+ children: PropTypes.node,
+ innerRef: PropTypes.func
+};
+
+export default CellMask;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js
new file mode 100644
index 0000000000..98ae236eda
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-handler.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+function DragHandler({ onDragStart, onDragEnd }) {
+ return (
+
+ );
+}
+
+DragHandler.propTypes = {
+ onDragStart: PropTypes.func.isRequired,
+ onDragEnd: PropTypes.func.isRequired,
+};
+
+export default DragHandler;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js
new file mode 100644
index 0000000000..85f4778cfd
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/drag-mask.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import CellMask from './cell-mask';
+
+function DragMask({ draggedRange, getSelectedRangeDimensions, getSelectedDimensions }) {
+ const { overRecordIdx, bottomRight } = draggedRange;
+ const { idx: endColumnIdx, rowIdx: endRowIdx, groupRowIndex: endGroupRowIndex } = bottomRight;
+ if (overRecordIdx !== null && endRowIdx < overRecordIdx) {
+ const className = 'react-grid-cell-dragged-over-down';
+ let dimensions = getSelectedRangeDimensions(draggedRange);
+ for (let currentRowIdx = endRowIdx + 1; currentRowIdx <= overRecordIdx; currentRowIdx++) {
+ const { height } = getSelectedDimensions({ idx: endColumnIdx, rowIdx: currentRowIdx, groupRowIndex: endGroupRowIndex });
+ dimensions.height += height;
+ }
+ return (
+
+ );
+ }
+ return null;
+}
+
+
+DragMask.propTypes = {
+ draggedRange: PropTypes.object.isRequired,
+ getSelectedRangeDimensions: PropTypes.func.isRequired,
+ getSelectedDimensions: PropTypes.func.isRequired
+};
+
+export default DragMask;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css
new file mode 100644
index 0000000000..49ff89da76
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.css
@@ -0,0 +1,54 @@
+.interaction-mask .rdg-selected {
+ border: 2px solid #66afe9;
+}
+
+.interaction-mask .rdg-selected-range {
+ border: 1px solid #66afe9;
+ background-color: rgba(102, 175, 233, 0.18823529411764706);
+}
+
+.rdg-selected .drag-handle,
+.rdg-selected-range .drag-handle,
+.checkbox-editor-container .drag-handle {
+ pointer-events: auto;
+ position: absolute;
+ bottom: -5px;
+ right: -4px;
+ background: #66afe9;
+ width: 8px;
+ height: 8px;
+ border: 1px solid #fff;
+ border-right: 0px;
+ border-bottom: 0px;
+ cursor: crosshair;
+ cursor: -moz-grab;
+ cursor: -webkit-grab;
+ cursor: grab;
+}
+
+.rdg-selected .drag-handle:hover,
+.rdg-selected-range .drag-handle:hover,
+.checkbox-editor-container .drag-handle:hover {
+ bottom: -8px;
+ right: -7px;
+ background: white;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #66afe9;
+ z-index: 2;
+}
+
+.rdg-selected:hover .drag-handle .glyphicon-arrow-down {
+ display: 'block';
+}
+
+.react-grid-cell-dragged-over-down {
+ border-top-width: 0;
+}
+
+.react-grid-cell-dragged-over-up,
+.react-grid-cell-dragged-over-down {
+ border: 1px dashed black;
+ background: rgba(0, 0, 255, 0.2) !important;
+}
+
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js
new file mode 100644
index 0000000000..946f93c2ed
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/interaction-masks/index.js
@@ -0,0 +1,1191 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import deepCopy from 'deep-copy';
+import { toaster } from '@seafile/sf-metadata-ui-component';
+import {
+ CellType,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+ KeyCodes,
+ isFunction,
+} from '../../../../_basic';
+import { EVENT_BUS_TYPE, TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP, GROUP_ROW_TYPE, TRANSFER_TYPES } from '../../../../constants';
+import {
+ getNewSelectedRange, getSelectedDimensions, selectedRangeIsSingleCell,
+ getSelectedRangeDimensions, getSelectedRow, getSelectedColumn,
+ isSelectedCellEditable, getRecordsFromSelectedRange,
+} from '../../../../utils/selected-cell-utils';
+import { isCtrlKeyHeldDown, isKeyPrintable } from '../../../../utils/keyboard-utils';
+import SelectionRangeMask from '../selection-range-mask';
+import SelectionMask from '../selection-mask';
+import { getFormatRowData } from '../../../../utils/cell-format-utils';
+import RecordMetrics from '../../../../utils/record-metrics';
+import setEventTransfer from '../../../../utils/set-event-transfer';
+import getEventTransfer from '../../../../utils/get-event-transfer';
+import { getGroupRecordByIndex } from '../../../../utils/group-metrics';
+import DragMask from '../drag-mask';
+import DragHandler from '../drag-handler';
+import { gettext } from '../../../../../../utils/constants';
+
+import './index.css';
+
+const READONLY_PREVIEW_COLUMNS = [
+ CellType.LONG_TEXT, CellType.IMAGE, CellType.FILE, CellType.LINK, CellType.DIGITAL_SIGN, CellType.LINK_FORMULA,
+];
+
+const propTypes = {
+ table: PropTypes.object,
+ columns: PropTypes.array,
+ canAddRow: PropTypes.bool,
+ isGroupView: PropTypes.bool,
+ recordsCount: PropTypes.number,
+ recordMetrics: PropTypes.object,
+ groups: PropTypes.array,
+ groupMetrics: PropTypes.object,
+ rowHeight: PropTypes.number,
+ groupOffsetLeft: PropTypes.number,
+ frozenColumnsWidth: PropTypes.number,
+ enableCellSelect: PropTypes.bool,
+ getRowTop: PropTypes.func,
+ scrollTop: PropTypes.number,
+ getScrollLeft: PropTypes.func,
+ getTableContentLeft: PropTypes.func,
+ getMobileFloatIconStyle: PropTypes.func,
+ onToggleMobileMoreOperations: PropTypes.func,
+ onToggleInsertRecordDialog: PropTypes.func,
+ onCellRangeSelectionStarted: PropTypes.func,
+ onCellRangeSelectionUpdated: PropTypes.func,
+ onCellRangeSelectionCompleted: PropTypes.func,
+ selectNone: PropTypes.func,
+ onCheckCellIsEditable: PropTypes.func,
+ editorPortalTarget: PropTypes.instanceOf(Element).isRequired,
+ modifyRecord: PropTypes.func.isRequired,
+ recordGetterByIndex: PropTypes.func,
+ recordGetterById: PropTypes.func,
+ updateRecords: PropTypes.func,
+ deleteRecordsLinks: PropTypes.func,
+ paste: PropTypes.func,
+ editMobileCell: PropTypes.func,
+ getVisibleIndex: PropTypes.func,
+ onHitBottomBoundary: PropTypes.func,
+ onHitTopBoundary: PropTypes.func,
+ onCellClick: PropTypes.func,
+ scrollToColumn: PropTypes.func,
+ setRecordsScrollLeft: PropTypes.func,
+ getGroupCanvasScrollTop: PropTypes.func,
+ setGroupCanvasScrollTop: PropTypes.func,
+ appPage: PropTypes.object,
+ onFillingDragRows: PropTypes.func,
+ onCellsDragged: PropTypes.func,
+ gridUtils: PropTypes.object,
+ getCopiedRecordsAndColumnsFromRange: PropTypes.func,
+};
+class InteractionMasks extends React.Component {
+
+
+ static defaultProps = {
+ enableCellSelect: true,
+ isGroupView: false,
+ groupOffsetLeft: 0,
+ };
+
+ throttle = null;
+
+ constructor(props) {
+ super(props);
+ const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 };
+ this.state = {
+ selectedPosition: initPosition,
+ selectedRange: {
+ topLeft: initPosition,
+ bottomRight: initPosition,
+ startCell: null,
+ cursorCell: null,
+ isDragging: false,
+ },
+ draggedRange: null,
+ isEditorEnabled: false,
+ openEditorMode: '',
+ };
+ this.selectionMask = null;
+ }
+
+ componentDidMount() {
+ this.unsubscribeSelectColumn = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_COLUMN, this.onColumnSelect);
+ this.unsubscribeDragEnter = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.DRAG_ENTER, this.handleDragEnter);
+ this.unsubscribeSelectCell = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_CELL, this.onSelectCell);
+ this.unsubscribeSelectNone = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, this.selectNone);
+ this.unsubscribeSelectStart = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_START, this.onSelectCellRangeStarted);
+ this.unsubscribeSelectUpdate = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_UPDATE, this.onSelectCellRangeUpdated);
+ this.unsubscribeSelectEnd = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_END, this.onSelectCellRangeEnded);
+ this.unsubscribeOpenEditorEvent = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_EDITOR, this.onOpenEditorEvent);
+ this.unsubscribeCloseEditorEvent = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.CLOSE_EDITOR, this.onCloseEditorEvent);
+ this.unsubscribeCopy = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.COPY_CELLS, this.onCopy);
+ this.unsubscribePaste = window.sfMetadataContext.eventBus.subscribe(EVENT_BUS_TYPE.PASTE_CELLS, this.onPaste);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { selectedRange, isEditorEnabled } = this.state;
+ const { selectedRange: prevSelectedRange, isEditorEnabled: prevIsEditorEnabled } = prevState;
+ const isEditorClosed = isEditorEnabled !== prevIsEditorEnabled && !isEditorEnabled;
+ const isSelectedRangeChanged = selectedRange !== prevSelectedRange && (selectedRange.topLeft !== prevSelectedRange.topLeft || selectedRange.bottomRight !== prevSelectedRange.bottomRight);
+ if (isSelectedRangeChanged || isEditorClosed) {
+ this.focus();
+ }
+ }
+
+ componentWillUnmount() {
+ this.unsubscribeSelectColumn();
+ this.unsubscribeSelectCell();
+ this.unsubscribeSelectStart();
+ this.unsubscribeSelectUpdate();
+ this.unsubscribeSelectEnd();
+ this.unsubscribeOpenEditorEvent();
+ this.unsubscribeCloseEditorEvent();
+ this.unsubscribeCopy();
+ this.unsubscribePaste();
+ this.setState = (state, callback) => {
+ return;
+ };
+ }
+
+ onColumnSelect = (column) => {
+ let { columns, isGroupView } = this.props;
+ if (isGroupView) return;
+ let selectColumnIndex = 0;
+ for (let i = 0; i < columns.length; i++) {
+ if (column.key === columns[i].key) {
+ selectColumnIndex = i;
+ break;
+ }
+ }
+ const rowsCount = this.props.recordsCount;
+ this.setState({
+ selectedPosition: { ...this.state.selectedPosition, idx: selectColumnIndex, rowIdx: 0 },
+ selectedRange: {
+ startCell: { idx: selectColumnIndex, rowIdx: 0 },
+ topLeft: { idx: selectColumnIndex, rowIdx: 0 },
+ bottomRight: { idx: selectColumnIndex, rowIdx: rowsCount - 1 },
+ isDragging: false,
+ }
+ });
+ };
+
+ onOpenEditorEvent = (mode) => {
+ this.setState({
+ openEditorMode: mode
+ });
+ this.openEditor();
+ };
+
+ onCloseEditorEvent = () => {
+ if (this.state.isEditorEnabled) {
+ this.closeEditor();
+ }
+ };
+
+ onSelectCell = (cell, openEditor) => {
+ const { selectedPosition, isEditorEnabled } = this.state;
+ const callback = openEditor ? this.openEditor : () => null;
+
+ if (isEditorEnabled) {
+ this.closeEditor();
+ }
+
+ this.setState((prevState) => {
+ const next = { ...selectedPosition, ...cell };
+ if (this.isCellWithinBounds(next)) {
+ return {
+ selectedPosition: next,
+ selectedRange: {
+ topLeft: next,
+ bottomRight: next,
+ startCell: next,
+ cursorCell: next,
+ isDragging: false,
+ }
+ };
+ }
+ return prevState;
+ }, callback);
+ };
+
+ selectNone = () => {
+ const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 };
+ this.setState({
+ selectedPosition: initPosition,
+ selectedRange: {
+ topLeft: initPosition,
+ bottomRight: initPosition,
+ startCell: null,
+ cursorCell: null,
+ },
+ });
+ this.props.selectNone();
+ };
+
+ getSelectedPosition = () => {
+ const { topLeft, bottomRight } = this.state.selectedRange;
+ return {
+ top: topLeft.rowIdx,
+ bottom: bottomRight.rowIdx,
+ left: topLeft.idx,
+ right: bottomRight.idx,
+ };
+ };
+
+ getSelectedRange = () => {
+ return this.state.selectedRange;
+ };
+
+ selectCell = (groupRecordIndex, rowIdx, idx) => {
+ const selectedPosition = { idx, groupRecordIndex, rowIdx };
+ this.setState({
+ selectedPosition,
+ selectedRange: {
+ topLeft: selectedPosition,
+ bottomRight: selectedPosition,
+ startCell: selectedPosition,
+ cursorCell: selectedPosition,
+ },
+ });
+ };
+
+ // onCellSelect || onKeyDown
+ openEditor = (event = null) => {
+ if (this.isSelectedCellIsLongText()) {
+ event && event.stopPropagation();
+ event && event.preventDefault();
+ }
+ const { key } = event || {};
+ const { selectedPosition } = this.state;
+ const { columns } = this.props;
+ const selectedColumn = getSelectedColumn({ selectedPosition, columns });
+ const { type: columnType } = selectedColumn;
+
+ // how to open editors?
+ // 1. editor is closed
+ // 2. record-cell is editable or open editor with preview mode
+ if (((this.isSelectedCellEditable() || READONLY_PREVIEW_COLUMNS.includes(columnType)) && !this.state.isEditorEnabled)) {
+ this.setState({
+ isEditorEnabled: true,
+ firstEditorKeyDown: key,
+ editorPosition: this.getEditorPosition()
+ });
+ }
+ };
+
+ openMobileEditor = () => {
+ const { recordGetterByIndex, isGroupView, columns } = this.props;
+ const { selectedPosition } = this.state;
+ const recordData = getSelectedRow({ selectedPosition, recordGetterByIndex, isGroupView });
+ const column = getSelectedColumn({ selectedPosition, columns });
+ if (!recordData || !column || !TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP[column.type]) {
+ return false;
+ }
+ const editingCell = { recordData, column };
+ this.props.editMobileCell(editingCell);
+ return true;
+ };
+
+ closeEditor = () => {
+ this.setState({
+ isEditorEnabled: false,
+ firstEditorKeyDown: null,
+ editorPosition: null,
+ openEditorMode: ''
+ });
+ };
+
+ onSelectCellRangeStarted = (selectedPosition) => {
+ if (!this.isCellWithinBounds(selectedPosition)) return;
+
+ const selectedRange = this.createSingleCellSelectedRange(selectedPosition, true);
+ this.setState({ selectedRange }, () => {
+ if (isFunction(this.props.onCellRangeSelectionStarted)) {
+ this.props.onCellRangeSelectionStarted(this.state.selectedRange);
+ }
+ });
+ };
+
+ onSelectCellRangeUpdated = (cellPosition, isFromKeyboard, callback) => {
+ if (!this.state.selectedRange.isDragging && !isFromKeyboard) {
+ return;
+ }
+
+ if (!this.isCellWithinBounds(cellPosition)) {
+ return;
+ }
+
+ const startCell = this.state.selectedRange.startCell || this.state.selectedPosition;
+ const { topLeft, bottomRight } = getNewSelectedRange(startCell, cellPosition);
+ const selectedRange = {
+ // default the startCell to the selected cell, in case we've just started via keyboard
+ startCell: this.state.selectedPosition,
+ // assign the previous state (which will override the startCell if we already have one)
+ ...this.state.selectedRange,
+ // assign the new state - the bounds of the range, and the new cursor cell
+ topLeft,
+ bottomRight,
+ cursorCell: cellPosition
+ };
+
+ this.setState({ selectedRange }, () => {
+ if (isFunction(this.props.onCellRangeSelectionUpdated)) {
+ this.props.onCellRangeSelectionUpdated(this.state.selectedRange);
+ }
+ if (isFunction(callback)) {
+ callback(this.state.selectedRange);
+ }
+ });
+ };
+
+ onSelectCellRangeEnded = () => {
+ const selectedRange = { ...this.state.selectedRange, isDragging: false };
+ this.setState({ selectedRange }, () => {
+ if (isFunction(this.props.onCellRangeSelectionCompleted)) {
+ this.props.onCellRangeSelectionCompleted(this.state.selectedRange);
+ }
+ });
+ };
+
+ createSingleCellSelectedRange(cellPosition, isDragging) {
+ return {
+ topLeft: cellPosition,
+ bottomRight: cellPosition,
+ startCell: cellPosition,
+ cursorCell: cellPosition,
+ isDragging
+ };
+ }
+
+ focus = () => {
+ if (this.selectionMask && !this.isFocused()) {
+ this.selectionMask.focus();
+ }
+ };
+
+ isFocused = () => {
+ return document.activeElement === this.selectionMask;
+ };
+
+ isCellSelected = () => {
+ const { selectedPosition } = this.state;
+ return selectedPosition.idx !== -1 && selectedPosition.rowIdx !== -1;
+ };
+
+ isCellWithinBounds = ({ idx, rowIdx }) => {
+ const { columns, recordsCount } = this.props;
+ const maxRowIdx = recordsCount;
+ return rowIdx >= 0 && rowIdx < maxRowIdx && idx >= 0 && idx < columns.length;
+ };
+
+ isSelectedCellEditable = () => {
+ const { enableCellSelect, columns, isGroupView, recordGetterByIndex, onCheckCellIsEditable } = this.props;
+ const { selectedPosition } = this.state;
+ const res = isSelectedCellEditable({ enableCellSelect, columns, isGroupView, recordGetterByIndex, selectedPosition, onCheckCellIsEditable });
+ return res;
+ };
+
+ isSelectedCellIsLongText = () => {
+ const { columns } = this.props;
+ const { selectedPosition } = this.state;
+ const column = getSelectedColumn({ selectedPosition, columns });
+ return column && column.type === CellType.LONG_TEXT;
+ };
+
+ isGridSelected = () => {
+ return this.isCellWithinBounds(this.state.selectedPosition);
+ };
+
+ getSelectedDimensions = (selectedPosition) => {
+ const { columns, rowHeight, isGroupView, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props;
+ const scrollLeft = this.props.getScrollLeft();
+ return { ...getSelectedDimensions({
+ selectedPosition, columns, scrollLeft, rowHeight, isGroupView, groupOffsetLeft, getRecordTopFromRecordsBody
+ }) };
+ };
+
+ getSelectedRangeDimensions = (selectedRange) => {
+ const { columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props;
+ return { ...getSelectedRangeDimensions({
+ selectedRange, columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRecordTopFromRecordsBody,
+ }) };
+ };
+
+ setScrollLeft = (scrollLeft) => {
+ const { selectionMask, state: { selectedPosition } } = this;
+ this.setMaskScrollLeft(selectionMask, selectedPosition, scrollLeft);
+ };
+
+ setMaskScrollLeft = (mask, position, scrollLeft) => {
+ if (mask) {
+ const { idx, rowIdx, groupRecordIndex } = position;
+ if (idx >= 0 && rowIdx >= 0) {
+ const { columns, getRowTop, isGroupView, groupOffsetLeft } = this.props;
+ const column = columns[idx];
+ const frozen = !!column.frozen;
+ if (frozen) {
+ // use fixed
+ const { top: containerTop } = this.container.getClientRects()[0];
+ const tableContentLeft = this.props.getTableContentLeft();
+ let top = containerTop + getRowTop(isGroupView ? groupRecordIndex : rowIdx);
+ let left = tableContentLeft + column.left;
+ if (isGroupView) {
+ top += 1;
+ left += groupOffsetLeft;
+ }
+ mask.style.position = 'fixed';
+ mask.style.top = top + 'px';
+ mask.style.left = left + 'px';
+ mask.style.transform = 'none';
+ }
+ }
+ }
+ };
+
+ cancelSetScrollLeft = () => {
+ if (this.selectionMask) {
+ this.cancelSetMaskScrollLeft(this.selectionMask, this.state.selectedPosition);
+ }
+ };
+
+ cancelSetMaskScrollLeft = (mask, position) => {
+ const { left, top } = this.getSelectedDimensions(position);
+ mask.style.position = 'absolute';
+ mask.style.top = 0;
+ mask.style.left = 0;
+ mask.style.transform = `translate(${left}px, ${top}px)`;
+ };
+
+ getEditorPosition = () => {
+ if (this.selectionMask) {
+ const { editorPortalTarget } = this.props;
+ const { left: selectionMaskLeft, top: selectionMaskTop } = this.selectionMask.getBoundingClientRect();
+ if (editorPortalTarget === document.body) {
+ const { scrollLeft, scrollTop } = document.scrollingElement || document.documentElement;
+ return {
+ left: selectionMaskLeft + scrollLeft,
+ top: selectionMaskTop + scrollTop
+ };
+ }
+
+ const { left: portalTargetLeft, top: portalTargetTop } = editorPortalTarget.getBoundingClientRect();
+ const { scrollLeft, scrollTop } = editorPortalTarget;
+ return {
+ left: selectionMaskLeft - portalTargetLeft + scrollLeft,
+ top: selectionMaskTop - portalTargetTop + scrollTop
+ };
+ }
+ };
+
+ modifyRecord = (updated, closeEditor = true) => {
+ this.props.modifyRecord(updated);
+ if (closeEditor) {
+ this.closeEditor();
+ }
+ };
+
+ onCommitCancel = () => {
+ this.closeEditor();
+ };
+
+ getEditorContainer = () => {
+ // todo
+ return null;
+ };
+
+ onKeyDown = (e) => {
+ const keyCode = e.keyCode;
+ if (isCtrlKeyHeldDown(e)) {
+ this.onPressKeyWithCtrl(e);
+ } else if (keyCode === KeyCodes.Escape) {
+ this.onPressEscape(e);
+ } else if (keyCode === KeyCodes.Tab) {
+ this.onPressTab(e);
+ } else if (this.isKeyboardNavigationEvent(e)) {
+ this.changeCellFromEvent(e);
+ } else if (isKeyPrintable(keyCode) || keyCode === KeyCodes.Enter) {
+ this.openEditor(e);
+ } else if (keyCode === KeyCodes.Backspace || keyCode === KeyCodes.Delete) {
+ const name = e.target.className;
+ if (name === 'rdg-selected') {
+ e.preventDefault();
+ this.handleSelectCellsDelete();
+ }
+ }
+ };
+
+ handleSelectCellsDelete = () => {
+ const { isGroupView, recordGetterByIndex, columns } = this.props;
+ const { selectedRange } = this.state;
+ const { topLeft, bottomRight } = selectedRange;
+ const recordsFromSelectedRange = getRecordsFromSelectedRange({ selectedRange, isGroupView, recordGetterByIndex });
+ const editableRecords = recordsFromSelectedRange.filter(record => window.sfMetadataContext.canModifyRow(record));
+ if (editableRecords.length === 0) {
+ return;
+ }
+ const { idx: startColumnIdx } = topLeft;
+ const { idx: endColumnIdx } = bottomRight;
+ let editableColumns = [];
+ let linkColumns = [];
+
+ // get editable columns from selected range
+ for (let j = startColumnIdx; j <= endColumnIdx; j++) {
+ const column = columns[j];
+ if (!column || NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[column.type] || !window.sfMetadataContext.canModifyCell(column)) {
+ break;
+ }
+ const { type, data } = column;
+ editableColumns.push(column);
+ if (type === CellType.LINK && data) {
+ linkColumns.push(column);
+ }
+ }
+
+ if (editableColumns.length === 0) {
+ return;
+ }
+
+ let updateRecordIds = [];
+ let idRecordUpdates = {}; // row's id to modified records data: { [row_id]: { [column.name: null] } }
+ let idOriginalRecordUpdates = {}; // row's id to modified original records data: { [row_id]: { [column.key: null] } }
+ let idOldRecordData = {}; // row's id to old records data: { [row_id]: { [column.name: xxx] } }
+ let idOriginalOldRecordData = {}; // row's id to old original records data: { [row_id]: { [column.key: xxx] } }
+ let idRowLinkItems = {}; // row's id to modified links: { [row_id]: { [column.key]: null } }
+ let idOldRowLinkItems = {}; // row's id to old links: { [row_id]: { [column.key]: [{ row_id: xxx, display_value: 'xxx' }] } }
+ editableRecords.forEach(record => {
+ const { _id } = record;
+ let originalUpdate = {};
+ let originalOldRecordData = {};
+ let linkItem = {};
+ let oldLinkItem = {};
+ editableColumns.forEach(column => {
+ const { key, type } = column;
+ const cellVal = record[key];
+ if (type === CellType.LINK) {
+ if (!Array.isArray(cellVal) || cellVal.length === 0) {
+ return;
+ }
+ linkItem[key] = null;
+ oldLinkItem[key] = cellVal;
+ return;
+ }
+ if (cellVal || cellVal === 0 || (Array.isArray(cellVal) && cellVal.length > 0)) {
+ originalOldRecordData[key] = cellVal;
+ if (type === CellType.FILE) {
+ originalUpdate[key] = [];
+ } else {
+ originalUpdate[key] = null;
+ }
+ }
+ });
+
+ // links data
+ if (Object.keys(linkItem).length > 0) {
+ idRowLinkItems[_id] = linkItem;
+ idOldRowLinkItems[_id] = oldLinkItem;
+ }
+
+ if (Object.keys(originalUpdate).length > 0) {
+ updateRecordIds.push(_id);
+ const update = getFormatRowData(editableColumns, originalUpdate);
+ const oldRecordData = getFormatRowData(editableColumns, originalOldRecordData);
+ idRecordUpdates[_id] = update;
+ idOriginalRecordUpdates[_id] = originalUpdate;
+ idOldRecordData[_id] = oldRecordData;
+ idOriginalOldRecordData[_id] = originalOldRecordData;
+ }
+ });
+
+ if (updateRecordIds.length > 0) {
+ const isCopyPaste = true;
+ this.props.updateRecords({
+ recordIds: updateRecordIds, idRecordUpdates, idOriginalRecordUpdates,
+ idOldRecordData, idOriginalOldRecordData, isCopyPaste,
+ });
+ }
+ };
+
+ onCopy = (e) => {
+ e.preventDefault();
+
+ const { recordMetrics } = this.props;
+ // select the records to copy
+ const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics);
+ if (selectedRecordIds.length > 0) {
+ this.copyRows(e, selectedRecordIds);
+ return;
+ }
+
+ // window.getSelection() doesn't work on the content of in FireFox, Edge and IE.
+ // The selectionStart and selectionEnd properties could be used to work around this.
+ let selectTxt = window.getSelection().toString();
+ if (!selectTxt && e.target.value) {
+ const { selectionStart, selectionEnd } = e.target;
+ selectTxt = e.target.value.substring(selectionStart, selectionEnd);
+ }
+ if (selectTxt) {
+ this.copyText(e, selectTxt);
+ return;
+ }
+
+ // when activeElement is not cellMask, can't copy cell
+ if (!this.isCellMaskActive()) {
+ return;
+ }
+ this.onCopyCells(e);
+ };
+
+ onPaste = (e) => {
+ // when activeElement is not cellMask or has no permission, can't paste cell
+ if (!this.isCellMaskActive() || window.sfMetadataContext.getPermission() === 'r') {
+ return;
+ }
+ const { columns, isGroupView } = this.props;
+ const { selectedPosition, selectedRange } = this.state;
+ const { idx, rowIdx } = selectedPosition;
+ if (idx === -1 || rowIdx === -1) {
+ return; // prevent paste when no cell selected
+ }
+ const cliperData = getEventTransfer(e);
+ if (!cliperData) {
+ return;
+ }
+
+ const cliperDataType = cliperData.type;
+ const copied = cliperData[TRANSFER_TYPES.DTABLE_FRAGMENT];
+ let copiedRecordsCount = 0;
+ let copiedColumnsCount = 0;
+ if (cliperDataType === TRANSFER_TYPES.DTABLE_FRAGMENT) {
+ const { selectedRecordIds, copiedRange } = copied;
+ if (Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0) {
+ // copy from selected records
+ copiedRecordsCount = selectedRecordIds.length;
+ copiedColumnsCount = columns.length;
+ } else {
+ // copy from selected range
+ const { topLeft: copiedTopLeft, bottomRight: copiedBottomRight } = copiedRange;
+ const { idx: startCopiedColumnIndex, rowIdx: startCopiedRecordIndex } = copiedTopLeft;
+ const { idx: endCopiedColumnIndex, rowIdx: endCopiedRecordIndex } = copiedBottomRight;
+ copiedRecordsCount = endCopiedRecordIndex - startCopiedRecordIndex + 1;
+ copiedColumnsCount = endCopiedColumnIndex - startCopiedColumnIndex + 1;
+ }
+ } else {
+ const { copiedRecords, copiedColumns } = copied;
+ copiedRecordsCount = copiedRecords.length;
+ copiedColumnsCount = copiedColumns.length;
+ }
+ const multiplePaste = this.isMultiplePaste(copiedRecordsCount, copiedColumnsCount);
+ this.props.paste({
+ copied,
+ multiplePaste,
+ type: cliperDataType,
+ pasteRange: selectedRange,
+ isGroupView,
+ });
+ if (!multiplePaste) {
+ this.setPasteRange(copiedRecordsCount, copiedColumnsCount);
+ }
+ };
+
+ copyText = (event, copiedText) => {
+ const type = 'text';
+ setEventTransfer({
+ type,
+ event,
+ copiedText,
+ });
+ };
+
+ copyRows = (event, selectedRecordIds) => {
+ const { table, columns, recordGetterById, isGroupView, getCopiedRecordsAndColumnsFromRange } = this.props;
+ const copiedRowsCount = selectedRecordIds.length;
+ toaster.success(
+ copiedRowsCount > 1 ? gettext('xxx rows are copied.').replace('xxx', copiedRowsCount) : gettext('1 row is copied.')
+ );
+ const type = TRANSFER_TYPES.DTABLE_FRAGMENT;
+ const copied = { selectedRecordIds };
+ const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, isGroupView });
+ const { _id: copiedTableId } = table;
+ setEventTransfer({
+ type,
+ event,
+ selectedRecordIds,
+ copiedRecords,
+ copiedColumns,
+ copiedTableId,
+ tableData: {
+ columns,
+ },
+ recordGetterById,
+ });
+ };
+
+ onCopyCells = (event) => {
+ const { table, columns, isGroupView, recordGetterByIndex, getCopiedRecordsAndColumnsFromRange } = this.props;
+ const { selectedPosition, selectedRange } = this.state;
+ const { _id: copiedTableId } = table;
+ const { rowIdx, idx } = selectedPosition;
+ if (rowIdx < 0 || idx < 0) {
+ return; // can not copy when no cell select
+ }
+ const { topLeft, bottomRight } = selectedRange;
+ const type = TRANSFER_TYPES.DTABLE_FRAGMENT;
+ const copiedCellsCount = (bottomRight.rowIdx - topLeft.rowIdx + 1) * (bottomRight.idx - topLeft.idx + 1);
+ toaster.success(
+ copiedCellsCount > 1 ? gettext('xxx cells copied').replace('xxx', copiedCellsCount) : gettext('1 cell copied')
+ );
+ const copied = { copiedRange: selectedRange };
+ const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, isGroupView });
+ setEventTransfer({
+ type,
+ event,
+ copiedRange: { ...selectedRange },
+ copiedRecords,
+ copiedColumns,
+ copiedTableId,
+ tableData: {
+ columns,
+ },
+ isGroupView,
+ recordGetterByIndex,
+ });
+ };
+
+ isMultiplePaste = (copiedRecordsCount, copiedColumnsCount) => {
+ const { selectedRange } = this.state;
+ const { topLeft, bottomRight } = selectedRange;
+ const { idx: startColumnIndex, rowIdx: startRecordIndex } = topLeft;
+ const { idx: endColumnIndex, rowIdx: endRecordIndex } = bottomRight;
+ return Number.isInteger((endColumnIndex - startColumnIndex + 1) / copiedColumnsCount) && Number.isInteger((endRecordIndex - startRecordIndex + 1) / copiedRecordsCount);
+ };
+
+ setPasteRange = (copiedRecordsCount, copiedColumnsCount) => {
+ const { recordsCount, columns } = this.props;
+ const { selectedPosition, selectedRange } = this.state;
+ const { topLeft } = selectedRange;
+ const { idx, rowIdx } = topLeft;
+ const columnsLen = columns.length;
+ const groupRecordIndex = selectedPosition.groupRecordIndex;
+ let nextColumnIndex = idx + copiedColumnsCount - 1;
+ let nextRecordIndex = rowIdx + copiedRecordsCount - 1;
+ if (nextColumnIndex >= columnsLen) {
+ nextColumnIndex = columnsLen - 1;
+ }
+ if (nextRecordIndex >= recordsCount) {
+ nextRecordIndex = recordsCount - 1;
+ }
+ const nextSelectedRange = {
+ topLeft,
+ startCell: selectedPosition,
+ bottomRight: {
+ idx: nextColumnIndex,
+ rowIdx: nextRecordIndex,
+ groupRecordIndex,
+ },
+ cursorCell: {
+ idx: selectedPosition.idx,
+ rowIdx: selectedPosition.rowIdx,
+ groupRecordIndex,
+ }
+ };
+ this.setState({
+ selectedRange: {
+ ...selectedRange,
+ ...nextSelectedRange
+ }
+ }, () => {
+ this.focus();
+ });
+ return nextSelectedRange;
+ };
+
+ onPressKeyWithCtrl = () => {
+
+ };
+
+ onPressEscape = () => {
+
+ };
+
+ onPressTab = (e) => {
+ this.changeCellFromEvent(e);
+ };
+
+ getLeftInterval = () => {
+ const { isGroupView, columns, groupOffsetLeft, frozenColumnsWidth } = this.props;
+ const firstColumnFrozen = columns[0] ? columns[0].frozen : false;
+ let leftInterval = 0;
+ if (firstColumnFrozen) {
+ leftInterval = groupOffsetLeft + frozenColumnsWidth;
+ if (isGroupView) {
+ leftInterval += groupOffsetLeft;
+ }
+ } else {
+ leftInterval = 0;
+ }
+ return leftInterval;
+ };
+
+ handleVerticalArrowAction = (current, actionType) => {
+ const { isGroupView, groupMetrics, rowHeight } = this.props;
+ const step = actionType === 'ArrowDown' ? 1 : -1;
+ if (isGroupView) {
+ const groupRows = groupMetrics.groupRows || [];
+ const groupRowsLen = groupRows.length;
+ const { groupRecordIndex: currentGroupRowIndex } = current;
+ let nextGroupRowIndex = currentGroupRowIndex + step;
+ let nextGroupRow;
+ while (nextGroupRowIndex > 0 && nextGroupRowIndex < groupRowsLen) {
+ nextGroupRow = getGroupRecordByIndex(nextGroupRowIndex, groupMetrics);
+ if (nextGroupRow.type === GROUP_ROW_TYPE.ROW) {
+ break;
+ }
+ nextGroupRowIndex += step;
+ }
+ if (!nextGroupRow || nextGroupRow.type !== GROUP_ROW_TYPE.ROW) {
+ return;
+ }
+
+ const currentScrollTop = this.props.getGroupCanvasScrollTop() || 0;
+ const { rowIdx: nextRowIdx, top: nextRowTop } = nextGroupRow;
+ let newScrollTop;
+
+ // 32: footerHeight; 16: preview of next row.
+ const HEADER_HEIGHT = 150;
+ if (nextRowTop <= currentScrollTop + 16) {
+ newScrollTop = nextRowTop - 16;
+ } else if (nextRowTop + HEADER_HEIGHT - currentScrollTop >= window.innerHeight - 32 - 16) {
+ newScrollTop = nextRowTop + HEADER_HEIGHT - window.innerHeight + 32 + rowHeight + 16;
+ }
+ if (newScrollTop !== undefined) {
+ this.props.setGroupCanvasScrollTop(newScrollTop);
+ }
+ return { ...current, rowIdx: nextRowIdx, groupRecordIndex: nextGroupRowIndex };
+ } else {
+ return { ...current, rowIdx: current.rowIdx + step };
+ }
+ };
+
+ handleLeftArrowAction = (current) => {
+ let cellContainer = this.selectionMask;
+ if (!cellContainer) return;
+ const { columns } = this.props;
+ const rect = cellContainer.getBoundingClientRect();
+ const leftInterval = this.getLeftInterval();
+ const nextColumnWidth = columns[current.idx - 1] ? columns[current.idx - 1].width : 0;
+ const appNavWidth = window.app.state.appNavWidth || 0;
+ const appLeftBarWidth = parseInt(appNavWidth) + 130;
+ // selectMask is outside the viewport, scroll to next column
+ if (rect.x < 0 || rect.x > window.innerWidth) {
+ this.props.scrollToColumn(current.idx - 1);
+ } else if (nextColumnWidth > rect.x - leftInterval - appLeftBarWidth) {
+ // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth
+ const newScrollLeft = nextColumnWidth - (rect.x - leftInterval - appLeftBarWidth);
+ this.props.setRecordsScrollLeft(this.props.getScrollLeft() - newScrollLeft);
+ }
+ return ({ ...current, idx: current.idx === 0 ? 0 : current.idx - 1 });
+ };
+
+ handleRightArrowAction = (current) => {
+ let cellContainer = this.selectionMask;
+ if (!cellContainer) return;
+ const { columns } = this.props;
+ const rect = cellContainer.getBoundingClientRect();
+ const columnIdx = current.idx;
+ const column = columns[columnIdx];
+ if (columnIdx === 1 && column.frozen === true) {
+ this.props.scrollToColumn(1);
+ } else {
+ const nextColumnWidth = columns[columnIdx + 1] ? columns[columnIdx + 1].width : 0;
+ // selectMask is outside the viewport, scroll to next column
+ if (rect.x < 0 || rect.x > window.innerWidth) {
+ this.props.scrollToColumn(columnIdx + 1);
+ } else if (rect.x + rect.width + nextColumnWidth > window.innerWidth) {
+ // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth
+ const newScrollLeft = nextColumnWidth - (window.innerWidth - rect.x - rect.width);
+ this.props.setRecordsScrollLeft(this.props.getScrollLeft() + newScrollLeft);
+ }
+ }
+ return ({ ...current, idx: current.idx + 1 });
+ };
+
+ isKeyboardNavigationEvent(e) {
+ return this.getKeyNavActionFromEvent(e) != null;
+ }
+
+ getKeyNavActionFromEvent = (e) => {
+ const { getVisibleIndex, onHitBottomBoundary, onHitTopBoundary } = this.props;
+
+ const { rowVisibleStartIdx, rowVisibleEndIdx } = getVisibleIndex();
+ const isCellAtBottomBoundary = cell => cell.rowIdx >= rowVisibleEndIdx - 1;
+ const isCellAtTopBoundary = cell => cell.rowIdx !== 0 && cell.rowIdx <= rowVisibleStartIdx;
+ const keyNavActions = {
+ ArrowDown: {
+ getNext: (current) => {
+ return this.handleVerticalArrowAction(current, 'ArrowDown');
+ },
+ isCellAtBoundary: isCellAtBottomBoundary,
+ onHitBoundary: onHitBottomBoundary
+ },
+ ArrowUp: {
+ getNext: (current) => {
+ return this.handleVerticalArrowAction(current, 'ArrowUp');
+ },
+ isCellAtBoundary: isCellAtTopBoundary,
+ onHitBoundary: onHitTopBoundary
+ },
+ ArrowRight: {
+ getNext: (current) => {
+ return this.handleRightArrowAction(current);
+ },
+ isCellAtBoundary: () => {
+ return false;
+ }
+ },
+ ArrowLeft: {
+ getNext: (current) => {
+ return this.handleLeftArrowAction(current);
+ },
+ isCellAtBoundary: () => {
+ return false;
+ }
+ }
+ };
+ if (e.keyCode === KeyCodes.Tab) {
+ return e.shiftKey === true ? keyNavActions.ArrowLeft : keyNavActions.ArrowRight;
+ }
+ return keyNavActions[e.key];
+ };
+
+ changeCellFromEvent = (e) => {
+ e.preventDefault();
+ if (e.keyCode === KeyCodes.ChineseInputMethod && this.state.isEditorEnabled) {
+ return;
+ }
+ if (this.throttle) return;
+ const currentPosition = this.state.selectedPosition;
+ const keyNavAction = this.getKeyNavActionFromEvent(e);
+ const next = keyNavAction.getNext(currentPosition);
+ if (!next) return;
+ this.checkIsAtGridBoundary(keyNavAction, next);
+ this.props.onCellClick(next);
+ this.onSelectCell({ ...next });
+ this.throttle = true;
+ setTimeout(() => {
+ this.throttle = false;
+ }, 30);
+ };
+
+ checkIsAtGridBoundary(keyNavAction, next) {
+ const { isCellAtBoundary, onHitBoundary } = keyNavAction;
+ if (isCellAtBoundary(next)) {
+ onHitBoundary(next);
+ }
+ }
+
+ onFocus = () => {
+
+ };
+
+ onScroll = (e) => {
+ e.stopPropagation();
+ };
+
+ setSelectionMaskRef = (ref) => {
+ this.selectionMask = ref;
+ };
+
+ setSelectionRangeMaskRef = (ref) => {
+ this.selectedRangeMask = ref;
+ };
+
+ setContainerRef = (ref) => {
+ this.container = ref;
+ };
+
+ isCellMaskActive = () => {
+ const activeElement = document.activeElement;
+ return (activeElement &&
+ (activeElement.getAttribute('data-test') === 'cell-mask' ||
+ activeElement.getAttribute('data-test') === 'active-editor')
+ );
+ };
+
+ handleDragCopy = (draggedRange) => {
+ const { columns, groupMetrics, table: { rows, id_row_map }, gridUtils, updateRecords } = this.props;
+ // compute the new records
+ const newRecords = gridUtils.getUpdateDraggedRecords(draggedRange, columns, rows, id_row_map, groupMetrics);
+ updateRecords({ ...newRecords, isCopyPaste: true });
+ };
+
+ handleDragStart = (e) => {
+ const { selectedRange: { topLeft, bottomRight, startCell, cursorCell } } = this.state;
+ // To prevent dragging down/up when reordering rows. (TODO: is this required)
+ const isViewportDragging = e && e.target && e.target.className;
+ if (topLeft.idx > -1 && isViewportDragging) {
+ try {
+ e.dataTransfer.setData('text/plain', '');
+ } catch (ex) {
+ // IE only supports 'text' and 'URL' for the 'type' argument
+ e.dataTransfer.setData('text', '');
+ }
+ this.setState({
+ draggedRange: { topLeft, bottomRight, startCell, cursorCell }
+ });
+ }
+ };
+
+ handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => {
+ if (this.state.draggedRange != null) {
+ this.setState(({ draggedRange }) => ({
+ draggedRange: { ...draggedRange, overRecordIdx, overGroupRecordIndex }
+ }));
+ }
+ };
+
+ handleDragEnd = () => {
+ const { draggedRange, selectedRange } = this.state;
+ let newSelectedRange = deepCopy(selectedRange);
+ if (draggedRange !== null) {
+ const { overRecordIdx, overGroupRecordIndex, bottomRight } = draggedRange;
+ if (overRecordIdx !== null && bottomRight.rowIdx < overRecordIdx) {
+ this.handleDragCopy(draggedRange);
+ newSelectedRange.bottomRight.rowIdx = overRecordIdx;
+ newSelectedRange.cursorCell.rowIdx = overRecordIdx;
+ newSelectedRange.bottomRight.groupRowIndex = overGroupRecordIndex;
+ newSelectedRange.cursorCell.groupRowIndex = overGroupRecordIndex;
+ }
+ this.setState({ draggedRange: null, selectedRange: newSelectedRange });
+ }
+ };
+
+
+ renderSingleCellSelectView = () => {
+ const { columns } = this.props;
+ const {
+ isEditorEnabled,
+ selectedPosition,
+ } = this.state;
+ const isDragEnabled = this.isSelectedCellEditable();
+ const canEdit = false;
+ const showDragHandle = (isDragEnabled && canEdit);
+ const column = getSelectedColumn({ selectedPosition, columns });
+ const { type: columnType } = column || {};
+ if (isEditorEnabled && columnType !== CellType.RATE && columnType !== CellType.CHECKBOX) return null;
+ if (!this.isGridSelected()) return null;
+
+ const props = {
+ innerRef: this.setSelectionMaskRef,
+ selectedPosition,
+ getSelectedDimensions: this.getSelectedDimensions,
+ };
+ return (
+
+ {showDragHandle ?
+
+ : null}
+
+ );
+ };
+
+ renderCellRangeSelectView = () => {
+ const { selectedRange } = this.state;
+ const { columns, rowHeight } = this.props;
+
+ const isDragEnabled = this.isSelectedCellEditable();
+ const canEdit = false;
+ const showDragHandle = (isDragEnabled && canEdit);
+ return [
+
+ {showDragHandle ?
+
+ : null}
+ ,
+
+ ];
+ };
+
+ renderMobileOperations = () => {
+ const { canAddRow, columns } = this.props;
+ const { selectedPosition } = this.state;
+ const isSelectCell = !(selectedPosition.idx === -1 && selectedPosition.rowIdx === -1);
+ const selectedColumn = isSelectCell && getSelectedColumn({ selectedPosition, columns });
+ const cellEditable = isSelectCell && selectedColumn && TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP[selectedColumn.type] && this.isSelectedCellEditable();
+ const style = this.props.getMobileFloatIconStyle();
+ let moreIconPositionStyle = {
+ bottom: canAddRow || cellEditable ? 102 : 42,
+ };
+ let buttons = [
+
+
+
+ ];
+
+
+ if (canAddRow && !cellEditable) {
+ buttons.push(
+
+
+
+ );
+ }
+
+ if (cellEditable) {
+ buttons.push(
+
+
+
+ );
+ }
+ return buttons;
+ };
+
+ render() {
+ const { selectedRange, draggedRange } = this.state;
+ const isSelectedSingleCell = selectedRangeIsSingleCell(selectedRange);
+ return (
+
+ {draggedRange && (
+
+ )}
+ {isSelectedSingleCell && this.renderSingleCellSelectView()}
+ {!isSelectedSingleCell && this.renderCellRangeSelectView()}
+
+ );
+ }
+}
+
+InteractionMasks.propTypes = propTypes;
+
+export default InteractionMasks;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js
new file mode 100644
index 0000000000..fb2a38e32e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-mask.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import CellMask from './cell-mask';
+
+function SelectionMask({ innerRef, selectedPosition, getSelectedDimensions, children }) {
+ const dimensions = getSelectedDimensions(selectedPosition);
+ return (
+
+ {children}
+
+ );
+}
+
+SelectionMask.propTypes = {
+ selectedPosition: PropTypes.object.isRequired,
+ getSelectedDimensions: PropTypes.func.isRequired,
+ innerRef: PropTypes.func.isRequired,
+ children: PropTypes.element,
+};
+
+export default SelectionMask;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js
new file mode 100644
index 0000000000..2297a56d7f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-masks/selection-range-mask.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import CellMask from './cell-mask';
+
+function SelectionRangeMask({ selectedRange, innerRef, getSelectedRangeDimensions, children }) {
+ const dimensions = getSelectedRangeDimensions(selectedRange);
+ return (
+
+ {children}
+
+ );
+}
+
+SelectionRangeMask.propTypes = {
+ selectedRange: PropTypes.shape({
+ topLeft: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired,
+ bottomRight: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired,
+ startCell: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired }).isRequired,
+ cursorCell: PropTypes.shape({ idx: PropTypes.number.isRequired, rowIdx: PropTypes.number.isRequired })
+ }).isRequired,
+ columns: PropTypes.array.isRequired,
+ rowHeight: PropTypes.number.isRequired,
+ children: PropTypes.element,
+ innerRef: PropTypes.func.isRequired,
+ getSelectedRangeDimensions: PropTypes.func
+};
+
+export default SelectionRangeMask;
diff --git a/frontend/src/metadata/metadata-view/components/table/table-tool/index.css b/frontend/src/metadata/metadata-view/components/table/table-tool/index.css
new file mode 100644
index 0000000000..c7e93ec16b
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-tool/index.css
@@ -0,0 +1,56 @@
+.sf-metadata-tool {
+ background-color: #fff;
+ border-bottom: 1px solid #e4e4e4;
+ border-top: 1px solid #ddd;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: nowrap;
+ height: 48px;
+ justify-content: space-between;
+ padding: 0 20px;
+ position: relative;
+ border-left: 1px solid #ddd;
+ border-right: 1px solid #ddd;
+}
+
+.sf-metadata-tool .sf-metadata-tool-left-operations,
+.sf-metadata-tool .sf-metadata-tool-right-operations {
+ display: flex;
+ align-items: center;
+}
+
+.sf-metadata-tool .sf-metadata-tool-left-operations .setting-item {
+ margin-right: 0 !important;
+ margin-left: .5rem;
+}
+
+.sf-metadata-tool .sf-metadata-tool-left-operations .setting-item:first-child {
+ margin-left: 0 !important;
+}
+
+.sf-metadata-tool .custom-filter-label {
+ padding: 0 .5rem;
+}
+
+.sf-metadata-tool .setting-item-btn {
+ border-radius: 4px;
+ cursor: pointer;
+ padding: 3px 4px;
+ width: 100%;
+ height: 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.sf-metadata-tool .setting-item-btn:hover {
+ background-color: #efefef;
+}
+
+.sf-metadata-tool .custom-tool-label .sf-metadata-icon {
+ color: #666;
+ font-size: 14px;
+ margin-right: 8px;
+}
diff --git a/frontend/src/metadata/metadata-view/components/table/table-tool/index.js b/frontend/src/metadata/metadata-view/components/table/table-tool/index.js
new file mode 100644
index 0000000000..a3fffece2c
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/components/table/table-tool/index.js
@@ -0,0 +1,67 @@
+import React, { useCallback } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'classnames';
+import { FilterSetter, GroupbySetter, SortSetter, HideColumnSetter } from '../../data-process-setter';
+import { Z_INDEX } from '../../../_basic';
+import { EVENT_BUS_TYPE } from '../../../constants';
+
+import './index.css';
+
+const TableTool = ({ searcherActive, onFiltersChange, onSortsChange, modifyGroupbys, modifyHiddenColumns }) => {
+
+ const onHeaderClick = useCallback(() => {
+ window.sfMetadataContext.eventBus.dispatch(EVENT_BUS_TYPE.SELECT_NONE);
+ }, []);
+
+ return (
+
+ );
+};
+
+TableTool.propTypes = {
+ searcherActive: PropTypes.bool,
+ onFiltersChange: PropTypes.func,
+ onSortsChange: PropTypes.func,
+ modifyGroupbys: PropTypes.func,
+ modifyHiddenColumns: PropTypes.func,
+};
+
+export default TableTool;
diff --git a/frontend/src/metadata/metadata-view/constants/TransferTypes.js b/frontend/src/metadata/metadata-view/constants/TransferTypes.js
new file mode 100644
index 0000000000..5d970e8127
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/constants/TransferTypes.js
@@ -0,0 +1,15 @@
+const FRAGMENT = 'application/x-sf-metadata-fragment';
+const HTML = 'text/html';
+const TEXT = 'text/plain';
+const FILES = 'files';
+const DTABLE_FRAGMENT = 'sf-metadata-fragment';
+
+const transferTypes = {
+ FRAGMENT,
+ HTML,
+ TEXT,
+ FILES,
+ DTABLE_FRAGMENT,
+};
+
+export default transferTypes;
diff --git a/frontend/src/metadata/metadata-view/constants/event-bus-type.js b/frontend/src/metadata/metadata-view/constants/event-bus-type.js
new file mode 100644
index 0000000000..ede764d7bf
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/constants/event-bus-type.js
@@ -0,0 +1,28 @@
+export const EVENT_BUS_TYPE = {
+ QUERY_COLLABORATORS: 'query-collaborators',
+ QUERY_COLLABORATOR: 'query-collaborator',
+ UPDATE_TABLE_ROWS: 'update-table-rows',
+
+ // table
+ LOCAL_TABLE_CHANGED: 'local-table-changed',
+ SERVER_TABLE_CHANGED: 'server-table-changed',
+ TABLE_ERROR: 'table-error',
+ OPEN_EDITOR: 'open-editor',
+ CLOSE_EDITOR: 'close-editor',
+ SELECT_CELL: 'select_cell',
+ SELECT_START: 'select_start',
+ SELECT_UPDATE: 'select_update',
+ SELECT_END: 'select_end',
+ SELECT_END_WITH_SHIFT: 'select_end_with_shift',
+ SELECT_NONE: 'select_none',
+ COPY_CELLS: 'copy_cells',
+ PASTE_CELLS: 'paste_cells',
+ SEARCH_CELLS: 'search-cells',
+ CLOSE_SEARCH_CELLS: 'close-search-cells',
+ OPEN_SELECT: 'open-select',
+ UPDATE_LINKED_RECORDS: 'update_linked_records',
+ SELECT_COLUMN: 'select_column',
+ DRAG_ENTER: 'drag_enter',
+ COLLAPSE_ALL_GROUPS: 'collapse_all_groups',
+ EXPAND_ALL_GROUPS: 'expand_all_groups',
+};
diff --git a/frontend/src/metadata/metadata-view/constants/index.js b/frontend/src/metadata/metadata-view/constants/index.js
new file mode 100644
index 0000000000..1ce706c286
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/constants/index.js
@@ -0,0 +1,102 @@
+import { CellType } from '../_basic';
+import { EVENT_BUS_TYPE } from './event-bus-type';
+import TRANSFER_TYPES from './TransferTypes';
+
+export const CELL_NAVIGATION_MODE = {
+ NONE: 'none',
+ CHANGE_ROW: 'changeRow',
+ LOOP_OVER_ROW: 'loopOverRow',
+};
+
+export const SEQUENCE_COLUMN_WIDTH = 80;
+
+export const ROW_HEIGHT = 32;
+
+export const GRID_HEADER_DEFAULT_HEIGHT = 32;
+
+export const GRID_HEADER_DOUBLE_HEIGHT = 56;
+
+export const GROUP_VIEW_OFFSET = 16;
+
+export const GROUP_HEADER_HEIGHT = 48;
+
+export const TABLE_LEFT_MARGIN = 10;
+
+export const TABLE_BORDER_WIDTH = 1;
+
+export const UNABLE_TO_CALCULATE = '--';
+
+export const FROZEN_COLUMN_SHADOW = '2px 0 5px -2px hsla(0,0%,53.3%,.3)';
+
+export const TABLE_NOT_SUPPORT_EDIT_TYPE_MAP = {
+ [CellType.CREATOR]: true,
+ [CellType.LAST_MODIFIER]: true,
+ [CellType.CTIME]: true,
+ [CellType.MTIME]: true,
+};
+
+export const TABLE_SUPPORT_EDIT_TYPE_MAP = {
+ [CellType.TEXT]: true,
+};
+
+export const TABLE_MOBILE_SUPPORT_EDIT_CELL_TYPE_MAP = {
+ [CellType.TEXT]: true,
+};
+
+export const CANVAS_RIGHT_INTERVAL = 240;
+
+export const LEFT_NAV = 280;
+export const ROW_DETAIL_PADDING = 40 * 2;
+export const ROW_DETAIL_MARGIN = 20 * 2;
+export const EDITOR_PADDING = 1.5 * 16; // 1.5: 0.75 * 2
+
+export const COLUMN_RATE_MAX_NUMBER = [
+ { name: 1 },
+ { name: 2 },
+ { name: 3 },
+ { name: 4 },
+ { name: 5 },
+ { name: 6 },
+ { name: 7 },
+ { name: 8 },
+ { name: 9 },
+ { name: 10 },
+];
+
+export const GROUP_ROW_TYPE = {
+ GROUP_CONTAINER: 'group_container',
+ ROW: 'row',
+ BTN_INSERT_ROW: 'btn_insert_row',
+};
+
+export const INSERT_ROW_HEIGHT = 32;
+
+export const CHANGE_HEADER_WIDTH = 'CHANGE_HEADER_WIDTH';
+
+export const NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES = [
+];
+
+export const SUPPORT_PREVIEW_COLUMN_TYPES = [];
+
+export const OVER_SCAN_COLUMNS = 10;
+
+export const DELETED_OPTION_BACKGROUND_COLOR = '#eaeaea';
+
+export const DELETED_OPTION_TIPS = 'deleted_option';
+
+export const SUPPORT_BATCH_DOWNLOAD_TYPES = [];
+
+export const DEFAULT_COLUMNS = [
+ { name: 'Name', type: CellType.TEXT, width: 200, editable: false, key: 'name' },
+ { name: 'Parent_dir', type: CellType.TEXT, width: 200, editable: false, key: 'parent_dir' },
+ { name: 'CTime', type: CellType.CTIME, width: 200, editable: false, key: 'created_time' },
+ { name: 'MTime', type: CellType.MTIME, width: 200, editable: false, key: 'modified_time' },
+ { name: 'Creator', type: CellType.CREATOR, width: 200, editable: false, key: 'creator' },
+ { name: 'Last_modified', type: CellType.LAST_MODIFIER, width: 200, editable: false, key: 'modifier' },
+ { name: 'Is_dir', type: CellType.TEXT, width: 200, editable: false, key: 'is_dir' },
+];
+
+export {
+ EVENT_BUS_TYPE,
+ TRANSFER_TYPES,
+};
diff --git a/frontend/src/metadata/metadata-view/context.js b/frontend/src/metadata/metadata-view/context.js
new file mode 100644
index 0000000000..c1a7fca2bb
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/context.js
@@ -0,0 +1,79 @@
+import metadataAPI from '../api';
+import { UserService, LocalStorage } from './_basic';
+import EventBus from './utils/event-bus';
+
+class Context {
+
+ constructor() {
+ this.settings = {};
+ this.metadataAPI = null;
+ this.localStorage = null;
+ this.userService = null;
+ this.eventBus = null;
+ this.hasInit = false;
+ }
+
+ async init({ otherSettings }) {
+ if (this.hasInit) return;
+
+ // init settings
+ this.settings = otherSettings || {};
+
+ // init metadataAPI
+ const { mediaUrl } = this.settings;
+ this.metadataAPI = metadataAPI;
+
+ // init localStorage
+ const { repoID } = this.settings;
+ this.localStorage = new LocalStorage(`sf-metadata-${repoID}`);
+
+ // init userService
+ this.userService = new UserService({ mediaUrl, api: this.metadataAPI.listUserInfo });
+
+ const eventBus = new EventBus();
+ this.eventBus = eventBus;
+
+ this.hasInit = true;
+ }
+
+ destroy = () => {
+ this.settings = {};
+ this.metadataAPI = null;
+ this.localStorage = null;
+ this.userService = null;
+ this.eventBus = null;
+ this.hasInit = false;
+ };
+
+ getSetting = (key) => {
+ if (this.settings[key] === false) return this.settings[key];
+ return this.settings[key] || '';
+ };
+
+ setSetting = (key, value) => {
+ this.settings[key] = value;
+ };
+
+ // metadata
+ getMetadata = (repoID) => {
+ return this.metadataAPI.getMetadata(repoID);
+ };
+
+ canModifyCell = (column) => {
+ return false;
+ };
+
+ canModifyRow = (row) => {
+ return false;
+ };
+
+ getPermission = () => {
+ return 'rw';
+ };
+
+ getCollaboratorsFromCache = () => {
+ //
+ };
+}
+
+export default Context;
diff --git a/frontend/src/metadata/metadata-view/hooks/collaborators.js b/frontend/src/metadata/metadata-view/hooks/collaborators.js
new file mode 100644
index 0000000000..7ce92d7844
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/hooks/collaborators.js
@@ -0,0 +1,36 @@
+/* eslint-disable react/prop-types */
+import React, { useContext, useState, useRef, useCallback } from 'react';
+
+const CollaboratorsContext = React.createContext(null);
+
+export const CollaboratorsProvider = ({
+ collaborators,
+ collaboratorsCache: propsCollaboratorsCache,
+ updateCollaboratorsCache: propsUpdateCollaboratorsCache,
+ children,
+}) => {
+ const collaboratorsCacheRef = useRef(propsCollaboratorsCache || {});
+ const [collaboratorsCache, setCollaboratorsCache] = useState(propsCollaboratorsCache || {});
+
+ const updateCollaboratorsCache = useCallback((user) => {
+ const newCollaboratorsCache = { ...collaboratorsCacheRef.current, [user.email]: user };
+ collaboratorsCacheRef.current = newCollaboratorsCache;
+ setCollaboratorsCache(newCollaboratorsCache);
+ propsUpdateCollaboratorsCache && propsUpdateCollaboratorsCache(user);
+ }, [propsUpdateCollaboratorsCache]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCollaborators = () => {
+ const context = useContext(CollaboratorsContext);
+ if (!context) {
+ throw new Error('\'CollaboratorsContext\' is null');
+ }
+ const { collaborators, collaboratorsCache, updateCollaboratorsCache } = context;
+ return { collaborators, collaboratorsCache, updateCollaboratorsCache };
+};
diff --git a/frontend/src/metadata/metadata-view/hooks/index.js b/frontend/src/metadata/metadata-view/hooks/index.js
new file mode 100644
index 0000000000..650e76b7f4
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/hooks/index.js
@@ -0,0 +1,9 @@
+import { CollaboratorsProvider, useCollaborators } from './collaborators';
+import { MetadataProvider, useMetadata } from './metadata';
+import { RecordDetailsProvider, useRecordDetails } from './record-details';
+
+export {
+ CollaboratorsProvider, useCollaborators,
+ MetadataProvider, useMetadata,
+ RecordDetailsProvider, useRecordDetails,
+};
diff --git a/frontend/src/metadata/metadata-view/hooks/metadata.js b/frontend/src/metadata/metadata-view/hooks/metadata.js
new file mode 100644
index 0000000000..6eb9023d55
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/hooks/metadata.js
@@ -0,0 +1,136 @@
+/* eslint-disable react/prop-types */
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+import { toaster } from '@seafile/sf-metadata-ui-component';
+import { Metadata } from '../model';
+import { gettext } from '../../../utils/constants';
+import { getErrorMsg, CellType } from '../_basic';
+import Context from '../context';
+
+const MetadataContext = React.createContext(null);
+
+export const MetadataProvider = ({
+ children,
+ ...params
+}) => {
+ const [isLoading, setLoading] = useState(true);
+ const [metadata, setMetadata] = useState({ records: [], columns: [] });
+
+ const getColumnName = useCallback((key, name) => {
+ switch (key) {
+ case '_ctime':
+ return gettext('Created time');
+ case '_mtime':
+ return gettext('Last modified time');
+ case '_creator':
+ return gettext('Creator');
+ case '_last_modifier':
+ return gettext('Last modifier');
+ case '_file_creator':
+ return gettext('File creator');
+ case '_file_modifier':
+ return gettext('File modifier');
+ case '_file_ctime':
+ return gettext('File created time');
+ case '_file_mtime':
+ return gettext('File last modified time');
+ case '_is_dir':
+ return gettext('Is dir');
+ case '_parent_dir':
+ return gettext('Parent dir');
+ case '_name':
+ return gettext('File name');
+ default:
+ return name;
+ }
+ }, []);
+
+ const getColumnType = useCallback((key, type) => {
+ switch (key) {
+ case '_ctime':
+ case '_file_ctime':
+ return CellType.CTIME;
+ case '_mtime':
+ case '_file_mtime':
+ return CellType.MTIME;
+ case '_creator':
+ case '_file_creator':
+ return CellType.CREATOR;
+ case '_last_modifier':
+ case '_file_modifier':
+ return CellType.LAST_MODIFIER;
+ default:
+ return type;
+ }
+ }, []);
+
+ const getColumns = useCallback((columns) => {
+ if (!Array.isArray(columns) || columns.length === 0) return [];
+ return columns.map((column) => {
+ const { type, key, name, ...params } = column;
+ return {
+ key,
+ type: getColumnType(key, type),
+ name: getColumnName(key, name),
+ ...params,
+ width: 200,
+ };
+ }).filter(column => !['_id', '_ctime', '_mtime', '_creator', '_last_modifier'].includes(column.key));
+ }, [getColumnType, getColumnName]);
+
+ // init
+ useEffect(() => {
+ const init = async () => {
+
+ // init context
+ const context = new Context();
+ window.sfMetadataContext = context;
+ await window.sfMetadataContext.init({ otherSettings: params });
+
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+ window.sfMetadataContext.getMetadata(repoID).then(res => {
+ setMetadata(new Metadata({ rows: res?.data?.results || [], columns: getColumns(res?.data?.metadata) }));
+ setLoading(false);
+ }).catch(error => {
+ const errorMsg = getErrorMsg(error);
+ toaster.danger(gettext(errorMsg));
+ });
+ };
+
+ init();
+
+ return () => {
+ window.sfMetadataContext.destroy();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const extendMetadataRows = useCallback((callback) => {
+ const repoID = window.sfMetadataContext.getSetting('repoID');
+ window.sfMetadataContext.getMetadata(repoID).then(res => {
+ const rows = res?.data?.results || [];
+ metadata.extendRows(rows);
+ setMetadata(metadata);
+ callback && callback(true);
+ }).catch(error => {
+ const errorMsg = getErrorMsg(error);
+ toaster.danger(gettext(errorMsg));
+ callback && callback(false);
+ });
+
+ }, [metadata]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useMetadata = () => {
+ const context = useContext(MetadataContext);
+ if (!context) {
+ throw new Error('\'MetadataContext\' is null');
+ }
+ const { isLoading, metadata, extendMetadataRows } = context;
+ return { isLoading, metadata, extendMetadataRows };
+};
diff --git a/frontend/src/metadata/metadata-view/hooks/record-details.js b/frontend/src/metadata/metadata-view/hooks/record-details.js
new file mode 100644
index 0000000000..639d4fcf22
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/hooks/record-details.js
@@ -0,0 +1,34 @@
+/* eslint-disable react/prop-types */
+import React, { useContext, useState, useCallback } from 'react';
+
+const RecordDetailsContext = React.createContext(null);
+
+export const RecordDetailsProvider = ({ children }) => {
+ const [isShowRecordDetails, setIsShowRecordDetails] = useState(false);
+ const [recordDetails, setRecordDetails] = useState({});
+
+ const openRecordDetails = useCallback((recordDetails) => {
+ setRecordDetails(recordDetails);
+ setIsShowRecordDetails(true);
+ }, []);
+
+ const closeRecordDetails = useCallback(() => {
+ setRecordDetails({});
+ setIsShowRecordDetails(false);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useRecordDetails = () => {
+ const context = useContext(RecordDetailsContext);
+ if (!context) {
+ throw new Error('\'RecordDetailsContext\' is null');
+ }
+ const { isShowRecordDetails, recordDetails, openRecordDetails, closeRecordDetails } = context;
+ return { isShowRecordDetails, recordDetails, openRecordDetails, closeRecordDetails };
+};
diff --git a/frontend/src/metadata/metadata-view/index.js b/frontend/src/metadata/metadata-view/index.js
new file mode 100644
index 0000000000..5fc62cf5a5
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/index.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MetadataProvider, CollaboratorsProvider, RecordDetailsProvider } from './hooks/index';
+import { Table } from './components/index';
+
+const SeafileMetadata = ({ collaborators, collaboratorsCache, updateCollaboratorsCache, ...params }) => {
+ const collaboratorsProviderProps = {
+ collaborators,
+ collaboratorsCache,
+ updateCollaboratorsCache,
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
+
+SeafileMetadata.propTypes = {
+ collaborators: PropTypes.array,
+ collaboratorsCache: PropTypes.object,
+ updateCollaboratorsCache: PropTypes.func,
+};
+
+export default SeafileMetadata;
diff --git a/frontend/src/metadata/metadata-view/model/index.js b/frontend/src/metadata/metadata-view/model/index.js
new file mode 100644
index 0000000000..ede00b259e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/model/index.js
@@ -0,0 +1,7 @@
+import Metadata from './metadata';
+import User from './user';
+
+export {
+ Metadata,
+ User,
+};
diff --git a/frontend/src/metadata/metadata-view/model/metadata/index.js b/frontend/src/metadata/metadata-view/model/metadata/index.js
new file mode 100644
index 0000000000..36cd21496f
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/model/metadata/index.js
@@ -0,0 +1,33 @@
+class Metadata {
+ constructor(object) {
+ this.columns = object.columns || [];
+ this.rows = object.rows || [];
+ this.id_row_map = {};
+ this.row_ids = [];
+ this.rows.forEach(record => {
+ this.row_ids.push(record._id);
+ this.id_row_map[record._id] = record;
+ });
+
+ this.hasMore = object.hasMore || false;
+ this.recordsCount = object.recordsCount || this.row_ids.length;
+ this.page = 1;
+ this.perPageCount = 1000;
+ }
+
+ extendRows = (rows) => {
+ if (!Array.isArray(rows) || rows.length === 0) {
+ this.hasMore = false;
+ return;
+ }
+
+ this.rows.push(...rows);
+ rows.forEach(record => {
+ this.row_ids.push(record._id);
+ this.id_row_map[record._id] = record;
+ });
+ };
+
+}
+
+export default Metadata;
diff --git a/frontend/src/metadata/metadata-view/model/user.js b/frontend/src/metadata/metadata-view/model/user.js
new file mode 100644
index 0000000000..f41a8888e2
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/model/user.js
@@ -0,0 +1,12 @@
+class User {
+ constructor(object) {
+ this.avatar_url = object.avatar_url || '';
+ this.contact_email = object.contact_email || '';
+ this.email = object.email || '';
+ this.name = object.name || '';
+ this.name_pinyin = object.name_pinyin || '';
+ this.id = object.id_in_org || '';
+ }
+}
+
+export default User;
diff --git a/frontend/src/metadata/metadata-view/utils/cell-comparer.js b/frontend/src/metadata/metadata-view/utils/cell-comparer.js
new file mode 100644
index 0000000000..1a6b633c73
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/cell-comparer.js
@@ -0,0 +1,28 @@
+import { CellType, isEmptyObject } from '../_basic';
+import ObjectUtils from './object-utils';
+
+export const isCellValueChanged = (oldVal, newVal, columnType) => {
+ if (oldVal === newVal) {
+ return false;
+ }
+ if (oldVal === undefined || oldVal === null) {
+ if (columnType === CellType.GEOLOCATION && isEmptyObject(newVal)) {
+ return false;
+ }
+ if ((columnType === CellType.DATE || columnType === CellType.NUMBER || columnType === CellType.AUTO_NUMBER) && newVal === null) {
+ return false;
+ }
+ if (Array.isArray(newVal)) {
+ return newVal.length !== 0;
+ }
+ return newVal !== false && newVal !== '';
+ }
+ if (Array.isArray(oldVal) && Array.isArray(newVal)) {
+ // [{}].toString(): [object Object]
+ return JSON.stringify(oldVal) !== JSON.stringify(newVal);
+ }
+ if (typeof oldVal === 'object' && typeof newVal === 'object' && newVal !== null) {
+ return !ObjectUtils.isSameObject(oldVal, newVal);
+ }
+ return oldVal !== newVal;
+};
diff --git a/frontend/src/metadata/metadata-view/utils/cell-format-utils.js b/frontend/src/metadata/metadata-view/utils/cell-format-utils.js
new file mode 100644
index 0000000000..a4fda9cafe
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/cell-format-utils.js
@@ -0,0 +1,79 @@
+import dayjs from 'dayjs';
+import {
+ CellType,
+} from '../_basic';
+
+const getAutoTimeDisplayString = (autoTime) => {
+ if (!autoTime) {
+ return null;
+ }
+ const date = dayjs(autoTime);
+ if (!date.isValid()) return autoTime;
+ return date.format('YYYY-MM-DD HH:mm:ss');
+};
+
+export const getClientCellValueDisplayString = (row, type, key, { data, collaborators = [] } = {}) => {
+ const cellValue = row[key];
+ if (type === CellType.CTIME || type === CellType.MTIME) {
+ return getAutoTimeDisplayString(cellValue);
+ }
+ return row[key];
+};
+
+export const getFormatRowData = (columns, rowData) => {
+ let keyColumnMap = {};
+ columns.forEach(column => {
+ keyColumnMap[column.key] = column;
+ });
+ return convertedToRecordData(rowData, keyColumnMap);
+};
+
+export const getFormattedRowsData = (rowsData, columns, excludesColumnTypes) => {
+ let keyColumnMap = {};
+ columns.forEach(column => {
+ keyColumnMap[column.key] = column;
+ });
+ return rowsData.map(rowData => {
+ let formattedRowsData = convertedToRecordData(rowData, keyColumnMap, excludesColumnTypes);
+ if (rowData._id) {
+ formattedRowsData._id = rowData._id;
+ }
+ if (Object.prototype.hasOwnProperty.call(rowData, '_archived')) {
+ formattedRowsData._archived = rowData._archived ? 'true' : 'false';
+ }
+ return formattedRowsData;
+ });
+};
+
+// { [column.key]: cellValue } -> { [column.name]: cellValue }
+// { [option-column.key]: option.id } -> { [option-column.name]: option.name }
+function convertedToRecordData(rowData, keyColumnMap, excludesColumnTypes = []) {
+ if (!rowData || !keyColumnMap) {
+ return {};
+ }
+ let recordData = {};
+ Object.keys(rowData).forEach(key => {
+ const column = keyColumnMap[key];
+ if (!column) {
+ return;
+ }
+
+ const { name: colName, type } = column;
+ if (excludesColumnTypes && excludesColumnTypes.includes(type)) {
+ return;
+ }
+
+ let cellValue = rowData[key];
+ recordData[colName] = cellValue;
+ switch (type) {
+ case CellType.TEXT: {
+ recordData[colName] = typeof cellValue === 'string' ? cellValue.trim() : '';
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ });
+ return recordData;
+}
diff --git a/frontend/src/metadata/metadata-view/utils/cell-value-utils.js b/frontend/src/metadata/metadata-view/utils/cell-value-utils.js
new file mode 100644
index 0000000000..afe163f8db
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/cell-value-utils.js
@@ -0,0 +1,14 @@
+class CellValueUtils {
+
+ isValidCellValue = (value) => {
+ if (value === undefined) return false;
+ if (value === null) return false;
+ if (value === '') return false;
+ if (JSON.stringify(value) === '{}') return false;
+ if (JSON.stringify(value) === '[]') return false;
+ return true;
+ };
+
+}
+
+export default CellValueUtils;
diff --git a/frontend/src/metadata/metadata-view/utils/column-utils.js b/frontend/src/metadata/metadata-view/utils/column-utils.js
new file mode 100644
index 0000000000..09ac05b798
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/column-utils.js
@@ -0,0 +1,176 @@
+import {
+ CellType,
+ DEFAULT_DATE_FORMAT,
+} from '../_basic';
+import {
+ SEQUENCE_COLUMN_WIDTH
+} from '../constants';
+
+export function getSelectColumnOptions(column) {
+ if (!column || !column.data || !Array.isArray(column.data.options)) {
+ return [];
+ }
+ return column.data.options;
+}
+
+export function getDateColumnFormat(column) {
+ const format = (column && column.data && column.data.format) ? column.data.format : DEFAULT_DATE_FORMAT;
+ // Old Europe format is D/M/YYYY new format is DD/MM/YYYY
+ return format;
+}
+
+export function isCheckboxColumn(column) {
+ let { type } = column;
+ return type === CellType.CHECKBOX;
+}
+
+export function getColumnByKey(columnKey, columns) {
+ if (!columnKey || !Array.isArray(columns)) {
+ return null;
+ }
+ return columns.find(column => column.key === columnKey);
+}
+
+export function getColumnByName(columnName, columns) {
+ if (!columnName || !Array.isArray(columns)) {
+ return null;
+ }
+ return columns.find(column => column.name === columnName);
+}
+
+export function getColumnByType(columnType, columns) {
+ if (!columnType || !Array.isArray(columns)) {
+ return null;
+ }
+ return columns.find(column => column.type === columnType);
+}
+
+export function getColumnByIndex(index, columns) {
+ if (Array.isArray(columns)) {
+ return columns[index];
+ }
+ if (typeof Immutable !== 'undefined') {
+ return columns.get(index);
+ }
+ return null;
+}
+
+export const getColumnWidth = (column) => {
+ let { type } = column;
+ switch (type) {
+ case CellType.CTIME:
+ case CellType.MTIME: {
+ return 160;
+ }
+ default: {
+ return 100;
+ }
+ }
+};
+
+export const isNameColumn = (column) => {
+ return column.key === '0000';
+};
+
+export const handleCascadeColumn = (optionValue, columnKey, columns, row, updated = {}, processedColumns = new Set()) => {
+ // This column has already been processed, avoid circular dependency.
+ if (!Array.isArray(columns) || processedColumns.has(columnKey)) {
+ return updated;
+ }
+ processedColumns.add(columnKey);
+ const singleSelectColumns = columns.filter(column => column.type === CellType.SINGLE_SELECT);
+ for (let i = 0; i < singleSelectColumns.length; i++) {
+ const singleSelectColumn = singleSelectColumns[i];
+ const { data: { cascade_column_key, cascade_settings } } = singleSelectColumn;
+ if (cascade_column_key === columnKey) {
+ const { key: childColumnKey } = singleSelectColumn;
+ const childColumnOptions = cascade_settings[optionValue];
+ const childColumnCellValue = row[childColumnKey];
+ const cellValueInOptions = childColumnOptions && childColumnOptions.includes(childColumnCellValue);
+ if (!cellValueInOptions) {
+ updated[childColumnKey] = '';
+ handleCascadeColumn('', childColumnKey, columns, row, updated, processedColumns);
+ }
+ }
+ }
+ return updated;
+};
+
+export const isFrozen = (column) => {
+ if (!column) return false;
+ return column.frozen === true;
+};
+
+export const findLastFrozenColumnIndex = (columns) => {
+ for (let i = 0; i < columns.length; i++) {
+ if (isFrozen(columns[i])) {
+ return i;
+ }
+ }
+ return -1;
+};
+
+export const setColumnOffsets = (columns) => {
+ let nextColumns = [];
+ let left = 0;
+ columns.forEach((column) => {
+ nextColumns.push({ ...column, left });
+ left += column.width;
+ });
+ return nextColumns;
+};
+
+export function isColumnSupportEdit(cell, columns) {
+ const column = columns[cell.idx];
+ if (column?.type === CellType.LINK_FORMULA && [CellType.IMAGE, CellType.FILE].includes(column?.data?.array_type)) {
+ return true;
+ }
+ return false;
+}
+
+export function isColumnSupportDirectEdit(cell, columns) {
+ const column = columns[cell.idx];
+ return [].includes(column?.type);
+}
+
+const _getCustomColumnsWidth = () => {
+ // todo
+ return {};
+};
+
+export const recalculate = (columns, allColumns, tableId) => {
+ const displayColumns = columns;
+ const displayAllColumns = allColumns;
+ const pageColumnsWidth = _getCustomColumnsWidth(); // get columns width from local storage
+ const totalWidth = displayColumns.reduce((total, column) => {
+ const key = `${tableId}-${column.key}`;
+ const width = pageColumnsWidth[key] || column.width;
+ total += width;
+ return total;
+ }, 0);
+ let left = SEQUENCE_COLUMN_WIDTH;
+ const frozenColumns = displayColumns.filter(c => isFrozen(c));
+ const frozenColumnsWidth = frozenColumns.reduce((w, column) => {
+ const key = `${tableId}-${column.key}`;
+ const width = pageColumnsWidth[key] || column.width;
+ return w + width;
+ }, 0);
+ const lastFrozenColumnKey = frozenColumnsWidth > 0 ? frozenColumns[frozenColumns.length - 1].key : null;
+ const newColumns = displayColumns.map((column, index) => {
+ const key = `${tableId}-${column.key}`;
+ const width = pageColumnsWidth[key] || column.width;
+ column.idx = index; // set column idx
+ column.left = left; // set column offset
+ column.width = width;
+ left += width;
+ return column;
+ });
+
+ return {
+ totalWidth,
+ lastFrozenColumnKey,
+ frozenColumnsWidth,
+ columns: newColumns,
+ allColumns: displayAllColumns,
+ };
+};
diff --git a/frontend/src/metadata/metadata-view/utils/date-translate.js b/frontend/src/metadata/metadata-view/utils/date-translate.js
new file mode 100644
index 0000000000..01e30f6dbe
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/date-translate.js
@@ -0,0 +1,70 @@
+import { gettext, lang } from '../../../utils/constants';
+
+const zhCN = require('@seafile/seafile-calendar/lib/locale/zh_CN');
+const zhTW = require('@seafile/seafile-calendar/lib/locale/zh_TW');
+const enUS = require('@seafile/seafile-calendar/lib/locale/en_US');
+const frFR = require('@seafile/seafile-calendar/lib/locale/fr_FR');
+const deDE = require('@seafile/seafile-calendar/lib/locale/de_DE');
+const esES = require('@seafile/seafile-calendar/lib/locale/es_ES');
+const plPL = require('@seafile/seafile-calendar/lib/locale/pl_PL');
+const csCZ = require('@seafile/seafile-calendar/lib/locale/cs_CZ');
+const ruRU = require('@seafile/seafile-calendar/lib/locale/ru_RU');
+
+function translateCalendar() {
+ const locale = lang ? lang : 'en';
+ let language;
+ switch (locale) {
+ case 'zh-cn':
+ language = zhCN;
+ break;
+ case 'zh-tw':
+ language = zhTW;
+ break;
+ case 'en':
+ language = enUS;
+ break;
+ case 'fr':
+ language = frFR;
+ break;
+ case 'de':
+ language = deDE;
+ break;
+ case 'es':
+ language = esES;
+ break;
+ case 'es-ar':
+ language = esES;
+ break;
+ case 'es-mx':
+ language = esES;
+ break;
+ case 'pl':
+ language = plPL;
+ break;
+ case 'cs':
+ language = csCZ;
+ break;
+ case 'ru':
+ language = ruRU;
+ break;
+ default:
+ language = enUS;
+ }
+ return language;
+}
+
+function getMobileDatePickerLocale() {
+ return {
+ DatePickerLocale: {
+ year: gettext('Year'),
+ month: gettext('Month'),
+ day: gettext('Day'),
+ hour: gettext('Hour'),
+ minute: gettext('Minute'),
+ },
+ okText: gettext('Done'),
+ dismissText: gettext('Cancel')
+ };
+}
+
+export { translateCalendar, getMobileDatePickerLocale };
diff --git a/frontend/src/metadata/metadata-view/utils/dayjs.js b/frontend/src/metadata/metadata-view/utils/dayjs.js
new file mode 100644
index 0000000000..aa040de5ab
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/dayjs.js
@@ -0,0 +1,6 @@
+import dayjs from 'dayjs';
+import customParseFormat from 'dayjs/plugin/customParseFormat';
+
+dayjs.extend(customParseFormat);
+
+export default dayjs;
diff --git a/frontend/src/metadata/metadata-view/utils/event-bus.js b/frontend/src/metadata/metadata-view/utils/event-bus.js
new file mode 100644
index 0000000000..ceb58b75b5
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/event-bus.js
@@ -0,0 +1,28 @@
+class EventBus {
+ subscribers = {};
+
+ subscribe(type, handler) {
+ if (!this.subscribers[type]) {
+ this.subscribers[type] = [];
+ }
+
+ const handlers = this.subscribers[type];
+ handlers.push(handler);
+
+ return () => {
+ const index = handlers.indexOf(handler);
+ if (index > -1) {
+ handlers.splice(index, 1);
+ }
+ };
+ }
+
+ dispatch(type, ...data) {
+ const handlers = this.subscribers[type];
+ if (Array.isArray(handlers)) {
+ handlers.forEach(handler => handler(...data));
+ }
+ }
+}
+
+export default EventBus;
diff --git a/frontend/src/metadata/metadata-view/utils/filters-utils.js b/frontend/src/metadata/metadata-view/utils/filters-utils.js
new file mode 100644
index 0000000000..e6a8e03acd
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/filters-utils.js
@@ -0,0 +1,251 @@
+import {
+ CellType,
+ FILTER_PREDICATE_TYPE,
+ FILTER_COLUMN_OPTIONS,
+ FILTER_TERM_MODIFIER_TYPE,
+ filterTermModifierNotWithin,
+ filterTermModifierIsWithin,
+ isDateColumn,
+ FILTER_ERR_MSG,
+} from '../_basic';
+
+export const SPECIAL_TERM_TYPE = {
+ CREATOR: 'creator',
+ SINGLE_SELECT: 'single_select',
+ MULTIPLE_SELECT: 'multiple_select',
+ COLLABORATOR: 'collaborator',
+ RATE: 'rate'
+};
+
+export const SIMPLE_TEXT_INPUT_COLUMNS_MAP = {
+ [CellType.TEXT]: true,
+ [CellType.URL]: true,
+};
+
+export const DATE_LABEL_MAP = {
+ [FILTER_TERM_MODIFIER_TYPE.EXACT_DATE]: true,
+ [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_AGO]: true,
+ [FILTER_TERM_MODIFIER_TYPE.NUMBER_OF_DAYS_FROM_NOW]: true,
+ [FILTER_TERM_MODIFIER_TYPE.THE_NEXT_NUMBERS_OF_DAYS]: true,
+ [FILTER_TERM_MODIFIER_TYPE.THE_PAST_NUMBERS_OF_DAYS]: true,
+};
+
+export const ARRAY_PREDICATE = {
+ [FILTER_PREDICATE_TYPE.IS_ANY_OF]: true,
+ [FILTER_PREDICATE_TYPE.IS_NONE_OF]: true,
+ [FILTER_PREDICATE_TYPE.HAS_ANY_OF]: true,
+ [FILTER_PREDICATE_TYPE.HAS_ALL_OF]: true,
+ [FILTER_PREDICATE_TYPE.HAS_NONE_OF]: true,
+ [FILTER_PREDICATE_TYPE.IS_EXACTLY]: true,
+};
+
+const STRING_PREDICATE = {
+ [FILTER_PREDICATE_TYPE.IS]: true,
+ [FILTER_PREDICATE_TYPE.IS_NOT]: true
+};
+
+export const DATE_EMPTY_LABEL_MAP = {
+ [FILTER_PREDICATE_TYPE.EMPTY]: true,
+ [FILTER_PREDICATE_TYPE.NOT_EMPTY]: true,
+};
+
+export const FILTER_ERR_MSG_LIST = [
+ FILTER_ERR_MSG.INVALID_FILTER,
+ FILTER_ERR_MSG.INCOMPLETE_FILTER,
+ FILTER_ERR_MSG.COLUMN_MISSING,
+ FILTER_ERR_MSG.COLUMN_NOT_SUPPORTED,
+ FILTER_ERR_MSG.UNMATCHED_PREDICATE,
+ FILTER_ERR_MSG.UNMATCHED_MODIFIER,
+ FILTER_ERR_MSG.INVALID_TERM,
+];
+
+const MULTIPLE_SELECTOR_COLUMNS = [CellType.CREATOR, CellType.LAST_MODIFIER];
+
+export const isFilterTermArray = (column, filterPredicate) => {
+ const { type } = column;
+ if (MULTIPLE_SELECTOR_COLUMNS.includes(type)) {
+ return true;
+ }
+ return false;
+};
+
+export const getUpdatedFilterByCreator = (filter, collaborator) => {
+ const multipleSelectType = [FILTER_PREDICATE_TYPE.CONTAINS, FILTER_PREDICATE_TYPE.NOT_CONTAIN];
+ let { filter_predicate, filter_term: filterTerm } = filter;
+ if (multipleSelectType.includes(filter_predicate)) {
+ filterTerm = filterTerm ? filter.filter_term.slice(0) : [];
+ let selectedEmail = collaborator.email;
+ let collaborator_index = filterTerm.indexOf(selectedEmail);
+ if (collaborator_index > -1) {
+ filterTerm.splice(collaborator_index, 1);
+ } else {
+ filterTerm.push(selectedEmail);
+ }
+ } else {
+ if (filterTerm[0] === collaborator.email) {
+ return;
+ }
+ filterTerm = [collaborator.email];
+ }
+ return Object.assign({}, filter, { filter_term: filterTerm });
+};
+
+export const getUpdatedFilterBySelectSingle = (filter, columnOption) => {
+ let new_filter_term;
+ // if predicate is any of / is none of, filter_term is array; else filter_term is string
+ if (ARRAY_PREDICATE[filter.filter_predicate]) {
+ new_filter_term = Array.isArray(filter.filter_term) ? [...filter.filter_term] : [];
+ const index = new_filter_term.indexOf(columnOption.id);
+ if (index === -1) {
+ new_filter_term.push(columnOption.id);
+ } else {
+ new_filter_term.splice(index, 1);
+ }
+ } else {
+ new_filter_term = columnOption.id;
+ }
+ return Object.assign({}, filter, { filter_term: new_filter_term });
+};
+
+export const getUpdatedFilterBySelectMultiple = (filter, columnOption) => {
+ let filterTerm = filter.filter_term ? filter.filter_term : [];
+ let index = filterTerm.indexOf(columnOption.id);
+ if (index > -1) {
+ filterTerm.splice(index, 1);
+ } else {
+ filterTerm.push(columnOption.id);
+ }
+ return Object.assign({}, filter, { filter_term: filterTerm });
+};
+
+export const getUpdatedFilterByCollaborator = (filter, collaborator) => {
+ let filterTerm = filter.filter_term ? filter.filter_term.slice(0) : [];
+ let selectedEmail = collaborator.email;
+ let collaborator_index = filterTerm.indexOf(selectedEmail);
+ if (collaborator_index > -1) {
+ filterTerm.splice(collaborator_index, 1);
+ } else {
+ filterTerm.push(selectedEmail);
+ }
+ return Object.assign({}, filter, { filter_term: filterTerm });
+};
+
+export const getUpdatedFilterByRate = (filter, value) => {
+ if (filter.filter_term === value) {
+ return Object.assign({}, filter, { filter_term: 0 });
+ }
+ return Object.assign({}, filter, { filter_term: value });
+};
+
+export const getColumnOptions = (column) => {
+ const { type } = column;
+ return FILTER_COLUMN_OPTIONS[type] || {};
+};
+
+export const getFilterByColumn = (column, filter = {}) => {
+ let { filterPredicateList } = getColumnOptions(column);
+ if (!filterPredicateList) return;
+ let filterPredicate = filterPredicateList[0];
+
+ let updatedFilter = Object.assign({}, filter, { column_key: column.key, filter_predicate: filterPredicate });
+
+ // text | number | long-text | url | email
+ // auto-number | geolocation | duration
+ updatedFilter.filter_term = '';
+
+ // single-select | multiple-select | collaborators | creator | last-modifier
+ if (isFilterTermArray(column, filterPredicate)) {
+ updatedFilter.filter_term = [];
+ return updatedFilter;
+ }
+ // date | ctime | mtime
+ if (isDateColumn(column)) {
+ let filterTermModifier = filterPredicate === FILTER_PREDICATE_TYPE.IS_WITHIN ? filterTermModifierIsWithin[0] : filterTermModifierNotWithin[0];
+ updatedFilter.filter_term_modifier = filterTermModifier;
+ updatedFilter.filter_term = '';
+ return updatedFilter;
+ }
+
+ return updatedFilter;
+};
+
+// file, image : not support
+// text, long-text, number, single-select, date, ctime, mtime, formula, link, geolocation : string
+// checkbox : boolean
+// multiple-select, collaborator, creator, last modifier : array
+
+export const getUpdatedFilterByColumn = (filters, filterIndex, column) => {
+ const filter = filters[filterIndex];
+ if (filter.column_key === column.key) {
+ return;
+ }
+ return getFilterByColumn(column, filter);
+};
+
+export const getUpdatedFilterByPredicate = (filter, column, filterPredicate) => {
+ let updatedFilter = Object.assign({}, filter, { filter_predicate: filterPredicate });
+ let { type: columnType } = column;
+ if ([CellType.CREATOR, CellType.LAST_MODIFIER].includes(columnType)) {
+ if (STRING_PREDICATE[filter.filter_predicate] !== STRING_PREDICATE[filterPredicate]
+ || filterPredicate === FILTER_PREDICATE_TYPE.INCLUDE_ME
+ ) {
+ updatedFilter.filter_term = [];
+ }
+ }
+ if (isFilterTermArray(column, filterPredicate)) {
+ if (DATE_EMPTY_LABEL_MAP[filterPredicate] || filterPredicate === FILTER_PREDICATE_TYPE.INCLUDE_ME) {
+ updatedFilter.filter_term = [];
+ }
+ return updatedFilter;
+ }
+ if (isDateColumn(column)) {
+ let filterTermModifier = filterPredicate === FILTER_PREDICATE_TYPE.IS_WITHIN ? filterTermModifierIsWithin[0] : filterTermModifierNotWithin[0];
+ updatedFilter.filter_term_modifier = filterTermModifier;
+ return updatedFilter;
+ }
+
+ return updatedFilter;
+};
+
+export const getUpdatedFilterByTermModifier = (filter, filterTermModifier) => {
+ if (filter.filter_term_modifier === filterTermModifier) {
+ return;
+ }
+ return Object.assign({}, filter, { filter_term_modifier: filterTermModifier });
+};
+
+export const getUpdatedFilterByNormalTerm = (filter, column, filterIndex, event) => {
+ let filterTerm;
+ if (column.type === CellType.CHECKBOX) {
+ filterTerm = event.target.checked;
+ } else {
+ filterTerm = event.target.value;
+ }
+ if (filter.filter_term === filterTerm) {
+ return filter;
+ }
+ return Object.assign({}, filter, { filter_term: filterTerm });
+};
+
+export const getUpdatedFilterBySpecialTerm = (filter, type, value) => {
+ switch (type) {
+ case SPECIAL_TERM_TYPE.CREATOR: {
+ return getUpdatedFilterByCreator(filter, value);
+ }
+ case SPECIAL_TERM_TYPE.SINGLE_SELECT: {
+ return getUpdatedFilterBySelectSingle(filter, value);
+ }
+ case SPECIAL_TERM_TYPE.MULTIPLE_SELECT: {
+ return getUpdatedFilterBySelectMultiple(filter, value);
+ }
+ case SPECIAL_TERM_TYPE.COLLABORATOR: {
+ return getUpdatedFilterByCollaborator(filter, value);
+ }
+ case SPECIAL_TERM_TYPE.RATE: {
+ return getUpdatedFilterByRate(filter, value);
+ }
+ default: {
+ return filter;
+ }
+ }
+};
diff --git a/frontend/src/metadata/metadata-view/utils/get-event-transfer.js b/frontend/src/metadata/metadata-view/utils/get-event-transfer.js
new file mode 100644
index 0000000000..f6a4e5997c
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/get-event-transfer.js
@@ -0,0 +1,107 @@
+import { TRANSFER_TYPES } from '../constants';
+
+const { FRAGMENT, HTML, TEXT } = TRANSFER_TYPES;
+
+function getEventTransfer(event) {
+ const transfer = event.dataTransfer || event.clipboardData;
+ let dtableFragment = getType(transfer, FRAGMENT);
+ let html = getType(transfer, HTML);
+ let text = getType(transfer, TEXT);
+ let files = getFiles(transfer);
+
+ // paste sf-metadata
+ if (dtableFragment) {
+ return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: JSON.parse(dtableFragment), type: TRANSFER_TYPES.DTABLE_FRAGMENT };
+ }
+
+ // paste html
+ if (html) {
+ let copiedTableNode = (new DOMParser()).parseFromString(html, HTML).querySelector('table');
+ if (copiedTableNode) {
+ return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: html2TableFragment(copiedTableNode), html, text, type: 'html' };
+ }
+ return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), html, text, type: 'html' };
+ }
+
+ // paste local picture or other files here
+ if (files && files.length) {
+ return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), 'files': files, type: 'files' };
+ }
+
+ // paste text
+ if (text) {
+ return { [TRANSFER_TYPES.DTABLE_FRAGMENT]: text2TableFragment(text), text, type: 'text' };
+ }
+}
+
+function getType(transfer, type) {
+ if (!transfer.types || !transfer.types.length) {
+ // COMPAT: In IE 11, there is no `types` field but `getData('Text')`
+ // is supported`. (2017/06/23)
+ return type === TEXT ? transfer.getData('Text') || null : null;
+ }
+
+ return transfer.getData(type);
+}
+
+function text2TableFragment(data) {
+ let formattedData = data ? data.replace(/\r/g, '') : '';
+ let dataSplitted = formattedData.split('\n');
+ let rowSplitted = dataSplitted[0].split('\t');
+ let copiedColumns = rowSplitted.map((value, j) => ({ key: `col${j}`, type: 'text' }));
+ let copiedRecords = [];
+ dataSplitted.forEach((row) => {
+ let obj = {};
+ if (row) {
+ row = row.split('\t');
+ row.forEach((col, j) => {
+ obj[`col${j}`] = col;
+ });
+ }
+ copiedRecords.push(obj);
+ });
+
+ return { copiedRecords, copiedColumns };
+}
+
+function html2TableFragment(tableNode) {
+ let trs = tableNode.querySelectorAll('tr');
+ let tds = trs[0].querySelectorAll('td');
+ let copiedColumns = [];
+ let copiedRecords = [];
+ tds.forEach((td, i) => {
+ copiedColumns.push({ key: `col${i}`, type: 'text' });
+ });
+ trs.forEach((tr) => {
+ let row = {};
+ let cells = tr.querySelectorAll('td');
+ cells.forEach((cell, i) => {
+ row[`col${i}`] = cell.innerText;
+ });
+ copiedRecords.push(row);
+ });
+ return { copiedRecords, copiedColumns };
+}
+
+function getFiles(transfer) {
+ let files;
+ try {
+ // Get and normalize files if they exist.
+ if (transfer.items && transfer.items.length) {
+ files = Array.from(transfer.items)
+ .map(item => (item.kind === 'file' ? item.getAsFile() : null))
+ .filter(exists => exists);
+ } else if (transfer.files && transfer.files.length) {
+ files = Array.from(transfer.files);
+ }
+ } catch (err) {
+ if (transfer.files && transfer.files.length) {
+ files = Array.from(transfer.files);
+ }
+ }
+ return files;
+}
+
+export { text2TableFragment };
+
+export default getEventTransfer;
diff --git a/frontend/src/metadata/metadata-view/utils/grid-utils.js b/frontend/src/metadata/metadata-view/utils/grid-utils.js
new file mode 100644
index 0000000000..d434d545fd
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/grid-utils.js
@@ -0,0 +1,404 @@
+import dayjs from 'dayjs';
+import {
+ CellType,
+ NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP,
+} from '../_basic';
+import { getColumnByIndex } from './column-utils';
+import { NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES, TRANSFER_TYPES } from '../constants';
+import { getGroupRecordByIndex } from './group-metrics';
+
+const NORMAL_RULE = ({ value }) => {
+ return value;
+};
+
+const isCopyPaste = true;
+
+class GridUtils {
+
+ constructor(metadata, api) {
+ this.metadata = metadata;
+ this.api = api;
+ }
+
+ getCopiedContent({ type, copied, isGroupView }) {
+ // copy from internal grid
+ if (type === TRANSFER_TYPES.DTABLE_FRAGMENT) {
+ const { shownColumns: columns } = this.tablePage.state;
+ const { selectedRecordIds, copiedRange } = copied;
+
+ // copy from selected rows
+ if (Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0) {
+ return {
+ copiedRecords: selectedRecordIds.map(recordId => this.tablePage.recordGetterById(recordId)),
+ copiedColumns: [...columns],
+ };
+ }
+
+ // copy from selected range
+ let copiedRecords = [];
+ let copiedColumns = [];
+ const { topLeft, bottomRight } = copiedRange;
+ const { rowIdx: minRecordIndex, idx: minColumnIndex, groupRecordIndex: minGroupRecordIndex } = topLeft;
+ const { rowIdx: maxRecordIndex, idx: maxColumnIndex } = bottomRight;
+ let currentGroupIndex = minGroupRecordIndex;
+ for (let i = minRecordIndex; i <= maxRecordIndex; i++) {
+ copiedRecords.push(this.tablePage.recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupIndex, recordIndex: i }));
+ if (isGroupView) {
+ currentGroupIndex++;
+ }
+ }
+ for (let i = minColumnIndex; i <= maxColumnIndex; i++) {
+ copiedColumns.push(getColumnByIndex(i, columns));
+ }
+ return { copiedRecords, copiedColumns };
+ }
+
+ // copy from other external apps as default
+ const { copiedRecords, copiedColumns } = copied;
+ return { copiedRecords, copiedColumns };
+ }
+
+ async paste({ copied, multiplePaste, pasteRange, isGroupView }) {
+ const { row_ids: renderRecordIds, columns } = this.metadata;
+ const { topLeft, bottomRight = {} } = pasteRange;
+ const { rowIdx: startRecordIndex, idx: startColumnIndex, groupRecordIndex } = topLeft;
+ const { rowIdx: endRecordIndex, idx: endColumnIndex } = bottomRight;
+ const { copiedRecords, copiedColumns } = copied;
+ const copiedRecordsLen = copiedRecords.length;
+ const copiedColumnsLen = copiedColumns.length;
+ const pasteRecordsLen = multiplePaste ? endRecordIndex - startRecordIndex + 1 : copiedRecordsLen;
+ const pasteColumnsLen = multiplePaste ? endColumnIndex - startColumnIndex + 1 : copiedColumnsLen;
+ const renderRecordsCount = renderRecordIds.length;
+
+ // need expand records
+ const startExpandRecordIndex = renderRecordsCount - startRecordIndex;
+ if ((copiedRecordsLen > startExpandRecordIndex)) return;
+
+ let updateRecordIds = [];
+ let idRecordUpdates = {};
+ let idOriginalRecordUpdates = {};
+ let idOldRecordData = {};
+ let idOriginalOldRecordData = {};
+ let currentGroupRecordIndex = groupRecordIndex;
+ for (let i = 0; i < pasteRecordsLen; i++) {
+ const pasteRecord = this.tablePage.recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRecordIndex, recordIndex: startRecordIndex + i });
+ if (isGroupView) {
+ currentGroupRecordIndex++;
+ }
+ if (!pasteRecord) {
+ continue;
+ }
+ const updateRecordId = pasteRecord._id;
+ const copiedRecordIndex = i % copiedRecordsLen;
+ const copiedRecord = copiedRecords[copiedRecordIndex];
+ let originalUpdate = {};
+ let originalOldRecordData = {};
+ for (let j = 0; j < pasteColumnsLen; j++) {
+ const pasteColumn = getColumnByIndex(j + startColumnIndex, columns);
+ if (!pasteColumn || NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[pasteColumn.type]) {
+ continue;
+ }
+ const copiedColumnIndex = j % copiedColumnsLen;
+ const copiedColumn = getColumnByIndex(copiedColumnIndex, copiedColumns);
+ const { key: pasteColumnKey } = pasteColumn;
+ const { key: copiedColumnKey } = copiedColumn;
+ const pasteCellValue = Object.prototype.hasOwnProperty.call(pasteRecord, pasteColumnKey) ? pasteRecord[pasteColumnKey] : null;
+ const copiedCellValue = Object.prototype.hasOwnProperty.call(copiedRecord, copiedColumnKey) ? copiedRecord[copiedColumnKey] : null;
+ const update = this.convertCellValue(copiedCellValue, pasteCellValue, pasteColumn, copiedColumn);
+ if (update === pasteCellValue) {
+ continue;
+ }
+ originalUpdate[pasteColumnKey] = update;
+ originalOldRecordData[pasteColumnKey] = pasteCellValue;
+ }
+
+ if (Object.keys(originalUpdate).length > 0) {
+ updateRecordIds.push(updateRecordId);
+ const update = originalUpdate;
+ const oldRecordData = originalOldRecordData;
+ idRecordUpdates[updateRecordId] = update;
+ idOriginalRecordUpdates[updateRecordId] = originalUpdate;
+ idOldRecordData[updateRecordId] = oldRecordData;
+ idOriginalOldRecordData[updateRecordId] = originalOldRecordData;
+ }
+ }
+
+ if (updateRecordIds.length === 0) return;
+ this.modifyRecords(updateRecordIds, idRecordUpdates, idOriginalRecordUpdates, idOldRecordData, idOriginalOldRecordData, isCopyPaste);
+ }
+
+ getLinkedRowsIdsByNameColumn(linkedTableRows, linkColumnKey, cellValue, linkItem) {
+ if (!Array.isArray(linkedTableRows) || linkedTableRows.length === 0) {
+ return [];
+ }
+ const cellValueStr = String(cellValue);
+
+ // 1、If all string match the corresponding row, return this row
+ const linkedRow = linkedTableRows.find(row => row['0000']?.trim() === cellValueStr.trim()) || null;
+ if (linkedRow) {
+ linkItem[linkColumnKey] = [{ display_value: cellValueStr, row_id: linkedRow._id }];
+ return [linkedRow._id];
+ }
+
+ // 2、If the string contains a comma, split into multiple substrings to match the corresponding rows
+ let linkedRowsIds = [];
+ if (cellValueStr.includes(',') || cellValueStr.includes(',')) {
+ const copiedNames = cellValueStr.split(/[,,]/).map(item => item.trim()).filter((value, index, self) => self.indexOf(value) === index);
+ if (!Array.isArray(copiedNames) || copiedNames.length === 0) {
+ return [];
+ }
+ linkItem[linkColumnKey] = [];
+ copiedNames.forEach((copiedName) => {
+ const linkedRow = linkedTableRows.find(row => row['0000']?.trim() === copiedName) || null;
+ if (linkedRow) {
+ linkItem[linkColumnKey].push({ display_value: copiedName, row_id: linkedRow._id });
+ linkedRowsIds.push(linkedRow._id);
+ }
+ });
+ }
+ return linkedRowsIds;
+ }
+
+ getUpdateDraggedRecords(draggedRange, shownColumns, rows, idRowMap, groupMetrics) {
+ let rowIds = [];
+ let updatedOriginalRows = {};
+ let oldOriginalRows = {};
+ const updatedRows = {};
+ const oldRows = {};
+ const { overRecordIdx, topLeft, bottomRight } = draggedRange;
+ let { idx: startColumnIdx } = topLeft;
+ let { idx: endColumnIdx, rowIdx: endRecordIdx, groupRecordIndex } = bottomRight;
+
+ let draggedRangeMatrix = this.getdraggedRangeMatrix(shownColumns, draggedRange, rows, groupMetrics, idRowMap);
+
+ let rules = this.getDraggedRangeRules(draggedRangeMatrix, shownColumns, startColumnIdx);
+
+ const selectedRowLength = draggedRangeMatrix[0].length;
+ let fillingIndex = draggedRangeMatrix[0].length;
+
+ // if group view then use index of gropRows which is different from the normal rows(they represent DOMs)
+ let currentGroupRowIndex = groupRecordIndex + 1;
+ for (let i = endRecordIdx + 1; i <= overRecordIdx; i++) {
+ let dragRow;
+ // find the row that need to be updated (it's draged)
+ if (currentGroupRowIndex) {
+ const groupRow = getGroupRecordByIndex(currentGroupRowIndex, groupMetrics);
+ dragRow = idRowMap[groupRow.rowId];
+ } else {
+ dragRow = rows[i];
+ }
+ let { _id: dragRowId, _locked } = dragRow;
+ fillingIndex++;
+ if (_locked) continue;
+ rowIds.push(dragRowId);
+
+ let idx = (i - endRecordIdx - 1) % selectedRowLength;
+
+ for (let j = startColumnIdx; j <= endColumnIdx; j++) {
+ let column = shownColumns[j];
+ let { key: cellKey, type, editable } = column;
+ if (editable && !NOT_SUPPORT_EDIT_COLUMN_TYPE_MAP[type] && !NOT_SUPPORT_DRAG_COPY_COLUMN_TYPES.includes(type)) {
+ let value = draggedRangeMatrix[j - startColumnIdx][idx];
+ let rule = rules[cellKey];
+ let fillingValue = rule({ n: fillingIndex - 1, value });
+ updatedOriginalRows[dragRowId] = Object.assign({}, updatedOriginalRows[dragRowId], { [cellKey]: fillingValue });
+ oldOriginalRows[dragRowId] = Object.assign({}, oldOriginalRows[dragRowId], { [cellKey]: dragRow[cellKey] });
+ // update: {[name]: value}
+ // originalUpdate: {[key]: id}
+ const update = updatedOriginalRows[dragRowId];
+ const oldUpdate = oldOriginalRows[dragRowId];
+
+ updatedRows[dragRowId] = Object.assign({}, updatedRows[dragRowId], update);
+ oldRows[dragRowId] = Object.assign({}, oldRows[dragRowId], oldUpdate);
+ }
+ }
+ currentGroupRowIndex++;
+ }
+
+ return { recordIds: rowIds, idOriginalRecordUpdates: updatedOriginalRows, idRecordUpdates: updatedRows, idOriginalOldRecordData: oldOriginalRows, idOldRecordData: oldRows };
+ }
+
+ getdraggedRangeMatrix(columns, draggedRange, rows, groupMetrics, idRowMap) {
+ let draggedRangeMatrix = [];
+ let { topLeft, bottomRight } = draggedRange;
+ let { idx: startColumnIdx, rowIdx: startRowIdx, groupRecordIndex } = topLeft;
+ let { idx: endColumnIdx, rowIdx: endRowIdx } = bottomRight;
+ for (let i = startColumnIdx; i <= endColumnIdx; i++) {
+ let currentGroupRecordIndex = groupRecordIndex;
+ draggedRangeMatrix[i - startColumnIdx] = [];
+ let column = columns[i];
+ let { key } = column;
+ for (let j = startRowIdx; j <= endRowIdx; j++) {
+ let selectedRecord;
+ if (currentGroupRecordIndex) {
+ const groupRecord = getGroupRecordByIndex(currentGroupRecordIndex, groupMetrics);
+ selectedRecord = idRowMap[groupRecord.rowId];
+ } else {
+ selectedRecord = rows[j];
+ }
+ draggedRangeMatrix[i - startColumnIdx][j - startRowIdx] = selectedRecord[key];
+ currentGroupRecordIndex++;
+ }
+ }
+ return draggedRangeMatrix;
+ }
+
+ getDraggedRangeRules(draggedRangeMatrix, columns, startColumnIdx) {
+ let draggedRangeRuleMatrix = {};
+ draggedRangeMatrix.forEach((valueList, i) => {
+ let column = columns[i + startColumnIdx];
+ let { type, data, key } = column;
+ let ruleMatrixItem = NORMAL_RULE;
+ if (valueList.length > 1) {
+ switch (type) {
+ case CellType.DATE: {
+ let format = data && data.format && data.format.indexOf('HH:mm') > -1 ? 'YYYY-MM-DD HH:mm' : 'YYYY-MM-DD';
+ let value0 = valueList[0];
+ let yearTolerance = this.getYearTolerance(valueList);
+ if (yearTolerance) {
+ ruleMatrixItem = ({ n }) => {
+ return dayjs(value0).add(n * yearTolerance, 'years').format(format);
+ };
+ break;
+ }
+ let monthTolerance = this.getMonthTolerance(valueList);
+ if (monthTolerance) {
+ ruleMatrixItem = ({ n }) => {
+ return dayjs(value0).add(n * monthTolerance, 'months').format(format);
+ };
+ break;
+ }
+ let dayTolerance = this.getDayTolerance(valueList);
+ if (dayTolerance) {
+ ruleMatrixItem = ({ n }) => {
+ let time = n * dayTolerance + this.getDateStringValue(value0);
+ return dayjs(time).format(format);
+ };
+ break;
+ }
+ break;
+ }
+ case CellType.NUMBER: {
+ ruleMatrixItem = this.getLeastSquares(valueList);
+ break;
+ }
+ case CellType.TEXT: {
+ ruleMatrixItem = this._getTextRule(valueList);
+ break;
+ }
+ case CellType.RATE: {
+ ruleMatrixItem = this.getRatingLeastSquares(valueList, data);
+ break;
+ }
+ default: {
+ ruleMatrixItem = NORMAL_RULE;
+ break;
+ }
+ }
+ }
+ draggedRangeRuleMatrix[key] = ruleMatrixItem;
+ });
+ return draggedRangeRuleMatrix;
+ }
+
+ getDateStringValue(date) {
+ let dateObject = dayjs(date);
+ return dateObject.isValid() ? dateObject.valueOf() : 0;
+ }
+
+ getYearTolerance(dateList) {
+ let date0 = dayjs(dateList[0]);
+ let date1 = dayjs(dateList[1]);
+ if (!date0.isValid() || !date1.isValid()) {
+ return 0;
+ }
+ if (date0.month() !== date1.month() || date0.date() !== date1.date()
+ || date0.hour() !== date1.hour() || date0.minute() !== date1.minute()) {
+ return 0;
+ }
+ let date0Year = date0.year();
+ let tolerance = date1.year() - date0Year;
+ let isYearArithmeticSequence = dateList.every((date, n) => {
+ let dateObject = dayjs(date);
+ if (!dateObject.isValid()) {
+ return false;
+ }
+ return dateObject.year() === n * tolerance + date0Year;
+ });
+ return isYearArithmeticSequence ? tolerance : 0;
+ }
+
+ getMonthTolerance(dateList) {
+ let date0 = dayjs(dateList[0]);
+ let date1 = dayjs(dateList[1]);
+ if (!date0.isValid() || !date1.isValid()) {
+ return 0;
+ }
+ if (date0.date() !== date1.date() || date0.hour() !== date1.hour() || date0.minute() !== date1.minute()) {
+ return 0;
+ }
+ let tolerance = (date1.month() - date0.month()) + (date1.year() - date0.year()) * 12;
+ let isMonthArithmeticSequence = dateList.every((date, i) => {
+ let month = i * tolerance;
+ let dateObject = dayjs(date);
+ if (!dateObject.isValid()) {
+ return false;
+ }
+ return dateObject.isSame(dayjs(dateList[0]).add(month, 'month'), 'minute');
+ });
+ return isMonthArithmeticSequence ? tolerance : 0;
+ }
+
+ getDayTolerance(dateList) {
+ let date0 = this.getDateStringValue(dateList[0]);
+ let tolerance = this.getDateStringValue(dateList[1]) - date0;
+ let isDayArithmeticSequence = dateList.every((date, i) => {
+ if (!dayjs(date).isValid()) {
+ return false;
+ }
+ return this.getDateStringValue(date) === i * tolerance + date0;
+ });
+ return isDayArithmeticSequence ? tolerance : 0;
+ }
+
+ getLeastSquares(numberList) {
+ let slope;
+ let intercept;
+ let xAverage;
+ let yAverage;
+ let xSum = 0;
+ let ySum = 0;
+ let xSquareSum = 0;
+ let xySum = 0;
+ let validCellsLen = 0;
+ let emptyCellPositions = [];
+ numberList.forEach((v, i) => {
+ if (v !== undefined && v !== null && v !== '') {
+ validCellsLen++;
+ xSum += i;
+ ySum += v;
+ xySum += (v * i);
+ xSquareSum += Math.pow(i, 2);
+ } else {
+ emptyCellPositions.push(i);
+ }
+ });
+ if (validCellsLen < 2) {
+ return NORMAL_RULE;
+ }
+ xAverage = xSum / validCellsLen;
+ yAverage = ySum / validCellsLen;
+ slope = (xySum - validCellsLen * xAverage * yAverage) / (xSquareSum - validCellsLen * Math.pow(xAverage, 2));
+ intercept = yAverage - slope * xAverage;
+ return ({ n }) => {
+ if (emptyCellPositions.length && emptyCellPositions.includes(n % numberList.length)) {
+ return '';
+ }
+ let y = n * slope + intercept;
+ return Number(parseFloat(y).toFixed(8));
+ };
+ }
+
+}
+
+export default GridUtils;
diff --git a/frontend/src/metadata/metadata-view/utils/grid.js b/frontend/src/metadata/metadata-view/utils/grid.js
new file mode 100644
index 0000000000..3a7d1eae68
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/grid.js
@@ -0,0 +1,9 @@
+import { OVER_SCAN_COLUMNS } from '../constants';
+
+export const getColOverScanStartIdx = (colVisibleStartIdx) => {
+ return Math.max(0, Math.floor(colVisibleStartIdx / 10) * 10 - OVER_SCAN_COLUMNS);
+};
+
+export const getColOverScanEndIdx = (colVisibleEndIdx, totalNumberColumns) => {
+ return Math.min(Math.ceil(colVisibleEndIdx / 10) * 10 + OVER_SCAN_COLUMNS, totalNumberColumns);
+};
diff --git a/frontend/src/metadata/metadata-view/utils/group-metrics.js b/frontend/src/metadata/metadata-view/utils/group-metrics.js
new file mode 100644
index 0000000000..d85073ce05
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/group-metrics.js
@@ -0,0 +1,183 @@
+import { getColumnByKey } from './column-utils';
+import { GROUP_HEADER_HEIGHT, GROUP_ROW_TYPE, GROUP_VIEW_OFFSET, INSERT_ROW_HEIGHT } from '../constants';
+
+export const createGroupMetrics = (groups, groupbys, pathFoldedGroupMap, columns, rowHeight, includeInsertRow) => {
+ let groupbyColumnsMap = {};
+ groupbys.forEach(groupby => {
+ const columnKey = groupby.column_key;
+ const column = getColumnByKey(columnKey, columns);
+ groupbyColumnsMap[columnKey] = column;
+ });
+ const maxLevel = groupbys.length;
+ const groupRows = getGroupsRows(
+ groups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel,
+ { parentGroupPath: [], currentLevel: maxLevel, isParentGroupVisible: true }
+ );
+ const { computedGroupRows, groupRowsHeight, idGroupRowMap } = setupGroupsRows(groupRows, maxLevel);
+ return {
+ groupRows: computedGroupRows,
+ idGroupRowMap,
+ groupRowsHeight,
+ maxLevel,
+ };
+};
+
+export const getGroupsRows = (
+ groups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel, {
+ parentGroupPath, parentGroupKey, currentLevel, isParentGroupVisible,
+ }
+) => {
+ let groupRows = [];
+ groups.forEach((group, groupIndex) => {
+ let groupPath = [];
+ if (parentGroupPath.length > 0) {
+ groupPath.push(...parentGroupPath);
+ }
+ groupPath.push(groupIndex);
+ const { cell_value, subgroups, row_ids, column_key, summaries, original_cell_value } = group;
+ const groupPathString = groupPath.join('-');
+ const isExpanded = isExpandedGroup(groupPathString, pathFoldedGroupMap);
+ const left = (maxLevel - currentLevel + 1) * GROUP_VIEW_OFFSET;
+ const groupKey = `${parentGroupKey ? parentGroupKey : column_key}_${cell_value}`;
+ let groupContainer = {
+ type: GROUP_ROW_TYPE.GROUP_CONTAINER,
+ level: currentLevel,
+ left,
+ key: groupKey,
+ cell_value,
+ column_key,
+ isExpanded,
+ summaries,
+ groupPath,
+ groupPathString,
+ column: groupbyColumnsMap[column_key],
+ visible: isParentGroupVisible,
+ original_cell_value
+ };
+ if (Array.isArray(subgroups) && subgroups.length > 0) {
+ const flattenSubgroups = getGroupsRows(
+ subgroups, groupbyColumnsMap, pathFoldedGroupMap, includeInsertRow, rowHeight, maxLevel,
+ { parentGroupPath: groupPath, parentGroupKey: groupKey, currentLevel: currentLevel - 1, isParentGroupVisible: isParentGroupVisible && isExpanded }
+ );
+ let groupCount = 0;
+ let subgroupsHeight = 0;
+ let first_row_id;
+ flattenSubgroups.forEach((subgroupContainer) => {
+ if (subgroupContainer.type === GROUP_ROW_TYPE.GROUP_CONTAINER && subgroupContainer.level + 1 === currentLevel) {
+ groupCount += subgroupContainer.count || 0;
+ subgroupsHeight += (subgroupContainer.height || 0) + GROUP_VIEW_OFFSET;
+ if (!first_row_id) {
+ first_row_id = subgroupContainer.first_row_id;
+ }
+ }
+ });
+ groupContainer.first_row_id = first_row_id;
+ groupContainer.count = groupCount;
+ groupContainer.height = (isExpanded ? subgroupsHeight : 0) + GROUP_HEADER_HEIGHT;
+ groupRows.push(groupContainer);
+ groupRows.push(...flattenSubgroups);
+ } else if (Array.isArray(row_ids) && row_ids.length > 0) {
+ const rowsLength = row_ids.length;
+ const lastRowIndex = rowsLength - 1;
+ const isRowVisible = isParentGroupVisible && isExpanded;
+ const isBtnInsertRowVisible = isRowVisible && includeInsertRow;
+ const rowsHeight = isRowVisible ? rowsLength * rowHeight : 0;
+ const btnInsertRowHeight = isBtnInsertRowVisible ? INSERT_ROW_HEIGHT : 0;
+ let rows = row_ids.map((rowId, index) => {
+ return {
+ type: GROUP_ROW_TYPE.ROW,
+ key: `row-${rowId}`,
+ rowIdx: index,
+ isLastRow: index === lastRowIndex,
+ visible: isRowVisible,
+ height: rowHeight,
+ level: currentLevel,
+ rowsLength,
+ left,
+ rowId,
+ groupPath,
+ groupPathString,
+ };
+ });
+ rows.push({
+ type: GROUP_ROW_TYPE.BTN_INSERT_ROW,
+ key: `btn-insert-row_${groupKey}`,
+ visible: isBtnInsertRowVisible,
+ height: INSERT_ROW_HEIGHT,
+ level: currentLevel,
+ lastRowIndex,
+ left,
+ groupPath,
+ groupPathString,
+ });
+ groupContainer.first_row_id = rows[0].rowId;
+ groupContainer.count = rowsLength;
+ groupContainer.height = rowsHeight + btnInsertRowHeight + GROUP_HEADER_HEIGHT;
+ groupRows.push(groupContainer);
+ groupRows.push(...rows);
+ }
+ });
+ return groupRows;
+};
+
+export const setupGroupsRows = (groupRows, maxLevel) => {
+ let groupRowsHeight = 0;
+ let top = GROUP_VIEW_OFFSET;
+ let idGroupRowMap = {};
+ let pervVisibleGroupLevel;
+ const computedGroupRows = groupRows.map((flattenGroup, index) => {
+ const { type, level, height, visible } = flattenGroup;
+ let newGroupRow = {
+ ...flattenGroup,
+ top,
+ groupRowIndex: index,
+ };
+ if (type === GROUP_ROW_TYPE.GROUP_CONTAINER) {
+ if (visible) {
+ if (level === maxLevel) {
+ groupRowsHeight += height + GROUP_VIEW_OFFSET;
+ }
+ top += GROUP_HEADER_HEIGHT;
+ pervVisibleGroupLevel = level;
+ }
+ } else if (type === GROUP_ROW_TYPE.ROW) {
+ const { rowId } = flattenGroup;
+ idGroupRowMap[rowId] = newGroupRow;
+ if (visible) {
+ top += height;
+ }
+ } else if (type === GROUP_ROW_TYPE.BTN_INSERT_ROW) {
+ if (visible) {
+ top += height;
+ }
+ }
+ const nextFlattenGroup = groupRows[index + 1];
+ if (nextFlattenGroup && nextFlattenGroup.visible && nextFlattenGroup.type === GROUP_ROW_TYPE.GROUP_CONTAINER) {
+ const { groupPath: nextGroupPath, level: nextGroupLevel } = nextFlattenGroup;
+ if (nextGroupPath[nextGroupPath.length - 1] > 0) {
+ top += GROUP_VIEW_OFFSET;
+ }
+ if (nextGroupLevel > pervVisibleGroupLevel) {
+ top += (nextGroupLevel - pervVisibleGroupLevel) * GROUP_VIEW_OFFSET;
+ }
+ }
+ return newGroupRow;
+ });
+ return { computedGroupRows, groupRowsHeight, idGroupRowMap };
+};
+
+export const isExpandedGroup = (groupPathString, pathFoldedGroupMap) => {
+ return !pathFoldedGroupMap || !pathFoldedGroupMap[groupPathString];
+};
+
+export const isNestedGroupRow = (currentGroupRow, targetGroupRow) => {
+ const { groupPath: currentGroupPath, groupPathString: currentGroupPathString, level: currentGroupLevel, type: currentGroupRowType } = currentGroupRow;
+ const { groupPath: targetGroupPath, groupPathString: targetGroupPathString, level: targetGroupLevel } = targetGroupRow;
+ return (currentGroupPathString === targetGroupPathString && currentGroupRowType !== GROUP_ROW_TYPE.GROUP_CONTAINER) ||
+ (currentGroupLevel < targetGroupLevel && currentGroupPath[0] === targetGroupPath[0]);
+};
+
+export const getGroupRecordByIndex = (index, groupMetrics) => {
+ const groupRows = groupMetrics.groupRows || [];
+ return groupRows[index] || {};
+};
diff --git a/frontend/src/metadata/metadata-view/utils/groupby-utils.js b/frontend/src/metadata/metadata-view/utils/groupby-utils.js
new file mode 100644
index 0000000000..f9d568699a
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/groupby-utils.js
@@ -0,0 +1,80 @@
+import {
+ CellType,
+ DISPLAY_GROUP_DATE_GRANULARITY,
+ GROUP_DATE_GRANULARITY,
+ FORMULA_COLUMN_TYPES_MAP,
+ FORMULA_RESULT_TYPE,
+ SORT_TYPE,
+ SUPPORT_GROUP_COLUMN_TYPES,
+ isDateColumn,
+ GROUPBY_DATE_GRANULARITY_LIST,
+} from '../_basic';
+
+const NOT_SUPPORT_GROUPBY_ARRAY_TYPE = [CellType.LONG_TEXT, CellType.IMAGE, CellType.FILE];
+
+export const getDefaultCountType = (column) => {
+ if (isDateColumn(column)) {
+ return GROUP_DATE_GRANULARITY.MONTH;
+ }
+ return null;
+};
+
+export const getGroupbyColumns = (columns, groupbys = []) => {
+ let groupbyColumnKeyMap = {};
+ groupbys.forEach(groupby => {
+ const { column_key } = groupby;
+ if (column_key) {
+ groupbyColumnKeyMap[column_key] = true;
+ }
+ });
+ return columns.filter(column => {
+ const { key, type, data } = column;
+ if (!SUPPORT_GROUP_COLUMN_TYPES.includes(type)) {
+ return false;
+ }
+ if (groupbyColumnKeyMap[key]) return false; // group by has already exist
+ if (FORMULA_COLUMN_TYPES_MAP[type]) {
+ const { result_type, array_type } = data || {};
+ if (result_type === FORMULA_RESULT_TYPE.ARRAY && NOT_SUPPORT_GROUPBY_ARRAY_TYPE.includes(array_type)) {
+ return false;
+ }
+ }
+ return true;
+ });
+};
+
+export const getSelectedCountType = (column, countType) => {
+ const type = countType || getDefaultCountType(column);
+ if (!type) {
+ return null;
+ }
+ if (isDateColumn(column)) {
+ return DISPLAY_GROUP_DATE_GRANULARITY[type];
+ }
+ return null;
+};
+
+export const isShowGroupCountType = (column) => {
+ if (isDateColumn(column)) return true;
+ return false;
+};
+
+export const getGroupbyGranularityByColumn = (column) => {
+ let granularityList = [];
+ let displayGranularity = {};
+ if (isDateColumn(column)) {
+ granularityList = GROUPBY_DATE_GRANULARITY_LIST;
+ displayGranularity = DISPLAY_GROUP_DATE_GRANULARITY;
+ }
+ return { granularityList, displayGranularity };
+};
+
+export const generateDefaultGroupby = (columns) => {
+ const dateColumn = columns.find(column => column.type === CellType.DATE) || columns.find(column => isDateColumn(column));
+ let groupby = { column_key: null, sort_type: SORT_TYPE.UP };
+ if (dateColumn) {
+ groupby.column_key = dateColumn.key;
+ groupby.count_type = getDefaultCountType(dateColumn);
+ }
+ return groupby;
+};
diff --git a/frontend/src/metadata/metadata-view/utils/index.js b/frontend/src/metadata/metadata-view/utils/index.js
new file mode 100644
index 0000000000..fdb3a232d1
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/index.js
@@ -0,0 +1,63 @@
+import getEventTransfer from './get-event-transfer';
+import CellValueUtils from './cell-value-utils';
+import { gettext } from '../../../utils/constants';
+
+export const getEventClassName = (e) => {
+ // svg mouseEvent event.target.className is an object
+ if (!e || !e.target) return '';
+ return e.target.getAttribute('class') || '';
+};
+
+export const initScrollBar = () => {
+ const isWin = (navigator.platform === 'Win32') || (navigator.platform === 'Windows');
+ if (isWin) {
+ const style = document.createElement('style');
+ document.head.appendChild(style);
+ const sheet = style.sheet;
+ sheet.addRule('div::-webkit-scrollbar', 'width: 8px;height: 8px;');
+ sheet.addRule('div::-webkit-scrollbar-button', 'display: none;');
+ sheet.addRule('div::-webkit-scrollbar-thumb', 'background-color: rgb(206, 206, 212);border-radius: 10px;');
+ }
+};
+
+export const isMobile = (typeof (window) !== 'undefined') && (window.innerWidth < 768 ||
+ navigator.userAgent.toLowerCase().match(/(ipod|ipad|iphone|android|coolpad|mmp|smartphone|midp|wap|xoom|symbian|j2me|blackberry|wince)/i) != null);
+
+export const addClassName = (originClassName, targetClassName) => {
+ const originClassNames = originClassName.split(' ');
+ if (originClassNames.indexOf(targetClassName) > -1) return originClassName;
+ return originClassName + ' ' + targetClassName;
+};
+
+export const removeClassName = (originClassName, targetClassName) => {
+ let originClassNames = originClassName.split(' ');
+ const targetClassNameIndex = originClassNames.indexOf(targetClassName);
+ if (targetClassNameIndex < 0) return originClassName;
+ originClassNames.splice(targetClassNameIndex, 1);
+ return originClassNames.join(' ');
+};
+
+/* is weiXin built-in browser */
+export const isWeiXinBuiltInBrowser = () => {
+ const agent = navigator.userAgent.toLowerCase();
+ if (agent.match(/MicroMessenger/i) === 'micromessenger' ||
+ (typeof window.WeixinJSBridge !== 'undefined')) {
+ return true;
+ }
+ return false;
+};
+
+export const isWindowsBrowser = () => {
+ return /windows|win32/i.test(navigator.userAgent);
+};
+
+export const isWebkitBrowser = () => {
+ let agent = navigator.userAgent.toLowerCase();
+ return agent.includes('webkit');
+};
+
+export {
+ gettext,
+ getEventTransfer,
+ CellValueUtils,
+};
diff --git a/frontend/src/metadata/metadata-view/utils/keyboard-utils.js b/frontend/src/metadata/metadata-view/utils/keyboard-utils.js
new file mode 100644
index 0000000000..35b39a9c6e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/keyboard-utils.js
@@ -0,0 +1,25 @@
+function isKeyPrintable(keycode) {
+ const valid =
+ (keycode > 47 && keycode < 58) || // number keys
+ keycode === 32 || keycode === 13 || // spacebar & return key(s) (if you want to allow carriage returns)
+ (keycode > 64 && keycode < 91) || // letter keys
+ (keycode > 95 && keycode < 112) || // numpad keys
+ (keycode > 185 && keycode < 193) || // ;=,-./` (in order)
+ (keycode > 218 && keycode < 223); // [\]' (in order)
+
+ return valid;
+}
+
+function isCtrlKeyHeldDown(e) {
+ return (e.ctrlKey === true || e.metaKey === true) && e.key !== 'Control';
+}
+
+function isShiftKeyDown(e) {
+ return e && e.shiftKey;
+}
+
+export {
+ isKeyPrintable,
+ isCtrlKeyHeldDown,
+ isShiftKeyDown,
+};
diff --git a/frontend/src/metadata/metadata-view/utils/object-utils.js b/frontend/src/metadata/metadata-view/utils/object-utils.js
new file mode 100644
index 0000000000..fac3e1fd98
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/object-utils.js
@@ -0,0 +1,47 @@
+class ObjectUtils {
+
+ static getDataType(data){
+ let type = typeof data;
+ if (type !== 'object') {
+ return type;
+ }
+ return Object.prototype.toString.call(data).replace(/^\[object (\S+)\]$/, '$1');
+ }
+
+ static iterable(data){
+ return ['Object', 'Array'].includes(this.getDataType(data));
+ }
+
+ static isObjectChanged(source, comparison) {
+ if (!this.iterable(source)) {
+ throw new Error(`source should be a Object or Array , but got ${this.getDataType(source)}`);
+ }
+ if (this.getDataType(source) !== this.getDataType(comparison)) {
+ return true;
+ }
+ const sourceKeys = Object.keys(source);
+ const comparisonKeys = Object.keys({ ...source, ...comparison });
+ if (sourceKeys.length !== comparisonKeys.length) {
+ return true;
+ }
+ return comparisonKeys.some(key => {
+ if (this.iterable(source[key])) {
+ return this.isObjectChanged(source[key], comparison[key]);
+ } else {
+ return source[key] !== comparison[key];
+ }
+ });
+ }
+
+ static isSameObject(source, comparison) {
+ if (!source || !comparison) return false;
+ return !this.isObjectChanged(source, comparison);
+ }
+}
+
+export const hasOwnProperty = (obj, propertyKey) => {
+ if (!obj || !propertyKey) return false;
+ return Object.prototype.hasOwnProperty.call(obj, propertyKey);
+};
+
+export default ObjectUtils;
diff --git a/frontend/src/metadata/metadata-view/utils/record-metrics.js b/frontend/src/metadata/metadata-view/utils/record-metrics.js
new file mode 100644
index 0000000000..7ffa0c187e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/record-metrics.js
@@ -0,0 +1,56 @@
+function selectRecord(recordId, recordMetrics) {
+ if (isRecordSelected(recordId, recordMetrics)) {
+ return;
+ }
+ recordMetrics.idSelectedRecordMap[recordId] = true;
+}
+
+function selectRecordsById(recordIds, recordMetrics) {
+ recordIds.forEach(recordId => {
+ selectRecord(recordId, recordMetrics);
+ });
+}
+
+function deselectRecord(recordId, recordMetrics) {
+ if (!isRecordSelected(recordId, recordMetrics)) {
+ return;
+ }
+ delete recordMetrics.idSelectedRecordMap[recordId];
+}
+
+function deselectAllRecords(recordMetrics) {
+ recordMetrics.idSelectedRecordMap = {};
+}
+
+function isRecordSelected(recordId, recordMetrics) {
+ return recordMetrics.idSelectedRecordMap[recordId];
+}
+
+function getSelectedIds(recordMetrics) {
+ return Object.keys(recordMetrics.idSelectedRecordMap);
+}
+
+function hasSelectedRecords(recordMetrics) {
+ return getSelectedIds(recordMetrics).length > 0;
+}
+
+function isSelectedAll(recordIds, recordMetrics) {
+ const selectedRecordsLen = getSelectedIds(recordMetrics).length;
+ if (selectedRecordsLen === 0) {
+ return false;
+ }
+ return recordIds.every(recordId => isRecordSelected(recordId, recordMetrics));
+}
+
+const recordMetrics = {
+ selectRecord,
+ selectRecordsById,
+ deselectRecord,
+ deselectAllRecords,
+ isRecordSelected,
+ getSelectedIds,
+ hasSelectedRecords,
+ isSelectedAll,
+};
+
+export default recordMetrics;
diff --git a/frontend/src/metadata/metadata-view/utils/records-body-utils.js b/frontend/src/metadata/metadata-view/utils/records-body-utils.js
new file mode 100644
index 0000000000..b655859d1e
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/records-body-utils.js
@@ -0,0 +1,53 @@
+import { isMobile } from '.';
+import { isFrozen } from './column-utils';
+
+export const getColumnScrollPosition = (columns, idx, tableContentWidth) => {
+ let left = 0;
+ let frozen = 0;
+ const selectedColumn = getColumn(columns, idx);
+ if (!selectedColumn) return null;
+
+ for (let i = 0; i < idx; i++) {
+ const column = getColumn(columns, i);
+ if (column) {
+ if (column.width) {
+ left += column.width;
+ }
+ if (isFrozen(column)) {
+ frozen += column.width;
+ }
+ }
+ }
+ return isMobile ? left - (tableContentWidth - selectedColumn.width) / 2 : left - frozen;
+};
+
+export const getColumn = (columns, idx) => {
+ if (Array.isArray(columns)) {
+ return columns[idx];
+ } else if (typeof Immutable !== 'undefined') {
+ return columns.get(idx);
+ }
+};
+
+export const getColVisibleStartIdx = (columns, scrollLeft) => {
+ let remainingScroll = scrollLeft;
+ for (let i = 0; i < columns.length; i++) {
+ let { width } = columns[i];
+ remainingScroll -= width;
+ if (remainingScroll < 0) {
+ return i;
+ }
+ }
+};
+
+export const getColVisibleEndIdx = (columns, recordBodyWidth, scrollLeft) => {
+ let usefulWidth = recordBodyWidth + scrollLeft;
+ for (let i = 0; i < columns.length; i++) {
+ let { width } = columns[i];
+ usefulWidth -= width;
+ if (usefulWidth < 0) {
+ return i - 1 - 1;
+ }
+ }
+ return columns.length - 1;
+};
diff --git a/frontend/src/metadata/metadata-view/utils/row-utils.js b/frontend/src/metadata/metadata-view/utils/row-utils.js
new file mode 100644
index 0000000000..944ebe4523
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/row-utils.js
@@ -0,0 +1,28 @@
+const RowUtils = {
+
+ get: function (row, property) {
+ if (typeof row.get === 'function') {
+ return row.get(property);
+ }
+
+ return row[property];
+ },
+
+ isRowSelected(keys, indexes, isSelectedKey, rowData, rowIdx) {
+ if (indexes && Object.prototype.toString.call(indexes) === '[object Array]') {
+ return indexes.indexOf(rowIdx) > -1;
+ } else if (keys && keys.rowKey && keys.values && Object.prototype.toString.call(keys.values) === '[object Array]') {
+ return keys.values.indexOf(rowData[keys.rowKey]) > -1;
+ } else if (isSelectedKey && rowData && typeof isSelectedKey === 'string') {
+ return rowData[isSelectedKey];
+ }
+ return false;
+ },
+
+ getRecordById(recordId, value) {
+ return recordId && value.id_row_map[recordId];
+ }
+
+};
+
+export default RowUtils;
diff --git a/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js b/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js
new file mode 100644
index 0000000000..6c0d15a7fc
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/selected-cell-utils.js
@@ -0,0 +1,188 @@
+import { Z_INDEX, getGroupByPath, isFunction } from '../_basic';
+import { getColumnByIndex } from './column-utils';
+import { SUPPORT_PREVIEW_COLUMN_TYPES } from '../constants';
+import { getGroupRecordByIndex } from './group-metrics';
+import RowUtils from './row-utils';
+
+const SELECT_DIRECTION = {
+ UP: 'upwards',
+ DOWN: 'downwards',
+};
+
+export const getRowTop = (rowIdx, rowHeight) => rowIdx * rowHeight;
+
+export const getSelectedRow = ({ selectedPosition, isGroupView, recordGetterByIndex }) => {
+ const { groupRecordIndex, rowIdx } = selectedPosition;
+ return recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx });
+};
+
+export const getSelectedColumn = ({ selectedPosition, columns }) => {
+ const { idx } = selectedPosition;
+ return getColumnByIndex(idx, columns);
+};
+
+export const getSelectedCellValue = ({ selectedPosition, columns, isGroupView, recordGetterByIndex }) => {
+ const column = getSelectedColumn({ selectedPosition, columns });
+ const row = getSelectedRow({ selectedPosition, isGroupView, recordGetterByIndex });
+
+ return row && column ? RowUtils.get(row, column.key) : null;
+};
+
+export const isSelectedCellSupportOpenEditor = (cell, columns, isGroupView, recordGetterByIndex) => {
+ const { idx, groupRecordIndex, rowIdx } = cell;
+ const column = columns[idx];
+ if (!column) return false;
+ if (SUPPORT_PREVIEW_COLUMN_TYPES.includes(column.type)) {
+ return true;
+ }
+
+ const record = recordGetterByIndex({ isGroupView, groupRecordIndex, recordIndex: rowIdx });
+ if (!record) return false;
+ return true;
+};
+
+export const isSelectedCellEditable = ({ enableCellSelect, selectedPosition, columns, isGroupView, recordGetterByIndex, onCheckCellIsEditable }) => {
+ const column = getSelectedColumn({ selectedPosition, columns });
+ const row = getSelectedRow({ selectedPosition, isGroupView, recordGetterByIndex });
+ if (!window.sfMetadataContext.canModifyRow(row)) {
+ return false;
+ }
+ const isCellEditable = isFunction(onCheckCellIsEditable) ? onCheckCellIsEditable({ row, column, ...selectedPosition }) : true;
+ return isCellEditable;
+};
+
+export function selectedRangeIsSingleCell(selectedRange) {
+ const { topLeft, bottomRight } = selectedRange;
+ if (
+ topLeft.idx !== bottomRight.idx ||
+ topLeft.rowIdx !== bottomRight.rowIdx
+ ) {
+ return false;
+ }
+ return true;
+}
+
+export const getSelectedDimensions = ({
+ selectedPosition, columns, rowHeight, scrollLeft, isGroupView, groupOffsetLeft,
+ getRecordTopFromRecordsBody,
+}) => {
+ const { idx, rowIdx, groupRecordIndex } = selectedPosition;
+ const defaultDimensions = { width: 0, left: 0, top: 0, height: rowHeight, zIndex: 1 };
+ if (idx >= 0) {
+ const column = columns && columns[idx];
+ if (!column) {
+ return defaultDimensions;
+ }
+ const { frozen, width } = column;
+ let left = frozen ? scrollLeft + column.left : column.left;
+ let top;
+ if (isGroupView) {
+ left += groupOffsetLeft;
+ // group view uses border-top, No group view uses border-bottom (for group animation) so selected top should be increased 1
+ top = getRecordTopFromRecordsBody(groupRecordIndex) + 1;
+ } else {
+ top = getRecordTopFromRecordsBody(rowIdx);
+ }
+ const zIndex = frozen ? Z_INDEX.FROZEN_CELL_MASK : Z_INDEX.CELL_MASK;
+ return { width, left, top, height: rowHeight, zIndex };
+ }
+ return defaultDimensions;
+};
+
+export function getNewSelectedRange(startCell, nextCellPosition) {
+ const { idx: currentIdx, rowIdx: currentRowIdx, groupRecordIndex: currentGroupRecordIndex } = startCell;
+ const { idx: newIdx, rowIdx: newRowIdx, groupRecordIndex: newGroupRecordIndex } = nextCellPosition;
+ const colIndexes = [currentIdx, newIdx].sort((a, b) => a - b);
+ const rowIndexes = [currentRowIdx, newRowIdx].sort((a, b) => a - b);
+ const groupRecordIndexes = [currentGroupRecordIndex, newGroupRecordIndex].sort((a, b) => a - b);
+ const topLeft = { idx: colIndexes[0], rowIdx: rowIndexes[0], groupRecordIndex: groupRecordIndexes[0] };
+ const bottomRight = { idx: colIndexes[1], rowIdx: rowIndexes[1], groupRecordIndex: groupRecordIndexes[1] };
+ return { topLeft, bottomRight };
+}
+
+const getColumnRangeProperties = (from, to, columns) => {
+ let totalWidth = 0;
+ let anyColFrozen = false;
+ for (let i = from; i <= to; i++) {
+ const column = columns[i];
+ if (column) {
+ totalWidth += column.width;
+ anyColFrozen = anyColFrozen || column.frozen;
+ }
+ }
+ return { totalWidth, anyColFrozen, left: columns[from].left };
+};
+
+export const getSelectedRangeDimensions = ({
+ selectedRange, columns, rowHeight, isGroupView, groups, groupMetrics,
+ groupOffsetLeft, getRecordTopFromRecordsBody,
+}) => {
+ const { topLeft, bottomRight, startCell, cursorCell } = selectedRange;
+ if (topLeft.idx < 0) {
+ return { width: 0, left: 0, top: 0, height: rowHeight, zIndex: Z_INDEX.CELL_MASK };
+ }
+
+ let { totalWidth, anyColFrozen, left } = getColumnRangeProperties(topLeft.idx, bottomRight.idx, columns);
+ let height;
+ let top;
+ if (isGroupView) {
+ let { groupRecordIndex: startGroupRecordIndex } = startCell;
+ let { groupRecordIndex: endGroupRecordIndex } = cursorCell;
+ const startGroupRow = getGroupRecordByIndex(startGroupRecordIndex, groupMetrics);
+ const endGroupRow = getGroupRecordByIndex(endGroupRecordIndex, groupMetrics);
+ const startGroupPathString = startGroupRow.groupPathString;
+ const endGroupPathString = endGroupRow.groupPathString;
+ let topGroupRowIndex;
+ let selectDirection;
+ if (startGroupRecordIndex < endGroupRecordIndex) {
+ topGroupRowIndex = startGroupRecordIndex;
+ selectDirection = SELECT_DIRECTION.DOWN;
+ } else {
+ topGroupRowIndex = endGroupRecordIndex;
+ selectDirection = SELECT_DIRECTION.UP;
+ }
+
+ if (startGroupPathString === endGroupPathString) {
+ // within the same group.
+ height = (Math.abs(endGroupRecordIndex - startGroupRecordIndex) + 1) * rowHeight;
+ } else if (selectDirection === SELECT_DIRECTION.DOWN) {
+ // within different group: select cells from top to bottom.
+ const groupPath = startGroupRow.groupPath;
+ const group = getGroupByPath(groupPath, groups);
+ const groupRowIds = group.row_ids || [];
+ height = (groupRowIds.length - startGroupRow.rowIdx || 0) * rowHeight;
+ } else if (selectDirection === SELECT_DIRECTION.UP) {
+ // within different group: select cells from bottom to top.
+ const startGroupRowIdx = startGroupRow.rowIdx || 0;
+ topGroupRowIndex = startGroupRecordIndex - startGroupRowIdx;
+ height = (startGroupRowIdx + 1) * rowHeight;
+ }
+ height += 1; // record height: 32
+ left += groupOffsetLeft;
+ top = getRecordTopFromRecordsBody(topGroupRowIndex);
+ } else {
+ height = (bottomRight.rowIdx - topLeft.rowIdx + 1) * rowHeight;
+ top = getRecordTopFromRecordsBody(topLeft.rowIdx);
+ }
+
+ const zIndex = anyColFrozen ? Z_INDEX.FROZEN_CELL_MASK : Z_INDEX.CELL_MASK;
+ return { width: totalWidth, left, top, height, zIndex };
+};
+
+export const getRecordsFromSelectedRange = ({ selectedRange, isGroupView, recordGetterByIndex }) => {
+ const { topLeft, bottomRight } = selectedRange;
+ const { rowIdx: startRecordIdx, groupRecordIndex } = topLeft;
+ const { rowIdx: endRecordIdx } = bottomRight;
+ let currentGroupRowIndex = groupRecordIndex;
+ let records = [];
+ for (let recordIndex = startRecordIdx, endIdx = endRecordIdx + 1; recordIndex < endIdx; recordIndex++) {
+ const record = recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRowIndex, recordIndex });
+ if (isGroupView) {
+ currentGroupRowIndex++;
+ }
+ if (record) {
+ records.push(record);
+ }
+ }
+ return records;
+};
diff --git a/frontend/src/metadata/metadata-view/utils/set-event-transfer.js b/frontend/src/metadata/metadata-view/utils/set-event-transfer.js
new file mode 100644
index 0000000000..bc57c5c5ce
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/set-event-transfer.js
@@ -0,0 +1,129 @@
+import { CellType } from '../_basic';
+import { toggleSelection } from './toggle-selection';
+import { TRANSFER_TYPES } from '../constants';
+import { getClientCellValueDisplayString } from './cell-format-utils';
+import { getColumnByIndex } from './column-utils';
+
+const { TEXT, FRAGMENT } = TRANSFER_TYPES;
+
+function setEventTransfer({
+ type, selectedRecordIds, copiedRange, copiedColumns, copiedRecords, copiedTableId, tableData, copiedText,
+ recordGetterById, isGroupView, recordGetterByIndex, event = {},
+}) {
+ const transfer = event.dataTransfer || event.clipboardData;
+ if (type === TRANSFER_TYPES.DTABLE_FRAGMENT) {
+ const copiedText = Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0 ?
+ getCopiedTextFormSelectedRecordIds(selectedRecordIds, tableData, recordGetterById) :
+ getCopiedTextFromSelectedCells(copiedRange, tableData, isGroupView, recordGetterByIndex);
+ const copiedGrid = {
+ selectedRecordIds,
+ copiedRange,
+ copiedColumns,
+ copiedRecords,
+ copiedTableId,
+ };
+ const serializeCopiedGrid = JSON.stringify(copiedGrid);
+ if (transfer) {
+ transfer.setData(TEXT, copiedText);
+ transfer.setData(FRAGMENT, serializeCopiedGrid);
+ } else {
+ execCopyWithNoEvents(copiedText, serializeCopiedGrid);
+ }
+ } else {
+ let format = TRANSFER_TYPES[type.toUpperCase()];
+ if (transfer) {
+ transfer.setData(format, copiedText);
+ } else {
+ execCopyWithNoEvents(copiedText, { format });
+ }
+ }
+}
+
+function getCopiedTextFormSelectedRecordIds(selectedRecordIds, tableData, recordGetterById) {
+ const records = selectedRecordIds.map(recordId => recordGetterById(recordId));
+ return getCopiedText(records, tableData.columns);
+}
+
+function getCopiedTextFromSelectedCells(copiedRange, tableData, isGroupView, recordGetterByIndex) {
+ const { topLeft, bottomRight } = copiedRange;
+ const { rowIdx: minRecordIndex, idx: minColumnIndex, groupRecordIndex } = topLeft;
+ const { rowIdx: maxRecordIndex, idx: maxColumnIndex } = bottomRight;
+ const { columns } = tableData;
+ let currentGroupRecordIndex = groupRecordIndex;
+ let operateRecords = [];
+ let operateColumns = [];
+ for (let i = minRecordIndex; i <= maxRecordIndex; i++) {
+ operateRecords.push(recordGetterByIndex({ isGroupView, groupRecordIndex: currentGroupRecordIndex, recordIndex: i }));
+ if (isGroupView) {
+ currentGroupRecordIndex++;
+ }
+ }
+ for (let i = minColumnIndex; i <= maxColumnIndex; i++) {
+ operateColumns.push(getColumnByIndex(i, columns));
+ }
+ return getCopiedText(operateRecords, operateColumns);
+}
+
+function getCopiedText(records, columns) {
+ const collaborators = window.sfMetadataContext.getCollaboratorsFromCache();
+ const lastRecordIndex = records.length - 1;
+ const lastColumnIndex = columns.length - 1;
+ let copiedText = '';
+ records.forEach((record, recordIndex) => {
+ columns.forEach((column, columnIndex) => {
+ const { key, type, data } = column || {};
+ if (type === CellType.LONG_TEXT) {
+ const cellValue = record[key];
+ copiedText += cellValue || '';
+ } else {
+ copiedText += (record && getClientCellValueDisplayString(record, type, key, { data, collaborators })) || '';
+ }
+ if (columnIndex < lastColumnIndex) {
+ copiedText += '\t';
+ }
+ });
+ if (recordIndex < lastRecordIndex) {
+ copiedText += '\n';
+ }
+ });
+ return copiedText;
+}
+
+export function execCopyWithNoEvents(text, serializeContent) {
+ let reselectPrevious;
+ let range;
+ let selection;
+ let mark;
+ let success = false;
+ try {
+ reselectPrevious = toggleSelection();
+ range = document.createRange();
+ selection = document.getSelection();
+ mark = document.createElement('span');
+ mark.textContent = text;
+ mark.addEventListener('copy', function (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ let transfer = e.dataTransfer || e.clipboardData;
+ transfer.clearData();
+ transfer.setData(TEXT, text);
+ transfer.setData(FRAGMENT, serializeContent);
+ });
+ document.body.appendChild(mark);
+ range.selectNodeContents(mark);
+ selection.addRange(range);
+ success = document.execCommand('copy');
+ if (!success) {
+ return false;
+ }
+ } catch {
+ return false;
+ } finally {
+ if (mark) {
+ document.body.removeChild(mark);
+ }
+ reselectPrevious();
+ }
+}
+
+export default setEventTransfer;
diff --git a/frontend/src/metadata/metadata-view/utils/table-utils.js b/frontend/src/metadata/metadata-view/utils/table-utils.js
new file mode 100644
index 0000000000..1ba4e00519
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/table-utils.js
@@ -0,0 +1,13 @@
+export const getFrozenColumns = (columns) => {
+ return columns.filter(column => column.frozen);
+};
+
+export const getFrozenColumnsWidth = (columns) => {
+ let width = 0;
+ columns.forEach(column => {
+ if (column.frozen) {
+ width += column.width;
+ }
+ });
+ return width;
+};
diff --git a/frontend/src/metadata/metadata-view/utils/toggle-selection.js b/frontend/src/metadata/metadata-view/utils/toggle-selection.js
new file mode 100644
index 0000000000..b32af312c6
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/toggle-selection.js
@@ -0,0 +1,34 @@
+export function toggleSelection() {
+ let selection = document.getSelection();
+ if (!selection.rangeCount) {
+ return function () {};
+ }
+ let active = document.activeElement;
+ let ranges = [];
+ for (let i = 0; i < selection.rangeCount; i++) {
+ ranges.push(selection.getRangeAt(i));
+ }
+
+ switch (active.tagName.toUpperCase()) { // .toUpperCase handles XHTML
+ case 'INPUT':
+ case 'TEXTAREA':
+ active.blur();
+ break;
+ default:
+ active = null;
+ break;
+ }
+
+ selection.removeAllRanges();
+ return function () {
+ selection.type === 'Caret' &&
+ selection.removeAllRanges();
+ if (!selection.rangeCount) {
+ ranges.forEach(function (range) {
+ selection.addRange(range);
+ });
+ }
+ active &&
+ active.focus();
+ };
+}
diff --git a/frontend/src/metadata/metadata-view/utils/viewport.js b/frontend/src/metadata/metadata-view/utils/viewport.js
new file mode 100644
index 0000000000..2a129baa6c
--- /dev/null
+++ b/frontend/src/metadata/metadata-view/utils/viewport.js
@@ -0,0 +1,29 @@
+export const getColVisibleStartIdx = (columns, scrollLeft) => {
+ let remainingScroll = scrollLeft;
+ const nonFrozenColumns = columns.slice(0);
+ for (let i = 0; i < nonFrozenColumns.length; i++) {
+ let { width } = columns[i];
+ remainingScroll -= width;
+ if (remainingScroll < 0) {
+ return i;
+ }
+ }
+};
+
+export const getColVisibleEndIdx = (columns, gridWidth, scrollLeft) => {
+ let remainingWidth = gridWidth + scrollLeft;
+ for (let i = 0; i < columns.length; i++) {
+ let { width } = columns[i];
+ remainingWidth -= width;
+ if (remainingWidth < 0) {
+ return i - 1;
+ }
+ }
+ return columns.length - 1;
+};
+
+export const getVisibleBoundaries = (columns, scrollLeft, gridWidth) => {
+ const colVisibleStartIdx = getColVisibleStartIdx(columns, scrollLeft);
+ const colVisibleEndIdx = getColVisibleEndIdx(columns, gridWidth, scrollLeft);
+ return { colVisibleStartIdx, colVisibleEndIdx };
+};
diff --git a/frontend/src/pages/lib-content-view/lib-content-view.js b/frontend/src/pages/lib-content-view/lib-content-view.js
index 62ca08cab4..2ab7290b14 100644
--- a/frontend/src/pages/lib-content-view/lib-content-view.js
+++ b/frontend/src/pages/lib-content-view/lib-content-view.js
@@ -461,6 +461,14 @@ class LibContentView extends React.Component {
window.history.pushState({url: url, path: filePath}, filePath, url);
};
+ showFileMetadata = (filePath) => {
+ const repoID = this.props.repoID;
+ this.setState({ path: filePath, isViewFile: true, isFileLoading: false, isFileLoadedErr: false, content: '__sf-metadata' });
+ const repoInfo = this.state.currentRepoInfo;
+ const url = siteRoot + 'library/' + repoID + '/' + encodeURIComponent(repoInfo.repo_name);
+ window.history.pushState({url: url, path: ''}, '', url);
+ };
+
loadDirentList = (path) => {
let repoID = this.props.repoID;
seafileAPI.listDir(repoID, path, {'with_thumbnail': true}).then(res => {
@@ -1649,6 +1657,7 @@ class LibContentView extends React.Component {
onTreeNodeClick = (node) => {
this.resetSelected();
let repoID = this.props.repoID;
+
if (!this.state.pathExist) {
this.setState({pathExist: true});
}
@@ -1680,7 +1689,7 @@ class LibContentView extends React.Component {
}
}
- if (node.path === this.state.path ) {
+ if (node.path === this.state.path) {
return;
}
@@ -1691,6 +1700,10 @@ class LibContentView extends React.Component {
if (node.path !== this.state.path) {
this.showColumnMarkdownFile(node.path);
}
+ } else if (Utils.isFileMetadata(node?.object?.type)) {
+ if (node.path !== this.state.path) {
+ this.showFileMetadata(node.path);
+ }
} else {
let url = siteRoot + 'lib/' + repoID + '/file' + Utils.encodePath(node.path);
let dirent = node.object;
diff --git a/frontend/src/utils/utils.js b/frontend/src/utils/utils.js
index ef2ae025d2..0a75a14515 100644
--- a/frontend/src/utils/utils.js
+++ b/frontend/src/utils/utils.js
@@ -4,6 +4,7 @@ import React from 'react';
import toaster from '../components/toast';
import PermissionDeniedTip from '../components/permission-denied-tip';
import { compareTwoString } from './compare-two-string';
+import { PRIVATE_FILE_TYPE } from '../constants';
export const Utils = {
@@ -872,6 +873,10 @@ export const Utils = {
}
},
+ isFileMetadata: function(type) {
+ return type === PRIVATE_FILE_TYPE.FILE_EXTENDED_PROPERTIES;
+ },
+
isInternalFileLink: function(url, repoID) {
var re = new RegExp(serviceURL + '/lib/' + repoID + '/file.*');
return re.test(url);
diff --git a/seahub/api2/endpoints/metadata_manage.py b/seahub/api2/endpoints/metadata_manage.py
index fa1e6828c3..6eb05af553 100644
--- a/seahub/api2/endpoints/metadata_manage.py
+++ b/seahub/api2/endpoints/metadata_manage.py
@@ -153,8 +153,8 @@ class MetadataRecords(APIView):
#args check
parent_dir = request.GET.get('parent_dir')
name = request.GET.get('name')
- page = request.GET.get('page', '1')
- per_page = request.GET.get('per_page', '1000')
+ page = request.GET.get('page', 1)
+ per_page = request.GET.get('per_page', 1000)
is_dir = request.GET.get('is_dir')
order_by = request.GET.get('order_by')
@@ -186,7 +186,7 @@ class MetadataRecords(APIView):
error_msg = f'The metadata module is disabled for repo {repo_id}.'
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
- # recource check
+ # resource check
repo = seafile_api.get_repo(repo_id)
if not repo:
error_msg = 'Library %s not found.' % repo_id
@@ -209,4 +209,4 @@ class MetadataRecords(APIView):
error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
- return Response({'results': results})
+ return Response(results)
diff --git a/seahub/api2/endpoints/user_list.py b/seahub/api2/endpoints/user_list.py
new file mode 100644
index 0000000000..cb70da4780
--- /dev/null
+++ b/seahub/api2/endpoints/user_list.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+import logging
+
+from rest_framework import status
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.authentication import SessionAuthentication
+
+from seahub.api2.utils import api_error, get_user_common_info
+from seahub.api2.throttling import UserRateThrottle
+from seahub.api2.authentication import TokenAuthentication
+
+logger = logging.getLogger(__name__)
+
+
+class UserListView(APIView):
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated, )
+ throttle_classes = (UserRateThrottle, )
+
+ def post(self, request):
+ """return user_list by user_id_list
+ """
+ # argument check
+ user_id_list = request.data.get('user_id_list')
+ if not isinstance(user_id_list, list):
+ error_msg = 'user_id_list invalid.'
+ return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
+
+ # main
+ user_list = list()
+ for user_id in user_id_list:
+ if not isinstance(user_id, str):
+ continue
+ user_info = get_user_common_info(user_id)
+ user_list.append(user_info)
+
+ return Response({'user_list': user_list})
diff --git a/seahub/repo_metadata/metadata_server_api.py b/seahub/repo_metadata/metadata_server_api.py
index 3dc1a66f21..303baff578 100644
--- a/seahub/repo_metadata/metadata_server_api.py
+++ b/seahub/repo_metadata/metadata_server_api.py
@@ -50,7 +50,7 @@ def list_metadata_records(repo_id, user, parent_dir=None, name=None, is_dir=None
sql += ';'
metadata_server_api = MetadataServerAPI(repo_id, user)
- response_results = metadata_server_api.query_rows(sql, parameters)['results']
+ response_results = metadata_server_api.query_rows(sql, parameters)
return response_results
diff --git a/seahub/repo_metadata/views.py b/seahub/repo_metadata/views.py
deleted file mode 100644
index 9de8734c8e..0000000000
--- a/seahub/repo_metadata/views.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from seahub.views import check_folder_permission
-from seaserv import seafile_api
-from seahub.auth.decorators import login_required
-from seahub.base.decorators import repo_passwd_set_required
-from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
-from seahub.api2.endpoints.metadata_manage import list_metadata_records
-from seahub.repo_metadata.models import RepoMetadata
-from django.shortcuts import render
-
-
-@login_required
-@repo_passwd_set_required
-def view_metadata(request, repo_id):
- template = 'metadata_table.html'
-
- # metadata enable check
- record = RepoMetadata.objects.filter(repo_id=repo_id).first()
- if not record or not record.enabled:
- return HttpResponseBadRequest(f'The metadata module is not enable for repo {repo_id}.')
-
- # recource check
- repo = seafile_api.get_repo(repo_id)
- if not repo:
- raise Http404
-
- # permission check
- permission = check_folder_permission(request, repo_id, '/')
- if not permission:
- return HttpResponseForbidden('Permission denied.')
-
- try:
- results = list_metadata_records(repo_id, request.user.username)
- except Exception as err:
- return HttpResponseServerError(repr(err))
-
- return_results = []
-
- for result in results:
-
- result_info = {
- 'id': result['_id'],
- 'creator': result['_file_creator'],
- 'file_ctime': result['_file_ctime'],
- 'modifier': result['_file_modifier'],
- 'file_mtime': result['_file_mtime'],
- 'parent_dir': result['_parent_dir'],
- 'name': result['_name'],
- 'is_dir': result['_is_dir'],
- }
- return_results.append(result_info)
-
- return_dict = {
- 'metadata_records': return_results
- }
-
- return render(request, template, return_dict)
diff --git a/seahub/templates/metadata_table.html b/seahub/templates/metadata_table.html
deleted file mode 100644
index a2771f75ee..0000000000
--- a/seahub/templates/metadata_table.html
+++ /dev/null
@@ -1,53 +0,0 @@
-{% extends "base.html" %}
-{% load i18n %}
-
-{% block main_panel %}
-
-
-
-
- id |
- Creator |
- Created Time |
- Modifier |
- Modified Time |
- Parent Folder |
- Name |
- Is Folder |
-
-
-
- {% for record in metadata_records %}
-
- {{ record.id }} |
- {{ record.creator }} |
- {{ record.file_ctime }} |
- {{ record.modifier }} |
- {{ record.file_mtime }} |
- {{ record.parent_dir }} |
- {{ record.name }} |
- {{ record.is_dir|yesno:"Yes,No" }} |
-
- {% endfor %}
-
-
-
-{% endblock %}
-
-{% block extra_script %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/seahub/urls.py b/seahub/urls.py
index a94cb9cade..23243fa46e 100644
--- a/seahub/urls.py
+++ b/seahub/urls.py
@@ -204,10 +204,10 @@ from seahub.ocm.settings import OCM_ENDPOINT
from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskStatus, \
LibraryIndexState, QuestionAnsweringSearchInLibrary, FileDownloadToken
from seahub.wiki2.views import wiki_view
-from seahub.repo_metadata.views import view_metadata
from seahub.api2.endpoints.wiki2 import Wikis2View, Wiki2View, Wiki2ConfigView, Wiki2PagesView, Wiki2PageView
from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView
from seahub.api2.endpoints.metadata_manage import MetadataRecords, MetadataManage
+from seahub.api2.endpoints.user_list import UserListView
urlpatterns = [
@@ -320,6 +320,9 @@ urlpatterns = [
# user:convert to team account
re_path(r'^api/v2.1/user/convert-to-team/$', UserConvertToTeamView.as_view(), name="api-v2.1-user-convert-to-team"),
+ # user list
+ re_path(r'^api/v2.1/user-list/$', UserListView.as_view(), name='api-v2.1-user-list'),
+
## obtain auth token by login session
re_path(r'^api/v2.1/auth-token-by-session/$', AuthTokenBySession.as_view(), name="api-v2.1-auth-token-by-session"),
@@ -1032,5 +1035,4 @@ if settings.ENABLE_METADATA_MANAGEMENT:
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'^repos/(?P[-0-9a-f]{36})/metadata/table-view/$', view_metadata, name='view_metadata'),
]