More Polish (#2394)

* Remove Import/Export buttons from dashboards index

* Reduce max label chars to 50

* Change format of dashboard "modified" column to relative time

Absolute time is included as a tooltip

* Redistribute column widths in dashboards table

Optimzied for long names

* Improve component spacer

Now supports more fine grained "stretch to fit" controls

* Introduce editable description component

Intended mainly for use in index list views

* Allow dashboard descriptions to be editable in place

* Give modified column a tad more space

* Standardize empty states of tabs in organization view

* Update test
pull/10616/head
alexpaxton 2019-01-09 17:15:59 -08:00 committed by GitHub
parent 7875051ace
commit 7cf643d1d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 350 additions and 75 deletions

View File

@ -6,6 +6,14 @@
.component-spacer {
display: flex;
flex-wrap: nowrap;
&.component-spacer--stretch-w {
width: 100%;
}
&.component-spacer--stretch-h {
height: 100%;
}
}
.component-spacer--left {
@ -24,10 +32,6 @@
flex-direction: row;
align-items: center;
&.component-spacer--stretch {
width: 100%;
}
&.component-spacer--left > * {
margin-right: $ix-marg-a;
@ -53,10 +57,6 @@
.component-spacer--vertical {
flex-direction: column;
&.component-spacer--stretch {
height: 100%;
}
&.component-spacer--left {
align-items: flex-start;
}

View File

@ -9,14 +9,16 @@ interface Props {
children: JSX.Element | JSX.Element[]
align: Alignment
stackChildren?: Stack
stretchToFit?: boolean
stretchToFitWidth?: boolean
stretchToFitHeight?: boolean
}
const ComponentSpacer: SFC<Props> = ({
children,
align,
stackChildren = Stack.Columns,
stretchToFit = false,
stretchToFitWidth = false,
stretchToFitHeight = false,
}) => (
<div
className={classnames('component-spacer', {
@ -25,7 +27,8 @@ const ComponentSpacer: SFC<Props> = ({
'component-spacer--right': align === Alignment.Right,
'component-spacer--horizontal': stackChildren === Stack.Columns,
'component-spacer--vertical': stackChildren === Stack.Rows,
'component-spacer--stretch': stretchToFit,
'component-spacer--stretch-w': stretchToFitWidth,
'component-spacer--stretch-h': stretchToFitHeight,
})}
>
{children}

View File

@ -200,18 +200,6 @@
------------------------------------------------------------------------------
*/
.index-list--description {
user-select: none;
font-size: 12px;
color: $g12-forge;
white-space: nowrap;
&.untitled {
color: $g9-mountain;
font-style: italic;
}
}
.index-list--labels {
margin-left: $ix-marg-b;
}

View File

@ -20,6 +20,7 @@ interface Props {
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onDeleteDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
notify: (message: Notification) => void
searchTerm: string
}
@ -34,6 +35,7 @@ export default class DashboardsIndexContents extends Component<Props> {
onCreateDashboard,
defaultDashboardLink,
onSetDefaultDashboard,
onUpdateDashboard,
searchTerm,
} = this.props
@ -48,6 +50,7 @@ export default class DashboardsIndexContents extends Component<Props> {
onExportDashboard={onExportDashboard}
defaultDashboardLink={defaultDashboardLink}
onSetDefaultDashboard={onSetDefaultDashboard}
onUpdateDashboard={onUpdateDashboard}
/>
</div>
)

View File

@ -28,6 +28,7 @@ import {
getDashboardsAsync,
importDashboardAsync,
deleteDashboardAsync,
updateDashboardAsync,
} from 'src/dashboards/actions/v2'
import {setDefaultDashboard} from 'src/shared/actions/links'
import {retainRangesDashTimeV1 as retainRangesDashTimeV1Action} from 'src/dashboards/actions/v2/ranges'
@ -59,6 +60,7 @@ interface Props {
handleGetDashboards: typeof getDashboardsAsync
handleDeleteDashboard: typeof deleteDashboardAsync
handleImportDashboard: typeof importDashboardAsync
handleUpdateDashboard: typeof updateDashboardAsync
notify: (message: Notification) => void
retainRangesDashTimeV1: (dashboardIDs: string[]) => void
dashboards: Dashboard[]
@ -88,7 +90,7 @@ class DashboardIndex extends PureComponent<Props, State> {
}
public render() {
const {dashboards, notify, links} = this.props
const {dashboards, notify, links, handleUpdateDashboard} = this.props
const {searchTerm} = this.state
return (
@ -103,12 +105,6 @@ class DashboardIndex extends PureComponent<Props, State> {
placeholderText="Filter dashboards by name..."
onSearch={this.filterDashboards}
/>
<Button
onClick={this.handleToggleOverlay}
icon={IconFont.Import}
text="Import"
titleText="Import a dashboard from a file"
/>
<Button
color={ComponentColor.Primary}
onClick={this.handleCreateDashboard}
@ -127,6 +123,7 @@ class DashboardIndex extends PureComponent<Props, State> {
onCreateDashboard={this.handleCreateDashboard}
onCloneDashboard={this.handleCloneDashboard}
onExportDashboard={this.handleExportDashboard}
onUpdateDashboard={handleUpdateDashboard}
notify={notify}
searchTerm={searchTerm}
/>
@ -271,6 +268,7 @@ const mdtp = {
handleGetDashboards: getDashboardsAsync,
handleDeleteDashboard: deleteDashboardAsync,
handleImportDashboard: importDashboardAsync,
handleUpdateDashboard: updateDashboardAsync,
retainRangesDashTimeV1: retainRangesDashTimeV1Action,
}

View File

@ -28,6 +28,7 @@ interface Props {
onCreateDashboard: () => void
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
onSetDefaultDashboard: (dashboardLink: string) => void
}
@ -62,26 +63,26 @@ class DashboardsTable extends PureComponent<Props & WithRouterProps, State> {
columnName={headerKeys[0]}
sortKey={headerKeys[0]}
sort={sortKey === headerKeys[0] ? sortDirection : Sort.None}
width="50%"
width="62%"
onClick={this.handleClickColumn}
/>
<IndexList.HeaderCell
columnName={headerKeys[1]}
sortKey={headerKeys[1]}
sort={sortKey === headerKeys[1] ? sortDirection : Sort.None}
width="10%"
width="17%"
onClick={this.handleClickColumn}
/>
<IndexList.HeaderCell
columnName={headerKeys[2]}
sortKey={headerKeys[2]}
sort={sortKey === headerKeys[2] ? sortDirection : Sort.None}
width="30%"
width="11%"
onClick={this.handleClickColumn}
/>
<IndexList.HeaderCell
columnName=""
width="20%"
width="10%"
alignment={Alignment.Right}
/>
</IndexList.Header>
@ -102,6 +103,7 @@ class DashboardsTable extends PureComponent<Props & WithRouterProps, State> {
onExportDashboard,
onCloneDashboard,
onDeleteDashboard,
onUpdateDashboard,
} = this.props
const {sortKey, sortDirection} = this.state
@ -119,6 +121,7 @@ class DashboardsTable extends PureComponent<Props & WithRouterProps, State> {
onCloneDashboard={onCloneDashboard}
onExportDashboard={onExportDashboard}
onDeleteDashboard={onDeleteDashboard}
onUpdateDashboard={onUpdateDashboard}
/>
)}
</SortingHat>

View File

@ -15,6 +15,7 @@ const setup = (override = {}) => {
onDeleteDashboard: jest.fn(),
onCloneDashboard: jest.fn(),
onExportDashboard: jest.fn(),
onUpdateDashboard: jest.fn(),
...override,
}

View File

@ -13,6 +13,7 @@ import {
Stack,
Label,
} from 'src/clockface'
import EditableDescription from 'src/shared/components/editable_description/EditableDescription'
// Types
import {Dashboard} from 'src/types/v2'
@ -30,6 +31,7 @@ interface Props {
onDeleteDashboard: (dashboard: Dashboard) => void
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
}
export default class DashboardsIndexTableRow extends PureComponent<Props> {
@ -40,7 +42,11 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
return (
<IndexList.Row key={`dashboard-id--${id}`} disabled={false}>
<IndexList.Cell>
<ComponentSpacer stackChildren={Stack.Rows} align={Alignment.Left}>
<ComponentSpacer
stackChildren={Stack.Rows}
align={Alignment.Left}
stretchToFitWidth={true}
>
<ComponentSpacer
stackChildren={Stack.Columns}
align={Alignment.Left}
@ -50,21 +56,17 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
</Link>
{this.labels}
</ComponentSpacer>
{this.description}
<EditableDescription
description={dashboard.description}
placeholder={`Describe ${dashboard.name}`}
onUpdate={this.handleUpdateDescription}
/>
</ComponentSpacer>
</IndexList.Cell>
<IndexList.Cell>Owner does not come back from API</IndexList.Cell>
<IndexList.Cell>
{moment(dashboard.meta.updatedAt).format(UPDATED_AT_TIME_FORMAT)}
</IndexList.Cell>
{this.lastModifiedCell}
<IndexList.Cell alignment={Alignment.Right} revealOnHover={true}>
<ComponentSpacer align={Alignment.Left} stackChildren={Stack.Columns}>
<Button
size={ComponentSize.ExtraSmall}
text="Export"
icon={IconFont.Export}
onClick={this.handleExport}
/>
<Button
size={ComponentSize.ExtraSmall}
text="Clone"
@ -106,6 +108,21 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
)
}
private get lastModifiedCell(): JSX.Element {
const {dashboard} = this.props
const relativeTimestamp = moment(dashboard.meta.updatedAt).fromNow()
const absoluteTimestamp = moment(dashboard.meta.updatedAt).format(
UPDATED_AT_TIME_FORMAT
)
return (
<IndexList.Cell>
<span title={absoluteTimestamp}>{relativeTimestamp}</span>
</IndexList.Cell>
)
}
private get name(): string {
const {dashboard} = this.props
@ -120,23 +137,11 @@ export default class DashboardsIndexTableRow extends PureComponent<Props> {
}
}
private get description(): JSX.Element {
const {dashboard} = this.props
private handleUpdateDescription = (description: string): void => {
const {onUpdateDashboard} = this.props
const dashboard = {...this.props.dashboard, description}
if (dashboard.description) {
return (
<div className="index-list--description">{dashboard.description}</div>
)
}
return (
<div className="index-list--description untitled">No description</div>
)
}
private handleExport = () => {
const {onExportDashboard, dashboard} = this.props
onExportDashboard(dashboard)
onUpdateDashboard(dashboard)
}
private handleClone = () => {

View File

@ -12,6 +12,7 @@ interface Props {
onDeleteDashboard: (dashboard: Dashboard) => void
onCloneDashboard: (dashboard: Dashboard) => void
onExportDashboard: (dashboard: Dashboard) => void
onUpdateDashboard: (dashboard: Dashboard) => void
}
export default class DashboardsIndexTableRows extends PureComponent<Props> {
@ -21,6 +22,7 @@ export default class DashboardsIndexTableRows extends PureComponent<Props> {
onExportDashboard,
onCloneDashboard,
onDeleteDashboard,
onUpdateDashboard,
} = this.props
return dashboards.map(d => (
@ -30,6 +32,7 @@ export default class DashboardsIndexTableRows extends PureComponent<Props> {
onExportDashboard={onExportDashboard}
onCloneDashboard={onCloneDashboard}
onDeleteDashboard={onDeleteDashboard}
onUpdateDashboard={onUpdateDashboard}
/>
))
}

View File

@ -159,19 +159,29 @@ export default class Buckets extends PureComponent<Props, State> {
}
private get emptyState(): JSX.Element {
const {org} = this.props
const {searchTerm} = this.state
if (_.isEmpty(searchTerm)) {
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text text="Oh noes I dun see na buckets" />
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text
text={`${org.name} does not own any Buckets , why not create one?`}
highlightWords={['Buckets']}
/>
<Button
text="Create Bucket"
icon={IconFont.Plus}
color={ComponentColor.Primary}
onClick={this.handleOpenModal}
/>
</EmptyState>
)
}
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text text="No buckets match your query" />
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text text="No Buckets match your query" />
</EmptyState>
)
}

View File

@ -1,5 +1,6 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import _ from 'lodash'
// Components
import TabbedPageHeader from 'src/shared/components/tabbed_page/TabbedPageHeader'
@ -15,6 +16,7 @@ import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
dashboards: Dashboard[]
orgName: string
}
interface State {
@ -66,9 +68,23 @@ export default class Dashboards extends PureComponent<Props, State> {
}
private get emptyState(): JSX.Element {
const {orgName} = this.props
const {searchTerm} = this.state
if (_.isEmpty(searchTerm)) {
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text
text={`${orgName} does not own any Dashboards , why not create one?`}
highlightWords={['Dashboards']}
/>
</EmptyState>
)
}
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text text="Oh noes I dun see na dashbardsss" />
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text text="No Dashboards match your query" />
</EmptyState>
)
}

View File

@ -26,7 +26,7 @@ import {
HEX_CODE_CHAR_LENGTH,
INPUT_ERROR_COLOR,
} from 'src/organizations/constants/LabelColors'
const MAX_LABEL_CHARS = 75
const MAX_LABEL_CHARS = 50
// Utils
import {validateHexCode} from 'src/organizations/utils/labels'

View File

@ -1,5 +1,6 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import _ from 'lodash'
// Components
import {ComponentSize, EmptyState, IconFont, Input} from 'src/clockface'
@ -15,6 +16,7 @@ import TabbedPageHeader from 'src/shared/components/tabbed_page/TabbedPageHeader
interface Props {
members: ResourceOwner[]
orgName: string
}
interface State {
@ -60,9 +62,23 @@ export default class Members extends PureComponent<Props, State> {
}
private get emptyState(): JSX.Element {
const {orgName} = this.props
const {searchTerm} = this.state
if (_.isEmpty(searchTerm)) {
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text
text={`${orgName} doesn't have any Members , why not invite some?`}
highlightWords={['Members']}
/>
</EmptyState>
)
}
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text text="This org has been abandoned" />
<EmptyState.Text text="No Members match your query" />
</EmptyState>
)
}

View File

@ -1,5 +1,6 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import _ from 'lodash'
// Components
import TabbedPageHeader from 'src/shared/components/tabbed_page/TabbedPageHeader'
@ -12,6 +13,7 @@ import {Task} from 'src/api'
interface Props {
tasks: Task[]
orgName: string
}
interface State {
@ -62,9 +64,23 @@ export default class Tasks extends PureComponent<Props, State> {
}
private get emptyState(): JSX.Element {
const {orgName} = this.props
const {searchTerm} = this.state
if (_.isEmpty(searchTerm)) {
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text
text={`${orgName} does not own any Tasks , why not create one?`}
highlightWords={'Tasks'}
/>
</EmptyState>
)
}
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text text="I see nay a task" />
<EmptyState.Text text="No Tasks match your query" />
</EmptyState>
)
}

View File

@ -75,7 +75,7 @@ class OrganizationView extends PureComponent<Props> {
>
{(members, loading) => (
<Spinner loading={loading}>
<Members members={members} />
<Members members={members} orgName={org.name} />
</Spinner>
)}
</GetOrgResources>
@ -107,7 +107,7 @@ class OrganizationView extends PureComponent<Props> {
>
{(dashboards, loading) => (
<Spinner loading={loading}>
<Dashboards dashboards={dashboards} />
<Dashboards dashboards={dashboards} orgName={org.name} />
</Spinner>
)}
</GetOrgResources>
@ -120,7 +120,7 @@ class OrganizationView extends PureComponent<Props> {
<GetOrgResources<Task[]> organization={org} fetcher={getTasks}>
{(tasks, loading) => (
<Spinner loading={loading}>
<Tasks tasks={tasks} />
<Tasks tasks={tasks} orgName={org.name} />
</Spinner>
)}
</GetOrgResources>

View File

@ -0,0 +1,83 @@
/*
Editable Description Component Styles
------------------------------------------------------------------------------
*/
@import 'src/style/modules';
$rename-dash-title-padding: 5px;
.editable-description {
height: $form-xs-height;
width: 100%;
}
.editable-description--preview,
.input.editable-description--input > input {
font-size: $form-xs-font;
font-weight: 400;
font-family: $ix-text-font;
padding: 0 $rename-dash-title-padding;
}
.editable-description--preview,
.editable-description--input {
position: relative;
width: 100%;
}
.editable-description--preview {
border-radius: $radius;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@include no-user-select();
color: $g13-mist;
transition: color 0.25s ease, background-color 0.25s ease, border-color 0.25s ease;
border: $ix-border solid transparent;
height: $form-xs-height;
line-height: $form-xs-height - $ix-border;
.icon {
position: relative;
top: -2px;
display: inline-block;
margin-left: $ix-marg-b;
opacity: 0;
transition: opacity 0.25s ease;
color: $g11-sidewalk;
}
&:hover .icon {
opacity: 1;
}
&.untitled {
color: $g9-mountain;
font-style: italic;
}
&:hover {
cursor: text;
color: $g20-white;
// background-color: $g3-castle;
// border-color: $g3-castle;
}
}
/* Ensure placeholder text matches font weight of title */
.input.editable-description--input > input {
&::-webkit-input-placeholder {
font-weight: $page-title-weight !important;
}
&::-moz-placeholder {
font-weight: $page-title-weight !important;
}
&:-ms-input-placeholder {
font-weight: $page-title-weight !important;
}
&:-moz-placeholder {
font-weight: $page-title-weight !important;
}
}

View File

@ -0,0 +1,130 @@
// Libraries
import React, {Component, KeyboardEvent, ChangeEvent} from 'react'
import classnames from 'classnames'
// Components
import {Input, ComponentSize} from 'src/clockface'
import {ClickOutside} from 'src/shared/components/ClickOutside'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
// Styles
import 'src/shared/components/editable_description/EditableDescription.scss'
interface Props {
onUpdate: (name: string) => void
description: string
placeholder?: string
}
interface State {
isEditing: boolean
workingDescription: string
}
@ErrorHandling
class EditableDescription extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = {
isEditing: false,
workingDescription: props.description,
}
}
public render() {
const {description} = this.props
const {isEditing} = this.state
if (isEditing) {
return (
<div className="editable-description">
<ClickOutside onClickOutside={this.handleStopEditing}>
{this.input}
</ClickOutside>
</div>
)
}
return (
<div className="editable-description">
<div
className={this.previewClassName}
onClick={this.handleStartEditing}
>
{description || 'No description'}
<span className="icon pencil" />
</div>
</div>
)
}
private get input(): JSX.Element {
const {placeholder} = this.props
const {workingDescription} = this.state
return (
<Input
size={ComponentSize.ExtraSmall}
maxLength={90}
autoFocus={true}
spellCheck={false}
placeholder={placeholder}
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onKeyDown={this.handleKeyDown}
customClass="editable-description--input"
value={workingDescription}
/>
)
}
private handleStartEditing = (): void => {
this.setState({isEditing: true})
}
private handleStopEditing = async (): Promise<void> => {
const {workingDescription} = this.state
const {onUpdate} = this.props
await onUpdate(workingDescription)
this.setState({isEditing: false})
}
private handleInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({workingDescription: e.target.value})
}
private handleKeyDown = async (
e: KeyboardEvent<HTMLInputElement>
): Promise<void> => {
const {onUpdate, description} = this.props
const {workingDescription} = this.state
if (e.key === 'Enter') {
await onUpdate(workingDescription)
this.setState({isEditing: false})
}
if (e.key === 'Escape') {
this.setState({isEditing: false, workingDescription: description})
}
}
private handleInputFocus = (e: ChangeEvent<HTMLInputElement>): void => {
e.currentTarget.select()
}
private get previewClassName(): string {
const {description} = this.props
return classnames('editable-description--preview', {
untitled: description === '',
})
}
}
export default EditableDescription

View File

@ -22,7 +22,7 @@ export default class TaskScheduleFormFields extends PureComponent<Props> {
return (
<>
<ComponentSpacer align={Alignment.Left} stretchToFit={true}>
<ComponentSpacer align={Alignment.Left} stretchToFitWidth={true}>
<label className="task-form--form-label">
{schedule === TaskSchedule.interval ? 'Interval' : 'Cron'}
</label>
@ -37,7 +37,7 @@ export default class TaskScheduleFormFields extends PureComponent<Props> {
/>
</ComponentSpacer>
<ComponentSpacer align={Alignment.Left} stretchToFit={true}>
<ComponentSpacer align={Alignment.Left} stretchToFitWidth={true}>
<label className="task-form--form-label">Offset</label>
<Input
name="offset"