cafe_car 0.2.1 → 0.3.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-plugin/marketplace.json +23 -0
- data/README.md +417 -29
- data/Rakefile +2 -2
- data/app/assets/stylesheets/cafe_car/chart.css +41 -0
- data/app/assets/stylesheets/cafe_car/dashboard.css +43 -0
- data/app/assets/stylesheets/cafe_car/filters.css +50 -0
- data/app/assets/stylesheets/cafe_car/themes/cool2.css +2 -0
- data/app/assets/stylesheets/cafe_car/themes/warm.css +30 -0
- data/app/assets/stylesheets/cafe_car/tooltips.css +1 -1
- data/app/assets/stylesheets/cafe_car.css +14 -4
- data/app/assets/stylesheets/tom-select.css +95 -0
- data/app/assets/stylesheets/ui/Button.css +7 -5
- data/app/assets/stylesheets/ui/Input.css +92 -1
- data/app/assets/stylesheets/ui/Page.css +7 -0
- data/app/assets/stylesheets/ui/Table.css +25 -0
- data/app/controllers/cafe_car/dashboards_controller.rb +29 -0
- data/app/javascript/cafe_car.js +68 -0
- data/app/javascript/tom-select.complete.min.js +444 -0
- data/app/policies/cafe_car/application_policy.rb +1 -1
- data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +4 -1
- data/app/presenters/cafe_car/presenter.rb +2 -2
- data/app/views/application/_actions.html.haml +9 -0
- data/app/views/application/_active_filters.html.haml +15 -0
- data/app/views/application/_belongs_to_filter.html.haml +6 -0
- data/app/views/application/_boolean_filter.html.haml +4 -0
- data/app/views/application/_bulk_actions.html.haml +9 -0
- data/app/views/application/_chart.html.haml +18 -0
- data/app/views/application/_controls.html.haml +8 -0
- data/app/views/application/_enum_filter.html.haml +5 -0
- data/app/views/application/_filter.html.haml +4 -0
- data/app/views/application/_filters.html.haml +13 -4
- data/app/views/application/_has_many_filter.html.haml +7 -0
- data/app/views/application/_head.html.haml +1 -0
- data/app/views/application/_index.html.haml +3 -1
- data/app/views/application/_index_actions.html.haml +12 -3
- data/app/views/application/_navigation.html.haml +5 -0
- data/app/views/application/_nested_field.html.haml +1 -1
- data/app/views/application/_range_filter.html.haml +8 -0
- data/app/views/application/_scope_filter.html.haml +5 -0
- data/app/views/application/_string_filter.html.haml +4 -0
- data/app/views/application/_table.html.haml +8 -6
- data/app/views/application/_text_filter.html.haml +1 -0
- data/app/views/cafe_car/application/index.html.haml +5 -2
- data/app/views/cafe_car/dashboard/_chart.html.haml +11 -0
- data/app/views/cafe_car/dashboard/_metric.html.haml +4 -0
- data/config/brakeman.ignore +0 -48
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +60 -0
- data/config/routes.rb +15 -2
- data/lib/cafe_car/attributes.rb +40 -12
- data/lib/cafe_car/chart_builder.rb +217 -0
- data/lib/cafe_car/controller/filtering.rb +108 -6
- data/lib/cafe_car/controller.rb +220 -11
- data/lib/cafe_car/engine.rb +2 -2
- data/lib/cafe_car/field_info.rb +14 -4
- data/lib/cafe_car/filter/field_info.rb +56 -7
- data/lib/cafe_car/filter/form_builder.rb +57 -7
- data/lib/cafe_car/filter_builder.rb +14 -1
- data/lib/cafe_car/form_builder.rb +61 -6
- data/lib/cafe_car/helpers.rb +196 -0
- data/lib/cafe_car/inputs/association_input.rb +11 -0
- data/lib/cafe_car/inputs/base_input.rb +56 -7
- data/lib/cafe_car/inputs/boolean_input.rb +9 -0
- data/lib/cafe_car/inputs/date_input.rb +8 -0
- data/lib/cafe_car/inputs/datetime_input.rb +8 -0
- data/lib/cafe_car/inputs/file_input.rb +11 -0
- data/lib/cafe_car/inputs/nested_input.rb +10 -0
- data/lib/cafe_car/inputs/number_input.rb +9 -0
- data/lib/cafe_car/inputs/password_input.rb +4 -2
- data/lib/cafe_car/inputs/rich_text_input.rb +9 -0
- data/lib/cafe_car/inputs/select_input.rb +10 -0
- data/lib/cafe_car/inputs/string_input.rb +3 -1
- data/lib/cafe_car/inputs/text_area_input.rb +9 -0
- data/lib/cafe_car/link_builder.rb +11 -0
- data/lib/cafe_car/model.rb +37 -8
- data/lib/cafe_car/navigation.rb +10 -0
- data/lib/cafe_car/policy.rb +85 -15
- data/lib/cafe_car/query_builder.rb +22 -0
- data/lib/cafe_car/routing.rb +26 -2
- data/lib/cafe_car/table/builder.rb +1 -1
- data/lib/cafe_car/table/foot_builder.rb +1 -0
- data/lib/cafe_car/table/head_builder.rb +6 -0
- data/lib/cafe_car/table/row_builder.rb +7 -0
- data/lib/cafe_car/version.rb +1 -1
- data/lib/cafe_car.rb +32 -2
- data/lib/generators/cafe_car/agents/USAGE +13 -0
- data/lib/generators/cafe_car/agents/agents_generator.rb +49 -0
- data/lib/generators/cafe_car/resource/resource_generator.rb +12 -1
- data/lib/tasks/demo.rake +9 -0
- data/llms.txt +25 -0
- data/skills/cafe_car/SKILL.md +135 -0
- data/skills/cafe_car/references/components.md +93 -0
- data/skills/cafe_car/references/controllers.md +106 -0
- data/skills/cafe_car/references/filtering.md +102 -0
- data/skills/cafe_car/references/forms.md +84 -0
- data/skills/cafe_car/references/locales.md +74 -0
- data/skills/cafe_car/references/navigation.md +67 -0
- data/skills/cafe_car/references/policies.md +112 -0
- data/skills/cafe_car/references/presenters.md +76 -0
- data/skills/cafe_car/references/turbo.md +53 -0
- data/skills/cafe_car/references/views.md +96 -0
- metadata +59 -16
- data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +0 -29
- data/app/views/application/_search.html.haml +0 -6
- data/lib/cafe_car/auto_resolver.rb +0 -49
- data/lib/cafe_car/inputs/association_builder.rb +0 -6
- data/lib/cafe_car/inputs/belongs_to_builder.rb +0 -6
- data/lib/tasks/holdco_tasks.rake +0 -532
- data/lib/tasks/templates/tasks_header.md +0 -37
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
.Dashboard {
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
5
|
+
gap: var(--gap);
|
|
6
|
+
align-items: start;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.Dashboard-tile {
|
|
10
|
+
padding: var(--gap);
|
|
11
|
+
border: 1px solid var(--border);
|
|
12
|
+
border-radius: var(--radius);
|
|
13
|
+
background: var(--card);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* A chart spans wider than a metric tile so its bars have room to breathe. */
|
|
17
|
+
.Dashboard-chart {
|
|
18
|
+
grid-column: 1 / -1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (min-width: 60rem) {
|
|
22
|
+
.Dashboard-chart {
|
|
23
|
+
grid-column: span 2;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.Dashboard-chartTitle {
|
|
28
|
+
margin: 0 0 var(--half-gap);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.Metric-value {
|
|
32
|
+
font-size: 2rem;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
line-height: 1.1;
|
|
35
|
+
color: var(--accent);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.Metric-label {
|
|
39
|
+
margin-block-start: var(--half-gap);
|
|
40
|
+
color: var(--dim);
|
|
41
|
+
font-size: 0.9rem;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
/* The active-filter chip row (_active_filters). Scoped entirely under
|
|
3
|
+
.ActiveFilters — no bare element selectors — so it never restyles chips,
|
|
4
|
+
links, or buttons reused elsewhere. */
|
|
5
|
+
.ActiveFilters {
|
|
6
|
+
display: flex;
|
|
7
|
+
flex-wrap: wrap;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: var(--half-gap);
|
|
10
|
+
margin-block-end: var(--gap);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ActiveFilters-chip {
|
|
14
|
+
display: inline-flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
gap: 0.4em;
|
|
17
|
+
padding: 0.15em 0.5em;
|
|
18
|
+
border: 1px solid var(--border);
|
|
19
|
+
border-radius: var(--control-radius);
|
|
20
|
+
background: var(--card);
|
|
21
|
+
font-size: 0.9rem;
|
|
22
|
+
line-height: 1.4;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ActiveFilters-label {
|
|
26
|
+
color: var(--dim);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ActiveFilters-label::after {
|
|
30
|
+
content: ":";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ActiveFilters-value {
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ActiveFilters-remove {
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
color: var(--dim);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.ActiveFilters-remove:hover {
|
|
43
|
+
color: var(--color);
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.ActiveFilters-clear {
|
|
48
|
+
font-size: 0.9rem;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -22,3 +22,33 @@
|
|
|
22
22
|
body {
|
|
23
23
|
scrollbar-color: rgb(209, 206, 203) rgb(246, 245, 243);
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
@media (prefers-color-scheme: dark) {
|
|
27
|
+
:root {
|
|
28
|
+
--blue: rgb(35, 175, 208);
|
|
29
|
+
|
|
30
|
+
--input: #1e1e1d;
|
|
31
|
+
--background: #191918;
|
|
32
|
+
--button: #333;
|
|
33
|
+
|
|
34
|
+
--color: #e7e3dc;
|
|
35
|
+
--dim: #6c7a91;
|
|
36
|
+
|
|
37
|
+
--card: #222221;
|
|
38
|
+
--border: #2A2A28;
|
|
39
|
+
--hover: rgb(42, 42, 40);
|
|
40
|
+
--shadow: rgba(0, 0, 0, 0.2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
body {
|
|
44
|
+
scrollbar-color: rgb(73, 72, 70) rgb(25, 25, 24);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.Card, .Table {
|
|
48
|
+
border: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.Table_Head {
|
|
52
|
+
background-color: var(--card);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -4,15 +4,22 @@
|
|
|
4
4
|
|
|
5
5
|
@import url(trix.css) layer(vendor);
|
|
6
6
|
@import url(iconoir.css) layer(vendor);
|
|
7
|
+
@import url(tom-select.css) layer(vendor);
|
|
7
8
|
@import url(cafe_car/trix.css);
|
|
8
9
|
|
|
10
|
+
@import url(cafe_car/chart.css);
|
|
11
|
+
@import url(cafe_car/dashboard.css);
|
|
12
|
+
@import url(cafe_car/filters.css);
|
|
9
13
|
@import url(cafe_car/pagination.css);
|
|
10
14
|
@import url(cafe_car/tooltips.css);
|
|
11
15
|
@import url(cafe_car/utility.css);
|
|
12
16
|
@import url(ui/components.css);
|
|
13
17
|
@import url(Lexend.css);
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
|
|
19
|
+
/* The theme's custom properties are injected per-request in <head> by
|
|
20
|
+
CafeCar::Helpers#theme_stylesheet_tag, selected via `CafeCar.theme`
|
|
21
|
+
(default :warm). Don't @import a theme here — that would pin it and
|
|
22
|
+
defeat the config API. */
|
|
16
23
|
|
|
17
24
|
@import url(cafe_car/code/base16-light.css);
|
|
18
25
|
@import url(cafe_car/code/base16-dark.css) (prefers-color-scheme: dark);
|
|
@@ -29,13 +36,16 @@ a {
|
|
|
29
36
|
color: var(--link);
|
|
30
37
|
text-decoration: none;
|
|
31
38
|
|
|
39
|
+
/* No `filter` here: it would make the link a containing block for the
|
|
40
|
+
fixed tooltip pseudo, trapping it over the trigger. A slight color
|
|
41
|
+
shift gives equivalent hover/active feedback without one. */
|
|
32
42
|
&:hover {
|
|
33
43
|
text-decoration: underline;
|
|
34
|
-
|
|
44
|
+
color: color-mix(in oklab, var(--link), white 12%);
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
&:active {
|
|
38
|
-
|
|
48
|
+
color: color-mix(in oklab, var(--link), black 12%);
|
|
39
49
|
}
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Tom Select v2.4.3 — https://tom-select.js.org
|
|
3
|
+
* Licensed under the Apache License, Version 2.0.
|
|
4
|
+
* Vendored (default theme) for CafeCar; see config/importmap.rb for the JS pin.
|
|
5
|
+
*/
|
|
6
|
+
.ts-control{border:1px solid #d0d0d0;border-radius:3px;box-shadow:inset 0 1px 1px rgba(0,0,0,.1);box-sizing:border-box;display:flex;flex-wrap:wrap;overflow:hidden;padding:8px;position:relative;width:100%;z-index:1}.ts-wrapper.multi.has-items .ts-control{padding:5px 8px 2px}.full .ts-control{background-color:#fff}.disabled .ts-control,.disabled .ts-control *{cursor:default!important}.focus .ts-control{box-shadow:inset 0 1px 2px rgba(0,0,0,.15)}.ts-control>*{display:inline-block;vertical-align:initial}.ts-wrapper.multi .ts-control>div{background:#1da7ee;border:1px solid #0073bb;color:#fff;cursor:pointer;margin:0 3px 3px 0;padding:2px 6px}.ts-wrapper.multi .ts-control>div.active{background:#92c836;border:1px solid #00578d;color:#fff}.ts-wrapper.multi.disabled .ts-control>div,.ts-wrapper.multi.disabled .ts-control>div.active{background:#d2d2d2;border:1px solid #aaa;color:#fff}.ts-control>input{background:none!important;border:0!important;box-shadow:none!important;display:inline-block!important;flex:1 1 auto;line-height:inherit!important;margin:0!important;max-height:none!important;max-width:100%!important;min-height:0!important;min-width:7rem;padding:0!important;text-indent:0!important;-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.ts-control>input::-ms-clear{display:none}.ts-control>input:focus{outline:none!important}.has-items .ts-control>input{margin:0 4px!important}.ts-control.rtl{text-align:right}.ts-control.rtl.single .ts-control:after{left:15px;right:auto}.ts-control.rtl .ts-control>input{margin:0 4px 0 -2px!important}.disabled .ts-control{background-color:#fafafa;opacity:.5}.input-hidden .ts-control>input{left:-10000px;opacity:0;position:absolute}.ts-dropdown{background:#fff;border:1px solid #d0d0d0;border-radius:0 0 3px 3px;border-top:0;box-shadow:0 1px 3px rgba(0,0,0,.1);box-sizing:border-box;left:0;margin:.25rem 0 0;position:absolute;top:100%;width:100%;z-index:10}.ts-dropdown [data-selectable]{cursor:pointer;overflow:hidden}.ts-dropdown [data-selectable] .highlight{background:rgba(125,168,208,.2);border-radius:1px}.ts-dropdown .create,.ts-dropdown .no-results,.ts-dropdown .optgroup-header,.ts-dropdown .option{padding:5px 8px}.ts-dropdown .option,.ts-dropdown [data-disabled],.ts-dropdown [data-disabled] [data-selectable].option{cursor:inherit;opacity:.5}.ts-dropdown [data-selectable].option{cursor:pointer;opacity:1}.ts-dropdown .optgroup:first-child .optgroup-header{border-top:0}.ts-dropdown .optgroup-header{background:#fff;color:#303030;cursor:default}.ts-dropdown .active{background-color:#f5fafd;color:#495c68}.ts-dropdown .active.create{color:#495c68}.ts-dropdown .create{color:rgba(48,48,48,.5)}.ts-dropdown .spinner{display:inline-block;height:30px;margin:5px 8px;width:30px}.ts-dropdown .spinner:after{animation:lds-dual-ring 1.2s linear infinite;border-color:#d0d0d0 transparent;border-radius:50%;border-style:solid;border-width:5px;content:" ";display:block;height:24px;margin:3px;width:24px}@keyframes lds-dual-ring{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.ts-dropdown-content{max-height:200px;overflow:hidden auto;scroll-behavior:smooth}.ts-wrapper.plugin-drag_drop .ts-dragging{color:transparent!important}.ts-wrapper.plugin-drag_drop .ts-dragging>*{visibility:hidden!important}.plugin-checkbox_options:not(.rtl) .option input{margin-right:.5rem}.plugin-checkbox_options.rtl .option input{margin-left:.5rem}.plugin-clear_button{--ts-pr-clear-button:1em}.plugin-clear_button .clear-button{background:transparent!important;cursor:pointer;margin-right:0!important;opacity:0;position:absolute;right:2px;top:50%;transform:translateY(-50%);transition:opacity .5s}.plugin-clear_button.form-select .clear-button,.plugin-clear_button.single .clear-button{right:max(var(--ts-pr-caret),8px)}.plugin-clear_button.focus.has-items .clear-button,.plugin-clear_button:not(.disabled):hover.has-items .clear-button{opacity:1}.ts-wrapper .dropdown-header{background:color-mix(#fff,#d0d0d0,85%);border-bottom:1px solid #d0d0d0;border-radius:3px 3px 0 0;padding:10px 8px;position:relative}.ts-wrapper .dropdown-header-close{color:#303030;font-size:20px!important;line-height:20px;margin-top:-12px;opacity:.4;position:absolute;right:8px;top:50%}.ts-wrapper .dropdown-header-close:hover{color:#000}.plugin-dropdown_input.focus.dropdown-active .ts-control{border:1px solid #d0d0d0;box-shadow:none}.plugin-dropdown_input .dropdown-input{background:transparent;border:solid #d0d0d0;border-width:0 0 1px;box-shadow:inset 0 1px 1px rgba(0,0,0,.1);display:block;padding:8px;width:100%}.plugin-dropdown_input .items-placeholder{border:0!important;box-shadow:none!important;width:100%}.plugin-dropdown_input.dropdown-active .items-placeholder,.plugin-dropdown_input.has-items .items-placeholder{display:none!important}.ts-wrapper.plugin-input_autogrow.has-items .ts-control>input{min-width:0}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input{flex:none;min-width:4px}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::-ms-input-placeholder{color:transparent}.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control>input::placeholder{color:transparent}.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content{display:flex}.ts-dropdown.plugin-optgroup_columns .optgroup{border-right:1px solid #f2f2f2;border-top:0;flex-basis:0;flex-grow:1;min-width:0}.ts-dropdown.plugin-optgroup_columns .optgroup:last-child{border-right:0}.ts-dropdown.plugin-optgroup_columns .optgroup:before{display:none}.ts-dropdown.plugin-optgroup_columns .optgroup-header{border-top:0}.ts-wrapper.plugin-remove_button .item{align-items:center;display:inline-flex}.ts-wrapper.plugin-remove_button .item .remove{border-radius:0 2px 2px 0;box-sizing:border-box;color:inherit;display:inline-block;padding:0 6px;text-decoration:none;vertical-align:middle}.ts-wrapper.plugin-remove_button .item .remove:hover{background:rgba(0,0,0,.05)}.ts-wrapper.plugin-remove_button.disabled .item .remove:hover{background:none}.ts-wrapper.plugin-remove_button .remove-single{font-size:23px;position:absolute;right:0;top:0}.ts-wrapper.plugin-remove_button:not(.rtl) .item{padding-right:0!important}.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove{border-left:1px solid #0073bb;margin-left:6px}.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove{border-left-color:#00578d}.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove{border-left-color:#aaa}.ts-wrapper.plugin-remove_button.rtl .item{padding-left:0!important}.ts-wrapper.plugin-remove_button.rtl .item .remove{border-right:1px solid #0073bb;margin-right:6px}.ts-wrapper.plugin-remove_button.rtl .item.active .remove{border-right-color:#00578d}.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove{border-right-color:#aaa}:root{--ts-pr-clear-button:0px;--ts-pr-caret:0px;--ts-pr-min:.75rem}.ts-wrapper.single .ts-control,.ts-wrapper.single .ts-control input{cursor:pointer}.ts-control:not(.rtl){padding-right:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-control.rtl{padding-left:max(var(--ts-pr-min),var(--ts-pr-clear-button) + var(--ts-pr-caret))!important}.ts-wrapper{position:relative}.ts-control,.ts-control input,.ts-dropdown{color:#303030;font-family:inherit;font-size:13px;line-height:18px}.ts-control,.ts-wrapper.single.input-active .ts-control{background:#fff;cursor:text}.ts-hidden-accessible{border:0!important;clip:rect(0 0 0 0)!important;-webkit-clip-path:inset(50%)!important;clip-path:inset(50%)!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important}.ts-wrapper.single .ts-control{--ts-pr-caret:2rem}.ts-wrapper.single .ts-control:after{border-color:grey transparent transparent;border-style:solid;border-width:5px 5px 0;content:" ";display:block;height:0;margin-top:-3px;position:absolute;top:50%;width:0}.ts-wrapper.single .ts-control:not(.rtl):after{right:15px}.ts-wrapper.single .ts-control.rtl:after{left:15px}.ts-wrapper.single.dropdown-active .ts-control:after{border-color:transparent transparent grey;border-width:0 5px 5px;margin-top:-4px}.ts-wrapper.single.input-active .ts-control,.ts-wrapper.single.input-active .ts-control input{cursor:text}.ts-wrapper{display:flex;min-height:36px}.ts-wrapper.multi.has-items .ts-control{padding-left:5px;--ts-pr-min:$padding-x}.ts-wrapper.multi .ts-control [data-value]{background-color:color-mix(#1da7ee,#178ee9,60%);background-image:linear-gradient(180deg,#1da7ee,#178ee9);background-repeat:repeat-x;border-radius:3px;box-shadow:0 1px 0 rgba(0,0,0,.2),inset 0 1px hsla(0,0%,100%,.03);text-shadow:0 1px 0 rgba(0,51,83,.3)}.ts-wrapper.multi .ts-control [data-value].active{background-color:color-mix(#008fd8,#0075cf,60%);background-image:linear-gradient(180deg,#008fd8,#0075cf);background-repeat:repeat-x}.ts-wrapper.multi.disabled .ts-control [data-value]{background:none;box-shadow:none;color:#999;text-shadow:none}.ts-wrapper.multi.disabled .ts-control [data-value],.ts-wrapper.multi.disabled .ts-control [data-value] .remove{border-color:#e6e6e6}.ts-wrapper.multi.disabled .ts-control [data-value] .remove{background:none}.ts-wrapper.single .ts-control{background-color:color-mix(#fefefe,#f2f2f2,60%);background-image:linear-gradient(180deg,#fefefe,#f2f2f2);background-repeat:repeat-x;box-shadow:0 1px 0 rgba(0,0,0,.05),inset 0 1px 0 hsla(0,0%,100%,.8)}.ts-dropdown.single,.ts-wrapper.single .ts-control{border-color:#b8b8b8}.dropdown-active .ts-control{border-radius:3px 3px 0 0}.ts-dropdown .optgroup-header{font-size:.85em;font-weight:700;padding-top:7px}.ts-dropdown .optgroup{border-top:1px solid #f0f0f0}.ts-dropdown .optgroup:first-child{border-top:0}
|
|
7
|
+
/*# sourceMappingURL=tom-select.default.min.css.map */
|
|
8
|
+
|
|
9
|
+
/* CafeCar theming — bind the vendored default theme to the app's CSS custom
|
|
10
|
+
properties so the association typeahead reads like every other input and
|
|
11
|
+
follows light/dark mode (see cafe_car/themes/*.css). No hardcoded colors:
|
|
12
|
+
the tokens carry the light/dark values, keeping styling inside the component
|
|
13
|
+
per CafeCar's "no styles outside of components." Selectors mirror the
|
|
14
|
+
vendored ones so specificity ties and source order (this block is last) wins. */
|
|
15
|
+
.ts-wrapper {
|
|
16
|
+
color: inherit;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* The control matches a text input (see ui/Input.css): token background/border,
|
|
20
|
+
the same radius and soft shadow, no baked-in gradient. */
|
|
21
|
+
.ts-control,
|
|
22
|
+
.full .ts-control,
|
|
23
|
+
.ts-wrapper.single .ts-control,
|
|
24
|
+
.ts-wrapper.single.input-active .ts-control {
|
|
25
|
+
background: var(--input);
|
|
26
|
+
background-image: none;
|
|
27
|
+
border: 1px solid var(--border);
|
|
28
|
+
border-radius: var(--control-radius);
|
|
29
|
+
box-shadow: rgba(34, 32, 29, 0.05) 0 1px 2px;
|
|
30
|
+
color: inherit;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.ts-control,
|
|
34
|
+
.ts-control input,
|
|
35
|
+
.ts-dropdown {
|
|
36
|
+
color: inherit;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ts-control input::placeholder {
|
|
40
|
+
color: inherit;
|
|
41
|
+
opacity: 0.3;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.focus .ts-control {
|
|
45
|
+
outline: 2px solid var(--outline);
|
|
46
|
+
outline-offset: -1px;
|
|
47
|
+
box-shadow: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Multi-select chips read as filled accent controls. */
|
|
51
|
+
.ts-wrapper.multi .ts-control > div,
|
|
52
|
+
.ts-wrapper.multi .ts-control [data-value] {
|
|
53
|
+
background: var(--accent);
|
|
54
|
+
background-image: none;
|
|
55
|
+
border: 1px solid var(--accent);
|
|
56
|
+
border-radius: var(--control-radius);
|
|
57
|
+
box-shadow: none;
|
|
58
|
+
color: var(--white);
|
|
59
|
+
text-shadow: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ts-wrapper.multi .ts-control > div.active,
|
|
63
|
+
.ts-wrapper.multi .ts-control [data-value].active {
|
|
64
|
+
background: var(--accent);
|
|
65
|
+
background-image: none;
|
|
66
|
+
border-color: var(--accent);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* The dropdown is a menu surface bound to the card/border tokens. */
|
|
70
|
+
.ts-dropdown {
|
|
71
|
+
background: var(--card);
|
|
72
|
+
border: 1px solid var(--border);
|
|
73
|
+
border-radius: var(--control-radius);
|
|
74
|
+
box-shadow: var(--box-shadow);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ts-dropdown .option,
|
|
78
|
+
.ts-dropdown .create,
|
|
79
|
+
.ts-dropdown .no-results,
|
|
80
|
+
.ts-dropdown .optgroup-header {
|
|
81
|
+
color: inherit;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ts-dropdown .optgroup-header {
|
|
85
|
+
background: var(--card);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ts-dropdown .active {
|
|
89
|
+
background: var(--selection);
|
|
90
|
+
color: inherit;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.ts-dropdown [data-selectable] .highlight {
|
|
94
|
+
background: var(--selection);
|
|
95
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
padding: var(--half-gap);
|
|
10
10
|
box-shadow: inset var(--darken) 0 -1px 0 1px;
|
|
11
11
|
border: 0;
|
|
12
|
-
display: flex;
|
|
12
|
+
display: inline-flex;
|
|
13
13
|
align-items: center;
|
|
14
14
|
gap: 0.5em;
|
|
15
15
|
user-select: none;
|
|
@@ -18,17 +18,19 @@
|
|
|
18
18
|
.Button:where(:not(.Button-current)):hover {
|
|
19
19
|
cursor: pointer;
|
|
20
20
|
text-decoration: none;
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
/* A translucent lighten overlay stands in for filter: brightness(1.05).
|
|
22
|
+
A `filter` would make this a containing block for the fixed tooltip
|
|
23
|
+
pseudo, trapping it over the trigger — box-shadow does not. */
|
|
24
|
+
box-shadow: inset var(--darken) 0 -2px 0 1px, inset var(--lighten) 0 0 0 100vmax;
|
|
23
25
|
z-index: 1;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
.Button:active,
|
|
27
29
|
.Button-current {
|
|
28
30
|
text-decoration: none;
|
|
29
|
-
filter: brightness(0.95)
|
|
31
|
+
/* Darken overlay in place of filter: brightness(0.95) — see hover note. */
|
|
30
32
|
border-bottom-width: 1px;
|
|
31
|
-
box-shadow: inset var(--darken) 0 1px;
|
|
33
|
+
box-shadow: inset var(--darken) 0 1px, inset var(--darken) 0 0 0 100vmax;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
.Button-primary {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
.Input,
|
|
2
|
-
input,
|
|
2
|
+
input:not([type="checkbox"], [type="radio"]),
|
|
3
3
|
textarea,
|
|
4
4
|
select {
|
|
5
5
|
background: var(--input);
|
|
@@ -10,6 +10,97 @@ select {
|
|
|
10
10
|
box-shadow: rgba(34, 32, 29, 0.05) 0px 1px 2px;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/* Checkboxes and radios: native inputs restyled to the theme so they read as
|
|
14
|
+
CafeCar controls, not raw browser widgets. `appearance: none` drops the OS
|
|
15
|
+
chrome while keeping the element a real, keyboard-operable input — focus,
|
|
16
|
+
Space/Enter, and label association all stay native. The mark is drawn with a
|
|
17
|
+
::before so no extra markup or image is needed.
|
|
18
|
+
|
|
19
|
+
SCOPED to the components that own these controls — form fields (.Field) and the
|
|
20
|
+
table's select column (.Table_Cell-select). Left global, an `input[type=checkbox]`
|
|
21
|
+
rule outweighs (specificity 0,1,1) the class-based styling of checkboxes reused for
|
|
22
|
+
other purposes — e.g. the sidebar's .Layout_Menu toggle — and clobbers them. Styling
|
|
23
|
+
lives inside the component, per "no styles outside of components." */
|
|
24
|
+
:where(.Field, .Table_Cell-select) {
|
|
25
|
+
input[type="checkbox"],
|
|
26
|
+
input[type="radio"] {
|
|
27
|
+
appearance: none;
|
|
28
|
+
-webkit-appearance: none;
|
|
29
|
+
flex: none;
|
|
30
|
+
display: inline-grid;
|
|
31
|
+
place-content: center;
|
|
32
|
+
width: 1.15em;
|
|
33
|
+
height: 1.15em;
|
|
34
|
+
margin: 0;
|
|
35
|
+
padding: 0;
|
|
36
|
+
color: inherit;
|
|
37
|
+
background: var(--input);
|
|
38
|
+
border: 1px solid var(--border);
|
|
39
|
+
border-radius: var(--control-radius);
|
|
40
|
+
box-shadow: rgba(34, 32, 29, 0.05) 0px 1px 2px;
|
|
41
|
+
cursor: pointer;
|
|
42
|
+
vertical-align: -0.2em;
|
|
43
|
+
transition: background var(--duration), border-color var(--duration);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
input[type="radio"] {
|
|
47
|
+
border-radius: 50%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
input[type="checkbox"]::before,
|
|
51
|
+
input[type="radio"]::before {
|
|
52
|
+
content: "";
|
|
53
|
+
width: 0.68em;
|
|
54
|
+
height: 0.68em;
|
|
55
|
+
transform: scale(0);
|
|
56
|
+
transition: transform var(--duration) ease-in-out;
|
|
57
|
+
background: var(--white);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
input[type="checkbox"]::before {
|
|
61
|
+
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
input[type="radio"]::before {
|
|
65
|
+
border-radius: 50%;
|
|
66
|
+
width: 0.55em;
|
|
67
|
+
height: 0.55em;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
input[type="checkbox"]:checked,
|
|
71
|
+
input[type="radio"]:checked {
|
|
72
|
+
background: var(--accent);
|
|
73
|
+
border-color: var(--accent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
input[type="checkbox"]:checked::before,
|
|
77
|
+
input[type="radio"]:checked::before {
|
|
78
|
+
transform: scale(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
input[type="checkbox"]:indeterminate {
|
|
82
|
+
background: var(--accent);
|
|
83
|
+
border-color: var(--accent);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
input[type="checkbox"]:indeterminate::before {
|
|
87
|
+
clip-path: inset(45% 15%);
|
|
88
|
+
transform: scale(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
input[type="checkbox"]:focus-visible,
|
|
92
|
+
input[type="radio"]:focus-visible {
|
|
93
|
+
outline: 2px solid var(--outline);
|
|
94
|
+
outline-offset: 2px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
input[type="checkbox"]:disabled,
|
|
98
|
+
input[type="radio"]:disabled {
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
opacity: 0.5;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
13
104
|
input,
|
|
14
105
|
textarea,
|
|
15
106
|
select {
|
|
@@ -79,6 +79,13 @@
|
|
|
79
79
|
/* overflow: auto; */
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/* The body is a stretch column, so cards/tables/forms fill its width. A lone
|
|
83
|
+
button is an action, not a block — size it to its label and align it left
|
|
84
|
+
instead of letting it span the whole page. */
|
|
85
|
+
.Page_Body > .Button {
|
|
86
|
+
align-self: start;
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
.Page-slim .Page_Body,
|
|
83
90
|
.Page_Body-slim {
|
|
84
91
|
max-width: 720px;
|
|
@@ -98,4 +98,29 @@
|
|
|
98
98
|
&-controls {
|
|
99
99
|
text-align: right;
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
&-select {
|
|
103
|
+
text-align: center;
|
|
104
|
+
padding-inline: 0.5em;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Index toolbar: the row above the table holding the bulk-action bar. Wraps
|
|
109
|
+
on narrow screens so the controls don't get crushed. */
|
|
110
|
+
.IndexToolbar {
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-wrap: wrap;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: var(--half-gap) var(--gap);
|
|
115
|
+
margin-bottom: var(--gap);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.BulkActions {
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
gap: var(--half-gap);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.BulkActions[hidden] {
|
|
125
|
+
display: none;
|
|
101
126
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module CafeCar
|
|
2
|
+
# The opt-in dashboard overview. A host defines the dashboard by writing one
|
|
3
|
+
# template — `app/views/cafe_car/dashboard/show.html.haml` — that composes the
|
|
4
|
+
# `metric`/`chart` helpers (and the policy-driven `metrics` helper). Its existence
|
|
5
|
+
# IS the opt-in: the route always mounts, but with no host template this 404s, so
|
|
6
|
+
# a CRUD-only host never inherits a blank page. It has no model of its own, so it
|
|
7
|
+
# skips the CRUD policy/authorization pipeline like the components gallery does.
|
|
8
|
+
class DashboardsController < const(:ApplicationController)
|
|
9
|
+
include Controller
|
|
10
|
+
helper CafeCar::Helpers
|
|
11
|
+
|
|
12
|
+
before_action :skip_policy_scope
|
|
13
|
+
before_action :skip_authorization
|
|
14
|
+
|
|
15
|
+
def show
|
|
16
|
+
return head(:not_found) unless dashboard_template?
|
|
17
|
+
render "cafe_car/dashboard/show"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Whether the host has written the dashboard template — the opt-in signal. The
|
|
23
|
+
# engine ships no `cafe_car/dashboard/show`, so this is true only when the host
|
|
24
|
+
# placed one at `app/views/cafe_car/dashboard/show`.
|
|
25
|
+
def dashboard_template? = template_exists?("show", %w[cafe_car/dashboard], false)
|
|
26
|
+
|
|
27
|
+
def model_name = @model_name ||= ActiveModel::Name.new(:dashboard)
|
|
28
|
+
end
|
|
29
|
+
end
|
data/app/javascript/cafe_car.js
CHANGED
|
@@ -1,4 +1,42 @@
|
|
|
1
1
|
import "@hotwired/turbo-rails"
|
|
2
|
+
import "tom-select" // decorates [data-searchable-select] selects; sets globalThis.TomSelect
|
|
3
|
+
|
|
4
|
+
// Searchable association selects. Tom Select DECORATES an existing <select>, so the
|
|
5
|
+
// field keeps working as a plain select if this never runs (progressive enhancement).
|
|
6
|
+
// A `data-searchable-select-url` points at the model's JSON typeahead feed, letting
|
|
7
|
+
// the field reach records past `CafeCar.max_collection_options`; without it the select
|
|
8
|
+
// falls back to local search over the rendered options.
|
|
9
|
+
function enhanceSelects() {
|
|
10
|
+
document.querySelectorAll("select[data-searchable-select]").forEach(el => {
|
|
11
|
+
if (el.tomselect) return // Tom Select errors on an already-enhanced element
|
|
12
|
+
|
|
13
|
+
let url = el.dataset.searchableSelectUrl
|
|
14
|
+
let config = { maxOptions: null }
|
|
15
|
+
|
|
16
|
+
if (url) Object.assign(config, {
|
|
17
|
+
valueField: "value",
|
|
18
|
+
labelField: "text",
|
|
19
|
+
searchField: ["text"],
|
|
20
|
+
load(query, callback) {
|
|
21
|
+
fetch(`${url}?q=${encodeURIComponent(query)}`, { headers: { Accept: "application/json" } })
|
|
22
|
+
.then(response => response.json())
|
|
23
|
+
.then(callback)
|
|
24
|
+
.catch(() => callback())
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
new TomSelect(el, config)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
addEventListener("turbo:load", enhanceSelects)
|
|
33
|
+
addEventListener("turbo:frame-render", enhanceSelects)
|
|
34
|
+
|
|
35
|
+
// Revert enhancement before Turbo snapshots the page, so a restored cache doesn't
|
|
36
|
+
// carry a duplicated (already-decorated) widget.
|
|
37
|
+
addEventListener("turbo:before-cache", () => {
|
|
38
|
+
document.querySelectorAll("select[data-searchable-select]").forEach(el => el.tomselect?.destroy())
|
|
39
|
+
})
|
|
2
40
|
|
|
3
41
|
Object.assign(Turbo.StreamActions, {
|
|
4
42
|
// <turbo-stream action="navigate" target="Hello, world"></turbo-stream>
|
|
@@ -61,6 +99,36 @@ addEventListener("click", event => {
|
|
|
61
99
|
}
|
|
62
100
|
})
|
|
63
101
|
|
|
102
|
+
// Bulk-select: the header checkbox toggles every row checkbox in its form, and
|
|
103
|
+
// the bulk-action bar stays hidden until at least one row is selected. The bar
|
|
104
|
+
// lives in the index toolbar (outside the table's form), so visibility keys off
|
|
105
|
+
// any checked `ids[]` box on the page, and the header box shows an indeterminate
|
|
106
|
+
// state when only some rows are picked.
|
|
107
|
+
function refreshBulkBar() {
|
|
108
|
+
let boxes = document.querySelectorAll("input[name='ids[]']")
|
|
109
|
+
let checked = [...boxes].filter(box => box.checked)
|
|
110
|
+
let selected = checked.length > 0
|
|
111
|
+
|
|
112
|
+
document.querySelectorAll("[data-bulk-bar]").forEach(bar => bar.hidden = !selected)
|
|
113
|
+
document.querySelectorAll("[data-bulk-select-all]").forEach(all => {
|
|
114
|
+
all.checked = selected && checked.length === boxes.length
|
|
115
|
+
all.indeterminate = selected && checked.length < boxes.length
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
addEventListener("change", event => {
|
|
120
|
+
let all = event.target.closest("[data-bulk-select-all]")
|
|
121
|
+
if (all) {
|
|
122
|
+
let form = all.closest("form")
|
|
123
|
+
form.querySelectorAll("input[name='ids[]']").forEach(box => box.checked = all.checked)
|
|
124
|
+
refreshBulkBar()
|
|
125
|
+
} else if (event.target.matches("input[name='ids[]']")) {
|
|
126
|
+
refreshBulkBar()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
addEventListener("turbo:load", refreshBulkBar)
|
|
131
|
+
|
|
64
132
|
function animationEnd({ target }) {
|
|
65
133
|
if (target.matches(".remove")) target.remove()
|
|
66
134
|
else if (target.matches(".popup")) {
|