From 554cd82434307b5774101cd2f95ad2bb01831250 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Fri, 22 Nov 2024 15:51:03 -0800 Subject: [PATCH] Reorganize and clean up tables documentation - create subpages, rewrite overview - move the table building blocks example to its own page (much better props DX) - rewrite a bunch of things that felt really hard to understand DX-wise - rewrite EuiBasicTable docs with in-memory table in mind (it felt - remove a bunch of manual pagination/sorting examples from docs - unnecessary extra complexity - improve smaller screen display by removing columns that didn't add much to DX use cases --- .../tabular_content/tables/_category_.yml | 3 + .../{tables.mdx => tables/basic_tables.mdx} | 2961 +++++------------ .../tabular_content/tables/custom_tables.mdx | 878 +++++ .../{ => tables}/in_memory_tables.mdx | 87 +- .../tabular_content/tables/overview.mdx | 16 + .../{ => tables}/table_selection.tsx | 100 +- 6 files changed, 1724 insertions(+), 2321 deletions(-) create mode 100644 packages/website/docs/components/tabular_content/tables/_category_.yml rename packages/website/docs/components/tabular_content/{tables.mdx => tables/basic_tables.mdx} (51%) create mode 100644 packages/website/docs/components/tabular_content/tables/custom_tables.mdx rename packages/website/docs/components/tabular_content/{ => tables}/in_memory_tables.mdx (91%) create mode 100644 packages/website/docs/components/tabular_content/tables/overview.mdx rename packages/website/docs/components/tabular_content/{ => tables}/table_selection.tsx (80%) diff --git a/packages/website/docs/components/tabular_content/tables/_category_.yml b/packages/website/docs/components/tabular_content/tables/_category_.yml new file mode 100644 index 00000000000..635b08584f9 --- /dev/null +++ b/packages/website/docs/components/tabular_content/tables/_category_.yml @@ -0,0 +1,3 @@ +link: + type: doc + id: tabular_content_tables diff --git a/packages/website/docs/components/tabular_content/tables.mdx b/packages/website/docs/components/tabular_content/tables/basic_tables.mdx similarity index 51% rename from packages/website/docs/components/tabular_content/tables.mdx rename to packages/website/docs/components/tabular_content/tables/basic_tables.mdx index 0cda90a267e..e98a58d7e22 100644 --- a/packages/website/docs/components/tabular_content/tables.mdx +++ b/packages/website/docs/components/tabular_content/tables/basic_tables.mdx @@ -1,30 +1,17 @@ --- -slug: /tabular-content/tables -id: tabular_content_tables +slug: /tabular-content/tables/basic +id: tabular_content_tables_basic +sidebar_position: 1 --- -# Tables - -:::tip EUI provides opinionated and non-opinionated ways to build tables - -Tables can get complicated very fast. If you're just looking for a basic table with pagination, sorting, checkbox selection, and actions then you should use **EuiBasicTable**. It's a **high level component** that removes the need to worry about constructing individual components together. You simply arrange your data in the format it asks for. - -However if your table is more complicated, you can still use the individual table components like rows, headers, and pagination separately to do what you need. Find examples for that **at the bottom of this page**. - -::: - -## A basic table +# Basic tables **EuiBasicTable** is an opinionated high level component that standardizes both display and injection. At its most simple it only accepts two properties: * `items` are an array of objects that should be displayed in the table; one item per row. The exact item data that will be rendered in each cell in these rows is determined by the `columns` property. You can define `rowProps` and `cellProps` props which can either be objects or functions that return objects. The returned objects will be applied as props to the rendered rows and row cells, respectively. * `columns` defines what columns the table has and how to extract item data to display each cell in each row. -This example shows the most basic form of the **EuiBasicTable**. It is configured with the required `items` and `columns` properties. It shows how each column defines the data it needs to display per item. Some columns display the value as is (e.g. `firstName` and `lastName` fields for the user column). Other columns customize the display of the data before it is injected. This customization can be done in two (non-mutual exclusive) ways: - -* Provide a hint about the type of data (e.g. the "Date of Birth" column indicates that the data it shows is of type `date`). Providing data type hints will cause built-in display components to be adjusted (e.g. numbers will become right aligned, just like Excel). -* Provide a `render` function that given the value (and the item as a second argument) returns the React node that should be displayed as the content of the cell. This can be as simple as formatting values (e.g. the "Date of Birth" column) to utilizing more complex React components (e.g. the "Online", "Github", and "Nationality" columns as seen below). - **Note:** the basic table will treat any cells that use a `render` function as being `textOnly: false`. This may cause unnecessary word breaks. Apply `textOnly: true` to ensure it breaks properly. +This example shows the most basic form of the **EuiBasicTable**. It is configured with the required `items` and `columns` properties, with certain display configurations per-column: ```tsx interactive import React from 'react'; @@ -45,10 +32,6 @@ type User = { github: string; dateOfBirth: Date; online: boolean; - location: { - city: string; - country: string; - }; }; const users: User[] = []; @@ -61,10 +44,6 @@ for (let i = 0; i < 10; i++) { github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), - location: { - city: faker.location.city(), - country: faker.location.country(), - }, }); } @@ -102,22 +81,14 @@ export default () => { {username} ), + truncateText: true, }, { field: 'dateOfBirth', name: 'Date of Birth', dataType: 'date', render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - }, - { - field: 'location', - name: 'Location', - truncateText: true, - textOnly: true, - render: (location: User['location']) => { - return `${location.city}, ${location.country}`; - }, + formatDate(dateOfBirth, 'dobShort'), }, { field: 'online', @@ -164,36 +135,60 @@ export default () => { /> ); }; - ``` -## Adding pagination to a table +In the above example, some columns displayed the value as-is (e.g. `firstName` and `lastName` fields). Other columns customized the display of the data before it was injected. This customization can be done in two (non-mutual exclusive) ways: + +- Provide a hint about the type of data (e.g. the "Date of Birth" column indicates that the data it shows is of type `date`). Providing data type hints will cause built-in display components to be adjusted (e.g. numbers will become right aligned, like in Excel). +- Provide a `render` function that given the value (and the item as a second argument) returns the React node that should be displayed as the content of the cell. This can be as simple as formatting values (e.g. the "Date of Birth" column), to utilizing more complex React components (e.g. the "Online" and "Github" columns). + - **Note:** the basic table will treat any cells that use a `render` function as being `textOnly: false`. This may cause unnecessary word breaks. Apply `textOnly: true` to ensure it breaks properly. + +## Row selection + +The following example shows how to configure row selection via the `selection` property. For uncontrolled usage, where selection changes are determined entirely by the user, you can set items to be selected initially by passing an array of items to `selection.initialSelected`. You can also use `selected.onSelectionChange` to track or respond to the items that users select. + +To completely control table selection, use `selection.selected` instead (which requires passing `selected.onSelectionChange`). This can be useful if you want to handle table selections based on user interaction with another part of the UI. + +import BasicTableSelection from './table_selection'; + + + +## Row actions + +The following example demonstrates "actions" columns. These are special columns where you define per-row, item level actions. The most basic action you might define is a type `button` or `icon` though you can always make your own custom actions as well. + +Actions enforce some strict UI/UX guidelines: -The following example shows how to configure pagination via the `pagination`property. +* There can only be up to 2 actions visible per row. When more than two actions are defined, the first 2 `isPrimary` actions will stay visible, an ellipses icon button will hold all actions in a single popover. +* Actions change opacity when user hovers over the row with the mouse. When more than 2 actions are supplied, only the ellipses icon button stays visible at all times. +* When one or more table row(s) are selected, all item actions are disabled. Users should be expected to use some bulk action outside the individual table rows instead. ```tsx interactive -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { formatDate, + Comparators, EuiBasicTable, EuiBasicTableColumn, + EuiTableSelectionType, + EuiTableSortingType, Criteria, - EuiCode, + DefaultItemAction, + CustomItemAction, EuiLink, EuiHealth, - EuiSpacer, + EuiButton, + EuiFlexGroup, + EuiFlexItem, EuiSwitch, - EuiHorizontalRule, - EuiText, + EuiSpacer, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; type User = { - id: string; + id: number; firstName: string | null | undefined; lastName: string; - github: string; - dateOfBirth: Date; online: boolean; location: { city: string; @@ -203,13 +198,11 @@ type User = { const users: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < 5; i++) { users.push({ - id: faker.string.uuid(), + id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), - dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { city: faker.location.city(), @@ -218,11 +211,29 @@ for (let i = 0; i < 20; i++) { }); } +const cloneUserbyId = (id: number) => { + const index = users.findIndex((user) => user.id === id); + if (index >= 0) { + const user = users[index]; + users.splice(index, 0, { ...user, id: users.length }); + } +}; + +const deleteUsersByIds = (...ids: number[]) => { + ids.forEach((id) => { + const index = users.findIndex((user) => user.id === id); + if (index >= 0) { + users.splice(index, 1); + } + }); +}; + const columns: Array> = [ { field: 'firstName', name: 'First Name', truncateText: true, + sortable: true, mobileOptions: { render: (user: User) => ( <> @@ -243,22 +254,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, - { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - }, { field: 'location', name: 'Location', @@ -277,119 +272,221 @@ const columns: Array> = [ const label = online ? 'Online' : 'Offline'; return {label}; }, + sortable: true, }, ]; export default () => { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(10); - const [showPerPageOptions, setShowPerPageOptions] = useState(true); + /** + * Actions + */ + const [multiAction, setMultiAction] = useState(false); + const [customAction, setCustomAction] = useState(false); - const onTableChange = ({ page }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } + const deleteUser = (user: User) => { + deleteUsersByIds(user.id); + setSelectedItems([]); }; - const togglePerPageOptions = () => setShowPerPageOptions(!showPerPageOptions); - - // Manually handle pagination of data - const findUsers = (users: User[], pageIndex: number, pageSize: number) => { - let pageOfItems; + const cloneUser = (user: User) => { + cloneUserbyId(user.id); + setSelectedItems([]); + }; - if (!pageIndex && !pageSize) { - pageOfItems = users; + const actions = useMemo(() => { + if (customAction) { + let actions: Array> = [ + { + render: (user: User) => { + return ( + deleteUser(user)} color="danger"> + Delete + + ); + }, + }, + ]; + if (multiAction) { + actions = [ + { + ...actions[0], + isPrimary: true, + showOnHover: true, + }, + { + render: (user: User) => { + return ( + cloneUser(user)}> + Clone + + ); + }, + }, + { + render: () => { + return {}}>Edit; + }, + }, + ]; + } + return actions; } else { - const startIndex = pageIndex * pageSize; - pageOfItems = users.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); + let actions: Array> = [ + { + name: 'User profile', + description: ({ firstName, lastName }) => + `Visit ${firstName} ${lastName}'s profile`, + icon: 'editorLink', + color: 'primary', + type: 'icon', + enabled: ({ online }) => !!online, + href: ({ id }) => `${window.location.href}?id=${id}`, + target: '_self', + 'data-test-subj': 'action-outboundlink', + }, + ]; + if (multiAction) { + actions = [ + { + name: <>Clone, + description: 'Clone this user', + icon: 'copy', + type: 'icon', + onClick: cloneUser, + 'data-test-subj': 'action-clone', + }, + { + name: (user: User) => (user.id ? 'Delete' : 'Remove'), + description: ({ firstName, lastName }) => + `Delete ${firstName} ${lastName}`, + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: deleteUser, + isPrimary: true, + 'data-test-subj': ({ id }) => `action-delete-${id}`, + }, + { + name: 'Edit', + isPrimary: true, + available: ({ online }) => !online, + enabled: ({ online }) => !!online, + description: 'Edit this user', + icon: 'pencil', + type: 'icon', + onClick: () => {}, + 'data-test-subj': 'action-edit', + }, + { + name: 'Share', + isPrimary: true, + description: 'Share this user', + icon: 'share', + type: 'icon', + onClick: () => {}, + 'data-test-subj': 'action-share', + }, + ...actions, + ]; + } + return actions; } + }, [customAction, multiAction]); - return { - pageOfItems, - totalItemCount: users.length, - }; - }; + const columnsWithActions = [ + ...columns, + { + name: 'Actions', + actions, + }, + ]; - const { pageOfItems, totalItemCount } = findUsers(users, pageIndex, pageSize); + /** + * Selection + */ + const [selectedItems, setSelectedItems] = useState([]); - const pagination = { - pageIndex, - pageSize, - totalItemCount, - pageSizeOptions: [10, 0], - showPerPageOptions, - }; + const onSelectionChange = (selectedItems: User[]) => { + setSelectedItems(selectedItems); + }; - const resultsCount = - pageSize === 0 ? ( - All - ) : ( - <> - - {pageSize * pageIndex + 1}-{pageSize * pageIndex + pageSize} - {' '} - of {totalItemCount} - - ); + const selection: EuiTableSelectionType = { + selectable: (user: User) => user.online, + selectableMessage: (selectable: boolean, user: User) => + !selectable + ? `${user.firstName} ${user.lastName} is currently offline` + : `Select ${user.firstName} ${user.lastName}`, + onSelectionChange, + }; + + const deleteSelectedUsers = () => { + deleteUsersByIds(...selectedItems.map((user: User) => user.id)); + setSelectedItems([]); + }; + + const deleteButton = + selectedItems.length > 0 ? ( + + Delete {selectedItems.length} Users + + ) : null; return ( <> - - Hide per page options with{' '} - pagination.showPerPageOptions = false - - } - onChange={togglePerPageOptions} - /> - - - Showing {resultsCount} Users - - - + ({ minHeight: euiTheme?.size?.xxl })} + > + + setMultiAction(!multiAction)} + /> + + + setCustomAction(!customAction)} + /> + + + {deleteButton} + + + + ); }; - ``` -## Adding sorting to a table +## Expanding rows -The following example shows how to configure column sorting via the `sorting` property and flagging the sortable columns as `sortable: true`. To enable the default sorting ability for **every** column, pass `enableAllColumns: true` to the `sorting` prop. If you don't want the user to have control over the sort you can pass `readOnly: true` to the `sorting` prop or per column. +You can expand rows by passing in a `itemIdToExpandedRowMap` prop which will contain the content you want rendered inside the expanded row. When building out your table manually (not using EuiBasicTable), you will also need to add the prop `isExpandedRow` to the row that will be revealed. ```tsx interactive -import React, { useState } from 'react'; +import React, { useState, ReactNode } from 'react'; import { formatDate, Comparators, EuiBasicTable, EuiBasicTableColumn, + EuiTableSelectionType, EuiTableSortingType, Criteria, + EuiButtonIcon, EuiHealth, - EuiIcon, - EuiLink, - EuiToolTip, - EuiFlexGroup, - EuiFlexItem, - EuiSwitch, - EuiSpacer, - EuiCode, + EuiDescriptionList, + EuiScreenReaderOnly, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -397,8 +494,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; - dateOfBirth: Date; online: boolean; location: { city: string; @@ -408,13 +503,11 @@ type User = { const users: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < 5; i++) { users.push({ id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), - dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { city: faker.location.city(), @@ -449,219 +542,121 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: ( - - <> - Github{' '} - - - - ), - render: (username: User['github']) => ( - - {username} - - ), - }, - { - field: 'dateOfBirth', - name: ( - - <> - Date of Birth{' '} - - - - ), - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - }, { field: 'location', - name: ( - - <> - Nationality{' '} - - - - ), + name: 'Location', + truncateText: true, + textOnly: true, render: (location: User['location']) => { return `${location.city}, ${location.country}`; }, - truncateText: true, - textOnly: true, }, { field: 'online', - name: ( - - <> - Online{' '} - - - - ), + name: 'Online', + dataType: 'boolean', render: (online: User['online']) => { const color = online ? 'success' : 'danger'; const label = online ? 'Online' : 'Offline'; return {label}; }, + sortable: true, + }, + { + name: 'Actions', + actions: [ + { + name: 'Clone', + description: 'Clone this person', + type: 'icon', + icon: 'copy', + onClick: () => '', + }, + ], }, ]; export default () => { - const [enableAll, setEnableAll] = useState(false); - const [readonly, setReadonly] = useState(false); - - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('firstName'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - const onTableChange = ({ page, sort }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } - if (sort) { - const { field: sortField, direction: sortDirection } = sort; - setSortField(sortField); - setSortDirection(sortDirection); - } - }; + /** + * Expanding rows + */ + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< + Record + >({}); - // Manually handle sorting and pagination of data - const findUsers = ( - users: User[], - pageIndex: number, - pageSize: number, - sortField: keyof User, - sortDirection: 'asc' | 'desc' - ) => { - let items; + const toggleDetails = (user: User) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - if (sortField) { - items = users - .slice(0) - .sort( - Comparators.property(sortField, Comparators.default(sortDirection)) - ); + if (itemIdToExpandedRowMapValues[user.id]) { + delete itemIdToExpandedRowMapValues[user.id]; } else { - items = users; + const { online, location } = user; + + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + const listItems = [ + { + title: 'Location', + description: `${location.city}, ${location.country}`, + }, + { + title: 'Online', + description: {label}, + }, + ]; + itemIdToExpandedRowMapValues[user.id] = ( + + ); } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; - let pageOfItems; - - if (!pageIndex && !pageSize) { - pageOfItems = items; - } else { - const startIndex = pageIndex * pageSize; - pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); - } - - return { - pageOfItems, - totalItemCount: users.length, - }; - }; - - const { pageOfItems, totalItemCount } = findUsers( - users, - pageIndex, - pageSize, - sortField, - sortDirection - ); - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [3, 5, 8], - }; + const columnsWithExpandingRowToggle: Array> = [ + ...columns, + { + align: 'right', + width: '40px', + isExpander: true, + name: ( + + Expand row + + ), + mobileOptions: { header: false }, + render: (user: User) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, + return ( + toggleDetails(user)} + aria-label={ + itemIdToExpandedRowMapValues[user.id] ? 'Collapse' : 'Expand' + } + iconType={ + itemIdToExpandedRowMapValues[user.id] ? 'arrowDown' : 'arrowRight' + } + /> + ); + }, }, - enableAllColumns: enableAll, - readOnly: readonly, - }; + ]; return ( - <> - - - enableAllColumns} - checked={enableAll} - onChange={() => setEnableAll((enabled) => !enabled)} - /> - - - readOnly} - checked={readonly} - onChange={() => setReadonly((readonly) => !readonly)} - /> - - - - - + ); }; - ``` -## Adding selection to a table - -The following example shows how to configure selection via the `selection` property. For uncontrolled usage, where selection changes are determined entirely by the user, you can set items to be selected initially by passing an array of items to `selection.initialSelected`. You can also use `selected.onSelectionChange` to track or respond to the items that users select. - -To completely control table selection, use `selection.selected` instead (which requires passing `selected.onSelectionChange`). This can be useful if you want to handle table selections based on user interaction with another part of the UI. - -import BasicTableSelection from './table_selection'; - - - ## Adding a footer to a table -The following example shows how to add a footer to your table by adding `footer` to your column definitions. If one or more of your columns contains a `footer` definition, the footer area will be visible. By default, columns with no footer specified (undefined) will render an empty cell to preserve the table layout. Check out the _Build a custom table_ section below for more examples of how you can work with table footers in EUI. +The following example shows how to add a footer to your table by adding `footer` to your column definitions. If one or more of your columns contains a `footer` definition, the footer area will be visible. By default, columns with no footer specified (undefined) will render an empty cell to preserve the table layout. Check out the [custom tables](../custom) page for more examples of how you can work with table footers in EUI. ```tsx interactive import React, { useState } from 'react'; @@ -673,7 +668,6 @@ import { EuiTableSelectionType, EuiTableSortingType, Criteria, - EuiLink, EuiHealth, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -682,8 +676,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; - dateOfBirth: Date; online: boolean; location: { city: string; @@ -693,12 +685,11 @@ type User = { const users: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < 5; i++) { users.push({ id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { @@ -713,7 +704,6 @@ const columns: Array> = [ field: 'firstName', name: 'First Name', footer: Page totals:, - sortable: true, truncateText: true, mobileOptions: { render: (user: User) => ( @@ -735,24 +725,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - footer: ({ items }: { items: User[] }) => <>{items.length} users, - render: (username: User['github']) => ( - - {username} - - ), - }, - { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - sortable: true, - }, { field: 'location', name: 'Location', @@ -780,137 +752,40 @@ const columns: Array> = [ const label = online ? 'Online' : 'Offline'; return {label}; }, - sortable: true, }, ]; export default () => { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('firstName'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - const [, setSelectedItems] = useState([]); - - const onTableChange = ({ page, sort }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } - if (sort) { - const { field: sortField, direction: sortDirection } = sort; - setSortField(sortField); - setSortDirection(sortDirection); - } - }; - - const onSelectionChange = (selectedItems: User[]) => { - setSelectedItems(selectedItems); - }; - - // Manually handle sorting and pagination of data - const findUsers = ( - users: User[], - pageIndex: number, - pageSize: number, - sortField: keyof User, - sortDirection: 'asc' | 'desc' - ) => { - let items; - - if (sortField) { - items = users - .slice(0) - .sort( - Comparators.property(sortField, Comparators.default(sortDirection)) - ); - } else { - items = users; - } - - let pageOfItems; - - if (!pageIndex && !pageSize) { - pageOfItems = items; - } else { - const startIndex = pageIndex * pageSize; - pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); - } - - return { - pageOfItems, - totalItemCount: users.length, - }; - }; - - const { pageOfItems, totalItemCount } = findUsers( - users, - pageIndex, - pageSize, - sortField, - sortDirection - ); - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [3, 5, 8], - }; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - - const selection: EuiTableSelectionType = { - selectable: (user: User) => user.online, - selectableMessage: (selectable: boolean, user: User) => - !selectable - ? `${user.firstName} ${user.lastName} is currently offline` - : `Select ${user.firstName} ${user.lastName}`, - onSelectionChange, - }; - return ( ); }; - ``` -## Expanding rows +## Table layout -You can expand rows by passing in a `itemIdToExpandedRowMap` prop which will contain the content you want rendered inside the expanded row. When building out your table manually (not using EuiBasicTable), you will also need to add the prop `isExpandedRow` to the row that will be revealed. +**EuiBasicTable** has a fixed layout by default. You can change it to `auto` using the `tableLayout` prop. Note that setting `tableLayout` to `auto` prevents the `truncateText` prop from working properly. If you want to set different columns widths while still being able to use `truncateText`, set the width of each column using the `width` prop. + +You can also set the vertical alignment (`valign`) at the column level which will affect the cell contents for that entire column excluding the header and footer. ```tsx interactive -import React, { useState, ReactNode } from 'react'; +import React, { useState } from 'react'; import { formatDate, - Comparators, EuiBasicTable, - EuiBasicTableColumn, - EuiTableSelectionType, - EuiTableSortingType, - Criteria, - EuiButtonIcon, - EuiHealth, - EuiDescriptionList, - EuiScreenReaderOnly, + EuiTableFieldDataColumnType, + EuiButtonGroup, + EuiButtonGroupOptionProps, + EuiCallOut, + EuiLink, + EuiSpacer, + EuiFlexGroup, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -920,35 +795,29 @@ type User = { lastName: string; github: string; dateOfBirth: Date; - online: boolean; - location: { - city: string; - country: string; - }; + jobTitle: string; + address: string; }; const users: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < 10; i++) { users.push({ id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), github: faker.internet.userName(), - dateOfBirth: faker.date.past(), - online: faker.datatype.boolean(), - location: { - city: faker.location.city(), - country: faker.location.country(), - }, + jobTitle: faker.person.jobTitle(), + address: `${faker.location.streetAddress()} ${faker.location.city()} ${faker.location.state( + { abbreviated: true } + )} ${faker.location.zipCode()}`, }); } -const columns: Array> = [ +const columns: Array> = [ { field: 'firstName', name: 'First Name', - sortable: true, truncateText: true, mobileOptions: { render: (user: User) => ( @@ -971,219 +840,169 @@ const columns: Array> = [ }, }, { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), + field: 'github', + name: 'Github', + render: (username: User['github']) => ( + + {username} + + ), }, { - name: 'Actions', - actions: [ - { - name: 'Clone', - description: 'Clone this person', - type: 'icon', - icon: 'copy', - onClick: () => '', - }, - ], + field: 'jobTitle', + name: 'Job title', + truncateText: true, + }, + { + field: 'address', + name: 'Address', + truncateText: { lines: 2 }, }, ]; -export default () => { - /** - * Expanding rows - */ - const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< - Record - >({}); - - const toggleDetails = (user: User) => { - const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; - - if (itemIdToExpandedRowMapValues[user.id]) { - delete itemIdToExpandedRowMapValues[user.id]; - } else { - const { online, location } = user; - - const color = online ? 'success' : 'danger'; - const label = online ? 'Online' : 'Offline'; - const listItems = [ - { - title: 'Location', - description: `${location.city}, ${location.country}`, - }, - { - title: 'Online', - description: {label}, - }, - ]; - itemIdToExpandedRowMapValues[user.id] = ( - - ); - } - setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); - }; +const tableLayoutButtons: EuiButtonGroupOptionProps[] = [ + { + id: 'tableLayoutFixed', + label: 'Fixed', + value: 'fixed', + }, + { + id: 'tableLayoutAuto', + label: 'Auto', + value: 'auto', + }, + { + id: 'tableLayoutCustom', + label: 'Custom', + value: 'custom', + }, +]; - const columnsWithExpandingRowToggle: Array> = [ - ...columns, - { - align: 'right', - width: '40px', - isExpander: true, - name: ( - - Expand row - - ), - mobileOptions: { header: false }, - render: (user: User) => { - const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; +const vAlignButtons: EuiButtonGroupOptionProps[] = [ + { + id: 'columnVAlignTop', + label: 'Top', + value: 'top', + }, + { + id: 'columnVAlignMiddle', + label: 'Middle', + value: 'middle', + }, + { + id: 'columnVAlignBottom', + label: 'Bottom', + value: 'bottom', + }, +]; - return ( - toggleDetails(user)} - aria-label={ - itemIdToExpandedRowMapValues[user.id] ? 'Collapse' : 'Expand' - } - iconType={ - itemIdToExpandedRowMapValues[user.id] ? 'arrowDown' : 'arrowRight' - } - /> - ); - }, - }, - ]; +const alignButtons: EuiButtonGroupOptionProps[] = [ + { + id: 'columnAlignLeft', + label: 'Left', + value: 'left', + }, + { + id: 'columnAlignCenter', + label: 'Center', + value: 'center', + }, + { + id: 'columnAlignRight', + label: 'Right', + value: 'right', + }, +]; - /** - * Selection - */ - const [, setSelectedItems] = useState([]); +export default () => { + const [tableLayout, setTableLayout] = useState('tableLayoutFixed'); + const [vAlign, setVAlign] = useState('columnVAlignMiddle'); + const [align, setAlign] = useState('columnAlignLeft'); - const onSelectionChange = (selectedItems: User[]) => { - setSelectedItems(selectedItems); + const onTableLayoutChange = (id: string, value: string) => { + setTableLayout(id); + columns[4].width = value === 'custom' ? '100px' : undefined; + columns[5].width = value === 'custom' ? '20%' : undefined; }; - /** - * Pagination & sorting - */ - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('firstName'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - const onTableChange = ({ page, sort }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } - if (sort) { - const { field: sortField, direction: sortDirection } = sort; - setSortField(sortField); - setSortDirection(sortDirection); - } + const onVAlignChange = (id: string, value: 'top' | 'middle' | 'bottom') => { + setVAlign(id); + columns.forEach((column) => (column.valign = value)); }; - const selection: EuiTableSelectionType = { - selectable: (user: User) => user.online, - selectableMessage: (selectable: boolean, user: User) => - !selectable - ? `${user.firstName} ${user.lastName} is currently offline` - : `Select ${user.firstName} ${user.lastName}`, - onSelectionChange, + const onAlignChange = (id: string, value: 'left' | 'center' | 'right') => { + setAlign(id); + columns.forEach((column) => (column.align = value)); }; - // Manually handle sorting and pagination of data - const findUsers = ( - users: User[], - pageIndex: number, - pageSize: number, - sortField: keyof User, - sortDirection: 'asc' | 'desc' - ) => { - let items; - - if (sortField) { - items = users - .slice(0) - .sort( - Comparators.property(sortField, Comparators.default(sortDirection)) - ); - } else { - items = users; - } - - let pageOfItems; - - if (!pageIndex && !pageSize) { - pageOfItems = items; - } else { - const startIndex = pageIndex * pageSize; - pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); - } + let callOutText; - return { - pageOfItems, - totalItemCount: users.length, - }; - }; - - const { pageOfItems, totalItemCount } = findUsers( - users, - pageIndex, - pageSize, - sortField, - sortDirection - ); - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [3, 5, 8], - }; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; + switch (tableLayout) { + case 'tableLayoutFixed': + callOutText = + 'Job title has truncateText set to true. Address is set to { lines: 2 }'; + break; + case 'tableLayoutAuto': + callOutText = + 'Job title will not wrap or truncate since tableLayout is set to auto. Address will truncate if necessary'; + break; + case 'tableLayoutCustom': + callOutText = + 'Job title has a custom column width of 100px. Address has a custom column width of 20%'; + break; + } return ( - + <> + + + + + + + + + + ); }; - ``` -## Adding actions to table +## Responsive tables -The following example demonstrates "actions" columns. These are special columns where you define per-row, item level actions. The most basic action you might define is a type `button` or `icon` though you can always make your own custom actions as well. +Tables will be mobile-responsive by default, breaking down each row into its own card section and individually displaying each table header above the cell contents. The default breakpoint at which the table will responsively shift into cards is the [`m` window size](../../theming/breakpoints/values), which can be customized with the `responsiveBreakpoint` prop (e.g., `responsiveBreakpoint="s"`). -Actions enforce some strict UI/UX guidelines: +To never render your table responsively (e.g. for tables with very few columns), you may set `responsiveBreakpoint={false}`. Inversely, if you always want your table to render in a mobile-friendly manner, pass `true`. The below example table switches between `true/false` for quick/easy preview between mobile and desktop table UIs at all breakpoints. -* There can only be up to 2 actions visible per row. When more than two actions are defined, the first 2 `isPrimary` actions will stay visible, an ellipses icon button will hold all actions in a single popover. -* Actions change opacity when user hovers over the row with the mouse. When more than 2 actions are supplied, only the ellipses icon button stays visible at all times. -* When one or more table row(s) are selected, all item actions are disabled. Users should be expected to use some bulk action outside the individual table rows instead. +To customize your cell's appearance/rendering in mobile vs. desktop view, use the `mobileOptions` configuration. This object can be passed to each column item in **EuiBasicTable** or to **EuiTableRowCell** directly. See the "Snippet" tab in the below example, or the "Props" tab for a full list of configuration options. ```tsx interactive -import React, { useState, useMemo } from 'react'; +import React, { useState } from 'react'; import { formatDate, Comparators, @@ -1192,11 +1011,8 @@ import { EuiTableSelectionType, EuiTableSortingType, Criteria, - DefaultItemAction, - CustomItemAction, EuiLink, EuiHealth, - EuiButton, EuiFlexGroup, EuiFlexItem, EuiSwitch, @@ -1219,7 +1035,7 @@ type User = { const users: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < 3; i++) { users.push({ id: i + 1, firstName: faker.person.firstName(), @@ -1251,396 +1067,208 @@ const deleteUsersByIds = (...ids: number[]) => { }); }; -const columns: Array> = [ - { - field: 'firstName', - name: 'First Name', - truncateText: true, - sortable: true, - mobileOptions: { - render: (user: User) => ( - <> - {user.firstName} {user.lastName} - +export default () => { + /** + * Mobile column options + */ + const [customHeader, setCustomHeader] = useState(true); + const [isResponsive, setIsResponsive] = useState(true); + + const columns: Array> = [ + { + field: 'firstName', + name: 'First Name', + truncateText: true, + sortable: true, + mobileOptions: { + render: customHeader + ? (user: User) => ( + <> + {user.firstName} {user.lastName} + + ) + : undefined, + header: customHeader ? false : true, + width: customHeader ? '100%' : undefined, + enlarge: customHeader ? true : false, + truncateText: customHeader ? false : true, + }, + }, + { + field: 'lastName', + name: 'Last Name', + truncateText: true, + mobileOptions: { + show: !isResponsive || !customHeader, + }, + }, + { + field: 'github', + name: 'Github', + render: (username: User['github']) => ( + + {username} + ), - header: false, - truncateText: false, - enlarge: true, - width: '100%', }, - }, - { - field: 'lastName', - name: 'Last Name', - truncateText: true, - mobileOptions: { - show: false, + { + field: 'dateOfBirth', + name: 'Date of Birth', + dataType: 'date', + render: (dateOfBirth: User['dateOfBirth']) => + formatDate(dateOfBirth, 'dobLong'), + sortable: true, }, - }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, - { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - }, - { - field: 'location', - name: 'Location', - truncateText: true, - textOnly: true, - render: (location: User['location']) => { - return `${location.city}, ${location.country}`; + { + field: 'location', + name: 'Location', + truncateText: true, + textOnly: true, + render: (location: User['location']) => { + return `${location.city}, ${location.country}`; + }, }, - }, - { - field: 'online', - name: 'Online', - dataType: 'boolean', - render: (online: User['online']) => { - const color = online ? 'success' : 'danger'; - const label = online ? 'Online' : 'Offline'; - return {label}; + { + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online: User['online']) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, + sortable: true, }, - sortable: true, - }, -]; - -export default () => { - /** - * Actions - */ - const [multiAction, setMultiAction] = useState(false); - const [customAction, setCustomAction] = useState(false); - - const deleteUser = (user: User) => { - deleteUsersByIds(user.id); - setSelectedItems([]); - }; - - const cloneUser = (user: User) => { - cloneUserbyId(user.id); - setSelectedItems([]); - }; - - const actions = useMemo(() => { - if (customAction) { - let actions: Array> = [ + { + field: '', + name: 'Mobile only', + mobileOptions: { + only: true, + render: () => 'This column only appears on mobile', + }, + }, + { + name: 'Actions', + actions: [ { - render: (user: User) => { - return ( - deleteUser(user)} color="danger"> - Delete - - ); + name: 'Clone', + description: 'Clone this person', + icon: 'copy', + type: 'icon', + onClick: (user: User) => { + cloneUserbyId(user.id); + setSelectedItems([]); }, }, - ]; - if (multiAction) { - actions = [ - { - ...actions[0], - isPrimary: true, - showOnHover: true, - }, - { - render: (user: User) => { - return ( - cloneUser(user)}> - Clone - - ); - }, - }, - { - render: () => { - return {}}>Edit; - }, - }, - ]; - } - return actions; - } else { - let actions: Array> = [ { - name: 'User profile', - description: ({ firstName, lastName }) => - `Visit ${firstName} ${lastName}'s profile`, - icon: 'editorLink', - color: 'primary', + name: 'Delete', + description: 'Delete this person', + icon: 'trash', type: 'icon', - enabled: ({ online }) => !!online, - href: ({ id }) => `${window.location.href}?id=${id}`, - target: '_self', - 'data-test-subj': 'action-outboundlink', - }, - ]; - if (multiAction) { - actions = [ - { - name: <>Clone, - description: 'Clone this user', - icon: 'copy', - type: 'icon', - onClick: cloneUser, - 'data-test-subj': 'action-clone', - }, - { - name: (user: User) => (user.id ? 'Delete' : 'Remove'), - description: ({ firstName, lastName }) => - `Delete ${firstName} ${lastName}`, - icon: 'trash', - color: 'danger', - type: 'icon', - onClick: deleteUser, - isPrimary: true, - 'data-test-subj': ({ id }) => `action-delete-${id}`, - }, - { - name: 'Edit', - isPrimary: true, - available: ({ online }) => !online, - enabled: ({ online }) => !!online, - description: 'Edit this user', - icon: 'pencil', - type: 'icon', - onClick: () => {}, - 'data-test-subj': 'action-edit', - }, - { - name: 'Share', - isPrimary: true, - description: 'Share this user', - icon: 'share', - type: 'icon', - onClick: () => {}, - 'data-test-subj': 'action-share', + color: 'danger', + onClick: (user: User) => { + deleteUsersByIds(user.id); + setSelectedItems([]); }, - ...actions, - ]; - } - return actions; - } - }, [customAction, multiAction]); - - const columnsWithActions = [ - ...columns, - { - name: 'Actions', - actions, + }, + ], }, ]; - /** - * Selection - */ - const [selectedItems, setSelectedItems] = useState([]); - - const onSelectionChange = (selectedItems: User[]) => { - setSelectedItems(selectedItems); - }; - - const selection: EuiTableSelectionType = { - selectable: (user: User) => user.online, - selectableMessage: (selectable: boolean, user: User) => - !selectable - ? `${user.firstName} ${user.lastName} is currently offline` - : `Select ${user.firstName} ${user.lastName}`, - onSelectionChange, - }; - - const deleteSelectedUsers = () => { - deleteUsersByIds(...selectedItems.map((user: User) => user.id)); - setSelectedItems([]); - }; - - const deleteButton = - selectedItems.length > 0 ? ( - - Delete {selectedItems.length} Users - - ) : null; - - /** - * Pagination & sorting - */ - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('firstName'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - const onTableChange = ({ page, sort }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } - if (sort) { - const { field: sortField, direction: sortDirection } = sort; - setSortField(sortField); - setSortDirection(sortDirection); - } - }; - - // Manually handle sorting and pagination of data - const findUsers = ( - users: User[], - pageIndex: number, - pageSize: number, - sortField: keyof User, - sortDirection: 'asc' | 'desc' - ) => { - let items; - - if (sortField) { - items = users - .slice(0) - .sort( - Comparators.property(sortField, Comparators.default(sortDirection)) - ); - } else { - items = users; - } - - let pageOfItems; - - if (!pageIndex && !pageSize) { - pageOfItems = items; - } else { - const startIndex = pageIndex * pageSize; - pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); - } - - return { - pageOfItems, - totalItemCount: users.length, - }; - }; - - const { pageOfItems, totalItemCount } = findUsers( - users, - pageIndex, - pageSize, - sortField, - sortDirection - ); - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [3, 5, 8], - }; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; - return ( <> - ({ minHeight: euiTheme?.size?.xxl })} - > + setMultiAction(!multiAction)} + label="Responsive" + checked={isResponsive} + onChange={() => setIsResponsive(!isResponsive)} /> setCustomAction(!customAction)} + label="Custom header" + disabled={!isResponsive} + checked={isResponsive && customHeader} + onChange={() => setCustomHeader(!customHeader)} /> - - {deleteButton} ); }; - ``` -## Table layout +## Manual pagination and sorting -**EuiBasicTable** has a fixed layout by default. You can change it to `auto` using the `tableLayout` prop. Note that setting `tableLayout` to `auto` prevents the `truncateText` prop from working properly. If you want to set different columns widths while still being able to use `truncateText`, set the width of each column using the `width` prop. +**EuiBasicTable**'s `pagination` and `sorting` properties _only_ affect the UI displayed on the table (e.g. rendering sorting arrows or pagination numbers). They do not actually handle showing paginated or sorting your `items` data. -You can also set the vertical alignment (`valign`) at the column level which will affect the cell contents for that entire column excluding the header and footer. +This is primarily useful for large amounts of API-based data, where storing or sorting all rows in-memory would pose significant performance issues. Your API backend should then asynchronously handle sorting/filtering/caching your data. + +:::tip +For non-asynchronous and smaller datasets use-cases (in the hundreds or less), we recommend using [**EuiInMemoryTable**](../in-memory), which automatically handles pagination, sorting, and searching in-memory. +::: + +### Pagination + +The following example shows how to configure pagination via the `pagination` property. ```tsx interactive import React, { useState } from 'react'; import { formatDate, EuiBasicTable, - EuiTableFieldDataColumnType, - EuiButtonGroup, - EuiButtonGroupOptionProps, - EuiCallOut, - EuiLink, + EuiBasicTableColumn, + Criteria, + EuiCode, + EuiHealth, EuiSpacer, - EuiFlexGroup, + EuiSwitch, + EuiHorizontalRule, + EuiText, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; type User = { - id: number; + id: string; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; - jobTitle: string; - address: string; + online: boolean; + location: { + city: string; + country: string; + }; }; const users: User[] = []; -for (let i = 0; i < 10; i++) { +for (let i = 0; i < 20; i++) { users.push({ - id: i + 1, + id: faker.string.uuid(), firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), - jobTitle: faker.person.jobTitle(), - address: `${faker.location.streetAddress()} ${faker.location.city()} ${faker.location.state( - { abbreviated: true } - )} ${faker.location.zipCode()}`, + online: faker.datatype.boolean(), + location: { + city: faker.location.city(), + country: faker.location.country(), + }, }); } -const columns: Array> = [ +const columns: Array> = [ { field: 'firstName', name: 'First Name', @@ -1665,15 +1293,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, { field: 'dateOfBirth', name: 'Date of Birth', @@ -1682,158 +1301,116 @@ const columns: Array> = [ formatDate(dateOfBirth, 'dobLong'), }, { - field: 'jobTitle', - name: 'Job title', + field: 'location', + name: 'Location', truncateText: true, + textOnly: true, + render: (location: User['location']) => { + return `${location.city}, ${location.country}`; + }, }, { - field: 'address', - name: 'Address', - truncateText: { lines: 2 }, + field: 'online', + name: 'Online', + dataType: 'boolean', + render: (online: User['online']) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; + }, }, ]; -const tableLayoutButtons: EuiButtonGroupOptionProps[] = [ - { - id: 'tableLayoutFixed', - label: 'Fixed', - value: 'fixed', - }, - { - id: 'tableLayoutAuto', - label: 'Auto', - value: 'auto', - }, - { - id: 'tableLayoutCustom', - label: 'Custom', - value: 'custom', - }, -]; +export default () => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [showPerPageOptions, setShowPerPageOptions] = useState(true); -const vAlignButtons: EuiButtonGroupOptionProps[] = [ - { - id: 'columnVAlignTop', - label: 'Top', - value: 'top', - }, - { - id: 'columnVAlignMiddle', - label: 'Middle', - value: 'middle', - }, - { - id: 'columnVAlignBottom', - label: 'Bottom', - value: 'bottom', - }, -]; + const onTableChange = ({ page }: Criteria) => { + if (page) { + const { index: pageIndex, size: pageSize } = page; + setPageIndex(pageIndex); + setPageSize(pageSize); + } + }; -const alignButtons: EuiButtonGroupOptionProps[] = [ - { - id: 'columnAlignLeft', - label: 'Left', - value: 'left', - }, - { - id: 'columnAlignCenter', - label: 'Center', - value: 'center', - }, - { - id: 'columnAlignRight', - label: 'Right', - value: 'right', - }, -]; + const togglePerPageOptions = () => setShowPerPageOptions(!showPerPageOptions); -export default () => { - const [tableLayout, setTableLayout] = useState('tableLayoutFixed'); - const [vAlign, setVAlign] = useState('columnVAlignMiddle'); - const [align, setAlign] = useState('columnAlignLeft'); + // Manually handle pagination of data + const findUsers = (users: User[], pageIndex: number, pageSize: number) => { + let pageOfItems; - const onTableLayoutChange = (id: string, value: string) => { - setTableLayout(id); - columns[4].width = value === 'custom' ? '100px' : undefined; - columns[5].width = value === 'custom' ? '20%' : undefined; - }; + if (!pageIndex && !pageSize) { + pageOfItems = users; + } else { + const startIndex = pageIndex * pageSize; + pageOfItems = users.slice( + startIndex, + Math.min(startIndex + pageSize, users.length) + ); + } - const onVAlignChange = (id: string, value: 'top' | 'middle' | 'bottom') => { - setVAlign(id); - columns.forEach((column) => (column.valign = value)); + return { + pageOfItems, + totalItemCount: users.length, + }; }; - const onAlignChange = (id: string, value: 'left' | 'center' | 'right') => { - setAlign(id); - columns.forEach((column) => (column.align = value)); - }; + const { pageOfItems, totalItemCount } = findUsers(users, pageIndex, pageSize); - let callOutText; + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [10, 0], + showPerPageOptions, + }; - switch (tableLayout) { - case 'tableLayoutFixed': - callOutText = - 'Job title has truncateText set to true. Address is set to { lines: 2 }'; - break; - case 'tableLayoutAuto': - callOutText = - 'Job title will not wrap or truncate since tableLayout is set to auto. Address will truncate if necessary'; - break; - case 'tableLayoutCustom': - callOutText = - 'Job title has a custom column width of 100px. Address has a custom column width of 20%'; - break; - } + const resultsCount = + pageSize === 0 ? ( + All + ) : ( + <> + + {pageSize * pageIndex + 1}-{pageSize * pageIndex + pageSize} + {' '} + of {totalItemCount} + + ); return ( <> - - - - - - - + Hide per page options with{' '} + pagination.showPerPageOptions = false + + } + onChange={togglePerPageOptions} /> - + + + Showing {resultsCount} Users + + + ); }; - ``` -## Responsive tables - -Tables will be mobile-responsive by default, breaking down each row into its own card section and individually displaying each table header above the cell contents. The default breakpoint at which the table will responsively shift into cards is the [`m` window size](/docs/theming/breakpoints/values), which can be customized with the `responsiveBreakpoint` prop (e.g., `responsiveBreakpoint="s"`). - -To never render your table responsively (e.g. for tables with very few columns), you may set `responsiveBreakpoint={false}`. Inversely, if you always want your table to render in a mobile-friendly manner, pass `true`. The below example table switches between `true/false` for quick/easy preview between mobile and desktop table UIs at all breakpoints. +### Sorting -To customize your cell's appearance/rendering in mobile vs. desktop view, use the `mobileOptions` configuration. This object can be passed to each column item in **EuiBasicTable** or to **EuiTableRowCell** directly. See the "Snippet" tab in the below example, or the "Props" tab for a full list of configuration options. +The following example shows how to configure column sorting via the `sorting` property and flagging the sortable columns as `sortable: true`. To enable the default sorting ability for **every** column, pass `enableAllColumns: true` to the `sorting` prop. If you don't want the user to have control over the sort you can pass `readOnly: true` to the `sorting` prop or per column. ```tsx interactive import React, { useState } from 'react'; @@ -1842,15 +1419,16 @@ import { Comparators, EuiBasicTable, EuiBasicTableColumn, - EuiTableSelectionType, EuiTableSortingType, Criteria, - EuiLink, EuiHealth, + EuiIcon, + EuiToolTip, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiSpacer, + EuiCode, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -1858,7 +1436,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; online: boolean; location: { @@ -1874,7 +1451,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { @@ -1884,154 +1460,100 @@ for (let i = 0; i < 20; i++) { }); } -const cloneUserbyId = (id: number) => { - const index = users.findIndex((user) => user.id === id); - if (index >= 0) { - const user = users[index]; - users.splice(index, 0, { ...user, id: users.length }); - } -}; - -const deleteUsersByIds = (...ids: number[]) => { - ids.forEach((id) => { - const index = users.findIndex((user) => user.id === id); - if (index >= 0) { - users.splice(index, 1); - } - }); -}; - -export default () => { - /** - * Mobile column options - */ - const [customHeader, setCustomHeader] = useState(true); - const [isResponsive, setIsResponsive] = useState(true); - - const columns: Array> = [ - { - field: 'firstName', - name: 'First Name', - truncateText: true, - sortable: true, - mobileOptions: { - render: customHeader - ? (user: User) => ( - <> - {user.firstName} {user.lastName} - - ) - : undefined, - header: customHeader ? false : true, - width: customHeader ? '100%' : undefined, - enlarge: customHeader ? true : false, - truncateText: customHeader ? false : true, - }, - }, - { - field: 'lastName', - name: 'Last Name', - truncateText: true, - mobileOptions: { - show: !isResponsive || !customHeader, - }, - }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - +const columns: Array> = [ + { + field: 'firstName', + name: 'First Name', + sortable: true, + truncateText: true, + mobileOptions: { + render: (user: User) => ( + <> + {user.firstName} {user.lastName} + ), + header: false, + truncateText: false, + enlarge: true, + width: '100%', }, - { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - sortable: true, - }, - { - field: 'location', - name: 'Location', - truncateText: true, - textOnly: true, - render: (location: User['location']) => { - return `${location.city}, ${location.country}`; - }, - }, - { - field: 'online', - name: 'Online', - dataType: 'boolean', - render: (online: User['online']) => { - const color = online ? 'success' : 'danger'; - const label = online ? 'Online' : 'Offline'; - return {label}; - }, - sortable: true, + }, + { + field: 'lastName', + name: 'Last Name', + truncateText: true, + mobileOptions: { + show: false, }, - { - field: '', - name: 'Mobile only', - mobileOptions: { - only: true, - render: () => 'This column only appears on mobile', - }, + }, + { + field: 'dateOfBirth', + name: ( + + <> + Date of Birth{' '} + + + + ), + render: (dateOfBirth: User['dateOfBirth']) => + formatDate(dateOfBirth, 'dobLong'), + }, + { + field: 'location', + name: ( + + <> + Nationality{' '} + + + + ), + render: (location: User['location']) => { + return `${location.city}, ${location.country}`; }, - { - name: 'Actions', - actions: [ - { - name: 'Clone', - description: 'Clone this person', - icon: 'copy', - type: 'icon', - onClick: (user: User) => { - cloneUserbyId(user.id); - setSelectedItems([]); - }, - }, - { - name: 'Delete', - description: 'Delete this person', - icon: 'trash', - type: 'icon', - color: 'danger', - onClick: (user: User) => { - deleteUsersByIds(user.id); - setSelectedItems([]); - }, - }, - ], + truncateText: true, + textOnly: true, + }, + { + field: 'online', + name: ( + + <> + Online{' '} + + + + ), + render: (online: User['online']) => { + const color = online ? 'success' : 'danger'; + const label = online ? 'Online' : 'Offline'; + return {label}; }, - ]; - - /** - * Selection - */ - const [, setSelectedItems] = useState([]); - - const onSelectionChange = (selectedItems: User[]) => { - setSelectedItems(selectedItems); - }; + }, +]; - const selection: EuiTableSelectionType = { - selectable: (user: User) => user.online, - selectableMessage: (selectable: boolean, user: User) => - !selectable - ? `${user.firstName} ${user.lastName} is currently offline` - : `Select ${user.firstName} ${user.lastName}`, - onSelectionChange, - }; +export default () => { + const [enableAll, setEnableAll] = useState(false); + const [readonly, setReadonly] = useState(false); - /** - * Pagination & sorting - */ const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(3); + const [pageSize, setPageSize] = useState(5); const [sortField, setSortField] = useState('firstName'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); @@ -2106,907 +1628,44 @@ export default () => { field: sortField, direction: sortDirection, }, + enableAllColumns: enableAll, + readOnly: readonly, }; return ( <> - + setIsResponsive(!isResponsive)} + label={enableAllColumns} + checked={enableAll} + onChange={() => setEnableAll((enabled) => !enabled)} /> setCustomHeader(!customHeader)} + label={readOnly} + checked={readonly} + onChange={() => setReadonly((readonly) => !readonly)} /> - - - + ); }; - -``` - -## Build a custom table from individual components - -As an alternative to **EuiBasicTable** you can instead construct a table from individual **low level, basic components** like **EuiTableHeader** and **EuiTableRowCell**. Below is one of many ways you might set this up on your own. Important to note are how you need to set individual props like the `truncateText` prop to cells to enforce a single-line behavior and truncate their contents, or set the `textOnly` prop to `false` if you need the contents to be a direct descendent of the cell. - -### Responsive extras - -You must supply a `mobileOptions.header` prop equivalent to the column header on each **EuiTableRowCell** so that the mobile version will use that to populate the per cell headers. - -Also, custom table implementations **will not** auto-populate any header level functions like selection and filtering. In order to add mobile support for these functions, you will need to implement the **EuiTableHeaderMobile** component as a wrapper around these and use **EuiTableSortMobile** and **EuiTableSortMobileItem** components to supply mobile sorting. See demo below. - -```tsx interactive -import React, { Component, ReactNode } from 'react'; -import { - EuiBadge, - EuiHealth, - EuiButton, - EuiButtonIcon, - EuiCheckbox, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFieldSearch, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLink, - EuiPopover, - EuiSpacer, - EuiTable, - EuiTableBody, - EuiTableFooter, - EuiTableFooterCell, - EuiTableHeader, - EuiTableHeaderCell, - EuiTableHeaderCellCheckbox, - EuiTablePagination, - EuiTableRow, - EuiTableRowCell, - EuiTableRowCellCheckbox, - EuiTableSortMobile, - EuiTableHeaderMobile, - EuiScreenReaderOnly, - EuiTableFieldDataColumnType, - EuiTableSortMobileProps, - LEFT_ALIGNMENT, - RIGHT_ALIGNMENT, - HorizontalAlignment, - Pager, - SortableProperties, -} from '@elastic/eui'; - -interface DataTitle { - value: ReactNode; - truncateText?: boolean; - isLink?: boolean; -} - -interface DataItem { - id: number; - title: ReactNode | DataTitle; - type: string; - dateCreated: string; - magnitude: number; - health: ReactNode; -} - -interface Column { - id: string; - label?: string; - isVisuallyHiddenLabel?: boolean; - isSortable?: boolean; - isCheckbox?: boolean; - isActionsPopover?: boolean; - textOnly?: boolean; - alignment?: HorizontalAlignment; - width?: string; - footer?: ReactNode | Function; - render?: Function; - cellProvider?: Function; - mobileOptions?: EuiTableFieldDataColumnType['mobileOptions']; -} - -interface State { - itemIdToSelectedMap: Record; - itemIdToOpenActionsPopoverMap: Record; - sortedColumn: keyof DataItem; - itemsPerPage: number; - firstItemIndex: number; - lastItemIndex: number; -} - -interface Pagination { - pageIndex: number; - pageSize: number; - totalItemCount: number; -} - -export default class extends Component<{}, State> { - items: DataItem[] = [ - { - id: 0, - title: - 'A very long line which will wrap on narrower screens and NOT become truncated and replaced by an ellipsis', - type: 'user', - dateCreated: 'Tue Dec 28 2016', - magnitude: 1, - health: Healthy, - }, - { - id: 1, - title: { - value: - 'A very long line which will not wrap on narrower screens and instead will become truncated and replaced by an ellipsis', - truncateText: true, - }, - type: 'user', - dateCreated: 'Tue Dec 01 2016', - magnitude: 1, - health: Healthy, - }, - { - id: 2, - title: ( - <> - A very long line in an ELEMENT which will wrap on narrower screens and - NOT become truncated and replaced by an ellipsis - - ), - type: 'user', - dateCreated: 'Tue Dec 01 2016', - magnitude: 10, - health: Warning, - }, - { - id: 3, - title: { - value: ( - <> - A very long line in an ELEMENT which will not wrap on narrower - screens and instead will become truncated and replaced by an - ellipsis - - ), - truncateText: true, - }, - type: 'user', - dateCreated: 'Tue Dec 16 2016', - magnitude: 100, - health: Healthy, - }, - { - id: 4, - title: { - value: 'Dog', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 13 2016', - magnitude: 1000, - health: Warning, - }, - { - id: 5, - title: { - value: 'Dragon', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Healthy, - }, - { - id: 6, - title: { - value: 'Bear', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Danger, - }, - { - id: 7, - title: { - value: 'Dinosaur', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Warning, - }, - { - id: 8, - title: { - value: 'Spider', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Warning, - }, - { - id: 9, - title: { - value: 'Bugbear', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Healthy, - }, - { - id: 10, - title: { - value: 'Bear', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Danger, - }, - { - id: 11, - title: { - value: 'Dinosaur', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Warning, - }, - { - id: 12, - title: { - value: 'Spider', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Healthy, - }, - { - id: 13, - title: { - value: 'Bugbear', - isLink: true, - }, - type: 'user', - dateCreated: 'Tue Dec 11 2016', - magnitude: 10000, - health: Danger, - }, - ]; - - columns: Column[] = [ - { - id: 'checkbox', - isCheckbox: true, - textOnly: false, - width: '32px', - }, - { - id: 'type', - label: 'Type', - isVisuallyHiddenLabel: true, - alignment: LEFT_ALIGNMENT, - width: '24px', - cellProvider: (cell: string) => , - mobileOptions: { - show: false, - }, - }, - { - id: 'title', - label: 'Title', - footer: Title, - alignment: LEFT_ALIGNMENT, - isSortable: false, - mobileOptions: { - show: false, - }, - }, - { - id: 'title_type', - label: 'Title', - mobileOptions: { - only: true, - header: false, - enlarge: true, - width: '100%', - }, - render: (title: DataItem['title'], item: DataItem) => ( - <> - {' '} - {title as ReactNode} - - ), - }, - { - id: 'health', - label: 'Health', - footer: '', - alignment: LEFT_ALIGNMENT, - }, - { - id: 'dateCreated', - label: 'Date created', - footer: 'Date created', - alignment: LEFT_ALIGNMENT, - isSortable: true, - }, - { - id: 'magnitude', - label: 'Orders of magnitude', - footer: ({ - items, - pagination, - }: { - items: DataItem[]; - pagination: Pagination; - }) => { - const { pageIndex, pageSize } = pagination; - const startIndex = pageIndex * pageSize; - const pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, items.length) - ); - return ( - - Total: {pageOfItems.reduce((acc, cur) => acc + cur.magnitude, 0)} - - ); - }, - alignment: RIGHT_ALIGNMENT, - isSortable: true, - }, - { - id: 'actions', - label: 'Actions', - isVisuallyHiddenLabel: true, - alignment: RIGHT_ALIGNMENT, - isActionsPopover: true, - width: '32px', - }, - ]; - - sortableProperties: SortableProperties; - pager: Pager; - - constructor(props: {}) { - super(props); - - const defaultItemsPerPage = 10; - this.pager = new Pager(this.items.length, defaultItemsPerPage); - - this.state = { - itemIdToSelectedMap: {}, - itemIdToOpenActionsPopoverMap: {}, - sortedColumn: 'magnitude', - itemsPerPage: defaultItemsPerPage, - firstItemIndex: this.pager.getFirstItemIndex(), - lastItemIndex: this.pager.getLastItemIndex(), - }; - - this.sortableProperties = new SortableProperties( - [ - { - name: 'dateCreated', - getValue: (item) => item.dateCreated.toLowerCase(), - isAscending: true, - }, - { - name: 'magnitude', - getValue: (item) => String(item.magnitude).toLowerCase(), - isAscending: true, - }, - ], - this.state.sortedColumn - ); - } - - onChangeItemsPerPage = (itemsPerPage: number) => { - this.pager.setItemsPerPage(itemsPerPage); - this.setState({ - itemsPerPage, - firstItemIndex: this.pager.getFirstItemIndex(), - lastItemIndex: this.pager.getLastItemIndex(), - }); - }; - - onChangePage = (pageIndex: number) => { - this.pager.goToPageIndex(pageIndex); - this.setState({ - firstItemIndex: this.pager.getFirstItemIndex(), - lastItemIndex: this.pager.getLastItemIndex(), - }); - }; - - onSort = (prop: string) => { - this.sortableProperties.sortOn(prop); - - this.setState({ - sortedColumn: prop as keyof DataItem, - }); - }; - - toggleItem = (itemId: number) => { - this.setState((previousState) => { - const newItemIdToSelectedMap = { - ...previousState.itemIdToSelectedMap, - [itemId]: !previousState.itemIdToSelectedMap[itemId], - }; - - return { - itemIdToSelectedMap: newItemIdToSelectedMap, - }; - }); - }; - - toggleAll = () => { - const allSelected = this.areAllItemsSelected(); - const newItemIdToSelectedMap: State['itemIdToSelectedMap'] = {}; - this.items.forEach( - (item) => (newItemIdToSelectedMap[item.id] = !allSelected) - ); - - this.setState({ - itemIdToSelectedMap: newItemIdToSelectedMap, - }); - }; - - isItemSelected = (itemId: number) => { - return this.state.itemIdToSelectedMap[itemId]; - }; - - areAllItemsSelected = () => { - const indexOfUnselectedItem = this.items.findIndex( - (item) => !this.isItemSelected(item.id) - ); - return indexOfUnselectedItem === -1; - }; - - areAnyRowsSelected = () => { - return ( - Object.keys(this.state.itemIdToSelectedMap).findIndex((id) => { - return this.state.itemIdToSelectedMap[id]; - }) !== -1 - ); - }; - - togglePopover = (itemId: number) => { - this.setState((previousState) => { - const newItemIdToOpenActionsPopoverMap = { - ...previousState.itemIdToOpenActionsPopoverMap, - [itemId]: !previousState.itemIdToOpenActionsPopoverMap[itemId], - }; - - return { - itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, - }; - }); - }; - - closePopover = (itemId: number) => { - // only update the state if this item's popover is open - if (this.isPopoverOpen(itemId)) { - this.setState((previousState) => { - const newItemIdToOpenActionsPopoverMap = { - ...previousState.itemIdToOpenActionsPopoverMap, - [itemId]: false, - }; - - return { - itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, - }; - }); - } - }; - - isPopoverOpen = (itemId: number) => { - return this.state.itemIdToOpenActionsPopoverMap[itemId]; - }; - - renderSelectAll = (mobile?: boolean) => { - return ( - - ); - }; - - private getTableMobileSortItems() { - const items: EuiTableSortMobileProps['items'] = []; - - this.columns.forEach((column) => { - if (column.isCheckbox || !column.isSortable) { - return; - } - items.push({ - name: column.label, - key: column.id, - onSort: this.onSort.bind(this, column.id), - isSorted: this.state.sortedColumn === column.id, - isSortAscending: this.sortableProperties.isAscendingByName(column.id), - }); - }); - return items; - } - - renderHeaderCells() { - const headers: ReactNode[] = []; - - this.columns.forEach((column, columnIndex) => { - if (column.isCheckbox) { - headers.push( - - {this.renderSelectAll()} - - ); - } else if (column.isVisuallyHiddenLabel) { - headers.push( - - - {column.label} - - - ); - } else { - headers.push( - - {column.label} - - ); - } - }); - return headers.length ? headers : null; - } - - renderRows() { - const renderRow = (item: DataItem) => { - const cells = this.columns.map((column) => { - const cell = item[column.id as keyof DataItem]; - - let child; - - if (column.isCheckbox) { - return ( - - - - ); - } - - if (column.isActionsPopover) { - return ( - - this.togglePopover(item.id)} - /> - } - isOpen={this.isPopoverOpen(item.id)} - closePopover={() => this.closePopover(item.id)} - panelPaddingSize="none" - anchorPosition="leftCenter" - > - { - this.closePopover(item.id); - }} - > - Edit - , - { - this.closePopover(item.id); - }} - > - Share - , - { - this.closePopover(item.id); - }} - > - Delete - , - ]} - /> - - - ); - } - - if (column.id === 'title' || column.id === 'title_type') { - let title = item.title; - - if ((item.title as DataTitle)?.value) { - const titleObj = item.title as DataTitle; - const titleText = titleObj.value; - title = titleObj.isLink ? ( - {titleText} - ) : ( - titleText - ); - } - - if (column.render) { - child = column.render(title, item); - } else { - child = title; - } - } else if (column.cellProvider) { - child = column.cellProvider(cell); - } else { - child = cell; - } - - return ( - - {child} - - ); - }); - - return ( - - {cells} - - ); - }; - - const rows = []; - - for ( - let itemIndex = this.state.firstItemIndex; - itemIndex <= this.state.lastItemIndex; - itemIndex++ - ) { - const item = this.items[itemIndex]; - rows.push(renderRow(item)); - } - - return rows; - } - - renderFooterCells() { - const footers: ReactNode[] = []; - - const items = this.items; - const pagination = { - pageIndex: this.pager.getCurrentPageIndex(), - pageSize: this.state.itemsPerPage, - totalItemCount: this.pager.getTotalPages(), - }; - - this.columns.forEach((column) => { - const footer = this.getColumnFooter(column, { items, pagination }); - if (column.mobileOptions && column.mobileOptions.only) { - return; // exclude columns that only exist for mobile headers - } - - if (footer) { - footers.push( - - {footer} - - ); - } else { - footers.push( - - {undefined} - - ); - } - }); - return footers; - } - - getColumnFooter = ( - column: Column, - { - items, - pagination, - }: { - items: DataItem[]; - pagination: Pagination; - } - ) => { - if (column.footer === null) { - return null; - } - - if (column.footer) { - if (typeof column.footer === 'function') { - return column.footer({ items, pagination }); - } - return column.footer; - } - - return undefined; - }; - - render() { - let optionalActionButtons; - const exampleId = 'example-id'; - - if (!!this.areAnyRowsSelected()) { - optionalActionButtons = ( - - Delete selected - - ); - } - - return ( - <> - - {optionalActionButtons} - - - - - - - - - - - {this.renderSelectAll(true)} - - - - - - - - {this.renderHeaderCells()} - - {this.renderRows()} - - {this.renderFooterCells()} - - - - - - - ); - } -} - ``` ## Props -import basicTableDocgen from '@elastic/eui-docgen/dist/components/basic_table'; -import tableDocgen from '@elastic/eui-docgen/dist/components/table'; - - - - - - - - - - - - - - - - +import docgen from '@elastic/eui-docgen/dist/components/basic_table'; + + diff --git a/packages/website/docs/components/tabular_content/tables/custom_tables.mdx b/packages/website/docs/components/tabular_content/tables/custom_tables.mdx new file mode 100644 index 00000000000..095bc29915f --- /dev/null +++ b/packages/website/docs/components/tabular_content/tables/custom_tables.mdx @@ -0,0 +1,878 @@ +--- +slug: /tabular-content/tables/custom +id: tabular_content_tables_custom +sidebar_position: 3 +--- + +# Custom tables + +If you need more custom behavior than either [**EuiBasicTable**](../basic) or [**EuiInMemoryTable**](../in-memory) allow, you can opt to completely construct your own table from EUI's low-level table building block components, like **EuiTableHeader** and **EuiTableRowCell**. + +There are several important caveats to keep in mind while doing so: + +:::note Selection and filtering +Custom table implementations must completely handle their own selection and filtering. +::: + +:::note Mobile headers +You must supply a `mobileOptions.header` prop equivalent to the column header on each **EuiTableRowCell** so that the mobile version will use that to populate the per cell headers. + +Also, in order to add mobile support for selection and filtering toolbars, you will need to implement the **EuiTableHeaderMobile** component as a wrapper around these and use **EuiTableSortMobile** and **EuiTableSortMobileItem** components to supply mobile sorting. See demo below. +::: + +:::note Cell text behavior +Set individual props like the `truncateText` prop to cells to enforce a single-line behavior and truncate their contents, or set the `textOnly` prop to `false` if you need the contents to be a direct descendent of the cell. +::: + +Below is one of many ways you might set up a custom table that account for all the above notes: + +```tsx interactive +import React, { Component, ReactNode } from 'react'; +import { + EuiBadge, + EuiHealth, + EuiButton, + EuiButtonIcon, + EuiCheckbox, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiPopover, + EuiSpacer, + EuiTable, + EuiTableBody, + EuiTableFooter, + EuiTableFooterCell, + EuiTableHeader, + EuiTableHeaderCell, + EuiTableHeaderCellCheckbox, + EuiTablePagination, + EuiTableRow, + EuiTableRowCell, + EuiTableRowCellCheckbox, + EuiTableSortMobile, + EuiTableHeaderMobile, + EuiScreenReaderOnly, + EuiTableFieldDataColumnType, + EuiTableSortMobileProps, + LEFT_ALIGNMENT, + RIGHT_ALIGNMENT, + HorizontalAlignment, + Pager, + SortableProperties, +} from '@elastic/eui'; + +interface DataTitle { + value: ReactNode; + truncateText?: boolean; + isLink?: boolean; +} + +interface DataItem { + id: number; + title: ReactNode | DataTitle; + type: string; + dateCreated: string; + magnitude: number; + health: ReactNode; +} + +interface Column { + id: string; + label?: string; + isVisuallyHiddenLabel?: boolean; + isSortable?: boolean; + isCheckbox?: boolean; + isActionsPopover?: boolean; + textOnly?: boolean; + alignment?: HorizontalAlignment; + width?: string; + footer?: ReactNode | Function; + render?: Function; + cellProvider?: Function; + mobileOptions?: EuiTableFieldDataColumnType['mobileOptions']; +} + +interface State { + itemIdToSelectedMap: Record; + itemIdToOpenActionsPopoverMap: Record; + sortedColumn: keyof DataItem; + itemsPerPage: number; + firstItemIndex: number; + lastItemIndex: number; +} + +interface Pagination { + pageIndex: number; + pageSize: number; + totalItemCount: number; +} + +export default class extends Component<{}, State> { + items: DataItem[] = [ + { + id: 0, + title: + 'A very long line which will wrap on narrower screens and NOT become truncated and replaced by an ellipsis', + type: 'user', + dateCreated: 'Tue Dec 28 2016', + magnitude: 1, + health: Healthy, + }, + { + id: 1, + title: { + value: + 'A very long line which will not wrap on narrower screens and instead will become truncated and replaced by an ellipsis', + truncateText: true, + }, + type: 'user', + dateCreated: 'Tue Dec 01 2016', + magnitude: 1, + health: Healthy, + }, + { + id: 2, + title: ( + <> + A very long line in an ELEMENT which will wrap on narrower screens and + NOT become truncated and replaced by an ellipsis + + ), + type: 'user', + dateCreated: 'Tue Dec 01 2016', + magnitude: 10, + health: Warning, + }, + { + id: 3, + title: { + value: ( + <> + A very long line in an ELEMENT which will not wrap on narrower + screens and instead will become truncated and replaced by an + ellipsis + + ), + truncateText: true, + }, + type: 'user', + dateCreated: 'Tue Dec 16 2016', + magnitude: 100, + health: Healthy, + }, + { + id: 4, + title: { + value: 'Dog', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 13 2016', + magnitude: 1000, + health: Warning, + }, + { + id: 5, + title: { + value: 'Dragon', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Healthy, + }, + { + id: 6, + title: { + value: 'Bear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Danger, + }, + { + id: 7, + title: { + value: 'Dinosaur', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Warning, + }, + { + id: 8, + title: { + value: 'Spider', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Warning, + }, + { + id: 9, + title: { + value: 'Bugbear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Healthy, + }, + { + id: 10, + title: { + value: 'Bear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Danger, + }, + { + id: 11, + title: { + value: 'Dinosaur', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Warning, + }, + { + id: 12, + title: { + value: 'Spider', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Healthy, + }, + { + id: 13, + title: { + value: 'Bugbear', + isLink: true, + }, + type: 'user', + dateCreated: 'Tue Dec 11 2016', + magnitude: 10000, + health: Danger, + }, + ]; + + columns: Column[] = [ + { + id: 'checkbox', + isCheckbox: true, + textOnly: false, + width: '32px', + }, + { + id: 'type', + label: 'Type', + isVisuallyHiddenLabel: true, + alignment: LEFT_ALIGNMENT, + width: '24px', + cellProvider: (cell: string) => , + mobileOptions: { + show: false, + }, + }, + { + id: 'title', + label: 'Title', + footer: Title, + alignment: LEFT_ALIGNMENT, + isSortable: false, + mobileOptions: { + show: false, + }, + }, + { + id: 'title_type', + label: 'Title', + mobileOptions: { + only: true, + header: false, + enlarge: true, + width: '100%', + }, + render: (title: DataItem['title'], item: DataItem) => ( + <> + {' '} + {title as ReactNode} + + ), + }, + { + id: 'health', + label: 'Health', + footer: '', + alignment: LEFT_ALIGNMENT, + }, + { + id: 'dateCreated', + label: 'Date created', + footer: 'Date created', + alignment: LEFT_ALIGNMENT, + isSortable: true, + }, + { + id: 'magnitude', + label: 'Orders of magnitude', + footer: ({ + items, + pagination, + }: { + items: DataItem[]; + pagination: Pagination; + }) => { + const { pageIndex, pageSize } = pagination; + const startIndex = pageIndex * pageSize; + const pageOfItems = items.slice( + startIndex, + Math.min(startIndex + pageSize, items.length) + ); + return ( + + Total: {pageOfItems.reduce((acc, cur) => acc + cur.magnitude, 0)} + + ); + }, + alignment: RIGHT_ALIGNMENT, + isSortable: true, + }, + { + id: 'actions', + label: 'Actions', + isVisuallyHiddenLabel: true, + alignment: RIGHT_ALIGNMENT, + isActionsPopover: true, + width: '32px', + }, + ]; + + sortableProperties: SortableProperties; + pager: Pager; + + constructor(props: {}) { + super(props); + + const defaultItemsPerPage = 10; + this.pager = new Pager(this.items.length, defaultItemsPerPage); + + this.state = { + itemIdToSelectedMap: {}, + itemIdToOpenActionsPopoverMap: {}, + sortedColumn: 'magnitude', + itemsPerPage: defaultItemsPerPage, + firstItemIndex: this.pager.getFirstItemIndex(), + lastItemIndex: this.pager.getLastItemIndex(), + }; + + this.sortableProperties = new SortableProperties( + [ + { + name: 'dateCreated', + getValue: (item) => item.dateCreated.toLowerCase(), + isAscending: true, + }, + { + name: 'magnitude', + getValue: (item) => String(item.magnitude).toLowerCase(), + isAscending: true, + }, + ], + this.state.sortedColumn + ); + } + + onChangeItemsPerPage = (itemsPerPage: number) => { + this.pager.setItemsPerPage(itemsPerPage); + this.setState({ + itemsPerPage, + firstItemIndex: this.pager.getFirstItemIndex(), + lastItemIndex: this.pager.getLastItemIndex(), + }); + }; + + onChangePage = (pageIndex: number) => { + this.pager.goToPageIndex(pageIndex); + this.setState({ + firstItemIndex: this.pager.getFirstItemIndex(), + lastItemIndex: this.pager.getLastItemIndex(), + }); + }; + + onSort = (prop: string) => { + this.sortableProperties.sortOn(prop); + + this.setState({ + sortedColumn: prop as keyof DataItem, + }); + }; + + toggleItem = (itemId: number) => { + this.setState((previousState) => { + const newItemIdToSelectedMap = { + ...previousState.itemIdToSelectedMap, + [itemId]: !previousState.itemIdToSelectedMap[itemId], + }; + + return { + itemIdToSelectedMap: newItemIdToSelectedMap, + }; + }); + }; + + toggleAll = () => { + const allSelected = this.areAllItemsSelected(); + const newItemIdToSelectedMap: State['itemIdToSelectedMap'] = {}; + this.items.forEach( + (item) => (newItemIdToSelectedMap[item.id] = !allSelected) + ); + + this.setState({ + itemIdToSelectedMap: newItemIdToSelectedMap, + }); + }; + + isItemSelected = (itemId: number) => { + return this.state.itemIdToSelectedMap[itemId]; + }; + + areAllItemsSelected = () => { + const indexOfUnselectedItem = this.items.findIndex( + (item) => !this.isItemSelected(item.id) + ); + return indexOfUnselectedItem === -1; + }; + + areAnyRowsSelected = () => { + return ( + Object.keys(this.state.itemIdToSelectedMap).findIndex((id) => { + return this.state.itemIdToSelectedMap[id]; + }) !== -1 + ); + }; + + togglePopover = (itemId: number) => { + this.setState((previousState) => { + const newItemIdToOpenActionsPopoverMap = { + ...previousState.itemIdToOpenActionsPopoverMap, + [itemId]: !previousState.itemIdToOpenActionsPopoverMap[itemId], + }; + + return { + itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, + }; + }); + }; + + closePopover = (itemId: number) => { + // only update the state if this item's popover is open + if (this.isPopoverOpen(itemId)) { + this.setState((previousState) => { + const newItemIdToOpenActionsPopoverMap = { + ...previousState.itemIdToOpenActionsPopoverMap, + [itemId]: false, + }; + + return { + itemIdToOpenActionsPopoverMap: newItemIdToOpenActionsPopoverMap, + }; + }); + } + }; + + isPopoverOpen = (itemId: number) => { + return this.state.itemIdToOpenActionsPopoverMap[itemId]; + }; + + renderSelectAll = (mobile?: boolean) => { + return ( + + ); + }; + + private getTableMobileSortItems() { + const items: EuiTableSortMobileProps['items'] = []; + + this.columns.forEach((column) => { + if (column.isCheckbox || !column.isSortable) { + return; + } + items.push({ + name: column.label, + key: column.id, + onSort: this.onSort.bind(this, column.id), + isSorted: this.state.sortedColumn === column.id, + isSortAscending: this.sortableProperties.isAscendingByName(column.id), + }); + }); + return items; + } + + renderHeaderCells() { + const headers: ReactNode[] = []; + + this.columns.forEach((column, columnIndex) => { + if (column.isCheckbox) { + headers.push( + + {this.renderSelectAll()} + + ); + } else if (column.isVisuallyHiddenLabel) { + headers.push( + + + {column.label} + + + ); + } else { + headers.push( + + {column.label} + + ); + } + }); + return headers.length ? headers : null; + } + + renderRows() { + const renderRow = (item: DataItem) => { + const cells = this.columns.map((column) => { + const cell = item[column.id as keyof DataItem]; + + let child; + + if (column.isCheckbox) { + return ( + + + + ); + } + + if (column.isActionsPopover) { + return ( + + this.togglePopover(item.id)} + /> + } + isOpen={this.isPopoverOpen(item.id)} + closePopover={() => this.closePopover(item.id)} + panelPaddingSize="none" + anchorPosition="leftCenter" + > + { + this.closePopover(item.id); + }} + > + Edit + , + { + this.closePopover(item.id); + }} + > + Share + , + { + this.closePopover(item.id); + }} + > + Delete + , + ]} + /> + + + ); + } + + if (column.id === 'title' || column.id === 'title_type') { + let title = item.title; + + if ((item.title as DataTitle)?.value) { + const titleObj = item.title as DataTitle; + const titleText = titleObj.value; + title = titleObj.isLink ? ( + {titleText} + ) : ( + titleText + ); + } + + if (column.render) { + child = column.render(title, item); + } else { + child = title; + } + } else if (column.cellProvider) { + child = column.cellProvider(cell); + } else { + child = cell; + } + + return ( + + {child} + + ); + }); + + return ( + + {cells} + + ); + }; + + const rows = []; + + for ( + let itemIndex = this.state.firstItemIndex; + itemIndex <= this.state.lastItemIndex; + itemIndex++ + ) { + const item = this.items[itemIndex]; + rows.push(renderRow(item)); + } + + return rows; + } + + renderFooterCells() { + const footers: ReactNode[] = []; + + const items = this.items; + const pagination = { + pageIndex: this.pager.getCurrentPageIndex(), + pageSize: this.state.itemsPerPage, + totalItemCount: this.pager.getTotalPages(), + }; + + this.columns.forEach((column) => { + const footer = this.getColumnFooter(column, { items, pagination }); + if (column.mobileOptions && column.mobileOptions.only) { + return; // exclude columns that only exist for mobile headers + } + + if (footer) { + footers.push( + + {footer} + + ); + } else { + footers.push( + + {undefined} + + ); + } + }); + return footers; + } + + getColumnFooter = ( + column: Column, + { + items, + pagination, + }: { + items: DataItem[]; + pagination: Pagination; + } + ) => { + if (column.footer === null) { + return null; + } + + if (column.footer) { + if (typeof column.footer === 'function') { + return column.footer({ items, pagination }); + } + return column.footer; + } + + return undefined; + }; + + render() { + let optionalActionButtons; + const exampleId = 'example-id'; + + if (!!this.areAnyRowsSelected()) { + optionalActionButtons = ( + + Delete selected + + ); + } + + return ( + <> + + {optionalActionButtons} + + + + + + + + + + + {this.renderSelectAll(true)} + + + + + + + + {this.renderHeaderCells()} + + {this.renderRows()} + + {this.renderFooterCells()} + + + + + + + ); + } +} +``` + +## Props + +import docgen from '@elastic/eui-docgen/dist/components/table'; + + + + + + + + + + + + + + + diff --git a/packages/website/docs/components/tabular_content/in_memory_tables.mdx b/packages/website/docs/components/tabular_content/tables/in_memory_tables.mdx similarity index 91% rename from packages/website/docs/components/tabular_content/in_memory_tables.mdx rename to packages/website/docs/components/tabular_content/tables/in_memory_tables.mdx index 7cff9b8c7dd..a0e9dd9b1bd 100644 --- a/packages/website/docs/components/tabular_content/in_memory_tables.mdx +++ b/packages/website/docs/components/tabular_content/tables/in_memory_tables.mdx @@ -1,11 +1,12 @@ --- -slug: /tabular-content/in-memory-tables -id: tabular_content_in_memory_tables +slug: /tabular-content/tables/in-memory +id: tabular_content_tables_in_memory +sidebar_position: 2 --- # In-memory tables -The **EuiInMemoryTable** is a higher level component wrapper around **EuiBasicTable** aimed at displaying tables data when all the data is in memory. It takes the full set of data (all possible items) and based on its configuration, will display it handling all configured functionality (pagination and sorting) for you. +**EuiInMemoryTable** is a higher level component wrapper around [**EuiBasicTable**](../basic) aimed at automatically handling certain functionality (selection, search, sorting, and pagination) in-memory for you, within certain preset configurations. It takes the full set of data that must include all possible items. :::warning Column names must be referentially stable @@ -21,7 +22,6 @@ import { formatDate, EuiInMemoryTable, EuiBasicTableColumn, - EuiLink, EuiHealth, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -30,7 +30,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; online: boolean; location: { @@ -46,7 +45,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { @@ -82,15 +80,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, { field: 'dateOfBirth', name: 'Date of Birth', @@ -159,7 +148,7 @@ import InMemoryTableSelection from './table_selection'; ## In-memory table with search -The example shows how to configure **EuiInMemoryTable** to display a search bar by passing the search prop. You can read more about the search bar's properties and its syntax [**here**](/docs/forms/search-bar) . +This example shows how to configure **EuiInMemoryTable** to display a search bar by passing the `search` prop. For more detailed information about the syntax and configuration accepted by this prop, see [**EuiSearchBar**](../../forms/search-bar). ```tsx interactive import React, { useState } from 'react'; @@ -168,7 +157,6 @@ import { EuiInMemoryTable, EuiBasicTableColumn, EuiSearchBarProps, - EuiLink, EuiHealth, EuiSpacer, EuiSwitch, @@ -182,7 +170,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; online: boolean; location: string; @@ -196,7 +183,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: faker.location.country(), @@ -235,15 +221,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, { field: 'dateOfBirth', name: 'Date of Birth', @@ -315,8 +292,9 @@ export default () => { /> setFilters(!filters)} + disabled={textSearchFormat} /> { ); }; - ``` ## In-memory table with search callback @@ -369,7 +346,6 @@ import { EuiInMemoryTable, EuiBasicTableColumn, EuiSearchBarProps, - EuiLink, EuiHealth, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -378,7 +354,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; online: boolean; location: { @@ -394,7 +369,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { @@ -430,15 +404,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, { field: 'dateOfBirth', name: 'Date of Birth', @@ -519,7 +484,6 @@ export default () => { /> ); }; - ``` ## In-memory table with search and external state @@ -533,7 +497,6 @@ import { EuiInMemoryTable, EuiBasicTableColumn, EuiSearchBarProps, - EuiLink, EuiHealth, EuiFlexGroup, EuiFlexItem, @@ -573,8 +536,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; - dateOfBirth: Date; online: boolean; location: string; locationData: (typeof countries)[number]; @@ -589,8 +550,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), - dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: randomCountry.code, locationData: randomCountry, @@ -623,23 +582,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, - { - field: 'dateOfBirth', - name: 'Date of Birth', - dataType: 'date', - render: (dateOfBirth: User['dateOfBirth']) => - formatDate(dateOfBirth, 'dobLong'), - sortable: true, - }, { field: 'location', name: 'Location', @@ -794,7 +736,6 @@ export default () => { ); }; - ``` ## In-memory table with custom sort values @@ -859,7 +800,6 @@ export default () => { /> ); }; - ``` ## In-memory table with controlled pagination @@ -876,7 +816,6 @@ import { EuiInMemoryTableProps, EuiBasicTableColumn, CriteriaWithPagination, - EuiLink, EuiHealth, } from '@elastic/eui'; import { faker } from '@faker-js/faker'; @@ -885,7 +824,6 @@ type User = { id: number; firstName: string | null | undefined; lastName: string; - github: string; dateOfBirth: Date; online: boolean; location: { @@ -901,7 +839,6 @@ for (let i = 0; i < 20; i++) { id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - github: faker.internet.userName(), dateOfBirth: faker.date.past(), online: faker.datatype.boolean(), location: { @@ -937,15 +874,6 @@ const columns: Array> = [ show: false, }, }, - { - field: 'github', - name: 'Github', - render: (username: User['github']) => ( - - {username} - - ), - }, { field: 'dateOfBirth', name: 'Date of Birth', @@ -1022,7 +950,6 @@ export default () => { /> ); }; - ``` ## Props diff --git a/packages/website/docs/components/tabular_content/tables/overview.mdx b/packages/website/docs/components/tabular_content/tables/overview.mdx new file mode 100644 index 00000000000..85ae291b6c1 --- /dev/null +++ b/packages/website/docs/components/tabular_content/tables/overview.mdx @@ -0,0 +1,16 @@ +--- +slug: /tabular-content/tables +id: tabular_content_tables +--- + +# Tables + +Tables can get complicated very fast. EUI provides both opinionated and non-opinionated ways to build tables. + +- **Opinionated high-level components:** + - These high-level components removes the need to worry about constructing individual building blocks together. You simply arrange your data in the format it asks for. + - [**EuiBasicTable**](./basic) handles mobile row selection, row actions, row expansion, and mobile UX out of the box with relatively simple-to-use APIs. It is best used with asynchronous data, or static datasets that do not need pagination/sorting. + - [**EuiInMemoryTable**](./in-memory) has all the features that EuiBasicTable has, and additionally handles pagination, sorting, and searching the passed data out-of-the-box with relatively minimal APIs. It is best used with smaller synchronous datasets. +- **Non-opinionated building blocks:** + - If your table requires completely custom behavior, you can use individual building block components like [EuiTable, EuiTableRow, EuiTableRowCell, and more](./custom) to do what you need. + - Please note that if you go this route, you must handle your own data management as well as table accessibility and mobile UX. diff --git a/packages/website/docs/components/tabular_content/table_selection.tsx b/packages/website/docs/components/tabular_content/tables/table_selection.tsx similarity index 80% rename from packages/website/docs/components/tabular_content/table_selection.tsx rename to packages/website/docs/components/tabular_content/tables/table_selection.tsx index ff9d5106530..8293cec5b47 100644 --- a/packages/website/docs/components/tabular_content/table_selection.tsx +++ b/packages/website/docs/components/tabular_content/tables/table_selection.tsx @@ -5,7 +5,11 @@ import { EuiSwitch } from '@elastic/eui'; // @ts-expect-error Docusaurus theme is missing types for this component import { Demo } from '@elastic/eui-docusaurus-theme/lib/components/demo'; -const userDataSetup = (varName: string = 'users', isControlled: boolean) => ` +const userDataSetup = ( + varName: string = 'users', + count: number = 20, + isControlled: boolean +) => ` type User = { id: number; firstName: string | null | undefined; @@ -19,12 +23,12 @@ type User = { const ${varName}: User[] = []; -for (let i = 0; i < 20; i++) { +for (let i = 0; i < ${count}; i++) { ${varName}.push({ id: i + 1, firstName: faker.person.firstName(), lastName: faker.person.lastName(), - online: faker.datatype.boolean(), + online: i === 0 ? true : faker.datatype.boolean(), location: { city: faker.location.city(), country: faker.location.country(), @@ -137,7 +141,7 @@ import { EuiButton, } from '@elastic/eui'; -${userDataSetup('users', isControlled)} +${userDataSetup('users', 5, isControlled)} export default () => { /** @@ -154,87 +158,6 @@ export default () => { }); setSelectedItems([]); } - - /** - * Pagination & sorting - */ - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [sortField, setSortField] = useState('firstName'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - - const onTableChange = ({ page, sort }: Criteria) => { - if (page) { - const { index: pageIndex, size: pageSize } = page; - setPageIndex(pageIndex); - setPageSize(pageSize); - } - if (sort) { - const { field: sortField, direction: sortDirection } = sort; - setSortField(sortField); - setSortDirection(sortDirection); - } - }; - - // Manually handle sorting and pagination of data - const findUsers = ( - users: User[], - pageIndex: number, - pageSize: number, - sortField: keyof User, - sortDirection: 'asc' | 'desc' - ) => { - let items; - - if (sortField) { - items = users - .slice(0) - .sort( - Comparators.property(sortField, Comparators.default(sortDirection)) - ); - } else { - items = users; - } - - let pageOfItems; - - if (!pageIndex && !pageSize) { - pageOfItems = items; - } else { - const startIndex = pageIndex * pageSize; - pageOfItems = items.slice( - startIndex, - Math.min(startIndex + pageSize, users.length) - ); - } - - return { - pageOfItems, - totalItemCount: users.length, - }; - }; - - const { pageOfItems, totalItemCount } = findUsers( - users, - pageIndex, - pageSize, - sortField, - sortDirection - ); - - const pagination = { - pageIndex: pageIndex, - pageSize: pageSize, - totalItemCount: totalItemCount, - pageSizeOptions: [3, 5, 8], - }; - - const sorting: EuiTableSortingType = { - sort: { - field: sortField, - direction: sortDirection, - }, - }; return ( <> @@ -262,13 +185,10 @@ export default () => { tableCaption="Demo for an EuiBasicTable with ${ isControlled ? 'controlled' : 'uncontrolled' } selection" - items={pageOfItems} + items={users} itemId="id" rowHeader="firstName" columns={columns} - pagination={pagination} - sorting={sorting} - onChange={onTableChange} selection={selection} /> @@ -290,7 +210,7 @@ import { EuiButton, } from '@elastic/eui'; -${userDataSetup('userData', isControlled)} +${userDataSetup('userData', 20, isControlled)} export default () => { ${selectionSetup(isControlled)}