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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9484b30d4253443cf9411e7e3bf6ae68446a092329b9f15b755db3fff166f0b0
|
|
4
|
+
data.tar.gz: 17e6a672a34b528006c55bf851e60f6ddf303df10acd15f4370067e3231240f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bbf59bd655c458f3dbbb9ea01e6ac39e4389738fd38311851ffdf08eb02c19a045268acff0b4daca2da375a39dc197b1d029932771952d2b5bc52afaf2dd6dd8
|
|
7
|
+
data.tar.gz: 23f062bd96b24754437dae622770d8f73f26f344018488cbdb68b60e4734888fdc06bde98dab314dd684e6f277aa8cd1c22f36407b12dfdf42e563833c804ef4
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lcp-custom-field
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Use when enabling or managing LCP Ruby custom fields — runtime user-defined
|
|
5
|
+
fields stored in a model's `custom_data` JSON column. Triggers on phrases like
|
|
6
|
+
"custom fields", "user-defined fields", "runtime fields", "extensible fields",
|
|
7
|
+
"add fields without migration", "custom_data", "custom_field_definition",
|
|
8
|
+
"field at runtime", "tenant-specific fields", "EAV", "show_in_table",
|
|
9
|
+
"show_in_form", "field permissions for custom fields", `custom_fields: true`
|
|
10
|
+
on a model. Also for editing the auto-generated `custom_field_definition`
|
|
11
|
+
model/presenter/permissions or rolling out the `lcp_ruby:custom_fields`
|
|
12
|
+
generator.
|
|
4
13
|
---
|
|
5
14
|
|
|
6
15
|
> **Reference docs base:** run `bundle exec rake lcp_ruby:docs` in an LCP app (version-matched), otherwise `lcp docs path`. Resolve any `docs/X` reference below under that base (or the project root if a `docs/` tree exists there).
|
|
@@ -22,7 +22,7 @@ Pick the path based on whether the user has an app yet.
|
|
|
22
22
|
|
|
23
23
|
### Path A — greenfield (`lcp new`)
|
|
24
24
|
|
|
25
|
-
Requires Ruby 3.
|
|
25
|
+
Requires Ruby 3.2+ and Rails 8.1+ on the user's machine. The CLI runs `rails new`, installs the gem, runs the chosen generators, prepares the database, and copies `lcp-*` Claude skills into `.claude/skills/`.
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
lcp new my_app # interactive wizard
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lcp-workflow
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Use when creating or modifying LCP Ruby workflow definitions in
|
|
5
|
+
`config/lcp_ruby/workflows/` (either YAML `*.yml` or DSL `*.rb`), including
|
|
6
|
+
approval processes. Triggers on phrases like "add a workflow", "state
|
|
7
|
+
machine", "transition", "approval process", "approver", "approve/reject task",
|
|
8
|
+
"guard condition", "set_fields on transition", "audit log for state changes",
|
|
9
|
+
"workflow timeline", "delegation", "rework policy", "bypass_when",
|
|
10
|
+
"on_entry/on_exit", "trigger: system", "workflow_source", "host workflow
|
|
11
|
+
provider".
|
|
4
12
|
---
|
|
5
13
|
|
|
6
14
|
> **Reference docs base:** run `bundle exec rake lcp_ruby:docs` in an LCP app (version-matched), otherwise `lcp docs path`. Resolve any `docs/X` reference below under that base (or the project root if a `docs/` tree exists there).
|
|
@@ -207,7 +215,7 @@ end
|
|
|
207
215
|
| Source | Behavior |
|
|
208
216
|
|--------|----------|
|
|
209
217
|
| `:static` | YAML/DSL files in `config/lcp_ruby/workflows/`. Always loaded if files exist. |
|
|
210
|
-
| `:model` | DB-backed. Requires a model with `name`, `
|
|
218
|
+
| `:model` | DB-backed. Requires a model with `name`, `target_model`, `field_name`, `states` (json), `transitions` (json), `version`, `active`, `audit_log`. |
|
|
211
219
|
| `:host` | Provider class implementing `StateMachineContract` (`workflows_for(model_name)`, `workflow_by_name(name)`). |
|
|
212
220
|
|
|
213
221
|
**Static is always active.** The configured non-static source is additive. **On name conflicts: `static > model > host`.**
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,63 @@ warnings until `1.0.0`.
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.2.0] - 2026-06-01
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **MySQL / MariaDB support.** The full non-system suite now passes on
|
|
17
|
+
MySQL/MariaDB and is gated in CI alongside PostgreSQL and SQLite. Adapter
|
|
18
|
+
-aware SQL throughout: date-grouping emits `DATE_FORMAT`/`CONCAT`+`QUARTER`
|
|
19
|
+
period keys, custom-field JSON queries wrap `JSON_EXTRACT` in `JSON_UNQUOTE`
|
|
20
|
+
with MySQL-valid `CAST` targets, `ArrayQuery` uses a `JSON_TABLE()` row source
|
|
21
|
+
(contains/overlaps/contained_by/text-search were previously broken on MySQL),
|
|
22
|
+
and `json`/`array` attribute types are auto-applied on `bind_to` models. New
|
|
23
|
+
`LcpRuby.mysql?` helper; MySQL-only default decimal precision (16,4) when a
|
|
24
|
+
field declares none.
|
|
25
|
+
- **Windows support.** Example apps bundle `tzinfo-data` (locks made
|
|
26
|
+
Windows-installable via `--add-platform x64-mingw-ucrt`); Sprockets'
|
|
27
|
+
file-backed asset cache is disabled on Windows in non-production to dodge a
|
|
28
|
+
non-atomic `File.rename` EACCES race; the `rails --version` probe is portable
|
|
29
|
+
on cmd.exe so `lcp new` / `lcp run` work. New
|
|
30
|
+
[docs/guides/windows-setup.md](docs/guides/windows-setup.md).
|
|
31
|
+
- **Asset-pipeline auto-wiring.** Sprockets/Propshaft engine assets are wired
|
|
32
|
+
up automatically with a boot-time check, opt-out via `--skip-asset-pipeline`
|
|
33
|
+
on both layouts. `rich_text` and `markdown` page widgets; generator
|
|
34
|
+
`DELETE ME` sample with `--skip-sample` opt-out.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- **Minimum Ruby is now 3.2** (was 3.1). `lib/` already uses `Data.define`
|
|
39
|
+
(Ruby 3.2), so the gem would `NameError` on 3.1 — the declared floor was
|
|
40
|
+
wrong. Ruby 3.1 is also EOL (security support ended March 2025).
|
|
41
|
+
- **AI authoring skills now install for Codex as well as Claude Code.**
|
|
42
|
+
`lcp new`, `rails generate lcp_ruby:agent_setup`, and
|
|
43
|
+
the skills-only `rails generate lcp_ruby:claude_skills` /
|
|
44
|
+
`lcp skills install` paths populate `.codex/skills/lcp-*` alongside
|
|
45
|
+
`.claude/skills/lcp-*`; shipped skills also include `agents/openai.yaml`
|
|
46
|
+
metadata for Codex/OpenAI clients.
|
|
47
|
+
- Dropped the unused `view_component` dependency; bumped the CSRF-protection
|
|
48
|
+
gem and declared `csv` + `ostruct` explicitly (silences Ruby 3.4 default-gem
|
|
49
|
+
deprecation warnings).
|
|
50
|
+
- Enum resolver returns raw keys and humanizes at display time, consistently
|
|
51
|
+
across all surfaces.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- `lcp run` is now idempotent on a re-used directory: a `.lcp-run` marker lets a
|
|
56
|
+
second invocation re-boot the already-materialized example instead of aborting
|
|
57
|
+
with "already exists and is not empty". A non-empty directory without the
|
|
58
|
+
marker is still refused, and the marker is written only after `db:setup`
|
|
59
|
+
succeeds so an interrupted first run can't downgrade the next.
|
|
60
|
+
- `bind_to` models on MariaDB serialized `json`/`array` columns via `Hash#to_s`
|
|
61
|
+
(the bind_to path skips the Builder, so AR attribute types were never applied),
|
|
62
|
+
tripping `json_valid()`. Built-in auth + OIDC also fixed (`LcpRuby::User` now
|
|
63
|
+
declares explicit `:json` attributes for `lcp_role`/`profile_data`).
|
|
64
|
+
- Usage-audit hardening (from building a real host app): menu key validation and
|
|
65
|
+
empty-menu guards, export `Array` handling for `has_many` dot-paths,
|
|
66
|
+
render-time `rich_text` sanitization on DB-backed pages, manifest
|
|
67
|
+
failure-mode detection, and `i18n_check` rake fixes.
|
|
68
|
+
|
|
12
69
|
## [0.1.0] - 2026-05-28
|
|
13
70
|
|
|
14
71
|
Initial public release.
|
|
@@ -65,5 +122,6 @@ Initial public release.
|
|
|
65
122
|
- Minimum **Ruby 3.1**, **Rails ≥ 7.1, < 9.0**.
|
|
66
123
|
- The CLI binary is `lcp`; the Ruby module is `LcpRuby`.
|
|
67
124
|
|
|
68
|
-
[Unreleased]: https://github.com/lksv/lcp/compare/v0.
|
|
125
|
+
[Unreleased]: https://github.com/lksv/lcp/compare/v0.2.0...HEAD
|
|
126
|
+
[0.2.0]: https://github.com/lksv/lcp/compare/v0.1.0...v0.2.0
|
|
69
127
|
[0.1.0]: https://github.com/lksv/lcp/releases/tag/v0.1.0
|
|
@@ -5,12 +5,11 @@
|
|
|
5
5
|
var BUTTON_CLASS = "lcp-dev-button";
|
|
6
6
|
var SELECTOR = "[data-lcp-dev-presenter], [data-lcp-dev-zone]";
|
|
7
7
|
|
|
8
|
-
// Defensive i18n shim: window.LcpI18n
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// is undefined, so we fall back to the caller-provided fallback string.
|
|
8
|
+
// Defensive i18n shim: window.LcpI18n.t comes from i18n.js (bundled into
|
|
9
|
+
// application.js). application.js and this dev_toolbar.js now load together
|
|
10
|
+
// behind `lcp_emit_engine_assets?` in the layout, so LcpI18n.t is normally
|
|
11
|
+
// present when this runs. The shim stays as defense-in-depth: if LcpI18n.t
|
|
12
|
+
// is ever undefined we fall back to the caller-provided fallback string.
|
|
14
13
|
function t(key, fallback) {
|
|
15
14
|
var i18n = window.LcpI18n;
|
|
16
15
|
if (i18n && typeof i18n.t === "function") return i18n.t(key, fallback);
|
|
@@ -11,7 +11,8 @@ module LcpRuby
|
|
|
11
11
|
|
|
12
12
|
if zone.widget?
|
|
13
13
|
resolved_context = resolve_zone_scope_context(zone)
|
|
14
|
-
Widgets::DataResolver.new(zone, user: effective_user, scope_context: resolved_context,
|
|
14
|
+
Widgets::DataResolver.new(zone, user: effective_user, scope_context: resolved_context,
|
|
15
|
+
record: @record, trusted: page_allows_raw_html?, **page_params_opts).resolve
|
|
15
16
|
elsif zone.custom?
|
|
16
17
|
resolve_custom_zone_data(zone)
|
|
17
18
|
elsif zone.record_source?
|
|
@@ -29,6 +30,18 @@ module LcpRuby
|
|
|
29
30
|
end
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Whether the page being rendered may emit raw (unsanitized) HTML widgets
|
|
34
|
+
# (rich_text). True unless the page is database-defined — DB pages are
|
|
35
|
+
# end-user-editable through the platform UI, so their rich_text content is
|
|
36
|
+
# sanitized at render. Defense-in-depth behind the save-time
|
|
37
|
+
# Pages::DefinitionValidator, which bypass paths (seeds, update_column,
|
|
38
|
+
# save(validate: false), records persisted before that validator shipped)
|
|
39
|
+
# can skip.
|
|
40
|
+
def page_allows_raw_html?
|
|
41
|
+
page = @page_definition || current_page
|
|
42
|
+
page.nil? || page.source_type != "database"
|
|
43
|
+
end
|
|
44
|
+
|
|
32
45
|
def resolve_zone_scope_context(zone)
|
|
33
46
|
return {} if zone.scope_context.blank?
|
|
34
47
|
|
|
@@ -28,7 +28,13 @@ module LcpRuby
|
|
|
28
28
|
return "" if field.blank?
|
|
29
29
|
|
|
30
30
|
value = resolve_title_value(record, field)
|
|
31
|
-
|
|
31
|
+
# Humanize when title_field points to an enum (issue #18 — resolver
|
|
32
|
+
# now returns raw enum keys). Non-enum fields pass through unchanged;
|
|
33
|
+
# a has_many dot-path Array renders comma-joined via the collection
|
|
34
|
+
# renderer (card_display_value), same as subtitle/description — a bare
|
|
35
|
+
# format_enum_display would print the Ruby array literal.
|
|
36
|
+
fd, model_name = card_field_meta(field)
|
|
37
|
+
rendered = empty_value_placeholder(card_display_value(value, fd, model_name || current_model_name), current_presenter)
|
|
32
38
|
|
|
33
39
|
# Blank/unauthorized title is the placeholder "—" — never a nav target.
|
|
34
40
|
return rendered if value.blank?
|
|
@@ -55,14 +61,16 @@ module LcpRuby
|
|
|
55
61
|
return "" unless @column_set.readable_named_slot?(field)
|
|
56
62
|
|
|
57
63
|
value = @field_resolver.resolve(record, field, fk_map: @fk_map)
|
|
58
|
-
field_def =
|
|
64
|
+
field_def, fd_model = card_field_meta(field)
|
|
65
|
+
fd_model ||= current_model_name
|
|
59
66
|
|
|
60
67
|
rendered =
|
|
61
68
|
if (renderer_key = card_config["subtitle_renderer"]).present?
|
|
62
|
-
render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record)
|
|
69
|
+
render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record, model_name: fd_model)
|
|
63
70
|
else
|
|
64
|
-
# Parity with table column rendering — humanize enum values
|
|
65
|
-
|
|
71
|
+
# Parity with table column rendering — humanize enum values
|
|
72
|
+
# (scalar or has_many Array) before placeholder fallback.
|
|
73
|
+
empty_value_placeholder(card_display_value(value, field_def, fd_model), current_presenter)
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
content_tag(:div, rendered, class: "lcp-card-subtitle")
|
|
@@ -77,10 +85,16 @@ module LcpRuby
|
|
|
77
85
|
value = @field_resolver.resolve(record, field, fk_map: @fk_map)
|
|
78
86
|
return "" if value.blank?
|
|
79
87
|
|
|
88
|
+
# Humanize when description_field points to an enum (uncommon but
|
|
89
|
+
# supported). Non-enum fields pass through unchanged; has_many Arrays
|
|
90
|
+
# render comma-joined via the collection renderer.
|
|
91
|
+
fd, fd_model = card_field_meta(field)
|
|
92
|
+
display = card_display_value(value, fd, fd_model || current_model_name)
|
|
93
|
+
|
|
80
94
|
max_lines = card_config["description_max_lines"]
|
|
81
95
|
style = ("--lcp-card-desc-lines: #{max_lines.to_i}" if max_lines)
|
|
82
96
|
|
|
83
|
-
content_tag(:div,
|
|
97
|
+
content_tag(:div, display, class: "lcp-card-description", style: style)
|
|
84
98
|
end
|
|
85
99
|
|
|
86
100
|
# Returns: <div class="lcp-card-image"><img .../></div> or "".
|
|
@@ -140,8 +154,9 @@ module LcpRuby
|
|
|
140
154
|
|
|
141
155
|
items = fields.map do |fc|
|
|
142
156
|
value = @field_resolver.resolve(record, fc["field"], fk_map: @fk_map)
|
|
143
|
-
field_def =
|
|
144
|
-
|
|
157
|
+
field_def, fd_model = card_field_meta(fc["field"])
|
|
158
|
+
fd_model ||= current_model_name
|
|
159
|
+
label = field_label_for(fc, field_def: field_def, model_name: fd_model)
|
|
145
160
|
|
|
146
161
|
rendered_value =
|
|
147
162
|
if fc["partial"].present?
|
|
@@ -151,9 +166,9 @@ module LcpRuby
|
|
|
151
166
|
context: "card.fields[].partial '#{fc['partial']}' for record_id=#{record.id}"
|
|
152
167
|
)
|
|
153
168
|
elsif fc["renderer"].present?
|
|
154
|
-
render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record)
|
|
169
|
+
render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record, model_name: fd_model)
|
|
155
170
|
else
|
|
156
|
-
empty_value_placeholder(
|
|
171
|
+
empty_value_placeholder(card_display_value(value, field_def, fd_model), current_presenter)
|
|
157
172
|
end
|
|
158
173
|
|
|
159
174
|
content_tag(:div, label, class: "lcp-card-field-label") +
|
|
@@ -241,20 +256,58 @@ module LcpRuby
|
|
|
241
256
|
|
|
242
257
|
private
|
|
243
258
|
|
|
259
|
+
# Returns [FieldDefinition, model_name] tuple for any field reference
|
|
260
|
+
# (direct field or dot-path). Used to humanize enum values against the
|
|
261
|
+
# correct i18n namespace — the dot-path target model, not the page's
|
|
262
|
+
# current_model_name. Returns [nil, nil] for template fields, blanks,
|
|
263
|
+
# or unresolvable paths.
|
|
264
|
+
def card_field_meta(field_name)
|
|
265
|
+
return [ nil, nil ] if field_name.blank?
|
|
266
|
+
return [ nil, nil ] if Presenter::FieldValueResolver.template_field?(field_name)
|
|
267
|
+
|
|
268
|
+
# through_collections: has_many dot-path enum terminals (e.g.
|
|
269
|
+
# `tasks.status`) must resolve their field def so the Array of values
|
|
270
|
+
# humanizes element-wise (issue #18).
|
|
271
|
+
Metadata::FieldPath.terminal(current_model_definition, field_name, through_collections: true)
|
|
272
|
+
end
|
|
273
|
+
|
|
244
274
|
def card_field_def(field_name)
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
card_field_meta(field_name).first
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Humanize + render a card field value that may be a scalar or an Array
|
|
279
|
+
# (has_many dot-path terminal). Arrays render comma-joined through the
|
|
280
|
+
# collection renderer so enum elements show labels, not raw keys
|
|
281
|
+
# (issue #18 — `format_enum_display` returns an Array for Array input,
|
|
282
|
+
# which a bare `content_tag` would print as a Ruby literal). Scalars
|
|
283
|
+
# humanize via `format_enum_display`. Mirrors the table/tree surfaces.
|
|
284
|
+
def card_display_value(value, field_def, model_name)
|
|
285
|
+
return render_display_value(value, "collection", {}, field_def, model_name: model_name) if value.is_a?(Array)
|
|
286
|
+
|
|
287
|
+
format_enum_display(value, field_def, model_name)
|
|
288
|
+
end
|
|
247
289
|
|
|
248
|
-
|
|
290
|
+
# Plain-text humanized value for alt attributes. Unlike card_display_value
|
|
291
|
+
# (which emits HTML through the collection renderer), a has_many dot-path
|
|
292
|
+
# Array of enum labels is comma-joined as plain text — alt is an attribute,
|
|
293
|
+
# not markup, so HTML would leak into it. Issue #18 / card surface: a bare
|
|
294
|
+
# `format_enum_display(...).to_s` printed the Ruby array literal instead.
|
|
295
|
+
def card_alt_text(value, field_def, model_name)
|
|
296
|
+
humanized = format_enum_display(value, field_def, model_name)
|
|
297
|
+
humanized.is_a?(Array) ? humanized.join(", ") : humanized.to_s
|
|
249
298
|
end
|
|
250
299
|
|
|
251
300
|
def card_image_alt(record, card_config)
|
|
252
301
|
if (alt_field = card_config["image_alt_field"]).present?
|
|
253
|
-
|
|
302
|
+
value = @field_resolver.resolve(record, alt_field, fk_map: @fk_map)
|
|
303
|
+
fd, fd_model = card_field_meta(alt_field)
|
|
304
|
+
return card_alt_text(value, fd, fd_model || current_model_name)
|
|
254
305
|
end
|
|
255
306
|
|
|
256
307
|
if (title_field = card_config["title_field"]).present?
|
|
257
|
-
|
|
308
|
+
value = resolve_title_value(record, title_field)
|
|
309
|
+
fd, fd_model = card_field_meta(title_field)
|
|
310
|
+
return card_alt_text(value, fd, fd_model || current_model_name)
|
|
258
311
|
end
|
|
259
312
|
|
|
260
313
|
""
|
|
@@ -279,7 +332,9 @@ module LcpRuby
|
|
|
279
332
|
alt_field = card_config["avatar_alt_field"]
|
|
280
333
|
return "" if alt_field.blank?
|
|
281
334
|
|
|
282
|
-
@field_resolver.resolve(record, alt_field, fk_map: @fk_map)
|
|
335
|
+
value = @field_resolver.resolve(record, alt_field, fk_map: @fk_map)
|
|
336
|
+
fd, fd_model = card_field_meta(alt_field)
|
|
337
|
+
card_alt_text(value, fd, fd_model || current_model_name)
|
|
283
338
|
end
|
|
284
339
|
|
|
285
340
|
def image_renderable?(value)
|
|
@@ -1,24 +1,51 @@
|
|
|
1
1
|
module LcpRuby
|
|
2
2
|
module DisplayHelper
|
|
3
|
-
def render_display_value(value, renderer_key, options = {}, field_def = nil, record: nil)
|
|
3
|
+
def render_display_value(value, renderer_key, options = {}, field_def = nil, record: nil, model_name: nil)
|
|
4
4
|
return value if renderer_key.blank?
|
|
5
5
|
|
|
6
6
|
renderer = LcpRuby::Display::RendererRegistry.renderer_for(renderer_key.to_s)
|
|
7
7
|
return value unless renderer
|
|
8
8
|
|
|
9
9
|
effective_options = options || {}
|
|
10
|
-
if field_def&.enum?
|
|
11
|
-
|
|
10
|
+
if field_def&.enum?
|
|
11
|
+
# For dot-path columns the i18n namespace is the target model, not the
|
|
12
|
+
# presenter's current model. Caller passes model_name: explicitly when
|
|
13
|
+
# the field def comes from a different model than the page is showing.
|
|
14
|
+
enum_model = model_name || current_model_name
|
|
15
|
+
if value.is_a?(Array)
|
|
16
|
+
# has_many → terminal enum dot-path (e.g. `tasks.status`) resolves to
|
|
17
|
+
# an Array of raw keys; humanize each element so list renderers
|
|
18
|
+
# (`collection`) emit labels, not raw keys (issue #18). The scalar
|
|
19
|
+
# `label` injection below can't help here — list renderers render
|
|
20
|
+
# each element, not a single pre-labeled value.
|
|
21
|
+
value = humanize_enum_array(value, field_def, enum_model)
|
|
22
|
+
elsif !effective_options.key?("label")
|
|
23
|
+
effective_options = effective_options.merge("label" => field_def.enum_label_for(value, model_name: enum_model))
|
|
24
|
+
end
|
|
12
25
|
end
|
|
13
26
|
|
|
14
27
|
renderer.render(value, effective_options, record: record, view_context: self)
|
|
15
28
|
end
|
|
16
29
|
|
|
30
|
+
# Humanize an enum value for display. Scalars route through
|
|
31
|
+
# `enum_label_for`; Arrays (has_many → terminal enum dot-path) humanize
|
|
32
|
+
# each element so the collection renderer / clipboard copy show labels,
|
|
33
|
+
# not raw keys (issue #18). Non-enum field defs and blank values pass
|
|
34
|
+
# through unchanged.
|
|
17
35
|
def format_enum_display(value, field_def, model_name = nil)
|
|
18
36
|
return value unless field_def&.enum? && value.present?
|
|
37
|
+
return humanize_enum_array(value, field_def, model_name) if value.is_a?(Array)
|
|
19
38
|
field_def.enum_label_for(value, model_name: model_name)
|
|
20
39
|
end
|
|
21
40
|
|
|
41
|
+
# Map each element of an enum-valued Array through `enum_label_for`,
|
|
42
|
+
# leaving blanks untouched. Shared by `render_display_value` and
|
|
43
|
+
# `format_enum_display` so on-table, in-card, and clipboard rendering
|
|
44
|
+
# of has_many enum dot-paths stay consistent.
|
|
45
|
+
def humanize_enum_array(values, field_def, model_name)
|
|
46
|
+
values.map { |v| v.blank? ? v : field_def.enum_label_for(v, model_name: model_name) }
|
|
47
|
+
end
|
|
48
|
+
|
|
22
49
|
def compute_item_classes(record, presenter)
|
|
23
50
|
rules = presenter.item_classes
|
|
24
51
|
return "" if rules.empty?
|
|
@@ -40,7 +40,7 @@ module LcpRuby
|
|
|
40
40
|
link_to_record, presenter_slug)
|
|
41
41
|
resolver = build_template_field_resolver(model_definition, permission_evaluator)
|
|
42
42
|
|
|
43
|
-
title_text = resolver ? resolver.resolve(record, template_def.template) : template_def.template
|
|
43
|
+
title_text = resolver ? resolver.resolve(record, template_def.template, humanize_enums: true) : template_def.template
|
|
44
44
|
title_html = ERB::Util.html_escape(title_text.to_s)
|
|
45
45
|
title_html = wrap_in_link(title_html, record, presenter_slug, model_definition) if link_to_record
|
|
46
46
|
|
|
@@ -53,13 +53,13 @@ module LcpRuby
|
|
|
53
53
|
parts << content_tag(:span, title_html, class: "lcp-display-template__title")
|
|
54
54
|
|
|
55
55
|
if template_def.subtitle.present?
|
|
56
|
-
subtitle_text = resolver ? resolver.resolve(record, template_def.subtitle) : template_def.subtitle
|
|
56
|
+
subtitle_text = resolver ? resolver.resolve(record, template_def.subtitle, humanize_enums: true) : template_def.subtitle
|
|
57
57
|
parts << content_tag(:span, ERB::Util.html_escape(subtitle_text.to_s),
|
|
58
58
|
class: "lcp-display-template__subtitle")
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
if template_def.badge.present?
|
|
62
|
-
badge_text = resolver ? resolver.resolve(record, template_def.badge) : template_def.badge
|
|
62
|
+
badge_text = resolver ? resolver.resolve(record, template_def.badge, humanize_enums: true) : template_def.badge
|
|
63
63
|
parts << content_tag(:span, ERB::Util.html_escape(badge_text.to_s),
|
|
64
64
|
class: "lcp-display-template__badge")
|
|
65
65
|
end
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
module LcpRuby
|
|
2
2
|
module LayoutHelper
|
|
3
|
+
# Whether the engine should emit its own pipeline-dependent asset tags
|
|
4
|
+
# (`javascript_include_tag`/`stylesheet_link_tag`/`asset_path` for
|
|
5
|
+
# `lcp_ruby/*`). False when the host opted out via
|
|
6
|
+
# `config.skip_asset_pipeline_check = true` — that host runs its own JS
|
|
7
|
+
# bundler and owns including the LCP assets, so emitting the tags would
|
|
8
|
+
# raise Propshaft::MissingAssetError → 500. Single source of truth so the
|
|
9
|
+
# layout's several engine-asset blocks (core bundle, dev toolbar, vendored
|
|
10
|
+
# mermaid) can't drift — adding a new one just calls this.
|
|
11
|
+
def lcp_emit_engine_assets?
|
|
12
|
+
!LcpRuby.configuration.skip_asset_pipeline_check
|
|
13
|
+
end
|
|
14
|
+
|
|
3
15
|
# Resolves a column or field label using the same fallback chain as
|
|
4
16
|
# FieldDefinition#resolved_label, with one extra step for synthetic
|
|
5
17
|
# fields (timestamps, FK columns, dotted association paths) that don't
|
|
@@ -200,11 +212,22 @@ module LcpRuby
|
|
|
200
212
|
true
|
|
201
213
|
end
|
|
202
214
|
|
|
203
|
-
#
|
|
204
|
-
#
|
|
205
|
-
#
|
|
215
|
+
# Internal/derived keys silently dropped from provider returns. `type`
|
|
216
|
+
# is always re-derived from the destination keys, so a provider that
|
|
217
|
+
# echoes it (e.g. from a stored to_kwargs snapshot) must not trip the
|
|
218
|
+
# forbidden-key check below — nor reach MenuItem.from_hash_at_depth,
|
|
219
|
+
# which rejects `type` to stay consistent with the JSON schema.
|
|
220
|
+
PROVIDER_STRIP_KEYS = %w[type].freeze
|
|
221
|
+
|
|
222
|
+
# MenuItem-shaped keys allowed in provider returns. `provider:` and
|
|
223
|
+
# `panel_provider:` are NOT listed (chains are banned — each YAML position
|
|
224
|
+
# resolves to one source, never a chain), so a provider return carrying
|
|
225
|
+
# them hits the forbidden-key branch and raises (record_error in prod) —
|
|
226
|
+
# they are rejected, not silently dropped (that is PROVIDER_STRIP_KEYS).
|
|
227
|
+
# MUST stay a subset of MenuItem::KNOWN_KEYS — every key here is fed
|
|
228
|
+
# straight into MenuItem.from_hash_at_depth, which rejects unknown keys.
|
|
206
229
|
PROVIDER_RETURN_ALLOWED_KEYS = %w[
|
|
207
|
-
|
|
230
|
+
label label_key icon url method aria_label aria_label_key
|
|
208
231
|
badge children visible_when disable_when presenter alias action
|
|
209
232
|
defaults render render_panel widget position options
|
|
210
233
|
].freeze
|
|
@@ -277,11 +300,12 @@ module LcpRuby
|
|
|
277
300
|
end
|
|
278
301
|
|
|
279
302
|
# Filters a provider-returned hash to the whitelisted MenuItem keys.
|
|
280
|
-
#
|
|
281
|
-
#
|
|
282
|
-
#
|
|
303
|
+
# PROVIDER_STRIP_KEYS (e.g. `type`) are dropped silently at EVERY depth
|
|
304
|
+
# first (so a `children: [{type:…}]` echo doesn't trip reject_unknown_keys!
|
|
305
|
+
# during the nested build), then top-level keys outside
|
|
306
|
+
# PROVIDER_RETURN_ALLOWED_KEYS raise — record_error in production.
|
|
283
307
|
def filter_provider_keys(hash, provider_name)
|
|
284
|
-
stringified = hash.transform_keys(&:to_s)
|
|
308
|
+
stringified = deep_strip_provider_keys(hash).transform_keys(&:to_s)
|
|
285
309
|
sanitized = {}
|
|
286
310
|
|
|
287
311
|
stringified.each do |k, v|
|
|
@@ -302,6 +326,23 @@ module LcpRuby
|
|
|
302
326
|
sanitized
|
|
303
327
|
end
|
|
304
328
|
|
|
329
|
+
# Removes PROVIDER_STRIP_KEYS (internal/derived keys a provider may echo,
|
|
330
|
+
# e.g. `type`) from a provider-returned item AND recursively from its
|
|
331
|
+
# nested `children:`. These keys are re-derived by MenuItem, so stripping
|
|
332
|
+
# them at every depth keeps reject_unknown_keys! from raising on an echoed
|
|
333
|
+
# `type:` in a nested child. Key type (String/Symbol) is preserved.
|
|
334
|
+
def deep_strip_provider_keys(hash)
|
|
335
|
+
hash.each_with_object({}) do |(k, v), out|
|
|
336
|
+
next if PROVIDER_STRIP_KEYS.include?(k.to_s)
|
|
337
|
+
|
|
338
|
+
out[k] = if k.to_s == "children" && v.is_a?(Array)
|
|
339
|
+
v.map { |child| child.is_a?(Hash) ? deep_strip_provider_keys(child) : child }
|
|
340
|
+
else
|
|
341
|
+
v
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
305
346
|
# Builds the `request_context:` hash passed to provider classes.
|
|
306
347
|
# Provider may run outside a controller-driven request (cache
|
|
307
348
|
# warm-up, mailer, background job that builds an HTML preview);
|
|
@@ -4,6 +4,21 @@ module LcpRuby
|
|
|
4
4
|
reparentable: false, search_active: false)
|
|
5
5
|
return "".html_safe if roots.blank?
|
|
6
6
|
|
|
7
|
+
# Pre-compute terminal field_def + owning model_name per column once,
|
|
8
|
+
# then pass through recursive calls. Avoids O(rows × cols × fields)
|
|
9
|
+
# linear scans on deep trees (the inline lookup in render_tree_row used
|
|
10
|
+
# to do this per (row × column) without memoization).
|
|
11
|
+
@_tree_col_field_defs ||= {}
|
|
12
|
+
@_tree_col_model_names ||= {}
|
|
13
|
+
columns.each do |col|
|
|
14
|
+
path = col["field"]
|
|
15
|
+
next unless path
|
|
16
|
+
next if @_tree_col_field_defs.key?(path)
|
|
17
|
+
fd, mn = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, path, through_collections: true)
|
|
18
|
+
@_tree_col_field_defs[path] = fd
|
|
19
|
+
@_tree_col_model_names[path] = mn || current_model_name
|
|
20
|
+
end
|
|
21
|
+
|
|
7
22
|
roots.each_with_index.map { |record, idx|
|
|
8
23
|
render_tree_row(record, children_map, columns, depth: depth,
|
|
9
24
|
default_expanded: default_expanded, match_ids: match_ids,
|
|
@@ -83,17 +98,23 @@ module LcpRuby
|
|
|
83
98
|
end
|
|
84
99
|
|
|
85
100
|
value = @field_resolver.resolve(record, col["field"], fk_map: @fk_map)
|
|
101
|
+
fd = @_tree_col_field_defs[col["field"]]
|
|
102
|
+
fd_model = @_tree_col_model_names[col["field"]] || current_model_name
|
|
86
103
|
|
|
87
104
|
rendered_value = if col["partial"]
|
|
88
105
|
render(partial: col["partial"], locals: { value: value, record: record, options: col["options"] || {} })
|
|
89
106
|
elsif col["renderer"]
|
|
90
|
-
render_display_value(value, col["renderer"], col["options"],
|
|
107
|
+
render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model).to_s
|
|
91
108
|
elsif value.is_a?(Array)
|
|
92
|
-
|
|
109
|
+
# Pass fd + model so enum elements humanize (issue #18) — the
|
|
110
|
+
# collection renderer itself only does item.to_s.
|
|
111
|
+
render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model).to_s
|
|
93
112
|
elsif value.is_a?(ActiveRecord::Base)
|
|
94
113
|
empty_value_placeholder(display_association_value(value), current_presenter).to_s
|
|
95
114
|
else
|
|
96
|
-
|
|
115
|
+
# Resolver returns raw enum keys (issue #18); humanize via field's
|
|
116
|
+
# owning model namespace.
|
|
117
|
+
empty_value_placeholder(format_enum_display(value, fd, fd_model), current_presenter).to_s
|
|
97
118
|
end
|
|
98
119
|
cell_content << wrap_link_through(value, rendered_value, record, col)
|
|
99
120
|
|
data/app/models/lcp_ruby/user.rb
CHANGED
|
@@ -9,6 +9,16 @@ module LcpRuby
|
|
|
9
9
|
class User < ActiveRecord::Base
|
|
10
10
|
self.table_name = "lcp_ruby_users"
|
|
11
11
|
|
|
12
|
+
# Declare the AR :json attribute type for the JSON columns explicitly.
|
|
13
|
+
# PostgreSQL (jsonb) and SQLite reflect these correctly, but MariaDB reports
|
|
14
|
+
# JSON columns as `longtext`, so without this AR serializes a Hash via
|
|
15
|
+
# Hash#to_s (`{"k"=>"v"}`) — invalid JSON that trips MariaDB's implicit
|
|
16
|
+
# json_valid() CHECK on insert, and returns a raw String on read. The proc
|
|
17
|
+
# defaults mirror the create_lcp_ruby_users migration and hand each new
|
|
18
|
+
# record its own object.
|
|
19
|
+
attribute :lcp_role, :json, default: -> { [ "viewer" ] }
|
|
20
|
+
attribute :profile_data, :json, default: -> { {} }
|
|
21
|
+
|
|
12
22
|
# Devise modules — lockable and timeoutable are always declared but
|
|
13
23
|
# their behavior depends on configuration (lock_after_attempts, session_timeout).
|
|
14
24
|
devise :database_authenticatable, :registerable, :recoverable,
|