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
         |