diff --git a/openmetadata-ui-core-components/src/main/resources/ui/package.json b/openmetadata-ui-core-components/src/main/resources/ui/package.json index ed31ae46a2b9..c8a25807b4e5 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/package.json +++ b/openmetadata-ui-core-components/src/main/resources/ui/package.json @@ -105,13 +105,13 @@ "@mui/x-date-pickers": "^8.11.0", "@react-aria/utils": "^3.33.0", "@react-stately/utils": "^3.11.0", - "@storybook/addon-essentials": "^8.6.14", - "@storybook/addon-interactions": "^8.6.14", - "@storybook/addon-links": "^8.6.14", - "@storybook/blocks": "^8.6.14", - "@storybook/react": "^8.6.14", - "@storybook/react-vite": "^8.6.14", - "@storybook/test": "^8.6.14", + "@storybook/addon-essentials": "^8.6.18", + "@storybook/addon-interactions": "^8.6.18", + "@storybook/addon-links": "^8.6.18", + "@storybook/blocks": "^8.6.18", + "@storybook/react": "^8.6.18", + "@storybook/react-vite": "^8.6.18", + "@storybook/test": "^8.6.18", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@types/node": "^20.0.0", @@ -126,7 +126,7 @@ "react-aria-components": "^1.16.0", "react-dom": "^18.2.0", "react-hook-form": "^7.71.1", - "storybook": "^8.6.17", + "storybook": "^8.6.18", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Table.stories.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Table.stories.tsx new file mode 100644 index 000000000000..34e3e1852a11 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/stories/Table.stories.tsx @@ -0,0 +1,1134 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with 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 React, { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import type { SortDescriptor } from "react-aria-components"; +import { + ColumnResizer, + Dialog, + DialogTrigger, + Popover, + ResizableTableContainer, + TableBody as AriaTableBody, +} from "react-aria-components"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; +import { Badge } from "../components/base/badges/badges"; +import { PaginationPageDefault } from "../components/application/pagination/pagination"; +import { + Table, + TableCard, + TableRowActionsDropdown, +} from "../components/application/table/table"; + +interface User { + id: number; + name: string; + email: string; + role: string; + status: "active" | "inactive"; +} + +const SAMPLE_DATA: User[] = [ + { + id: 1, + name: "Olivia Rhye", + email: "olivia@example.com", + role: "Admin", + status: "active", + }, + { + id: 2, + name: "Phoenix Baker", + email: "phoenix@example.com", + role: "Editor", + status: "active", + }, + { + id: 3, + name: "Lana Steiner", + email: "lana@example.com", + role: "Viewer", + status: "inactive", + }, + { + id: 4, + name: "Demi Wilkinson", + email: "demi@example.com", + role: "Editor", + status: "active", + }, + { + id: 5, + name: "Candice Wu", + email: "candice@example.com", + role: "Admin", + status: "inactive", + }, +]; + +const StatusBadge = ({ status }: { status: "active" | "inactive" }) => ( + + {status === "active" ? "Active" : "Inactive"} + +); + +const meta = { + title: "Components/Table", + component: Table, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+ ), +}; + +export const SmallSize: Story = { + render: () => ( + + + + + Name + + + + + Email + + + + + Role + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + ))} + +
+ ), +}; + +const WithSelectionExample = () => { + const [selectedKeys, setSelectedKeys] = useState<"all" | Set>( + new Set(), + ); + + return ( +
+ + setSelectedKeys(keys as "all" | Set) + } + > + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+

+ Selected:{" "} + {selectedKeys === "all" + ? "All" + : [...selectedKeys].join(", ") || "None"} +

+
+ ); +}; + +export const WithSelection: StoryObj = { + render: () => , +}; + +const WithSortingExample = () => { + const [sortDescriptor, setSortDescriptor] = useState({ + column: "name", + direction: "ascending", + }); + + const sorted = [...SAMPLE_DATA].sort((a, b) => { + const key = sortDescriptor.column as keyof User; + const cmp = String(a[key]).localeCompare(String(b[key])); + + return sortDescriptor.direction === "descending" ? -cmp : cmp; + }); + + return ( + + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + {sorted.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+ ); +}; + +export const WithSorting: StoryObj = { + render: () => , +}; + +export const WithRowActions: StoryObj = { + render: () => ( + + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + + + + ))} + +
+ ), +}; + +export const InsideTableCard: StoryObj = { + render: () => ( +
+ + + + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+
+
+ ), +}; + +export const EmptyState: StoryObj = { + render: () => ( + + + + + Name + + + + + Email + + + + + Role + + + + ( +
+ No data available +
+ )} + > + {[]} +
+
+ ), +}; + +export const WithColumnTooltips: StoryObj = { + render: () => ( + + + + + Name + + + + + Email + + + + + Role + + + + + Status + + + + + {SAMPLE_DATA.slice(0, 3).map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+ ), +}; + +const WithSingleSelectionExample = () => { + const [selectedKeys, setSelectedKeys] = useState<"all" | Set>( + new Set(), + ); + + return ( +
+ + setSelectedKeys(keys as "all" | Set) + } + > + + + + Name + + + + + Email + + + + + Role + + + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + ))} + +
+

+ Selected:{" "} + {selectedKeys === "all" + ? "All" + : [...selectedKeys].join(", ") || "None"} +

+
+ ); +}; + +export const WithSingleSelection: StoryObj = { + render: () => , +}; + +export const TableCardSmall: StoryObj = { + render: () => ( +
+ + + + + + + Name + + + + + Role + + + + + Status + + + + + {SAMPLE_DATA.slice(0, 3).map((user) => ( + + + + {user.name} + + + {user.role} + + + + + ))} + +
+
+
+ ), +}; + +// ─── WithColumnFilterDropdown ───────────────────────────────────────────────── +// Demonstrates the pattern used in TableV2: a controlled DialogTrigger in a +// column header where confirm() / close() both close the popover, so the filter +// panel dismisses after the user applies their selection. + +const WithColumnFilterDropdownExample = () => { + const [openCol, setOpenCol] = useState(null); + const [selectedStatuses, setSelectedStatuses] = useState>( + new Set(), + ); + + const visibleData = + selectedStatuses.size === 0 + ? SAMPLE_DATA + : SAMPLE_DATA.filter((u) => selectedStatuses.has(u.status)); + + const toggleStatus = (status: string, checked: boolean) => { + setSelectedStatuses((prev) => { + const next = new Set(prev); + checked ? next.add(status) : next.delete(status); + return next; + }); + }; + + const confirm = () => setOpenCol(null); + const clearFilters = () => { + setSelectedStatuses(new Set()); + setOpenCol(null); + }; + + return ( +
+ + + Name + Email + Role + +
+ Status + setOpenCol(isOpen ? "status" : null)} + > + + + +
+

+ Filter by status +

+ {(["active", "inactive"] as const).map((s) => ( + + ))} +
+ + +
+
+
+
+
+
+
+
+ + {visibleData.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+

+ Active filter:{" "} + {selectedStatuses.size > 0 + ? [...selectedStatuses].join(", ") + : "none — showing all rows"} +

+
+ ); +}; + +export const WithColumnFilterDropdown: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: + "Column header filter using a controlled `DialogTrigger`. Clicking **Confirm** calls `confirm()` which sets `isOpen=false`, closing the popover immediately — the same fix applied in `TableV2`.", + }, + }, + }, +}; + +// ─── WithNestedRowDepth ──────────────────────────────────────────────────────── +// Demonstrates that rowClassName receives the actual nesting depth, not 0. +// Rows at depth 0 are plain, depth 1 are indented + muted, depth 2 further +// indented + lighter — matching what TableV2 now passes to rowClassName. + +interface TreeRow { + id: string; + name: string; + type: string; + depth: number; +} + +const TREE_DATA: TreeRow[] = [ + { id: "1", name: "Users", type: "Folder", depth: 0 }, + { id: "1-1", name: "Olivia Rhye", type: "Admin", depth: 1 }, + { id: "1-2", name: "Phoenix Baker", type: "Editor", depth: 1 }, + { id: "2", name: "Groups", type: "Folder", depth: 0 }, + { id: "2-1", name: "Engineering", type: "Group", depth: 1 }, + { id: "2-1-1", name: "Backend", type: "Sub-group", depth: 2 }, + { id: "2-1-2", name: "Frontend", type: "Sub-group", depth: 2 }, + { id: "2-2", name: "Design", type: "Group", depth: 1 }, +]; + +const depthRowClass = (depth: number): string => { + if (depth === 1) return "tw:bg-secondary/50"; + if (depth >= 2) return "tw:bg-secondary tw:text-tertiary"; + return ""; +}; + +export const WithNestedRowDepth: StoryObj = { + render: () => ( +
+ + + Name + Type + Depth + + + {TREE_DATA.map((row) => ( + + + + {row.depth > 0 ? "↳ " : ""} + {row.name} + + + + {row.type} + + + + depth {row.depth} + + + + ))} + +
+

+ Each row receives its actual depth value (0 / 1 / 2) in{" "} + rowClassName(record, index, depth) — previously always{" "} + 0. +

