1
- import type { IconProp } from '@fortawesome/fontawesome-svg-core' ;
2
1
import { faAndroid , faApple } from '@fortawesome/free-brands-svg-icons' ;
3
2
import { faDesktop } from '@fortawesome/free-solid-svg-icons' ;
4
3
import { Checkbox , SimpleCard } from '@shlinkio/shlink-frontend-kit' ;
5
4
import { clsx } from 'clsx' ;
6
5
import { parseISO } from 'date-fns' ;
7
- import type { ChangeEvent , FC } from 'react' ;
8
- import { useEffect , useState } from 'react' ;
9
- import { Button , FormGroup , Input , Row } from 'reactstrap' ;
10
- import type { InputType } from 'reactstrap/types/lib/Input' ;
6
+ import type { FC , FormEvent } from 'react' ;
7
+ import { useCallback , useMemo , useState } from 'react' ;
8
+ import { Button , Input , Row } from 'reactstrap' ;
11
9
import type { ShlinkCreateShortUrlData , ShlinkDeviceLongUrls , ShlinkEditShortUrlData } from '../api-contract' ;
12
10
import type { FCWithDeps } from '../container/utils' ;
13
11
import { componentFactory , useDependencies } from '../container/utils' ;
@@ -18,18 +16,13 @@ import { IconInput } from '../utils/components/IconInput';
18
16
import { formatIsoDate } from '../utils/dates/helpers/date' ;
19
17
import { LabelledDateInput } from '../utils/dates/LabelledDateInput' ;
20
18
import { useFeature } from '../utils/features' ;
21
- import { handleEventPreventingDefault , hasValue } from '../utils/helpers' ;
19
+ import { hasValue } from '../utils/helpers' ;
22
20
import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup' ;
23
21
import { UseExistingIfFoundInfoIcon } from './UseExistingIfFoundInfoIcon' ;
24
22
import './ShortUrlForm.scss' ;
25
23
26
- export type Mode = 'create' | 'create-basic' | 'edit' ;
27
-
28
- type NonDateFields = 'longUrl' | 'customSlug' | 'shortCodeLength' | 'domain' | 'maxVisits' | 'title' ;
29
-
30
24
export interface ShortUrlFormProps < T extends ShlinkCreateShortUrlData | ShlinkEditShortUrlData > {
31
- // FIXME Try to get rid of the mode param, and infer creation or edition from initialState if possible
32
- mode : Mode ;
25
+ basicMode ?: boolean ;
33
26
saving : boolean ;
34
27
initialState : T ;
35
28
onSave : ( shortUrlData : T ) => Promise < unknown > ;
@@ -52,98 +45,74 @@ const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData)
52
45
const isErrorAction = ( action : any ) : boolean => 'error' in action ;
53
46
54
47
const ShortUrlForm : FCWithDeps < ShortUrlFormConnectProps , ShortUrlFormDeps > = (
55
- { mode , saving, onSave, initialState, tagsList } ,
48
+ { basicMode = false , saving, onSave, initialState, tagsList } ,
56
49
) => {
57
50
const { TagsSelector, DomainSelector } = useDependencies ( ShortUrlForm as unknown as ShortUrlFormDeps ) ;
58
51
const [ shortUrlData , setShortUrlData ] = useState ( initialState ) ;
59
- const reset = ( ) => setShortUrlData ( initialState ) ;
52
+ const isCreation = isCreationData ( shortUrlData ) ;
60
53
const supportsDeviceLongUrls = useFeature ( 'deviceLongUrls' ) ;
61
54
62
- const isEdit = mode === 'edit' ;
63
- const isCreation = isCreationData ( shortUrlData ) ;
64
- const isBasicMode = mode === 'create-basic' ;
65
- const changeTags = ( tags : string [ ] ) => setShortUrlData ( ( prev ) => ( { ...prev , tags } ) ) ;
66
- const setResettableValue = ( value : string , initialValue ?: any ) => {
55
+ const reset = useCallback ( ( ) => setShortUrlData ( initialState ) , [ initialState ] ) ;
56
+ const setResettableValue = useCallback ( ( value : string , initialValue ?: any ) => {
67
57
if ( hasValue ( value ) ) {
68
58
return value ;
69
59
}
70
60
71
61
// If an initial value was provided for this when the input is "emptied", explicitly set it to null so that the
72
62
// value gets removed. Otherwise, set undefined so that it gets ignored.
73
63
return hasValue ( initialValue ) ? null : undefined ;
74
- } ;
75
- const submit = handleEventPreventingDefault ( async ( ) => onSave ( shortUrlData )
76
- . then ( ( result : any ) => ! isEdit && ! isErrorAction ( result ) && reset ( ) )
77
- . catch ( ( ) => { } ) ) ;
64
+ } , [ ] ) ;
65
+ const changeDeviceLongUrl = useCallback (
66
+ ( id : keyof ShlinkDeviceLongUrls , url : string ) => setShortUrlData ( ( { deviceLongUrls = { } , ...prev } ) => ( {
67
+ ...prev ,
68
+ deviceLongUrls : {
69
+ ...deviceLongUrls ,
70
+ [ id ] : setResettableValue ( url , initialState . deviceLongUrls ?. [ id ] ) ,
71
+ } ,
72
+ } ) ) ,
73
+ [ initialState . deviceLongUrls , setResettableValue ] ,
74
+ ) ;
75
+ const changeTags = useCallback ( ( tags : string [ ] ) => setShortUrlData ( ( prev ) => ( { ...prev , tags } ) ) , [ ] ) ;
78
76
79
- useEffect ( ( ) => {
80
- setShortUrlData ( initialState ) ;
81
- } , [ initialState ] ) ;
77
+ const submit = useCallback ( async ( e : FormEvent ) => {
78
+ e . preventDefault ( ) ;
79
+ return onSave ( shortUrlData )
80
+ . then ( ( result : any ) => isCreation && ! isErrorAction ( result ) && reset ( ) )
81
+ . catch ( ( ) => { } ) ;
82
+ } , [ isCreation , onSave , reset , shortUrlData ] ) ;
82
83
83
- // TODO Consider extracting these functions to local components
84
- const renderOptionalInput = (
85
- id : NonDateFields ,
86
- placeholder : string ,
87
- type : InputType = 'text' ,
88
- props : any = { } ,
89
- fromGroupProps = { } ,
90
- ) => (
91
- < FormGroup { ...fromGroupProps } >
84
+ const basicComponents = useMemo ( ( ) => (
85
+ < div className = "d-flex flex-column gap-3" >
92
86
< Input
93
- name = { id }
94
- id = { id }
95
- type = { type }
96
- placeholder = { placeholder }
97
- // @ts -expect-error FIXME Make sure id is a key from T
98
- value = { shortUrlData [ id ] ?? '' }
99
- onChange = { props . onChange ?? ( ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , [ id ] : e . target . value } ) ) ) }
100
- { ...props }
87
+ bsSize = "lg"
88
+ type = "url"
89
+ placeholder = "URL to be shortened"
90
+ required
91
+ value = { shortUrlData . longUrl }
92
+ onChange = { ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , longUrl : e . target . value } ) ) }
101
93
/>
102
- </ FormGroup >
103
- ) ;
104
- const renderDeviceLongUrlInput = ( id : keyof ShlinkDeviceLongUrls , placeholder : string , icon : IconProp ) => (
105
- < IconInput
106
- icon = { icon }
107
- name = { id }
108
- id = { id }
109
- type = "url"
110
- placeholder = { placeholder }
111
- value = { shortUrlData . deviceLongUrls ?. [ id ] ?? '' }
112
- onChange = { ( e ) => setShortUrlData ( ( prev ) => ( {
113
- ...prev ,
114
- deviceLongUrls : {
115
- ...( prev . deviceLongUrls ?? { } ) ,
116
- [ id ] : setResettableValue ( e . target . value , initialState . deviceLongUrls ?. [ id ] ) ,
117
- } ,
118
- } ) ) }
119
- />
120
- ) ;
121
- const basicComponents = (
122
- < >
123
- < FormGroup >
124
- < Input
125
- name = "longUrl"
126
- bsSize = "lg"
127
- type = "url"
128
- placeholder = "URL to be shortened"
129
- required
130
- value = { shortUrlData . longUrl }
131
- onChange = { ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , longUrl : e . target . value } ) ) }
132
- />
133
- </ FormGroup >
134
94
< Row >
135
- { isBasicMode && renderOptionalInput ( 'customSlug' , 'Custom slug' , 'text' , { bsSize : 'lg' } , { className : 'col-lg-6' } ) }
136
- < div className = { isBasicMode ? 'col-lg-6 mb-3' : 'col-12' } >
95
+ { basicMode && isCreation && (
96
+ < div className = "col-lg-6 mb-3" >
97
+ < Input
98
+ bsSize = "lg"
99
+ placeholder = "Custom slug"
100
+ value = { shortUrlData . customSlug ?? '' }
101
+ onChange = { ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , customSlug : e . target . value } ) ) }
102
+ />
103
+ </ div >
104
+ ) }
105
+ < div className = { basicMode ? 'col-lg-6 mb-3' : 'col-12' } >
137
106
< TagsSelector tags = { tagsList . tags } selectedTags = { shortUrlData . tags ?? [ ] } onChange = { changeTags } />
138
107
</ div >
139
108
</ Row >
140
- </ >
141
- ) ;
109
+ </ div >
110
+ ) , [ TagsSelector , basicMode , changeTags , isCreation , shortUrlData , tagsList . tags ] ) ;
142
111
143
112
return (
144
113
< form name = "shortUrlForm" className = "short-url-form" onSubmit = { submit } >
145
- { isBasicMode && basicComponents }
146
- { ! isBasicMode && (
114
+ { basicMode && basicComponents }
115
+ { ! basicMode && (
147
116
< >
148
117
< Row >
149
118
< div className = { clsx ( 'mb-3' , { 'col-sm-6' : supportsDeviceLongUrls , 'col-12' : ! supportsDeviceLongUrls } ) } >
@@ -153,46 +122,69 @@ const ShortUrlForm: FCWithDeps<ShortUrlFormConnectProps, ShortUrlFormDeps> = (
153
122
</ div >
154
123
{ supportsDeviceLongUrls && (
155
124
< div className = "col-sm-6 mb-3" >
156
- < SimpleCard title = "Device-specific long URLs" >
157
- < FormGroup >
158
- { renderDeviceLongUrlInput ( 'android' , 'Android-specific redirection' , faAndroid ) }
159
- </ FormGroup >
160
- < FormGroup >
161
- { renderDeviceLongUrlInput ( 'ios' , 'iOS-specific redirection' , faApple ) }
162
- </ FormGroup >
163
- { renderDeviceLongUrlInput ( 'desktop' , 'Desktop-specific redirection' , faDesktop ) }
125
+ < SimpleCard title = "Device-specific long URLs" bodyClassName = "d-flex flex-column gap-3" >
126
+ < IconInput
127
+ type = "url"
128
+ icon = { faAndroid }
129
+ placeholder = "Android-specific redirection"
130
+ value = { shortUrlData . deviceLongUrls ?. android ?? '' }
131
+ onChange = { ( { target } ) => changeDeviceLongUrl ( 'android' , target . value ) }
132
+ />
133
+ < IconInput
134
+ type = "url"
135
+ icon = { faApple }
136
+ placeholder = "iOS-specific redirection"
137
+ value = { shortUrlData . deviceLongUrls ?. ios ?? '' }
138
+ onChange = { ( { target } ) => changeDeviceLongUrl ( 'ios' , target . value ) }
139
+ />
140
+ < IconInput
141
+ type = "url"
142
+ icon = { faDesktop }
143
+ placeholder = "Desktop-specific redirection"
144
+ value = { shortUrlData . deviceLongUrls ?. desktop ?? '' }
145
+ onChange = { ( { target } ) => changeDeviceLongUrl ( 'desktop' , target . value ) }
146
+ />
164
147
</ SimpleCard >
165
148
</ div >
166
149
) }
167
150
</ Row >
168
151
169
152
< Row >
170
153
< div className = "col-sm-6 mb-3" >
171
- < SimpleCard title = "Customize the short URL" >
172
- { renderOptionalInput ( 'title' , 'Title' , 'text' , {
173
- onChange : ( { target } : ChangeEvent < HTMLInputElement > ) => setShortUrlData ( ( prev ) => ( {
154
+ < SimpleCard title = "Customize the short URL" bodyClassName = "d-flex flex-column gap-3" >
155
+ < Input
156
+ placeholder = "Title"
157
+ value = { shortUrlData . title ?? '' }
158
+ onChange = { ( { target } ) => setShortUrlData ( ( prev ) => ( {
174
159
...prev ,
175
160
title : setResettableValue ( target . value , initialState . title ) ,
176
- } ) ) ,
177
- } ) }
178
- { ! isEdit && isCreation && (
161
+ } ) ) }
162
+ />
163
+ { isCreation && (
179
164
< >
180
165
< Row >
181
- < div className = "col-lg-6" >
182
- { renderOptionalInput ( 'customSlug' , 'Custom slug' , 'text' , {
183
- disabled : hasValue ( shortUrlData . shortCodeLength ) ,
184
- } ) }
166
+ < div className = "col-lg-6 mb-3 mb-lg-0" >
167
+ < Input
168
+ placeholder = "Custom slug"
169
+ value = { shortUrlData . customSlug ?? '' }
170
+ onChange = { ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , customSlug : e . target . value } ) ) }
171
+ disabled = { hasValue ( shortUrlData . shortCodeLength ) }
172
+ />
185
173
</ div >
186
174
< div className = "col-lg-6" >
187
- { renderOptionalInput ( 'shortCodeLength' , 'Short code length' , 'number' , {
188
- min : 4 ,
189
- disabled : hasValue ( shortUrlData . customSlug ) ,
190
- } ) }
175
+ < Input
176
+ type = "number"
177
+ placeholder = "Short code length"
178
+ value = { shortUrlData . shortCodeLength ?? '' }
179
+ onChange = { ( e ) => setShortUrlData ( ( prev ) => ( { ...prev , shortCodeLength : e . target . value } ) ) }
180
+ min = { 4 }
181
+ disabled = { hasValue ( shortUrlData . customSlug ) }
182
+ />
191
183
</ div >
192
184
</ Row >
193
185
< DomainSelector
194
186
value = { shortUrlData . domain }
195
- onChange = { ( domain ?: string ) => setShortUrlData ( ( prev ) => ( { ...prev , domain } ) ) }
187
+ onChange = { ( domain ) => setShortUrlData ( ( prev ) => ( { ...prev , domain } ) ) }
196
188
/>
197
189
</ >
198
190
) }
@@ -207,7 +199,6 @@ const ShortUrlForm: FCWithDeps<ShortUrlFormConnectProps, ShortUrlFormDeps> = (
207
199
label = "Enabled since"
208
200
withTime
209
201
maxDate = { shortUrlData . validUntil ? toDate ( shortUrlData . validUntil ) : undefined }
210
- name = "validSince"
211
202
value = { shortUrlData . validSince ? toDate ( shortUrlData . validSince ) : null }
212
203
onChange = { ( date ) => setShortUrlData ( ( prev ) => ( { ...prev , validSince : formatIsoDate ( date ) } ) ) }
213
204
/>
@@ -217,7 +208,6 @@ const ShortUrlForm: FCWithDeps<ShortUrlFormConnectProps, ShortUrlFormDeps> = (
217
208
label = "Enabled until"
218
209
withTime
219
210
minDate = { shortUrlData . validSince ? toDate ( shortUrlData . validSince ) : undefined }
220
- name = "validUntil"
221
211
value = { shortUrlData . validUntil ? toDate ( shortUrlData . validUntil ) : null }
222
212
onChange = { ( date ) => setShortUrlData ( ( prev ) => ( { ...prev , validUntil : formatIsoDate ( date ) } ) ) }
223
213
/>
@@ -227,7 +217,6 @@ const ShortUrlForm: FCWithDeps<ShortUrlFormConnectProps, ShortUrlFormDeps> = (
227
217
< div >
228
218
< label htmlFor = "maxVisits" className = "mb-1" > Maximum visits allowed:</ label >
229
219
< Input
230
- name = "maxVisits"
231
220
id = "maxVisits"
232
221
type = "number"
233
222
min = { 1 }
@@ -253,7 +242,7 @@ const ShortUrlForm: FCWithDeps<ShortUrlFormConnectProps, ShortUrlFormDeps> = (
253
242
>
254
243
Validate URL
255
244
</ ShortUrlFormCheckboxGroup >
256
- { ! isEdit && isCreation && (
245
+ { isCreation && (
257
246
< p >
258
247
< Checkbox
259
248
inline
0 commit comments