rails_omnibar 1.0.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/.github/workflows/tests.yml +24 -0
- data/.gitignore +41 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/Rakefile +45 -0
- data/app/controllers/rails_omnibar/base_controller.rb +2 -0
- data/app/controllers/rails_omnibar/js_controller.rb +11 -0
- data/app/controllers/rails_omnibar/queries_controller.rb +7 -0
- data/bin/console +7 -0
- data/bin/setup +8 -0
- data/config/routes.rb +4 -0
- data/javascript/compiled.js +2 -0
- data/javascript/src/app.tsx +44 -0
- data/javascript/src/hooks/index.ts +5 -0
- data/javascript/src/hooks/use_hotkey.ts +18 -0
- data/javascript/src/hooks/use_item_action.tsx +20 -0
- data/javascript/src/hooks/use_modal.tsx +33 -0
- data/javascript/src/hooks/use_omnibar_extensions.ts +33 -0
- data/javascript/src/hooks/use_toggle_focus.ts +15 -0
- data/javascript/src/index.ts +1 -0
- data/javascript/src/types.ts +34 -0
- data/lib/rails_omnibar/command/base.rb +44 -0
- data/lib/rails_omnibar/command/search.rb +68 -0
- data/lib/rails_omnibar/commands.rb +28 -0
- data/lib/rails_omnibar/config.rb +35 -0
- data/lib/rails_omnibar/engine.rb +5 -0
- data/lib/rails_omnibar/item/base.rb +21 -0
- data/lib/rails_omnibar/item/help.rb +22 -0
- data/lib/rails_omnibar/items.rb +25 -0
- data/lib/rails_omnibar/rendering.rb +34 -0
- data/lib/rails_omnibar/version.rb +3 -0
- data/lib/rails_omnibar.rb +8 -0
- data/package.json +27 -0
- data/rails_omnibar.gemspec +31 -0
- data/spec/app_template.rb +17 -0
- data/spec/lib/rails_omnibar/config_spec.rb +53 -0
- data/spec/lib/rails_omnibar/version_spec.rb +7 -0
- data/spec/my_omnibar_template.rb +45 -0
- data/spec/rails_helper.rb +22 -0
- data/spec/support/factories.rb +8 -0
- data/spec/system/rails_omnibar_spec.rb +87 -0
- data/tsconfig.json +18 -0
- data/webpack.config.js +32 -0
- metadata +226 -0
@@ -0,0 +1,18 @@
|
|
1
|
+
import {useCallback, useEffect} from "preact/hooks"
|
2
|
+
|
3
|
+
export const useHotkey = (hotkey: string, action: () => void) => {
|
4
|
+
const onKeyDown = useCallback(
|
5
|
+
({metaKey, ctrlKey, key}: KeyboardEvent) => {
|
6
|
+
if ((metaKey || ctrlKey) && key.toLowerCase() == hotkey) {
|
7
|
+
action()
|
8
|
+
}
|
9
|
+
},
|
10
|
+
[action, hotkey]
|
11
|
+
)
|
12
|
+
|
13
|
+
useEffect(() => {
|
14
|
+
document.addEventListener("keydown", onKeyDown)
|
15
|
+
|
16
|
+
return () => document.removeEventListener("keydown", onKeyDown)
|
17
|
+
}, [onKeyDown])
|
18
|
+
}
|
@@ -0,0 +1,20 @@
|
|
1
|
+
import React, {useCallback} from "preact/hooks"
|
2
|
+
import {Item, ModalBag} from "../types"
|
3
|
+
|
4
|
+
export const useItemAction = (modal: ModalBag) => {
|
5
|
+
return useCallback(
|
6
|
+
(item: Item) => {
|
7
|
+
if (!item) return
|
8
|
+
|
9
|
+
item = item as Item
|
10
|
+
if (item.url) {
|
11
|
+
window.location.href = item.url
|
12
|
+
} else if (item.modalHTML) {
|
13
|
+
const __html = item.modalHTML
|
14
|
+
modal.setBody(<div dangerouslySetInnerHTML={{__html}} />)
|
15
|
+
modal.toggle()
|
16
|
+
}
|
17
|
+
},
|
18
|
+
[modal]
|
19
|
+
)
|
20
|
+
}
|
@@ -0,0 +1,33 @@
|
|
1
|
+
import React, {useCallback, useMemo, useState} from "preact/hooks"
|
2
|
+
import ReactModal from "react-modal"
|
3
|
+
import {ModalBag} from "../types"
|
4
|
+
|
5
|
+
export const useModal = (staticBody?: JSX.Element): ModalBag => {
|
6
|
+
const [open, setOpen] = useState(false)
|
7
|
+
const [body, setBody] = useState(<></>)
|
8
|
+
|
9
|
+
const modal = useMemo(
|
10
|
+
() => (
|
11
|
+
<ReactModal
|
12
|
+
ariaHideApp={false}
|
13
|
+
isOpen={open}
|
14
|
+
onRequestClose={() => setOpen(false)}
|
15
|
+
style={{overlay: {zIndex: 1001}, content: {zIndex: 1001}}}
|
16
|
+
>
|
17
|
+
<span
|
18
|
+
onClick={() => setOpen(false)}
|
19
|
+
style={{cursor: "pointer", float: "right"}}
|
20
|
+
>
|
21
|
+
╳
|
22
|
+
</span>
|
23
|
+
<div style={{visibility: "hidden"}}>╳</div>
|
24
|
+
{staticBody || body}
|
25
|
+
</ReactModal>
|
26
|
+
),
|
27
|
+
[body, open, setOpen, staticBody]
|
28
|
+
)
|
29
|
+
|
30
|
+
const toggle = useCallback(() => setOpen(!open), [setOpen, open])
|
31
|
+
|
32
|
+
return {modal, setBody, toggle}
|
33
|
+
}
|
@@ -0,0 +1,33 @@
|
|
1
|
+
import {useMemo} from "preact/hooks"
|
2
|
+
import {AppArgs, Item} from "../types"
|
3
|
+
import fuzzysort from "fuzzysort"
|
4
|
+
import {tryCalculate} from "yaam"
|
5
|
+
|
6
|
+
export const useOmnibarExtensions = (args: AppArgs) => {
|
7
|
+
return useMemo(() => [commands(args), items(args), calculator(args)], [args])
|
8
|
+
}
|
9
|
+
|
10
|
+
const commands = ({commandPattern: p, queryPath}: AppArgs) => {
|
11
|
+
const commandRegexp = useMemo(() => p && new RegExp(p.source, p.options), [p])
|
12
|
+
|
13
|
+
return async (q: string) => {
|
14
|
+
if (!queryPath || !commandRegexp?.test(q)) return []
|
15
|
+
|
16
|
+
const response = await fetch(`${queryPath}&q=${q}`)
|
17
|
+
return await response.json()
|
18
|
+
}
|
19
|
+
}
|
20
|
+
|
21
|
+
const items = ({items}: AppArgs) => {
|
22
|
+
return (q: string) => fuzzysort.go(q, items, {key: "title"}).map((r) => r.obj)
|
23
|
+
}
|
24
|
+
|
25
|
+
const calculator = ({calculator}: AppArgs) => {
|
26
|
+
return (q: string) => {
|
27
|
+
const result = calculator ? tryCalculate(q) : null
|
28
|
+
if (result !== null) {
|
29
|
+
return [{title: String(result), type: "default" as Item["type"]}]
|
30
|
+
}
|
31
|
+
return new Array<Item>()
|
32
|
+
}
|
33
|
+
}
|
@@ -0,0 +1,15 @@
|
|
1
|
+
import {useCallback} from "preact/hooks"
|
2
|
+
import {INPUT_DATA_ID} from "../types"
|
3
|
+
|
4
|
+
export const useToggleFocus = () => {
|
5
|
+
return useCallback(() => {
|
6
|
+
setTimeout(() => {
|
7
|
+
const input = document.querySelector(`[data-id=${INPUT_DATA_ID}]`)
|
8
|
+
if (input && document.activeElement === input) {
|
9
|
+
;(input as HTMLInputElement).blur()
|
10
|
+
} else if (input) {
|
11
|
+
;(input as HTMLInputElement).focus()
|
12
|
+
}
|
13
|
+
}, 100)
|
14
|
+
}, [])
|
15
|
+
}
|
@@ -0,0 +1 @@
|
|
1
|
+
export * from './app'
|
@@ -0,0 +1,34 @@
|
|
1
|
+
export const INPUT_DATA_ID = "rails-omnibar"
|
2
|
+
|
3
|
+
export type AppArgs = {
|
4
|
+
calculator: boolean
|
5
|
+
commandPattern: JsRegex
|
6
|
+
hotkey: string
|
7
|
+
items: Array<Item>
|
8
|
+
maxResults: number
|
9
|
+
modal: boolean
|
10
|
+
placeholder?: string
|
11
|
+
queryPath?: string
|
12
|
+
}
|
13
|
+
|
14
|
+
export type Item = {
|
15
|
+
title: string
|
16
|
+
url?: string
|
17
|
+
modalHTML?: string
|
18
|
+
type: "default" | "help"
|
19
|
+
}
|
20
|
+
|
21
|
+
export type JsRegex = {
|
22
|
+
source: string
|
23
|
+
options: string
|
24
|
+
}
|
25
|
+
|
26
|
+
export type ModalBag = {
|
27
|
+
modal: JSX.Element
|
28
|
+
setBody: (v: JSX.Element) => void
|
29
|
+
toggle: () => void
|
30
|
+
}
|
31
|
+
|
32
|
+
export type ModalArg = {
|
33
|
+
itemModal: ModalBag
|
34
|
+
}
|
@@ -0,0 +1,44 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
module Command
|
3
|
+
class Base
|
4
|
+
attr_reader :pattern, :resolver, :description, :example
|
5
|
+
|
6
|
+
def initialize(pattern:, resolver:, description: nil, example: nil)
|
7
|
+
@pattern = cast_to_pattern(pattern)
|
8
|
+
@resolver = cast_to_proc(resolver, 2)
|
9
|
+
@description = description
|
10
|
+
@example = example
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(input, omnibar = nil)
|
14
|
+
match = pattern.match(input)
|
15
|
+
# look at match for highest capturing group or whole pattern
|
16
|
+
value = match.to_a.last || raise(ArgumentError, 'input !~ pattern')
|
17
|
+
results = resolver.call(value, omnibar)
|
18
|
+
results = results.try(:to_ary) || [results]
|
19
|
+
results.map { |e| RailsOmnibar.cast_to_item(e) }
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def cast_to_pattern(arg)
|
25
|
+
regexp =
|
26
|
+
case arg
|
27
|
+
when Regexp then arg
|
28
|
+
when String then /^#{Regexp.escape(arg)}.+/
|
29
|
+
when NilClass then /.+/
|
30
|
+
else raise ArgumentError, "pattern can't be a #{arg.class}"
|
31
|
+
end
|
32
|
+
|
33
|
+
regexp
|
34
|
+
end
|
35
|
+
|
36
|
+
def cast_to_proc(arg, arity)
|
37
|
+
arg = arg.is_a?(Proc) ? arg : arg.method(:call).to_proc
|
38
|
+
arg.is_a?(Proc) && arg.arity == arity ||
|
39
|
+
raise(ArgumentError, "arg must be a Proc with arity #{arity}")
|
40
|
+
arg
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.add_search(**kwargs)
|
3
|
+
add_command Command::Search.new(**kwargs)
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.add_record_search(**kwargs)
|
7
|
+
add_command Command::RecordSearch.new(**kwargs)
|
8
|
+
end
|
9
|
+
|
10
|
+
module Command
|
11
|
+
# Generic search.
|
12
|
+
class Search < Base
|
13
|
+
def initialize(finder:, itemizer:, **kwargs)
|
14
|
+
finder = cast_to_proc(finder, 1)
|
15
|
+
itemizer = cast_to_proc(itemizer, 1)
|
16
|
+
resolver = ->(value, omnibar) do
|
17
|
+
findings = finder.call(value)
|
18
|
+
findings = Array(findings) unless findings.respond_to?(:first)
|
19
|
+
findings.first(omnibar.max_results).map(&itemizer)
|
20
|
+
end
|
21
|
+
|
22
|
+
super(resolver: resolver, **kwargs)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# ActiveRecord-specific search.
|
27
|
+
class RecordSearch < Search
|
28
|
+
def initialize(model:, columns: :id, pattern: nil, finder: nil, itemizer: nil, example: nil)
|
29
|
+
# casting and validations
|
30
|
+
model = model.to_s.classify.constantize unless model.is_a?(Class)
|
31
|
+
model < ActiveRecord::Base || raise(ArgumentError, 'model: must be a model')
|
32
|
+
columns = Array(columns).map(&:to_s)
|
33
|
+
columns.present? || raise(ArgumentError, 'columns: must be given')
|
34
|
+
columns.each { |c| c.in?(model.column_names) || raise(ArgumentError, "bad column #{c}") }
|
35
|
+
|
36
|
+
# default finder, uses LIKE/ILIKE for non-id columns
|
37
|
+
finder ||= ->(q) do
|
38
|
+
return model.none if q.blank?
|
39
|
+
|
40
|
+
columns.inject(model.none) do |rel, col|
|
41
|
+
rel.or(col =~ /id$/ ? model.where(col => q) :
|
42
|
+
model.where("#{col} #{RailsOmnibar.like} ?", "%#{q}%"))
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# default itemizer
|
47
|
+
itemizer ||= ->(record) do
|
48
|
+
{
|
49
|
+
title: "#{record.class.name}##{record.id}",
|
50
|
+
url: "/#{model.model_name.route_key}/#{record.to_param}",
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
super(
|
55
|
+
description: "Find #{model.name} by #{columns.join(' OR ')}".tr('_', ' '),
|
56
|
+
example: example,
|
57
|
+
pattern: pattern,
|
58
|
+
finder: finder,
|
59
|
+
itemizer: itemizer,
|
60
|
+
)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.like
|
66
|
+
@like ||= ActiveRecord::Base.connection.adapter_name =~ /^post|pg/i ? 'ILIKE' : 'LIKE'
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.handle(input)
|
3
|
+
commands.find { |h| h.pattern.match?(input) }&.then { |h| h.call(input, self) } || []
|
4
|
+
end
|
5
|
+
|
6
|
+
def self.command_pattern
|
7
|
+
commands.any? ? Regexp.union(commands.map(&:pattern)) : /$NO_COMMANDS/
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.add_command(command)
|
11
|
+
commands << RailsOmnibar.cast_to_command(command)
|
12
|
+
clear_cache
|
13
|
+
self
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.cast_to_command(arg)
|
17
|
+
case arg
|
18
|
+
when Command::Base then arg
|
19
|
+
when Hash then Command::Base.new(**arg)
|
20
|
+
else raise(ArgumentError, "expected command, got #{arg.class}")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private_class_method\
|
25
|
+
def self.commands
|
26
|
+
@commands ||= []
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.max_results=(arg)
|
3
|
+
arg.is_a?(Integer) && arg > 0 || raise(ArgumentError, 'max_results must be > 0')
|
4
|
+
@max_results = arg
|
5
|
+
end
|
6
|
+
def self.max_results
|
7
|
+
@max_results || 10
|
8
|
+
end
|
9
|
+
|
10
|
+
singleton_class.attr_writer :modal
|
11
|
+
def self.modal?
|
12
|
+
instance_variable_defined?(:@modal) ? !!@modal : false
|
13
|
+
end
|
14
|
+
|
15
|
+
singleton_class.attr_writer :calculator
|
16
|
+
def self.calculator?
|
17
|
+
instance_variable_defined?(:@calculator) ? !!@calculator : true
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.hotkey
|
21
|
+
@hotkey || 'k'
|
22
|
+
end
|
23
|
+
def self.hotkey=(arg)
|
24
|
+
arg.to_s.size == 1 || raise(ArgumentError, 'hotkey must have length 1')
|
25
|
+
@hotkey = arg.to_s.downcase
|
26
|
+
end
|
27
|
+
|
28
|
+
singleton_class.attr_writer :placeholder
|
29
|
+
def self.placeholder
|
30
|
+
return @placeholder.presence unless @placeholder.nil?
|
31
|
+
|
32
|
+
help_item = items.find { |i| i.type == :help }
|
33
|
+
help_item && "Hint: Type `#{help_item.title}` for help"
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
module Item
|
3
|
+
class Base
|
4
|
+
attr_reader :title, :url, :modal_html, :type
|
5
|
+
|
6
|
+
def initialize(title:, url: nil, modal_html: nil, type: :default)
|
7
|
+
title.class.in?([String, Symbol]) && title.present? || raise(ArgumentError, 'title: must be a String')
|
8
|
+
url.present? && modal_html.present? && raise(ArgumentError, 'use EITHER url: OR modal_html:')
|
9
|
+
|
10
|
+
@title = title
|
11
|
+
@url = url
|
12
|
+
@modal_html = modal_html
|
13
|
+
@type = type
|
14
|
+
end
|
15
|
+
|
16
|
+
def as_json(*)
|
17
|
+
{ title: title, url: url, modalHTML: modal_html, type: type }
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.add_help(**kwargs)
|
3
|
+
add_item Item::Help.new(for_commands: commands, **kwargs)
|
4
|
+
self.class
|
5
|
+
end
|
6
|
+
|
7
|
+
module Item
|
8
|
+
class Help < Base
|
9
|
+
def initialize(title: '? - Help', for_commands:, custom_content: nil)
|
10
|
+
super title: title, type: :help, modal_html: <<~HTML
|
11
|
+
<span>Available actions:<span>
|
12
|
+
<ul>
|
13
|
+
#{custom_content&.then { |c| "<li>#{c}</li>" } }
|
14
|
+
#{for_commands.map do |h|
|
15
|
+
"<li><b>#{h.description}</b><br>Example: `#{h.example}`</li>"
|
16
|
+
end.join}
|
17
|
+
</ul>
|
18
|
+
HTML
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.add_item(item)
|
3
|
+
items << RailsOmnibar.cast_to_item(item)
|
4
|
+
clear_cache
|
5
|
+
self.class
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.add_items(*args)
|
9
|
+
args.each { |arg| add_item(arg) }
|
10
|
+
self.class
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.cast_to_item(arg)
|
14
|
+
case arg
|
15
|
+
when Item::Base then arg
|
16
|
+
when Hash then Item::Base.new(**arg)
|
17
|
+
else raise(ArgumentError, "expected Item, got #{arg.class}")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private_class_method\
|
22
|
+
def self.items
|
23
|
+
@items ||= []
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class RailsOmnibar
|
2
|
+
def self.render
|
3
|
+
@cached_html ||= <<~HTML.html_safe
|
4
|
+
<script src='#{urls.js_path}?v=#{RailsOmnibar::VERSION}' type='text/javascript'></script>
|
5
|
+
<div id='mount-rails-omnibar'>
|
6
|
+
<script type="application/json">#{to_json}</script>
|
7
|
+
</div>
|
8
|
+
HTML
|
9
|
+
end
|
10
|
+
|
11
|
+
require 'js_regex'
|
12
|
+
|
13
|
+
def self.as_json(*)
|
14
|
+
{
|
15
|
+
calculator: calculator?,
|
16
|
+
commandPattern: JsRegex.new!(command_pattern, target: 'ES2018'),
|
17
|
+
hotkey: hotkey,
|
18
|
+
items: items,
|
19
|
+
maxResults: max_results,
|
20
|
+
modal: modal?,
|
21
|
+
placeholder: placeholder,
|
22
|
+
queryPath: urls.query_path(omnibar_class: self),
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.urls
|
27
|
+
@urls ||= RailsOmnibar::Engine.routes.url_helpers
|
28
|
+
end
|
29
|
+
|
30
|
+
private_class_method\
|
31
|
+
def self.clear_cache
|
32
|
+
@cached_html = nil
|
33
|
+
end
|
34
|
+
end
|
data/package.json
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
{
|
2
|
+
"scripts": {
|
3
|
+
"compile": "npx webpack",
|
4
|
+
"test": "bundle exec rake"
|
5
|
+
},
|
6
|
+
"dependencies": {
|
7
|
+
"fuzzysort": "^2.0.4",
|
8
|
+
"omnibar2": "^2.4.0",
|
9
|
+
"preact": "^10.11.3",
|
10
|
+
"preact-habitat": "^3.3.0",
|
11
|
+
"react-modal": "^3.16.1",
|
12
|
+
"yaam": "^1.1.1"
|
13
|
+
},
|
14
|
+
"devDependencies": {
|
15
|
+
"@babel/cli": "^7.17.6",
|
16
|
+
"@babel/core": "^7.17.8",
|
17
|
+
"@babel/preset-env": "^7.16.11",
|
18
|
+
"@babel/preset-react": "^7.16.7",
|
19
|
+
"@babel/preset-typescript": "^7.18.6",
|
20
|
+
"@types/react-modal": "^3.13.1",
|
21
|
+
"babel-loader": "^8.2.4",
|
22
|
+
"ts-loader": "^9.4.2",
|
23
|
+
"tslib": "^2.4.1",
|
24
|
+
"webpack": "^5.70.0",
|
25
|
+
"webpack-cli": "^4.9.2"
|
26
|
+
}
|
27
|
+
}
|
@@ -0,0 +1,31 @@
|
|
1
|
+
lib = File.expand_path('lib', __dir__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
|
4
|
+
require_relative 'lib/rails_omnibar/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'rails_omnibar'
|
8
|
+
spec.version = RailsOmnibar::VERSION
|
9
|
+
spec.authors = ['Janosch Müller']
|
10
|
+
spec.summary = 'Omnibar for Rails'
|
11
|
+
spec.description = 'Omnibar for Rails'
|
12
|
+
spec.homepage = 'https://github.com/jaynetics/rails_omnibar'
|
13
|
+
spec.license = 'MIT'
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0") + %w[javascript/compiled.js]
|
16
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
17
|
+
spec.require_paths = ['lib']
|
18
|
+
|
19
|
+
spec.required_ruby_version = '>= 2.7'
|
20
|
+
|
21
|
+
spec.add_dependency 'js_regex'
|
22
|
+
spec.add_dependency 'rails', ['>= 6.0', '< 8.0']
|
23
|
+
|
24
|
+
spec.add_development_dependency 'capybara', '~> 3.0'
|
25
|
+
spec.add_development_dependency 'factory_bot_rails', '~> 6.0'
|
26
|
+
spec.add_development_dependency 'puma', '~> 5.0'
|
27
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
28
|
+
spec.add_development_dependency 'rspec-rails', '~> 5.0'
|
29
|
+
spec.add_development_dependency 'sqlite3', '>= 1.3.6'
|
30
|
+
spec.add_development_dependency 'webdrivers', '~> 5.0'
|
31
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
gem 'rails_omnibar', path: __dir__ + '/../'
|
2
|
+
|
3
|
+
generate 'model', 'User first_name:string last_name:string admin:boolean --no-test-framework'
|
4
|
+
|
5
|
+
file 'app/lib/my_omnibar.rb', File.read(__dir__ + '/my_omnibar_template.rb')
|
6
|
+
|
7
|
+
inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', <<-RUBY
|
8
|
+
def index
|
9
|
+
render html: MyOmnibar.render
|
10
|
+
end
|
11
|
+
RUBY
|
12
|
+
|
13
|
+
route 'mount RailsOmnibar::Engine => "/rails_omnibar"'
|
14
|
+
route 'root "application#index"'
|
15
|
+
route 'get "users/(*path)" => "application#index"'
|
16
|
+
|
17
|
+
rake 'db:migrate db:test:prepare'
|
@@ -0,0 +1,53 @@
|
|
1
|
+
require 'rails_helper'
|
2
|
+
|
3
|
+
describe RailsOmnibar do
|
4
|
+
subject { Class.new(RailsOmnibar) }
|
5
|
+
|
6
|
+
it 'has a configurable max_results' do
|
7
|
+
expect(subject.max_results).to eq 10
|
8
|
+
subject.max_results = 5
|
9
|
+
expect(subject.max_results).to eq 5
|
10
|
+
expect { subject.max_results = 0 }.to raise_error(ArgumentError)
|
11
|
+
expect { subject.max_results = '5' }.to raise_error(ArgumentError)
|
12
|
+
expect { subject.max_results = 5.0 }.to raise_error(ArgumentError)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'has a configurable hotkey' do
|
16
|
+
expect(subject.hotkey).to eq 'k'
|
17
|
+
subject.hotkey = 'z'
|
18
|
+
expect(subject.hotkey).to eq 'z'
|
19
|
+
subject.hotkey = 'K' # should be downcased
|
20
|
+
expect(subject.hotkey).to eq 'k'
|
21
|
+
expect { subject.hotkey = '' }.to raise_error(ArgumentError)
|
22
|
+
expect { subject.hotkey = 'kk' }.to raise_error(ArgumentError)
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'has a configurable rendering' do
|
26
|
+
expect(subject.modal?).to eq false
|
27
|
+
subject.modal = true
|
28
|
+
expect(subject.modal?).to eq true
|
29
|
+
subject.modal = false
|
30
|
+
expect(subject.modal?).to eq false
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'has a configurable calculator' do
|
34
|
+
expect(subject.calculator?).to eq true
|
35
|
+
subject.calculator = false
|
36
|
+
expect(subject.calculator?).to eq false
|
37
|
+
subject.calculator = true
|
38
|
+
expect(subject.calculator?).to eq true
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'has a configurable placeholder' do
|
42
|
+
expect(subject.placeholder).to eq nil
|
43
|
+
subject.placeholder = 'foo'
|
44
|
+
expect(subject.placeholder).to eq 'foo'
|
45
|
+
# falls back to help item hint if help item exists
|
46
|
+
subject.add_help(title: 'REEE')
|
47
|
+
expect(subject.placeholder).to eq 'foo'
|
48
|
+
subject.placeholder = nil
|
49
|
+
expect(subject.placeholder).to eq 'Hint: Type `REEE` for help'
|
50
|
+
subject.placeholder = false
|
51
|
+
expect(subject.placeholder).to eq nil
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
MyOmnibar = RailsOmnibar.configure do |c|
|
2
|
+
c.modal = true
|
3
|
+
|
4
|
+
c.add_item(title: 'important URL', url: 'https://www.disney.com')
|
5
|
+
c.add_item(title: 'boring URL', url: 'https://www.github.com')
|
6
|
+
|
7
|
+
c.add_record_search(pattern: /^u(\d+)/, model: User, example: 'u123')
|
8
|
+
|
9
|
+
c.add_record_search(pattern: /^u (.+)/, model: User, columns: %i[first_name last_name],
|
10
|
+
example: 'u Joe')
|
11
|
+
|
12
|
+
c.add_record_search(
|
13
|
+
pattern: /^U(\d+)/,
|
14
|
+
example: 'U123',
|
15
|
+
model: User,
|
16
|
+
finder: ->(id) { User.where(admin: true, id: input[1..]) },
|
17
|
+
itemizer: ->(user) { { title: "Admin #{user.name}", url: admin_url(user) } }
|
18
|
+
)
|
19
|
+
|
20
|
+
c.add_search(
|
21
|
+
pattern: /^g (.+)/,
|
22
|
+
example: 'g kittens',
|
23
|
+
finder: ->(input) { [:fake_result_1, :fake_result_2, :fake_result_3] },
|
24
|
+
itemizer: ->(entry) { { title: entry, url: "/#{entry}" } },
|
25
|
+
description: 'Google',
|
26
|
+
)
|
27
|
+
|
28
|
+
c.add_command(
|
29
|
+
description: 'Get count of a DB table',
|
30
|
+
pattern: /COUNT (.+)/i,
|
31
|
+
example: 'COUNT users',
|
32
|
+
resolver: ->(value, _omnibar) do
|
33
|
+
{ title: value.classify.constantize.count.to_s }
|
34
|
+
rescue => e
|
35
|
+
{ title: e.message }
|
36
|
+
end,
|
37
|
+
)
|
38
|
+
|
39
|
+
c.add_help
|
40
|
+
|
41
|
+
# Use a hotkey that is the same in most keyboard layouts to work around
|
42
|
+
# https://bugs.chromium.org/p/chromedriver/issues/detail?id=553
|
43
|
+
# (This is only relevant for testing with chromedriver.)
|
44
|
+
c.hotkey = 'm'
|
45
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
ENV['RAILS_ENV'] ||= 'test'
|
2
|
+
require File.expand_path('dummy/config/environment', __dir__)
|
3
|
+
require 'rspec/rails'
|
4
|
+
|
5
|
+
require 'selenium/webdriver'
|
6
|
+
require 'webdrivers/chromedriver'
|
7
|
+
|
8
|
+
Capybara.server = :puma, { Silent: true }
|
9
|
+
|
10
|
+
Dir['spec/support/**/*.rb'].each { |f| require File.expand_path(f) }
|
11
|
+
|
12
|
+
RSpec.configure do |config|
|
13
|
+
config.use_transactional_fixtures = true
|
14
|
+
config.infer_spec_type_from_file_location!
|
15
|
+
config.before(:each, type: :system) do
|
16
|
+
driven_by :selenium_chrome_headless
|
17
|
+
end
|
18
|
+
config.after(:each, type: :system) do
|
19
|
+
logs = page.driver.browser.logs.get(:browser)
|
20
|
+
logs.empty? || raise("JS errors: #{logs.map(&:message)}")
|
21
|
+
end
|
22
|
+
end
|