plutonium 0.49.1 → 0.51.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/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +37 -0
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1323 -1184
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +50 -49
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +4 -4
- data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
- data/app/views/resource/_resource_grid.html.erb +1 -0
- data/config/brakeman.ignore +25 -2
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
- data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/plutonium/action/base.rb +44 -1
- data/lib/plutonium/action/interactive.rb +1 -1
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/definition/actions.rb +3 -0
- data/lib/plutonium/definition/base.rb +8 -0
- data/lib/plutonium/definition/index_views.rb +95 -0
- data/lib/plutonium/definition/metadata.rb +40 -0
- data/lib/plutonium/helpers/turbo_helper.rb +12 -1
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/interaction/response/redirect.rb +1 -1
- data/lib/plutonium/query/base.rb +8 -0
- data/lib/plutonium/query/filters/association.rb +30 -8
- data/lib/plutonium/query/filters/boolean.rb +5 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/resource/query_object.rb +64 -6
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/testing/resource_definition.rb +2 -2
- data/lib/plutonium/ui/action_button.rb +4 -2
- data/lib/plutonium/ui/component/kit.rb +12 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/display/base.rb +3 -1
- data/lib/plutonium/ui/display/resource.rb +109 -25
- data/lib/plutonium/ui/display/theme.rb +2 -1
- data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
- data/lib/plutonium/ui/empty_card.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +35 -3
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +45 -10
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
- data/lib/plutonium/ui/grid/card.rb +235 -0
- data/lib/plutonium/ui/grid/resource.rb +149 -0
- data/lib/plutonium/ui/layout/base.rb +38 -1
- data/lib/plutonium/ui/layout/header.rb +1 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
- data/lib/plutonium/ui/layout/sidebar.rb +12 -24
- data/lib/plutonium/ui/layout/topbar.rb +100 -0
- data/lib/plutonium/ui/modal/base.rb +109 -0
- data/lib/plutonium/ui/modal/centered.rb +21 -0
- data/lib/plutonium/ui/modal/slideover.rb +26 -0
- data/lib/plutonium/ui/page/base.rb +18 -6
- data/lib/plutonium/ui/page/edit.rb +13 -1
- data/lib/plutonium/ui/page/index.rb +40 -1
- data/lib/plutonium/ui/page/interactive_action.rb +8 -39
- data/lib/plutonium/ui/page/new.rb +13 -1
- data/lib/plutonium/ui/page/show.rb +8 -1
- data/lib/plutonium/ui/page_header.rb +8 -13
- data/lib/plutonium/ui/panel.rb +10 -19
- data/lib/plutonium/ui/sidebar_menu.rb +2 -25
- data/lib/plutonium/ui/tab_list.rb +29 -7
- data/lib/plutonium/ui/table/base.rb +106 -0
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
- data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
- data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
- data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
- data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
- data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
- data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
- data/lib/plutonium/ui/table/resource.rb +158 -89
- data/lib/plutonium/ui/table/theme.rb +14 -5
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +14 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/components.css +304 -131
- data/src/css/slim_select.css +4 -0
- data/src/css/tokens.css +101 -85
- data/src/js/controllers/autosubmit_controller.js +24 -0
- data/src/js/controllers/bulk_actions_controller.js +15 -16
- data/src/js/controllers/capture_url_controller.js +14 -0
- data/src/js/controllers/filter_panel_controller.js +77 -19
- data/src/js/controllers/frame_navigator_controller.js +34 -6
- data/src/js/controllers/icon_rail_controller.js +22 -0
- data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- data/src/js/controllers/register_controllers.js +16 -0
- data/src/js/controllers/resource_tab_list_controller.js +56 -3
- data/src/js/controllers/row_click_controller.js +21 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/controllers/table_column_menu_controller.js +43 -0
- data/src/js/controllers/table_header_controller.js +16 -0
- data/src/js/controllers/view_switcher_controller.js +29 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +71 -32
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -325
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -592
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -449
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -268
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -501
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planPath": "docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md",
|
|
3
|
+
"tasks": [
|
|
4
|
+
{
|
|
5
|
+
"id": 7,
|
|
6
|
+
"subject": "Task 0: Density tokens",
|
|
7
|
+
"status": "pending",
|
|
8
|
+
"description": "**Goal:** Codify balanced density scale.\n\n```json:metadata\n{\"files\":[\"src/css/tokens.css\",\"src/css/components.css\",\"lib/plutonium/ui/component/tokens.rb\"],\"verifyCommand\":\"yarn build && bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"density vars defined\",\"button/input sizes updated\",\"card padding 16px\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": 8,
|
|
12
|
+
"subject": "Task 1: PageHeader redesign (Stripe-style)",
|
|
13
|
+
"status": "pending",
|
|
14
|
+
"blockedBy": [7],
|
|
15
|
+
"description": "**Goal:** Tighter Stripe-style PageHeader.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page_header.rb\",\"lib/plutonium/ui/page/base.rb\",\"test/plutonium/ui/page_header_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page_header_test.rb -v\",\"acceptanceCriteria\":[\"title 18-20px\",\"description muted\",\"actions right-aligned\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": 9,
|
|
19
|
+
"subject": "Task 2: IconRail component",
|
|
20
|
+
"status": "pending",
|
|
21
|
+
"blockedBy": [7],
|
|
22
|
+
"description": "**Goal:** 56px icon-only nav.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/icon_rail.rb\",\"test/plutonium/ui/layout/icon_rail_test.rb\",\"src/css/components.css\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/icon_rail_test.rb -v\",\"acceptanceCriteria\":[\"aside 56px\",\"slots present\",\"active styling\",\"mobile-hidden\"],\"requiresUserVerification\":false}\n```"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": 10,
|
|
26
|
+
"subject": "Task 3: Topbar component",
|
|
27
|
+
"status": "pending",
|
|
28
|
+
"blockedBy": [7],
|
|
29
|
+
"description": "**Goal:** Sticky 48px topbar.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/topbar.rb\",\"lib/plutonium/ui/breadcrumbs.rb\",\"test/plutonium/ui/layout/topbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/layout/topbar_test.rb -v\",\"acceptanceCriteria\":[\"48px height\",\"breadcrumbs/search/actions slots\",\"hamburger wired\",\"empty-breadcrumbs handled\"],\"requiresUserVerification\":false}\n```"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": 11,
|
|
33
|
+
"subject": "Task 4: Wire ResourceLayout to new shell",
|
|
34
|
+
"status": "pending",
|
|
35
|
+
"blockedBy": [9, 10],
|
|
36
|
+
"description": "**Goal:** Replace partials with IconRail+Topbar; drop legacy.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/layout/resource_layout.rb\",\"lib/plutonium/ui/layout/base.rb\",\"lib/plutonium/ui/layout/header.rb\",\"lib/plutonium/ui/layout/sidebar.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 rake test\",\"acceptanceCriteria\":[\"new shell wired\",\"old partials removed\",\"tests pass\"],\"requiresUserVerification\":false}\n```"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": 12,
|
|
40
|
+
"subject": "Task 5: Index toolbar",
|
|
41
|
+
"status": "pending",
|
|
42
|
+
"blockedBy": [7, 8],
|
|
43
|
+
"description": "**Goal:** New Toolbar component (view switcher, filter, group, search, column-config, overflow).\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/toolbar.rb\",\"lib/plutonium/ui/table/components/view_switcher.rb\",\"lib/plutonium/ui/table/resource.rb\",\"test/plutonium/ui/table/components/toolbar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/toolbar_test.rb -v\",\"acceptanceCriteria\":[\"toolbar order\",\"search wired\",\"filter popover\",\"disabled segments\"],\"requiresUserVerification\":false}\n```"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": 13,
|
|
47
|
+
"subject": "Task 6: Active filter pills + result count",
|
|
48
|
+
"status": "pending",
|
|
49
|
+
"blockedBy": [12],
|
|
50
|
+
"description": "**Goal:** Removable filter pills + result count strip.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/filter_pills.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/resource/query_object.rb\",\"test/plutonium/ui/table/components/filter_pills_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/filter_pills_test.rb -v\",\"acceptanceCriteria\":[\"pill per filter\",\"add-filter pill\",\"clear URL\",\"result count\"],\"requiresUserVerification\":false}\n```"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": 14,
|
|
54
|
+
"subject": "Task 7: Column-header sort with multi-sort + ⋯ menu",
|
|
55
|
+
"status": "pending",
|
|
56
|
+
"blockedBy": [12],
|
|
57
|
+
"description": "**Goal:** Sort in column headers, shift-click multi-sort, per-column ⋯ menu.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/query_object.rb\",\"lib/plutonium/ui/table/resource.rb\",\"lib/plutonium/ui/table/theme.rb\",\"src/js/controllers/table_controller.js\",\"test/plutonium/resource/query_object_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/query_object_test.rb -v\",\"acceptanceCriteria\":[\"sort_params_for has multi_url\",\"click vs shift-click\",\"priority badges\",\"column menu\"],\"requiresUserVerification\":false}\n```"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": 15,
|
|
61
|
+
"subject": "Task 8: Floating bulk action bar",
|
|
62
|
+
"status": "pending",
|
|
63
|
+
"blockedBy": [13],
|
|
64
|
+
"description": "**Goal:** Bulk action bar replaces pills strip when rows selected.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/table/components/bulk_action_bar.rb\",\"lib/plutonium/ui/table/resource.rb\",\"src/js/controllers/bulk_actions_controller.js\",\"test/plutonium/ui/table/components/bulk_action_bar_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/table/components/bulk_action_bar_test.rb -v\",\"acceptanceCriteria\":[\"bar/pills mutual exclusion\",\"selection count\",\"danger tone\",\"clear deselect\"],\"requiresUserVerification\":false}\n```"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": 16,
|
|
68
|
+
"subject": "Task 9: Show page redesign",
|
|
69
|
+
"status": "pending",
|
|
70
|
+
"blockedBy": [8],
|
|
71
|
+
"description": "**Goal:** Single-column show + reserved aside slot.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/show.rb\",\"lib/plutonium/ui/page/base.rb\",\"lib/plutonium/ui/display/resource.rb\",\"lib/plutonium/ui/panel.rb\",\"test/plutonium/ui/page/show_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/page/show_test.rb -v\",\"acceptanceCriteria\":[\"max-width 960\",\"card panels\",\"aside slot reserved\",\"tabs flush\"],\"requiresUserVerification\":false}\n```"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": 17,
|
|
75
|
+
"subject": "Task 10: Form page redesign + sticky footer",
|
|
76
|
+
"status": "pending",
|
|
77
|
+
"blockedBy": [8],
|
|
78
|
+
"description": "**Goal:** Centered narrow form column + sticky footer + inline validation.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/ui/page/new.rb\",\"lib/plutonium/ui/page/edit.rb\",\"lib/plutonium/ui/page/interactive_action.rb\",\"lib/plutonium/ui/form/resource.rb\",\"lib/plutonium/ui/form/interaction.rb\",\"lib/plutonium/ui/form/theme.rb\",\"lib/plutonium/ui/form/components/sticky_footer.rb\",\"test/plutonium/ui/form/sticky_footer_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/ui/form -v\",\"acceptanceCriteria\":[\"580px centered\",\"sticky footer\",\"inline validation\",\"modal mode skips footer\"],\"requiresUserVerification\":false}\n```"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": 18,
|
|
82
|
+
"subject": "Task 11: Slideover modal mode + per-interaction option",
|
|
83
|
+
"status": "pending",
|
|
84
|
+
"blockedBy": [17],
|
|
85
|
+
"description": "**Goal:** Add slideover mode; opt-in via interactive_action :name, modal: :slideover.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/interaction.rb\",\"lib/plutonium/interaction/base.rb\",\"src/js/controllers/remote_modal_controller.js\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/interaction -v\",\"acceptanceCriteria\":[\"modal: kwarg accepted\",\"two outer containers\",\"slideover transition\",\"mobile full-screen\"],\"requiresUserVerification\":false}\n```"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": 19,
|
|
89
|
+
"subject": "Task 12: Per-resource modal declaration",
|
|
90
|
+
"status": "pending",
|
|
91
|
+
"blockedBy": [18],
|
|
92
|
+
"description": "**Goal:** modal :slideover on resource definition for new/edit modal mode.\n\n```json:metadata\n{\"files\":[\"lib/plutonium/resource/definition.rb\",\"test/plutonium/resource/definition_test.rb\"],\"verifyCommand\":\"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/resource/definition_test.rb -v\",\"acceptanceCriteria\":[\"modal DSL on definition\",\"invalid raises\",\"modal frame uses declared mode\",\"page URLs unchanged\"],\"requiresUserVerification\":false}\n```"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": 20,
|
|
96
|
+
"subject": "Task 13: Documentation + changelog",
|
|
97
|
+
"status": "pending",
|
|
98
|
+
"blockedBy": [16, 19],
|
|
99
|
+
"description": "**Goal:** Document overhaul; update skills.\n\n```json:metadata\n{\"files\":[\"docs/guides/ui-overhaul-2026.md\",\"CHANGELOG.md\",\".claude/skills/plutonium-views.md\"],\"verifyCommand\":\"yarn docs:build\",\"acceptanceCriteria\":[\"upgrade guide written\",\"skills updated\",\"changelog entry\",\"docs build passes\"],\"requiresUserVerification\":false}\n```"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"lastUpdated": "2026-05-07"
|
|
103
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Plutonium UI Layout Overhaul — Design Spec
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-07
|
|
4
|
+
**Scope:** Visual + structural redesign of Plutonium's app shell, page header, index/show/form pages. Code-level component refactoring (slot APIs, hook reduction, partial-to-Phlex conversion) is intentionally out of scope here — this spec captures the *target UI* only. A separate refactor pass can re-architect the Phlex internals to deliver this target.
|
|
5
|
+
|
|
6
|
+
## Goals
|
|
7
|
+
|
|
8
|
+
1. Modernize the look and feel to match contemporary admin tools (Linear, Stripe Dashboard, Vercel, Plane).
|
|
9
|
+
2. Increase information density without sacrificing scannability.
|
|
10
|
+
3. Establish a coherent visual vocabulary across all four page types (index / show / form / interactive-action).
|
|
11
|
+
4. Leave clean extension points for upcoming features (metadata side panel, view switchers).
|
|
12
|
+
|
|
13
|
+
## Non-Goals
|
|
14
|
+
|
|
15
|
+
- Theming / token rebuild (deferred — current `--pu-*` token system stays).
|
|
16
|
+
- Component API consolidation (separate effort).
|
|
17
|
+
- Mobile-first redesign (mobile must work, but desktop is the optimization target).
|
|
18
|
+
- New colors / typography (use existing tokens unless a decision below requires a new one).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. App Shell — Icon Rail + Topbar
|
|
23
|
+
|
|
24
|
+
A narrow icon-only left rail plus a topbar replaces the current expanded sidebar.
|
|
25
|
+
|
|
26
|
+
**Left rail**
|
|
27
|
+
- Width: ~56px, fixed.
|
|
28
|
+
- Icon-only nav items with tooltips on hover.
|
|
29
|
+
- Top: brand mark / portal switcher.
|
|
30
|
+
- Middle: primary nav (resources grouped by section, dividers between groups).
|
|
31
|
+
- Bottom: settings, theme toggle.
|
|
32
|
+
- Active item: filled background, primary tone.
|
|
33
|
+
- Mobile (<lg breakpoint): rail collapses to hamburger drawer.
|
|
34
|
+
|
|
35
|
+
**Topbar**
|
|
36
|
+
- Height: ~48px, sticky.
|
|
37
|
+
- Left: breadcrumbs (resource path; replaces in-content breadcrumbs).
|
|
38
|
+
- Center: global search input (filled, ~360px max).
|
|
39
|
+
- Right: notifications, user menu.
|
|
40
|
+
|
|
41
|
+
**Removed**
|
|
42
|
+
- Current expanded sidebar (240px) — labels now live in tooltips and breadcrumbs.
|
|
43
|
+
- In-page breadcrumbs above title — moved to topbar.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 2. Page Header — Stripe-Style
|
|
48
|
+
|
|
49
|
+
Every page renders a unified header below the topbar.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
┌────────────────────────────────────────────────────┐
|
|
53
|
+
│ Customers [Cancel] [Save] │
|
|
54
|
+
│ Manage customer accounts and contact details │
|
|
55
|
+
├────────────────────────────────────────────────────┤
|
|
56
|
+
│ Overview │ Orders │ Invoices │ Activity │
|
|
57
|
+
└────────────────────────────────────────────────────┘
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- Title: 18–20px, semibold.
|
|
61
|
+
- Description: 13px, muted, optional, sits directly below title.
|
|
62
|
+
- Actions: right-aligned at title's vertical level. Primary as filled button, secondary as outline. Overflow into a `⋯` dropdown after 2 visible actions.
|
|
63
|
+
- Tabs: connected strip directly under header (no gap), 1px bottom border becomes the active-tab indicator's baseline.
|
|
64
|
+
|
|
65
|
+
The header is uniform across index / show / form / interactive-action.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 3. Index Page — Hybrid Toolbar + Pills + Column Sort
|
|
70
|
+
|
|
71
|
+
### Toolbar (single 36-40px row above the table)
|
|
72
|
+
|
|
73
|
+
Order, left to right:
|
|
74
|
+
1. **View switcher** — segmented control (Grid / Cards / Kanban — Cards/Kanban are placeholders for now; only Grid is wired initially).
|
|
75
|
+
2. Vertical divider.
|
|
76
|
+
3. **Filter** button (popover).
|
|
77
|
+
4. **Group** button (popover).
|
|
78
|
+
5. Spacer (`flex-grow`).
|
|
79
|
+
6. **Search input** — visible, ~220px wide, expands on focus.
|
|
80
|
+
7. Vertical divider.
|
|
81
|
+
8. **Column config** icon button (`⊞`).
|
|
82
|
+
9. **Overflow** icon button (`⋯`) — exports, advanced options.
|
|
83
|
+
|
|
84
|
+
The "Sort" button is intentionally absent — sort is column-driven (see below).
|
|
85
|
+
|
|
86
|
+
### Active Filter Strip (below toolbar, only when filters are active)
|
|
87
|
+
|
|
88
|
+
- Each active filter renders as a removable pill: `<field> <op> <value>` with `✕`.
|
|
89
|
+
- After the last pill: `+ Filter` dashed pill that opens the same popover as the toolbar Filter button.
|
|
90
|
+
- Right-aligned: result count (e.g., "147 results").
|
|
91
|
+
|
|
92
|
+
### Table — Column-Header Sort
|
|
93
|
+
|
|
94
|
+
- Click a column header: sorts asc → desc → none (cycles).
|
|
95
|
+
- Shift-click: adds a secondary/tertiary sort (multi-sort).
|
|
96
|
+
- Active sort columns show: arrow (↑/↓) + small priority badge (1, 2, 3) when more than one column is active.
|
|
97
|
+
- Each header has a `⋯` menu: Sort asc / Sort desc / Clear sort / Group by / Filter by / Hide column.
|
|
98
|
+
- Row height: 32px (balanced density). Header height: 32px.
|
|
99
|
+
- Selection: leftmost column is a 12px checkbox.
|
|
100
|
+
|
|
101
|
+
### Bulk Action Bar
|
|
102
|
+
|
|
103
|
+
- Appears as a 36px strip *replacing* the active-filter strip when ≥1 row is selected.
|
|
104
|
+
- Tinted background (primary-50 light / primary-950/30 dark).
|
|
105
|
+
- Left: count + "Clear selection".
|
|
106
|
+
- After spacer: action buttons (Export, Archive, Delete) — Delete uses danger tone.
|
|
107
|
+
|
|
108
|
+
### Pagination Footer
|
|
109
|
+
|
|
110
|
+
- Sticky-ish strip below the table.
|
|
111
|
+
- Left: "Showing N–M of Total".
|
|
112
|
+
- Right: prev / page indicator / next.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## 4. Show Page — Single Column + Tabs
|
|
117
|
+
|
|
118
|
+
A single content column under the page header. Nested resources render as tabs.
|
|
119
|
+
|
|
120
|
+
### Structure
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
PageHeader (title, description, actions, tab strip)
|
|
124
|
+
└── content
|
|
125
|
+
├── [Aside slot — empty by default; reserved]
|
|
126
|
+
└── Main column
|
|
127
|
+
├── Field panel: Details
|
|
128
|
+
├── Field panel: Address
|
|
129
|
+
└── ...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Field panels: card-styled (1px border, radius-md, white surface) with uppercase 9px section labels.
|
|
133
|
+
- Default content max-width: ~960px, centered if rail+topbar leaves wider area.
|
|
134
|
+
- Tabs render nested resources (the existing tab strip mechanism).
|
|
135
|
+
|
|
136
|
+
### Reserved Aside Slot (Future Hook)
|
|
137
|
+
|
|
138
|
+
The page layout reserves a `render_aside` slot that is empty by default. A future `metadata` DSL on resource definitions will populate this slot:
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
class CustomerDefinition < Plutonium::Resource::Definition
|
|
142
|
+
metadata do
|
|
143
|
+
field :status, badge: true
|
|
144
|
+
field :owner
|
|
145
|
+
field :created_at
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
When populated, the aside renders as a 200–240px right side panel with a sticky 16px-padded background-`surface-alt` column. Implementation of the DSL itself is a separate task — this spec only requires the layout to leave room.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 5. Form Page — Centered Narrow + Sticky Footer
|
|
155
|
+
|
|
156
|
+
For new / edit / interactive-action.
|
|
157
|
+
|
|
158
|
+
### Structure
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
PageHeader (title, description; no actions in header)
|
|
162
|
+
└── content (max-width ~580px, centered)
|
|
163
|
+
├── Card: Section 1 (uppercase 9px label + fields)
|
|
164
|
+
├── Card: Section 2
|
|
165
|
+
└── ...
|
|
166
|
+
StickyFooter (full width, right-aligned [Cancel] [Save])
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- Form column max-width: 580px.
|
|
170
|
+
- Card-style sections, same chrome as show-page panels.
|
|
171
|
+
- Inline validation: errors render as 12px danger text directly under each field. No toasts for field-level errors. Toast/flash only for form-level outcomes.
|
|
172
|
+
- Sticky footer: 56px tall, white surface, top border, sticks to viewport bottom when form scrolls.
|
|
173
|
+
- Cancel: outline button. Save: primary filled button. Right-aligned.
|
|
174
|
+
|
|
175
|
+
### Modal Variant
|
|
176
|
+
|
|
177
|
+
The same form can render in a modal when triggered as a quick-create / quick-edit (e.g., `+ New` from an index toolbar that targets the remote modal frame, or a row-edit action):
|
|
178
|
+
- No sticky footer; the modal's own footer bar holds Cancel / Save.
|
|
179
|
+
- Internal layout otherwise identical (card sections, inline validation).
|
|
180
|
+
- Modal mode (`:centered` vs `:slideover`) is configurable per resource definition — see §7.
|
|
181
|
+
- Page-level new/edit URLs always render the full page form (§5); modal rendering is invoked via the modal turbo frame.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 6. Density
|
|
186
|
+
|
|
187
|
+
**Balanced (Stripe / Vercel-class)** as the framework default.
|
|
188
|
+
|
|
189
|
+
| Token | Value |
|
|
190
|
+
|------------------|---------------|
|
|
191
|
+
| Table row height | 32px |
|
|
192
|
+
| Body text | 14px |
|
|
193
|
+
| Section gap | 16px |
|
|
194
|
+
| Field gap | 12px |
|
|
195
|
+
| Button (md) | 32px height, 14px text, 12px horizontal padding |
|
|
196
|
+
| Button (sm) | 28px, 13px, 10px |
|
|
197
|
+
| Input height | 36px (forms), 32px (toolbars) |
|
|
198
|
+
| Card padding | 16px |
|
|
199
|
+
| Page side padding | 24px |
|
|
200
|
+
|
|
201
|
+
These values become the canonical scale; spot-deviations are allowed but should be rare.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 7. Modals — Both Modes, Per-Action Opt-In
|
|
206
|
+
|
|
207
|
+
Two modal modes ship as siblings.
|
|
208
|
+
|
|
209
|
+
### Default: Centered Dialog
|
|
210
|
+
- Max-width 520px, max-height 80vh, centered, dimmed backdrop.
|
|
211
|
+
- Header: dialog title + close (✕).
|
|
212
|
+
- Body: form / content with internal scroll.
|
|
213
|
+
- Footer: 56px strip, right-aligned [Cancel] [Confirm].
|
|
214
|
+
- Use cases: short forms, confirmations, most interactive actions.
|
|
215
|
+
|
|
216
|
+
### Opt-In: Right Slide-Over Panel
|
|
217
|
+
- Slides in from right, full height, 480px wide on desktop, full-screen on mobile.
|
|
218
|
+
- Header / body / footer same as centered.
|
|
219
|
+
- Underlying list visible (dimmed); user keeps context.
|
|
220
|
+
### Configuration
|
|
221
|
+
|
|
222
|
+
**Per interaction** — defaults to `:centered`, opt into `:slideover`:
|
|
223
|
+
```ruby
|
|
224
|
+
interactive_action :reschedule, modal: :slideover
|
|
225
|
+
interactive_action :archive # implicit modal: :centered
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
**Per resource (for quick-create / quick-edit modals)** — definition declares the mode used when new/edit is rendered through the modal turbo frame:
|
|
229
|
+
```ruby
|
|
230
|
+
class CustomerDefinition < Plutonium::Resource::Definition
|
|
231
|
+
modal :slideover # default :centered
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The page-level new/edit URLs always render the full §5 page layout. Whether `+ New` opens a modal or navigates to the page is a per-context call-site choice (e.g., index toolbar can target the modal frame for quick-create; a "Create customer" landing CTA navigates to the full page).
|
|
236
|
+
|
|
237
|
+
Both modal modes share the same Phlex modal component; only the outer container varies.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 8. Compatibility & Migration Notes
|
|
242
|
+
|
|
243
|
+
- **`Layout::ResourceLayout`** currently uses Rails partials for `resource_header` / `resource_sidebar`. Conversion to Phlex is implicit in this work — the icon rail and topbar must be Phlex components.
|
|
244
|
+
- **`Page::Base` hook explosion** (~12 before/after hooks) — most apps don't use these. The redesign assumes apps that override `render_breadcrumbs` etc. continue to work; new slot APIs are additive. Hook deprecation is a future cleanup.
|
|
245
|
+
- **Existing CSS classes** — `.pu-input`, `.pu-btn`, `.pu-card` keep their names; sizes shift to the density table above. Apps that hard-code Tailwind utilities on top will need cosmetic touch-ups but no breakage.
|
|
246
|
+
- **Breadcrumbs** — moving from in-page to topbar means `Plutonium::UI::Breadcrumbs` becomes a topbar component. The definition-level `breadcrumbs` toggle stays; "off" means hidden in topbar (or replaced with title only).
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 9. Out-of-Scope Followups (referenced, not designed here)
|
|
251
|
+
|
|
252
|
+
- **Metadata DSL** for show-page side panel (§4).
|
|
253
|
+
- **View switcher** wiring beyond Grid (Cards, Kanban) — placeholders in toolbar; implementation deferred.
|
|
254
|
+
- **Code-level Phlex refactor** — slot API, hook reduction, asset registry — separate spec.
|
|
255
|
+
- **Token / theme rebuild** — separate spec.
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## 10. Acceptance Checklist
|
|
260
|
+
|
|
261
|
+
- [ ] Icon rail (56px) replaces expanded sidebar; topbar adds breadcrumbs + search + user.
|
|
262
|
+
- [ ] Page header is a single component (`PageHeader`) used by every page type, supporting title / description / actions / tabs.
|
|
263
|
+
- [ ] Index page renders the toolbar in the order of §3 with no Sort button.
|
|
264
|
+
- [ ] Active filters render as removable pills below the toolbar with a result count.
|
|
265
|
+
- [ ] Column headers sort on click (asc/desc/none) with shift-click multi-sort and priority badges.
|
|
266
|
+
- [ ] Bulk action bar replaces the filter strip when rows are selected.
|
|
267
|
+
- [ ] Show page is single-column with tab strip; an empty aside slot is reserved.
|
|
268
|
+
- [ ] Form pages use a 580px centered column with sticky footer; modal variant uses dialog footer.
|
|
269
|
+
- [ ] Density tokens (§6) are codified in CSS / Phlex constants and used consistently.
|
|
270
|
+
- [ ] Modal component supports both `:centered` (default) and `:slideover` via per-action / per-form opt-in.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Typeahead Endpoint Design
|
|
2
|
+
|
|
3
|
+
**Status:** Approved (2026-05-09)
|
|
4
|
+
**Author:** stefan
|
|
5
|
+
**Scope:** New backend-driven typeahead/autocomplete primitive for resource form inputs and index filter inputs.
|
|
6
|
+
|
|
7
|
+
## Goal
|
|
8
|
+
|
|
9
|
+
Add an async typeahead endpoint to every Plutonium resource so association-backed selects (and any future typeahead-capable input) can fetch matching records from the server instead of materialising up to `DEFAULT_CHOICE_LIMIT` options into the page at render time. This unblocks association pickers over large tables (where the existing 100-row cap silently truncates) without forcing every input into a custom JS solution.
|
|
10
|
+
|
|
11
|
+
## Non-goals
|
|
12
|
+
|
|
13
|
+
- Pagination of typeahead results (we use a hard cap with an overflow indicator; pagination can be added later if a real need surfaces).
|
|
14
|
+
- Rich result rows (subtitle, icon, avatar). MVP returns minimal `{value, label}` per row; richer payloads are a separate iteration.
|
|
15
|
+
- Replacing the existing eager-list ResourceSelect; the eager path stays as the fallback / small-table mode.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
Three layers, mirroring how `Plutonium::Resource::Controllers::InteractiveActions` is composed today.
|
|
20
|
+
|
|
21
|
+
### 1. Routing — `Plutonium::Routing::MapperExtensions`
|
|
22
|
+
|
|
23
|
+
Two routes are added to the existing `interactive_resource_actions` concern (auto-mounted on every Plutonium resource alongside `record_actions`, `bulk_actions`, etc.):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
GET /<resource>/typeahead/input/:name?q=… → typeahead_input
|
|
27
|
+
GET /<resource>/typeahead/filter/:name?q=… → typeahead_filter
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Both collection-level. **No member variant** — authorization on the parent resource class is sufficient (see "Authorization" below).
|
|
31
|
+
|
|
32
|
+
### 2. Controller concern — `Plutonium::Resource::Controllers::Typeahead`
|
|
33
|
+
|
|
34
|
+
Two thin actions plus a single `before_action` for auth.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
module Plutonium::Resource::Controllers::Typeahead
|
|
38
|
+
extend ActiveSupport::Concern
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def typeahead_input
|
|
45
|
+
name = params[:name].to_sym
|
|
46
|
+
defn = current_definition.defined_inputs[name]
|
|
47
|
+
return head(:not_found) unless defn
|
|
48
|
+
|
|
49
|
+
render_typeahead_response(defn)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def typeahead_filter
|
|
53
|
+
name = params[:name].to_sym
|
|
54
|
+
filter = current_query_object.filter_definitions[name]
|
|
55
|
+
return head(:not_found) unless filter
|
|
56
|
+
|
|
57
|
+
defn = filter.defined_inputs[:value]
|
|
58
|
+
return head(:not_found) unless defn
|
|
59
|
+
|
|
60
|
+
render_typeahead_response(defn)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def render_typeahead_response(defn)
|
|
66
|
+
klass = lookup_input_class(defn)
|
|
67
|
+
return render(json: { error: "input is not typeahead-capable" }, status: :bad_request) unless klass < Plutonium::UI::Form::Components::Searchable
|
|
68
|
+
|
|
69
|
+
widget = klass.build_for_typeahead(defn[:options] || {})
|
|
70
|
+
results, has_more = widget.typeahead(
|
|
71
|
+
query: params[:q].to_s,
|
|
72
|
+
limit: TYPEAHEAD_LIMIT,
|
|
73
|
+
controller: self
|
|
74
|
+
)
|
|
75
|
+
render json: { results: results, has_more: has_more }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def authorize_typeahead!
|
|
79
|
+
authorize! resource_class, to: :typeahead?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Maps the input definition's :as symbol (e.g. :resource_select) to a
|
|
83
|
+
# component class. Backed by an explicit registry — only inputs that
|
|
84
|
+
# opted in by including Searchable register here, so anything not in
|
|
85
|
+
# the registry falls through to the 400 branch.
|
|
86
|
+
def lookup_input_class(defn)
|
|
87
|
+
Plutonium::UI::Form::Components::Searchable.registry[defn[:options]&.[](:as)&.to_sym]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`TYPEAHEAD_LIMIT` is a module-level constant (default `50`). Easy to tune.
|
|
93
|
+
|
|
94
|
+
### 3. Search behavior — `Plutonium::UI::Form::Components::Searchable`
|
|
95
|
+
|
|
96
|
+
A small mixin. Mixed into `ResourceSelect` (and into any future input that wants typeahead). Two-method public surface:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
module Plutonium::UI::Form::Components::Searchable
|
|
100
|
+
extend ActiveSupport::Concern
|
|
101
|
+
|
|
102
|
+
# Maps :as symbol -> component class. Each typeahead-capable widget
|
|
103
|
+
# populates this when it includes Searchable so the controller can
|
|
104
|
+
# dispatch by name without a brittle inflection convention.
|
|
105
|
+
def self.registry
|
|
106
|
+
@registry ||= {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class_methods do
|
|
110
|
+
# Subclasses call this to claim their :as symbol in the registry.
|
|
111
|
+
def typeahead_input_name(name)
|
|
112
|
+
Plutonium::UI::Form::Components::Searchable.registry[name.to_sym] = self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Allocates the widget and assigns just the ivars #typeahead needs.
|
|
116
|
+
# Bypasses Phlex's render-time build_attributes pipeline so we don't
|
|
117
|
+
# need a field/form context to run the search.
|
|
118
|
+
def build_for_typeahead(options)
|
|
119
|
+
allocate.tap { |w| w.send(:apply_typeahead_options, options) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns [results_array, has_more_bool]. results entries are { value:, label: }.
|
|
124
|
+
def typeahead(query:, limit:, controller:)
|
|
125
|
+
raw = collect_typeahead_candidates(query, controller: controller)
|
|
126
|
+
over = raw.length > limit
|
|
127
|
+
[raw.first(limit).map { |r| serialize_typeahead_row(r) }, over]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`ResourceSelect` implements `apply_typeahead_options`, `collect_typeahead_candidates`, and `serialize_typeahead_row`:
|
|
133
|
+
|
|
134
|
+
- `apply_typeahead_options(options)` reads `@association_class`, `@raw_choices`, `@choice_limit`, `@skip_authorization` from the input definition's options hash — the same keys the existing `build_attributes` consumes at render time.
|
|
135
|
+
- `collect_typeahead_candidates` branches:
|
|
136
|
+
- if `@raw_choices` (static list) — `@raw_choices.select { |label, _| label.to_s.downcase.include?(query.downcase) }`. No auth: choices are static, definition-author-controlled.
|
|
137
|
+
- elsif `@association_class` — runs the search through `controller.send(:authorized_resource_scope, @association_class)` so the associated resource's `policy.relation_scope` enforces row-level auth, then applies the associated resource definition's `search` block if present, else `LIKE` on the column backing `to_label` (or skips filtering when query is blank).
|
|
138
|
+
- `serialize_typeahead_row(row)` returns `{ value: row.to_signed_global_id.to_s, label: row.to_label }` for records, or `{ value: raw_value, label: raw_label }` for static choices.
|
|
139
|
+
|
|
140
|
+
The cap is **`limit + 1`** at the SQL level (`LIMIT 51` for a `limit: 50` request) so we can detect overflow without a separate `COUNT`.
|
|
141
|
+
|
|
142
|
+
## Authorization
|
|
143
|
+
|
|
144
|
+
Two gates, layered:
|
|
145
|
+
|
|
146
|
+
1. **Parent gate** — `policy.typeahead?` on the resource hosting the endpoint. Defaults to `index?` (collection-shaped — typeahead is "list/search records of this class", not "show one record"). Override per-resource if needed (e.g. `def typeahead? = create? || update?` to require write intent).
|
|
147
|
+
2. **Row gate** — when the input is association-backed, results are scoped through the *associated* resource's `policy.relation_scope` via the existing `authorized_resource_scope` helper. So a user can typeahead Authors only if they're allowed to read Authors, regardless of whether they can edit Posts.
|
|
148
|
+
|
|
149
|
+
Static `choices` lists bypass the row gate (they're not records, they're definition-author-controlled enumerations).
|
|
150
|
+
|
|
151
|
+
## Data flow
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Browser (Stimulus controller)
|
|
155
|
+
fetch GET /widgets/typeahead/input/author?q=ali
|
|
156
|
+
↓
|
|
157
|
+
Typeahead#typeahead_input
|
|
158
|
+
authorize_typeahead! → policy.typeahead? on Widget [parent gate]
|
|
159
|
+
defn = current_definition.defined_inputs[:author]
|
|
160
|
+
widget = ResourceSelect.build_for_typeahead(defn[:options])
|
|
161
|
+
widget.typeahead(query: "ali", limit: 50, controller: self)
|
|
162
|
+
authorized_resource_scope(User).where("name LIKE ?", "%ali%").limit(51) [row gate]
|
|
163
|
+
serialize each → { value: sgid, label: to_label }
|
|
164
|
+
render json: { results: [...], has_more: false }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Components
|
|
168
|
+
|
|
169
|
+
| File | Responsibility |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `lib/plutonium/routing/mapper_extensions.rb` | Add 2 routes to the `interactive_resource_actions` concern. |
|
|
172
|
+
| `lib/plutonium/resource/controllers/typeahead.rb` | **New.** Controller concern with `typeahead_input`/`typeahead_filter` actions, auth, dispatch, JSON serialization. |
|
|
173
|
+
| `lib/plutonium/resource/controller.rb` | Include `Controllers::Typeahead`. |
|
|
174
|
+
| `lib/plutonium/resource/policy.rb` | Add `typeahead?` defaulting to `index?`. |
|
|
175
|
+
| `lib/plutonium/ui/form/components/searchable.rb` | **New.** `Searchable` mixin (class-level `build_for_typeahead`, instance-level `typeahead`). |
|
|
176
|
+
| `lib/plutonium/ui/form/components/resource_select.rb` | Include `Searchable`, call `typeahead_input_name :resource_select` to register. Implement `apply_typeahead_options`, `collect_typeahead_candidates`, `serialize_typeahead_row`. Wire Stimulus controller + remote URL data attrs into the rendered `<select>`. |
|
|
177
|
+
| `src/js/controllers/resource_select_controller.js` | **New.** Stimulus controller: debounced fetch, populates options on the underlying `<select>`, surfaces overflow hint, handles network errors. |
|
|
178
|
+
|
|
179
|
+
## Error handling
|
|
180
|
+
|
|
181
|
+
- Unknown input/filter name → `404 Not Found`.
|
|
182
|
+
- Input class registered but doesn't include `Searchable` → `400 Bad Request` with `{error: "input is not typeahead-capable"}`.
|
|
183
|
+
- Authorization failure → existing `ActionPolicy::Unauthorized` flow → `403`.
|
|
184
|
+
- Empty/blank `q` → return all candidates within the cap (so initial dropdown open shows something useful, mirroring the eager mode).
|
|
185
|
+
- Network/parse errors on the JS side → controller leaves the existing `<select>` options intact and shows a small "couldn't search" inline notice; user can retry.
|
|
186
|
+
|
|
187
|
+
## Testing
|
|
188
|
+
|
|
189
|
+
- **Unit — `Searchable#typeahead` (ResourceSelect):** static choices filter case-insensitively; association case routes through `authorized_resource_scope`; overflow detection (`limit+1` rows in DB → `has_more: true`); blank query returns top-N.
|
|
190
|
+
- **Controller — `Typeahead#typeahead_input` / `typeahead_filter`:** happy path renders correct JSON envelope; unknown name → 404; non-searchable input class → 400; auth denied → 403.
|
|
191
|
+
- **Integration:** full request through `admin_portal` hitting a registered resource, verifying SGID round-trip (the value in the response is accepted by ResourceSelect on form submit).
|
|
192
|
+
- **JS — Stimulus controller:** debounces input, handles `has_more`, handles network errors. Lightweight, behavior-focused.
|
|
193
|
+
|
|
194
|
+
## Migration & rollout
|
|
195
|
+
|
|
196
|
+
- Existing eager `ResourceSelect` keeps working — typeahead is opt-in per render via a flag on the input definition (e.g. `as: :resource_select, typeahead: true`). When unset, the component renders today's eager list. The Stimulus controller only attaches when `data-resource-select-typeahead-url-value` is present.
|
|
197
|
+
- Filter inputs default to typeahead when the underlying input class supports it (filters are the worst pain point for the 100-row cap).
|
|
198
|
+
|
|
199
|
+
## Open questions / deferred
|
|
200
|
+
|
|
201
|
+
- Server-driven sort order beyond what `relation_scope` returns (e.g. recency, fuzzy-rank). Out of scope for MVP.
|
|
202
|
+
- Multi-select typeahead UX (chips, paste-multiple). MVP supports `multiple: true` mechanically (the array of SGIDs round-trips fine), but the dropdown UX is single-select-shaped. Iteration.
|
|
203
|
+
- Caching/coalescing repeated queries client-side. Defer.
|