rails_omnibar 1.0.0

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