easy_flow 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 230776aa4cde3ff6b14e622b6556b8fc27c7cc200689fe1760f79c9517419186
4
- data.tar.gz: 7617ecac9e8bffd31608e407ffb685f21df70311a7693ffb20d923b8abeb7996
3
+ metadata.gz: 1c21a6edf0086eee7de013dd151cd128f0bcc5d8d412e4a6c79b2a2231278855
4
+ data.tar.gz: cc47c44ecbb25b1acf1854ab1cd8d66112a49fafc8c2eaa9ceaac34b631059ce
5
5
  SHA512:
6
- metadata.gz: dbe9e03688e1a7f6bbe0ec3c1b11112342da52a4a346be1711125530eab22a25c70a52c1b036c42c3dd03c583c324ef44799f713691e2161eaa4d5d64d97a58b
7
- data.tar.gz: 46ae29e0296da8586207576e9db24fa84e02bab9a1ccd63d3fc5d0e7aca769bbcca126096a31cdadfc869372c4feeb543f96707c1914ab045a516f681f621e99
6
+ metadata.gz: 87527bde8f215b3cde4ad930936c25c2174a45b16a0d5d89737e3891e090ba4a798a2b8ca615ed186bc8422d69a62e3f5a7c48b78ad97623c8380c7f5acf1d65
7
+ data.tar.gz: 10969742e56b6725db6571637dee3be07d5ae6076b5e5021e725f1f3a0108e104bf27f307b6ed1bb662f60aa70389eac594ab7b62debd12c7efb6c30089d118d
@@ -1 +1 @@
1
- (()=>{var je=globalThis.ReactDOM;var{createPortal:Lt,createRoot:Q,flushSync:It,hydrateRoot:Ot,preconnect:Mt,prefetchDNS:Ht,preinit:jt,preinitModule:Ft,preload:Wt,preloadModule:Vt,requestFormReset:Ut,unstable_batchedUpdates:Yt,useFormState:Xt,useFormStatus:Gt,version:qt}=je;var R=globalThis.React,t=R,{Children:Kt,Component:Zt,Fragment:Qt,PureComponent:Rt,StrictMode:eo,Suspense:to,cloneElement:oo,createContext:ro,createElement:no,createRef:so,forwardRef:ao,isValidElement:lo,lazy:io,memo:co,startTransition:po,useCallback:uo,useContext:mo,useDebugValue:fo,useDeferredValue:go,useEffect:$,useId:yo,useImperativeHandle:ho,useLayoutEffect:ee,useMemo:A,useReducer:bo,useRef:U,useState:k,useSyncExternalStore:xo,useTransition:vo}=R;var te=new Map,O=new Map,oe=(e,o)=>te.set(e,o),re=()=>{O.forEach(e=>e.unmount()),O.clear()},ne=(e,o)=>e.querySelectorAll("[data-react-ui]").forEach(n=>{if(O.has(n))return;let r=te.get(n.dataset.reactUi),a=o(n);O.set(n,a),a.render(t.createElement(r,JSON.parse(n.dataset.props)))});var se=(e,o)=>{let n=()=>ne(e,o);e.addEventListener("turbo:load",n),e.addEventListener("turbo:frame-load",n),e.addEventListener("turbo:before-cache",re),e.readyState==="loading"?e.addEventListener("DOMContentLoaded",n):n()};var S=(...e)=>e.filter(Boolean).join(" ");var Fe={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},We={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},Ve=({variant:e="primary",size:o="md",className:n,children:r,...a})=>{let i=a.href?"a":"button";return t.createElement(i,{...a,className:S("ks-button",Fe[e],We[o],n)},r)},v=Ve;var Ue="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",Ye="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",Xe="border border-accent-600 bg-accent-600 text-white",Ge=({name:e,connected:o,armed:n,onArm:r,connecting:a})=>t.createElement("button",{onClick:i=>{a||(i.stopPropagation(),r(i))},title:n?"Now choose a step to connect to":"Connect this branch",className:n?Xe:o?Ye:Ue,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},e||"next"),ae=Ge;var qe={width:240,boxSizing:"border-box",borderRadius:8},Je={padding:"8px 14px",textAlign:"center",fontWeight:600},Ke={fontWeight:600,lineHeight:1.3},Ze=(e,o,n)=>{let r=n?"ring-4 ring-accent-600/15":"shadow-sm";return e||n&&!o?`border-accent-600 ${r}`:o?`border-red-600 ${r}`:"border-gray-300 shadow-sm dark:border-zinc-700"},Qe=(e,o)=>o&&e.label===e.id&&e.type_name||e.label,Re=(e,o,n)=>n?o===e.type_name?null:e.type_name:e.type,et=({node:e,selected:o,armed:n,connecting:r,onSelect:a,onOpen:i,onArm:c,onDragEnd:l,onDragStart:p,hideStepTypeCodes:f})=>{let g=e.begins_here||e.ends_here,d=e.begins_here,h=e.ends_here?[]:e.ports,u=r&&!d,y=Qe(e,f),w=Re(e,y,f);return t.createElement("div",{ref:e.ref,"data-step":e.id,draggable:!d,onDragStart:b=>{b.dataTransfer.effectAllowed="move",b.dataTransfer.setData("text/plain",e.id),p()},onDragEnd:l,onClick:e.opens&&!r?()=>i(e.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Ze(u,e.violations.length>0,o)}`,style:{...qe,padding:g?0:"12px 14px",cursor:d?"default":r?"crosshair":"grab"}},g&&t.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:Je},y),!g&&t.createElement("div",{style:Ke},y),!g&&w&&t.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginTop:2}},w),e.picture_url&&t.createElement("div",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:11,marginTop:2}},"Has a picture"),e.starts_a_flow&&t.createElement("div",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:11,marginTop:2}},"Runs a flow"),e.opens&&t.createElement(v,{variant:"secondary",size:"sm",className:"mt-2",onClick:b=>{b.stopPropagation(),a()}},"Settings"),e.violations.map(b=>t.createElement("div",{key:b.problem+b.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},b.problem.replace(/_/g," "),b.detail?`: ${b.detail}`:"")),h.length>0&&t.createElement("div",{style:{padding:e.begins_here?"0 14px 8px":"10px 0 0"}},h.map(b=>t.createElement(ae,{key:b,name:b,connected:e.connected.includes(b),connecting:r,armed:n===b,onArm:C=>c(b,C)}))))},Y=et;var X=(e,o)=>e.includes(o)?e.filter(n=>n!==o):[...e,o],le=(e,o)=>({...o?.choices||{},...e?.choices||{}});var tt=({as:e="input",disabled:o,className:n,...r})=>t.createElement(e,{...r,disabled:o,className:S("ks-input",o&&"ks-input-disabled",n)}),_=tt;var ot=e=>t.createElement(_,{...e,as:"select"}),ie=ot;var rt=({className:e,...o})=>t.createElement("input",{type:"checkbox",...o,className:S("ks-checkbox",e)}),M=rt;var de=e=>(e||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),nt=({type:e,value:o,choices:n,onChange:r,onSettle:a,onPick:i,pictureUrl:c})=>{if(e==="picture")return t.createElement("div",{className:"mb-3"},o&&c&&t.createElement("img",{src:c,alt:"The step's picture",style:{maxWidth:"100%"}}),o&&t.createElement("button",{type:"button","data-remove-picture":!0,className:"ks-button ks-button-secondary ks-button-sm",onClick:()=>a("")},"Remove picture"),t.createElement("input",{type:"file",accept:"image/*",onChange:l=>l.target.files[0]&&i(l.target.files[0])}));if(e==="boolean")return t.createElement(M,{checked:!!o,onChange:l=>a(l.target.checked)});if(e==="select"||e==="previous_step"||e==="from_step"||e==="flow")return t.createElement(ie,{className:"mb-3",value:o??"",onChange:l=>a(l.target.value)},t.createElement("option",{value:""}),de(n).map(l=>t.createElement("option",{key:l.value,value:l.value},l.label)));if(e==="multi_select"){let l=Array.isArray(o)?o:[],p=f=>a(X(l,f));return t.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(f=>t.createElement("label",{key:f,style:{display:"inline-flex",alignItems:"center",gap:4}},t.createElement(M,{checked:l.includes(f),onChange:()=>p(f)}),t.createElement("span",null,f))))}if(e==="flows"){let l=Array.isArray(o)?o:[];return t.createElement("div",{style:{display:"flex",flexDirection:"column",gap:4},className:"mb-3"},de(n).map(p=>t.createElement("label",{key:p.value,style:{display:"inline-flex",alignItems:"center",gap:4}},t.createElement(M,{checked:l.includes(p.value),onChange:()=>a(X(l,p.value))}),t.createElement("span",null,p.label))))}return e==="integer"||e==="float"?t.createElement(_,{className:"mb-3",type:"number",step:e==="integer"?"1":"any",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)}):t.createElement(_,{className:"mb-3",type:"text",value:o??"",onChange:l=>r(l.target.value),onBlur:l=>a(l.target.value)})},H=nt;var ce=(e,o,n,r)=>e.map((a,i)=>i===o?{...a,[n]:r}:a);var st=(e,o,n,r)=>{let a=(e||{})[n],i=(o||{})[n];return i?(a||{})[r[i]]:a},at=({holds:e,labels:o,rows:n,choices:r,outputsOf:a,onChange:i,onSettle:c})=>{let l=Array.isArray(n)?n:[],p=(f,g,d,h)=>{let u=ce(l,f,g,d);h?c(u):i(u)};return t.createElement("div",{style:{marginBottom:12}},l.map((f,g)=>t.createElement("div",{key:g,className:"rounded-md border border-gray-200 dark:border-zinc-700",style:{padding:"8px 8px 2px",marginBottom:6}},Object.entries(e).map(([d,h])=>t.createElement("label",{key:d,style:{display:"block"}},t.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[d]||d),t.createElement(H,{type:h,value:f[d],choices:st(r,a,d,f),onChange:u=>p(g,d,u,!1),onSettle:u=>p(g,d,u,!0)}))),t.createElement(v,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>c(l.filter((d,h)=>h!==g))},"Remove"))),t.createElement(v,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>c([...l,{}])},"Add"))},pe=at;var lt=({as:e="div",className:o,children:n,...r})=>t.createElement(e,{...r,className:S("ks-panel",o)},n),L=lt;var j=(e,o)=>({className:n,children:r,...a})=>t.createElement(e,{...a,className:S(o,n)},r),I=j("label","ks-label"),pr=j("p","ks-hint"),ur=j("p","ks-error"),mr=j("span","ks-required");var it={width:280,padding:20,overflowY:"auto"},dt=({node:e,fields:o,holds:n,labels:r,recordLabels:a,recordChoices:i,recordOutputsOf:c,choices:l,onSave:p,onDelete:f,onClose:g,onPickPicture:d,refusal:h})=>{let[u,y]=k(e.config),[w,b]=k(e.picture_url);$(()=>b(e.picture_url),[e.id,e.picture_url]),$(()=>y(e.config),[e.id,e.config]);let C=x=>{y(x),JSON.stringify(x)!==JSON.stringify(e.config)&&p(x)};return t.createElement(L,{as:"aside",style:it,"data-inspector":!0},t.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},t.createElement("h2",{style:{fontWeight:600,marginBottom:2}},e.label),t.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")),t.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginBottom:16}},e.id," \xB7 ",e.type),h&&t.createElement("p",{className:"text-red-800 dark:text-red-400",style:{fontWeight:600,marginBottom:12},"data-refusal":!0},h),Object.entries(o).map(([x,T])=>t.createElement(I,{key:x},t.createElement("span",{style:{display:"block",marginBottom:3}},r[x]||x),T==="list"?t.createElement(pe,{holds:n[x]||{},labels:a[x]||{},rows:u[x],choices:{...(i||{})[x],...(e.record_choices||{})[x]},outputsOf:(c||{})[x],onChange:N=>y({...u,[x]:N}),onSettle:N=>C({...u,[x]:N})}):t.createElement(H,{type:T,value:u[x],choices:l[x],pictureUrl:w,onChange:N=>y({...u,[x]:N}),onSettle:N=>C({...u,[x]:N}),onPick:async N=>{let B=await d(N);B&&(b(B.url),C({...u,[x]:B.reference}))}}))),t.createElement(v,{variant:"danger",size:"sm",className:"w-full",onClick:f},"Delete step"))},ue=dt;var ct={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},pt="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",ut="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",mt=({node:e,dragging:o,onFill:n,onDrop:r})=>t.createElement("div",{ref:e.ref,"data-placeholder":e.id,title:`Choose the step \u201C${e.label}\u201D should lead to`,onClick:n,onDragOver:a=>{o&&(a.preventDefault(),a.dataTransfer.dropEffect="move")},onDrop:a=>{a.preventDefault(),r()},className:o?ut:pt,style:ct},t.createElement("div",{style:{fontWeight:600}},e.label),t.createElement("div",{style:{fontSize:11}},o?"drop a step here":"leads nowhere yet \u2014 click to choose")),me=mt;var ft=({entries:e,at:o,onPick:n,onConnect:r,onDismiss:a,hideStepTypeCodes:i})=>t.createElement(L,{className:"shadow-lg",style:{position:"absolute",zIndex:9,top:o.y,left:o.x,width:210,padding:8,borderRadius:8}},t.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{margin:"0 0 6px",fontSize:11}},"Add a step"),e.map(c=>t.createElement(v,{key:c.type,variant:"secondary",size:"sm",className:"w-full mb-1.5 flex-col",onClick:()=>n(c)},c.label,!i&&t.createElement("span",{className:"opacity-75",style:{display:"block",fontSize:11}},c.type))),r&&t.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:r},"Connect to a step already here"),t.createElement(v,{variant:"secondary",size:"sm",className:"w-full",onClick:a},"Cancel")),fe=ft;var gt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},ge=({label:e,title:o,idle:n,enabled:r,onUse:a})=>t.createElement(v,{variant:"secondary",size:"sm",title:r?o:n,disabled:!r,onClick:a,style:{opacity:r?1:.4,cursor:r?"pointer":"default"}},e),yt=({undoable:e,redoable:o,onAdd:n,onUndo:r,onRedo:a})=>t.createElement("div",{style:{position:"absolute",top:16,left:16,zIndex:5,display:"flex",gap:8}},t.createElement(v,{variant:"secondary",className:"rounded-full",style:gt,title:"Add a step",onClick:n},"+"),t.createElement(ge,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:e,onUse:r}),t.createElement(ge,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),ye=yt;var ht=({type:e="info",title:o,message:n,onDismiss:r,className:a})=>t.createElement("div",{className:S("ks-alert",`ks-alert-${e}`,a),role:"alert"},t.createElement("div",{className:"ks-alert-body"},t.createElement("div",{className:"ks-alert-content"},o&&t.createElement("p",{className:"ks-alert-title"},o),t.createElement("p",{className:o?"ks-alert-message-titled":"ks-alert-message"},n)),r&&t.createElement("button",{type:"button",className:"ks-alert-dismiss","aria-label":"Dismiss",onClick:r},"\xD7"))),he=ht;var be=e=>{let o=e.problem.replace(/_/g," ");return e.node?`\u201C${e.node}\u201D ${o}`:o},xe=e=>`${e.map(n=>`\u201C${n.label}\u201D`).join(", ")} ${e.length===1?"is":"are"} not connected to the flow and will be removed.`;var ve=({version:e,published:o})=>e?o?o===e?`Version ${e} \xB7 live`:`Version ${e} \xB7 visitors still on ${o}`:`Version ${e} \xB7 never published`:"No version yet";var bt={width:280,padding:20,overflowY:"auto"},G={fontWeight:600,marginBottom:6,marginTop:14},F={fontSize:12,marginBottom:4,lineHeight:1.4},ke={display:"block",marginBottom:3},we=(e,o,n)=>r=>{r.target.value!==(e[o]??"")&&n({[o]:r.target.value})},xt=({flow:e,changes:o,problems:n,refusal:r,notice:a,publishNote:i,warning:c,onCreate:l,onPublish:p,onAccept:f,onDecline:g,onSaveDetails:d,onClose:h})=>t.createElement(L,{as:"aside",style:bt,"data-builder-panel":!0},t.createElement("div",{style:{display:"flex",alignItems:"start",justifyContent:"space-between",gap:8}},t.createElement("h2",{style:{fontWeight:600},"data-flow-name":!0},e.title||e.slug),t.createElement("button",{title:"Close",onClick:h,className:"text-gray-500 dark:text-gray-400",style:{border:"none",background:"none",cursor:"pointer",fontSize:18,lineHeight:1}},"\xD7")),t.createElement("a",{className:"text-accent-600 dark:text-accent-400",style:{fontSize:12,display:"block"},href:e.history_url,"data-history":!0},ve(e)),c?t.createElement("div",{style:{marginTop:14},"data-unconnected-warning":!0},t.createElement(he,{type:"warning",className:"mb-1.5",message:xe(c.steps)}),t.createElement(v,{size:"sm",className:"w-full mb-1.5",onClick:f,"data-accept-removal":!0},c.path==="/publish"?"Remove and publish":"Remove and create version"),t.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:g,"data-decline-removal":!0},"Cancel")):t.createElement("div",{style:{marginTop:14}},t.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:l,"data-create-version":!0},"Create version"),t.createElement(v,{size:"sm",className:"w-full mb-1.5",onClick:p,"data-publish":!0},"Publish"),i&&t.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12,lineHeight:1.4},"data-publish-note":!0},i)),t.createElement("h2",{style:G},"Problems"),r&&t.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...F,fontWeight:600},"data-refusal":!0},r),a&&!r&&t.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...F,fontWeight:600},"data-notice":!0},a),n.length===0&&!r?t.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing wrong with this flow."):n.map(u=>t.createElement("p",{key:`${u.node}-${u.problem}`,className:"text-amber-700 dark:text-amber-400",style:F,"data-problem":!0},"\u26A0 ",be(u))),t.createElement("h2",{style:G},"Changes since the last version"),o.length===0?t.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12}},"Nothing has changed."):o.map((u,y)=>t.createElement("p",{key:y,style:F,"data-change":!0},u)),t.createElement("h2",{style:G},"Details"),t.createElement(I,null,t.createElement("span",{style:ke},"Title"),t.createElement(_,{className:"mb-3",defaultValue:e.title??"",onBlur:we(e,"title",d),"data-flow-title":!0})),t.createElement(I,null,t.createElement("span",{style:ke},"Start label"),t.createElement(_,{className:"mb-3",defaultValue:e.start_label??"",onBlur:we(e,"start_label",d),"data-flow-start-label":!0})),t.createElement("div",{style:{marginTop:16}},t.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:e.definition_url,"data-definition":!0},"Definition"),t.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:e.details_url,"data-details":!0},"Edit details"))),Ce=xt;var Ne={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},Se=e=>({opacity:e?1:0,transition:"opacity .12s"}),vt=({link:e,onInsert:o,onRemove:n,onDrop:r,dragging:a,alwaysShowInsertButtons:i})=>{let[c,l]=k(!1),p=c||a;return t.createElement("div",{"data-connector":`${e.source}-${e.target}`,style:{position:"absolute",left:e.midX-34,top:e.midY-20,width:68,height:40,zIndex:4},onMouseEnter:()=>l(!0),onMouseLeave:()=>l(!1),onDragEnter:()=>l(!0),onDragLeave:()=>l(!1),onDragOver:f=>{f.preventDefault(),f.dataTransfer.dropEffect="move"},onDrop:f=>{f.preventDefault(),l(!1),r()}},t.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%"}},t.createElement(v,{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&&c),style:{...Ne,...Se(p||i)}},"+"),!a&&t.createElement(v,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:{...Ne,...Se(p)}},"\xD7")))},ze=vt;var kt=({links:e,extent:o,dragging:n,onInsert:r,onRemove:a,onDrop:i,alwaysShowInsertButtons:c})=>t.createElement(t.Fragment,null,t.createElement("svg",{width:o.width,height:o.height,style:{position:"absolute",top:0,left:0,pointerEvents:"none",zIndex:1}},t.createElement("defs",null,t.createElement("marker",{id:"easy_flow-arrow",viewBox:"0 0 10 10",refX:"8",refY:"5",markerWidth:"5",markerHeight:"5",orient:"auto"},t.createElement("path",{d:"M 0 0 L 10 5 L 0 10 z",className:"fill-gray-400 dark:fill-zinc-500"}))),e.map(l=>t.createElement("path",{key:l.id,"data-link":l.id,className:"stroke-gray-400 dark:stroke-zinc-500",fill:"none",strokeWidth:"1.5",markerEnd:"url(#easy_flow-arrow)",d:l.path}))),e.filter(l=>l.label).map(l=>t.createElement("div",{key:`${l.id}-label`,"data-link-label":l.id,className:"bg-surface-50 text-gray-500 dark:bg-surface-950 dark:text-gray-400",style:{position:"absolute",left:l.midX+(c?2:-10),top:l.midY-18,zIndex:3,fontSize:11,padding:"0 3px"}},l.label)),e.filter(l=>!l.placeholder).map(l=>t.createElement(ze,{key:l.id,link:l,dragging:!!n,alwaysShowInsertButtons:c,onInsert:()=>r(l),onRemove:()=>a(l),onDrop:()=>i(l)}))),Be=kt;var _e="Your change was not saved because the server could not be reached.",Te=({base:e,token:o,fetch:n,onFlow:r,onError:a,onNotice:i,onWarning:c=()=>{}})=>async(l,p,f)=>{let g=await n(e+l,{method:p,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:f&&JSON.stringify(f)}).catch(()=>null);if(!g)return i(null),a(_e);let d=await g.json().catch(()=>({}));if(d.unconnected)return i(null),a(null),c({path:l,steps:d.unconnected}),r(await(await n(e+".json",{headers:{Accept:"application/json"}})).json());if(c(null),!g.ok)return i(null),a(d.error||"That change was refused");a(null),i(d.notice||null),r(await(await n(e+".json",{headers:{Accept:"application/json"}})).json())},Pe=({base:e,token:o,fetch:n,onError:r})=>async a=>{let i=new FormData;i.append("picture",a);let c=await n(e+"/pictures",{method:"POST",headers:{"X-CSRF-Token":o},body:i}).catch(()=>null);if(!c)return r(_e),null;let l=await c.json().catch(()=>({}));return c.ok?(r(null),l):(r(l.error||"That picture was refused"),null)};var wt=(e,o,n)=>{let[r,a]=k(n),[i,c]=k(null),[l,p]=k(null),[f,g]=k(null),d=A(()=>Te({base:e,token:o,fetch:(...u)=>window.fetch(...u),onFlow:a,onError:c,onNotice:p,onWarning:g}),[e,o]),h=A(()=>Pe({base:e,token:o,fetch:(...u)=>window.fetch(...u),onError:c}),[e,o]);return{flow:r,error:i,notice:l,warning:f,setWarning:g,send:d,pickPicture:h}},Ee=wt;var W=(e,o,n,r)=>{let a=e.left-n.left+r.scrollLeft,i=e.top-n.top+r.scrollTop;return o==="left"?{x:a,y:i+e.height/2}:o==="right"?{x:a+e.width,y:i+e.height/2}:o==="top"?{x:a+e.width/2,y:i}:{x:a+e.width/2,y:i+e.height}},Ct=(e,o)=>o==="top"?{x:e.x,y:e.y-3}:o==="left"?{x:e.x-3,y:e.y}:o==="right"?{x:e.x+3,y:e.y}:{x:e.x,y:e.y+3},Nt=(e,o,n,r,a)=>{let i={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}`}[e.route];return{...e,path:i,midX:e.route==="detour"?a:(o.x+n.x)/2,midY:e.route==="lane"?r:(o.y+n.y)/2}},St=(e,o,n,r)=>{let[a,i]=k([]),[c,l]=k({width:"100%",height:"100%"});return ee(()=>{let p=()=>{let g=o.current?.getBoundingClientRect();g&&(l({width:o.current.scrollWidth,height:o.current.scrollHeight}),i(e.edges.flatMap(d=>{let h=n.current[d.source]?.getBoundingClientRect(),u=n.current[d.target]?.getBoundingClientRect();if(!h||!u)return[];let y=W(h,d.leaves,g,o.current),w=Ct(W(u,d.enters,g,o.current),d.enters),b=(W(h,"bottom",g,o.current).y+W(u,"top",g,o.current).y)/2,C=d.leaves==="left"?Math.min(y.x,w.x)-28:Math.max(y.x,w.x)+28;return[Nt(d,y,w,b,C)]})))};p(),window.addEventListener("resize",p);let f=new ResizeObserver(p);return o.current&&f.observe(o.current),()=>{window.removeEventListener("resize",p),f.disconnect()}},r),{links:a,extent:c}},$e=St;var Ae=(e,o)=>{let n=new Set(o),r=e,a=2;for(;n.has(r);)r=`${e}_${a++}`;return r};var _t={display:"flex",height:"100%",minHeight:0,fontSize:13},Tt={flex:1,overflow:"auto",position:"relative"},Pt={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},Et={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},$t={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},At=({port:e,onCancel:o})=>t.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:$t},"Choose the step \u201C",e||"next","\u201D should lead to \u2014 ",t.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")),Dt=({base:e,token:o,initial:n})=>{let{flow:r,error:a,notice:i,warning:c,setWarning:l,send:p,pickPicture:f}=Ee(e,o,n),[g,d]=k(null),[h,u]=k(null),[y,w]=k(null),[b,C]=k(null),[x,T]=k(!1),N=U({}),B=U(null);$(()=>{let s=()=>{d(null),T(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),$(()=>{if(!x){l(null);return}let s=m=>{m.target.isConnected&&!m.target.closest("[data-builder-panel], [data-open-panel]")&&T(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[x]),$(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let Le=A(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:Ie,extent:Oe}=$e(r,B,N,[r,g,Le]),q=A(()=>{let s={};return r.violations.forEach(m=>{var z;return(s[z=m.node]||(s[z]=[])).push(m)}),s},[r.violations]),V=r.nodes.filter(s=>!s.loose),J=r.nodes.filter(s=>s.loose),Me=Math.max(0,...V.map(s=>s.row))+1,He=Math.max(0,...V.map(s=>s.column))+1,K=s=>{if(!y)return;let[m,z]=y;w(null),m!==s&&p("/edges","POST",{from:m,to:s,on:z||null})},P=r.nodes.find(s=>s.id===g),E=[...r.palette,...r.unoffered||[]].find(s=>s.type===P?.type);return t.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:_t,onMouseUp:()=>C(null),onKeyDown:s=>{s.key==="Escape"&&(d(null),w(null),u(null))},tabIndex:-1},t.createElement("div",{ref:B,className:"bg-surface-50 dark:bg-surface-950",style:Tt,onClick:s=>{s.target===B.current&&(d(null),w(null),T(!1))}},y&&t.createElement(At,{port:y[1],onCancel:()=>w(null)}),t.createElement(ye,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>u({at:{x:16,y:52}}),onUndo:()=>{d(null),p("/undo","POST")},onRedo:()=>{d(null),p("/redo","POST")}}),t.createElement(Be,{links:Ie,extent:Oe,dragging:b,alwaysShowInsertButtons:r.always_show_insert_buttons,onInsert:s=>u({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{d(null),p("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let m=b;C(null),p("/steps/"+m+"/move","PATCH",{from:s.source,to:s.target})}}),J.length>0&&t.createElement("div",{"data-loose":!0,className:"border border-dashed border-gray-300 bg-gray-50 dark:border-zinc-700 dark:bg-zinc-900",style:Pt},t.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=>t.createElement("div",{key:s.id,style:{marginBottom:8}},t.createElement(Y,{node:{...s,ref:m=>{N.current[s.id]=m},violations:q[s.id]||[],connected:[]},selected:g===s.id,armed:y&&y[0]===s.id?y[1]:null,connecting:!!y,onSelect:()=>y?K(s.id):d(s.id),onOpen:m=>window.location.assign(m),onArm:(m,z)=>{z.stopPropagation(),w([s.id,m])},onDragStart:()=>C(s.id),onDragEnd:()=>C(null),hideStepTypeCodes:r.hide_step_type_codes})))),t.createElement("div",{style:{...Et,gridTemplateColumns:`repeat(${He+1}, ${280/2}px)`,gridTemplateRows:`repeat(${Me}, auto)`}},V.map(s=>t.createElement("div",{key:s.id,style:{gridRow:s.row+1,gridColumn:`${s.column+1} / span 2`,zIndex:2}},s.placeholder&&t.createElement(me,{node:{...s,ref:m=>{N.current[s.id]=m}},dragging:!!b,onFill:()=>u({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let m=b;C(null),m&&p("/edges","POST",{from:s.from,to:m,on:s.on})}}),!s.placeholder&&t.createElement(Y,{node:{...s,ref:m=>{N.current[s.id]=m},violations:q[s.id]||[],connected:r.edges.filter(m=>m.source===s.id).map(m=>m.label||null)},selected:s.id===g,armed:y&&y[0]===s.id?y[1]:null,connecting:!!y&&y[0]!==s.id,onSelect:()=>y?K(s.id):d(s.id),onOpen:m=>window.location.assign(m),onArm:(m,z)=>{let Z=B.current.getBoundingClientRect();u({from:s.id,on:m,at:{x:z.clientX-Z.left+B.current.scrollLeft+8,y:z.clientY-Z.top+B.current.scrollTop+8}})},onDragEnd:()=>C(null),onDragStart:()=>C(s.id),hideStepTypeCodes:r.hide_step_type_codes})))),h&&t.createElement(fe,{entries:r.palette,at:h.at,onDismiss:()=>u(null),hideStepTypeCodes:r.hide_step_type_codes,onConnect:h.on!==void 0?()=>{w([h.from,h.on]),u(null)}:null,onPick:s=>{let m=h;u(null),p("/steps","POST",{id:Ae(s.type,r.nodes.map(z=>z.id)),type:s.type,from:m.from,to:m.to,on:m.on})}})),x&&!P&&t.createElement(Ce,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:i,publishNote:r.publish_note,warning:c,onClose:()=>T(!1),onCreate:()=>p("/versions","POST"),onPublish:()=>p("/publish","POST"),onAccept:()=>p(c.path,"POST",{remove_unconnected:c.steps.map(s=>s.id)}),onDecline:()=>l(null),onSaveDetails:s=>p("/details","PATCH",{flow:s})}),P&&t.createElement(ue,{node:P,fields:E?.fields||{},holds:E?.records||{},labels:E?.labels||{},recordLabels:E?.record_labels||{},recordChoices:E?.record_choices||{},recordOutputsOf:E?.record_outputs_of||{},choices:le(P,E),onClose:()=>d(null),onSave:s=>p(`/steps/${P.id}`,"PATCH",{config:s}),onPickPicture:f,refusal:a,onDelete:()=>{d(null),p(`/steps/${P.id}`,"DELETE")}}))},De=Dt;oe("easy_flow/flow-editor",De);document.addEventListener("easy_flow:flow-named",e=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=e.detail)});se(document,Q);})();
1
+ (()=>{var jt=globalThis.ReactDOM;var{createPortal:Ie,createRoot:et,flushSync:Oe,hydrateRoot:Me,preconnect:He,prefetchDNS:je,preinit:Fe,preinitModule:We,preload:Ve,preloadModule:Ue,requestFormReset:Ye,unstable_batchedUpdates:Xe,useFormState:Ge,useFormStatus:qe,version:Je}=jt;var ot=globalThis.React,e=ot,{Children:Ze,Component:Qe,Fragment:Re,PureComponent:to,StrictMode:eo,Suspense:oo,cloneElement:ro,createContext:no,createElement:so,createRef:ao,forwardRef:io,isValidElement:lo,lazy:co,memo:po,startTransition:uo,useCallback:mo,useContext:fo,useDebugValue:go,useDeferredValue:yo,useEffect:A,useId:ho,useImperativeHandle:xo,useLayoutEffect:rt,useMemo:$,useReducer:bo,useRef:G,useState:w,useSyncExternalStore:vo,useTransition:ko}=ot;var nt=new Map,H=new Map,st=(t,o)=>nt.set(t,o),at=()=>{H.forEach(t=>t.unmount()),H.clear()},it=(t,o)=>t.querySelectorAll("[data-react-ui]").forEach(n=>{if(H.has(n))return;let r=nt.get(n.dataset.reactUi),a=o(n);H.set(n,a),a.render(e.createElement(r,JSON.parse(n.dataset.props)))});var lt=(t,o)=>{let n=()=>it(t,o);t.addEventListener("turbo:load",n),t.addEventListener("turbo:frame-load",n),t.addEventListener("turbo:before-cache",at),t.readyState==="loading"?t.addEventListener("DOMContentLoaded",n):n()};var N=(...t)=>t.filter(Boolean).join(" ");var Ft={primary:"ks-button-primary",secondary:"ks-button-secondary",danger:"ks-button-danger"},Wt={sm:"ks-button-sm",md:"ks-button-md",lg:"ks-button-lg"},Vt=({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",Ft[t],Wt[o],n)},r)},v=Vt;var Ut="border border-amber-500 bg-white text-amber-700 dark:bg-zinc-900 dark:text-amber-400",Yt="border border-gray-300 bg-white text-gray-500 dark:border-zinc-600 dark:bg-zinc-900 dark:text-gray-400",Xt="border border-accent-600 bg-accent-600 text-white",Gt=({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?Xt:o?Yt:Ut,style:{padding:"1px 8px",marginRight:4,borderRadius:999,fontSize:11,cursor:"pointer"}},t||"next"),dt=Gt;var qt={width:240,boxSizing:"border-box",borderRadius:8},Jt={padding:"8px 14px",textAlign:"center",fontWeight:600},Kt={fontWeight:600,lineHeight:1.3},Zt=(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,Rt=(t,o,n)=>n?o===t.type_name?null:t.type_name:t.type,te=({node:t,selected:o,armed:n,connecting:r,onSelect:a,onOpen:l,onArm:p,onDragEnd:i,onDragStart:d,hideStepTypeCodes:y})=>{let h=t.begins_here||t.ends_here,c=t.begins_here,f=t.ends_here?[]:t.ports,g=r&&!c,u=Qt(t,y),k=Rt(t,u,y);return e.createElement("div",{ref:t.ref,"data-step":t.id,draggable:!c,onDragStart:b=>{b.dataTransfer.effectAllowed="move",b.dataTransfer.setData("text/plain",t.id),d()},onDragEnd:i,onClick:t.opens&&!r?()=>l(t.opens):a,className:`bg-white border-2 dark:bg-zinc-900 ${Zt(g,t.violations.length>0,o)}`,style:{...qt,padding:h?0:"12px 14px",cursor:c?"default":r?"crosshair":"grab"}},h&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:Jt},u),!h&&e.createElement("div",{style:Kt},u),!h&&k&&e.createElement("div",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:11,marginTop:2}},k),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(v,{variant:"secondary",size:"sm",className:"mt-2",onClick:b=>{b.stopPropagation(),a()}},"Settings"),t.violations.map(b=>e.createElement("div",{key:b.problem+b.detail,className:"text-red-600 dark:text-red-400",style:{fontSize:11,padding:"0 14px 6px"}},b.sentence)),f.length>0&&e.createElement("div",{style:{padding:t.begins_here?"0 14px 8px":"10px 0 0"}},f.map(b=>e.createElement(dt,{key:b,name:b,connected:t.connected.includes(b),connecting:r,armed:n===b,onArm:C=>p(b,C)}))))},q=te;var J=(t,o)=>t.includes(o)?t.filter(n=>n!==o):[...t,o],ct=(t,o)=>({...o?.choices||{},...t?.choices||{}});var ee=({as:t="input",disabled:o,className:n,...r})=>e.createElement(t,{...r,disabled:o,className:N("ks-input",o&&"ks-input-disabled",n)}),E=ee;var oe=t=>e.createElement(E,{...t,as:"select"}),pt=oe;var re=({className:t,...o})=>e.createElement("input",{type:"checkbox",...o,className:N("ks-checkbox",t)}),j=re;var ut=t=>(t||[]).map(o=>typeof o=="object"?o:{value:o,label:o}),ne=({type:t,value:o,choices:n,onChange:r,onSettle:a,onPick:l,pictureUrl:p})=>{if(t==="picture")return e.createElement("div",{className:"mb-3"},o&&p&&e.createElement("img",{src:p,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]&&l(i.target.files[0])}));if(t==="boolean")return e.createElement(j,{checked:!!o,onChange:i=>a(i.target.checked)});if(t==="select"||t==="previous_step"||t==="from_step"||t==="flow")return e.createElement(pt,{className:"mb-3",value:o??"",onChange:i=>a(i.target.value)},e.createElement("option",{value:""}),ut(n).map(i=>e.createElement("option",{key:i.value,value:i.value},i.label)));if(t==="multi_select"){let i=Array.isArray(o)?o:[],d=y=>a(J(i,y));return e.createElement("div",{style:{display:"flex",flexWrap:"wrap",gap:8}},(n||[]).map(y=>e.createElement("label",{key:y,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(j,{checked:i.includes(y),onChange:()=>d(y)}),e.createElement("span",null,y))))}if(t==="flows"){let i=Array.isArray(o)?o:[];return e.createElement("div",{style:{display:"flex",flexDirection:"column",gap:4},className:"mb-3"},ut(n).map(d=>e.createElement("label",{key:d.value,style:{display:"inline-flex",alignItems:"center",gap:4}},e.createElement(j,{checked:i.includes(d.value),onChange:()=>a(J(i,d.value))}),e.createElement("span",null,d.label))))}return t==="integer"||t==="float"?e.createElement(E,{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(E,{className:"mb-3",type:"text",value:o??"",onChange:i=>r(i.target.value),onBlur:i=>a(i.target.value)})},F=ne;var mt=(t,o,n,r)=>t.map((a,l)=>l===o?{...a,[n]:r}:a);var se=({text:t})=>e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",fontSize:11,lineHeight:1.4,marginBottom:12},"data-setting-explanation":!0},t),W=se;var ae=(t,o,n,r)=>{let a=(t||{})[n],l=(o||{})[n];return l?(a||{})[r[l]]:a},ie=({holds:t,labels:o,explanations:n,rows:r,choices:a,outputsOf:l,onChange:p,onSettle:i})=>{let d=Array.isArray(r)?r:[],y=(h,c,f,g)=>{let u=mt(d,h,c,f);g?i(u):p(u)};return e.createElement("div",{style:{marginBottom:12}},d.map((h,c)=>e.createElement("div",{key:c,className:"rounded-md border border-gray-200 dark:border-zinc-700",style:{padding:"8px 8px 2px",marginBottom:6}},Object.entries(t).map(([f,g])=>e.createElement("label",{key:f,style:{display:"block"}},e.createElement("span",{className:"text-gray-500 dark:text-gray-400",style:{display:"block",marginBottom:2,fontSize:11}},(o||{})[f]||f),e.createElement(F,{type:g,value:h[f],choices:ae(a,l,f,h),onChange:u=>y(c,f,u,!1),onSettle:u=>y(c,f,u,!0)}),(n||{})[f]&&e.createElement(W,{text:n[f]}))),e.createElement(v,{variant:"secondary",size:"sm",className:"mb-1.5",onClick:()=>i(d.filter((f,g)=>g!==c))},"Remove"))),e.createElement(v,{variant:"secondary",size:"sm",className:"w-full",onClick:()=>i([...d,{}])},"Add"))},ft=ie;var le=({as:t="div",className:o,children:n,...r})=>e.createElement(t,{...r,className:N("ks-panel",o)},n),I=le;var V=(t,o)=>({className:n,children:r,...a})=>e.createElement(t,{...a,className:N(o,n)},r),M=V("label","ks-label"),gr=V("p","ks-hint"),yr=V("p","ks-error"),hr=V("span","ks-required");var de={width:280,padding:20,overflowY:"auto"},ce=({node:t,fields:o,holds:n,labels:r,explanations:a,recordLabels:l,recordExplanations:p,recordChoices:i,recordOutputsOf:d,choices:y,onSave:h,onDelete:c,onClose:f,onPickPicture:g,refusal:u})=>{let[k,b]=w(t.config),[C,D]=w(t.picture_url);A(()=>D(t.picture_url),[t.id,t.picture_url]),A(()=>b(t.config),[t.id,t.config]);let T=x=>{b(x),JSON.stringify(x)!==JSON.stringify(t.config)&&h(x)};return e.createElement(I,{as:"aside",style:de,"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:f,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),u&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{fontWeight:600,marginBottom:12},"data-refusal":!0},u),Object.entries(o).map(([x,B])=>e.createElement(M,{key:x},e.createElement("span",{style:{display:"block",marginBottom:3}},r[x]||x),B==="list"?e.createElement(ft,{holds:n[x]||{},labels:l[x]||{},explanations:(p||{})[x],rows:k[x],choices:{...(i||{})[x],...(t.record_choices||{})[x]},outputsOf:(d||{})[x],onChange:S=>b({...k,[x]:S}),onSettle:S=>T({...k,[x]:S})}):e.createElement(F,{type:B,value:k[x],choices:y[x],pictureUrl:C,onChange:S=>b({...k,[x]:S}),onSettle:S=>T({...k,[x]:S}),onPick:async S=>{let O=await g(S);O&&(D(O.url),T({...k,[x]:O.reference}))}}),(a||{})[x]&&e.createElement(W,{text:a[x]}))),e.createElement(v,{variant:"danger",size:"sm",className:"w-full",onClick:c},"Delete step"))},gt=ce;var pe={width:240,boxSizing:"border-box",padding:"10px 14px",borderRadius:8,textAlign:"center",fontSize:12,cursor:"pointer"},ue="border-2 border-dashed border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",me="border-2 border-dashed border-accent-600 bg-accent-50 text-accent-800 dark:bg-accent-950 dark:text-accent-200",fe=({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?me:ue,style:pe},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")),yt=fe;var ge=({entries:t,at:o,onPick:n,onConnect:r,onDismiss:a,hideStepTypeCodes:l})=>e.createElement(I,{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(p=>e.createElement(v,{key:p.type,variant:"secondary",size:"sm",className:"w-full mb-1.5 flex-col",onClick:()=>n(p)},p.label,!l&&e.createElement("span",{className:"opacity-75",style:{display:"block",fontSize:11}},p.type))),r&&e.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:r},"Connect to a step already here"),e.createElement(v,{variant:"secondary",size:"sm",className:"w-full",onClick:a},"Cancel")),ht=ge;var ye={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},xt=({label:t,title:o,idle:n,enabled:r,onUse:a})=>e.createElement(v,{variant:"secondary",size:"sm",title:r?o:n,disabled:!r,onClick:a,style:{opacity:r?1:.4,cursor:r?"pointer":"default"}},t),he=({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(v,{variant:"secondary",className:"rounded-full",style:ye,title:"Add a step",onClick:n},"+"),e.createElement(xt,{label:"\u21B6 Undo",title:"Undo the last change",idle:"Nothing to undo",enabled:t,onUse:r}),e.createElement(xt,{label:"\u21B7 Redo",title:"Redo the change you undid",idle:"Nothing to redo",enabled:o,onUse:a})),bt=he;var xe=({type:t="info",title:o,message:n,onDismiss:r,className:a})=>e.createElement("div",{className:N("ks-alert",`ks-alert-${t}`,a),role:"alert"},e.createElement("div",{className:"ks-alert-body"},e.createElement("div",{className:"ks-alert-content"},o&&e.createElement("p",{className:"ks-alert-title"},o),e.createElement("p",{className:o?"ks-alert-message-titled":"ks-alert-message"},n)),r&&e.createElement("button",{type:"button",className:"ks-alert-dismiss","aria-label":"Dismiss",onClick:r},"\xD7"))),vt=xe;var kt=t=>{let o=t.map(n=>`\u201C${n.name}\u201D`).join(", ");return t.length===1?`${o} is not connected to the flow and will be removed. Remove it to go on, or cancel and connect it first.`:`${o} are not connected to the flow and will be removed. Remove them to go on, or cancel and connect them first.`};var wt=({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 be={width:280,padding:20,overflowY:"auto"},K={fontWeight:600,marginBottom:6,marginTop:14},U={fontSize:12,marginBottom:4,lineHeight:1.4},Ct={display:"block",marginBottom:3},Nt=(t,o,n)=>r=>{r.target.value!==(t[o]??"")&&n({[o]:r.target.value})},ve=({flow:t,changes:o,problems:n,refusal:r,notice:a,publishNote:l,warning:p,onCreate:i,onPublish:d,onAccept:y,onDecline:h,onSaveDetails:c,onClose:f})=>e.createElement(I,{as:"aside",style:be,"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:f,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},wt(t)),p?e.createElement("div",{style:{marginTop:14},"data-unconnected-warning":!0},e.createElement(vt,{type:"warning",className:"mb-1.5",message:kt(p.steps)}),e.createElement(v,{size:"sm",className:"w-full mb-1.5",onClick:y,"data-accept-removal":!0},p.path==="/publish"?"Remove and publish":"Remove and create version"),e.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:h,"data-decline-removal":!0},"Cancel")):e.createElement("div",{style:{marginTop:14}},e.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",onClick:i,"data-create-version":!0},"Create version"),e.createElement(v,{size:"sm",className:"w-full mb-1.5",onClick:d,"data-publish":!0},"Publish"),l&&e.createElement("p",{className:"text-gray-500 dark:text-gray-400",style:{fontSize:12,lineHeight:1.4},"data-publish-note":!0},l)),e.createElement("h2",{style:K},"Problems"),r&&e.createElement("p",{className:"text-red-800 dark:text-red-400",style:{...U,fontWeight:600},"data-refusal":!0},r),a&&!r&&e.createElement("p",{className:"text-emerald-800 dark:text-emerald-400",style:{...U,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(g=>e.createElement("p",{key:`${g.node}-${g.problem}`,className:"text-amber-700 dark:text-amber-400",style:U,"data-problem":!0},"\u26A0 ",g.sentence)),e.createElement("h2",{style:K},"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((g,u)=>e.createElement("p",{key:u,style:U,"data-change":!0},g)),e.createElement("h2",{style:K},"Details"),e.createElement(M,null,e.createElement("span",{style:Ct},"Title"),e.createElement(E,{className:"mb-3",defaultValue:t.title??"",onBlur:Nt(t,"title",c),"data-flow-title":!0})),e.createElement(M,null,e.createElement("span",{style:Ct},"Start label"),e.createElement(E,{className:"mb-3",defaultValue:t.start_label??"",onBlur:Nt(t,"start_label",c),"data-flow-start-label":!0})),e.createElement("div",{style:{marginTop:16}},e.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.definition_url,"data-definition":!0},"Definition"),e.createElement(v,{variant:"secondary",size:"sm",className:"w-full mb-1.5",href:t.details_url,"data-details":!0},"Edit details"))),St=ve;var zt={width:24,height:24,padding:0,fontSize:15,lineHeight:"15px"},Bt=t=>({opacity:t?1:0,transition:"opacity .12s"}),ke=({link:t,onInsert:o,onRemove:n,onDrop:r,dragging:a,alwaysShowInsertButtons:l})=>{let[p,i]=w(!1),d=p||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:y=>{y.preventDefault(),y.dataTransfer.dropEffect="move"},onDrop:y=>{y.preventDefault(),i(!1),r()}},e.createElement("div",{style:{display:"flex",gap:4,justifyContent:"center",alignItems:"center",height:"100%"}},e.createElement(v,{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&&p),style:{...zt,...Bt(d||l)}},"+"),!a&&e.createElement(v,{variant:"danger",title:"Remove this connection",onClick:n,className:"rounded-full",style:{...zt,...Bt(d)}},"\xD7")))},_t=ke;var we=({links:t,extent:o,dragging:n,onInsert:r,onRemove:a,onDrop:l,alwaysShowInsertButtons:p})=>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+(p?2:-10),top:i.midY-18,zIndex:3,fontSize:11,padding:"0 3px"}},i.label)),t.filter(i=>!i.placeholder).map(i=>e.createElement(_t,{key:i.id,link:i,dragging:!!n,alwaysShowInsertButtons:p,onInsert:()=>r(i),onRemove:()=>a(i),onDrop:()=>l(i)}))),Tt=we;var Et="Your change was not saved because the server could not be reached.",Pt=({base:t,token:o,fetch:n,onFlow:r,onError:a,onNotice:l,onWarning:p=()=>{}})=>async(i,d,y)=>{let h=await n(t+i,{method:d,headers:{"Content-Type":"application/json","X-CSRF-Token":o},body:y&&JSON.stringify(y)}).catch(()=>null);if(!h)return l(null),a(Et);let c=await h.json().catch(()=>({}));if(c.unconnected)return l(null),a(null),p({path:i,steps:c.unconnected}),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json());if(p(null),!h.ok)return l(null),a(c.error||"That change was refused");a(null),l(c.notice||null),r(await(await n(t+".json",{headers:{Accept:"application/json"}})).json())},At=({base:t,token:o,fetch:n,onError:r})=>async a=>{let l=new FormData;l.append("picture",a);let p=await n(t+"/pictures",{method:"POST",headers:{"X-CSRF-Token":o},body:l}).catch(()=>null);if(!p)return r(Et),null;let i=await p.json().catch(()=>({}));return p.ok?(r(null),i):(r(i.error||"That picture was refused"),null)};var Ce=(t,o,n)=>{let[r,a]=w(n),[l,p]=w(null),[i,d]=w(null),[y,h]=w(null),c=$(()=>Pt({base:t,token:o,fetch:(...g)=>window.fetch(...g),onFlow:a,onError:p,onNotice:d,onWarning:h}),[t,o]),f=$(()=>At({base:t,token:o,fetch:(...g)=>window.fetch(...g),onError:p}),[t,o]);return{flow:r,error:l,notice:i,warning:y,setWarning:h,send:c,pickPicture:f}},Dt=Ce;var Y=(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}},Ne=(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},Se=(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}},ze=(t,o,n,r)=>{let[a,l]=w([]),[p,i]=w({width:"100%",height:"100%"});return rt(()=>{let d=()=>{let h=o.current?.getBoundingClientRect();h&&(i({width:o.current.scrollWidth,height:o.current.scrollHeight}),l(t.edges.flatMap(c=>{let f=n.current[c.source]?.getBoundingClientRect(),g=n.current[c.target]?.getBoundingClientRect();if(!f||!g)return[];let u=Y(f,c.leaves,h,o.current),k=Ne(Y(g,c.enters,h,o.current),c.enters),b=(Y(f,"bottom",h,o.current).y+Y(g,"top",h,o.current).y)/2,C=c.leaves==="left"?Math.min(u.x,k.x)-28:Math.max(u.x,k.x)+28;return[Se(c,u,k,b,C)]})))};d(),window.addEventListener("resize",d);let y=new ResizeObserver(d);return o.current&&y.observe(o.current),()=>{window.removeEventListener("resize",d),y.disconnect()}},r),{links:a,extent:p}},$t=ze;var Lt=(t,o)=>{let n=new Set(o),r=t,a=2;for(;n.has(r);)r=`${t}_${a++}`;return r};var Te={display:"flex",height:"100%",minHeight:0,fontSize:13},Ee={flex:1,overflow:"auto",position:"relative"},Pe={position:"absolute",top:56,left:16,zIndex:5,width:264,padding:12,borderRadius:8},Ae={display:"grid",rowGap:64,columnGap:0,padding:40,justifyContent:"center",position:"relative"},De={position:"sticky",zIndex:8,top:8,margin:"8px auto 0",width:"fit-content",padding:"6px 12px",borderRadius:6,fontSize:12},$e=({port:t,onCancel:o})=>e.createElement("div",{className:"bg-accent-100 text-accent-800 dark:bg-accent-900 dark:text-accent-100",style:De},"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")),Le=({base:t,token:o,initial:n})=>{let{flow:r,error:a,notice:l,warning:p,setWarning:i,send:d,pickPicture:y}=Dt(t,o,n),[h,c]=w(null),[f,g]=w(null),[u,k]=w(null),[b,C]=w(null),[D,T]=w(!1),x=G({}),B=G(null);A(()=>{let s=()=>{c(null),T(!0)};return document.addEventListener("easy_flow:open-flow",s),()=>document.removeEventListener("easy_flow:open-flow",s)},[]),A(()=>{if(!D){i(null);return}let s=m=>{m.target.isConnected&&!m.target.closest("[data-builder-panel], [data-open-panel]")&&T(!1)};return document.addEventListener("click",s),()=>document.removeEventListener("click",s)},[D]),A(()=>{document.dispatchEvent(new CustomEvent("easy_flow:flow-named",{detail:r.flow?.title||r.flow?.slug}))},[r.flow?.title,r.flow?.slug]);let S=$(()=>Object.fromEntries(r.nodes.map(s=>[s.id,s])),[r.nodes]),{links:O,extent:Ot}=$t(r,B,x,[r,h,S]),Z=$(()=>{let s={};return r.violations.forEach(m=>{var z;return(s[z=m.node]||(s[z]=[])).push(m)}),s},[r.violations]),X=r.nodes.filter(s=>!s.loose),Q=r.nodes.filter(s=>s.loose),Mt=Math.max(0,...X.map(s=>s.row))+1,Ht=Math.max(0,...X.map(s=>s.column))+1,R=s=>{if(!u)return;let[m,z]=u;k(null),m!==s&&d("/edges","POST",{from:m,to:s,on:z||null})},P=r.nodes.find(s=>s.id===h),_=[...r.palette,...r.unoffered||[]].find(s=>s.type===P?.type);return e.createElement("div",{className:"text-gray-900 dark:text-gray-100",style:Te,onMouseUp:()=>C(null),onKeyDown:s=>{s.key==="Escape"&&(c(null),k(null),g(null))},tabIndex:-1},e.createElement("div",{ref:B,className:"bg-surface-50 dark:bg-surface-950",style:Ee,onClick:s=>{s.target===B.current&&(c(null),k(null),T(!1))}},u&&e.createElement($e,{port:u[1],onCancel:()=>k(null)}),e.createElement(bt,{undoable:r.undoable,redoable:r.redoable,onAdd:()=>g({at:{x:16,y:52}}),onUndo:()=>{c(null),d("/undo","POST")},onRedo:()=>{c(null),d("/redo","POST")}}),e.createElement(Tt,{links:O,extent:Ot,dragging:b,alwaysShowInsertButtons:r.always_show_insert_buttons,onInsert:s=>g({from:s.source,to:s.target,at:{x:s.midX+30,y:s.midY}}),onRemove:s=>{c(null),d("/edges","DELETE",{from:s.source,to:s.target})},onDrop:s=>{let m=b;C(null),d("/steps/"+m+"/move","PATCH",{from:s.source,to:s.target})}}),Q.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:Pe},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"),Q.map(s=>e.createElement("div",{key:s.id,style:{marginBottom:8}},e.createElement(q,{node:{...s,ref:m=>{x.current[s.id]=m},violations:Z[s.id]||[],connected:[]},selected:h===s.id,armed:u&&u[0]===s.id?u[1]:null,connecting:!!u,onSelect:()=>u?R(s.id):c(s.id),onOpen:m=>window.location.assign(m),onArm:(m,z)=>{z.stopPropagation(),k([s.id,m])},onDragStart:()=>C(s.id),onDragEnd:()=>C(null),hideStepTypeCodes:r.hide_step_type_codes})))),e.createElement("div",{style:{...Ae,gridTemplateColumns:`repeat(${Ht+1}, ${280/2}px)`,gridTemplateRows:`repeat(${Mt}, auto)`}},X.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(yt,{node:{...s,ref:m=>{x.current[s.id]=m}},dragging:!!b,onFill:()=>g({from:s.from,on:s.on,at:{x:16,y:52}}),onDrop:()=>{let m=b;C(null),m&&d("/edges","POST",{from:s.from,to:m,on:s.on})}}),!s.placeholder&&e.createElement(q,{node:{...s,ref:m=>{x.current[s.id]=m},violations:Z[s.id]||[],connected:r.edges.filter(m=>m.source===s.id).map(m=>m.label||null)},selected:s.id===h,armed:u&&u[0]===s.id?u[1]:null,connecting:!!u&&u[0]!==s.id,onSelect:()=>u?R(s.id):c(s.id),onOpen:m=>window.location.assign(m),onArm:(m,z)=>{let tt=B.current.getBoundingClientRect();g({from:s.id,on:m,at:{x:z.clientX-tt.left+B.current.scrollLeft+8,y:z.clientY-tt.top+B.current.scrollTop+8}})},onDragEnd:()=>C(null),onDragStart:()=>C(s.id),hideStepTypeCodes:r.hide_step_type_codes})))),f&&e.createElement(ht,{entries:r.palette,at:f.at,onDismiss:()=>g(null),hideStepTypeCodes:r.hide_step_type_codes,onConnect:f.on!==void 0?()=>{k([f.from,f.on]),g(null)}:null,onPick:s=>{let m=f;g(null),d("/steps","POST",{id:Lt(s.type,r.nodes.map(z=>z.id)),type:s.type,from:m.from,to:m.to,on:m.on})}})),D&&!P&&e.createElement(St,{flow:r.flow||{},changes:r.changes||[],problems:r.violations,refusal:a,notice:l,publishNote:r.publish_note,warning:p,onClose:()=>T(!1),onCreate:()=>d("/versions","POST"),onPublish:()=>d("/publish","POST"),onAccept:()=>d(p.path,"POST",{remove_unconnected:p.steps.map(s=>s.id)}),onDecline:()=>i(null),onSaveDetails:s=>d("/details","PATCH",{flow:s})}),P&&e.createElement(gt,{node:P,fields:_?.fields||{},holds:_?.records||{},labels:_?.labels||{},explanations:_?.explanations||{},recordLabels:_?.record_labels||{},recordExplanations:_?.record_explanations||{},recordChoices:_?.record_choices||{},recordOutputsOf:_?.record_outputs_of||{},choices:ct(P,_),onClose:()=>c(null),onSave:s=>d(`/steps/${P.id}`,"PATCH",{config:s}),onPickPicture:y,refusal:a,onDelete:()=>{c(null),d(`/steps/${P.id}`,"DELETE")}}))},It=Le;st("easy_flow/flow-editor",It);document.addEventListener("easy_flow:flow-named",t=>{let o=document.querySelector("[data-flow-heading]");o&&(o.textContent=t.detail)});lt(document,et);})();
@@ -179,13 +179,9 @@ module EasyFlow
179
179
  end
