Skip to content

Commit ec7aa3c

Browse files
committed
feat(RenameDataset): add modal to rename dataset
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.
1 parent 4eb29ae commit ec7aa3c

File tree

10 files changed

+249
-82
lines changed

10 files changed

+249
-82
lines changed

app/components/DatasetReference.tsx

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,89 @@
11
// a component that displays the dataset reference including edit-in-place UI
22
// for dataset rename
33
import * as React from 'react'
4+
import { AnyAction } from 'redux'
45
import classNames from 'classnames'
56

6-
import { ApiActionThunk } from '../store/api'
77
import { QriRef } from '../models/qriRef'
88

99
import { connectComponentToPropsWithRouter } from '../utils/connectComponentToProps'
10-
import { validateDatasetName } from '../utils/formValidation'
10+
import { validateDatasetName, ValidationError } from '../utils/formValidation'
1111

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

1414
interface DatasetReferenceProps {
15+
/**
16+
* the qriRef is particularly important in this component: it's where we get
17+
* the username and original dataset name
18+
*/
1519
qriRef: QriRef
16-
renameDataset: (username: string, name: string, newName: string) => ApiActionThunk
20+
/**
21+
* the optional isValid function allows us to inject additional actions when
22+
* the dataset rename has been validated or invalidated
23+
*/
24+
isValid?: (err: ValidationError) => void
25+
/**
26+
* if used as a container, the DatasetReference will get the `renameDataset`
27+
* api action as it's submit function. The `DatasetReferenceComponent` may
28+
* or may not use the onSubmit action
29+
*/
30+
onSubmit?: (username: string, name: string, newName: string) => Promise<AnyAction>
31+
/**
32+
* the optional onChange function allows us to inject additional actions when
33+
* any input has changed
34+
*/
35+
onChange?: (newName: string) => void
36+
/**
37+
* if focusOnFirstRender is true, the dataset name input will be focused when the
38+
* component is first rendered
39+
*/
40+
focusOnFirstRender?: boolean
1741
}
1842

