lcp 0.1.0 → 0.2.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/lcp-custom-field/SKILL.md +10 -1
- data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
- data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-workflow/SKILL.md +10 -2
- data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
- data/CHANGELOG.md +59 -1
- data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
- data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
- data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
- data/app/helpers/lcp_ruby/display_helper.rb +30 -3
- data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
- data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
- data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
- data/app/models/lcp_ruby/user.rb +10 -0
- data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
- data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
- data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
- data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
- data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
- data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
- data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
- data/docs/README.md +3 -0
- data/docs/feature-catalog.md +5 -2
- data/docs/feature-catalog.yml +89 -9
- data/docs/getting-started.md +38 -22
- data/docs/guides/dashboards.md +44 -1
- data/docs/guides/display-types.md +6 -0
- data/docs/guides/host-application.md +1 -1
- data/docs/guides/windows-setup.md +211 -0
- data/docs/guides/workflow.md +1 -1
- data/docs/reference/asset-pipeline.md +79 -0
- data/docs/reference/pages.md +50 -1
- data/docs/reference/presenters.md +6 -0
- data/docs/reference/workflow.md +2 -2
- data/examples/crm/Gemfile +3 -0
- data/examples/crm/Gemfile.lock +14 -9
- data/examples/crm/app/assets/config/manifest.js +4 -0
- data/examples/hr/Gemfile +3 -0
- data/examples/hr/Gemfile.lock +14 -9
- data/examples/hr/app/assets/config/manifest.js +4 -0
- data/examples/showcase/Gemfile +3 -0
- data/examples/showcase/Gemfile.lock +12 -9
- data/examples/showcase/app/assets/config/manifest.js +4 -0
- data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
- data/examples/showcase/config/locales/cs.yml +8 -0
- data/examples/showcase/config/locales/en.yml +8 -0
- data/examples/todo/Gemfile +3 -0
- data/examples/todo/Gemfile.lock +14 -9
- data/examples/todo/app/assets/config/manifest.js +4 -0
- data/exe/lcp +1 -1
- data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
- data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
- data/lib/generators/lcp_ruby/install_generator.rb +171 -3
- data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
- data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
- data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
- data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
- data/lib/lcp_ruby/app_template.rb +37 -1
- data/lib/lcp_ruby/array_query.rb +33 -10
- data/lib/lcp_ruby/cli/new_command.rb +9 -4
- data/lib/lcp_ruby/cli/run_command.rb +98 -11
- data/lib/lcp_ruby/cli/skills_command.rb +27 -15
- data/lib/lcp_ruby/cli.rb +5 -1
- data/lib/lcp_ruby/configuration.rb +13 -2
- data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
- data/lib/lcp_ruby/custom_fields/query.rb +28 -20
- data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
- data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
- data/lib/lcp_ruby/engine.rb +185 -1
- data/lib/lcp_ruby/export/data_generator.rb +5 -1
- data/lib/lcp_ruby/export/value_formatter.rb +34 -0
- data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
- data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
- data/lib/lcp_ruby/metadata/field_path.rb +57 -0
- data/lib/lcp_ruby/metadata/loader.rb +33 -1
- data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
- data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
- data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
- data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
- data/lib/lcp_ruby/metrics/json_query.rb +5 -0
- data/lib/lcp_ruby/model_factory/builder.rb +5 -0
- data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
- data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
- data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
- data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
- data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
- data/lib/lcp_ruby/schemas/page.json +33 -1
- data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
- data/lib/lcp_ruby/skills_installer.rb +15 -2
- data/lib/lcp_ruby/tasks/doctor.rb +13 -8
- data/lib/lcp_ruby/version.rb +1 -1
- data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
- data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
- data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
- data/lib/lcp_ruby/workflow/model_source.rb +2 -2
- data/lib/lcp_ruby.rb +18 -0
- data/lib/tasks/lcp_ruby_db.rake +8 -1
- data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
- metadata +34 -24
- data/examples/crm/erd.md +0 -163
- data/examples/hr/erd.md +0 -396
- data/examples/showcase/erd.md +0 -567
- data/examples/todo/erd.md +0 -21
|
@@ -25,7 +25,18 @@
|
|
|
25
25
|
h.setAttribute("data-lcp-radius", pick("lcp-radius", "<%= radius_def %>"));
|
|
26
26
|
})();
|
|
27
27
|
</script>
|
|
28
|
-
|
|
28
|
+
<%# Engine asset tags. `lcp_emit_engine_assets?` is false when the host
|
|
29
|
+
opted out via `config.skip_asset_pipeline_check = true` — that host runs
|
|
30
|
+
its own JS bundler (esbuild / importmap / jsbundling) and owns including
|
|
31
|
+
the LCP bundle, so the engine must NOT emit these pipeline-dependent tags
|
|
32
|
+
(Propshaft would raise MissingAssetError → 500 on every render). When NOT
|
|
33
|
+
opted out, the `lcp_ruby.asset_pipeline_check` boot initializer has
|
|
34
|
+
already guaranteed Sprockets is wired (audit #16), so the tags are safe.
|
|
35
|
+
The i18n payload is an inline <script> with no pipeline dependency and is
|
|
36
|
+
always emitted so a host-bundled LCP app.js can still consume it. The
|
|
37
|
+
dev-toolbar and vendored-mermaid blocks below use the same gate. %>
|
|
38
|
+
<%= lcp_i18n_payload_tag %>
|
|
39
|
+
<% if lcp_emit_engine_assets? %>
|
|
29
40
|
<%= stylesheet_link_tag "lcp_ruby/tom-select", media: "all" %>
|
|
30
41
|
<%= stylesheet_link_tag "lcp_ruby/application", media: "all" %>
|
|
31
42
|
<script type="module" src="<%= asset_path('turbo.min.js') %>" data-turbo-track="reload"></script>
|
|
@@ -33,25 +44,32 @@
|
|
|
33
44
|
<%= javascript_include_tag "lcp_ruby/activestorage.min", defer: true, "data-turbo-track": "reload" %>
|
|
34
45
|
<%= javascript_include_tag "lcp_ruby/lucide.min", defer: true, "data-turbo-track": "reload" %>
|
|
35
46
|
<%= javascript_include_tag "lcp_ruby/lucide_init", defer: true, "data-turbo-track": "reload" %>
|
|
36
|
-
<%= lcp_i18n_payload_tag %>
|
|
37
47
|
<%= javascript_include_tag "lcp_ruby/application", defer: true, "data-turbo-track": "reload" %>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
<% end %>
|
|
49
|
+
<%# FOUC mask for responsive top nav (Phase 1). The `data-measuring`
|
|
50
|
+
attribute hides the nav while JS runs initial overflow measurement;
|
|
51
|
+
the noscript override ensures the nav remains visible when JS is
|
|
52
|
+
disabled or fails to load. %>
|
|
53
|
+
<noscript><style>nav[data-measuring]{visibility:visible !important;}</style></noscript>
|
|
54
|
+
<%# Gated on lcp_emit_engine_assets? first: on a Propshaft opt-out host
|
|
55
|
+
`Rails.application.assets` is a non-nil Propshaft::Assembly with no
|
|
56
|
+
`find_asset`, so the `&.` would NOT short-circuit and would raise
|
|
57
|
+
NoMethodError → 500. The opt-out host owns its own Chartkick anyway. %>
|
|
58
|
+
<% if lcp_emit_engine_assets? && defined?(Chartkick) && Rails.application.assets&.find_asset("Chart.bundle") %>
|
|
59
|
+
<%= javascript_include_tag "Chart.bundle", defer: true, "data-turbo-track": "reload" %>
|
|
60
|
+
<%= javascript_include_tag "chartkick", defer: true, "data-turbo-track": "reload" %>
|
|
61
|
+
<% end %>
|
|
62
|
+
<%# Mermaid (workflow graphs). Gated on lcp_emit_engine_assets? too: the
|
|
63
|
+
:vendored branch resolves asset_path("lcp_ruby/mermaid.min.js"), which
|
|
64
|
+
would 500 on a Propshaft opt-out host — and an opt-out host owns its own
|
|
65
|
+
mermaid anyway. %>
|
|
66
|
+
<% if LcpRuby::Workflow::Registry.available? && lcp_emit_engine_assets? %>
|
|
67
|
+
<% mermaid_src = case LcpRuby.configuration.mermaid_source
|
|
68
|
+
when :cdn then "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"
|
|
69
|
+
when :vendored then asset_path("lcp_ruby/mermaid.min.js")
|
|
70
|
+
else LcpRuby.configuration.mermaid_source.to_s
|
|
71
|
+
end %>
|
|
72
|
+
<script src="<%= mermaid_src %>" defer data-turbo-track="reload"></script>
|
|
55
73
|
<% end %>
|
|
56
74
|
</head>
|
|
57
75
|
<body class="lcp-layout-<%= menu_layout %>"
|
|
@@ -157,7 +175,11 @@
|
|
|
157
175
|
</div>
|
|
158
176
|
</dialog>
|
|
159
177
|
|
|
160
|
-
|
|
178
|
+
<%# Dev toolbar. Whole block (incl. the metadata <meta>) gated on
|
|
179
|
+
lcp_emit_engine_assets? — the meta URL is consumed by the pipeline-loaded
|
|
180
|
+
dev_toolbar.js, so emitting it without the JS on an opt-out Propshaft host
|
|
181
|
+
would 500 on the asset tags and otherwise advertise a dead endpoint. %>
|
|
182
|
+
<% if LcpRuby::DevToolbar.enabled? && lcp_emit_engine_assets? %>
|
|
161
183
|
<meta name="lcp-dev-metadata-url" content="<%= lcp_ruby.dev_toolbar_metadata_path %>">
|
|
162
184
|
<%= stylesheet_link_tag "lcp_ruby/highlight-github.min", media: "all" %>
|
|
163
185
|
<%= stylesheet_link_tag "lcp_ruby/dev_toolbar", media: "all" %>
|
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
h.setAttribute("data-lcp-radius", localStorage.getItem("lcp-radius") || "rounded");
|
|
16
16
|
})();
|
|
17
17
|
</script>
|
|
18
|
-
|
|
18
|
+
<%# Same opt-out gate as the main layout (lcp_emit_engine_assets?): a host
|
|
19
|
+
that set skip_asset_pipeline_check bundles LCP itself, so emitting the
|
|
20
|
+
pipeline-dependent engine stylesheet here would 500 the login pages on
|
|
21
|
+
Propshaft. The old `respond_to?(:assets)` gate was true on Propshaft too,
|
|
22
|
+
so it never actually suppressed the tag. %>
|
|
23
|
+
<% if lcp_emit_engine_assets? %>
|
|
19
24
|
<%= stylesheet_link_tag "lcp_ruby/application", media: "all" %>
|
|
20
25
|
<% end %>
|
|
21
26
|
<style>
|
|
@@ -65,17 +65,12 @@
|
|
|
65
65
|
id="field-<%= field_name.to_s.tr('.', '-') %>"
|
|
66
66
|
<%= "style=\"grid-column: span #{field_config['col_span']}\"" if field_config["col_span"] %>>
|
|
67
67
|
<% field_def = field_config["field_definition"]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
field_def = _target_def&.field(_parts[1])
|
|
75
|
-
end
|
|
76
|
-
else
|
|
77
|
-
_label_model = current_model_name
|
|
78
|
-
end %>
|
|
68
|
+
# Walk the dot-path through metadata to find the terminal field
|
|
69
|
+
# def + owning model name. FieldPath handles any depth; falls
|
|
70
|
+
# back to current_model_name for non-LCP / unresolvable paths.
|
|
71
|
+
_resolved_fd, _resolved_model = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, field_name, through_collections: true)
|
|
72
|
+
field_def ||= _resolved_fd
|
|
73
|
+
_label_model = _resolved_model || current_model_name %>
|
|
79
74
|
<label><%= field_label_for(field_config.merge("field" => field_name), field_def: field_def, model_name: _label_model) %></label>
|
|
80
75
|
<div class="lcp-field-value">
|
|
81
76
|
<%
|
|
@@ -88,31 +83,43 @@
|
|
|
88
83
|
# Spec § Phase 4: "Show and card pick up the fix automatically
|
|
89
84
|
# through the runtime registry fallback."
|
|
90
85
|
effective_renderer_key = field_config["renderer"].presence
|
|
91
|
-
|
|
86
|
+
# Skip the type-name fallback for Array values (has_many enum
|
|
87
|
+
# dot-paths): a scalar type renderer (e.g. enum → badge) can't
|
|
88
|
+
# render an Array, and it would shadow the collection branch
|
|
89
|
+
# below, emitting the Ruby array literal (issue #18 / finding E).
|
|
90
|
+
if effective_renderer_key.blank? && field_def && LcpRuby.configuration.runtime_type_renderers && !value.is_a?(Array)
|
|
92
91
|
effective_renderer_key = field_def.type.to_s
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
rendered_value = if field_config["partial"]
|
|
96
95
|
render partial: field_config["partial"], locals: { value: value, record: record, options: field_config["options"] || {} }
|
|
97
96
|
elsif effective_renderer_key && LcpRuby::Display::RendererRegistry.renderer_for(effective_renderer_key)
|
|
98
|
-
render_display_value(value, effective_renderer_key, field_config["options"], field_def, record: record)
|
|
97
|
+
render_display_value(value, effective_renderer_key, field_config["options"], field_def, record: record, model_name: _label_model)
|
|
99
98
|
elsif value.is_a?(Array)
|
|
100
|
-
|
|
99
|
+
# Pass field_def + model so enum elements humanize (issue #18) —
|
|
100
|
+
# the collection renderer itself only does item.to_s.
|
|
101
|
+
render_display_value(value, "collection", {}, field_def, record: record, model_name: _label_model)
|
|
101
102
|
elsif value.is_a?(ActiveRecord::Base)
|
|
102
103
|
empty_value_placeholder(display_association_value(value), current_presenter)
|
|
103
104
|
else
|
|
104
|
-
display_val = format_enum_display(value, field_def,
|
|
105
|
+
display_val = format_enum_display(value, field_def, _label_model)
|
|
105
106
|
empty_value_placeholder(display_val, current_presenter)
|
|
106
107
|
end
|
|
107
108
|
%>
|
|
108
109
|
<%= wrap_link_through(value, rendered_value, record, field_config) %>
|
|
109
110
|
<% if field_config["copyable"] && value.present? %>
|
|
110
111
|
<% copy_value = if value.is_a?(Array)
|
|
111
|
-
|
|
112
|
+
# Humanize enum elements for copy (issue #18) so the
|
|
113
|
+
# clipboard gets "Active, Done" instead of "active, done".
|
|
114
|
+
format_enum_display(value, field_def, _label_model).join(", ")
|
|
112
115
|
elsif value.is_a?(ActiveRecord::Base)
|
|
113
116
|
strip_tags(display_association_value(value).to_s)
|
|
114
117
|
else
|
|
115
|
-
|
|
118
|
+
# Enum scalars resolve to raw keys (issue #18); humanize for copy
|
|
119
|
+
# so the clipboard gets "Active" instead of "active".
|
|
120
|
+
# (Scalar only — has_many enum dot-paths are validator-blocked
|
|
121
|
+
# in show sections, so the Array branch above is non-enum.)
|
|
122
|
+
strip_tags(format_enum_display(value, field_def, _label_model).to_s)
|
|
116
123
|
end %>
|
|
117
124
|
<% copy_label = I18n.t('lcp_ruby.toolbar.copy_value', default: 'Copy value') %>
|
|
118
125
|
<button type="button" class="lcp-copy-field" data-lcp-copy-value="<%= copy_value %>" title="<%= copy_label %>" aria-label="<%= copy_label %>">
|
|
@@ -9,20 +9,12 @@
|
|
|
9
9
|
columns.each do |col|
|
|
10
10
|
field_path = col["field"]
|
|
11
11
|
next unless field_path
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
col_field_defs[field_path] = target_def.field(parts[1])
|
|
19
|
-
col_model_names[field_path] = assoc.target_model
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
else
|
|
23
|
-
col_field_defs[field_path] = current_model_definition&.field(field_path)
|
|
24
|
-
col_model_names[field_path] = current_model_name
|
|
25
|
-
end
|
|
12
|
+
# Metadata::FieldPath walks any-depth dot-paths; nil/nil for non-LCP
|
|
13
|
+
# or unresolvable segments. Pre-A2 humanization in the resolver masked
|
|
14
|
+
# 3+ part path failures here — now this is the single lookup point.
|
|
15
|
+
fd, model_name = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, field_path, through_collections: true)
|
|
16
|
+
col_field_defs[field_path] = fd
|
|
17
|
+
col_model_names[field_path] = model_name || current_model_name
|
|
26
18
|
end %>
|
|
27
19
|
<% reorderable = current_presenter.reorderable? &&
|
|
28
20
|
current_model_definition.positioned? &&
|
|
@@ -105,17 +97,20 @@
|
|
|
105
97
|
<td class="<%= hidden_on_classes(col) %> <%= 'lcp-pinned-left' if col['pinned'] == 'left' %>">
|
|
106
98
|
<% value = @field_resolver.resolve(record, col["field"], fk_map: @fk_map) %>
|
|
107
99
|
<% fd = col_field_defs[col["field"]] %>
|
|
100
|
+
<% fd_model = col_model_names[col["field"]] || current_model_name %>
|
|
108
101
|
<%
|
|
109
102
|
rendered_value = if col["partial"]
|
|
110
103
|
render partial: col["partial"], locals: { value: value, record: record, options: col["options"] || {} }
|
|
111
104
|
elsif col["renderer"]
|
|
112
|
-
render_display_value(value, col["renderer"], col["options"], fd, record: record)
|
|
105
|
+
render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model)
|
|
113
106
|
elsif value.is_a?(Array)
|
|
114
|
-
|
|
107
|
+
# Pass fd + model so enum elements humanize (issue #18) — the
|
|
108
|
+
# collection renderer itself only does item.to_s.
|
|
109
|
+
render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model)
|
|
115
110
|
elsif value.is_a?(ActiveRecord::Base)
|
|
116
111
|
empty_value_placeholder(display_association_value(value), current_presenter)
|
|
117
112
|
else
|
|
118
|
-
display_val = format_enum_display(value, fd,
|
|
113
|
+
display_val = format_enum_display(value, fd, fd_model)
|
|
119
114
|
empty_value_placeholder(display_val, current_presenter)
|
|
120
115
|
end
|
|
121
116
|
%>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Markdown widget — i18n-resolved source is converted to HTML in the
|
|
3
|
+
resolver (Commonmarker without `unsafe:` so raw HTML in the source
|
|
4
|
+
is stripped at parse time), then sanitized here against the shared
|
|
5
|
+
allow-list in `LcpRuby::Display::MarkdownSanitize` — same list the
|
|
6
|
+
model-level :markdown renderer uses, so on-page and in-table
|
|
7
|
+
markdown render identically.
|
|
8
|
+
%>
|
|
9
|
+
<div class="lcp-text-content lcp-markdown">
|
|
10
|
+
<%= sanitize(data[:html],
|
|
11
|
+
tags: LcpRuby::Display::MarkdownSanitize::TAGS,
|
|
12
|
+
attributes: LcpRuby::Display::MarkdownSanitize::ATTRIBUTES) %>
|
|
13
|
+
</div>
|
|
@@ -11,16 +11,26 @@
|
|
|
11
11
|
<%
|
|
12
12
|
zone_model_def = data[:model_definition]
|
|
13
13
|
zone_model_name = zone_model_def&.name
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
14
|
+
# Resolve every column's terminal field def + owning model name via
|
|
15
|
+
# Metadata::FieldPath so dot-path enum columns (e.g. "contact.status")
|
|
16
|
+
# humanize against the correct i18n namespace. Direct fields resolve
|
|
17
|
+
# via the same walker (single-segment path).
|
|
18
|
+
zone_field_defs = {}
|
|
19
|
+
zone_field_models = {}
|
|
20
|
+
data[:column_set].visible_table_columns.each do |col|
|
|
21
|
+
path = col["field"]
|
|
22
|
+
next unless path
|
|
23
|
+
fd, mn = LcpRuby::Metadata::FieldPath.terminal(zone_model_def, path, through_collections: true)
|
|
24
|
+
zone_field_defs[path] = fd
|
|
25
|
+
zone_field_models[path] = mn || zone_model_name
|
|
26
|
+
end
|
|
17
27
|
%>
|
|
18
28
|
<table class="lcp-table lcp-table-compact">
|
|
19
29
|
<thead>
|
|
20
30
|
<tr>
|
|
21
31
|
<% data[:column_set].visible_table_columns.each do |col| %>
|
|
22
32
|
<th>
|
|
23
|
-
<% label = field_label_for(col, field_def: zone_field_defs[col["field"]], model_name: zone_model_name) %>
|
|
33
|
+
<% label = field_label_for(col, field_def: zone_field_defs[col["field"]], model_name: zone_field_models[col["field"]] || zone_model_name) %>
|
|
24
34
|
<% if col["sortable"] %>
|
|
25
35
|
<%
|
|
26
36
|
field = col["field"]
|
|
@@ -60,15 +70,21 @@
|
|
|
60
70
|
<% data[:column_set].visible_table_columns.each do |col| %>
|
|
61
71
|
<td>
|
|
62
72
|
<% value = data[:field_value_resolver].resolve(record, col["field"]) %>
|
|
73
|
+
<% fd = zone_field_defs[col["field"]] %>
|
|
74
|
+
<% fd_model = zone_field_models[col["field"]] %>
|
|
63
75
|
<%
|
|
64
76
|
rendered_value = if col["renderer"]
|
|
65
|
-
render_display_value(value, col["renderer"], col["options"],
|
|
77
|
+
render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model)
|
|
66
78
|
elsif value.is_a?(Array)
|
|
67
|
-
|
|
79
|
+
# Pass fd + model so enum elements humanize (issue #18) — the
|
|
80
|
+
# collection renderer itself only does item.to_s.
|
|
81
|
+
render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model)
|
|
68
82
|
elsif value.is_a?(ActiveRecord::Base)
|
|
69
83
|
empty_value_placeholder(display_association_value(value))
|
|
70
84
|
else
|
|
71
|
-
|
|
85
|
+
# Resolver returns raw enum keys (issue #18); humanize via
|
|
86
|
+
# the field's owning model i18n namespace.
|
|
87
|
+
empty_value_placeholder(format_enum_display(value, fd, fd_model))
|
|
72
88
|
end
|
|
73
89
|
%>
|
|
74
90
|
<%= wrap_link_through(value, rendered_value, record, col) %>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Rich text widget — renders the i18n-resolved content as HTML.
|
|
3
|
+
|
|
4
|
+
Trust model (defense-in-depth):
|
|
5
|
+
* Trusted page (YAML/auto-defined, code-reviewed locale source) →
|
|
6
|
+
raw pass-through, same posture as any engine view that uses `raw`.
|
|
7
|
+
* Untrusted page (database-defined, end-user-editable through the
|
|
8
|
+
platform UI) → sanitized against the shared MarkdownSanitize allow-list.
|
|
9
|
+
`data[:trusted]` is set by Widgets::DataResolver from the page origin;
|
|
10
|
+
this branch is fail-closed (absent/false → sanitize), so even a
|
|
11
|
+
rich_text widget that slipped past the save-time Pages::DefinitionValidator
|
|
12
|
+
(seeds, update_column, pre-existing records) can't emit unsanitized HTML.
|
|
13
|
+
|
|
14
|
+
When the source is author-written markdown, prefer the :markdown widget —
|
|
15
|
+
friendlier authoring and the same sanitize guarantee.
|
|
16
|
+
%>
|
|
17
|
+
<div class="lcp-text-content lcp-text-content--rich">
|
|
18
|
+
<% if data[:trusted] %>
|
|
19
|
+
<%= raw(data[:content]) %>
|
|
20
|
+
<% else %>
|
|
21
|
+
<%= sanitize(data[:content],
|
|
22
|
+
tags: LcpRuby::Display::MarkdownSanitize::TAGS,
|
|
23
|
+
attributes: LcpRuby::Display::MarkdownSanitize::ATTRIBUTES) %>
|
|
24
|
+
<% end %>
|
|
25
|
+
</div>
|
data/docs/README.md
CHANGED
|
@@ -9,6 +9,7 @@ LCP Ruby is a Rails mountable engine that generates full CRUD information system
|
|
|
9
9
|
- **`rails generate lcp_ruby:install`** — add LCP to an existing Rails app
|
|
10
10
|
- **`rails generate lcp_ruby:entity NAME field:type ...`** — scaffold a complete CRUD entity (model + presenter + permissions + view group). See [Getting Started → Scaffolding new entities](getting-started.md#scaffolding-new-entities) and the [design spec](design/entity_generator.md) for the field grammar and worked examples.
|
|
11
11
|
- [Host Application Guide](guides/host-application.md) — Full app bootstrap, optional dependencies (XLSX, charts, metrics), feature generators
|
|
12
|
+
- [Windows Setup](guides/windows-setup.md) — Native-compile toolchain on a clean Windows box (rbenv-for-windows, MSYS2/GCC gotchas, libyaml, the stale `libwinpthread` fix), the seven-step quick path
|
|
12
13
|
|
|
13
14
|
## YAML Reference
|
|
14
15
|
|
|
@@ -49,6 +50,7 @@ Complete attribute reference for every YAML configuration file:
|
|
|
49
50
|
- [Import](reference/import.md) — Data import: CSV/XLSX upload, column mapping, strategies, nested has_one, profiles, background execution, generator
|
|
50
51
|
- [Theme Variables](reference/theme-variables.md) — All `--lcp-*` CSS custom properties with light/dark values
|
|
51
52
|
- [Engine Configuration](reference/engine-configuration.md) — `LcpRuby.configure` options
|
|
53
|
+
- [Asset Pipeline](reference/asset-pipeline.md) — Sprockets compatibility shim on Rails 8 / Propshaft, boot-time `AssetPipelineError` check, `--skip-asset-pipeline` opt-out, Phase 2 ESM roadmap
|
|
52
54
|
- [Boot / Reload Lifecycle](reference/boot_lifecycle.md) — Engine boot phases, extension hook timing (`on_model_ready`, `on_models_loaded`), dev-mode metadata watcher, `lcp_ruby:ensure_tables` rake + `db:prepare` / `db:setup` / `db:reset` integration, `boot.lcp_ruby` and `reload.lcp_ruby` AS::Notifications events
|
|
53
55
|
- [`lcp_ruby:doctor`](reference/doctor.md) — Health check rake task: install manifest replay, feature state vs. prereqs, environment leaks (`BUNDLE_GEMFILE`, `.envrc`), seeds hygiene; `FORMAT=text|json|summary` and `EXIT_ON=error|warning`; install manifest format and JSON schema for CI consumption
|
|
54
56
|
|
|
@@ -136,6 +138,7 @@ Implemented:
|
|
|
136
138
|
- [Auditing](design/auditing.md) — Native change tracking and audit trail
|
|
137
139
|
- [Tree Structures](design/tree_structures.md) — Declarative parent-child hierarchies with traversal, cycle detection, and tree index view
|
|
138
140
|
- [Saved Filters & Parameterized Scopes](design/saved_filters.md) — User-persistent named filters with visibility levels, parameterized scopes with typed parameters, generator, CRUD API
|
|
141
|
+
- [Unify Scope Filters onto Presenters](design/grouped_predefined_filters.md) — Surface the shipped page `scope_filters` mechanism (grouped, multi-widget, AND across groups) on plain presenter indexes; reframe `predefined_filters` as sugar over it; fix the saved-filter dead-click; future: retire sugar, multi-select, parameterized options, DB/host sources
|
|
139
142
|
- [Aggregate Columns](design/aggregate_columns.md) — Virtual computed columns (COUNT, SUM, MIN, MAX, AVG) from associated records, custom SQL, service-based aggregates
|
|
140
143
|
- [Virtual Columns](design/virtual_columns.md) — Generalized query extensions: expressions, JOINs, GROUP BY, auto-include, backward-compatible with aggregates
|
|
141
144
|
- [Tiles View](design/tiles_view.md) — Card grid layout for index pages with sort dropdown, per-page selector, and summary bar
|
data/docs/feature-catalog.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Auto-generated from `docs/feature-catalog.yml`. Do not edit manually.
|
|
4
4
|
Regenerate: `cd examples/showcase && bundle exec rake lcp_ruby:feature_catalog`
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
624 features across 45 categories.
|
|
7
7
|
|
|
8
8
|
## API Backed Models
|
|
9
9
|
|
|
@@ -218,13 +218,15 @@ Regenerate: `cd examples/showcase && bundle exec rake lcp_ruby:feature_catalog`
|
|
|
218
218
|
- **KPI Trend Indicator** — KPI with delta arrow (compare_scope: on kpi_card). Shows percentage change vs comparison scope. Up/down/neutral. · `docs/reference/pages.md#kpi_card` `docs/guides/dashboards.md#kpi-trend-indicators` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb`
|
|
219
219
|
- **Landing Page Configuration** — Post-login redirect (config.landing_page = { role => slug }). Per-role landing pages with default fallback. · `docs/reference/pages.md#page-attributes` `docs/guides/dashboards.md#3-configure-landing-page-optional` `docs/design/dashboards.md#dashboard-as-landing-page` `app/helpers/lcp_ruby/dashboard_helper.rb` `app/views/lcp_ruby/resources/_grid_page.html.erb`
|
|
220
220
|
- **List Widget** — Compact record list (widget type: list). model:, limit:, link_to:. Shows label_method values in a list. · `docs/reference/pages.md#list` `docs/guides/dashboards.md#list` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb`
|
|
221
|
+
- **Markdown Widget** — i18n source parsed by Commonmarker, output sanitized (widget type: markdown, content_key:). Raw HTML in source is stripped (`unsafe:` deliberately omitted). · `docs/reference/pages.md#markdown` `docs/guides/dashboards.md#markdown` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_markdown.html.erb` `lib/lcp_ruby/display/renderers/markdown.rb`
|
|
221
222
|
- **Multi-Select Filter** — input_options.multi: true on filter form fields. URL emits bracket-array (?page_filter[field][]=…). Defensive Type::ArrayOf coercion. · `docs/reference/page_filters.md#multi-select-multi-true` `docs/reference/virtual_forms.md#security-invariants` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/virtual_fields/types/array_of.rb` `lib/lcp_ruby/pages/filter_form.rb`
|
|
222
223
|
- **Multi-Series Chart** — Multiple data series in one chart (series: array). Each series has its own name, color, scope, and may override group_by/aggregate. · `docs/reference/pages.md#chart` `docs/design/chart_widget_multi_series.md` `lib/lcp_ruby/widgets/data_resolver.rb`
|
|
223
224
|
- **Page Filter Form** — Page-level filter bar (filter_form: array, form-syntax fields). Propagated to all zones via ScopeApplicator. Supports text, select, multi-select, association_select, date_range, boolean, … · `docs/reference/page_filters.md` `docs/reference/pages.md#page-filters` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/pages/filter_form.rb` `lib/lcp_ruby/pages/filter_form_validator.rb` `lib/lcp_ruby/widgets/scope_applicator.rb` `app/views/lcp_ruby/resources/_filter_form.html.erb`
|
|
224
225
|
- **Presenter Zone on Dashboard** — Embedded index table in dashboard grid (presenter:, limit:). Reuses existing presenter columns. No widget config. · `docs/reference/pages.md#presenter-zones` `docs/guides/dashboards.md#presenter-zone` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/presenter_zone_resolver.rb`
|
|
226
|
+
- **Rich Text Widget** — i18n source emitted as raw HTML (widget type: rich_text, content_key:). Locale carries `<h2>`/`<a>`/`<strong>`; same trust posture as any engine view using `raw`. · `docs/reference/pages.md#rich_text` `docs/guides/dashboards.md#rich-text` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_rich_text.html.erb`
|
|
225
227
|
- **Scope Filters** — Page-level AR-scope toggle bar (scope_filters: array). Each entry maps UI options to named scopes on the page model. widget: select/radio/button_group. · `docs/reference/page_filters.md#scope_filters-syntax` `docs/design/page_filters_as_virtual_forms.md` `lib/lcp_ruby/pages/scope_filter_set.rb` `lib/lcp_ruby/widgets/scope_applicator.rb` `app/views/lcp_ruby/resources/_scope_filters.html.erb`
|
|
226
228
|
- **Stacked Chart** — Stack series on the Y axis (stacked: true). Legal for column/bar/line/area; hard error for pie/donut. · `docs/reference/pages.md#chart` `docs/design/chart_widget_multi_series.md` `lib/lcp_ruby/widgets/data_resolver.rb`
|
|
227
|
-
- **Text Widget** —
|
|
229
|
+
- **Text Widget** — Plain HTML-escaped i18n content (widget type: text, content_key:). For welcome messages and announcements. See rich_text / markdown variants for formatted content. · `docs/reference/pages.md#text` `docs/guides/dashboards.md#text` `docs/design/dashboards.md#widget-zone-types` `lib/lcp_ruby/widgets/data_resolver.rb` `app/views/lcp_ruby/widgets/_text.html.erb`
|
|
228
230
|
|
|
229
231
|
## Dialogs
|
|
230
232
|
|
|
@@ -498,6 +500,7 @@ recovery codes, generated credentials). dialog_behavior show_result + result.sty
|
|
|
498
500
|
- **Health Check Endpoint** — Built-in /lcp_health JSON endpoint. DB + boot checks. 200/503. Unauthenticated for K8s probes. · `docs/reference/monitoring.md#health-check` `docs/guides/monitoring.md#health-check` `docs/design/monitoring.md#layer-3-health-check-endpoint` `docs/design/monitoring.md#health-check-response` `lib/lcp_ruby/metrics/`
|
|
499
501
|
- **Monitoring Dashboard (Page)** — Dashboard page (/monitoring). KPI cards from Prometheus metrics + recent errors list widget. · `docs/reference/monitoring.md` `docs/guides/monitoring.md#monitoring-dashboard` `docs/design/monitoring.md#monitoring-dashboard` `docs/design/monitoring.md#layer-5-dashboard-visualization` `lib/lcp_ruby/metrics/` `lib/lcp_ruby/pages/`
|
|
500
502
|
- **Monitoring Generator** — Single generator (lcp_ruby:monitoring). Creates error log model, presenter, permissions, dashboard page. · `docs/reference/monitoring.md#generator` `docs/design/monitoring.md#generator-output` `lib/lcp_ruby/metrics/`
|
|
503
|
+
- **Page-Level Access Gate** — Runtime enforcement of `page.visible_when:` via PageAuthorization concern + PageGate pure function. Standalone pages with role/service gates return 403 for non-matching users. AUTH-002-runtime / AUTH-003 / AUTH-010 / AUTH-011 codes. Silent-bypass sentinel. · `docs/design/authorization_hardening.md` `docs/reference/invariant_check.md` `docs/design/authorization_hardening.md` `app/controllers/concerns/lcp_ruby/page_authorization.rb` `lib/lcp_ruby/authorization/page_gate.rb` `lib/lcp_ruby/authorization/misconfigured_page_error.rb`
|
|
501
504
|
- **Prometheus Metrics Endpoint** — /metrics endpoint in Prometheus format. Requires prometheus-client gem. 9 built-in metrics. · `docs/reference/monitoring.md#prometheus-metrics` `docs/guides/monitoring.md#prometheus-grafana` `docs/design/monitoring.md#prometheus-metrics` `docs/design/monitoring.md#layer-2-prometheus-metrics-collection` `lib/lcp_ruby/metrics/`
|
|
502
505
|
- **Verify Authorized Framework Guarantee** — after_action :verify_authorized on ResourcesController. Future contributor forgets authorize → 500 in dev/test (Pundit::AuthorizationNotPerformedError). Skips 4xx/5xx + Devise. · `docs/design/authorization_hardening.md` `docs/design/authorization_hardening.md` `lib/lcp_ruby/authorized_controller.rb` `lib/lcp_ruby/controller/authorization.rb`
|
|
503
506
|
|
data/docs/feature-catalog.yml
CHANGED
|
@@ -10214,11 +10214,13 @@ features:
|
|
|
10214
10214
|
status: stable
|
|
10215
10215
|
- name: Text Widget
|
|
10216
10216
|
category: dashboards
|
|
10217
|
-
synopsis: '
|
|
10218
|
-
announcements
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10217
|
+
synopsis: 'Plain HTML-escaped i18n content (widget type: text, content_key:). For
|
|
10218
|
+
welcome messages and announcements. See rich_text / markdown variants for formatted
|
|
10219
|
+
content.'
|
|
10220
|
+
description: 'A widget zone with `type: text` that renders static i18n content as
|
|
10221
|
+
plain text (Rails default escaping). Use `content_key` to reference a locale key.
|
|
10222
|
+
Ideal for plain announcements; for formatted content use the parallel `rich_text`
|
|
10223
|
+
or `markdown` widget types.'
|
|
10222
10224
|
config_example: |-
|
|
10223
10225
|
```yaml
|
|
10224
10226
|
- name: welcome_note
|
|
@@ -10229,16 +10231,94 @@ features:
|
|
|
10229
10231
|
position: { row: 1, col: 10, width: 3 }
|
|
10230
10232
|
```
|
|
10231
10233
|
demo_path: "/showcase/showcase-dashboard"
|
|
10232
|
-
demo_hint: The
|
|
10233
|
-
|
|
10234
|
+
demo_hint: 'The dashboard demonstrates the formatted variants (markdown for welcome,
|
|
10235
|
+
rich_text for the announcement). For plain text behaviour, use `type: text` instead
|
|
10236
|
+
— the locale string would render escaped.'
|
|
10234
10237
|
docs_refs:
|
|
10235
10238
|
- docs/reference/pages.md#text
|
|
10236
10239
|
- docs/guides/dashboards.md#text
|
|
10237
10240
|
design_refs:
|
|
10238
10241
|
- docs/design/dashboards.md#widget-zone-types
|
|
10239
10242
|
code_refs:
|
|
10240
|
-
-
|
|
10241
|
-
- app/views/lcp_ruby/
|
|
10243
|
+
- lib/lcp_ruby/widgets/data_resolver.rb
|
|
10244
|
+
- app/views/lcp_ruby/widgets/_text.html.erb
|
|
10245
|
+
status: stable
|
|
10246
|
+
- name: Rich Text Widget
|
|
10247
|
+
category: dashboards
|
|
10248
|
+
synopsis: 'i18n source emitted as raw HTML (widget type: rich_text, content_key:).
|
|
10249
|
+
Locale carries `<h2>`/`<a>`/`<strong>`; same trust posture as any engine view
|
|
10250
|
+
using `raw`.'
|
|
10251
|
+
description: |-
|
|
10252
|
+
A widget zone with `type: rich_text` that renders the i18n-resolved content as raw HTML — so the translation can contain `<h2>`, `<a>`, `<strong>`, etc. and they render as tags.
|
|
10253
|
+
|
|
10254
|
+
Trust model: locale files are author-controlled, the same trust level as any engine view that uses `raw`. When the translation pipeline involves external translators outside code review, prefer the `markdown` widget instead — it sanitizes Commonmarker output against an explicit tag allow-list.
|
|
10255
|
+
config_example: |-
|
|
10256
|
+
```yaml
|
|
10257
|
+
- name: announcement
|
|
10258
|
+
type: widget
|
|
10259
|
+
widget:
|
|
10260
|
+
type: rich_text
|
|
10261
|
+
content_key: lcp_ruby.dashboard.announcement_html
|
|
10262
|
+
position: { row: 4, col: 5, width: 8 }
|
|
10263
|
+
```
|
|
10264
|
+
|
|
10265
|
+
```yaml
|
|
10266
|
+
# en.yml
|
|
10267
|
+
en:
|
|
10268
|
+
lcp_ruby:
|
|
10269
|
+
dashboard:
|
|
10270
|
+
announcement_html: '<strong>Heads up:</strong> rich_text pipes locale source through raw.'
|
|
10271
|
+
```
|
|
10272
|
+
demo_path: "/showcase/showcase-dashboard"
|
|
10273
|
+
demo_hint: 'The announcement on row 4 (next to "Draft Articles") uses `type: rich_text`.
|
|
10274
|
+
Inspect the `<strong>` / `<code>` tags — they come straight from the i18n source,
|
|
10275
|
+
no markdown conversion.'
|
|
10276
|
+
docs_refs:
|
|
10277
|
+
- docs/reference/pages.md#rich_text
|
|
10278
|
+
- docs/guides/dashboards.md#rich-text
|
|
10279
|
+
code_refs:
|
|
10280
|
+
- lib/lcp_ruby/widgets/data_resolver.rb
|
|
10281
|
+
- app/views/lcp_ruby/widgets/_rich_text.html.erb
|
|
10282
|
+
status: stable
|
|
10283
|
+
- name: Markdown Widget
|
|
10284
|
+
category: dashboards
|
|
10285
|
+
synopsis: 'i18n source parsed by Commonmarker, output sanitized (widget type: markdown,
|
|
10286
|
+
content_key:). Raw HTML in source is stripped (`unsafe:` deliberately omitted).'
|
|
10287
|
+
description: |-
|
|
10288
|
+
A widget zone with `type: markdown` that runs the i18n source through Commonmarker (CommonMark + GFM tables / tasklists / strikethrough / autolinks) and sanitizes the rendered HTML against the same tag/attribute allow-list as the model-level `:markdown` renderer (`display/renderers/markdown.rb`).
|
|
10289
|
+
|
|
10290
|
+
`unsafe:` is deliberately omitted from the Commonmarker options — raw HTML embedded in the markdown source is stripped at parse time. Friendlier authoring than HTML for hand-written welcome blurbs, and the safer choice when translations come from an external pipeline.
|
|
10291
|
+
config_example: |-
|
|
10292
|
+
```yaml
|
|
10293
|
+
- name: welcome
|
|
10294
|
+
type: widget
|
|
10295
|
+
widget:
|
|
10296
|
+
type: markdown
|
|
10297
|
+
content_key: lcp_ruby.dashboard.welcome_md
|
|
10298
|
+
position: { row: 1, col: 10, width: 3 }
|
|
10299
|
+
```
|
|
10300
|
+
|
|
10301
|
+
```yaml
|
|
10302
|
+
# en.yml
|
|
10303
|
+
en:
|
|
10304
|
+
lcp_ruby:
|
|
10305
|
+
dashboard:
|
|
10306
|
+
welcome_md: |
|
|
10307
|
+
## Welcome
|
|
10308
|
+
|
|
10309
|
+
Mix **KPIs**, lists, charts, and *text-style* widgets on one page.
|
|
10310
|
+
```
|
|
10311
|
+
demo_path: "/showcase/showcase-dashboard"
|
|
10312
|
+
demo_hint: The "Welcome" zone in the top-right (row 1, col 10) renders Markdown
|
|
10313
|
+
from the `lcp_ruby.dashboard.welcome_md` key — `##` becomes `<h2>`, `**bold**`
|
|
10314
|
+
becomes `<strong>`.
|
|
10315
|
+
docs_refs:
|
|
10316
|
+
- docs/reference/pages.md#markdown
|
|
10317
|
+
- docs/guides/dashboards.md#markdown
|
|
10318
|
+
code_refs:
|
|
10319
|
+
- lib/lcp_ruby/widgets/data_resolver.rb
|
|
10320
|
+
- app/views/lcp_ruby/widgets/_markdown.html.erb
|
|
10321
|
+
- lib/lcp_ruby/display/renderers/markdown.rb
|
|
10242
10322
|
status: stable
|
|
10243
10323
|
- name: List Widget
|
|
10244
10324
|
category: dashboards
|