180
180
 
181
181
  def refusal(objections)
182
- "Cannot publish: #{objections.map { |problem| worded(problem) }.join(', ')}."
183
- end
184
-
185
- def worded(problem)
186
- trouble = problem.problem.to_s.humanize(capitalize: false)
182
+ sentences = ViolationSentence.new(document)
187
183
 
188
- problem.node ? "“#{problem.node}” is #{trouble}" : trouble
184
+ "Cannot publish: #{objections.map { |objection| sentences.of(objection) }.join(' ')}"
189
185
  end
190
186
 
191
187
  def coerced(edited, id)
@@ -193,7 +193,7 @@ const Canvas = ({ base, token, initial }) => {
193
193
  {selectedNode && (
194
194
  <Inspector node={selectedNode}
195
195
  fields={entryFor?.fields || {}} holds={entryFor?.records || {}}
196
- labels={entryFor?.labels || {}} recordLabels={entryFor?.record_labels || {}}
196
+ labels={entryFor?.labels || {}} explanations={entryFor?.explanations || {}} recordLabels={entryFor?.record_labels || {}} recordExplanations={entryFor?.record_explanations || {}}
197
197
  recordChoices={entryFor?.record_choices || {}} recordOutputsOf={entryFor?.record_outputs_of || {}}
198
198
  choices={offeredTo(selectedNode, entryFor)}
199
199
  onClose={() => setSelected(null)}
@@ -0,0 +1,7 @@
1
+ import React from "react"
2
+
3
+ const Explanation = ({ text }) => (
4
+ <span className="text-gray-500 dark:text-gray-400" style={{ display: "block", fontSize: 11, lineHeight: 1.4, marginBottom: 12 }} data-setting-explanation>{text}</span>
5
+ )
6
+
7
+ export default Explanation
@@ -1,13 +1,14 @@
1
1
  import React, { useEffect, useState } from "react"
2
2
  import Control from "./Control"
3
3
  import Records from "./Records"
4
+ import Explanation from "./Explanation"
4
5
  import Panel from "keystone_ui-react/src/Panel.jsx"
5
6
  import Button from "keystone_ui-react/src/Button.jsx"
6
7
  import { Label } from "keystone_ui-react/src/FieldText.jsx"
7
8
 
8
9
  const panel = { width: 280, padding: 20, overflowY: "auto" }
9
10
 
10
- const Inspector = ({ node, fields, holds, labels, recordLabels, recordChoices, recordOutputsOf, choices, onSave, onDelete, onClose, onPickPicture, refusal }) => {
11
+ const Inspector = ({ node, fields, holds, labels, explanations, recordLabels, recordExplanations, recordChoices, recordOutputsOf, choices, onSave, onDelete, onClose, onPickPicture, refusal }) => {
11
12
  const [ draft, setDraft ] = useState(node.config)
12
13
  const [ pictureUrl, setPictureUrl ] = useState(node.picture_url)
13
14
  useEffect(() => setPictureUrl(node.picture_url), [ node.id, node.picture_url ])
@@ -31,7 +32,7 @@ const Inspector = ({ node, fields, holds, labels, recordLabels, recordChoices, r
31
32
  <Label key={name}>
32
33
  <span style={{ display: "block", marginBottom: 3 }}>{labels[name] || name}</span>
33
34
  {type === "list"
34
- ? <Records holds={holds[name] || {}} labels={recordLabels[name] || {}} rows={draft[name]}
35
+ ? <Records holds={holds[name] || {}} labels={recordLabels[name] || {}} explanations={(recordExplanations || {})[name]} rows={draft[name]}
35
36
  choices={{ ...(recordChoices || {})[name], ...(node.record_choices || {})[name] }}
36
37
  outputsOf={(recordOutputsOf || {})[name]}
37
38
  onChange={(next) => setDraft({ ...draft, [name]: next })}
@@ -45,6 +46,7 @@ const Inspector = ({ node, fields, holds, labels, recordLabels, recordChoices, r
45
46
  setPictureUrl(kept.url)
46
47
  settle({ ...draft, [name]: kept.reference })
47
48
  }} />}
49
+ {(explanations || {})[name] && <Explanation text={explanations[name]} />}
48
50
  </Label>
49
51
  ))}
50
52
  <Button variant="danger" size="sm" className="w-full" onClick={onDelete}>Delete step</Button>
@@ -4,7 +4,7 @@ import Button from "keystone_ui-react/src/Button.jsx"
4
4
  import Input from "keystone_ui-react/src/Input.jsx"
5
5
  import Alert from "keystone_ui-react/src/Alert.jsx"
6
6
  import { Label } from "keystone_ui-react/src/FieldText.jsx"
7
- import { warned, worded } from "./changes"
7
+ import { warned } from "./changes"
8
8
  import { standing } from "./flow"
9
9
 
10
10
  const sheet = { width: 280, padding: 20, overflowY: "auto" }
@@ -47,7 +47,7 @@ const Panel = ({ flow, changes, problems, refusal, notice, publishNote, warning,
47
47
  {problems.length === 0 && !refusal
48
48
  ? <p className="text-gray-500 dark:text-gray-400" style={{ fontSize: 12 }}>Nothing wrong with this flow.</p>
49
49
  : problems.map((problem) => (
50
- <p key={`${problem.node}-${problem.problem}`} className="text-amber-700 dark:text-amber-400" style={item} data-problem>⚠ {worded(problem)}</p>
50
+ <p key={`${problem.node}-${problem.problem}`} className="text-amber-700 dark:text-amber-400" style={item} data-problem>⚠ {problem.sentence}</p>
51
51
  ))}
52
52
 
53
53
  <h2 style={heading}>Changes since the last version</h2>
@@ -1,6 +1,7 @@
1
1
  import React from "react"
2
2
  import Control from "./Control"
3
3
  import { amended } from "./rows"
4
+ import Explanation from "./Explanation"
4
5
  import Button from "keystone_ui-react/src/Button.jsx"
5
6
 
6
7
  const offeredFor = (choices, outputsOf, name, row) => {
@@ -9,7 +10,7 @@ const offeredFor = (choices, outputsOf, name, row) => {
9
10
  return source ? (offered || {})[row[source]] : offered
10
11
  }
11
12
 
12
- const Records = ({ holds, labels, rows, choices, outputsOf, onChange, onSettle }) => {
13
+ const Records = ({ holds, labels, explanations, rows, choices, outputsOf, onChange, onSettle }) => {
13
14
  const kept = Array.isArray(rows) ? rows : []
14
15
  const amend = (index, name, next, settle) => {
15
16
  const updated = amended(kept, index, name, next)
@@ -26,6 +27,7 @@ const Records = ({ holds, labels, rows, choices, outputsOf, onChange, onSettle }
26
27
  <Control type={type} value={row[name]} choices={offeredFor(choices, outputsOf, name, row)}
27
28
  onChange={(next) => amend(index, name, next, false)}
28
29
  onSettle={(next) => amend(index, name, next, true)} />
30
+ {(explanations || {})[name] && <Explanation text={explanations[name]} />}
29
31
  </label>
30
32
  ))}
31
33
  <Button variant="secondary" size="sm" className="mb-1.5"
@@ -53,7 +53,7 @@ const StepCard = ({ node, selected, armed, connecting, onSelect, onOpen, onArm,
53
53
  {node.opens && <Button variant="secondary" size="sm" className="mt-2" onClick={(event) => { event.stopPropagation(); onSelect() }}>Settings</Button>}
54
54
  {node.violations.map((violation) => (
55
55
  <div key={violation.problem + violation.detail} className="text-red-600 dark:text-red-400" style={{ fontSize: 11, padding: "0 14px 6px" }}>
56
- {violation.problem.replace(/_/g, " ")}{violation.detail ? `: ${violation.detail}` : ""}
56
+ {violation.sentence}
57
57
  </div>
58
58
  ))}
59
59
  {ports.length > 0 && (
@@ -1,11 +1,7 @@
1
- export const worded = (violation) => {
2
- const problem = violation.problem.replace(/_/g, " ")
3
-
4
- return violation.node ? `“${violation.node}” ${problem}` : problem
5
- }
6
-
7
1
  export const warned = (steps) => {
8
- const named = steps.map((step) => `“${step.label}”`).join(", ")
2
+ const named = steps.map((step) => `“${step.name}”`).join(", ")
3
+
4
+ if (steps.length === 1) return `${named} is not connected to the flow and will be removed. Remove it to go on, or cancel and connect it first.`
9
5
 
10
- return `${named} ${steps.length === 1 ? "is" : "are"} not connected to the flow and will be removed.`
6
+ return `${named} are not connected to the flow and will be removed. Remove them to go on, or cancel and connect them first.`
11
7
  }
@@ -104,14 +104,28 @@ module EasyFlow
104
104
  { "type" => step_type.id.to_s, "label" => step_type.step_name,
105
105
  "fields" => step_type.settings.fields.transform_keys(&:to_s).transform_values(&:to_s),
106
106
  "labels" => step_type.settings.labels.transform_keys(&:to_s),
107
+ "explanations" => explanations_of(step_type),
107
108
  "choices" => step_type.settings.choices.transform_keys(&:to_s),
108
109
  "records" => holdings_of(step_type),
109
110
  "record_choices" => step_type.settings.record_choices.to_h { |name, offered| [ name.to_s, offered.transform_keys(&:to_s) ] },
110
111
  "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
112
  "record_labels" => step_type.settings.record_labels.to_h { |name, held| [ name.to_s, held.transform_keys(&:to_s) ] },
113
+ "record_explanations" => record_explanations_of(step_type),
112
114
  "awaits_input" => step_type.awaits_input? }
113
115
  end
114
116
 
117
+ def explanations_of(step_type)
118
+ settings = step_type.settings
119
+ settings.fields.keys.to_h { |name| [ name.to_s, @host&.explanation_for(step_type.id, name) || settings.explanations[name] ] }.compact
120
+ end
121
+
122
+ def record_explanations_of(step_type)
123
+ settings = step_type.settings
124
+ settings.record_fields.to_h do |list, holds|
125
+ [ list.to_s, holds.keys.to_h { |name| [ name.to_s, @host&.explanation_for(step_type.id, "#{list}.#{name}") || settings.record_explanations.dig(list, name) ] }.compact ]
126
+ end
127
+ end
128
+
115
129
  def holdings_of(step_type)
116
130
  step_type.settings.record_fields.to_h do |name, holds|
117
131
  [ name.to_s, holds.transform_keys(&:to_s).transform_values(&:to_s) ]
@@ -120,10 +134,14 @@ module EasyFlow
120
134
 
121
135
  def violations
122
136
  Validator.new(@document, registry: @registry, flow: @flow).violations.map do |violation|
123
- { "node" => violation.node, "problem" => violation.problem.to_s, "detail" => violation.detail }
137
+ { "node" => violation.node, "problem" => violation.problem.to_s, "detail" => violation.detail, "sentence" => sentences.of(violation) }
124
138
  end
125
139
  end
126
140
 
141
+ def sentences
142
+ ViolationSentence.new(@document, registry: @registry)
143
+ end
144
+
127
145
  def kept_values(node)
128
146
  step_type_for(node)&.settings&.kept_values(node.config).to_h
129
147
  end
@@ -133,7 +133,7 @@ module EasyFlow
133
133
  def with(nodes:, edges:)
134
134
  Document.new(@document.merge("nodes" => nodes, "edges" => edges), registry: @registry).tap do |edited|
135
135
  broken = Validator.new(edited, registry: @registry).malformations
136
- raise InvalidEdit, broken.map { |violation| "#{violation.node}: #{violation.problem}" }.join(", ") if broken.any?
136
+ raise InvalidEdit, broken.map { |violation| ViolationSentence.new(edited, registry: @registry).of(violation) }.join(" ") if broken.any?
137
137
  end
138
138
  end
139
139
  end
@@ -6,8 +6,18 @@ module EasyFlow
6
6
  named(node, registry) || node.id
7
7
  end
8
8
 
9
+ def self.shown(node, registry = EasyFlow.registry)
10
+ return "a step" unless node
11
+
12
+ named(node, registry) || type_name(node, registry) || "Unnamed step"
13
+ end
14
+
9
15
  def self.named(node, registry)
10
16
  registry.fetch(node.type).name_of(node) if registry.registered?(node.type)
11
17
  end
18
+
19
+ def self.type_name(node, registry)
20
+ registry.fetch(node.type).step_name if registry.registered?(node.type)
21
+ end
12
22
  end
13
23
  end
@@ -1,13 +1,15 @@
1
1
  module EasyFlow
2
2
  class Settings
3
- attr_reader :fields, :labels, :record_fields, :record_labels, :record_choices, :record_outputs_of, :record_required, :drawn_from, :outputs_of
3
+ attr_reader :fields, :labels, :explanations, :record_fields, :record_labels, :record_explanations, :record_choices, :record_outputs_of, :record_required, :drawn_from, :outputs_of
4
4
 
5
- def initialize(fields: {}, labels: {}, record_fields: {}, record_labels: {}, record_choices: {}, record_outputs_of: {}, record_required: {}, choices: {},
5
+ def initialize(fields: {}, labels: {}, explanations: {}, record_fields: {}, record_labels: {}, record_explanations: {}, record_choices: {}, record_outputs_of: {}, record_required: {}, choices: {},
6
6
  limits: {}, checks: {}, required: [], optional: [], drawn_from: {}, outputs_of: {}, kept: {})
7
7
  @fields = fields
8
8
  @labels = labels
9
+ @explanations = explanations
9
10
  @record_fields = record_fields
10
11
  @record_labels = record_labels
12
+ @record_explanations = record_explanations
11
13
  @record_choices = record_choices
12
14
  @record_outputs_of = record_outputs_of
13
15
  @record_required = record_required
@@ -6,8 +6,10 @@ module EasyFlow
6
6
  @step_name = id.to_s
7
7
  @fields = {}
8
8
  @labels = {}
9
+ @explanations = {}
9
10
  @record_fields = {}
10
11
  @record_labels = {}
12
+ @record_explanations = {}
11
13
  @record_choices = {}
12
14
  @record_outputs_of = {}
13
15
  @record_required = {}
@@ -108,7 +110,7 @@ module EasyFlow
108
110
  @naming = naming
109
111
  end
110
112
 
111
- def setting(name, type: nil, from: nil, outputs_of: nil, label: nil, options: nil, limit: nil, check: nil, required: false, optional: false, kept_on: nil, attribute: nil, &entries)
113
+ def setting(name, type: nil, from: nil, outputs_of: nil, label: nil, explanation: nil, options: nil, limit: nil, check: nil, required: false, optional: false, kept_on: nil, attribute: nil, &entries)
112
114
  type ||= :from_step if from
113
115
  type ||= :from_step if outputs_of
114
116
  raise UnknownFieldType, "#{type} is not one of #{FIELD_TYPES.join(', ')}" unless FIELD_TYPES.include?(type)
@@ -124,12 +126,13 @@ module EasyFlow
124
126
  @limits[name] = limit if limit
125
127
  @checks[name] = check if check
126
128
  @labels[name] = label.presence || name.to_s.humanize
129
+ @explanations[name] = explanation if explanation
127
130
  @fields[name] = type
128
131
  end
129
132
 
130
133
  protected
131
134
 
132
- attr_reader :fields, :labels, :choices, :outputs_of, :required
135
+ attr_reader :fields, :labels, :explanations, :choices, :outputs_of, :required
133
136
 
134
137
  private
135
138
 
@@ -144,6 +147,7 @@ module EasyFlow
144
147
  declared = Declaration.new(:entry).tap { |entry| entry.instance_eval(&entries) }
145
148
  @record_fields[name] = declared.fields
146
149
  @record_labels[name] = declared.labels
150
+ @record_explanations[name] = declared.explanations
147
151
  @record_choices[name] = declared.choices
148
152
  @record_outputs_of[name] = declared.outputs_of
149
153
  @record_required[name] = declared.required
@@ -159,8 +163,8 @@ module EasyFlow
159
163
  end
160
164
 
161
165
  def settings
162
- Settings.new(fields: @fields, labels: @labels, record_fields: @record_fields,
163
- record_labels: @record_labels, record_choices: @record_choices, record_outputs_of: @record_outputs_of, record_required: @record_required, choices: @choices, limits: @limits, checks: @checks,
166
+ Settings.new(fields: @fields, labels: @labels, explanations: @explanations, record_fields: @record_fields,
167
+ record_labels: @record_labels, record_explanations: @record_explanations, record_choices: @record_choices, record_outputs_of: @record_outputs_of, record_required: @record_required, choices: @choices, limits: @limits, checks: @checks,
164
168
  required: @required, optional: @optional, drawn_from: @drawn_from, outputs_of: @outputs_of, kept: @kept)
165
169
  end
166
170
  end
@@ -11,7 +11,7 @@ module EasyFlow
11
11
  end
12
12
 
13
13
  def named
14
- ids.map { |id| { "id" => id, "label" => Name.of(@document.node(id), @registry) } }
14
+ ids.map { |id| { "id" => id, "label" => Name.of(@document.node(id), @registry), "name" => Name.shown(@document.node(id), @registry).tr("_", " ") } }
15
15
  end
16
16
 
17
17
  def accepted?(named_steps)
@@ -0,0 +1,118 @@
1
+ module EasyFlow
2
+ class ViolationSentence
3
+ def self.worded?(problem)
4
+ private_method_defined?(problem, false)
5
+ end
6
+
7
+ def initialize(document, registry: EasyFlow.registry)
8
+ @document = document
9
+ @registry = registry
10
+ end
11
+
12
+ def of(violation)
13
+ sentence = self.class.worded?(violation.problem) ? send(violation.problem, violation) : unworded(violation)
14
+
15
+ sentence.tr("_", " ")
16
+ end
17
+
18
+ private
19
+
20
+ def unworded(violation)
21
+ "#{step(violation.node)} has a problem: #{violation.problem}."
22
+ end
23
+
24
+ def step(id)
25
+ Name.shown(@document.node(id), @registry)
26
+ end
27
+
28
+ def dead_end(violation)
29
+ "#{step(violation.node)} leads nowhere. Connect it to the step that should come next."
30
+ end
31
+
32
+ def unrouted_value(violation)
33
+ "#{violation.detail.to_s.upcase_first} on #{step(violation.node)} leads nowhere. Connect it to the step that should come next."
34
+ end
35
+
36
+ def unreachable(violation)
37
+ "#{step(violation.node)} is not connected to the flow. Connect a step to it, or delete it."
38
+ end
39
+
40
+ def past_the_end(violation)
41
+ "#{step(violation.node)} ends the flow, but a connection leaves it. Remove that connection."
42
+ end
43
+
44
+ def unfollowed_path(violation)
45
+ "The connection from #{step(violation.node)} to #{step(violation.detail)} is never followed, because #{step(violation.node)} only follows its first connection. Remove the connection to #{step(violation.detail)}."
46
+ end
47
+
48
+ def missing_setting(violation)
49
+ "#{step(violation.node)} needs a value for #{setting_label(violation)}. Set it in the step's settings."
50
+ end
51
+
52
+ def setting_label(violation)
53
+ settings = @registry.fetch(@document.node(violation.node).type).settings
54
+ list, position, name = violation.detail.split(" ", 3)
55
+ return settings.labels[list.to_sym] unless name
56
+
57
+ "#{settings.record_labels[list.to_sym][name.to_sym]} in #{settings.labels[list.to_sym]} entry #{position}"
58
+ end
59
+
60
+ def missing_value(violation)
61
+ "#{step(violation.node)} picks the answer #{violation.detail}, which the earlier step no longer offers. Pick another answer in the step's settings."
62
+ end
63
+
64
+ def unmet_requirement(violation)
65
+ return "#{step(violation.node)} reads a step that no longer exists. Pick another step in its settings." unless @document.node(violation.detail)
66
+
67
+ "#{step(violation.node)} reads #{step(violation.detail)}, which not every path to it goes through. Move #{step(violation.detail)} ahead of it on every path, or pick another step in its settings."
68
+ end
69
+
70
+ def missing_edge_target(violation)
71
+ "A connection from #{step(violation.node)} leads to a step that does not exist. Remove it in the flow's definition."
72
+ end
73
+
74
+ def missing_edge_source(violation)
75
+ "A connection into #{step(violation.node)} comes from a step that does not exist. Remove it in the flow's definition."
76
+ end
77
+
78
+ def duplicate_id(violation)
79
+ "#{step(violation.node)} has the same id as another step. Change one of the ids in the flow's definition."
80
+ end
81
+
82
+ def reserved_id(violation)
83
+ "#{step(violation.node)} has an id with an @ in it, which a flow cannot use. Change the id in the flow's definition."
84
+ end
85
+
86
+ def no_beginning(_violation)
87
+ "This flow has no start step. Add one in the flow's definition."
88
+ end
89
+
90
+ def many_beginnings(violation)
91
+ "#{step(violation.node)} is a second start step. Delete it."
92
+ end
93
+
94
+ def before_the_beginning(violation)
95
+ "A connection leads into #{step(violation.node)}, where the flow starts. Remove that connection."
96
+ end
97
+
98
+ def missing_flow(violation)
99
+ "#{step(violation.node)} is missing a flow. Pick the flow in its settings."
100
+ end
101
+
102
+ def unrunnable_version(violation)
103
+ "#{step(violation.node)} names a version that cannot run. Pick a published version in its settings."
104
+ end
105
+
106
+ def owned_by_another_owner(violation)
107
+ "#{step(violation.node)} names a flow that belongs to another owner. Pick another flow in its settings."
108
+ end
109
+
110
+ def circular(violation)
111
+ "#{step(violation.node)} leads back to the flow it sits in. Pick a flow in its settings that does not lead back here."
112
+ end
113
+
114
+ def reading_an_unconnected_step(violation)
115
+ "#{step(violation.node)} reads a step that is not connected to the flow. Connect that step, or pick another in its settings."
116
+ end
117
+ end
118
+ end
@@ -14,6 +14,13 @@ module EasyFlow
14
14
  @back_link = link && BackLink.new(**link)
15
15
  end
16
16
 
17
+ def setting_explanations=(explanations)
18
+ explained = explanations.to_h.deep_stringify_keys
19
+ raise ArgumentError, "an explanation must be text, and a list entry's setting is named like answers.weight" unless explained.values.flat_map(&:values).all? { |text| text.nil? || text.is_a?(String) }
20
+
21
+ @setting_explanations = explained
22
+ end
23
+
17
24
  def layout
18
25
  @layout || "easy_flow/application"
19
26
  end
@@ -42,6 +49,10 @@ module EasyFlow
42
49
  @hide_step_type_codes.present?
43
50
  end
44
51
 
52
+ def explanation_for(step_type, setting)
53
+ @setting_explanations.to_h.dig(step_type.to_s, setting.to_s).presence
54
+ end
55
+
45
56
  def offers?(step_type)
46
57
  @offers.nil? || @offers.map(&:to_s).include?(step_type.to_s)
47
58
  end
@@ -1,3 +1,3 @@
1
1
  module EasyFlow
2
- VERSION = "0.34.0"
2
+ VERSION = "0.35.0"
3
3
  end
@@ -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 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.
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 a warning that the step `needs a value for Output`, `needs a value for Other output` for a Compare two values step's second step, or `needs a value for Output in Comparisons entry <n>` and `needs a value for Other output in Comparisons entry <n>` 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
 
@@ -82,7 +82,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
82
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.
83
83
  5. Use these words to declare the type. Each is called once at class level (or inside the `EasyFlow.step` block):
84
84
  - `step_name "<label>"` — the name admins see on the canvas. Defaults to the id.
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".
85
+ - `setting :name, type:, label:, explanation:, 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 a warning on the step's card that the step `needs a value for <label>`, 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 `needs a value for <label> in <list label> entry <n>` 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. `explanation:` is an optional sentence shown under the setting in the step's settings, inside a `:list` entry as well, and a setting with none shows nothing there. A host can replace it with its own through `setting_explanations`, set through `easy_flow-install`. `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".
86
86
  - `output :name, type:, label:, values:, from:` — a value the step records. `type` is one of `:string`, `:integer`, `:float`, `:boolean`, or boot raises `EasyFlow::UnknownOutputType`. `values:` is an array or a lambda taking the node, listing the values the output can take; the canvas offers these as the connections leaving the step, so a type that routes must declare them. `from: :<setting>` takes the values from the step chosen in that setting. A Condition step requires the admin to pick one of the earlier step's outputs, so a type whose answer admins should test with a Condition declares at least one `output`, with `values:` so the Condition offers answers to pick.
87
87
  - `outputs_named_by :<list setting>` — adds one output for each entry an admin enters in that `:list` setting, named by the entry's `name`, so the setting's entry block must declare `setting :name, type: :string`. Ask the developer whether the admin or the type decides the names of the values the step records. Do not pick.
88
88
  - The named outputs follow any outputs the type declares with `output`, in the order the admin entered them. An entry with a blank `name` adds none.
@@ -115,14 +115,14 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
115
115
  - A flow's page reached by clicking the step shows a `Back to <title>` button, or `Back to <slug>` when the flow it came from has no title, which returns the admin to the flow the step is in. The button is shown only when that flow belongs to the same host.
116
116
  - While the step's `flow` setting is blank, clicking the step opens its settings like any other step.
117
117
  - While an admin is connecting two steps, clicking the step picks it as the connection's target, the same as any other step.
118
- - The step's card shows `missing flow` while its `flow` setting is blank or names no existing flow, and `unrunnable version` while the flow exists but its `version` setting is blank, names no version of that flow, or names one that is a draft, retired or withdrawn.
119
- - The step's card shows `circular` while its `flow` setting names the flow the step sits in, or names a flow whose chosen version holds a `starts_a_flow` step that leads back to it the same way, through any number of flows. Each flow along the way is read at the version named in the `version` setting of the step that leads to it.
120
- - While `missing flow`, `unrunnable version` or `circular` shows, saving the flow as a new version and publishing it are refused. These checks are always made and are not among the optional checks set through `easy_flow-install`, so the type needs no `required:` or `check:` on those two settings for them.
118
+ - The step's card warns that the step `is missing a flow` while its `flow` setting is blank or names no existing flow, and that it `names a version that cannot run` while the flow exists but its `version` setting is blank, names no version of that flow, or names one that is a draft, retired or withdrawn.
119
+ - The step's card warns that the step `leads back to the flow it sits in` while its `flow` setting names the flow the step sits in, or names a flow whose chosen version holds a `starts_a_flow` step that leads back to it the same way, through any number of flows. Each flow along the way is read at the version named in the `version` setting of the step that leads to it.
120
+ - While any of those three warnings shows, saving the flow as a new version and publishing it are refused. These checks are always made and are not among the optional checks set through `easy_flow-install`, so the type needs no `required:` or `check:` on those two settings for them.
121
121
  - `chooses_flow { |node| { flow: ..., version: ... } }` — for a `starts_a_flow` type, works out the flow and version a step runs from the step's own settings instead of reading the settings named `flow` and `version`. Ask the developer which settings decide the flow and version, and how. Do not pick.
122
122
  - The block is given the node only. `node.config` holds the admin's settings with string keys, without any setting declared with `kept_on:`.
123
123
  - It returns a hash with the symbol keys `flow:`, the flow's id as an integer or a string, and `version:`, the version's number. A blank value under either key is treated as not chosen.
124
124
  - When the block returns `nil`, the step falls back to the settings named `flow` and `version`.
125
- - What it returns is used everywhere the settings named `flow` and `version` are used otherwise: the run started when a stored run stops at the step, the `missing flow`, `unrunnable version` and `circular` marks and the refusal to save or publish, and the flow's page opened by clicking the step on the canvas.
125
+ - What it returns is used everywhere the settings named `flow` and `version` are used otherwise: the run started when a stored run stops at the step, the warnings about a missing flow, a version that cannot run and a flow that leads back, and the refusal to save or publish, and the flow's page opened by clicking the step on the canvas.
126
126
  - The flow is found by its id alone, not through the host or owner. Ask the developer which flows the block may name, and look the flow up through the host inside the block when it must be limited to one host or owner.
127
127
  - A step of a type with no `names_by`, or whose `names_by` gives a blank, is named after the flow and version the block returns, as that flow's title and the version number, such as `Onboarding, version 2`, or `version 2` alone when the flow has no title. That name is shown on the canvas and in `Waiting for <name>.` while a run waits at the step. When the block returns `nil`, the step is not named after the settings named `flow` and `version`, and falls back to its id on the canvas and its `step_name` in the waiting message. Ask the developer whether that name is what admins and visitors should read, and declare `names_by` when it is not.
128
128
  - The block is called each time the canvas draws the step, each time the step's name is shown, each time the flow is saved as a version or published, and when a run stops at the step, so it must not be slow and must not change anything.
@@ -153,7 +153,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
153
153
  - The block is given the node and the stored `EasyFlow::Run`. It returns a hash with the symbol keys `flow:`, the flow's id as an integer or a string, and `version:`, the version's number.
154
154
  - It is called only when a stored run stops at the step, once per visit, before that visit's run is started. It is never called on the canvas, when the step is named, or when the flow is saved or published.
155
155
  - A type that declares it never reads `chooses_flow` or the settings named `flow` and `version`, so it needs neither setting.
156
- - Since the flow is not known until a run reaches the step, the step is never marked `missing flow`, `unrunnable version` or `circular`, saving and publishing are never refused for it, and nothing checks that the chosen flow does not lead back to the flow the step is in. Clicking the step on the canvas opens its settings.
156
+ - Since the flow is not known until a run reaches the step, the step never shows the warnings about a missing flow, a version that cannot run or a flow that leads back, saving and publishing are never refused for it, and nothing checks that the chosen flow does not lead back to the flow the step is in. Clicking the step on the canvas opens its settings.
157
157
  - A step of the type with no `names_by` is named by its id on the canvas and by its `step_name` in `Waiting for <name>.`, so declare `names_by` or a `step_name` a visitor can read.
158
158
  - The flow is found by its id alone, not through the host or owner. Ask the developer which flows the block may name, and look the flow up through the run's flow's host or the run's owner inside the block when it must be limited.
159
159
  - When the block returns `nil`, a blank `flow:`, or the id of no existing flow, `EasyFlow::InnerFlowError` is raised with a message naming the step, such as `The step pick chose no flow to run`, and no run is started. A retired or withdrawn version raises `EasyFlow::InnerFlowError` as for a Flow step, and a blank version number, one naming no version of the flow, or one naming a draft raises `ActiveRecord::RecordInvalid`.
@@ -278,7 +278,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
278
278
  - The page does not reload itself. Ask the developer how the run should move on when what it waits for happens:
279
279
  - The visitor reloads the page, or presses `Check again` in a flow that keeps no stored run. This needs nothing more, and works whether or not the flow keeps a stored run.
280
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.
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
+ 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 warns it `is missing a flow` while no flow is picked, `names a version that cannot run` while the version number names no live or replaced version, `leads back to the flow it sits in` 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`.
282
282
 
283
283
  ### Take named inputs at a step type of the app's own
284
284
 
@@ -53,7 +53,7 @@ easy_flow declares no commands of its own for this local. Its surface is split b
53
53
  - **Step type** — what a kind of step is: its display name, its settings (the fields an admin fills in), its outputs (the values it records), and whether it waits for the visitor, acts on its own, or waits for something outside the flow. A step that acts on its own may record a value worked out from the answers so far, pick which connection to follow, or both.
54
54
  - **Display** — what a step type hands the visitor's page to draw a step, such as its question text and its answers. A step type may build its display from the step alone, or from the step and the visitor's stored run, so the same step can show something different to each visitor. There is no stored run in an admin's preview or in a flow that carries its answers in the page, and the display is then built without one. A host can draw a single-choice, multiple-select or fill-in-the-blank step with an answer already chosen by passing it as `chosen`, and the step is drawn blank when none is passed.
55
55
  - **Answer check** — a rule a step type may carry that looks at a visitor's answer and, when the answer is not acceptable, returns the message the visitor sees. A step type with no such rule accepts every answer.
56
- - **Setting** — one field an admin fills in when configuring a step on the canvas. It is text, a whole number, a decimal, a yes or no, a pick from a list, several picks from a list, an earlier step, an earlier step's output, another flow of the same host, or a list of entries that each hold their own fields. A setting can be required, limited to a number of picks, or checked by a rule the step type supplies.
56
+ - **Setting** — one field an admin fills in when configuring a step on the canvas. A step type may give a setting a one-sentence explanation, which shows under it in the step's settings. It is text, a whole number, a decimal, a yes or no, a pick from a list, several picks from a list, an earlier step, an earlier step's output, another flow of the same host, or a list of entries that each hold their own fields. A setting can be required, limited to a number of picks, or checked by a rule the step type supplies.
57
57
  - **Options** — the values a pick-from-a-list setting offers. They are either a fixed list written into the step type, or a lookup the app provides that is read each time the canvas or a save asks for them, so they can come from the app's own data. A saved value that is not among the options is refused.
58
58
  - **Kept setting** — a setting whose value lives on one of the app's own records rather than in the flow. The step type says how to find that record from the step's other settings and which of its attributes holds the value. The canvas shows the value read from the record, and saving the step's settings writes it straight to the record, so the flow's document and its versions never hold it and undo or publishing does not change it. When no record is found, nothing is written. When the record refuses a value, the save is refused with the record's own error and none of the step's kept settings are written.
59
59
  - **Output** — a value a step records into the run, with a type and a label. Later steps read outputs, and a setting that points at an earlier step offers that step's known values on the canvas. A step type may also take further outputs from a list setting, one for each entry the admin gives a name, labelled with that name written as a phrase, such as "First reading" for `first_reading`. An entry left without a name adds no output. Those outputs are offered wherever the step's declared outputs are, such as when a Condition, Compare, Compare two values, All of or Any of or Count step picks which output to read.
@@ -84,9 +84,9 @@ easy_flow declares no commands of its own for this local. Its surface is split b
84
84
  - **Wait step name** — what a Wait step is called on its canvas card and in the "Waiting for" line a visitor sees, following the same order as the rule that holds the run. A date set names it by that date, such as "2026-12-01". Otherwise it is named by the time of day, or by the minutes followed by "minutes", then "after" and the earlier step, such as "09:00 after signup" or "30 minutes after signup", with "minute" in place of "minutes" when the number is 1, such as "1 minute after signup". A Wait step with no date, time of day or minutes has no name of its own, so its canvas card shows the step's id and the visitor sees "Waiting for Wait".
85
85
  - **Count** — a built-in step that acts on its own and records a whole number: how many visits to an earlier step the run has recorded. When the admin picks an answer, only visits that gave that answer are counted, and when the admin picks none, every visit is counted. The admin picks which of the earlier step's outputs the answer is read from, as described under chosen output. On the canvas the step is named "Count" followed by the earlier step and the answer it counts, such as "Count tests failed". Each visit to a Count step in a loop records a new count under its own visit key, so a Compare step after it can end the loop once the count reaches an amount.
86
86
  - **Flow step** — a built-in step that names another flow and the number of one of its versions. The admin picks the flow from the same host's flows, listed by title as the admin's list of flows shows them. The step has one connection for each different output the End steps of that version write, and none when no flow or version is picked or no End step there has an output. On the canvas its card is named after the flow's title and the version, such as "Onboarding, version 2", and is marked "Runs a flow". Once a flow is picked, clicking the card opens that flow's canvas, and the card's Settings button opens the step's own settings instead. A canvas opened from a Flow step's card shows a "Back to" button named after the title of the flow it was opened from, or its slug when it has no title, which returns to that flow's canvas. The button only appears when that flow belongs to the same host, and to the same owner when the host has one. While the admin is connecting steps, clicking the card picks it as the connection's target. A run that reaches a Flow step stops there. When the run is a stored run, it starts one inner run of the named flow on the picked version, and a visitor on the run's page is taken to that inner run's first unanswered step. A run that carries its answers in the page, and an admin's preview, stop at the Flow step, start no run, and never move past it.
87
- - **Play flows step** — a built-in step (`play_flows`) that lists several flows and lets a visitor play each one once, in the order they choose. The admin ticks the flows from the same host's flows, and only that owner's flows when the host has an owner, and fills in the question asked above them. A visitor whose stored run reaches the step picks one of the listed flows not yet played, and it plays as an inner run of the visitor's run, linked to that visit of the step, the same way a Flow step's inner run is. When that inner run ends, the step records its output, or `ended`, under the visit's key, and the visitor is back at the step under its next visit with only the flows not yet played offered. A pick of a flow already played plays nothing. Once every listed flow has been played the run follows the step's one connection out, and a listed flow that no longer exists or has no live version is neither offered nor waited for. Its card shows "missing flow" while a listed flow no longer exists, and in a host that names an owner, saving the step or publishing its flow is refused while it lists another owner's flow. A run that carries its answers in the page, and an admin's preview, offer no flows at the step.
88
- - **Unrunnable Flow step** — a Flow step whose picked flow or version cannot be run. Its card shows "missing flow" when no flow is picked or the picked flow no longer exists, and "unrunnable version" when the picked flow has no version with that number or that version is a draft, retired or withdrawn. These are always on, unlike the optional checks below, and the flow holding such a step cannot be saved as a new version or published until it is fixed.
89
- - **Circular Flow step** — a Flow step that leads back to the flow it sits in. That is either a Flow step naming its own flow, or one naming a flow whose picked version holds a Flow step that leads back the same way, through any number of flows. Each flow along the way is read at the version its Flow step picked. Its card shows "circular", and like an unrunnable Flow step this is always on and keeps the flow from being saved as a new version or published until it is fixed.
87
+ - **Play flows step** — a built-in step (`play_flows`) that lists several flows and lets a visitor play each one once, in the order they choose. The admin ticks the flows from the same host's flows, and only that owner's flows when the host has an owner, and fills in the question asked above them. A visitor whose stored run reaches the step picks one of the listed flows not yet played, and it plays as an inner run of the visitor's run, linked to that visit of the step, the same way a Flow step's inner run is. When that inner run ends, the step records its output, or `ended`, under the visit's key, and the visitor is back at the step under its next visit with only the flows not yet played offered. A pick of a flow already played plays nothing. Once every listed flow has been played the run follows the step's one connection out, and a listed flow that no longer exists or has no live version is neither offered nor waited for. Its card warns that the step is missing a flow while a listed flow no longer exists, and in a host that names an owner, saving the step or publishing its flow is refused while it lists another owner's flow. A run that carries its answers in the page, and an admin's preview, offer no flows at the step.
88
+ - **Unrunnable Flow step** — a Flow step whose picked flow or version cannot be run. Its card warns that the step is missing a flow when no flow is picked or the picked flow no longer exists, and that it names a version that cannot run when the picked flow has no version with that number or that version is a draft, retired or withdrawn. These are always on, unlike the optional checks below, and the flow holding such a step cannot be saved as a new version or published until it is fixed.
89
+ - **Circular Flow step** — a Flow step that leads back to the flow it sits in. That is either a Flow step naming its own flow, or one naming a flow whose picked version holds a Flow step that leads back the same way, through any number of flows. Each flow along the way is read at the version its Flow step picked. Its card warns that the step leads back to the flow it sits in, and like an unrunnable Flow step this is always on and keeps the flow from being saved as a new version or published until it is fixed.
90
90
  - **Flow-starting step type** — a step type of the app's own that starts another flow the way the Flow step does. By default it runs the flow and version named in its settings called flow and version. It may instead carry a rule that works out the flow and version from its other settings, and that choice is then used everywhere a Flow step's choice is: starting the inner run, the unrunnable and circular checks, and the canvas link to that flow. When the step type gives the step no name of its own, the step is named after the flow and version that rule works out, the same way a Flow step is, such as "Onboarding, version 2", on its canvas card and in the "Waiting for" line a visitor sees. Everything said below about a Flow step's inner run and handing back applies to it too, except where a run-chosen flow says otherwise.
91
91
  - **Run-chosen flow** — a flow-starting step type of the app's own that chooses the flow and version from the run it is in rather than from its settings, so two runs of one flow can start two different inner flows from the same step. Where there is no run, such as on the canvas, it has no chosen flow. Its steps are left out of the unrunnable and circular checks, so a flow holding one can be published with no flow in its settings. A run that reaches it and has no flow chosen fails with an error naming the step. When its inner run ends with no output, the step records `ended` and its run moves on.
92
92
  - **Inner run** — the run a Flow step starts. It records which run started it and at which step, and that run holds at most one inner run for each Flow step, however often it is moved on. It belongs to the same owner as the run that started it. When the picked version has been retired or withdrawn, no inner run is started and the run that reached the Flow step fails with an error naming that step. While the run that started it is stopped at the Flow step, that run can name the inner run it is waiting on, and it names none when it is stopped at any other step or has finished. Its steps that act on their own run as soon as it starts, and it stops at its first step that asks for input or waits.
@@ -101,7 +101,8 @@ easy_flow declares no commands of its own for this local. Its surface is split b
101
101
  - **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.
102
102
  - **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.
103
103
  - **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`.
104
- - **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
+ - **Host setting explanations** — a setting a host may set, `setting_explanations`, giving its own explanation for one setting of one step type, including a setting inside a list. It shows in place of the step type's own. A setting it gives none for shows the step type's own, and with none set nothing changes.
105
+ - **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. Each problem is a sentence naming the step by its name, or by its step type's name when it has none, never by its id, and saying what to do to clear it. 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.
105
106
  - **Picture mark** — the words `Has a picture` on the box of a step with a picture on the canvas, on every host.
106
107
  - **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.
107
108
  - **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.
@@ -62,6 +62,7 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
62
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
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
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`.
65
+ - `setting_explanations` — this host's own explanations for step type settings, as a hash of step type name to setting name to a sentence, such as `{ multiple_select: { category: "The round this question counts toward.", "answers.weight" => "Points this answer adds to the score." } }`. A setting inside a list is named by the list and the setting joined with a dot, and a list setting's own explanation is named by the list alone. The sentence is shown as plain text under the setting in the step's settings, in place of the step type's own. A setting with none, or with a blank one, shows the step type's own, if it has one. A step type or setting name that matches nothing is ignored. A value that is not text, such as a nested hash, raises `ArgumentError` when the host is declared. With none set, every setting shows the step type's own explanation.
65
66
 
66
67
  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.
67
68
  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:
@@ -136,16 +137,16 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
136
137
  - 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`.
137
138
  - After install, check that a Compare two values step placed on the canvas offers the outputs of each earlier step picked for its two sides, a comparison of "more than", "less than", "at least", "at most" or "equal to", and an amount added to the second side. It decides a true or false result and is connected onward once for each. It needs no migration and no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
138
139
  - After install, check that a Compare two values step with its second step left empty can be saved and published, and that it compares the first side's output with the amount alone.
139
- - After install, check that a Compare step, a Count step, a Compare two values step, or a comparison of an All of or Any of step that names an earlier step with no output chosen shows "missing setting" in red on its canvas card, and that saving a version or publishing is refused until an output is chosen. This check is always on and is not turned on with `EasyFlow.check`.
140
+ - After install, check that a Compare step, a Count step, a Compare two values step, or a comparison of an All of or Any of step that names an earlier step with no output chosen shows a warning in red on its canvas card that it needs a value for Output, and that saving a version or publishing is refused until an output is chosen. This check is always on and is not turned on with `EasyFlow.check`.
140
141
  - After publishing a flow with a Compare two values step, check that a run whose answer on either side is not a number follows the connection for a false result.
141
142
  - After install, check that an All of or Any of step placed on the canvas asks whether "all of" or "any of" its comparisons must hold, and for a list of comparisons. Each comparison offers the outputs of the earlier step picked for it, a comparison of "more than", "less than", "at least", "at most" or "equal to", an amount, and an optional second step with its output. With no second step, the output is compared with the amount alone. The step decides a true or false result and is connected onward once for each. It needs no migration and no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
142
143
  - After publishing a flow with an All of or Any of step, check that a run follows the connection for a false result when the step holds no comparisons, and that a comparison whose answer is not a number counts as not holding.
143
144
  - After install, check that a Flow step placed on the canvas offers the host's flows by title as its flow setting. With a host's `owner_method` set, it offers only that owner's flows. It needs no setting in the initializer.
144
145
  - After choosing a flow on a Flow step, check that its canvas card reads "Runs a flow", that clicking the card opens the chosen flow's canvas under the same mount path, and that its Settings button opens the step's settings instead.
145
146
  - After opening a chosen flow's canvas from a Flow step's card, check that its header shows a "Back to" button naming the flow it was opened from, by title or by slug when it has no title, and that the button opens that flow's canvas. A canvas opened from the flow list shows no such button.
146
- - After install, check that a Flow step with no flow chosen, or whose chosen flow has been deleted, shows "missing flow" in red on its canvas card, and that saving a version or publishing is refused until it is fixed. This check is always on and is not turned on with `EasyFlow.check`.
147
- - After install, check that a Flow step whose chosen flow exists but whose version is not set, does not exist, or was never published or has been retired or withdrawn shows "unrunnable version" in red on its canvas card, and that saving a version or publishing is refused until it names a live or superseded version. This check is always on and is not turned on with `EasyFlow.check`.
148
- - After install, check that a Flow step whose chosen flow is the flow it sits in, or whose chosen flow leads back to it through the Flow steps of other flows, shows "circular" in red on its canvas card, and that saving a version or publishing is refused until it is fixed. This check is always on and is not turned on with `EasyFlow.check`.
147
+ - After install, check that a Flow step with no flow chosen, or whose chosen flow has been deleted, shows a warning in red that it is missing a flow on its canvas card, and that saving a version or publishing is refused until it is fixed. This check is always on and is not turned on with `EasyFlow.check`.
148
+ - After install, check that a Flow step whose chosen flow exists but whose version is not set, does not exist, or was never published or has been retired or withdrawn shows a warning in red that it names a version that cannot run on its canvas card, and that saving a version or publishing is refused until it names a live or superseded version. This check is always on and is not turned on with `EasyFlow.check`.
149
+ - After install, check that a Flow step whose chosen flow is the flow it sits in, or whose chosen flow leads back to it through the Flow steps of other flows, shows a warning in red that it leads back to the flow it sits in on its canvas card, and that saving a version or publishing is refused until it is fixed. This check is always on and is not turned on with `EasyFlow.check`.
149
150
  - When a stored run reaches a Flow step, the engine starts a run of the chosen flow and records the run it was started from. That record needs the migration that adds a parent run to the runs table, so an app upgrading from a version without it runs `bin/rails easy_flow:install:migrations` and `bin/rails db:migrate` before publishing a flow with a Flow step.
150
151
  - After publishing a flow with a Flow step, check that when a visitor finishes the chosen flow, the run that holds the Flow step moves on along the connection named after the output that flow ended with. If it stays at the Flow step, check the queue adapter from step 12.
151
152
  - When a chosen flow ends at an End step with no output filled in, the engine records nothing at the Flow step, and the job queued on `default` fails with `EasyFlow::InnerFlowError` naming the Flow step. The run that holds the Flow step stays at it. Check that the queue adapter from step 12 keeps or reports failed jobs, and that every End step of a flow chosen on a Flow step has an output.
@@ -153,9 +154,9 @@ A Rails engine for flows an admin draws on a canvas and a visitor runs one step
153
154
  - When a run reaches a Flow step whose chosen version was retired or withdrawn after the flow was published, the engine raises `EasyFlow::InnerFlowError` naming the Flow step and starts no run. The engine does not turn this error into a refusal, so the visitor gets the app's error page. Check that the app's error reporting captures it.
154
155
  - After install, check that removing a flow from `<mount path>/manage/flows` shows "Flow removed." and takes the flow off the list. Removing a flow deletes its versions and its runs, and needs no setting in the initializer.
155
156
  - After publishing a flow with a Flow step, check that while a run of it is stopped at that Flow step, removing the chosen flow is refused and the flow list shows "This flow cannot be removed while" followed by the waiting flow's title, or its slug when it has no title, and "waits on one of its runs". If no message is shown, check that the admin layout shows `flash[:alert]`.
156
- - A step type of the app's own that starts another flow is treated as a Flow step by every check above: the "missing flow", "unrunnable version" and "circular" flags, the refusal to remove a flow a waiting run depends on, and the job on `default`. It runs the flow and version its own settings name, or the ones its declaration works out from its other settings, and needs no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
157
+ - A step type of the app's own that starts another flow is treated as a Flow step by every check above: the warnings about a missing flow, a version that cannot run and a flow that leads back, the refusal to remove a flow a waiting run depends on, and the job on `default`. It runs the flow and version its own settings name, or the ones its declaration works out from its other settings, and needs no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
157
158
  - A step type of the app's own whose outputs an admin names on each step offers those names wherever an earlier step's outputs are offered: on a Condition step, on each side of a Compare two values step, and in each comparison of an All of or Any of step. It needs no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
158
- - A step type of the app's own that chooses the flow to start from the run it is in is the exception. It is never flagged "missing flow", "unrunnable version" or "circular", so a flow holding it can be published with no flow in its settings. When a run reaches it and it chooses no flow, the engine raises `EasyFlow::InnerFlowError` naming the step and starts no run, and the visitor gets the app's error page. When the run it started ends at an End step with no output, the engine records `ended` as the step's answer and the job on `default` moves the run on from it, rather than failing.
159
+ - A step type of the app's own that chooses the flow to start from the run it is in is the exception. It never shows the warnings about a missing flow, a version that cannot run or a flow that leads back, so a flow holding it can be published with no flow in its settings. When a run reaches it and it chooses no flow, the engine raises `EasyFlow::InnerFlowError` naming the step and starts no run, and the visitor gets the app's error page. When the run it started ends at an End step with no output, the engine records `ended` as the step's answer and the job on `default` moves the run on from it, rather than failing.
159
160
  - Every stored run keeps each step's value together with the time the step was completed. An app upgrading from a version that kept values alone runs `bin/rails easy_flow:install:migrations` and `bin/rails db:migrate` before any stored run takes an answer. The migration wraps each value its existing runs already hold, and those values carry no completion time. Running the migration back drops every completion time.
160
161
  - After install, check that a Compare step, a Compare two values step, or a comparison of an All of or Any of step, once its earlier step is picked, offers "Hour completed", "Weekday completed" and "Minutes since 1970 when completed" beside that step's own outputs. They need no setting in the initializer.
161
162
  - After install, check that a Wait step placed on the canvas asks for the earlier step it counts from, minutes after it, a time of day as HH:MM and a date as YYYY-MM-DD. It needs no migration beyond the one that keeps each step's completion time with its value and no setting in the initializer beyond being listed in a host's `offers` when that host sets `offers`.
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.34.0
4
+ version: 0.35.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tylercschneider
@@ -81,6 +81,7 @@ files:
81
81
  - app/javascript/easy_flow/canvas/Connector.jsx
82
82
  - app/javascript/easy_flow/canvas/ConnectorLayer.jsx
83
83
  - app/javascript/easy_flow/canvas/Control.jsx
84
+ - app/javascript/easy_flow/canvas/Explanation.jsx
84
85
  - app/javascript/easy_flow/canvas/Inspector.jsx
85
86
  - app/javascript/easy_flow/canvas/Panel.jsx
86
87
  - app/javascript/easy_flow/canvas/Placeholder.jsx
@@ -150,6 +151,7 @@ files:
150
151
  - app/models/easy_flow/validator.rb
151
152
  - app/models/easy_flow/version.rb
152
153
  - app/models/easy_flow/violation.rb
154
+ - app/models/easy_flow/violation_sentence.rb
153
155
  - app/models/easy_flow/wait.rb
154
156
  - app/models/easy_flow/write_data.rb
155
157
  - app/views/easy_flow/flows/complete.html.erb