diff --git a/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml b/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml new file mode 100644 index 0000000000..6f2b56f54b --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_coverage.yml @@ -0,0 +1,54 @@ +name: ruby_event_store-browser-swimlane_coverage +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/Gemfile.lock + - ".github/workflows/ruby_event_store-browser-swimlane_coverage.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/Gemfile.lock + - ".github/workflows/ruby_event_store-browser-swimlane_coverage.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + schedule: + - cron: 0 17 * * * +jobs: + coverage: + runs-on: macos-14 + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + BUNDLE_WITHOUT: database + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make mutate + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml b/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml new file mode 100644 index 0000000000..2931f82c99 --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_mutate.yml @@ -0,0 +1,53 @@ +name: ruby_event_store-browser-swimlane_mutate +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_mutate.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_mutate.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" +jobs: + mutate: + runs-on: macos-14 + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + BUNDLE_WITHOUT: database + SINCE_SHA: "${{ github.event.pull_request.base.sha || 'HEAD~1' }}" + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make mutate-changes + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/.github/workflows/ruby_event_store-browser-swimlane_test.yml b/.github/workflows/ruby_event_store-browser-swimlane_test.yml new file mode 100644 index 0000000000..6a1a75eb99 --- /dev/null +++ b/.github/workflows/ruby_event_store-browser-swimlane_test.yml @@ -0,0 +1,55 @@ +name: ruby_event_store-browser-swimlane_test +on: + workflow_dispatch: + repository_dispatch: + types: + - script + push: + branches: + - master + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" + pull_request: + paths: + - contrib/ruby_event_store-browser-swimlane/** + - ".github/workflows/ruby_event_store-browser-swimlane_test.yml" + - support/** + - "!support/bundler/**" + - "!support/ci/**" +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + WORKING_DIRECTORY: contrib/ruby_event_store-browser-swimlane + RUBY_VERSION: "${{ matrix.ruby_version }}" + BUNDLE_GEMFILE: "${{ matrix.bundle_gemfile }}" + strategy: + fail-fast: false + matrix: + include: + - ruby_version: ruby-4.0 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.4 + bundle_gemfile: Gemfile + - ruby_version: ruby-3.3 + bundle_gemfile: Gemfile + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - run: test -e ${{ env.BUNDLE_GEMFILE }}.lock + working-directory: "${{ env.WORKING_DIRECTORY }}" + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "${{ env.RUBY_VERSION }}" + bundler-cache: true + working-directory: "${{ env.WORKING_DIRECTORY }}" + - run: make test + working-directory: "${{ env.WORKING_DIRECTORY }}" + env: + RUBYOPT: "--enable-frozen-string-literal" \ No newline at end of file diff --git a/contrib/ruby_event_store-browser-swimlane/.mutant.yml b/contrib/ruby_event_store-browser-swimlane/.mutant.yml new file mode 100644 index 0000000000..856e62a86c --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/.mutant.yml @@ -0,0 +1,17 @@ +# https://github.com/mbj/mutant/blob/master/docs/configuration.md + +usage: opensource +requires: + - ruby_event_store/browser/app + - ruby_event_store/browser/swimlane +includes: + - lib +integration: + name: rspec +mutation: + operators: light +coverage_criteria: + process_abort: true +matcher: + subjects: + - RubyEventStore::Browser::Swimlane* diff --git a/contrib/ruby_event_store-browser-swimlane/Gemfile b/contrib/ruby_event_store-browser-swimlane/Gemfile new file mode 100644 index 0000000000..3f019394a9 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Gemfile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gemspec + +eval_gemfile "../../support/bundler/Gemfile.shared" +gem "ruby_event_store", path: "../.." +gem "ruby_event_store-browser", path: "../.." diff --git a/contrib/ruby_event_store-browser-swimlane/Gemfile.lock b/contrib/ruby_event_store-browser-swimlane/Gemfile.lock new file mode 100644 index 0000000000..cf5d659edf --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Gemfile.lock @@ -0,0 +1,148 @@ +PATH + remote: ../.. + specs: + ruby_event_store (3.0.0) + concurrent-ruby (~> 1.0, >= 1.1.6) + ruby_event_store-browser (3.0.0) + rack + ruby_event_store (= 3.0.0) + +PATH + remote: . + specs: + ruby_event_store-browser-swimlane (0.1.0) + ruby_event_store (>= 2.0.0) + ruby_event_store-browser (>= 3.0.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + concurrent-ruby (1.3.7) + diff-lcs (1.6.2) + drb (2.2.3) + erb (6.0.5) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + logger (1.7.0) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + mutant (0.16.3) + diff-lcs (>= 1.6, < 3) + irb (~> 1.15) + parser (~> 3.3.10) + regexp_parser (~> 2.10) + securerandom (>= 0.3) + sorbet-runtime (~> 0.6.0) + unparser (>= 0.8.2, < 0.10) + mutant-minitest (0.16.3) + minitest (>= 5.11, < 7) + mutant (= 0.16.3) + mutex_m (~> 0.2) + mutant-rspec (0.16.3) + mutant (= 0.16.3) + rspec-core (>= 3.8.0, < 5.0.0) + mutex_m (0.3.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + racc (1.8.1) + rack (3.2.6) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + securerandom (0.4.1) + sorbet-runtime (0.6.13342) + tsort (0.2.0) + unparser (0.9.0) + diff-lcs (>= 1.6, < 3) + parser (>= 3.3.0) + prism (>= 1.5.1) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + irb + mutant + mutant-minitest + mutant-rspec + rake (>= 10.0) + rspec + ruby_event_store! + ruby_event_store-browser! + ruby_event_store-browser-swimlane! + +CHECKSUMS + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.5) sha256=858e63488cb796c9daba8b6e9ff4b3879c395022049be9a66a8e00980e612eac + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + mutant (0.16.3) sha256=7e10fb09b80a78d82f8e38283fc1e4d562cbb1392451e2a21611ea2bc44b5023 + mutant-minitest (0.16.3) sha256=504d110c1bd503528a7e57a4f062960e571bba4e3939e4a66ddc0f75d2071d90 + mutant-rspec (0.16.3) sha256=006ee9640800f205278604bb2160ca4b1d0013464fb216a48b7c09571a1fc547 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + ruby_event_store (3.0.0) + ruby_event_store-browser (3.0.0) + ruby_event_store-browser-swimlane (0.1.0) + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + sorbet-runtime (0.6.13342) sha256=dbeb9892597f593ed2b29e38018912787f6c4e0c4b736d8af0d6bd3c63bde3c3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + unparser (0.9.0) sha256=4331f174a73a23b69250b13d47da3794ed1449711ee0f9ed8947dc020ba76067 + +BUNDLED WITH + 4.0.10 diff --git a/contrib/ruby_event_store-browser-swimlane/Makefile b/contrib/ruby_event_store-browser-swimlane/Makefile new file mode 100644 index 0000000000..b6f8d785c6 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/Makefile @@ -0,0 +1,8 @@ +GEM_VERSION = $(shell cat lib/ruby_event_store/browser/swimlane/version.rb | grep VERSION | egrep -o '[0-9]+\.[0-9]+\.[0-9]+') +GEM_NAME = ruby_event_store-browser-swimlane + +include ../../support/make/install.mk +include ../../support/make/test.mk +include ../../support/make/mutant.mk +include ../../support/make/gem.mk +include ../../support/make/help.mk diff --git a/contrib/ruby_event_store-browser-swimlane/README.md b/contrib/ruby_event_store-browser-swimlane/README.md new file mode 100644 index 0000000000..89babb4973 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/README.md @@ -0,0 +1,14 @@ +# RubyEventStore::Browser::Swimlane + +A [RubyEventStore browser](https://railseventstore.org) extension comparing several streams side by side: their events merged into one newest-first timeline table, one column per stream, with infinite scroll. An event linked into several compared streams renders once, as a link in each matching column. The timeline can be sorted along either axis of the bi-temporal model — created-at (default) or valid-at. + +## Usage + +```ruby +mount RubyEventStore::Browser::App.for( + event_store_locator: -> { Rails.configuration.event_store }, + extensions: [RubyEventStore::Browser::Swimlane.new], +) => "/res" +``` + +A `Compare` link shows up on every stream page; the comparison itself lives under `/swimlane?streams[]=first&streams[]=second`. diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb new file mode 100644 index 0000000000..82060f8a70 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "uri" +require_relative "swimlane/version" +require_relative "swimlane/get_events_from_streams" + +module RubyEventStore + module Browser + # Browser extension comparing several streams side by side: their events + # merged into one newest-first timeline table, one column per stream, + # with infinite scroll. + class Swimlane + VIEWS_ROOT = File.expand_path("swimlane/views", __dir__) + PUBLIC_ROOT = File.expand_path("swimlane/public", __dir__) + + def register_routes(router, context) + router.add_route("GET", "/swimlane") do |params, urls| + stream_names, sort = read_params(params) + reader = GetEventsFromStreams.new(event_store: context.event_store, stream_names: stream_names, sort: sort) + + context.render( + "swimlane", + views_root: VIEWS_ROOT, + urls: urls, + swimlane: self, + stream_names: stream_names, + events: reader.events, + sort: sort, + more_url: (more_url(urls, stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + + router.add_route("GET", "/swimlane/more") do |params, urls| + stream_names, sort = read_params(params) + reader = + GetEventsFromStreams.new( + event_store: context.event_store, + stream_names: stream_names, + cursor: params["cursor"], + sort: sort, + ) + + context.json( + html: + context.render_partial( + "_rows", + views_root: VIEWS_ROOT, + urls: urls, + stream_names: stream_names, + events: reader.events, + sort: sort, + ), + more_url: (more_url(urls, stream_names, reader.next_cursor, sort) if reader.more?), + ) + end + + router.add_route("GET", "/swimlane/swimlane.js") do |_, _| + [200, { "content-type" => "text/javascript" }, [File.read(File.join(PUBLIC_ROOT, "swimlane.js"))]] + end + end + + def scripts(urls) + ["#{urls.app_url}/swimlane/swimlane.js"] + end + + def stream_links(stream_name, urls) + [{ label: "Streamline", url: swimlane_url(urls, [stream_name]) }] + end + + def swimlane_url(urls, stream_names, sort = nil) + "#{urls.app_url}/swimlane?#{query(stream_names, sort)}" + end + + def more_url(urls, stream_names, cursor, sort) + query = query(stream_names, sort, [["cursor", cursor]]) + "#{urls.app_url}/swimlane/more?#{query}" + end + + private + + def read_params(params) + stream_names = Array(params["streams"]).reject { |name| name.nil? || name.empty? }.uniq + sort = ("as_of" if params["sort"] == "as_of") + [stream_names, sort] + end + + def query(stream_names, sort, extra = []) + pairs = stream_names.map { |name| ["streams[]", name] } + pairs.concat(extra) + pairs << ["sort", sort] if sort + URI.encode_www_form(pairs) + end + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb new file mode 100644 index 0000000000..c5a651ffe5 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/get_events_from_streams.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class Swimlane + # Reads the next page of events across several streams merged into one + # newest-first timeline, using only the single-stream read API. Each + # stream is read in time order — as_at by default, as_of when sorting + # by validity — so the per-stream pages are sorted by the same key the + # merge uses and can be zipped together correctly. + # + # Count is the read depth per stream, not the page size: a page emits + # everything above the completeness horizon — the highest chunk end + # among the streams whose read was cut by count. Above that line every + # stream is fully known, so the page carries between count and + # count times the number of streams events, and the next page can ask + # for strictly older ones with nothing dropped or repeated. The shared + # cursor is that horizon timestamp. + GetEventsFromStreams = + Struct.new(:event_store, :stream_names, :cursor, :sort, :count, keyword_init: true) do + def initialize(event_store:, stream_names:, cursor: nil, sort: nil, count: PAGE_SIZE) + super(event_store: event_store, stream_names: stream_names, cursor: cursor, sort: sort, count: count) + end + + def events + @events ||= build_page + end + + def more? + events + @more + end + + def next_cursor + events.last && time_of(events.last.last).iso8601(TIMESTAMP_PRECISION) + end + + private + + def build_page + rows = stream_names.flat_map { |name| chunks[name].map { |event| [name, event] } } + @more = full_chunks.any? + return group(rows) unless @more + + boundary = full_chunks.map { |name| time_of(chunks[name].last) }.max + page_rows = rows.select { |_, event| time_of(event) >= boundary } + group(page_rows + drained_rows(boundary)) + end + + def chunks + @chunks ||= stream_names.to_h { |name| [name, read_chunk(name)] } + end + + def read_chunk(name) + scope = time_sorted(stream_scope(name)).backward.limit(count) + scope = scope.older_than(cursor_time) if cursor_time + scope.to_a + end + + def full_chunks + stream_names.select { |name| chunks[name].size == count } + end + + # A full chunk cut exactly at the boundary timestamp may have left + # some of that timestamp's events behind — fetch the whole group. + def drained_rows(boundary) + full_chunks + .select { |name| time_of(chunks[name].last) == boundary } + .flat_map { |name| drain(name, boundary).map { |event| [name, event] } } + end + + def drain(name, boundary) + time_sorted(stream_scope(name)).between(boundary..boundary).to_a + end + + # The browser addresses the global stream by its serialized alias, + # which is not a stream name a repository would recognize. + def stream_scope(name) + name == SERIALIZED_GLOBAL_STREAM_NAME ? event_store.read : event_store.read.stream(name) + end + + def group(rows) + rows + .group_by { |_, event| event.event_id } + .map { |_, list| [list.map(&:first).uniq, list.first.last] } + .sort_by { |_, event| [time_of(event), event.event_id] } + .reverse + end + + def time_sorted(scope) + as_of? ? scope.as_of : scope.as_at + end + + def time_of(event) + event.metadata.fetch(as_of? ? :valid_at : :timestamp) + end + + def as_of? + sort == "as_of" + end + + def cursor_time + @cursor_time ||= cursor && Time.iso8601(cursor) + end + end + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js new file mode 100644 index 0000000000..a73761ac74 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/public/swimlane.js @@ -0,0 +1,112 @@ +import { Application, Controller } from "../stimulus-3.2.2.js" + +const application = Application.start() + +application.register( + "swimlane", + class extends Controller { + static targets = ["tbody", "time"] + static values = { moreUrl: String } + + get storageKey() { + return "ruby_event_store_browser.timezone" + } + + connect() { + this.onScroll = () => this.catchUp() + window.addEventListener("scroll", this.onScroll, { passive: true }) + this.onZoneChange = (event) => { + if (event.target.matches('[data-timezone-target="select"]')) this.renderTimes(event.target.value) + } + document.addEventListener("change", this.onZoneChange) + this.renderTimes(this.zone()) + this.catchUp() + } + + disconnect() { + window.removeEventListener("scroll", this.onScroll) + document.removeEventListener("change", this.onZoneChange) + } + + // Rows are just a plain table — the browser aligns columns and draws row + // borders on its own, no layout math needed here. One shared cursor for + // every stream at once means there's nothing to fall behind — just check + // whether we're near the bottom of the page after every scroll and after + // every load. + catchUp() { + if (!this.moreUrlValue) return + if (document.body.scrollHeight - (window.scrollY + window.innerHeight) > 200) return + this.loadMore() + } + + loadMore() { + const url = this.moreUrlValue + if (!url) return + this.moreUrlValue = "" + + fetch(url, { headers: { Accept: "application/json" } }) + .then((response) => response.json()) + .then(({ html, more_url }) => { + this.tbodyTarget.insertAdjacentHTML("beforeend", html) + this.moreUrlValue = more_url || "" + this.renderTimes(this.zone()) + this.catchUp() + }) + } + + zone() { + const stored = localStorage.getItem(this.storageKey) + const detected = Intl.DateTimeFormat().resolvedOptions().timeZone + try { + Intl.DateTimeFormat("en-US", { timeZone: stored }) + return stored || detected + } catch (_) { + return detected + } + } + + renderTimes(tz) { + this.timeTargets.forEach((el) => { + el.textContent = this.format(el.dataset.iso, tz) + el.setAttribute("title", tz) + }) + } + + format(iso, tz) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat("en-US", { + timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", second: "2-digit", + fractionalSecondDigits: 3, hour12: false, + }).formatToParts(new Date(iso)).map((p) => [p.type, p.value]) + ) + return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond}` + } + }, +) + +application.register( + "swimlane-add", + class extends Controller { + static targets = ["dialog", "input"] + + open(event) { + event?.preventDefault() + this.dialogTarget.showModal() + this.inputTarget.focus() + } + + close() { + this.dialogTarget.close() + } + + go(event) { + event.preventDefault() + const name = this.inputTarget.value + if (!name) return + const url = new URL(window.location.href) + url.searchParams.append("streams[]", name) + window.location = url.toString() + } + }, +) diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb new file mode 100644 index 0000000000..f0cae4a572 --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyEventStore + module Browser + class Swimlane + VERSION = "0.1.0" + end + end +end diff --git a/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb new file mode 100644 index 0000000000..791c0aaaeb --- /dev/null +++ b/contrib/ruby_event_store-browser-swimlane/lib/ruby_event_store/browser/swimlane/views/_rows.html.erb @@ -0,0 +1,20 @@ +<% time_field = sort == "as_of" ? :valid_at : :timestamp -%> +<% events.each_with_index do |(names, event), index| -%> + <% next_event = events[index + 1]&.last -%> + <% same_group = next_event && next_event.metadata[time_field].utc.iso8601(3) == event.metadata[time_field].utc.iso8601(3) -%> + <% cell = "align-middle px-2 py-1#{" border-b border-gray-300" unless same_group}" -%> +
| Time | + <% stream_names.each_with_index do |stream_name, column| -%> +">
+
+ <%= h(stream_name) %>
+ <% if stream_names.size > 1 -%>
+ ×
+ <% end -%>
+
+ |
+ <% end -%>
+
|---|