wallflower 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ba9a1d426696f5a091d18e4266c1ef2e5dfcb8166e110b3e143e3417fa055af7
4
+ data.tar.gz: '09abd80d4d1f08ec0a38bbe7ef19b5ae75601631da08edc3125cb6027a987097'
5
+ SHA512:
6
+ metadata.gz: dc04356f627dcee3e1181778f479a49762ef5b72444238df923dbb37e8a71f710d1327eeffc0020fbe84e447c08088baac6e0c3b2e8f8b995ee147b1ab26035b
7
+ data.tar.gz: 8e98021b01854edc0548b4f8f73eaff8759c97bd572bfadf951d685cc169fe3d7cfbfe71bb441619d68da7a7e03e55ac3a3311c39386a632f43b6a48d180875d
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Tyler Schneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # Wallflower
2
+
3
+ Background task tracking for Rails apps: a person starts long-running work, follows its status and progress, and gets its result when it finishes.
4
+
5
+ ## Installation
6
+
7
+ Add the gem and run its install generator, which copies a migration for its tasks table:
8
+
9
+ ```ruby
10
+ gem "wallflower"
11
+ ```
12
+
13
+ ```bash
14
+ bundle install
15
+ bin/rails generate wallflower:install
16
+ bin/rails db:migrate
17
+ ```
18
+
19
+ Mount the engine at the path its pages should live under:
20
+
21
+ ```ruby
22
+ mount Wallflower::Engine => "/background"
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Every setting has a default, so set only what differs in your app:
28
+
29
+ ```ruby
30
+ Wallflower.configure do |config|
31
+ config.authentication_method = :authenticate_user! # runs before every Wallflower page
32
+ config.current_person_method = :current_user # returns the person who starts and views tasks
33
+ config.current_account_method = nil # returns the account a task belongs to, when there is one
34
+ config.layout = "application" # the layout Wallflower's pages are drawn in
35
+ end
36
+ ```
37
+
38
+ ## Kinds and runners
39
+
40
+ A kind is a type of task the app offers, such as an export. Register each one in an initializer with its key, its title and the name of its runner class:
41
+
42
+ ```ruby
43
+ Wallflower.register_kind :export_transactions, title: "Export transactions", runner: "ExportTransactionsRunner"
44
+ ```
45
+
46
+ An app whose registered runner class does not exist fails to boot, with a message naming the class.
47
+
48
+ A runner is a plain class whose `call` receives the task. The task carries the `params`, `person` and `account` it was started with, and the runner reports progress on it while it works:
49
+
50
+ ```ruby
51
+ class ExportTransactionsRunner
52
+ def call(task)
53
+ rows = Transaction.where(month: task.params["month"])
54
+ task.set_total(rows.count)
55
+ rows.find_each { |row| export(row); task.advance }
56
+ end
57
+ end
58
+ ```
59
+
60
+ A runner that produces a file attaches it as the task's result:
61
+
62
+ ```ruby
63
+ task.attach_result(io: StringIO.new(csv), filename: "transactions.csv")
64
+ ```
65
+
66
+ A finished task's page then offers a Download button. The file is sent through Wallflower's own route, which gives it only to the person who started the task and answers anyone else with not found.
67
+
68
+ ## Starting a task
69
+
70
+ ```ruby
71
+ task = Wallflower.start(kind: :export_transactions, person: current_user, account: current_account, params: { "month" => "2026-10" })
72
+ ```
73
+
74
+ The task is returned queued and its job is enqueued. When the job runs, the task is marked running, its runner is called, and when the runner returns the task is marked finished with the time it finished.
75
+
76
+ ## The task page
77
+
78
+ A task's page lives at `tasks/:id` under the engine's mount path. The person who started the task sees its kind's title, its status and, once the runner has set a total, a progress bar of done against total. The page listens for changes to its task over Turbo Streams, so its status and progress update without a reload, in a browser and in the Hotwire Native iOS app alike. Anyone else, and the same person viewing from another account, gets a not-found response.
79
+
80
+ Wallflower's pages are drawn with keystone_ui, and live updates need Action Cable and turbo-rails, which the host's layout already loads for Turbo Streams.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class ApplicationController < ::ApplicationController
5
+ before_action { send(Wallflower.configuration.authentication_method) }
6
+
7
+ layout -> { Wallflower.configuration.layout }
8
+
9
+ helper KeystoneUiHelper
10
+
11
+ private
12
+
13
+ def wallflower_person
14
+ send(Wallflower.configuration.current_person_method)
15
+ end
16
+
17
+ def wallflower_account
18
+ send(Wallflower.configuration.current_account_method)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class TasksController < ApplicationController
5
+ def index
6
+ end
7
+
8
+ def show
9
+ @task = visible_tasks.find_by(id: params[:id])
10
+ head :not_found unless @task
11
+ end
12
+
13
+ def download
14
+ task = visible_tasks.find_by(id: params[:id])
15
+ return head :not_found unless task&.result_file&.attached?
16
+
17
+ send_data task.result_file.download, filename: task.result_file.filename.to_s, type: task.result_file.content_type
18
+ end
19
+
20
+ private
21
+
22
+ def visible_tasks
23
+ tasks = Task.where(person: wallflower_person)
24
+ Wallflower.configuration.current_account_method ? tasks.where(account: wallflower_account) : tasks
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class RunJob < ActiveJob::Base
5
+ def perform(task)
6
+ task.update!(status: "running")
7
+ Wallflower.kind(task.kind).runner.constantize.new.call(task)
8
+ task.update!(status: "finished", finished_at: Time.current)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class Task < ApplicationRecord
5
+ self.table_name = "wallflower_tasks"
6
+
7
+ belongs_to :person, polymorphic: true
8
+ belongs_to :account, polymorphic: true, optional: true
9
+
10
+ has_one_attached :result_file
11
+
12
+ after_update_commit do
13
+ broadcast_replace_to self, target: ActionView::RecordIdentifier.dom_id(self, :live), partial: "wallflower/tasks/live", locals: { task: self }
14
+ end
15
+
16
+ def kind_title
17
+ Wallflower.kind(kind).title
18
+ end
19
+
20
+ def attach_result(io:, filename:)
21
+ result_file.attach(io: io, filename: filename)
22
+ end
23
+
24
+ def set_total(total)
25
+ update!(total: total)
26
+ end
27
+
28
+ def advance(by = 1)
29
+ increment(:done, by).save!
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ <div id="<%= dom_id(task, :live) %>">
2
+ <p id="<%= dom_id(task, :status) %>"><%= task.status.humanize %></p>
3
+ <div id="<%= dom_id(task, :progress) %>">
4
+ <% if task.total %>
5
+ <%= render Keystone::Ui::ProgressComponent.new(value: task.done, max: task.total, label: "#{task.done} of #{task.total}") %>
6
+ <% end %>
7
+ </div>
8
+ </div>
@@ -0,0 +1 @@
1
+ <h1>Tasks</h1>
@@ -0,0 +1,8 @@
1
+ <%= turbo_stream_from @task %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <h1><%= @task.kind_title %></h1>
4
+ <%= render "wallflower/tasks/live", task: @task %>
5
+ <% if @task.status == "finished" && @task.result_file.attached? %>
6
+ <%= ui_button(label: "Download", href: download_task_path(@task), variant: :secondary) %>
7
+ <% end %>
8
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Wallflower::Engine.routes.draw do
2
+ root "tasks#index"
3
+ resources :tasks, only: :show do
4
+ get :download, on: :member
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Wallflower
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Installs Wallflower: copies its migration."
14
+
15
+ def copy_migration
16
+ migration_template "create_wallflower_tasks.rb.erb", "db/migrate/create_wallflower_tasks.rb"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ class CreateWallflowerTasks < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :wallflower_tasks do |t|
4
+ t.string :kind, null: false
5
+ t.string :status, null: false, default: "queued"
6
+ t.json :params, null: false, default: {}
7
+ t.integer :total
8
+ t.integer :done, null: false, default: 0
9
+ t.datetime :finished_at
10
+ t.references :person, polymorphic: true, null: false
11
+ t.references :account, polymorphic: true, null: true
12
+ t.timestamps
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class Configuration
5
+ attr_accessor :authentication_method, :layout, :current_person_method, :current_account_method
6
+
7
+ def initialize
8
+ @authentication_method = :authenticate_user!
9
+ @layout = "application"
10
+ @current_person_method = :current_user
11
+ end
12
+ end
13
+
14
+ def self.configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def self.configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def self.reset_configuration!
23
+ @configuration = Configuration.new
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Wallflower
6
+
7
+ config.after_initialize { Wallflower.check_kinds! }
8
+ end
9
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ Kind = Struct.new(:key, :title, :runner, keyword_init: true)
5
+
6
+ class MissingRunner < StandardError; end
7
+
8
+ def self.register_kind(key, title:, runner:)
9
+ kinds[key.to_sym] = Kind.new(key: key.to_sym, title: title, runner: runner.to_s)
10
+ end
11
+
12
+ def self.kind(key)
13
+ kinds.fetch(key.to_sym)
14
+ end
15
+
16
+ def self.kinds
17
+ @kinds ||= {}
18
+ end
19
+
20
+ def self.check_kinds!
21
+ kinds.each_value do |kind|
22
+ next if kind.runner.safe_constantize
23
+
24
+ raise MissingRunner, "Wallflower kind #{kind.key} names the runner #{kind.runner}, which does not exist."
25
+ end
26
+ end
27
+
28
+ def self.reset_kinds!
29
+ @kinds = {}
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wallflower
4
+ VERSION = "0.1.0"
5
+ end
data/lib/wallflower.rb ADDED
@@ -0,0 +1,12 @@
1
+ require "keystone_ui"
2
+ require "turbo-rails"
3
+ require "wallflower/version"
4
+ require "wallflower/configuration"
5
+ require "wallflower/kinds"
6
+ require "wallflower/engine"
7
+
8
+ module Wallflower
9
+ def self.start(kind:, person:, account: nil, params: {})
10
+ Task.create!(kind: kind.to_s, person: person, account: account, params: params).tap { |task| RunJob.perform_later(task) }
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wallflower
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Schneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: keystone_ui
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.37.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.37.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: turbo-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activerecord
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: activesupport
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '7.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '7.0'
82
+ description: A Rails engine that lets a person start long-running work, follow its
83
+ status and progress, and get its result when it finishes.
84
+ email:
85
+ - tylercschneider@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - MIT-LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - app/controllers/wallflower/application_controller.rb
94
+ - app/controllers/wallflower/tasks_controller.rb
95
+ - app/jobs/wallflower/run_job.rb
96
+ - app/models/wallflower/application_record.rb
97
+ - app/models/wallflower/task.rb
98
+ - app/views/wallflower/tasks/_live.html.erb
99
+ - app/views/wallflower/tasks/index.html.erb
100
+ - app/views/wallflower/tasks/show.html.erb
101
+ - config/routes.rb
102
+ - lib/generators/wallflower/install/install_generator.rb
103
+ - lib/generators/wallflower/install/templates/create_wallflower_tasks.rb.erb
104
+ - lib/wallflower.rb
105
+ - lib/wallflower/configuration.rb
106
+ - lib/wallflower/engine.rb
107
+ - lib/wallflower/kinds.rb
108
+ - lib/wallflower/version.rb
109
+ homepage: https://github.com/DYB-Development/wallflower
110
+ licenses:
111
+ - MIT
112
+ metadata:
113
+ homepage_uri: https://github.com/DYB-Development/wallflower
114
+ source_code_uri: https://github.com/DYB-Development/wallflower
115
+ changelog_uri: https://github.com/DYB-Development/wallflower/blob/main/CHANGELOG.md
116
+ bug_tracker_uri: https://github.com/DYB-Development/wallflower/issues
117
+ documentation_uri: https://github.com/DYB-Development/wallflower#readme
118
+ rubygems_mfa_required: 'true'
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: 3.1.0
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 4.0.20
134
+ specification_version: 4
135
+ summary: Background task tracking for Rails apps.
136
+ test_files: []