diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js index 2b94128e5cc..49921cfc599 100644 --- a/superset-frontend/.storybook/main.js +++ b/superset-frontend/.storybook/main.js @@ -22,7 +22,7 @@ const path = require('path'); const customConfig = require('../webpack.config.js'); module.exports = { - stories: ['../src/components/**/*.stories.jsx'], + stories: ['../src/components/**/*.stories.(t|j)sx'], addons: [ '@storybook/addon-actions', '@storybook/addon-links', diff --git a/superset-frontend/images/chart-card-fallback.png b/superset-frontend/images/chart-card-fallback.png new file mode 100644 index 00000000000..aa34d4f0136 Binary files /dev/null and b/superset-frontend/images/chart-card-fallback.png differ diff --git a/superset-frontend/images/dashboard-card-fallback.png b/superset-frontend/images/dashboard-card-fallback.png new file mode 100644 index 00000000000..1b4e968a09f Binary files /dev/null and b/superset-frontend/images/dashboard-card-fallback.png differ diff --git a/superset-frontend/images/icons/card-view.svg b/superset-frontend/images/icons/card-view.svg new file mode 100644 index 00000000000..009409b5949 --- /dev/null +++ b/superset-frontend/images/icons/card-view.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/images/icons/list-view.svg b/superset-frontend/images/icons/list-view.svg new file mode 100644 index 00000000000..9d33b74157b --- /dev/null +++ b/superset-frontend/images/icons/list-view.svg @@ -0,0 +1,21 @@ + + + + diff --git a/superset-frontend/images/icons/search.svg b/superset-frontend/images/icons/search.svg index 5b86f13859f..bef0709fd65 100644 --- a/superset-frontend/images/icons/search.svg +++ b/superset-frontend/images/icons/search.svg @@ -16,7 +16,7 @@ specific language governing permissions and limitations under the License. --> - + Icon / Search@1.5x Created with Sketch. diff --git a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx index cab27f7d422..2083b4a6bec 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx @@ -24,7 +24,9 @@ import fetchMock from 'fetch-mock'; import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import ChartList from 'src/views/CRUD/chart/ChartList'; -import ListView from 'src/components/ListView/ListView'; +import ListView from 'src/components/ListView'; +import PropertiesModal from 'src/explore/components/PropertiesModal'; +import ListViewCard from 'src/components/ListViewCard'; // store needed for withToasts(ChartTable) const mockStore = configureStore([thunk]); @@ -96,4 +98,19 @@ describe('ChartList', () => { `"http://localhost/api/v1/chart/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); }); + + it('renders a card view', () => { + expect(wrapper.find(ListViewCard)).toExist(); + }); + + it('renders a table view', () => { + wrapper.find('[data-test="list-view"]').first().simulate('click'); + expect(wrapper.find('table')).toExist(); + }); + + it('edits', () => { + expect(wrapper.find(PropertiesModal)).not.toExist(); + wrapper.find('[data-test="pencil"]').first().simulate('click'); + expect(wrapper.find(PropertiesModal)).toExist(); + }); }); diff --git a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx index 5c448ea59d4..eef4ca03eb4 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx @@ -24,8 +24,9 @@ import fetchMock from 'fetch-mock'; import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import DashboardList from 'src/views/CRUD/dashboard/DashboardList'; -import ListView from 'src/components/ListView/ListView'; +import ListView from 'src/components/ListView'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; +import ListViewCard from 'src/components/ListViewCard'; // store needed for withToasts(DashboardTable) const mockStore = configureStore([thunk]); @@ -88,6 +89,16 @@ describe('DashboardList', () => { `"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); }); + + it('renders a card view', () => { + expect(wrapper.find(ListViewCard)).toExist(); + }); + + it('renders a table view', () => { + wrapper.find('[data-test="list-view"]').first().simulate('click'); + expect(wrapper.find('table')).toExist(); + }); + it('edits', () => { expect(wrapper.find(PropertiesModal)).not.toExist(); wrapper.find('[data-test="pencil"]').first().simulate('click'); diff --git a/superset-frontend/spec/javascripts/views/CRUD/dataset/DatasetList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/dataset/DatasetList_spec.jsx index 01fb2e80c9e..53de35d0e7a 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/dataset/DatasetList_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/dataset/DatasetList_spec.jsx @@ -24,7 +24,7 @@ import fetchMock from 'fetch-mock'; import { supersetTheme, ThemeProvider } from '@superset-ui/style'; import DatasetList from 'src/views/CRUD/dataset/DatasetList'; -import ListView from 'src/components/ListView/ListView'; +import ListView from 'src/components/ListView'; import Button from 'src/components/Button'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; diff --git a/superset-frontend/spec/javascripts/welcome/DashboardTable_spec.tsx b/superset-frontend/spec/javascripts/welcome/DashboardTable_spec.tsx index d8f4f2b0346..b612fbbb68a 100644 --- a/superset-frontend/spec/javascripts/welcome/DashboardTable_spec.tsx +++ b/superset-frontend/spec/javascripts/welcome/DashboardTable_spec.tsx @@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { supersetTheme, ThemeProvider } from '@superset-ui/style'; -import ListView from 'src/components/ListView/ListView'; +import ListView from 'src/components/ListView'; import DashboardTable from 'src/welcome/DashboardTable'; // store needed for withToasts(DashboardTable) diff --git a/superset-frontend/src/components/AvatarIcon.tsx b/superset-frontend/src/components/AvatarIcon.tsx index 7dcf8f7eff4..f1a2fd32ab2 100644 --- a/superset-frontend/src/components/AvatarIcon.tsx +++ b/superset-frontend/src/components/AvatarIcon.tsx @@ -17,7 +17,6 @@ * under the License. */ import React from 'react'; -import styled from '@superset-ui/style'; import { getCategoricalSchemeRegistry } from '@superset-ui/color'; import Avatar, { ConfigProvider } from 'react-avatar'; import TooltipWrapper from 'src/components/TooltipWrapper'; @@ -25,27 +24,20 @@ import TooltipWrapper from 'src/components/TooltipWrapper'; interface Props { firstName: string; lastName: string; - tableName: string; - userName: string; + uniqueKey: string; iconSize: number; textSize: number; } const colorList = getCategoricalSchemeRegistry().get(); -const StyledAvatar = styled(Avatar)` - margin: 0px 5px; -`; - export default function AvatarIcon({ - tableName, + uniqueKey, firstName, lastName, - userName, iconSize, textSize, }: Props) { - const uniqueKey = `${tableName}-${userName}`; const fullName = `${firstName} ${lastName}`; return ( @@ -55,7 +47,7 @@ export default function AvatarIcon({ tooltip={fullName} > - { } viewBox="0 0 16 15" width={this.props.width || 20} - height="auto" + height={this.props.height || 'auto'} /> diff --git a/superset-frontend/src/components/Icon/index.tsx b/superset-frontend/src/components/Icon/index.tsx index a0d516db1c1..c325c8133f8 100644 --- a/superset-frontend/src/components/Icon/index.tsx +++ b/superset-frontend/src/components/Icon/index.tsx @@ -18,12 +18,13 @@ */ import React, { SVGProps } from 'react'; import { ReactComponent as CancelXIcon } from 'images/icons/cancel-x.svg'; -import { ReactComponent as CheckIcon } from 'images/icons/check.svg'; -import { ReactComponent as CircleCheckIcon } from 'images/icons/circle-check.svg'; -import { ReactComponent as CircleCheckSolidIcon } from 'images/icons/circle-check-solid.svg'; +import { ReactComponent as CardViewIcon } from 'images/icons/card-view.svg'; import { ReactComponent as CheckboxHalfIcon } from 'images/icons/checkbox-half.svg'; import { ReactComponent as CheckboxOffIcon } from 'images/icons/checkbox-off.svg'; import { ReactComponent as CheckboxOnIcon } from 'images/icons/checkbox-on.svg'; +import { ReactComponent as CheckIcon } from 'images/icons/check.svg'; +import { ReactComponent as CircleCheckIcon } from 'images/icons/circle-check.svg'; +import { ReactComponent as CircleCheckSolidIcon } from 'images/icons/circle-check-solid.svg'; import { ReactComponent as CloseIcon } from 'images/icons/close.svg'; import { ReactComponent as CompassIcon } from 'images/icons/compass.svg'; import { ReactComponent as DatasetPhysicalIcon } from 'images/icons/dataset_physical.svg'; @@ -31,39 +32,42 @@ import { ReactComponent as DatasetVirtualIcon } from 'images/icons/dataset_virtu import { ReactComponent as ErrorIcon } from 'images/icons/error.svg'; import { ReactComponent as FavoriteSelectedIcon } from 'images/icons/favorite-selected.svg'; import { ReactComponent as FavoriteUnselectedIcon } from 'images/icons/favorite-unselected.svg'; -import { ReactComponent as PencilIcon } from 'images/icons/pencil.svg'; +import { ReactComponent as ListViewIcon } from 'images/icons/list-view.svg'; import { ReactComponent as MoreIcon } from 'images/icons/more.svg'; +import { ReactComponent as PencilIcon } from 'images/icons/pencil.svg'; import { ReactComponent as SearchIcon } from 'images/icons/search.svg'; +import { ReactComponent as ShareIcon } from 'images/icons/share.svg'; import { ReactComponent as SortAscIcon } from 'images/icons/sort-asc.svg'; import { ReactComponent as SortDescIcon } from 'images/icons/sort-desc.svg'; import { ReactComponent as SortIcon } from 'images/icons/sort.svg'; import { ReactComponent as TrashIcon } from 'images/icons/trash.svg'; import { ReactComponent as WarningIcon } from 'images/icons/warning.svg'; -import { ReactComponent as ShareIcon } from 'images/icons/share.svg'; type IconName = | 'cancel-x' + | 'card-view' | 'check' | 'checkbox-half' | 'checkbox-off' | 'checkbox-on' - | 'close' - | 'circle-check' | 'circle-check-solid' + | 'circle-check' + | 'close' | 'compass' | 'dataset-physical' | 'dataset-virtual' | 'error' | 'favorite-selected' | 'favorite-unselected' + | 'list-view' | 'more' | 'pencil' | 'search' - | 'sort' + | 'share' | 'sort-asc' | 'sort-desc' + | 'sort' | 'trash' - | 'share' | 'warning'; export const iconsRegistry: Record< @@ -71,15 +75,17 @@ export const iconsRegistry: Record< React.ComponentType> > = { 'cancel-x': CancelXIcon, + 'card-view': CardViewIcon, 'checkbox-half': CheckboxHalfIcon, 'checkbox-off': CheckboxOffIcon, 'checkbox-on': CheckboxOnIcon, - 'circle-check': CircleCheckIcon, 'circle-check-solid': CircleCheckSolidIcon, + 'circle-check': CircleCheckIcon, 'dataset-physical': DatasetPhysicalIcon, 'dataset-virtual': DatasetVirtualIcon, 'favorite-selected': FavoriteSelectedIcon, 'favorite-unselected': FavoriteUnselectedIcon, + 'list-view': ListViewIcon, 'sort-asc': SortAscIcon, 'sort-desc': SortDescIcon, check: CheckIcon, @@ -89,18 +95,25 @@ export const iconsRegistry: Record< more: MoreIcon, pencil: PencilIcon, search: SearchIcon, + share: ShareIcon, sort: SortIcon, trash: TrashIcon, warning: WarningIcon, - share: ShareIcon, }; interface IconProps extends SVGProps { name: IconName; } -const Icon = ({ name, color = '#666666', ...rest }: IconProps) => { +const Icon = ({ + name, + color = '#666666', + viewBox = '0 0 24 24', + ...rest +}: IconProps) => { const Component = iconsRegistry[name]; - return ; + return ( + + ); }; export default Icon; diff --git a/superset-frontend/src/components/Label/index.tsx b/superset-frontend/src/components/Label/index.tsx index 359b95e5f5c..58c336e1884 100644 --- a/superset-frontend/src/components/Label/index.tsx +++ b/superset-frontend/src/components/Label/index.tsx @@ -63,6 +63,13 @@ const SupersetLabel = styled(BootstrapLabel)` background-color: ${({ theme }) => theme.colors.error.base}; } } + + &.secondaryLabel { + background-color: ${({ theme }) => theme.colors.secondary.base}; + &:hover { + background-color: ${({ theme }) => theme.colors.secondary.base}; + } + } `; export default function Label(props: LabelProps) { diff --git a/superset-frontend/src/components/ListView/CardCollection.tsx b/superset-frontend/src/components/ListView/CardCollection.tsx new file mode 100644 index 00000000000..6668850369c --- /dev/null +++ b/superset-frontend/src/components/ListView/CardCollection.tsx @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { TableInstance } from 'react-table'; +import styled from '@superset-ui/style'; + +interface Props { + renderCard?: (row: any) => React.ReactNode; + prepareRow: TableInstance['prepareRow']; + rows: TableInstance['rows']; + loading: boolean; +} + +const CardContainer = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(459px, max-content)); + grid-gap: ${({ theme }) => theme.gridUnit * 8}px; + justify-content: center; + padding: ${({ theme }) => theme.gridUnit * 2}px + ${({ theme }) => theme.gridUnit * 4}px; +`; + +export default function CardCollection({ + renderCard, + prepareRow, + rows, + loading, +}: Props) { + return ( + + {rows.map(row => { + if (!renderCard) return null; + prepareRow(row); + return ( +
{renderCard({ ...row.original, loading })}
+ ); + })} + + ); +} diff --git a/superset-frontend/src/components/ListView/Filters.tsx b/superset-frontend/src/components/ListView/Filters.tsx index c6017687ed3..a27a1d6baa6 100644 --- a/superset-frontend/src/components/ListView/Filters.tsx +++ b/superset-frontend/src/components/ListView/Filters.tsx @@ -218,7 +218,10 @@ interface UIFiltersProps { } const FilterWrapper = styled.div` - padding: 24px 16px 8px; + display: inline-block; + padding: ${({ theme }) => theme.gridUnit * 6}px + ${({ theme }) => theme.gridUnit * 4}px + ${({ theme }) => theme.gridUnit * 2}px; `; function UIFilters({ diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 2f797e975a2..1a8313b6846 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -17,13 +17,15 @@ * under the License. */ import { t } from '@superset-ui/translation'; -import React, { FunctionComponent } from 'react'; -import { Col, Row, Alert } from 'react-bootstrap'; +import React, { FunctionComponent, useState } from 'react'; +import { Alert } from 'react-bootstrap'; import styled from '@superset-ui/style'; import cx from 'classnames'; import Button from 'src/components/Button'; +import Icon from 'src/components/Icon'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import TableCollection from './TableCollection'; +import CardCollection from './CardCollection'; import Pagination from './Pagination'; import FilterControls from './Filters'; import { FetchDataConfig, Filters, SortColumn } from './types'; @@ -42,173 +44,21 @@ const ListViewStyles = styled.div` .body { overflow: scroll; max-height: 64vh; - - table { - border-collapse: separate; - - th { - background: white; - position: sticky; - top: 0; - &:first-of-type { - padding-left: ${({ theme }) => theme.gridUnit * 4}px; - } - } - } - } - - .filter-dropdown { - margin-top: 20px; - } - - .filter-column { - height: 30px; - padding: 5px; - font-size: 16px; - } - - .filter-close { - height: 30px; - padding: 5px; - - i { - font-size: 20px; - } - } - - .table-cell-loader { - position: relative; - - .loading-bar { - background-color: ${({ theme }) => theme.colors.secondary.light4}; - border-radius: 7px; - - span { - visibility: hidden; - } - } - - &:after { - position: absolute; - transform: translateY(-50%); - top: 50%; - left: 0; - content: ''; - display: block; - width: 100%; - height: 48px; - background-image: linear-gradient( - 100deg, - rgba(255, 255, 255, 0), - rgba(255, 255, 255, 0.5) 60%, - rgba(255, 255, 255, 0) 80% - ); - background-size: 200px 48px; - background-position: -100px 0; - background-repeat: no-repeat; - animation: loading-shimmer 1s infinite; - } - } - - .actions { - white-space: nowrap; - font-size: 24px; - min-width: 100px; - - svg, - i { - margin-right: 8px; - - &:hover { - path { - fill: ${({ theme }) => theme.colors.primary.base}; - } - } - } - } - - .table-row { - .actions { - opacity: 0; - } - - &:hover { - background-color: ${({ theme }) => theme.colors.secondary.light5}; - - .actions { - opacity: 1; - transition: opacity ease-in ${({ theme }) => theme.transitionTiming}s; - } - } - } - - .table-row-selected { - background-color: ${({ theme }) => theme.colors.secondary.light4}; - - &:hover { - background-color: ${({ theme }) => theme.colors.secondary.light4}; - } - } - - .table-cell { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - max-width: 300px; - line-height: 1; - vertical-align: middle; - &:first-of-type { - padding-left: ${({ theme }) => theme.gridUnit * 4}px; - } - } - - .sort-icon { - position: absolute; - } - - .form-actions-container { - position: absolute; - left: 28px; - } - - .row-count-container { - float: right; - padding-right: 24px; } } - @keyframes loading-shimmer { - 40% { - background-position: 100% 0; - } + .pagination-container { + display: flex; + flex-direction: column; + justify-content: center; + } - 100% { - background-position: 100% 0; - } + .row-count-container { + margin-top: ${({ theme }) => theme.gridUnit * 2}px; + color: ${({ theme }) => theme.colors.grayscale.base}; } `; -export interface ListViewProps { - columns: any[]; - data: any[]; - count: number; - pageSize: number; - fetchData: (conf: FetchDataConfig) => any; - loading: boolean; - className?: string; - initialSort?: SortColumn[]; - filters?: Filters; - bulkActions?: Array<{ - key: string; - name: React.ReactNode; - onSelect: (rows: any[]) => any; - type?: 'primary' | 'secondary' | 'danger'; - }>; - bulkSelectEnabled?: boolean; - disableBulkSelect?: () => void; - renderBulkSelectCopy?: (selects: any[]) => React.ReactNode; -} - const BulkSelectWrapper = styled(Alert)` border-radius: 0; margin-bottom: 0; @@ -257,6 +107,89 @@ const bulkSelectColumnConfig = { size: 'sm', }; +const ViewModeContainer = styled.div` + padding: ${({ theme }) => theme.gridUnit * 6}px 0px + ${({ theme }) => theme.gridUnit * 2}px + ${({ theme }) => theme.gridUnit * 4}px; + display: inline-block; + position: relative; + top: 8px; + + .toggle-button { + display: inline-block; + border-radius: ${({ theme }) => theme.gridUnit / 2}px; + padding: ${({ theme }) => theme.gridUnit}px; + padding-bottom: 0; + + &:first-of-type { + margin-right: ${({ theme }) => theme.gridUnit * 2}px; + } + } + + .active { + background-color: ${({ theme }) => theme.colors.grayscale.base}; + svg { + color: ${({ theme }) => theme.colors.grayscale.light5}; + } + } +`; + +const ViewModeToggle = ({ + mode, + setMode, +}: { + mode: 'table' | 'card'; + setMode: (mode: 'table' | 'card') => void; +}) => { + return ( + +
{ + e.currentTarget.blur(); + setMode('card'); + }} + className={cx('toggle-button', { active: mode === 'card' })} + > + +
+
{ + e.currentTarget.blur(); + setMode('table'); + }} + className={cx('toggle-button', { active: mode === 'table' })} + > + +
+
+ ); +}; +export interface ListViewProps { + columns: any[]; + data: T[]; + count: number; + pageSize: number; + fetchData: (conf: FetchDataConfig) => any; + loading: boolean; + className?: string; + initialSort?: SortColumn[]; + filters?: Filters; + bulkActions?: Array<{ + key: string; + name: React.ReactNode; + onSelect: (rows: any[]) => any; + type?: 'primary' | 'secondary' | 'danger'; + }>; + bulkSelectEnabled?: boolean; + disableBulkSelect?: () => void; + renderBulkSelectCopy?: (selects: any[]) => React.ReactNode; + renderCard?: (row: T) => React.ReactNode; +} + const ListView: FunctionComponent = ({ columns, data, @@ -271,6 +204,7 @@ const ListView: FunctionComponent = ({ bulkSelectEnabled = false, disableBulkSelect = () => {}, renderBulkSelectCopy = selected => t('%s Selected', selected.length), + renderCard, }) => { const { getTableProps, @@ -310,10 +244,18 @@ const ListView: FunctionComponent = ({ }); } + const cardViewEnabled = Boolean(renderCard); + const [viewingMode, setViewingMode] = useState<'table' | 'card'>( + cardViewEnabled ? 'card' : 'table', + ); + return (
+ {cardViewEnabled && ( + + )} {filterable && ( = ({ )} )} - -
-
- - - - showing{' '} - - {pageSize * pageIndex + (rows.length && 1)}- - {pageSize * pageIndex + rows.length} - {' '} - of {count} - - - + {viewingMode === 'card' && ( + + )} + {viewingMode === 'table' && ( + + )} +
+
+
+ gotoPage(p - 1)} + hideFirstAndLastPageLinks + /> +
+ {t( + '%s-%s of %s', + pageSize * pageIndex + (rows.length && 1), + pageSize * pageIndex + rows.length, + count, + )}
- gotoPage(p - 1)} - hideFirstAndLastPageLinks - />
); }; diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx index 42bb720cf04..7fd37f30320 100644 --- a/superset-frontend/src/components/ListView/TableCollection.tsx +++ b/superset-frontend/src/components/ListView/TableCollection.tsx @@ -22,7 +22,7 @@ import { TableInstance } from 'react-table'; import styled from '@superset-ui/style'; import Icon from 'src/components/Icon'; -interface Props { +interface TableCollectionProps { getTableProps: (userProps?: any) => any; getTableBodyProps: (userProps?: any) => any; prepareRow: TableInstance['prepareRow']; @@ -32,7 +32,17 @@ interface Props { } const Table = styled.table` + border-collapse: separate; + th { + background: ${({ theme }) => theme.colors.grayscale.light5}; + position: sticky; + top: 0; + + &:first-of-type { + padding-left: ${({ theme }) => theme.gridUnit * 4}px; + } + &.xs { min-width: 25px; } @@ -58,6 +68,7 @@ const Table = styled.table` position: relative; } } + td { &.xs { width: 25px; @@ -78,6 +89,105 @@ const Table = styled.table` width: 200px; } } + + .table-cell-loader { + position: relative; + + .loading-bar { + background-color: ${({ theme }) => theme.colors.secondary.light4}; + border-radius: 7px; + + span { + visibility: hidden; + } + } + + &:after { + position: absolute; + transform: translateY(-50%); + top: 50%; + left: 0; + content: ''; + display: block; + width: 100%; + height: 48px; + background-image: linear-gradient( + 100deg, + rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0.5) 60%, + rgba(255, 255, 255, 0) 80% + ); + background-size: 200px 48px; + background-position: -100px 0; + background-repeat: no-repeat; + animation: loading-shimmer 1s infinite; + } + } + + .actions { + white-space: nowrap; + min-width: 100px; + + svg, + i { + margin-right: 8px; + + &:hover { + path { + fill: ${({ theme }) => theme.colors.primary.base}; + } + } + } + } + + .table-row { + .actions { + opacity: 0; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.secondary.light5}; + + .actions { + opacity: 1; + transition: opacity ease-in ${({ theme }) => theme.transitionTiming}s; + } + } + } + + .table-row-selected { + background-color: ${({ theme }) => theme.colors.secondary.light4}; + + &:hover { + background-color: ${({ theme }) => theme.colors.secondary.light4}; + } + } + + .table-cell { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 300px; + line-height: 1; + vertical-align: middle; + &:first-of-type { + padding-left: ${({ theme }) => theme.gridUnit * 4}px; + } + } + + .sort-icon { + position: absolute; + } + + @keyframes loading-shimmer { + 40% { + background-position: 100% 0; + } + + 100% { + background-position: 100% 0; + } + } `; export default function TableCollection({ @@ -87,7 +197,7 @@ export default function TableCollection({ headerGroups, rows, loading, -}: Props) { +}: TableCollectionProps) { return ( diff --git a/superset-frontend/src/components/ListView/index.ts b/superset-frontend/src/components/ListView/index.ts new file mode 100644 index 00000000000..30be8a1c810 --- /dev/null +++ b/superset-frontend/src/components/ListView/index.ts @@ -0,0 +1,23 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './ListView'; +export * from './types'; + +export { default } from './ListView'; diff --git a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx new file mode 100644 index 00000000000..07881f6bb89 --- /dev/null +++ b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { withKnobs, boolean } from '@storybook/addon-knobs'; +import DashboardImg from 'images/dashboard-card-fallback.png'; +import ChartImg from 'images/chart-card-fallback.png'; +import { Dropdown, Menu } from 'src/common/components'; +import Icon from 'src/components/Icon'; +import FaveStar from 'src/components/FaveStar'; +import ListViewCard from './'; + +export default { + title: 'ListViewCard', + component: ListViewCard, + decorators: [withKnobs], +}; + +export const SupersetListViewCard = () => { + return ( + + + + + Delete + + + Edit + + + } + > + + + + } + /> + ); +}; diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx new file mode 100644 index 00000000000..25e731ddeff --- /dev/null +++ b/superset-frontend/src/components/ListViewCard/index.tsx @@ -0,0 +1,197 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import styled from '@superset-ui/style'; +import Icon from 'src/components/Icon'; +import { Card } from 'src/common/components'; + +const MenuIcon = styled(Icon)` + width: ${({ theme }) => theme.gridUnit * 4}px; + height: ${({ theme }) => theme.gridUnit * 4}px; + position: relative; + top: ${({ theme }) => theme.gridUnit / 2}px; +`; + +const ActionsWrapper = styled.div` + width: 64px; + display: flex; + justify-content: space-between; +`; + +const StyledCard = styled(Card)` + width: 459px; + + .ant-card-body { + padding: ${({ theme }) => theme.gridUnit * 4}px + ${({ theme }) => theme.gridUnit * 2}px; + } + .ant-card-meta-detail > div:not(:last-child) { + margin-bottom: 0; + } +`; + +const Cover = styled.div` + height: 264px; + overflow: hidden; + + .cover-footer { + transform: translateY(${({ theme }) => theme.gridUnit * 9}px); + transition: ${({ theme }) => theme.transitionTiming}s ease-out; + } + + &:hover { + .cover-footer { + transform: translateY(0); + } + } +`; + +const GradientContainer = styled.div` + position: relative; + display: inline-block; + + &:after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: inline-block; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0) 47.83%, + rgba(0, 0, 0, 0.219135) 79.64%, + rgba(0, 0, 0, 0.5) 100% + ); + } +`; +const CardCoverImg = styled.img` + display: block; + object-fit: cover; + width: 459px; + height: 264px; +`; + +const TitleContainer = styled.div` + display: flex; + justify-content: flex-start; + flex-direction: row; + + .card-actions { + margin-left: auto; + align-self: flex-end; + padding-left: ${({ theme }) => theme.gridUnit * 8}px; + } +`; + +const TitleLink = styled.a` + color: ${({ theme }) => theme.colors.grayscale.dark1} !important; + overflow: hidden; + text-overflow: ellipsis; + + & + .title-right { + margin-left: ${({ theme }) => theme.gridUnit * 2}px; + } +`; + +const CoverFooter = styled.div` + display: flex; + flex-wrap: nowrap; + position: relative; + top: -${({ theme }) => theme.gridUnit * 9}px; + padding: 0 8px; +`; + +const CoverFooterLeft = styled.div` + flex: 1; + overflow: hidden; +`; + +const CoverFooterRight = styled.div` + align-self: flex-end; + margin-left: auto; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +`; + +interface CardProps { + title: React.ReactNode; + url: string; + imgURL: string; + imgFallbackURL: string; + description: string; + titleRight?: React.ReactNode; + coverLeft?: React.ReactNode; + coverRight?: React.ReactNode; + actions: React.ReactNode; +} + +function ListViewCard({ + title, + url, + titleRight, + imgURL, + imgFallbackURL, + description, + coverLeft, + coverRight, + actions, +}: CardProps) { + return ( + + + + { + e.currentTarget.src = imgFallbackURL; + }} + /> + + + + {coverLeft && {coverLeft}} + {coverRight && {coverRight}} + + + } + > + + + {title} + {titleRight &&
{titleRight}
} +
{actions}
+
+ + } + description={description} + /> +
+ ); +} + +ListViewCard.Actions = ActionsWrapper; +ListViewCard.MenuIcon = MenuIcon; +export default ListViewCard; diff --git a/superset-frontend/src/components/Pagination.tsx b/superset-frontend/src/components/Pagination.tsx index a023f091980..78e806c54de 100644 --- a/superset-frontend/src/components/Pagination.tsx +++ b/superset-frontend/src/components/Pagination.tsx @@ -77,6 +77,7 @@ interface PaginationProps { const PaginationList = styled.ul` display: inline-block; margin: 16px 0; + padding: 0; li { display: inline; diff --git a/superset-frontend/src/components/SearchInput.tsx b/superset-frontend/src/components/SearchInput.tsx index 670de69ea89..314c9a41019 100644 --- a/superset-frontend/src/components/SearchInput.tsx +++ b/superset-frontend/src/components/SearchInput.tsx @@ -49,20 +49,18 @@ const commonStyles = ` position: absolute; z-index: 2; display: block; - width: 28px; - height: 28px; cursor: pointer; `; const SearchIcon = styled(Icon)` ${commonStyles} - top: 2px; + top: 1px; left: 2px; `; const ClearIcon = styled(Icon)` ${commonStyles} right: 0px; - top: 3px; + top: 1px; `; export default function SearchInput({ diff --git a/superset-frontend/src/explore/components/PropertiesModal.tsx b/superset-frontend/src/explore/components/PropertiesModal.tsx index 0cb59888851..bbcb508e833 100644 --- a/superset-frontend/src/explore/components/PropertiesModal.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal.tsx @@ -38,6 +38,7 @@ import FormLabel from 'src/components/FormLabel'; import getClientErrorObject from '../../utils/getClientErrorObject'; export type Slice = { + id?: number; slice_id: number; slice_name: string; description: string | null; diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts index 9b74337515e..e78d81007b3 100644 --- a/superset-frontend/src/types/Chart.ts +++ b/superset-frontend/src/types/Chart.ts @@ -21,6 +21,8 @@ * The Chart model as returned from the API */ +import Owner from './Owner'; + export default interface Chart { id: number; url: string; @@ -30,4 +32,8 @@ export default interface Chart { changed_on: string; description: string | null; cache_timeout: number | null; + thumbnail_url?: string; + changed_on_delta_humanized?: string; + owners?: Owner[]; + datasource_name_text?: string; } diff --git a/superset-frontend/src/types/Owner.ts b/superset-frontend/src/types/Owner.ts new file mode 100644 index 00000000000..890115e9d4e --- /dev/null +++ b/superset-frontend/src/types/Owner.ts @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The Owner model as returned from the API + */ + +export default interface Owner { + first_name: string; + id: string; + last_name: string; + username: string; +} diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index 3a651d17e02..785a238bd8e 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -30,17 +30,21 @@ import { } from 'src/views/CRUD/utils'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; +import AvatarIcon from 'src/components/AvatarIcon'; import Icon from 'src/components/Icon'; import FaveStar from 'src/components/FaveStar'; -import ListView, { ListViewProps } from 'src/components/ListView/ListView'; -import { +import ListView, { + ListViewProps, FetchDataConfig, Filters, SelectOption, -} from 'src/components/ListView/types'; +} from 'src/components/ListView'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal, { Slice } from 'src/explore/components/PropertiesModal'; import Chart from 'src/types/Chart'; +import ListViewCard from 'src/components/ListViewCard'; +import Label from 'src/components/Label'; +import { Dropdown, Menu } from 'src/common/components'; const PAGE_SIZE = 25; const FAVESTAR_BASE_URL = '/superset/favstar/slice'; @@ -53,7 +57,7 @@ interface Props { interface State { bulkSelectEnabled: boolean; chartCount: number; - charts: any[]; + charts: Chart[]; favoriteStatus: object; lastFetchDataConfig: FetchDataConfig | null; loading: boolean; @@ -191,7 +195,7 @@ class ChartList extends React.PureComponent { }, }: any) => {dsNameTxt}, Header: t('Datasource'), - accessor: 'datasource_id', + accessor: 'datasource_name', }, { Cell: ({ @@ -225,7 +229,7 @@ class ChartList extends React.PureComponent { disableSortBy: true, }, { - accessor: 'datasource', + accessor: 'datasource_id', hidden: true, disableSortBy: true, }, @@ -457,6 +461,85 @@ class ChartList extends React.PureComponent { }); }; + renderCard = (props: Chart) => { + const menu = ( + + {this.canDelete && ( + + + {t('Are you sure you want to delete')}{' '} + {props.slice_name}? + + } + onConfirm={() => this.handleChartDelete(props)} + > + {confirmDelete => ( +
+ Delete +
+ )} +
+
+ )} + {this.canEdit && ( + this.openChartEditModal(props)} + > + Edit + + )} +
+ ); + + return ( + ( + + ))} + coverRight={ + + } + actions={ + + + + + + + } + /> + ); + }; + render() { const { bulkSelectEnabled, @@ -519,6 +602,7 @@ class ChartList extends React.PureComponent { initialSort={this.initialSort} loading={loading} pageSize={PAGE_SIZE} + renderCard={this.renderCard} /> ); }} diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index f91fb0561b2..dc4194e1c2f 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -28,13 +28,21 @@ import { } from 'src/views/CRUD/utils'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu from 'src/components/Menu/SubMenu'; -import ListView, { ListViewProps } from 'src/components/ListView/ListView'; +import AvatarIcon from 'src/components/AvatarIcon'; +import ListView, { + ListViewProps, + FetchDataConfig, + Filters, +} from 'src/components/ListView'; import ExpandableList from 'src/components/ExpandableList'; -import { FetchDataConfig, Filters } from 'src/components/ListView/types'; +import Owner from 'src/types/Owner'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import Icon from 'src/components/Icon'; +import Label from 'src/components/Label'; import FaveStar from 'src/components/FaveStar'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; +import ListViewCard from 'src/components/ListViewCard'; +import { Dropdown, Menu } from 'src/common/components'; const PAGE_SIZE = 25; const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; @@ -47,7 +55,7 @@ interface Props { interface State { bulkSelectEnabled: boolean; dashboardCount: number; - dashboards: any[]; + dashboards: Dashboard[]; favoriteStatus: object; dashboardToEdit: Dashboard | null; lastFetchDataConfig: FetchDataConfig | null; @@ -64,6 +72,8 @@ interface Dashboard { id: number; published: boolean; url: string; + thumbnail_url: string; + owners: Owner[]; } class DashboardList extends React.PureComponent { @@ -205,61 +215,7 @@ class DashboardList extends React.PureComponent { disableSortBy: true, }, { - Cell: ({ row: { original } }: any) => { - const handleDelete = () => this.handleDashboardDelete(original); - const handleEdit = () => this.openDashboardEditModal(original); - const handleExport = () => this.handleBulkDashboardExport([original]); - if (!this.canEdit && !this.canDelete && !this.canExport) { - return null; - } - return ( - - {this.canDelete && ( - - {t('Are you sure you want to delete')}{' '} - {original.dashboard_title}? - - } - onConfirm={handleDelete} - > - {confirmDelete => ( - - - - )} - - )} - {this.canExport && ( - - - - )} - {this.canEdit && ( - - - - )} - - ); - }, + Cell: ({ row: { original } }: any) => this.renderActions(original), Header: t('Actions'), id: 'actions', disableSortBy: true, @@ -444,6 +400,148 @@ class DashboardList extends React.PureComponent { }); }; + renderActions(original: Dashboard) { + const handleDelete = () => this.handleDashboardDelete(original); + const handleEdit = () => this.openDashboardEditModal(original); + const handleExport = () => this.handleBulkDashboardExport([original]); + if (!this.canEdit && !this.canDelete && !this.canExport) { + return null; + } + return ( + + {this.canDelete && ( + + {t('Are you sure you want to delete')}{' '} + {original.dashboard_title}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + )} + + )} + {this.canExport && ( + + + + )} + {this.canEdit && ( + + + + )} + + ); + } + + renderCard = (props: Dashboard) => { + const menu = ( + + {this.canDelete && ( + + + {t('Are you sure you want to delete')}{' '} + {props.dashboard_title}? + + } + onConfirm={() => this.handleDashboardDelete(props)} + > + {confirmDelete => ( +
+ Delete +
+ )} +
+
+ )} + {this.canExport && ( + this.handleBulkDashboardExport([props])} + > + Export + + )} + {this.canEdit && ( + this.openDashboardEditModal(props)} + > + Edit + + )} +
+ ); + + return ( + {props.published ? 'published' : 'draft'}} + url={props.url} + imgURL={props.thumbnail_url} + imgFallbackURL="/static/assets/images/dashboard-card-fallback.png" + description={t('Last modified %s', props.changed_on_delta_humanized)} + coverLeft={props.owners.slice(0, 5).map(owner => ( + + ))} + actions={ + + + + + + + } + /> + ); + }; + render() { const { bulkSelectEnabled, @@ -495,6 +593,7 @@ class DashboardList extends React.PureComponent { {dashboardToEdit && ( this.setState({ dashboardToEdit: null })} onSubmit={this.handleDashboardEdit} /> @@ -512,6 +611,7 @@ class DashboardList extends React.PureComponent { initialSort={this.initialSort} loading={loading} pageSize={PAGE_SIZE} + renderCard={this.renderCard} /> ); diff --git a/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx index a9bebd7f3f7..8ee1e5f0a8c 100644 --- a/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx @@ -29,10 +29,14 @@ import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DatasourceModal from 'src/datasource/DatasourceModal'; import DeleteModal from 'src/components/DeleteModal'; -import ListView, { ListViewProps } from 'src/components/ListView/ListView'; +import ListView, { + ListViewProps, + FetchDataConfig, + Filters, +} from 'src/components/ListView'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; import AvatarIcon from 'src/components/AvatarIcon'; -import { FetchDataConfig, Filters } from 'src/components/ListView/types'; +import Owner from 'src/types/Owner'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import TooltipWrapper from 'src/components/TooltipWrapper'; import Icon from 'src/components/Icon'; @@ -40,13 +44,6 @@ import AddDatasetModal from './AddDatasetModal'; const PAGE_SIZE = 25; -type Owner = { - first_name: string; - id: string; - last_name: string; - username: string; -}; - type Dataset = { changed_by_name: string; changed_by_url: string; @@ -359,10 +356,9 @@ const DatasetList: FunctionComponent = ({ .map((owner: Owner) => ( @@ -379,7 +375,7 @@ const DatasetList: FunctionComponent = ({ disableSortBy: true, }, { - Cell: ({ row: { state, original } }: any) => { + Cell: ({ row: { original } }: any) => { const handleEdit = () => openDatasetEditModal(original); const handleDelete = () => openDatasetDeleteModal(original); if (!canEdit && !canDelete) { diff --git a/superset-frontend/src/welcome/DashboardTable.tsx b/superset-frontend/src/welcome/DashboardTable.tsx index 971b54f8cb2..345f8e80724 100644 --- a/superset-frontend/src/welcome/DashboardTable.tsx +++ b/superset-frontend/src/welcome/DashboardTable.tsx @@ -20,10 +20,9 @@ import React from 'react'; import { t } from '@superset-ui/translation'; import { SupersetClient } from '@superset-ui/connection'; import { debounce } from 'lodash'; -import ListView from 'src/components/ListView/ListView'; +import ListView, { FetchDataConfig } from 'src/components/ListView'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import { Dashboard } from 'src/types/bootstrapTypes'; -import { FetchDataConfig } from 'src/components/ListView/types'; const PAGE_SIZE = 25; diff --git a/superset/charts/api.py b/superset/charts/api.py index 6c3792e14d4..09f4e066bd0 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -116,15 +116,21 @@ class ChartRestApi(BaseSupersetModelRestApi): "datasource_url", "table.default_endpoint", "table.table_name", + "thumbnail_url", "viz_type", "params", "cache_timeout", + "owners.id", + "owners.username", + "owners.first_name", + "owners.last_name", ] list_select_columns = list_columns + ["changed_on", "changed_by_fk"] order_columns = [ "slice_name", "viz_type", "datasource_name", + "datasource_id", "changed_by.first_name", "changed_on_delta_humanized", ]