+
+ ), + parameters: { + docs: { + description: { + story: + "`rowClassName` now receives the real nesting depth instead of a hardcoded `0`. Rows at depth 0/1/2 get progressively stronger background tints via `depthRowClass(depth)`.", + }, + }, + }, +}; + +// ─── WithResizableColumns ───────────────────────────────────────────────────── +// Wraps the table in ResizableTableContainer and adds a ColumnResizer drag +// handle to each header cell — the same pattern used in TableV2's +// `resizableColumns` prop. + +export const WithResizableColumns: StoryObj = { + render: () => ( + + + + {["Name", "Email", "Role", "Status"].map((col) => ( + + + {col} + + + + ))} + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + + {user.email} + + {user.role} + + + + + ))} + +
+
+ ), + parameters: { + docs: { + description: { + story: + "Wrap the table in `ResizableTableContainer` and add a `ColumnResizer` to each `Table.Head` to enable drag-to-resize. This is the primitive behind `TableV2`'s `resizableColumns` prop.", + }, + }, + }, +}; + +// ─── WithExpandableRows ─────────────────────────────────────────────────────── +// Controlled expand/collapse tree using ChevronDown / ChevronRight icons. +// Matches the expand-icon pattern in TableV2's `expandable` prop support. + +interface Department { + id: string; + name: string; + headCount: number; + members?: { id: string; name: string; role: string }[]; +} + +const DEPT_DATA: Department[] = [ + { + id: "eng", + name: "Engineering", + headCount: 3, + members: [ + { id: "eng-1", name: "Olivia Rhye", role: "Backend" }, + { id: "eng-2", name: "Phoenix Baker", role: "Frontend" }, + { id: "eng-3", name: "Lana Steiner", role: "DevOps" }, + ], + }, + { + id: "design", + name: "Design", + headCount: 2, + members: [ + { id: "des-1", name: "Demi Wilkinson", role: "UX" }, + { id: "des-2", name: "Candice Wu", role: "Visual" }, + ], + }, + { id: "legal", name: "Legal", headCount: 0 }, +]; + +const WithExpandableRowsExample = () => { + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const toggle = (id: string) => + setExpandedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + return ( + + + Department + Head Count + Member + Role + + + {DEPT_DATA.flatMap((dept) => { + const isExpanded = expandedIds.has(dept.id); + const hasChildren = (dept.members?.length ?? 0) > 0; + + return [ + + +
+ {hasChildren ? ( + + ) : ( + + )} + + {dept.name} + +
+
+ + + {dept.headCount} + + + + +
, + ...(isExpanded && dept.members + ? dept.members.map((m) => ( + + + + + + {m.name} + + + + + {m.role} + + + + )) + : []), + ]; + })} +
+
+ ); +}; + +export const WithExpandableRows: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: + "Controlled expand/collapse using `ChevronDown`/`ChevronRight` icons and a `Set` of expanded IDs. This is the same expand-icon pattern that `TableV2` renders when the `expandable` prop is provided.", + }, + }, + }, +}; + +// ─── WithLoadingState ───────────────────────────────────────────────────────── +// Shows an absolute spinner overlay while the table content is loading. +// Matches the `loading` prop behavior in TableV2. + +const WithLoadingStateExample = () => { + const [loading, setLoading] = useState(true); + + return ( +
+
+ +
+
+ {loading && ( +
+
+
+ )} + + + Name + Email + Role + Status + + + {SAMPLE_DATA.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+
+
+ ); +}; + +export const WithLoadingState: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: + "An absolute overlay with a spinner covers the table while `loading=true`. Toggle the button to switch between states — same as `TableV2`'s `loading` prop.", + }, + }, + }, +}; + +// ─── WithPagination ──────────────────────────────────────────────────────────── +// Client-side pagination using PaginationPageDefault below the table. +// Matches TableV2's internal pagination behaviour when the `pagination` prop is +// provided with a `pageSize`. + +const PAGE_SIZE = 2; + +const WithPaginationExample = () => { + const [page, setPage] = useState(1); + const total = SAMPLE_DATA.length; + const slice = SAMPLE_DATA.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + return ( +
+ + + Name + Email + Role + Status + + + {slice.map((user) => ( + + + + {user.name} + + + {user.email} + {user.role} + + + + + ))} + +
+
+ + {total} results · page {page} of {Math.ceil(total / PAGE_SIZE)} + + +
+
+ ); +}; + +export const WithPagination: StoryObj = { + render: () => , + parameters: { + docs: { + description: { + story: + "Client-side pagination: slice `dataSource` by `pageSize` and render `PaginationPageDefault` below the table. This mirrors `TableV2`'s internal `clientPagination` logic.", + }, + }, + }, +}; diff --git a/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock b/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock index 3ceff940d371..2f2aebc784e2 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui-core-components/src/main/resources/ui/yarn.lock @@ -2277,10 +2277,10 @@ argparse "~1.0.9" string-argv "~0.3.1" -"@storybook/addon-actions@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz" - integrity sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ== +"@storybook/addon-actions@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-8.6.18.tgz#802221d016308c3d086dd4f41271386a44e9078c" + integrity sha512-GcYhtE91GjIQTuZlwpTJ8jfMp6NC79nkpe1DGe0eetTpyQqLq1WUt+ACkk0Z5lqq2u8HBc09zCCGw+D8iCLpYQ== dependencies: "@storybook/global" "^5.0.0" "@types/uuid" "^9.0.1" @@ -2288,111 +2288,111 @@ polished "^4.2.2" uuid "^9.0.0" -"@storybook/addon-backgrounds@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz" - integrity sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg== +"@storybook/addon-backgrounds@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.18.tgz#098342aacc51b9b0bf0eea2eba1eb8ef43a75bf1" + integrity sha512-froND3WwvSCYzjEBO8QODStaWNL+aGXqxBEbrMnGYejDFST4qEFkvM2IYWMnLBkRgrgJ0yIqTeDQoyH9b9/8uQ== dependencies: "@storybook/global" "^5.0.0" memoizerific "^1.11.3" ts-dedent "^2.0.0" -"@storybook/addon-controls@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.14.tgz" - integrity sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw== +"@storybook/addon-controls@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-controls/-/addon-controls-8.6.18.tgz#31f3655ab08103a414980f53f47064fd9210a89c" + integrity sha512-K09dHDCfGW3cudsfuyfu0Yi49aZ2h7VYK4IXDGo1sfmtzVh4xd3HrZQQMVUeKLcfDP/NnJowT+fLVwg04CLrxQ== dependencies: "@storybook/global" "^5.0.0" dequal "^2.0.2" ts-dedent "^2.0.0" -"@storybook/addon-docs@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.14.tgz" - integrity sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ== +"@storybook/addon-docs@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-8.6.18.tgz#1910942ecdff4e5cda6352d22bc483f0c2058f61" + integrity sha512-55ADer0yNmmeR928Y3UAv3r4i7bJSd9LwywsQ+lRol/FNe0ZcwLEz31xL+jVsqQFNnDh/imsDIp8aYapGMtfEQ== dependencies: "@mdx-js/react" "^3.0.0" - "@storybook/blocks" "8.6.14" - "@storybook/csf-plugin" "8.6.14" - "@storybook/react-dom-shim" "8.6.14" + "@storybook/blocks" "8.6.18" + "@storybook/csf-plugin" "8.6.18" + "@storybook/react-dom-shim" "8.6.18" react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" ts-dedent "^2.0.0" -"@storybook/addon-essentials@^8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.14.tgz" - integrity sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA== - dependencies: - "@storybook/addon-actions" "8.6.14" - "@storybook/addon-backgrounds" "8.6.14" - "@storybook/addon-controls" "8.6.14" - "@storybook/addon-docs" "8.6.14" - "@storybook/addon-highlight" "8.6.14" - "@storybook/addon-measure" "8.6.14" - "@storybook/addon-outline" "8.6.14" - "@storybook/addon-toolbars" "8.6.14" - "@storybook/addon-viewport" "8.6.14" +"@storybook/addon-essentials@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-essentials/-/addon-essentials-8.6.18.tgz#7957394d7e45be1d5d1ceb4a26c36a498e8b48fb" + integrity sha512-MmH7gFb8pyfRoAth0w2RW8j7mBaEJbEWGP3juIoH03ZqTGmbMUbJXElCuRgxQhve7pyz39zLsgtE78D7G+76ew== + dependencies: + "@storybook/addon-actions" "8.6.18" + "@storybook/addon-backgrounds" "8.6.18" + "@storybook/addon-controls" "8.6.18" + "@storybook/addon-docs" "8.6.18" + "@storybook/addon-highlight" "8.6.18" + "@storybook/addon-measure" "8.6.18" + "@storybook/addon-outline" "8.6.18" + "@storybook/addon-toolbars" "8.6.18" + "@storybook/addon-viewport" "8.6.18" ts-dedent "^2.0.0" -"@storybook/addon-highlight@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.14.tgz" - integrity sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ== +"@storybook/addon-highlight@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-highlight/-/addon-highlight-8.6.18.tgz#cf429c1ec64553fb293be660fa37b67fdf1da590" + integrity sha512-wTFJ1DPM0C8gK6nGTJxH75byayQj7BPAz02fME4AOmT6clrBpVl1zSTFTkXaSr+k4xOfeMR/xNUfVskaXz6T9w== dependencies: "@storybook/global" "^5.0.0" -"@storybook/addon-interactions@^8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.14.tgz" - integrity sha512-8VmElhm2XOjh22l/dO4UmXxNOolGhNiSpBcls2pqWSraVh4a670EyYBZsHpkXqfNHo2YgKyZN3C91+9zfH79qQ== +"@storybook/addon-interactions@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-interactions/-/addon-interactions-8.6.18.tgz#b0070ee70a1a4b1aaae60af19b9bd364a41d90a0" + integrity sha512-4Ie7sThNpHs+HH69ip4Pl7Ce/OVwiOuuXLO7mLGghkz6hTUz77IvH3P/09v3X0UOOcIbcF7LM3j+H7EVyY4ULA== dependencies: "@storybook/global" "^5.0.0" - "@storybook/instrumenter" "8.6.14" - "@storybook/test" "8.6.14" + "@storybook/instrumenter" "8.6.18" + "@storybook/test" "8.6.18" polished "^4.2.2" ts-dedent "^2.2.0" -"@storybook/addon-links@^8.6.14": +"@storybook/addon-links@^8.6.18": version "8.6.18" - resolved "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.18.tgz" + resolved "https://registry.yarnpkg.com/@storybook/addon-links/-/addon-links-8.6.18.tgz#026207b5e9d1256a2d33d118236ddfddf848e509" integrity sha512-FFlQcPRTgXoFZr2uawtf7lNc/ceIVRhU13BkJbJZKlil3+C8ORFDO1vnREzHje9JzeOWm/rzI0ay0RVetCcXzg== dependencies: "@storybook/global" "^5.0.0" ts-dedent "^2.0.0" -"@storybook/addon-measure@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.14.tgz" - integrity sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw== +"@storybook/addon-measure@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-measure/-/addon-measure-8.6.18.tgz#016c053e792bb76daedc7f3f6252fe17f3aef074" + integrity sha512-fMEOJXgPrTm6qHlWoRM+WTLE7Mr1QBIf2ei+pujBQFcWkD6Gjc2pV8zKzvh93d+EA13wD8AmwOq1DEw9J+XH+g== dependencies: "@storybook/global" "^5.0.0" tiny-invariant "^1.3.1" -"@storybook/addon-outline@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.14.tgz" - integrity sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w== +"@storybook/addon-outline@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-outline/-/addon-outline-8.6.18.tgz#9aedb90ab0639ddddc1f6e7da6215d07a41cd7fc" + integrity sha512-TErFqfCtlV2xt9B6/kskROt69TPjr6AXdHpMselaRrN1X4WEjcMk9GT9PcNP7FXqL88/VYqUb3uNMiAmpDmS/g== dependencies: "@storybook/global" "^5.0.0" ts-dedent "^2.0.0" -"@storybook/addon-toolbars@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.14.tgz" - integrity sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ== +"@storybook/addon-toolbars@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-toolbars/-/addon-toolbars-8.6.18.tgz#39a384f79f365b6131f20bf2ed7d437ec01a6d16" + integrity sha512-x037KXCEcNfPISGX485DtiP+8Bw/cOT45plcQa8eiAQVrVcUwYaDoLubE9YV5b5CsSAjX8sDviGTme6ALfq7+w== -"@storybook/addon-viewport@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.14.tgz" - integrity sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA== +"@storybook/addon-viewport@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/addon-viewport/-/addon-viewport-8.6.18.tgz#15599a1de03f3fc32869c581675f746dfa113239" + integrity sha512-z9sDJSkuWQb4BP+Z1+H+y/Q0rFbPSDcw+OBBEhMfRcJPPXavdC2pNQ0GdQNVw+tDwhAXj+U7jehKnMDKaP7TyA== dependencies: memoizerific "^1.11.3" -"@storybook/blocks@8.6.14", "@storybook/blocks@^8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.14.tgz" - integrity sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ== +"@storybook/blocks@8.6.18", "@storybook/blocks@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/blocks/-/blocks-8.6.18.tgz#d1bf7e9639a86cdf690bea1c53028be725afb1e8" + integrity sha512-esZv4msPQ9LxgTb8YUIZhhxVMuI6BPi5bkXtk8c7w7sWuAsqsCe/RnVInn7ooUry2gjnD4hd9+8Eqj0b8oTVoA== dependencies: "@storybook/icons" "^1.2.12" ts-dedent "^2.0.0" @@ -2428,13 +2428,6 @@ util "^0.12.5" ws "^8.2.3" -"@storybook/csf-plugin@8.6.14": - version "8.6.14" - resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-8.6.14.tgz#c7fc0361204a34693e8d62ebe5922d77dfec06c0" - integrity sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ== - dependencies: - unplugin "^1.3.1" - "@storybook/csf-plugin@8.6.18": version "8.6.18" resolved "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.18.tgz" @@ -2452,18 +2445,10 @@ resolved "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz" integrity sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw== -"@storybook/instrumenter@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.14.tgz" - integrity sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ== - dependencies: - "@storybook/global" "^5.0.0" - "@vitest/utils" "^2.1.1" - -"@storybook/instrumenter@8.6.15": - version "8.6.15" - resolved "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.15.tgz" - integrity sha512-TvHR/+yyIAOp/1bLulFai2kkhIBtAlBw7J6Jd9DKyInoGhTWNE1G1Y61jD5GWXX29AlwaHfzGUaX5NL1K+FJpg== +"@storybook/instrumenter@8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/instrumenter/-/instrumenter-8.6.18.tgz#054305b98ea0a5999ed2235697ec89cd2741fe3a" + integrity sha512-viEC1BGlYyjAzi1Tv3LZjByh7Y3Oh04u6QKsujxdeUbr5rUOH4pa/wCKmxXmY6yWrD4WjcNtojmUvQZN/66FXQ== dependencies: "@storybook/global" "^5.0.0" "@vitest/utils" "^2.1.1" @@ -2478,19 +2463,14 @@ resolved "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.18.tgz" integrity sha512-joXRXh3GdVvzhbfIgmix1xs90p8Q/nja7AhEAC2egn5Pl7SKsIYZUCYI6UdrQANb2myg9P552LKXfPect8llKg== -"@storybook/react-dom-shim@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.14.tgz" - integrity sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw== - "@storybook/react-dom-shim@8.6.18": version "8.6.18" resolved "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.18.tgz" integrity sha512-N4xULcAWZQTUv4jy1/d346Tyb4gufuC3UaLCuU/iVSZ1brYF4OW3ANr+096btbMxY8pR/65lmtoqr5CTGwnBvA== -"@storybook/react-vite@^8.6.14": +"@storybook/react-vite@^8.6.18": version "8.6.18" - resolved "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.18.tgz" + resolved "https://registry.yarnpkg.com/@storybook/react-vite/-/react-vite-8.6.18.tgz#861ccfa3574d2825898328129a0b9f3f418ca201" integrity sha512-qpSYyH2IizlEsI95MJTdIL6xpLSgiNCMoJpHu+IEqLnyvmecRR/YEZvcHalgdtawuXlimH0bAYuwIu3l8Vo6FQ== dependencies: "@joshwooding/vite-plugin-react-docgen-typescript" "0.5.0" @@ -2503,9 +2483,9 @@ resolve "^1.22.8" tsconfig-paths "^4.2.0" -"@storybook/react@8.6.18", "@storybook/react@^8.6.14": +"@storybook/react@8.6.18", "@storybook/react@^8.6.18": version "8.6.18" - resolved "https://registry.npmjs.org/@storybook/react/-/react-8.6.18.tgz" + resolved "https://registry.yarnpkg.com/@storybook/react/-/react-8.6.18.tgz#61dbe3b565ff2046d22c3666ea0841850bbe75d9" integrity sha512-BuLpzMkKtF+UCQCbi+lYVX9cdcAMG86Lu2dDn7UFkPi5HRNFq/zHPSvlz1XDgL0OYMtcqB1aoVzFzcyzUBhhjw== dependencies: "@storybook/components" "8.6.18" @@ -2515,26 +2495,13 @@ "@storybook/react-dom-shim" "8.6.18" "@storybook/theming" "8.6.18" -"@storybook/test@8.6.14": - version "8.6.14" - resolved "https://registry.npmjs.org/@storybook/test/-/test-8.6.14.tgz" - integrity sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw== - dependencies: - "@storybook/global" "^5.0.0" - "@storybook/instrumenter" "8.6.14" - "@testing-library/dom" "10.4.0" - "@testing-library/jest-dom" "6.5.0" - "@testing-library/user-event" "14.5.2" - "@vitest/expect" "2.0.5" - "@vitest/spy" "2.0.5" - -"@storybook/test@^8.6.14": - version "8.6.15" - resolved "https://registry.npmjs.org/@storybook/test/-/test-8.6.15.tgz" - integrity sha512-EwquDRUDVvWcZds3T2abmB5wSN/Vattal4YtZ6fpBlIUqONV4o/cOBX39cFfQSUCBrIXIjQ6RmapQCHK/PvBYw== +"@storybook/test@8.6.18", "@storybook/test@^8.6.18": + version "8.6.18" + resolved "https://registry.yarnpkg.com/@storybook/test/-/test-8.6.18.tgz#cd8720f82c9b1c575fba75fbc6146ddcb8434d34" + integrity sha512-u/RwfWMyHcH0N2hqfMTw2CoZ58IXdeED3b8NmcHc8bmERB3byI5vVAkwYbcD7+WeRHIiym38ZHi0SRn+IpkO3Q== dependencies: "@storybook/global" "^5.0.0" - "@storybook/instrumenter" "8.6.15" + "@storybook/instrumenter" "8.6.18" "@testing-library/dom" "10.4.0" "@testing-library/jest-dom" "6.5.0" "@testing-library/user-event" "14.5.2" @@ -4621,9 +4588,9 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -storybook@^8.6.17: +storybook@^8.6.18: version "8.6.18" - resolved "https://registry.npmjs.org/storybook/-/storybook-8.6.18.tgz" + resolved "https://registry.yarnpkg.com/storybook/-/storybook-8.6.18.tgz#2a635a4b0c99693f43ba21b8eb511c5cc513a807" integrity sha512-p8seiSI6FiVY6P3V0pG+5v7c8pDMehMAFRWEhG5XqIBSQszzOjDnW2rNvm3odoLKfo3V3P6Cs6Hv9ILzymULyQ== dependencies: "@storybook/core" "8.6.18" @@ -4634,6 +4601,7 @@ string-argv@~0.3.1: integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: + name string-width-cjs version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4652,6 +4620,7 @@ string-width@^5.0.1, string-width@^5.1.2: strip-ansi "^7.0.1" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index f04226e1f135..47f215857cdd 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -152,6 +152,7 @@ "react-papaparse": "^4.1.0", "react-quill-new": "^3.4.0", "react-reflex": "^4.1.0", + "react-resizable": "^3.1.3", "react-router-dom": "^6.30.2", "reactflow": "^11.10.2", "reactjs-localstorage": "^1.0.1", @@ -208,6 +209,7 @@ "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-grid-layout": "^1.3.5", + "@types/react-resizable": "^3.0.8", "@types/react-test-renderer": "^17.0.0", "@types/reactjs-localstorage": "^1.0.0", "@types/recharts": "^1.8.23", diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.interface.ts index ebbae2803cd1..989e0abf3fc7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/Table.interface.ts @@ -12,6 +12,7 @@ */ import { TableProps } from 'antd/lib/table'; import { NextPreviousProps } from '../NextPrevious/NextPrevious.interface'; +import type { DragAndDropHooks } from 'react-aria-components'; import { SearchBarProps } from '../SearchBarComponent/SearchBar.component'; export interface TableComponentProps extends TableProps { @@ -29,6 +30,11 @@ export interface TableComponentProps extends TableProps { showPagination: boolean; }; entityType?: string; + /** CSS class applied to every data cell. Defaults to 'tw:py-2 tw:pl-4 tw:pr-2 tw:align-top'. */ + cellClassName?: string; + /** React Aria drag-and-drop hooks returned by `useDragAndDrop`. */ + dragAndDropHooks?: DragAndDropHooks; + 'data-testid'?: string; } export interface TableColumnDropdownList { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.interface.ts new file mode 100644 index 000000000000..3ec828e51cdc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.interface.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with 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 interface FlatRow { + record: T; + depth: number; + actualIndex: number; + hasChildren: boolean; + rowKey: string; +} + +/** Structural aliases to avoid a direct react-aria-components peer import. */ +export type AriaKey = string | number; +export type AriaSelection = 'all' | Set; +export interface AriaSortDescriptor { + column?: AriaKey; + direction?: 'ascending' | 'descending'; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.tsx new file mode 100644 index 000000000000..5df5b6b46d2a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2.tsx @@ -0,0 +1,976 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with 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. + */ + +/** + * TableV2 — Untitled UI migration of Table.tsx + * + * Drop-in replacement for Table.tsx using @openmetadata/ui-core-components. + * Accepts the same TableComponentProps interface for zero-friction adoption. + * + * Unsupported in v1 (accepted but ignored): + * - components (AntD custom cell/header renderers) + * + * Not in props type (compile-time error if passed): + * - className → use containerClassName instead + * + * Partially supported: + * - expandable → tree/nested rows via record.children; expandedRowRender not supported + * - onRow → onClick and onDoubleClick are forwarded to the row element + * - onCell → onClick, data-*, colSpan forwarded to the underlying td element + * - filterIcon/filterDropdown/onFilter → filter state managed internally; confirm/close close the dropdown + * + * Sorting: + * - sorter: (a, b) => number → applied client-side on full dataset before pagination + * - sorter: true → visual indicator only; parent must handle via onChange + */ + +import { + Button, + Dropdown, + Table as UntitledTable, +} from '@openmetadata/ui-core-components'; +import { ChevronDown, ChevronRight } from '@untitledui/icons'; +import type { + ColumnType, + FilterValue, + SorterResult, + TableCurrentDataSource, + TablePaginationConfig, +} from 'antd/lib/table/interface'; +import type { ColumnsType } from 'antd/es/table/interface'; +import { + ColumnResizer, + Dialog, + DialogTrigger, + Popover, + ResizableTableContainer, +} from 'react-aria-components'; +import type { + AriaSortDescriptor, + AriaSelection, + FlatRow, +} from './TableV2.interface'; +import { + flattenTreeRows, + getColumnStickyStyle, + resolveCellValue, + resolveColumnTitle, +} from './TableV2Utils'; +import classNames from 'classnames'; +import { isEmpty, isEqual } from 'lodash'; +import React, { + forwardRef, + ReactElement, + ReactNode, + Ref, + RefAttributes, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ColumnIcon } from '../../../assets/svg/ic-column-customize.svg'; +import { useCurrentUserPreferences } from '../../../hooks/currentUserStore/useCurrentUserStore'; +import { + getCustomizeColumnDetails, + getReorderedColumns, +} from '../../../utils/CustomizeColumnUtils'; +import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider'; +import Loader from '../Loader/Loader'; +import NextPrevious from '../NextPrevious/NextPrevious'; +import Searchbar from '../SearchBarComponent/SearchBar.component'; +import DraggableMenuItem from './DraggableMenu/DraggableMenuItem.component'; +import { + TableColumnDropdownList, + TableComponentProps, +} from './Table.interface'; +import './table.less'; + +type TableV2Props = TableComponentProps; + +const TableV2 = ( + { + loading, + searchProps, + customPaginationProps, + entityType, + defaultVisibleColumns, + dragAndDropHooks, + 'data-testid': dataTestId, + scroll, + ...rest + }: TableV2Props, + ref: Ref | null | undefined +) => { + const { t } = useTranslation(); + const { type } = useGenericContext(); + const [propsColumns, setPropsColumns] = useState>([]); + const [columnWidths, setColumnWidths] = useState>({}); + const [internalCurrentPage, setInternalCurrentPage] = useState(1); + const [sortState, setSortState] = useState<{ + columnKey: string | null; + direction: 'ascending' | 'descending' | null; + }>({ columnKey: null, direction: null }); + const [dropdownColumnList, setDropdownColumnList] = useState< + TableColumnDropdownList[] + >([]); + const [columnDropdownSelections, setColumnDropdownSelections] = useState< + string[] + >([]); + const [internalExpandedKeys, setInternalExpandedKeys] = useState>( + new Set() + ); + const [filterState, setFilterState] = useState>( + {} + ); + const [openFilterKey, setOpenFilterKey] = useState(null); + const { + preferences: { selectedEntityTableColumns }, + setPreference, + } = useCurrentUserPreferences(); + + const isLoading = useMemo( + () => + (loading as { spinning?: boolean })?.spinning ?? + (loading as boolean) ?? + false, + [loading] + ); + + const entityKey = useMemo(() => entityType ?? type, [type, entityType]); + + const clientPagination = useMemo(() => { + if (rest.pagination === false) { + return null; + } + const cfg = (rest.pagination ?? {}) as TablePaginationConfig; + + return { + pageSize: (cfg.pageSize as number) ?? 10, + hideOnSinglePage: cfg.hideOnSinglePage ?? false, + }; + }, [rest.pagination]); + + const sortedDataSource = useMemo((): T[] => { + const data = (rest.dataSource ?? []) as T[]; + if (!sortState.columnKey || !sortState.direction) { + return data; + } + const col = propsColumns.find((c, idx) => { + const key = String(c.key ?? (c as ColumnType).dataIndex ?? idx); + + return key === sortState.columnKey; + }) as ColumnType | undefined; + + if (!col?.sorter || typeof col.sorter !== 'function') { + return data; + } + const compareFn = col.sorter as (a: T, b: T) => number; + const sorted = [...data].sort((a, b) => compareFn(a, b)); + + return sortState.direction === 'descending' ? sorted.reverse() : sorted; + }, [rest.dataSource, sortState, propsColumns]); + + const filteredDataSource = useMemo((): T[] => { + const activeFilters = Object.entries(filterState).filter( + ([, keys]) => keys.length > 0 + ); + if (!activeFilters.length) { + return sortedDataSource; + } + + return sortedDataSource.filter((record) => + activeFilters.every(([colKey, selectedKeys]) => { + const col = propsColumns.find( + (c, idx) => + String(c.key ?? (c as ColumnType).dataIndex ?? idx) === colKey + ) as ColumnType | undefined; + + return col?.onFilter + ? selectedKeys.some((key) => + col.onFilter!(key as React.Key | boolean, record) + ) + : true; + }) + ); + }, [sortedDataSource, filterState, propsColumns]); + + const pagedDataSource = useMemo((): T[] => { + if (!clientPagination) { + return filteredDataSource; + } + const start = (internalCurrentPage - 1) * clientPagination.pageSize; + + return filteredDataSource.slice(start, start + clientPagination.pageSize); + }, [filteredDataSource, clientPagination, internalCurrentPage]); + + const expandedKeys = useMemo>(() => { + if (!rest.expandable) { + return new Set(); + } + + return rest.expandable.expandedRowKeys + ? new Set(rest.expandable.expandedRowKeys.map(String)) + : internalExpandedKeys; + }, [rest.expandable, internalExpandedKeys]); + + const isCustomizeColumnEnable = useMemo( + () => + !isEmpty(rest.staticVisibleColumns) && !isEmpty(defaultVisibleColumns), + [rest.staticVisibleColumns, defaultVisibleColumns] + ); + + const scrollStyle = useMemo((): React.CSSProperties => { + if (!scroll) { + return {}; + } + + return { + ...(scroll.x ? { overflowX: 'auto' } : {}), + ...(scroll.y + ? { overflowY: 'auto', maxHeight: scroll.y as string | number } + : {}), + }; + }, [scroll?.x, scroll?.y]); + + // ─── Column customization (identical to Table.tsx) ─────────────────────── + + const handleMoveItem = useCallback( + (updatedList: TableColumnDropdownList[]) => { + setDropdownColumnList(updatedList); + setPropsColumns(getReorderedColumns(updatedList, propsColumns)); + }, + [propsColumns] + ); + + const handleColumnItemSelect = useCallback( + (key: string, selected: boolean) => { + const updatedSelections = selected + ? [...columnDropdownSelections, key] + : columnDropdownSelections.filter((item) => item !== key); + + setPreference({ + selectedEntityTableColumns: { + ...selectedEntityTableColumns, + [entityKey]: updatedSelections, + }, + }); + + setColumnDropdownSelections(updatedSelections); + }, + [columnDropdownSelections, selectedEntityTableColumns, entityKey] + ); + + const handleBulkColumnAction = useCallback(() => { + if (dropdownColumnList.length === columnDropdownSelections.length) { + setColumnDropdownSelections([]); + setPreference({ + selectedEntityTableColumns: { + ...selectedEntityTableColumns, + [entityKey]: [], + }, + }); + } else { + const columns = dropdownColumnList.map((option) => option.value); + setColumnDropdownSelections(columns); + setPreference({ + selectedEntityTableColumns: { + ...selectedEntityTableColumns, + [entityKey]: columns, + }, + }); + } + }, [ + dropdownColumnList, + columnDropdownSelections, + selectedEntityTableColumns, + entityKey, + ]); + + // ─── Row key ────────────────────────────────────────────────────────────── + + const getRowKey = useCallback( + (record: T, index: number): string => { + if (typeof rest.rowKey === 'function') { + return String((rest.rowKey as (record: T) => string | number)(record)); + } + if (typeof rest.rowKey === 'string') { + const val = (record as Record)[rest.rowKey]; + + return val !== undefined && val !== null ? String(val) : String(index); + } + + return String(index); + }, + [rest.rowKey] + ); + + // ─── Expand toggle ──────────────────────────────────────────────────────── + + const handleExpandToggle = useCallback( + (record: T, rowKey: string) => { + const isExpanded = expandedKeys.has(rowKey); + const next = isExpanded + ? new Set([...expandedKeys].filter((k) => k !== rowKey)) + : new Set([...expandedKeys, rowKey]); + + if (!rest.expandable?.expandedRowKeys) { + setInternalExpandedKeys(next); + } + rest.expandable?.onExpand?.(!isExpanded, record); + rest.expandable?.onExpandedRowsChange?.([...next]); + }, + [expandedKeys, rest.expandable] + ); + + // ─── Row selection ──────────────────────────────────────────────────────── + + // AntD default rowSelection.type is 'checkbox', which maps to 'multiple'. + // Passing type: undefined with a truthy rowSelection object also yields 'multiple'. + const selectionMode = useMemo((): 'none' | 'single' | 'multiple' => { + if (!rest.rowSelection) { + return 'none'; + } + + return rest.rowSelection.type === 'radio' ? 'single' : 'multiple'; + }, [rest.rowSelection]); + + const handleSelectionChange = useCallback( + (keys: AriaSelection) => { + if (!rest.rowSelection?.onChange) { + return; + } + const dataSource = filteredDataSource; + const selectedKeys = + keys === 'all' + ? dataSource.map((r, i) => getRowKey(r, i)) + : [...keys].map(String); + const selectedRows = dataSource.filter((r, i) => + selectedKeys.includes(getRowKey(r, i)) + ); + + rest.rowSelection.onChange(selectedKeys, selectedRows, { + type: selectionMode === 'single' ? 'single' : 'multiple', + }); + }, + [rest.rowSelection, filteredDataSource, getRowKey, selectionMode] + ); + + // ─── Column resize (via React Aria ColumnResizer) ────────────────────────── + + const handleColumnResize = useCallback( + (widths: Map) => { + setColumnWidths((prev) => { + const next = { ...prev }; + widths.forEach((w, k) => { + next[String(k)] = Number(w); + }); + + return next; + }); + }, + [] + ); + + // ─── Sorting ────────────────────────────────────────────────────────────── + + const handleSortChange = useCallback( + (descriptor: AriaSortDescriptor) => { + const newKey = descriptor.column ? String(descriptor.column) : null; + const newDirection = descriptor.direction ?? null; + setSortState({ columnKey: newKey, direction: newDirection }); + + if (!rest.onChange) { + return; + } + const matchedCol = propsColumns.find((c, colIdx) => { + const resolvedKey = String( + c.key ?? (c as ColumnType).dataIndex ?? colIdx + ); + + return resolvedKey === descriptor.column; + }) as ColumnType | undefined; + + rest.onChange( + { + current: internalCurrentPage, + pageSize: clientPagination?.pageSize ?? 10, + total: filteredDataSource.length, + } as TablePaginationConfig, + {} as Record, + { + column: matchedCol, + columnKey: String(descriptor.column ?? ''), + field: String(descriptor.column ?? ''), + order: + descriptor.direction === 'ascending' + ? 'ascend' + : descriptor.direction === 'descending' + ? 'descend' + : null, + } as SorterResult, + { + currentDataSource: (rest.dataSource ?? []) as T[], + action: 'sort', + } as TableCurrentDataSource + ); + }, + [ + rest.onChange, + propsColumns, + rest.dataSource, + internalCurrentPage, + clientPagination, + ] + ); + + // ─── Search ─────────────────────────────────────────────────────────────── + + const handleSearchAction = (value: string) => { + searchProps?.onSearch?.(value); + }; + + // ─── Column state effects (identical to Table.tsx) ──────────────────────── + + useEffect(() => { + if (isCustomizeColumnEnable) { + const newList = getCustomizeColumnDetails( + rest.columns, + rest.staticVisibleColumns + ); + setDropdownColumnList((prev) => + isEqual(prev, newList) ? prev : newList + ); + } + }, [isCustomizeColumnEnable, rest.columns, rest.staticVisibleColumns]); + + useEffect(() => { + if (isCustomizeColumnEnable) { + const filteredColumns = (rest.columns ?? []).filter( + (item) => + columnDropdownSelections.includes(item.key as string) || + (rest.staticVisibleColumns ?? []).includes(item.key as string) + ); + + setPropsColumns(getReorderedColumns(dropdownColumnList, filteredColumns)); + } else { + setPropsColumns(rest.columns ?? []); + } + }, [ + isCustomizeColumnEnable, + rest.columns, + columnDropdownSelections, + rest.staticVisibleColumns, + dropdownColumnList, + ]); + + useEffect(() => { + if (isCustomizeColumnEnable) { + setColumnDropdownSelections( + selectedEntityTableColumns?.[entityKey] ?? defaultVisibleColumns ?? [] + ); + } + }, [ + isCustomizeColumnEnable, + selectedEntityTableColumns, + entityKey, + defaultVisibleColumns, + ]); + + const dataSourceLength = filteredDataSource.length; + useEffect(() => { + const maxPage = clientPagination + ? Math.ceil(dataSourceLength / clientPagination.pageSize) || 1 + : 1; + if (internalCurrentPage > maxPage) { + setInternalCurrentPage(1); + } + }, [dataSourceLength, clientPagination, internalCurrentPage]); + + // ─── Flat rows (tree data flattened with depth tracking) ────────────────── + + const flatRows = useMemo[]>(() => { + if (!rest.expandable) { + return pagedDataSource.map((record, idx) => { + const actualIndex = clientPagination + ? (internalCurrentPage - 1) * clientPagination.pageSize + idx + : idx; + + return { + record, + depth: 0, + actualIndex, + hasChildren: false, + rowKey: getRowKey(record, actualIndex), + }; + }); + } + + return flattenTreeRows( + pagedDataSource, + getRowKey, + expandedKeys, + rest.expandable.rowExpandable as ((r: T) => boolean) | undefined + ); + }, [ + pagedDataSource, + rest.expandable, + expandedKeys, + getRowKey, + internalCurrentPage, + clientPagination, + ]); + + // ─── Render ─────────────────────────────────────────────────────────────── + + return ( +
+
+
+ {searchProps && ( +
+ +
+ )} + {(rest.extraTableFilters || isCustomizeColumnEnable) && ( +
+ {rest.extraTableFilters} + {isCustomizeColumnEnable && ( + + + +
+ + {t('label.column')} + + +
+ {dropdownColumnList.map( + (item: TableColumnDropdownList, index: number) => ( + + ) + )} +
+
+ )} +
+ )} +
+
+ +
+ {isLoading && ( +
+ +
+ )} + + {(() => { + const tableContent = ( + + + {propsColumns.map((col, colIdx) => { + const colType = col as ColumnType; + const colKey = String(col.key ?? colType.dataIndex ?? colIdx); + const colWidth = + columnWidths[colKey] ?? + (colType.width as number | undefined); + + const stickyStyle = getColumnStickyStyle(colType.fixed, 2); + + return ( + +
+ {resolveColumnTitle(colType, propsColumns)} + {Boolean(colType.filters || colType.filterDropdown) && ( + + setOpenFilterKey(isOpen ? colKey : null) + } + > + + + +
+ {typeof colType.filterDropdown === 'function' + ? colType.filterDropdown({ + prefixCls: 'ant-table-filter-dropdown', + setSelectedKeys: (keys) => + setFilterState((prev) => ({ + ...prev, + [colKey]: keys, + })), + selectedKeys: filterState[colKey] ?? [], + confirm: () => setOpenFilterKey(null), + clearFilters: () => + setFilterState((prev) => { + const next = { ...prev }; + delete next[colKey]; + + return next; + }), + filters: colType.filters, + visible: true, + close: () => setOpenFilterKey(null), + }) + : colType.filterDropdown} +
+
+
+
+ )} +
+ {rest.resizableColumns && ( + + )} +
+ ); + })} +
+ + + isLoading ? null : ( +
+ {(rest.locale?.emptyText as ReactNode) ?? + t('label.no-data')} +
+ ) + } + > + {flatRows.map((flatRow) => { + const { record, actualIndex, depth, hasChildren, rowKey } = + flatRow; + const rowHandlers = rest.onRow?.(record, actualIndex) ?? {}; + const isExpanded = expandedKeys.has(rowKey); + + return ( + + {propsColumns.map((col, colIdx) => { + const colType = col as ColumnType; + const cellKey = String( + col.key ?? colType.dataIndex ?? colIdx + ); + const stickyStyle = getColumnStickyStyle( + colType.fixed, + 1 + ); + + const isFirstColumn = colIdx === 0; + const showExpandInCell = + rest.expandable && isFirstColumn; + const ExpandIcon = rest.expandable?.expandIcon; + const cellHandlerProps = + (colType.onCell?.( + record, + actualIndex + ) as React.TdHTMLAttributes) ?? + {}; + + return ( + +
+ {showExpandInCell && ( +
+ {hasChildren ? ( + ExpandIcon ? ( + { + e.stopPropagation(); + handleExpandToggle(rec as T, rowKey); + }} + /> + ) : ( + + ) + ) : ExpandIcon ? ( + {}} + /> + ) : ( + + )} +
+ )} + {colType.ellipsis ? ( +
+ {resolveCellValue( + colType, + record, + actualIndex + )} +
+ ) : ( + resolveCellValue(colType, record, actualIndex) + )} +
+
+ ); + })} +
+ ); + })} +
+
+ ); + + return rest.resizableColumns ? ( + + {tableContent} + + ) : ( + tableContent + ); + })()} +
+ + {customPaginationProps && customPaginationProps.showPagination ? ( +
+ +
+ ) : clientPagination && + !( + clientPagination.hideOnSinglePage && + filteredDataSource.length <= clientPagination.pageSize + ) ? ( +
+ + setInternalCurrentPage(currentPage) + } + /> +
+ ) : null} +
+ ); +}; + +type TableV2WithGenerics = ( + props: TableV2Props & RefAttributes +) => ReactElement | null; + +export default forwardRef(TableV2) as unknown as TableV2WithGenerics; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2Utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2Utils.ts new file mode 100644 index 000000000000..a44f3fc2be5a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Table/TableV2Utils.ts @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with 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 type { + ColumnType, + FilterValue, + SortOrder, +} from 'antd/lib/table/interface'; +import type { ColumnsType } from 'antd/es/table/interface'; +import { isEmpty } from 'lodash'; +import React, { ReactNode } from 'react'; +import type { FlatRow } from './TableV2.interface'; + +export function flattenTreeRows( + data: T[], + getRowKey: (r: T, i: number) => string, + expandedKeys: Set, + rowExpandable: ((record: T) => boolean) | undefined, + depth = 0, + startIdx = 0 +): FlatRow[] { + const rows: FlatRow[] = []; + let nextIdx = startIdx; + + for (const record of data) { + const actualIndex = nextIdx++; + const rowKey = getRowKey(record, actualIndex); + const children = (record as Record).children as + | T[] + | undefined; + const hasChildren = rowExpandable + ? rowExpandable(record) + : !isEmpty(children); + + rows.push({ record, depth, actualIndex, hasChildren, rowKey }); + + if (hasChildren && expandedKeys.has(rowKey) && children?.length) { + const childRows = flattenTreeRows( + children, + getRowKey, + expandedKeys, + rowExpandable, + depth + 1, + nextIdx + ); + rows.push(...childRows); + nextIdx += childRows.length; + } + } + + return rows; +} + +export function resolveCellValue( + col: ColumnType, + record: T, + index: number +): ReactNode { + const { dataIndex, render } = col; + const rawValue = Array.isArray(dataIndex) + ? dataIndex.reduce( + (obj: unknown, key) => + (obj as Record)?.[key as string], + record as unknown + ) + : typeof dataIndex === 'string' + ? (record as Record)[dataIndex] + : undefined; + + if (render) { + const rendered = render(rawValue, record, index); + if ( + rendered !== null && + typeof rendered === 'object' && + 'children' in rendered && + !('$$typeof' in rendered) + ) { + return (rendered as { children: ReactNode }).children; + } + + return rendered as ReactNode; + } + + return rawValue !== undefined && rawValue !== null ? String(rawValue) : null; +} + +export function resolveColumnTitle( + col: ColumnType, + propsColumns: ColumnsType +): ReactNode { + if (typeof col.title === 'function') { + const sortedColumn = propsColumns.find( + (c) => (c as ColumnType).sortOrder + ) as ColumnType | undefined; + + return ( + col.title as (props: { + sortOrder?: SortOrder; + sortColumn?: ColumnType; + filters?: Record; + }) => ReactNode + )({ + sortOrder: col.sortOrder ?? null, + sortColumn: sortedColumn, + filters: {}, + }); + } + + return col.title as ReactNode; +} + +/** + * Returns sticky positioning styles for a fixed column. + * Note: assumes a single fixed column per side. If multiple columns are fixed + * to the same side, offsets must be computed by the caller. + */ +export function getColumnStickyStyle( + fixed: ColumnType['fixed'], + zIndex: number +): React.CSSProperties { + if (fixed === 'left') { + return { background: 'white', left: 0, position: 'sticky', zIndex }; + } + if (fixed === 'right') { + return { background: 'white', position: 'sticky', right: 0, zIndex }; + } + + return {}; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less index e4058887875d..fcbcc23a8c64 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/table.less @@ -141,6 +141,17 @@ } } +// TableV2 whole-table drop highlight — driven by React Aria's native data-drop-target +[data-drop-target] table:not(.ant-table) { + border-collapse: collapse; + tbody { + border: 2px dashed @primary-color; + tr td { + background: @active-color !important; + } + } +} + .align-table-filter-left { .ant-table-filter-column { flex-direction: row-reverse; @@ -171,15 +182,11 @@ // Hide the border of the columns except the resizable columns .ant-table-thead > tr { // Regular header cells - > th:not(:last-child):not(.ant-table-selection-column):not( - .ant-table-row-expand-icon-cell - ):not([colspan])::before { + > th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before { width: 0px; } // Resizable header cells - > .resizable-container:not(:last-child):not(.ant-table-selection-column):not( - .ant-table-row-expand-icon-cell - ):not([colspan])::before { + > .resizable-container:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before { width: 1px; } } diff --git a/openmetadata-ui/src/main/resources/ui/yarn.lock b/openmetadata-ui/src/main/resources/ui/yarn.lock index 1572a99564bd..69d28a97e01a 100644 --- a/openmetadata-ui/src/main/resources/ui/yarn.lock +++ b/openmetadata-ui/src/main/resources/ui/yarn.lock @@ -2421,9 +2421,8 @@ compare-versions "^4.1.2" "@openmetadata/ui-core-components@link:../../../../../openmetadata-ui-core-components/src/main/resources/ui": - version "1.0.0" - dependencies: - "@material/material-color-utilities" "^0.3.0" + version "0.0.0" + uid "" "@peculiar/asn1-schema@^2.3.13", "@peculiar/asn1-schema@^2.3.8": version "2.6.0" @@ -5600,6 +5599,13 @@ dependencies: "@types/react" "*" +"@types/react-resizable@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/react-resizable/-/react-resizable-3.0.8.tgz#b27001b4d262c82cc076272df4b8ef91d9487918" + integrity sha512-Pcvt2eGA7KNXldt1hkhVhAgZ8hK41m0mp89mFgQi7LAAEZiaLgm4fHJ5zbJZ/4m2LVaAyYrrRRv1LHDcrGQanA== + dependencies: + "@types/react" "*" + "@types/react-test-renderer@^17.0.0": version "17.0.9" resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-17.0.9.tgz#da6d06f3f37eefab39386c390140374dc5db5b33" @@ -12307,7 +12313,7 @@ react-refresh@^0.18.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== -react-resizable@^3.0.5: +react-resizable@^3.0.5, react-resizable@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.1.3.tgz#b8c3f8aeffb7b0b2c2306bfc7a742462e58125fb" integrity sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==