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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +2 -0
- data/Rakefile +6 -0
- data/app/assets/builds/easy_connect/board.js +1 -0
- data/app/controllers/concerns/easy_connect/hosted.rb +17 -0
- data/app/controllers/concerns/easy_connect/manage/authenticates_admin.rb +19 -0
- data/app/controllers/easy_connect/manage/base_controller.rb +18 -0
- data/app/controllers/easy_connect/manage/boards_controller.rb +14 -0
- data/app/controllers/easy_connect/manage/items_controller.rb +10 -0
- data/app/controllers/easy_connect/manage/lines_controller.rb +21 -0
- data/app/controllers/easy_connect/manage/saves_controller.rb +15 -0
- data/app/javascript/easy_connect/board/Board.jsx +158 -0
- data/app/javascript/easy_connect/board/lines.js +21 -0
- data/app/javascript/easy_connect/board/sender.js +20 -0
- data/app/javascript/easy_connect/board/untouched.js +7 -0
- data/app/javascript/easy_connect/board.jsx +8 -0
- data/app/models/easy_connect/application_record.rb +5 -0
- data/app/models/easy_connect/board.rb +68 -0
- data/app/models/easy_connect/rows.rb +41 -0
- data/app/models/easy_connect/shapes/connections.rb +38 -0
- data/app/models/easy_connect/shapes/ordering.rb +34 -0
- data/app/models/easy_connect/shapes.rb +15 -0
- data/app/views/easy_connect/manage/boards/show.html.erb +8 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20261008120000_create_easy_connect_boards.rb +15 -0
- data/db/migrate/20261008130000_add_lines_to_easy_connect_boards.rb +5 -0
- data/db/migrate/20261008140000_add_saved_at_to_easy_connect_boards.rb +5 -0
- data/lib/easy_connect/engine.rb +7 -0
- data/lib/easy_connect/host.rb +19 -0
- data/lib/easy_connect/version.rb +3 -0
- data/lib/easy_connect.rb +27 -0
- 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
data/Rakefile
ADDED
|
@@ -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,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,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,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,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,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,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
|
data/lib/easy_connect.rb
ADDED
|
@@ -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: []
|