19-
const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps> = (props) => {
20-
const { qriRef, renameDataset } = props
43+
export const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps> = (props) => {
44+
const {
45+
qriRef,
46+
onSubmit,
47+
onChange,
48+
isValid,
49+
focusOnFirstRender = false
50+
} = props
51+
2152
const { username, name } = qriRef
22-
const [ nameEditing, setNameEditing ] = React.useState(false)
53+
const [ nameEditing, setNameEditing ] = React.useState(focusOnFirstRender)
2354
const [ newName, setNewName ] = React.useState(name)
24-
const [ inValid, setInvalid ] = React.useState(null)
55+
const [ invalidErr, setInvalidErr ] = React.useState<ValidationError | null>(null)
2556

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

59+
// use a ref so we can set up a click handler
60+
const nameRef: any = React.useRef(null)
61+
2862
const confirmRename = (username: string, name: string, newName: string) => {
2963
// cancel if no change, change invalid, or empty
30-
if ((name === newName) || inValid || newName === '') {
64+
if ((name === newName) || invalidErr || newName === '') {
3165
cancelRename()
3266
} else {
33-
renameDataset(username, name, newName)
34-
.then(() => {
35-
setNameEditing(false)
36-
})
67+
if (onSubmit) {
68+
onSubmit(username, name, newName)
69+
.then(() => {
70+
setNameEditing(false)
71+
})
72+
return
73+
}
74+
setNameEditing(false)
3775
}
3876
}
3977

4078
const cancelRename = () => {
4179
setNewName(name)
42-
setInvalid(null)
80+
onChange && onChange(name)
81+
setInvalidErr(null)
82+
isValid && isValid(null)
4383
setNameEditing(false)
4484
}
4585

46-
const handleKeyDown = (e: any) => {
86+
const handleKeyDown = (e: KeyboardEvent) => {
4787
// cancel on esc
4888
if (e.keyCode === 27) {
4989
cancelRename()
@@ -55,9 +95,6 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
5595
}
5696
}
5797

58-
// use a ref so we can set up a click handler
59-
const nameRef: any = React.useRef(null)
60-
6198
const handleMousedown = (e: MouseEvent) => {
6299
const { target } = e
63100
// allows the user to resize the sidebar when editing the dataset name
@@ -85,8 +122,11 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
85122

86123
const handleInputChange = (e: any) => {
87124
let { value } = e.target
88-
setInvalid(validateDatasetName(value))
125+
const err = validateDatasetName(value)
126+
setInvalidErr(err)
127+
isValid && isValid(err)
89128
setNewName(value)
129+
onChange && onChange(value)
90130
}
91131

92132
// when the input is focused, set the cursor to the left, and scroll all the way to the left
@@ -101,7 +141,7 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
101141
<div className={classNames('dataset-name', { 'no-pointer': !datasetSelected })} id='dataset-name' ref={nameRef}>
102142
{ nameEditing && datasetSelected && <input
103143
id='dataset-name-input'
104-
className={classNames({ invalid: inValid })}
144+
className={classNames({ invalid: invalidErr })}
105145
type='text'
106146
value={newName}
107147
maxLength={150}
@@ -119,5 +159,5 @@ const DatasetReferenceComponent: React.FunctionComponent<DatasetReferenceProps>
119159
export default connectComponentToPropsWithRouter<DatasetReferenceProps>(
120160
DatasetReferenceComponent,
121161
{},
122-
{ renameDataset }
162+
{ onSubmit: renameDataset }
123163
)

app/components/modals/Error.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ interface ErrorProps {
55
id: string
66
}
77
const Error: React.FunctionComponent<ErrorProps> = ({ text, id }) =>
8-
<div id={`${id}_error`} className='error'>{text}</div>
8+
<div id={id} className='error'>{text}</div>
99

1010
export default Error

app/components/modals/Modals.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import PullDataset from './PullDataset'
66
import LinkDataset from './LinkDataset'
77
import RemoveDataset from './RemoveDataset'
88
import ExportDataset from './ExportDataset'
9+
import RenameDataset from './RenameDataset'
910
import NewDataset from './NewDataset'
1011
import PublishDataset from './PublishDataset'
1112
import UnpublishDataset from './UnpublishDataset'
@@ -29,6 +30,8 @@ const Modals: React.FunctionComponent<ModalsProps> = ({ type }) => {
2930
return <RemoveDataset />
3031
case ModalType.ExportDataset:
3132
return <ExportDataset />
33+
case ModalType.RenameDataset:
34+
return <RenameDataset />
3235
case ModalType.Search:
3336
return <SearchModal />
3437
case ModalType.UnpublishDataset:
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { useState } from 'react'
2+
3+
import { dismissModal } from '../../actions/ui'
4+
import { RenameDatasetModal } from '../../../app/models/modals'
5+
import {
6+
selectModal,
7+
selectDatasetRef
8+
} from '../../selections'
9+
import { ApiAction } from '../../store/api'
10+
import { connectComponentToProps } from '../../utils/connectComponentToProps'
11+
12+
import Modal from './Modal'
13+
import Error from './Error'
14+
import { DatasetReferenceComponent } from '../DatasetReference'
15+
import Buttons from './Buttons'
16+
import { renameDataset } from '../../actions/api'
17+
import { ValidationError } from '../../utils/formValidation'
18+
19+
interface RenameDatasetProps {
20+
modal: RenameDatasetModal
21+
onDismissed: () => void
22+
onSubmit: (username: string, oldName: string, newName: string) => Promise<ApiAction>
23+
}
24+
25+
export const RenameDatasetComponent: React.FC<RenameDatasetProps> = (props: RenameDatasetProps) => {
26+
const { onDismissed, onSubmit, modal } = props
27+
28+
const [dismissable, setDismissable] = useState(true)
29+
const [error, setError] = useState<ValidationError>(null)
30+
const [loading, setLoading] = useState(false)
31+
const [newName, setNewName] = useState(modal.name)
32+
33+
const handleSubmit = () => {
34+
setDismissable(false)
35+
setLoading(true)
36+
error && setError(null)
37+
onSubmit(modal.username, modal.name, newName).then(() => {
38+
onDismissed()
39+
})
40+
}
41+
42+
const handleDatasetReferenceSubmit = (newName: string) => {
43+
setNewName(newName)
44+
}
45+
46+
return (
47+
<Modal
48+
id='rename-dataset'
49+
title='Rename Dataset'
50+
onDismissed={onDismissed}
51+
onSubmit={() => {}}
52+
dismissable={dismissable}
53+
setDismissable={setDismissable}
54+
>
55+
<div className='content-wrap'>
56+
<div className='content'>
57+
<DatasetReferenceComponent
58+
qriRef={{ username: modal.username, name: newName || modal.name }}
59+
onChange={handleDatasetReferenceSubmit}
60+
isValid={setError}
61+
focusOnFirstRender
62+
/>
63+
<div className='margin-top'>
64+
<Error text={error} id='rename-dataset-error' />
65+
</div>
66+
</div>
67+
</div>
68+
<Buttons
69+
cancelText='cancel'
70+
onCancel={onDismissed}
71+
submitText='rename'
72+
onSubmit={handleSubmit}
73+
disabled={modal.name === newName || !!error}
74+
loading={loading}
75+
/>
76+
</Modal>
77+
)
78+
}
79+
80+
export default connectComponentToProps(
81+
RenameDatasetComponent,
82+
(state: any, ownProps: RenameDatasetProps) => {
83+
return {
84+
...ownProps,
85+
modal: selectModal(state),
86+
qriRef: selectDatasetRef(state)
87+
}
88+
},
89+
{
90+
onDismissed: dismissModal,
91+
onSubmit: renameDataset
92+
}
93+
)

app/models/modals.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export enum ModalType {
99
LinkDataset,
1010
RemoveDataset,
1111
ExportDataset,
12+
RenameDataset,
1213
PublishDataset,
1314
UnpublishDataset,
1415
Search
@@ -28,6 +29,12 @@ export interface ExportDatasetModal {
2829
version: VersionInfo
2930
}
3031

32+
export interface RenameDatasetModal {
33+
type: ModalType.RenameDataset
34+
username: string
35+
name: string
36+
}
37+
3138
export interface HideModal {
3239
type: ModalType.NoModal
3340
}
@@ -69,5 +76,6 @@ export type Modal = PullDatasetModal
6976
| PublishDatasetModal
7077
| RemoveDatasetModal
7178
| ExportDatasetModal
79+
| RenameDatasetModal
7280
| SearchModal
7381
| UnpublishDatasetModal

app/scss/_dataset.scss

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -233,44 +233,6 @@ $header-font-size: .9rem;
233233
font-size: 1.2rem;
234234
font-weight: 900;
235235
}
236-
237-
.dataset-reference {
238-
font-size: 1.05rem;
239-
display: flex;
240-
margin: 20px 20px 0 20px;
241-
}
242-
243-
.dataset-name {
244-
245-
flex: 1;
246-
white-space: nowrap;
247-
text-overflow: ellipsis;
248-
overflow: hidden;
249-
250-
input {
251-
background-color: $primary-muted;
252-
border: none;
253-
border-radius: 5px;
254-
color: black;
255-
padding: 0 5px;
256-
outline: none;
257-
width: 100%;
258-
transition: background 0.2s;
259-
260-
&.invalid {
261-
background: #F43535;
262-
}
263-
264-
}
265-
266-
&:hover {
267-
&.no-pointer {
268-
cursor: default;
269-
}
270-
cursor: pointer;
271-
text-decoration: underline;
272-
}
273-
}
274236
}
275237

276238
#list {
@@ -360,6 +322,47 @@ $header-font-size: .9rem;
360322
}
361323

362324

325+
#dataset-reference {
326+
.dataset-reference {
327+
font-size: 1.05rem;
328+
display: flex;
329+
margin: 20px 20px 0 20px;
330+
}
331+
332+
.dataset-name {
333+
334+
flex: 1;
335+
white-space: nowrap;
336+
text-overflow: ellipsis;
337+
overflow: hidden;
338+
339+
input {
340+
background-color: $primary-muted;
341+
border: none;
342+
border-radius: 5px;
343+
color: black;
344+
padding: 0 5px;
345+
outline: none;
346+
width: 100%;
347+
transition: background 0.2s;
348+
349+
&.invalid {
350+
background: #F43535;
351+
}
352+
353+
}
354+
355+
&:hover {
356+
&.no-pointer {
357+
cursor: default;
358+
}
359+
cursor: pointer;
360+
text-decoration: underline;
361+
}
362+
}
363+
}
364+
365+
363366
.status-dot {
364367
height: 12px;
365368
width: 12px;

app/utils/formValidation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Status } from '../models/store'
22

3-
type ValidationError = string | null
3+
export type ValidationError = string | null
44

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

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

4444
export const validateDatasetName = (name: string): ValidationError => {
4545
if (name) {

0 commit comments

Comments
 (0)