easy_flow 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46efb29b090e2a98dc5bf554d2c1faaeb278197e2b0bb98baf1ad7ed12d97334
4
- data.tar.gz: eae3a957b760f661fe10a1becf31c989b5c3b06a5e39bc0c72b5ea03ade64e1e
3
+ metadata.gz: 6c3f5410fc5f056129c38c8538226461dd426e932bf267d6116bc6c07ad872d1
4
+ data.tar.gz: 19221f96eb57398266925771014571f69aeb155bbfab9a3bb55b45dc2401175c
5
5
  SHA512:
6
- metadata.gz: 2834c585d06a13a36d809a36fd804d46e03011d19910e2b3a08291f9bbc601d57dfe11abb72f6f315bf9cb03dda03f6bb10b69a275320dbf3aa33ff2d6a7af2b
7
- data.tar.gz: 188e23abda87fb93eff368f4c2c2cb0a85d80a141003b33654f597a8e82de4081bb0829d83cfb32c2cf66049c6767b2609a19bb2d2501a01354407775b34a807
6
+ metadata.gz: 9786ee8c6b473f189f062870851305bc16ec2031586ea6eb4833cc792558a0efd3b5aa88e4da23ba913fba30e73dfa7c84e3d25ce6132ead745712e9c1cc1c46
7
+ data.tar.gz: 89d01cd446c018ae52a86932c8235c77f49ca92de1e65aef00570b9b81511e604a7bcb569df50ce618976a66fcb2a624e3209ac397e17e8920980de00be118f2
@@ -1 +1 @@
1
- (()=>{var Dt=globalThis.ReactDOM;var{createPortal:Ne,createRoot:q,flushSync:ze,hydrateRoot:Be,preconnect:Ee,prefetchDNS:Te,preinit:$e,preinitModule:De,preload:Ae,preloadModule:Pe,requestFormReset:_e,unstable_batchedUpdates:Le,useFormState:Ie,useFormStatus:Me,version:Oe}=Dt;var J=globalThis.React,e=J,{Children:Fe,Component:je,Fragment:Ve,PureComponent:We,StrictMode:Ye,Suspense:Ue,cloneElement:Xe,createContext:Ge,createElement:qe,createRef:Je,forwardRef:Ke,isValidElement:Ze,lazy:Qe,memo:Re,startTransition:to,useCallback:eo,useContext:oo,useDebugValue:ro,useDeferredValue:no,useEffect:T,useId:so,useImperativeHandle:ao,useLayoutEffect:K,useMemo:A,useReducer:lo,useRef:F,useState:b,useSyncExternalStore:io,useTransition:co}=J;var Z=new Map,_=new Map,Q=(t,o)=>Z.set(t,o),R=()=>{_.forEach(t=>t.unmount()),_.clear()},tt=(t,o)=>t.querySelectorAll("[data-react-ui]").forEach(n=>{if(_.has(n))return;let r=Z.get(n.dataset.reactUi),a=o(n);_.set(n,a),a.render(e.createElement(r,JSON.parse(n.dataset.props)))});var et=(t,o)=>{let n=()=>tt(t,o);t.addEventListener("turbo:load",n),t.addEventListener("turbo:frame-load",n),t.addEventListener("turbo:before-cache",R),t.readyState==="loading"?t.addEventListener("DOMContentLoaded",n):n()};var C=(...t)=>t.filter(Boolean).join(" ");var At={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},Pt={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},_t=({variant:t="primary",size:o="md",className:n,children:r,...a})=>{let l=a.href?"a":"button";return e.createElement(l,{...a,className:C("ks-button",At[t],Pt[o],n)},r)},x=_t;var Lt="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",It="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",Mt="border border-accent-600 bg-accent-600 text-white",Ot=({name:t,connected:o,armed:n,onArm:r,connecting:a})=>e.createElement("button",{onClick:l=>{a||(l.stopPropagation(),r(l))},title:n?"Now choose a step to connect to":"Connect this branch",className:n?Mt:o?It:Lt,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},t||"next"),ot=Ot;var Ht={width:240,boxSizing:"border-box",borderRadius:8},Ft={padding:"8px 14px",textAlign:"center",fontWeight:600},jt={fontWeight:600,lineHeight:1.3},Vt=(t,o,n)=>{let r=n?"ring-4 ring-accent-600/15":"shadow-sm";return t||n&&!o?`border-accent-600 ${r}`:o?`border-red-600 ${r}`:"border-gray-300 shadow-sm dark:border-zinc-700"},Wt=({node:t,selected:o,armed:n,connecting:r,onSelect:a,onOpen:l,onArm:i,onDragEnd:y,onDragStart:p})=>{let f=t.begins_here||t.ends_here,u=t.begins_here,d=t.ends_here?[]:t.ports,h=r&&!u;return e.createElement("div",{ref:t.ref,"data-step":t.id,draggable:!u,onDragStart:c=>{c.dataTransfer.effectAllowed="move",c.dataTransfer.setData("text/plain",t.id),p()},onDragEnd:y,onClick:t.opens&&!r?()=>l(t.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Vt(h,t.violations.length>0,o)}`,style:{...Ht,padding:f?0:"12px 14px",cursor:u?"default":r?"crosshair":"grab"}},f&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:Ft},t.label),!f&&e.createElement("div",{style:jt},t.label),!f&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginTop:2}},t.type),t.starts_a_flow&&e.createElement("div",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:11,marginTop:2}},"Runs a flow"),t.opens&&e.createElement(x,{variant:"secondary",size:"sm",className:"mt-2",onClick:c=>{c.stopPropagation(),a()}},"Settings"),t.violations.map(c=>e.createElement("div",{key:c.problem+c.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},c.problem.replace(/_/g," "),c.detail?`: ${c.detail}`:"")),d.length>0&&e.createElement("div",{style:{padding:t.begins_here?"0 14px 8px":"10px 0 0"}},d.map(c=>e.createElement(ot,{key:c,name:c,connected:t.connected.includes(c),connecting:r,armed:n===c,onArm:g=>i(c,g)}))))},j=Wt;var rt=(t,o)=>t.includes(o)?t.filter(n=>n!==o):[...t,o],nt=(t,o)=>({...o?.choices||{},...t?.choices||{}});var Yt=({as:t="input",disabled:o,className:n,...r})=>e.createElement(t,{...r,disabled:o,className:C("ks-input",o&&"ks-input-disabled",n)}),S=Yt;var Ut=t=>e.createElement(S,{...t,as:"select"}),st=Ut;var Xt=({className:t,...o})=>e.createElement("input",{type:"checkbox",...o,className:C("ks-checkbox",t)}),V=Xt;var Gt=t=>(t||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),qt=({type:t,value:o,choices:n,onChange:r,onSettle:a})=>{if(t==="boolean")return e.createElement(V,{checked:!!o,onChange:l=>a(l.target.checked)});if(t==="select"||t==="previous_step"||t==="from_step"||t==="flow")return e.createElement(st,{className:"mb-3",value:o??"",onChange:l=>a(l.target.value)},e.createElement("option",{value:""}),Gt(n).map(l=>e.createElement("option",{key:l.value,value:l.value},l.label)));if(t==="multi_select"){let l=Array.isArray(o)?o:[],i=y=>a(rt(l,y));return e.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(y=>e.createElement("label",{key:y,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(V,{checked:l.includes(y),onChange:()=>i(y)}),e.createElement("span",null,y))))}return t==="integer"||t==="float"?e.createElement(S,{className:"mb-3",type:"number",step:t==="integer"?"1":"any",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)}):e.createElement(S,{className:"mb-3",type:"text",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)})},L=qt;var at=(t,o,n,r)=>t.map((a,l)=>l===o?{...a,[n]:r}:a);var Jt=(t,o,n,r)=>{let a=(t||{})[n],l=(o||{})[n];return l?(a||{})[r[l]]:a},Kt=({holds:t,labels:o,rows:n,choices:r,outputsOf:a,onChange:l,onSettle:i})=>{let y=Array.isArray(n)?n:[],p=(f,u,d,h)=>{let c=at(y,f,u,d);h?i(c):l(c)};return e.createElement("div",{style:{marginBottom:12}},y.map((f,u)=>e.createElement("div",{key:u,className:"rounded-md border border-gray-200 dark:border-zinc-700",style:{padding:"8px 8px 2px",marginBottom:6}},Object.entries(t).map(([d,h])=>e.createElement("label",{key:d,style:{display:"block"}},e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[d]||d),e.createElement(L,{type:h,value:f[d],choices:Jt(r,a,d,f),onChange:c=>p(u,d,c,!1),onSettle:c=>p(u,d,c,!0)}))),e.createElement(x,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>i(y.filter((d,h)=>h!==u))},"Remove"))),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>i([...y,{}])},"Add"))},lt=Kt;var Zt=({as:t="div",className:o,children:n,...r})=>e.createElement(t,{...r,className:C("ks-panel",o)},n),D=Zt;var I=(t,o)=>({className:n,children:r,...a})=>e.createElement(t,{...a,className:C(o,n)},r),P=I("label","ks-label"),er=I("p","ks-hint"),or=I("p","ks-error"),rr=I("span","ks-required");var Qt={width:280,padding:20,overflowY:"auto"},Rt=({node:t,fields:o,holds:n,labels:r,recordLabels:a,recordChoices:l,recordOutputsOf:i,choices:y,onSave:p,onDelete:f,onClose:u})=>{let[d,h]=b(t.config);T(()=>h(t.config),[t.id,t.config]);let c=g=>{h(g),JSON.stringify(g)!==JSON.stringify(t.config)&&p(g)};return e.createElement(D,{as:"aside",style:Qt,"data-inspector":!0},e.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},e.createElement("h2",{style:{fontWeight:600,marginBottom:2}},t.label),e.createElement("button",{title:"Close",onClick:u,className:"text-gray-500 dark:text-gray-400",style:{border:"none",background:"none",cursor:"pointer",fontSize:18,lineHeight:1}},"\xD7")),e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginBottom:16}},t.id," \xB7 ",t.type),Object.entries(o).map(([g,w])=>e.createElement(P,{key:g},e.createElement("span",{style:{display:"block",marginBottom:3}},r[g]||g),w==="list"?e.createElement(lt,{holds:n[g]||{},labels:a[g]||{},rows:d[g],choices:{...(l||{})[g],...(t.record_choices||{})[g]},outputsOf:(i||{})[g],onChange:v=>h({...d,[g]:v}),onSettle:v=>c({...d,[g]:v})}):e.createElement(L,{type:w,value:d[g],choices:y[g],onChange:v=>h({...d,[g]:v}),onSettle:v=>c({...d,[g]:v})}))),e.createElement(x,{variant:"danger",size:"sm",className:"w-full",onClick:f},"Delete step"))},it=Rt;var te={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},ee="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",oe="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",re=({node:t,dragging:o,onFill:n,onDrop:r})=>e.createElement("div",{ref:t.ref,"data-placeholder":t.id,title:`Choose the step \u201C${t.label}\u201D should lead to`,onClick:n,onDragOver:a=>{o&&(a.preventDefault(),a.dataTransfer.dropEffect="move")},onDrop:a=>{a.preventDefault(),r()},className:o?oe:ee,style:te},e.createElement("div",{style:{fontWeight:600}},t.label),e.createElement("div",{style:{fontSize:11}},o?"drop a step here":"leads nowhere yet \u2014 click to choose")),dt=re;var ne=({entries:t,at:o,onPick:n,onConnect:r,onDismiss:a})=>e.createElement(D,{className:"shadow-lg",style:{position:"absolute",zIndex:9,top:o.y,left:o.x,width:210,padding:8,borderRadius:8}},e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{margin:"0 0 6px",fontSize:11}},"Add a step"),t.map(l=>e.createElement(x,{key:l.type,variant:"secondary",size:"sm",className:"w-full mb-1.5 flex-col",onClick:()=>n(l)},l.label,e.createElement("span",{className:"opacity-75",style:{display:"block",fontSize:11}},l.type))),r&&e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:r},"Connect to a step already here"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full",onClick:a},"Cancel")),ct=ne;var se={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},pt=({label:t,title:o,idle:n,enabled:r,onUse:a})=>e.createElement(x,{variant:"secondary",size:"sm",title:r?o:n,disabled:!r,onClick:a,style:{opacity:r?1:.4,cursor:r?"pointer":"default"}},t),ae=({undoable:t,redoable:o,onAdd:n,onUndo:r,onRedo:a})=>e.createElement("div",{style:{position:"absolute",top:16,left:16,zIndex:5,display:"flex",gap:8}},e.createElement(x,{variant:"secondary",className:"rounded-full",style:se,title:"Add a step",onClick:n},"+"),e.createElement(pt,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:t,onUse:r}),e.createElement(pt,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),ut=ae;var mt=t=>{let o=t.problem.replace(/_/g," ");return t.node?`\u201C${t.node}\u201D ${o}`:o};var ft=({version:t,published:o})=>t?o?o===t?`Version ${t} \xB7 live`:`Version ${t} \xB7 visitors still on ${o}`:`Version ${t} \xB7 never published`:"No version yet";var le={width:280,padding:20,overflowY:"auto"},W={fontWeight:600,marginBottom:6,marginTop:14},M={fontSize:12,marginBottom:4,lineHeight:1.4},gt={display:"block",marginBottom:3},yt=(t,o,n)=>r=>{r.target.value!==(t[o]??"")&&n({[o]:r.target.value})},ie=({flow:t,changes:o,problems:n,refusal:r,notice:a,onCreate:l,onPublish:i,onSaveDetails:y,onClose:p})=>e.createElement(D,{as:"aside",style:le,"data-builder-panel":!0},e.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},e.createElement("h2",{style:{fontWeight:600},"data-flow-name":!0},t.title||t.slug),e.createElement("button",{title:"Close",onClick:p,className:"text-gray-500 dark:text-gray-400",style:{border:"none",background:"none",cursor:"pointer",fontSize:18,lineHeight:1}},"\xD7")),e.createElement("a",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:12,display:"block"},href:t.history_url,"data-history":!0},ft(t)),e.createElement("h2",{style:W},"Problems"),r&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...M,fontWeight:600},"data-refusal":!0},r),a&&!r&&e.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...M,fontWeight:600},"data-notice":!0},a),n.length===0&&!r?e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing wrong with this flow."):n.map(f=>e.createElement("p",{key:`${f.node}-${f.problem}`,className:"text-amber-700 dark:text-amber-400",style:M,"data-problem":!0},"\u26A0 ",mt(f))),e.createElement("h2",{style:W},"Changes since the last version"),o.length===0?e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing has changed."):o.map((f,u)=>e.createElement("p",{key:u,style:M,"data-change":!0},f)),e.createElement("h2",{style:W},"Details"),e.createElement(P,null,e.createElement("span",{style:gt},"Title"),e.createElement(S,{className:"mb-3",defaultValue:t.title??"",onBlur:yt(t,"title",y),"data-flow-title":!0})),e.createElement(P,null,e.createElement("span",{style:gt},"Start label"),e.createElement(S,{className:"mb-3",defaultValue:t.start_label??"",onBlur:yt(t,"start_label",y),"data-flow-start-label":!0})),e.createElement("div",{style:{marginTop:16}},e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:l,"data-create-version":!0},"Create version"),e.createElement(x,{size:"sm",className:"w-full mb-1.5",onClick:i,"data-publish":!0},"Publish"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.definition_url,"data-definition":!0},"Definition"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.details_url,"data-details":!0},"Edit details"))),ht=ie;var xt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},de=({link:t,onInsert:o,onRemove:n,onDrop:r,dragging:a})=>{let[l,i]=b(!1),y=l||a;return e.createElement("div",{"data-connector":`${t.source}-${t.target}`,style:{position:"absolute",left:t.midX-34,top:t.midY-20,width:68,height:40,zIndex:4},onMouseEnter:()=>i(!0),onMouseLeave:()=>i(!1),onDragEnter:()=>i(!0),onDragLeave:()=>i(!1),onDragOver:p=>{p.preventDefault(),p.dataTransfer.dropEffect="move"},onDrop:p=>{p.preventDefault(),i(!1),r()}},e.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%",opacity:y?1:0,transition:"opacity .12s"}},e.createElement(x,{variant:"secondary",title:a?"Move the step here":"Insert a step here",onClick:o,className:"rounded-full data-[over=true]:ring-2 data-[over=true]:ring-accent-600","data-over":!!(a&&l),style:xt},"+"),!a&&e.createElement(x,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:xt},"\xD7")))},bt=de;var ce=({links:t,extent:o,dragging:n,onInsert:r,onRemove:a,onDrop:l})=>e.createElement(e.Fragment,null,e.createElement("svg",{width:o.width,height:o.height,style:{position:"absolute",top:0,left:0,pointerEvents:"none",zIndex:1}},e.createElement("defs",null,e.createElement("marker",{id:"easy_flow-arrow",viewBox:"0 0 10 10",refX:"8",refY:"5",markerWidth:"5",markerHeight:"5",orient:"auto"},e.createElement("path",{d:"M 0 0 L 10 5 L 0 10 z",className:"fill-gray-400 dark:fill-zinc-500"}))),t.map(i=>e.createElement("path",{key:i.id,"data-link":i.id,className:"stroke-gray-400 dark:stroke-zinc-500",fill:"none",strokeWidth:"1.5",markerEnd:"url(#easy_flow-arrow)",d:i.path}))),t.filter(i=>i.label).map(i=>e.createElement("div",{key:`${i.id}-label`,"data-link-label":i.id,className:"bg-surface-50 text-gray-500 dark:bg-surface-950 dark:text-gray-400",style:{position:"absolute",left:i.midX-10,top:i.midY-18,zIndex:3,fontSize:11,padding:"0 3px"}},i.label)),t.filter(i=>!i.placeholder).map(i=>e.createElement(bt,{key:i.id,link:i,dragging:!!n,onInsert:()=>r(i),onRemove:()=>a(i),onDrop:()=>l(i)}))),vt=ce;var pe="Your change was not saved because the server could not be reached.",kt=({base:t,token:o,fetch:n,onFlow:r,onError:a,onNotice:l})=>async(i,y,p)=>{let f=await n(t+i,{method:y,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:p&&JSON.stringify(p)}).catch(()=>null);if(!f)return l(null),a(pe);let u=await f.json().catch(()=>({}));if(!f.ok)return l(null),a(u.error||"That change was refused");a(null),l(u.notice||null),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json())};var ue=(t,o,n)=>{let[r,a]=b(n),[l,i]=b(null),[y,p]=b(null),f=A(()=>kt({base:t,token:o,fetch:(...u)=>window.fetch(...u),onFlow:a,onError:i,onNotice:p}),[t,o]);return{flow:r,error:l,notice:y,send:f}},wt=ue;var O=(t,o,n,r)=>{let a=t.left-n.left+r.scrollLeft,l=t.top-n.top+r.scrollTop;return o==="left"?{x:a,y:l+t.height/2}:o==="right"?{x:a+t.width,y:l+t.height/2}:o==="top"?{x:a+t.width/2,y:l}:{x:a+t.width/2,y:l+t.height}},me=(t,o)=>o==="top"?{x:t.x,y:t.y-3}:o==="left"?{x:t.x-3,y:t.y}:o==="right"?{x:t.x+3,y:t.y}:{x:t.x,y:t.y+3},fe=(t,o,n,r,a)=>{let l={straight:`M ${o.x} ${o.y} L ${n.x} ${n.y}`,turn:`M ${o.x} ${o.y} H ${n.x} V ${n.y}`,lane:`M ${o.x} ${o.y} V ${r} H ${n.x} V ${n.y}`,detour:`M ${o.x} ${o.y} H ${a} V ${n.y} H ${n.x}`}[t.route];return{...t,path:l,midX:t.route==="detour"?a:(o.x+n.x)/2,midY:t.route==="lane"?r:(o.y+n.y)/2}},ge=(t,o,n,r)=>{let[a,l]=b([]),[i,y]=b({width:"100%",height:"100%"});return K(()=>{let p=()=>{let u=o.current?.getBoundingClientRect();u&&(y({width:o.current.scrollWidth,height:o.current.scrollHeight}),l(t.edges.flatMap(d=>{let h=n.current[d.source]?.getBoundingClientRect(),c=n.current[d.target]?.getBoundingClientRect();if(!h||!c)return[];let g=O(h,d.leaves,u,o.current),w=me(O(c,d.enters,u,o.current),d.enters),v=(O(h,"bottom",u,o.current).y+O(c,"top",u,o.current).y)/2,B=d.leaves==="left"?Math.min(g.x,w.x)-28:Math.max(g.x,w.x)+28;return[fe(d,g,w,v,B)]})))};p(),window.addEventListener("resize",p);let f=new ResizeObserver(p);return o.current&&f.observe(o.current),()=>{window.removeEventListener("resize",p),f.disconnect()}},r),{links:a,extent:i}},Ct=ge;var St=(t,o)=>{let n=new Set(o),r=t,a=2;for(;n.has(r);)r=`${t}_${a++}`;return r};var xe={display:"flex",height:"100%",minHeight:0,fontSize:13},be={flex:1,overflow:"auto",position:"relative"},ve={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},ke={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},we={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},Ce=({port:t,onCancel:o})=>e.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:we},"Choose the step \u201C",t||"next","\u201D should lead to \u2014 ",e.createElement("button",{onClick:o,className:"text-accent-800 underline dark:text-accent-100",style:{border:"none",background:"none",cursor:"pointer",fontSize:12,padding:0}},"cancel")),Se=({base:t,token:o,initial:n})=>{let{flow:r,error:a,notice:l,send:i}=wt(t,o,n),[y,p]=b(null),[f,u]=b(null),[d,h]=b(null),[c,g]=b(null),[w,v]=b(!1),B=F({}),E=F(null);T(()=>{let s=()=>{p(null),v(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),T(()=>{if(!w)return;let s=m=>{m.target.closest("[data-builder-panel], [data-open-panel]")||v(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[w]),T(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let zt=A(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:Bt,extent:Et}=Ct(r,E,B,[r,y,zt]),Y=A(()=>{let s={};return r.violations.forEach(m=>{var k;return(s[k=m.node]||(s[k]=[])).push(m)}),s},[r.violations]),H=r.nodes.filter(s=>!s.loose),U=r.nodes.filter(s=>s.loose),Tt=Math.max(0,...H.map(s=>s.row))+1,$t=Math.max(0,...H.map(s=>s.column))+1,X=s=>{if(!d)return;let[m,k]=d;h(null),m!==s&&i("/edges","POST",{from:m,to:s,on:k||null})},N=r.nodes.find(s=>s.id===y),z=r.palette.find(s=>s.type===N?.type);return e.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:xe,onMouseUp:()=>g(null),onKeyDown:s=>{s.key==="Escape"&&(p(null),h(null),u(null))},tabIndex:-1},e.createElement("div",{ref:E,className:"bg-surface-50 dark:bg-surface-950",style:be,onClick:s=>{s.target===E.current&&(p(null),h(null),v(!1))}},d&&e.createElement(Ce,{port:d[1],onCancel:()=>h(null)}),e.createElement(ut,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>u({at:{x:16,y:52}}),onUndo:()=>{p(null),i("/undo","POST")},onRedo:()=>{p(null),i("/redo","POST")}}),e.createElement(vt,{links:Bt,extent:Et,dragging:c,onInsert:s=>u({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{p(null),i("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let m=c;g(null),i("/steps/"+m+"/move","PATCH",{from:s.source,to:s.target})}}),U.length>0&&e.createElement("div",{"data-loose":!0,className:"border border-dashed border-gray-300 bg-gray-50 dark:border-zinc-700 dark:bg-zinc-900",style:ve},e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginBottom:6}},"Not in the flow yet \u2014 drag one onto a connection"),U.map(s=>e.createElement("div",{key:s.id,style:{marginBottom:8}},e.createElement(j,{node:{...s,ref:m=>{B.current[s.id]=m},violations:Y[s.id]||[],connected:[]},selected:y===s.id,armed:d&&d[0]===s.id?d[1]:null,connecting:!!d,onSelect:()=>d?X(s.id):p(s.id),onOpen:m=>window.location.assign(m),onArm:(m,k)=>{k.stopPropagation(),h([s.id,m])},onDragStart:()=>g(s.id),onDragEnd:()=>g(null)})))),e.createElement("div",{style:{...ke,gridTemplateColumns:`repeat(${$t+1}, ${280/2}px)`,gridTemplateRows:`repeat(${Tt}, auto)`}},H.map(s=>e.createElement("div",{key:s.id,style:{gridRow:s.row+1,gridColumn:`${s.column+1} / span 2`,zIndex:2}},s.placeholder&&e.createElement(dt,{node:{...s,ref:m=>{B.current[s.id]=m}},dragging:!!c,onFill:()=>u({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let m=c;g(null),m&&i("/edges","POST",{from:s.from,to:m,on:s.on})}}),!s.placeholder&&e.createElement(j,{node:{...s,ref:m=>{B.current[s.id]=m},violations:Y[s.id]||[],connected:r.edges.filter(m=>m.source===s.id).map(m=>m.label||null)},selected:s.id===y,armed:d&&d[0]===s.id?d[1]:null,connecting:!!d&&d[0]!==s.id,onSelect:()=>d?X(s.id):p(s.id),onOpen:m=>window.location.assign(m),onArm:(m,k)=>{let G=E.current.getBoundingClientRect();u({from:s.id,on:m,at:{x:k.clientX-G.left+E.current.scrollLeft+8,y:k.clientY-G.top+E.current.scrollTop+8}})},onDragEnd:()=>g(null),onDragStart:()=>g(s.id)})))),f&&e.createElement(ct,{entries:r.palette,at:f.at,onDismiss:()=>u(null),onConnect:f.on!==void 0?()=>{h([f.from,f.on]),u(null)}:null,onPick:s=>{let m=f;u(null),i("/steps","POST",{id:St(s.type,r.nodes.map(k=>k.id)),type:s.type,from:m.from,to:m.to,on:m.on})}})),w&&!N&&e.createElement(ht,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:l,onClose:()=>v(!1),onCreate:()=>i("/versions","POST"),onPublish:()=>i("/publish","POST"),onSaveDetails:s=>i("/details","PATCH",{flow:s})}),N&&e.createElement(it,{node:N,fields:z?.fields||{},holds:z?.records||{},labels:z?.labels||{},recordLabels:z?.record_labels||{},recordChoices:z?.record_choices||{},recordOutputsOf:z?.record_outputs_of||{},choices:nt(N,z),onClose:()=>p(null),onSave:s=>i(`/steps/${N.id}`,"PATCH",{config:s}),onDelete:()=>{p(null),i(`/steps/${N.id}`,"DELETE")}}))},Nt=Se;Q("easy_flow/flow-editor",Nt);document.addEventListener("easy_flow:flow-named",t=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=t.detail)});et(document,q);})();
1
+ (()=>{var Dt=globalThis.ReactDOM;var{createPortal:Ne,createRoot:J,flushSync:ze,hydrateRoot:Be,preconnect:Ee,prefetchDNS:Te,preinit:$e,preinitModule:Ae,preload:De,preloadModule:Pe,requestFormReset:_e,unstable_batchedUpdates:Le,useFormState:Ie,useFormStatus:Me,version:Oe}=Dt;var K=globalThis.React,e=K,{Children:Fe,Component:je,Fragment:Ve,PureComponent:We,StrictMode:Ye,Suspense:Ue,cloneElement:Xe,createContext:Ge,createElement:qe,createRef:Je,forwardRef:Ke,isValidElement:Ze,lazy:Qe,memo:Re,startTransition:to,useCallback:eo,useContext:oo,useDebugValue:ro,useDeferredValue:no,useEffect:T,useId:so,useImperativeHandle:ao,useLayoutEffect:Z,useMemo:D,useReducer:lo,useRef:j,useState:b,useSyncExternalStore:io,useTransition:co}=K;var Q=new Map,_=new Map,R=(t,o)=>Q.set(t,o),tt=()=>{_.forEach(t=>t.unmount()),_.clear()},et=(t,o)=>t.querySelectorAll("[data-react-ui]").forEach(n=>{if(_.has(n))return;let r=Q.get(n.dataset.reactUi),a=o(n);_.set(n,a),a.render(e.createElement(r,JSON.parse(n.dataset.props)))});var ot=(t,o)=>{let n=()=>et(t,o);t.addEventListener("turbo:load",n),t.addEventListener("turbo:frame-load",n),t.addEventListener("turbo:before-cache",tt),t.readyState==="loading"?t.addEventListener("DOMContentLoaded",n):n()};var C=(...t)=>t.filter(Boolean).join(" ");var Pt={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},_t={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},Lt=({variant:t="primary",size:o="md",className:n,children:r,...a})=>{let l=a.href?"a":"button";return e.createElement(l,{...a,className:C("ks-button",Pt[t],_t[o],n)},r)},x=Lt;var It="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",Mt="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",Ot="border border-accent-600 bg-accent-600 text-white",Ht=({name:t,connected:o,armed:n,onArm:r,connecting:a})=>e.createElement("button",{onClick:l=>{a||(l.stopPropagation(),r(l))},title:n?"Now choose a step to connect to":"Connect this branch",className:n?Ot:o?Mt:It,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},t||"next"),rt=Ht;var Ft={width:240,boxSizing:"border-box",borderRadius:8},jt={padding:"8px 14px",textAlign:"center",fontWeight:600},Vt={fontWeight:600,lineHeight:1.3},Wt=(t,o,n)=>{let r=n?"ring-4 ring-accent-600/15":"shadow-sm";return t||n&&!o?`border-accent-600 ${r}`:o?`border-red-600 ${r}`:"border-gray-300 shadow-sm dark:border-zinc-700"},Yt=({node:t,selected:o,armed:n,connecting:r,onSelect:a,onOpen:l,onArm:i,onDragEnd:y,onDragStart:p})=>{let f=t.begins_here||t.ends_here,m=t.begins_here,d=t.ends_here?[]:t.ports,h=r&&!m;return e.createElement("div",{ref:t.ref,"data-step":t.id,draggable:!m,onDragStart:c=>{c.dataTransfer.effectAllowed="move",c.dataTransfer.setData("text/plain",t.id),p()},onDragEnd:y,onClick:t.opens&&!r?()=>l(t.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Wt(h,t.violations.length>0,o)}`,style:{...Ft,padding:f?0:"12px 14px",cursor:m?"default":r?"crosshair":"grab"}},f&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:jt},t.label),!f&&e.createElement("div",{style:Vt},t.label),!f&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginTop:2}},t.type),t.starts_a_flow&&e.createElement("div",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:11,marginTop:2}},"Runs a flow"),t.opens&&e.createElement(x,{variant:"secondary",size:"sm",className:"mt-2",onClick:c=>{c.stopPropagation(),a()}},"Settings"),t.violations.map(c=>e.createElement("div",{key:c.problem+c.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},c.problem.replace(/_/g," "),c.detail?`: ${c.detail}`:"")),d.length>0&&e.createElement("div",{style:{padding:t.begins_here?"0 14px 8px":"10px 0 0"}},d.map(c=>e.createElement(rt,{key:c,name:c,connected:t.connected.includes(c),connecting:r,armed:n===c,onArm:g=>i(c,g)}))))},V=Yt;var W=(t,o)=>t.includes(o)?t.filter(n=>n!==o):[...t,o],nt=(t,o)=>({...o?.choices||{},...t?.choices||{}});var Ut=({as:t="input",disabled:o,className:n,...r})=>e.createElement(t,{...r,disabled:o,className:C("ks-input",o&&"ks-input-disabled",n)}),S=Ut;var Xt=t=>e.createElement(S,{...t,as:"select"}),st=Xt;var Gt=({className:t,...o})=>e.createElement("input",{type:"checkbox",...o,className:C("ks-checkbox",t)}),L=Gt;var at=t=>(t||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),qt=({type:t,value:o,choices:n,onChange:r,onSettle:a})=>{if(t==="boolean")return e.createElement(L,{checked:!!o,onChange:l=>a(l.target.checked)});if(t==="select"||t==="previous_step"||t==="from_step"||t==="flow")return e.createElement(st,{className:"mb-3",value:o??"",onChange:l=>a(l.target.value)},e.createElement("option",{value:""}),at(n).map(l=>e.createElement("option",{key:l.value,value:l.value},l.label)));if(t==="multi_select"){let l=Array.isArray(o)?o:[],i=y=>a(W(l,y));return e.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(y=>e.createElement("label",{key:y,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(L,{checked:l.includes(y),onChange:()=>i(y)}),e.createElement("span",null,y))))}if(t==="flows"){let l=Array.isArray(o)?o:[];return e.createElement("div",{style:{display:"flex",flexDirection:"column",gap:4},className:"mb-3"},at(n).map(i=>e.createElement("label",{key:i.value,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(L,{checked:l.includes(i.value),onChange:()=>a(W(l,i.value))}),e.createElement("span",null,i.label))))}return t==="integer"||t==="float"?e.createElement(S,{className:"mb-3",type:"number",step:t==="integer"?"1":"any",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)}):e.createElement(S,{className:"mb-3",type:"text",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)})},I=qt;var lt=(t,o,n,r)=>t.map((a,l)=>l===o?{...a,[n]:r}:a);var Jt=(t,o,n,r)=>{let a=(t||{})[n],l=(o||{})[n];return l?(a||{})[r[l]]:a},Kt=({holds:t,labels:o,rows:n,choices:r,outputsOf:a,onChange:l,onSettle:i})=>{let y=Array.isArray(n)?n:[],p=(f,m,d,h)=>{let c=lt(y,f,m,d);h?i(c):l(c)};return e.createElement("div",{style:{marginBottom:12}},y.map((f,m)=>e.createElement("div",{key:m,className:"rounded-md border border-gray-200 dark:border-zinc-700",style:{padding:"8px 8px 2px",marginBottom:6}},Object.entries(t).map(([d,h])=>e.createElement("label",{key:d,style:{display:"block"}},e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[d]||d),e.createElement(I,{type:h,value:f[d],choices:Jt(r,a,d,f),onChange:c=>p(m,d,c,!1),onSettle:c=>p(m,d,c,!0)}))),e.createElement(x,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>i(y.filter((d,h)=>h!==m))},"Remove"))),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>i([...y,{}])},"Add"))},it=Kt;var Zt=({as:t="div",className:o,children:n,...r})=>e.createElement(t,{...r,className:C("ks-panel",o)},n),A=Zt;var M=(t,o)=>({className:n,children:r,...a})=>e.createElement(t,{...a,className:C(o,n)},r),P=M("label","ks-label"),er=M("p","ks-hint"),or=M("p","ks-error"),rr=M("span","ks-required");var Qt={width:280,padding:20,overflowY:"auto"},Rt=({node:t,fields:o,holds:n,labels:r,recordLabels:a,recordChoices:l,recordOutputsOf:i,choices:y,onSave:p,onDelete:f,onClose:m})=>{let[d,h]=b(t.config);T(()=>h(t.config),[t.id,t.config]);let c=g=>{h(g),JSON.stringify(g)!==JSON.stringify(t.config)&&p(g)};return e.createElement(A,{as:"aside",style:Qt,"data-inspector":!0},e.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},e.createElement("h2",{style:{fontWeight:600,marginBottom:2}},t.label),e.createElement("button",{title:"Close",onClick:m,className:"text-gray-500 dark:text-gray-400",style:{border:"none",background:"none",cursor:"pointer",fontSize:18,lineHeight:1}},"\xD7")),e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginBottom:16}},t.id," \xB7 ",t.type),Object.entries(o).map(([g,w])=>e.createElement(P,{key:g},e.createElement("span",{style:{display:"block",marginBottom:3}},r[g]||g),w==="list"?e.createElement(it,{holds:n[g]||{},labels:a[g]||{},rows:d[g],choices:{...(l||{})[g],...(t.record_choices||{})[g]},outputsOf:(i||{})[g],onChange:v=>h({...d,[g]:v}),onSettle:v=>c({...d,[g]:v})}):e.createElement(I,{type:w,value:d[g],choices:y[g],onChange:v=>h({...d,[g]:v}),onSettle:v=>c({...d,[g]:v})}))),e.createElement(x,{variant:"danger",size:"sm",className:"w-full",onClick:f},"Delete step"))},dt=Rt;var te={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},ee="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",oe="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",re=({node:t,dragging:o,onFill:n,onDrop:r})=>e.createElement("div",{ref:t.ref,"data-placeholder":t.id,title:`Choose the step \u201C${t.label}\u201D should lead to`,onClick:n,onDragOver:a=>{o&&(a.preventDefault(),a.dataTransfer.dropEffect="move")},onDrop:a=>{a.preventDefault(),r()},className:o?oe:ee,style:te},e.createElement("div",{style:{fontWeight:600}},t.label),e.createElement("div",{style:{fontSize:11}},o?"drop a step here":"leads nowhere yet \u2014 click to choose")),ct=re;var ne=({entries:t,at:o,onPick:n,onConnect:r,onDismiss:a})=>e.createElement(A,{className:"shadow-lg",style:{position:"absolute",zIndex:9,top:o.y,left:o.x,width:210,padding:8,borderRadius:8}},e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{margin:"0 0 6px",fontSize:11}},"Add a step"),t.map(l=>e.createElement(x,{key:l.type,variant:"secondary",size:"sm",className:"w-full mb-1.5 flex-col",onClick:()=>n(l)},l.label,e.createElement("span",{className:"opacity-75",style:{display:"block",fontSize:11}},l.type))),r&&e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:r},"Connect to a step already here"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full",onClick:a},"Cancel")),pt=ne;var se={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},mt=({label:t,title:o,idle:n,enabled:r,onUse:a})=>e.createElement(x,{variant:"secondary",size:"sm",title:r?o:n,disabled:!r,onClick:a,style:{opacity:r?1:.4,cursor:r?"pointer":"default"}},t),ae=({undoable:t,redoable:o,onAdd:n,onUndo:r,onRedo:a})=>e.createElement("div",{style:{position:"absolute",top:16,left:16,zIndex:5,display:"flex",gap:8}},e.createElement(x,{variant:"secondary",className:"rounded-full",style:se,title:"Add a step",onClick:n},"+"),e.createElement(mt,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:t,onUse:r}),e.createElement(mt,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),ut=ae;var ft=t=>{let o=t.problem.replace(/_/g," ");return t.node?`\u201C${t.node}\u201D ${o}`:o};var gt=({version:t,published:o})=>t?o?o===t?`Version ${t} \xB7 live`:`Version ${t} \xB7 visitors still on ${o}`:`Version ${t} \xB7 never published`:"No version yet";var le={width:280,padding:20,overflowY:"auto"},Y={fontWeight:600,marginBottom:6,marginTop:14},O={fontSize:12,marginBottom:4,lineHeight:1.4},yt={display:"block",marginBottom:3},ht=(t,o,n)=>r=>{r.target.value!==(t[o]??"")&&n({[o]:r.target.value})},ie=({flow:t,changes:o,problems:n,refusal:r,notice:a,onCreate:l,onPublish:i,onSaveDetails:y,onClose:p})=>e.createElement(A,{as:"aside",style:le,"data-builder-panel":!0},e.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},e.createElement("h2",{style:{fontWeight:600},"data-flow-name":!0},t.title||t.slug),e.createElement("button",{title:"Close",onClick:p,className:"text-gray-500 dark:text-gray-400",style:{border:"none",background:"none",cursor:"pointer",fontSize:18,lineHeight:1}},"\xD7")),e.createElement("a",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:12,display:"block"},href:t.history_url,"data-history":!0},gt(t)),e.createElement("h2",{style:Y},"Problems"),r&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...O,fontWeight:600},"data-refusal":!0},r),a&&!r&&e.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...O,fontWeight:600},"data-notice":!0},a),n.length===0&&!r?e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing wrong with this flow."):n.map(f=>e.createElement("p",{key:`${f.node}-${f.problem}`,className:"text-amber-700 dark:text-amber-400",style:O,"data-problem":!0},"\u26A0 ",ft(f))),e.createElement("h2",{style:Y},"Changes since the last version"),o.length===0?e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing has changed."):o.map((f,m)=>e.createElement("p",{key:m,style:O,"data-change":!0},f)),e.createElement("h2",{style:Y},"Details"),e.createElement(P,null,e.createElement("span",{style:yt},"Title"),e.createElement(S,{className:"mb-3",defaultValue:t.title??"",onBlur:ht(t,"title",y),"data-flow-title":!0})),e.createElement(P,null,e.createElement("span",{style:yt},"Start label"),e.createElement(S,{className:"mb-3",defaultValue:t.start_label??"",onBlur:ht(t,"start_label",y),"data-flow-start-label":!0})),e.createElement("div",{style:{marginTop:16}},e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:l,"data-create-version":!0},"Create version"),e.createElement(x,{size:"sm",className:"w-full mb-1.5",onClick:i,"data-publish":!0},"Publish"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.definition_url,"data-definition":!0},"Definition"),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.details_url,"data-details":!0},"Edit details"))),xt=ie;var bt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},de=({link:t,onInsert:o,onRemove:n,onDrop:r,dragging:a})=>{let[l,i]=b(!1),y=l||a;return e.createElement("div",{"data-connector":`${t.source}-${t.target}`,style:{position:"absolute",left:t.midX-34,top:t.midY-20,width:68,height:40,zIndex:4},onMouseEnter:()=>i(!0),onMouseLeave:()=>i(!1),onDragEnter:()=>i(!0),onDragLeave:()=>i(!1),onDragOver:p=>{p.preventDefault(),p.dataTransfer.dropEffect="move"},onDrop:p=>{p.preventDefault(),i(!1),r()}},e.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%",opacity:y?1:0,transition:"opacity .12s"}},e.createElement(x,{variant:"secondary",title:a?"Move the step here":"Insert a step here",onClick:o,className:"rounded-full data-[over=true]:ring-2 data-[over=true]:ring-accent-600","data-over":!!(a&&l),style:bt},"+"),!a&&e.createElement(x,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:bt},"\xD7")))},vt=de;var ce=({links:t,extent:o,dragging:n,onInsert:r,onRemove:a,onDrop:l})=>e.createElement(e.Fragment,null,e.createElement("svg",{width:o.width,height:o.height,style:{position:"absolute",top:0,left:0,pointerEvents:"none",zIndex:1}},e.createElement("defs",null,e.createElement("marker",{id:"easy_flow-arrow",viewBox:"0 0 10 10",refX:"8",refY:"5",markerWidth:"5",markerHeight:"5",orient:"auto"},e.createElement("path",{d:"M 0 0 L 10 5 L 0 10 z",className:"fill-gray-400 dark:fill-zinc-500"}))),t.map(i=>e.createElement("path",{key:i.id,"data-link":i.id,className:"stroke-gray-400 dark:stroke-zinc-500",fill:"none",strokeWidth:"1.5",markerEnd:"url(#easy_flow-arrow)",d:i.path}))),t.filter(i=>i.label).map(i=>e.createElement("div",{key:`${i.id}-label`,"data-link-label":i.id,className:"bg-surface-50 text-gray-500 dark:bg-surface-950 dark:text-gray-400",style:{position:"absolute",left:i.midX-10,top:i.midY-18,zIndex:3,fontSize:11,padding:"0 3px"}},i.label)),t.filter(i=>!i.placeholder).map(i=>e.createElement(vt,{key:i.id,link:i,dragging:!!n,onInsert:()=>r(i),onRemove:()=>a(i),onDrop:()=>l(i)}))),kt=ce;var pe="Your change was not saved because the server could not be reached.",wt=({base:t,token:o,fetch:n,onFlow:r,onError:a,onNotice:l})=>async(i,y,p)=>{let f=await n(t+i,{method:y,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:p&&JSON.stringify(p)}).catch(()=>null);if(!f)return l(null),a(pe);let m=await f.json().catch(()=>({}));if(!f.ok)return l(null),a(m.error||"That change was refused");a(null),l(m.notice||null),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json())};var me=(t,o,n)=>{let[r,a]=b(n),[l,i]=b(null),[y,p]=b(null),f=D(()=>wt({base:t,token:o,fetch:(...m)=>window.fetch(...m),onFlow:a,onError:i,onNotice:p}),[t,o]);return{flow:r,error:l,notice:y,send:f}},Ct=me;var H=(t,o,n,r)=>{let a=t.left-n.left+r.scrollLeft,l=t.top-n.top+r.scrollTop;return o==="left"?{x:a,y:l+t.height/2}:o==="right"?{x:a+t.width,y:l+t.height/2}:o==="top"?{x:a+t.width/2,y:l}:{x:a+t.width/2,y:l+t.height}},ue=(t,o)=>o==="top"?{x:t.x,y:t.y-3}:o==="left"?{x:t.x-3,y:t.y}:o==="right"?{x:t.x+3,y:t.y}:{x:t.x,y:t.y+3},fe=(t,o,n,r,a)=>{let l={straight:`M ${o.x} ${o.y} L ${n.x} ${n.y}`,turn:`M ${o.x} ${o.y} H ${n.x} V ${n.y}`,lane:`M ${o.x} ${o.y} V ${r} H ${n.x} V ${n.y}`,detour:`M ${o.x} ${o.y} H ${a} V ${n.y} H ${n.x}`}[t.route];return{...t,path:l,midX:t.route==="detour"?a:(o.x+n.x)/2,midY:t.route==="lane"?r:(o.y+n.y)/2}},ge=(t,o,n,r)=>{let[a,l]=b([]),[i,y]=b({width:"100%",height:"100%"});return Z(()=>{let p=()=>{let m=o.current?.getBoundingClientRect();m&&(y({width:o.current.scrollWidth,height:o.current.scrollHeight}),l(t.edges.flatMap(d=>{let h=n.current[d.source]?.getBoundingClientRect(),c=n.current[d.target]?.getBoundingClientRect();if(!h||!c)return[];let g=H(h,d.leaves,m,o.current),w=ue(H(c,d.enters,m,o.current),d.enters),v=(H(h,"bottom",m,o.current).y+H(c,"top",m,o.current).y)/2,B=d.leaves==="left"?Math.min(g.x,w.x)-28:Math.max(g.x,w.x)+28;return[fe(d,g,w,v,B)]})))};p(),window.addEventListener("resize",p);let f=new ResizeObserver(p);return o.current&&f.observe(o.current),()=>{window.removeEventListener("resize",p),f.disconnect()}},r),{links:a,extent:i}},St=ge;var Nt=(t,o)=>{let n=new Set(o),r=t,a=2;for(;n.has(r);)r=`${t}_${a++}`;return r};var xe={display:"flex",height:"100%",minHeight:0,fontSize:13},be={flex:1,overflow:"auto",position:"relative"},ve={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},ke={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},we={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},Ce=({port:t,onCancel:o})=>e.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:we},"Choose the step \u201C",t||"next","\u201D should lead to \u2014 ",e.createElement("button",{onClick:o,className:"text-accent-800 underline dark:text-accent-100",style:{border:"none",background:"none",cursor:"pointer",fontSize:12,padding:0}},"cancel")),Se=({base:t,token:o,initial:n})=>{let{flow:r,error:a,notice:l,send:i}=Ct(t,o,n),[y,p]=b(null),[f,m]=b(null),[d,h]=b(null),[c,g]=b(null),[w,v]=b(!1),B=j({}),E=j(null);T(()=>{let s=()=>{p(null),v(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),T(()=>{if(!w)return;let s=u=>{u.target.closest("[data-builder-panel], [data-open-panel]")||v(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[w]),T(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let Bt=D(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:Et,extent:Tt}=St(r,E,B,[r,y,Bt]),U=D(()=>{let s={};return r.violations.forEach(u=>{var k;return(s[k=u.node]||(s[k]=[])).push(u)}),s},[r.violations]),F=r.nodes.filter(s=>!s.loose),X=r.nodes.filter(s=>s.loose),$t=Math.max(0,...F.map(s=>s.row))+1,At=Math.max(0,...F.map(s=>s.column))+1,G=s=>{if(!d)return;let[u,k]=d;h(null),u!==s&&i("/edges","POST",{from:u,to:s,on:k||null})},N=r.nodes.find(s=>s.id===y),z=r.palette.find(s=>s.type===N?.type);return e.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:xe,onMouseUp:()=>g(null),onKeyDown:s=>{s.key==="Escape"&&(p(null),h(null),m(null))},tabIndex:-1},e.createElement("div",{ref:E,className:"bg-surface-50 dark:bg-surface-950",style:be,onClick:s=>{s.target===E.current&&(p(null),h(null),v(!1))}},d&&e.createElement(Ce,{port:d[1],onCancel:()=>h(null)}),e.createElement(ut,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>m({at:{x:16,y:52}}),onUndo:()=>{p(null),i("/undo","POST")},onRedo:()=>{p(null),i("/redo","POST")}}),e.createElement(kt,{links:Et,extent:Tt,dragging:c,onInsert:s=>m({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{p(null),i("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let u=c;g(null),i("/steps/"+u+"/move","PATCH",{from:s.source,to:s.target})}}),X.length>0&&e.createElement("div",{"data-loose":!0,className:"border border-dashed border-gray-300 bg-gray-50 dark:border-zinc-700 dark:bg-zinc-900",style:ve},e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginBottom:6}},"Not in the flow yet \u2014 drag one onto a connection"),X.map(s=>e.createElement("div",{key:s.id,style:{marginBottom:8}},e.createElement(V,{node:{...s,ref:u=>{B.current[s.id]=u},violations:U[s.id]||[],connected:[]},selected:y===s.id,armed:d&&d[0]===s.id?d[1]:null,connecting:!!d,onSelect:()=>d?G(s.id):p(s.id),onOpen:u=>window.location.assign(u),onArm:(u,k)=>{k.stopPropagation(),h([s.id,u])},onDragStart:()=>g(s.id),onDragEnd:()=>g(null)})))),e.createElement("div",{style:{...ke,gridTemplateColumns:`repeat(${At+1}, ${280/2}px)`,gridTemplateRows:`repeat(${$t}, auto)`}},F.map(s=>e.createElement("div",{key:s.id,style:{gridRow:s.row+1,gridColumn:`${s.column+1} / span 2`,zIndex:2}},s.placeholder&&e.createElement(ct,{node:{...s,ref:u=>{B.current[s.id]=u}},dragging:!!c,onFill:()=>m({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let u=c;g(null),u&&i("/edges","POST",{from:s.from,to:u,on:s.on})}}),!s.placeholder&&e.createElement(V,{node:{...s,ref:u=>{B.current[s.id]=u},violations:U[s.id]||[],connected:r.edges.filter(u=>u.source===s.id).map(u=>u.label||null)},selected:s.id===y,armed:d&&d[0]===s.id?d[1]:null,connecting:!!d&&d[0]!==s.id,onSelect:()=>d?G(s.id):p(s.id),onOpen:u=>window.location.assign(u),onArm:(u,k)=>{let q=E.current.getBoundingClientRect();m({from:s.id,on:u,at:{x:k.clientX-q.left+E.current.scrollLeft+8,y:k.clientY-q.top+E.current.scrollTop+8}})},onDragEnd:()=>g(null),onDragStart:()=>g(s.id)})))),f&&e.createElement(pt,{entries:r.palette,at:f.at,onDismiss:()=>m(null),onConnect:f.on!==void 0?()=>{h([f.from,f.on]),m(null)}:null,onPick:s=>{let u=f;m(null),i("/steps","POST",{id:Nt(s.type,r.nodes.map(k=>k.id)),type:s.type,from:u.from,to:u.to,on:u.on})}})),w&&!N&&e.createElement(xt,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:l,onClose:()=>v(!1),onCreate:()=>i("/versions","POST"),onPublish:()=>i("/publish","POST"),onSaveDetails:s=>i("/details","PATCH",{flow:s})}),N&&e.createElement(dt,{node:N,fields:z?.fields||{},holds:z?.records||{},labels:z?.labels||{},recordLabels:z?.record_labels||{},recordChoices:z?.record_choices||{},recordOutputsOf:z?.record_outputs_of||{},choices:nt(N,z),onClose:()=>p(null),onSave:s=>i(`/steps/${N.id}`,"PATCH",{config:s}),onDelete:()=>{p(null),i(`/steps/${N.id}`,"DELETE")}}))},zt=Se;R("easy_flow/flow-editor",zt);document.addEventListener("easy_flow:flow-named",t=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=t.detail)});ot(document,J);})();
@@ -14,12 +14,15 @@ module EasyFlow
14
14
  @guide = runner_for(running_definition)
15
15
  @progress = progress
16
16
  @guide.run(@progress)
17
+ return redirect_to run_location(run.waiting_on) if run&.waiting_on
18
+
17
19
  @answers = @progress.recorded
18
20
  @question = @guide.next_step(@answers, run: run, times: @progress.completed_at)
19
21
  @drawing = @guide.drawing_at(@answers, times: @progress.completed_at)
20
22
  flash.now[:alert] = @refused if @refused
21
23
  @waiting = waiting_on(@question)
22
24
  return render :step if @question
25
+ return redirect_to run_location(run.parent_run) if handed_back?
23
26
 
24
27
  render_completion
25
28
  end
@@ -31,6 +34,10 @@ module EasyFlow
31
34
 
32
35
  private
33
36
 
37
+ def handed_back?
38
+ run&.parent_run && run.parent_run.waiting_on != run
39
+ end
40
+
34
41
  def go_back
35
42
  guide = runner_for(running_definition)
36
43
  loop do
@@ -177,10 +177,19 @@ module EasyFlow
177
177
  objections = step_type.settings.objections(values)
178
178
  raise InvalidEdit, objections.join(", ") if objections.any?
179
179
 
180
+ foreign = foreign_flows(step_type, values)
181
+ raise InvalidEdit, foreign.join(", ") if foreign.any?
182
+
180
183
  refused = step_type.settings.keep(values)
181
184
  raise InvalidEdit, refused.join(", ") if refused.any?
182
185
  end.then { |values| step_type.settings.unkept(values) }
183
186
  end
187
+
188
+ def foreign_flows(step_type, values)
189
+ step_type.settings.fields.select { |name, type| %i[flow flows].include?(type) && values[name.to_s].present? }
190
+ .reject { |name, _type| Array(values[name.to_s]).all? { |id| flow.reaches?(Definition.find_by(id: id)) } }
191
+ .map { |name, _type| "#{step_type.settings.labels[name]} names another owner's flow" }
192
+ end
184
193
  end
185
194
  end
186
195
  end
@@ -30,6 +30,18 @@ const Control = ({ type, value, choices, onChange, onSettle }) => {
30
30
  </div>
31
31
  }
32
32
 
33
+ if (type === "flows") {
34
+ const chosen = Array.isArray(value) ? value : []
35
+ return <div style={{ display: "flex", flexDirection: "column", gap: 4 }} className="mb-3">
36
+ {offered(choices).map((choice) => (
37
+ <label key={choice.value} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
38
+ <Checkbox checked={chosen.includes(choice.value)} onChange={() => onSettle(toggled(chosen, choice.value))} />
39
+ <span>{choice.label}</span>
40
+ </label>
41
+ ))}
42
+ </div>
43
+ }
44
+
33
45
  if (type === "integer" || type === "float") {
34
46
  return <Input className="mb-3" type="number" step={type === "integer" ? "1" : "any"} value={value ?? ""}
35
47
  onChange={(e) => onChange(e.target.value)} onBlur={(e) => onSettle(e.target.value)} />
@@ -137,7 +137,7 @@ module EasyFlow
137
137
  end
138
138
 
139
139
  def naming_flows(node)
140
- step_type_for(node)&.settings&.fields.to_h.select { |_name, type| type == :flow }.keys.map(&:to_s)
140
+ step_type_for(node)&.settings&.fields.to_h.select { |_name, type| %i[flow flows].include?(type) }.keys.map(&:to_s)
141
141
  end
142
142
 
143
143
  def offered_flows
@@ -104,6 +104,10 @@ module EasyFlow
104
104
  live_version&.definition
105
105
  end
106
106
 
107
+ def reaches?(inner)
108
+ EasyFlow.host_named(host).owner_method.nil? || inner&.owner == owner
109
+ end
110
+
107
111
  def runner
108
112
  Runner.new(live_definition)
109
113
  end
@@ -93,7 +93,9 @@ module EasyFlow
93
93
  def questions_left(state)
94
94
  cursor = next_step(state) && step(next_step(state).id)
95
95
  left = 0
96
- while cursor && decided?(cursor, state)
96
+ visits = Hash.new(0)
97
+ while cursor && decided?(cursor, state) && visits[cursor.id].zero?
98
+ visits[cursor.id] += 1
97
99
  left += 1 if step_type(cursor)&.awaits_input?
98
100
  cursor = successor(cursor, state)
99
101
  end
@@ -126,12 +128,16 @@ module EasyFlow
126
128
  latest_times[cursor.id] = times[key] if times.key?(key)
127
129
  answered += 1 if step_type(cursor)&.awaits_input?
128
130
  end
129
- cursor = successor(cursor, state.merge(latest), latest_times)
131
+ cursor = successor(cursor, reached(state, recorded).merge(latest), latest_times)
130
132
  end
131
133
 
132
134
  [ recorded, nil ]
133
135
  end
134
136
 
137
+ def reached(state, recorded)
138
+ state.reject { |key, _value| step(key) && !recorded.include?(key) }
139
+ end
140
+
135
141
  def records_a_value?(node)
136
142
  type = step_type(node)
137
143
  type.present? && (type.awaits_input? || type.acts? || type.waits?)
@@ -158,6 +164,8 @@ module EasyFlow
158
164
  end
159
165
 
160
166
  def successor(node, state, times = {})
167
+ return node if step_type(node)&.comes_back?(node, state)
168
+
161
169
  leaving = @document.edges_from(node.id)
162
170
  port = step_type(node)&.route(node, state, times)
163
171
  taken = port.nil? ? leaving.first : leaving.find { |edge| edge.on.to_s == port.to_s }
@@ -17,7 +17,7 @@ module EasyFlow
17
17
 
18
18
  def self.endings_of(node)
19
19
  version = ChosenFlow.of(node).version
20
- Array(version&.definition.to_h["nodes"]).select { |step| step["type"] == "terminal" }.filter_map { |step| step["output"].presence }.uniq
20
+ Array(version&.definition.to_h["nodes"]).select { |step| step["type"] == "terminal" }.map { |step| step["output"].presence || "ended" }.uniq
21
21
  end
22
22
 
23
23
  def route(node, state)
@@ -0,0 +1,39 @@
1
+ module EasyFlow
2
+ class PlayFlows
3
+ include Step
4
+
5
+ step_name "Play flows"
6
+
7
+ setting :question, type: :string
8
+ setting :flows, type: :flows, required: true
9
+
10
+ names_by :question
11
+ awaits_input
12
+
13
+ plays_the_picked_flow
14
+
15
+ comes_back_while { |node, state| PlayFlows.visits(node, state) < PlayFlows.playable(node).size }
16
+
17
+ displays_by { |node, run| Asked.new(id: node.id.to_sym, text: node.config["question"], choices: PlayFlows.offered(node, run)) }
18
+
19
+ def self.visits(node, state)
20
+ state.keys.count { |key| key.to_s == node.id || key.to_s.start_with?("#{node.id}@") }
21
+ end
22
+
23
+ def self.playable(node)
24
+ Definition.where(id: Array(node.config["flows"])).select(&:live_version)
25
+ end
26
+
27
+ def self.offered(node, run)
28
+ return [] unless run
29
+
30
+ listed = Array(node.config["flows"]) - played(node, run)
31
+ playable(node).select { |flow| listed.include?(flow.id.to_s) && run.flow.reaches?(flow) }.sort_by { |flow| listed.index(flow.id.to_s) }.map { |flow| Choice.new(value: flow.id.to_s, label: flow.title) }
32
+ end
33
+
34
+ def self.played(node, run)
35
+ step = node.id.to_s.split("@").first
36
+ run.inner_runs.where(parent_step: step).or(run.inner_runs.where("parent_step LIKE ?", "#{Run.sanitize_sql_like(step)}@%")).pluck(:flow_id).map(&:to_s)
37
+ end
38
+ end
39
+ end
@@ -18,6 +18,8 @@ module EasyFlow
18
18
  end
19
19
 
20
20
  def record(id, value)
21
+ return play(id, value) if plays_the_picked_flow?(id)
22
+
21
23
  @run.record(id.to_sym, value)
22
24
  end
23
25
 
@@ -27,6 +29,7 @@ module EasyFlow
27
29
  chosen = ChosenFlow.of(node, run: @run)
28
30
  raise InnerFlowError, "The step #{node.id} chose no flow to run" if chosen.flow.nil? && EasyFlow.registry.fetch(node.type).chooses_flow_from_run?
29
31
  raise InnerFlowError, "The Flow step #{node.id} names a version that cannot run" if chosen.version&.out_of_service?
32
+ raise InnerFlowError, "The Flow step #{node.id} names another owner's flow" if chosen.flow && !@run.flow.reaches?(chosen.flow)
30
33
  Run.start(chosen.flow || raise(ActiveRecord::RecordNotFound), version: chosen.version)
31
34
  .tap { |inner| inner.update!(parent_run: @run, parent_step: node.id, owner: @run.owner) }.advance
32
35
  end
@@ -36,16 +39,10 @@ module EasyFlow
36
39
  return if @run.stopped_on_loop
37
40
  return if parent.recorded.key?(@run.parent_step.to_sym)
38
41
 
39
- ending = @run.output || ("ended" if chose_from_run?(parent))
40
- parent.record(@run.parent_step.to_sym, ending) if ending
42
+ parent.record(@run.parent_step.to_sym, @run.output || "ended")
41
43
  AdvanceParentJob.perform_later(parent, @run.parent_step)
42
44
  end
43
45
 
44
- def chose_from_run?(parent)
45
- type = parent.pinned_steps.dig(@run.parent_step, "type")
46
- EasyFlow.registry.registered?(type) && EasyFlow.registry.fetch(type).chooses_flow_from_run?
47
- end
48
-
49
46
  def finish(_state)
50
47
  @run
51
48
  end
@@ -53,6 +50,25 @@ module EasyFlow
53
50
  def discard_last
54
51
  @run.discard_last
55
52
  end
53
+
54
+ private
55
+
56
+ def plays_the_picked_flow?(id)
57
+ node = @run.digest.step(id)
58
+ node && EasyFlow.registry.registered?(node.type) && EasyFlow.registry.fetch(node.type).plays_the_picked_flow?
59
+ end
60
+
61
+ def play(id, flow_id)
62
+ return if @run.inner_runs.exists?(parent_step: id.to_s)
63
+ return unless offered?(id, flow_id)
64
+
65
+ Run.start(Definition.find(flow_id)).tap { |inner| inner.update!(parent_run: @run, parent_step: id.to_s, owner: @run.owner) }.advance
66
+ end
67
+
68
+ def offered?(id, flow_id)
69
+ node = @run.digest.step(id)
70
+ EasyFlow.registry.fetch(node.type).display_of(node.with(id: id.to_s), @run).choices.map(&:value).include?(flow_id.to_s)
71
+ end
56
72
  end
57
73
  end
58
74
  end
@@ -24,6 +24,7 @@ module EasyFlow
24
24
  @ends_here = false
25
25
  @begins_here = false
26
26
  @starts_a_flow = false
27
+ @plays_the_picked_flow = false
27
28
  end
28
29
 
29
30
  def output(name, type: :string, label: nil, values: nil, from: nil)
@@ -58,6 +59,10 @@ module EasyFlow
58
59
  @answer_labelling = labelling
59
60
  end
60
61
 
62
+ def comes_back_while(&return_rule)
63
+ @return_rule = return_rule
64
+ end
65
+
61
66
  def waits_until(&readiness)
62
67
  @readiness = readiness
63
68
  end
@@ -82,6 +87,10 @@ module EasyFlow
82
87
  @starts_a_flow = true
83
88
  end
84
89
 
90
+ def plays_the_picked_flow
91
+ @plays_the_picked_flow = true
92
+ end
93
+
85
94
  def chooses_flow_from_run(&chooser)
86
95
  @run_chooser = chooser
87
96
  end
@@ -140,9 +149,9 @@ module EasyFlow
140
149
 
141
150
  def to_step_type
142
151
  StepType.new(id: @id, step_name: @step_name, settings: settings, awaits_input: @awaits_input,
143
- ends_here: @ends_here, begins_here: @begins_here, starts_a_flow: @starts_a_flow, flow_chooser: @flow_chooser, run_chooser: @run_chooser, behaviour: @behaviour, routing: @routing,
152
+ ends_here: @ends_here, begins_here: @begins_here, starts_a_flow: @starts_a_flow, plays_the_picked_flow: @plays_the_picked_flow, flow_chooser: @flow_chooser, run_chooser: @run_chooser, behaviour: @behaviour, routing: @routing,
144
153
  display: @display, drawn_by: @drawn_by, naming_field: @naming_field, naming: @naming,
145
- outputs: @declared_outputs, outputs_named_by: @outputs_named_by, answer_check: @answer_check, answer_labelling: @answer_labelling, readiness: @readiness)
154
+ outputs: @declared_outputs, outputs_named_by: @outputs_named_by, answer_check: @answer_check, answer_labelling: @answer_labelling, readiness: @readiness, return_rule: @return_rule)
146
155
  end
147
156
 
148
157
  def settings
@@ -7,8 +7,8 @@ module EasyFlow
7
7
  attr_reader :id, :step_name, :settings, :naming_field, :naming, :outputs, :drawn_by
8
8
 
9
9
  def initialize(id:, step_name:, settings:, awaits_input:, behaviour:, routing:,
10
- ends_here: false, begins_here: false, starts_a_flow: false, flow_chooser: nil, run_chooser: nil, display: nil, drawn_by: nil,
11
- naming_field: nil, naming: nil, outputs: [], outputs_named_by: nil, answer_check: nil, answer_labelling: nil, readiness: nil)
10
+ ends_here: false, begins_here: false, starts_a_flow: false, plays_the_picked_flow: false, flow_chooser: nil, run_chooser: nil, display: nil, drawn_by: nil,
11
+ naming_field: nil, naming: nil, outputs: [], outputs_named_by: nil, answer_check: nil, answer_labelling: nil, readiness: nil, return_rule: nil)
12
12
  @id = id
13
13
  @step_name = step_name
14
14
  @settings = settings
@@ -16,6 +16,7 @@ module EasyFlow
16
16
  @ends_here = ends_here
17
17
  @begins_here = begins_here
18
18
  @starts_a_flow = starts_a_flow
19
+ @plays_the_picked_flow = plays_the_picked_flow
19
20
  @flow_chooser = flow_chooser
20
21
  @run_chooser = run_chooser
21
22
  @behaviour = behaviour
@@ -29,6 +30,7 @@ module EasyFlow
29
30
  @answer_check = answer_check
30
31
  @answer_labelling = answer_labelling
31
32
  @readiness = readiness
33
+ @return_rule = return_rule
32
34
  end
33
35
 
34
36
  def display_of(node, run = nil)
@@ -61,6 +63,10 @@ module EasyFlow
61
63
  @readiness.call(node, state, times)
62
64
  end
63
65
 
66
+ def comes_back?(node, state)
67
+ @return_rule&.call(node, state) || false
68
+ end
69
+
64
70
  def name_of(node)
65
71
  @naming&.call(node).presence || node.config[naming_field.to_s].presence || worked_out_flow_name(node)
66
72
  end
@@ -97,6 +103,10 @@ module EasyFlow
97
103
  @starts_a_flow
98
104
  end
99
105
 
106
+ def plays_the_picked_flow?
107
+ @plays_the_picked_flow
108
+ end
109
+
100
110
  def chooses_flow_from_run?
101
111
  !@run_chooser.nil?
102
112
  end
@@ -10,7 +10,7 @@ module EasyFlow
10
10
  end
11
11
 
12
12
  def violations
13
- structural_violations + unmet_requirements + missing_settings + missing_values + missing_flows + unrunnable_versions + circular_flows + beginnings + asked_for
13
+ structural_violations + unmet_requirements + missing_settings + missing_values + missing_flows + unrunnable_versions + foreign_flows + circular_flows + beginnings + asked_for
14
14
  end
15
15
 
16
16
  def structural_violations
@@ -140,7 +140,7 @@ module EasyFlow
140
140
  end
141
141
 
142
142
  def missing_flows
143
- starting_a_flow.reject { |node| ChosenFlow.of(node).flow }
143
+ (starting_a_flow.reject { |node| ChosenFlow.of(node).flow } + listing_flows.select { |node| listed_ids(node).size > listed_flows(node).size })
144
144
  .map { |node| Violation.new(node: node.id, problem: :missing_flow) }
145
145
  end
146
146
 
@@ -153,6 +153,32 @@ module EasyFlow
153
153
  version&.live? || version&.superseded? || false
154
154
  end
155
155
 
156
+ def foreign_flows
157
+ return [] unless @flow
158
+
159
+ (starting_a_flow.select { |node| ChosenFlow.of(node).flow }.reject { |node| @flow.reaches?(ChosenFlow.of(node).flow) } +
160
+ listing_flows.reject { |node| listed_flows(node).all? { |listed| @flow.reaches?(listed) } })
161
+ .map { |node| Violation.new(node: node.id, problem: :owned_by_another_owner) }
162
+ end
163
+
164
+ def listing_flows
165
+ @document.nodes.select { |node| flow_lists(node).any? }
166
+ end
167
+
168
+ def listed_flows(node)
169
+ Definition.where(id: listed_ids(node))
170
+ end
171
+
172
+ def listed_ids(node)
173
+ flow_lists(node).flat_map { |name| Array(node.config[name.to_s]) }.uniq
174
+ end
175
+
176
+ def flow_lists(node)
177
+ return [] unless @registry.registered?(node.type)
178
+
179
+ @registry.fetch(node.type).settings.fields.select { |_name, type| type == :flows }.keys
180
+ end
181
+
156
182
  def circular_flows
157
183
  return [] unless @flow
158
184
 
@@ -31,6 +31,7 @@ module EasyFlow
31
31
  EasyFlow::Count.register
32
32
  EasyFlow::Wait.register
33
33
  EasyFlow::FlowStep.register
34
+ EasyFlow::PlayFlows.register
34
35
 
35
36
  EasyFlow.check(:unrouted_value)
36
37
  EasyFlow.check(:unfollowed_path)
@@ -1,3 +1,3 @@
1
1
  module EasyFlow
2
- VERSION = "0.26.0"
2
+ VERSION = "0.28.0"
3
3
  end
data/lib/easy_flow.rb CHANGED
@@ -7,7 +7,7 @@ require "easy_flow/host_routes"
7
7
  require "easy_flow/engine"
8
8
 
9
9
  module EasyFlow
10
- FIELD_TYPES = %i[string integer float boolean select multi_select previous_step from_step list flow].freeze
10
+ FIELD_TYPES = %i[string integer float boolean select multi_select previous_step from_step list flow flows].freeze
11
11
 
12
12
  OUTPUT_TYPES = %i[string integer float boolean].freeze
13
13
  OPTIONAL_CHECKS = %i[unrouted_value unfollowed_path dead_end].freeze
@@ -158,6 +158,8 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
158
158
  - When the block returns `nil`, a blank `flow:`, or the id of no existing flow, `EasyFlow::InnerFlowError` is raised with a message naming the step, such as `The step pick chose no flow to run`, and no run is started. A retired or withdrawn version raises `EasyFlow::InnerFlowError` as for a Flow step, and a blank version number, one naming no version of the flow, or one naming a draft raises `ActiveRecord::RecordInvalid`.
159
159
  - When the started run ends with an output, that output is recorded under the step's id as for a Flow step. When it ends with no output, `"ended"` is recorded instead, and the parent run moves on. A type that routes on what is recorded declares an `output` whose `values:` include `"ended"` beside the outputs the chosen flows can end with. Ask the developer what `"ended"` should lead to.
160
160
  - When a loop stops the started run, nothing is recorded, not even `"ended"`, no job is queued, and the parent run stays waiting at the step. See step 5 of "Follow a run into the flow a Flow step started".
161
+ - `plays_the_picked_flow` — for an `awaits_input` type, the visitor's answer at a stored run is the id of a flow to play rather than a value to record. The engine starts a run of that flow on its live version as an inner run of the visitor's run, linked to the visit the answer was given at, and records nothing at that visit until the inner run ends. It plays a flow only when it is among the choices the step's `displays_by` block offers the run, so a pick of a flow the step no longer offers plays nothing, and it plays nothing at a visit that already has an inner run. Before writing such a type, ask the developer whether the built-in Play flows step is enough.
162
+ - `comes_back_while { |node, state| ... }` — after a visit to the step is recorded, the run comes back to the same step under its next visit key while the block returns a truthy value, and follows the step's connection out once it does not. The block is given the base node and the answers recorded on the path so far, every visit under its own key. Use it for a step a visitor repeats a set number of times; ask the developer what decides that number.
161
163
  - A flow set to save on finish or to save nothing, and an admin's preview, keep no stored run, so the block is not called and no run is started.
162
164
 
163
165
  ```ruby
@@ -366,7 +368,7 @@ Use this when a visitor's run reaches a Flow step and the app must take the visi
366
368
  - The new run is moved on at once past every step that acts without the visitor, the same as `run.advance`. When it stops at a Flow step of its own, that starts a further run the same way.
367
369
  - It is started once per visit to the step, so loading the page again or calling `advance` again starts no second one.
368
370
  - Its `owner` is the parent run's `owner` at the moment the new run is started, or blank when the parent run has none. Setting or changing the parent run's owner later does not change it. Its `label` and `status` are not copied and are left blank.
369
- - The parent run shows the visitor `Waiting for <name>.`, where the built-in Flow step's name is the named flow's title and version number, such as `Waiting for Intake, version 2.`, with no form, no Next button and no Back button, and stays on the Flow step until the new run ends.
371
+ - A visitor who loads the parent run's page is redirected to the new run's page, at the controller's own run address from `run_address`, and shown its first unanswered step. This holds whenever the parent run is stopped at the Flow step, so a visitor who opens the parent run's address while the new run is unfinished is sent to the new run. The parent run stays on the Flow step until the new run ends.
370
372
  - A flow set to save on finish or to save nothing, and an admin's preview, keep no stored run, so they start no run at a Flow step.
371
373
  - A flow cannot be published while one of its Flow steps names a missing flow, a version that cannot run, or a flow that leads back to it. A flow or version can still stop being runnable after the flow holding the step was published, such as a named flow being deleted or its version retired or withdrawn. Then, at the moment the run stops at the Flow step, one of these is raised and no run is started:
372
374
  - `ActiveRecord::RecordNotFound` when the named flow is gone.
@@ -378,19 +380,19 @@ Use this when a visitor's run reaches a Flow step and the app must take the visi
378
380
  - A job is queued with the app's Active Job adapter, on the `default` queue, that calls `advance` on the parent run. The parent run also moves on the next time its own page is loaded, whichever comes first, so a parent run whose job never runs still moves on when its visitor reloads.
379
381
  - The parent run follows the connection leaving the Flow step labelled with that output. An output that matches no connection's label ends the parent run at the Flow step.
380
382
  - When the parent run then ends and was itself started by a Flow step, its own output is handed to its parent the same way.
381
- - The new run's own `finished` is still called on its visitor's page load. The job does not call `finished` for the parent run, and if the parent run reaches its end, `finished` is called the next time its visitor loads its page.
382
- - A visitor who presses Back on the step after a Flow step removes the recorded output, and the parent run waits on the Flow step again. No second run is started, and the parent run moves on again only when the new run's page is loaded or `advance` is called on it.
383
+ - The new run's visitor is redirected to the parent run's page, at the controller's own run address, and the new run's own `finished` is not called. The parent run's page moves the parent run on itself without waiting for the job, then shows its next step, redirects into the next inner run when it stops at another Flow step, or calls the parent run's `finished` when it has ended. The job does not call `finished` for the parent run.
384
+ - A visitor who presses Back on the step after a Flow step removes the recorded output, and the parent run waits on the Flow step again. No second run is started. The parent run's page then redirects the visitor to the ended new run, whose page hands its output back again and redirects the visitor to the same step after the Flow step, so Back does not take a visitor past a Flow step.
383
385
  4. What holds when the new run ends with no output, which happens when it ends on an End step whose output was left blank, or ends without reaching an End step for any reason other than a loop stopping it, and the step that started it is not of a type that declares `chooses_flow_from_run` (for that type, `"ended"` is recorded and the parent run moves on):
384
- - Nothing is recorded in the parent run, and the parent run stays at the Flow step showing `Waiting for <name>.` on every page load. Nothing in the engine moves it on.
386
+ - Nothing is recorded in the parent run, and the parent run stays at the Flow step, so loading its page redirects the visitor to the new run. Nothing in the engine moves it on.
385
387
  - A job is still queued that fails with `EasyFlow::InnerFlowError` and a message naming the Flow step's id, such as `Nothing was recorded at the Flow step sub, so its run cannot move on`. The job does not move the parent run.
386
388
  - Each later page load of the new run, and each later `advance` on it, queues another job that fails the same way.
387
389
  - What happens to a failed job, such as a retry or a report, is decided by the app's Active Job adapter and error reporting.
388
- - The new run's own `finished` is still called on its visitor's page load.
390
+ - The new run's visitor is not sent back to the parent run, and the new run's own `finished` is still called on its visitor's page load.
389
391
  5. What holds when a loop stops the new run, which is when `inner.stopped_on_loop` names a step (see "Read a run and its answers"), whatever type the step that started it is:
390
392
  - Nothing is recorded in the parent run, not even `"ended"` for a step of a type that declares `chooses_flow_from_run`.
391
393
  - No job is queued, on the page load that stops the new run or on any later page load or `advance` of it, so no `EasyFlow::InnerFlowError` is raised for it.
392
- - The parent run stays at the step showing `Waiting for <name>.` on every page load, and `parent.waiting_on` is still the stopped run. Nothing in the engine moves it on.
393
- - The new run's own `finished` is still called on its visitor's page load.
394
+ - The parent run stays at the step, so loading its page redirects the visitor to the stopped run, and `parent.waiting_on` is still the stopped run. Nothing in the engine moves it on.
395
+ - The new run's visitor is not sent back to the parent run, and the new run's own `finished` is still called on its visitor's page load.
394
396
  6. What holds when an admin removes a flow:
395
397
  - Every run of the removed flow is deleted with it.
396
398
  - A run that one of those runs' Flow steps started is kept. Its `parent_run` becomes `nil` and its `parent_step` keeps the step id.
@@ -11,7 +11,7 @@ This local explains easy_flow and the words it uses. It makes no changes and giv
11
11
 
12
12
  easy_flow is a Rails engine for guided, branching flows. An admin builds a flow on a canvas by placing steps and connecting them, previews it, and publishes it. A visitor then walks the published flow one step at a time, and each answer decides which step comes next.
13
13
 
14
- Reach for it when an app needs a questionnaire, an intake form, or any decision path that an admin should be able to change without a deploy. The engine ships a start step, an end step, a question step, a checklist step, a multiple select step, a fill in the blank step, a file upload step, five steps that pick a branch from earlier answers, a Count step that counts earlier answers, a Wait step that holds a run until a time, and a Flow step that names another flow. Anything else a flow needs to ask, do, or wait for is a step type the app declares itself.
14
+ Reach for it when an app needs a questionnaire, an intake form, or any decision path that an admin should be able to change without a deploy. The engine ships a start step, an end step, a question step, a checklist step, a multiple select step, a fill in the blank step, a file upload step, five steps that pick a branch from earlier answers, a Count step that counts earlier answers, a Wait step that holds a run until a time, a Flow step that names another flow, and a Play flows step that lets a visitor play several flows in the order they choose. Anything else a flow needs to ask, do, or wait for is a step type the app declares itself.
15
15
 
16
16
  ## Interface
17
17
 
@@ -79,13 +79,14 @@ easy_flow declares no commands of its own for this local. Its surface is split b
79
79
  - **Wait** — a built-in waiting step that holds a run until a time. The admin picks the earlier step it counts from and gives a number of minutes after it, a time of day as HH:MM, or a date as YYYY-MM-DD. A date holds the run until the start of that day in the app's time zone and wins over the other two, and it holds even when the earlier step has no completion time. Otherwise a time of day holds the run until the next time that time of day comes after the earlier step's completion time, the same day when it is still to come and the next day when it is not, and it wins over minutes. With neither, the run is held for the minutes after the earlier step's completion time, and blank minutes hold it not at all. While the earlier step has no completion time and no date is set, the run stays held. It is counted from the completion time of the latest visit to that step. Like any waiting step, once its time has come the run moves past it the next time the visitor's page is loaded or the app moves the run on.
80
80
  - **Wait step name** — what a Wait step is called on its canvas card and in the "Waiting for" line a visitor sees, following the same order as the rule that holds the run. A date set names it by that date, such as "2026-12-01". Otherwise it is named by the time of day, or by the minutes followed by "minutes", then "after" and the earlier step, such as "09:00 after signup" or "30 minutes after signup", with "minute" in place of "minutes" when the number is 1, such as "1 minute after signup". A Wait step with no date, time of day or minutes has no name of its own, so its canvas card shows the step's id and the visitor sees "Waiting for Wait".
81
81
  - **Count** — a built-in step that acts on its own and records a whole number: how many visits to an earlier step the run has recorded. When the admin picks an answer, only visits that gave that answer are counted, and when the admin picks none, every visit is counted. The admin picks which of the earlier step's outputs the answer is read from, as described under chosen output. On the canvas the step is named "Count" followed by the earlier step and the answer it counts, such as "Count tests failed". Each visit to a Count step in a loop records a new count under its own visit key, so a Compare step after it can end the loop once the count reaches an amount.
82
- - **Flow step** — a built-in step that names another flow and the number of one of its versions. The admin picks the flow from the same host's flows, listed by title as the admin's list of flows shows them. The step has one connection for each different output the End steps of that version write, and none when no flow or version is picked or no End step there has an output. On the canvas its card is named after the flow's title and the version, such as "Onboarding, version 2", and is marked "Runs a flow". Once a flow is picked, clicking the card opens that flow's canvas, and the card's Settings button opens the step's own settings instead. A canvas opened from a Flow step's card shows a "Back to" button named after the title of the flow it was opened from, or its slug when it has no title, which returns to that flow's canvas. The button only appears when that flow belongs to the same host, and to the same owner when the host has one. While the admin is connecting steps, clicking the card picks it as the connection's target. A run that reaches a Flow step stops there. When the run is a stored run, it starts one inner run of the named flow on the picked version. A run that carries its answers in the page, and an admin's preview, stop at the Flow step, start no run, and never move past it.
82
+ - **Flow step** — a built-in step that names another flow and the number of one of its versions. The admin picks the flow from the same host's flows, listed by title as the admin's list of flows shows them. The step has one connection for each different output the End steps of that version write, and none when no flow or version is picked or no End step there has an output. On the canvas its card is named after the flow's title and the version, such as "Onboarding, version 2", and is marked "Runs a flow". Once a flow is picked, clicking the card opens that flow's canvas, and the card's Settings button opens the step's own settings instead. A canvas opened from a Flow step's card shows a "Back to" button named after the title of the flow it was opened from, or its slug when it has no title, which returns to that flow's canvas. The button only appears when that flow belongs to the same host, and to the same owner when the host has one. While the admin is connecting steps, clicking the card picks it as the connection's target. A run that reaches a Flow step stops there. When the run is a stored run, it starts one inner run of the named flow on the picked version, and a visitor on the run's page is taken to that inner run's first unanswered step. A run that carries its answers in the page, and an admin's preview, stop at the Flow step, start no run, and never move past it.
83
+ - **Play flows step** — a built-in step (`play_flows`) that lists several flows and lets a visitor play each one once, in the order they choose. The admin ticks the flows from the same host's flows, and only that owner's flows when the host has an owner, and fills in the question asked above them. A visitor whose stored run reaches the step picks one of the listed flows not yet played, and it plays as an inner run of the visitor's run, linked to that visit of the step, the same way a Flow step's inner run is. When that inner run ends, the step records its output, or `ended`, under the visit's key, and the visitor is back at the step under its next visit with only the flows not yet played offered. A pick of a flow already played plays nothing. Once every listed flow has been played the run follows the step's one connection out, and a listed flow that no longer exists or has no live version is neither offered nor waited for. Its card shows "missing flow" while a listed flow no longer exists, and in a host that names an owner, saving the step or publishing its flow is refused while it lists another owner's flow. A run that carries its answers in the page, and an admin's preview, offer no flows at the step.
83
84
  - **Unrunnable Flow step** — a Flow step whose picked flow or version cannot be run. Its card shows "missing flow" when no flow is picked or the picked flow no longer exists, and "unrunnable version" when the picked flow has no version with that number or that version is a draft, retired or withdrawn. These are always on, unlike the optional checks below, and the flow holding such a step cannot be saved as a new version or published until it is fixed.
84
85
  - **Circular Flow step** — a Flow step that leads back to the flow it sits in. That is either a Flow step naming its own flow, or one naming a flow whose picked version holds a Flow step that leads back the same way, through any number of flows. Each flow along the way is read at the version its Flow step picked. Its card shows "circular", and like an unrunnable Flow step this is always on and keeps the flow from being saved as a new version or published until it is fixed.
85
86
  - **Flow-starting step type** — a step type of the app's own that starts another flow the way the Flow step does. By default it runs the flow and version named in its settings called flow and version. It may instead carry a rule that works out the flow and version from its other settings, and that choice is then used everywhere a Flow step's choice is: starting the inner run, the unrunnable and circular checks, and the canvas link to that flow. When the step type gives the step no name of its own, the step is named after the flow and version that rule works out, the same way a Flow step is, such as "Onboarding, version 2", on its canvas card and in the "Waiting for" line a visitor sees. Everything said below about a Flow step's inner run and handing back applies to it too, except where a run-chosen flow says otherwise.
86
87
  - **Run-chosen flow** — a flow-starting step type of the app's own that chooses the flow and version from the run it is in rather than from its settings, so two runs of one flow can start two different inner flows from the same step. Where there is no run, such as on the canvas, it has no chosen flow. Its steps are left out of the unrunnable and circular checks, so a flow holding one can be published with no flow in its settings. A run that reaches it and has no flow chosen fails with an error naming the step. When its inner run ends with no output, the step records `ended` and its run moves on.
87
88
  - **Inner run** — the run a Flow step starts. It records which run started it and at which step, and that run holds at most one inner run for each Flow step, however often it is moved on. It belongs to the same owner as the run that started it. When the picked version has been retired or withdrawn, no inner run is started and the run that reached the Flow step fails with an error naming that step. While the run that started it is stopped at the Flow step, that run can name the inner run it is waiting on, and it names none when it is stopped at any other step or has finished. Its steps that act on their own run as soon as it starts, and it stops at its first step that asks for input or waits.
88
- - **Handing back** — what happens when an inner run ends. Its flow output is recorded in the run that started it, as that run's answer at the Flow step, and a background job then moves that run on along the connection labelled with the output. An inner run that ended with no output records nothing in the run that started it, and the background job then fails with an error naming the Flow step, so that run does not move on. An inner run that a loop stopped hands nothing back and starts no background job, so the run that started it stays stopped at the Flow step and still names that inner run as the one it is waiting on. An output is handed back only once, so an inner run moved on again after it ended changes nothing. Because the move happens in a background job, the app must run its jobs for a run to go past a Flow step.
89
+ - **Handing back** — what happens when an inner run ends. Its flow output is recorded in the run that started it, as that run's answer at the Flow step, and a background job then moves that run on along the connection labelled with the output. The inner run's visitor is taken back to the run that started it, whose page moves it on without waiting for the job and shows its next step. An inner run that ended with no output records nothing in the run that started it, and the background job then fails with an error naming the Flow step, so that run does not move on. An inner run that a loop stopped hands nothing back and starts no background job, so the run that started it stays stopped at the Flow step and still names that inner run as the one it is waiting on. An output is handed back only once, so an inner run moved on again after it ended changes nothing. Because the move otherwise happens in a background job, the app must run its jobs for a run nobody is visiting to go past a Flow step.
89
90
  - **Waiting step** — a step whose type carries a rule saying whether what it waits for has happened, such as a payment clearing or a document being signed. A run that reaches it stops there, and the visitor sees "Waiting for" followed by the step's name, with no form to submit. Once the rule says it has happened, the run moves past the step the next time the visitor's page is loaded or the app moves the run on, and the step is recorded as `true`.
90
91
  - **Check again** — a button a visitor sees under the "Waiting for" line in a flow that carries its answers in the page. It loads the step page again with the answers and their completion times, so the run moves past a waiting step whose rule now says it has happened, such as a Wait step whose time has come. A stored run shows no such button, since loading its page again does the same.
91
92
  - **Registry** — the list of step types the app has registered, alongside the built-in ones. A step whose type is not registered is not shown or run as that type, and its answers are not checked.
@@ -97,7 +98,7 @@ easy_flow declares no commands of its own for this local. Its surface is split b
97
98
  - **Stored run** and **carried answers** — the two ways a visitor's progress is held. A stored run is saved by the app and found again by its id. A flow that keeps nothing carries the answers given so far, and their completion times, in the page itself and sends them along with each step.
98
99
  - **Visit entry** — what a stored run keeps for one visit: the value the step recorded and the step's completion time, held together as one entry. Reading a run's answers gives each visit's value, and reading its completion times gives each visit's time. A run stored before entries existed holds bare values, and the engine's migration wraps each one in an entry with no completion time, and running that migration back unwraps them.
99
100
  - **Answers** — the recorded values of a run, keyed by visit. The next step is always worked out from these values, their completion times and the connections in the pinned version.
100
- - **Loop** — a connection that leads back to a step the run has already passed, so that step is visited again. Branching steps read the most recent answer to a step, so a loop ends when an answer sends the run down a different connection. Reading a finished run again follows the same connections it took at the time, since each branching step reads the answers as they stood at that visit.
101
+ - **Loop** — a connection that leads back to a step the run has already passed, so that step is visited again. Branching steps read the most recent answer to a step recorded before them on the run's path, and never an answer a step further along recorded, so a loop ends when an answer sends the run down a different connection. Reading a finished run again follows the same connections it took at the time, since each branching step reads the answers as they stood at that visit.
101
102
  - **Visit** — one time a run reaches a step. The first visit is keyed by the step id alone, and each later visit is keyed by the step id, an `@`, and the visit number, such as `size@2`. Every visit's value and completion time are kept, and a finished run lists each one.
102
103
  - **Stopped loop** — a run that comes back to a step with no new answer given since its last visit there ends at that point rather than cycling forever. Only answers the visitor gives count, so a loop made only of steps that act on their own stops the first time it comes round. A stored run stopped this way names the step the loop came back to, and does not count as having ended at an End step. An inner run stopped this way leaves the run that started it at its Flow step, as described under handing back.
103
104
  - **Ended at an End step** — whether a stored run's path finished on an End step, or on a step of the app's own type that ends a flow. It does not while the run is still going or held at a step, when a loop stopped it, or when it stopped at a step with no connection to follow.
@@ -55,7 +55,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
55
55
  - `visitor_authorization_method` — a method on the base controller, called with the flow, returning true when the visitor may run it. With none set, every visitor is refused.
56
56
  - `refusal_method` — a method on the base controller, called with the refusal error when a visitor is refused or a flow is unpublished or withdrawn. With none set, the response is `404 Not Found`.
57
57
  - `owner_method` — a method on the base controller, called with no arguments, returning the record whose flows the current request works with, such as the signed-in account. When set, the admin pages list, create and edit only that record's flows, a visitor runs only that record's flows, and two records may each hold a flow with the same slug. With none set, every flow in the host is shared by everyone who reaches it. Ask the developer whether each host's flows belong to one record or are shared.
58
- - `offers` — the step types this host's admins can add from the canvas palette, as a list of step type names. With none set, every registered step type is offered. The engine's own names are `question`, `checklist`, `multiple_select`, `fill_in_the_blank`, `file_upload`, `condition`, `switch`, `compare`, `compare_two_values`, `all_or_any`, `count`, `wait` and `flow_step`, `file_upload` being the File upload step, which needs the file store from step 10, `compare_two_values` being the Compare two values step, which compares the outputs of two earlier steps, `all_or_any` being the All of or Any of step, which decides whether all or any of several such comparisons hold, `count` being the Count step, which counts how often an earlier step was answered, `wait` being the Wait step, which holds a run until a time counted from an earlier answer or until a date, and `flow_step` being the Flow step, which names one of the same host's flows. A step type the app declares is named by the id it was declared with, or after its class when it is a step class, so `Steps::Notify` is `notify`. The End step is offered whether it is listed or not, and the Start step is never offered. Ask the developer which step types each host should offer.
58
+ - `offers` — the step types this host's admins can add from the canvas palette, as a list of step type names. With none set, every registered step type is offered. The engine's own names are `question`, `checklist`, `multiple_select`, `fill_in_the_blank`, `file_upload`, `condition`, `switch`, `compare`, `compare_two_values`, `all_or_any`, `count`, `wait`, `flow_step` and `play_flows`, `file_upload` being the File upload step, which needs the file store from step 10, `compare_two_values` being the Compare two values step, which compares the outputs of two earlier steps, `all_or_any` being the All of or Any of step, which decides whether all or any of several such comparisons hold, `count` being the Count step, which counts how often an earlier step was answered, `wait` being the Wait step, which holds a run until a time counted from an earlier answer or until a date, `flow_step` being the Flow step, which names one of the same host's flows, and `play_flows` being the Play flows step, which lists several of the same host's flows for a visitor to play in the order they choose. A step type the app declares is named by the id it was declared with, or after its class when it is a step class, so `Steps::Notify` is `notify`. The End step is offered whether it is listed or not, and the Start step is never offered. Ask the developer which step types each host should offer.
59
59
 
60
60
  Each method named here must exist on the base controller. Ask the developer to point at it or write it. Do not invent its logic.
61
61
  8. Mount the engine in `config/routes.rb`, once per host. The host name in `defaults` must match a name declared in step 7. When there is more than one mount, give each an `as:` name:
@@ -89,7 +89,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
89
89
  - The store answers `name_of(reference)`, called with a reference `keep` returned. It returns the name the visitor's completion page shows for that answer. When it returns nothing, the page shows the reference itself.
90
90
  - With no store set, a visitor who submits a file at any step of a stored run gets the app's error page, raised as `EasyFlow::NoFileStore`.
91
91
  11. Leave `EasyFlow.check` out unless the developer asks for it. The engine already turns on `:unrouted_value`, `:unfollowed_path` and `:dead_end` at boot. Any other name raises `EasyFlow::UnknownCheck` when the app boots.
92
- 12. Check that the app loads Active Job (`require "rails/all"` or `require "active_job/railtie"` in `config/application.rb`) and has a queue adapter that runs jobs from the `default` queue. When a run started by a Flow step, or by a step type of the app's own that starts another flow, ends, the engine records its output in the run it was started from and queues a job on `default` that moves that run on. If no adapter runs that queue, the run that holds the step stays at it. If `config.active_job.queue_adapter` is not set for an environment, ask the developer which adapter to use there.
92
+ 12. Check that the app loads Active Job (`require "rails/all"` or `require "active_job/railtie"` in `config/application.rb`) and has a queue adapter that runs jobs from the `default` queue. When a run started by a Flow step, or by a step type of the app's own that starts another flow, ends, the engine records its output in the run it was started from and queues a job on `default` that moves that run on. If no adapter runs that queue, the run that holds the step stays at it until its visitor next loads its page. If `config.active_job.queue_adapter` is not set for an environment, ask the developer which adapter to use there.
93
93
  13. Check `config.time_zone` in `config/application.rb`. A Wait step's time of day and date, and the "Hour completed" and "Weekday completed" outputs every completed step offers, are read in that zone, and with none set it is UTC. If it is not set, ask the developer which zone the app's admins mean when they write a time.
94
94
  14. Start the server and open `<mount path>/manage/flows` for each host.
95
95
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: easy_flow
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -123,6 +123,7 @@ files:
123
123
  - app/models/easy_flow/name.rb
124
124
  - app/models/easy_flow/node.rb
125
125
  - app/models/easy_flow/output.rb
126
+ - app/models/easy_flow/play_flows.rb
126
127
  - app/models/easy_flow/progress.rb
127
128
  - app/models/easy_flow/progress/kept.rb
128
129
  - app/models/easy_flow/progress/loose.rb