Merge main into collapsing-columns branch

Resolves merge conflicts by:
- Integrating new Cards::Columns architecture from main
- Preserving collapsible columns functionality
- Maintaining stage-specific doing columns support
- Using new column-based rendering while keeping expanders

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jorge Manrubia
2025-09-24 10:19:04 +02:00
96 changed files with 2308 additions and 574 deletions
@@ -18,6 +18,7 @@ export default class extends Controller {
this.dialogOutlet.close()
this.#clearTurboFrame()
this.searchInputTarget.querySelector("input").value = ""
this.#showItem(this.buttonsContainerTarget)
this.#hideItem(this.searchInputTarget)
if (this.hasAskInputTarget) {
@@ -0,0 +1,85 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
#hiddenField
static targets = [ "label", "item", "hiddenFieldTemplate" ]
static values = {
selectPropertyName: { type: String, default: "aria-checked" },
defaultValue: String,
defaultLabel: String
}
static classes = ["withDefault"]
connect() {
this.#selectedItem = this.#selectedItem
}
change(event) {
const item = event.target.closest("[role='checkbox']")
if (item) {
this.#selectedItem = item
}
}
get #selectedLabel() {
const selectedValue = this.#selectedItemValue()
if (this.hasDefaultLabelValue && (selectedValue === this.defaultValueValue || !selectedValue)) {
return this.defaultLabelValue
}
return this.#selectedItem?.dataset?.comboboxLabel || ""
}
get #selectedItem() {
return this.itemTargets.find(item => item.getAttribute(this.selectPropertyNameValue) === "true")
}
#selectedItemValue() {
return this.#selectedItem?.dataset?.comboboxValue || ""
}
set #selectedItem(item) {
if (!item) return
this.#clearSelection()
item.setAttribute(this.selectPropertyNameValue, "true")
this.labelTarget.textContent = this.#selectedLabel
this.hiddenField.value = item.dataset.comboboxValue
this.hiddenField.disabled = !item.dataset.comboboxValue
this.#updateWithDefaultClass()
}
#clearSelection() {
this.itemTargets.forEach(target => {
target.setAttribute(this.selectPropertyNameValue, "false")
})
}
get hiddenField() {
if (!this.#hiddenField) {
this.#hiddenField = this.#buildHiddenField()
}
return this.#hiddenField
}
#buildHiddenField() {
const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
this.element.appendChild(field)
return field
}
#updateWithDefaultClass() {
if (this.hasWithDefaultClass && this.hasDefaultValueValue) {
const selectedValue = this.#selectedItemValue()
const shouldHaveClass = selectedValue === this.defaultValueValue
if (shouldHaveClass) {
this.element.classList.add(this.withDefaultClass)
} else {
this.element.classList.remove(this.withDefaultClass)
}
}
}
}
@@ -11,7 +11,7 @@ export default class extends Controller {
filter() {
this.itemTargets.forEach(item => {
if (filterMatches(item.innerText, this.inputTarget.value)) {
if (filterMatches(item.textContent, this.inputTarget.value)) {
item.removeAttribute("hidden")
} else {
item.toggleAttribute("hidden", true)
@@ -0,0 +1,68 @@
import { Controller } from "@hotwired/stimulus"
import { debounce } from "helpers/timing_helpers";
import { post } from "@rails/request.js"
export default class extends Controller {
static classes = ["filtersSet"]
static targets = ["field", "form"]
static values = { refreshUrl: String }
initialize() {
this.debouncedToggle = debounce(this.#toggle.bind(this), 50)
}
connect() {
this.#toggle()
}
change() {
this.#toggle()
this.#refreshSaveToggleButton()
}
async fieldTargetConnected(field) {
this.debouncedToggle()
}
#toggle() {
this.element.classList.toggle(this.filtersSetClass, this.#hasFiltersSet)
}
get #hasFiltersSet() {
return this.fieldTargets.some(field => this.#isFieldSet(field))
}
#isFieldSet(field) {
const value = field.value?.trim()
if (!value) return false
const defaultValue = this.#defaultValueForField(field)
return defaultValue ? value !== defaultValue : true
}
#defaultValueForField(field) {
const comboboxContainer = field.closest("[data-combobox-default-value-value]")
return comboboxContainer?.dataset?.comboboxDefaultValueValue
}
#refreshSaveToggleButton() {
post(this.refreshUrlValue, {
body: this.#collectFilterFormData(),
responseKind: "turbo-stream"
})
}
#collectFilterFormData() {
const formData = new FormData()
this.formTargets.forEach(form => {
const hiddenFields = form.querySelectorAll('input[type="hidden"]:not([disabled])[name]')
hiddenFields.forEach(field => {
formData.append(field.name, field.value)
})
})
return formData
}
}
@@ -0,0 +1,98 @@
import { Controller } from "@hotwired/stimulus"
import { toSentence } from "helpers/text_helpers"
export default class extends Controller {
#hiddenField
static targets = [ "label", "item", "hiddenFieldTemplate" ]
static values = {
selectPropertyName: { type: String, default: "aria-checked" },
defaultValue: String,
noSelectionLabel: { type: String, default: "No selection" },
labelPrefix: String
}
connect() {
this.labelTarget.textContent = this.#selectedLabel
this.#updateHiddenFields()
}
change(event) {
const item = event.target.closest("[role='checkbox']")
if (item) {
this.#toggleSelection(item)
}
}
clear(event) {
this.#deselectAll()
this.#updateHiddenFields()
this.labelTarget.textContent = this.#selectedLabel
}
get #selectedLabel() {
const selectedValues = this.#selectedValues()
if (selectedValues.length === 0) {
return this.noSelectionLabelValue
}
const labels = this.#selectedItems().map(item => item.dataset.multiSelectionComboboxLabel)
const sentence = toSentence(labels, {
two_words_connector: " or ",
last_word_connector: ", or "
})
return this.hasLabelPrefixValue ? `${this.labelPrefixValue} ${sentence}` : sentence
}
#toggleSelection(item) {
const isSelected = item.getAttribute(this.selectPropertyNameValue) === "true"
if (isSelected) {
item.setAttribute(this.selectPropertyNameValue, "false")
} else {
item.setAttribute(this.selectPropertyNameValue, "true")
}
this.#updateHiddenFields()
this.labelTarget.textContent = this.#selectedLabel
}
#updateHiddenFields() {
this.#clearHiddenFields()
this.#addHiddenFields()
}
#deselectAll() {
this.itemTargets.forEach(item => {
item.setAttribute(this.selectPropertyNameValue, "false")
})
}
#selectedItems() {
return this.itemTargets.filter(item =>
item.getAttribute(this.selectPropertyNameValue) === "true"
)
}
#selectedValues() {
return this.#selectedItems().map(item => item.dataset.multiSelectionComboboxValue)
}
#clearHiddenFields() {
this.element.querySelectorAll('input[type="hidden"]').forEach(field => {
if (field !== this.hiddenField) {
field.remove()
}
})
}
#addHiddenFields() {
this.#selectedValues().forEach(value => {
const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
field.value = value
this.element.appendChild(field)
})
}
}
@@ -0,0 +1,49 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { key: String }
static targets = [ "input", "section" ]
sectionTargetConnected() {
this.#restoreToggles()
}
toggle(event) {
const section = event.target
if (section.hasAttribute("data-is-filtering")) return
const key = this.#localStorageKeyFor(section)
if (section.open) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, true)
}
}
showWhileFiltering() {
if (this.inputTarget.value) {
this.#expandAll();
} else {
this.#restoreToggles()
}
}
#expandAll() {
this.sectionTargets.forEach(section => {
section.setAttribute("data-is-filtering", true)
section.open = true
})
}
#restoreToggles() {
this.sectionTargets.forEach(section => {
const key = this.#localStorageKeyFor(section)
section.open = !localStorage.getItem(key)
section.removeAttribute("data-is-filtering")
})
}
#localStorageKeyFor(section) {
return section.getAttribute("data-nav-section-expander-key-value")
}
}
+24
View File
@@ -11,3 +11,27 @@ export function normalizeFilteredText(string) {
export function filterMatches(text, potentialMatch) {
return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
}
export function toSentence(array, options = {}) {
const defaultConnectors = {
words_connector: ", ",
two_words_connector: " and ",
last_word_connector: ", and "
}
const connectors = { ...defaultConnectors, ...options }
if (array.length === 0) {
return ""
}
if (array.length === 1) {
return array[0]
}
if (array.length === 2) {
return array.join(connectors.two_words_connector)
}
return array.slice(0, -1).join(connectors.words_connector) + connectors.last_word_connector + array[array.length - 1]
}