studio-engine 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/app/assets/javascripts/studio/sortable.js +11 -0
- data/app/controllers/concerns/studio/board/reorderable.rb +97 -0
- data/app/models/concerns/studio/board/rankable.rb +84 -0
- data/app/views/components/_badge.html.erb +4 -2
- data/app/views/layouts/studio/_head.html.erb +6 -0
- data/app/views/studio/_board_assets.html.erb +341 -0
- data/app/views/studio/board/_board.html.erb +151 -0
- data/app/views/studio/board/_card_shell.html.erb +47 -0
- data/app/views/studio/board/_column.html.erb +80 -0
- data/app/views/style/_tasks.html.erb +82 -11
- data/app/views/style/board/_demo_card.html.erb +22 -0
- data/lib/studio/engine.rb +1 -0
- data/lib/studio/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e37d3038ae5d72bbd0829247f562aa744960458dc9e54faa73c38fc1a2adcb83
|
|
4
|
+
data.tar.gz: a74cb5455f9977009b3499981dec577bb7df9782a5ab2f08a2bda92fe9cbe9c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45ecd0d11aef5443dc5342013e39124b52ecf67b46261b096e45a57b088a22335e5ce7c1b58f894c0878888cd1dc087b886f76e7ed15875ec08759222a52ae74
|
|
7
|
+
data.tar.gz: 2e1b9c6dd268214317af8a93aa27bf49e1aa2ebff4e487010a81fd5f332ee261a0d1011fb636cbd9577dd3300c23ba0b1edc6e40e7b56366d83c7314d40a0636
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
|
|
4
4
|
|
|
5
|
+
## 0.23.0 — 2026-07-28
|
|
6
|
+
|
|
7
|
+
Phase D, slice 1 — the **board primitive**. The three near-identical McRitchie
|
|
8
|
+
Studio kanban boards (tasks / news / content) each hand-rolled the SAME SortableJS
|
|
9
|
+
init, the SAME optimistic move → revert → toast, and a BYTE-IDENTICAL `reorder`
|
|
10
|
+
controller action; the depth chart hand-rolled a within-lane variant. This slice
|
|
11
|
+
lifts that convergence into a neutral engine primitive, following the same seam as
|
|
12
|
+
the leveling modals and tricks (documented `local_assigns.fetch` locals → a
|
|
13
|
+
page-level Alpine factory in an `_assets` partial → capability / `fx` / event hooks
|
|
14
|
+
→ a `demo:` local for `/admin/style` → vendored JS).
|
|
15
|
+
|
|
16
|
+
**Purely additive — new engine files only, no existing primitive's default
|
|
17
|
+
changes. MS and TM render identically on the bump** (they do not use
|
|
18
|
+
`studio/board/*` yet; the board rebases are separate later deploys).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`studio/board/_board`** — the board shell: a row of columns (drop zones), an
|
|
23
|
+
optional live `turbo_stream_from`, and a toast host. Documented `local_assigns`
|
|
24
|
+
locals: `columns:` `card_partial:` `card_as:` `card_locals:` `reorder_url:`
|
|
25
|
+
`reorder_payload:` (`:slugs` | `:ids`) `move_url:` `move_param:` `group:`
|
|
26
|
+
(`false` = within-zone) `id_attr:` `zone_attr:` `handle:` `filter:`
|
|
27
|
+
`zone_selector:` `draggable:` `empty_selector:` `live_channel:` `optimistic:`
|
|
28
|
+
`toasts:` `empty_label:` `header_slot:` `above_board_slot:` `fx:` `on_move_hook:`
|
|
29
|
+
`on_drop_hook:` `demo:`.
|
|
30
|
+
- **`studio/board/_column`** — one column: header + count badge + the
|
|
31
|
+
`.kanban-dropzone` (`id="dropzone-<key>"`, `data-<zone_attr>`) + `.kanban-empty`
|
|
32
|
+
empty state (the ZONE half of the identity contract).
|
|
33
|
+
- **`studio/board/_card_shell`** — OPTIONAL `render layout:` chrome that emits the
|
|
34
|
+
CARD half of the contract (`id="card-<id>"`, `.kanban-card`, `data-<id_attr>`,
|
|
35
|
+
`data-<zone_attr>`). Apps may hand-roll a bespoke card that satisfies the same
|
|
36
|
+
contract instead.
|
|
37
|
+
- **`studioBoard(opts)` factory — `studio/_board_assets`.** The Alpine data behind
|
|
38
|
+
the board, shipped at page level (a `<script>` cloned from a component template
|
|
39
|
+
never runs), like `levelingActionModal`. ONE factory drives BOTH shapes — it
|
|
40
|
+
reads `group` / `handle` / `filter` / `draggable` / `id_attr` / `zone_attr` from
|
|
41
|
+
opts, so the cross-column kanban and the within-lane depth chart are the same
|
|
42
|
+
code. Owns `initSortables`, `handleSortEnd` (cross-column move → PATCH →
|
|
43
|
+
optimistic revert + red-ring + toast; a same-zone drop NEVER PATCHes the zone),
|
|
44
|
+
`saveOrder` (reorder POST), `updateCounts`, `observeLive`,
|
|
45
|
+
`installExitStreamFallback`, `animateIn`, and toasts. Preserves the
|
|
46
|
+
`$nextTick` → `data-alpine-ready` init ordering (a documented flake fix). The
|
|
47
|
+
primary extension seam is the dispatched window events
|
|
48
|
+
`studio:board-moved` / `:board-reordered` / `:card-added` (detail
|
|
49
|
+
`{ record, from, to }`) — apps LISTEN, never patch the factory.
|
|
50
|
+
- **SortableJS v1.15.6 (MIT) VENDORED** — `app/assets/javascripts/studio/sortable.js`,
|
|
51
|
+
added to `config.assets.precompile` and loaded (deferred) by
|
|
52
|
+
`layouts/studio/_head`, mirroring the vendored canvas-confetti. CSP-safe, no CDN;
|
|
53
|
+
any board consumer gets `Sortable` with zero per-app wiring.
|
|
54
|
+
- **`Studio::Board::Rankable`** — the shared 100-gap rank read-model: a
|
|
55
|
+
`board_ordered` scope (`position DESC NULLS LAST, created_at DESC`),
|
|
56
|
+
`set_initial_position` (max + 100 within the zone), and `reposition!(ids, gap:,
|
|
57
|
+
direction: :desc | :asc)`.
|
|
58
|
+
- **`Studio::Board::Reorderable`** — the shared `reorder` controller action,
|
|
59
|
+
neutral param, delegating the 100-gap restamp to `Rankable#reposition!`.
|
|
60
|
+
- **`/admin/style` Board specimen** — the Tasks section now renders the REAL
|
|
61
|
+
`studio/board/board` primitive in `demo: true` mode (drag works, no POST),
|
|
62
|
+
above the static stage-palette reference.
|
|
63
|
+
|
|
5
64
|
## 0.22.0 — 2026-07-28
|
|
6
65
|
|
|
7
66
|
Two additive batches for the living style guide, converged under one release.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
SortableJS v1.15.6 — VENDORED into studio-engine (no CDN, CSP-safe).
|
|
3
|
+
Source: https://github.com/SortableJS/Sortable (MIT, (c) All Sortable contributors).
|
|
4
|
+
Minified build: https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js
|
|
5
|
+
Shipped as an engine asset so consumers get zero-per-app drag-and-drop with no
|
|
6
|
+
third-party CDN dependency. Defines the global `Sortable`; the studio/board
|
|
7
|
+
primitive (studio/_board_assets -> window.studioBoard) drives it. To re-vendor on
|
|
8
|
+
a bump: re-fetch the pinned dist file above, keep this header, bump the version.
|
|
9
|
+
*/
|
|
10
|
+
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
|
|
11
|
+
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(t,!0),i=X(t)[e];o;){var r=X(o)[n];if(!("top"===n||"left"===n?r<=i:i<=r))return o;if(o===O())break;o=M(o,!1)}return!1}function B(t,e,n,o){for(var i=0,r=0,a=t.children;r<a.length;){if("none"!==a[r].style.display&&a[r]!==jt.ghost&&(o||a[r]!==jt.dragged)&&P(a[r],n.draggable,t,!1)){if(i===e)return a[r];i++}r++}return null}function F(t,e){for(var n=t.lastElementChild;n&&(n===jt.ghost||"none"===R(n,"display")||e&&!f(n,e));)n=n.previousElementSibling;return n||null}function j(t,e){var n=0;if(!t||!t.parentNode)return-1;for(;t=t.previousElementSibling;)"TEMPLATE"===t.nodeName.toUpperCase()||t===jt.clone||e&&!f(t,e)||n++;return n}function E(t){var e=0,n=0,o=O();if(t)do{var i=b(t),r=i.a,i=i.d}while(e+=t.scrollLeft*r,n+=t.scrollTop*i,t!==o&&(t=t.parentNode));return[e,n]}function M(t,e){if(!t||!t.getBoundingClientRect)return O();var n=t,o=!1;do{if(n.clientWidth<n.scrollWidth||n.clientHeight<n.scrollHeight){var i=R(n);if(n.clientWidth<n.scrollWidth&&("auto"==i.overflowX||"scroll"==i.overflowX)||n.clientHeight<n.scrollHeight&&("auto"==i.overflowY||"scroll"==i.overflowY)){if(!n.getBoundingClientRect||n===document.body)return O();if(o||e)return n;o=!0}}}while(n=n.parentNode);return O()}function S(t,e){return Math.round(t.top)===Math.round(e.top)&&Math.round(t.left)===Math.round(e.left)&&Math.round(t.height)===Math.round(e.height)&&Math.round(t.width)===Math.round(e.width)}function _(e,n){return function(){var t;m||(1===(t=arguments).length?e.call(this,t[0]):e.apply(this,t),m=setTimeout(function(){m=void 0},n))}}function H(t,e,n){t.scrollLeft+=e,t.scrollTop+=n}function C(t){var e=window.Polymer,n=window.jQuery||window.Zepto;return e&&e.dom?e.dom(t).cloneNode(!0):n?n(t).clone(!0)[0]:t.cloneNode(!0)}function T(t,e){R(t,"position","absolute"),R(t,"top",e.top),R(t,"left",e.left),R(t,"width",e.width),R(t,"height",e.height)}function x(t){R(t,"position",""),R(t,"top",""),R(t,"left",""),R(t,"width",""),R(t,"height","")}function L(n,o,i){var r={};return Array.from(n.children).forEach(function(t){var e;P(t,o.draggable,n,!1)&&!t.animated&&t!==i&&(e=X(t),r.left=Math.min(null!==(t=r.left)&&void 0!==t?t:1/0,e.left),r.top=Math.min(null!==(t=r.top)&&void 0!==t?t:1/0,e.top),r.right=Math.max(null!==(t=r.right)&&void 0!==t?t:-1/0,e.right),r.bottom=Math.max(null!==(t=r.bottom)&&void 0!==t?t:-1/0,e.bottom))}),r.width=r.right-r.left,r.height=r.bottom-r.top,r.x=r.left,r.y=r.top,r}var K="Sortable"+(new Date).getTime();function A(){var e,o=[];return{captureAnimationState:function(){o=[],this.options.animation&&[].slice.call(this.el.children).forEach(function(t){var e,n;"none"!==R(t,"display")&&t!==jt.ghost&&(o.push({target:t,rect:X(t)}),e=I({},o[o.length-1].rect),!t.thisAnimationDuration||(n=b(t,!0))&&(e.top-=n.f,e.left-=n.e),t.fromRect=e)})},addAnimationState:function(t){o.push(t)},removeAnimationState:function(t){o.splice(function(t,e){for(var n in t)if(t.hasOwnProperty(n))for(var o in e)if(e.hasOwnProperty(o)&&e[o]===t[n][o])return Number(n);return-1}(o,{target:t}),1)},animateAll:function(t){var c=this;if(!this.options.animation)return clearTimeout(e),void("function"==typeof t&&t());var u=!1,d=0;o.forEach(function(t){var e=0,n=t.target,o=n.fromRect,i=X(n),r=n.prevFromRect,a=n.prevToRect,l=t.rect,s=b(n,!0);s&&(i.top-=s.f,i.left-=s.e),n.toRect=i,n.thisAnimationDuration&&S(r,i)&&!S(o,i)&&(l.top-i.top)/(l.left-i.left)==(o.top-i.top)/(o.left-i.left)&&(t=l,s=r,r=a,a=c.options,e=Math.sqrt(Math.pow(s.top-t.top,2)+Math.pow(s.left-t.left,2))/Math.sqrt(Math.pow(s.top-r.top,2)+Math.pow(s.left-r.left,2))*a.animation),S(i,o)||(n.prevFromRect=o,n.prevToRect=i,e=e||c.options.animation,c.animate(n,l,i,e)),e&&(u=!0,d=Math.max(d,e),clearTimeout(n.animationResetTimer),n.animationResetTimer=setTimeout(function(){n.animationTime=0,n.prevFromRect=null,n.fromRect=null,n.prevToRect=null,n.thisAnimationDuration=null},e),n.thisAnimationDuration=e)}),clearTimeout(e),u?e=setTimeout(function(){"function"==typeof t&&t()},d):"function"==typeof t&&t(),o=[]},animate:function(t,e,n,o){var i,r;o&&(R(t,"transition",""),R(t,"transform",""),i=(r=b(this.el))&&r.a,r=r&&r.d,i=(e.left-n.left)/(i||1),r=(e.top-n.top)/(r||1),t.animatingX=!!i,t.animatingY=!!r,R(t,"transform","translate3d("+i+"px,"+r+"px,0)"),this.forRepaintDummy=t.offsetWidth,R(t,"transition","transform "+o+"ms"+(this.options.easing?" "+this.options.easing:"")),R(t,"transform","translate3d(0,0,0)"),"number"==typeof t.animated&&clearTimeout(t.animated),t.animated=setTimeout(function(){R(t,"transition",""),R(t,"transform",""),t.animated=!1,t.animatingX=!1,t.animatingY=!1},o))}}}var N=[],W={initializeByDefault:!0},z={mount:function(e){for(var t in W)!W.hasOwnProperty(t)||t in e||(e[t]=W[t]);N.forEach(function(t){if(t.pluginName===e.pluginName)throw"Sortable: Cannot mount plugin ".concat(e.pluginName," more than once")}),N.push(e)},pluginEvent:function(e,n,o){var t=this;this.eventCanceled=!1,o.cancel=function(){t.eventCanceled=!0};var i=e+"Global";N.forEach(function(t){n[t.pluginName]&&(n[t.pluginName][i]&&n[t.pluginName][i](I({sortable:n},o)),n.options[t.pluginName]&&n[t.pluginName][e]&&n[t.pluginName][e](I({sortable:n},o)))})},initializePlugins:function(n,o,i,t){for(var e in N.forEach(function(t){var e=t.pluginName;(n.options[e]||t.initializeByDefault)&&((t=new t(n,o,n.options)).sortable=n,t.options=n.options,n[e]=t,a(i,t.defaults))}),n.options){var r;n.options.hasOwnProperty(e)&&(void 0!==(r=this.modifyOption(n,e,n.options[e]))&&(n.options[e]=r))}},getEventProperties:function(e,n){var o={};return N.forEach(function(t){"function"==typeof t.eventProperties&&a(o,t.eventProperties.call(n[t.pluginName],e))}),o},modifyOption:function(e,n,o){var i;return N.forEach(function(t){e[t.pluginName]&&t.optionListeners&&"function"==typeof t.optionListeners[n]&&(i=t.optionListeners[n].call(e[t.pluginName],o))}),i}};function G(t){var e=t.sortable,n=t.rootEl,o=t.name,i=t.targetEl,r=t.cloneEl,a=t.toEl,l=t.fromEl,s=t.oldIndex,c=t.newIndex,u=t.oldDraggableIndex,d=t.newDraggableIndex,h=t.originalEvent,p=t.putSortable,f=t.extraEventProperties;if(e=e||n&&n[K]){var g,m=e.options,t="on"+o.charAt(0).toUpperCase()+o.substr(1);!window.CustomEvent||y||w?(g=document.createEvent("Event")).initEvent(o,!0,!0):g=new CustomEvent(o,{bubbles:!0,cancelable:!0}),g.to=a||n,g.from=l||n,g.item=i||n,g.clone=r,g.oldIndex=s,g.newIndex=c,g.oldDraggableIndex=u,g.newDraggableIndex=d,g.originalEvent=h,g.pullMode=p?p.lastPutMode:void 0;var v,b=I(I({},f),z.getEventProperties(o,e));for(v in b)g[v]=b[v];n&&n.dispatchEvent(g),m[t]&&m[t].call(e,g)}}function U(t,e){var n=(o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:{}).evt,o=i(o,q);z.pluginEvent.bind(jt)(t,e,I({dragEl:Z,parentEl:$,ghostEl:Q,rootEl:J,nextEl:tt,lastDownEl:et,cloneEl:nt,cloneHidden:ot,dragStarted:mt,putSortable:ct,activeSortable:jt.active,originalEvent:n,oldIndex:it,oldDraggableIndex:at,newIndex:rt,newDraggableIndex:lt,hideGhostForTarget:Xt,unhideGhostForTarget:Yt,cloneNowHidden:function(){ot=!0},cloneNowShown:function(){ot=!1},dispatchSortableEvent:function(t){V({sortable:e,name:t,originalEvent:n})}},o))}var q=["evt"];function V(t){G(I({putSortable:ct,cloneEl:nt,targetEl:Z,rootEl:J,oldIndex:it,oldDraggableIndex:at,newIndex:rt,newDraggableIndex:lt},t))}var Z,$,Q,J,tt,et,nt,ot,it,rt,at,lt,st,ct,ut,dt,ht,pt,ft,gt,mt,vt,bt,yt,wt,Dt=!1,Et=!1,St=[],_t=!1,Ct=!1,Tt=[],xt=!1,Ot=[],Mt="undefined"!=typeof document,At=c,Nt=w||y?"cssFloat":"float",It=Mt&&!n&&!c&&"draggable"in document.createElement("div"),Pt=function(){if(Mt){if(y)return!1;var t=document.createElement("x");return t.style.cssText="pointer-events:auto","auto"===t.style.pointerEvents}}(),kt=function(t,e){var n=R(t),o=parseInt(n.width)-parseInt(n.paddingLeft)-parseInt(n.paddingRight)-parseInt(n.borderLeftWidth)-parseInt(n.borderRightWidth),i=B(t,0,e),r=B(t,1,e),a=i&&R(i),l=r&&R(r),s=a&&parseInt(a.marginLeft)+parseInt(a.marginRight)+X(i).width,t=l&&parseInt(l.marginLeft)+parseInt(l.marginRight)+X(r).width;if("flex"===n.display)return"column"===n.flexDirection||"column-reverse"===n.flexDirection?"vertical":"horizontal";if("grid"===n.display)return n.gridTemplateColumns.split(" ").length<=1?"vertical":"horizontal";if(i&&a.float&&"none"!==a.float){e="left"===a.float?"left":"right";return!r||"both"!==l.clear&&l.clear!==e?"horizontal":"vertical"}return i&&("block"===a.display||"flex"===a.display||"table"===a.display||"grid"===a.display||o<=s&&"none"===n[Nt]||r&&"none"===n[Nt]&&o<s+t)?"vertical":"horizontal"},Rt=function(t){function l(r,a){return function(t,e,n,o){var i=t.options.group.name&&e.options.group.name&&t.options.group.name===e.options.group.name;if(null==r&&(a||i))return!0;if(null==r||!1===r)return!1;if(a&&"clone"===r)return r;if("function"==typeof r)return l(r(t,e,n,o),a)(t,e,n,o);e=(a?t:e).options.group.name;return!0===r||"string"==typeof r&&r===e||r.join&&-1<r.indexOf(e)}}var e={},n=t.group;n&&"object"==o(n)||(n={name:n}),e.name=n.name,e.checkPull=l(n.pull,!0),e.checkPut=l(n.put),e.revertClone=n.revertClone,t.group=e},Xt=function(){!Pt&&Q&&R(Q,"display","none")},Yt=function(){!Pt&&Q&&R(Q,"display","")};Mt&&!n&&document.addEventListener("click",function(t){if(Et)return t.preventDefault(),t.stopPropagation&&t.stopPropagation(),t.stopImmediatePropagation&&t.stopImmediatePropagation(),Et=!1},!0);function Bt(t){if(Z){t=t.touches?t.touches[0]:t;var e=(i=t.clientX,r=t.clientY,St.some(function(t){var e=t[K].options.emptyInsertThreshold;if(e&&!F(t)){var n=X(t),o=i>=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))<n)return;this._onDragStart(t,!0)}Q&&(r?(r.e+=a-(ht||0),r.f+=l-(pt||0)):r={a:1,b:0,c:0,d:1,e:a,f:l},r="matrix(".concat(r.a,",").concat(r.b,",").concat(r.c,",").concat(r.d,",").concat(r.e,",").concat(r.f,")"),R(Q,"webkitTransform",r),R(Q,"mozTransform",r),R(Q,"msTransform",r),R(Q,"transform",r),ht=a,pt=l,dt=i),t.cancelable&&t.preventDefault()}},_appendGhost:function(){if(!Q){var t=this.options.fallbackOnBody?document.body:J,e=X(Z,!0,At,!0,t),n=this.options;if(At){for(wt=t;"static"===R(wt,"position")&&"none"===R(wt,"transform")&&wt!==document;)wt=wt.parentNode;wt!==document.body&&wt!==document.documentElement?(wt===document&&(wt=O()),e.top+=wt.scrollTop,e.left+=wt.scrollLeft):wt=O(),Tt=E(wt)}k(Q=Z.cloneNode(!0),n.ghostClass,!1),k(Q,n.fallbackClass,!0),k(Q,n.dragClass,!0),R(Q,"transition",""),R(Q,"transform",""),R(Q,"box-sizing","border-box"),R(Q,"margin",0),R(Q,"top",e.top),R(Q,"left",e.left),R(Q,"width",e.width),R(Q,"height",e.height),R(Q,"opacity","0.8"),R(Q,"position",At?"absolute":"fixed"),R(Q,"zIndex","100000"),R(Q,"pointerEvents","none"),jt.ghost=Q,t.appendChild(Q),R(Q,"transform-origin",ft/parseInt(Q.style.width)*100+"% "+gt/parseInt(Q.style.height)*100+"%")}},_onDragStart:function(t,e){var n=this,o=t.dataTransfer,i=n.options;U("dragStart",this,{evt:t}),jt.eventCanceled?this._onDrop():(U("setupClone",this),jt.eventCanceled||((nt=C(Z)).removeAttribute("id"),nt.draggable=!1,nt.style["will-change"]="",this._hideClone(),k(nt,this.options.chosenClass,!1),jt.clone=nt),n.cloneId=Wt(function(){U("clone",n),jt.eventCanceled||(n.options.removeCloneOnHide||J.insertBefore(nt,Z),n._hideClone(),V({sortable:n,name:"clone"}))}),e||k(Z,i.dragClass,!0),e?(Et=!0,n._loopId=setInterval(n._emulateDragOver,50)):(p(document,"mouseup",n._onDrop),p(document,"touchend",n._onDrop),p(document,"touchcancel",n._onDrop),o&&(o.effectAllowed="move",i.setData&&i.setData.call(n,o,Z)),h(document,"drop",n),R(Z,"transform","translateZ(0)")),Dt=!0,n._dragStartId=Wt(n._dragStarted.bind(n,e,t)),h(document,"selectstart",n),mt=!0,window.getSelection().removeAllRanges(),u&&R(document.body,"user-select","none"))},_onDragOver:function(n){var o,i,r,t,e,a=this.el,l=n.target,s=this.options,c=s.group,u=jt.active,d=st===c,h=s.sort,p=ct||u,f=this,g=!1;if(!xt){if(void 0!==n.preventDefault&&n.cancelable&&n.preventDefault(),l=P(l,s.draggable,a,!0),O("dragOver"),jt.eventCanceled)return g;if(Z.contains(n.target)||l.animated&&l.animatingX&&l.animatingY||f._ignoreWhileAnimating===l)return A(!1);if(Et=!1,u&&!s.disabled&&(d?h||(i=$!==J):ct===this||(this.lastPutMode=st.checkPull(this,u,Z,n))&&c.checkPut(this,u,Z,n))){if(r="vertical"===this._getDirection(n,l),o=X(Z),O("dragOverValid"),jt.eventCanceled)return g;if(i)return $=J,M(),this._hideClone(),O("revert"),jt.eventCanceled||(tt?J.insertBefore(Z,tt):J.appendChild(Z)),A(!0);var m=F(a,s.draggable);if(m&&(S=n,c=r,x=X(F((E=this).el,E.options.draggable)),E=L(E.el,E.options,Q),!(c?S.clientX>E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY<C.top&&t.clientX<C.right:t.clientY<_.top-10||t.clientY<C.bottom&&t.clientX<C.left)){var v=B(a,0,s,!0);if(v===Z)return A(!1);if(D=X(l=v),!1!==Ht(J,a,Z,o,l,D,n,!1))return M(),a.insertBefore(Z,v),$=a,N(),A(!0)}else if(l.parentNode===a){var b,y,w,D=X(l),E=Z.parentNode!==a,S=(S=Z.animated&&Z.toRect||o,x=l.animated&&l.toRect||D,_=(e=r)?S.left:S.top,t=e?S.right:S.bottom,C=e?S.width:S.height,v=e?x.left:x.top,S=e?x.right:x.bottom,x=e?x.width:x.height,!(_===v||t===S||_+C/2===v+x/2)),_=r?"top":"left",C=Y(l,"top","top")||Y(Z,"top","top"),v=C?C.scrollTop:void 0;if(vt!==l&&(y=D[_],_t=!1,Ct=!S&&s.invertSwap||E),0!==(b=function(t,e,n,o,i,r,a,l){var s=o?t.clientY:t.clientX,c=o?n.height:n.width,t=o?n.top:n.left,o=o?n.bottom:n.right,n=!1;if(!a)if(l&&yt<c*i){if(_t=!_t&&(1===bt?t+c*r/2<s:s<o-c*r/2)?!0:_t)n=!0;else if(1===bt?s<t+yt:o-yt<s)return-bt}else if(t+c*(1-i)/2<s&&s<o-c*(1-i)/2)return function(t){return j(Z)<j(t)?1:-1}(e);if((n=n||a)&&(s<t+c*r/2||o-c*r/2<s))return t+c/2<s?1:-1;return 0}(n,l,D,r,S?1:s.swapThreshold,null==s.invertedSwapThreshold?s.swapThreshold:s.invertedSwapThreshold,Ct,vt===l)))for(var T=j(Z);(w=$.children[T-=b])&&("none"===R(w,"display")||w===Q););if(0===b||w===l)return A(!1);bt=b;var x=(vt=l).nextElementSibling,E=!1,S=Ht(J,a,Z,o,l,D,n,E=1===b);if(!1!==S)return 1!==S&&-1!==S||(E=1===S),xt=!0,setTimeout(Kt,30),M(),E&&!x?a.appendChild(Z):l.parentNode.insertBefore(Z,E?x:l),C&&H(C,0,v-C.scrollTop),$=Z.parentNode,void 0===y||Ct||(yt=Math.abs(y-X(l)[_])),N(),A(!0)}}else{if(m===Z)return A(!1);if((l=m&&a===n.target?m:l)&&(D=X(l)),!1!==Ht(J,a,Z,o,l,D,n,!!l))return M(),m&&m.nextSibling?a.insertBefore(Z,m.nextSibling):a.appendChild(Z),$=a,N(),A(!0)}if(a.contains(Z))return A(!1)}return!1}function O(t,e){U(t,f,I({evt:n,isOwner:d,axis:r?"vertical":"horizontal",revert:i,dragRect:o,targetRect:D,canSort:h,fromSortable:p,target:l,completed:A,onMove:function(t,e){return Ht(J,a,Z,o,t,X(t),n,e)},changed:N},e))}function M(){O("dragOverAnimationCapture"),f.captureAnimationState(),f!==p&&p.captureAnimationState()}function A(t){return O("dragOverCompleted",{insertion:t}),t&&(d?u._hideClone():u._showClone(f),f!==p&&(k(Z,(ct||u).options.ghostClass,!1),k(Z,s.ghostClass,!0)),ct!==f&&f!==jt.active?ct=f:f===jt.active&&ct&&(ct=null),p===f&&(f._ignoreWhileAnimating=l),f.animateAll(function(){O("dragOverAnimationComplete"),f._ignoreWhileAnimating=null}),f!==p&&(p.animateAll(),p._ignoreWhileAnimating=null)),(l===Z&&!Z.animated||l===a&&!l.animated)&&(vt=null),s.dragoverBubble||n.rootEl||l===document||(Z.parentNode[K]._isOutsideThisEl(n.target),t||Bt(n)),!s.dragoverBubble&&n.stopPropagation&&n.stopPropagation(),g=!0}function N(){rt=j(Z),lt=j(Z,s.draggable),V({sortable:f,name:"change",toEl:a,newIndex:rt,newDraggableIndex:lt,originalEvent:n})}},_ignoreWhileAnimating:null,_offMoveEvents:function(){p(document,"mousemove",this._onTouchMove),p(document,"touchmove",this._onTouchMove),p(document,"pointermove",this._onTouchMove),p(document,"dragover",Bt),p(document,"mousemove",Bt),p(document,"touchmove",Bt)},_offUpEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._onDrop),p(t,"touchend",this._onDrop),p(t,"pointerup",this._onDrop),p(t,"pointercancel",this._onDrop),p(t,"touchcancel",this._onDrop),p(document,"selectstart",this)},_onDrop:function(t){var e=this.el,n=this.options;rt=j(Z),lt=j(Z,n.draggable),U("drop",this,{evt:t}),$=Z&&Z.parentNode,rt=j(Z),lt=j(Z,n.draggable),jt.eventCanceled||(_t=Ct=Dt=!1,clearInterval(this._loopId),clearTimeout(this._dragStartTimer),zt(this.cloneId),zt(this._dragStartId),this.nativeDraggable&&(p(document,"drop",this),p(e,"dragstart",this._onDragStart)),this._offMoveEvents(),this._offUpEvents(),u&&R(document.body,"user-select",""),R(Z,"transform",""),t&&(mt&&(t.cancelable&&t.preventDefault(),n.dropBubble||t.stopPropagation()),Q&&Q.parentNode&&Q.parentNode.removeChild(Q),(J===$||ct&&"clone"!==ct.lastPutMode)&&nt&&nt.parentNode&&nt.parentNode.removeChild(nt),Z&&(this.nativeDraggable&&p(Z,"dragend",this),Lt(Z),Z.style["will-change"]="",mt&&!Dt&&k(Z,(ct||this).options.ghostClass,!1),k(Z,this.options.chosenClass,!1),V({sortable:this,name:"unchoose",toEl:$,newIndex:null,newDraggableIndex:null,originalEvent:t}),J!==$?(0<=rt&&(V({rootEl:$,name:"add",toEl:$,fromEl:J,originalEvent:t}),V({sortable:this,name:"remove",toEl:$,originalEvent:t}),V({rootEl:$,name:"sort",toEl:$,fromEl:J,originalEvent:t}),V({sortable:this,name:"sort",toEl:$,originalEvent:t})),ct&&ct.save()):rt!==it&&0<=rt&&(V({sortable:this,name:"update",toEl:$,originalEvent:t}),V({sortable:this,name:"sort",toEl:$,originalEvent:t})),jt.active&&(null!=rt&&-1!==rt||(rt=it,lt=at),V({sortable:this,name:"end",toEl:$,originalEvent:t}),this.save())))),this._nulling()},_nulling:function(){U("nulling",this),J=Z=$=Q=tt=nt=et=ot=ut=dt=mt=rt=lt=it=at=vt=bt=ct=st=jt.dragged=jt.ghost=jt.clone=jt.active=null,Ot.forEach(function(t){t.checked=!0}),Ot.length=ht=pt=0},handleEvent:function(t){switch(t.type){case"drop":case"dragend":this._onDrop(t);break;case"dragenter":case"dragover":Z&&(this._onDragOver(t),function(t){t.dataTransfer&&(t.dataTransfer.dropEffect="move");t.cancelable&&t.preventDefault()}(t));break;case"selectstart":t.preventDefault()}},toArray:function(){for(var t,e=[],n=this.el.children,o=0,i=n.length,r=this.options;o<i;o++)P(t=n[o],r.draggable,this.el,!1)&&e.push(t.getAttribute(r.dataIdAttr)||function(t){var e=t.tagName+t.className+t.src+t.href+t.textContent,n=e.length,o=0;for(;n--;)o+=e.charCodeAt(n);return o.toString(36)}(t));return e},sort:function(t,e){var n={},o=this.el;this.toArray().forEach(function(t,e){e=o.children[e];P(e,this.options.draggable,o,!1)&&(n[t]=e)},this),e&&this.captureAnimationState(),t.forEach(function(t){n[t]&&(o.removeChild(n[t]),o.appendChild(n[t]))}),e&&this.animateAll()},save:function(){var t=this.options.store;t&&t.set&&t.set(this)},closest:function(t,e){return P(t,e||this.options.draggable,this.el,!1)},option:function(t,e){var n=this.options;if(void 0===e)return n[t];var o=z.modifyOption(this,t,e);n[t]=void 0!==o?o:e,"group"===t&&Rt(n)},destroy:function(){U("destroy",this);var t=this.el;t[K]=null,p(t,"mousedown",this._onTapStart),p(t,"touchstart",this._onTapStart),p(t,"pointerdown",this._onTapStart),this.nativeDraggable&&(p(t,"dragover",this),p(t,"dragenter",this)),Array.prototype.forEach.call(t.querySelectorAll("[draggable]"),function(t){t.removeAttribute("draggable")}),this._onDrop(),this._disableDelayedDragEvents(),St.splice(St.indexOf(this.el),1),this.el=t=null},_hideClone:function(){ot||(U("hideClone",this),jt.eventCanceled||(R(nt,"display","none"),this.options.removeCloneOnHide&&nt.parentNode&&nt.parentNode.removeChild(nt),ot=!0))},_showClone:function(t){"clone"===t.lastPutMode?ot&&(U("showClone",this),jt.eventCanceled||(Z.parentNode!=J||this.options.group.revertClone?tt?J.insertBefore(nt,tt):J.appendChild(nt):J.insertBefore(nt,Z),this.options.group.revertClone&&this.animate(Z,nt),R(nt,"display",""),ot=!1)):this._hideClone()}},Mt&&h(document,"touchmove",function(t){(jt.active||Dt)&&t.cancelable&&t.preventDefault()}),jt.utils={on:h,off:p,css:R,find:D,is:function(t,e){return!!P(t,e,t,!1)},extend:function(t,e){if(t&&e)for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t},throttle:_,closest:P,toggleClass:k,clone:C,index:j,nextTick:Wt,cancelNextTick:zt,detectDirection:kt,getChild:B,expando:K},jt.get=function(t){return t[K]},jt.mount=function(){for(var t=arguments.length,e=new Array(t),n=0;n<t;n++)e[n]=arguments[n];(e=e[0].constructor===Array?e[0]:e).forEach(function(t){if(!t.prototype||!t.prototype.constructor)throw"Sortable: Mounted plugin must be a constructor function, not ".concat({}.toString.call(t));t.utils&&(jt.utils=I(I({},jt.utils),t.utils)),z.mount(t)})},jt.create=function(t,e){return new jt(t,e)};var Gt,Ut,qt,Vt,Zt,$t,Qt=[],Jt=!(jt.version="1.15.6");function te(){Qt.forEach(function(t){clearInterval(t.pid)}),Qt=[]}function ee(){clearInterval($t)}var ne,oe=_(function(n,t,e,o){if(t.scroll){var i,r=(n.touches?n.touches[0]:n).clientX,a=(n.touches?n.touches[0]:n).clientY,l=t.scrollSensitivity,s=t.scrollSpeed,c=O(),u=!1;Ut!==e&&(Ut=e,te(),Gt=t.scroll,i=t.scrollFn,!0===Gt&&(Gt=M(e,!0)));var d=0,h=Gt;do{var p=h,f=X(p),g=f.top,m=f.bottom,v=f.left,b=f.right,y=f.width,w=f.height,D=void 0,E=void 0,S=p.scrollWidth,_=p.scrollHeight,C=R(p),T=p.scrollLeft,f=p.scrollTop,E=p===c?(D=y<S&&("auto"===C.overflowX||"scroll"===C.overflowX||"visible"===C.overflowX),w<_&&("auto"===C.overflowY||"scroll"===C.overflowY||"visible"===C.overflowY)):(D=y<S&&("auto"===C.overflowX||"scroll"===C.overflowX),w<_&&("auto"===C.overflowY||"scroll"===C.overflowY)),T=D&&(Math.abs(b-r)<=l&&T+y<S)-(Math.abs(v-r)<=l&&!!T),f=E&&(Math.abs(m-a)<=l&&f+w<_)-(Math.abs(g-a)<=l&&!!f);if(!Qt[d])for(var x=0;x<=d;x++)Qt[x]||(Qt[x]={});Qt[d].vx==T&&Qt[d].vy==f&&Qt[d].el===p||(Qt[d].el=p,Qt[d].vx=T,Qt[d].vy=f,clearInterval(Qt[d].pid),0==T&&0==f||(u=!0,Qt[d].pid=setInterval(function(){o&&0===this.layer&&jt.active._onTouchMove(Zt);var t=Qt[this.layer].vy?Qt[this.layer].vy*s:0,e=Qt[this.layer].vx?Qt[this.layer].vx*s:0;"function"==typeof i&&"continue"!==i.call(jt.dragged.parentNode[K],e,t,n,Zt,Qt[this.layer].el)||H(Qt[this.layer].el,e,t)}.bind({layer:d}),24))),d++}while(t.bubbleScroll&&h!==c&&(h=M(h,!1)));Jt=u}},30),n=function(t){var e=t.originalEvent,n=t.putSortable,o=t.dragEl,i=t.activeSortable,r=t.dispatchSortableEvent,a=t.hideGhostForTarget,t=t.unhideGhostForTarget;e&&(i=n||i,a(),e=e.changedTouches&&e.changedTouches.length?e.changedTouches[0]:e,e=document.elementFromPoint(e.clientX,e.clientY),t(),i&&!i.el.contains(e)&&(r("spill"),this.onSpill({dragEl:o,putSortable:n})))};function ie(){}function re(){}ie.prototype={startIndex:null,dragStart:function(t){t=t.oldDraggableIndex;this.startIndex=t},onSpill:function(t){var e=t.dragEl,n=t.putSortable;this.sortable.captureAnimationState(),n&&n.captureAnimationState();t=B(this.sortable.el,this.startIndex,this.options);t?this.sortable.el.insertBefore(e,t):this.sortable.el.appendChild(e),this.sortable.animateAll(),n&&n.animateAll()},drop:n},a(ie,{pluginName:"revertOnSpill"}),re.prototype={onSpill:function(t){var e=t.dragEl,t=t.putSortable||this.sortable;t.captureAnimationState(),e.parentNode&&e.parentNode.removeChild(e),t.animateAll()},drop:n},a(re,{pluginName:"removeOnSpill"});var ae,le,se,ce,ue,de=[],he=[],pe=!1,fe=!1,ge=!1;function me(n,o){he.forEach(function(t,e){e=o.children[t.sortableIndex+(n?Number(e):0)];e?o.insertBefore(t,e):o.appendChild(t)})}function ve(){de.forEach(function(t){t!==se&&t.parentNode&&t.parentNode.removeChild(t)})}return jt.mount(new function(){function t(){for(var t in this.defaults={scroll:!0,forceAutoScrollFallback:!1,scrollSensitivity:30,scrollSpeed:10,bubbleScroll:!0},this)"_"===t.charAt(0)&&"function"==typeof this[t]&&(this[t]=this[t].bind(this))}return t.prototype={dragStarted:function(t){t=t.originalEvent;this.sortable.nativeDraggable?h(document,"dragover",this._handleAutoScroll):this.options.supportPointer?h(document,"pointermove",this._handleFallbackAutoScroll):t.touches?h(document,"touchmove",this._handleFallbackAutoScroll):h(document,"mousemove",this._handleFallbackAutoScroll)},dragOverCompleted:function(t){t=t.originalEvent;this.options.dragOverBubble||t.rootEl||this._handleAutoScroll(t)},drop:function(){this.sortable.nativeDraggable?p(document,"dragover",this._handleAutoScroll):(p(document,"pointermove",this._handleFallbackAutoScroll),p(document,"touchmove",this._handleFallbackAutoScroll),p(document,"mousemove",this._handleFallbackAutoScroll)),ee(),te(),clearTimeout(m),m=void 0},nulling:function(){Zt=Ut=Gt=Jt=$t=qt=Vt=null,Qt.length=0},_handleFallbackAutoScroll:function(t){this._handleAutoScroll(t,!0)},_handleAutoScroll:function(e,n){var o,i=this,r=(e.touches?e.touches[0]:e).clientX,a=(e.touches?e.touches[0]:e).clientY,t=document.elementFromPoint(r,a);Zt=e,n||this.options.forceAutoScrollFallback||w||y||u?(oe(e,this.options,t,n),o=M(t,!0),!Jt||$t&&r===qt&&a===Vt||($t&&ee(),$t=setInterval(function(){var t=M(document.elementFromPoint(r,a),!0);t!==o&&(o=t,te()),oe(e,i.options,t,n)},10),qt=r,Vt=a)):this.options.bubbleScroll&&M(t,!0)!==O()?oe(e,this.options,M(t,!1),!1):te()}},a(t,{pluginName:"scroll",initializeByDefault:!0})}),jt.mount(re,ie),jt.mount(new function(){function t(){this.defaults={swapClass:"sortable-swap-highlight"}}return t.prototype={dragStart:function(t){t=t.dragEl;ne=t},dragOverValid:function(t){var e=t.completed,n=t.target,o=t.onMove,i=t.activeSortable,r=t.changed,a=t.cancel;i.options.swap&&(t=this.sortable.el,i=this.options,n&&n!==t&&(t=ne,ne=!1!==o(n)?(k(n,i.swapClass,!0),n):null,t&&t!==ne&&k(t,i.swapClass,!1)),r(),e(!0),a())},drop:function(t){var e,n,o=t.activeSortable,i=t.putSortable,r=t.dragEl,a=i||this.sortable,l=this.options;ne&&k(ne,l.swapClass,!1),ne&&(l.swap||i&&i.options.swap)&&r!==ne&&(a.captureAnimationState(),a!==o&&o.captureAnimationState(),n=ne,t=(e=r).parentNode,l=n.parentNode,t&&l&&!t.isEqualNode(n)&&!l.isEqualNode(e)&&(i=j(e),r=j(n),t.isEqualNode(l)&&i<r&&r++,t.insertBefore(n,t.children[i]),l.insertBefore(e,l.children[r])),a.animateAll(),a!==o&&o.animateAll())},nulling:function(){ne=null}},a(t,{pluginName:"swap",eventProperties:function(){return{swapItem:ne}}})}),jt.mount(new function(){function t(o){for(var t in this)"_"===t.charAt(0)&&"function"==typeof this[t]&&(this[t]=this[t].bind(this));o.options.avoidImplicitDeselect||(o.options.supportPointer?h(document,"pointerup",this._deselectMultiDrag):(h(document,"mouseup",this._deselectMultiDrag),h(document,"touchend",this._deselectMultiDrag))),h(document,"keydown",this._checkKeyDown),h(document,"keyup",this._checkKeyUp),this.defaults={selectedClass:"sortable-selected",multiDragKey:null,avoidImplicitDeselect:!1,setData:function(t,e){var n="";de.length&&le===o?de.forEach(function(t,e){n+=(e?", ":"")+t.textContent}):n=e.textContent,t.setData("Text",n)}}}return t.prototype={multiDragKeyDown:!1,isMultiDrag:!1,delayStartGlobal:function(t){t=t.dragEl;se=t},delayEnded:function(){this.isMultiDrag=~de.indexOf(se)},setupClone:function(t){var e=t.sortable,t=t.cancel;if(this.isMultiDrag){for(var n=0;n<de.length;n++)he.push(C(de[n])),he[n].sortableIndex=de[n].sortableIndex,he[n].draggable=!1,he[n].style["will-change"]="",k(he[n],this.options.selectedClass,!1),de[n]===se&&k(he[n],this.options.chosenClass,!1);e._hideClone(),t()}},clone:function(t){var e=t.sortable,n=t.rootEl,o=t.dispatchSortableEvent,t=t.cancel;this.isMultiDrag&&(this.options.removeCloneOnHide||de.length&&le===e&&(me(!0,n),o("clone"),t()))},showClone:function(t){var e=t.cloneNowShown,n=t.rootEl,t=t.cancel;this.isMultiDrag&&(me(!1,n),he.forEach(function(t){R(t,"display","")}),e(),ue=!1,t())},hideClone:function(t){var e=this,n=(t.sortable,t.cloneNowHidden),t=t.cancel;this.isMultiDrag&&(he.forEach(function(t){R(t,"display","none"),e.options.removeCloneOnHide&&t.parentNode&&t.parentNode.removeChild(t)}),n(),ue=!0,t())},dragStartGlobal:function(t){t.sortable;!this.isMultiDrag&&le&&le.multiDrag._deselectMultiDrag(),de.forEach(function(t){t.sortableIndex=j(t)}),de=de.sort(function(t,e){return t.sortableIndex-e.sortableIndex}),ge=!0},dragStarted:function(t){var e,n=this,t=t.sortable;this.isMultiDrag&&(this.options.sort&&(t.captureAnimationState(),this.options.animation&&(de.forEach(function(t){t!==se&&R(t,"position","absolute")}),e=X(se,!1,!0,!0),de.forEach(function(t){t!==se&&T(t,e)}),pe=fe=!0)),t.animateAll(function(){pe=fe=!1,n.options.animation&&de.forEach(function(t){x(t)}),n.options.sort&&ve()}))},dragOver:function(t){var e=t.target,n=t.completed,t=t.cancel;fe&&~de.indexOf(e)&&(n(!1),t())},revert:function(t){var n,o,e=t.fromSortable,i=t.rootEl,r=t.sortable,a=t.dragRect;1<de.length&&(de.forEach(function(t){r.addAnimationState({target:t,rect:fe?X(t):a}),x(t),t.fromRect=a,e.removeAnimationState(t)}),fe=!1,n=!this.options.removeCloneOnHide,o=i,de.forEach(function(t,e){e=o.children[t.sortableIndex+(n?Number(e):0)];e?o.insertBefore(t,e):o.appendChild(t)}))},dragOverCompleted:function(t){var e,n=t.sortable,o=t.isOwner,i=t.insertion,r=t.activeSortable,a=t.parentEl,l=t.putSortable,t=this.options;i&&(o&&r._hideClone(),pe=!1,t.animation&&1<de.length&&(fe||!o&&!r.options.sort&&!l)&&(e=X(se,!1,!0,!0),de.forEach(function(t){t!==se&&(T(t,e),a.appendChild(t))}),fe=!0),o||(fe||ve(),1<de.length?(o=ue,r._showClone(n),r.options.animation&&!ue&&o&&he.forEach(function(t){r.addAnimationState({target:t,rect:ce}),t.fromRect=ce,t.thisAnimationDuration=null})):r._showClone(n)))},dragOverAnimationCapture:function(t){var e=t.dragRect,n=t.isOwner,t=t.activeSortable;de.forEach(function(t){t.thisAnimationDuration=null}),t.options.animation&&!n&&t.multiDrag.isMultiDrag&&(ce=a({},e),e=b(se,!0),ce.top-=e.f,ce.left-=e.e)},dragOverAnimationComplete:function(){fe&&(fe=!1,ve())},drop:function(t){var o,i,r,a,n,e,l,s=t.originalEvent,c=t.rootEl,u=t.parentEl,d=t.sortable,h=t.dispatchSortableEvent,p=t.oldIndex,t=t.putSortable,f=t||this.sortable;s&&(o=this.options,i=u.children,ge||(o.multiDragKey&&!this.multiDragKeyDown&&this._deselectMultiDrag(),k(se,o.selectedClass,!~de.indexOf(se)),~de.indexOf(se)?(de.splice(de.indexOf(se),1),ae=null,G({sortable:d,rootEl:c,name:"deselect",targetEl:se,originalEvent:s})):(de.push(se),G({sortable:d,rootEl:c,name:"select",targetEl:se,originalEvent:s}),s.shiftKey&&ae&&d.el.contains(ae)?(r=j(ae),a=j(se),~r&&~a&&r!==a&&function(){for(var e,t=r<a?(e=r,a):(e=a,r+1),n=o.filter;e<t;e++)~de.indexOf(i[e])||P(i[e],o.draggable,u,!1)&&(n&&("function"==typeof n?n.call(d,s,i[e],d):n.split(",").some(function(t){return P(i[e],t.trim(),u,!1)}))||(k(i[e],o.selectedClass,!0),de.push(i[e]),G({sortable:d,rootEl:c,name:"select",targetEl:i[e],originalEvent:s})))}()):ae=se,le=f)),ge&&this.isMultiDrag&&(fe=!1,(u[K].options.sort||u!==c)&&1<de.length&&(n=X(se),e=j(se,":not(."+this.options.selectedClass+")"),!pe&&o.animation&&(se.thisAnimationDuration=null),f.captureAnimationState(),pe||(o.animation&&(se.fromRect=n,de.forEach(function(t){var e;t.thisAnimationDuration=null,t!==se&&(e=fe?X(t):n,t.fromRect=e,f.addAnimationState({target:t,rect:e}))})),ve(),de.forEach(function(t){i[e]?u.insertBefore(t,i[e]):u.appendChild(t),e++}),p===j(se)&&(l=!1,de.forEach(function(t){t.sortableIndex!==j(t)&&(l=!0)}),l&&(h("update"),h("sort")))),de.forEach(function(t){x(t)}),f.animateAll()),le=f),(c===u||t&&"clone"!==t.lastPutMode)&&he.forEach(function(t){t.parentNode&&t.parentNode.removeChild(t)}))},nullingGlobal:function(){this.isMultiDrag=ge=!1,he.length=0},destroyGlobal:function(){this._deselectMultiDrag(),p(document,"pointerup",this._deselectMultiDrag),p(document,"mouseup",this._deselectMultiDrag),p(document,"touchend",this._deselectMultiDrag),p(document,"keydown",this._checkKeyDown),p(document,"keyup",this._checkKeyUp)},_deselectMultiDrag:function(t){if(!(void 0!==ge&&ge||le!==this.sortable||t&&P(t.target,this.options.draggable,this.sortable.el,!1)||t&&0!==t.button))for(;de.length;){var e=de[0];k(e,this.options.selectedClass,!1),de.shift(),G({sortable:this.sortable,rootEl:this.sortable.el,name:"deselect",targetEl:e,originalEvent:t})}},_checkKeyDown:function(t){t.key===this.options.multiDragKey&&(this.multiDragKeyDown=!0)},_checkKeyUp:function(t){t.key===this.options.multiDragKey&&(this.multiDragKeyDown=!1)}},a(t,{pluginName:"multiDrag",utils:{select:function(t){var e=t.parentNode[K];e&&e.options.multiDrag&&!~de.indexOf(t)&&(le&&le!==e&&(le.multiDrag._deselectMultiDrag(),le=e),k(t,e.options.selectedClass,!0),de.push(t))},deselect:function(t){var e=t.parentNode[K],n=de.indexOf(t);e&&e.options.multiDrag&&~n&&(k(t,e.options.selectedClass,!1),de.splice(n,1))}},eventProperties:function(){var n=this,o=[],i=[];return de.forEach(function(t){var e;o.push({multiDragElement:t,index:t.sortableIndex}),e=fe&&t!==se?-1:fe?j(t,":not(."+n.options.selectedClass+")"):j(t),i.push({multiDragElement:t,index:e})}),{items:r(de),clones:[].concat(he),oldIndicies:o,newIndicies:i}},optionListeners:{multiDragKey:function(t){return"ctrl"===(t=t.toLowerCase())?t="Control":1<t.length&&(t=t.charAt(0).toUpperCase()+t.substr(1)),t}}})}),jt});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Board
|
|
5
|
+
# Shared `reorder` action for board controllers. The three McRitchie Studio
|
|
6
|
+
# kanban controllers (tasks / news / content) each carried a BYTE-IDENTICAL
|
|
7
|
+
# `reorder` — guard the incoming id array, restamp the column with 100-gaps
|
|
8
|
+
# INSIDE `rescue_and_log` (so a failure lands in ErrorLog — backend write
|
|
9
|
+
# discipline), render `{ success: true }`, and rescue to a 422. This concern is
|
|
10
|
+
# that action, neutral: the including controller declares its model, id column,
|
|
11
|
+
# and the incoming param, and the ranking rule itself is delegated to the model's
|
|
12
|
+
# Studio::Board::Rankable#reposition! (one place owns the 100-gap math). This is
|
|
13
|
+
# THE designated shared write action, so it owns the ErrorLog logging here rather
|
|
14
|
+
# than leaving each host to re-add it.
|
|
15
|
+
#
|
|
16
|
+
# class TasksController < ApplicationController
|
|
17
|
+
# include Studio::Board::Reorderable
|
|
18
|
+
# board_reorderable model: Task, id_attr: :slug, param: :slugs
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # Route it however the app names the endpoint:
|
|
22
|
+
# post "tasks/reorder", to: "tasks#reorder"
|
|
23
|
+
#
|
|
24
|
+
# The POST body the studio/board factory sends is `{ <param>: [...ids],
|
|
25
|
+
# zone: "<zone-key>" }`; this action reads only `<param>` (the ordered id list)
|
|
26
|
+
# and ignores the advisory `zone`, so a kanban and a depth-chart board share it.
|
|
27
|
+
module Reorderable
|
|
28
|
+
extend ActiveSupport::Concern
|
|
29
|
+
|
|
30
|
+
included do
|
|
31
|
+
class_attribute :board_reorder_model, instance_accessor: false
|
|
32
|
+
class_attribute :board_reorder_id_attr, instance_accessor: false, default: :slug
|
|
33
|
+
class_attribute :board_reorder_param, instance_accessor: false, default: :slugs
|
|
34
|
+
class_attribute :board_reorder_gap, instance_accessor: false, default: 100
|
|
35
|
+
class_attribute :board_reorder_direction, instance_accessor: false, default: :desc
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class_methods do
|
|
39
|
+
# Configure the reorder action for this controller's board.
|
|
40
|
+
def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc)
|
|
41
|
+
self.board_reorder_model = model
|
|
42
|
+
self.board_reorder_id_attr = id_attr
|
|
43
|
+
self.board_reorder_param = param
|
|
44
|
+
self.board_reorder_gap = gap
|
|
45
|
+
self.board_reorder_direction = direction
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# POST — restamp a column from its DOM-ordered id list. Neutral param
|
|
50
|
+
# (`slugs` or `ids`), 100-gap, DESC by default. Mirrors the MS hand-rolled
|
|
51
|
+
# `reorder` exactly, INCLUDING its ErrorLog logging: the restamp runs inside
|
|
52
|
+
# `rescue_and_log(target: nil)` (Studio::ErrorHandling), which captures a
|
|
53
|
+
# failure to ErrorLog and RE-RAISES to the outer 422 net below — the "every
|
|
54
|
+
# write path logs" discipline, owned here since this is the one shared write
|
|
55
|
+
# action. `respond_to?` guards it so a host whose ApplicationController somehow
|
|
56
|
+
# lacks the concern still degrades to a bare restamp + 422 (no NoMethodError).
|
|
57
|
+
def reorder
|
|
58
|
+
model = self.class.board_reorder_model
|
|
59
|
+
raise "board_reorderable model not configured" unless model
|
|
60
|
+
|
|
61
|
+
param = self.class.board_reorder_param
|
|
62
|
+
ids = params[param]
|
|
63
|
+
return render(json: { error: "#{param} required" }, status: :unprocessable_entity) unless ids.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
if respond_to?(:rescue_and_log)
|
|
66
|
+
rescue_and_log(target: nil) { board_reorder_apply(model, ids) }
|
|
67
|
+
else
|
|
68
|
+
board_reorder_apply(model, ids)
|
|
69
|
+
end
|
|
70
|
+
render json: { success: true }
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Delegate to the model's Rankable#reposition! when available (the shared
|
|
78
|
+
# 100-gap rule), else fall back to the same per-id update_all loop inline so
|
|
79
|
+
# a model that only carries a `position` column still reorders.
|
|
80
|
+
def board_reorder_apply(model, ids)
|
|
81
|
+
id_attr = self.class.board_reorder_id_attr
|
|
82
|
+
gap = self.class.board_reorder_gap
|
|
83
|
+
direction = self.class.board_reorder_direction
|
|
84
|
+
|
|
85
|
+
if model.respond_to?(:reposition!)
|
|
86
|
+
model.reposition!(ids, gap: gap, direction: direction, id_attr: id_attr)
|
|
87
|
+
else
|
|
88
|
+
length = ids.length
|
|
89
|
+
ids.each_with_index do |id, index|
|
|
90
|
+
rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
|
|
91
|
+
model.where(id_attr => id).update_all(position: rank)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
module Board
|
|
5
|
+
# Shared 100-gap rank read-model for board-orderable records — the kanban
|
|
6
|
+
# columns (McRitchie Studio tasks / news / content) and the depth-chart lanes.
|
|
7
|
+
# A record carries an integer `position`; the board renders by `board_ordered`
|
|
8
|
+
# (highest position on top), and a drag-reorder restamps the whole column with
|
|
9
|
+
# 100-gaps via `reposition!`. Ranking is ZONE-SCOPED: a fresh rank is computed
|
|
10
|
+
# within the record's zone (a kanban `stage`, a depth-chart position group), so
|
|
11
|
+
# every column ranks independently and 100-spacing leaves room for the next
|
|
12
|
+
# drag insert without a full re-stamp.
|
|
13
|
+
#
|
|
14
|
+
# This is the read/rank half of the board primitive; the write half is
|
|
15
|
+
# Studio::Board::Reorderable (the controller action that calls reposition!).
|
|
16
|
+
#
|
|
17
|
+
# class Task < ApplicationRecord
|
|
18
|
+
# include Studio::Board::Rankable
|
|
19
|
+
# before_create :set_initial_position # seeds the genesis rank
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# class DepthChartEntry < ApplicationRecord
|
|
23
|
+
# include Studio::Board::Rankable
|
|
24
|
+
# self.board_zone_attr = :position_group # a lane, not a kanban stage
|
|
25
|
+
# end
|
|
26
|
+
module Rankable
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
# The column the record ranks WITHIN. Default matches the three MS kanban
|
|
31
|
+
# boards (stage); a within-lane board overrides it, and `nil` ranks globally.
|
|
32
|
+
class_attribute :board_zone_attr, instance_accessor: false, default: :stage
|
|
33
|
+
|
|
34
|
+
# Board order: highest `position` first (freshest/top card wins), NULLS last,
|
|
35
|
+
# then newest by created_at as a stable tiebreak. Arel.sql: the fragment is a
|
|
36
|
+
# fixed literal, not user input.
|
|
37
|
+
scope :board_ordered, -> { order(Arel.sql("position DESC NULLS LAST, created_at DESC")) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Seed the genesis rank on create: max(position) within this record's zone plus
|
|
41
|
+
# a 100 gap. A no-op when `position` is already set, so an explicit rank is
|
|
42
|
+
# never clobbered. Wire from the host via `before_create :set_initial_position`.
|
|
43
|
+
def set_initial_position
|
|
44
|
+
return if position.present?
|
|
45
|
+
|
|
46
|
+
self.position = self.class.board_next_position(zone_value_for_rank)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# This record's zone value (nil when the model ranks globally).
|
|
50
|
+
def zone_value_for_rank
|
|
51
|
+
attr = self.class.board_zone_attr
|
|
52
|
+
attr && respond_to?(attr) ? public_send(attr) : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class_methods do
|
|
56
|
+
# max(position) + gap within a zone (or globally when the zone value / attr
|
|
57
|
+
# is blank). The genesis seed and the "bump to top on a stage move" both read
|
|
58
|
+
# from here, so the 100-gap rule lives in one place.
|
|
59
|
+
def board_next_position(zone_value = nil, gap: 100)
|
|
60
|
+
scope = board_zone_attr && zone_value ? where(board_zone_attr => zone_value) : all
|
|
61
|
+
(scope.maximum(:position) || 0) + gap
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Restamp a column top-to-bottom in ONE pass. `ids` arrive in DOM order (top
|
|
65
|
+
# first). Under the `position DESC` board sort the TOP card must hold the
|
|
66
|
+
# HIGHEST rank, so with `direction: :desc` (the board default) the first id
|
|
67
|
+
# gets the largest value and each next one drops by `gap` — 100-spacing that
|
|
68
|
+
# leaves gaps for the next drag insert. `direction: :asc` ranks the other way
|
|
69
|
+
# (first id smallest) for an ascending board. This is exactly the per-id
|
|
70
|
+
# `update_all(position: ...)` loop the MS tasks/news/content reorder actions
|
|
71
|
+
# each ran by hand, lifted into the model. Returns the ids it stamped.
|
|
72
|
+
def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key)
|
|
73
|
+
ids = Array(ids)
|
|
74
|
+
length = ids.length
|
|
75
|
+
ids.each_with_index do |id, index|
|
|
76
|
+
rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
|
|
77
|
+
where(id_attr => id).update_all(position: rank)
|
|
78
|
+
end
|
|
79
|
+
ids
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<%# locals: (text:, scheme: "neutral") %>
|
|
1
|
+
<%# locals: (text:, scheme: "neutral", data_board_count: nil) %>
|
|
2
2
|
<%#
|
|
3
3
|
Stage roles (stage-fresh ... stage-closed) form a shared pipeline palette
|
|
4
4
|
used by News and Content (and any future pipelines). Mapping:
|
|
@@ -32,4 +32,6 @@
|
|
|
32
32
|
else "bg-surface-alt text-body border-subtle"
|
|
33
33
|
end
|
|
34
34
|
%>
|
|
35
|
-
|
|
35
|
+
<%# data_board_count wires the badge as a studio/board count target (updateCounts sets
|
|
36
|
+
its textContent) — additive + opt-in, so every existing caller renders identically. %>
|
|
37
|
+
<span class="badge <%= scheme_classes %>"<% if local_assigns[:data_board_count] %> data-board-count="<%= data_board_count %>"<% end %>><%= text %></span>
|
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
is unchanged and rides the same global. %>
|
|
29
29
|
<%= javascript_include_tag "studio/canvas_confetti", "data-turbo-track": "reload" %>
|
|
30
30
|
<%= javascript_include_tag "studio/studio_confetti", "data-turbo-track": "reload" %>
|
|
31
|
+
<%# SortableJS is VENDORED into the engine (studio/sortable.js) and shipped through
|
|
32
|
+
the asset pipeline — no CDN, CSP-safe (same-origin :self). Defines the global
|
|
33
|
+
`Sortable`; the studio/board primitive (studio/_board_assets -> window.studioBoard)
|
|
34
|
+
drives it for drag-reorder kanban AND depth-chart boards. Loaded here (deferred)
|
|
35
|
+
so any board consumer has it with zero per-app wiring, mirroring canvas_confetti. %>
|
|
36
|
+
<%= javascript_include_tag "studio/sortable", defer: true, "data-turbo-track": "reload" %>
|
|
31
37
|
<script>
|
|
32
38
|
window.fireSuccessConfetti = function() {
|
|
33
39
|
if (typeof confetti === 'undefined') return;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studioBoard factory — the Alpine data behind the engine board primitive
|
|
3
|
+
(studio/board/_board). Ships here at PAGE level on purpose: a <script> inside the
|
|
4
|
+
board component template would be cloned/re-parsed and never run, so a consumer
|
|
5
|
+
renders THIS once in its layout (like studio/_leveling_activity_assets homes
|
|
6
|
+
levelingActionModal and studio/_age_verify_assets homes ageVerifyModal), and the
|
|
7
|
+
board anywhere below just references window.studioBoard. SortableJS is loaded
|
|
8
|
+
globally by layouts/studio/_head (vendored, no CDN); this factory drives it.
|
|
9
|
+
|
|
10
|
+
ONE factory drives BOTH board shapes — it reads its selectors and behavior from
|
|
11
|
+
opts, so the kanban cross-column board (group + move PATCH + toasts + counts) and
|
|
12
|
+
the within-lane depth chart (group:false, a drag handle, reorder-only) are the
|
|
13
|
+
same code with different opts. See studio/board/_board for the full opts contract.
|
|
14
|
+
|
|
15
|
+
CRITICAL BOUNDARY — this factory is UI ONLY. It PATCHes an app-supplied move_url,
|
|
16
|
+
POSTs an app-supplied reorder_url, and reacts to a neutral JSON contract; it holds
|
|
17
|
+
ZERO domain logic. Custom behavior attaches by LISTENING to the dispatched
|
|
18
|
+
window events (studio:board-moved / :board-reordered / :card-added), and heavier
|
|
19
|
+
animation is delegated OPAQUELY to a window[fx] module + window[hook] fns the
|
|
20
|
+
engine never inspects.
|
|
21
|
+
%>
|
|
22
|
+
<script>
|
|
23
|
+
(function () {
|
|
24
|
+
if (window.studioBoard) return;
|
|
25
|
+
|
|
26
|
+
window.studioBoard = function (opts) {
|
|
27
|
+
opts = opts || {};
|
|
28
|
+
return {
|
|
29
|
+
// --- config (from opts) --------------------------------------------
|
|
30
|
+
zoneSelector: opts.zoneSelector || ".kanban-dropzone",
|
|
31
|
+
cardSelector: opts.draggable || ".kanban-card",
|
|
32
|
+
emptySelector: opts.emptySelector || null,
|
|
33
|
+
groupName: (opts.group === false ? null : (opts.group || "studio-board")),
|
|
34
|
+
handle: opts.handle || null,
|
|
35
|
+
sortFilter: opts.filter || ".kanban-empty",
|
|
36
|
+
idAttr: opts.idAttr || "slug",
|
|
37
|
+
zoneAttr: opts.zoneAttr || "stage",
|
|
38
|
+
domIdPrefix: opts.domIdPrefix || "card-",
|
|
39
|
+
moveUrl: opts.moveUrl || null,
|
|
40
|
+
moveParam: opts.moveParam || null,
|
|
41
|
+
reorderUrl: opts.reorderUrl || "",
|
|
42
|
+
reorderPayload: opts.reorderPayload || "slugs",
|
|
43
|
+
optimistic: opts.optimistic !== false,
|
|
44
|
+
toastsEnabled: opts.toasts !== false,
|
|
45
|
+
live: !!opts.live,
|
|
46
|
+
demo: !!opts.demo,
|
|
47
|
+
fxName: opts.fx || null,
|
|
48
|
+
onMoveHook: opts.onMoveHook || null,
|
|
49
|
+
onDropHook: opts.onDropHook || null,
|
|
50
|
+
labels: opts.labels || {},
|
|
51
|
+
|
|
52
|
+
// --- state ---------------------------------------------------------
|
|
53
|
+
toasts: [],
|
|
54
|
+
|
|
55
|
+
// --- init ----------------------------------------------------------
|
|
56
|
+
init: function () {
|
|
57
|
+
// Set data-alpine-ready in $nextTick, NOT synchronously: x-init runs while
|
|
58
|
+
// Alpine is still walking this subtree, so inner @click handlers are not
|
|
59
|
+
// bound yet. $nextTick fires after the directive walk, so the flag then
|
|
60
|
+
// means "the board is fully wired and interactive" — the documented init
|
|
61
|
+
// flake fix carried over from the MS kanbanBoard.
|
|
62
|
+
var self = this;
|
|
63
|
+
this.$nextTick(function () {
|
|
64
|
+
self.$el.dataset.alpineReady = "true";
|
|
65
|
+
self.initSortables();
|
|
66
|
+
if (self.live) {
|
|
67
|
+
self.observeLive();
|
|
68
|
+
if (!self.fx()) self.installExitStreamFallback();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
fx: function () { return this.fxName ? window[this.fxName] : null; },
|
|
74
|
+
cardId: function (el) { return el ? el.getAttribute("data-" + this.idAttr) : null; },
|
|
75
|
+
zoneKey: function (el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null; },
|
|
76
|
+
label: function (zone) { return this.labels[zone] || zone; },
|
|
77
|
+
|
|
78
|
+
// --- SortableJS wiring ---------------------------------------------
|
|
79
|
+
initSortables: function () {
|
|
80
|
+
if (typeof Sortable === "undefined") return;
|
|
81
|
+
var self = this;
|
|
82
|
+
document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
|
|
83
|
+
var cfg = {
|
|
84
|
+
animation: 150,
|
|
85
|
+
ghostClass: "opacity-30",
|
|
86
|
+
draggable: self.cardSelector,
|
|
87
|
+
filter: self.sortFilter,
|
|
88
|
+
onStart: function () { window.__studioBoardDragging = true; },
|
|
89
|
+
onEnd: function (evt) {
|
|
90
|
+
self.handleSortEnd(evt);
|
|
91
|
+
requestAnimationFrame(function () { window.__studioBoardDragging = false; });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
|
|
95
|
+
if (self.handle) cfg.handle = self.handle;
|
|
96
|
+
Sortable.create(zone, cfg);
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// A cross-column drop moves the card (PATCH); a same-zone drop is a pure
|
|
101
|
+
// reorder and must NEVER PATCH the zone. Source the from/to from the
|
|
102
|
+
// DROPZONES (evt.from / evt.to), never the card, so an in-place reorder
|
|
103
|
+
// can't mismove.
|
|
104
|
+
handleSortEnd: function (evt) {
|
|
105
|
+
var self = this;
|
|
106
|
+
var card = evt.item;
|
|
107
|
+
var fromZone = evt.from;
|
|
108
|
+
var toZone = evt.to;
|
|
109
|
+
var id = this.cardId(card);
|
|
110
|
+
var moved = fromZone !== toZone;
|
|
111
|
+
|
|
112
|
+
var afterMove = function (ok) {
|
|
113
|
+
if (moved && !ok) return; // reverted — leave the order alone
|
|
114
|
+
self.saveOrder(toZone);
|
|
115
|
+
self.updateCounts();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (moved) {
|
|
119
|
+
Promise.resolve(this.applyMove(card, fromZone, toZone, id)).then(afterMove);
|
|
120
|
+
} else {
|
|
121
|
+
afterMove(true);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Cross-column move: PATCH the zone attr; optimistic revert on failure.
|
|
126
|
+
applyMove: function (card, fromZone, toZone, id) {
|
|
127
|
+
var self = this;
|
|
128
|
+
var newZone = this.zoneKey(toZone);
|
|
129
|
+
var fromKey = this.zoneKey(fromZone);
|
|
130
|
+
|
|
131
|
+
// Moves not configured (or a within-zone board): a cross-zone drop is
|
|
132
|
+
// disallowed — snap back so a stray drag can't silently mismove. In demo
|
|
133
|
+
// mode with no move endpoint, still reflect the move locally.
|
|
134
|
+
if (!this.moveUrl || !this.moveParam) {
|
|
135
|
+
if (this.demo) {
|
|
136
|
+
this.setCardZone(card, newZone);
|
|
137
|
+
this.emit("board-moved", id, fromKey, newZone);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
this.revert(card, fromZone);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this.demo) {
|
|
145
|
+
this.setCardZone(card, newZone);
|
|
146
|
+
this.toast("Moved to " + this.label(newZone), "success");
|
|
147
|
+
this.emit("board-moved", id, fromKey, newZone);
|
|
148
|
+
this.hook(this.onMoveHook, { record: id, from: fromKey, to: newZone });
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
var url = this.moveUrl.replace(":id", encodeURIComponent(id));
|
|
153
|
+
var body = {};
|
|
154
|
+
body[this.moveParam.resource] = {};
|
|
155
|
+
body[this.moveParam.resource][this.moveParam.attr] = newZone;
|
|
156
|
+
|
|
157
|
+
return this.request(url, "PATCH", body).then(function (resp) {
|
|
158
|
+
if (!resp.ok) {
|
|
159
|
+
return resp.json().catch(function () { return {}; }).then(function (err) {
|
|
160
|
+
throw new Error(err.error || ("Failed (" + resp.status + ")"));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
self.setCardZone(card, newZone);
|
|
164
|
+
self.toast("Moved to " + self.label(newZone), "success");
|
|
165
|
+
self.emit("board-moved", id, fromKey, newZone);
|
|
166
|
+
self.hook(self.onMoveHook, { record: id, from: fromKey, to: newZone });
|
|
167
|
+
return true;
|
|
168
|
+
}).catch(function (e) {
|
|
169
|
+
if (self.optimistic) self.revert(card, fromZone);
|
|
170
|
+
self.toast((e && e.message) || "Move failed", "error");
|
|
171
|
+
self.updateCounts();
|
|
172
|
+
return false;
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Save the destination column's order. Reads the ordered id list and POSTs
|
|
177
|
+
// it under the neutral payload key (`slugs` | `ids`) plus the advisory zone.
|
|
178
|
+
saveOrder: function (toZone) {
|
|
179
|
+
var self = this;
|
|
180
|
+
var ids = Array.prototype.slice
|
|
181
|
+
.call(toZone.querySelectorAll(this.cardSelector))
|
|
182
|
+
.map(function (c) { return self.cardId(c); });
|
|
183
|
+
var zone = this.zoneKey(toZone);
|
|
184
|
+
this.emit("board-reordered", null, zone, zone, { ids: ids, zone: zone });
|
|
185
|
+
this.hook(this.onDropHook, { ids: ids, zone: zone });
|
|
186
|
+
if (this.demo || !this.reorderUrl) return;
|
|
187
|
+
var payload = {};
|
|
188
|
+
payload[this.reorderPayload] = ids;
|
|
189
|
+
payload.zone = zone;
|
|
190
|
+
this.request(this.reorderUrl, "POST", payload).catch(function () {
|
|
191
|
+
// Order save failed — positions are stale but the board stays functional.
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
setCardZone: function (card, zone) { card.setAttribute("data-" + this.zoneAttr, zone); },
|
|
196
|
+
|
|
197
|
+
// Revert a failed move: drop the card back into its SOURCE zone (which
|
|
198
|
+
// always exists) above the empty state, and flash a red ring.
|
|
199
|
+
revert: function (card, fromZone) {
|
|
200
|
+
var anchor = this.emptySelector ? fromZone.querySelector(this.emptySelector) : null;
|
|
201
|
+
fromZone.insertBefore(card, anchor);
|
|
202
|
+
card.classList.add("ring-2", "ring-red-500");
|
|
203
|
+
setTimeout(function () { card.classList.remove("ring-2", "ring-red-500"); }, 1500);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
updateCounts: function () {
|
|
207
|
+
var self = this;
|
|
208
|
+
document.querySelectorAll("[data-board-count]").forEach(function (badge) {
|
|
209
|
+
var key = badge.getAttribute("data-board-count");
|
|
210
|
+
var zone = document.getElementById("dropzone-" + key);
|
|
211
|
+
if (!zone) return;
|
|
212
|
+
var count = zone.querySelectorAll(self.cardSelector).length;
|
|
213
|
+
badge.textContent = count;
|
|
214
|
+
if (self.emptySelector) {
|
|
215
|
+
var empty = zone.querySelector(self.emptySelector);
|
|
216
|
+
if (empty) empty.style.display = count === 0 ? "flex" : "none";
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
// --- live updates (Turbo Streams) ----------------------------------
|
|
222
|
+
// The board subscribes via turbo_stream_from(live_channel); Turbo patches the
|
|
223
|
+
// DOM on every broadcast. This observer adds the flourish Turbo doesn't: a
|
|
224
|
+
// fade/scale-in on each patched-in card (deferred to window[fx] when present)
|
|
225
|
+
// and a count refresh after any column change.
|
|
226
|
+
observeLive: function () {
|
|
227
|
+
var self = this;
|
|
228
|
+
var onChange = function (mutations) {
|
|
229
|
+
for (var i = 0; i < mutations.length; i++) {
|
|
230
|
+
mutations[i].addedNodes.forEach(function (node) {
|
|
231
|
+
if (node.nodeType === 1 && node.matches && node.matches(self.cardSelector)) {
|
|
232
|
+
var fx = self.fx();
|
|
233
|
+
if (fx && fx.onAdd) { fx.onAdd(node); } else { self.animateIn(node); }
|
|
234
|
+
self.emit("card-added", self.cardId(node), null, self.zoneKey(node.parentElement));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
self.updateCounts();
|
|
239
|
+
};
|
|
240
|
+
document.querySelectorAll(this.zoneSelector).forEach(function (zone) {
|
|
241
|
+
new MutationObserver(onChange).observe(zone, { childList: true });
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
animateIn: function (el) {
|
|
246
|
+
el.style.opacity = "0";
|
|
247
|
+
el.style.transform = "scale(0.96)";
|
|
248
|
+
el.classList.add("ring-2", "ring-primary");
|
|
249
|
+
requestAnimationFrame(function () {
|
|
250
|
+
el.style.transition = "opacity 300ms ease, transform 300ms ease";
|
|
251
|
+
el.style.opacity = "";
|
|
252
|
+
el.style.transform = "";
|
|
253
|
+
});
|
|
254
|
+
setTimeout(function () { el.classList.remove("ring-2", "ring-primary"); el.style.transition = ""; }, 900);
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// Archive re-parent / delete exit animation via a Turbo `remove` stream
|
|
258
|
+
// carrying data-exit-action. Installed only when no fx module owns exits.
|
|
259
|
+
installExitStreamFallback: function () {
|
|
260
|
+
if (window.__studioBoardExitFallback) return;
|
|
261
|
+
window.__studioBoardExitFallback = true;
|
|
262
|
+
var self = this;
|
|
263
|
+
document.addEventListener("turbo:before-stream-render", function (event) {
|
|
264
|
+
var stream = event.target;
|
|
265
|
+
if (stream.getAttribute("action") !== "remove") return;
|
|
266
|
+
var target = stream.getAttribute("target") || "";
|
|
267
|
+
var exitAction = stream.dataset.exitAction;
|
|
268
|
+
if (target.indexOf(self.domIdPrefix) !== 0 || !exitAction) return;
|
|
269
|
+
var renderNow = event.detail.render;
|
|
270
|
+
event.detail.render = function (el) {
|
|
271
|
+
var card = document.getElementById(target);
|
|
272
|
+
if (!card) { renderNow(el); return; }
|
|
273
|
+
self.animateCardExit(card, exitAction).then(function () {
|
|
274
|
+
renderNow(el);
|
|
275
|
+
self.updateCounts();
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
animateCardExit: function (card, kind) {
|
|
282
|
+
if (!card || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve();
|
|
283
|
+
card.style.pointerEvents = "none";
|
|
284
|
+
var frames = kind === "archive"
|
|
285
|
+
? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
|
|
286
|
+
: [{ opacity: 1, transform: "scale(1)" }, { opacity: 0, transform: "scale(0.6)" }];
|
|
287
|
+
var anim = card.animate(frames, {
|
|
288
|
+
duration: kind === "archive" ? 500 : 420,
|
|
289
|
+
easing: "cubic-bezier(.35,0,.2,1)",
|
|
290
|
+
fill: "forwards"
|
|
291
|
+
});
|
|
292
|
+
return anim.finished.catch(function () {});
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
// --- toasts --------------------------------------------------------
|
|
296
|
+
toast: function (message, type) {
|
|
297
|
+
if (!this.toastsEnabled) {
|
|
298
|
+
if (type === "error" && typeof window.alert === "function") window.alert(message);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
var self = this;
|
|
302
|
+
var id = Date.now() + Math.random();
|
|
303
|
+
this.toasts.push({ id: id, message: message, type: type, visible: true });
|
|
304
|
+
setTimeout(function () {
|
|
305
|
+
var t = self.toasts.find(function (x) { return x.id === id; });
|
|
306
|
+
if (t) t.visible = false;
|
|
307
|
+
setTimeout(function () {
|
|
308
|
+
self.toasts = self.toasts.filter(function (x) { return x.id !== id; });
|
|
309
|
+
}, 300);
|
|
310
|
+
}, 3000);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
// --- helpers -------------------------------------------------------
|
|
314
|
+
request: function (url, method, body) {
|
|
315
|
+
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || "";
|
|
316
|
+
var fetcher = window.authedFetch || window.fetch;
|
|
317
|
+
return fetcher(url, {
|
|
318
|
+
method: method,
|
|
319
|
+
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Accept": "application/json" },
|
|
320
|
+
body: JSON.stringify(body || {})
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
// The primary extension seam — apps LISTEN for these on window and attach
|
|
325
|
+
// confetti / swipe / cart / analytics, never patching this factory.
|
|
326
|
+
emit: function (name, record, from, to, extra) {
|
|
327
|
+
try {
|
|
328
|
+
var detail = Object.assign({ record: record, from: from, to: to }, extra || {});
|
|
329
|
+
window.dispatchEvent(new CustomEvent("studio:" + name, { detail: detail }));
|
|
330
|
+
} catch (e) {}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
hook: function (hookName, detail) {
|
|
334
|
+
if (!hookName) return;
|
|
335
|
+
var fn = window[hookName];
|
|
336
|
+
if (typeof fn === "function") { try { fn(detail); } catch (e) {} }
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
})();
|
|
341
|
+
</script>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board — the shared kanban / depth-chart board shell. An ADDITIVE engine
|
|
3
|
+
primitive (Phase D): the SortableJS drag-reorder, optimistic cross-column move →
|
|
4
|
+
revert → toast, and count/live wiring that the three near-identical McRitchie
|
|
5
|
+
Studio boards (tasks / news / content) and the depth chart each hand-rolled,
|
|
6
|
+
lifted into one place. Renders a horizontal row of columns, each a drop zone the
|
|
7
|
+
vendored SortableJS + window.studioBoard factory (studio/_board_assets, rendered
|
|
8
|
+
once at layout level — a <script> inside this component template would never run)
|
|
9
|
+
wires.
|
|
10
|
+
|
|
11
|
+
UI ONLY and NEUTRAL: no model, no routes, no domain. The app supplies its columns,
|
|
12
|
+
its card partial, and its endpoints. Custom behavior attaches by LISTENING to the
|
|
13
|
+
DOM events the factory dispatches on window — `studio:board-moved`,
|
|
14
|
+
`studio:board-reordered`, `studio:card-added` (detail `{ record, from, to }`) —
|
|
15
|
+
never by patching the factory. Heavier per-app animation defers OPAQUELY to a
|
|
16
|
+
`fx:` module (window[<name>]) and to `on_move_hook:` / `on_drop_hook:` fns.
|
|
17
|
+
|
|
18
|
+
The identity contract every card + zone must satisfy is emitted by
|
|
19
|
+
studio/board/_column (the `.kanban-dropzone`) and studio/board/_card_shell (the
|
|
20
|
+
`.kanban-card`) — an app may hand-roll a bespoke card as long as it matches.
|
|
21
|
+
|
|
22
|
+
locals:
|
|
23
|
+
columns: (req) [{ key:, label:, cards:, count:, badge_class:,
|
|
24
|
+
collapsible:, hidden:, header_extra: }]. `cards` are records
|
|
25
|
+
passed to `card_partial`; `count` defaults to cards.size.
|
|
26
|
+
card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
|
|
27
|
+
card_as: local the record binds to in card_partial. Default :card.
|
|
28
|
+
card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
|
|
29
|
+
reorder_url: POST endpoint the drag-reorder saves the new order to.
|
|
30
|
+
reorder_payload: :slugs | :ids — the key the ordered id list POSTs under.
|
|
31
|
+
Default :slugs.
|
|
32
|
+
move_url: PATCH template for a cross-column move, ":id" → the card's id
|
|
33
|
+
(e.g. "/tasks/:id.json"). nil disallows moves (reorder only).
|
|
34
|
+
move_param: { resource:, attr: } → PATCH body { resource => { attr => zone } }.
|
|
35
|
+
nil disallows moves.
|
|
36
|
+
group: SortableJS group — a shared string allows cross-column drags;
|
|
37
|
+
false keeps drags within a zone (the depth-chart shape).
|
|
38
|
+
Default "studio-board".
|
|
39
|
+
id_attr: card id data-attr + dom-id suffix. Default "slug".
|
|
40
|
+
zone_attr: column/zone data-attr. Default "stage".
|
|
41
|
+
handle: drag-handle selector (nil = whole card draggable).
|
|
42
|
+
filter: SortableJS filter (undraggable els). Default ".kanban-empty".
|
|
43
|
+
zone_selector: the drop-zone selector. Default ".kanban-dropzone".
|
|
44
|
+
draggable: the card selector. Default ".kanban-card".
|
|
45
|
+
empty_selector: the empty-state selector (revert anchor + count toggle).
|
|
46
|
+
Default ".kanban-empty"; nil for a board with no empty state.
|
|
47
|
+
live_channel: Turbo Stream channel for live updates (nil = static board).
|
|
48
|
+
optimistic: revert + red-ring flash a failed move. Default true.
|
|
49
|
+
toasts: show the toast host (false = window.alert on error). Default true.
|
|
50
|
+
empty_label: empty-zone text. Default "Drop here".
|
|
51
|
+
header_slot: raw HTML rendered above the columns (pass html_safe content).
|
|
52
|
+
above_board_slot: raw HTML rendered between the header and the columns.
|
|
53
|
+
fx: window[<name>] module the factory defers add/exit anims to.
|
|
54
|
+
on_move_hook: window[<name>] opaque fn, called with { record, from, to }.
|
|
55
|
+
on_drop_hook: window[<name>] opaque fn, called with { ids, zone }.
|
|
56
|
+
demo: style-guide preview — drag works locally, no network POST.
|
|
57
|
+
%>
|
|
58
|
+
<%
|
|
59
|
+
columns = local_assigns.fetch(:columns)
|
|
60
|
+
card_partial = local_assigns.fetch(:card_partial)
|
|
61
|
+
card_as = local_assigns.fetch(:card_as, :card)
|
|
62
|
+
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
63
|
+
reorder_url = local_assigns.fetch(:reorder_url, "")
|
|
64
|
+
reorder_payload = local_assigns.fetch(:reorder_payload, :slugs).to_s
|
|
65
|
+
move_url = local_assigns.fetch(:move_url, nil)
|
|
66
|
+
move_param = local_assigns.fetch(:move_param, nil)
|
|
67
|
+
group = local_assigns.fetch(:group, "studio-board")
|
|
68
|
+
id_attr = local_assigns.fetch(:id_attr, "slug").to_s
|
|
69
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage").to_s
|
|
70
|
+
handle = local_assigns.fetch(:handle, nil)
|
|
71
|
+
sort_filter = local_assigns.fetch(:filter, ".kanban-empty")
|
|
72
|
+
zone_selector = local_assigns.fetch(:zone_selector, ".kanban-dropzone")
|
|
73
|
+
draggable_sel = local_assigns.fetch(:draggable, ".kanban-card")
|
|
74
|
+
empty_selector = local_assigns.fetch(:empty_selector, ".kanban-empty")
|
|
75
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
76
|
+
live_channel = local_assigns.fetch(:live_channel, nil)
|
|
77
|
+
optimistic = local_assigns.fetch(:optimistic, true)
|
|
78
|
+
toasts = local_assigns.fetch(:toasts, true)
|
|
79
|
+
empty_label = local_assigns.fetch(:empty_label, "Drop here")
|
|
80
|
+
header_slot = local_assigns.fetch(:header_slot, nil)
|
|
81
|
+
above_board_slot = local_assigns.fetch(:above_board_slot, nil)
|
|
82
|
+
fx = local_assigns.fetch(:fx, nil)
|
|
83
|
+
on_move_hook = local_assigns.fetch(:on_move_hook, nil)
|
|
84
|
+
on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
|
|
85
|
+
demo = local_assigns.fetch(:demo, false)
|
|
86
|
+
|
|
87
|
+
board_opts = {
|
|
88
|
+
zoneSelector: zone_selector,
|
|
89
|
+
draggable: draggable_sel,
|
|
90
|
+
emptySelector: empty_selector,
|
|
91
|
+
group: (group == false ? false : group.to_s),
|
|
92
|
+
handle: handle,
|
|
93
|
+
filter: sort_filter,
|
|
94
|
+
idAttr: id_attr,
|
|
95
|
+
zoneAttr: zone_attr,
|
|
96
|
+
domIdPrefix: dom_id_prefix,
|
|
97
|
+
moveUrl: move_url,
|
|
98
|
+
moveParam: (move_param ? { resource: move_param[:resource].to_s, attr: move_param[:attr].to_s } : nil),
|
|
99
|
+
reorderUrl: reorder_url,
|
|
100
|
+
reorderPayload: reorder_payload,
|
|
101
|
+
optimistic: !!optimistic,
|
|
102
|
+
toasts: !!toasts,
|
|
103
|
+
live: live_channel.present?,
|
|
104
|
+
demo: !!demo,
|
|
105
|
+
fx: fx,
|
|
106
|
+
onMoveHook: on_move_hook,
|
|
107
|
+
onDropHook: on_drop_hook,
|
|
108
|
+
labels: columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
|
|
109
|
+
}
|
|
110
|
+
%>
|
|
111
|
+
<section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
|
|
112
|
+
<% if live_channel.present? %><%= turbo_stream_from live_channel %><% end %>
|
|
113
|
+
|
|
114
|
+
<% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
|
|
115
|
+
<% if above_board_slot %><%= above_board_slot %><% end %>
|
|
116
|
+
|
|
117
|
+
<div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
|
|
118
|
+
<% columns.each do |column| %>
|
|
119
|
+
<%= render "studio/board/column",
|
|
120
|
+
column: column,
|
|
121
|
+
zone_attr: zone_attr,
|
|
122
|
+
dom_id_prefix: dom_id_prefix,
|
|
123
|
+
zone_selector_class: zone_selector.delete_prefix("."),
|
|
124
|
+
empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
|
|
125
|
+
empty_label: empty_label,
|
|
126
|
+
card_partial: card_partial,
|
|
127
|
+
card_as: card_as,
|
|
128
|
+
card_locals: card_locals %>
|
|
129
|
+
<% end %>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<% if toasts %>
|
|
133
|
+
<div class="studio-board-toasts fixed top-4 right-4 z-50 space-y-2" data-test="studio-board-toasts">
|
|
134
|
+
<template x-for="toast in toasts" :key="toast.id">
|
|
135
|
+
<div x-show="toast.visible"
|
|
136
|
+
x-transition:enter="transition ease-out duration-300"
|
|
137
|
+
x-transition:enter-start="opacity-0 translate-x-8"
|
|
138
|
+
x-transition:enter-end="opacity-100 translate-x-0"
|
|
139
|
+
x-transition:leave="transition ease-in duration-200"
|
|
140
|
+
x-transition:leave-start="opacity-100 translate-x-0"
|
|
141
|
+
x-transition:leave-end="opacity-0 translate-x-8"
|
|
142
|
+
class="bg-surface border rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg backdrop-blur-sm"
|
|
143
|
+
:style="toast.type === 'success'
|
|
144
|
+
? 'border-color: var(--color-success); color: var(--color-success)'
|
|
145
|
+
: 'border-color: var(--color-danger); color: var(--color-danger)'">
|
|
146
|
+
<span x-text="toast.message"></span>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</div>
|
|
150
|
+
<% end %>
|
|
151
|
+
</section>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board/card_shell — OPTIONAL chrome that emits the board CARD half of the
|
|
3
|
+
identity contract, so a card partial can `render layout: "studio/board/card_shell"`
|
|
4
|
+
around its own body instead of hand-rolling the wrapper. It emits:
|
|
5
|
+
|
|
6
|
+
id="<dom_id_prefix><id>" (default id="card-<id>")
|
|
7
|
+
data-<id_attr>="<id>" (default data-slug — window.studioBoard reads this)
|
|
8
|
+
data-<zone_attr>="<zone>" (default data-stage — the card's current column)
|
|
9
|
+
class="kanban-card <extra>" (the SortableJS draggable + live-observe hook)
|
|
10
|
+
|
|
11
|
+
Apps may SKIP this and hand-roll a bespoke card, as long as it satisfies the same
|
|
12
|
+
contract (that is the point of the primitive — the contract, not this exact
|
|
13
|
+
markup). UI only; no domain, no persistence.
|
|
14
|
+
|
|
15
|
+
Checks local_assigns (never block_given?) for its locals, but always yields the
|
|
16
|
+
caller block — it is only ever used as a `render layout:` wrapper.
|
|
17
|
+
|
|
18
|
+
locals:
|
|
19
|
+
id: (req) the card id value (a slug / record id).
|
|
20
|
+
zone: (req) the card's current column/zone key.
|
|
21
|
+
id_attr: data-attr name for the id. Default "slug".
|
|
22
|
+
zone_attr: data-attr name for the zone. Default "stage".
|
|
23
|
+
dom_id_prefix: dom-id prefix. Default "card-".
|
|
24
|
+
card_class: the required base class. Default "kanban-card".
|
|
25
|
+
class: extra classes appended after card_class.
|
|
26
|
+
style: inline style string.
|
|
27
|
+
attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
|
|
28
|
+
values are HTML-escaped — the rebase seam for a card that carries
|
|
29
|
+
its own Alpine/data-* on the root.
|
|
30
|
+
%>
|
|
31
|
+
<%
|
|
32
|
+
id = local_assigns.fetch(:id)
|
|
33
|
+
zone = local_assigns.fetch(:zone)
|
|
34
|
+
id_attr = local_assigns.fetch(:id_attr, "slug")
|
|
35
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
36
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
37
|
+
card_class = local_assigns.fetch(:card_class, "kanban-card")
|
|
38
|
+
extra_class = local_assigns.fetch(:class, nil)
|
|
39
|
+
inline_style = local_assigns.fetch(:style, nil)
|
|
40
|
+
root_attrs = local_assigns.fetch(:attrs, {}) || {}
|
|
41
|
+
%>
|
|
42
|
+
<div id="<%= dom_id_prefix %><%= id %>"
|
|
43
|
+
class="<%= [card_class, extra_class].compact.join(" ") %>"
|
|
44
|
+
data-<%= id_attr %>="<%= id %>"
|
|
45
|
+
data-<%= zone_attr %>="<%= zone %>"
|
|
46
|
+
<% root_attrs.each do |name, value| %><%= name %>="<%= value %>" <% end %>
|
|
47
|
+
<% if inline_style %>style="<%= inline_style %>"<% end %>><%= yield %></div>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
studio/board/column — ONE board column: the header (label + count), the drop
|
|
3
|
+
zone (the SortableJS target), its cards, and the empty state. Emits the ZONE half
|
|
4
|
+
of the board identity contract:
|
|
5
|
+
|
|
6
|
+
id="dropzone-<key>" (window.studioBoard.updateCounts reads this)
|
|
7
|
+
class="kanban-dropzone …" (the SortableJS + live-observe target)
|
|
8
|
+
data-<zone_attr>="<key>" (the column key a dropped card moves TO)
|
|
9
|
+
|
|
10
|
+
Cards emit the other half (see studio/board/_card_shell). Rendered by
|
|
11
|
+
studio/board/_board; not usually rendered directly.
|
|
12
|
+
|
|
13
|
+
locals:
|
|
14
|
+
column: (req) { key:, label:, cards:, count:, badge_class:,
|
|
15
|
+
collapsible:, hidden:, header_extra: }. `cards` are the
|
|
16
|
+
records passed to the card partial; `count` defaults to
|
|
17
|
+
cards.size; `badge_class` picks a components/_badge scheme
|
|
18
|
+
for the count (else a plain neutral count chip).
|
|
19
|
+
zone_attr: data-attr name for the zone. Default "stage".
|
|
20
|
+
dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
|
|
21
|
+
zone_selector_class: the drop-zone class. Default "kanban-dropzone".
|
|
22
|
+
empty_selector_class: the empty-state class. Default "kanban-empty".
|
|
23
|
+
empty_label: empty-zone text. Default "Drop here".
|
|
24
|
+
card_partial: (req) partial rendered per card.
|
|
25
|
+
card_as: local the record binds to in card_partial. Default :card.
|
|
26
|
+
card_locals: per-card extra locals — a Proc ->(record){ Hash } or Hash.
|
|
27
|
+
%>
|
|
28
|
+
<%
|
|
29
|
+
column = local_assigns.fetch(:column)
|
|
30
|
+
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
31
|
+
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
32
|
+
zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
|
|
33
|
+
empty_selector_class = local_assigns.fetch(:empty_selector_class, "kanban-empty")
|
|
34
|
+
empty_label = local_assigns.fetch(:empty_label, "Drop here")
|
|
35
|
+
card_partial = local_assigns.fetch(:card_partial)
|
|
36
|
+
card_as = local_assigns.fetch(:card_as, :card)
|
|
37
|
+
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
38
|
+
|
|
39
|
+
key = column[:key].to_s
|
|
40
|
+
label = column[:label] || key
|
|
41
|
+
cards = column[:cards] || []
|
|
42
|
+
count = column.fetch(:count) { cards.size }
|
|
43
|
+
badge_class = column[:badge_class]
|
|
44
|
+
collapsible = column[:collapsible]
|
|
45
|
+
hidden = column[:hidden]
|
|
46
|
+
header_extra = column[:header_extra]
|
|
47
|
+
|
|
48
|
+
# Merge the per-card hook (Proc or Hash) with the record binding.
|
|
49
|
+
resolve_locals = lambda do |record|
|
|
50
|
+
extra = card_locals.respond_to?(:call) ? card_locals.call(record) : (card_locals || {})
|
|
51
|
+
{ card_as => record }.merge(extra || {})
|
|
52
|
+
end
|
|
53
|
+
%>
|
|
54
|
+
<div class="studio-board-column w-full flex-none sm:flex-1 sm:min-w-[190px]"
|
|
55
|
+
data-board-column="<%= key %>"
|
|
56
|
+
<% if collapsible %>data-board-collapsible="true"<% end %>
|
|
57
|
+
<% if hidden %>x-cloak style="display:none"<% end %>>
|
|
58
|
+
<div class="mb-3 px-1 flex items-center gap-2">
|
|
59
|
+
<h3 class="text-sm font-bold text-heading uppercase tracking-wider"><%= label %></h3>
|
|
60
|
+
<% if badge_class %>
|
|
61
|
+
<%= render "components/badge", text: count.to_s, scheme: badge_class, data_board_count: key %>
|
|
62
|
+
<% else %>
|
|
63
|
+
<span class="stage-count inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold bg-surface-alt text-secondary border border-subtle"
|
|
64
|
+
data-board-count="<%= key %>"><%= count %></span>
|
|
65
|
+
<% end %>
|
|
66
|
+
<% if header_extra %><%= header_extra %><% end %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div id="dropzone-<%= key %>"
|
|
70
|
+
data-<%= zone_attr %>="<%= key %>"
|
|
71
|
+
class="<%= zone_selector_class %> rounded-xl border-2 border-dashed border-subtle min-h-40 p-2 space-y-2 transition-colors duration-150 sm:min-h-[calc(100vh-220px)]">
|
|
72
|
+
<% cards.each do |record| %>
|
|
73
|
+
<%= render partial: card_partial, locals: resolve_locals.call(record) %>
|
|
74
|
+
<% end %>
|
|
75
|
+
<div class="<%= empty_selector_class %> flex items-center justify-center h-24 text-muted text-sm"
|
|
76
|
+
<% if cards.any? %>style="display: none"<% end %>>
|
|
77
|
+
<%= empty_label %>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
<%# Tasks section of the living style guide (admin/style).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
The shared board primitive, LIVE. Phase D lifted the columns / cards / drag +
|
|
4
|
+
optimistic move / reorder that the three near-identical McRitchie Studio boards
|
|
5
|
+
(tasks / news / content) each hand-rolled into one engine primitive
|
|
6
|
+
(studio/board/_board + the window.studioBoard factory + vendored SortableJS).
|
|
7
|
+
The first block below renders that real primitive with `demo: true` — you can
|
|
8
|
+
DRAG a card between columns and the move animates + toasts, but nothing POSTs.
|
|
9
|
+
The blocks under it stay a static reference: the stage-* badge palette
|
|
10
|
+
(components/_badge) that is the pipeline spine, and the standard-vs-custom
|
|
11
|
+
split of what a board inherits vs sets. %>
|
|
9
12
|
<%
|
|
10
|
-
#
|
|
13
|
+
# LIVE specimen — the real studio/board/_board primitive, demo mode. Plain Hash
|
|
14
|
+
# records (the style guide has no models); each card id becomes id="card-<id>".
|
|
15
|
+
# Each demo card carries its current `zone` (the column it sits in) — in a real
|
|
16
|
+
# board the record knows its own stage; here the Hash does.
|
|
17
|
+
live_board_columns = [
|
|
18
|
+
{ key: "designed", label: "Designed", badge_class: "stage-fresh",
|
|
19
|
+
cards: [
|
|
20
|
+
{ id: "engine-board-primitive", zone: "designed", title: "Engine board primitive", repo: "studio-engine", who: "SH" },
|
|
21
|
+
{ id: "coinflow-buy-rail", zone: "designed", title: "Coinflow buy rail", repo: "turf-monster", who: "JA" }
|
|
22
|
+
] },
|
|
23
|
+
{ key: "building", label: "Building", badge_class: "stage-shaping",
|
|
24
|
+
cards: [{ id: "navbar-rpc-preload", zone: "building", title: "Navbar RPC preload", repo: "turf-monster", who: "CA" }] },
|
|
25
|
+
{ key: "submitted", label: "Submitted", badge_class: "stage-structured",
|
|
26
|
+
cards: [{ id: "magic-link-ttl-bump", zone: "submitted", title: "Magic-link TTL bump", repo: "studio-engine", who: "SH" }] },
|
|
27
|
+
{ key: "shipped", label: "Shipped", badge_class: "stage-shipped",
|
|
28
|
+
cards: [{ id: "ses-email-backend", zone: "shipped", title: "SES email backend", repo: "mcritchie", who: "ST" }] }
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Copy-paste-for-an-agent usage. Built by array-join, NOT a heredoc: a heredoc as
|
|
32
|
+
# the last expression of an ERB scriptlet trips Erubi, and literal ERB tags in the
|
|
33
|
+
# body would close the tag. Plain render() calls read clearly for an agent.
|
|
34
|
+
board_usage_snippet = [
|
|
35
|
+
"# once at layout level (a <script> in the board template never runs):",
|
|
36
|
+
'render "studio/board_assets"',
|
|
37
|
+
"",
|
|
38
|
+
"# then the board itself:",
|
|
39
|
+
'render "studio/board/board",',
|
|
40
|
+
" columns: @columns, # [{ key:, label:, cards:, badge_class: }]",
|
|
41
|
+
' card_partial: "tasks/task_card",',
|
|
42
|
+
" reorder_url: tasks_reorder_path, # POST { slugs: [...], zone: }",
|
|
43
|
+
' move_url: "/tasks/:id.json", # PATCH { task: { stage: } }',
|
|
44
|
+
' move_param: { resource: "task", attr: "stage" },',
|
|
45
|
+
' live_channel: "deployments"'
|
|
46
|
+
].join("\n")
|
|
47
|
+
|
|
48
|
+
# The static reference palette below (unchanged): each column maps to a stage-*
|
|
49
|
+
# badge scheme.
|
|
11
50
|
board_columns = [
|
|
12
51
|
{ key: "stage-fresh", label: "Fresh", count: 2 },
|
|
13
52
|
{ key: "stage-shaping", label: "Shaping", count: 1 },
|
|
@@ -32,13 +71,45 @@
|
|
|
32
71
|
<h2 class="text-2xl font-bold text-heading">Tasks</h2>
|
|
33
72
|
<p class="text-muted text-sm max-w-2xl">
|
|
34
73
|
The shared board primitive that every board builds from — columns, cards, the
|
|
35
|
-
<code class="font-mono text-2xs">stage-*</code> badge palette, and the drag /
|
|
36
|
-
McRitchie Studio's <code class="font-mono text-2xs">/deployments</code> board is
|
|
37
|
-
|
|
74
|
+
<code class="font-mono text-2xs">stage-*</code> badge palette, and the drag / optimistic
|
|
75
|
+
move + reorder. McRitchie Studio's <code class="font-mono text-2xs">/deployments</code> board is
|
|
76
|
+
the top-of-line; the specimen below is the <strong>real</strong>
|
|
77
|
+
<code class="font-mono text-2xs">studio/board/board</code> primitive in
|
|
78
|
+
<code class="font-mono text-2xs">demo: true</code> mode — drag a card, no save.
|
|
38
79
|
</p>
|
|
39
80
|
</div>
|
|
40
81
|
|
|
41
|
-
<%# ---- The
|
|
82
|
+
<%# ---- The LIVE primitive (demo mode) --------------------------------- %>
|
|
83
|
+
<%# Render the factory once (a <script> in the board template would never run), then
|
|
84
|
+
the board. demo: true → drag animates + toasts locally, nothing POSTs. %>
|
|
85
|
+
<%= render "studio/board_assets" %>
|
|
86
|
+
<div class="space-y-3">
|
|
87
|
+
<div class="flex items-center gap-2">
|
|
88
|
+
<span class="badge" style="color: var(--color-success); border-color: var(--color-success)">Live</span>
|
|
89
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · demo — drag a card between columns</p>
|
|
90
|
+
</div>
|
|
91
|
+
<%= render "studio/board/board",
|
|
92
|
+
demo: true,
|
|
93
|
+
columns: live_board_columns,
|
|
94
|
+
card_partial: "style/board/demo_card",
|
|
95
|
+
card_as: :card,
|
|
96
|
+
reorder_url: "#",
|
|
97
|
+
move_url: "#",
|
|
98
|
+
move_param: { resource: "task", attr: "stage" },
|
|
99
|
+
group: "studio-board-demo",
|
|
100
|
+
empty_label: "Drop tasks here" %>
|
|
101
|
+
<div class="relative" x-data="{ copied: false }">
|
|
102
|
+
<pre class="bg-inset text-body text-2xs rounded-lg px-3 py-2 pr-16 overflow-x-auto"><code x-ref="boardUsage"><%= board_usage_snippet %></code></pre>
|
|
103
|
+
<button type="button"
|
|
104
|
+
class="btn btn-neutral btn-sm absolute top-2 right-2"
|
|
105
|
+
aria-label="Copy usage snippet"
|
|
106
|
+
@click="navigator.clipboard?.writeText($refs.boardUsage.innerText); copied = true; setTimeout(() => copied = false, 1200)">
|
|
107
|
+
<span x-text="copied ? 'Copied' : 'Copy'">Copy</span>
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<%# ---- The static reference sketch ------------------------------------- %>
|
|
42
113
|
<div class="fade-edge-x overflow-x-auto pb-2">
|
|
43
114
|
<div class="flex gap-4 min-w-max">
|
|
44
115
|
<% board_columns.each do |col| %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%# locals: (card:, id_attr: "slug", zone_attr: "stage") %>
|
|
2
|
+
<%#
|
|
3
|
+
A demo board card for the /admin/style Board specimen. Renders THROUGH the engine
|
|
4
|
+
card-shell chrome (studio/board/_card_shell) so the specimen exercises the real
|
|
5
|
+
identity contract (id="card-<id>", class .kanban-card, data-<id_attr>/<zone_attr>),
|
|
6
|
+
not a bespoke stand-in. `card` is a plain Hash — the specimen has no models. The
|
|
7
|
+
id_attr / zone_attr flow through from the board so the same partial demonstrates
|
|
8
|
+
the primitive under both the default and a custom attribute naming.
|
|
9
|
+
%>
|
|
10
|
+
<%= render layout: "studio/board/card_shell",
|
|
11
|
+
locals: { id: card[:id], zone: card[:zone], id_attr: id_attr, zone_attr: zone_attr,
|
|
12
|
+
class: "card p-3 space-y-2 cursor-grab active:cursor-grabbing" } do %>
|
|
13
|
+
<div class="flex items-start gap-2">
|
|
14
|
+
<span class="text-muted select-none leading-none" aria-hidden="true">⠿</span>
|
|
15
|
+
<p class="text-sm font-semibold text-heading leading-snug flex-1"><%= card[:title] %></p>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="flex items-center justify-between gap-2">
|
|
18
|
+
<%= render "components/badge", text: card[:repo], scheme: "neutral" %>
|
|
19
|
+
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full text-2xs font-bold text-white shrink-0"
|
|
20
|
+
style="background: var(--color-cta)"><%= card[:who] %></span>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
data/lib/studio/engine.rb
CHANGED
data/lib/studio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.23.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -171,6 +171,7 @@ files:
|
|
|
171
171
|
- app/assets/images/resend-favicon.png
|
|
172
172
|
- app/assets/images/ses-favicon.png
|
|
173
173
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
174
|
+
- app/assets/javascripts/studio/sortable.js
|
|
174
175
|
- app/assets/javascripts/studio/sticky_table_header.js
|
|
175
176
|
- app/assets/javascripts/studio/studio_confetti.js
|
|
176
177
|
- app/assets/stylesheets/studio/sticky_table_header.css
|
|
@@ -178,6 +179,7 @@ files:
|
|
|
178
179
|
- app/assets/tailwind/studio_engine/engine.css
|
|
179
180
|
- app/controllers/concerns/solana/session_auth.rb
|
|
180
181
|
- app/controllers/concerns/studio/admin_models.rb
|
|
182
|
+
- app/controllers/concerns/studio/board/reorderable.rb
|
|
181
183
|
- app/controllers/concerns/studio/error_handling.rb
|
|
182
184
|
- app/controllers/concerns/studio/impersonation.rb
|
|
183
185
|
- app/controllers/concerns/studio/link_consumption.rb
|
|
@@ -205,6 +207,7 @@ files:
|
|
|
205
207
|
- app/mailers/application_mailer.rb
|
|
206
208
|
- app/mailers/user_mailer.rb
|
|
207
209
|
- app/models/concerns/sluggable.rb
|
|
210
|
+
- app/models/concerns/studio/board/rankable.rb
|
|
208
211
|
- app/models/concerns/studio/broadcastable.rb
|
|
209
212
|
- app/models/current.rb
|
|
210
213
|
- app/models/error_log.rb
|
|
@@ -246,6 +249,7 @@ files:
|
|
|
246
249
|
- app/views/sessions/_sso_continue.html.erb
|
|
247
250
|
- app/views/sessions/new.html.erb
|
|
248
251
|
- app/views/studio/_age_verify_assets.html.erb
|
|
252
|
+
- app/views/studio/_board_assets.html.erb
|
|
249
253
|
- app/views/studio/_confirm_interstitial.html.erb
|
|
250
254
|
- app/views/studio/_cropper_assets.html.erb
|
|
251
255
|
- app/views/studio/_leveling_activity_assets.html.erb
|
|
@@ -259,6 +263,9 @@ files:
|
|
|
259
263
|
- app/views/studio/banners/_email_status_button.html.erb
|
|
260
264
|
- app/views/studio/banners/_environment.html.erb
|
|
261
265
|
- app/views/studio/banners/_impersonation.html.erb
|
|
266
|
+
- app/views/studio/board/_board.html.erb
|
|
267
|
+
- app/views/studio/board/_card_shell.html.erb
|
|
268
|
+
- app/views/studio/board/_column.html.erb
|
|
262
269
|
- app/views/studio/email_images/index.html.erb
|
|
263
270
|
- app/views/studio/links/confirm.html.erb
|
|
264
271
|
- app/views/studio/local_emails/index.html.erb
|
|
@@ -299,6 +306,7 @@ files:
|
|
|
299
306
|
- app/views/style/_tasks.html.erb
|
|
300
307
|
- app/views/style/_theme.html.erb
|
|
301
308
|
- app/views/style/_tricks.html.erb
|
|
309
|
+
- app/views/style/board/_demo_card.html.erb
|
|
302
310
|
- app/views/style/index.html.erb
|
|
303
311
|
- app/views/style/modals/_age_verify.html.erb
|
|
304
312
|
- app/views/style/modals/_auth.html.erb
|