easy_connect 0.1.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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +2 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/builds/easy_connect/board.js +1 -0
  6. data/app/controllers/concerns/easy_connect/hosted.rb +17 -0
  7. data/app/controllers/concerns/easy_connect/manage/authenticates_admin.rb +19 -0
  8. data/app/controllers/easy_connect/manage/base_controller.rb +18 -0
  9. data/app/controllers/easy_connect/manage/boards_controller.rb +14 -0
  10. data/app/controllers/easy_connect/manage/items_controller.rb +10 -0
  11. data/app/controllers/easy_connect/manage/lines_controller.rb +21 -0
  12. data/app/controllers/easy_connect/manage/saves_controller.rb +15 -0
  13. data/app/javascript/easy_connect/board/Board.jsx +158 -0
  14. data/app/javascript/easy_connect/board/lines.js +21 -0
  15. data/app/javascript/easy_connect/board/sender.js +20 -0
  16. data/app/javascript/easy_connect/board/untouched.js +7 -0
  17. data/app/javascript/easy_connect/board.jsx +8 -0
  18. data/app/models/easy_connect/application_record.rb +5 -0
  19. data/app/models/easy_connect/board.rb +68 -0
  20. data/app/models/easy_connect/rows.rb +41 -0
  21. data/app/models/easy_connect/shapes/connections.rb +38 -0
  22. data/app/models/easy_connect/shapes/ordering.rb +34 -0
  23. data/app/models/easy_connect/shapes.rb +15 -0
  24. data/app/views/easy_connect/manage/boards/show.html.erb +8 -0
  25. data/config/routes.rb +9 -0
  26. data/db/migrate/20261008120000_create_easy_connect_boards.rb +15 -0
  27. data/db/migrate/20261008130000_add_lines_to_easy_connect_boards.rb +5 -0
  28. data/db/migrate/20261008140000_add_saved_at_to_easy_connect_boards.rb +5 -0
  29. data/lib/easy_connect/engine.rb +7 -0
  30. data/lib/easy_connect/host.rb +19 -0
  31. data/lib/easy_connect/version.rb +3 -0
  32. data/lib/easy_connect.rb +27 -0
  33. metadata +103 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 654a6e1e269bb504f49b7210387623291db8c164b6dd1a1eff76f865577619e7
