studio-engine 0.28.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 +14 -1
- data/app/views/studio/board/_board.html.erb +71 -20
- data/app/views/studio/board/_card_shell.html.erb +9 -1
- data/app/views/studio/board/_column.html.erb +9 -1
- data/app/views/style/_tasks.html.erb +48 -0
- 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
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
// gap 3: the column key a data-exit-action="archive" remove re-parents INTO
|
|
52
52
|
// (its #dropzone-<key>) rather than deleting the card. null ⇒ 0.27.0 removal.
|
|
53
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,
|
|
54
58
|
|
|
55
59
|
// --- state ---------------------------------------------------------
|
|
56
60
|
toasts: [],
|
|
@@ -104,7 +108,9 @@
|
|
|
104
108
|
animation: 150,
|
|
105
109
|
ghostClass: "opacity-30",
|
|
106
110
|
draggable: self.cardSelector,
|
|
107
|
-
|
|
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,
|
|
108
114
|
onStart: function () { window.__studioBoardDragging = true; },
|
|
109
115
|
onEnd: function (evt) {
|
|
110
116
|
self.handleSortEnd(evt);
|
|
@@ -113,6 +119,13 @@
|
|
|
113
119
|
};
|
|
114
120
|
if (self.groupName) cfg.group = self.groupName; // omit ⇒ within-zone only
|
|
115
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
|
+
}
|
|
116
129
|
Sortable.create(zone, cfg);
|
|
117
130
|
});
|
|
118
131
|
},
|
|
@@ -33,13 +33,29 @@
|
|
|
33
33
|
4. An Archived column that a stage→archived broadcast should re-parent INTO
|
|
34
34
|
(not delete) is named by the board's `archive_zone:`.
|
|
35
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
|
+
|
|
36
42
|
locals:
|
|
37
|
-
columns: (req) [{ key:, label:, cards:, count:,
|
|
38
|
-
count_class:, collapsible:, hidden:, show_expr:,
|
|
39
|
-
kickoff:, header_extra: }]. `cards` are records
|
|
40
|
-
`card_partial`; `count` defaults to cards.size. See
|
|
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
|
|
41
47
|
studio/board/_column for the additive per-column hooks
|
|
42
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).
|
|
43
59
|
card_partial: (req) partial rendered per card (e.g. "tasks/task_card").
|
|
44
60
|
card_as: local the record binds to in card_partial. Default :card.
|
|
45
61
|
card_locals: per-card extra locals — a Proc ->(record){ Hash } or a Hash.
|
|
@@ -78,7 +94,11 @@
|
|
|
78
94
|
demo: style-guide preview — drag works locally, no network POST.
|
|
79
95
|
%>
|
|
80
96
|
<%
|
|
81
|
-
|
|
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 || [])
|
|
82
102
|
card_partial = local_assigns.fetch(:card_partial)
|
|
83
103
|
card_as = local_assigns.fetch(:card_as, :card)
|
|
84
104
|
card_locals = local_assigns.fetch(:card_locals, nil)
|
|
@@ -103,6 +123,7 @@
|
|
|
103
123
|
above_board_slot = local_assigns.fetch(:above_board_slot, nil)
|
|
104
124
|
board_state = local_assigns.fetch(:board_state, nil) # gap 1: chrome-state home
|
|
105
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
|
|
106
127
|
fx = local_assigns.fetch(:fx, nil)
|
|
107
128
|
on_move_hook = local_assigns.fetch(:on_move_hook, nil)
|
|
108
129
|
on_drop_hook = local_assigns.fetch(:on_drop_hook, nil)
|
|
@@ -128,10 +149,11 @@
|
|
|
128
149
|
demo: !!demo,
|
|
129
150
|
state: (board_state.is_a?(Hash) ? board_state : nil),
|
|
130
151
|
archiveZone: (archive_zone ? archive_zone.to_s : nil),
|
|
152
|
+
lockedSelector: (locked_selector ? locked_selector.to_s : nil),
|
|
131
153
|
fx: fx,
|
|
132
154
|
onMoveHook: on_move_hook,
|
|
133
155
|
onDropHook: on_drop_hook,
|
|
134
|
-
labels:
|
|
156
|
+
labels: all_columns.each_with_object({}) { |c, h| h[c[:key].to_s] = (c[:label] || c[:key]).to_s if c[:key] }
|
|
135
157
|
}
|
|
136
158
|
%>
|
|
137
159
|
<section class="studio-board" data-test="studio-board" x-data="studioBoard(<%= board_opts.to_json %>)">
|
|
@@ -140,20 +162,49 @@
|
|
|
140
162
|
<% if header_slot %><div class="studio-board-header mb-4"><%= header_slot %></div><% end %>
|
|
141
163
|
<% if above_board_slot %><%= above_board_slot %><% end %>
|
|
142
164
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 %>
|
|
157
208
|
|
|
158
209
|
<% if toasts %>
|
|
159
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>
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
kickoff: raw HTML rendered in a fixed-height slot
|
|
32
32
|
under the header row (a per-column kickoff chip);
|
|
33
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".
|
|
34
38
|
zone_attr: data-attr name for the zone. Default "stage".
|
|
35
39
|
dom_id_prefix: card dom-id prefix, forwarded to the cards. Default "card-".
|
|
36
40
|
zone_selector_class: the drop-zone class. Default "kanban-dropzone".
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
%>
|
|
43
47
|
<%
|
|
44
48
|
column = local_assigns.fetch(:column)
|
|
49
|
+
layout = local_assigns.fetch(:layout, "row").to_s
|
|
45
50
|
zone_attr = local_assigns.fetch(:zone_attr, "stage")
|
|
46
51
|
dom_id_prefix = local_assigns.fetch(:dom_id_prefix, "card-")
|
|
47
52
|
zone_selector_class = local_assigns.fetch(:zone_selector_class, "kanban-dropzone")
|
|
@@ -70,6 +75,9 @@
|
|
|
70
75
|
# The neutral count chip's colour classes: an opt-in raw class, else the 0.27.0
|
|
71
76
|
# neutral default. The layout classes are shared; only the colour band varies.
|
|
72
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}"
|
|
73
81
|
|
|
74
82
|
# Merge the per-card hook (Proc or Hash) with the record binding.
|
|
75
83
|
resolve_locals = lambda do |record|
|
|
@@ -77,7 +85,7 @@
|
|
|
77
85
|
{ card_as => record }.merge(extra || {})
|
|
78
86
|
end
|
|
79
87
|
%>
|
|
80
|
-
<div class="studio-board-column
|
|
88
|
+
<div class="studio-board-column <%= width_class %>"
|
|
81
89
|
data-board-column="<%= key %>"
|
|
82
90
|
<% if collapsible %>data-board-collapsible="true"<% end %>
|
|
83
91
|
<% if show_expr %>x-show="<%= show_expr %>" x-cloak<% end %>
|
|
@@ -172,6 +172,54 @@
|
|
|
172
172
|
empty_label: "Drop tasks here" %>
|
|
173
173
|
</div>
|
|
174
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
|
+
|
|
175
223
|
<%# ---- The static reference sketch ------------------------------------- %>
|
|
176
224
|
<div class="fade-edge-x overflow-x-auto pb-2">
|
|
177
225
|
<div class="flex gap-4 min-w-max">
|
|
@@ -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