easy_flow 0.32.0 → 0.33.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 +4 -4
- data/app/assets/builds/easy_flow/canvas.js +1 -1
- data/app/controllers/concerns/easy_flow/manage/draws_canvas.rb +5 -2
- data/app/controllers/easy_flow/flows_controller.rb +3 -0
- data/app/controllers/easy_flow/manage/flows_controller.rb +1 -0
- data/app/javascript/easy_flow/canvas/Canvas.jsx +7 -5
- data/app/javascript/easy_flow/canvas/Connector.jsx +5 -5
- data/app/javascript/easy_flow/canvas/ConnectorLayer.jsx +3 -3
- data/app/javascript/easy_flow/canvas/Panel.jsx +7 -3
- data/app/javascript/easy_flow/canvas/StepCard.jsx +15 -4
- data/app/javascript/easy_flow/canvas/TypePicker.jsx +2 -2
- data/app/models/easy_flow/canvas.rb +23 -13
- data/app/models/easy_flow/change.rb +8 -0
- data/app/models/easy_flow/data_step.rb +33 -0
- data/app/models/easy_flow/inputs.rb +24 -0
- data/app/models/easy_flow/progress/kept.rb +4 -0
- data/app/models/easy_flow/progress/loose.rb +4 -0
- data/app/models/easy_flow/read_data.rb +11 -0
- data/app/models/easy_flow/runner.rb +9 -1
- data/app/models/easy_flow/step_type/declaration.rb +5 -1
- data/app/models/easy_flow/step_type.rb +3 -2
- data/app/models/easy_flow/write_data.rb +11 -0
- data/app/views/easy_flow/flows/refused.html.erb +3 -0
- data/app/views/easy_flow/manage/flows/show.html.erb +5 -1
- data/db/migrate/20261104184315_remove_kind_from_easy_flow_definitions.rb +2 -0
- data/lib/easy_flow/engine.rb +2 -0
- data/lib/easy_flow/host.rb +17 -3
- data/lib/easy_flow/version.rb +1 -1
- data/lib/easy_flow.rb +1 -1
- data/the_local/agents/easy_flow-develop.md +29 -3
- data/the_local/agents/easy_flow-info.md +10 -1
- data/the_local/agents/easy_flow-install.md +23 -6
- data/the_local/interface.yml +6 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c374968459676a34d96c347eabe25dea521857c679fe668822f131d9d33c53de
|
|
4
|
+
data.tar.gz: bea63ffaf73c8478a06d9b3873075707731dd0d0dc9c1383fc455ab6be8fc393
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee80c96f1d1be85e5dcf44cd3223649276d658357b4f1392347aaa7b63c5ce5c7d1d5715f4504924c527e49c8f3f17914e848a27426bb0303cf0096b43aacbce
|
|
7
|
+
data.tar.gz: d1ed92fb3d91b9e6e8521c67c9bfa17b45acaf36307ea66ebe4e07562158e88acc7cac388bfc7ae0cb1de06539c1e423439c3b44260cb71c0233c090e26d8722
|
|
@@ -1 +1 @@
|
|
|
1
|
-
(()=>{var Lt=globalThis.ReactDOM;var{createPortal:Be,createRoot:Q,flushSync:Te,hydrateRoot:Ee,preconnect:Pe,prefetchDNS:De,preinit:$e,preinitModule:Ae,preload:_e,preloadModule:Le,requestFormReset:Ie,unstable_batchedUpdates:Me,useFormState:Oe,useFormStatus:He,version:Fe}=Lt;var R=globalThis.React,e=R,{Children:We,Component:Ve,Fragment:Ue,PureComponent:Ye,StrictMode:Xe,Suspense:Ge,cloneElement:qe,createContext:Je,createElement:Ke,createRef:Ze,forwardRef:Qe,isValidElement:Re,lazy:to,memo:eo,startTransition:oo,useCallback:ro,useContext:no,useDebugValue:so,useDeferredValue:ao,useEffect:D,useId:io,useImperativeHandle:lo,useLayoutEffect:tt,useMemo:$,useReducer:co,useRef:U,useState:b,useSyncExternalStore:po,useTransition:uo}=R;var et=new Map,M=new Map,ot=(t,o)=>et.set(t,o),rt=()=>{M.forEach(t=>t.unmount()),M.clear()},nt=(t,o)=>t.querySelectorAll("[data-react-ui]").forEach(n=>{if(M.has(n))return;let r=et.get(n.dataset.reactUi),a=o(n);M.set(n,a),a.render(e.createElement(r,JSON.parse(n.dataset.props)))});var st=(t,o)=>{let n=()=>nt(t,o);t.addEventListener("turbo:load",n),t.addEventListener("turbo:frame-load",n),t.addEventListener("turbo:before-cache",rt),t.readyState==="loading"?t.addEventListener("DOMContentLoaded",n):n()};var N=(...t)=>t.filter(Boolean).join(" ");var It={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},Mt={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},Ot=({variant:t="primary",size:o="md",className:n,children:r,...a})=>{let l=a.href?"a":"button";return e.createElement(l,{...a,className:N("ks-button",It[t],Mt[o],n)},r)},x=Ot;var Ht="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",Ft="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",jt="border border-accent-600 bg-accent-600 text-white",Wt=({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?jt:o?Ft:Ht,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},t||"next"),at=Wt;var Vt={width:240,boxSizing:"border-box",borderRadius:8},Ut={padding:"8px 14px",textAlign:"center",fontWeight:600},Yt={fontWeight:600,lineHeight:1.3},Xt=(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"},Gt=({node:t,selected:o,armed:n,connecting:r,onSelect:a,onOpen:l,onArm:i,onDragEnd:d,onDragStart:m})=>{let c=t.begins_here||t.ends_here,g=t.begins_here,u=t.ends_here?[]:t.ports,y=r&&!g;return e.createElement("div",{ref:t.ref,"data-step":t.id,draggable:!g,onDragStart:p=>{p.dataTransfer.effectAllowed="move",p.dataTransfer.setData("text/plain",t.id),m()},onDragEnd:d,onClick:t.opens&&!r?()=>l(t.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Xt(y,t.violations.length>0,o)}`,style:{...Vt,padding:c?0:"12px 14px",cursor:g?"default":r?"crosshair":"grab"}},c&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:Ut},t.label),!c&&e.createElement("div",{style:Yt},t.label),!c&&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:p=>{p.stopPropagation(),a()}},"Settings"),t.violations.map(p=>e.createElement("div",{key:p.problem+p.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},p.problem.replace(/_/g," "),p.detail?`: ${p.detail}`:"")),u.length>0&&e.createElement("div",{style:{padding:t.begins_here?"0 14px 8px":"10px 0 0"}},u.map(p=>e.createElement(at,{key:p,name:p,connected:t.connected.includes(p),connecting:r,armed:n===p,onArm:v=>i(p,v)}))))},Y=Gt;var X=(t,o)=>t.includes(o)?t.filter(n=>n!==o):[...t,o],it=(t,o)=>({...o?.choices||{},...t?.choices||{}});var qt=({as:t="input",disabled:o,className:n,...r})=>e.createElement(t,{...r,disabled:o,className:N("ks-input",o&&"ks-input-disabled",n)}),T=qt;var Jt=t=>e.createElement(T,{...t,as:"select"}),lt=Jt;var Kt=({className:t,...o})=>e.createElement("input",{type:"checkbox",...o,className:N("ks-checkbox",t)}),O=Kt;var dt=t=>(t||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),Zt=({type:t,value:o,choices:n,onChange:r,onSettle:a,onPick:l,pictureUrl:i})=>{if(t==="picture")return e.createElement("div",{className:"mb-3"},o&&i&&e.createElement("img",{src:i,alt:"The step's picture",style:{maxWidth:"100%"}}),o&&e.createElement("button",{type:"button","data-remove-picture":!0,className:"ks-button ks-button-secondary ks-button-sm",onClick:()=>a("")},"Remove picture"),e.createElement("input",{type:"file",accept:"image/*",onChange:d=>d.target.files[0]&&l(d.target.files[0])}));if(t==="boolean")return e.createElement(O,{checked:!!o,onChange:d=>a(d.target.checked)});if(t==="select"||t==="previous_step"||t==="from_step"||t==="flow")return e.createElement(lt,{className:"mb-3",value:o??"",onChange:d=>a(d.target.value)},e.createElement("option",{value:""}),dt(n).map(d=>e.createElement("option",{key:d.value,value:d.value},d.label)));if(t==="multi_select"){let d=Array.isArray(o)?o:[],m=c=>a(X(d,c));return e.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(c=>e.createElement("label",{key:c,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(O,{checked:d.includes(c),onChange:()=>m(c)}),e.createElement("span",null,c))))}if(t==="flows"){let d=Array.isArray(o)?o:[];return e.createElement("div",{style:{display:"flex",flexDirection:"column",gap:4},className:"mb-3"},dt(n).map(m=>e.createElement("label",{key:m.value,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(O,{checked:d.includes(m.value),onChange:()=>a(X(d,m.value))}),e.createElement("span",null,m.label))))}return t==="integer"||t==="float"?e.createElement(T,{className:"mb-3",type:"number",step:t==="integer"?"1":"any",value:o??"",onChange:d=>r(d.target.value),onBlur:d=>a(d.target.value)}):e.createElement(T,{className:"mb-3",type:"text",value:o??"",onChange:d=>r(d.target.value),onBlur:d=>a(d.target.value)})},H=Zt;var ct=(t,o,n,r)=>t.map((a,l)=>l===o?{...a,[n]:r}:a);var Qt=(t,o,n,r)=>{let a=(t||{})[n],l=(o||{})[n];return l?(a||{})[r[l]]:a},Rt=({holds:t,labels:o,rows:n,choices:r,outputsOf:a,onChange:l,onSettle:i})=>{let d=Array.isArray(n)?n:[],m=(c,g,u,y)=>{let p=ct(d,c,g,u);y?i(p):l(p)};return e.createElement("div",{style:{marginBottom:12}},d.map((c,g)=>e.createElement("div",{key:g,className:"rounded-md border border-gray-200 dark:border-zinc-700",style:{padding:"8px 8px 2px",marginBottom:6}},Object.entries(t).map(([u,y])=>e.createElement("label",{key:u,style:{display:"block"}},e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[u]||u),e.createElement(H,{type:y,value:c[u],choices:Qt(r,a,u,c),onChange:p=>m(g,u,p,!1),onSettle:p=>m(g,u,p,!0)}))),e.createElement(x,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>i(d.filter((u,y)=>y!==g))},"Remove"))),e.createElement(x,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>i([...d,{}])},"Add"))},pt=Rt;var te=({as:t="div",className:o,children:n,...r})=>e.createElement(t,{...r,className:N("ks-panel",o)},n),_=te;var F=(t,o)=>({className:n,children:r,...a})=>e.createElement(t,{...a,className:N(o,n)},r),I=F("label","ks-label"),rr=F("p","ks-hint"),nr=F("p","ks-error"),sr=F("span","ks-required");var ee={width:280,padding:20,overflowY:"auto"},oe=({node:t,fields:o,holds:n,labels:r,recordLabels:a,recordChoices:l,recordOutputsOf:i,choices:d,onSave:m,onDelete:c,onClose:g,onPickPicture:u,refusal:y})=>{let[p,v]=b(t.config),[k,B]=b(t.picture_url);D(()=>B(t.picture_url),[t.id,t.picture_url]),D(()=>v(t.config),[t.id,t.config]);let S=h=>{v(h),JSON.stringify(h)!==JSON.stringify(t.config)&&m(h)};return e.createElement(_,{as:"aside",style:ee,"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:g,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),y&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{fontWeight:600,marginBottom:12},"data-refusal":!0},y),Object.entries(o).map(([h,z])=>e.createElement(I,{key:h},e.createElement("span",{style:{display:"block",marginBottom:3}},r[h]||h),z==="list"?e.createElement(pt,{holds:n[h]||{},labels:a[h]||{},rows:p[h],choices:{...(l||{})[h],...(t.record_choices||{})[h]},outputsOf:(i||{})[h],onChange:w=>v({...p,[h]:w}),onSettle:w=>S({...p,[h]:w})}):e.createElement(H,{type:z,value:p[h],choices:d[h],pictureUrl:k,onChange:w=>v({...p,[h]:w}),onSettle:w=>S({...p,[h]:w}),onPick:async w=>{let L=await u(w);L&&(B(L.url),S({...p,[h]:L.reference}))}}))),e.createElement(x,{variant:"danger",size:"sm",className:"w-full",onClick:c},"Delete step"))},ut=oe;var re={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},ne="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",se="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",ae=({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?se:ne,style:re},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")),mt=ae;var ie=({entries:t,at:o,onPick:n,onConnect:r,onDismiss:a})=>e.createElement(_,{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")),ft=ie;var le={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},gt=({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),de=({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:le,title:"Add a step",onClick:n},"+"),e.createElement(gt,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:t,onUse:r}),e.createElement(gt,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),yt=de;var ht=t=>{let o=t.problem.replace(/_/g," ");return t.node?`\u201C${t.node}\u201D ${o}`:o};var xt=({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 ce={width:280,padding:20,overflowY:"auto"},G={fontWeight:600,marginBottom:6,marginTop:14},j={fontSize:12,marginBottom:4,lineHeight:1.4},bt={display:"block",marginBottom:3},vt=(t,o,n)=>r=>{r.target.value!==(t[o]??"")&&n({[o]:r.target.value})},pe=({flow:t,changes:o,problems:n,refusal:r,notice:a,onCreate:l,onPublish:i,onSaveDetails:d,onClose:m})=>e.createElement(_,{as:"aside",style:ce,"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:m,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},xt(t)),e.createElement("h2",{style:G},"Problems"),r&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...j,fontWeight:600},"data-refusal":!0},r),a&&!r&&e.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...j,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(c=>e.createElement("p",{key:`${c.node}-${c.problem}`,className:"text-amber-700 dark:text-amber-400",style:j,"data-problem":!0},"\u26A0 ",ht(c))),e.createElement("h2",{style:G},"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((c,g)=>e.createElement("p",{key:g,style:j,"data-change":!0},c)),e.createElement("h2",{style:G},"Details"),e.createElement(I,null,e.createElement("span",{style:bt},"Title"),e.createElement(T,{className:"mb-3",defaultValue:t.title??"",onBlur:vt(t,"title",d),"data-flow-title":!0})),e.createElement(I,null,e.createElement("span",{style:bt},"Start label"),e.createElement(T,{className:"mb-3",defaultValue:t.start_label??"",onBlur:vt(t,"start_label",d),"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"))),kt=pe;var wt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},ue=({link:t,onInsert:o,onRemove:n,onDrop:r,dragging:a})=>{let[l,i]=b(!1),d=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:m=>{m.preventDefault(),m.dataTransfer.dropEffect="move"},onDrop:m=>{m.preventDefault(),i(!1),r()}},e.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%",opacity:d?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:wt},"+"),!a&&e.createElement(x,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:wt},"\xD7")))},Ct=ue;var me=({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(Ct,{key:i.id,link:i,dragging:!!n,onInsert:()=>r(i),onRemove:()=>a(i),onDrop:()=>l(i)}))),St=me;var Nt="Your change was not saved because the server could not be reached.",zt=({base:t,token:o,fetch:n,onFlow:r,onError:a,onNotice:l})=>async(i,d,m)=>{let c=await n(t+i,{method:d,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:m&&JSON.stringify(m)}).catch(()=>null);if(!c)return l(null),a(Nt);let g=await c.json().catch(()=>({}));if(!c.ok)return l(null),a(g.error||"That change was refused");a(null),l(g.notice||null),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json())},Bt=({base:t,token:o,fetch:n,onError:r})=>async a=>{let l=new FormData;l.append("picture",a);let i=await n(t+"/pictures",{method:"POST",headers:{"X-CSRF-Token":o},body:l}).catch(()=>null);if(!i)return r(Nt),null;let d=await i.json().catch(()=>({}));return i.ok?(r(null),d):(r(d.error||"That picture was refused"),null)};var fe=(t,o,n)=>{let[r,a]=b(n),[l,i]=b(null),[d,m]=b(null),c=$(()=>zt({base:t,token:o,fetch:(...u)=>window.fetch(...u),onFlow:a,onError:i,onNotice:m}),[t,o]),g=$(()=>Bt({base:t,token:o,fetch:(...u)=>window.fetch(...u),onError:i}),[t,o]);return{flow:r,error:l,notice:d,send:c,pickPicture:g}},Tt=fe;var W=(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}},ge=(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},ye=(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}},he=(t,o,n,r)=>{let[a,l]=b([]),[i,d]=b({width:"100%",height:"100%"});return tt(()=>{let m=()=>{let g=o.current?.getBoundingClientRect();g&&(d({width:o.current.scrollWidth,height:o.current.scrollHeight}),l(t.edges.flatMap(u=>{let y=n.current[u.source]?.getBoundingClientRect(),p=n.current[u.target]?.getBoundingClientRect();if(!y||!p)return[];let v=W(y,u.leaves,g,o.current),k=ge(W(p,u.enters,g,o.current),u.enters),B=(W(y,"bottom",g,o.current).y+W(p,"top",g,o.current).y)/2,S=u.leaves==="left"?Math.min(v.x,k.x)-28:Math.max(v.x,k.x)+28;return[ye(u,v,k,B,S)]})))};m(),window.addEventListener("resize",m);let c=new ResizeObserver(m);return o.current&&c.observe(o.current),()=>{window.removeEventListener("resize",m),c.disconnect()}},r),{links:a,extent:i}},Et=he;var Pt=(t,o)=>{let n=new Set(o),r=t,a=2;for(;n.has(r);)r=`${t}_${a++}`;return r};var ve={display:"flex",height:"100%",minHeight:0,fontSize:13},ke={flex:1,overflow:"auto",position:"relative"},we={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},Ce={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},Se={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},Ne=({port:t,onCancel:o})=>e.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:Se},"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")),ze=({base:t,token:o,initial:n})=>{let{flow:r,error:a,notice:l,send:i,pickPicture:d}=Tt(t,o,n),[m,c]=b(null),[g,u]=b(null),[y,p]=b(null),[v,k]=b(null),[B,S]=b(!1),h=U({}),z=U(null);D(()=>{let s=()=>{c(null),S(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),D(()=>{if(!B)return;let s=f=>{f.target.closest("[data-builder-panel], [data-open-panel]")||S(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[B]),D(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let w=$(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:L,extent:$t}=Et(r,z,h,[r,m,w]),q=$(()=>{let s={};return r.violations.forEach(f=>{var C;return(s[C=f.node]||(s[C]=[])).push(f)}),s},[r.violations]),V=r.nodes.filter(s=>!s.loose),J=r.nodes.filter(s=>s.loose),At=Math.max(0,...V.map(s=>s.row))+1,_t=Math.max(0,...V.map(s=>s.column))+1,K=s=>{if(!y)return;let[f,C]=y;p(null),f!==s&&i("/edges","POST",{from:f,to:s,on:C||null})},E=r.nodes.find(s=>s.id===m),P=r.palette.find(s=>s.type===E?.type);return e.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:ve,onMouseUp:()=>k(null),onKeyDown:s=>{s.key==="Escape"&&(c(null),p(null),u(null))},tabIndex:-1},e.createElement("div",{ref:z,className:"bg-surface-50 dark:bg-surface-950",style:ke,onClick:s=>{s.target===z.current&&(c(null),p(null),S(!1))}},y&&e.createElement(Ne,{port:y[1],onCancel:()=>p(null)}),e.createElement(yt,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>u({at:{x:16,y:52}}),onUndo:()=>{c(null),i("/undo","POST")},onRedo:()=>{c(null),i("/redo","POST")}}),e.createElement(St,{links:L,extent:$t,dragging:v,onInsert:s=>u({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{c(null),i("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let f=v;k(null),i("/steps/"+f+"/move","PATCH",{from:s.source,to:s.target})}}),J.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:we},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"),J.map(s=>e.createElement("div",{key:s.id,style:{marginBottom:8}},e.createElement(Y,{node:{...s,ref:f=>{h.current[s.id]=f},violations:q[s.id]||[],connected:[]},selected:m===s.id,armed:y&&y[0]===s.id?y[1]:null,connecting:!!y,onSelect:()=>y?K(s.id):c(s.id),onOpen:f=>window.location.assign(f),onArm:(f,C)=>{C.stopPropagation(),p([s.id,f])},onDragStart:()=>k(s.id),onDragEnd:()=>k(null)})))),e.createElement("div",{style:{...Ce,gridTemplateColumns:`repeat(${_t+1}, ${280/2}px)`,gridTemplateRows:`repeat(${At}, auto)`}},V.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(mt,{node:{...s,ref:f=>{h.current[s.id]=f}},dragging:!!v,onFill:()=>u({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let f=v;k(null),f&&i("/edges","POST",{from:s.from,to:f,on:s.on})}}),!s.placeholder&&e.createElement(Y,{node:{...s,ref:f=>{h.current[s.id]=f},violations:q[s.id]||[],connected:r.edges.filter(f=>f.source===s.id).map(f=>f.label||null)},selected:s.id===m,armed:y&&y[0]===s.id?y[1]:null,connecting:!!y&&y[0]!==s.id,onSelect:()=>y?K(s.id):c(s.id),onOpen:f=>window.location.assign(f),onArm:(f,C)=>{let Z=z.current.getBoundingClientRect();u({from:s.id,on:f,at:{x:C.clientX-Z.left+z.current.scrollLeft+8,y:C.clientY-Z.top+z.current.scrollTop+8}})},onDragEnd:()=>k(null),onDragStart:()=>k(s.id)})))),g&&e.createElement(ft,{entries:r.palette,at:g.at,onDismiss:()=>u(null),onConnect:g.on!==void 0?()=>{p([g.from,g.on]),u(null)}:null,onPick:s=>{let f=g;u(null),i("/steps","POST",{id:Pt(s.type,r.nodes.map(C=>C.id)),type:s.type,from:f.from,to:f.to,on:f.on})}})),B&&!E&&e.createElement(kt,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:l,onClose:()=>S(!1),onCreate:()=>i("/versions","POST"),onPublish:()=>i("/publish","POST"),onSaveDetails:s=>i("/details","PATCH",{flow:s})}),E&&e.createElement(ut,{node:E,fields:P?.fields||{},holds:P?.records||{},labels:P?.labels||{},recordLabels:P?.record_labels||{},recordChoices:P?.record_choices||{},recordOutputsOf:P?.record_outputs_of||{},choices:it(E,P),onClose:()=>c(null),onSave:s=>i(`/steps/${E.id}`,"PATCH",{config:s}),onPickPicture:d,refusal:a,onDelete:()=>{c(null),i(`/steps/${E.id}`,"DELETE")}}))},Dt=ze;ot("easy_flow/flow-editor",Dt);document.addEventListener("easy_flow:flow-named",t=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=t.detail)});st(document,Q);})();
|
|
1
|
+
(()=>{var It=globalThis.ReactDOM;var{createPortal:Ee,createRoot:Q,flushSync:Pe,hydrateRoot:De,preconnect:$e,prefetchDNS:Ae,preinit:Le,preinitModule:Ie,preload:Oe,preloadModule:Me,requestFormReset:He,unstable_batchedUpdates:Fe,useFormState:je,useFormStatus:We,version:Ve}=It;var R=globalThis.React,e=R,{Children:Ye,Component:Xe,Fragment:Ge,PureComponent:qe,StrictMode:Je,Suspense:Ke,cloneElement:Ze,createContext:Qe,createElement:Re,createRef:to,forwardRef:eo,isValidElement:oo,lazy:ro,memo:no,startTransition:so,useCallback:ao,useContext:io,useDebugValue:lo,useDeferredValue:co,useEffect:P,useId:po,useImperativeHandle:uo,useLayoutEffect:tt,useMemo:D,useReducer:mo,useRef:U,useState:v,useSyncExternalStore:fo,useTransition:go}=R;var et=new Map,O=new Map,ot=(t,o)=>et.set(t,o),rt=()=>{O.forEach(t=>t.unmount()),O.clear()},nt=(t,o)=>t.querySelectorAll("[data-react-ui]").forEach(n=>{if(O.has(n))return;let r=et.get(n.dataset.reactUi),a=o(n);O.set(n,a),a.render(e.createElement(r,JSON.parse(n.dataset.props)))});var st=(t,o)=>{let n=()=>nt(t,o);t.addEventListener("turbo:load",n),t.addEventListener("turbo:frame-load",n),t.addEventListener("turbo:before-cache",rt),t.readyState==="loading"?t.addEventListener("DOMContentLoaded",n):n()};var z=(...t)=>t.filter(Boolean).join(" ");var Ot={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},Mt={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},Ht=({variant:t="primary",size:o="md",className:n,children:r,...a})=>{let d=a.href?"a":"button";return e.createElement(d,{...a,className:z("ks-button",Ot[t],Mt[o],n)},r)},b=Ht;var Ft="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",jt="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",Wt="border border-accent-600 bg-accent-600 text-white",Vt=({name:t,connected:o,armed:n,onArm:r,connecting:a})=>e.createElement("button",{onClick:d=>{a||(d.stopPropagation(),r(d))},title:n?"Now choose a step to connect to":"Connect this branch",className:n?Wt:o?jt:Ft,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},t||"next"),at=Vt;var Ut={width:240,boxSizing:"border-box",borderRadius:8},Yt={padding:"8px 14px",textAlign:"center",fontWeight:600},Xt={fontWeight:600,lineHeight:1.3},Gt=(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"},qt=(t,o)=>o&&t.label===t.id&&t.type_name||t.label,Jt=(t,o,n)=>n?o===t.type_name?null:t.type_name:t.type,Kt=({node:t,selected:o,armed:n,connecting:r,onSelect:a,onOpen:d,onArm:l,onDragEnd:i,onDragStart:m,hideStepTypeCodes:c})=>{let u=t.begins_here||t.ends_here,p=t.begins_here,g=t.ends_here?[]:t.ports,y=r&&!p,k=qt(t,c),w=Jt(t,k,c);return e.createElement("div",{ref:t.ref,"data-step":t.id,draggable:!p,onDragStart:h=>{h.dataTransfer.effectAllowed="move",h.dataTransfer.setData("text/plain",t.id),m()},onDragEnd:i,onClick:t.opens&&!r?()=>d(t.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Gt(y,t.violations.length>0,o)}`,style:{...Ut,padding:u?0:"12px 14px",cursor:p?"default":r?"crosshair":"grab"}},u&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:Yt},k),!u&&e.createElement("div",{style:Xt},k),!u&&w&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginTop:2}},w),t.picture_url&&e.createElement("div",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:11,marginTop:2}},"Has a picture"),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(b,{variant:"secondary",size:"sm",className:"mt-2",onClick:h=>{h.stopPropagation(),a()}},"Settings"),t.violations.map(h=>e.createElement("div",{key:h.problem+h.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},h.problem.replace(/_/g," "),h.detail?`: ${h.detail}`:"")),g.length>0&&e.createElement("div",{style:{padding:t.begins_here?"0 14px 8px":"10px 0 0"}},g.map(h=>e.createElement(at,{key:h,name:h,connected:t.connected.includes(h),connecting:r,armed:n===h,onArm:C=>l(h,C)}))))},Y=Kt;var X=(t,o)=>t.includes(o)?t.filter(n=>n!==o):[...t,o],it=(t,o)=>({...o?.choices||{},...t?.choices||{}});var Zt=({as:t="input",disabled:o,className:n,...r})=>e.createElement(t,{...r,disabled:o,className:z("ks-input",o&&"ks-input-disabled",n)}),B=Zt;var Qt=t=>e.createElement(B,{...t,as:"select"}),lt=Qt;var Rt=({className:t,...o})=>e.createElement("input",{type:"checkbox",...o,className:z("ks-checkbox",t)}),M=Rt;var dt=t=>(t||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),te=({type:t,value:o,choices:n,onChange:r,onSettle:a,onPick:d,pictureUrl:l})=>{if(t==="picture")return e.createElement("div",{className:"mb-3"},o&&l&&e.createElement("img",{src:l,alt:"The step's picture",style:{maxWidth:"100%"}}),o&&e.createElement("button",{type:"button","data-remove-picture":!0,className:"ks-button ks-button-secondary ks-button-sm",onClick:()=>a("")},"Remove picture"),e.createElement("input",{type:"file",accept:"image/*",onChange:i=>i.target.files[0]&&d(i.target.files[0])}));if(t==="boolean")return e.createElement(M,{checked:!!o,onChange:i=>a(i.target.checked)});if(t==="select"||t==="previous_step"||t==="from_step"||t==="flow")return e.createElement(lt,{className:"mb-3",value:o??"",onChange:i=>a(i.target.value)},e.createElement("option",{value:""}),dt(n).map(i=>e.createElement("option",{key:i.value,value:i.value},i.label)));if(t==="multi_select"){let i=Array.isArray(o)?o:[],m=c=>a(X(i,c));return e.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(c=>e.createElement("label",{key:c,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(M,{checked:i.includes(c),onChange:()=>m(c)}),e.createElement("span",null,c))))}if(t==="flows"){let i=Array.isArray(o)?o:[];return e.createElement("div",{style:{display:"flex",flexDirection:"column",gap:4},className:"mb-3"},dt(n).map(m=>e.createElement("label",{key:m.value,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(M,{checked:i.includes(m.value),onChange:()=>a(X(i,m.value))}),e.createElement("span",null,m.label))))}return t==="integer"||t==="float"?e.createElement(B,{className:"mb-3",type:"number",step:t==="integer"?"1":"any",value:o??"",onChange:i=>r(i.target.value),onBlur:i=>a(i.target.value)}):e.createElement(B,{className:"mb-3",type:"text",value:o??"",onChange:i=>r(i.target.value),onBlur:i=>a(i.target.value)})},H=te;var ct=(t,o,n,r)=>t.map((a,d)=>d===o?{...a,[n]:r}:a);var ee=(t,o,n,r)=>{let a=(t||{})[n],d=(o||{})[n];return d?(a||{})[r[d]]:a},oe=({holds:t,labels:o,rows:n,choices:r,outputsOf:a,onChange:d,onSettle:l})=>{let i=Array.isArray(n)?n:[],m=(c,u,p,g)=>{let y=ct(i,c,u,p);g?l(y):d(y)};return e.createElement("div",{style:{marginBottom:12}},i.map((c,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(([p,g])=>e.createElement("label",{key:p,style:{display:"block"}},e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[p]||p),e.createElement(H,{type:g,value:c[p],choices:ee(r,a,p,c),onChange:y=>m(u,p,y,!1),onSettle:y=>m(u,p,y,!0)}))),e.createElement(b,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>l(i.filter((p,g)=>g!==u))},"Remove"))),e.createElement(b,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>l([...i,{}])},"Add"))},pt=oe;var re=({as:t="div",className:o,children:n,...r})=>e.createElement(t,{...r,className:z("ks-panel",o)},n),A=re;var F=(t,o)=>({className:n,children:r,...a})=>e.createElement(t,{...a,className:z(o,n)},r),I=F("label","ks-label"),ar=F("p","ks-hint"),ir=F("p","ks-error"),lr=F("span","ks-required");var ne={width:280,padding:20,overflowY:"auto"},se=({node:t,fields:o,holds:n,labels:r,recordLabels:a,recordChoices:d,recordOutputsOf:l,choices:i,onSave:m,onDelete:c,onClose:u,onPickPicture:p,refusal:g})=>{let[y,k]=v(t.config),[w,h]=v(t.picture_url);P(()=>h(t.picture_url),[t.id,t.picture_url]),P(()=>k(t.config),[t.id,t.config]);let C=x=>{k(x),JSON.stringify(x)!==JSON.stringify(t.config)&&m(x)};return e.createElement(A,{as:"aside",style:ne,"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),g&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{fontWeight:600,marginBottom:12},"data-refusal":!0},g),Object.entries(o).map(([x,_])=>e.createElement(I,{key:x},e.createElement("span",{style:{display:"block",marginBottom:3}},r[x]||x),_==="list"?e.createElement(pt,{holds:n[x]||{},labels:a[x]||{},rows:y[x],choices:{...(d||{})[x],...(t.record_choices||{})[x]},outputsOf:(l||{})[x],onChange:S=>k({...y,[x]:S}),onSettle:S=>C({...y,[x]:S})}):e.createElement(H,{type:_,value:y[x],choices:i[x],pictureUrl:w,onChange:S=>k({...y,[x]:S}),onSettle:S=>C({...y,[x]:S}),onPick:async S=>{let L=await p(S);L&&(h(L.url),C({...y,[x]:L.reference}))}}))),e.createElement(b,{variant:"danger",size:"sm",className:"w-full",onClick:c},"Delete step"))},ut=se;var ae={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},ie="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",le="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",de=({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?le:ie,style:ae},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")),mt=de;var ce=({entries:t,at:o,onPick:n,onConnect:r,onDismiss:a,hideStepTypeCodes:d})=>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(b,{key:l.type,variant:"secondary",size:"sm",className:"w-full mb-1.5 flex-col",onClick:()=>n(l)},l.label,!d&&e.createElement("span",{className:"opacity-75",style:{display:"block",fontSize:11}},l.type))),r&&e.createElement(b,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:r},"Connect to a step already here"),e.createElement(b,{variant:"secondary",size:"sm",className:"w-full",onClick:a},"Cancel")),ft=ce;var pe={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},gt=({label:t,title:o,idle:n,enabled:r,onUse:a})=>e.createElement(b,{variant:"secondary",size:"sm",title:r?o:n,disabled:!r,onClick:a,style:{opacity:r?1:.4,cursor:r?"pointer":"default"}},t),ue=({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(b,{variant:"secondary",className:"rounded-full",style:pe,title:"Add a step",onClick:n},"+"),e.createElement(gt,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:t,onUse:r}),e.createElement(gt,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),yt=ue;var ht=t=>{let o=t.problem.replace(/_/g," ");return t.node?`\u201C${t.node}\u201D ${o}`:o};var xt=({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 me={width:280,padding:20,overflowY:"auto"},G={fontWeight:600,marginBottom:6,marginTop:14},j={fontSize:12,marginBottom:4,lineHeight:1.4},bt={display:"block",marginBottom:3},vt=(t,o,n)=>r=>{r.target.value!==(t[o]??"")&&n({[o]:r.target.value})},fe=({flow:t,changes:o,problems:n,refusal:r,notice:a,publishNote:d,onCreate:l,onPublish:i,onSaveDetails:m,onClose:c})=>e.createElement(A,{as:"aside",style:me,"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:c,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},xt(t)),e.createElement("div",{style:{marginTop:14}},e.createElement(b,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:l,"data-create-version":!0},"Create version"),e.createElement(b,{size:"sm",className:"w-full mb-1.5",onClick:i,"data-publish":!0},"Publish"),d&&e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12,lineHeight:1.4},"data-publish-note":!0},d)),e.createElement("h2",{style:G},"Problems"),r&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...j,fontWeight:600},"data-refusal":!0},r),a&&!r&&e.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...j,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(u=>e.createElement("p",{key:`${u.node}-${u.problem}`,className:"text-amber-700 dark:text-amber-400",style:j,"data-problem":!0},"\u26A0 ",ht(u))),e.createElement("h2",{style:G},"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((u,p)=>e.createElement("p",{key:p,style:j,"data-change":!0},u)),e.createElement("h2",{style:G},"Details"),e.createElement(I,null,e.createElement("span",{style:bt},"Title"),e.createElement(B,{className:"mb-3",defaultValue:t.title??"",onBlur:vt(t,"title",m),"data-flow-title":!0})),e.createElement(I,null,e.createElement("span",{style:bt},"Start label"),e.createElement(B,{className:"mb-3",defaultValue:t.start_label??"",onBlur:vt(t,"start_label",m),"data-flow-start-label":!0})),e.createElement("div",{style:{marginTop:16}},e.createElement(b,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.definition_url,"data-definition":!0},"Definition"),e.createElement(b,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.details_url,"data-details":!0},"Edit details"))),kt=fe;var wt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},Ct=t=>({opacity:t?1:0,transition:"opacity .12s"}),ge=({link:t,onInsert:o,onRemove:n,onDrop:r,dragging:a,alwaysShowInsertButtons:d})=>{let[l,i]=v(!1),m=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:c=>{c.preventDefault(),c.dataTransfer.dropEffect="move"},onDrop:c=>{c.preventDefault(),i(!1),r()}},e.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%"}},e.createElement(b,{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:{...wt,...Ct(m||d)}},"+"),!a&&e.createElement(b,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:{...wt,...Ct(m)}},"\xD7")))},St=ge;var ye=({links:t,extent:o,dragging:n,onInsert:r,onRemove:a,onDrop:d,alwaysShowInsertButtons: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+(l?2:-10),top:i.midY-18,zIndex:3,fontSize:11,padding:"0 3px"}},i.label)),t.filter(i=>!i.placeholder).map(i=>e.createElement(St,{key:i.id,link:i,dragging:!!n,alwaysShowInsertButtons:l,onInsert:()=>r(i),onRemove:()=>a(i),onDrop:()=>d(i)}))),Nt=ye;var zt="Your change was not saved because the server could not be reached.",_t=({base:t,token:o,fetch:n,onFlow:r,onError:a,onNotice:d})=>async(l,i,m)=>{let c=await n(t+l,{method:i,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:m&&JSON.stringify(m)}).catch(()=>null);if(!c)return d(null),a(zt);let u=await c.json().catch(()=>({}));if(!c.ok)return d(null),a(u.error||"That change was refused");a(null),d(u.notice||null),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json())},Bt=({base:t,token:o,fetch:n,onError:r})=>async a=>{let d=new FormData;d.append("picture",a);let l=await n(t+"/pictures",{method:"POST",headers:{"X-CSRF-Token":o},body:d}).catch(()=>null);if(!l)return r(zt),null;let i=await l.json().catch(()=>({}));return l.ok?(r(null),i):(r(i.error||"That picture was refused"),null)};var he=(t,o,n)=>{let[r,a]=v(n),[d,l]=v(null),[i,m]=v(null),c=D(()=>_t({base:t,token:o,fetch:(...p)=>window.fetch(...p),onFlow:a,onError:l,onNotice:m}),[t,o]),u=D(()=>Bt({base:t,token:o,fetch:(...p)=>window.fetch(...p),onError:l}),[t,o]);return{flow:r,error:d,notice:i,send:c,pickPicture:u}},Tt=he;var W=(t,o,n,r)=>{let a=t.left-n.left+r.scrollLeft,d=t.top-n.top+r.scrollTop;return o==="left"?{x:a,y:d+t.height/2}:o==="right"?{x:a+t.width,y:d+t.height/2}:o==="top"?{x:a+t.width/2,y:d}:{x:a+t.width/2,y:d+t.height}},xe=(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},be=(t,o,n,r,a)=>{let d={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:d,midX:t.route==="detour"?a:(o.x+n.x)/2,midY:t.route==="lane"?r:(o.y+n.y)/2}},ve=(t,o,n,r)=>{let[a,d]=v([]),[l,i]=v({width:"100%",height:"100%"});return tt(()=>{let m=()=>{let u=o.current?.getBoundingClientRect();u&&(i({width:o.current.scrollWidth,height:o.current.scrollHeight}),d(t.edges.flatMap(p=>{let g=n.current[p.source]?.getBoundingClientRect(),y=n.current[p.target]?.getBoundingClientRect();if(!g||!y)return[];let k=W(g,p.leaves,u,o.current),w=xe(W(y,p.enters,u,o.current),p.enters),h=(W(g,"bottom",u,o.current).y+W(y,"top",u,o.current).y)/2,C=p.leaves==="left"?Math.min(k.x,w.x)-28:Math.max(k.x,w.x)+28;return[be(p,k,w,h,C)]})))};m(),window.addEventListener("resize",m);let c=new ResizeObserver(m);return o.current&&c.observe(o.current),()=>{window.removeEventListener("resize",m),c.disconnect()}},r),{links:a,extent:l}},Et=ve;var Pt=(t,o)=>{let n=new Set(o),r=t,a=2;for(;n.has(r);)r=`${t}_${a++}`;return r};var Ce={display:"flex",height:"100%",minHeight:0,fontSize:13},Se={flex:1,overflow:"auto",position:"relative"},Ne={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},ze={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},_e={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},Be=({port:t,onCancel:o})=>e.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:_e},"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")),Te=({base:t,token:o,initial:n})=>{let{flow:r,error:a,notice:d,send:l,pickPicture:i}=Tt(t,o,n),[m,c]=v(null),[u,p]=v(null),[g,y]=v(null),[k,w]=v(null),[h,C]=v(!1),x=U({}),_=U(null);P(()=>{let s=()=>{c(null),C(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),P(()=>{if(!h)return;let s=f=>{f.target.closest("[data-builder-panel], [data-open-panel]")||C(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[h]),P(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let S=D(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:L,extent:$t}=Et(r,_,x,[r,m,S]),q=D(()=>{let s={};return r.violations.forEach(f=>{var N;return(s[N=f.node]||(s[N]=[])).push(f)}),s},[r.violations]),V=r.nodes.filter(s=>!s.loose),J=r.nodes.filter(s=>s.loose),At=Math.max(0,...V.map(s=>s.row))+1,Lt=Math.max(0,...V.map(s=>s.column))+1,K=s=>{if(!g)return;let[f,N]=g;y(null),f!==s&&l("/edges","POST",{from:f,to:s,on:N||null})},T=r.nodes.find(s=>s.id===m),E=[...r.palette,...r.unoffered||[]].find(s=>s.type===T?.type);return e.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:Ce,onMouseUp:()=>w(null),onKeyDown:s=>{s.key==="Escape"&&(c(null),y(null),p(null))},tabIndex:-1},e.createElement("div",{ref:_,className:"bg-surface-50 dark:bg-surface-950",style:Se,onClick:s=>{s.target===_.current&&(c(null),y(null),C(!1))}},g&&e.createElement(Be,{port:g[1],onCancel:()=>y(null)}),e.createElement(yt,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>p({at:{x:16,y:52}}),onUndo:()=>{c(null),l("/undo","POST")},onRedo:()=>{c(null),l("/redo","POST")}}),e.createElement(Nt,{links:L,extent:$t,dragging:k,alwaysShowInsertButtons:r.always_show_insert_buttons,onInsert:s=>p({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{c(null),l("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let f=k;w(null),l("/steps/"+f+"/move","PATCH",{from:s.source,to:s.target})}}),J.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:Ne},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"),J.map(s=>e.createElement("div",{key:s.id,style:{marginBottom:8}},e.createElement(Y,{node:{...s,ref:f=>{x.current[s.id]=f},violations:q[s.id]||[],connected:[]},selected:m===s.id,armed:g&&g[0]===s.id?g[1]:null,connecting:!!g,onSelect:()=>g?K(s.id):c(s.id),onOpen:f=>window.location.assign(f),onArm:(f,N)=>{N.stopPropagation(),y([s.id,f])},onDragStart:()=>w(s.id),onDragEnd:()=>w(null),hideStepTypeCodes:r.hide_step_type_codes})))),e.createElement("div",{style:{...ze,gridTemplateColumns:`repeat(${Lt+1}, ${280/2}px)`,gridTemplateRows:`repeat(${At}, auto)`}},V.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(mt,{node:{...s,ref:f=>{x.current[s.id]=f}},dragging:!!k,onFill:()=>p({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let f=k;w(null),f&&l("/edges","POST",{from:s.from,to:f,on:s.on})}}),!s.placeholder&&e.createElement(Y,{node:{...s,ref:f=>{x.current[s.id]=f},violations:q[s.id]||[],connected:r.edges.filter(f=>f.source===s.id).map(f=>f.label||null)},selected:s.id===m,armed:g&&g[0]===s.id?g[1]:null,connecting:!!g&&g[0]!==s.id,onSelect:()=>g?K(s.id):c(s.id),onOpen:f=>window.location.assign(f),onArm:(f,N)=>{let Z=_.current.getBoundingClientRect();p({from:s.id,on:f,at:{x:N.clientX-Z.left+_.current.scrollLeft+8,y:N.clientY-Z.top+_.current.scrollTop+8}})},onDragEnd:()=>w(null),onDragStart:()=>w(s.id),hideStepTypeCodes:r.hide_step_type_codes})))),u&&e.createElement(ft,{entries:r.palette,at:u.at,onDismiss:()=>p(null),hideStepTypeCodes:r.hide_step_type_codes,onConnect:u.on!==void 0?()=>{y([u.from,u.on]),p(null)}:null,onPick:s=>{let f=u;p(null),l("/steps","POST",{id:Pt(s.type,r.nodes.map(N=>N.id)),type:s.type,from:f.from,to:f.to,on:f.on})}})),h&&!T&&e.createElement(kt,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:d,publishNote:r.publish_note,onClose:()=>C(!1),onCreate:()=>l("/versions","POST"),onPublish:()=>l("/publish","POST"),onSaveDetails:s=>l("/details","PATCH",{flow:s})}),T&&e.createElement(ut,{node:T,fields:E?.fields||{},holds:E?.records||{},labels:E?.labels||{},recordLabels:E?.record_labels||{},recordChoices:E?.record_choices||{},recordOutputsOf:E?.record_outputs_of||{},choices:it(T,E),onClose:()=>c(null),onSave:s=>l(`/steps/${T.id}`,"PATCH",{config:s}),onPickPicture:i,refusal:a,onDelete:()=>{c(null),l(`/steps/${T.id}`,"DELETE")}}))},Dt=Te;ot("easy_flow/flow-editor",Dt);document.addEventListener("easy_flow:flow-named",t=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=t.detail)});st(document,Q);})();
|
|
@@ -8,7 +8,10 @@ module EasyFlow
|
|
|
8
8
|
def canvas_payload(flow)
|
|
9
9
|
Canvas.new(canvas_document(flow), host: flow_host, flows: hosted_flows.order(:title), flow_path: ->(id) { manage_flow_path(hosted_flows.find_by(id: id) || id, from: flow) }, flow: flow).to_h
|
|
10
10
|
.merge("undoable" => flow.edit_history.undoable?, "redoable" => flow.edit_history.redoable?,
|
|
11
|
-
"changes" => listed_changes(flow), "flow" => flow_details(flow)
|
|
11
|
+
"changes" => listed_changes(flow), "flow" => flow_details(flow),
|
|
12
|
+
"always_show_insert_buttons" => flow_host.always_show_insert_buttons?,
|
|
13
|
+
"hide_step_type_codes" => flow_host.hide_step_type_codes?,
|
|
14
|
+
"publish_note" => flow_host.publish_note)
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def canvas_document(flow)
|
|
@@ -16,7 +19,7 @@ module EasyFlow
|
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def listed_changes(flow)
|
|
19
|
-
flow.changes_since_version.to_a
|
|
22
|
+
Change.listed(flow.changes_since_version.to_a)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def flow_details(flow)
|
|
@@ -17,6 +17,9 @@ module EasyFlow
|
|
|
17
17
|
return redirect_to run_location(run.waiting_on) if run&.waiting_on
|
|
18
18
|
|
|
19
19
|
@answers = @progress.recorded
|
|
20
|
+
@unsaved_refusal = @guide.unsaved_refusal(@answers, times: @progress.completed_at) unless @progress.saved?
|
|
21
|
+
return render :refused if @unsaved_refusal
|
|
22
|
+
|
|
20
23
|
@question = @guide.next_step(@answers, run: run, times: @progress.completed_at)
|
|
21
24
|
@drawing = @guide.drawing_at(@answers, times: @progress.completed_at)
|
|
22
25
|
flash.now[:alert] = @refused if @refused
|
|
@@ -78,7 +78,7 @@ const Canvas = ({ base, token, initial }) => {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const selectedNode = flow.nodes.find((node) => node.id === selected)
|
|
81
|
-
const entryFor = flow.palette.find((entry) => entry.type === selectedNode?.type)
|
|
81
|
+
const entryFor = [ ...flow.palette, ...(flow.unoffered || []) ].find((entry) => entry.type === selectedNode?.type)
|
|
82
82
|
|
|
83
83
|
return (
|
|
84
84
|
<div className="text-gray-900 dark:text-gray-100" style={page} onMouseUp={() => setDragging(null)}
|
|
@@ -94,7 +94,7 @@ const Canvas = ({ base, token, initial }) => {
|
|
|
94
94
|
onUndo={() => { setSelected(null); send("/undo", "POST") }}
|
|
95
95
|
onRedo={() => { setSelected(null); send("/redo", "POST") }} />
|
|
96
96
|
|
|
97
|
-
<ConnectorLayer links={links} extent={extent} dragging={dragging}
|
|
97
|
+
<ConnectorLayer links={links} extent={extent} dragging={dragging} alwaysShowInsertButtons={flow.always_show_insert_buttons}
|
|
98
98
|
onInsert={(link) => setAdding({ from: link.source, to: link.target, at: { x: link.midX + 30, y: link.midY } })}
|
|
99
99
|
onRemove={(link) => { setSelected(null); send("/edges", "DELETE", { from: link.source, to: link.target }) }}
|
|
100
100
|
onDrop={(link) => { const held = dragging; setDragging(null); send("/steps/" + held + "/move", "PATCH", { from: link.source, to: link.target }) }} />
|
|
@@ -118,7 +118,8 @@ const Canvas = ({ base, token, initial }) => {
|
|
|
118
118
|
onOpen={(path) => window.location.assign(path)}
|
|
119
119
|
onArm={(port, event) => { event.stopPropagation(); setArmed([ node.id, port ]) }}
|
|
120
120
|
onDragStart={() => setDragging(node.id)}
|
|
121
|
-
onDragEnd={() => setDragging(null)}
|
|
121
|
+
onDragEnd={() => setDragging(null)}
|
|
122
|
+
hideStepTypeCodes={flow.hide_step_type_codes} />
|
|
122
123
|
</div>
|
|
123
124
|
))}
|
|
124
125
|
</div>
|
|
@@ -159,13 +160,14 @@ const Canvas = ({ base, token, initial }) => {
|
|
|
159
160
|
}}
|
|
160
161
|
onDragEnd={() => setDragging(null)}
|
|
161
162
|
onDragStart={() => setDragging(node.id)}
|
|
163
|
+
hideStepTypeCodes={flow.hide_step_type_codes}
|
|
162
164
|
/>}
|
|
163
165
|
</div>
|
|
164
166
|
))}
|
|
165
167
|
</div>
|
|
166
168
|
|
|
167
169
|
{adding && (
|
|
168
|
-
<TypePicker entries={flow.palette} at={adding.at} onDismiss={() => setAdding(null)}
|
|
170
|
+
<TypePicker entries={flow.palette} at={adding.at} onDismiss={() => setAdding(null)} hideStepTypeCodes={flow.hide_step_type_codes}
|
|
169
171
|
onConnect={adding.on !== undefined ? () => { setArmed([ adding.from, adding.on ]); setAdding(null) } : null}
|
|
170
172
|
onPick={(entry) => {
|
|
171
173
|
const where = adding
|
|
@@ -176,7 +178,7 @@ const Canvas = ({ base, token, initial }) => {
|
|
|
176
178
|
</div>
|
|
177
179
|
|
|
178
180
|
{showing && !selectedNode && (
|
|
179
|
-
<Panel flow={flow.flow || {}} changes={flow.changes || []} problems={flow.violations} refusal={error} notice={notice}
|
|
181
|
+
<Panel flow={flow.flow || {}} changes={flow.changes || []} problems={flow.violations} refusal={error} notice={notice} publishNote={flow.publish_note}
|
|
180
182
|
onClose={() => setShowing(false)}
|
|
181
183
|
onCreate={() => send("/versions", "POST")}
|
|
182
184
|
onPublish={() => send("/publish", "POST")}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { useState } from "react"
|
|
2
2
|
import Button from "keystone_ui-react/src/Button.jsx"
|
|
3
3
|
const round = { width: 24, height: 24, padding: 0, fontSize: 15, lineHeight: "15px" }
|
|
4
|
+
const shownWhen = (shown) => ({ opacity: shown ? 1 : 0, transition: "opacity .12s" })
|
|
4
5
|
|
|
5
|
-
const Connector = ({ link, onInsert, onRemove, onDrop, dragging }) => {
|
|
6
|
+
const Connector = ({ link, onInsert, onRemove, onDrop, dragging, alwaysShowInsertButtons }) => {
|
|
6
7
|
const [ over, setOver ] = useState(false)
|
|
7
8
|
const showing = over || dragging
|
|
8
9
|
|
|
@@ -13,14 +14,13 @@ const Connector = ({ link, onInsert, onRemove, onDrop, dragging }) => {
|
|
|
13
14
|
onDragEnter={() => setOver(true)} onDragLeave={() => setOver(false)}
|
|
14
15
|
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move" }}
|
|
15
16
|
onDrop={(event) => { event.preventDefault(); setOver(false); onDrop() }}>
|
|
16
|
-
<div style={{ display: "flex", gap: 4, justifyContent: "center", alignItems: "center", height: "100%"
|
|
17
|
-
opacity: showing ? 1 : 0, transition: "opacity .12s" }}>
|
|
17
|
+
<div style={{ display: "flex", gap: 4, justifyContent: "center", alignItems: "center", height: "100%" }}>
|
|
18
18
|
<Button variant="secondary" title={dragging ? "Move the step here" : "Insert a step here"} onClick={onInsert}
|
|
19
19
|
className="rounded-full data-[over=true]:ring-2 data-[over=true]:ring-accent-600"
|
|
20
|
-
data-over={Boolean(dragging && over)} style={round}>+</Button>
|
|
20
|
+
data-over={Boolean(dragging && over)} style={{ ...round, ...shownWhen(showing || alwaysShowInsertButtons) }}>+</Button>
|
|
21
21
|
{!dragging && (
|
|
22
22
|
<Button variant="danger" title="Remove this connection" onClick={onRemove}
|
|
23
|
-
className="rounded-full" style={round}>×</Button>
|
|
23
|
+
className="rounded-full" style={{ ...round, ...shownWhen(showing) }}>×</Button>
|
|
24
24
|
)}
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react"
|
|
2
2
|
import Connector from "./Connector"
|
|
3
3
|
|
|
4
|
-
const ConnectorLayer = ({ links, extent, dragging, onInsert, onRemove, onDrop }) => (
|
|
4
|
+
const ConnectorLayer = ({ links, extent, dragging, onInsert, onRemove, onDrop, alwaysShowInsertButtons }) => (
|
|
5
5
|
<>
|
|
6
6
|
<svg width={extent.width} height={extent.height}
|
|
7
7
|
style={{ position: "absolute", top: 0, left: 0, pointerEvents: "none", zIndex: 1 }}>
|
|
@@ -17,12 +17,12 @@ const ConnectorLayer = ({ links, extent, dragging, onInsert, onRemove, onDrop })
|
|
|
17
17
|
|
|
18
18
|
{links.filter((link) => link.label).map((link) => (
|
|
19
19
|
<div key={`${link.id}-label`} data-link-label={link.id} className="bg-surface-50 text-gray-500 dark:bg-surface-950 dark:text-gray-400"
|
|
20
|
-
style={{ position: "absolute", left: link.midX -
|
|
20
|
+
style={{ position: "absolute", left: link.midX + (alwaysShowInsertButtons ? 2 : -10), top: link.midY - 18, zIndex: 3,
|
|
21
21
|
fontSize: 11, padding: "0 3px" }}>{link.label}</div>
|
|
22
22
|
))}
|
|
23
23
|
|
|
24
24
|
{links.filter((link) => !link.placeholder).map((link) => (
|
|
25
|
-
<Connector key={link.id} link={link} dragging={Boolean(dragging)}
|
|
25
|
+
<Connector key={link.id} link={link} dragging={Boolean(dragging)} alwaysShowInsertButtons={alwaysShowInsertButtons}
|
|
26
26
|
onInsert={() => onInsert(link)} onRemove={() => onRemove(link)} onDrop={() => onDrop(link)} />
|
|
27
27
|
))}
|
|
28
28
|
</>
|
|
@@ -15,7 +15,7 @@ const settling = (flow, name, onSaveDetails) => (event) => {
|
|
|
15
15
|
if (event.target.value !== (flow[name] ?? "")) onSaveDetails({ [name]: event.target.value })
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const Panel = ({ flow, changes, problems, refusal, notice, onCreate, onPublish, onSaveDetails, onClose }) => (
|
|
18
|
+
const Panel = ({ flow, changes, problems, refusal, notice, publishNote, onCreate, onPublish, onSaveDetails, onClose }) => (
|
|
19
19
|
<KeystonePanel as="aside" style={sheet} data-builder-panel>
|
|
20
20
|
<div style={{ display: "flex", alignItems: "start", justifyContent: "space-between", gap: 8 }}>
|
|
21
21
|
<h2 style={{ fontWeight: 600 }} data-flow-name>{flow.title || flow.slug}</h2>
|
|
@@ -24,6 +24,12 @@ const Panel = ({ flow, changes, problems, refusal, notice, onCreate, onPublish,
|
|
|
24
24
|
</div>
|
|
25
25
|
<a className="text-accent-600 dark:text-accent-400" style={{ fontSize: 12, display: "block" }} href={flow.history_url} data-history>{standing(flow)}</a>
|
|
26
26
|
|
|
27
|
+
<div style={{ marginTop: 14 }}>
|
|
28
|
+
<Button variant="secondary" size="sm" className="w-full mb-1.5" onClick={onCreate} data-create-version>Create version</Button>
|
|
29
|
+
<Button size="sm" className="w-full mb-1.5" onClick={onPublish} data-publish>Publish</Button>
|
|
30
|
+
{publishNote && <p className="text-gray-500 dark:text-gray-400" style={{ fontSize: 12, lineHeight: 1.4 }} data-publish-note>{publishNote}</p>}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
27
33
|
<h2 style={heading}>Problems</h2>
|
|
28
34
|
{refusal && <p className="text-red-800 dark:text-red-400" style={{ ...item, fontWeight: 600 }} data-refusal>{refusal}</p>}
|
|
29
35
|
{notice && !refusal && <p className="text-emerald-800 dark:text-emerald-400" style={{ ...item, fontWeight: 600 }} data-notice>{notice}</p>}
|
|
@@ -50,8 +56,6 @@ const Panel = ({ flow, changes, problems, refusal, notice, onCreate, onPublish,
|
|
|
50
56
|
</Label>
|
|
51
57
|
|
|
52
58
|
<div style={{ marginTop: 16 }}>
|
|
53
|
-
<Button variant="secondary" size="sm" className="w-full mb-1.5" onClick={onCreate} data-create-version>Create version</Button>
|
|
54
|
-
<Button size="sm" className="w-full mb-1.5" onClick={onPublish} data-publish>Publish</Button>
|
|
55
59
|
<Button variant="secondary" size="sm" className="w-full mb-1.5" href={flow.definition_url} data-definition>Definition</Button>
|
|
56
60
|
<Button variant="secondary" size="sm" className="w-full mb-1.5" href={flow.details_url} data-details>Edit details</Button>
|
|
57
61
|
</div>
|
|
@@ -16,11 +16,21 @@ const edge = (target, troubled, selected) => {
|
|
|
16
16
|
return "border-gray-300 shadow-sm dark:border-zinc-700"
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const
|
|
19
|
+
const headingOf = (node, hideStepTypeCodes) =>
|
|
20
|
+
hideStepTypeCodes && node.label === node.id ? node.type_name || node.label : node.label
|
|
21
|
+
|
|
22
|
+
const typeLineOf = (node, heading, hideStepTypeCodes) => {
|
|
23
|
+
if (!hideStepTypeCodes) return node.type
|
|
24
|
+
return heading === node.type_name ? null : node.type_name
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const StepCard = ({ node, selected, armed, connecting, onSelect, onOpen, onArm, onDragEnd, onDragStart, hideStepTypeCodes }) => {
|
|
20
28
|
const bookend = node.begins_here || node.ends_here
|
|
21
29
|
const pinned = node.begins_here
|
|
22
30
|
const ports = node.ends_here ? [] : node.ports
|
|
23
31
|
const target = connecting && !pinned
|
|
32
|
+
const heading = headingOf(node, hideStepTypeCodes)
|
|
33
|
+
const typeLine = typeLineOf(node, heading, hideStepTypeCodes)
|
|
24
34
|
|
|
25
35
|
return (
|
|
26
36
|
<div ref={node.ref}
|
|
@@ -35,9 +45,10 @@ const StepCard = ({ node, selected, armed, connecting, onSelect, onOpen, onArm,
|
|
|
35
45
|
padding: bookend ? 0 : "12px 14px",
|
|
36
46
|
cursor: pinned ? "default" : connecting ? "crosshair" : "grab"
|
|
37
47
|
}}>
|
|
38
|
-
{bookend && <div className="text-gray-500 dark:text-gray-400" style={bookendCard}>{
|
|
39
|
-
{!bookend && <div style={named}>{
|
|
40
|
-
{!bookend && <div className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginTop: 2 }}>{
|
|
48
|
+
{bookend && <div className="text-gray-500 dark:text-gray-400" style={bookendCard}>{heading}</div>}
|
|
49
|
+
{!bookend && <div style={named}>{heading}</div>}
|
|
50
|
+
{!bookend && typeLine && <div className="text-gray-500 dark:text-gray-400" style={{ fontSize: 11, marginTop: 2 }}>{typeLine}</div>}
|
|
51
|
+
{node.picture_url && <div className="text-accent-600 dark:text-accent-400" style={{ fontSize: 11, marginTop: 2 }}>Has a picture</div>}
|
|
41
52
|
{node.starts_a_flow && <div className="text-accent-600 dark:text-accent-400" style={{ fontSize: 11, marginTop: 2 }}>Runs a flow</div>}
|
|
42
53
|
{node.opens && <Button variant="secondary" size="sm" className="mt-2" onClick={(event) => { event.stopPropagation(); onSelect() }}>Settings</Button>}
|
|
43
54
|
{node.violations.map((violation) => (
|
|
@@ -2,13 +2,13 @@ import React from "react"
|
|
|
2
2
|
import Panel from "keystone_ui-react/src/Panel.jsx"
|
|
3
3
|
import Button from "keystone_ui-react/src/Button.jsx"
|
|
4
4
|
|
|
5
|
-
const TypePicker = ({ entries, at, onPick, onConnect, onDismiss }) => (
|
|
5
|
+
const TypePicker = ({ entries, at, onPick, onConnect, onDismiss, hideStepTypeCodes }) => (
|
|
6
6
|
<Panel className="shadow-lg" style={{ position: "absolute", zIndex: 9, top: at.y, left: at.x, width: 210, padding: 8, borderRadius: 8 }}>
|
|
7
7
|
<p className="text-gray-500 dark:text-gray-400" style={{ margin: "0 0 6px", fontSize: 11 }}>Add a step</p>
|
|
8
8
|
{entries.map((entry) => (
|
|
9
9
|
<Button key={entry.type} variant="secondary" size="sm" className="w-full mb-1.5 flex-col" onClick={() => onPick(entry)}>
|
|
10
10
|
{entry.label}
|
|
11
|
-
<span className="opacity-75" style={{ display: "block", fontSize: 11 }}>{entry.type}</span>
|
|
11
|
+
{!hideStepTypeCodes && <span className="opacity-75" style={{ display: "block", fontSize: 11 }}>{entry.type}</span>}
|
|
12
12
|
</Button>
|
|
13
13
|
))}
|
|
14
14
|
{onConnect && (
|
|
@@ -10,7 +10,7 @@ module EasyFlow
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def to_h
|
|
13
|
-
{ "nodes" => nodes, "edges" => edges, "palette" => palette, "violations" => violations }
|
|
13
|
+
{ "nodes" => nodes, "edges" => edges, "palette" => palette, "unoffered" => unoffered, "violations" => violations }
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
private
|
|
@@ -20,7 +20,7 @@ module EasyFlow
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def drawn_step(node)
|
|
23
|
-
{ "id" => node.id, "type" => node.type, "label" => label_for(node),
|
|
23
|
+
{ "id" => node.id, "type" => node.type, "type_name" => step_type_for(node)&.step_name, "label" => label_for(node),
|
|
24
24
|
"config" => node.config.merge(kept_values(node)), "ports" => [], "ends_here" => ends_here?(node), "begins_here" => begins_here?(node),
|
|
25
25
|
"starts_a_flow" => starts_a_flow?(node), "opens" => opens(node), "loose" => loose?(node), "choices" => choices_for(node), "record_choices" => record_choices_for(node), "placeholder" => false, "picture_url" => picture_url(node), **placed[node.id] }
|
|
26
26
|
end
|
|
@@ -89,17 +89,27 @@ module EasyFlow
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def palette
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
92
|
+
offered_step_types.map { |step_type| palette_entry(step_type) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def unoffered
|
|
96
|
+
(@document.nodes.filter_map { |node| step_type_for(node) }.uniq.reject(&:begins_here?) - offered_step_types).map { |step_type| palette_entry(step_type) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def offered_step_types
|
|
100
|
+
@registry.step_types.reject(&:begins_here?).select { |step_type| @host.nil? || step_type.ends_here? || @host.offers?(step_type.id) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def palette_entry(step_type)
|
|
104
|
+
{ "type" => step_type.id.to_s, "label" => step_type.step_name,
|
|
105
|
+
"fields" => step_type.settings.fields.transform_keys(&:to_s).transform_values(&:to_s),
|
|
106
|
+
"labels" => step_type.settings.labels.transform_keys(&:to_s),
|
|
107
|
+
"choices" => step_type.settings.choices.transform_keys(&:to_s),
|
|
108
|
+
"records" => holdings_of(step_type),
|
|
109
|
+
"record_choices" => step_type.settings.record_choices.to_h { |name, offered| [ name.to_s, offered.transform_keys(&:to_s) ] },
|
|
110
|
+
"record_outputs_of" => step_type.settings.record_outputs_of.to_h { |name, sources| [ name.to_s, sources.to_h { |field, source| [ field.to_s, source.to_s ] } ] },
|
|
111
|
+
"record_labels" => step_type.settings.record_labels.to_h { |name, held| [ name.to_s, held.transform_keys(&:to_s) ] },
|
|
112
|
+
"awaits_input" => step_type.awaits_input? }
|
|
103
113
|
end
|
|
104
114
|
|
|
105
115
|
def holdings_of(step_type)
|
|
@@ -3,6 +3,14 @@ module EasyFlow
|
|
|
3
3
|
PHRASING = { "added" => "Added", "updated" => "Updated", "removed" => "Removed",
|
|
4
4
|
"moved" => "Moved", "connected" => "Connected", "disconnected" => "Disconnected", "returned" => "Returned to" }.freeze
|
|
5
5
|
|
|
6
|
+
def self.listed(changes)
|
|
7
|
+
changes.chunk_while { |earlier, later| saves_to_one_step?(earlier, later) }.map { |saves| phrase(saves.last) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.saves_to_one_step?(earlier, later)
|
|
11
|
+
earlier["action"] == "updated" && later["action"] == "updated" && earlier["steps"] == later["steps"]
|
|
12
|
+
end
|
|
13
|
+
|
|
6
14
|
def self.phrase(change)
|
|
7
15
|
named = Array(change["named"]).compact_blank.map { |name| "“#{name}”" }
|
|
8
16
|
verb = PHRASING.fetch(change["action"], change["action"])
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module EasyFlow
|
|
2
|
+
module DataStep
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
include Inputs
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
names_by :data
|
|
8
|
+
needs_a_saved_run "Data can only be read or written in a flow that saves its runs."
|
|
9
|
+
|
|
10
|
+
setting(:values, type: :list, label: "Values it reports") { setting :name, type: :string, required: true }
|
|
11
|
+
outputs_named_by :values
|
|
12
|
+
|
|
13
|
+
output :result, values: %w[passed failed]
|
|
14
|
+
output :reason
|
|
15
|
+
output :answer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def process(node, state)
|
|
19
|
+
raise ArgumentError, "#{node.config["data"]} is not offered by this step" unless self.class.step_type.settings.choices[:data].include?(node.config["data"])
|
|
20
|
+
|
|
21
|
+
answer = ActiveRecord::Base.transaction(requires_new: true) do
|
|
22
|
+
EasyFlow.data_source.carry_out(node.config["data"], Inputs.values_of(node, state))
|
|
23
|
+
end.as_json
|
|
24
|
+
(answer.is_a?(Hash) ? answer : { "answer" => answer }).merge("result" => "passed")
|
|
25
|
+
rescue => error
|
|
26
|
+
{ "result" => "failed", "reason" => error.message }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def route(node, state)
|
|
30
|
+
state.dig(node.id, "result")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module EasyFlow
|
|
2
|
+
module Inputs
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
setting :inputs, type: :list do
|
|
7
|
+
setting :name, type: :string, required: true
|
|
8
|
+
setting :value, type: :string
|
|
9
|
+
setting :step, type: :previous_step, label: "Earlier step"
|
|
10
|
+
setting :output, outputs_of: :step, label: "Earlier step's output"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.values_of(node, state)
|
|
15
|
+
Array(node.config["inputs"]).to_h { |input| [ input["name"], input["step"].present? ? latest_output(input, state) : input["value"] ] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.latest_output(input, state)
|
|
19
|
+
latest_visit = state.keys.select { |key| key.split("@").first == input["step"] }.max_by { |key| key.split("@")[1].to_i }
|
|
20
|
+
CompletedAt.value_of(state, {}, latest_visit, input["output"])
|
|
21
|
+
end
|
|
22
|
+
private_class_method :latest_output
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -29,13 +29,17 @@ module EasyFlow
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def run(progress)
|
|
32
|
-
while (node = @digest.next_step(named(progress.recorded), named(progress.completed_at))) && goes_on?(node, named(progress.recorded), CompletedAt.latest(named(progress.completed_at)))
|
|
32
|
+
while (node = @digest.next_step(named(progress.recorded), named(progress.completed_at))) && goes_on?(node, named(progress.recorded), CompletedAt.latest(named(progress.completed_at))) && (progress.saved? || refusal_of(node).nil?)
|
|
33
33
|
progress.record(node.id, result_of(@digest.step(node.id), named(progress.recorded)))
|
|
34
34
|
end
|
|
35
35
|
progress.start_inner(node) if node && starts_a_flow?(node)
|
|
36
36
|
progress.ended unless node
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def unsaved_refusal(state, times: {})
|
|
40
|
+
refusal_of(@digest.next_step(named(state), named(times)))
|
|
41
|
+
end
|
|
42
|
+
|
|
39
43
|
def fills_in_next?(state)
|
|
40
44
|
node = @digest.next_step(named(state))
|
|
41
45
|
node.present? && decided?(node, state)
|
|
@@ -59,6 +63,10 @@ module EasyFlow
|
|
|
59
63
|
|
|
60
64
|
private
|
|
61
65
|
|
|
66
|
+
def refusal_of(node)
|
|
67
|
+
@registry.fetch(node.type).unsaved_refusal if node && @registry.registered?(node.type)
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
def starts_a_flow?(node)
|
|
63
71
|
@registry.registered?(node.type) && @registry.fetch(node.type).starts_a_flow?
|
|
64
72
|
end
|
|
@@ -59,6 +59,10 @@ module EasyFlow
|
|
|
59
59
|
@answer_labelling = labelling
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
def needs_a_saved_run(refusal)
|
|
63
|
+
@unsaved_refusal = refusal
|
|
64
|
+
end
|
|
65
|
+
|
|
62
66
|
def comes_back_while(&return_rule)
|
|
63
67
|
@return_rule = return_rule
|
|
64
68
|
end
|
|
@@ -151,7 +155,7 @@ module EasyFlow
|
|
|
151
155
|
StepType.new(id: @id, step_name: @step_name, settings: settings, awaits_input: @awaits_input,
|
|
152
156
|
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,
|
|
153
157
|
display: @display, drawn_by: @drawn_by, naming_field: @naming_field, naming: @naming,
|
|
154
|
-
outputs: @declared_outputs, outputs_named_by: @outputs_named_by, answer_check: @answer_check, answer_labelling: @answer_labelling, readiness: @readiness, return_rule: @return_rule)
|
|
158
|
+
outputs: @declared_outputs, outputs_named_by: @outputs_named_by, answer_check: @answer_check, answer_labelling: @answer_labelling, readiness: @readiness, return_rule: @return_rule, unsaved_refusal: @unsaved_refusal)
|
|
155
159
|
end
|
|
156
160
|
|
|
157
161
|
def settings
|
|
@@ -4,11 +4,11 @@ module EasyFlow
|
|
|
4
4
|
Declaration.new(id).tap { |decl| decl.instance_eval(&declaration) }.to_step_type
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
-
attr_reader :id, :step_name, :settings, :naming_field, :naming, :outputs, :drawn_by
|
|
7
|
+
attr_reader :id, :step_name, :settings, :naming_field, :naming, :outputs, :drawn_by, :unsaved_refusal
|
|
8
8
|
|
|
9
9
|
def initialize(id:, step_name:, settings:, awaits_input:, behaviour:, routing:,
|
|
10
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)
|
|
11
|
+
naming_field: nil, naming: nil, outputs: [], outputs_named_by: nil, answer_check: nil, answer_labelling: nil, readiness: nil, return_rule: nil, unsaved_refusal: nil)
|
|
12
12
|
@id = id
|
|
13
13
|
@step_name = step_name
|
|
14
14
|
@settings = settings
|
|
@@ -31,6 +31,7 @@ module EasyFlow
|
|
|
31
31
|
@answer_labelling = answer_labelling
|
|
32
32
|
@readiness = readiness
|
|
33
33
|
@return_rule = return_rule
|
|
34
|
+
@unsaved_refusal = unsaved_refusal
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
def display_of(node, run = nil)
|
|
@@ -20,7 +20,11 @@
|
|
|
20
20
|
<%= ui_button(label: "Back to #{@parent.title.presence || @parent.slug}", href: flow_routes.manage_flow_path(@parent), variant: :secondary) %>
|
|
21
21
|
<% end %>
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
<% if @back_link %>
|
|
24
|
+
<%= ui_button(label: @back_link.label, href: @back_link.address, variant: :secondary) %>
|
|
25
|
+
<% else %>
|
|
26
|
+
<%= ui_button(label: "All flows", href: flow_routes.manage_flows_path, variant: :secondary) %>
|
|
27
|
+
<% end %>
|
|
24
28
|
</div>
|
|
25
29
|
</header>
|
|
26
30
|
|
data/lib/easy_flow/engine.rb
CHANGED
|
@@ -29,6 +29,8 @@ module EasyFlow
|
|
|
29
29
|
EasyFlow::CompareTwoValues.register
|
|
30
30
|
EasyFlow::AllOrAny.register
|
|
31
31
|
EasyFlow::Count.register
|
|
32
|
+
EasyFlow::ReadData.register
|
|
33
|
+
EasyFlow::WriteData.register
|
|
32
34
|
EasyFlow::Wait.register
|
|
33
35
|
EasyFlow::FlowStep.register
|
|
34
36
|
EasyFlow::PlayFlows.register
|
data/lib/easy_flow/host.rb
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
module EasyFlow
|
|
2
2
|
class Host
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
BackLink = Data.define(:label, :address)
|
|
4
|
+
|
|
5
|
+
attr_reader :name, :back_link
|
|
6
|
+
attr_writer :layout, :admin_layout, :offers, :allowed_answers, :always_show_insert_buttons, :hide_step_type_codes
|
|
7
|
+
attr_accessor :admin_authentication_method, :visitor_authorization_method, :refusal_method, :owner_method, :finds_flows_across_owners, :publish_note
|
|
6
8
|
|
|
7
9
|
def initialize(name)
|
|
8
10
|
@name = name.to_s
|
|
9
11
|
end
|
|
10
12
|
|
|
13
|
+
def back_link=(link)
|
|
14
|
+
@back_link = link && BackLink.new(**link)
|
|
15
|
+
end
|
|
16
|
+
|
|
11
17
|
def layout
|
|
12
18
|
@layout || "easy_flow/application"
|
|
13
19
|
end
|
|
@@ -28,6 +34,14 @@ module EasyFlow
|
|
|
28
34
|
@allowed_answers&.call(step, answers)
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
def always_show_insert_buttons?
|
|
38
|
+
@always_show_insert_buttons.present?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def hide_step_type_codes?
|
|
42
|
+
@hide_step_type_codes.present?
|
|
43
|
+
end
|
|
44
|
+
|
|
31
45
|
def offers?(step_type)
|
|
32
46
|
@offers.nil? || @offers.map(&:to_s).include?(step_type.to_s)
|
|
33
47
|
end
|
data/lib/easy_flow/version.rb
CHANGED
data/lib/easy_flow.rb
CHANGED
|
@@ -9,7 +9,7 @@ This local follows the steps below exactly and invents none. Where a step names
|
|
|
9
9
|
|
|
10
10
|
## What easy_flow is
|
|
11
11
|
|
|
12
|
-
A Rails engine for flows an admin draws on a canvas and a visitor runs one step at a time. Each step is an instance of a step type. The engine ships
|
|
12
|
+
A Rails engine for flows an admin draws on a canvas and a visitor runs one step at a time. Each step is an instance of a step type. The engine ships seventeen: start (`:start`), end (`:terminal`, whose one setting is the output an admin writes for a flow that ends there), question (`:question`, a question with a list of answers of which the visitor picks one, which the admin can mark required so a blank answer is refused), checklist (`:checklist`, a question with a list of answers of which the visitor ticks any number, which the admin can mark required so a visitor who ticks nothing is refused), Multiple select (`:multiple_select`, a question with a category and a list of answers, each with a value, a label and a weight, of which the visitor ticks any number, recording the list of ticked values, which the admin can mark required so a visitor who ticks nothing is refused with `Pick at least one answer to go on.`), Fill in the blank (`:fill_in_the_blank`, a question with a category and a list of accepted answers, each with a value, which the visitor answers by typing into a text box, recording the text exactly as typed, whose accepted answers are never sent to the visitor's page, which the admin can mark required so a visitor who types nothing or only spaces is refused with `Type an answer to go on.`; easy_flow records the answer and does not mark it right or wrong), File upload (`:file_upload`, a question the visitor answers by choosing one file from a file upload control labelled with the question text, with an admin setting listing the accepted file types as comma-separated extensions such as `.pdf, .png`, which limits the visitor's file picker to those types and is shown under the control as `Accepts` followed by the list as the admin wrote it, such as `Accepts .pdf, .png`, which the admin can mark required so a visitor who chooses no file is refused, and whose recorded answer is the reference the host's file store returned for the file), five that pick a branch from earlier answers with no code — condition (`:condition`, is or is not a chosen value, where the admin must pick one of the earlier step's outputs and the answer is read from that output when the earlier step recorded several and whole otherwise, so a Condition cannot be completed on a step whose type declares no output), switch (`:switch`, follows the connection labelled with the answer), compare (`:compare`, reads the answer as a number, from the output the admin picks when the earlier step recorded several, and checks it by more than, less than, at least or at most against an amount) and Compare two values (`:compare_two_values`, reads the answers of two earlier steps the admin picks as numbers, reading each from the output the admin picks when that step recorded several, and checks the first by more than, less than, at least, at most or equal to against the second plus an amount the admin may give, a blank amount adding nothing, checking the first against the amount alone when the admin leaves the second step empty, following its `true` or `false` connection, and following `false` when either answer is missing, is an array or is not a number) and All of or Any of (`:all_or_any`, holds a list of comparisons the admin enters and a `Must hold` setting of `all of` or `any of`, where each comparison asks for the same things as a Compare two values step, a step, its output, a comparison, an amount, and an optional second step and its output, and is checked the same way, a comparison with no second step checking the first answer against the amount alone, following its `true` connection when every comparison holds for `all of` or at least one holds for `any of`, and its `false` connection otherwise, including when it holds no comparisons, a comparison whose answer is missing, is an array or is not a number counting as not holding) — Count (`:count`, which acts without the visitor and records a whole number, how many visits to an earlier step the admin picks the run has recorded, counting only visits that gave the answer the admin picks or every visit when the admin picks none, reading that answer from the output the admin picks when the earlier step recorded several, named on the canvas `Count` followed by the earlier step's id and the answer, such as `Count tests failed`, and recording a new count under its own visit key each time a loop reaches it, so a compare step after it can end a loop once the count reaches an amount), Read data and Write data (`:read_data` and `:write_data`, which ask the app to carry out one thing from what it says can be read or written, with named inputs, and follow a `passed` or `failed` connection; `easy_flow-install` sets what the app offers), Wait (`:wait`, which holds the run until a time worked out from an earlier step the admin picks as `Counting from`; see "Hold a run until a time"), and Flow (`:flow_step`, names another flow of the same host and the number of one of its versions, with one connection for each different output that version's End steps write, and named on the canvas by that flow's title and the version number, such as `Intake, version 2`). On a compare, Compare two values or Count step, and in each comparison of an All of or Any of step, once the admin picks an earlier step, the admin must also pick which of that step's outputs to read: until then the step's card shows `missing setting: output`, `missing setting: other_output` for a Compare two values step's second step, or `missing setting: comparisons <n> output` and `missing setting: comparisons <n> other_output` for the comparison at position `n` counting from 1, and saving the flow as a new version and publishing it are refused. When a stored run stops at a Flow step, a run of the named flow is started on the named version, and the first run waits on the Flow step. When the second run ends with an output, that output is recorded in the first run at the Flow step, and the first run moves on along the connection labelled with that output. When the second run ends with no output, nothing is recorded and the first run stays waiting at the Flow step. On a question and on a checklist the admin can give each answer info, a longer explanation the visitor opens with an info button beside that answer. An admin may connect a step back to an earlier one, so a flow can ask the same question more than once, and each visit's answer is kept. The time each step visit was completed is kept together with its recorded value; see "Read when steps were completed". A File upload step takes a file only in a flow the admin set to save each step. In a flow set to save on finish or to save nothing, a visitor who chooses a file is shown `A file can only be uploaded in a flow that saves its runs.` and cannot go on with it. Before writing a step type to ask for one answer or several, to take a file, to branch on an answer or a number, to branch on how two earlier answers compare, to branch on whether all or any of several comparisons hold, to branch on when an earlier step was completed, to count the visits to an earlier step, or to hold a run until a time, ask the developer whether an admin placing one of the built-in types on the canvas is enough. None of condition, switch, compare, Compare two values or All of or Any of reads a checklist's answer, so branching on what a visitor ticked takes a step type of the app's own that defines `route`. Use this local when the app needs a step type of its own, needs a host's flows on its own pages with its own behaviour when a visitor finishes, needs a visitor's earlier answers to decide which answers a later step offers, or needs to read what a visitor answered. It assumes easy_flow is already installed and a host is declared; if not, hand off to `easy_flow-install` first.
|
|
13
13
|
|
|
14
14
|
## Interface
|
|
15
15
|
|
|
@@ -17,6 +17,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
17
17
|
- `EasyFlow.step` — declares and registers a step type in one call from a block of the same words, for a type small enough not to need its own class.
|
|
18
18
|
- `labels_answer_by` — a word in a step type's declaration giving the label a run's recorded answer to a step of that type is shown by, on the completion page and from `EasyFlow::QuestionRunner#choice_label`.
|
|
19
19
|
- `outputs_named_by` — a word in a step type's declaration adding one output for each entry an admin enters in one of the type's list settings, named by that entry, so the admin decides the names of the values the step records.
|
|
20
|
+
- `EasyFlow::Inputs` — a module a step type's class includes, after `EasyFlow::Step`, to declare the list of named inputs an admin fills in, each a fixed value or an output of an earlier step. `EasyFlow::Inputs.values_of(node, state)` returns those values as a hash with string keys, reading a step reached more than once at its latest visit.
|
|
20
21
|
- `host.allowed_answers=` — set on a host to a lambda given a step and the answers recorded so far, returning the answers that step may take in this run: several narrows the answers shown, one is recorded without showing the step, and none records a blank and skips it.
|
|
21
22
|
- `EasyFlow::FlowsController` — the visitor controller; the app subclasses it to serve one host's flows from its own routes and to change what happens at the start, on each step and at the finish.
|
|
22
23
|
- `hosted_by` — class method on a `FlowsController` subclass naming the host whose flows it serves.
|
|
@@ -78,7 +79,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
78
79
|
```
|
|
79
80
|
|
|
80
81
|
A step whose type is not registered is neither shown nor run as that type.
|
|
81
|
-
4. Ask the developer which hosts' admins should be able to add the type. A registered type is in a host's palette, the step types an admin can add on that host's canvas, only when the host names no list of offered step types or its list includes the type's id. A type that declares `ends_here` is in every host's palette, and a type that declares `begins_here` is in none. When a host that names a list should offer the new type, add the type's id to that host's list through `easy_flow-install`. Leaving a type off a host's list only keeps admins from adding it there, and a step of that type already in one of the host's flows is still drawn and run.
|
|
82
|
+
4. Ask the developer which hosts' admins should be able to add the type. A registered type is in a host's palette, the step types an admin can add on that host's canvas, only when the host names no list of offered step types or its list includes the type's id. A type that declares `ends_here` is in every host's palette, and a type that declares `begins_here` is in none. When a host that names a list should offer the new type, add the type's id to that host's list through `easy_flow-install`. Leaving a type off a host's list only keeps admins from adding it there, and a step of that type already in one of the host's flows is still drawn and run, and still opens with all its settings and can be saved.
|
|
82
83
|
5. Use these words to declare the type. Each is called once at class level (or inside the `EasyFlow.step` block):
|
|
83
84
|
- `step_name "<label>"` — the name admins see on the canvas. Defaults to the id.
|
|
84
85
|
- `setting :name, type:, label:, options:, required:, optional:, limit:, check:, from:, outputs_of:, kept_on:, attribute:` — a field the admin fills in on the canvas. `type` is one of `:string`, `:integer`, `:float`, `:boolean`, `:select`, `:multi_select`, `:previous_step`, `:from_step`, `:list`, `:flow`, or boot raises `EasyFlow::UnknownFieldType`. A `:select` or `:multi_select` must pass `options:`, or boot raises the same error. `options:` is either an array of strings, or a lambda taking no arguments that returns one; the lambda is called each time the canvas is opened and each time an admin saves the step's settings, so the options follow the app's data without a restart (`options: -> { Region.order(:name).pluck(:code) }`). A saved setting whose value is no longer among the options is refused the next time the admin saves that step. When the options come from the app's records, ask the developer which records, which column is stored as the value, and whether the list must differ by account or host, since the lambda is passed nothing and the same list is offered everywhere the type is used. A `:list` must take a block of `setting` calls describing one entry. `:previous_step` lets the admin pick an earlier step, and is required unless declared with `optional: true`. Ask the developer whether a step of the type may be left without that earlier step, and when it may, declare `optional: true` and handle a blank value in the type's code. `outputs_of: :<setting>` offers the outputs of the step chosen in that setting, and `from: :<setting>` offers the values of the output chosen in that setting; either one makes the type `:from_step`. A required setting left blank shows `missing setting: <name>` on the step's card, and saving the flow as a new version and publishing it are refused until the admin fills it in. An `outputs_of:` setting left blank is flagged and refused the same way once the setting it reads names a step, whether or not it is declared `required:`. Inside a `:list` entry, a blank required setting, or a blank `outputs_of:` setting whose step is picked, shows `missing setting: <list> <n> <name>` for the entry at position `n` counting from 1. `optional:` has no effect inside a `:list` entry, where a `:previous_step` is required only when declared `required: true`. `:flow` lets the admin pick one of the flows the admin's list shows on the same host, offered by title, and is stored in `node.config` as that flow's id as a string. A `:flow` setting is not checked on save against the flows offered, so code that reads it looks the flow up through the host and handles a blank or unknown id. `limit:` is the most values a `:multi_select` takes. `check:` is a lambda given the value that returns an error message, or `nil` when the value is acceptable. `label:` defaults to the name humanized. `kept_on:` and `attribute:` keep the setting's value on one of the app's own records instead of in the flow; see "Keep a setting on one of the app's records".
|
|
@@ -279,6 +280,31 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
279
280
|
- The app calls `run.advance` where the event is handled, such as a webhook or a job. See "Read a run and its answers". This needs a stored run, so it only applies to a flow the admin set to save each step.
|
|
280
281
|
9. Restart the server, open a flow on the canvas of each host that should offer the type, and check the type is in the palette, its settings show, and a preview walks through it. On a host that names a list of offered step types without the type's id, check the type is not in the palette. For a type that declares `starts_a_flow`, check the step is marked `Runs a flow`, that once a flow is picked in its settings clicking the step goes to that flow's page, that the `Back to` button on that page returns to the flow the step is in, and that its `Settings` button opens its settings. Also check that the step shows `missing flow` while no flow is picked, `unrunnable version` while the version number names no live or replaced version, `circular` while it names the flow it sits in, and that publishing is refused until each is fixed. For a type that declares `chooses_flow`, set the settings the block reads and check that clicking the step goes to the flow the block names, that a type with no `names_by` is named on the canvas by that flow's title and version, and that a run stopping at the step starts a run of that flow on that version. For a type that declares `chooses_flow_from_run`, start a stored run whose owner or answers the block reads, walk it to the step, and check `run.waiting_on` is a run of the flow and version the block names, and that a run for which the block names no flow raises `EasyFlow::InnerFlowError`.
|
|
281
282
|
|
|
283
|
+
### Take named inputs at a step type of the app's own
|
|
284
|
+
|
|
285
|
+
1. Include `EasyFlow::Inputs` after `EasyFlow::Step`, and read the values in `process`:
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
class FlowSteps::Script
|
|
289
|
+
include EasyFlow::Step
|
|
290
|
+
include EasyFlow::Inputs
|
|
291
|
+
|
|
292
|
+
step_name "Script"
|
|
293
|
+
|
|
294
|
+
def process(node, state)
|
|
295
|
+
ScriptRunner.call(EasyFlow::Inputs.values_of(node, state))
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
2. What then holds:
|
|
301
|
+
- The step's settings on the canvas hold an `inputs` list, and each entry takes a name, a fixed value, an earlier step and that step's output.
|
|
302
|
+
- An entry that names an earlier step reads that step's output, at its latest visit when a loop reached it more than once. Any other entry gives its fixed value.
|
|
303
|
+
- `state` in `process` still holds every visit under its own key, so a step type that adds up every visit keeps working.
|
|
304
|
+
- An earlier step that recorded a plain value gives that value for whichever output the entry names.
|
|
305
|
+
- `process` is not given completion times, so an entry naming "Hour completed", "Weekday completed" or "Minutes since 1970 when completed" gives nothing.
|
|
306
|
+
3. Publish a flow set to save each step with a loop back to a question before the step, answer it twice, and check the step reads the second answer.
|
|
307
|
+
|
|
282
308
|
### Keep a setting on one of the app's records
|
|
283
309
|
|
|
284
310
|
Use this when an admin should edit a value that belongs to one of the app's own records, such as a customer's name, from a step's settings on the canvas, and the flow should not hold a copy of it.
|
|
@@ -536,7 +562,7 @@ Use this when a visitor's run should stop until a set time has passed, such as a
|
|
|
536
562
|
## Conventions
|
|
537
563
|
|
|
538
564
|
- A step type's id comes from its class name or the id passed to `EasyFlow.step`, and it is stored in flow documents, so renaming the class or id breaks every flow that uses it.
|
|
539
|
-
- Never give a step type one of the built-in ids: `start`, `terminal`, `question`, `checklist`, `multiple_select`, `fill_in_the_blank`, `file_upload`, `condition`, `switch`, `compare`, `compare_two_values`, `all_or_any`, `count`, `wait`, `flow_step`.
|
|
565
|
+
- Never give a step type one of the built-in ids: `start`, `terminal`, `question`, `checklist`, `multiple_select`, `fill_in_the_blank`, `file_upload`, `condition`, `switch`, `compare`, `compare_two_values`, `all_or_any`, `count`, `read_data`, `write_data`, `wait`, `flow_step`.
|
|
540
566
|
- Register class-based step types inside `to_prepare`. A type registered anywhere else is lost on code reload in development.
|
|
541
567
|
- Registering a type does not put it in the palette of a host that names a list of offered step types without the type's id. Which step types a host offers is set through `easy_flow-install`, not here.
|
|
542
568
|
- A type that declares `ends_here` is in every host's palette, and a type that declares `begins_here` is in no host's palette.
|
|
@@ -31,6 +31,7 @@ easy_flow declares no commands of its own for this local. Its surface is split b
|
|
|
31
31
|
- To branch a flow on several comparisons at once, such as whether a reading is above one amount and a second reading is below another, no code is needed: an admin places an All of or Any of step on the canvas and lists the comparisons.
|
|
32
32
|
- To branch a flow on when a step was completed, such as the hour of the day or the weekday, or on how many minutes passed between two steps being completed, no code is needed: an admin places a Compare or Compare two values step on the canvas and picks one of the completion time outputs.
|
|
33
33
|
- To hold a run for a number of minutes after a step was completed, until the next time of day after it, or until a date, no code is needed: an admin places a Wait step on the canvas.
|
|
34
|
+
- To read from or write to the app inside a flow, no step type is needed: the app says what can be read and written and how, and an admin places a Read data or Write data step on the canvas.
|
|
34
35
|
- To make a visitor answer a question or tick something on a checklist before going on, no code is needed: an admin marks that step as required on the canvas.
|
|
35
36
|
- To let a visitor tick several answers on one step, no code is needed: an admin places a checklist step on the canvas.
|
|
36
37
|
- To explain an answer to the visitor, no code is needed: an admin fills in that answer's info on a question or checklist step.
|
|
@@ -74,6 +75,8 @@ easy_flow declares no commands of its own for this local. Its surface is split b
|
|
|
74
75
|
- **Compare** — a built-in branching step that reads an earlier step's answer as a number and checks it against an amount by more than, less than, at least or at most, then follows the true or the false connection. The admin picks which of the earlier step's outputs to read, as described under chosen output. An answer that is missing or is not a number decides false.
|
|
75
76
|
- **Compare two values** — a built-in branching step that reads two earlier steps' answers as numbers and checks the first against the second by more than, less than, at least, at most or equal to, then follows the true or the false connection. For each side the admin picks which of that step's outputs to read, as described under chosen output. An optional amount is added to the second answer before the check, so "more than" with an amount of 5 is true only when the first answer exceeds the second by more than 5, and a blank amount adds nothing. The second step may be left empty, and the first answer is then checked against the amount alone, or against 0 when the amount is blank too. When either answer is missing or is not a number, the step decides false.
|
|
76
77
|
- **All of or Any of** — a built-in branching step that holds a list of comparisons and a "Must hold" setting of "all of" or "any of", then follows the true or the false connection. Each comparison names an earlier step and which of its outputs to read, then more than, less than, at least, at most or equal to, then an amount, a second step with its output, or both. A comparison with a second step holds the same way a Compare two values step would decide, adding the amount to the second answer, and one with no second step checks the first answer against the amount alone. "All of" decides true only when every comparison holds, and "any of" when at least one does. A comparison whose answer is missing or is not a number does not hold, and a step with no comparisons decides false.
|
|
78
|
+
- **Read data and Write data** — built-in steps that ask the app to carry out one thing from its list of what a flow can read or write, in a nested transaction, then follow the `passed` or the `failed` connection. The step records `result`, `reason` on a failure, and the app's answer: a hash as named outputs, anything else as `answer`. The admin lists the answer's names under "Values it reports" so later steps can pick them. Both need a flow that keeps every answer as it is given. In a flow that keeps nothing or keeps the run only once it finishes, the visitor is told "Data can only be read or written in a flow that saves its runs." when the run reaches the step and cannot go on, and the app is not called.
|
|
79
|
+
- **Input** — a named value a Read data or Write data step passes to the app, either a fixed value or an output of an earlier step. A step reached more than once gives its most recent output.
|
|
77
80
|
- **Completion time** — the time a step was completed, kept for every visit beside the value that visit recorded. Every step that records a value has one, including steps that act on their own and waiting steps, not only steps a visitor answers. A stored run saves it with the run. A flow that carries its answers in the page carries each completion time in the page too, gives a value sent without a time the time it arrived, and drops a time it cannot read. A run stored when such a flow finishes keeps the completion times of the visits on its path. Going back discards the last visit's completion time with its value.
|
|
78
81
|
- **Completion time outputs** — three outputs every step that records a value offers beside its own: "Hour completed", the hour from 0 to 23 in the app's time zone, "Weekday completed", from 1 for Monday to 7 for Sunday, and "Minutes since 1970 when completed", the whole minutes since the start of 1 January 1970 in UTC. Each is read from the completion time of the latest visit to that step, and a step with no completion time has none of them. Compare, Compare two values and All of or Any of read them, so Compare two values with "more than" and an amount of 30 on two steps' minutes is true when the first step was completed more than 30 minutes after the second. Condition and Count list them among a step's outputs but do not read the time when one is picked.
|
|
79
82
|
- **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.
|
|
@@ -93,7 +96,13 @@ easy_flow declares no commands of its own for this local. Its surface is split b
|
|
|
93
96
|
- **Host** — a named part of the app that owns a set of flows. A host sets the visitor layout and the admin layout, how admins are authenticated, how visitors are authorized, how a refusal is answered, and optionally which step types it offers and which answers it allows a step in a run. Flows are always looked up through a host, so one host never sees another's flows.
|
|
94
97
|
- **Offered step types** — the list of step types a host names for its admins to add. A host that names no list offers every registered step type. The list only narrows the palette.
|
|
95
98
|
- **Allowed answers** — a rule a host may carry that looks at a step and the answers given so far in a run and names the answer values that step allows. When it names nothing for a step, the step offers every answer. When it names several, a question or checklist offers only those. When it names exactly one, the step records that answer without being shown, and when it names none, the step is passed over and records a blank answer. This holds in a stored run, a run moved on outside a visitor's request, a flow that carries its answers in the page, and an admin's preview. Going back skips the steps the host filled in and returns to the last step the visitor was shown.
|
|
96
|
-
- **
|
|
99
|
+
- **Always-visible insert buttons** — a setting a host may turn on, `always_show_insert_buttons`, that shows each connection's button for inserting a step on its canvas without the pointer over the connection, with the connection's label moved to the right of the line. It is off by default, and with it off the button shows only with the pointer over the connection.
|
|
100
|
+
- **Hidden step type codes** — a setting a host may turn on, `hide_step_type_codes`, that hides step type code names on its canvas. The add menu lists each step type by its name alone, and a step's box shows its step type's name in place of the code name, with a step that has no name of its own headed by its step type's name. It is off by default, and with it off the add menu and step boxes show the code names. The settings panel shows a step's id and code name either way.
|
|
101
|
+
- **Publish note** — a setting a host may set, `publish_note`, whose sentence is shown directly under the Publish button in its flow panel. With none set, the panel shows no note.
|
|
102
|
+
- **Back link** — a setting a host may set, `back_link`, holding a label and an address, that shows a button with that label and address in its builder header in place of `All flows`. With none set, the header shows `All flows`.
|
|
103
|
+
- **Flow panel** — the panel the `This flow` button opens on the canvas, on every host. It shows Create version and Publish above its lists of problems and changes. Several saves to the same step in a row show as one line in the changes list, named by the step's latest name. A save, a move and any other change each keep their own line, and the history of versions still lists every change a version captured.
|
|
104
|
+
- **Picture mark** — the words `Has a picture` on the box of a step with a picture on the canvas, on every host.
|
|
105
|
+
- **Palette** — the step types an admin can add on a host's canvas. It never holds the start step, always holds the end step, and otherwise holds the step types the host offers. A step already in a flow whose type the host does not offer is still drawn and run, and still opens with all its settings and can be saved.
|
|
97
106
|
- **Run** — one visitor's pass through a flow. A run may belong to one of the app's own records, its owner, which the app sets. A run is pinned to the version it started on, so publishing a new version does not change a run already under way. A run starts on the live version unless the app chooses another of the flow's published versions. A run is refused when the chosen version is a draft, is retired or withdrawn, or belongs to another flow. It records each visit, and going back discards the last visit on the path.
|
|
98
107
|
- **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.
|
|
99
108
|
- **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.
|
|
@@ -20,6 +20,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
20
20
|
- `EasyFlow.draws_with` — the partial that draws a visitor's step when the step's type names none. Defaults to the engine's own `easy_flow/steps/choosing`.
|
|
21
21
|
- `EasyFlow.file_store` — the object that keeps a file a visitor uploads at a File upload step and names it again later. Defaults to none, and with none set an uploaded file is refused with `EasyFlow::NoFileStore`.
|
|
22
22
|
- `EasyFlow.picture_store` — the object that keeps a picture an admin adds to a question step on the canvas, for the flow it belongs to, and gives the address the picture is loaded from. Defaults to none, and with none set an admin who chooses a picture is told pictures cannot be added yet.
|
|
23
|
+
- `EasyFlow.data_source` — the object that says what Read data and Write data steps can read and write, and carries each one out. Defaults to none, and with none set those steps offer no choices.
|
|
23
24
|
- `EasyFlow.check` — turns on an optional flow check: `:unrouted_value`, `:unfollowed_path` or `:dead_end`.
|
|
24
25
|
- `/manage/flows` — the admin pages, under each mount path, where flows are listed, created, drawn on the canvas, previewed and published.
|
|
25
26
|
|
|
@@ -46,7 +47,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
46
47
|
host.visitor_authorization_method = :easy_flow_visitor_permitted?
|
|
47
48
|
host.refusal_method = :refuse_flow
|
|
48
49
|
host.owner_method = :current_account
|
|
49
|
-
host.offers = %i[question checklist multiple_select fill_in_the_blank file_upload condition switch compare compare_two_values all_or_any count wait flow_step]
|
|
50
|
+
host.offers = %i[question checklist multiple_select fill_in_the_blank file_upload condition switch compare compare_two_values all_or_any count read_data write_data wait flow_step]
|
|
50
51
|
end
|
|
51
52
|
```
|
|
52
53
|
|
|
@@ -57,6 +58,10 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
57
58
|
- `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`.
|
|
58
59
|
- `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.
|
|
59
60
|
- `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.
|
|
61
|
+
- `always_show_insert_buttons` — set to `true` to show each connection's button for inserting a step on this host's canvas without the pointer over the connection. A connection's label then sits to the right of the line so the button does not cover it. With none set, the button shows only with the pointer over the connection.
|
|
62
|
+
- `hide_step_type_codes` — set to `true` to hide step type code names on this host's canvas. The add menu then lists each step type by its name alone, and a step's box shows its step type's name in place of the code name. With none set, the add menu and step boxes show the code names. The settings panel shows a step's id and code name either way.
|
|
63
|
+
- `publish_note` — a sentence shown directly under the Publish button in this host's flow panel. It is shown as plain text. With none set, the panel shows no note.
|
|
64
|
+
- `back_link` — a hash with a `label` and an `address`, such as `{ label: "Games", address: "/admin/games" }`, that shows a button with that label and address in this host's builder header in place of `All flows`. The header then has no `All flows` button. A hash missing either key raises `ArgumentError` when the host is declared, and a blank value is not refused. The address is used as written and is not checked, so it comes from the app's own configuration and never from a request. With none set, the header shows `All flows`.
|
|
60
65
|
|
|
61
66
|
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.
|
|
62
67
|
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:
|
|
@@ -100,10 +105,21 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
100
105
|
- The store answers `url_of(reference)`, called with a reference `keep` returned. It returns the address a page loads the picture from.
|
|
101
106
|
- The store refuses a file it will not keep, such as one that is not an image or is too large, by raising `EasyFlow::PictureRefused` with the message the admin should see. The canvas shows that message and leaves the step's picture as it was.
|
|
102
107
|
- With no store set, an admin who chooses a picture on the canvas is told "Pictures cannot be added yet. Ask the app's developer to set up a picture store." and the step is left unchanged.
|
|
103
|
-
11.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
11. Ask the developer whether any host will offer the Read data or Write data step, and if so, what a flow may read and write and how the app carries each one out. Set the object that answers in the initializer:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
EasyFlow.data_source = FlowData.new
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- The object answers `reads`, a list of strings naming what a flow can read.
|
|
115
|
+
- The object answers `writes`, a list of strings naming what a flow can write.
|
|
116
|
+
- The object answers `carry_out(name, values)`, called with one of those names and a hash of the step's input values, with string keys as the admin named them. What it returns is the step's answer. What it raises is the step's failure.
|
|
117
|
+
- A step whose name is no longer on its list records a failure and does not call `carry_out`.
|
|
118
|
+
- Read data and Write data need a flow that keeps every answer as it is given. In a flow that keeps nothing or keeps the run only once it finishes, the visitor is told "Data can only be read or written in a flow that saves its runs." when the run reaches the step and cannot go on, and the app is not called.
|
|
119
|
+
12. 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.
|
|
120
|
+
13. 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.
|
|
121
|
+
14. 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.
|
|
122
|
+
15. Start the server and open `<mount path>/manage/flows` for each host.
|
|
107
123
|
|
|
108
124
|
## Conventions
|
|
109
125
|
|
|
@@ -113,7 +129,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
113
129
|
- After install, check that an End step selected on the canvas shows an output setting. An admin fills it in on each End step, and it needs no migration and no setting in the initializer.
|
|
114
130
|
- After publishing a flow with a checklist, check that a visitor can tick several answers and go on, and that a checklist marked required refuses to go on with nothing ticked.
|
|
115
131
|
- After upgrading easy_flow, run `bin/rails easy_flow:install:migrations` again and then `bin/rails db:migrate`. Only migrations the app does not already have are copied.
|
|
116
|
-
- A flow has no kind. An app upgrading from a version that stored one stops setting `kind` on `EasyFlow::Definition` before upgrading, since setting it raises `ActiveModel::UnknownAttributeError`, and removes any use of the `scored` and `guide` scopes and the `scored?` and `guide?` methods. It deploys the upgraded gem first, then runs `bin/rails easy_flow:install:migrations` and `bin/rails db:migrate` in a later deploy, since a process of the earlier version cannot create a flow once the column is dropped. An app using alembic runs alembic's migration moving flows onto easy_flow on the earlier easy_flow version first.
|
|
132
|
+
- A flow has no kind. An app upgrading from a version that stored one stops setting `kind` on `EasyFlow::Definition` before upgrading, since setting it raises `ActiveModel::UnknownAttributeError`, and removes any use of the `scored` and `guide` scopes and the `scored?` and `guide?` methods. It deploys the upgraded gem first, then runs `bin/rails easy_flow:install:migrations` and `bin/rails db:migrate` in a later deploy, since a process of the earlier version cannot create a flow once the column is dropped. An app using alembic runs alembic's migration moving flows onto easy_flow on the earlier easy_flow version first. An app on SQLite that copied this migration from 0.32.0 adds `disable_ddl_transaction!` as the first line inside the class of its copy before running it, since that copy deletes rows in other tables that reference flows and the install command does not replace a copy the app already has.
|
|
117
133
|
- After setting a host's `owner_method`, check that two different owners each see only their own flows on `<mount path>/manage/flows`.
|
|
118
134
|
- After install, check that a Condition step placed on the canvas, once its earlier step is picked, offers that step's outputs, a comparison of "is" or "is not", and the answers of the output picked. It is named after that step, the comparison and the answer, decides a true or false result, and needs no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
|
|
119
135
|
- After install, check that a Count step placed on the canvas, once its earlier step and answer are picked, is named "Count" followed by that step and that answer. It needs no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
|
|
@@ -145,6 +161,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
|
|
|
145
161
|
- After filling in a Wait step, check its name on the canvas. With a date set it is named by the date alone, and the run waits until that date. Otherwise, with a time of day set, it is named by that time followed by "after" and the step it counts from, and the run waits until that time of day first comes after the step was answered. Otherwise it is named by the minutes, as "1 minute after" or "<n> minutes after" followed by the step it counts from, and the run waits that many minutes past the answer. With none of the three set it has no name of its own, and a visitor held at it sees "Waiting for Wait."
|
|
146
162
|
- The engine queues no job for a Wait step. After publishing a flow with a Wait step, check that a visitor held at it sees "Waiting for" followed by the step's name, and that a stored run moves on when the visitor's page is loaded after its time has come. Moving a run on with no visitor present belongs to the `easy_flow-develop` local.
|
|
147
163
|
- After publishing a flow with a Wait step whose "What this keeps of a run" detail is "Nothing" or "The whole run once it finishes", check that a visitor held at it sees a "Check again" button, and that pressing it after the time has come carries their answers and completion times forward and moves them past the Wait step.
|
|
164
|
+
- After setting `EasyFlow.data_source`, check that a Read data step on the canvas offers the object's `reads` and a Write data step offers its `writes`. Publish a flow set to save each step, connect each step's `passed` and `failed` connections, and check a run follows `passed` when `carry_out` answers and `failed` when it raises.
|
|
148
165
|
- After setting `EasyFlow.file_store`, publish a flow with a File upload step whose "What this keeps of a run" detail is "Every answer as it is given", upload a file as a visitor, and check that the completion page shows the name the store gives it.
|
|
149
166
|
- A File upload step takes a file only in a flow that keeps every answer as it is given. In a flow that keeps nothing or keeps the run only once it finishes, the visitor is told "A file can only be uploaded in a flow that saves its runs." and cannot go on with a file chosen.
|
|
150
167
|
- A File upload step's "Accepted file types" setting is a comma-separated list of extensions, such as `.pdf, .png`. A file of another kind is refused with a message naming the accepted kinds and is never passed to the file store.
|
data/the_local/interface.yml
CHANGED
|
@@ -8,6 +8,7 @@ install:
|
|
|
8
8
|
- EasyFlow.draws_with
|
|
9
9
|
- EasyFlow.file_store
|
|
10
10
|
- EasyFlow.picture_store
|
|
11
|
+
- EasyFlow.data_source
|
|
11
12
|
- EasyFlow.check
|
|
12
13
|
- /manage/flows
|
|
13
14
|
|
|
@@ -16,6 +17,7 @@ develop:
|
|
|
16
17
|
- EasyFlow.step
|
|
17
18
|
- labels_answer_by
|
|
18
19
|
- outputs_named_by
|
|
20
|
+
- EasyFlow::Inputs
|
|
19
21
|
- host.allowed_answers=
|
|
20
22
|
- EasyFlow::FlowsController
|
|
21
23
|
- hosted_by
|
|
@@ -48,6 +50,10 @@ sources:
|
|
|
48
50
|
- app/models/easy_flow/count.rb
|
|
49
51
|
- app/models/easy_flow/compare_two_values.rb
|
|
50
52
|
- app/models/easy_flow/all_or_any.rb
|
|
53
|
+
- app/models/easy_flow/read_data.rb
|
|
54
|
+
- app/models/easy_flow/write_data.rb
|
|
55
|
+
- app/models/easy_flow/data_step.rb
|
|
56
|
+
- app/models/easy_flow/inputs.rb
|
|
51
57
|
- app/models/easy_flow/wait.rb
|
|
52
58
|
- app/models/easy_flow/completed_at.rb
|
|
53
59
|
- app/models/easy_flow/step_type.rb
|
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.
|
|
4
|
+
version: 0.33.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- tylercschneider
|
|
@@ -112,6 +112,7 @@ files:
|
|
|
112
112
|
- app/models/easy_flow/completed_at.rb
|
|
113
113
|
- app/models/easy_flow/condition.rb
|
|
114
114
|
- app/models/easy_flow/count.rb
|
|
115
|
+
- app/models/easy_flow/data_step.rb
|
|
115
116
|
- app/models/easy_flow/definition.rb
|
|
116
117
|
- app/models/easy_flow/digest.rb
|
|
117
118
|
- app/models/easy_flow/document.rb
|
|
@@ -119,6 +120,7 @@ files:
|
|
|
119
120
|
- app/models/easy_flow/edge.rb
|
|
120
121
|
- app/models/easy_flow/edit_history.rb
|
|
121
122
|
- app/models/easy_flow/flow_step.rb
|
|
123
|
+
- app/models/easy_flow/inputs.rb
|
|
122
124
|
- app/models/easy_flow/layout.rb
|
|
123
125
|
- app/models/easy_flow/name.rb
|
|
124
126
|
- app/models/easy_flow/node.rb
|
|
@@ -128,6 +130,7 @@ files:
|
|
|
128
130
|
- app/models/easy_flow/progress/kept.rb
|
|
129
131
|
- app/models/easy_flow/progress/loose.rb
|
|
130
132
|
- app/models/easy_flow/question_runner.rb
|
|
133
|
+
- app/models/easy_flow/read_data.rb
|
|
131
134
|
- app/models/easy_flow/registry.rb
|
|
132
135
|
- app/models/easy_flow/run.rb
|
|
133
136
|
- app/models/easy_flow/runner.rb
|
|
@@ -147,7 +150,9 @@ files:
|
|
|
147
150
|
- app/models/easy_flow/version.rb
|
|
148
151
|
- app/models/easy_flow/violation.rb
|
|
149
152
|
- app/models/easy_flow/wait.rb
|
|
153
|
+
- app/models/easy_flow/write_data.rb
|
|
150
154
|
- app/views/easy_flow/flows/complete.html.erb
|
|
155
|
+
- app/views/easy_flow/flows/refused.html.erb
|
|
151
156
|
- app/views/easy_flow/flows/show.html.erb
|
|
152
157
|
- app/views/easy_flow/flows/step.html.erb
|
|
153
158
|
- app/views/easy_flow/manage/definitions/edit.html.erb
|