1
1
import { nanoid } from "nanoid" ;
2
- import { useId } from "react" ;
2
+ import { computed } from "nanostores" ;
3
+ import {
4
+ forwardRef ,
5
+ useId ,
6
+ useMemo ,
7
+ useRef ,
8
+ useState ,
9
+ type ComponentProps ,
10
+ } from "react" ;
3
11
import { useStore } from "@nanostores/react" ;
4
- import { InputField } from "@webstudio-is/design-system" ;
12
+ import { isFeatureEnabled } from "@webstudio-is/feature-flags" ;
13
+ import { GearIcon } from "@webstudio-is/icons" ;
14
+ import {
15
+ EnhancedTooltip ,
16
+ Flex ,
17
+ FloatingPanel ,
18
+ InputField ,
19
+ NestedInputButton ,
20
+ theme ,
21
+ } from "@webstudio-is/design-system" ;
5
22
import { isLiteralExpression , Resource , type Prop } from "@webstudio-is/sdk" ;
6
23
import {
7
24
BindingControl ,
8
25
BindingPopover ,
9
26
type BindingVariant ,
10
27
} from "~/builder/shared/binding-popover" ;
11
- import {
12
- type ControlProps ,
13
- useLocalValue ,
14
- humanizeAttribute ,
15
- VerticalLayout ,
16
- } from "../shared" ;
17
- import { $resources } from "~/shared/nano-states" ;
18
- import { $selectedInstanceResourceScope } from "../resource-panel" ;
28
+ import { $props , $resources } from "~/shared/nano-states" ;
19
29
import { computeExpression } from "~/shared/data-variables" ;
20
30
import { updateWebstudioData } from "~/shared/instance-utils" ;
31
+ import { $selectedInstance } from "~/shared/awareness" ;
32
+ import {
33
+ $selectedInstanceResourceScope ,
34
+ UrlField ,
35
+ MethodField ,
36
+ Headers ,
37
+ parseResource ,
38
+ } from "../resource-panel" ;
39
+ import { type ControlProps , useLocalValue , VerticalLayout } from "../shared" ;
21
40
import { PropertyLabel } from "../property-label" ;
22
41
23
- export const ResourceControl = ( {
24
- meta,
25
- prop,
42
+ // dirty, dirty hack
43
+ const areAllFormErrorsVisible = ( form : null | HTMLFormElement ) => {
44
+ if ( form === null ) {
45
+ return false ;
46
+ }
47
+ // check all errors in form fields are visible
48
+ for ( const element of form . elements ) {
49
+ if (
50
+ element instanceof HTMLInputElement ||
51
+ element instanceof HTMLTextAreaElement
52
+ ) {
53
+ // field is invalid and the error is not visible
54
+ if (
55
+ element . validity . valid === false &&
56
+ // rely on data-color=error convention in webstudio design system
57
+ element . getAttribute ( "data-color" ) !== "error"
58
+ ) {
59
+ return false ;
60
+ }
61
+ }
62
+ }
63
+ return true ;
64
+ } ;
65
+
66
+ const ResourceButton = forwardRef <
67
+ HTMLButtonElement ,
68
+ ComponentProps < typeof NestedInputButton >
69
+ > ( ( props , ref ) => {
70
+ return (
71
+ < EnhancedTooltip content = "Edit Resource" >
72
+ < NestedInputButton { ...props } ref = { ref } aria-label = "Edit Resource" >
73
+ < GearIcon />
74
+ </ NestedInputButton >
75
+ </ EnhancedTooltip >
76
+ ) ;
77
+ } ) ;
78
+ ResourceButton . displayName = "ResourceButton" ;
79
+
80
+ const ResourceForm = ( { resource } : { resource : Resource } ) => {
81
+ const { scope, aliases } = useStore ( $selectedInstanceResourceScope ) ;
82
+ const [ url , setUrl ] = useState ( resource . url ) ;
83
+ const [ method , setMethod ] = useState < Resource [ "method" ] > ( resource . method ) ;
84
+ const [ headers , setHeaders ] = useState < Resource [ "headers" ] > ( resource . headers ) ;
85
+ return (
86
+ < Flex
87
+ direction = "column"
88
+ css = { {
89
+ width : theme . spacing [ 30 ] ,
90
+ overflow : "hidden" ,
91
+ gap : theme . spacing [ 9 ] ,
92
+ p : theme . spacing [ 9 ] ,
93
+ } }
94
+ >
95
+ < UrlField
96
+ scope = { scope }
97
+ aliases = { aliases }
98
+ value = { url }
99
+ onChange = { setUrl }
100
+ onCurlPaste = { ( curl ) => {
101
+ // update all feilds when curl is paste into url field
102
+ setUrl ( JSON . stringify ( curl . url ) ) ;
103
+ setMethod ( curl . method ) ;
104
+ setHeaders (
105
+ curl . headers . map ( ( header ) => ( {
106
+ name : header . name ,
107
+ value : JSON . stringify ( header . value ) ,
108
+ } ) )
109
+ ) ;
110
+ } }
111
+ />
112
+ < MethodField value = { method } onChange = { setMethod } />
113
+ < Headers
114
+ scope = { scope }
115
+ aliases = { aliases }
116
+ headers = { headers }
117
+ onChange = { setHeaders }
118
+ />
119
+ </ Flex >
120
+ ) ;
121
+ } ;
122
+
123
+ const ResourceControlPanel = ( {
124
+ resource,
26
125
propName,
126
+ onChange,
127
+ } : {
128
+ resource : Resource ;
129
+ propName : string ;
130
+ onChange : ( resource : Resource ) => void ;
131
+ } ) => {
132
+ const [ isResourceOpen , setIsResourceOpen ] = useState ( false ) ;
133
+ const form = useRef < HTMLFormElement > ( null ) ;
134
+ return (
135
+ < FloatingPanel
136
+ title = "Edit Resource"
137
+ open = { isResourceOpen }
138
+ onOpenChange = { ( isOpen ) => {
139
+ if ( isOpen ) {
140
+ setIsResourceOpen ( true ) ;
141
+ return ;
142
+ }
143
+ // attempt to save form on close
144
+ if ( areAllFormErrorsVisible ( form . current ) ) {
145
+ form . current ?. requestSubmit ( ) ;
146
+ setIsResourceOpen ( false ) ;
147
+ } else {
148
+ form . current ?. checkValidity ( ) ;
149
+ // prevent closing when not all errors are shown to user
150
+ }
151
+ } }
152
+ content = {
153
+ < form
154
+ ref = { form }
155
+ // ref={formRef}
156
+ noValidate = { true }
157
+ // exclude from the flow
158
+ style = { { display : "contents" } }
159
+ onSubmit = { ( event ) => {
160
+ event . preventDefault ( ) ;
161
+ if ( event . currentTarget . checkValidity ( ) ) {
162
+ const formData = new FormData ( event . currentTarget ) ;
163
+ const newResource = parseResource ( {
164
+ id : resource ?. id ?? nanoid ( ) ,
165
+ name : resource ?. name ?? propName ,
166
+ formData,
167
+ } ) ;
168
+ onChange ( newResource ) ;
169
+ }
170
+ } }
171
+ >
172
+ { /* submit is not triggered when press enter on input without submit button */ }
173
+ < button hidden > </ button >
174
+ < ResourceForm resource = { resource } />
175
+ </ form >
176
+ }
177
+ >
178
+ < ResourceButton />
179
+ </ FloatingPanel >
180
+ ) ;
181
+ } ;
182
+
183
+ const $methodPropValue = computed (
184
+ [ $selectedInstance , $props ] ,
185
+ ( instance , props ) : Resource [ "method" ] => {
186
+ for ( const prop of props . values ( ) ) {
187
+ if (
188
+ prop . instanceId === instance ?. id &&
189
+ prop . type === "string" &&
190
+ prop . name === "method"
191
+ ) {
192
+ const value = prop . value . toLowerCase ( ) ;
193
+ if (
194
+ value === "get" ||
195
+ value === "post" ||
196
+ value === "put" ||
197
+ value === "delete"
198
+ ) {
199
+ return value ;
200
+ }
201
+ break ;
202
+ }
203
+ }
204
+ return "post" ;
205
+ }
206
+ ) ;
207
+
208
+ export const ResourceControl = ( {
27
209
instanceId,
210
+ propName,
211
+ prop,
28
212
} : ControlProps < "resource" > ) => {
29
213
const resources = useStore ( $resources ) ;
30
214
const { variableValues, scope, aliases } = useStore (
31
215
$selectedInstanceResourceScope
32
216
) ;
33
-
34
- let computedValue : unknown ;
35
- let expression : string = JSON . stringify ( "" ) ;
217
+ const methodPropValue = useStore ( $methodPropValue ) ;
218
+ let resource : undefined | Resource ;
219
+ let urlExpression : string = JSON . stringify ( "" ) ;
36
220
if ( prop ?. type === "string" ) {
37
- expression = JSON . stringify ( prop . value ) ;
38
- computedValue = prop . value ;
221
+ urlExpression = JSON . stringify ( prop . value ) ;
39
222
}
40
223
if ( prop ?. type === "expression" ) {
41
- expression = prop . value ;
42
- computedValue = computeExpression ( prop . value , variableValues ) ;
224
+ urlExpression = prop . value ;
43
225
}
44
226
if ( prop ?. type === "resource" ) {
45
- const resource = resources . get ( prop . value ) ;
227
+ resource = resources . get ( prop . value ) ;
46
228
if ( resource ) {
47
- expression = resource . url ;
48
- computedValue = computeExpression ( resource . url , variableValues ) ;
229
+ urlExpression = resource . url ;
49
230
}
50
231
}
232
+ // create temporary resource
233
+ const resourceId = useMemo ( ( ) => resource ?. id ?? nanoid ( ) , [ ] ) ;
234
+ resource ??= {
235
+ id : resourceId ,
236
+ name : propName ,
237
+ url : urlExpression ,
238
+ method : methodPropValue ,
239
+ headers : [ { name : "Content-Type" , value : `"application/json"` } ] ,
240
+ } ;
51
241
52
- const updateResourceUrl = ( urlExpression : string ) => {
242
+ const updateResource = ( newResource : Resource ) => {
53
243
updateWebstudioData ( ( data ) => {
54
244
if ( prop ?. type === "resource" ) {
55
- const resource = data . resources . get ( prop . value ) ;
56
- if ( resource ) {
57
- resource . url = urlExpression ;
58
- }
245
+ data . resources . set ( newResource . id , newResource ) ;
59
246
} else {
60
- let method : Resource [ "method" ] = "post" ;
61
- for ( const prop of data . props . values ( ) ) {
62
- if (
63
- prop . instanceId === instanceId &&
64
- prop . type === "string" &&
65
- prop . name === "method"
66
- ) {
67
- const value = prop . value . toLowerCase ( ) ;
68
- if (
69
- value === "get" ||
70
- value === "post" ||
71
- value === "put" ||
72
- value === "delete"
73
- ) {
74
- method = value ;
75
- }
76
- break ;
77
- }
78
- }
79
-
80
- const newResource : Resource = {
81
- id : nanoid ( ) ,
82
- name : propName ,
83
- url : urlExpression ,
84
- method,
85
- headers : [ { name : "Content-Type" , value : `"application/json"` } ] ,
86
- } ;
87
247
const newProp : Prop = {
88
248
id : prop ?. id ?? nanoid ( ) ,
89
249
instanceId,
@@ -98,15 +258,15 @@ export const ResourceControl = ({
98
258
} ;
99
259
100
260
const id = useId ( ) ;
101
- const label = humanizeAttribute ( meta . label || propName ) ;
102
261
let variant : BindingVariant = "bound" ;
103
262
let readOnly = true ;
104
- if ( isLiteralExpression ( expression ) ) {
263
+ if ( isLiteralExpression ( urlExpression ) ) {
105
264
variant = "default" ;
106
265
readOnly = false ;
107
266
}
108
- const localValue = useLocalValue ( String ( computedValue ?? "" ) , ( value ) =>
109
- updateResourceUrl ( JSON . stringify ( value ) )
267
+ const localValue = useLocalValue (
268
+ String ( computeExpression ( resource . url , variableValues ) ?? "" ) ,
269
+ ( value ) => updateResource ( { ...resource , url : JSON . stringify ( value ) } )
110
270
) ;
111
271
112
272
return (
@@ -121,20 +281,34 @@ export const ResourceControl = ({
121
281
onChange = { ( event ) => localValue . set ( event . target . value ) }
122
282
onBlur = { localValue . save }
123
283
onSubmit = { localValue . save }
284
+ suffix = {
285
+ isFeatureEnabled ( "resourceProp" ) && (
286
+ < ResourceControlPanel
287
+ resource = { resource }
288
+ propName = { propName }
289
+ onChange = { updateResource }
290
+ />
291
+ )
292
+ }
124
293
/>
125
294
< BindingPopover
126
295
scope = { scope }
127
296
aliases = { aliases }
128
297
validate = { ( value ) => {
129
298
if ( value !== undefined && typeof value !== "string" ) {
130
- return `${ label } expects a string value` ;
299
+ return `Expected URL string value` ;
131
300
}
132
301
} }
133
302
variant = { variant }
134
- value = { expression }
135
- onChange = { ( newExpression ) => updateResourceUrl ( newExpression ) }
303
+ value = { urlExpression }
304
+ onChange = { ( newExpression ) =>
305
+ updateResource ( { ...resource , url : newExpression } )
306
+ }
136
307
onRemove = { ( evaluatedValue ) =>
137
- updateResourceUrl ( JSON . stringify ( String ( evaluatedValue ) ) )
308
+ updateResource ( {
309
+ ...resource ,
310
+ url : JSON . stringify ( String ( evaluatedValue ) ) ,
311
+ } )
138
312
}
139
313
/>
140
314
</ BindingControl >
0 commit comments