11import { 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" ;
311import { 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" ;
522import { isLiteralExpression , Resource , type Prop } from "@webstudio-is/sdk" ;
623import {
724 BindingControl ,
825 BindingPopover ,
926 type BindingVariant ,
1027} 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" ;
1929import { computeExpression } from "~/shared/data-variables" ;
2030import { 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" ;
2140import { PropertyLabel } from "../property-label" ;
2241
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,
26125 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 = ( {
27209 instanceId,
210+ propName,
211+ prop,
28212} : ControlProps < "resource" > ) => {
29213 const resources = useStore ( $resources ) ;
30214 const { variableValues, scope, aliases } = useStore (
31215 $selectedInstanceResourceScope
32216 ) ;
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 ( "" ) ;
36220 if ( prop ?. type === "string" ) {
37- expression = JSON . stringify ( prop . value ) ;
38- computedValue = prop . value ;
221+ urlExpression = JSON . stringify ( prop . value ) ;
39222 }
40223 if ( prop ?. type === "expression" ) {
41- expression = prop . value ;
42- computedValue = computeExpression ( prop . value , variableValues ) ;
224+ urlExpression = prop . value ;
43225 }
44226 if ( prop ?. type === "resource" ) {
45- const resource = resources . get ( prop . value ) ;
227+ resource = resources . get ( prop . value ) ;
46228 if ( resource ) {
47- expression = resource . url ;
48- computedValue = computeExpression ( resource . url , variableValues ) ;
229+ urlExpression = resource . url ;
49230 }
50231 }
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+ } ;
51241
52- const updateResourceUrl = ( urlExpression : string ) => {
242+ const updateResource = ( newResource : Resource ) => {
53243 updateWebstudioData ( ( data ) => {
54244 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 ) ;
59246 } 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- } ;
87247 const newProp : Prop = {
88248 id : prop ?. id ?? nanoid ( ) ,
89249 instanceId,
@@ -98,15 +258,15 @@ export const ResourceControl = ({
98258 } ;
99259
100260 const id = useId ( ) ;
101- const label = humanizeAttribute ( meta . label || propName ) ;
102261 let variant : BindingVariant = "bound" ;
103262 let readOnly = true ;
104- if ( isLiteralExpression ( expression ) ) {
263+ if ( isLiteralExpression ( urlExpression ) ) {
105264 variant = "default" ;
106265 readOnly = false ;
107266 }
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 ) } )
110270 ) ;
111271
112272 return (
@@ -121,20 +281,34 @@ export const ResourceControl = ({
121281 onChange = { ( event ) => localValue . set ( event . target . value ) }
122282 onBlur = { localValue . save }
123283 onSubmit = { localValue . save }
284+ suffix = {
285+ isFeatureEnabled ( "resourceProp" ) && (
286+ < ResourceControlPanel
287+ resource = { resource }
288+ propName = { propName }
289+ onChange = { updateResource }
290+ />
291+ )
292+ }
124293 />
125294 < BindingPopover
126295 scope = { scope }
127296 aliases = { aliases }
128297 validate = { ( value ) => {
129298 if ( value !== undefined && typeof value !== "string" ) {
130- return `${ label } expects a string value` ;
299+ return `Expected URL string value` ;
131300 }
132301 } }
133302 variant = { variant }
134- value = { expression }
135- onChange = { ( newExpression ) => updateResourceUrl ( newExpression ) }
303+ value = { urlExpression }
304+ onChange = { ( newExpression ) =>
305+ updateResource ( { ...resource , url : newExpression } )
306+ }
136307 onRemove = { ( evaluatedValue ) =>
137- updateResourceUrl ( JSON . stringify ( String ( evaluatedValue ) ) )
308+ updateResource ( {
309+ ...resource ,
310+ url : JSON . stringify ( String ( evaluatedValue ) ) ,
311+ } )
138312 }
139313 />
140314 </ BindingControl >
0 commit comments