diff --git a/packages/odyssey-react-mui/src/Card.tsx b/packages/odyssey-react-mui/src/Card.tsx
index 41c540ec05..3097fd42ba 100644
--- a/packages/odyssey-react-mui/src/Card.tsx
+++ b/packages/odyssey-react-mui/src/Card.tsx
@@ -33,6 +33,7 @@ import {
useOdysseyDesignTokens,
} from "./OdysseyDesignTokensContext";
import { Heading5, Paragraph, Support } from "./Typography";
+import { Box } from "./Box";
export const CARD_IMAGE_HEIGHT = "64px";
@@ -63,7 +64,7 @@ const ImageContainer = styled("div", {
}>(({ odysseyDesignTokens, hasMenuButtonChildren }) => ({
display: "flex",
alignItems: "flex-start",
- maxHeight: `${CARD_IMAGE_HEIGHT}`,
+ maxHeight: CARD_IMAGE_HEIGHT,
marginBlockEnd: odysseyDesignTokens.Spacing5,
paddingRight: hasMenuButtonChildren ? odysseyDesignTokens.Spacing5 : 0,
}));
@@ -76,6 +77,10 @@ const MenuButtonContainer = styled("div", {
top: odysseyDesignTokens.Spacing3,
}));
+const CardContentContainer = styled("div")(() => ({
+ display: "flex",
+}));
+
const buttonProviderValue = { isFullWidth: true };
const Card = ({
@@ -91,30 +96,32 @@ const Card = ({
const cardContent = useMemo(
() => (
- <>
- {image && (
-
- {image}
-
- )}
-
- {overline && {overline}}
- {title && {title}}
- {description && (
- {description}
- )}
-
- {button && (
-
-
- {button}
-
-
- )}
- >
+
+
+ {image && (
+
+ {image}
+
+ )}
+
+ {overline && {overline}}
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {button && (
+
+
+ {button}
+
+
+ )}
+
+
),
[
button,
diff --git a/packages/odyssey-react-mui/src/DataTable/DataTable.tsx b/packages/odyssey-react-mui/src/DataTable/DataTable.tsx
index ae6d5b0547..e727eabdd8 100644
--- a/packages/odyssey-react-mui/src/DataTable/DataTable.tsx
+++ b/packages/odyssey-react-mui/src/DataTable/DataTable.tsx
@@ -34,6 +34,7 @@ import {
MRT_ColumnDef,
MRT_TableInstance,
} from "material-react-table";
+import { useTranslation } from "react-i18next";
import {
ArrowDownIcon,
ArrowUnsortedIcon,
@@ -61,7 +62,6 @@ import { useScrollIndication } from "./useScrollIndication";
import styled from "@emotion/styled";
import { EmptyState } from "../EmptyState";
import { Callout } from "../Callout";
-import { t } from "i18next";
export type DataTableColumn = MRT_ColumnDef & {
/**
@@ -387,6 +387,8 @@ const DataTable = ({
searchDelayTime,
totalRows,
}: DataTableProps) => {
+ const { t } = useTranslation();
+
const [data, setData] = useState([]);
const [pagination, setPagination] = useState({
pageIndex: currentPage,
diff --git a/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx b/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx
index 773aed4882..2e80e6407f 100644
--- a/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx
+++ b/packages/odyssey-react-mui/src/DataTable/DataTableRowActions.tsx
@@ -27,7 +27,7 @@ import { DataTableProps } from "./DataTable";
import { Trans, useTranslation } from "react-i18next";
export type DataTableRowActionsProps = {
- row: MRT_Row;
+ row: MRT_Row | MRT_RowData;
rowIndex: number;
rowActionButtons?: (
row: MRT_RowData,
diff --git a/packages/odyssey-react-mui/src/OdysseyCacheProvider.tsx b/packages/odyssey-react-mui/src/OdysseyCacheProvider.tsx
index 43742f4491..3973c7e726 100644
--- a/packages/odyssey-react-mui/src/OdysseyCacheProvider.tsx
+++ b/packages/odyssey-react-mui/src/OdysseyCacheProvider.tsx
@@ -23,16 +23,20 @@ import { CacheProvider } from "@emotion/react";
export type OdysseyCacheProviderProps = {
children: ReactNode;
- nonce?: string;
/**
* Emotion caches styles into the style element.
* When enabling this prop, Emotion caches the styles at this element, rather than in .
*/
emotionRoot?: HTMLStyleElement;
+ hasShadowDom?: boolean;
+ nonce?: string;
/**
* Emotion renders into this HTML element.
* When enabling this prop, Emotion renders at the top of this component rather than the bottom like it does in the HTML ``.
*/
+ /**
+ * @deprecated Will be removed in a future Odyssey version. Use `hasShadowDomElement` instead.
+ */
shadowDomElement?: HTMLDivElement | HTMLElement;
stylisPlugins?: StylisPlugin[];
};
@@ -40,21 +44,25 @@ export type OdysseyCacheProviderProps = {
const OdysseyCacheProvider = ({
children,
emotionRoot,
+ hasShadowDom: hasShadowDomProp,
nonce,
+ shadowDomElement,
stylisPlugins,
}: OdysseyCacheProviderProps) => {
const uniqueAlphabeticalId = useUniqueAlphabeticalId();
+ const hasShadowDom = hasShadowDomProp || shadowDomElement;
+
const emotionCache = useMemo(() => {
return createCache({
...(emotionRoot && { container: emotionRoot }),
key: uniqueAlphabeticalId,
nonce: nonce ?? window.cspNonce,
prepend: true,
- speedy: false, // <-- Needs to be set to false when shadow-dom is used!! https://github.com/emotion-js/emotion/issues/2053#issuecomment-713429122
+ speedy: hasShadowDom ? false : true, // <-- Needs to be set to false when shadow-dom is used!! https://github.com/emotion-js/emotion/issues/2053#issuecomment-713429122
...(stylisPlugins && { stylisPlugins }),
});
- }, [emotionRoot, nonce, stylisPlugins, uniqueAlphabeticalId]);
+ }, [emotionRoot, hasShadowDom, nonce, stylisPlugins, uniqueAlphabeticalId]);
return {children};
};
diff --git a/packages/odyssey-react-mui/src/OdysseyProvider.tsx b/packages/odyssey-react-mui/src/OdysseyProvider.tsx
index 69e8f89368..228ee84535 100644
--- a/packages/odyssey-react-mui/src/OdysseyProvider.tsx
+++ b/packages/odyssey-react-mui/src/OdysseyProvider.tsx
@@ -49,7 +49,7 @@ const OdysseyProvider = ({
{
+ const { t } = useTranslation();
+
const firstRow = pageSize * (pageIndex - 1) + 1;
const lastRow = firstRow + (pageSize - 1);
if (totalRows && lastRow > totalRows) {
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/BulkActionsMenu.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/BulkActionsMenu.tsx
new file mode 100644
index 0000000000..bc39f705fc
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/BulkActionsMenu.tsx
@@ -0,0 +1,97 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { memo, useCallback, Dispatch, SetStateAction } from "react";
+import { MRT_RowData, MRT_RowSelectionState } from "material-react-table";
+import styled from "@emotion/styled";
+import { useTranslation } from "react-i18next";
+
+import { Box } from "../../Box";
+import { Button } from "../../Button";
+import { ChevronDownIcon } from "../../icons.generated";
+import { MenuButton } from "../../MenuButton";
+import { UniversalProps } from "./componentTypes";
+import {
+ DesignTokens,
+ useOdysseyDesignTokens,
+} from "../../OdysseyDesignTokensContext";
+
+export type BulkActionsMenuProps = {
+ data: MRT_RowData[];
+ menuItems: UniversalProps["bulkActionMenuItems"];
+ rowSelection: MRT_RowSelectionState;
+ setRowSelection: Dispatch>;
+};
+
+const BulkActionsContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{
+ odysseyDesignTokens: DesignTokens;
+}>(({ odysseyDesignTokens }) => ({
+ display: "flex",
+ gap: odysseyDesignTokens.Spacing2,
+}));
+
+const BulkActionsMenu = ({
+ data,
+ menuItems,
+ rowSelection,
+ setRowSelection,
+}: BulkActionsMenuProps) => {
+ const odysseyDesignTokens = useOdysseyDesignTokens();
+ const { t } = useTranslation();
+
+ const selectedRowCount = Object.values(rowSelection).filter(Boolean).length;
+
+ const handleSelectAll = useCallback(() => {
+ const rows = Object.fromEntries(data.map((row) => [row.id, true]));
+ setRowSelection(rows);
+ }, [data, setRowSelection]);
+
+ const handleSelectNone = useCallback(() => {
+ setRowSelection({});
+ }, [setRowSelection]);
+
+ return (
+
+ {selectedRowCount > 0 && (
+ }
+ >
+ {menuItems?.(rowSelection)}
+
+ )}
+
+
+
+
+
+ );
+};
+
+const MemoizedBulkActionsMenu = memo(BulkActionsMenu);
+MemoizedBulkActionsMenu.displayName = "BulkActionsMenu";
+
+export { MemoizedBulkActionsMenu as BulkActionsMenu };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/DataStack.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/DataStack.tsx
new file mode 100644
index 0000000000..2bfd3359d7
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/DataStack.tsx
@@ -0,0 +1,99 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { memo, useMemo } from "react";
+
+import { availableStackLayouts } from "./constants";
+import {
+ AvailableStackLayouts,
+ StackProps,
+ UniversalProps,
+} from "./componentTypes";
+import { DataView } from "./DataView";
+
+export type DataStackProps = UniversalProps &
+ StackProps & {
+ initialLayout?: (typeof availableStackLayouts)[number];
+ availableLayouts?: AvailableStackLayouts;
+ };
+
+const DataStack = ({
+ availableLayouts,
+ bulkActionMenuItems,
+ cardProps,
+ currentPage,
+ emptyPlaceholder,
+ errorMessage,
+ filters,
+ getData,
+ hasFilters,
+ hasPagination,
+ hasRowReordering,
+ hasRowSelection,
+ hasSearch,
+ hasSearchSubmitButton,
+ isEmpty,
+ isLoading,
+ isNoResults,
+ isRowReorderingDisabled,
+ maxGridColumns,
+ noResultsPlaceholder,
+ onChangeRowSelection,
+ paginationType,
+ resultsPerPage,
+ rowActionMenuItems,
+ searchDelayTime,
+ totalRows,
+}: DataStackProps) => {
+ const stackOptions = useMemo(
+ () => ({
+ cardProps,
+ maxGridColumns,
+ rowActionMenuItems,
+ }),
+ [cardProps, maxGridColumns, rowActionMenuItems],
+ );
+
+ return (
+
+ );
+};
+
+const MemoizedDataStack = memo(DataStack);
+MemoizedDataStack.displayName = "DataStack";
+
+export { MemoizedDataStack as DataStack };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/DataTable.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/DataTable.tsx
new file mode 100644
index 0000000000..707c3bc6b5
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/DataTable.tsx
@@ -0,0 +1,111 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { memo, useMemo } from "react";
+
+import { DataView } from "./DataView";
+import { TableProps, UniversalProps } from "./componentTypes";
+
+export type DataTableProps = UniversalProps & TableProps;
+
+const DataTable = ({
+ bulkActionMenuItems,
+ columns,
+ currentPage,
+ emptyPlaceholder,
+ errorMessage,
+ filters,
+ getData,
+ hasChangeableDensity,
+ hasColumnResizing,
+ hasColumnVisibility,
+ initialDensity,
+ hasFilters,
+ hasPagination,
+ hasRowReordering,
+ hasRowSelection,
+ hasSearch,
+ hasSearchSubmitButton,
+ hasSorting,
+ isLoading,
+ isEmpty,
+ isNoResults,
+ isRowReorderingDisabled,
+ noResultsPlaceholder,
+ onChangeRowSelection,
+ paginationType,
+ renderDetailPanel,
+ resultsPerPage,
+ rowActionButtons,
+ rowActionMenuItems,
+ searchDelayTime,
+ totalRows,
+}: DataTableProps) => {
+ const tableOptions = useMemo(
+ () => ({
+ columns,
+ hasChangeableDensity,
+ hasColumnResizing,
+ hasColumnVisibility,
+ hasSorting,
+ initialDensity,
+ renderDetailPanel,
+ rowActionButtons,
+ rowActionMenuItems,
+ }),
+ [
+ columns,
+ hasChangeableDensity,
+ hasColumnResizing,
+ hasColumnVisibility,
+ hasSorting,
+ initialDensity,
+ renderDetailPanel,
+ rowActionButtons,
+ rowActionMenuItems,
+ ],
+ );
+
+ return (
+
+ );
+};
+
+const MemoizedDataTable = memo(DataTable);
+MemoizedDataTable.displayName = "DataTable";
+
+export { MemoizedDataTable as DataTable };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/DataView.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/DataView.tsx
new file mode 100644
index 0000000000..8c5adc1c82
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/DataView.tsx
@@ -0,0 +1,394 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { memo, useEffect, useMemo, useState } from "react";
+import {
+ MRT_Row,
+ MRT_RowData,
+ MRT_RowSelectionState,
+} from "material-react-table";
+import { useTranslation } from "react-i18next";
+
+import {
+ availableLayouts as allAvailableLayouts,
+ densityValues,
+} from "./constants";
+import {
+ Layout,
+ UniversalProps,
+ ViewProps,
+ TableState,
+} from "./componentTypes";
+import { Box } from "../../Box";
+import { BulkActionsMenu } from "./BulkActionsMenu";
+import { Callout } from "../../Callout";
+import { DataFilters } from "../DataFilters";
+import { EmptyState } from "../../EmptyState";
+import { fetchData } from "./fetchData";
+import { LayoutSwitcher } from "./LayoutSwitcher";
+import { TableSettings } from "./TableSettings";
+import { Pagination, usePagination } from "../../Pagination";
+import { TableContent } from "./TableContent";
+import { StackContent } from "./StackContent";
+import { useFilterConversion } from "./useFilterConversion";
+import { useRowReordering } from "../../DataTable/useRowReordering";
+import {
+ DesignTokens,
+ useOdysseyDesignTokens,
+} from "../../OdysseyDesignTokensContext";
+import styled from "@emotion/styled";
+
+export type DataViewProps = UniversalProps & ViewProps;
+
+const DataViewContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{ odysseyDesignTokens: DesignTokens }>(({ odysseyDesignTokens }) => ({
+ display: "flex",
+ flexDirection: "column",
+ gap: odysseyDesignTokens.Spacing4,
+}));
+
+const BulkActionsContainer = styled("div")(() => ({
+ display: "flex",
+ justifyContent: "space-between",
+}));
+
+const AdditionalActionsContainer = styled("div")(() => ({
+ display: "flex",
+ justifyContent: "flex-end",
+}));
+
+const DataView = ({
+ availableLayouts = allAvailableLayouts,
+ bulkActionMenuItems,
+ currentPage = 1,
+ emptyPlaceholder,
+ errorMessage: errorMessageProp,
+ filters: filtersProp,
+ getData,
+ getRowId: getRowIdProp,
+ hasFilters,
+ hasPagination,
+ hasSearch,
+ hasSearchSubmitButton,
+ hasRowReordering,
+ hasRowSelection,
+ initialLayout,
+ isEmpty: isEmptyProp,
+ isLoading: isLoadingProp,
+ isNoResults: isNoResultsProp,
+ isRowReorderingDisabled,
+ noResultsPlaceholder,
+ onChangeRowSelection,
+ onReorderRows,
+ paginationType = "paged",
+ resultsPerPage = 20,
+ searchDelayTime,
+ stackOptions,
+ tableOptions,
+ totalRows,
+}: DataViewProps) => {
+ const odysseyDesignTokens = useOdysseyDesignTokens();
+ const { t } = useTranslation();
+
+ const [currentLayout, setCurrentLayout] = useState(
+ initialLayout ?? availableLayouts[0],
+ );
+
+ const [data, setData] = useState([]);
+ const [isLoading, setIsLoading] = useState(isLoadingProp ?? true);
+ const [isEmpty, setIsEmpty] = useState(isEmptyProp ?? true);
+ const [isNoResults, setIsNoResults] = useState(
+ isNoResultsProp ?? false,
+ );
+ const [errorMessage, setErrorMessage] =
+ useState(errorMessageProp);
+
+ const [search, setSearch] = useState("");
+
+ const [initialFilters, setInitialFilters] =
+ useState(filtersProp);
+ const [filters, setFilters] =
+ useState(filtersProp);
+
+ const [draggingRow, setDraggingRow] = useState | null>();
+
+ const [rowSelection, setRowSelection] = useState({});
+
+ useEffect(() => {
+ onChangeRowSelection?.(rowSelection);
+ }, [rowSelection, onChangeRowSelection]);
+
+ const [pagination, setPagination] = useState({
+ pageIndex: currentPage,
+ pageSize: resultsPerPage,
+ });
+
+ const [tableState, setTableState] = useState({
+ columnSorting: [],
+ columnVisibility: {},
+ rowDensity: tableOptions?.initialDensity ?? densityValues[0],
+ });
+
+ const shouldShowFilters = hasSearch || hasFilters;
+
+ const availableFilters = useFilterConversion({
+ filters: filters,
+ columns: tableOptions?.columns,
+ });
+
+ useEffect(() => {
+ if (!initialFilters && availableFilters) {
+ setInitialFilters(availableFilters);
+ }
+ }, [availableFilters, initialFilters]);
+
+ const dataQueryParams = useMemo(
+ () => ({
+ page: pagination.pageIndex,
+ resultsPerPage: pagination.pageSize,
+ search,
+ filters: availableFilters,
+ sort: tableState?.columnSorting,
+ }),
+ [
+ pagination.pageIndex,
+ pagination.pageSize,
+ search,
+ availableFilters,
+ tableState?.columnSorting,
+ ],
+ );
+
+ const getRowId = getRowIdProp ? getRowIdProp : (row: MRT_RowData) => row.id;
+
+ // Update pagination state if props change
+ useEffect(() => {
+ setPagination({
+ pageIndex: currentPage,
+ pageSize: resultsPerPage,
+ });
+ }, [currentPage, resultsPerPage]);
+
+ // Retrieve the data
+ useEffect(() => {
+ fetchData({
+ dataQueryParams,
+ errorMessageProp,
+ getData,
+ setData,
+ setErrorMessage,
+ setIsLoading,
+ });
+ }, [dataQueryParams, errorMessageProp, getData]);
+
+ // When data is updated
+ useEffect(() => {
+ setIsEmpty(
+ pagination.pageIndex === currentPage &&
+ pagination.pageSize === resultsPerPage &&
+ search === "" &&
+ filters === initialFilters &&
+ data.length === 0,
+ );
+ }, [
+ currentPage,
+ data,
+ filters,
+ initialFilters,
+ pagination,
+ resultsPerPage,
+ search,
+ ]);
+
+ // Change loading, empty and noResults state on prop change
+ useEffect(() => {
+ setIsLoading((prevValue) => isLoadingProp ?? prevValue);
+ }, [isLoadingProp]);
+
+ useEffect(() => {
+ setIsEmpty((prevValue) => isEmptyProp ?? prevValue);
+ }, [isEmptyProp]);
+
+ useEffect(() => {
+ setIsNoResults((prevValue) => isNoResultsProp ?? prevValue);
+ }, [isNoResultsProp]);
+
+ const emptyState = useMemo(() => {
+ const noResultsInnerContent = noResultsPlaceholder || (
+
+ );
+
+ if (isEmpty) {
+ return emptyPlaceholder || noResultsInnerContent;
+ }
+
+ if (isNoResults) {
+ return noResultsInnerContent;
+ }
+
+ return;
+ }, [noResultsPlaceholder, t, isEmpty, isNoResults, emptyPlaceholder]);
+
+ const additionalActions = useMemo(
+ () => (
+ <>
+ {currentLayout === "table" && tableOptions && (
+
+ )}
+
+ {availableLayouts.length > 1 && (
+
+ )}
+ >
+ ),
+ [currentLayout, tableOptions, tableState, availableLayouts],
+ );
+
+ const { lastRow: lastRowOnPage } = usePagination({
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ totalRows,
+ });
+
+ const rowReorderingUtilities = useRowReordering({
+ totalRows,
+ onReorderRows,
+ data,
+ setData,
+ draggingRow,
+ setDraggingRow,
+ resultsPerPage: pagination.pageSize,
+ page: pagination.pageIndex,
+ });
+
+ return (
+
+ {errorMessage && (
+
+
+
+ )}
+
+ {shouldShowFilters && (
+
+ )}
+
+ {(bulkActionMenuItems || hasRowSelection) && (
+
+
+ {!shouldShowFilters && additionalActions}
+
+ )}
+
+ {!shouldShowFilters && !bulkActionMenuItems && !hasRowSelection && (
+
+ {additionalActions}
+
+ )}
+
+ {currentLayout === "table" && tableOptions && (
+
+ )}
+ {(currentLayout === "list" || currentLayout === "grid") &&
+ stackOptions && (
+
+ )}
+
+ {hasPagination && (
+
+ )}
+
+ );
+};
+
+const MemoizedDataView = memo(DataView);
+MemoizedDataView.displayName = "DataView";
+
+export { MemoizedDataView as DataView };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/DetailPanel.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/DetailPanel.tsx
new file mode 100644
index 0000000000..809bd959f1
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/DetailPanel.tsx
@@ -0,0 +1,31 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { memo } from "react";
+import { StackProps } from "./componentTypes";
+import { DataRow } from "./dataTypes";
+
+const DetailPanel = ({
+ row,
+ renderDetailPanel,
+}: {
+ row: DataRow;
+ renderDetailPanel: StackProps["renderDetailPanel"];
+}) => {
+ if (!renderDetailPanel) return null;
+ return renderDetailPanel({ row });
+};
+
+const MemoizedDetailPanel = memo(DetailPanel);
+MemoizedDetailPanel.displayName = "DetailPanel";
+
+export { MemoizedDetailPanel as DetailPanel };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/LayoutSwitcher.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/LayoutSwitcher.tsx
new file mode 100644
index 0000000000..ab5c704387
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/LayoutSwitcher.tsx
@@ -0,0 +1,73 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { Dispatch, memo, useCallback, SetStateAction, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+
+import { AvailableLayouts, Layout } from "./componentTypes";
+import { MenuButton } from "../../MenuButton";
+import { MenuItem } from "../../MenuItem";
+
+export type LayoutSwitcherProps = {
+ availableLayouts: AvailableLayouts;
+ currentLayout: Layout;
+ setCurrentLayout: Dispatch>;
+};
+
+const LayoutSwitcher = ({
+ availableLayouts,
+ currentLayout,
+ setCurrentLayout,
+}: LayoutSwitcherProps) => {
+ const { t } = useTranslation();
+
+ const changeLayout = useCallback(
+ (value: Layout) => {
+ setCurrentLayout(value);
+ },
+ [setCurrentLayout],
+ );
+
+ const memoizedMenuItems = useMemo(
+ () =>
+ availableLayouts.map((value: Layout) => ({
+ value,
+ onClick: () => changeLayout(value),
+ label: t(`dataview.layout.${value}`),
+ })),
+ [availableLayouts, changeLayout, t],
+ );
+
+ return (
+
+ {memoizedMenuItems.map(({ value, onClick, label }) => (
+
+ ))}
+
+ );
+};
+
+const MemoizedLayoutSwitcher = memo(LayoutSwitcher);
+MemoizedLayoutSwitcher.displayName = "LayoutSwitcher";
+
+export { MemoizedLayoutSwitcher as LayoutSwitcher };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/RowActions.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/RowActions.tsx
new file mode 100644
index 0000000000..4587ae6c40
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/RowActions.tsx
@@ -0,0 +1,122 @@
+/*!
+ * Copyright (c) 2023-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { Fragment, ReactElement, memo, useCallback } from "react";
+import { MRT_Row, MRT_RowData } from "material-react-table";
+import { useTranslation } from "react-i18next";
+
+import {
+ ArrowBottomIcon,
+ ArrowDownIcon,
+ ArrowTopIcon,
+ ArrowUpIcon,
+} from "../../icons.generated";
+import { Button } from "../../Button";
+import { MenuItem } from "../../MenuItem";
+import { DataTableProps } from "./DataTable";
+import { MenuButtonProps } from "../../MenuButton";
+
+export type RowActionsProps = {
+ isRowReorderingDisabled?: boolean;
+ row: MRT_Row | MRT_RowData;
+ rowActionButtons?: (
+ row: MRT_RowData,
+ ) => ReactElement | ReactElement;
+ rowActionMenuItems?: (row: MRT_RowData) => MenuButtonProps["children"];
+ rowIndex: number;
+ totalRows?: DataTableProps["totalRows"];
+ updateRowOrder?: ({
+ newRowIndex,
+ rowId,
+ }: {
+ newRowIndex: number;
+ rowId: string;
+ }) => void;
+};
+
+const RowActions = ({
+ isRowReorderingDisabled,
+ row,
+ rowActionMenuItems,
+ rowIndex,
+ totalRows,
+ updateRowOrder,
+}: RowActionsProps) => {
+ const { t } = useTranslation();
+
+ const handleToFrontClick = useCallback(() => {
+ updateRowOrder && updateRowOrder({ rowId: row.id, newRowIndex: 0 });
+ }, [row.id, updateRowOrder]);
+
+ const handleForwardClick = useCallback(() => {
+ updateRowOrder &&
+ updateRowOrder({ rowId: row.id, newRowIndex: Math.max(0, rowIndex - 1) });
+ }, [row.id, rowIndex, updateRowOrder]);
+
+ const handleBackwardClick = useCallback(() => {
+ updateRowOrder &&
+ updateRowOrder({ rowId: row.id, newRowIndex: rowIndex + 1 });
+ }, [row.id, rowIndex, updateRowOrder]);
+
+ const handleToBackClick = useCallback(() => {
+ updateRowOrder &&
+ updateRowOrder({
+ rowId: row.id,
+ newRowIndex: totalRows ? totalRows - 1 : rowIndex,
+ });
+ }, [row.id, rowIndex, totalRows, updateRowOrder]);
+
+ return (
+ <>
+ {rowActionMenuItems && rowActionMenuItems(row)}
+ {rowActionMenuItems && updateRowOrder &&
}
+ {updateRowOrder && (
+ <>
+
+
+
+ {totalRows && (
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+const MemoizedRowActions = memo(RowActions);
+MemoizedRowActions.displayName = "RowActions";
+
+export { MemoizedRowActions as RowActions };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/StackCard.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/StackCard.tsx
new file mode 100644
index 0000000000..37707ebe9d
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/StackCard.tsx
@@ -0,0 +1,256 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import {
+ MouseEventHandler,
+ ReactElement,
+ memo,
+ useMemo,
+ ReactNode,
+ useState,
+} from "react";
+import {
+ IconButton as MuiIconButton,
+ Card as MuiCard,
+ CardActions as MuiCardActions,
+ CardActionArea as MuiCardActionArea,
+ Tooltip as MuiTooltip,
+} from "@mui/material";
+import styled from "@emotion/styled";
+import { useTranslation } from "react-i18next";
+
+import { Box } from "../../Box";
+import { Button } from "../../Button";
+import { ButtonContext } from "../../ButtonContext";
+import {
+ DesignTokens,
+ useOdysseyDesignTokens,
+} from "../../OdysseyDesignTokensContext";
+import { Heading5, Paragraph, Support } from "../../Typography";
+import { MenuButton, MenuButtonProps } from "../../MenuButton";
+import {
+ ChevronDownIcon,
+ ChevronUpIcon,
+ MoreIcon,
+} from "../../icons.generated";
+
+export const CARD_IMAGE_HEIGHT = "64px";
+
+export type StackCardProps = {
+ children?: ReactNode;
+ description?: string;
+ detailPanel?: ReactNode;
+ image?: ReactElement;
+ overline?: string;
+ title?: string;
+} & (
+ | {
+ Accessory?: never;
+ button?: never;
+ menuButtonChildren?: never;
+ onClick: MouseEventHandler;
+ }
+ | {
+ Accessory?: ReactNode;
+ button?: ReactElement;
+ menuButtonChildren?: MenuButtonProps["children"];
+ onClick?: never;
+ }
+);
+
+const AccessoryContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{
+ odysseyDesignTokens: DesignTokens;
+}>(({ odysseyDesignTokens }) => ({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: odysseyDesignTokens.Spacing2,
+}));
+
+const ImageContainer = styled("div", {
+ shouldForwardProp: (prop) =>
+ prop !== "odysseyDesignTokens" && prop !== "hasMenuButtonChildren",
+})<{
+ odysseyDesignTokens: DesignTokens;
+ hasMenuButtonChildren: boolean;
+}>(({ odysseyDesignTokens, hasMenuButtonChildren }) => ({
+ display: "flex",
+ alignItems: "flex-start",
+ maxHeight: `${CARD_IMAGE_HEIGHT}`,
+ marginBlockEnd: odysseyDesignTokens.Spacing5,
+ paddingRight: hasMenuButtonChildren ? odysseyDesignTokens.Spacing5 : 0,
+}));
+
+const MenuButtonContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{ odysseyDesignTokens: DesignTokens }>(({ odysseyDesignTokens }) => ({
+ position: "absolute",
+ right: odysseyDesignTokens.Spacing3,
+ top: odysseyDesignTokens.Spacing3,
+}));
+
+const CardContentContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{ odysseyDesignTokens: DesignTokens }>(({ odysseyDesignTokens }) => ({
+ display: "flex",
+ gap: odysseyDesignTokens.Spacing3,
+}));
+
+const CardChildrenContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{ odysseyDesignTokens: DesignTokens }>(({ odysseyDesignTokens }) => ({
+ ["div + &"]: {
+ marginBlockStart: odysseyDesignTokens.Spacing3,
+ },
+}));
+
+const buttonProviderValue = { isFullWidth: true };
+
+const StackCard = ({
+ Accessory: AccessoryProp,
+ button,
+ children,
+ description,
+ detailPanel,
+ image,
+ menuButtonChildren,
+ onClick,
+ overline,
+ title,
+}: StackCardProps) => {
+ const odysseyDesignTokens = useOdysseyDesignTokens();
+ const { t } = useTranslation();
+
+ const [isDetailPanelOpen, setIsDetailPanelOpen] = useState(false);
+
+ const Accessory = useMemo(
+ () => (
+
+ {AccessoryProp}
+ {detailPanel && (
+
+ :
+ }
+ onClick={() => setIsDetailPanelOpen(!isDetailPanelOpen)}
+ aria-label={
+ isDetailPanelOpen
+ ? t("table.rowexpansion.collapse")
+ : t("table.rowexpansion.expand")
+ }
+ />
+
+ )}
+
+ ),
+ [AccessoryProp, detailPanel, isDetailPanelOpen, odysseyDesignTokens, t],
+ );
+
+ const cardContent = useMemo(
+ () => (
+
+ {(AccessoryProp || detailPanel) && {Accessory}}
+
+ {image && (
+
+ {image}
+
+ )}
+
+ {overline && {overline}}
+ {title && {title}}
+ {description && (
+ {description}
+ )}
+
+ {button && (
+
+
+ {button}
+
+
+ )}
+
+ {children && (
+
+ {children}
+
+ )}
+
+ {detailPanel && isDetailPanelOpen && (
+
+ {detailPanel}
+
+ )}
+
+
+ ),
+ [
+ odysseyDesignTokens,
+ AccessoryProp,
+ detailPanel,
+ Accessory,
+ image,
+ menuButtonChildren,
+ overline,
+ title,
+ description,
+ button,
+ children,
+ isDetailPanelOpen,
+ ],
+ );
+
+ return (
+
+ {onClick ? (
+ {cardContent}
+ ) : (
+ cardContent
+ )}
+
+ {menuButtonChildren && (
+
+ }
+ ariaLabel={t("table.moreactions.arialabel")}
+ buttonVariant="floating"
+ menuAlignment="right"
+ size="small"
+ tooltipText={t("table.actions")}
+ >
+ {menuButtonChildren}
+
+
+ )}
+
+ );
+};
+
+const MemoizedStackCard = memo(StackCard);
+MemoizedStackCard.displayName = "StackCard";
+
+export { MemoizedStackCard as StackCard };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/StackContent.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/StackContent.tsx
new file mode 100644
index 0000000000..38fa77071a
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/StackContent.tsx
@@ -0,0 +1,254 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { Dispatch, ReactNode, SetStateAction, memo, useCallback } from "react";
+import styled, { CSSObject } from "@emotion/styled";
+import {
+ MRT_Row,
+ MRT_RowData,
+ MRT_RowSelectionState,
+ MRT_TableInstance,
+} from "material-react-table";
+
+import { Box } from "../../Box";
+import { Checkbox as MuiCheckbox } from "@mui/material";
+import { CircularProgress } from "../../CircularProgress";
+import {
+ DesignTokens,
+ useOdysseyDesignTokens,
+} from "../../OdysseyDesignTokensContext";
+import { RowActions } from "./RowActions";
+import { StackCard } from "./StackCard";
+import { StackLayout, StackProps, UniversalProps } from "./componentTypes";
+import { DetailPanel } from "./DetailPanel";
+
+export type StackContentProps = {
+ currentLayout: StackLayout;
+ data: MRT_RowData[];
+ draggingRow?: MRT_Row | null;
+ emptyState: ReactNode;
+ getRowId: UniversalProps["getRowId"];
+ hasRowReordering: UniversalProps["hasRowReordering"];
+ hasRowSelection: UniversalProps["hasRowSelection"];
+ isEmpty?: boolean;
+ isLoading: boolean;
+ isNoResults?: boolean;
+ isRowReorderingDisabled?: boolean;
+ onReorderRows: UniversalProps["onReorderRows"];
+ pagination: { pageIndex: number; pageSize: number };
+ rowReorderingUtilities: {
+ dragHandleStyles: CSSObject;
+ dragHandleText: {
+ title: string;
+ "aria-label": string;
+ };
+ draggableTableBodyRowClassName: ({
+ currentRowId,
+ draggingRowId,
+ hoveredRowId,
+ }: {
+ currentRowId: string;
+ draggingRowId?: string;
+ hoveredRowId?: string;
+ }) => string | undefined;
+ handleDragHandleKeyDown: ({
+ table,
+ row,
+ event,
+ }: {
+ table: MRT_TableInstance;
+ row: MRT_Row;
+ event: React.KeyboardEvent;
+ }) => void;
+ handleDragHandleOnDragCapture: (
+ table: MRT_TableInstance,
+ ) => void;
+ handleDragHandleOnDragEnd: (table: MRT_TableInstance) => void;
+ resetDraggingAndHoveredRow: (table: MRT_TableInstance) => void;
+ updateRowOrder: ({
+ rowId,
+ newRowIndex,
+ }: {
+ rowId: string;
+ newRowIndex: number;
+ }) => void;
+ };
+ rowSelection: MRT_RowSelectionState;
+ setRowSelection: Dispatch>;
+ stackOptions: StackProps;
+ totalRows: UniversalProps["totalRows"];
+};
+
+const StackContainer = styled("div", {
+ shouldForwardProp: (prop) =>
+ prop !== "odysseyDesignTokens" &&
+ prop !== "currentLayout" &&
+ prop !== "maxGridColumns",
+})<{
+ odysseyDesignTokens: DesignTokens;
+ currentLayout: StackLayout;
+ maxGridColumns: number;
+}>(({ odysseyDesignTokens, currentLayout, maxGridColumns }) => ({
+ display: currentLayout === "list" ? "flex" : "grid",
+ flexDirection: "column",
+ gap: odysseyDesignTokens.Spacing5,
+
+ ...(currentLayout === "grid" && {
+ [`@media (max-width: 720px)`]: {
+ gridTemplateColumns: "repeat(1, 1fr)",
+ },
+ [`@media (min-width: 720px) and (max-width: 960px)`]: {
+ gridTemplateColumns: "repeat(2, 1fr)",
+ },
+ [`@media (min-width: 960px)`]: {
+ gridTemplateColumns: `repeat(${maxGridColumns}, 1fr)`,
+ },
+ }),
+}));
+
+const LoadingContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{
+ odysseyDesignTokens: DesignTokens;
+}>(({ odysseyDesignTokens }) => ({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "100%",
+ paddingBlock: odysseyDesignTokens.Spacing5,
+}));
+
+const CheckboxContainer = styled("div", {
+ shouldForwardProp: (prop) => prop !== "odysseyDesignTokens",
+})<{
+ odysseyDesignTokens: DesignTokens;
+}>(({ odysseyDesignTokens }) => ({
+ marginBlockStart: `-${odysseyDesignTokens.Spacing1}`,
+}));
+
+const StackContent = ({
+ currentLayout,
+ data,
+ emptyState,
+ hasRowReordering,
+ hasRowSelection,
+ isEmpty,
+ isLoading,
+ isNoResults,
+ isRowReorderingDisabled,
+ onReorderRows,
+ pagination,
+ rowReorderingUtilities,
+ rowSelection,
+ setRowSelection,
+ stackOptions,
+ totalRows,
+}: StackContentProps) => {
+ const odysseyDesignTokens = useOdysseyDesignTokens();
+
+ const handleRowSelectionChange = useCallback(
+ (row: MRT_RowData) => {
+ setRowSelection((rowSelection) =>
+ Object.fromEntries(
+ row.id in rowSelection
+ ? Object.entries(rowSelection).filter(([key]) => key !== row.id)
+ : Object.entries(rowSelection).concat([[row.id, true]]),
+ ),
+ );
+ },
+ [setRowSelection],
+ );
+
+ const { updateRowOrder } = rowReorderingUtilities;
+
+ return (
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {!data || data.length === 0 || isEmpty || isNoResults ? (
+ {emptyState}
+ ) : (
+ <>
+ {data.map((row: MRT_RowData, index: number) => {
+ const { overline, title, description, image, children } =
+ stackOptions.cardProps(row);
+ const currentIndex =
+ index + (pagination.pageIndex - 1) * pagination.pageSize;
+
+ return (
+
+ handleRowSelectionChange(row)}
+ />
+
+ )
+ }
+ children={children}
+ description={description}
+ detailPanel={
+ stackOptions.renderDetailPanel ? (
+
+ ) : undefined
+ }
+ image={image}
+ key={row.id}
+ menuButtonChildren={
+ (stackOptions.rowActionMenuItems || hasRowReordering) && (
+
+ )
+ }
+ overline={overline}
+ title={title}
+ />
+ );
+ })}
+ >
+ )}
+ >
+ )}
+
+ );
+};
+
+const MemoizedStackContent = memo(StackContent);
+MemoizedStackContent.displayName = "StackContent";
+
+export { MemoizedStackContent as StackContent };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/TableContent.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/TableContent.tsx
new file mode 100644
index 0000000000..67aa85ba10
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/TableContent.tsx
@@ -0,0 +1,390 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import {
+ SetStateAction,
+ memo,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+ ReactNode,
+ Dispatch,
+ ReactElement,
+} from "react";
+import styled, { CSSObject } from "@emotion/styled";
+import {
+ MRT_Row,
+ MRT_RowData,
+ MRT_RowSelectionState,
+ MRT_TableContainer,
+ MRT_TableInstance,
+ MRT_TableOptions,
+ useMaterialReactTable,
+} from "material-react-table";
+import { useTranslation } from "react-i18next";
+
+import {
+ ArrowDownIcon,
+ ArrowUnsortedIcon,
+ ChevronDownIcon,
+ DragIndicatorIcon,
+} from "../../icons.generated";
+import { Box } from "../../Box";
+import { TableProps, TableState, UniversalProps } from "./componentTypes";
+import { DataTableCell } from "./dataTypes";
+import {
+ dataTableImmutableSettings,
+ displayColumnDefOptions,
+ ScrollableTableContainer,
+} from "./tableConstants";
+import { MenuButton } from "../../MenuButton";
+import { MoreIcon } from "../../icons.generated";
+import { RowActions } from "./RowActions";
+import { useOdysseyDesignTokens } from "../../OdysseyDesignTokensContext";
+import { useScrollIndication } from "../../DataTable/useScrollIndication";
+
+const TextWrapper = styled("div")(() => ({
+ whiteSpace: "nowrap",
+ textOverflow: "ellipsis",
+ overflow: "hidden",
+}));
+
+const RowActionsContainer = styled("div")(() => ({
+ display: "flex",
+}));
+
+export type TableContentProps = {
+ columns: TableProps["columns"];
+ data: MRT_RowData[];
+ draggingRow?: MRT_Row | null;
+ emptyState: ReactNode;
+ getRowId: UniversalProps["getRowId"];
+ hasRowReordering: UniversalProps["hasRowReordering"];
+ hasRowSelection: UniversalProps["hasRowSelection"];
+ isEmpty?: boolean;
+ isLoading: boolean;
+ isNoResults?: boolean;
+ isRowReorderingDisabled?: boolean;
+ onReorderRows: UniversalProps["onReorderRows"];
+ pagination: {
+ pageIndex: number;
+ pageSize: number;
+ };
+ rowReorderingUtilities: {
+ dragHandleStyles: CSSObject;
+ dragHandleText: {
+ title: string;
+ "aria-label": string;
+ };
+ draggableTableBodyRowClassName: ({
+ currentRowId,
+ draggingRowId,
+ hoveredRowId,
+ }: {
+ currentRowId: string;
+ draggingRowId?: string;
+ hoveredRowId?: string;
+ }) => string | undefined;
+ handleDragHandleKeyDown: ({
+ table,
+ row,
+ event,
+ }: {
+ table: MRT_TableInstance;
+ row: MRT_Row;
+ event: React.KeyboardEvent;
+ }) => void;
+ handleDragHandleOnDragCapture: (
+ table: MRT_TableInstance,
+ ) => void;
+ handleDragHandleOnDragEnd: (table: MRT_TableInstance) => void;
+ resetDraggingAndHoveredRow: (table: MRT_TableInstance) => void;
+ updateRowOrder: ({
+ rowId,
+ newRowIndex,
+ }: {
+ rowId: string;
+ newRowIndex: number;
+ }) => void;
+ };
+ rowSelection: MRT_RowSelectionState;
+ setRowSelection: Dispatch>;
+ setTableState: Dispatch>;
+ tableOptions: TableProps;
+ tableState: TableState;
+ totalRows: UniversalProps["totalRows"];
+};
+
+const TableContent = ({
+ columns,
+ data,
+ draggingRow,
+ emptyState,
+ getRowId,
+ hasRowReordering,
+ hasRowSelection,
+ isEmpty,
+ isLoading,
+ isNoResults,
+ isRowReorderingDisabled,
+ onReorderRows,
+ pagination,
+ rowReorderingUtilities,
+ rowSelection,
+ setRowSelection,
+ setTableState,
+ tableOptions,
+ tableState,
+ totalRows,
+}: TableContentProps) => {
+ const [isTableContainerScrolledToStart, setIsTableContainerScrolledToStart] =
+ useState(true);
+ const [isTableContainerScrolledToEnd, setIsTableContainerScrolledToEnd] =
+ useState(true);
+ const [tableInnerContainerWidth, setTableInnerContainerWidth] =
+ useState("100%");
+ const tableOuterContainerRef = useRef(null);
+ const tableInnerContainerRef = useRef(null);
+ const tableContentRef = useRef(null);
+
+ useScrollIndication({
+ setIsTableContainerScrolledToEnd: setIsTableContainerScrolledToEnd,
+ setIsTableContainerScrolledToStart: setIsTableContainerScrolledToStart,
+ setTableInnerContainerWidth: setTableInnerContainerWidth,
+ tableInnerContainer: tableInnerContainerRef.current,
+ tableOuterContainer: tableOuterContainerRef.current,
+ });
+
+ const odysseyDesignTokens = useOdysseyDesignTokens();
+ const { t } = useTranslation();
+
+ const columnIds = useMemo(() => {
+ return columns.map((column) => column.accessorKey) ?? [];
+ }, [columns]);
+
+ const columnOrder = useMemo(
+ () => [
+ "mrt-row-drag",
+ "mrt-row-select",
+ "mrt-row-expand",
+ ...(columnIds?.filter((id): id is string => typeof id === "string") ||
+ []),
+ "mrt-row-actions",
+ ],
+ [columnIds],
+ );
+
+ const rowDensityClassName = useMemo(() => {
+ return tableState.rowDensity === "spacious"
+ ? "MuiTableBody-spacious"
+ : tableState.rowDensity === "compact"
+ ? "MuiTableBody-compact"
+ : "MuiTableBody-default";
+ }, [tableState]);
+
+ const defaultCell = useCallback<
+ ({ cell }: { cell: DataTableCell }) => ReactElement | string
+ >(({ cell }) => {
+ const value = cell.getValue();
+ const hasTextWrapping =
+ cell.column.columnDef.hasTextWrapping ||
+ cell.column.columnDef.enableWrapping;
+ return hasTextWrapping ? value : {value};
+ }, []);
+
+ const {
+ draggableTableBodyRowClassName,
+ dragHandleStyles,
+ dragHandleText,
+ handleDragHandleKeyDown,
+ handleDragHandleOnDragCapture,
+ handleDragHandleOnDragEnd,
+ resetDraggingAndHoveredRow,
+ updateRowOrder,
+ } = rowReorderingUtilities;
+
+ const renderRowActions = useCallback(
+ ({ row }: { row: MRT_Row }) => {
+ const currentIndex =
+ row.index + (pagination.pageIndex - 1) * pagination.pageSize;
+ return (
+
+ {tableOptions.rowActionButtons?.(row)}
+ {(tableOptions.rowActionMenuItems || hasRowReordering) && (
+ }
+ menuAlignment="right"
+ size="small"
+ >
+
+
+ )}
+
+ );
+ },
+ [
+ hasRowReordering,
+ isRowReorderingDisabled,
+ onReorderRows,
+ pagination.pageIndex,
+ pagination.pageSize,
+ t,
+ tableOptions,
+ totalRows,
+ updateRowOrder,
+ ],
+ );
+
+ const innerWidthStyle = useMemo(
+ () => ({ width: tableInnerContainerWidth }),
+ [tableInnerContainerWidth],
+ );
+
+ const emptyStateContainer = useCallback(
+ () => {emptyState},
+ [innerWidthStyle, emptyState],
+ );
+
+ const dataTable = useMaterialReactTable({
+ data: !isEmpty && !isNoResults ? data : [],
+ columns,
+ getRowId,
+ state: {
+ sorting: tableState.columnSorting,
+ columnVisibility: tableState.columnVisibility,
+ isLoading: isLoading,
+ rowSelection: rowSelection,
+ columnOrder: columnOrder,
+ },
+ icons: {
+ ArrowDownwardIcon: ArrowDownIcon,
+ DragHandleIcon: DragIndicatorIcon,
+ SyncAltIcon: ArrowUnsortedIcon,
+ ExpandMoreIcon: ChevronDownIcon,
+ },
+ ...dataTableImmutableSettings,
+ displayColumnDefOptions: displayColumnDefOptions satisfies Partial<
+ MRT_TableOptions["displayColumnDefOptions"]
+ >,
+ muiTableProps: {
+ ref: tableContentRef,
+ },
+ muiTableContainerProps: {
+ ref: tableInnerContainerRef,
+ },
+ muiTableBodyProps: () => ({
+ className: rowDensityClassName,
+ }),
+ enableColumnResizing: tableOptions.hasColumnResizing,
+ defaultColumn: {
+ Cell: defaultCell,
+ },
+ enableRowActions:
+ (hasRowReordering === true && onReorderRows) ||
+ tableOptions.rowActionButtons ||
+ tableOptions.rowActionMenuItems
+ ? true
+ : false,
+ renderRowActions: ({ row }) => renderRowActions({ row }),
+ enableRowOrdering: hasRowReordering && Boolean(onReorderRows),
+ enableRowDragging: hasRowReordering && Boolean(onReorderRows),
+ muiDetailPanelProps: ({ row }) => ({
+ sx: {
+ paddingBlock: row.getIsExpanded()
+ ? `${odysseyDesignTokens.Spacing3} !important`
+ : undefined,
+ },
+ }),
+ muiTableBodyRowProps: ({ table, row, isDetailPanel }) => ({
+ className: draggableTableBodyRowClassName({
+ currentRowId: row.id,
+ draggingRowId: draggingRow?.id,
+ hoveredRowId: table.getState().hoveredRow?.id,
+ }),
+ sx: isDetailPanel
+ ? {
+ paddingBlock: "0 !important",
+ border: 0,
+ ["&:hover"]: {
+ backgroundColor: `${odysseyDesignTokens.HueNeutralWhite} !important`,
+ },
+ }
+ : {},
+ }),
+ muiRowDragHandleProps: ({ table, row }) => ({
+ onKeyDown: (event) => handleDragHandleKeyDown({ table, row, event }),
+ onBlur: () => resetDraggingAndHoveredRow(table),
+ onDragEnd: () => handleDragHandleOnDragEnd(table),
+ onDragCapture: () => handleDragHandleOnDragCapture(table),
+ disabled: isRowReorderingDisabled,
+ sx: dragHandleStyles,
+ ...dragHandleText,
+ }),
+ renderDetailPanel: tableOptions.renderDetailPanel,
+ enableRowVirtualization: data.length >= 50,
+ muiTableHeadCellProps: ({ column: currentColumn }) => ({
+ className: tableState.columnSorting.find(
+ (sortedColumn) => sortedColumn.id === currentColumn.id,
+ )
+ ? "isSorted"
+ : "isUnsorted",
+ }),
+ enableSorting: tableOptions.hasSorting === true, // I don't know why this needs to be true, but it still works if undefined otherwise
+ onSortingChange: (sortingUpdater) => {
+ const newSortVal =
+ typeof sortingUpdater === "function"
+ ? sortingUpdater(tableState.columnSorting)
+ : tableState.columnSorting;
+ setTableState((prevState) => ({
+ ...prevState,
+ columnSorting: newSortVal,
+ }));
+ },
+ enableRowSelection: hasRowSelection,
+ onRowSelectionChange: setRowSelection,
+ renderEmptyRowsFallback: emptyStateContainer,
+ localization: {
+ collapse: t("table.rowexpansion.collapse"),
+ collapseAll: t("table.rowexpansion.collapseall"),
+ expand: t("table.rowexpansion.expand"),
+ expandAll: t("table.rowexpansion.expandall"),
+ },
+ });
+
+ return (
+
+
+
+ );
+};
+
+const MemoizedTableContent = memo(TableContent);
+MemoizedTableContent.displayName = "TableContent";
+
+export { MemoizedTableContent as TableContent };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/TableSettings.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/TableSettings.tsx
new file mode 100644
index 0000000000..da2cdd6ffa
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/TableSettings.tsx
@@ -0,0 +1,138 @@
+/*!
+ * Copyright (c) 2023-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { Dispatch, SetStateAction, memo, useCallback, useMemo } from "react";
+import { Checkbox as MuiCheckbox } from "@mui/material";
+import { useTranslation } from "react-i18next";
+
+import { densityValues } from "./constants";
+import { ListIcon, ShowIcon } from "../../icons.generated";
+import { MenuButton } from "../../MenuButton";
+import { MenuItem } from "../../MenuItem";
+import { TableProps, TableState } from "./componentTypes";
+
+export type TableSettingsProps = {
+ setTableState: Dispatch>;
+ tableOptions: TableProps;
+ tableState: TableState;
+};
+
+const TableSettings = ({
+ setTableState,
+ tableOptions,
+ tableState,
+}: TableSettingsProps) => {
+ const { t } = useTranslation();
+
+ const { hasChangeableDensity, hasColumnVisibility, columns } = tableOptions;
+ const { rowDensity, columnVisibility } = tableState;
+
+ const changeRowDensity = useCallback(
+ (value: (typeof densityValues)[number]) =>
+ (_event: React.MouseEvent) => {
+ // This is necessary to avoid linter errors, while the _event is necessary to satisfy the onClick type
+ if (process.env.NODE_ENV === "development") console.debug(_event);
+
+ setTableState((prevState) => ({
+ ...prevState,
+ rowDensity: value,
+ }));
+ },
+ [setTableState],
+ );
+
+ const changeColumnVisibility = useCallback(
+ (columnId: string) => (_event: React.MouseEvent) => {
+ // This is necessary to avoid linter errors, while the _event is necessary to satisfy the onClick type
+ if (process.env.NODE_ENV === "development") console.debug(_event);
+
+ setTableState((prevState) => ({
+ ...prevState,
+ columnVisibility: {
+ ...prevState.columnVisibility,
+ [columnId]: prevState.columnVisibility
+ ? prevState.columnVisibility[columnId] === false
+ : false,
+ },
+ }));
+ },
+ [setTableState],
+ );
+
+ const isColumnVisibilityChecked = useMemo(() => {
+ return columns.reduce(
+ (acc, column) => {
+ const isChecked = columnVisibility
+ ? columnVisibility[column.accessorKey!] !== false
+ : true;
+ acc[column.accessorKey!] = isChecked;
+ return acc;
+ },
+ {} as Record,
+ );
+ }, [columns, columnVisibility]);
+
+ return (
+ <>
+ {hasChangeableDensity && (
+ }
+ menuAlignment="right"
+ shouldCloseOnSelect={false}
+ >
+ <>
+ {densityValues.map((value: (typeof densityValues)[number]) => (
+
+ ))}
+ >
+
+ )}
+
+ {hasColumnVisibility && (
+ }
+ menuAlignment="right"
+ shouldCloseOnSelect={false}
+ >
+ <>
+ {columns
+ .filter((column) => column.enableHiding !== false)
+ .map((column) => (
+
+ ))}
+ >
+
+ )}
+ >
+ );
+};
+
+const MemoizedTableSettings = memo(TableSettings);
+MemoizedTableSettings.displayName = "TableSettings";
+
+export { MemoizedTableSettings as TableSettings };
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/componentTypes.ts b/packages/odyssey-react-mui/src/labs/DataComponents/componentTypes.ts
new file mode 100644
index 0000000000..13ffde63bd
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/componentTypes.ts
@@ -0,0 +1,111 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import {
+ MRT_DensityState,
+ MRT_RowData,
+ MRT_RowSelectionState,
+ MRT_SortingState,
+ MRT_TableOptions,
+ MRT_VisibilityState,
+} from "material-react-table";
+
+import {
+ availableLayouts,
+ availableStackLayouts,
+ densityValues,
+} from "./constants";
+import { DataFilter } from "../DataFilters";
+import {
+ DataGetDataType,
+ DataOnReorderRowsType,
+ DataRowSelectionState,
+ DataTableColumn,
+} from "./dataTypes";
+import { DataTableRowActionsProps } from "../../DataTable/DataTableRowActions";
+import { MenuButtonProps } from "../..";
+import { paginationTypeValues } from "../DataTablePagination";
+import { ReactNode } from "react";
+import { StackCardProps } from "./StackCard";
+
+export type Layout = (typeof availableLayouts)[number];
+export type StackLayout = (typeof availableStackLayouts)[number];
+
+export type AvailableLayouts = Layout[];
+export type AvailableStackLayouts = StackLayout[];
+
+export type UniversalProps = {
+ bulkActionMenuItems?: (
+ selectedRows: MRT_RowSelectionState,
+ ) => MenuButtonProps["children"];
+ currentPage?: number;
+ emptyPlaceholder?: ReactNode;
+ errorMessage?: string;
+ filters?: Array | string>;
+ getData: ({
+ page,
+ resultsPerPage,
+ search,
+ filters,
+ sort,
+ }: DataGetDataType) => MRT_RowData[] | Promise;
+ getRowId?: MRT_TableOptions["getRowId"];
+ hasFilters?: boolean;
+ hasPagination?: boolean;
+ hasRowReordering?: boolean;
+ hasRowSelection?: boolean;
+ hasSearch?: boolean;
+ hasSearchSubmitButton?: boolean;
+ isEmpty?: boolean;
+ isLoading?: boolean;
+ isNoResults?: boolean;
+ isRowReorderingDisabled?: boolean;
+ noResultsPlaceholder?: ReactNode;
+ onChangeRowSelection?: (rowSelection: DataRowSelectionState) => void;
+ onReorderRows?: ({ rowId, newRowIndex }: DataOnReorderRowsType) => void;
+ paginationType?: (typeof paginationTypeValues)[number];
+ resultsPerPage?: number;
+ searchDelayTime?: number;
+ totalRows?: number;
+};
+
+export type TableProps = {
+ columns: DataTableColumn[];
+ hasChangeableDensity?: boolean;
+ hasColumnResizing?: boolean;
+ hasColumnVisibility?: boolean;
+ hasSorting?: boolean;
+ initialDensity?: (typeof densityValues)[number];
+ renderDetailPanel?: MRT_TableOptions["renderDetailPanel"];
+ rowActionButtons?: DataTableRowActionsProps["rowActionButtons"];
+ rowActionMenuItems?: DataTableRowActionsProps["rowActionMenuItems"];
+};
+
+export type StackProps = {
+ cardProps: (row: MRT_RowData) => StackCardProps;
+ maxGridColumns?: number;
+ renderDetailPanel?: (props: { row: MRT_RowData }) => ReactNode;
+ rowActionMenuItems?: DataTableRowActionsProps["rowActionMenuItems"];
+};
+
+export type ViewProps = {
+ availableLayouts?: L[];
+ initialLayout?: L;
+ stackOptions?: StackProps;
+ tableOptions?: TableProps;
+};
+
+export type TableState = {
+ columnSorting: MRT_SortingState;
+ columnVisibility: MRT_VisibilityState;
+ rowDensity?: MRT_DensityState;
+};
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/constants.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/constants.tsx
new file mode 100644
index 0000000000..9d1e5c4716
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/constants.tsx
@@ -0,0 +1,20 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+export const availableStackLayouts = ["list", "grid"];
+export const availableTableLayouts = ["table"];
+export const availableLayouts = [
+ ...availableTableLayouts,
+ ...availableStackLayouts,
+];
+
+export const densityValues = ["comfortable", "spacious", "compact"] as const;
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/dataTypes.ts b/packages/odyssey-react-mui/src/labs/DataComponents/dataTypes.ts
new file mode 100644
index 0000000000..640b15e009
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/dataTypes.ts
@@ -0,0 +1,77 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import {
+ MRT_Cell,
+ MRT_Column,
+ MRT_ColumnDef,
+ MRT_RowData,
+ MRT_RowSelectionState,
+ MRT_SortingState,
+} from "material-react-table";
+
+import { DataFilter } from "../DataFilters";
+
+export type DataQueryParamsType = {
+ filters?: DataFilter[];
+ page?: number;
+ resultsPerPage?: number;
+ search?: string;
+ sort?: MRT_SortingState;
+};
+
+export type DataTableColumn = MRT_ColumnDef & {
+ /**
+ * @deprecated use hasTextWrapping instead of enableWrapping
+ */
+ enableWrapping?: boolean;
+ hasTextWrapping?: boolean;
+};
+
+export type DataTableColumnInstance = Omit<
+ MRT_Column,
+ "columnDef"
+> & {
+ columnDef: DataTableColumn;
+};
+
+export type DataTableCell = Omit<
+ MRT_Cell,
+ "column"
+> & {
+ column: DataTableColumnInstance;
+};
+
+export type DataColumns = DataTableColumn[];
+
+export type DataRow = MRT_RowData;
+
+export type DataGetDataType = {
+ filters?: DataFilter[];
+ page?: number;
+ resultsPerPage?: number;
+ search?: string;
+ sort?: MRT_SortingState;
+};
+
+export type DataOnReorderRowsType = {
+ newRowIndex: number;
+ rowId: string;
+};
+
+export type DataRowSelectionState = MRT_RowSelectionState;
+
+// Provided for backwards compatibilty with old DataTable types
+export type DataTableGetDataType = DataGetDataType;
+export type DataTableOnReorderRowsType = DataOnReorderRowsType;
+export type DataTableRowSelectionState = DataRowSelectionState;
+export type DataTableRow = DataRow;
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/fetchData.ts b/packages/odyssey-react-mui/src/labs/DataComponents/fetchData.ts
new file mode 100644
index 0000000000..78223fd849
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/fetchData.ts
@@ -0,0 +1,47 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { Dispatch, SetStateAction } from "react";
+import { MRT_RowData } from "material-react-table";
+import { t } from "i18next";
+
+import { DataQueryParamsType } from "./dataTypes";
+import { UniversalProps } from "./componentTypes";
+
+type DataRequestType = {
+ dataQueryParams: DataQueryParamsType;
+ errorMessageProp: UniversalProps["errorMessage"];
+ getData: UniversalProps["getData"];
+ setData: Dispatch>;
+ setErrorMessage: Dispatch>;
+ setIsLoading: Dispatch>;
+};
+
+export const fetchData = async ({
+ dataQueryParams,
+ errorMessageProp,
+ getData,
+ setData,
+ setErrorMessage,
+ setIsLoading,
+}: DataRequestType) => {
+ setIsLoading(true);
+ setErrorMessage(errorMessageProp);
+ try {
+ const incomingData = await getData?.(dataQueryParams);
+ setData(incomingData);
+ } catch (error) {
+ setErrorMessage(typeof error === "string" ? error : t("table.error"));
+ } finally {
+ setIsLoading(false);
+ }
+};
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/index.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/index.tsx
new file mode 100644
index 0000000000..d317460b6a
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/index.tsx
@@ -0,0 +1,19 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+export { DataStack, type DataStackProps } from "./DataStack";
+export { DataTable, type DataTableProps } from "./DataTable";
+export { DataView, type DataViewProps } from "./DataView";
+
+export * from "./componentTypes";
+export * from "./constants";
+export * from "./dataTypes";
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/tableConstants.tsx b/packages/odyssey-react-mui/src/labs/DataComponents/tableConstants.tsx
new file mode 100644
index 0000000000..1bc3058252
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/tableConstants.tsx
@@ -0,0 +1,162 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import styled from "@emotion/styled";
+import { MRT_RowData, MRT_TableOptions } from "material-react-table";
+
+import { Box } from "../../Box";
+import { DesignTokens } from "../../OdysseyDesignTokensContext";
+import { DragIndicatorIcon } from "../../icons.generated";
+
+export const dataTableImmutableSettings = {
+ enableColumnActions: false,
+ enableDensityToggle: false,
+ enableExpandAll: false,
+ enableFilters: false,
+ enableFullScreenToggle: false,
+ enableGlobalFilter: false,
+ enableHiding: false,
+ enablePagination: false,
+ layoutMode: "grid-no-grow" as MRT_TableOptions["layoutMode"],
+ manualFiltering: true,
+ manualSorting: true,
+ muiTablePaperProps: {
+ elevation: 0,
+ sx: {
+ overflow: "visible",
+ },
+ },
+ positionActionsColumn:
+ "last" as MRT_TableOptions["positionActionsColumn"],
+ rowVirtualizerOptions: {
+ overscan: 4,
+ },
+ selectAllMode: "all" as MRT_TableOptions["selectAllMode"],
+};
+
+export const displayColumnDefOptions = {
+ "mrt-row-actions": {
+ header: "",
+ grow: true,
+ muiTableBodyCellProps: {
+ align: "right" as const,
+ sx: {
+ overflow: "visible",
+ width: "unset",
+ },
+ className: "ods-actions-cell",
+ },
+ muiTableHeadCellProps: {
+ align: "right" as const,
+ sx: {
+ width: "unset",
+ },
+ className: "ods-actions-cell",
+ },
+ },
+ "mrt-row-drag": {
+ header: "",
+ muiTableBodyCellProps: {
+ sx: {
+ minWidth: 0,
+ width: "auto",
+ },
+ className: "ods-drag-handle",
+ },
+ muiTableHeadCellProps: {
+ sx: {
+ minWidth: 0,
+ width: "auto",
+ },
+ children: (
+ // Add a spacer to simulate the width of the drag handle in the column.
+ // Without this, the head cells are offset from their body cell counterparts
+
+
+
+ ),
+ },
+ },
+ "mrt-row-select": {
+ muiTableHeadCellProps: {
+ padding: "checkbox" as const,
+ },
+ muiTableBodyCellProps: {
+ padding: "checkbox" as const,
+ },
+ },
+ "mrt-row-expand": {
+ header: "",
+ muiTableBodyCellProps: {
+ sx: {
+ overflow: "visible",
+ },
+ },
+ },
+};
+
+export const ScrollableTableContainer = styled("div", {
+ shouldForwardProp: (prop) =>
+ prop !== "odysseyDesignTokens" &&
+ prop !== "isScrollableStart" &&
+ prop !== "isScrollableEnd",
+})(
+ ({
+ odysseyDesignTokens,
+ isScrollableStart,
+ isScrollableEnd,
+ }: {
+ odysseyDesignTokens: DesignTokens;
+ isScrollableStart: boolean;
+ isScrollableEnd: boolean;
+ }) => ({
+ marginBlockEnd: odysseyDesignTokens.Spacing4,
+ position: "relative",
+ borderInlineStartColor: isScrollableStart
+ ? odysseyDesignTokens.HueNeutral200
+ : "transparent",
+ borderInlineStartStyle: "solid",
+ borderInlineStartWidth: odysseyDesignTokens.BorderWidthMain,
+ "::before": {
+ background:
+ "linear-gradient(-90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.33) 50%, rgba(0, 0, 0, 1) 100%)",
+ content: '""',
+ opacity: isScrollableStart ? "0.075" : "0",
+ pointerEvents: "none",
+ position: "absolute",
+ top: 0,
+ left: 0,
+ bottom: 0,
+ width: odysseyDesignTokens.Spacing6,
+ zIndex: 100,
+ transition: `opacity ${odysseyDesignTokens.TransitionDurationMain} ${odysseyDesignTokens.TransitionTimingMain}`,
+ },
+ borderInlineEndColor: isScrollableEnd
+ ? odysseyDesignTokens.HueNeutral200
+ : "transparent",
+ borderInlineEndStyle: "solid",
+ borderInlineEndWidth: odysseyDesignTokens.BorderWidthMain,
+ "::after": {
+ background:
+ "linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.33) 50%, rgba(0, 0, 0, 1) 100%)",
+ content: '""',
+ opacity: isScrollableEnd ? "0.075" : "0",
+ pointerEvents: "none",
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: odysseyDesignTokens.Spacing6,
+ transition: `opacity ${odysseyDesignTokens.TransitionDurationMain} ${odysseyDesignTokens.TransitionTimingMain}`,
+ },
+ }),
+);
diff --git a/packages/odyssey-react-mui/src/labs/DataComponents/useFilterConversion.ts b/packages/odyssey-react-mui/src/labs/DataComponents/useFilterConversion.ts
new file mode 100644
index 0000000000..091d61710b
--- /dev/null
+++ b/packages/odyssey-react-mui/src/labs/DataComponents/useFilterConversion.ts
@@ -0,0 +1,92 @@
+/*!
+ * Copyright (c) 2024-present, Okta, Inc. and/or its affiliates. All rights reserved.
+ * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.")
+ *
+ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *
+ * See the License for the specific language governing permissions and limitations under the License.
+ */
+
+import { useCallback, useMemo } from "react";
+import { MRT_RowData } from "material-react-table";
+
+import { DataFilter } from "../DataFilters";
+import { DataTableColumn } from "../../DataTable";
+import { UniversalProps, TableProps } from "./componentTypes";
+
+type FilterConversionType = {
+ columns?: TableProps["columns"];
+ filters?: UniversalProps["filters"];
+};
+
+export const useFilterConversion = ({
+ columns,
+ filters,
+}: FilterConversionType) => {
+ const convertFilterSelectOptions = useCallback(
+ (options: DataTableColumn["filterSelectOptions"]) =>
+ options?.map((option) =>
+ typeof option === "string"
+ ? {
+ label: option,
+ value: option,
+ }
+ : {
+ // If the option isn't a string, it must have value and/or option defined
+ // If either is undefined, use the other
+ label: option.label ?? option.value,
+ value: option.value ?? option.label,
+ },
+ ),
+ [],
+ );
+
+ const convertColumnToFilter = useCallback(
+ (column: DataTableColumn) =>
+ column.enableColumnFilter !== false && column.accessorKey
+ ? ({
+ id: column.accessorKey,
+ label: column.header,
+ variant: column.filterVariant,
+ options: convertFilterSelectOptions(column.filterSelectOptions),
+ } satisfies DataFilter as DataFilter)
+ : null,
+ [convertFilterSelectOptions],
+ );
+
+ const dataTableFilters = useMemo(() => {
+ const providedFilters = filters || columns;
+ if (!providedFilters) {
+ return [];
+ }
+ return providedFilters.reduce((accumulator, item) => {
+ if (typeof item === "string") {
+ const foundColumn = columns?.find(
+ (column) => column.accessorKey === item,
+ );
+ if (foundColumn) {
+ const filter = convertColumnToFilter(foundColumn);
+ if (filter) {
+ return accumulator.concat(filter);
+ }
+ }
+ } else if ("accessorKey" in item) {
+ // Checks if it's a column
+ const filter = convertColumnToFilter(item);
+ if (filter) {
+ return accumulator.concat(filter);
+ }
+ } else if ("label" in item) {
+ // Checks if it's a DataFilter
+ return accumulator.concat(item);
+ }
+ // If none of the conditions match, item is ignored (not mapping to undefined)
+ return accumulator;
+ }, []);
+ }, [columns, filters]);
+
+ return dataTableFilters;
+};
diff --git a/packages/odyssey-react-mui/src/labs/DataFilters.tsx b/packages/odyssey-react-mui/src/labs/DataFilters.tsx
index c828a2f0d5..62e81d1647 100644
--- a/packages/odyssey-react-mui/src/labs/DataFilters.tsx
+++ b/packages/odyssey-react-mui/src/labs/DataFilters.tsx
@@ -283,10 +283,9 @@ const DataFilters = ({
const [searchValue, setSearchValue] = useState(defaultSearchTerm);
- const activeFilters = useMemo(
- () => filters.filter((filter) => filter.value),
- [filters],
- );
+ const activeFilters = useMemo(() => {
+ return filters.filter((filter) => filter.value);
+ }, [filters]);
const [isFiltersMenuOpen, setIsFiltersMenuOpen] = useState(false);
@@ -338,11 +337,14 @@ const DataFilters = ({
const updateFilters = useCallback(
({ filterId, value }) => {
+ setInputValues((prevInputValues) => ({
+ ...prevInputValues,
+ [filterId]: value,
+ }));
const updatedFilters = filtersProp.map((filter) => ({
...filter,
value: filter.id === filterId ? value : inputValues[filter.id],
}));
-
setFilters(updatedFilters);
},
[inputValues, filtersProp],
diff --git a/packages/odyssey-react-mui/src/labs/index.ts b/packages/odyssey-react-mui/src/labs/index.ts
index e6710d69b8..0e10af7f0f 100644
--- a/packages/odyssey-react-mui/src/labs/index.ts
+++ b/packages/odyssey-react-mui/src/labs/index.ts
@@ -17,6 +17,8 @@ export type { LocalizationProviderProps } from "@mui/x-date-pickers";
export * from "./DatePicker";
export * from "./datePickerTheme";
+export * from "./DataComponents";
+
/** @deprecated Will be removed in a future Odyssey version in lieu of the one shipping with DataTable */
export * from "./DataTablePagination";
export * from "./DataFilters";
diff --git a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties
index 5af4b2adf5..b43c0db521 100644
--- a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties
+++ b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties
@@ -79,7 +79,6 @@ severity.success = success
severity.warning = warning
switch.active = Active
switch.inactive = Inactive
-table.actions = Actions
table.columnvisibility.arialabel = Show/hide columns
table.density.arialabel = Table density
table.draghandle.arialabel = Drag row to reorder. Or, press space or enter to start and stop reordering and esc to cancel.
@@ -108,3 +107,13 @@ pagination.page = Page
pagination.rowsperpage = Rows per page
pagination.rowswithtotal = {{firstRow}}-{{lastRow}} of {{totalRows}} rows
pagination.rowswithouttotal = {{firstRow}}-{{lastRow}} rows
+table.actions.selectall = Select all
+table.actions.selectnone = Select none
+table.actions.selectsome = {{selectedRowCount}} selected
+table.rowexpansion.expand = Expand
+table.rowexpansion.expandall = Expand all
+table.rowexpansion.collapse = Collapse
+table.rowexpansion.collapseall = Collapse all
+dataview.layout.table = Table
+dataview.layout.grid = Grid
+dataview.layout.list = List
\ No newline at end of file
diff --git a/packages/odyssey-react-mui/src/theme/components.tsx b/packages/odyssey-react-mui/src/theme/components.tsx
index 827edc6d99..c5c61f76ac 100644
--- a/packages/odyssey-react-mui/src/theme/components.tsx
+++ b/packages/odyssey-react-mui/src/theme/components.tsx
@@ -786,6 +786,10 @@ export const components = ({
height: CARD_IMAGE_HEIGHT,
},
+ "&.hasAccessory": {
+ paddingLeft: odysseyTokens.Spacing4,
+ },
+
"&.isClickable:hover": {
backgroundColor: odysseyTokens.HueNeutral50,
boxShadow: odysseyTokens.DepthHigh,
@@ -2562,14 +2566,15 @@ export const components = ({
[`.${tableHeadClasses.root} &`]: {
color: odysseyTokens.TypographyColorHeading,
- fontSize: `0.71428571rem`,
- lineHeight: odysseyTokens.TypographyLineHeightBody,
fontWeight: odysseyTokens.TypographyWeightBodyBold,
textTransform: "uppercase",
backgroundColor: odysseyTokens.HueNeutral50,
borderBottom: 0,
height: `${odysseyTokens.Spacing7} !important`,
paddingBlock: `${odysseyTokens.Spacing3} !important`,
+ fontSize: odysseyTokens.TypographySizeOverline,
+ lineHeight: odysseyTokens.TypographyLineHeightBody,
+ letterSpacing: 1.3,
},
[`.${tableHeadClasses.root} &:first-of-type`]: {
diff --git a/packages/odyssey-storybook/src/components/odyssey-labs/DataComponents/DataComponents.mdx b/packages/odyssey-storybook/src/components/odyssey-labs/DataComponents/DataComponents.mdx
new file mode 100644
index 0000000000..97829fcced
--- /dev/null
+++ b/packages/odyssey-storybook/src/components/odyssey-labs/DataComponents/DataComponents.mdx
@@ -0,0 +1,476 @@
+import {
+ Canvas,
+ Meta,
+ Title,
+ Subtitle,
+ Description,
+ Primary,
+ Controls,
+ Stories,
+} from "@storybook/addon-docs";
+import { Story } from "@storybook/blocks";
+import * as DataComponentsStories from "./DataComponents.stories";
+
+
+
+
+
+
+
+The `DataComponents` suite is a powerful and flexible set of tools for displaying and interacting with data in various formats. It includes `DataView`, `DataTable`, and `DataStack`, which can be used to create complex data representations with features like filtering, sorting, pagination, and more.
+
+## Overview
+
+The `DataComponents` suite is built on top of Material-React-Table v2 (MRT) and offers additional functionality and styling to match the Okta Odyssey design system. It's designed to be easy to set up while remaining highly customizable to meet diverse data display needs.
+
+Key components:
+
+- `DataView`: The most flexible component, allowing you to switch between table and stack layouts.
+- `DataTable`: Focused on tabular data display with advanced features.
+- `DataStack`: For displaying data in a card-based layout, either as a list or grid.
+
+## Getting Started
+
+To use the `DataComponents`, you'll need to provide at minimum a `getData` function. Here's a basic example:
+
+```tsx
+import { DataView, DataViewProps } from "@okta/odyssey-react-mui/labs";
+import { columns, data } from "./yourDataFile";
+
+const YourComponent = () => {
+ const getData = useCallback(
+ ({ ...props }: DataGetDataType) => {
+ return filterData({ data, ...props });
+ },
+ [data],
+ );
+
+ return (
+
+ );
+};
+```
+
+This will create a fully functional data view with filtering, searching, and pagination capabilities.
+
+## Key Features
+
+### Layouts
+
+`DataView` supports multiple layouts:
+
+- Table: A traditional tabular layout
+- List: A vertical stack of cards
+- Grid: A grid of cards
+
+You can control available layouts using the `availableLayouts` prop:
+
+```tsx
+
+```
+
+### Filtering and Searching
+
+Enable filtering and searching with the `hasFilters` and `hasSearch` props:
+
+```tsx
+
+```
+
+### Pagination
+
+Control pagination with the `hasPagination`, `paginationType`, `currentPage`, and `resultsPerPage` props:
+
+```tsx
+
+```
+
+### Row Selection
+
+Enable row selection with the `hasRowSelection` prop and handle selection changes with `onChangeRowSelection`:
+
+```tsx
+ {
+ console.log(`${Object.keys(rowSelection).length} rows selected`);
+ }}
+ // ... other props
+/>
+```
+
+### Row Reordering
+
+Enable row reordering with the `hasRowReordering` prop and handle reordering with `onReorderRows`:
+
+```tsx
+ {
+ // Handle row reordering logic
+ }}
+ // ... other props
+/>
+```
+
+### Custom Empty States
+
+Provide custom empty states for when there's no data or no search results:
+
+```tsx
+}
+ noResultsPlaceholder={}
+ // ... other props
+/>
+```
+
+## DataView, DataTable, and DataStack
+
+It's important to understand the relationship between these components:
+
+- `DataView` is the core component with the most flexibility.
+- `DataTable` and `DataStack` are convenience wrappers around `DataView`.
+
+`DataTable` and `DataStack` are provided as a convenience for common use cases, but they ultimately use `DataView` under the hood. If you need more control or a mix of table and stack layouts, you should use `DataView` directly.
+
+### TableOptions
+
+When using `DataView`, you can pass a `tableOptions` prop to customize the table behavior and appearance. Here's a detailed look at the available options:
+
+```tsx
+ ,
+ rowActionButtons: (row) => (
+ <>
+ }
+ ariaLabel="Edit"
+ size="small"
+ variant="floating"
+ onClick={() => handleEdit(row.original)}
+ />
+ }
+ ariaLabel="Delete"
+ size="small"
+ variant="floating"
+ onClick={() => handleDelete(row.original)}
+ />
+ >
+ ),
+ rowActionMenuItems: (row) => (
+ <>
+
+
+ >
+ ),
+ }}
+ // ... other props
+/>
+```
+
+Let's break down these options:
+
+- `columns`: Array of column definitions (required).
+- `hasChangeableDensity`: Allows users to change row density (compact, comfortable, spacious).
+- `hasColumnResizing`: Enables column resizing.
+- `hasColumnVisibility`: Allows users to show/hide columns.
+- `hasSorting`: Enables column sorting.
+- `initialDensity`: Sets the initial row density.
+- `renderDetailPanel`: Function to render an expandable detail panel for each row.
+- `rowActionButtons`: Function to render action buttons for each row.
+- `rowActionMenuItems`: Function to render menu items for additional row actions.
+
+### StackOptions
+
+When using `DataView` with stack layouts, you can pass a `stackOptions` prop to customize the card-based layout:
+
+```tsx
+ ({
+ overline: `${row.category}`,
+ title: row.name,
+ description: row.description,
+ image: ,
+ children: ,
+ detailPanel: ,
+ button: (
+