acts_as_calculator_editor 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +107 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/acts_as_calculator_editor/application.js +26 -0
- data/app/assets/javascripts/acts_as_calculator_editor/dentaku_tokenizer.js +139 -0
- data/app/assets/javascripts/acts_as_calculator_editor/expression_editor_controller.js +380 -0
- data/app/assets/javascripts/acts_as_calculator_editor/list_filter_controller.js +39 -0
- data/app/assets/javascripts/acts_as_calculator_editor/template_editor_controller.js +86 -0
- data/app/assets/stylesheets/acts_as_calculator_editor/application.css +2 -0
- data/app/controllers/acts_as_calculator_editor/application_controller.rb +115 -0
- data/app/controllers/acts_as_calculator_editor/exports_controller.rb +41 -0
- data/app/controllers/acts_as_calculator_editor/formula_versions_controller.rb +128 -0
- data/app/controllers/acts_as_calculator_editor/formulas_controller.rb +94 -0
- data/app/controllers/acts_as_calculator_editor/lookup_tables_controller.rb +112 -0
- data/app/controllers/acts_as_calculator_editor/templates_controller.rb +130 -0
- data/app/controllers/acts_as_calculator_editor/validations_controller.rb +23 -0
- data/app/helpers/acts_as_calculator_editor/application_helper.rb +85 -0
- data/app/jobs/acts_as_calculator_editor/application_job.rb +6 -0
- data/app/mailers/acts_as_calculator_editor/application_mailer.rb +8 -0
- data/app/models/acts_as_calculator_editor/application_record.rb +7 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_expression_field.html.erb +64 -0
- data/app/views/acts_as_calculator_editor/formula_versions/_variable_fields.html.erb +51 -0
- data/app/views/acts_as_calculator_editor/formula_versions/index.html.erb +45 -0
- data/app/views/acts_as_calculator_editor/formula_versions/new.html.erb +80 -0
- data/app/views/acts_as_calculator_editor/formula_versions/show.html.erb +75 -0
- data/app/views/acts_as_calculator_editor/formulas/_form.html.erb +23 -0
- data/app/views/acts_as_calculator_editor/formulas/edit.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/formulas/index.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/formulas/new.html.erb +10 -0
- data/app/views/acts_as_calculator_editor/formulas/show.html.erb +72 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_entry_fields.html.erb +24 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/_form.html.erb +47 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/edit.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/index.html.erb +67 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/new.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/lookup_tables/show.html.erb +71 -0
- data/app/views/acts_as_calculator_editor/shared/_error_message.html.erb +12 -0
- data/app/views/acts_as_calculator_editor/shared/_export_panel.html.erb +52 -0
- data/app/views/acts_as_calculator_editor/shared/_flash.html.erb +8 -0
- data/app/views/acts_as_calculator_editor/shared/_page_header.html.erb +16 -0
- data/app/views/acts_as_calculator_editor/shared/error.html.erb +11 -0
- data/app/views/acts_as_calculator_editor/templates/_form.html.erb +124 -0
- data/app/views/acts_as_calculator_editor/templates/_lexxy_toolbar.html.erb +101 -0
- data/app/views/acts_as_calculator_editor/templates/index.html.erb +83 -0
- data/app/views/acts_as_calculator_editor/templates/new.html.erb +13 -0
- data/app/views/acts_as_calculator_editor/templates/preview.html.erb +29 -0
- data/app/views/acts_as_calculator_editor/templates/show.html.erb +111 -0
- data/app/views/layouts/acts_as_calculator_editor/application.html.erb +52 -0
- data/config/importmap.rb +13 -0
- data/config/routes.rb +46 -0
- data/lib/acts_as_calculator_editor/build_export_document.rb +37 -0
- data/lib/acts_as_calculator_editor/build_sample_result.rb +26 -0
- data/lib/acts_as_calculator_editor/cast_entry_rows.rb +52 -0
- data/lib/acts_as_calculator_editor/cast_export_decimal.rb +19 -0
- data/lib/acts_as_calculator_editor/cast_variable_rows.rb +90 -0
- data/lib/acts_as_calculator_editor/delete_lookup_table.rb +54 -0
- data/lib/acts_as_calculator_editor/engine.rb +32 -0
- data/lib/acts_as_calculator_editor/export_formulas.rb +89 -0
- data/lib/acts_as_calculator_editor/export_lookup_tables.rb +49 -0
- data/lib/acts_as_calculator_editor/export_owner_reference.rb +18 -0
- data/lib/acts_as_calculator_editor/export_templates.rb +51 -0
- data/lib/acts_as_calculator_editor/publish_lookup_table.rb +53 -0
- data/lib/acts_as_calculator_editor/render_template_preview.rb +60 -0
- data/lib/acts_as_calculator_editor/validate_expression.rb +127 -0
- data/lib/acts_as_calculator_editor/version.rb +5 -0
- data/lib/acts_as_calculator_editor.rb +76 -0
- data/lib/tasks/acts_as_calculator_editor_tasks.rake +6 -0
- metadata +248 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7b0730de5b7917653aa2dc71dbf72e481ffccbe0a5c8e739b0e26620b4cd5c0e
|
|
4
|
+
data.tar.gz: ec17f8bf185a98d896eb0ef88a1d6780da76a0d73645c9da20bb0c1171bfb98e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 302ab53a61fb1fdde36d9138c7ee2be129659dfb3db0617067c1875a768f6ef4a090e6ffb2dc5feed0a0fe977d547e86b9649d142aa7eaee3d2f76cf9c36839c
|
|
7
|
+
data.tar.gz: 6e01ac956b1cc1596ee2a1ab6f4d10548f8030b631690aab87f53498ef29fb3b80cd37e3ec994c54df17f4bd1b4eb09cb2f9c99ca1340771e27b1767ec5ae96b
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright TODO: Write your name
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ActsAsCalculatorEditor
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine providing a Hotwire UI for authoring
|
|
4
|
+
[`acts_as_calculator`](https://github.com/lautarograc/acts_as_calculator)
|
|
5
|
+
formulas and templates — CRUD, live validation preview, and JSON export. No
|
|
6
|
+
permissions layer; wire the engine's `current_owner` and route constraints to
|
|
7
|
+
your host app's auth as needed.
|
|
8
|
+
|
|
9
|
+
This gem never duplicates the core gem's persistence or calculation logic —
|
|
10
|
+
every write goes through `acts_as_calculator`'s own service objects
|
|
11
|
+
(`PublishFormulaVersion`, `PublishTemplate`, `ImportLookupTable`, etc.),
|
|
12
|
+
called in-process rather than over HTTP. See
|
|
13
|
+
[`docs/core-gem-contract.md`](docs/core-gem-contract.md) for the full
|
|
14
|
+
contract this gem is built against.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add both gems (this one is not yet published, so a path or git reference is
|
|
19
|
+
required until it is):
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "acts_as_calculator"
|
|
23
|
+
gem "acts_as_calculator_editor"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Mount the engine:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/routes.rb
|
|
30
|
+
mount ActsAsCalculatorEditor::Engine => "/calculator-editor"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Pin Lexxy in your host's importmap (this gem does not do it for you, since a
|
|
34
|
+
host may use a different JS bundler):
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# config/importmap.rb
|
|
38
|
+
pin "lexxy"
|
|
39
|
+
```
|
|
40
|
+
```js
|
|
41
|
+
// app/javascript/application.js
|
|
42
|
+
import "lexxy"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Run `acts_as_calculator`'s own install generator if you haven't already —
|
|
46
|
+
this engine reads and writes its tables directly, it ships no migrations of
|
|
47
|
+
its own.
|
|
48
|
+
|
|
49
|
+
## The two editors
|
|
50
|
+
|
|
51
|
+
- **Templates** (`format: "html"`) use [Lexxy](https://github.com/basecamp/lexxy)
|
|
52
|
+
— a real rich text editor, since template bodies are genuinely prose/HTML
|
|
53
|
+
meant for a payslip, invoice, or policy document.
|
|
54
|
+
- **Formula expressions** deliberately do **not** use Lexxy. A Dentaku
|
|
55
|
+
expression like `salary - (salary * federal_2026)` is not prose, and
|
|
56
|
+
Lexxy's editor value is HTML even in "plain" mode — an expression using `<`
|
|
57
|
+
without a following space (`salary <bonus`) gets parsed as the start of an
|
|
58
|
+
HTML tag and silently truncated, producing a shorter expression that still
|
|
59
|
+
parses as valid Dentaku but computes something different. See
|
|
60
|
+
[`docs/lexxy-decision.md`](docs/lexxy-decision.md) for the full
|
|
61
|
+
investigation, including a rejected markdown-based workaround. The formula
|
|
62
|
+
editor is a plain textarea with a Stimulus-driven syntax-highlighting
|
|
63
|
+
overlay and live validation instead.
|
|
64
|
+
|
|
65
|
+
## A note on Lexxy and Rails version
|
|
66
|
+
|
|
67
|
+
`lexxy` requires Rails ≥ 8.0.2, and this gemspec's floors match that — it
|
|
68
|
+
cannot install against Rails 7.1/7.2 even though `acts_as_calculator` itself
|
|
69
|
+
supports those versions. Lexxy also unconditionally touches `ActionText` at
|
|
70
|
+
boot on Rails 8.0/8.1 (a `to_prepare` hook), regardless of whether any view
|
|
71
|
+
in your app renders rich text — this gem requires `action_text/engine`
|
|
72
|
+
itself to guarantee that's loaded, but if your host app hand-picks railties
|
|
73
|
+
rather than requiring `rails/all`, be aware Action Text (and transitively,
|
|
74
|
+
Active Storage) will load as a result of installing this gem.
|
|
75
|
+
|
|
76
|
+
Lexxy also sets `config.lexxy.override_action_text_defaults = true` on Rails
|
|
77
|
+
8.0/8.1, which aliases `rich_text_area` to its own helper **globally** — if
|
|
78
|
+
your host app has its own Action Text fields and doesn't want them swapped to
|
|
79
|
+
Lexxy, set that config to `false` yourself. This gem doesn't set it (a third
|
|
80
|
+
gem changing a host-level setting silently would be worse than the problem).
|
|
81
|
+
|
|
82
|
+
## Export / import
|
|
83
|
+
|
|
84
|
+
`GET /export` produces the same JSON document shape `acts_as_calculator`'s
|
|
85
|
+
own generator/rake task import, with `?key=`, `?scope=`, `?full_history=`,
|
|
86
|
+
and `?disposition=` query params — see
|
|
87
|
+
[`docs/core-gem-contract.md`](docs/core-gem-contract.md) for the exact
|
|
88
|
+
shape and the deliberate differences from the core gem's API response
|
|
89
|
+
serializers. There is no import UI yet; use the core gem's own
|
|
90
|
+
`rails generate acts_as_calculator:import` or `POST /import` (if
|
|
91
|
+
`enable_api` is on) to bring a document back in.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
bundle install
|
|
97
|
+
bundle exec rspec
|
|
98
|
+
bundle exec rubocop
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The dummy app lives at `spec/dummy` and builds its schema by running
|
|
102
|
+
`acts_as_calculator`'s own install-generator migration, so an upstream
|
|
103
|
+
column rename shows up as a red spec here rather than silent drift.
|
|
104
|
+
|
|
105
|
+
See `docs/` for the full integration contract and the Lexxy decision
|
|
106
|
+
writeup, and `.claude/` for the harness (agents + skills) that built this
|
|
107
|
+
gem — read `CLAUDE.md` for the pointer and change log.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Entry point for this engine's JavaScript, loaded by the layout with
|
|
2
|
+
// `javascript_import_module_tag "acts_as_calculator_editor/application"`.
|
|
3
|
+
//
|
|
4
|
+
// It starts its OWN Stimulus application rather than registering onto the host's. Two
|
|
5
|
+
// reasons: a host may not run Stimulus at all, and even when it does, its controller
|
|
6
|
+
// identifiers are its own namespace — registering "expression-editor" into a shared
|
|
7
|
+
// application would be this gem reaching into the host app's registry to claim a name.
|
|
8
|
+
//
|
|
9
|
+
// The root element is the layout's `<body class="acts-as-calculator-editor">`, so nothing
|
|
10
|
+
// outside this engine's own pages is ever scanned.
|
|
11
|
+
|
|
12
|
+
import { Application } from "@hotwired/stimulus"
|
|
13
|
+
import ExpressionEditorController from "acts_as_calculator_editor/expression_editor_controller"
|
|
14
|
+
import TemplateEditorController from "acts_as_calculator_editor/template_editor_controller"
|
|
15
|
+
import ListFilterController from "acts_as_calculator_editor/list_filter_controller"
|
|
16
|
+
|
|
17
|
+
const root = document.querySelector(".acts-as-calculator-editor") || document.documentElement
|
|
18
|
+
|
|
19
|
+
const application = Application.start(root)
|
|
20
|
+
application.debug = false
|
|
21
|
+
|
|
22
|
+
application.register("expression-editor", ExpressionEditorController)
|
|
23
|
+
application.register("template-editor", TemplateEditorController)
|
|
24
|
+
application.register("list-filter", ListFilterController)
|
|
25
|
+
|
|
26
|
+
export { application }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// A tokenizer for Dentaku expressions, used for the syntax-highlighting overlay and for
|
|
2
|
+
// working out what word the caret sits in for autocomplete.
|
|
3
|
+
//
|
|
4
|
+
// This is deliberately NOT a parser. It never decides whether an expression is valid — the
|
|
5
|
+
// server does that, through the real Dentaku parser with the real function registry
|
|
6
|
+
// (POST /validations). Anything this file concludes is a colouring hint, and a wrong hint
|
|
7
|
+
// must never be able to change the submitted string.
|
|
8
|
+
//
|
|
9
|
+
// Operator set is docs/core-gem-contract.md §5, verified there against Dentaku 4.0.2:
|
|
10
|
+
// + - * / % ^ < > <= >= == = != and or && ||
|
|
11
|
+
// Note `<` and `>` are ordinary operators here. That is the whole reason this field is a
|
|
12
|
+
// textarea and not a rich text editor (docs/lexxy-decision.md §3) — an HTML round-trip eats
|
|
13
|
+
// `salary <bonus` down to `salary `, silently, and it still parses.
|
|
14
|
+
|
|
15
|
+
// Longest-first: `<=` must be tried before `<`, or `a <= b` lexes as `<` then `=`.
|
|
16
|
+
const OPERATORS = [
|
|
17
|
+
"<=", ">=", "==", "!=", "&&", "||",
|
|
18
|
+
"+", "-", "*", "/", "%", "^", "<", ">", "="
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Word-shaped operators and literals. `and`/`or` are operators in Dentaku, not functions.
|
|
22
|
+
const WORD_OPERATORS = new Set([ "and", "or" ])
|
|
23
|
+
const LITERALS = new Set([ "true", "false" ])
|
|
24
|
+
|
|
25
|
+
const IDENTIFIER_START = /[A-Za-z_]/
|
|
26
|
+
const IDENTIFIER_CHAR = /[A-Za-z0-9_]/
|
|
27
|
+
const DIGIT = /[0-9]/
|
|
28
|
+
|
|
29
|
+
// Dentaku is case-insensitive (BuildCalculator defaults to `case_sensitive: false`), so every
|
|
30
|
+
// name comparison here folds case — matching what the server reports back in `identifiers`.
|
|
31
|
+
export function normalize(name) {
|
|
32
|
+
return String(name).toLowerCase()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tokenize(source) {
|
|
36
|
+
const tokens = []
|
|
37
|
+
const text = String(source ?? "")
|
|
38
|
+
let i = 0
|
|
39
|
+
|
|
40
|
+
const push = (type, start, end) => tokens.push({ type, start, end, value: text.slice(start, end) })
|
|
41
|
+
|
|
42
|
+
while (i < text.length) {
|
|
43
|
+
const char = text[i]
|
|
44
|
+
|
|
45
|
+
if (char === " " || char === "\t" || char === "\n" || char === "\r") {
|
|
46
|
+
const start = i
|
|
47
|
+
while (i < text.length && /\s/.test(text[i])) i++
|
|
48
|
+
push("whitespace", start, i)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Strings. An unterminated quote runs to end-of-input and is tagged so the overlay can
|
|
53
|
+
// show it as broken without claiming to know why.
|
|
54
|
+
if (char === "'" || char === '"') {
|
|
55
|
+
const start = i
|
|
56
|
+
const quote = char
|
|
57
|
+
i++
|
|
58
|
+
while (i < text.length && text[i] !== quote) i++
|
|
59
|
+
const terminated = i < text.length
|
|
60
|
+
if (terminated) i++
|
|
61
|
+
push(terminated ? "string" : "unterminated-string", start, i)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (DIGIT.test(char) || (char === "." && DIGIT.test(text[i + 1] ?? ""))) {
|
|
66
|
+
const start = i
|
|
67
|
+
while (i < text.length && DIGIT.test(text[i])) i++
|
|
68
|
+
if (text[i] === ".") {
|
|
69
|
+
i++
|
|
70
|
+
while (i < text.length && DIGIT.test(text[i])) i++
|
|
71
|
+
}
|
|
72
|
+
push("number", start, i)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (IDENTIFIER_START.test(char)) {
|
|
77
|
+
const start = i
|
|
78
|
+
while (i < text.length && IDENTIFIER_CHAR.test(text[i])) i++
|
|
79
|
+
const word = text.slice(start, i)
|
|
80
|
+
const folded = normalize(word)
|
|
81
|
+
|
|
82
|
+
if (WORD_OPERATORS.has(folded)) {
|
|
83
|
+
push("operator", start, i)
|
|
84
|
+
} else if (LITERALS.has(folded)) {
|
|
85
|
+
push("literal", start, i)
|
|
86
|
+
} else {
|
|
87
|
+
// A name immediately followed by `(` is a call. Whitespace between the two is legal
|
|
88
|
+
// Dentaku, so skip it before deciding.
|
|
89
|
+
let lookahead = i
|
|
90
|
+
while (lookahead < text.length && /\s/.test(text[lookahead])) lookahead++
|
|
91
|
+
push(text[lookahead] === "(" ? "function" : "identifier", start, i)
|
|
92
|
+
}
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (char === "(" || char === ")") {
|
|
97
|
+
push("paren", i, i + 1)
|
|
98
|
+
i++
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (char === ",") {
|
|
103
|
+
push("comma", i, i + 1)
|
|
104
|
+
i++
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const operator = OPERATORS.find((candidate) => text.startsWith(candidate, i))
|
|
109
|
+
if (operator) {
|
|
110
|
+
push("operator", i, i + operator.length)
|
|
111
|
+
i += operator.length
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Anything unrecognised is still emitted, so the overlay's concatenated text is always
|
|
116
|
+
// byte-for-byte the textarea's text. If that ever stops being true the overlay drifts out
|
|
117
|
+
// of alignment with the caret, which is the one bug this design can actually produce.
|
|
118
|
+
push("unknown", i, i + 1)
|
|
119
|
+
i++
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return tokens
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The identifier the caret is sitting inside or at the end of, for autocomplete. Returns
|
|
126
|
+
// null when the caret is not in a word (so typing `1 + ` does not open a menu).
|
|
127
|
+
export function wordAt(source, caret) {
|
|
128
|
+
const text = String(source ?? "")
|
|
129
|
+
let start = caret
|
|
130
|
+
|
|
131
|
+
while (start > 0 && IDENTIFIER_CHAR.test(text[start - 1])) start--
|
|
132
|
+
if (start === caret) return null
|
|
133
|
+
if (!IDENTIFIER_START.test(text[start])) return null
|
|
134
|
+
|
|
135
|
+
let end = caret
|
|
136
|
+
while (end < text.length && IDENTIFIER_CHAR.test(text[end])) end++
|
|
137
|
+
|
|
138
|
+
return { start, end, value: text.slice(start, caret) }
|
|
139
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { tokenize, wordAt, normalize } from "acts_as_calculator_editor/dentaku_tokenizer"
|
|
3
|
+
|
|
4
|
+
// The formula `expression` field.
|
|
5
|
+
//
|
|
6
|
+
// Three jobs, none of which may ever alter the submitted string:
|
|
7
|
+
// 1. a syntax-highlighting overlay (a <pre> behind a transparent <textarea>)
|
|
8
|
+
// 2. debounced validation against POST /validations
|
|
9
|
+
// 3. autocomplete over the function list and the declared variable names
|
|
10
|
+
//
|
|
11
|
+
// The textarea IS the field. It keeps its `name` and `id`, it is never replaced, and nothing
|
|
12
|
+
// here writes to `value` except the autocomplete accept path — which splices at exact offsets
|
|
13
|
+
// and leaves the rest of the string untouched. This is the whole point of
|
|
14
|
+
// docs/lexxy-decision.md §3: the value that reaches the server is the string the operator
|
|
15
|
+
// typed, character for character.
|
|
16
|
+
// Every field of a variable row, in the sibling section of the form. Kept as one selector so
|
|
17
|
+
// the delegated listener and the readers below cannot drift apart.
|
|
18
|
+
const VARIABLE_FIELD = '[name^="version[variables]"]'
|
|
19
|
+
const VARIABLE_NAME_FIELD = 'input[name^="version[variables]"][name$="[name]"]'
|
|
20
|
+
|
|
21
|
+
export default class extends Controller {
|
|
22
|
+
static targets = [ "input", "highlight", "status", "suggestions" ]
|
|
23
|
+
|
|
24
|
+
static values = {
|
|
25
|
+
validateUrl: String,
|
|
26
|
+
functions: { type: Array, default: [] },
|
|
27
|
+
scope: { type: String, default: "" },
|
|
28
|
+
delay: { type: Number, default: 400 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.debounceTimer = null
|
|
33
|
+
this.inFlight = null
|
|
34
|
+
this.activeSuggestion = -1
|
|
35
|
+
this.suggestions = []
|
|
36
|
+
|
|
37
|
+
// The variable rows are NOT inside this controller's element — they are a sibling section
|
|
38
|
+
// of the same <form>. A Stimulus `data-action` only routes to a controller found on an
|
|
39
|
+
// ancestor, so `input->expression-editor#onVariablesChanged` written on those fields never
|
|
40
|
+
// fires; declaring a variable left a stale "not declared as a variable" error on screen
|
|
41
|
+
// until the expression itself was touched again. This controller already reaches out to
|
|
42
|
+
// the form to READ those fields (`variableNameFields`), so it listens out there too,
|
|
43
|
+
// delegated from the form rather than bound per row — the row count is not fixed.
|
|
44
|
+
this.variablesListener = (event) => {
|
|
45
|
+
if (event.target?.matches?.(VARIABLE_FIELD)) this.onVariablesChanged()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.form = this.element.closest("form")
|
|
49
|
+
this.form?.addEventListener("input", this.variablesListener)
|
|
50
|
+
this.form?.addEventListener("change", this.variablesListener)
|
|
51
|
+
|
|
52
|
+
this.render()
|
|
53
|
+
this.scheduleValidation({ immediate: true })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
disconnect() {
|
|
57
|
+
clearTimeout(this.debounceTimer)
|
|
58
|
+
this.inFlight?.abort()
|
|
59
|
+
|
|
60
|
+
this.form?.removeEventListener("input", this.variablesListener)
|
|
61
|
+
this.form?.removeEventListener("change", this.variablesListener)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- input plumbing ------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
onInput() {
|
|
67
|
+
this.render()
|
|
68
|
+
this.refreshSuggestions()
|
|
69
|
+
this.scheduleValidation()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Declared variable names live in sibling form rows the operator edits on this same page,
|
|
73
|
+
// so they are re-read rather than cached: renaming a variable should re-colour the
|
|
74
|
+
// expression and re-validate it without a reload.
|
|
75
|
+
onVariablesChanged() {
|
|
76
|
+
this.render()
|
|
77
|
+
this.scheduleValidation()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
syncScroll() {
|
|
81
|
+
if (!this.hasHighlightTarget) return
|
|
82
|
+
|
|
83
|
+
this.highlightTarget.scrollTop = this.inputTarget.scrollTop
|
|
84
|
+
this.highlightTarget.scrollLeft = this.inputTarget.scrollLeft
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- highlighting --------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
render() {
|
|
90
|
+
if (!this.hasHighlightTarget) return
|
|
91
|
+
|
|
92
|
+
const declared = this.declaredNames()
|
|
93
|
+
const known = new Set(this.functionsValue.map(normalize))
|
|
94
|
+
const source = this.inputTarget.value
|
|
95
|
+
|
|
96
|
+
const html = tokenize(source).map((token) => {
|
|
97
|
+
const classes = [ `aac-tok aac-tok--${token.type}` ]
|
|
98
|
+
|
|
99
|
+
if (token.type === "identifier" && !declared.has(normalize(token.value))) {
|
|
100
|
+
classes.push("aac-tok--undeclared")
|
|
101
|
+
}
|
|
102
|
+
if (token.type === "function" && known.size > 0 && !known.has(normalize(token.value))) {
|
|
103
|
+
classes.push("aac-tok--unknown-function")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return `<span class="${classes.join(" ")}">${escapeHtml(token.value)}</span>`
|
|
107
|
+
}).join("")
|
|
108
|
+
|
|
109
|
+
// The trailing newline keeps a final "\n" in the textarea from collapsing the <pre>'s
|
|
110
|
+
// last line, which would put the overlay one line out of step with the caret.
|
|
111
|
+
this.highlightTarget.innerHTML = `${html}\n`
|
|
112
|
+
this.syncScroll()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
declaredNames() {
|
|
116
|
+
return new Set(this.variableNameFields().map((field) => normalize(field.value)).filter(Boolean))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
variableNameFields() {
|
|
120
|
+
if (!this.form) return []
|
|
121
|
+
|
|
122
|
+
return Array.from(this.form.querySelectorAll(VARIABLE_NAME_FIELD))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- validation ----------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
scheduleValidation({ immediate = false } = {}) {
|
|
128
|
+
clearTimeout(this.debounceTimer)
|
|
129
|
+
|
|
130
|
+
if (!this.hasValidateUrlValue) return
|
|
131
|
+
|
|
132
|
+
this.debounceTimer = setTimeout(() => this.validate(), immediate ? 0 : this.delayValue)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async validate() {
|
|
136
|
+
const expression = this.inputTarget.value
|
|
137
|
+
|
|
138
|
+
// Nothing typed yet is not a problem worth shouting about — the server would call it
|
|
139
|
+
// `blank`, which is true but not useful on an untouched form.
|
|
140
|
+
if (expression.trim() === "") {
|
|
141
|
+
this.serverIdentifiers = []
|
|
142
|
+
this.renderStatus(null)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.inFlight?.abort()
|
|
147
|
+
this.inFlight = new AbortController()
|
|
148
|
+
|
|
149
|
+
const payload = {
|
|
150
|
+
expression,
|
|
151
|
+
scope: this.scopeValue,
|
|
152
|
+
variables: this.variableRows()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(this.validateUrlValue, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
signal: this.inFlight.signal,
|
|
159
|
+
headers: {
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"Accept": "application/json",
|
|
162
|
+
"X-CSRF-Token": csrfToken()
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify(payload)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const result = await response.json()
|
|
168
|
+
this.serverIdentifiers = Array.isArray(result.identifiers) ? result.identifiers : []
|
|
169
|
+
this.renderStatus(result)
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error.name === "AbortError") return
|
|
172
|
+
|
|
173
|
+
// A failed check is not a failed expression. Say which one it was.
|
|
174
|
+
this.renderStatus({ unreachable: true })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Mirrors the row shape in _workspace/03_rails_report.md §3. `required` is sent as a real
|
|
179
|
+
// boolean rather than "1"/"0" because this payload is JSON, not a form post — but the name
|
|
180
|
+
// is the only field validation actually reads.
|
|
181
|
+
variableRows() {
|
|
182
|
+
const form = this.form
|
|
183
|
+
if (!form) return []
|
|
184
|
+
|
|
185
|
+
return this.variableNameFields().map((field) => {
|
|
186
|
+
const prefix = field.name.replace(/\[name\]$/, "")
|
|
187
|
+
const sourceType = form.querySelector(`[name="${prefix}[source_type]"]`)
|
|
188
|
+
const config = form.querySelector(`[name="${prefix}[source_config]"]`)
|
|
189
|
+
const required = form.querySelector(`input[type="checkbox"][name="${prefix}[required]"]`)
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
name: field.value,
|
|
193
|
+
source_type: sourceType?.value || "context",
|
|
194
|
+
source_config: config?.value || "",
|
|
195
|
+
required: required ? required.checked : true
|
|
196
|
+
}
|
|
197
|
+
}).filter((row) => row.name.trim() !== "")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
renderStatus(result) {
|
|
201
|
+
if (!this.hasStatusTarget) return
|
|
202
|
+
|
|
203
|
+
if (result === null) {
|
|
204
|
+
this.statusTarget.innerHTML = ""
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.unreachable) {
|
|
209
|
+
this.statusTarget.innerHTML = message("aac-status--unknown", "Could not reach the checker — the expression itself has not been judged.")
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The endpoint has TWO response shapes and both have to be handled here. The 200 shape is
|
|
214
|
+
// `{valid, errors, warnings, identifiers}`; anything ApplicationController rescues comes
|
|
215
|
+
// back as `{error: {type, message}}` with a 4xx — most reachably a 422 from
|
|
216
|
+
// `CastVariableRows`, which raises while a `source_config` is half-typed
|
|
217
|
+
// (`{"table":` is not yet a JSON object). Reading only the 200 shape left `parts` empty,
|
|
218
|
+
// so the status region silently blanked mid-keystroke and never said why.
|
|
219
|
+
if (result.error) {
|
|
220
|
+
this.statusTarget.innerHTML = message("aac-status--error", result.error.message || "The checker rejected this request.")
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const parts = []
|
|
225
|
+
|
|
226
|
+
if (result.valid) {
|
|
227
|
+
// Carefully worded, per contract §7.10 and the Rails report's note: this endpoint sees
|
|
228
|
+
// syntax, declared names and lookup-table existence. It cannot see whether an
|
|
229
|
+
// `attribute` or `method` variable resolves against the host's model, because that
|
|
230
|
+
// needs a real record. Saying "valid" here would be a claim the check did not make.
|
|
231
|
+
parts.push(message("aac-status--ok", "Syntax and variable wiring check out. Attribute and method variables are only verified against real data when the formula runs."))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const error of result.errors || []) {
|
|
235
|
+
parts.push(message("aac-status--error", error.message))
|
|
236
|
+
}
|
|
237
|
+
for (const warning of result.warnings || []) {
|
|
238
|
+
parts.push(message("aac-status--warning", warning.message))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
this.statusTarget.innerHTML = parts.join("")
|
|
242
|
+
this.render()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- autocomplete --------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
refreshSuggestions() {
|
|
248
|
+
const word = wordAt(this.inputTarget.value, this.inputTarget.selectionStart)
|
|
249
|
+
|
|
250
|
+
if (!word || word.value.length === 0) return this.closeSuggestions()
|
|
251
|
+
|
|
252
|
+
const needle = normalize(word.value)
|
|
253
|
+
const declared = Array.from(this.declaredNames())
|
|
254
|
+
|
|
255
|
+
const candidates = [
|
|
256
|
+
...declared.map((name) => ({ name, kind: "variable" })),
|
|
257
|
+
...this.functionsValue.map((name) => ({ name, kind: "function" }))
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
const seen = new Set()
|
|
261
|
+
this.suggestions = candidates
|
|
262
|
+
.filter(({ name }) => {
|
|
263
|
+
const folded = normalize(name)
|
|
264
|
+
if (!folded.startsWith(needle) || folded === needle || seen.has(folded)) return false
|
|
265
|
+
seen.add(folded)
|
|
266
|
+
return true
|
|
267
|
+
})
|
|
268
|
+
.slice(0, 8)
|
|
269
|
+
|
|
270
|
+
if (this.suggestions.length === 0) return this.closeSuggestions()
|
|
271
|
+
|
|
272
|
+
this.wordRange = word
|
|
273
|
+
this.activeSuggestion = 0
|
|
274
|
+
this.renderSuggestions()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
renderSuggestions() {
|
|
278
|
+
if (!this.hasSuggestionsTarget) return
|
|
279
|
+
|
|
280
|
+
this.suggestionsTarget.innerHTML = this.suggestions.map((suggestion, index) => `
|
|
281
|
+
<li role="option" id="aac-suggestion-${index}" aria-selected="${index === this.activeSuggestion}"
|
|
282
|
+
class="aac-suggestion ${index === this.activeSuggestion ? "is-active" : ""}"
|
|
283
|
+
data-index="${index}"
|
|
284
|
+
data-action="mousedown->expression-editor#pick">
|
|
285
|
+
<span class="aac-suggestion__name">${escapeHtml(suggestion.name)}</span>
|
|
286
|
+
<span class="aac-suggestion__kind">${suggestion.kind}</span>
|
|
287
|
+
</li>
|
|
288
|
+
`).join("")
|
|
289
|
+
|
|
290
|
+
this.suggestionsTarget.hidden = false
|
|
291
|
+
this.inputTarget.setAttribute("aria-expanded", "true")
|
|
292
|
+
this.inputTarget.setAttribute("aria-activedescendant", `aac-suggestion-${this.activeSuggestion}`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
closeSuggestions() {
|
|
296
|
+
this.suggestions = []
|
|
297
|
+
this.activeSuggestion = -1
|
|
298
|
+
|
|
299
|
+
if (this.hasSuggestionsTarget) {
|
|
300
|
+
this.suggestionsTarget.hidden = true
|
|
301
|
+
this.suggestionsTarget.innerHTML = ""
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.inputTarget.setAttribute("aria-expanded", "false")
|
|
305
|
+
this.inputTarget.removeAttribute("aria-activedescendant")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
onKeydown(event) {
|
|
309
|
+
if (this.suggestions.length === 0) return
|
|
310
|
+
|
|
311
|
+
switch (event.key) {
|
|
312
|
+
case "ArrowDown":
|
|
313
|
+
event.preventDefault()
|
|
314
|
+
this.activeSuggestion = (this.activeSuggestion + 1) % this.suggestions.length
|
|
315
|
+
this.renderSuggestions()
|
|
316
|
+
break
|
|
317
|
+
case "ArrowUp":
|
|
318
|
+
event.preventDefault()
|
|
319
|
+
this.activeSuggestion = (this.activeSuggestion - 1 + this.suggestions.length) % this.suggestions.length
|
|
320
|
+
this.renderSuggestions()
|
|
321
|
+
break
|
|
322
|
+
case "Enter":
|
|
323
|
+
case "Tab":
|
|
324
|
+
// Only swallow the key while the menu is genuinely open, so Enter still submits the
|
|
325
|
+
// form and Tab still moves on when it is not.
|
|
326
|
+
event.preventDefault()
|
|
327
|
+
this.accept(this.activeSuggestion)
|
|
328
|
+
break
|
|
329
|
+
case "Escape":
|
|
330
|
+
event.preventDefault()
|
|
331
|
+
this.closeSuggestions()
|
|
332
|
+
break
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
pick(event) {
|
|
337
|
+
// mousedown, not click: the textarea would blur first and close the menu.
|
|
338
|
+
event.preventDefault()
|
|
339
|
+
this.accept(Number(event.currentTarget.dataset.index))
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
accept(index) {
|
|
343
|
+
const suggestion = this.suggestions[index]
|
|
344
|
+
if (!suggestion || !this.wordRange) return this.closeSuggestions()
|
|
345
|
+
|
|
346
|
+
const { start, end } = this.wordRange
|
|
347
|
+
const source = this.inputTarget.value
|
|
348
|
+
const insert = suggestion.kind === "function" ? `${suggestion.name}(` : suggestion.name
|
|
349
|
+
|
|
350
|
+
this.inputTarget.value = source.slice(0, start) + insert + source.slice(end)
|
|
351
|
+
const caret = start + insert.length
|
|
352
|
+
this.inputTarget.setSelectionRange(caret, caret)
|
|
353
|
+
|
|
354
|
+
this.closeSuggestions()
|
|
355
|
+
this.inputTarget.focus()
|
|
356
|
+
this.render()
|
|
357
|
+
this.scheduleValidation()
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
onBlur() {
|
|
361
|
+
// Deferred so a mousedown on a suggestion still lands.
|
|
362
|
+
setTimeout(() => this.closeSuggestions(), 120)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function message(className, text) {
|
|
367
|
+
return `<p class="aac-status ${className}">${escapeHtml(text)}</p>`
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function csrfToken() {
|
|
371
|
+
return document.querySelector('meta[name="csrf-token"]')?.content || ""
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function escapeHtml(value) {
|
|
375
|
+
return String(value)
|
|
376
|
+
.replace(/&/g, "&")
|
|
377
|
+
.replace(/</g, "<")
|
|
378
|
+
.replace(/>/g, ">")
|
|
379
|
+
.replace(/"/g, """)
|
|
380
|
+
}
|