rails_omnibar 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|