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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/tests.yml +24 -0
  3. data/.gitignore +41 -0
  4. data/CHANGELOG.md +9 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +178 -0
  8. data/Rakefile +45 -0
  9. data/app/controllers/rails_omnibar/base_controller.rb +2 -0
  10. data/app/controllers/rails_omnibar/js_controller.rb +11 -0
  11. data/app/controllers/rails_omnibar/queries_controller.rb +7 -0
  12. data/bin/console +7 -0
  13. data/bin/setup +8 -0
  14. data/config/routes.rb +4 -0
  15. data/javascript/compiled.js +2 -0
  16. data/javascript/src/app.tsx +44 -0
  17. data/javascript/src/hooks/index.ts +5 -0
  18. data/javascript/src/hooks/use_hotkey.ts +18 -0
  19. data/javascript/src/hooks/use_item_action.tsx +20 -0
  20. data/javascript/src/hooks/use_modal.tsx +33 -0
  21. data/javascript/src/hooks/use_omnibar_extensions.ts +33 -0
  22. data/javascript/src/hooks/use_toggle_focus.ts +15 -0
  23. data/javascript/src/index.ts +1 -0
  24. data/javascript/src/types.ts +34 -0
  25. data/lib/rails_omnibar/command/base.rb +44 -0
  26. data/lib/rails_omnibar/command/search.rb +68 -0
  27. data/lib/rails_omnibar/commands.rb +28 -0
  28. data/lib/rails_omnibar/config.rb +35 -0
  29. data/lib/rails_omnibar/engine.rb +5 -0
  30. data/lib/rails_omnibar/item/base.rb +21 -0
  31. data/lib/rails_omnibar/item/help.rb +22 -0
  32. data/lib/rails_omnibar/items.rb +25 -0
  33. data/lib/rails_omnibar/rendering.rb +34 -0
  34. data/lib/rails_omnibar/version.rb +3 -0
  35. data/lib/rails_omnibar.rb +8 -0
  36. data/package.json +27 -0
  37. data/rails_omnibar.gemspec +31 -0
  38. data/spec/app_template.rb +17 -0
  39. data/spec/lib/rails_omnibar/config_spec.rb +53 -0
  40. data/spec/lib/rails_omnibar/version_spec.rb +7 -0
  41. data/spec/my_omnibar_template.rb +45 -0
  42. data/spec/rails_helper.rb +22 -0
  43. data/spec/support/factories.rb +8 -0
  44. data/spec/system/rails_omnibar_spec.rb +87 -0
  45. data/tsconfig.json +18 -0
  46. data/webpack.config.js +32 -0
  47. 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,5 @@
1
+ class RailsOmnibar
2
+ class Engine < Rails::Engine
3
+ isolate_namespace RailsOmnibar
4
+ end
5
+ 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
@@ -0,0 +1,3 @@
1
+ class RailsOmnibar
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,8 @@
1
+ require 'rails'
2
+
3
+ Dir[File.join(__dir__, 'rails_omnibar', '**', '*.rb')].sort.each { |f| require(f) }
4
+
5
+ class RailsOmnibar
6
+ singleton_class.alias_method :configure, :tap
7
+ singleton_class.undef_method :new
8
+ 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,7 @@
1
+ require 'rails_helper'
2
+
3
+ describe RailsOmnibar::VERSION do
4
+ it 'is valid' do
5
+ expect(subject).to match(/\A\d+\.\d+\.\d+\z/)
6
+ end
7
+ 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