Skip to content
Snippets Groups Projects
Unverified Commit bbb45ec3 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

Update List visualization look (#24272)

* Increase image size in lists

* Use card-like layout for lists

* Make secondary "info" list text smaller

* Switch to HTML table based layout
parent f8b14199
No related branches found
No related tags found
No related merge requests found
import styled from "@emotion/styled";
import { css } from "@emotion/react";
import { color } from "metabase/lib/colors";
import { alpha, color } from "metabase/lib/colors";
import TableFooter from "../TableSimple/TableFooter";
import { CellRoot } from "./ListCell.styled";
import { CellRoot, CellContent } from "./ListCell.styled";
export const LIST_ITEM_VERTICAL_GAP = "16px";
export const LIST_ITEM_BORDER_DIVIDER_WIDTH = "1";
export const Root = styled.div<{ isQueryBuilder?: boolean }>`
display: flex;
flex-direction: column;
position: relative;
margin: 0.3rem;
${props =>
props.isQueryBuilder &&
......@@ -20,19 +21,31 @@ export const Root = styled.div<{ isQueryBuilder?: boolean }>`
`}
`;
export const RowActionsContainer = styled.div`
display: flex;
align-items: center;
const standardTableStyleReset = css`
border-collapse: collapse;
border-spacing: 0;
width: 100%;
font-size: 12px;
line-height: 12px;
text-align: left;
`;
export const Table = styled.table`
${standardTableStyleReset};
`;
export const RowActionsContainer = styled.td`
transition: all 0.1s ease-in-out;
`;
export const BulkSelectionControlContainer = styled(RowActionsContainer)<{
isSelectingItems?: boolean;
}>`
padding-left: 6px;
${props =>
props.isSelectingItems &&
css`
width: 100% !important;
opacity: 1 !important;
`}
`;
......@@ -42,16 +55,8 @@ export const RowActionButtonContainer = styled(CellRoot)`
padding-right: 0.25rem;
`;
export const ListItemContainer = styled.div<{ disabled?: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
export const ListItemContainer = styled.tr<{ disabled?: boolean }>`
height: 4rem;
border-radius: 8px;
box-shadow: 2px 3px 5px ${color("shadow")};
border: 1px solid transparent;
padding: 0 0.5rem;
background-color: ${color("bg-white")};
......@@ -64,37 +69,80 @@ export const ListItemContainer = styled.div<{ disabled?: boolean }>`
css`
&:hover {
cursor: pointer;
border-color: ${color("border")};
background-color: ${alpha(color("brand"), 0.05)};
}
`}
${RowActionsContainer}, ${BulkSelectionControlContainer} {
width: 0;
opacity: 0;
}
&:hover {
${RowActionsContainer}, ${BulkSelectionControlContainer} {
width: 100%;
opacity: 1;
}
}
`;
export const ListItemContent = styled.div`
export const InfoContentContainer = styled.div`
display: flex;
align-items: center;
flex-direction: column;
${CellContent} {
display: block;
}
${CellContent}:first-of-type {
font-size: 0.875rem;
}
${CellContent}:last-of-type {
margin-top: 4px;
font-size: 0.75rem;
}
`;
// Adding horizontal margin so list item shadows don't get cut in dashboard cards
// Because of overflow: hidden style. We need overflow-y: hidden to limit the number of visible rows
// And it's impossible to combine overflow-x: visible with overflow-y: hidden
// https://stackoverflow.com/questions/6421966/css-overflow-x-visible-and-overflow-y-hidden-causing-scrollbar-issue
export const ContentContainer = styled.div`
margin: 0 0.3rem;
export const TableHeader = styled.thead`
font-size: 0.8rem;
color: ${color("text-medium")};
&:after {
content: "-";
display: block;
line-height: 1em;
color: transparent;
}
`;
export const ColumnHeader = styled.th<{ width: string }>`
padding-left: 0.5rem;
width: ${props => props.width};
`;
const LIST_ITEM_BORDER_RADIUS = "6px";
export const TableBody = styled.tbody`
box-shadow: 0px 1px 10px ${color("shadow")};
border-radius: ${LIST_ITEM_BORDER_RADIUS};
${ListItemContainer}:first-of-type td:first-of-type {
border-top-left-radius: ${LIST_ITEM_BORDER_RADIUS};
}
${ListItemContainer}:first-of-type td:last-of-type {
border-top-right-radius: ${LIST_ITEM_BORDER_RADIUS};
}
${ListItemContainer}:last-of-type td:first-of-type {
border-bottom-left-radius: ${LIST_ITEM_BORDER_RADIUS};
}
${ListItemContainer}:last-of-type td:last-of-type {
border-bottom-right-radius: ${LIST_ITEM_BORDER_RADIUS};
}
${ListItemContainer}:not(:first-of-type) {
margin-top: ${LIST_ITEM_VERTICAL_GAP};
${ListItemContainer}:not(:last-of-type) {
border-bottom: 1px solid ${color("border")};
}
`;
......
......@@ -37,18 +37,20 @@ import { DashboardWithCards } from "metabase-types/types/Dashboard";
import { VisualizationProps } from "metabase-types/types/Visualization";
import { State } from "metabase-types/store";
import { CellSlot } from "./types";
import ListCell from "./ListCell";
import {
Root,
ContentContainer,
Table,
TableHeader,
TableBody,
ColumnHeader,
Footer,
ListItemContainer,
ListItemContent,
BulkSelectionControlContainer,
InfoContentContainer,
RowActionsContainer,
RowActionButtonContainer,
LIST_ITEM_VERTICAL_GAP,
LIST_ITEM_BORDER_DIVIDER_WIDTH,
} from "./List.styled";
function getBoundingClientRectSafe(ref: React.RefObject<HTMLBaseElement>) {
......@@ -89,6 +91,7 @@ function List({
className,
isDataApp,
isQueryBuilder,
getColumnTitle,
onVisualizationClick,
visualizationIsClickable,
updateRow,
......@@ -106,11 +109,13 @@ function List({
const { bulkActions } = useDataAppContext();
const listVariant = settings["list.variant"];
useLayoutEffect(() => {
const { height: footerHeight = 0 } = getBoundingClientRectSafe(footerRef);
const { height: rowHeight = 0 } = getBoundingClientRectSafe(firstRowRef);
const rowHeightWithMargin =
rowHeight + parseInt(LIST_ITEM_VERTICAL_GAP, 10);
rowHeight + parseInt(LIST_ITEM_BORDER_DIVIDER_WIDTH, 10);
const currentPageSize = Math.floor(
(height - footerHeight) / rowHeightWithMargin,
);
......@@ -248,26 +253,31 @@ function List({
);
}, [connectedDashCard, settings, bulkActions]);
const hasInlineActions =
!isSelectingItems && (hasEditButton || hasDeleteButton);
const renderBulkSelectionControl = useCallback(
(rowIndex: number) => {
const isSelected = bulkActions.selectedRowIndexes.includes(rowIndex);
return (
<BulkSelectionControlContainer isSelectingItems={isSelectingItems}>
<CheckBox
checked={isSelected}
onClick={stopClickPropagation}
onChange={event => {
const isSelectedNow = event.target.checked;
if (isSelectedNow) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
bulkActions.addRow(card.id, rowIndex);
} else {
bulkActions.removeRow(rowIndex);
}
}}
/>
<ListCell.Root>
<CheckBox
checked={isSelected}
onClick={stopClickPropagation}
onChange={event => {
const isSelectedNow = event.target.checked;
if (isSelectedNow) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
bulkActions.addRow(card.id, rowIndex);
} else {
bulkActions.removeRow(rowIndex);
}
}}
/>
</ListCell.Root>
</BulkSelectionControlContainer>
);
},
......@@ -275,7 +285,7 @@ function List({
);
const renderListItemCell = useCallback(
(rowIndex: number, columnIndex: number | null, slot: CellSlot) => {
(rowIndex: number, columnIndex: number | null) => {
if (columnIndex === null) {
return null;
}
......@@ -283,7 +293,6 @@ function List({
<ListCell
key={`${rowIndex}-${columnIndex}`}
value={data.rows[rowIndex][columnIndex]}
slot={slot}
data={data}
settings={settings}
columnIndex={columnIndex}
......@@ -301,27 +310,42 @@ function List({
if (listVariant === "info") {
const [firstColumnIndex, secondColumnIndex, thirdColumnIndex] = left;
return (
<ListItemContent>
<>
{canSelectForBulkAction && renderBulkSelectionControl(rowIndex)}
{renderListItemCell(rowIndex, firstColumnIndex, "left")}
<div>
{renderListItemCell(rowIndex, secondColumnIndex, "left")}
{renderListItemCell(rowIndex, thirdColumnIndex, "left")}
</div>
</ListItemContent>
{renderListItemCell(rowIndex, firstColumnIndex)}
<ListCell.Root>
<InfoContentContainer>
{secondColumnIndex !== null && (
<ListCell.Content
value={data.rows[rowIndex][secondColumnIndex]}
data={data}
settings={settings}
columnIndex={secondColumnIndex}
/>
)}
{thirdColumnIndex !== null && (
<ListCell.Content
value={data.rows[rowIndex][thirdColumnIndex]}
data={data}
settings={settings}
columnIndex={thirdColumnIndex}
/>
)}
</InfoContentContainer>
</ListCell.Root>
</>
);
}
return (
<ListItemContent>
<>
{canSelectForBulkAction && renderBulkSelectionControl(rowIndex)}
{left.map(columnIndex =>
renderListItemCell(rowIndex, columnIndex, "left"),
)}
</ListItemContent>
{left.map(columnIndex => renderListItemCell(rowIndex, columnIndex))}
</>
);
},
[
data,
settings,
listColumnIndexes,
canSelectForBulkAction,
......@@ -380,9 +404,6 @@ function List({
const canClick = isSelectingItems || isClickable;
const hasInlineActions =
!isSelectingItems && (hasEditButton || hasDeleteButton);
return (
<ListItemContainer
key={rowIndex}
......@@ -392,34 +413,30 @@ function List({
data-testid="table-row"
>
{renderListItemLeftPart(rowIndex)}
<ListItemContent>
{right.map(columnIndex =>
renderListItemCell(rowIndex, columnIndex, "right"),
)}
{hasInlineActions && (
<RowActionsContainer>
{hasEditButton && (
<RowActionButtonContainer slot="right">
<Button
disabled={!isDataApp}
onClick={onEditClick}
small
>{t`Edit`}</Button>
</RowActionButtonContainer>
)}
{hasDeleteButton && (
<RowActionButtonContainer slot="right">
<Button
disabled={!isDataApp}
onClick={onDeleteClick}
small
danger
>{t`Delete`}</Button>
</RowActionButtonContainer>
)}
</RowActionsContainer>
)}
</ListItemContent>
{right.map(columnIndex => renderListItemCell(rowIndex, columnIndex))}
{hasInlineActions && (
<RowActionsContainer>
{hasEditButton && (
<RowActionButtonContainer>
<Button
disabled={!isDataApp}
onClick={onEditClick}
small
>{t`Edit`}</Button>
</RowActionButtonContainer>
)}
{hasDeleteButton && (
<RowActionButtonContainer>
<Button
disabled={!isDataApp}
onClick={onDeleteClick}
small
danger
>{t`Delete`}</Button>
</RowActionButtonContainer>
)}
</RowActionsContainer>
)}
</ListItemContainer>
);
},
......@@ -428,6 +445,7 @@ function List({
data,
settings,
listColumnIndexes,
hasInlineActions,
hasEditButton,
hasDeleteButton,
isDataApp,
......@@ -441,12 +459,97 @@ function List({
],
);
const getBasicVariantColumnHeaders = useCallback(() => {
const leftColumnsCount = listColumnIndexes.left.filter(Boolean).length;
const columnIndexes = [
...listColumnIndexes.left,
...listColumnIndexes.right,
];
return columnIndexes.map((columnIndex, index) => {
if (columnIndex === null) {
return null;
}
const isLastLeft = index === leftColumnsCount - 1;
return (
<ColumnHeader key={columnIndex} width={isLastLeft ? "60%" : "10%"}>
{getColumnTitle(columnIndex)}
</ColumnHeader>
);
});
}, [listColumnIndexes, getColumnTitle]);
const getInfoVariantColumnHeaders = useCallback(() => {
const [firstColumnIndex, secondColumnIndex, thirdColumnIndex] =
listColumnIndexes.left;
const cols = [];
if (firstColumnIndex) {
cols.push(
<ColumnHeader key={firstColumnIndex} width="5%">
{getColumnTitle(firstColumnIndex)}
</ColumnHeader>,
);
}
if (secondColumnIndex || thirdColumnIndex) {
cols.push(
<ColumnHeader
key={`${secondColumnIndex}-${thirdColumnIndex}`}
width="45%"
></ColumnHeader>,
);
}
listColumnIndexes.right.forEach(columnIndex => {
if (columnIndex === null) {
return null;
}
cols.push(
<ColumnHeader key={columnIndex} width="10%">
{getColumnTitle(columnIndex)}
</ColumnHeader>,
);
});
return cols;
}, [listColumnIndexes, getColumnTitle]);
const renderColumnHeaders = useCallback(() => {
const cols = [];
if (canSelectForBulkAction) {
cols.push(<ColumnHeader key="bulk-selection-control" width="1%" />);
}
if (listVariant === "info") {
cols.push(...getInfoVariantColumnHeaders());
} else {
cols.push(...getBasicVariantColumnHeaders());
}
if (hasInlineActions) {
cols.push(<ColumnHeader key="inline-actions" width="5%" />);
}
return cols;
}, [
listVariant,
canSelectForBulkAction,
hasInlineActions,
getBasicVariantColumnHeaders,
getInfoVariantColumnHeaders,
]);
return (
<>
<Root className={className} isQueryBuilder={isQueryBuilder}>
<ContentContainer>
{paginatedRowIndexes.map(renderListItem)}
</ContentContainer>
<div>
<Table>
<TableHeader>
<tr>{renderColumnHeaders()}</tr>
</TableHeader>
<TableBody>{paginatedRowIndexes.map(renderListItem)}</TableBody>
</Table>
</div>
{pageSize < rows.length && (
<Footer
start={start}
......
......@@ -3,19 +3,13 @@ import { css } from "@emotion/react";
import { color } from "metabase/lib/colors";
import { CellSlot } from "./types";
function getCellAlignment(slot: CellSlot) {
return slot === "left" ? "left" : "right";
}
export const CellRoot = styled.div<{ slot: CellSlot }>`
export const CellRoot = styled.td`
padding-left: 0.5rem;
padding-right: 0.5rem;
color: ${color("text-dark")};
color: ${color("text-medium")};
font-weight: bold;
text-align: ${props => getCellAlignment(props.slot)};
text-align: left;
white-space: nowrap;
`;
......@@ -24,6 +18,7 @@ export const CellContent = styled.span<{ isClickable: boolean }>`
img {
border-radius: 99px;
height: 36px !important;
}
${props =>
......
/* eslint-disable react/prop-types */
import React, { useMemo } from "react";
import cx from "classnames";
import _ from "underscore";
......@@ -11,7 +10,6 @@ import { getColumnExtent } from "metabase/visualizations/lib/utils";
import { Column, Row, Value } from "metabase-types/types/Dataset";
import { VisualizationProps } from "metabase-types/types/Visualization";
import { CellSlot } from "./types";
import MiniBar from "../MiniBar";
import { CellRoot, CellContent } from "./ListCell.styled";
......@@ -19,7 +17,6 @@ export interface ListCellProps
extends Pick<VisualizationProps, "data" | "settings"> {
value: Value;
columnIndex: number;
slot: CellSlot;
}
interface CellDataProps {
......@@ -57,7 +54,12 @@ function getCellData({
});
}
function ListCell({ value, data, settings, columnIndex, slot }: ListCellProps) {
function ListCellContent({
value,
data,
settings,
columnIndex,
}: ListCellProps) {
const { rows, cols } = data;
const column = cols[columnIndex];
const columnSettings = settings.column(column);
......@@ -81,12 +83,26 @@ function ListCell({ value, data, settings, columnIndex, slot }: ListCellProps) {
});
return (
<CellRoot className={classNames} slot={slot}>
<CellContent isClickable={isLink} data-testid="cell-data">
{cellData}
</CellContent>
<CellContent
className={classNames}
isClickable={isLink}
data-testid="cell-data"
>
{cellData}
</CellContent>
);
}
function ListCell(props: ListCellProps) {
return (
<CellRoot>
<ListCellContent {...props} />
</CellRoot>
);
}
export default ListCell;
export default Object.assign(ListCell, {
Root: CellRoot,
ContentStyled: CellContent,
Content: ListCellContent,
});
export type CellSlot = "left" | "right";
export type ListVariant = "basic" | "info";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment