Skip to content

Commit

Permalink
feat(RenameDataset): add modal to rename dataset
Browse files Browse the repository at this point in the history
Since we already had a pretty slick way to rename a dataset coded up in `DatasetReference` , I decided to just generalize/add functionality that would allow us to use the `DatasetReferenceComponent` as a part of the `RenameModal`. This involved adding optional functions that could inject additional functionality at key points: when the dataset was marked valid/invalid, when the user made any change to the text. The `onSubmit` function became optional, if you add an `onSubmit` function it will submit when the user hits `enter`, otherwise, you control the submitting outside of the component, which is exactly what we do now in the `RenameDataset` modal: we use the `onChange` function to keep track of what the user is typing, and the `isValid` component to keep track of any errors, displaying them to the user.

We've also added the `RenameDataset` modal to storybook.
  • Loading branch information
ramfox committed Oct 9, 2020
1 parent 4eb29ae commit ec7aa3c
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 82 deletions.
80 changes: 60 additions & 20 deletions app/components/DatasetReference.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,89 @@
// a component that displays the dataset reference including edit-in-place UI
// for dataset rename
import * as React from 'react'
import { AnyAction } from 'redux'
import classNames from 'classnames'

import { ApiActionThunk } from '../store/api'
import { QriRef } from '../models/qriRef'

import { connectComponentToPropsWithRouter } from '../utils/connectComponentToProps'
import { validateDatasetName } from '../utils/formValidation'
import { validateDatasetName, ValidationError } from '../utils/formValidation'

import { renameDataset } from '../actions/api'

interface DatasetReferenceProps {
/**
* the qriRef is particularly important in this component: it's where we get
* the username and original dataset name
*/
qriRef: QriRef
renameDataset: (username: string, name: string, newName: string) => ApiActionThunk
/**
* the optional isValid function allows us to inject additional actions when
* the dataset rename has been validated or invalidated
*/
isValid?: (err: ValidationError) => void
/**
* if used as a container, the DatasetReference will get the `renameDataset`
* api action as it's submit function. The `DatasetReferenceComponent` may
* or may not use the onSubmit action
*/
onSubmit?: (username: string, name: string, newName: string) => Promise<AnyAction>
/**
* the optional onChange function allows us to inject additional actions when
* any input has changed
*/
onChange?: (newName: string) => void
/**
* if focusOnFirstRender is true, the dataset name input will be focused when the
* component is first rendered
*/
focusOnFirstRender?: boolean
}

const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps> = (props) => {
const { qriRef, renameDataset } = props
export const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps> = (props) => {
const {
qriRef,
onSubmit,
onChange,
isValid,
focusOnFirstRender = false
} = props

const { username, name } = qriRef
const [ nameEditing, setNameEditing ] = React.useState(false)
const [ nameEditing, setNameEditing ] = React.useState(focusOnFirstRender)
const [ newName, setNewName ] = React.useState(name)
const [ inValid, setInvalid ] = React.useState(null)
const [ invalidErr, setInvalidErr ] = React.useState<ValidationError | null>(null)

const datasetSelected = username !== '' && name !== ''

// use a ref so we can set up a click handler
const nameRef: any = React.useRef(null)

const confirmRename = (username: string, name: string, newName: string) => {
// cancel if no change, change invalid, or empty
if ((name === newName) || inValid || newName === '') {
if ((name === newName) || invalidErr || newName === '') {
cancelRename()
} else {
renameDataset(username, name, newName)
.then(() => {
setNameEditing(false)
})
if (onSubmit) {
onSubmit(username, name, newName)
.then(() => {
setNameEditing(false)
})
return
}
setNameEditing(false)
}
}

const cancelRename = () => {
setNewName(name)
setInvalid(null)
onChange && onChange(name)
setInvalidErr(null)
isValid && isValid(null)
setNameEditing(false)
}

const handleKeyDown = (e: any) => {
const handleKeyDown = (e: KeyboardEvent) => {
// cancel on esc
if (e.keyCode === 27) {
cancelRename()
Expand All @@ -55,9 +95,6 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
}
}

// use a ref so we can set up a click handler
const nameRef: any = React.useRef(null)

const handleMousedown = (e: MouseEvent) => {
const { target } = e
// allows the user to resize the sidebar when editing the dataset name
Expand Down Expand Up @@ -85,8 +122,11 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>

const handleInputChange = (e: any) => {
let { value } = e.target
setInvalid(validateDatasetName(value))
const err = validateDatasetName(value)
setInvalidErr(err)
isValid && isValid(err)
setNewName(value)
onChange && onChange(value)
}

// when the input is focused, set the cursor to the left, and scroll all the way to the left
Expand All @@ -101,7 +141,7 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
<div className={classNames('dataset-name', { 'no-pointer': !datasetSelected })} id='dataset-name' ref={nameRef}>
{ nameEditing && datasetSelected && <input
id='dataset-name-input'
className={classNames({ invalid: inValid })}
className={classNames({ invalid: invalidErr })}
type='text'
value={newName}
maxLength={150}
Expand All @@ -119,5 +159,5 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
export default connectComponentToPropsWithRouter<DatasetReferenceProps>(
DatasetReferenceComponent,
{},
{ renameDataset }
{ onSubmit: renameDataset }
)
2 changes: 1 addition & 1 deletion app/components/modals/Error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ interface ErrorProps {
id: string
}
const Error: React.FunctionComponent<ErrorProps> = ({ text, id }) =>
<div id={`${id}_error`} className='error'>{text}</div>
<div id={id} className='error'>{text}</div>

export default Error
3 changes: 3 additions & 0 deletions app/components/modals/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PullDataset from './PullDataset'
import LinkDataset from './LinkDataset'
import RemoveDataset from './RemoveDataset'
import ExportDataset from './ExportDataset'
import RenameDataset from './RenameDataset'
import NewDataset from './NewDataset'
import PublishDataset from './PublishDataset'
import UnpublishDataset from './UnpublishDataset'
Expand All @@ -29,6 +30,8 @@ const Modals: React.FunctionComponent<ModalsProps> = ({ type }) => {
return <RemoveDataset />
case ModalType.ExportDataset:
return <ExportDataset />
case ModalType.RenameDataset:
return <RenameDataset />
case ModalType.Search:
return <SearchModal />
case ModalType.UnpublishDataset:
Expand Down
93 changes: 93 additions & 0 deletions app/components/modals/RenameDataset.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { useState } from 'react'

import { dismissModal } from '../../actions/ui'
import { RenameDatasetModal } from '../../../app/models/modals'
import {
selectModal,
selectDatasetRef
} from '../../selections'
import { ApiAction } from '../../store/api'
import { connectComponentToProps } from '../../utils/connectComponentToProps'

import Modal from './Modal'
import Error from './Error'
import { DatasetReferenceComponent } from '../DatasetReference'
import Buttons from './Buttons'
import { renameDataset } from '../../actions/api'
import { ValidationError } from '../../utils/formValidation'

interface RenameDatasetProps {
modal: RenameDatasetModal
onDismissed: () => void
onSubmit: (username: string, oldName: string, newName: string) => Promise<ApiAction>
}

export const RenameDatasetComponent: React.FC<RenameDatasetProps> = (props: RenameDatasetProps) => {
const { onDismissed, onSubmit, modal } = props

const [dismissable, setDismissable] = useState(true)
const [error, setError] = useState<ValidationError>(null)
const [loading, setLoading] = useState(false)
const [newName, setNewName] = useState(modal.name)

const handleSubmit = () => {
setDismissable(false)
setLoading(true)
error && setError(null)
onSubmit(modal.username, modal.name, newName).then(() => {
onDismissed()
})
}

const handleDatasetReferenceSubmit = (newName: string) => {
setNewName(newName)
}

return (
<Modal
id='rename-dataset'
title='Rename Dataset'
onDismissed={onDismissed}
onSubmit={() => {}}
dismissable={dismissable}
setDismissable={setDismissable}
>
<div className='content-wrap'>
<div className='content'>
<DatasetReferenceComponent
qriRef={{ username: modal.username, name: newName || modal.name }}
onChange={handleDatasetReferenceSubmit}
isValid={setError}
focusOnFirstRender
/>
<div className='margin-top'>
<Error text={error} id='rename-dataset-error' />
</div>
</div>
</div>
<Buttons
cancelText='cancel'
onCancel={onDismissed}
submitText='rename'
onSubmit={handleSubmit}
disabled={modal.name === newName || !!error}
loading={loading}
/>
</Modal>
)
}

export default connectComponentToProps(
RenameDatasetComponent,
(state: any, ownProps: RenameDatasetProps) => {
return {
...ownProps,
modal: selectModal(state),
qriRef: selectDatasetRef(state)
}
},
{
onDismissed: dismissModal,
onSubmit: renameDataset
}
)
8 changes: 8 additions & 0 deletions app/models/modals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export enum ModalType {
LinkDataset,
RemoveDataset,
ExportDataset,
RenameDataset,
PublishDataset,
UnpublishDataset,
Search
Expand All @@ -28,6 +29,12 @@ export interface ExportDatasetModal {
version: VersionInfo
}

export interface RenameDatasetModal {
type: ModalType.RenameDataset
username: string
name: string
}

export interface HideModal {
type: ModalType.NoModal
}
Expand Down Expand Up @@ -69,5 +76,6 @@ export type Modal = PullDatasetModal
| PublishDatasetModal
| RemoveDatasetModal
| ExportDatasetModal
| RenameDatasetModal
| SearchModal
| UnpublishDatasetModal
79 changes: 41 additions & 38 deletions app/scss/_dataset.scss
Original file line number Diff line number Diff line change
Expand Up @@ -233,44 +233,6 @@ $header-font-size: .9rem;
font-size: 1.2rem;
font-weight: 900;
}

.dataset-reference {
font-size: 1.05rem;
display: flex;
margin: 20px 20px 0 20px;
}

.dataset-name {

flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;

input {
background-color: $primary-muted;
border: none;
border-radius: 5px;
color: black;
padding: 0 5px;
outline: none;
width: 100%;
transition: background 0.2s;

&.invalid {
background: #F43535;
}

}

&:hover {
&.no-pointer {
cursor: default;
}
cursor: pointer;
text-decoration: underline;
}
}
}

#list {
Expand Down Expand Up @@ -360,6 +322,47 @@ $header-font-size: .9rem;
}


#dataset-reference {
.dataset-reference {
font-size: 1.05rem;
display: flex;
margin: 20px 20px 0 20px;
}

.dataset-name {

flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;

input {
background-color: $primary-muted;
border: none;
border-radius: 5px;
color: black;
padding: 0 5px;
outline: none;
width: 100%;
transition: background 0.2s;

&.invalid {
background: #F43535;
}

}

&:hover {
&.no-pointer {
cursor: default;
}
cursor: pointer;
text-decoration: underline;
}
}
}


.status-dot {
height: 12px;
width: 12px;
Expand Down
4 changes: 2 additions & 2 deletions app/utils/formValidation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Status } from '../models/store'

type ValidationError = string | null
export type ValidationError = string | null

export const ERR_INVALID_USERNAME_CHARACTERS: ValidationError = 'Usernames may only include lowercase letters, numbers, underscores, and hyphens'
export const ERR_INVALID_USERNAME_LENGTH: ValidationError = 'Username must be 50 characters or fewer'
Expand Down Expand Up @@ -39,7 +39,7 @@ export const validatePassword = (password: string) => {

export const ERR_INVALID_DATASETNAME_START: ValidationError = 'Dataset names must start with a letter'
export const ERR_INVALID_DATASETNAME_CHARACTERS: ValidationError = 'Dataset names may only include lowercase letters, numbers, and underscores'
export const ERR_INVALID_DATASETNAME_LENGTH: ValidationError = 'Username must be 144 characters or fewer'
export const ERR_INVALID_DATASETNAME_LENGTH: ValidationError = 'Dataset names must be 144 characters or fewer'

export const validateDatasetName = (name: string): ValidationError => {
if (name) {
Expand Down
Loading

0 comments on commit ec7aa3c

Please sign in to comment.