querylane 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/CHANGELOG.md +84 -0
- data/CONTRIBUTING.md +118 -0
- data/LICENSE +21 -0
- data/README.md +287 -0
- data/SECURITY.md +29 -0
- data/app/assets/stylesheets/querylane.css +300 -0
- data/app/helpers/querylane/search_helper.rb +111 -0
- data/app/javascript/querylane/controllers/box_controller.js +170 -0
- data/app/javascript/querylane/controllers/options_controller.js +54 -0
- data/app/javascript/querylane/controllers/results_controller.js +137 -0
- data/app/javascript/querylane/controllers/suggest_controller.js +187 -0
- data/app/javascript/querylane/index.js +33 -0
- data/app/javascript/querylane/overlay.js +46 -0
- data/app/javascript/querylane/package.json +7 -0
- data/app/views/querylane/_chips.html.erb +15 -0
- data/app/views/querylane/_denied.html.erb +6 -0
- data/app/views/querylane/_empty.html.erb +17 -0
- data/app/views/querylane/_facets.html.erb +30 -0
- data/app/views/querylane/_first_load.html.erb +15 -0
- data/app/views/querylane/_form.html.erb +46 -0
- data/app/views/querylane/_group.html.erb +21 -0
- data/app/views/querylane/_help.html.erb +29 -0
- data/app/views/querylane/_options.html.erb +32 -0
- data/app/views/querylane/_options_row.html.erb +51 -0
- data/app/views/querylane/_outcome.html.erb +16 -0
- data/app/views/querylane/_pager.html.erb +19 -0
- data/app/views/querylane/_preview.html.erb +39 -0
- data/app/views/querylane/_record.html.erb +16 -0
- data/app/views/querylane/_related.html.erb +17 -0
- data/app/views/querylane/_results.html.erb +66 -0
- data/app/views/querylane/_rows.html.erb +13 -0
- data/app/views/querylane/_saved.html.erb +31 -0
- data/app/views/querylane/_suggest_box.html.erb +45 -0
- data/app/views/querylane/_suggestions.html.erb +47 -0
- data/app/views/querylane/_toolbar.html.erb +28 -0
- data/app/views/querylane/_unavailable.html.erb +7 -0
- data/app/views/querylane/_writes.html.erb +16 -0
- data/config/importmap.rb +34 -0
- data/config/locales/querylane.en.yml +136 -0
- data/lib/querylane/ast.rb +17 -0
- data/lib/querylane/chips.rb +85 -0
- data/lib/querylane/compiler.rb +52 -0
- data/lib/querylane/configuration.rb +49 -0
- data/lib/querylane/context.rb +11 -0
- data/lib/querylane/controller.rb +85 -0
- data/lib/querylane/definition.rb +447 -0
- data/lib/querylane/doctor.rb +52 -0
- data/lib/querylane/engine.rb +61 -0
- data/lib/querylane/errors.rb +27 -0
- data/lib/querylane/filter.rb +75 -0
- data/lib/querylane/group_token.rb +33 -0
- data/lib/querylane/parser.rb +178 -0
- data/lib/querylane/presenter.rb +160 -0
- data/lib/querylane/preview.rb +45 -0
- data/lib/querylane/query.rb +183 -0
- data/lib/querylane/request.rb +66 -0
- data/lib/querylane/result.rb +90 -0
- data/lib/querylane/rewriter.rb +245 -0
- data/lib/querylane/saving.rb +49 -0
- data/lib/querylane/scope.rb +53 -0
- data/lib/querylane/search.rb +347 -0
- data/lib/querylane/searchers/meilisearch.rb +226 -0
- data/lib/querylane/suggest.rb +67 -0
- data/lib/querylane/version.rb +5 -0
- data/lib/querylane.rb +60 -0
- data/lib/tasks/querylane.rake +14 -0
- metadata +132 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<%# One options-panel row. Locals: row (Definition::PanelRow), values, held, definition, id.
|
|
2
|
+
A masked field starts empty: its current terms stay in the box (shown
|
|
3
|
+
masked as the placeholder); what is typed here is added. %>
|
|
4
|
+
<% field_id = ->(name) { "#{id}-compose-#{name}" } %>
|
|
5
|
+
<div class="ql-options-row">
|
|
6
|
+
<% case row.kind %>
|
|
7
|
+
<% when :inputs %>
|
|
8
|
+
<% names = row.bare ? [row.name] : row.keys.map(&:to_s) %>
|
|
9
|
+
<label class="ql-options-label" for="<%= field_id.(names.first) %>"><%= row.label %></label>
|
|
10
|
+
<div class="ql-options-inputs">
|
|
11
|
+
<% names.each_with_index do |name, position| %>
|
|
12
|
+
<span class="ql-options-input">
|
|
13
|
+
<%# Sensitive fields stay type=text (a password field would invite the browser to save it), masked by CSS. %>
|
|
14
|
+
<input type="text" id="<%= field_id.(name) %>" name="compose[<%= name %>]" form="<%= id %>-form"
|
|
15
|
+
value="<%= values[name] %>" class="form-control form-control-sm <%= "ql-masked" if row.masked %>" autocomplete="off" spellcheck="false"
|
|
16
|
+
<%= tag.attributes(placeholder: (t("querylane.options.held", text: held[name]) if held[name])) %>
|
|
17
|
+
<%= tag.attributes(aria: {label: ("#{row.label} #{name.humanize}" if position.positive?)}) %>
|
|
18
|
+
data-querylane--options-target="field">
|
|
19
|
+
<% if row.masked %>
|
|
20
|
+
<button type="button" class="ql-link ql-options-reveal" aria-controls="<%= field_id.(name) %>" aria-pressed="false"
|
|
21
|
+
data-action="querylane--options#reveal"
|
|
22
|
+
data-show="<%= t("querylane.options.show") %>" data-hide="<%= t("querylane.options.hide") %>"><%= t("querylane.options.show") %></button>
|
|
23
|
+
<% end %>
|
|
24
|
+
</span>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
<% when :flags %>
|
|
28
|
+
<% flags = definition.operator(row.keys.first) %>
|
|
29
|
+
<span class="ql-options-label" id="<%= field_id.(row.name) %>-label"><%= row.label %></span>
|
|
30
|
+
<div class="ql-options-checks" role="group" aria-labelledby="<%= field_id.(row.name) %>-label">
|
|
31
|
+
<% definition.flag_values(flags, row.values).each do |value, label| %>
|
|
32
|
+
<label class="ql-options-check">
|
|
33
|
+
<input type="checkbox" name="compose[<%= row.name %>][]" value="<%= value %>" form="<%= id %>-form"
|
|
34
|
+
<%= "checked" if Array(values[row.name]).include?(value.to_s) %> data-querylane--options-target="field">
|
|
35
|
+
<%= label %>
|
|
36
|
+
</label>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<% when :options %>
|
|
40
|
+
<span class="ql-options-label"><%= row.label %></span>
|
|
41
|
+
<div class="ql-options-checks">
|
|
42
|
+
<% row.keys.each do |key| %>
|
|
43
|
+
<label class="ql-options-check">
|
|
44
|
+
<input type="checkbox" name="compose[options][]" value="<%= key %>" form="<%= id %>-form"
|
|
45
|
+
<%= "checked" if values["options"].include?(key.to_s) %> data-querylane--options-target="field">
|
|
46
|
+
<%= definition.option(key).label %>
|
|
47
|
+
</label>
|
|
48
|
+
<% end %>
|
|
49
|
+
</div>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Open/View button (full-page POST) or the reason it cannot be opened. Locals: outcome. %>
|
|
2
|
+
<% case outcome %>
|
|
3
|
+
<% when Querylane::Presenter::Action %>
|
|
4
|
+
<%= button_to outcome.label, outcome.url, params: outcome.params || {}, method: :post,
|
|
5
|
+
class: "btn btn-sm #{(outcome.style == :primary) ? "btn-primary" : "btn-outline-primary"} ql-open",
|
|
6
|
+
form: {class: "d-inline", data: {turbo: false}}, data: {querylane_open: true} %>
|
|
7
|
+
<% when Querylane::Presenter::Reason %>
|
|
8
|
+
<span class="ql-reason"><%= outcome.text %><% if outcome.link_url %> <%= link_to outcome.link_label, outcome.link_url, data: {turbo: false} %><% end %>
|
|
9
|
+
<% if outcome.more.any? %>
|
|
10
|
+
<details class="ql-reason-more">
|
|
11
|
+
<summary class="ql-link"><%= t("querylane.reasons.more", count: outcome.more.size) %></summary>
|
|
12
|
+
<ul><% outcome.more.each do |more| %><li><%= more %></li><% end %></ul>
|
|
13
|
+
</details>
|
|
14
|
+
<% end %>
|
|
15
|
+
</span>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# Previous, up to five pages, Next. Submit buttons of the search form. Locals: result, id. %>
|
|
2
|
+
<% pages = [result.pages, 1].max
|
|
3
|
+
start = [[result.page - 2, 1].max, [pages - 4, 1].max].min
|
|
4
|
+
window = (start..[start + 4, pages].min) %>
|
|
5
|
+
<nav aria-label="<%= t("querylane.toolbar.pages") %>">
|
|
6
|
+
<ul class="pagination pagination-sm mb-0 ql-pager">
|
|
7
|
+
<li class="page-item previous <%= "disabled" if result.page <= 1 %>">
|
|
8
|
+
<button type="submit" form="<%= id %>-form" name="page" value="<%= result.page - 1 %>" class="page-link" <%= "disabled" if result.page <= 1 %>><%= t("querylane.toolbar.previous") %></button>
|
|
9
|
+
</li>
|
|
10
|
+
<% window.each do |number| %>
|
|
11
|
+
<li class="page-item <%= "active" if number == result.page %>">
|
|
12
|
+
<button type="submit" form="<%= id %>-form" name="page" value="<%= number %>" class="page-link" <%= 'aria-current="page"'.html_safe if number == result.page %>><%= number %></button>
|
|
13
|
+
</li>
|
|
14
|
+
<% end %>
|
|
15
|
+
<li class="page-item next <%= "disabled" if result.page >= pages %>">
|
|
16
|
+
<button type="submit" form="<%= id %>-form" name="page" value="<%= result.page + 1 %>" class="page-link" <%= "disabled" if result.page >= pages %>><%= t("querylane.toolbar.next") %></button>
|
|
17
|
+
</li>
|
|
18
|
+
</ul>
|
|
19
|
+
</nav>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%# The preview (Space on a record): every record of its group, any status.
|
|
2
|
+
Locals: result (from querylane_related), presenter, id. %>
|
|
3
|
+
<turbo-frame id="<%= id %>-preview" class="ql-preview">
|
|
4
|
+
<section class="ql-preview-card" aria-labelledby="<%= id %>-preview-title">
|
|
5
|
+
<% if result.state == :unavailable %>
|
|
6
|
+
<p role="status"><%= t("querylane.group.unavailable") %></p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<% group = Querylane::Presenter::Group.new(nil, result.hits) %>
|
|
9
|
+
<header class="ql-preview-head">
|
|
10
|
+
<div>
|
|
11
|
+
<h2 id="<%= id %>-preview-title" class="ql-preview-title"><%= presenter.title(group) if result.hits.any? %></h2>
|
|
12
|
+
<% if result.hits.any? && (subtitle = presenter.subtitle(group)) %><div class="ql-preview-subtitle"><%= subtitle %></div><% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<button type="button" class="ql-link" data-action="querylane--results#closePreview"><%= t("querylane.group.close") %></button>
|
|
15
|
+
</header>
|
|
16
|
+
<div class="ql-preview-count"><%= t("querylane.group.preview_count", count: result.total) %></div>
|
|
17
|
+
<ul class="ql-preview-list">
|
|
18
|
+
<% result.hits.each do |hit| %>
|
|
19
|
+
<li class="ql-preview-record">
|
|
20
|
+
<div>
|
|
21
|
+
<div class="ql-preview-line">
|
|
22
|
+
<strong><%= safe_join(presenter.cells(hit).first(3).map(&:text).compact_blank, " · ") %></strong>
|
|
23
|
+
<% if (badge = presenter.status(hit)) %><span class="ql-badge ql-badge-<%= badge.tone %>"><%= badge.text %></span><% end %>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="ql-preview-detail">
|
|
26
|
+
<%= safe_join([*presenter.cells(hit).drop(3).map(&:text), *presenter.flags(hit).map(&:text)].compact_blank, " · ") %>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="ql-preview-outcome"><%= render "querylane/outcome", outcome: presenter.outcome(hit) %></div>
|
|
30
|
+
</li>
|
|
31
|
+
<% end %>
|
|
32
|
+
</ul>
|
|
33
|
+
<footer class="ql-preview-foot" aria-hidden="true">
|
|
34
|
+
<span><kbd>↑↓</kbd> <%= t("querylane.group.keys.next") %></span>
|
|
35
|
+
<span><kbd>Esc</kbd> <%= t("querylane.group.keys.close") %></span>
|
|
36
|
+
</footer>
|
|
37
|
+
<% end %>
|
|
38
|
+
</section>
|
|
39
|
+
</turbo-frame>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# One record in lanes. Focusable for ↑/↓/Enter. Locals: presenter, hit, columns. %>
|
|
2
|
+
<div class="ql-record" tabindex="-1" data-querylane-record="<%= presenter.record_key(hit) %>">
|
|
3
|
+
<% presenter.cells(hit).each_with_index do |cell, index| %>
|
|
4
|
+
<% width = columns[index]&.width %>
|
|
5
|
+
<div class="ql-lane <%= "ql-cell-#{cell.style}" if cell.style %>" style="<%= "width: #{width}px" if width %>"><%= cell.text %></div>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% if presenter.status_label %>
|
|
8
|
+
<div class="ql-lane ql-lane-status">
|
|
9
|
+
<% if (badge = presenter.status(hit)) %><span class="ql-badge ql-badge-<%= badge.tone %>"><%= badge.text %></span><% end %>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
<div class="ql-lane ql-lane-flags">
|
|
13
|
+
<% presenter.flags(hit).each do |flag| %><span class="ql-flag ql-flag-<%= flag.tone %>"><%= flag.text %></span><% end %>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="ql-lane ql-lane-outcome"><%= render "querylane/outcome", outcome: presenter.outcome(hit) %></div>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<%# "More records": every record of one group the page did not show.
|
|
2
|
+
Locals: result (from querylane_related), presenter, frame. %>
|
|
3
|
+
<turbo-frame id="<%= frame %>" class="ql-related">
|
|
4
|
+
<% if result.state == :unavailable %>
|
|
5
|
+
<div class="ql-related-head" role="status"><%= t("querylane.group.unavailable") %></div>
|
|
6
|
+
<% else %>
|
|
7
|
+
<% hits = result.unshown_hits %>
|
|
8
|
+
<div class="ql-related-head"><%= t("querylane.group.all", loaded: result.hits.size, total: result.total) %></div>
|
|
9
|
+
<% if hits.empty? %>
|
|
10
|
+
<div class="ql-related-none"><%= t("querylane.group.none") %></div>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% columns = presenter.columns %>
|
|
13
|
+
<% hits.each do |hit| %>
|
|
14
|
+
<%= render "querylane/record", presenter: presenter, hit: hit, columns: columns %>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% end %>
|
|
17
|
+
</turbo-frame>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<%# The results frame: everything one search request answers.
|
|
2
|
+
Locals: result, id, info_id, per_id, first_load, rows. %>
|
|
3
|
+
<turbo-frame id="<%= id %>-results" class="ql-results" data-revision="<%= result.revision %>" data-controller="querylane--results"
|
|
4
|
+
data-action="change->querylane--results#submitForm keydown->querylane--results#keys">
|
|
5
|
+
<%# The answer's state, inside the frame (Turbo keeps the frame element's own
|
|
6
|
+
attributes): the revision answered, for host tests waiting on a search. %>
|
|
7
|
+
<span hidden data-querylane-state data-revision="<%= result.revision %>" data-options-open="<%= result.options_open? %>" data-saved-active="<%= result.saved_active? %>"></span>
|
|
8
|
+
<% if result.rewritten %>
|
|
9
|
+
<turbo-stream action="querylane_set_query" target="<%= id %>-box" data-revision="<%= result.revision %>"><template><%= result.query %></template></turbo-stream>
|
|
10
|
+
<% end %>
|
|
11
|
+
<div id="<%= id %>-error" class="ql-error" role="alert"
|
|
12
|
+
<%= tag.attributes(data: {querylane_error_range: querylane_utf16_range(result.query, result.error&.range)&.to_json}) %>>
|
|
13
|
+
<% if result.error %>
|
|
14
|
+
<strong><%= t("querylane.states.invalid") %></strong> <%= result.error.message %>
|
|
15
|
+
<% if result.error.fix %>
|
|
16
|
+
<button type="submit" form="<%= id %>-form" name="fix" value="1" class="ql-link"><%= t("querylane.states.fix") %></button>
|
|
17
|
+
<% end %>
|
|
18
|
+
<% end %>
|
|
19
|
+
</div>
|
|
20
|
+
<% if result.notice %>
|
|
21
|
+
<div class="ql-notice ql-notice-<%= result.notice %>" role="status"><%= t("querylane.saved.notice.#{result.notice}") %></div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<%= render "querylane/saved", result: result, id: id unless result.saved_searches.nil? %>
|
|
24
|
+
<%= render "querylane/chips", result: result, id: id %>
|
|
25
|
+
<%= render "querylane/options", result: result, id: id if result.options_open? %>
|
|
26
|
+
|
|
27
|
+
<div class="ql-under" <%= "inert" if result.options_open? %>>
|
|
28
|
+
<% case result.state %>
|
|
29
|
+
<% when :first_load %>
|
|
30
|
+
<%= first_load || render("querylane/first_load", definition: result.definition) %>
|
|
31
|
+
<div class="ql-footer"><div class="ql-info" <%= %(id="#{info_id}").html_safe if info_id %>><%= t("querylane.footer.showing", first: 0, last: 0, total: 0) %></div></div>
|
|
32
|
+
<% when :invalid %>
|
|
33
|
+
<% when :short %>
|
|
34
|
+
<div class="ql-state" role="status"><%= t("querylane.states.short", count: result.definition.min_text_length) %></div>
|
|
35
|
+
<% when :unavailable %>
|
|
36
|
+
<%= render "querylane/unavailable", result: result, id: id %>
|
|
37
|
+
<% when :denied %>
|
|
38
|
+
<%= render "querylane/denied", result: result, id: id %>
|
|
39
|
+
<% else %>
|
|
40
|
+
<div class="ql-layout">
|
|
41
|
+
<%= render "querylane/facets", result: result, id: id %>
|
|
42
|
+
<section class="ql-main" aria-live="polite">
|
|
43
|
+
<%= render "querylane/toolbar", result: result, id: id, per_id: per_id %>
|
|
44
|
+
<% if result.state == :empty %>
|
|
45
|
+
<%= render "querylane/empty", result: result, id: id %>
|
|
46
|
+
<% else %>
|
|
47
|
+
<div class="ql-rows"><%= rows %></div>
|
|
48
|
+
<button type="submit" form="<%= id %>-form" name="group" hidden tabindex="-1"
|
|
49
|
+
data-turbo-frame="<%= id %>-preview" data-querylane--results-target="previewButton"></button>
|
|
50
|
+
<turbo-frame id="<%= id %>-preview" class="ql-preview" data-querylane--results-target="preview"></turbo-frame>
|
|
51
|
+
<% end %>
|
|
52
|
+
<div class="ql-footer">
|
|
53
|
+
<div class="ql-info" <%= %(id="#{info_id}").html_safe if info_id %>><%= t("querylane.footer.showing", first: result.first_shown, last: result.last_shown, total: result.total) %></div>
|
|
54
|
+
<div class="ql-keys" aria-hidden="true">
|
|
55
|
+
<span><kbd>/</kbd> <%= t("querylane.footer.keys.search") %></span>
|
|
56
|
+
<span><kbd>↑↓</kbd> <%= t("querylane.footer.keys.move") %></span>
|
|
57
|
+
<span><kbd>Enter</kbd> <%= t("querylane.footer.keys.open") %></span>
|
|
58
|
+
<span><kbd>Space</kbd> <%= t("querylane.footer.keys.preview") %></span>
|
|
59
|
+
<span><kbd>Esc</kbd> <%= t("querylane.footer.keys.clear") %></span>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
</div>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
</turbo-frame>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# Grouped records drawn from a Querylane::Presenter. Locals: presenter. %>
|
|
2
|
+
<% columns = presenter.columns %>
|
|
3
|
+
<div class="ql-record ql-record-head" aria-hidden="true">
|
|
4
|
+
<% columns.each do |column| %>
|
|
5
|
+
<div class="ql-lane" style="<%= "width: #{column.width}px" if column.width %>"><%= column.label %></div>
|
|
6
|
+
<% end %>
|
|
7
|
+
<% if presenter.status_label %><div class="ql-lane ql-lane-status"><%= presenter.status_label %></div><% end %>
|
|
8
|
+
<div class="ql-lane ql-lane-flags"></div>
|
|
9
|
+
<div class="ql-lane ql-lane-outcome"><%= presenter.outcome_label %></div>
|
|
10
|
+
</div>
|
|
11
|
+
<% presenter.groups.each do |group| %>
|
|
12
|
+
<%= render "querylane/group", presenter: presenter, group: group, columns: columns %>
|
|
13
|
+
<% end %>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<%# Saved searches (M12): a native popover opened by the box's Saved button.
|
|
2
|
+
Name and Save belong to the search form; choosing one sends its id and the
|
|
3
|
+
server rewrites the box. Locals: result, id. %>
|
|
4
|
+
<div id="<%= id %>-saved" popover class="ql-saved">
|
|
5
|
+
<div class="ql-saved-head">
|
|
6
|
+
<label for="<%= id %>-saved-name"><%= t("querylane.saved.title") %></label>
|
|
7
|
+
<input type="text" id="<%= id %>-saved-name" name="saved_name" form="<%= id %>-form" maxlength="80" autocomplete="off"
|
|
8
|
+
data-action="keydown.enter->querylane--results#enter" data-querylane-enter="<%= id %>-saved-submit"
|
|
9
|
+
class="form-control form-control-sm" placeholder="<%= t("querylane.saved.name") %>" <%= "disabled" if result.save_refusal || result.query.blank? %>>
|
|
10
|
+
<button type="submit" form="<%= id %>-form" name="save_search" value="1" class="btn btn-sm btn-primary" id="<%= id %>-saved-submit"
|
|
11
|
+
<%= "disabled" if result.save_refusal || result.query.blank? %>><%= t("querylane.saved.save") %></button>
|
|
12
|
+
</div>
|
|
13
|
+
<% if (refusal = result.save_refusal) %>
|
|
14
|
+
<p class="ql-saved-refusal">
|
|
15
|
+
<strong><%= t("querylane.saved.cannot") %></strong>
|
|
16
|
+
<%= t("querylane.saved.refusal", value: refusal.value, operator: refusal.operator.downcase) %>
|
|
17
|
+
<%= refusal.rest.present? ? t("querylane.saved.refusal_rest", rest: refusal.rest) : t("querylane.saved.refusal_alone") %>
|
|
18
|
+
</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
<div class="ql-saved-heading"><%= t("querylane.saved.yours") %></div>
|
|
21
|
+
<% if result.saved_searches.empty? %>
|
|
22
|
+
<p class="ql-saved-none"><%= t("querylane.saved.none") %></p>
|
|
23
|
+
<% end %>
|
|
24
|
+
<% result.saved_searches.each do |saved| %>
|
|
25
|
+
<button type="submit" form="<%= id %>-form" name="saved" value="<%= saved.id %>" class="ql-saved-item">
|
|
26
|
+
<span class="ql-saved-name"><%= saved.name %></span>
|
|
27
|
+
<code class="ql-saved-query"><%= saved.query %></code>
|
|
28
|
+
<% if saved.used_at %><span class="ql-saved-used"><%= t("querylane.saved.used", date: l(saved.used_at.to_date, format: :short)) %></span><% end %>
|
|
29
|
+
</button>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%# A search box with suggestions for any page (M7 header, M13 palette).
|
|
2
|
+
The form posts the text for suggestions into the list frame; the hidden
|
|
3
|
+
submitters post it to the search page (full page, never in a URL).
|
|
4
|
+
Locals: id, url, search_url, mode (:header or :palette), label, placeholder,
|
|
5
|
+
trigger (palette: show its button; ⌘K / Ctrl-K always opens it). %>
|
|
6
|
+
<% palette = mode == :palette %>
|
|
7
|
+
<div class="ql-suggest ql-suggest-<%= mode %>" data-controller="querylane--suggest"
|
|
8
|
+
data-querylane--suggest-debounce-value="<%= Querylane.configuration.debounce_ms %>"
|
|
9
|
+
data-querylane--suggest-palette-value="<%= palette %>"
|
|
10
|
+
data-action="keydown@document->querylane--suggest#shortcut">
|
|
11
|
+
<% if palette && trigger %>
|
|
12
|
+
<button type="button" class="ql-suggest-trigger" data-action="querylane--suggest#open">
|
|
13
|
+
<span><%= placeholder %></span> <kbd aria-hidden="true">⌘K</kbd>
|
|
14
|
+
</button>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% if palette %>
|
|
17
|
+
<dialog class="ql-palette" aria-label="<%= label %>" data-querylane--suggest-target="dialog"
|
|
18
|
+
data-action="close->querylane--suggest#closed click->querylane--suggest#backdrop">
|
|
19
|
+
<% end %>
|
|
20
|
+
<%= form_with url: url, method: :post, id: "#{id}-form", class: "ql-suggest-form", html: {role: "search"},
|
|
21
|
+
data: {turbo: true, turbo_frame: "#{id}-list", action: "submit->querylane--suggest#submitted"} do %>
|
|
22
|
+
<%= hidden_field_tag :revision, 0, id: nil, data: {querylane__suggest_target: "revision"} %>
|
|
23
|
+
<svg class="ql-box-icon" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="8.5" cy="8.5" r="6" fill="none" stroke="currentColor" stroke-width="2"/><path d="M13 13l5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
|
24
|
+
<label class="visually-hidden" for="<%= id %>-input"><%= label %></label>
|
|
25
|
+
<input type="text" id="<%= id %>-input" name="<%= Querylane.configuration.param_name %>" class="ql-suggest-input"
|
|
26
|
+
placeholder="<%= placeholder %>" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
27
|
+
role="combobox" aria-autocomplete="list" aria-controls="<%= id %>-list" aria-expanded="false"
|
|
28
|
+
data-querylane--suggest-target="input"
|
|
29
|
+
data-action="input->querylane--suggest#typed keydown->querylane--suggest#keys focus->querylane--suggest#reopen compositionstart->querylane--suggest#compose compositionend->querylane--suggest#composed">
|
|
30
|
+
<kbd class="ql-suggest-key" aria-hidden="true"><%= palette ? "Esc" : "/" %></kbd>
|
|
31
|
+
<button type="submit" formaction="<%= search_url %>" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="searchAll"></button>
|
|
32
|
+
<button type="submit" formaction="<%= search_url %>" name="options" value="1" data-turbo="false" hidden tabindex="-1" data-querylane--suggest-target="openOptions"></button>
|
|
33
|
+
<% end %>
|
|
34
|
+
<div class="ql-suggest-pop" hidden data-querylane--suggest-target="pop">
|
|
35
|
+
<turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" aria-label="<%= label %>"
|
|
36
|
+
data-querylane--suggest-target="list" data-action="combobox-commit->querylane--suggest#commit"></turbo-frame>
|
|
37
|
+
<div class="ql-suggest-foot" aria-hidden="true">
|
|
38
|
+
<span><kbd>↑↓</kbd> <%= t("querylane.suggest.keys.move") %></span>
|
|
39
|
+
<span><kbd>Enter</kbd> <%= t("querylane.suggest.keys.open") %></span>
|
|
40
|
+
<span><kbd>Tab</kbd> <%= t("querylane.suggest.keys.complete") %></span>
|
|
41
|
+
<span><kbd>Esc</kbd> <%= t("querylane.suggest.keys.close") %></span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
<% if palette %></dialog><% end %>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<%# Suggestions: operator completions, people (groups), go-to. Options carry
|
|
2
|
+
no query text: a completion holds only its word, people their own Action.
|
|
3
|
+
Locals: result, presenter, groups, id. %>
|
|
4
|
+
<turbo-frame id="<%= id %>-list" class="ql-suggest-list" role="listbox" data-revision="<%= result.revision %>">
|
|
5
|
+
<% if result.completions.any? %>
|
|
6
|
+
<div role="group" aria-labelledby="<%= id %>-operators">
|
|
7
|
+
<div class="ql-suggest-heading" id="<%= id %>-operators"><%= t("querylane.suggest.operators") %></div>
|
|
8
|
+
<% result.completions.each_with_index do |completion, position| %>
|
|
9
|
+
<div role="option" id="<%= id %>-operator-<%= position %>" class="ql-suggest-option" data-querylane-complete="<%= completion.word %>">
|
|
10
|
+
<code class="ql-suggest-word"><%= completion.word %></code>
|
|
11
|
+
<span class="ql-suggest-detail"><%= completion.hint %></span>
|
|
12
|
+
<% if position.zero? %><span class="ql-suggest-aside"><%= t("querylane.suggest.complete") %></span><% end %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<% if groups.any? || %i[empty unavailable].include?(result.state) %>
|
|
18
|
+
<div role="group" aria-labelledby="<%= id %>-people">
|
|
19
|
+
<div class="ql-suggest-heading" id="<%= id %>-people"><%= t("querylane.suggest.people") %></div>
|
|
20
|
+
<% if result.state == :unavailable %>
|
|
21
|
+
<div class="ql-suggest-note"><%= t("querylane.suggest.unavailable") %></div>
|
|
22
|
+
<% elsif groups.empty? %>
|
|
23
|
+
<div class="ql-suggest-note"><%= t("querylane.suggest.none") %></div>
|
|
24
|
+
<% end %>
|
|
25
|
+
<% groups.each_with_index do |group, position| %>
|
|
26
|
+
<% suggestion = presenter.suggestion(group) %>
|
|
27
|
+
<div role="option" id="<%= id %>-person-<%= position %>" class="ql-suggest-option ql-suggest-person">
|
|
28
|
+
<span class="ql-suggest-title"><%= presenter.title(group) %></span>
|
|
29
|
+
<span class="ql-suggest-subtitle"><%= presenter.subtitle(group) %></span>
|
|
30
|
+
<span class="ql-suggest-detail"><%= suggestion.detail %></span>
|
|
31
|
+
<% if suggestion.badge %><span class="ql-suggest-aside ql-badge ql-badge-<%= suggestion.badge.tone %>"><%= suggestion.badge.text %></span><% end %>
|
|
32
|
+
<% if suggestion.action %><div hidden><%= render "querylane/outcome", outcome: suggestion.action %></div><% end %>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div>
|
|
36
|
+
<% end %>
|
|
37
|
+
<div role="group" aria-labelledby="<%= id %>-go">
|
|
38
|
+
<div class="ql-suggest-heading" id="<%= id %>-go"><%= t("querylane.suggest.go_to") %></div>
|
|
39
|
+
<div role="option" id="<%= id %>-search-all" class="ql-suggest-option ql-suggest-go" data-querylane-go="search">
|
|
40
|
+
<%= result.typed.present? ? t("querylane.suggest.search_all", text: result.typed) : t("querylane.suggest.search_all_empty") %>
|
|
41
|
+
<span class="ql-suggest-aside"><kbd>Enter</kbd></span>
|
|
42
|
+
</div>
|
|
43
|
+
<div role="option" id="<%= id %>-open-options" class="ql-suggest-option ql-suggest-go" data-querylane-go="options">
|
|
44
|
+
<%= t("querylane.suggest.options") %>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</turbo-frame>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<%# Count, sort, page size and pager; controls belong to the search form. Locals: result, id, per_id. %>
|
|
2
|
+
<div class="ql-toolbar">
|
|
3
|
+
<div class="ql-count">
|
|
4
|
+
<%= case result.total_kind
|
|
5
|
+
when :estimated then t("querylane.toolbar.about", count: number_with_delimiter(result.total))
|
|
6
|
+
when :capped then t("querylane.toolbar.capped", count: number_with_delimiter(result.total))
|
|
7
|
+
else t("querylane.toolbar.records", count: result.total)
|
|
8
|
+
end %>
|
|
9
|
+
<% if result.merged %><span class="ql-count-note"><%= t("querylane.toolbar.merged") %></span><% end %>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="ql-toolbar-controls">
|
|
12
|
+
<label class="ql-control"><%= t("querylane.toolbar.sort") %>
|
|
13
|
+
<select name="sort" form="<%= id %>-form" class="form-select form-select-sm">
|
|
14
|
+
<% result.definition.sorts.each do |sort| %>
|
|
15
|
+
<option value="<%= sort.key %>" <%= "selected" if sort == result.sort %>><%= sort.label %></option>
|
|
16
|
+
<% end %>
|
|
17
|
+
</select>
|
|
18
|
+
</label>
|
|
19
|
+
<label class="ql-control"><%= t("querylane.toolbar.show") %>
|
|
20
|
+
<select name="per" form="<%= id %>-form" class="form-select form-select-sm" <%= %(id="#{per_id}").html_safe if per_id %>>
|
|
21
|
+
<% result.definition.per_options.each do |per| %>
|
|
22
|
+
<option value="<%= per %>" <%= "selected" if per == result.per %>><%= per %></option>
|
|
23
|
+
<% end %>
|
|
24
|
+
</select>
|
|
25
|
+
</label>
|
|
26
|
+
<%= render "querylane/pager", result: result, id: id %>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<%# The backend did not answer: nothing was searched. Hosts override this
|
|
2
|
+
partial to add their own way round (e.g. a roster page). Locals: result, id. %>
|
|
3
|
+
<div class="ql-state ql-state-unavailable" role="status">
|
|
4
|
+
<h2><%= t("querylane.states.unavailable_title") %></h2>
|
|
5
|
+
<p><%= t("querylane.states.unavailable") %></p>
|
|
6
|
+
<button type="submit" form="<%= id %>-form" class="ql-link"><%= t("querylane.states.try_again") %></button>
|
|
7
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# The options panel's "Writes" line: what Search would put in the box, with
|
|
2
|
+
sensitive values masked. Its own frame, refreshed as fields change.
|
|
3
|
+
Locals: preview (Querylane::Preview), id. %>
|
|
4
|
+
<turbo-frame id="<%= id %>-writes" class="ql-writes" aria-live="polite">
|
|
5
|
+
<div class="ql-writes-line">
|
|
6
|
+
<span class="ql-writes-label"><%= t("querylane.options.writes") %></span>
|
|
7
|
+
<% if preview.error %>
|
|
8
|
+
<span class="ql-writes-error"><%= t("querylane.options.invalid") %> <%= preview.error.message %></span>
|
|
9
|
+
<% else %>
|
|
10
|
+
<code class="ql-writes-text"><%= preview.text %></code>
|
|
11
|
+
<% end %>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="ql-writes-kept">
|
|
14
|
+
<%= preview.kept.present? ? t("querylane.options.kept", text: preview.kept) : t("querylane.options.kept_none") %>
|
|
15
|
+
</p>
|
|
16
|
+
</turbo-frame>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# querylane's browser modules for hosts on importmap-rails (Propshaft or
|
|
4
|
+
# Sprockets). Querylane::Engine appends this file to the host's
|
|
5
|
+
# `config.importmap.paths`, so the host map gains `querylane` (index.js) and
|
|
6
|
+
# one pin per module under its own file name, extension included:
|
|
7
|
+
#
|
|
8
|
+
# querylane -> querylane/index.js
|
|
9
|
+
# querylane/overlay.js -> querylane/overlay.js
|
|
10
|
+
# querylane/controllers/box_controller.js -> querylane/controllers/box_controller.js
|
|
11
|
+
# ...
|
|
12
|
+
#
|
|
13
|
+
# The modules import each other by those names, which also resolve under a
|
|
14
|
+
# webpack or esbuild `querylane` alias, as an npm package, and in a hand-written
|
|
15
|
+
# import map with one `"querylane/": "/querylane/"` prefix entry.
|
|
16
|
+
#
|
|
17
|
+
# Nothing is preloaded: a page that imports "querylane" fetches the rest, and
|
|
18
|
+
# pages without a search box fetch nothing. A host that wants preloading pins
|
|
19
|
+
# the same names again with `preload: true`.
|
|
20
|
+
#
|
|
21
|
+
# The host pins the peers its page shares with querylane:
|
|
22
|
+
#
|
|
23
|
+
# pin "@hotwired/stimulus", to: "stimulus.min.js" # stimulus-rails
|
|
24
|
+
# pin "@github/combobox-nav"
|
|
25
|
+
#
|
|
26
|
+
# and loads Turbo 8 however it already does (turbo-rails' pin is fine):
|
|
27
|
+
# querylane never imports Turbo.
|
|
28
|
+
#
|
|
29
|
+
# The pins are listed when the map is drawn: after adding a module file, restart
|
|
30
|
+
# the server (importmap-rails redraws only when an importmap.rb changes).
|
|
31
|
+
pin "querylane", to: "querylane/index.js", preload: false
|
|
32
|
+
(Querylane::Engine.javascript_modules - ["querylane/index.js"]).each do |file|
|
|
33
|
+
pin file, to: file, preload: false
|
|
34
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
en:
|
|
2
|
+
querylane:
|
|
3
|
+
box:
|
|
4
|
+
label: "Search"
|
|
5
|
+
placeholder: "Name, last four, policy, or operators like is:active"
|
|
6
|
+
clear: "Clear the search"
|
|
7
|
+
search: "Search"
|
|
8
|
+
options: "Options"
|
|
9
|
+
options:
|
|
10
|
+
title: "Search options"
|
|
11
|
+
lead: "Filled from the box · Search writes it back"
|
|
12
|
+
writes: "Writes"
|
|
13
|
+
kept: "Kept as typed: %{text}."
|
|
14
|
+
kept_none: "Kept as typed: nothing. Parts the panel cannot show stay in the box untouched."
|
|
15
|
+
reset: "Reset fields"
|
|
16
|
+
cancel: "Cancel"
|
|
17
|
+
search: "Search"
|
|
18
|
+
held: "In the box: %{text}"
|
|
19
|
+
show: "Show"
|
|
20
|
+
hide: "Hide"
|
|
21
|
+
invalid: "The fields would write a query that cannot run:"
|
|
22
|
+
suggest:
|
|
23
|
+
label: "Search members"
|
|
24
|
+
placeholder: "Search members"
|
|
25
|
+
operators: "Operators"
|
|
26
|
+
people: "People"
|
|
27
|
+
go_to: "Go to"
|
|
28
|
+
search_all: "Search all for %{text}"
|
|
29
|
+
search_all_empty: "Open Member Search"
|
|
30
|
+
options: "Open search options"
|
|
31
|
+
complete: "Tab to complete"
|
|
32
|
+
none: "No people match yet."
|
|
33
|
+
unavailable: "People could not be loaded; Search all still works."
|
|
34
|
+
records:
|
|
35
|
+
one: "1 record"
|
|
36
|
+
other: "%{count} records"
|
|
37
|
+
keys:
|
|
38
|
+
move: "move"
|
|
39
|
+
open: "open"
|
|
40
|
+
complete: "complete"
|
|
41
|
+
close: "close"
|
|
42
|
+
saved:
|
|
43
|
+
button: "Saved"
|
|
44
|
+
title: "Save this search"
|
|
45
|
+
name: "Name"
|
|
46
|
+
save: "Save"
|
|
47
|
+
yours: "Your saved searches"
|
|
48
|
+
none: "Nothing saved yet."
|
|
49
|
+
used: "used %{date}"
|
|
50
|
+
cannot: "Cannot save:"
|
|
51
|
+
refusal: "“%{value}” is read as %{operator}. Saved searches never keep member IDs, SSNs or last fours, even when the box worked them out."
|
|
52
|
+
refusal_rest: "Remove it, or save “%{rest}” alone."
|
|
53
|
+
refusal_alone: "Remove it first."
|
|
54
|
+
notice:
|
|
55
|
+
saved: "Search saved."
|
|
56
|
+
refused: "This search was not saved: it holds a member ID, SSN or last four."
|
|
57
|
+
store_failed: "Saved searches are unavailable right now."
|
|
58
|
+
reasons:
|
|
59
|
+
more:
|
|
60
|
+
one: "+1 more"
|
|
61
|
+
other: "+%{count} more"
|
|
62
|
+
errors:
|
|
63
|
+
parked: "%{key}: is not available yet."
|
|
64
|
+
group:
|
|
65
|
+
more:
|
|
66
|
+
one: "1 more record for this person"
|
|
67
|
+
other: "%{count} more records for this person"
|
|
68
|
+
hide: "Hide"
|
|
69
|
+
all: "All records for this person, any status · %{loaded} of %{total}"
|
|
70
|
+
none: "Every record is already shown above."
|
|
71
|
+
unavailable: "These records could not be loaded. Search again."
|
|
72
|
+
close: "Close · Esc"
|
|
73
|
+
preview_count:
|
|
74
|
+
one: "1 record you can see, any status"
|
|
75
|
+
other: "%{count} records you can see, any status"
|
|
76
|
+
keys:
|
|
77
|
+
next: "next person, preview follows"
|
|
78
|
+
close: "closes"
|
|
79
|
+
chips:
|
|
80
|
+
count:
|
|
81
|
+
one: "1 character"
|
|
82
|
+
other: "%{count} characters"
|
|
83
|
+
searching_for: "Searching for"
|
|
84
|
+
remove: "Remove %{label}"
|
|
85
|
+
syntax_help: "Syntax help"
|
|
86
|
+
clear: "Clear"
|
|
87
|
+
toolbar:
|
|
88
|
+
records:
|
|
89
|
+
one: "1 record"
|
|
90
|
+
other: "%{count} records"
|
|
91
|
+
about: "about %{count} records"
|
|
92
|
+
capped: "%{count}+ records"
|
|
93
|
+
merged: "Copies of the same record are shown once on this page."
|
|
94
|
+
sort: "Sort"
|
|
95
|
+
show: "Show"
|
|
96
|
+
previous: "Previous"
|
|
97
|
+
next: "Next"
|
|
98
|
+
pages: "Result pages"
|
|
99
|
+
footer:
|
|
100
|
+
showing: "Showing %{first} to %{last} of %{total} entries"
|
|
101
|
+
keys:
|
|
102
|
+
search: "search"
|
|
103
|
+
move: "move"
|
|
104
|
+
open: "open record"
|
|
105
|
+
preview: "preview"
|
|
106
|
+
clear: "clear"
|
|
107
|
+
facets:
|
|
108
|
+
find: "Find a %{facet}…"
|
|
109
|
+
find_label: "Find a %{facet} value"
|
|
110
|
+
note: "Counts are records. Ticking more boxes in one group widens the search (OR); the counts beside unticked boxes show what they would add. Different groups narrow (AND)."
|
|
111
|
+
unavailable: "Counts unavailable"
|
|
112
|
+
help:
|
|
113
|
+
title: "Search syntax"
|
|
114
|
+
lead: "Plain words search names. Operators narrow. Esc closes."
|
|
115
|
+
words: "words"
|
|
116
|
+
masked: "masked"
|
|
117
|
+
boolean: "on operators only; terms are ANDed"
|
|
118
|
+
states:
|
|
119
|
+
short: "Type at least %{count} letters of a name, or add an operator."
|
|
120
|
+
first_load_title: "Who are you looking for?"
|
|
121
|
+
first_load: "Type what you have. The page works out whether it is a name, the last four or a policy."
|
|
122
|
+
try: "Try"
|
|
123
|
+
empty_title: "Nothing matches %{searched}."
|
|
124
|
+
widen: "Search all %{facet}"
|
|
125
|
+
drop: "Drop %{label}"
|
|
126
|
+
relax: "Allow fewer name matches"
|
|
127
|
+
empty_note: "Each button changes the search box, so the new search is visible."
|
|
128
|
+
empty: "No matching records found"
|
|
129
|
+
unavailable_title: "Search is not answering right now."
|
|
130
|
+
unavailable: "Your search was not run. Try again in a minute."
|
|
131
|
+
try_again: "Try again"
|
|
132
|
+
denied_title: "No results: your access could not be checked."
|
|
133
|
+
denied: "Search shows nothing rather than risk showing records outside your access. Reload the page; if it keeps happening, contact support."
|
|
134
|
+
invalid: "Not searched."
|
|
135
|
+
fix: "Fix it"
|
|
136
|
+
searching: "Searching…"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Querylane
|
|
4
|
+
# Parsed query nodes. Ranges are character ranges into the query text.
|
|
5
|
+
module Ast
|
|
6
|
+
# `key:value`, or a bare word / quoted phrase when key is nil.
|
|
7
|
+
Term = Data.define(:key, :value, :quoted, :range)
|
|
8
|
+
# `-term`, `NOT term`, `-(…)`.
|
|
9
|
+
Not = Data.define(:node, :range)
|
|
10
|
+
# `a OR b OR c` (binds tighter than the implicit AND).
|
|
11
|
+
Or = Data.define(:nodes, :range)
|
|
12
|
+
# `( … )`, an AND of its nodes.
|
|
13
|
+
Group = Data.define(:nodes, :range)
|
|
14
|
+
# The whole query: an AND of its nodes.
|
|
15
|
+
Root = Data.define(:nodes, :source)
|
|
16
|
+
end
|
|
17
|
+
end
|