Merge branch 'main' into tooltip-orient

* main: (161 commits)
  Save expanded state to localStorage
  Update seeds to use the workflow stages
  Disable autocomplete when editing columns
  Reset column form and disable autocomplete
  Remove unused job
  Fix Stream workflow stage BG
  We need the grid layout when filtering events
  Fix: proper filtering by collection in the collection perma
  Fix test
  Fix test
  Position expander menu
  Use parent layout when using with_manual_pagination
  Reinstate gap to cards list
  Fix: include collection filter in the collection perma
  Add some basic caching for the new columns approach
  Format
  Fix tests
  No need to use extra method here
  Move actions to stream templates
  Prevent recursive loop now that we have nested frames
  ...
This commit is contained in:
Andy Smith
2025-09-29 13:55:33 -05:00
278 changed files with 6993 additions and 1898 deletions
@@ -0,0 +1,11 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers";
export default class extends Controller {
static targets = [ "clickable" ]
async click() {
await nextFrame()
this.clickableTarget.click()
}
}
@@ -0,0 +1,79 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "collapsed" ]
static targets = [ "column", "button" ]
connect() {
this.#restoreColumns()
}
toggle({ target }) {
const column = target.closest('[data-collapsible-columns-target="column"]')
this.#toggleColumn(column);
}
preventToggle(event) {
if (event.detail.attributeName === "class") {
event.preventDefault()
}
}
#toggleColumn(column) {
this.#collapseAllExcept(column)
if (this.#isCollapsed(column)) {
this.#expand(column)
} else {
this.#collapse(column)
}
console.log("TOGGLE")
console.log(localStorage)
}
#collapseAllExcept(clickedColumn) {
this.columnTargets.forEach(column => {
if (column !== clickedColumn) {
this.#collapse(column)
}
})
}
#isCollapsed(column) {
return column.classList.contains(this.collapsedClass)
}
#collapse(column) {
const key = this.#localStorageKeyFor(column)
this.#buttonFor(column).setAttribute("aria-expanded", "false")
column.classList.add(this.collapsedClass)
localStorage.removeItem(key)
}
#expand(column) {
const key = this.#localStorageKeyFor(column)
this.#buttonFor(column).setAttribute("aria-expanded", "true")
column.classList.remove(this.collapsedClass)
localStorage.setItem(key, true)
}
#buttonFor(column) {
return this.buttonTargets.find(button => column.contains(button))
}
#restoreColumns() {
this.columnTargets.forEach(column => {
const key = this.#localStorageKeyFor(column)
if (localStorage.getItem(key)) {
this.#expand(column)
}
})
}
#localStorageKeyFor(column) {
return `expand-${column.getAttribute("id")}`
}
}
@@ -8,7 +8,7 @@ export default class extends Controller {
}
connect() {
this.dialogTarget.setAttribute('aria-hidden', 'true')
this.dialogTarget.setAttribute("aria-hidden", "true")
}
open() {
@@ -21,7 +21,7 @@ export default class extends Controller {
orient(this.dialogTarget)
}
this.dialogTarget.setAttribute('aria-hidden', 'false')
this.dialogTarget.setAttribute("aria-hidden", "false")
this.dispatch("show")
}
@@ -35,7 +35,7 @@ export default class extends Controller {
close() {
this.dialogTarget.close()
this.dialogTarget.setAttribute('aria-hidden', 'true')
this.dialogTarget.setAttribute("aria-hidden", "true")
this.dialogTarget.blur()
orient(this.dialogTarget, false)
}
@@ -4,7 +4,6 @@ import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static targets = [ "item", "container" ]
static values = { url: String }
static classes = [ "draggedItem", "hoverContainer" ]
// Actions
@@ -38,8 +37,10 @@ export default class extends Controller {
if (!container || container === this.sourceContainer) { return }
this.wasDropped = true
this.#decreaseCounter(this.sourceContainer)
const sourceContainer = this.sourceContainer
await this.#submitDropRequest(this.dragItem, container)
this.#reloadSourceFrame(sourceContainer);
}
dragEnd() {
@@ -67,15 +68,30 @@ export default class extends Controller {
this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))
}
// Private
async #submitDropRequest(item, container) {
const body = new FormData()
const id = item.dataset.id
const containerTarget = container.dataset.dropTarget
const url = container.dataset.dragAndDropUrl.replaceAll("__id__", id)
body.append("dropped_item_id", id)
body.append("drop_target", containerTarget)
return post(this.urlValue, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
return post(url, { body, headers: { Accept: "text/vnd.turbo-stream.html" } })
}
#reloadSourceFrame(sourceContainer) {
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
if (frame) frame.reload()
}
#decreaseCounter(sourceContainer) {
const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]")
if (counterElement) {
const currentValue = counterElement.textContent.trim()
if (!/^\d+$/.test(currentValue)) return
const count = parseInt(currentValue)
if (count > 0) {
counterElement.textContent = count - 1
}
}
}
}
@@ -0,0 +1,109 @@
import { Controller } from "@hotwired/stimulus"
const INSTRUMENTS = [
[
"/audio/vibes/B3.mp3",
"/audio/vibes/C3.mp3",
"/audio/vibes/D4.mp3",
"/audio/vibes/E3.mp3",
"/audio/vibes/Fsharp4.mp3",
"/audio/vibes/G3.mp3"
],
[
"/audio/banjo/B3.mp3",
"/audio/banjo/C3.mp3",
"/audio/banjo/D4.mp3",
"/audio/banjo/E3.mp3",
"/audio/banjo/Fsharp4.mp3",
"/audio/banjo/G3.mp3"
],
[
"/audio/harpsichord/B3.mp3",
"/audio/harpsichord/C3.mp3",
"/audio/harpsichord/D4.mp3",
"/audio/harpsichord/E3.mp3",
"/audio/harpsichord/Fsharp4.mp3",
"/audio/harpsichord/G3.mp3"
],
[
"/audio/mandolin/B3.mp3",
"/audio/mandolin/C3.mp3",
"/audio/mandolin/D4.mp3",
"/audio/mandolin/E3.mp3",
"/audio/mandolin/Fsharp4.mp3",
"/audio/mandolin/G3.mp3"
],
[
"/audio/piano/B3.mp3",
"/audio/piano/C3.mp3",
"/audio/piano/D4.mp3",
"/audio/piano/E3.mp3",
"/audio/piano/Fsharp4.mp3",
"/audio/piano/G3.mp3"
],
]
export default class extends Controller {
static targets = [ "container" ]
connect() {
this.instrumentIndex = 0
this.preloadedAudioFiles = []
document.addEventListener("keydown", this.handleKeyDown.bind(this));
}
disconnect() {
document.removeEventListener("keydown", this.handleKeyDown.bind(this));
}
handleKeyDown(event) {
if (event.shiftKey) {
this.instrumentIndex = this.#getInstrumentIndex(event)
if (this.instrumentIndex < INSTRUMENTS.length) {
this.#preloadAudioFiles(this.instrumentIndex)
}
}
}
dragEnter(event) {
event.preventDefault()
const container = this.#containerContaining(event.target)
if (!container) { return }
if (container !== this.sourceContainer && event.shiftKey) {
this.#playSound()
}
}
#getInstrumentIndex(event) {
const number = Number(event.code.replace("Digit", ""))
return isNaN(number) ? 0 : number
}
#preloadAudioFiles(instrumentIndex) {
this.preloadedAudioFiles = []
const audioFiles = INSTRUMENTS[instrumentIndex];
if (audioFiles) {
this.preloadedAudioFiles = audioFiles.map(file => {
const audio = new Audio(file)
audio.load()
return audio
})
}
}
#containerContaining(element) {
return this.containerTargets.find(container => container.contains(element) || container === element)
}
#playSound() {
const randomIndex = Math.floor(Math.random() * this.preloadedAudioFiles.length)
const audio = this.preloadedAudioFiles[randomIndex]
const audioInstance = new Audio(audio.src)
audioInstance.play()
}
}
@@ -5,7 +5,7 @@ import { post } from "@rails/request.js"
export default class extends Controller {
static classes = ["filtersSet"]
static targets = ["field", "form"]
static values = { refreshUrl: String }
static values = { refreshUrl: String, noFilteringUrl: String }
initialize() {
this.debouncedToggle = debounce(this.#toggle.bind(this), 50)
@@ -15,11 +15,18 @@ export default class extends Controller {
this.#toggle()
}
change() {
change(event) {
this.#toggle()
this.#refreshSaveToggleButton()
}
resetIfNoFiltering(event) {
if (!this.#hasFiltersSet) {
this.#showNoFilteringUrl()
event.stopImmediatePropagation()
}
}
async fieldTargetConnected(field) {
this.debouncedToggle()
}
@@ -65,4 +72,8 @@ export default class extends Controller {
return formData
}
#showNoFilteringUrl() {
Turbo.visit(this.noFilteringUrlValue, { frame: "cards_container", action: "advance" })
}
}
@@ -25,6 +25,10 @@ export default class extends Controller {
this.submit()
}
reset() {
this.element.reset()
}
cancel() {
this.cancelTarget?.click()
}
@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
export default class extends Controller {
morphRender({ detail }) {
detail.render = function (currentElement, newElement) {
Turbo.morphChildren(currentElement, newElement)
}
}
morphReload(event) {
const newElement = event.detail.newElement
if (newElement && newElement.tagName === "TURBO-FRAME" && newElement.matches('[data-controller~="frame"]')) {
event.preventDefault()
this.element.reload()
}
}
}
@@ -0,0 +1,124 @@
import { Controller } from "@hotwired/stimulus"
import { createElement } from "helpers/html_helpers"
import { delay, nextEvent } from "helpers/timing_helpers"
import { keepingScrollPosition } from "helpers/scroll_helpers"
import { get } from "@rails/request.js"
const DELAY_BEFORE_OBSERVING = 400
export default class extends Controller {
static targets = [ "paginationLink" ]
static values = {
paginateOnIntersection: { type: Boolean, default: false },
discardFrame: Boolean,
manualActivation: Boolean
}
initialize() {
if (!this.manualActivation) {
this.activate()
}
}
disconnect() {
this.observer?.disconnect()
}
async activate() {
await delay(DELAY_BEFORE_OBSERVING)
if (this.paginateOnIntersectionValue) {
this.observer = new IntersectionObserver(this.#intersect, { rootMargin: "300px", threshold: 1 })
}
}
async paginationLinkTargetConnected(linkElement) {
if (this.paginateOnIntersectionValue) {
await delay(DELAY_BEFORE_OBSERVING)
this.observer?.observe(linkElement)
}
}
// Actions
loadPage({ target }) {
this.#loadPaginationLink(target)
}
// Private
#intersect = ([ entry ]) => {
if (entry?.isIntersecting && entry.intersectionRatio === 1) {
this.#loadPaginationLink(entry.target)
}
}
#loadPaginationLink(linkElement) {
this.observer?.unobserve(linkElement)
keepingScrollPosition(this.#closestSiblingTo(linkElement) || linkElement.parentNode, this.#expandPaginationLink(linkElement))
}
#closestSiblingTo(element) {
return element.nextElementSibling || element.previousElementSibling
}
async #expandPaginationLink(linkElement) {
linkElement.setAttribute("aria-busy", "true")
if (this.discardFrameValue) {
await this.#replacePaginationLinkWithFrameContents(linkElement)
} else {
await this.#replacePaginationLinkWithFrame(linkElement)
}
linkElement.removeAttribute("aria-busy")
}
async #replacePaginationLinkWithFrameContents(linkElement) {
linkElement.outerHTML = await this.#loadHtmlFrom(linkElement)
}
async #loadHtmlFrom(linkElement) {
const response = await get(linkElement.href, { responseKind: "html" })
const html = await response.text
const doc = new DOMParser().parseFromString(html, "text/html")
const element = doc.querySelector(`turbo-frame#${linkElement.dataset.frame}`)
return element ? element.innerHTML.trim() : ""
}
#replacePaginationLinkWithFrame(linkElement) {
const turboFrame = this.#buildTurboFrameFor(linkElement)
this.#insertTurboFrameAtPosition(linkElement, turboFrame)
linkElement.remove()
}
#buildTurboFrameFor(linkElement) {
const turboFrame = createElement("turbo-frame", {
id: linkElement.dataset.frame,
src: linkElement.href,
refresh: "morph",
target: "_top"
})
this.#keepScrollPositionOnFrameRender(turboFrame, linkElement)
return turboFrame
}
async #keepScrollPositionOnFrameRender(turboFrame, linkElement) {
await nextEvent(turboFrame, "turbo:before-frame-render")
keepingScrollPosition(linkElement, nextEvent(turboFrame, "turbo:frame-render"))
}
#insertTurboFrameAtPosition(linkElement, turboFrame) {
const container = linkElement.parentNode.parentNode
if (linkElement.parentNode.firstElementChild === linkElement) {
container.prepend(turboFrame)
} else {
container.append(turboFrame)
}
}
}