studio-engine 0.27.0 → 0.29.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 +37 -0
- data/app/controllers/concerns/studio/board/reorderable.rb +64 -18
- data/app/models/concerns/studio/board/rankable.rb +43 -12
- data/app/views/studio/_board_assets.html.erb +68 -2
- data/app/views/studio/board/_board.html.erb +96 -19
- data/app/views/studio/board/_card_shell.html.erb +9 -1
- data/app/views/studio/board/_column.html.erb +45 -3
- data/app/views/style/_tasks.html.erb +115 -2
- data/app/views/style/board/_demo_card.html.erb +6 -2
- data/lib/studio/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b93ed19a9ea116acfe2d15bfcdd7cabc9abcf6ae2de9a63bfdcfc6d412d60e73
|
|
4
|
+
data.tar.gz: 9a98b2c22d817cddc642df050e1f9ecc34dfd87d9d87e4730b735774fec579b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d6cc744ebba89724d9811785d79a954d43b9cd9827d8fc5f86780cd465312668215b31a989cbb1a57c973ad74507600d7b4ae9179c912780c5beffa073c2f76
|
|
7
|
+
data.tar.gz: 39cace3ee05070698a8f06cf1c4463e03cf8bd8fdc97d2ad6b1b5d158a87fc23425e8618f09335034e5dfb04a70a5529b00c9a9743cedd001d6e9f6fe57204a4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
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.29.0 — 2026-07-29
|
|
6
|
+
|
|
7
|
+
**The four depth-chart gaps folded into the `studio/board` primitive (Phase D), so
|
|
8
|
+
the MS depth chart can rebase onto it like `/tasks`, news, and content did.** The
|
|
9
|
+
0.28.0 primitive fit the near-identical kanban boards; the depth chart is more
|
|
10
|
+
custom — it ranks by a `depth` column (1 = starter) while its `position` column
|
|
11
|
+
holds a lane-code string, lays its lanes out in a two-level side→position grid, and
|
|
12
|
+
pins locked starters. Each capability is **additive and opt-in — every hook omitted
|
|
13
|
+
renders and ranks exactly as 0.28.0**, so `/tasks`, news, and content are untouched.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **DG1 — configurable rank column + sort direction** (`Studio::Board::Rankable`):
|
|
18
|
+
`board_rank_attr` (default `:position`) and `board_rank_order` (default `:desc`)
|
|
19
|
+
thread through `board_ordered`, `board_next_position`, `set_initial_position`, and
|
|
20
|
+
`reposition!`. A depth chart sets `board_rank_attr = :depth`, `board_rank_order =
|
|
21
|
+
:asc` (depth 1 on top). The order fragment quotes the column and whitelists the
|
|
22
|
+
ASC/DESC literal — never user input.
|
|
23
|
+
- **DG2 — sequential 1..N ranks**: `reposition!(ids, gap: 1, direction: :asc)` stamps
|
|
24
|
+
`1, 2, 3, … N` (the depth numbers) instead of the 100-gapped default.
|
|
25
|
+
- **DG3 — two-level side→position grid**: `studio/board/_board` accepts `groups:`
|
|
26
|
+
(`[{ key:, label:, cols_class:, columns: [...] }]`) and renders each group as a
|
|
27
|
+
labelled section whose lanes lay out in a responsive grid; `studio/board/_column`
|
|
28
|
+
gains `layout: "grid"` (drops the flex-row width utilities so the grid cell sizes
|
|
29
|
+
the column). Omit `groups:` for the unchanged flat row.
|
|
30
|
+
- **DG4 — per-card lock/pin**: `reposition!(skip_locked: true, lock_attr: :locked)`
|
|
31
|
+
leaves a pinned entry's rank untouched; `Studio::Board::Reorderable` gains the
|
|
32
|
+
matching `board_reorderable(rank_attr:, skip_locked:, lock_attr:)` config plus a
|
|
33
|
+
`board_toggle_lock` action (config-gated, `rescue_and_log(target:)` discipline,
|
|
34
|
+
`{ ok:, locked: }`); `studio/board/_card_shell` accepts `locked: true` (adds
|
|
35
|
+
`.kanban-locked` + `data-locked`); the `studioBoard` factory takes a
|
|
36
|
+
`lockedSelector` opt that filters locked cards from dragging and refuses a move
|
|
37
|
+
that would cross one, so a pinned starter keeps its slot while the rest reflow.
|
|
38
|
+
- **`/admin/style`** gains a third live board specimen — the depth-chart shape:
|
|
39
|
+
Offense/Defense sections, position lanes in a grid, within-lane reorder, and 🔒
|
|
40
|
+
pinned starters.
|
|
41
|
+
|
|
5
42
|
## 0.27.0 — 2026-07-28
|
|
6
43
|
|
|
7
44
|
**Two coordinated modal flows on the `/admin/style` Design System page — a walked
|
|
@@ -28,21 +28,31 @@ module Studio
|
|
|
28
28
|
extend ActiveSupport::Concern
|
|
29
29
|
|
|
30
30
|
included do
|
|
31
|
-
class_attribute :board_reorder_model,
|
|
32
|
-
class_attribute :board_reorder_id_attr,
|
|
33
|
-
class_attribute :board_reorder_param,
|
|
34
|
-
class_attribute :board_reorder_gap,
|
|
35
|
-
class_attribute :board_reorder_direction,
|
|
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
|
+
# DG1/DG2/DG4 — optional column + lock config. Defaults keep the exact 0.28.0
|
|
37
|
+
# kanban reorder: rank by the model's board_rank_attr, no lock skip. A depth
|
|
38
|
+
# chart passes gap: 1, direction: :asc (1..N depths) + skip_locked: true.
|
|
39
|
+
class_attribute :board_reorder_rank_attr, instance_accessor: false, default: nil
|
|
40
|
+
class_attribute :board_reorder_skip_locked, instance_accessor: false, default: false
|
|
41
|
+
class_attribute :board_reorder_lock_attr, instance_accessor: false, default: :locked
|
|
36
42
|
end
|
|
37
43
|
|
|
38
44
|
class_methods do
|
|
39
45
|
# Configure the reorder action for this controller's board.
|
|
40
|
-
def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc
|
|
41
|
-
|
|
42
|
-
self.
|
|
43
|
-
self.
|
|
44
|
-
self.
|
|
45
|
-
self.
|
|
46
|
+
def board_reorderable(model:, id_attr: :slug, param: :slugs, gap: 100, direction: :desc,
|
|
47
|
+
rank_attr: nil, skip_locked: false, lock_attr: :locked)
|
|
48
|
+
self.board_reorder_model = model
|
|
49
|
+
self.board_reorder_id_attr = id_attr
|
|
50
|
+
self.board_reorder_param = param
|
|
51
|
+
self.board_reorder_gap = gap
|
|
52
|
+
self.board_reorder_direction = direction
|
|
53
|
+
self.board_reorder_rank_attr = rank_attr
|
|
54
|
+
self.board_reorder_skip_locked = skip_locked
|
|
55
|
+
self.board_reorder_lock_attr = lock_attr
|
|
46
56
|
end
|
|
47
57
|
end
|
|
48
58
|
|
|
@@ -72,23 +82,59 @@ module Studio
|
|
|
72
82
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
73
83
|
end
|
|
74
84
|
|
|
85
|
+
# POST — flip a card's lock flag (DG4). A locked entry is a pinned card the
|
|
86
|
+
# reorder skips (a confirmed depth-chart starter). Config-gated on the
|
|
87
|
+
# board_reorderable model + lock_attr; mirrors the MS depth-chart toggle_lock,
|
|
88
|
+
# INCLUDING its rescue_and_log(target: record) backend-write discipline, and
|
|
89
|
+
# the same respond_to? degrade the reorder action uses. Route it however the
|
|
90
|
+
# app names the endpoint (e.g. `post "…/:id/toggle_lock"`).
|
|
91
|
+
def board_toggle_lock
|
|
92
|
+
model = self.class.board_reorder_model
|
|
93
|
+
raise "board_reorderable model not configured" unless model
|
|
94
|
+
|
|
95
|
+
id_attr = self.class.board_reorder_id_attr
|
|
96
|
+
lock_attr = self.class.board_reorder_lock_attr
|
|
97
|
+
record = model.find_by(id_attr => params[:id])
|
|
98
|
+
return render(json: { error: "not found" }, status: :not_found) unless record
|
|
99
|
+
|
|
100
|
+
flip = -> { record.update!(lock_attr => !record.public_send(lock_attr)) }
|
|
101
|
+
if respond_to?(:rescue_and_log)
|
|
102
|
+
rescue_and_log(target: record) { flip.call }
|
|
103
|
+
else
|
|
104
|
+
flip.call
|
|
105
|
+
end
|
|
106
|
+
render json: { ok: true, locked: record.public_send(lock_attr) }
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
109
|
+
end
|
|
110
|
+
|
|
75
111
|
private
|
|
76
112
|
|
|
77
113
|
# Delegate to the model's Rankable#reposition! when available (the shared
|
|
78
|
-
#
|
|
79
|
-
#
|
|
114
|
+
# rank rule), else fall back to the same per-id update_all loop inline so a
|
|
115
|
+
# model that only carries a rank column still reorders. Threads the DG1 rank
|
|
116
|
+
# column and DG4 lock-skip config through both paths.
|
|
80
117
|
def board_reorder_apply(model, ids)
|
|
81
|
-
id_attr
|
|
82
|
-
gap
|
|
83
|
-
direction
|
|
118
|
+
id_attr = self.class.board_reorder_id_attr
|
|
119
|
+
gap = self.class.board_reorder_gap
|
|
120
|
+
direction = self.class.board_reorder_direction
|
|
121
|
+
rank_attr = self.class.board_reorder_rank_attr
|
|
122
|
+
skip_locked = self.class.board_reorder_skip_locked
|
|
123
|
+
lock_attr = self.class.board_reorder_lock_attr
|
|
84
124
|
|
|
85
125
|
if model.respond_to?(:reposition!)
|
|
86
|
-
|
|
126
|
+
kwargs = { gap: gap, direction: direction, id_attr: id_attr,
|
|
127
|
+
skip_locked: skip_locked, lock_attr: lock_attr }
|
|
128
|
+
kwargs[:rank_attr] = rank_attr if rank_attr
|
|
129
|
+
model.reposition!(ids, **kwargs)
|
|
87
130
|
else
|
|
131
|
+
rank_col = rank_attr || :position
|
|
88
132
|
length = ids.length
|
|
89
133
|
ids.each_with_index do |id, index|
|
|
90
134
|
rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
|
|
91
|
-
model.where(id_attr => id)
|
|
135
|
+
rel = model.where(id_attr => id)
|
|
136
|
+
rel = rel.where.not(lock_attr => true) if skip_locked
|
|
137
|
+
rel.update_all(rank_col => rank)
|
|
92
138
|
end
|
|
93
139
|
end
|
|
94
140
|
end
|
|
@@ -21,7 +21,9 @@ module Studio
|
|
|
21
21
|
#
|
|
22
22
|
# class DepthChartEntry < ApplicationRecord
|
|
23
23
|
# include Studio::Board::Rankable
|
|
24
|
-
# self.board_zone_attr = :
|
|
24
|
+
# self.board_zone_attr = :position # a lane (QB/RB/…), not a kanban stage
|
|
25
|
+
# self.board_rank_attr = :depth # rank by depth; `position` is a string
|
|
26
|
+
# self.board_rank_order = :asc # depth 1 (the starter) sits on top
|
|
25
27
|
# end
|
|
26
28
|
module Rankable
|
|
27
29
|
extend ActiveSupport::Concern
|
|
@@ -31,19 +33,37 @@ module Studio
|
|
|
31
33
|
# boards (stage); a within-lane board overrides it, and `nil` ranks globally.
|
|
32
34
|
class_attribute :board_zone_attr, instance_accessor: false, default: :stage
|
|
33
35
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
# The column the board RANKS BY. Default :position — the three MS kanban
|
|
37
|
+
# boards and the 100-gap read-model. A board whose rank lives in another
|
|
38
|
+
# column overrides it: the depth chart ranks by `depth` while its `position`
|
|
39
|
+
# column holds a lane-code STRING, so every ranking read/write below follows
|
|
40
|
+
# `board_rank_attr` rather than a hardcoded `position`.
|
|
41
|
+
class_attribute :board_rank_attr, instance_accessor: false, default: :position
|
|
42
|
+
|
|
43
|
+
# The board_ordered sort direction for the rank column. :desc (default) puts
|
|
44
|
+
# the HIGHEST rank on top — the kanban freshest-first shape; :asc puts the
|
|
45
|
+
# LOWEST on top so depth 1 (the starter) sits at the top of its lane.
|
|
46
|
+
class_attribute :board_rank_order, instance_accessor: false, default: :desc
|
|
47
|
+
|
|
48
|
+
# Board order: the rank column first (direction per board_rank_order), NULLS
|
|
49
|
+
# last, then newest by created_at as a stable tiebreak. The column name is a
|
|
50
|
+
# quoted identifier and the direction a whitelisted ASC/DESC literal — never
|
|
51
|
+
# user input — so the Arel.sql fragment is injection-safe.
|
|
52
|
+
scope :board_ordered, lambda {
|
|
53
|
+
dir = board_rank_order.to_s.casecmp("asc").zero? ? "ASC" : "DESC"
|
|
54
|
+
col = connection.quote_column_name(board_rank_attr)
|
|
55
|
+
order(Arel.sql("#{col} #{dir} NULLS LAST, created_at DESC"))
|
|
56
|
+
}
|
|
38
57
|
end
|
|
39
58
|
|
|
40
|
-
# Seed the genesis rank on create: max(
|
|
41
|
-
# a 100 gap. A no-op when
|
|
59
|
+
# Seed the genesis rank on create: max(rank column) within this record's zone
|
|
60
|
+
# plus a 100 gap. A no-op when the rank is already set, so an explicit rank is
|
|
42
61
|
# never clobbered. Wire from the host via `before_create :set_initial_position`.
|
|
43
62
|
def set_initial_position
|
|
44
|
-
|
|
63
|
+
rank_attr = self.class.board_rank_attr
|
|
64
|
+
return if public_send(rank_attr).present?
|
|
45
65
|
|
|
46
|
-
|
|
66
|
+
public_send("#{rank_attr}=", self.class.board_next_position(zone_value_for_rank))
|
|
47
67
|
end
|
|
48
68
|
|
|
49
69
|
# This record's zone value (nil when the model ranks globally).
|
|
@@ -58,7 +78,7 @@ module Studio
|
|
|
58
78
|
# from here, so the 100-gap rule lives in one place.
|
|
59
79
|
def board_next_position(zone_value = nil, gap: 100)
|
|
60
80
|
scope = board_zone_attr && zone_value ? where(board_zone_attr => zone_value) : all
|
|
61
|
-
(scope.maximum(
|
|
81
|
+
(scope.maximum(board_rank_attr) || 0) + gap
|
|
62
82
|
end
|
|
63
83
|
|
|
64
84
|
# Restamp a column top-to-bottom in ONE pass. `ids` arrive in DOM order (top
|
|
@@ -69,12 +89,23 @@ module Studio
|
|
|
69
89
|
# (first id smallest) for an ascending board. This is exactly the per-id
|
|
70
90
|
# `update_all(position: ...)` loop the MS tasks/news/content reorder actions
|
|
71
91
|
# each ran by hand, lifted into the model. Returns the ids it stamped.
|
|
72
|
-
|
|
92
|
+
#
|
|
93
|
+
# DG2 — SEQUENTIAL 1..N RANKS: `gap: 1, direction: :asc` stamps 1, 2, 3, … N
|
|
94
|
+
# (the depth chart's depth numbers, 1 = starter), not the 100-gapped default.
|
|
95
|
+
# DG1 — a board that ranks by another column passes `rank_attr:` (defaults to
|
|
96
|
+
# the model's `board_rank_attr`, so a configured model needs nothing here).
|
|
97
|
+
# DG4 — `skip_locked: true` leaves a PINNED entry untouched: its rank column is
|
|
98
|
+
# not written, so a locked starter keeps its depth while the others reflow
|
|
99
|
+
# around it. The lock flag column is `lock_attr` (default :locked).
|
|
100
|
+
def reposition!(ids, gap: 100, direction: :desc, id_attr: primary_key,
|
|
101
|
+
rank_attr: board_rank_attr, skip_locked: false, lock_attr: :locked)
|
|
73
102
|
ids = Array(ids)
|
|
74
103
|
length = ids.length
|
|
75
104
|
ids.each_with_index do |id, index|
|
|
76
105
|
rank = direction.to_sym == :asc ? (index + 1) * gap : (length - index) * gap
|
|
77
|
-
where(id_attr => id)
|
|
106
|
+
rel = where(id_attr => id)
|
|
107
|
+
rel = rel.where.not(lock_attr => true) if skip_locked
|
|
108
|
+
rel.update_all(rank_attr => rank)
|
|
78
109
|
end
|
|
79
110
|
ids
|
|
80
111
|
end
|
|
@@ -48,9 +48,20 @@
|
|
|
48
48
|
onMoveHook: opts.onMoveHook || null,
|
|
49
49
|
onDropHook: opts.onDropHook || null,
|
|
50
50
|
labels: opts.labels || {},
|
|
51
|
+
// gap 3: the column key a data-exit-action="archive" remove re-parents INTO
|
|
52
|
+
// (its #dropzone-<key>) rather than deleting the card. null ⇒ 0.27.0 removal.
|
|
53
|
+
archiveZone: opts.archiveZone || null,
|
|
54
|
+
// DG4: selector for PINNED cards (a locked depth-chart starter). When set,
|
|
55
|
+
// locked cards are filtered from dragging AND a drag can't cross one, so a
|
|
56
|
+
// pinned card keeps its slot while the rest reflow. null ⇒ every card drags.
|
|
57
|
+
lockedSelector: opts.lockedSelector || null,
|
|
51
58
|
|
|
52
59
|
// --- state ---------------------------------------------------------
|
|
53
60
|
toasts: [],
|
|
61
|
+
// gap 1: board-level chrome state lives HERE, on the studioBoard scope, so a
|
|
62
|
+
// board's header_slot controls + column show_expr bind to it directly — no
|
|
63
|
+
// bespoke outer Alpine wrapper. Seeded from board_state; {} when omitted.
|
|
64
|
+
state: (opts.state && typeof opts.state === "object") ? opts.state : {},
|
|
54
65
|
|
|
55
66
|
// --- init ----------------------------------------------------------
|
|
56
67
|
init: function () {
|
|
@@ -75,6 +86,19 @@
|
|
|
75
86
|
zoneKey: function (el) { return el ? el.getAttribute("data-" + this.zoneAttr) : null; },
|
|
76
87
|
label: function (zone) { return this.labels[zone] || zone; },
|
|
77
88
|
|
|
89
|
+
// --- board-chrome state helpers (gap 1) ----------------------------
|
|
90
|
+
// Generic, domain-free operations over `state` so a board's header_slot /
|
|
91
|
+
// column show_expr drive filters + toggles without a bespoke wrapper.
|
|
92
|
+
toggleState: function (key) { this.state[key] = !this.state[key]; },
|
|
93
|
+
listHas: function (key, val) {
|
|
94
|
+
return Array.isArray(this.state[key]) && this.state[key].indexOf(val) !== -1;
|
|
95
|
+
},
|
|
96
|
+
toggleInState: function (key, val) {
|
|
97
|
+
if (!Array.isArray(this.state[key])) this.state[key] = [];
|
|
98
|
+
var i = this.state[key].indexOf(val);
|
|
99
|
+
if (i === -1) { this.state[key].push(val); } else { this.state[key].splice(i, 1); }
|
|
100
|
+
},
|
|
101
|
+
|
|
78
102
|
// --- SortableJS wiring ---------------------------------------------
|
|
79
103
|
initSortables: function () {
|
|
80
104
|
if (typeof Sortable === "undefined") return;
|
|
@@ -84,7 +108,9 @@
|
|
|
84
108
|
animation: 150,
|
|
85
109
|
ghostClass: "opacity-30",
|
|
86
110
|
draggable: self.cardSelector,
|
|
87
|
-
|
|
111
|
+
// DG4: locked cards join the undraggable filter so a pinned entry
|
|
112
|
+
// can't be picked up (empty state was already filtered).
|
|
113
|
+
filter: self.lockedSelector ? (self.sortFilter + ", " + self.lockedSelector) : self.sortFilter,
|
|
88
114
|
onStart: function () { window.__studioBoardDragging = true; },
|
|
89
115
|
onEnd: function (evt) {
|
|
90
116
|
self.handleSortEnd(evt);
|
|
@@ -93,6 +119,13 @@
|
|
|
93
119
|
};
|
|
94
120
|
if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
|
|
95
121
|
if (self.handle) cfg.handle = self.handle;
|
|
122
|
+
if (self.lockedSelector) {
|
|
123
|
+
// DG4: pin locked cards — refuse a move that would cross a locked
|
|
124
|
+
// sibling, so a dragged card can't leapfrog a pinned starter.
|
|
125
|
+
cfg.onMove = function (evt) {
|
|
126
|
+
return !(evt.related && evt.related.matches && evt.related.matches(self.lockedSelector));
|
|
127
|
+
};
|
|
128
|
+
}
|
|
96
129
|
Sortable.create(zone, cfg);
|
|
97
130
|
});
|
|
98
131
|
},
|
|
@@ -271,6 +304,21 @@
|
|
|
271
304
|
var card = document.getElementById(target);
|
|
272
305
|
if (!card) { renderNow(el); return; }
|
|
273
306
|
self.animateCardExit(card, exitAction).then(function () {
|
|
307
|
+
// gap 3: an `archive` exit RE-PARENTS the card into the archive
|
|
308
|
+
// column (#dropzone-<archiveZone>) instead of vanishing, when the
|
|
309
|
+
// board named one and it exists. Everything else falls through to
|
|
310
|
+
// Turbo's remove (the 0.27.0 behavior).
|
|
311
|
+
if (exitAction === "archive" && self.archiveZone) {
|
|
312
|
+
var zone = document.getElementById("dropzone-" + self.archiveZone);
|
|
313
|
+
if (zone) {
|
|
314
|
+
var anchor = self.emptySelector ? zone.querySelector(self.emptySelector) : null;
|
|
315
|
+
zone.insertBefore(card, anchor);
|
|
316
|
+
self.setCardZone(card, self.archiveZone);
|
|
317
|
+
self.resetCardExit(card);
|
|
318
|
+
self.updateCounts();
|
|
319
|
+
return; // keep the card in the archive column — do NOT remove it
|
|
320
|
+
}
|
|
321
|
+
}
|
|
274
322
|
renderNow(el);
|
|
275
323
|
self.updateCounts();
|
|
276
324
|
});
|
|
@@ -279,7 +327,13 @@
|
|
|
279
327
|
},
|
|
280
328
|
|
|
281
329
|
animateCardExit: function (card, kind) {
|
|
282
|
-
if (!card
|
|
330
|
+
if (!card) return Promise.resolve();
|
|
331
|
+
// gap 4: stamp an observable marker for WHICH exit is running (archive vs
|
|
332
|
+
// delete). Set before the reduced-motion guard so the marker is present
|
|
333
|
+
// even when the animation itself is skipped — the re-parent branch and any
|
|
334
|
+
// e2e both key off it.
|
|
335
|
+
card.dataset.exitAction = kind;
|
|
336
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return Promise.resolve();
|
|
283
337
|
card.style.pointerEvents = "none";
|
|
284
338
|
var frames = kind === "archive"
|
|
285
339
|
? [{ opacity: 1, transform: "translateY(0) scale(1)" }, { opacity: 0, transform: "translateY(18px) scale(0.86)" }]
|
|
@@ -292,6 +346,18 @@
|
|
|
292
346
|
return anim.finished.catch(function () {});
|
|
293
347
|
},
|
|
294
348
|
|
|
349
|
+
// Clear an exit animation + its marker so a re-parented card is fully
|
|
350
|
+
// visible again in the archive column (used by the gap-3 re-parent path).
|
|
351
|
+
resetCardExit: function (card) {
|
|
352
|
+
if (!card) return;
|
|
353
|
+
card.getAnimations({ subtree: false }).forEach(function (a) { a.cancel(); });
|
|
354
|
+
delete card.dataset.exitAction;
|
|
355
|
+
card.style.pointerEvents = "";
|
|
356
|
+
card.style.opacity = "";
|
|
357
|
+
card.style.transform = "";
|
|
358
|
+
card.style.filter = "";
|
|
359
|
+
},
|
|
360
|
+
|
|
295
361
|
// --- toasts --------------------------------------------------------
|
|
296
362
|
toast: function (message, type) {
|
|
297
363
|
if (!this.toastsEnabled) {
|
|
@@ -19,10 +19,43 @@
|
|
|
19
19
|
studio/board/_column (the `.kanban-dropzone`) and studio/board/_card_shell (the
|
|
20
20
|
`.kanban-card`) — an app may hand-roll a bespoke card as long as it matches.
|
|
21
21
|
|
|
22
|
+
CHROME COMPOSITION (the recipe that replaces a bespoke outer Alpine wrapper) —
|
|
23
|
+
a board with filters / toggles no longer needs to wrap this primitive in its own
|
|
24
|
+
x-data component. Everything the chrome needs lives ON the studioBoard scope:
|
|
25
|
+
1. `board_state:` seeds a reactive bag on the factory (`state`). Pass the
|
|
26
|
+
chrome's flags/lists, e.g. { showArchived: false, hiddenApps: [] }.
|
|
27
|
+
2. `header_slot:` (and `above_board_slot:`) render INSIDE the studioBoard
|
|
28
|
+
section, so their controls bind straight to that state and the generic
|
|
29
|
+
helpers the factory exposes: `toggleState('showArchived')`,
|
|
30
|
+
`toggleInState('hiddenApps', repo)`, `listHas('hiddenApps', repo)`.
|
|
31
|
+
3. A toggle-able column reads the same state via its `show_expr:`
|
|
32
|
+
(e.g. show_expr: "state.showArchived"); cards x-show off `listHas(...)`.
|
|
33
|
+
4. An Archived column that a stage→archived broadcast should re-parent INTO
|
|
34
|
+
(not delete) is named by the board's `archive_zone:`.
|
|
35
|
+
|
|
36
|
+
TWO-LEVEL GRID (DG3, the depth-chart shape) — a board whose columns group into
|
|
37
|
+
labelled SECTIONS laid out as a grid (offense/defense/special_teams, each a grid
|
|
38
|
+
of position lanes) passes `groups:` instead of a flat `columns:` row. Each group
|
|
39
|
+
renders a header + a responsive grid of its columns (the same studio/board/column,
|
|
40
|
+
layout: "grid"). Omit `groups:` and the board renders the 0.28.0 flat row unchanged.
|
|
41
|
+
|
|
22
42
|
locals:
|
|
23
|
-
columns: (req) [{ key:, label:, cards:, count:,
|
|
24
|
-
collapsible:, hidden:,
|
|
25
|
-
|
|
43
|
+
columns: (req unless `groups:`) [{ key:, label:, cards:, count:,
|
|
44
|
+
badge_class:, count_class:, collapsible:, hidden:, show_expr:,
|
|
45
|
+
min_width:, kickoff:, header_extra: }]. `cards` are records
|
|
46
|
+
passed to `card_partial`; `count` defaults to cards.size. See
|
|
47
|
+
studio/board/_column for the additive per-column hooks
|
|
48
|
+
(count_class / show_expr / min_width / kickoff).
|
|
49
|
+
groups: DG3 — [{ key:, label:, cols_class:, columns: [<column hash>] }]
|
|
50
|
+
renders a two-level side→position GRID: each group is a labelled
|
|
51
|
+
section whose `columns` lay out in a grid (`cols_class` overrides
|
|
52
|
+
the responsive default). Takes precedence over the flat `columns`
|
|
53
|
+
row; the factory's labels merge across every group's columns.
|
|
54
|
+
locked_selector: DG4 — CSS selector for PINNED cards (default ".kanban-locked",
|
|
55
|
+
emitted by studio/board/_card_shell `locked: true`). When set,
|
|
56
|
+
the studioBoard factory filters those cards from dragging and
|
|
57
|
+
won't let a drag cross them, so a locked starter stays put. nil
|
|
58
|
+
keeps every card draggable (0.28.0).
|
|
26
59
|
card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
|
|
27
60
|
card_as: local the record binds to in card_partial. Default :card.
|
|
28
61
|
card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
|
|
@@ -50,13 +83,22 @@
|
|
|
50
83
|
empty_label: empty-zone text. Default "Drop here".
|
|
51
84
|
header_slot: raw HTML rendered above the columns (pass html_safe content).
|
|
52
85
|
above_board_slot: raw HTML rendered between the header and the columns.
|
|
86
|
+
board_state: Hash seeding the factory's reactive `state` bag (board-level
|
|
87
|
+
chrome: filter/toggle flags + lists). nil ⇒ empty {}.
|
|
88
|
+
archive_zone: column key a `remove` exit-stream marked data-exit-action=
|
|
89
|
+
"archive" RE-PARENTS the card into (its #dropzone-<key>),
|
|
90
|
+
instead of deleting it. nil ⇒ the card is removed (0.27.0).
|
|
53
91
|
fx: window[<name>] module the factory defers add/exit anims to.
|
|
54
92
|
on_move_hook: window[<name>] opaque fn, called with { record, from, to }.
|
|
55
93
|
on_drop_hook: window[<name>] opaque fn, called with { ids, zone }.
|
|
56
94
|
demo: style-guide preview — drag works locally, no network POST.
|
|
57
95
|
%>
|
|
58
96
|
<%
|
|
59
|
-
|
|
97
|
+
groups = local_assigns.fetch(:groups, nil) # DG3: two-level side→position grid
|
|
98
|
+
columns = local_assigns.fetch(:columns, nil)
|
|
99
|
+
# The factory's label map + the flat fallback both need every column; a grouped
|
|
100
|
+
# board flattens its groups' columns, a flat board uses `columns` directly.
|
|
101
|
+
all_columns = groups ? groups.flat_map { |g| Array(g[:columns]) } : (columns || [])
|
|
60
102
|
card_partial = local_assigns.fetch(:card_partial)
|
|
61
103
|
card_as = local_assigns.fetch(:card_as, :card)
|
|
62
104
|
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
@@ -79,6 +121,9 @@
|
|
|
79
121
|
empty_label = local_assigns.fetch(:empty_label, "Drop here")
|
|
80
122
|
header_slot = local_assigns.fetch(:header_slot, nil)
|
|
81
123
|
above_board_slot = local_assigns.fetch(:above_board_slot, nil)
|
|
124
|
+
board_state = local_assigns.fetch(:board_state, nil) # gap 1: chrome-state home
|
|
125
|
+
archive_zone = local_assigns.fetch(:archive_zone, nil) # gap 3: exit re-parent target
|
|
126
|
+
locked_selector = local_assigns.fetch(:locked_selector, nil) # DG4: pinned-card selector
|
|
82
127
|
fx = local_assigns.fetch(:fx, nil)
|
|
83
128
|
on_move_hook = local_assigns.fetch(:on_move_hook, nil)
|
|
84
129
|
on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
|
|
@@ -102,10 +147,13 @@
|
|
|
102
147
|
toasts: !!toasts,
|
|
103
148
|
live: live_channel.present?,
|
|
104
149
|
demo: !!demo,
|
|
150
|
+
state: (board_state.is_a?(Hash) ? board_state : nil),
|
|
151
|
+
archiveZone: (archive_zone ? archive_zone.to_s : nil),
|
|
152
|
+
lockedSelector: (locked_selector ? locked_selector.to_s : nil),
|
|
105
153
|
fx: fx,
|
|
106
154
|
onMoveHook: on_move_hook,
|
|
107
155
|
onDropHook: on_drop_hook,
|
|
108
|
-
labels:
|
|
156
|
+
labels: all_columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
|
|
109
157
|
}
|
|
110
158
|
%>
|
|
111
159
|
<section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
|
|
@@ -114,20 +162,49 @@
|
|
|
114
162
|
<% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
|
|
115
163
|
<% if above_board_slot %><%= above_board_slot %><% end %>
|
|
116
164
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
165
|
+
<% if groups %>
|
|
166
|
+
<%# DG3: two-level side→position grid — each group a labelled section, its
|
|
167
|
+
columns in a responsive grid (the same studio/board/column, layout: grid). %>
|
|
168
|
+
<div class="studio-board-grid space-y-8">
|
|
169
|
+
<% groups.each do |group| %>
|
|
170
|
+
<section class="studio-board-group" data-board-group="<%= group[:key] %>">
|
|
171
|
+
<% if group[:label] %>
|
|
172
|
+
<h2 class="mb-3 text-xs font-bold uppercase tracking-widest text-secondary"><%= group[:label] %></h2>
|
|
173
|
+
<% end %>
|
|
174
|
+
<div class="grid gap-4 <%= group[:cols_class] || "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4" %>">
|
|
175
|
+
<% Array(group[:columns]).each do |column| %>
|
|
176
|
+
<%= render "studio/board/column",
|
|
177
|
+
column: column,
|
|
178
|
+
layout: "grid",
|
|
179
|
+
zone_attr: zone_attr,
|
|
180
|
+
dom_id_prefix: dom_id_prefix,
|
|
181
|
+
zone_selector_class: zone_selector.delete_prefix("."),
|
|
182
|
+
empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
|
|
183
|
+
empty_label: empty_label,
|
|
184
|
+
card_partial: card_partial,
|
|
185
|
+
card_as: card_as,
|
|
186
|
+
card_locals: card_locals %>
|
|
187
|
+
<% end %>
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
<% end %>
|
|
191
|
+
</div>
|
|
192
|
+
<% else %>
|
|
193
|
+
<div class="flex flex-col gap-4 overflow-x-visible pb-4 sm:flex-row sm:overflow-x-auto">
|
|
194
|
+
<% (columns || []).each do |column| %>
|
|
195
|
+
<%= render "studio/board/column",
|
|
196
|
+
column: column,
|
|
197
|
+
zone_attr: zone_attr,
|
|
198
|
+
dom_id_prefix: dom_id_prefix,
|
|
199
|
+
zone_selector_class: zone_selector.delete_prefix("."),
|
|
200
|
+
empty_selector_class: (empty_selector || "kanban-empty").delete_prefix("."),
|
|
201
|
+
empty_label: empty_label,
|
|
202
|
+
card_partial: card_partial,
|
|
203
|
+
card_as: card_as,
|
|
204
|
+
card_locals: card_locals %>
|
|
205
|
+
<% end %>
|
|
206
|
+
</div>
|
|
207
|
+
<% end %>
|
|
131
208
|
|
|
132
209
|
<% if toasts %>
|
|
133
210
|
<div class="studio-board-toasts fixed top-4 right-4 z-50 space-y-2" data-test="studio-board-toasts">
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
card_class: the required base class. Default "kanban-card".
|
|
25
25
|
class: extra classes appended after card_class.
|
|
26
26
|
style: inline style string.
|
|
27
|
+
locked: DG4 — a PINNED card the reorder skips (a confirmed depth-chart
|
|
28
|
+
starter). Adds the `.kanban-locked` class the studioBoard factory
|
|
29
|
+
filters from dragging + the `data-locked="true"` marker; false
|
|
30
|
+
(default) renders identically to before. Purely a hook — the lock
|
|
31
|
+
glyph/visual lives in the app's card body.
|
|
27
32
|
attrs: Hash of extra root attributes ({ "x-data" => "…", "data-foo" => 1 }),
|
|
28
33
|
values are HTML-escaped — the rebase seam for a card that carries
|
|
29
34
|
its own Alpine/data-* on the root.
|
|
@@ -37,11 +42,14 @@
|
|
|
37
42
|
card_class = local_assigns.fetch(:card_class, "kanban-card")
|
|
38
43
|
extra_class = local_assigns.fetch(:class, nil)
|
|
39
44
|
inline_style = local_assigns.fetch(:style, nil)
|
|
45
|
+
locked = local_assigns.fetch(:locked, false)
|
|
40
46
|
root_attrs = local_assigns.fetch(:attrs, {}) || {}
|
|
47
|
+
classes = [card_class, ("kanban-locked" if locked), extra_class].compact.join(" ")
|
|
41
48
|
%>
|
|
42
49
|
<div id="<%= dom_id_prefix %><%= id %>"
|
|
43
|
-
class="<%=
|
|
50
|
+
class="<%= classes %>"
|
|
44
51
|
data-<%= id_attr %>="<%= id %>"
|
|
45
52
|
data-<%= zone_attr %>="<%= zone %>"
|
|
53
|
+
<% if locked %>data-locked="true"<% end %>
|
|
46
54
|
<% root_attrs.each do |name, value| %><%= name %>="<%= value %>" <% end %>
|
|
47
55
|
<% if inline_style %>style="<%= inline_style %>"<% end %>><%= yield %></div>
|
|
@@ -12,10 +12,29 @@
|
|
|
12
12
|
|
|
13
13
|
locals:
|
|
14
14
|
column: (req) { key:, label:, cards:, count:, badge_class:,
|
|
15
|
-
collapsible:, hidden:,
|
|
15
|
+
count_class:, collapsible:, hidden:, show_expr:,
|
|
16
|
+
min_width:, kickoff:, header_extra: }. `cards` are the
|
|
16
17
|
records passed to the card partial; `count` defaults to
|
|
17
18
|
cards.size; `badge_class` picks a components/_badge scheme
|
|
18
19
|
for the count (else a plain neutral count chip).
|
|
20
|
+
Additive board-chrome hooks (all opt-in, all default to
|
|
21
|
+
the 0.27.0 rendering when omitted):
|
|
22
|
+
count_class: raw class string colouring the neutral
|
|
23
|
+
count chip (e.g. a task_stage_count_classes value).
|
|
24
|
+
Ignored when badge_class is set. Default neutral chip.
|
|
25
|
+
show_expr: Alpine x-show expression for a toggle-able
|
|
26
|
+
column (binds against the board's studioBoard scope,
|
|
27
|
+
e.g. "state.showArchived"). Adds x-cloak so it can't
|
|
28
|
+
flash before Alpine boots. Independent of `hidden:`.
|
|
29
|
+
min_width: the column's sm min-width utility class.
|
|
30
|
+
Default "sm:min-w-[190px]".
|
|
31
|
+
kickoff: raw HTML rendered in a fixed-height slot
|
|
32
|
+
under the header row (a per-column kickoff chip);
|
|
33
|
+
nil renders nothing.
|
|
34
|
+
layout: DG3 — "row" (default) sizes the column for the horizontal
|
|
35
|
+
flex row (flex-1 + min_width); "grid" drops those so a
|
|
36
|
+
two-level side→position GRID cell owns the sizing. Any
|
|
37
|
+
other value behaves as "row".
|
|
19
38
|
zone_attr: data-attr name for the zone. Default "stage".
|
|
20
39
|
dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
|
|
21
40
|
zone_selector_class: the drop-zone class. Default "kanban-dropzone".
|
|
@@ -27,6 +46,7 @@
|
|
|
27
46
|
%>
|
|
28
47
|
<%
|
|
29
48
|
column = local_assigns.fetch(:column)
|
|
49
|
+
layout = local_assigns.fetch(:layout, "row").to_s
|
|
30
50
|
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
31
51
|
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
32
52
|
zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
|
|
@@ -41,9 +61,23 @@
|
|
|
41
61
|
cards = column[:cards] || []
|
|
42
62
|
count = column.fetch(:count) { cards.size }
|
|
43
63
|
badge_class = column[:badge_class]
|
|
64
|
+
# gap 2: an opt-in raw class colouring the neutral count chip; nil keeps the
|
|
65
|
+
# exact 0.27.0 neutral chip classes.
|
|
66
|
+
count_class = column[:count_class]
|
|
44
67
|
collapsible = column[:collapsible]
|
|
45
68
|
hidden = column[:hidden]
|
|
69
|
+
# gap 5: a bindable visibility expr (x-show), a per-board min-width, and a
|
|
70
|
+
# per-column kickoff slot — each nil/absent renders identically to 0.27.0.
|
|
71
|
+
show_expr = column[:show_expr]
|
|
72
|
+
min_width = column[:min_width] || "sm:min-w-[190px]"
|
|
73
|
+
kickoff = column[:kickoff]
|
|
46
74
|
header_extra = column[:header_extra]
|
|
75
|
+
# The neutral count chip's colour classes: an opt-in raw class, else the 0.27.0
|
|
76
|
+
# neutral default. The layout classes are shared; only the colour band varies.
|
|
77
|
+
count_color = count_class || "bg-surface-alt text-secondary border border-subtle"
|
|
78
|
+
# DG3: in a grid the cell owns the sizing, so drop the flex-row width utilities;
|
|
79
|
+
# "row" (default) keeps the exact 0.28.0 flex-1 + min-width column.
|
|
80
|
+
width_class = layout == "grid" ? "w-full" : "w-full flex-none sm:flex-1 #{min_width}"
|
|
47
81
|
|
|
48
82
|
# Merge the per-card hook (Proc or Hash) with the record binding.
|
|
49
83
|
resolve_locals = lambda do |record|
|
|
@@ -51,21 +85,29 @@
|
|
|
51
85
|
{ card_as => record }.merge(extra || {})
|
|
52
86
|
end
|
|
53
87
|
%>
|
|
54
|
-
<div class="studio-board-column
|
|
88
|
+
<div class="studio-board-column <%= width_class %>"
|
|
55
89
|
data-board-column="<%= key %>"
|
|
56
90
|
<% if collapsible %>data-board-collapsible="true"<% end %>
|
|
91
|
+
<% if show_expr %>x-show="<%= show_expr %>" x-cloak<% end %>
|
|
57
92
|
<% if hidden %>x-cloak style="display:none"<% end %>>
|
|
58
93
|
<div class="mb-3 px-1 flex items-center gap-2">
|
|
59
94
|
<h3 class="text-sm font-bold text-heading uppercase tracking-wider"><%= label %></h3>
|
|
60
95
|
<% if badge_class %>
|
|
61
96
|
<%= render "components/badge", text: count.to_s, scheme: badge_class, data_board_count: key %>
|
|
62
97
|
<% 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
|
|
98
|
+
<span class="stage-count inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full text-xs font-bold <%= count_color %>"
|
|
64
99
|
data-board-count="<%= key %>"><%= count %></span>
|
|
65
100
|
<% end %>
|
|
66
101
|
<% if header_extra %><%= header_extra %><% end %>
|
|
67
102
|
</div>
|
|
68
103
|
|
|
104
|
+
<%# gap 5: per-column kickoff slot — a fixed-height row under the header so every
|
|
105
|
+
column's dropzone starts at the same y (no height jog) when only some carry a
|
|
106
|
+
chip. Renders nothing when the column passes no kickoff. %>
|
|
107
|
+
<% if kickoff %>
|
|
108
|
+
<div class="studio-board-kickoff mt-2 min-h-[26px]"><%= kickoff %></div>
|
|
109
|
+
<% end %>
|
|
110
|
+
|
|
69
111
|
<div id="dropzone-<%= key %>"
|
|
70
112
|
data-<%= zone_attr %>="<%= key %>"
|
|
71
113
|
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)]">
|
|
@@ -109,6 +109,117 @@
|
|
|
109
109
|
</div>
|
|
110
110
|
</div>
|
|
111
111
|
|
|
112
|
+
<%# ---- The ENRICHED board chrome (0.28.0) ------------------------------ %>
|
|
113
|
+
<%# One specimen that exercises the five additive hooks the /tasks pilot surfaced,
|
|
114
|
+
all opt-in: (1) board_state + header_slot chrome home, (2) a coloured count
|
|
115
|
+
chip via count_class, (3) archive_zone exit re-parent, (4) the exit marker,
|
|
116
|
+
(5) a toggle-able column (show_expr) + a per-column kickoff slot + min_width.
|
|
117
|
+
No outer Alpine wrapper: the Show/Hide-archived toggle in header_slot and the
|
|
118
|
+
archived column's show_expr both bind to the SAME studioBoard state. %>
|
|
119
|
+
<% chrome_header = capture do %>
|
|
120
|
+
<div class="flex items-center gap-3 flex-wrap">
|
|
121
|
+
<button type="button"
|
|
122
|
+
class="px-3 py-1.5 rounded-lg text-sm font-medium transition border border-subtle"
|
|
123
|
+
:class="state.showArchived ? 'bg-primary text-white border-primary' : 'bg-surface text-secondary hover:text-heading'"
|
|
124
|
+
@click="toggleState('showArchived')"
|
|
125
|
+
data-test="chrome-archived-toggle">
|
|
126
|
+
<span x-text="state.showArchived ? 'Hide archived' : 'Show archived'">Show archived</span>
|
|
127
|
+
</button>
|
|
128
|
+
<span class="label-upper"><code class="font-mono text-2xs">header_slot</code> chrome · binds to <code class="font-mono text-2xs">state.showArchived</code> — no outer wrapper</span>
|
|
129
|
+
</div>
|
|
130
|
+
<% end %>
|
|
131
|
+
<% chrome_kickoff = capture do %>
|
|
132
|
+
<span class="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-primary">kickoff slot</span>
|
|
133
|
+
<% end %>
|
|
134
|
+
<%
|
|
135
|
+
# Per-column count_class colours (raw classes, the task_stage_count_classes shape),
|
|
136
|
+
# a kickoff chip + widened min_width on Designed, and a toggle-able Archived column
|
|
137
|
+
# (show_expr) the board re-parents into (archive_zone).
|
|
138
|
+
chrome_columns = [
|
|
139
|
+
{ key: "designed", label: "Designed",
|
|
140
|
+
count_class: "bg-blue-500/10 text-blue-500 border border-blue-500/30",
|
|
141
|
+
kickoff: chrome_kickoff, min_width: "sm:min-w-[15rem]",
|
|
142
|
+
cards: [{ id: "chrome-count-color", zone: "designed", title: "Coloured count chip", repo: "studio-engine", who: "SH" }] },
|
|
143
|
+
{ key: "building", label: "Building",
|
|
144
|
+
count_class: "bg-yellow-500/10 text-yellow-400 border border-yellow-500/30",
|
|
145
|
+
cards: [{ id: "chrome-bindable-col", zone: "building", title: "Bindable columns", repo: "turf-monster", who: "CA" }] },
|
|
146
|
+
{ key: "shipped", label: "Shipped",
|
|
147
|
+
count_class: "bg-emerald-500/10 text-emerald-500 border border-emerald-500/30",
|
|
148
|
+
cards: [{ id: "chrome-exit-marker", zone: "shipped", title: "Exit re-parent marker", repo: "mcritchie", who: "ST" }] },
|
|
149
|
+
{ key: "archived", label: "Archived",
|
|
150
|
+
count_class: "bg-gray-500/10 text-gray-400 border border-gray-500/30",
|
|
151
|
+
show_expr: "state.showArchived",
|
|
152
|
+
cards: [{ id: "chrome-archived-card", zone: "archived", title: "Archived (re-parent target)", repo: "mcritchie", who: "ST" }] }
|
|
153
|
+
]
|
|
154
|
+
%>
|
|
155
|
+
<div class="space-y-3">
|
|
156
|
+
<div class="flex items-center gap-2">
|
|
157
|
+
<span class="badge" style="color: var(--color-primary); border-color: var(--color-primary)">Enriched</span>
|
|
158
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · 0.28.0 chrome — coloured counts, toggle-able column, archive re-parent</p>
|
|
159
|
+
</div>
|
|
160
|
+
<%= render "studio/board/board",
|
|
161
|
+
demo: true,
|
|
162
|
+
columns: chrome_columns,
|
|
163
|
+
card_partial: "style/board/demo_card",
|
|
164
|
+
card_as: :card,
|
|
165
|
+
reorder_url: "#",
|
|
166
|
+
move_url: "#",
|
|
167
|
+
move_param: { resource: "task", attr: "stage" },
|
|
168
|
+
group: "studio-board-chrome",
|
|
169
|
+
board_state: { showArchived: false },
|
|
170
|
+
archive_zone: "archived",
|
|
171
|
+
header_slot: chrome_header,
|
|
172
|
+
empty_label: "Drop tasks here" %>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<%# ---- The DEPTH-CHART shape (0.29.0 / DG) ----------------------------- %>
|
|
176
|
+
<%# The two-level side→position GRID + per-card lock the depth chart needs, all
|
|
177
|
+
additive: groups: renders labelled sections (Offense/Defense) whose position
|
|
178
|
+
lanes lay out in a grid (not the flat row); group: false keeps a drag WITHIN
|
|
179
|
+
its lane; zone_attr "position" is the lane; 🔒 starters carry `locked: true`
|
|
180
|
+
so the factory filters them from dragging (locked_selector) and a drag can't
|
|
181
|
+
cross them. reorder is 1..N (depth) — demonstrated statically here (demo). %>
|
|
182
|
+
<%
|
|
183
|
+
dc_groups = [
|
|
184
|
+
{ key: "offense", label: "Offense", cols_class: "grid-cols-2 sm:grid-cols-3",
|
|
185
|
+
columns: [
|
|
186
|
+
{ key: "QB", label: "QB", cards: [
|
|
187
|
+
{ id: "dc-qb1", zone: "QB", title: "Starter QB", repo: "depth 1", who: "1", locked: true },
|
|
188
|
+
{ id: "dc-qb2", zone: "QB", title: "Backup QB", repo: "depth 2", who: "2" },
|
|
189
|
+
{ id: "dc-qb3", zone: "QB", title: "3rd string", repo: "depth 3", who: "3" } ] },
|
|
190
|
+
{ key: "RB", label: "RB", cards: [
|
|
191
|
+
{ id: "dc-rb1", zone: "RB", title: "RB1", repo: "depth 1", who: "1" },
|
|
192
|
+
{ id: "dc-rb2", zone: "RB", title: "RB2", repo: "depth 2", who: "2" } ] },
|
|
193
|
+
{ key: "WR", label: "WR", cards: [
|
|
194
|
+
{ id: "dc-wr1", zone: "WR", title: "WR1", repo: "depth 1", who: "1" } ] } ] },
|
|
195
|
+
{ key: "defense", label: "Defense", cols_class: "grid-cols-2 sm:grid-cols-3",
|
|
196
|
+
columns: [
|
|
197
|
+
{ key: "EDGE", label: "EDGE", cards: [
|
|
198
|
+
{ id: "dc-edge1", zone: "EDGE", title: "EDGE1", repo: "depth 1", who: "1" },
|
|
199
|
+
{ id: "dc-edge2", zone: "EDGE", title: "EDGE2", repo: "depth 2", who: "2" } ] },
|
|
200
|
+
{ key: "CB", label: "CB", cards: [
|
|
201
|
+
{ id: "dc-cb1", zone: "CB", title: "Starter CB", repo: "depth 1", who: "1", locked: true },
|
|
202
|
+
{ id: "dc-cb2", zone: "CB", title: "CB2", repo: "depth 2", who: "2" } ] } ] }
|
|
203
|
+
]
|
|
204
|
+
%>
|
|
205
|
+
<div class="space-y-3">
|
|
206
|
+
<div class="flex items-center gap-2">
|
|
207
|
+
<span class="badge" style="color: var(--color-warning); border-color: var(--color-warning)">Grid + Lock</span>
|
|
208
|
+
<p class="label-upper"><code class="font-mono text-2xs">studio/board/board</code> · DG (0.29.0) — two-level side→position grid, reorder within a lane, 🔒 pinned starters stay put</p>
|
|
209
|
+
</div>
|
|
210
|
+
<%= render "studio/board/board",
|
|
211
|
+
demo: true,
|
|
212
|
+
groups: dc_groups,
|
|
213
|
+
card_partial: "style/board/demo_card",
|
|
214
|
+
card_as: :card,
|
|
215
|
+
reorder_url: "#",
|
|
216
|
+
group: false,
|
|
217
|
+
zone_attr: "position",
|
|
218
|
+
locked_selector: ".kanban-locked",
|
|
219
|
+
card_locals: { zone_attr: "position" },
|
|
220
|
+
empty_label: "—".html_safe %>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
112
223
|
<%# ---- The static reference sketch ------------------------------------- %>
|
|
113
224
|
<div class="fade-edge-x overflow-x-auto pb-2">
|
|
114
225
|
<div class="flex gap-4 min-w-max">
|
|
@@ -162,9 +273,11 @@
|
|
|
162
273
|
</div>
|
|
163
274
|
<ul class="text-secondary text-sm space-y-1 list-disc list-inside">
|
|
164
275
|
<li>Column layout + horizontal scroll with faded edges</li>
|
|
165
|
-
<li>The <code class="font-mono text-2xs">stage-*</code> badge palette + count chips</li>
|
|
276
|
+
<li>The <code class="font-mono text-2xs">stage-*</code> badge palette + count chips (colour via <code class="font-mono text-2xs">count_class</code>)</li>
|
|
166
277
|
<li>Card chrome (<code class="font-mono text-2xs">.card</code>) and the drag / rank handle</li>
|
|
167
278
|
<li>Empty-column drop state</li>
|
|
279
|
+
<li>Chrome-state home (<code class="font-mono text-2xs">board_state</code> + <code class="font-mono text-2xs">header_slot</code>) — filters/toggles without an outer wrapper</li>
|
|
280
|
+
<li>Toggle-able columns (<code class="font-mono text-2xs">show_expr</code>), a kickoff slot, and archive re-parent (<code class="font-mono text-2xs">archive_zone</code>)</li>
|
|
168
281
|
</ul>
|
|
169
282
|
</div>
|
|
170
283
|
<div class="card p-5 space-y-2">
|
|
@@ -175,7 +288,7 @@
|
|
|
175
288
|
<ul class="text-secondary text-sm space-y-1 list-disc list-inside">
|
|
176
289
|
<li>Which stages become columns</li>
|
|
177
290
|
<li>The card body — fields, chips, avatars, live timers</li>
|
|
178
|
-
<li>
|
|
291
|
+
<li>Filter/toggle logic + header actions, composed onto the shared chrome state</li>
|
|
179
292
|
<li>Live-update wiring (Turbo Streams on the deploy board)</li>
|
|
180
293
|
</ul>
|
|
181
294
|
</div>
|
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
id_attr / zone_attr flow through from the board so the same partial demonstrates
|
|
8
8
|
the primitive under both the default and a custom attribute naming.
|
|
9
9
|
%>
|
|
10
|
+
<%# A locked card (DG4) flows `locked: true` to the card-shell — it gains the
|
|
11
|
+
.kanban-locked class the factory filters from dragging, and shows a 🔒 in place
|
|
12
|
+
of the drag glyph so the pinned state reads at a glance. %>
|
|
10
13
|
<%= render layout: "studio/board/card_shell",
|
|
11
14
|
locals: { id: card[:id], zone: card[:zone], id_attr: id_attr, zone_attr: zone_attr,
|
|
12
|
-
|
|
15
|
+
locked: card[:locked],
|
|
16
|
+
class: "card p-3 space-y-2 #{card[:locked] ? "cursor-default opacity-90" : "cursor-grab active:cursor-grabbing"}" } do %>
|
|
13
17
|
<div class="flex items-start gap-2">
|
|
14
|
-
<span class="text-muted select-none leading-none" aria-hidden="true"
|
|
18
|
+
<span class="text-muted select-none leading-none" aria-hidden="true"><%= card[:locked] ? "🔒" : "⠿" %></span>
|
|
15
19
|
<p class="text-sm font-semibold text-heading leading-snug flex-1"><%= card[:title] %></p>
|
|
16
20
|
</div>
|
|
17
21
|
<div class="flex items-center justify-between gap-2">
|
data/lib/studio/version.rb
CHANGED