4
+ data.tar.gz: ecbc1fe761242d120982dbd0237c086b78d8bc4bb78db038bbfacea17954da24
5
+ SHA512:
6
+ metadata.gz: 0eb5312966d53e0d8125b33d152c24a9a1e14c6f162c907ec5cf116a5e2e8eef86f7ac4eb5505d4d799ce8149c6d3536dbd6d557efb3b61022050de175c22cd5
7
+ data.tar.gz: 1a5b739bcbc85ec4ed1a015085d414f4a1bcbf6aff73f11952eb54af080a4c0010483d5c6ff4123f58c06cbe51919158eaf812111cc5dccb7f12d9ca6ada159f
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright tylercschneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # easy_connect
2
+ A canvas where an admin draws lines between items a Rails host app hands in
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ (()=>{var X=globalThis.ReactDOM;var{createPortal:ct,createRoot:T,flushSync:ut,hydrateRoot:pt,preconnect:ft,prefetchDNS:mt,preinit:gt,preinitModule:bt,preload:ht,preloadModule:yt,requestFormReset:wt,unstable_batchedUpdates:vt,useFormState:xt,useFormStatus:St,version:kt}=X;var L=globalThis.React,r=L,{Children:Ct,Component:Dt,Fragment:Tt,PureComponent:Lt,StrictMode:Mt,Suspense:$t,cloneElement:Ot,createContext:jt,createElement:At,createRef:Bt,forwardRef:Nt,isValidElement:Pt,lazy:zt,memo:Ht,startTransition:It,useCallback:Wt,useContext:Ft,useDebugValue:Rt,useDeferredValue:Ut,useEffect:_t,useId:Vt,useImperativeHandle:Xt,useLayoutEffect:M,useMemo:Yt,useReducer:qt,useRef:v,useState:g,useSyncExternalStore:Jt,useTransition:Kt}=L;var $=new Map,h=new Map,O=(e,n)=>$.set(e,n),j=()=>{h.forEach(e=>e.unmount()),h.clear()},A=(e,n)=>e.querySelectorAll("[data-react-ui]").forEach(o=>{if(h.has(o))return;let s=$.get(o.dataset.reactUi),d=n(o);h.set(o,d),d.render(r.createElement(s,JSON.parse(o.dataset.props)))});var B=(e,n)=>{let o=()=>A(e,n);e.addEventListener("turbo:load",o),e.addEventListener("turbo:frame-load",o),e.addEventListener("turbo:before-cache",j),e.readyState==="loading"?e.addEventListener("DOMContentLoaded",o):o()};var Y="Your change was not saved because the server could not be reached.",N=({base:e,token:n,fetch:o,onDrawing:s,onError:d,onRedirect:i})=>async(u,p,c)=>{let l=await o(e+u,{method:p,headers:{"Content-Type":"application/json","X-CSRF-Token":n},body:c&&JSON.stringify(c)}).catch(()=>null);if(!l)return d(Y);if(!l.ok){let m=await l.json().catch(()=>({}));return d(m.error||"That change was refused.")}d(null);let f=l.status===204?{}:await l.json().catch(()=>({}));if(f.redirect)return i(f.redirect);s(await(await o(e+".json",{headers:{Accept:"application/json"}})).json())};var y=(e,n,o,s)=>{let d=e.left-o.left+s.scrollLeft,i=e.top-o.top+s.scrollTop;return n==="left"?{x:d,y:i+e.height/2}:n==="top"?{x:d+e.width/2,y:i}:n==="bottom"?{x:d+e.width/2,y:i+e.height}:{x:d+e.width,y:i+e.height/2}},P=(e,n)=>({path:`M ${e.x} ${e.y} L ${n.x} ${n.y}`,midX:(e.x+n.x)/2,midY:(e.y+n.y)/2}),z=(e,n,o,s)=>P(y(e,"bottom",o,s),y(n,"top",o,s)),H=(e,n,o,s)=>{let d=y(e,"right",o,s),i=y(n,"left",o,s);return P(d,i)};var I=e=>{if(!e.rows)return[];let n=new Set(e.lines.flatMap(o=>[o.from,o.to]));return e.rows.flat().filter(o=>!n.has(o.id)).map(o=>o.label)};var q={position:"relative"},J={display:"flex",gap:160,alignItems:"flex-start",padding:24},K={display:"flex",flexDirection:"column",gap:12,minWidth:240},G={display:"flex",flexDirection:"column",gap:72,padding:24},Q={display:"flex",gap:24,alignItems:"flex-start"},W={border:"1px solid #d1d5db",borderRadius:8,padding:"8px 14px",background:"white",cursor:"grab"},F={display:"flex",alignItems:"center",gap:12,padding:"0 24px"},R={padding:"8px 16px",borderRadius:8,border:0,background:"#2563eb",color:"white",fontWeight:600,cursor:"pointer"},Z=e=>e?`Saved ${new Date(e).toLocaleString()}`:"Not saved yet",tt={fontWeight:600},et={fontSize:13,color:"#6b7280"},ot={fontSize:13,color:"#2563eb"},nt=e=>/^https?:\/\//.test(e||""),x={marginTop:6,fontSize:12,padding:"2px 8px",borderRadius:6,border:"1px solid #d1d5db",background:"white",cursor:"pointer"},rt={margin:"0 24px",padding:"12px 14px",borderRadius:8,background:"#fffbeb",color:"#92400e"},st={margin:"0 24px",padding:"8px 14px",borderRadius:8,background:"#fef2f2",color:"#991b1b"},at={position:"absolute",transform:"translate(-50%, -50%)",width:22,height:22,borderRadius:11,border:"1px solid #d1d5db",background:"white",color:"#4b5563",cursor:"pointer",lineHeight:"18px",padding:0},dt={position:"absolute",inset:0,width:"100%",height:"100%",pointerEvents:"none",overflow:"visible"},it=(e,n,o,s)=>{let[d,i]=g([]);return M(()=>{let u=()=>{let p=n.current?.getBoundingClientRect();p&&i(e.lines.flatMap(c=>{let l=o.current[c.from]?.getBoundingClientRect(),f=o.current[c.to]?.getBoundingClientRect();if(!l||!f)return[];let m=e.rows?z:H;return[{...c,...m(l,f,p,n.current)}]}))};return u(),window.addEventListener("resize",u),()=>window.removeEventListener("resize",u)},[e,s]),d},lt=({base:e,token:n,initial:o})=>{let[s,d]=g(o),[i,u]=g(null),p=v(null),c=v({}),[l,f]=g(!1),[m,w]=g([]),S=it(s,p,c,l),k=t=>t.filter(a=>!(l&&a.done)),b=N({base:e,token:n,fetch:(...t)=>window.fetch(...t),onDrawing:d,onError:u,onRedirect:t=>window.location.assign(t)}),_=s.rows?s.rows.flat():s.columns.flatMap(t=>t.items),E=Object.fromEntries(_.map(t=>[t.id,t.label])),V=(t,a)=>{t.preventDefault();let D=t.dataTransfer.getData("text/plain");D&&b("/lines","POST",{from:D,to:a})},C=t=>r.createElement("div",{key:t.id,ref:a=>{c.current[t.id]=a},"data-item":t.id,"data-done":t.done?"true":void 0,draggable:!0,onDragStart:a=>{a.dataTransfer.effectAllowed="link",a.dataTransfer.setData("text/plain",t.id)},onDragOver:a=>a.preventDefault(),onDrop:a=>V(a,t.id),style:t.done?{...W,opacity:.55}:W},r.createElement("div",{style:tt},t.label),(t.details||[]).map(a=>r.createElement("div",{key:a,style:et},a)),r.createElement("button",{type:"button",draggable:!1,style:x,onClick:()=>b(`/items/${encodeURIComponent(t.id)}`,"PATCH",{done:!t.done})},t.done?"Undo done":"Mark done"),nt(t.url)&&r.createElement("a",{href:t.url,target:"_blank",rel:"noopener noreferrer",draggable:!1,style:ot},"Open"));return r.createElement("div",{ref:p,style:q},r.createElement("div",{style:F},r.createElement("button",{type:"button",style:R,onClick:()=>{let t=I(s);t.length?w(t):b("/save","POST")}},"Save"),r.createElement("button",{type:"button",style:x,onClick:()=>f(!l)},l?"Show done items":"Hide done items"),r.createElement("span",{"data-saved":!0},Z(s.saved_at))),m.length>0&&r.createElement("div",{role:"alertdialog",style:rt},r.createElement("p",null,"No line touches ",m.join(", "),". Save anyway?"),r.createElement("div",{style:F},r.createElement("button",{type:"button",style:x,onClick:()=>w([])},"Keep drawing"),r.createElement("button",{type:"button",style:R,onClick:()=>{w([]),b("/save","POST")}},"Save anyway"))),i&&r.createElement("p",{role:"alert",style:st},i),s.rows?r.createElement("div",{style:G},s.rows.map((t,a)=>r.createElement("div",{key:a,"data-row":a,style:Q},k(t).map(C)))):r.createElement("div",{style:J},s.columns.map(t=>r.createElement("section",{key:t.name,style:K},r.createElement("h2",null,t.name),k(t.items).map(C)))),r.createElement("svg",{style:dt},S.map(t=>r.createElement("path",{key:`${t.from} ${t.to}`,"data-line":`${t.from} ${t.to}`,d:t.path,stroke:"#4b5563",strokeWidth:"2",fill:"none"}))),S.map(t=>r.createElement("button",{key:`${t.from} ${t.to}`,type:"button",style:{...at,left:t.midX,top:t.midY},title:`Remove the line from ${E[t.from]} to ${E[t.to]}`,onClick:()=>b("/lines","DELETE",{from:t.from,to:t.to})},"\xD7")))},U=lt;O("easy_connect/board",U);B(document,T);})();
@@ -0,0 +1,17 @@
1
+ module EasyConnect
2
+ module Hosted
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ def connect_host
8
+ @connect_host ||= EasyConnect.host_named(request.path_parameters[:easy_connect_host])
9
+ end
10
+
11
+ def hosted_boards
12
+ return connect_host.boards unless connect_host.owner_method
13
+
14
+ connect_host.boards.where(owner: send(connect_host.owner_method))
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ module EasyConnect
2
+ module Manage
3
+ module AuthenticatesAdmin
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ before_action :authenticate_admin
8
+ end
9
+
10
+ private
11
+
12
+ def authenticate_admin
13
+ return unless connect_host.admin_authentication_method
14
+
15
+ send(connect_host.admin_authentication_method)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ require "keystone_ui/react/mount_helper"
2
+
3
+ module EasyConnect
4
+ module Manage
5
+ class BaseController < EasyConnect.base_controller.constantize
6
+ include Hosted
7
+ include AuthenticatesAdmin
8
+
9
+ layout -> { connect_host.admin_layout }
10
+
11
+ helper KeystoneUi::React::MountHelper
12
+
13
+ rescue_from Refused do |refusal|
14
+ render json: { error: refusal.message }, status: :unprocessable_entity
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module EasyConnect
2
+ module Manage
3
+ class BoardsController < BaseController
4
+ def show
5
+ @board = hosted_boards.find(params[:id])
6
+
7
+ respond_to do |format|
8
+ format.html
9
+ format.json { render json: @board.drawing }
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ module EasyConnect
2
+ module Manage
3
+ class ItemsController < BaseController
4
+ def update
5
+ hosted_boards.find(params[:board_id]).mark(params[:id], done: ActiveModel::Type::Boolean.new.cast(params[:done]))
6
+ head :no_content
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,21 @@
1
+ module EasyConnect
2
+ module Manage
3
+ class LinesController < BaseController
4
+ def create
5
+ board.connect(params[:from], params[:to])
6
+ head :no_content
7
+ end
8
+
9
+ def destroy
10
+ board.disconnect(params[:from], params[:to])
11
+ head :no_content
12
+ end
13
+
14
+ private
15
+
16
+ def board
17
+ @board ||= hosted_boards.find(params[:board_id])
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ module EasyConnect
2
+ module Manage
3
+ class SavesController < BaseController
4
+ def create
5
+ board = hosted_boards.find(params[:board_id])
6
+ board.mark_saved!
7
+ address = send(connect_host.after_save_method, board.id) if connect_host.after_save_method
8
+
9
+ return render json: { redirect: address } if address.present?
10
+
11
+ head :no_content
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,158 @@
1
+ import React, { useLayoutEffect, useRef, useState } from "react"
2
+ import { createSender } from "./sender"
3
+ import { between, downward } from "./lines"
4
+ import { untouched } from "./untouched"
5
+
6
+ const surfaceSide = { position: "relative" }
7
+
8
+ const columnsSide = { display: "flex", gap: 160, alignItems: "flex-start", padding: 24 }
9
+
10
+ const column = { display: "flex", flexDirection: "column", gap: 12, minWidth: 240 }
11
+
12
+ const rowsSide = { display: "flex", flexDirection: "column", gap: 72, padding: 24 }
13
+
14
+ const row = { display: "flex", gap: 24, alignItems: "flex-start" }
15
+
16
+ const node = { border: "1px solid #d1d5db", borderRadius: 8, padding: "8px 14px", background: "white", cursor: "grab" }
17
+
18
+ const toolbar = { display: "flex", alignItems: "center", gap: 12, padding: "0 24px" }
19
+
20
+ const saveButton = { padding: "8px 16px", borderRadius: 8, border: 0, background: "#2563eb", color: "white", fontWeight: 600, cursor: "pointer" }
21
+
22
+ const savedWhen = (savedAt) => savedAt ? `Saved ${new Date(savedAt).toLocaleString()}` : "Not saved yet"
23
+
24
+ const named = { fontWeight: 600 }
25
+
26
+ const detail = { fontSize: 13, color: "#6b7280" }
27
+
28
+ const opener = { fontSize: 13, color: "#2563eb" }
29
+
30
+ const onTheWeb = (address) => /^https?:\/\//.test(address || "")
31
+
32
+ const marker = { marginTop: 6, fontSize: 12, padding: "2px 8px", borderRadius: 6, border: "1px solid #d1d5db", background: "white", cursor: "pointer" }
33
+
34
+ const asking = { margin: "0 24px", padding: "12px 14px", borderRadius: 8, background: "#fffbeb", color: "#92400e" }
35
+
36
+ const refusal = { margin: "0 24px", padding: "8px 14px", borderRadius: 8, background: "#fef2f2", color: "#991b1b" }
37
+
38
+ const remover = { position: "absolute", transform: "translate(-50%, -50%)", width: 22, height: 22, borderRadius: 11, border: "1px solid #d1d5db", background: "white", color: "#4b5563", cursor: "pointer", lineHeight: "18px", padding: 0 }
39
+
40
+ const linesLayer = { position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "visible" }
41
+
42
+ const useLines = (drawing, surface, nodes, hidingDone) => {
43
+ const [ placed, setPlaced ] = useState([])
44
+
45
+ useLayoutEffect(() => {
46
+ const measure = () => {
47
+ const frame = surface.current?.getBoundingClientRect()
48
+ if (!frame) return
49
+
50
+ setPlaced(drawing.lines.flatMap((line) => {
51
+ const fromBox = nodes.current[line.from]?.getBoundingClientRect()
52
+ const toBox = nodes.current[line.to]?.getBoundingClientRect()
53
+ if (!fromBox || !toBox) return []
54
+
55
+ const joining = drawing.rows ? downward : between
56
+ return [ { ...line, ...joining(fromBox, toBox, frame, surface.current) } ]
57
+ }))
58
+ }
59
+
60
+ measure()
61
+ window.addEventListener("resize", measure)
62
+
63
+ return () => window.removeEventListener("resize", measure)
64
+ }, [ drawing, hidingDone ])
65
+
66
+ return placed
67
+ }
68
+
69
+ const Board = ({ base, token, initial }) => {
70
+ const [ drawing, setDrawing ] = useState(initial)
71
+ const [ error, setError ] = useState(null)
72
+ const surface = useRef(null)
73
+ const nodes = useRef({})
74
+ const [ hidingDone, setHidingDone ] = useState(false)
75
+ const [ leftOut, setLeftOut ] = useState([])
76
+ const placed = useLines(drawing, surface, nodes, hidingDone)
77
+ const shown = (held) => held.filter((item) => !(hidingDone && item.done))
78
+
79
+ const send = createSender({ base, token, fetch: (...request) => window.fetch(...request), onDrawing: setDrawing, onError: setError, onRedirect: (address) => window.location.assign(address) })
80
+
81
+ const items = drawing.rows ? drawing.rows.flat() : drawing.columns.flatMap((group) => group.items)
82
+ const labels = Object.fromEntries(items.map((item) => [ item.id, item.label ]))
83
+
84
+ const dropped = (event, to) => {
85
+ event.preventDefault()
86
+ const from = event.dataTransfer.getData("text/plain")
87
+ if (from) send("/lines", "POST", { from, to })
88
+ }
89
+
90
+ const drawNode = (item) => (
91
+ <div key={item.id}
92
+ ref={(element) => { nodes.current[item.id] = element }}
93
+ data-item={item.id}
94
+ data-done={item.done ? "true" : undefined}
95
+ draggable
96
+ onDragStart={(event) => { event.dataTransfer.effectAllowed = "link"; event.dataTransfer.setData("text/plain", item.id) }}
97
+ onDragOver={(event) => event.preventDefault()}
98
+ onDrop={(event) => dropped(event, item.id)}
99
+ style={item.done ? { ...node, opacity: 0.55 } : node}>
100
+ <div style={named}>{item.label}</div>
101
+ {(item.details || []).map((line) => <div key={line} style={detail}>{line}</div>)}
102
+ <button type="button" draggable={false} style={marker}
103
+ onClick={() => send(`/items/${encodeURIComponent(item.id)}`, "PATCH", { done: !item.done })}>{item.done ? "Undo done" : "Mark done"}</button>
104
+ {onTheWeb(item.url) && <a href={item.url} target="_blank" rel="noopener noreferrer" draggable={false} style={opener}>Open</a>}
105
+ </div>
106
+ )
107
+
108
+ return (
109
+ <div ref={surface} style={surfaceSide}>
110
+ <div style={toolbar}>
111
+ <button type="button" style={saveButton} onClick={() => {
112
+ const missing = untouched(drawing)
113
+ missing.length ? setLeftOut(missing) : send("/save", "POST")
114
+ }}>Save</button>
115
+ <button type="button" style={marker} onClick={() => setHidingDone(!hidingDone)}>{hidingDone ? "Show done items" : "Hide done items"}</button>
116
+ <span data-saved>{savedWhen(drawing.saved_at)}</span>
117
+ </div>
118
+ {leftOut.length > 0 && (
119
+ <div role="alertdialog" style={asking}>
120
+ <p>No line touches {leftOut.join(", ")}. Save anyway?</p>
121
+ <div style={toolbar}>
122
+ <button type="button" style={marker} onClick={() => setLeftOut([])}>Keep drawing</button>
123
+ <button type="button" style={saveButton} onClick={() => { setLeftOut([]); send("/save", "POST") }}>Save anyway</button>
124
+ </div>
125
+ </div>
126
+ )}
127
+ {error && <p role="alert" style={refusal}>{error}</p>}
128
+ {drawing.rows ? (
129
+ <div style={rowsSide}>
130
+ {drawing.rows.map((held, index) => (
131
+ <div key={index} data-row={index} style={row}>{shown(held).map(drawNode)}</div>
132
+ ))}
133
+ </div>
134
+ ) : (
135
+ <div style={columnsSide}>
136
+ {drawing.columns.map((group) => (
137
+ <section key={group.name} style={column}>
138
+ <h2>{group.name}</h2>
139
+ {shown(group.items).map(drawNode)}
140
+ </section>
141
+ ))}
142
+ </div>
143
+ )}
144
+ <svg style={linesLayer}>
145
+ {placed.map((line) => (
146
+ <path key={`${line.from} ${line.to}`} data-line={`${line.from} ${line.to}`} d={line.path} stroke="#4b5563" strokeWidth="2" fill="none" />
147
+ ))}
148
+ </svg>
149
+ {placed.map((line) => (
150
+ <button key={`${line.from} ${line.to}`} type="button" style={{ ...remover, left: line.midX, top: line.midY }}
151
+ title={`Remove the line from ${labels[line.from]} to ${labels[line.to]}`}
152
+ onClick={() => send("/lines", "DELETE", { from: line.from, to: line.to })}>×</button>
153
+ ))}
154
+ </div>
155
+ )
156
+ }
157
+
158
+ export default Board
@@ -0,0 +1,21 @@
1
+ export const anchor = (box, side, frame, surface) => {
2
+ const left = box.left - frame.left + surface.scrollLeft
3
+ const top = box.top - frame.top + surface.scrollTop
4
+
5
+ if (side === "left") return { x: left, y: top + box.height / 2 }
6
+ if (side === "top") return { x: left + box.width / 2, y: top }
7
+ if (side === "bottom") return { x: left + box.width / 2, y: top + box.height }
8
+ return { x: left + box.width, y: top + box.height / 2 }
9
+ }
10
+
11
+ const joined = (a, b) => ({ path: `M ${a.x} ${a.y} L ${b.x} ${b.y}`, midX: (a.x + b.x) / 2, midY: (a.y + b.y) / 2 })
12
+
13
+ export const downward = (fromBox, toBox, frame, surface) =>
14
+ joined(anchor(fromBox, "bottom", frame, surface), anchor(toBox, "top", frame, surface))
15
+
16
+ export const between = (fromBox, toBox, frame, surface) => {
17
+ const a = anchor(fromBox, "right", frame, surface)
18
+ const b = anchor(toBox, "left", frame, surface)
19
+
20
+ return joined(a, b)
21
+ }
@@ -0,0 +1,20 @@
1
+ const UNREACHABLE = "Your change was not saved because the server could not be reached."
2
+
3
+ export const createSender = ({ base, token, fetch, onDrawing, onError, onRedirect }) => async (path, method, body) => {
4
+ const response = await fetch(base + path, {
5
+ method, headers: { "Content-Type": "application/json", "X-CSRF-Token": token }, body: body && JSON.stringify(body)
6
+ }).catch(() => null)
7
+ if (!response) return onError(UNREACHABLE)
8
+
9
+ if (!response.ok) {
10
+ const answered = await response.json().catch(() => ({}))
11
+ return onError(answered.error || "That change was refused.")
12
+ }
13
+
14
+ onError(null)
15
+
16
+ const accepted = response.status === 204 ? {} : await response.json().catch(() => ({}))
17
+ if (accepted.redirect) return onRedirect(accepted.redirect)
18
+
19
+ onDrawing(await (await fetch(base + ".json", { headers: { Accept: "application/json" } })).json())
20
+ }
@@ -0,0 +1,7 @@
1
+ export const untouched = (drawing) => {
2
+ if (!drawing.rows) return []
3
+
4
+ const touched = new Set(drawing.lines.flatMap((line) => [ line.from, line.to ]))
5
+
6
+ return drawing.rows.flat().filter((item) => !touched.has(item.id)).map((item) => item.label)
7
+ }
@@ -0,0 +1,8 @@
1
+ import { createRoot } from "react-dom/client"
2
+ import { register } from "keystone_ui-react/src/registry.js"
3
+ import { startMounting } from "keystone_ui-react/src/mounting.js"
4
+ import Board from "./board/Board"
5
+
6
+ register("easy_connect/board", Board)
7
+
8
+ startMounting(document, createRoot)
@@ -0,0 +1,5 @@
1
+ module EasyConnect
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,68 @@
1
+ module EasyConnect
2
+ class Board < ApplicationRecord
3
+ self.table_name = "easy_connect_boards"
4
+
5
+ SHOWN = %w[id label details url done].freeze
6
+
7
+ belongs_to :owner, polymorphic: true, optional: true
8
+
9
+ validate :board_is_sound
10
+
11
+ def mark_saved!
12
+ update!(saved_at: Time.current)
13
+ end
14
+
15
+ def saved?
16
+ saved_at.present?
17
+ end
18
+
19
+ def result
20
+ { shape: shape, items: items.pluck("id"), lines: lines.map { |line| line.values_at("from", "to") } }.to_json
21
+ end
22
+
23
+ def connect(from, to)
24
+ line = shape_rules.line(from, to)
25
+ raise Refused, "Those two items are already joined." if lines.include?(line)
26
+
27
+ update!(lines: lines + [ line ])
28
+ end
29
+
30
+ def disconnect(from, to)
31
+ update!(lines: lines - [ shape_rules.line(from, to) ])
32
+ end
33
+
34
+ def add_items(added)
35
+ update!(items: items + added.map(&:stringify_keys))
36
+ end
37
+
38
+ def remove_items(ids)
39
+ update!(items: items.reject { |item| ids.include?(item["id"]) },
40
+ lines: lines.reject { |line| ids.include?(line["from"]) || ids.include?(line["to"]) })
41
+ end
42
+
43
+ def mark(id, done:)
44
+ raise Refused, "That item is not on this board." unless items.any? { |item| item["id"] == id }
45
+
46
+ update!(items: items.map { |item| item["id"] == id ? item.merge("done" => done) : item })
47
+ end
48
+
49
+ def shape_rules
50
+ Shapes.named(shape).new(groups, items, lines)
51
+ end
52
+
53
+ def drawing
54
+ shape_rules.placement(SHOWN).merge("lines" => lines, "saved_at" => saved_at&.iso8601)
55
+ end
56
+
57
+ private
58
+
59
+ def board_is_sound
60
+ return errors.add(:shape, "#{shape} is not a shape") unless Shapes.known?(shape)
61
+
62
+ shape_rules.problems.each { |problem| errors.add(:base, problem) }
63
+ items.pluck("id").tally.select { |_id, count| count > 1 }.each_key { |id| errors.add(:items, "share the id #{id}") }
64
+ items.select { |item| item["label"].blank? }.each { |item| errors.add(:items, "#{item["id"]} has no label") }
65
+ items.reject { |item| groups.include?(item["group"]) }.each { |item| errors.add(:items, "#{item["id"]} is in a group the board does not have") }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,41 @@
1
+ module EasyConnect
2
+ class Rows
3
+ def initialize(ids, lines)
4
+ @ids = ids
5
+ @lines = lines
6
+ end
7
+
8
+ def rows
9
+ walked = walk
10
+ stranded = @ids - walked.keys
11
+
12
+ walked.merge(stranded.index_with((walked.values.max || -1) + 1))
13
+ end
14
+
15
+ private
16
+
17
+ def walk
18
+ seen = {}
19
+ frontier = starting_points.map { |id| [ id, 0 ] }
20
+
21
+ until frontier.empty?
22
+ id, depth = frontier.shift
23
+ next if seen.key?(id)
24
+
25
+ seen[id] = depth
26
+ lines_from(id).each { |line| frontier << [ line["to"], depth + 1 ] }
27
+ end
28
+
29
+ seen
30
+ end
31
+
32
+ def starting_points
33
+ arrived = @lines.map { |line| line["to"] }
34
+ @ids.reject { |id| arrived.include?(id) }
35
+ end
36
+
37
+ def lines_from(id)
38
+ @lines.select { |line| line["from"] == id }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ module EasyConnect
2
+ module Shapes
3
+ class Connections
4
+ def initialize(groups, items, lines = [])
5
+ @groups = groups
6
+ @items = items
7
+ @lines = lines
8
+ end
9
+
10
+ def problems
11
+ @groups.size == 2 ? [] : [ "A connections board needs exactly two groups" ]
12
+ end
13
+
14
+ def placement(shown)
15
+ { "columns" => @groups.map { |group| { "name" => group, "items" => held_by(group).map { |item| item.slice(*shown) } } } }
16
+ end
17
+
18
+ def line(from, to)
19
+ raise Refused, "That item is not on this board." unless group_of(from) && group_of(to)
20
+ raise Refused, "Both items are in the same group." if group_of(from) == group_of(to)
21
+
22
+ ends = [ from, to ].sort_by { |id| @groups.index(group_of(id)) }
23
+
24
+ { "from" => ends.first, "to" => ends.last }
25
+ end
26
+
27
+ private
28
+
29
+ def held_by(group)
30
+ @items.select { |item| item["group"] == group }
31
+ end
32
+
33
+ def group_of(id)
34
+ @items.find { |item| item["id"] == id }&.dig("group")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ module EasyConnect
2
+ module Shapes
3
+ class Ordering
4
+ def initialize(groups, items, lines = [])
5
+ @groups = groups
6
+ @items = items
7
+ @lines = lines
8
+ end
9
+
10
+ def problems
11
+ []
12
+ end
13
+
14
+ def placement(shown)
15
+ placed = Rows.new(@items.pluck("id"), @lines).rows
16
+
17
+ { "rows" => @items.group_by { |item| placed[item["id"]] }.sort.map { |_row, held| held.map { |item| item.slice(*shown) } } }
18
+ end
19
+
20
+ def line(from, to)
21
+ raise Refused, "That item is not on this board." unless on_board?(from) && on_board?(to)
22
+ raise Refused, "A line cannot run from an item to itself." if from == to
23
+
24
+ { "from" => from, "to" => to }
25
+ end
26
+
27
+ private
28
+
29
+ def on_board?(id)
30
+ @items.any? { |item| item["id"] == id }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module EasyConnect
2
+ module Shapes
3
+ def self.named(name)
4
+ all.fetch(name)
5
+ end
6
+
7
+ def self.known?(name)
8
+ all.key?(name)
9
+ end
10
+
11
+ def self.all
12
+ { "connections" => Connections, "ordering" => Ordering }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,8 @@
1
+ <% content_for :head do %>
2
+ <%= javascript_include_tag "keystone_ui/react", defer: true %>
3
+ <%= javascript_include_tag "easy_connect/board", defer: true %>
4
+ <% end %>
5
+
6
+ <h1><%= @board.title %></h1>
7
+
8
+ <%= react_ui("easy_connect/board", { base: manage_board_path(@board), token: form_authenticity_token, initial: @board.drawing }) %>
data/config/routes.rb ADDED
@@ -0,0 +1,9 @@
1
+ EasyConnect::Engine.routes.draw do
2
+ namespace :manage do
3
+ resources :boards, only: :show do
4
+ resource :lines, only: [ :create, :destroy ]
5
+ resource :save, only: :create
6
+ resources :items, only: :update, id: /[^\/]+/
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ class CreateEasyConnectBoards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :easy_connect_boards do |t|
4
+ t.string :host, null: false
5
+ t.references :owner, polymorphic: true, index: false
6
+ t.string :shape, null: false, default: "connections"
7
+ t.string :title, null: false
8
+ t.json :groups, null: false
9
+ t.json :items, null: false
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :easy_connect_boards, [ :host, :owner_type, :owner_id ]
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ class AddLinesToEasyConnectBoards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :easy_connect_boards, :lines, :json, null: false, default: []
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ class AddSavedAtToEasyConnectBoards < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :easy_connect_boards, :saved_at, :datetime
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ require "keystone_ui-react"
2
+
3
+ module EasyConnect
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace EasyConnect
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ module EasyConnect
2
+ class Host
3
+ attr_reader :name
4
+ attr_writer :admin_layout
5
+ attr_accessor :admin_authentication_method, :owner_method, :after_save_method
6
+
7
+ def initialize(name)
8
+ @name = name.to_s
9
+ end
10
+
11
+ def admin_layout
12
+ @admin_layout || "application"
13
+ end
14
+
15
+ def boards
16
+ Board.where(host: name)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ module EasyConnect
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,27 @@
1
+ require "easy_connect/version"
2
+ require "easy_connect/host"
3
+ require "easy_connect/engine"
4
+
5
+ module EasyConnect
6
+ class Refused < StandardError; end
7
+
8
+ class << self
9
+ attr_writer :base_controller
10
+
11
+ def base_controller
12
+ @base_controller || "ActionController::Base"
13
+ end
14
+
15
+ def host(name)
16
+ hosts[name.to_s] = Host.new(name)
17
+ end
18
+
19
+ def hosts
20
+ @hosts ||= {}
21
+ end
22
+
23
+ def host_named(name)
24
+ hosts.fetch(name.to_s)
25
+ end
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: easy_connect
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - tylercschneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.3
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.3
26
+ - !ruby/object:Gem::Dependency
27
+ name: keystone_ui-react
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.1.2
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.1.2
40
+ description: EasyConnect stores boards of items a host app creates, lets an admin
41
+ draw lines between them, and gives the host the lines as JSON.
42
+ email:
43
+ - tylercschneider@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - MIT-LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - app/assets/builds/easy_connect/board.js
52
+ - app/controllers/concerns/easy_connect/hosted.rb
53
+ - app/controllers/concerns/easy_connect/manage/authenticates_admin.rb
54
+ - app/controllers/easy_connect/manage/base_controller.rb
55
+ - app/controllers/easy_connect/manage/boards_controller.rb
56
+ - app/controllers/easy_connect/manage/items_controller.rb
57
+ - app/controllers/easy_connect/manage/lines_controller.rb
58
+ - app/controllers/easy_connect/manage/saves_controller.rb
59
+ - app/javascript/easy_connect/board.jsx
60
+ - app/javascript/easy_connect/board/Board.jsx
61
+ - app/javascript/easy_connect/board/lines.js
62
+ - app/javascript/easy_connect/board/sender.js
63
+ - app/javascript/easy_connect/board/untouched.js
64
+ - app/models/easy_connect/application_record.rb
65
+ - app/models/easy_connect/board.rb
66
+ - app/models/easy_connect/rows.rb
67
+ - app/models/easy_connect/shapes.rb
68
+ - app/models/easy_connect/shapes/connections.rb
69
+ - app/models/easy_connect/shapes/ordering.rb
70
+ - app/views/easy_connect/manage/boards/show.html.erb
71
+ - config/routes.rb
72
+ - db/migrate/20261008120000_create_easy_connect_boards.rb
73
+ - db/migrate/20261008130000_add_lines_to_easy_connect_boards.rb
74
+ - db/migrate/20261008140000_add_saved_at_to_easy_connect_boards.rb
75
+ - lib/easy_connect.rb
76
+ - lib/easy_connect/engine.rb
77
+ - lib/easy_connect/host.rb
78
+ - lib/easy_connect/version.rb
79
+ homepage: https://github.com/DYB-Development/easy_connect
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/DYB-Development/easy_connect
84
+ source_code_uri: https://github.com/DYB-Development/easy_connect
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.20
100
+ specification_version: 4
101
+ summary: A canvas where an admin draws lines between items a Rails host app hands
102
+ in.
103
+ test_files: []