(originalSelectedColumnIds);
+ const styles = useColumnSelectionStyles();
+
+ const selectedColumnIdsSet = new Set(newSelectedColumnIds);
+ const onCheckedValueChange = (id: string, checkedData?: CheckboxOnChangeData): void => {
+ const checked = checkedData?.checked;
+ if (checked === "mixed" || checked === undefined) {
+ return;
+ }
+
+ if (checked) {
+ selectedColumnIdsSet.add(id);
+ } else {
+ /* selectedColumnIds may contain ids that are not in columnDefinitions, because the selected
+ * ids may have been loaded from persistence, but don't exist in the current retrieved documents.
+ */
+
+ if (
+ Array.from(selectedColumnIdsSet).filter((id) => columnDefinitions.find((def) => def.id === id) !== undefined)
+ .length === 1 &&
+ selectedColumnIdsSet.has(id)
+ ) {
+ // Don't allow unchecking the last column
+ return;
+ }
+ selectedColumnIdsSet.delete(id);
+ }
+ setNewSelectedColumnIds([...selectedColumnIdsSet]);
+ };
+
+ const onSave = (): void => {
+ onSelectionChange(newSelectedColumnIds);
+ closeSidePanel();
+ };
+
+ const onSearchChange: (event: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) =>
+ // eslint-disable-next-line react/prop-types
+ setColumnSearchText(data.value);
+
+ const theme = getPlatformTheme(configContext.platform);
+
+ // Filter and move partition keys to the top
+ const columnDefinitionList = columnDefinitions
+ .filter((def) => !columnSearchText || def.label.toLowerCase().includes(columnSearchText.toLowerCase()))
+ .sort((a, b) => {
+ const ID = "id";
+ // "id" always at the top, then partition keys, then everything else sorted
+ if (a.id === ID) {
+ return b.id === ID ? 0 : -1;
+ } else if (b.id === ID) {
+ return a.id === ID ? 0 : 1;
+ } else if (a.isPartitionKey && !b.isPartitionKey) {
+ return -1;
+ } else if (b.isPartitionKey && !a.isPartitionKey) {
+ return 1;
+ } else {
+ return a.label.localeCompare(b.label);
+ }
+ });
+
+ return (
+
+
+
+
+
Select which columns to display in your view of items in your container.
+
to avoid margin-bottom set by panelMainContent css */>
+
+
+
+
+ {columnDefinitionList.map((columnDefinition) => (
+ onCheckedValueChange(columnDefinition.id, data)}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
index aaccfa3ac..6c63ab446 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil.ts
@@ -1,5 +1,6 @@
// Definitions of State data
+import { ColumnDefinition } from "Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent";
import { deleteState, loadState, saveState, saveStateDebounced } from "Shared/AppStatePersistenceUtility";
import { userContext } from "UserContext";
import * as ViewModels from "../../../Contracts/ViewModels";
@@ -11,11 +12,16 @@ export enum SubComponentName {
ColumnSizes = "ColumnSizes",
FilterHistory = "FilterHistory",
MainTabDivider = "MainTabDivider",
+ ColumnsSelection = "ColumnsSelection",
+ ColumnSort = "ColumnSort",
}
export type ColumnSizesMap = { [columnId: string]: WidthDefinition };
+export type FilterHistory = string[];
export type WidthDefinition = { widthPx: number };
export type TabDivider = { leftPaneWidthPercent: number };
+export type ColumnsSelection = { selectedColumnIds: string[]; columnDefinitions: ColumnDefinition[] };
+export type ColumnSort = { columnId: string; direction: "ascending" | "descending" };
/**
*
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
index 59ae9052a..ab6c8ee70 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.test.tsx
@@ -92,7 +92,13 @@ async function waitForComponentToPaint(wrapper: ReactWrapper
| S
describe("Documents tab (noSql API)", () => {
describe("buildQuery", () => {
it("should generate the right select query for SQL API", () => {
- expect(buildQuery(false, "")).toContain("select");
+ expect(
+ buildQuery(false, "", ["pk"], {
+ paths: ["pk"],
+ kind: "Hash",
+ version: 2,
+ }),
+ ).toContain("select");
});
});
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx
index e6b726bca..67358b739 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTabV2.tsx
@@ -1,10 +1,9 @@
import { Item, ItemDefinition, PartitionKey, PartitionKeyDefinition, QueryIterator, Resource } from "@azure/cosmos";
import { Button, Input, TableRowId, makeStyles, shorthands } from "@fluentui/react-components";
-import { ArrowClockwise16Filled, Dismiss16Filled } from "@fluentui/react-icons";
+import { Dismiss16Filled } from "@fluentui/react-icons";
import { KeyCodes, QueryCopilotSampleContainerId, QueryCopilotSampleDatabaseId } from "Common/Constants";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import MongoUtility from "Common/MongoUtility";
-import { StyleConstants } from "Common/StyleConstants";
import { createDocument } from "Common/dataAccess/createDocument";
import {
deleteDocument as deleteNoSqlDocument,
@@ -21,11 +20,14 @@ import Explorer from "Explorer/Explorer";
import { useCommandBar } from "Explorer/Menus/CommandBar/CommandBarComponentAdapter";
import { querySampleDocuments, readSampleDocument } from "Explorer/QueryCopilot/QueryCopilotUtilities";
import {
+ ColumnsSelection,
+ FilterHistory,
SubComponentName,
TabDivider,
readSubComponentState,
saveSubComponentState,
} from "Explorer/Tabs/DocumentsTabV2/DocumentsTabStateUtil";
+import { usePrevious } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { CosmosFluentProvider, LayoutConstants, cosmosShorthands, tokens } from "Explorer/Theme/ThemeUtil";
import { useSelectedNode } from "Explorer/useSelectedNode";
import { KeyboardAction, KeyboardActionGroup, useKeyboardActionGroup } from "KeyboardShortcuts";
@@ -51,11 +53,11 @@ import * as ViewModels from "../../../Contracts/ViewModels";
import { CollectionBase } from "../../../Contracts/ViewModels";
import * as TelemetryProcessor from "../../../Shared/Telemetry/TelemetryProcessor";
import * as QueryUtils from "../../../Utils/QueryUtils";
-import { extractPartitionKeyValues } from "../../../Utils/QueryUtils";
+import { defaultQueryFields, extractPartitionKeyValues } from "../../../Utils/QueryUtils";
import DocumentId from "../../Tree/DocumentId";
import ObjectId from "../../Tree/ObjectId";
import TabsBase from "../TabsBase";
-import { DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
+import { ColumnDefinition, DocumentsTableComponent, DocumentsTableComponentItem } from "./DocumentsTableComponent";
const MAX_FILTER_HISTORY_COUNT = 100; // Datalist will become scrollable, so we can afford to keep more items than fit on the screen
@@ -89,6 +91,13 @@ export const useDocumentsTabStyles = makeStyles({
tableCell: {
...cosmosShorthands.borderLeft(),
},
+ tableHeader: {
+ display: "flex",
+ },
+ tableHeaderFiller: {
+ width: "20px",
+ boxShadow: `0px -1px ${tokens.colorNeutralStroke2} inset`,
+ },
loadMore: {
...cosmosShorthands.borderTop(),
display: "grid",
@@ -101,17 +110,6 @@ export const useDocumentsTabStyles = makeStyles({
...shorthands.outline("1px", "dotted"),
},
},
- floatingControlsContainer: {
- position: "relative",
- },
- floatingControls: {
- position: "absolute",
- top: "6px",
- right: 0,
- float: "right",
- backgroundColor: "white",
- zIndex: 1,
- },
});
export class DocumentsTabV2 extends TabsBase {
@@ -281,7 +279,7 @@ const createUploadButton = (container: Explorer): CommandButtonComponentProps =>
iconAlt: label,
onCommandClick: () => {
const selectedCollection: ViewModels.Collection = useSelectedNode.getState().findSelectedCollection();
- selectedCollection && container.openUploadItemsPanePane();
+ selectedCollection && container.openUploadItemsPane();
},
commandButtonLabel: label,
ariaLabel: label,
@@ -469,17 +467,33 @@ export const showPartitionKey = (collection: ViewModels.CollectionBase, isPrefer
};
// Export to expose to unit tests
+/**
+ * Build default query
+ * @param isMongo true if mongo api
+ * @param filter
+ * @param partitionKeyProperties optional for mongo
+ * @param partitionKey optional for mongo
+ * @param additionalField
+ * @returns
+ */
export const buildQuery = (
isMongo: boolean,
filter: string,
partitionKeyProperties?: string[],
partitionKey?: DataModels.PartitionKey,
+ additionalField?: string[],
): string => {
if (isMongo) {
return filter || "{}";
}
- return QueryUtils.buildDocumentsQuery(filter, partitionKeyProperties, partitionKey);
+ // Filter out fields starting with "/" (partition keys)
+ return QueryUtils.buildDocumentsQuery(
+ filter,
+ partitionKeyProperties,
+ partitionKey,
+ additionalField?.filter((f) => !f.startsWith("/")) || [],
+ );
};
/**
@@ -522,6 +536,12 @@ const getDefaultSqlFilters = (partitionKeys: string[]) =>
);
const defaultMongoFilters = ['{"id":"foo"}', "{ qty: { $gte: 20 } }"];
+// Extend DocumentId to include fields displayed in the table
+type ExtendedDocumentId = DocumentId & { tableFields?: DocumentsTableComponentItem };
+
+// This is based on some heuristics
+const calculateOffset = (columnNumber: number): number => columnNumber * 16 - 27;
+
// Export to expose to unit tests
export const DocumentsTabComponent: React.FunctionComponent = ({
isPreferredApiMongoDB,
@@ -540,7 +560,7 @@ export const DocumentsTabComponent: React.FunctionComponent(false);
const [appliedFilter, setAppliedFilter] = useState("");
const [filterContent, setFilterContent] = useState("");
- const [documentIds, setDocumentIds] = useState([]);
+ const [documentIds, setDocumentIds] = useState([]);
const [isExecuting, setIsExecuting] = useState(false);
const filterInput = useRef(null);
const styles = useDocumentsTabStyles();
@@ -571,7 +591,7 @@ export const DocumentsTabComponent: React.FunctionComponent(() =>
- readSubComponentState(SubComponentName.MainTabDivider, _collection, {
+ readSubComponentState(SubComponentName.MainTabDivider, _collection, {
leftPaneWidthPercent: 35,
}),
);
@@ -585,8 +605,8 @@ export const DocumentsTabComponent: React.FunctionComponent(undefined);
// User's filter history
- const [lastFilterContents, setLastFilterContents] = useState(() =>
- readSubComponentState(SubComponentName.FilterHistory, _collection, []),
+ const [lastFilterContents, setLastFilterContents] = useState(() =>
+ readSubComponentState(SubComponentName.FilterHistory, _collection, [] as FilterHistory),
);
const setKeyboardActions = useKeyboardActionGroup(KeyboardActionGroup.ACTIVE_TAB);
@@ -635,10 +655,37 @@ export const DocumentsTabComponent: React.FunctionComponent {
+ const defaultColumnsIds = ["id"];
+ if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
+ defaultColumnsIds.push(...partitionKeyPropertyHeaders);
+ }
+
+ return defaultColumnsIds;
+ };
+
+ const [selectedColumnIds, setSelectedColumnIds] = useState(() => {
+ const persistedColumnsSelection = readSubComponentState(
+ SubComponentName.ColumnsSelection,
+ _collection,
+ undefined,
+ );
+
+ if (!persistedColumnsSelection) {
+ return getInitialColumnSelection();
+ }
+
+ return persistedColumnsSelection.selectedColumnIds;
+ });
+
// new DocumentId() requires a DocumentTab which we mock with only the required properties
const newDocumentId = useCallback(
- (rawDocument: DataModels.DocumentId, partitionKeyProperties: string[], partitionKeyValue: string[]) =>
- new DocumentId(
+ (
+ rawDocument: DataModels.DocumentId,
+ partitionKeyProperties: string[],
+ partitionKeyValue: string[],
+ ): ExtendedDocumentId => {
+ const extendedDocumentId = new DocumentId(
{
partitionKey,
partitionKeyProperties,
@@ -648,7 +695,10 @@ export const DocumentsTabComponent: React.FunctionComponent {
onExecutionErrorChange(true);
@@ -1103,7 +1161,13 @@ export const DocumentsTabComponent: React.FunctionComponent {
@@ -1271,16 +1336,6 @@ export const DocumentsTabComponent: React.FunctionComponent = (event) => {
- if (event.key === " " || event.key === "Enter") {
- const focusElement = event.target as HTMLElement;
- refreshDocumentsGrid(false);
- focusElement && focusElement.focus();
- event.stopPropagation();
- event.preventDefault();
- }
- };
-
const onLoadMoreKeyInput: KeyboardEventHandler = (event) => {
if (event.key === " " || event.key === "Enter") {
const focusElement = event.target as HTMLElement;
@@ -1312,9 +1367,7 @@ export const DocumentsTabComponent: React.FunctionComponent {
- const item: Record & { id: string } = {
- id: documentId.id(),
- };
+ const item: DocumentsTableComponentItem = documentId.tableFields || { id: documentId.id() };
if (partitionKeyPropertyHeaders && documentId.stringPartitionKeyValues) {
for (let i = 0; i < partitionKeyPropertyHeaders.length; i++) {
@@ -1325,6 +1378,44 @@ export const DocumentsTabComponent: React.FunctionComponent {
+ let columnDefinitions: ColumnDefinition[] = Object.keys(document)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .filter((key) => typeof (document as any)[key] === "string" || typeof (document as any)[key] === "number") // Only allow safe types for displayable React children
+ .map((key) =>
+ key === "id"
+ ? { id: key, label: isPreferredApiMongoDB ? "_id" : "id", isPartitionKey: false }
+ : { id: key, label: key, isPartitionKey: false },
+ );
+
+ if (showPartitionKey(_collection, isPreferredApiMongoDB)) {
+ columnDefinitions.push(
+ ...partitionKeyPropertyHeaders.map((key) => ({ id: key, label: key, isPartitionKey: true })),
+ );
+
+ // Remove properties that are the partition keys, since they are already included
+ columnDefinitions = columnDefinitions.filter(
+ (columnDefinition) => !partitionKeyProperties.includes(columnDefinition.id),
+ );
+ }
+
+ return columnDefinitions;
+ };
+
+ /**
+ * Extract column definitions from document and add to the definitions
+ * @param document
+ */
+ const setColumnDefinitionsFromDocument = (document: unknown): void => {
+ const currentIds = new Set(columnDefinitions.map((columnDefinition) => columnDefinition.id));
+ extractColumnDefinitionsFromDocument(document).forEach((columnDefinition) => {
+ if (!currentIds.has(columnDefinition.id)) {
+ columnDefinitions.push(columnDefinition);
+ }
+ });
+ setColumnDefinitions([...columnDefinitions]);
+ };
+
/**
* replicate logic of selectedDocument.click();
* Document has been clicked on in table
@@ -1340,6 +1431,9 @@ export const DocumentsTabComponent: React.FunctionComponent {
initDocumentEditor(documentId, content);
+
+ // Update columns
+ setColumnDefinitionsFromDocument(content);
},
);
@@ -1430,10 +1524,22 @@ export const DocumentsTabComponent: React.FunctionComponent resizeObserver.disconnect(); // clean up
}, []);
- const columnHeaders = {
- idHeader: isPreferredApiMongoDB ? "_id" : "id",
- partitionKeyHeaders: (showPartitionKey(_collection, isPreferredApiMongoDB) && partitionKeyPropertyHeaders) || [],
- };
+ // Column definition is a map to garantee uniqueness
+ const [columnDefinitions, setColumnDefinitions] = useState(() => {
+ const persistedColumnsSelection = readSubComponentState(
+ SubComponentName.ColumnsSelection,
+ _collection,
+ undefined,
+ );
+
+ if (!persistedColumnsSelection) {
+ return extractColumnDefinitionsFromDocument({
+ id: "id",
+ });
+ }
+
+ return persistedColumnsSelection.columnDefinitions;
+ });
const onSelectedRowsChange = (selectedRows: Set) => {
confirmDiscardingChange(() => {
@@ -1665,7 +1771,7 @@ export const DocumentsTabComponent: React.FunctionComponent(SubComponentName.FilterHistory, _collection, lastFilterContents);
};
const refreshDocumentsGrid = useCallback(
@@ -1764,6 +1870,41 @@ export const DocumentsTabComponent: React.FunctionComponent {
+ // Do not allow to unselecting all columns
+ if (newSelectedColumnIds.length === 0) {
+ return;
+ }
+
+ setSelectedColumnIds(newSelectedColumnIds);
+
+ saveSubComponentState(SubComponentName.ColumnsSelection, _collection, {
+ selectedColumnIds: newSelectedColumnIds,
+ columnDefinitions,
+ });
+ };
+
+ const prevSelectedColumnIds = usePrevious({ selectedColumnIds, setSelectedColumnIds });
+
+ useEffect(() => {
+ // If we are adding a field, let's refresh to include the field in the query
+ let addedField = false;
+ for (const field of selectedColumnIds) {
+ if (
+ !defaultQueryFields.includes(field) &&
+ prevSelectedColumnIds &&
+ !prevSelectedColumnIds.selectedColumnIds.includes(field)
+ ) {
+ addedField = true;
+ break;
+ }
+ }
+
+ if (addedField) {
+ refreshDocumentsGrid(false);
+ }
+ }, [prevSelectedColumnIds, refreshDocumentsGrid, selectedColumnIds]);
+
// TODO: remove isMongoBulkDeleteDisabled when new mongo proxy is enabled for all users
// TODO: remove partitionKey.systemKey when JS SDK bug is fixed
const isMongoBulkDeleteDisabled = !MongoProxyClient.useMongoProxyEndpoint("bulkdelete");
@@ -1865,42 +2006,41 @@ export const DocumentsTabComponent: React.FunctionComponent {
tabStateData.leftPaneWidthPercent = (100 * sizes[0]) / (sizes[0] + sizes[1]);
- saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
+ saveSubComponentState(SubComponentName.MainTabDivider, _collection, tabStateData);
setTabStateData(tabStateData);
}}
>
-
-
-
}
- style={{
- color: StyleConstants.AccentMedium,
- }}
- onClick={() => refreshDocumentsGrid(false)}
- onKeyDown={onRefreshKeyInput}
+
+
+ refreshDocumentsGrid(false)}
+ items={tableItems}
+ onItemClicked={(index) => onDocumentClicked(index, documentIds)}
+ onSelectedRowsChange={onSelectedRowsChange}
+ selectedRows={selectedRows}
+ size={tableContainerSizePx}
+ selectedColumnIds={selectedColumnIds}
+ columnDefinitions={columnDefinitions}
+ isRowSelectionDisabled={
+ isBulkDeleteDisabled ||
+ (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
+ }
+ onColumnSelectionChange={onColumnSelectionChange}
+ defaultColumnSelection={getInitialColumnSelection()}
+ collection={_collection}
+ isColumnSelectionDisabled={isPreferredApiMongoDB}
/>
-
- onDocumentClicked(index, documentIds)}
- onSelectedRowsChange={onSelectedRowsChange}
- selectedRows={selectedRows}
- size={tableContainerSizePx}
- columnHeaders={columnHeaders}
- isSelectionDisabled={
- isBulkDeleteDisabled ||
- (configContext.platform === Platform.Fabric && userContext.fabricContext?.isReadOnly)
- }
- collection={_collection}
- />
-
{tableItems.length > 0 && (
{
height: 0,
width: 0,
},
- columnHeaders: {
- idHeader: ID_HEADER,
- partitionKeyHeaders: [PARTITION_KEY_HEADER],
- },
- isSelectionDisabled: false,
+ columnDefinitions: [
+ { id: ID_HEADER, label: "ID", isPartitionKey: false },
+ { id: PARTITION_KEY_HEADER, label: "Partition Key", isPartitionKey: true },
+ ],
+ isRowSelectionDisabled: false,
collection: {
databaseId: "db",
id: ((): string => "coll") as ko.Observable,
} as ViewModels.CollectionBase,
+ onRefreshTable: (): void => {
+ throw new Error("Function not implemented.");
+ },
+ selectedColumnIds: [],
});
it("should render documents and partition keys in header", () => {
@@ -40,7 +44,7 @@ describe("DocumentsTableComponent", () => {
it("should not render selection column when isSelectionDisabled is true", () => {
const props: IDocumentsTableComponentProps = createMockProps();
- props.isSelectionDisabled = true;
+ props.isRowSelectionDisabled = true;
const wrapper = mount();
expect(wrapper).toMatchSnapshot();
});
diff --git a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx
index 127c5d6d9..c96d63ff5 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx
+++ b/src/Explorer/Tabs/DocumentsTabV2/DocumentsTableComponent.tsx
@@ -1,30 +1,48 @@
import {
- createTableColumn,
+ Button,
Menu,
+ MenuDivider,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
TableRowData as RowStateBase,
+ SortDirection,
Table,
TableBody,
TableCell,
TableCellLayout,
TableColumnDefinition,
+ TableColumnId,
TableColumnSizingOptions,
TableHeader,
TableHeaderCell,
TableRow,
TableRowId,
TableSelectionCell,
+ tokens,
useArrowNavigationGroup,
useTableColumnSizing_unstable,
useTableFeatures,
useTableSelection,
+ useTableSort,
} from "@fluentui/react-components";
+import {
+ ArrowClockwise16Regular,
+ ArrowResetRegular,
+ DeleteRegular,
+ EditRegular,
+ MoreHorizontalRegular,
+ TableResizeColumnRegular,
+ TextSortAscendingRegular,
+ TextSortDescendingRegular,
+} from "@fluentui/react-icons";
import { NormalizedEventKey } from "Common/Constants";
+import { TableColumnSelectionPane } from "Explorer/Panes/TableColumnSelectionPane/TableColumnSelectionPane";
import {
ColumnSizesMap,
+ ColumnSort,
+ deleteSubComponentState,
readSubComponentState,
saveSubComponentState,
SubComponentName,
@@ -32,29 +50,37 @@ import {
import { INITIAL_SELECTED_ROW_INDEX, useDocumentsTabStyles } from "Explorer/Tabs/DocumentsTabV2/DocumentsTabV2";
import { selectionHelper } from "Explorer/Tabs/DocumentsTabV2/SelectionHelper";
import { LayoutConstants } from "Explorer/Theme/ThemeUtil";
+import { userContext } from "UserContext";
import { isEnvironmentCtrlPressed, isEnvironmentShiftPressed } from "Utils/KeyboardUtils";
+import { useSidePanel } from "hooks/useSidePanel";
import React, { useCallback, useMemo } from "react";
import { FixedSizeList as List, ListChildComponentProps } from "react-window";
import * as ViewModels from "../../../Contracts/ViewModels";
export type DocumentsTableComponentItem = {
id: string;
-} & Record;
+} & Record;
-export type ColumnHeaders = {
- idHeader: string;
- partitionKeyHeaders: string[];
+export type ColumnDefinition = {
+ id: string;
+ label: string;
+ isPartitionKey: boolean;
};
export interface IDocumentsTableComponentProps {
+ onRefreshTable: () => void;
items: DocumentsTableComponentItem[];
onItemClicked: (index: number) => void;
onSelectedRowsChange: (selectedItemsIndices: Set) => void;
selectedRows: Set;
size: { height: number; width: number };
- columnHeaders: ColumnHeaders;
+ selectedColumnIds: string[];
+ columnDefinitions: ColumnDefinition[];
style?: React.CSSProperties;
- isSelectionDisabled?: boolean;
+ isRowSelectionDisabled?: boolean;
collection: ViewModels.CollectionBase;
+ onColumnSelectionChange?: (newSelectedColumnIds: string[]) => void;
+ defaultColumnSelection?: string[];
+ isColumnSelectionDisabled?: boolean;
}
interface TableRowData extends RowStateBase {
@@ -67,25 +93,33 @@ interface ReactWindowRenderFnProps extends ListChildComponentProps {
data: TableRowData[];
}
+const COLUMNS_MENU_NAME = "columnsMenu";
+
const defaultSize = {
idealWidth: 200,
minWidth: 50,
};
export const DocumentsTableComponent: React.FC = ({
+ onRefreshTable,
items,
onSelectedRowsChange,
selectedRows,
style,
size,
- columnHeaders,
- isSelectionDisabled,
+ selectedColumnIds,
+ columnDefinitions,
+ isRowSelectionDisabled: isSelectionDisabled,
collection,
+ onColumnSelectionChange,
+ defaultColumnSelection,
+ isColumnSelectionDisabled,
}: IDocumentsTableComponentProps) => {
+ const styles = useDocumentsTabStyles();
+
const [columnSizingOptions, setColumnSizingOptions] = React.useState(() => {
- const columnIds = ["id"].concat(columnHeaders.partitionKeyHeaders);
const columnSizesMap: ColumnSizesMap = readSubComponentState(SubComponentName.ColumnSizes, collection, {});
const columnSizesPx: TableColumnSizingOptions = {};
- columnIds.forEach((columnId) => {
+ selectedColumnIds.forEach((columnId) => {
if (
!columnSizesMap ||
!columnSizesMap[columnId] ||
@@ -103,7 +137,24 @@ export const DocumentsTableComponent: React.FC =
return columnSizesPx;
});
- const styles = useDocumentsTabStyles();
+ const [sortState, setSortState] = React.useState<{
+ sortDirection: "ascending" | "descending";
+ sortColumn: TableColumnId | undefined;
+ }>(() => {
+ const sort = readSubComponentState(SubComponentName.ColumnSort, collection, undefined);
+
+ if (!sort) {
+ return {
+ sortDirection: undefined,
+ sortColumn: undefined,
+ };
+ }
+
+ return {
+ sortDirection: sort.direction,
+ sortColumn: sort.columnId,
+ };
+ });
const onColumnResize = React.useCallback((_, { columnId, width }: { columnId: string; width: number }) => {
setColumnSizingOptions((state) => {
@@ -122,42 +173,123 @@ export const DocumentsTableComponent: React.FC =
return acc;
}, {} as ColumnSizesMap);
- saveSubComponentState(SubComponentName.ColumnSizes, collection, persistentSizes, true);
+ saveSubComponentState(SubComponentName.ColumnSizes, collection, persistentSizes, true);
return newSizingOptions;
});
}, []);
+ // const restoreFocusTargetAttribute = useRestoreFocusTarget();
+
+ const onSortClick = (event: React.SyntheticEvent, columnId: string, direction: SortDirection) => {
+ setColumnSort(event, columnId, direction);
+
+ if (columnId === undefined || direction === undefined) {
+ deleteSubComponentState(SubComponentName.ColumnSort, collection);
+ return;
+ }
+
+ saveSubComponentState(SubComponentName.ColumnSort, collection, { columnId, direction });
+ };
+
// Columns must be a static object and cannot change on re-renders otherwise React will complain about too many refreshes
const columns: TableColumnDefinition[] = useMemo(
() =>
- [
- createTableColumn({
- columnId: "id",
- compare: (a, b) => a.id.localeCompare(b.id),
- renderHeaderCell: () => columnHeaders.idHeader,
+ columnDefinitions
+ .filter((column) => selectedColumnIds.includes(column.id))
+ .map((column) => ({
+ columnId: column.id,
+ compare: (a, b) => {
+ if (typeof a[column.id] === "string") {
+ return (a[column.id] as string).localeCompare(b[column.id] as string);
+ } else if (typeof a[column.id] === "number") {
+ return (a[column.id] as number) - (b[column.id] as number);
+ } else {
+ // Should not happen
+ return 0;
+ }
+ },
+ renderHeaderCell: () => (
+ <>
+ {column.label}
+
+ >
+ ),
renderCell: (item) => (
-
- {item.id}
+
+ {item[column.id]}
),
- }),
- ].concat(
- columnHeaders.partitionKeyHeaders.map((pkHeader) =>
- createTableColumn({
- columnId: pkHeader,
- compare: (a, b) => a[pkHeader].localeCompare(b[pkHeader]),
- // Show Refresh button on last column
- renderHeaderCell: () => {pkHeader},
- renderCell: (item) => (
-
- {item[pkHeader]}
-
- ),
- }),
- ),
- ),
- [columnHeaders],
+ })),
+ [columnDefinitions, onColumnSelectionChange, selectedColumnIds],
);
const [selectionStartIndex, setSelectionStartIndex] = React.useState(INITIAL_SELECTED_ROW_INDEX);
@@ -247,6 +379,7 @@ export const DocumentsTableComponent: React.FC =
columnSizing_unstable: columnSizing,
tableRef,
selection: { allRowsSelected, someRowsSelected, toggleAllRows, toggleRow, isRowSelected },
+ sort: { getSortDirection, setColumnSort, sort },
} = useTableFeatures(
{
columns,
@@ -260,25 +393,36 @@ export const DocumentsTableComponent: React.FC =
// eslint-disable-next-line react/prop-types
onSelectionChange: (e, data) => onSelectedRowsChange(data.selectedItems),
}),
+ useTableSort({
+ sortState,
+ onSortChange: (e, nextSortState) => setSortState(nextSortState),
+ }),
],
);
- const rows: TableRowData[] = getRows((row) => {
- const selected = isRowSelected(row.rowId);
- return {
- ...row,
- onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
- onKeyDown: (e: React.KeyboardEvent) => {
- if (e.key === " ") {
- e.preventDefault();
- toggleRow(e, row.rowId);
- }
- },
- selected,
- appearance: selected ? ("brand" as const) : ("none" as const),
- };
+ const headerSortProps = (columnId: TableColumnId) => ({
+ // onClick: (e: React.MouseEvent) => toggleColumnSort(e, columnId),
+ sortDirection: getSortDirection(columnId),
});
+ const rows: TableRowData[] = sort(
+ getRows((row) => {
+ const selected = isRowSelected(row.rowId);
+ return {
+ ...row,
+ onClick: (e: React.MouseEvent) => toggleRow(e, row.rowId),
+ onKeyDown: (e: React.KeyboardEvent) => {
+ if (e.key === " ") {
+ e.preventDefault();
+ toggleRow(e, row.rowId);
+ }
+ },
+ selected,
+ appearance: selected ? ("brand" as const) : ("none" as const),
+ };
+ }),
+ );
+
const toggleAllKeydown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === " ") {
@@ -304,39 +448,53 @@ export const DocumentsTableComponent: React.FC =
...style,
};
+ const checkedValues: { [COLUMNS_MENU_NAME]: string[] } = {
+ [COLUMNS_MENU_NAME]: [],
+ };
+ columnDefinitions.forEach(
+ (columnDefinition) =>
+ selectedColumnIds.includes(columnDefinition.id) && checkedValues[COLUMNS_MENU_NAME].push(columnDefinition.id),
+ );
+
+ const openColumnSelectionPane = (): void => {
+ useSidePanel
+ .getState()
+ .openSidePanel(
+ "Select columns",
+ ,
+ );
+ };
+
return (
-
+
{!isSelectionDisabled && (
)}
- {columns.map((column /* index */) => (
-
+ {columns.map((column) => (
+
+ {column.renderHeaderCell()}
+
))}
+
(value: T): T | undefined => {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+};
diff --git a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap
index 93719e55c..794d609b8 100644
--- a/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap
+++ b/src/Explorer/Tabs/DocumentsTabV2/__snapshots__/DocumentsTabV2.test.tsx.snap
@@ -55,53 +55,57 @@ exports[`Documents tab (noSql API) when rendered should render the page 1`] = `
}
>
- }
- onClick={[Function]}
- onKeyDown={[Function]}
- size="small"
- style={
+
-
-
-
-
+
,
- },
- }
- }
- >
-
-
- }
- className="___16q6g07_0000000 finvdd3 fjik90z fw35ms5"
- data-tabster="{"restorer":{"type":1}}"
- id="menu17"
- key="id"
- onContextMenu={[Function]}
- onMouseEnter={[Function]}
- onMouseLeave={[Function]}
- onMouseMove={[Function]}
- style={
- {
- "maxWidth": 50,
- "minWidth": 50,
- "width": 50,
- }
- }
- >
-
-
-
-
-
-
-
+ />
+
@@ -509,106 +272,7 @@ exports[`DocumentsTableComponent should not render selection column when isSelec
"width": "100%",
}
}
- >
-
-
-
-
-
-
-
+ />
-
-
-
-
-
-
-
+ />
-
-
-
-
-
-
-
+ />
@@ -1007,15 +473,21 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
"id": [Function],
}
}
- columnHeaders={
- {
- "idHeader": "id",
- "partitionKeyHeaders": [
- "partitionKey",
- ],
- }
+ columnDefinitions={
+ [
+ {
+ "id": "id",
+ "isPartitionKey": false,
+ "label": "ID",
+ },
+ {
+ "id": "partitionKey",
+ "isPartitionKey": true,
+ "label": "Partition Key",
+ },
+ ]
}
- isSelectionDisabled={false}
+ isRowSelectionDisabled={false}
items={
[
{
@@ -1033,7 +505,9 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
]
}
onItemClicked={[Function]}
+ onRefreshTable={[Function]}
onSelectedRowsChange={[Function]}
+ selectedColumnIds={[]}
selectedRows={Set {}}
size={
{
@@ -1065,9 +539,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
}
}
>
-
+
@@ -1127,257 +604,11 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
-
-
+
@@ -1548,6 +779,137 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
"width": "100%",
}
}
+ >
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
@@ -2035,7 +1070,7 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
aria-label="Select row"
checked={false}
className="fui-Checkbox__input ruo9svu ___qlal8r0_1xrlghj f1vgc2s3"
- id="checkbox-16"
+ id="checkbox-14"
onChange={[Function]}
type="checkbox"
/>
@@ -2047,104 +1082,6 @@ exports[`DocumentsTableComponent should render documents and partition keys in h
-
-
-
-
-
-
diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts
index 5bd84516e..2ae14e59e 100644
--- a/src/Platform/Hosted/extractFeatures.ts
+++ b/src/Platform/Hosted/extractFeatures.ts
@@ -38,6 +38,7 @@ export type Features = {
readonly copilotChatFixedMonacoEditorHeight: boolean;
readonly enablePriorityBasedExecution: boolean;
readonly disableConnectionStringLogin: boolean;
+ readonly enableDocumentsTableColumnSelection: boolean;
// can be set via both flight and feature flag
autoscaleDefault: boolean;
@@ -108,6 +109,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear
copilotChatFixedMonacoEditorHeight: "true" === get("copilotchatfixedmonacoeditorheight"),
enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"),
disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"),
+ enableDocumentsTableColumnSelection: "true" === get("enabledocumentstablecolumnselection"),
};
}
diff --git a/src/Shared/StorageUtility.ts b/src/Shared/StorageUtility.ts
index 1cea30145..7a55513ed 100644
--- a/src/Shared/StorageUtility.ts
+++ b/src/Shared/StorageUtility.ts
@@ -29,6 +29,7 @@ export enum StorageKey {
GalleryCalloutDismissed,
VisitedAccounts,
PriorityLevel,
+ DocumentsTabPrefs,
DefaultQueryResultsView,
AppState,
}
diff --git a/src/Utils/QueryUtils.test.ts b/src/Utils/QueryUtils.test.ts
index e6b413a98..699626569 100644
--- a/src/Utils/QueryUtils.test.ts
+++ b/src/Utils/QueryUtils.test.ts
@@ -4,7 +4,7 @@ import * as sinon from "sinon";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
import * as QueryUtils from "./QueryUtils";
-import { extractPartitionKeyValues } from "./QueryUtils";
+import { defaultQueryFields, extractPartitionKeyValues } from "./QueryUtils";
describe("Query Utils", () => {
const generatePartitionKeyForPath = (path: string): DataModels.PartitionKey => {
@@ -54,6 +54,20 @@ describe("Query Utils", () => {
expect(partitionProjection).toContain('c["\\\\\\"a\\\\\\""]');
});
+
+ it("should always include the default fields", () => {
+ const query: string = QueryUtils.buildDocumentsQuery("", [], generatePartitionKeyForPath("/a"), []);
+
+ defaultQueryFields.forEach((field) => {
+ expect(query).toContain(`c.${field}`);
+ });
+ });
+
+ it("should always include the default fields even if they are themselves partition key fields", () => {
+ const query: string = QueryUtils.buildDocumentsQuery("", ["id"], generatePartitionKeyForPath("/id"), ["id"]);
+
+ expect(query).toContain("c.id");
+ });
});
describe("queryPagesUntilContentPresent()", () => {
diff --git a/src/Utils/QueryUtils.ts b/src/Utils/QueryUtils.ts
index 5c621b2fb..3cb3979d3 100644
--- a/src/Utils/QueryUtils.ts
+++ b/src/Utils/QueryUtils.ts
@@ -2,18 +2,29 @@ import { PartitionKey, PartitionKeyDefinition } from "@azure/cosmos";
import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels";
+export const defaultQueryFields = ["id", "_self", "_rid", "_ts"];
+
export function buildDocumentsQuery(
filter: string,
partitionKeyProperties: string[],
partitionKey: DataModels.PartitionKey,
+ additionalField: string[] = [],
): string {
+ const fieldSet = new Set(defaultQueryFields);
+ additionalField.forEach((prop) => {
+ if (!partitionKeyProperties.includes(prop)) {
+ fieldSet.add(prop);
+ }
+ });
+
+ const objectListSpec = [...fieldSet].map((prop) => `c.${prop}`).join(",");
let query =
partitionKeyProperties && partitionKeyProperties.length > 0
- ? `select c.id, c._self, c._rid, c._ts, [${buildDocumentsQueryPartitionProjections(
+ ? `select ${objectListSpec}, [${buildDocumentsQueryPartitionProjections(
"c",
partitionKey,
)}] as _partitionKeyValue from c`
- : `select c.id, c._self, c._rid, c._ts from c`;
+ : `select ${objectListSpec} from c`;
if (filter) {
query += " " + filter;