Merge branch 'main' into mobile/bridge-components

* main: (65 commits)
  Repair scope for card styles within the columns view
  Add missing list to cards page
  Account for simpler public views
  Fix public layout
  Pull board-tools outside of the list for mobile
  saas: move log_level setting into an environment variable
  Restore log level configurability in production environment
  Remove engagements
  Use notch class so Save button is positioned correctly
  Go back to board after clearing filters
  Revert "Use existing no_filtering_url to direct back to the board when clearing filters"
  Use existing no_filtering_url to direct back to the board when clearing filters
  Ensure filters sit on top of cards
  Fix public boards
  Fix card grid layout
  Fix Chinese/Japanese characters missing from printed PDFs on macOS
  Add SQLite FTS5 support for CJK search
  Add CJK (Chinese, Japanese, Korean) search support
  Fix scrolling on mobile
  Remember expanded state on navigation
  ...
This commit is contained in:
Denis Švara
2026-01-08 14:34:10 +01:00
78 changed files with 714 additions and 440 deletions
+1 -1
View File
@@ -207,7 +207,7 @@
--color-terminal-text: var(--color-ink);
--color-terminal-text-light: var(--color-ink-lighter);
--color-golden: oklch(89.1% 0.178 95.7);
--color-considering: oklch(var(--lch-blue-medium));
--color-maybe: oklch(var(--lch-blue-medium));
/* Colors: Cards */
--color-card-default: oklch(var(--lch-blue-dark));
+2 -1
View File
@@ -102,7 +102,8 @@
}
/* Turbo */
turbo-frame {
turbo-frame,
turbo-cable-stream-source {
display: contents;
}
+1 -1
View File
@@ -11,7 +11,7 @@
white-space: nowrap;
}
.cards--considering & {
.cards--maybe & {
background-color: var(--card-bg-color) !important;
}
+176 -162
View File
@@ -1,4 +1,33 @@
@layer components {
/* Layout adjustments for contained scrolling
/* ------------------------------------------------------------------------ */
/* Scroll columns individually on mobile */
@media (max-width: 639px) {
body.contained-scrolling {
block-size: 100dvh;
grid-template-rows: 1fr var(--footer-height);
#global-container {
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
#main {
display: grid;
grid-template-rows: auto auto 1fr;
overflow: auto;
padding: 0;
}
/* Adapt the grid to public views (no filters or watchers sections) */
&.public #main {
grid-template-rows: 1fr;
}
}
}
/* Column container
/* ------------------------------------------------------------------------ */
@@ -10,7 +39,7 @@
--bubble-size: 3.5rem;
--cards-gap: min(1.2cqi, 1.7rem);
--column-gap: 8px;
--column-padding-expanded: calc(var(--column-gap) * 2);
--column-padding: calc(var(--column-gap) * 2);
--column-transition-duration: 300ms;
--column-width-collapsed: 40px;
--column-width-expanded: 450px;
@@ -21,17 +50,22 @@
container-type: inline-size;
display: grid;
gap: var(--column-gap);
grid-template-columns: 1fr var(--column-width-expanded) 1fr;
grid-template-columns: 1fr auto 1fr;
inline-size: 100%;
margin-inline: auto;
max-inline-size: var(--main-width);
outline: none;
overflow-x: auto;
overflow-y: hidden;
padding-block-end: var(--column-width-collapsed);
position: relative;
/* When it has something expanded */
&:has(.card-columns__left .cards:not(.is-collapsed), .card-columns__right .cards:not(.is-collapsed)) {
grid-template-columns: auto var(--column-width-expanded) auto;
&:has(.card-columns__left .is-expanded, .card-columns__right .is-expanded) {
grid-template-columns: auto auto auto;
@media (min-width: 640px) {
grid-template-columns: auto var(--column-width-expanded) auto;
}
}
&:has(.cards) {
@@ -39,11 +73,19 @@
min-block-size: 20lh;
}
body:not(.public) & {
@media (max-width: 519px) {
display: none;
@media (max-width: 639px) {
--column-width-expanded: calc(100vw - var(--column-gap) * 4);
scroll-snap-type: inline mandatory;
&:not(:has(.is-expanded)) {
grid-template-columns: auto var(--column-width-collapsed) auto;
}
}
@media (min-width: 640px) {
padding-block-end: var(--column-width-collapsed);
}
}
.card-columns__left,
@@ -51,16 +93,27 @@
align-items: stretch;
display: flex;
gap: var(--column-gap);
position: relative;
@media (max-width: 639px) {
min-block-size: 0;
}
}
.card-columns__left {
justify-content: end;
margin-inline-start: auto;
padding-inline-start: var(--column-gap);
@media (max-width: 639px) {
padding-inline-start: calc(var(--column-gap) * 2);
}
}
.card-columns__right {
justify-content: start;
padding-inline-end: var(--column-gap);
margin-inline-end: auto;
}
/* Column
@@ -72,7 +125,13 @@
inline-size: var(--column-width-expanded);
outline: none;
position: relative;
scroll-snap-align: start;
scroll-snap-align: center;
&.is-expanded {
@media (max-width: 639px) {
overflow: hidden;
}
}
&.is-collapsed {
inline-size: var(--column-width-collapsed);
@@ -126,15 +185,17 @@
.cards__transition-container {
block-size: 100%;
border-radius: calc(var(--column-width-collapsed) / 2);
translate: 0;
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
transition: translate var(--column-transition-duration) var(--ease-out-overshoot-subtle);
.is-collapsed:not(.cards--considering) & {
translate: 0 var(--column-width-collapsed);
}
@media (min-width: 640px) {
.is-expanded & {
translate: 0 0.5ch; /* Allow a little room for the mini bubble */
}
.cards:not(.is-collapsed) & {
padding-inline: var(--column-padding-expanded);
.is-collapsed & {
translate: 0 var(--column-width-collapsed);
}
}
.drag-and-drop__hover-container & {
@@ -151,29 +212,33 @@
.no-transitions & {
transition: none;
}
/* Use flex so the __list container can take up the remaining space for scrolling */
@media (max-width: 639px) {
.is-expanded & {
display: flex;
flex-direction: column;
}
}
}
/* The wrapper around the cards used to clip overflow while transitioning.
* Also, don't resize cards while transitioning to avoid reflow. */
.cards__list {
align-items: flex-end; /* use flex-start to wipe from left */
display: flex;
flex-direction: column;
gap: var(--cards-gap);
margin-block-start: -1ch;
margin-inline: -1ch;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
.cards:not(.is-collapsed) & {
padding: 1ch;
}
.is-expanded & {
padding: var(--column-padding);
.card {
inline-size: calc(var(--column-width-expanded) - var(--column-padding-expanded) * 2);
}
.cards--grid & {
display: contents;
/* Use the rest of the column height for scrolling */
@media (max-width: 639px) {
flex: 1;
padding-inline: calc(var(--column-padding) / 4);
}
}
[aria-selected] & .card[aria-selected] {
@@ -193,7 +258,17 @@
}
.cards__new-column {
margin-block-start: var(--column-width-collapsed);
position: relative;
@media (max-width: 639px) {
inset-inline-end: 0;
position: absolute;
translate: 100%;
}
@media (min-width: 640px) {
margin-block-start: var(--column-width-collapsed);
}
}
/* Cards grid; used when filtering
@@ -204,11 +279,7 @@
--card-grid-columns: 1;
container-type: inline-size;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--cards-gap);
justify-content: center;
inline-size: 100%;
margin-inline: auto;
max-inline-size: var(--main-width);
@@ -220,6 +291,15 @@
--card-grid-columns: 3;
}
.cards__list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--cards-gap);
justify-content: center;
padding: 1ch;
}
.card {
inline-size: calc((100% - var(--cards-gap) * (var(--card-grid-columns) - 1) ) / var(--card-grid-columns));
}
@@ -245,11 +325,11 @@
block-size: 100%;
}
.cards:not(.is-collapsed) & {
.cards.is-expanded & {
display: grid;
grid-template-areas: "menu expander maximize";
grid-template-columns: var(--column-width-collapsed) 1fr var(--column-width-collapsed);
margin-block-end: calc(0.5 * var(--cards-gap));
padding-inline: var(--column-padding);
}
}
@@ -292,12 +372,18 @@
font-weight: 600;
gap: 0.5ch;
grid-area: expander;
justify-content: center;
outline: none;
outline-offset: -2px;
position: relative;
text-transform: uppercase;
&[disabled] {
opacity: 1;
}
@media (any-hover: hover) {
&:hover {
.is-collapsed:hover {
filter: brightness(0.9);
}
}
@@ -338,7 +424,7 @@
block-size: 100%;
content: "";
inline-size: 1px;
inset: 0 auto;
inset-block: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment)) 0;
position: absolute;
z-index: -2;
}
@@ -351,22 +437,17 @@
}
}
.cards:not(.is-collapsed) & {
.cards.is-expanded & {
inline-size: 100%;
justify-content: center;
}
.cards:is(.cards--considering) &:hover {
cursor: unset;
filter: none;
}
}
.cards__expander-count {
line-height: var(--column-width-collapsed);
inline-size: var(--column-width-collapsed);
.cards:not(.is-collapsed) & {
.cards.is-expanded & {
display: none;
}
}
@@ -384,7 +465,7 @@
writing-mode: vertical-rl;
}
.cards:not(.is-collapsed, .cards--considering) & {
.cards.is-expanded & {
align-items: center;
display: flex;
gap: 0.25ch;
@@ -394,74 +475,40 @@
.icon--collapse {
--icon-size: 1.15em;
opacity: 0;
scale: 1.5;
opacity: 0.66;
transition: 150ms ease-out;
transition-property: opacity, scale;
@media (min-width: 640px) {
opacity: 0;
scale: 1.5;
}
.cards.is-collapsed & {
display: none;
}
.cards:not(.is-collapsed) .cards__expander:hover & {
.cards.is-expanded .cards__expander:hover & {
opacity: 0.66;
scale: 1;
}
}
}
/* TODO: I think this is legacy now? */
.cards__heading {
display: flex;
font-size: var(--text-medium);
inset-block-start: 0;
justify-content: center;
margin-block-start: 1px;
margin-inline: -8px; /* enough to cover card shadows, but avoid overlapping bubbles */
padding-block: var(--cards-gap) 0;
position: sticky;
z-index: 3;
/* Cover bubbles and cards on small viewports */
@media (max-width: 639px) {
margin-inline: calc(-1 * var(--cards-gap) - var(--main-padding));
}
&, .cards__filter {
font-weight: bold;
text-transform: uppercase;
}
.cards__filter {
--filter-block-padding: 0.5rem;
background-color: var(--color-canvas);
border-radius: 99rem;
inline-size: auto;
margin-block: calc(-1 * var(--filter-block-padding));
padding-block: var(--filter-block-padding);
}
dialog {
font-weight: normal;
text-transform: none;
}
}
/* Override card styles within columns
/* Adding .board-tools here since it sits outside the cards container on mobile */
/* ------------------------------------------------------------------------ */
.cards {
.card {
--block-space: 1em;
--block-space-half: 0.5em;
--card-padding-inline: 1em;
--text-xx-large: 1.6em;
--text-x-small: 1em;
.cards .card,
.board-tools {
--block-space: 1em;
--block-space-half: 0.5em;
--card-padding-inline: 1em;
--text-xx-large: 1.6em;
--text-x-small: 1em;
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
}
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
.card__comments {
--column-gap: 0.8ch;
@@ -544,7 +591,7 @@
/* Considering
/* ------------------------------------------------------------------------ */
.cards--considering {
.cards--maybe {
--card-color: oklch(var(--lch-blue-medium));
position: relative;
@@ -598,15 +645,37 @@
--card-padding-block: var(--block-space);
border: 1px solid var(--border-color);
margin-block-end: var(--cards-gap);
inline-size: auto;
text-align: center;
@media (max-width: 639px) {
/* On mobile, hide the tool card inside the Maybe column */
.cards & {
display: none;
}
#cards_container > & {
margin: 0 3ch 1ch;
}
}
@media (min-width: 640px) {
/* On desktop, hide the tool card above the columns */
#cards_container > & {
display: none;
}
}
@media (min-width: 800px) {
margin: var(--column-padding) var(--column-padding) 0;
}
&:has(dialog[open]) {
z-index: 5;
}
.separator--horizontal {
margin-block: var(--block-space);
.divider {
--divider-color: oklch(var(--lch-blue-light));
}
.btn--link {
@@ -620,7 +689,7 @@
footer {
font-size: var(--text-x-small);
margin-block-end: calc(var(--block-space-half) * -1);
margin-block: 1ch calc(var(--block-space-half) * -1);
}
.overflow-count {
@@ -671,18 +740,12 @@
}
}
/* Closed (Done)
/* ------------------------------------------------------------------------ */
.cards--closed {
}
/* Doing
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
.cards--considering:has(.bubble:not([hidden])) .cards__expander-title,
.cards--maybe:has(.bubble:not([hidden])) .cards__expander-title,
.cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container,
.cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
@@ -703,13 +766,14 @@
}
/* Maybe column: position bubble relative to the title, not the container */
.cards--considering & {
.cards--maybe.is-expanded & {
overflow: visible;
position: relative;
&:before {
inset-block-start: 50%;
translate: 125% -75%;
inset-inline-start: 0;
translate: -125% -75%;
z-index: -1;
}
}
@@ -749,54 +813,4 @@
}
}
}
/* Mobile columns
/* -------------------------------------------------------------------------- */
.mobile-card-columns {
--column-gap: 8px;
--column-padding-expanded: calc(var(--column-gap) * 2);
--column-width-collapsed: 40px;
--progress-increment: var(--progress-max-width) / var(--progress-max-cards);
--progress-max-cards: 15; /* should match first geared pagination page size */
--progress-max-width: 100%;
padding-inline: 3vw;
/* Hide on larger devices with cursors */
@media (min-width: 520px) {
display: none;
}
.cards.is-collapsed {
inline-size: auto;
display: block;
padding-block: 0.5ch;
.cards__expander {
--gradient-direction: to right;
flex-direction: row;
inline-size: auto;
&:before {
block-size: 1px;
inline-size: 100%;
inset: 50% 0 auto;
translate: 0 -50%;
}
&:after {
block-size: var(--column-width-collapsed);
inline-size: calc(var(--column-width-collapsed) + var(--card-count) * var(--progress-increment));
margin-inline-start: 0;
max-inline-size: var(--progress-max-width);
}
}
.cards__expander-title {
writing-mode: revert;
}
}
}
}
-4
View File
@@ -15,9 +15,5 @@
outline-offset: -2px;
transition: background-color 200ms;
z-index: 1;
.cards__heading {
background-color: transparent;
}
}
}
+3 -2
View File
@@ -7,11 +7,12 @@
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 1ch;
gap: var(--inline-space-half);
justify-content: center;
padding-block-start: 2px; /* prevents input focus-ring clipping on mobile */
position: relative;
view-transition-name: "filters";
view-transition-name: filters;
z-index: 1;
.btn {
--btn-border-color: var(--color-ink-medium);
+10
View File
@@ -8,6 +8,16 @@
}
}
/* Required for the card column page on mobile, but not needed otherwise */
:where(#global-container) {
display: contents;
}
:where(#header) {
position: relative;
z-index: var(--z-nav);
}
:where(#main) {
inline-size: 100dvw;
margin-inline: auto;
+1 -1
View File
@@ -108,7 +108,7 @@
}
.notifications__status {
--panel-border-radius: 0.5em;
--panel-padding: var(--block-space);
}
}
+3 -2
View File
@@ -7,6 +7,7 @@
--color-canvas: white;
--border-dark: 1px solid var(--color-ink);
--border-light: 1px solid color-mix(in oklch, var(--color-ink), transparent 75%);
--font-sans: "Adwaita Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Hiragino Sans GB", "Hiragino Sans", "Apple SD Gothic Neo", "Microsoft YaHei", "Meiryo", "Malgun Gothic";
}
@page {
@@ -125,7 +126,7 @@
.card--new,
.cards__decoration,
.card-columns:before,
.cards--considering:before {
.cards--maybe:before {
display: none;
}
@@ -140,7 +141,7 @@
padding-inline: 0;
}
.cards--considering {
.cards--maybe {
background: none;
margin: 0;
padding-inline: 1ch;
-6
View File
@@ -96,12 +96,6 @@
.overflow-clip { text-overflow: clip; white-space: nowrap; overflow: hidden; }
.overflow-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; }
.overflow-hide-scrollbar::-webkit-scrollbar {
@media (pointer: course) {
display: none;
}
}
.overflow-line-clamp {
-webkit-line-clamp: var(--lines, 2);
-webkit-box-orient: vertical;
@@ -1,18 +0,0 @@
class Cards::EngagementsController < ApplicationController
include CardScoped
def create
case params[:engagement]
when "doing"
@card.engage
when "on_deck"
@card.move_to_on_deck
end
render_card_replacement
end
def destroy
@card.reconsider
render_card_replacement
end
end
+1 -1
View File
@@ -12,7 +12,7 @@ class CardsController < ApplicationController
def create
respond_to do |format|
format.html do
card = @board.cards.find_or_create_by!(creator: Current.user, status: "drafted")
card = Current.user.draft_new_card_in(@board)
redirect_to card
end
+1 -1
View File
@@ -4,7 +4,7 @@ class Users::RolesController < ApplicationController
def update
@user.update!(role_params)
redirect_to users_path
redirect_to account_settings_path
end
private
+1 -1
View File
@@ -30,7 +30,7 @@ class UsersController < ApplicationController
@user.deactivate
respond_to do |format|
format.html { redirect_to users_path }
format.html { redirect_to account_settings_path }
format.json { head :no_content }
end
end
+3 -1
View File
@@ -30,7 +30,9 @@ module AccessesHelper
displayed_watchers = watchers.first(8)
overflow_count = watchers.size - 8
tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") +
tag.div(class: "divider divider--fade") do
tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase")
end +
tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do
tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do
tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) +
+1 -1
View File
@@ -11,7 +11,7 @@ module ColumnsHelper
end
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed, "is-expanded": !collapsed)
hotkeys_disabled = data[:card_hotkeys_disabled]
data = {
@@ -34,6 +34,7 @@ export default class extends Controller {
showModalAndSubmit(event) {
this.showModal()
this.formTarget.requestSubmit()
this.#restoreFocusAfterTurboFrameLoads()
}
showModal() {
@@ -51,6 +52,12 @@ export default class extends Controller {
}
}
#restoreFocusAfterTurboFrameLoads() {
this.turboFrameTarget.addEventListener("turbo:frame-load", () => {
this.searchInputTarget.focus()
}, { once: true })
}
#loadTurboFrame() {
this.turboFrameTarget.src = this.searchUrlValue
}
@@ -2,10 +2,11 @@ import { Controller } from "@hotwired/stimulus"
import { nextFrame, debounce } from "helpers/timing_helpers";
export default class extends Controller {
static classes = [ "collapsed", "noTransitions", "titleNotVisible" ]
static targets = [ "column", "button", "title" ]
static classes = [ "collapsed", "expanded", "noTransitions", "titleNotVisible" ]
static targets = [ "column", "button", "title", "maybeColumn" ]
static values = {
board: String
board: String,
desktopBreakpoint: { type: String, default: "(min-width: 640px)" }
}
initialize() {
@@ -15,6 +16,11 @@ export default class extends Controller {
async connect() {
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)
this.handleDesktop = this.#handleDesktop.bind(this)
this.mediaQuery.addEventListener("change", this.handleDesktop)
this.handleDesktop(this.mediaQuery)
}
disconnect() {
@@ -22,10 +28,11 @@ export default class extends Controller {
this._intersectionObserver.disconnect()
this._intersectionObserver = null
}
this.mediaQuery.removeEventListener("change", this.handleDesktop)
}
toggle({ target }) {
const column = target.closest('[data-collapsible-columns-target="column"]')
const column = target.closest('[data-collapsible-columns-target~="column"]')
this.#toggleColumn(column);
}
@@ -41,7 +48,7 @@ export default class extends Controller {
}
focusOnColumn({ target }) {
if (this.#isCollapsed(target)) {
if (this.#isDesktop && this.#isCollapsed(target)) {
this.#collapseAllExcept(target)
this.#expand(target)
}
@@ -74,7 +81,9 @@ export default class extends Controller {
}
#collapseAllExcept(clickedColumn) {
this.columnTargets.forEach(column => {
const columns = this.#isDesktop ? this.columnTargets.filter(c => c !== this.maybeColumnTarget) : this.columnTargets
columns.forEach(column => {
if (column !== clickedColumn) {
this.#collapse(column)
}
@@ -88,17 +97,25 @@ export default class extends Controller {
#collapse(column) {
const key = this.#localStorageKeyFor(column)
this.#buttonFor(column).setAttribute("aria-expanded", "false")
this.#buttonFor(column)?.setAttribute("aria-expanded", "false")
column.classList.remove(this.expandedClass)
column.classList.add(this.collapsedClass)
localStorage.removeItem(key)
}
#expand(column) {
const key = this.#localStorageKeyFor(column)
this.#buttonFor(column).setAttribute("aria-expanded", "true")
#expand(column, saveState = true) {
this.#buttonFor(column)?.setAttribute("aria-expanded", "true")
column.classList.remove(this.collapsedClass)
localStorage.setItem(key, true)
column.classList.add(this.expandedClass)
if (saveState) {
const key = this.#localStorageKeyFor(column)
localStorage.setItem(key, true)
}
if (window.matchMedia('(max-width: 639px)').matches) {
column.scrollIntoView({ behavior: "smooth", inline: "center" })
}
}
#buttonFor(column) {
@@ -114,6 +131,7 @@ export default class extends Controller {
#restoreColumn(column) {
const key = this.#localStorageKeyFor(column)
if (localStorage.getItem(key)) {
this.#collapseAllExcept(column)
this.#expand(column)
}
}
@@ -140,4 +158,33 @@ export default class extends Controller {
this.titleTargets.forEach(title => this._intersectionObserver.observe(title))
}
get #isDesktop() {
return this.mediaQuery?.matches
}
#handleDesktop() {
this.#isDesktop ? this.#handleDesktopMode() : this.#handleMobileMode()
}
async #handleDesktopMode() {
this.#expand(this.maybeColumnTarget, false)
this.#maybeButton.setAttribute("disabled", true)
}
#handleMobileMode() {
this.#maybeButton.removeAttribute("disabled")
const expandedColumn = this.columnTargets.find(column => column !== this.maybeColumnTarget && !this.#isCollapsed(column))
if (expandedColumn) {
this.#collapseAllExcept(expandedColumn)
} else {
this.#collapseAllExcept(this.maybeColumnTarget)
}
}
get #maybeButton() {
return this.maybeColumnTarget.querySelector('[data-collapsible-columns-target="button"]')
}
}
+1
View File
@@ -31,6 +31,7 @@ module Card::Closeable
def close(user: Current.user)
unless closed?
transaction do
not_now&.destroy
create_closure! user: user
track_event :closed, creator: user
end
-6
View File
@@ -1,6 +0,0 @@
class Card::Engagement < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, class_name: "::Card", touch: true
validates :status, presence: true, inclusion: { in: %w[doing on_deck] }
end
+5 -1
View File
@@ -14,7 +14,7 @@ class Event::Description
end
def to_plain_text
to_sentence(creator_name, h(card.title))
to_sentence(creator_name, quoted(card.title))
end
private
@@ -41,6 +41,10 @@ class Event::Description
h(event.creator.name)
end
def quoted(text)
%("#{h text}")
end
def card
@card ||= event.action.comment_created? ? event.eventable.card : event.eventable
end
+2
View File
@@ -1,4 +1,6 @@
module Search
CJK_PATTERN = /\p{Han}|\p{Hiragana}|\p{Katakana}|\p{Hangul}/
def self.table_name_prefix
"search_"
end
+8 -2
View File
@@ -13,8 +13,14 @@ class Search::Highlighter
result = text.dup
terms.each do |term|
result.gsub!(/\b(#{Regexp.escape(term)}\w*)\b/i) do |match|
"#{OPENING_MARK}#{match}#{CLOSING_MARK}"
if term.match?(Search::CJK_PATTERN)
result.gsub!(/(#{Regexp.escape(term)})/i) do |match|
"#{OPENING_MARK}#{match}#{CLOSING_MARK}"
end
else
result.gsub!(/\b(#{Regexp.escape(term)}\w*)\b/i) do |match|
"#{OPENING_MARK}#{match}#{CLOSING_MARK}"
end
end
end
+1 -1
View File
@@ -33,7 +33,7 @@ class Search::Query < ApplicationRecord
end
def remove_invalid_search_characters(terms)
terms.gsub(/[^\w"]/, " ")
terms.gsub(/[^\p{L}\p{N}_"]/, " ")
end
def remove_unbalanced_quotes(terms)
+7 -1
View File
@@ -8,9 +8,10 @@ module Search::Record::SQLite
has_one :search_records_fts, -> { with_rowid },
class_name: "Search::Record::SQLite::Fts", foreign_key: :rowid, primary_key: :id, dependent: :destroy
before_save :stem_content
after_save :upsert_to_fts5_table
scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", query) }
scope :matching, ->(query, account_id) { joins(:search_records_fts).where("search_records_fts MATCH ?", Search::Stemmer.stem(query.to_s)) }
end
class_methods do
@@ -42,6 +43,11 @@ module Search::Record::SQLite
end
private
def stem_content
self.title = Search::Stemmer.stem(title) if title_changed?
self.content = Search::Stemmer.stem(content) if content_changed?
end
def escape_fts_highlight(html)
return nil unless html.present?
+35 -1
View File
@@ -5,9 +5,43 @@ module Search::Stemmer
def stem(value)
if value.present?
value.gsub(/[^\w\s]/, "").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ")
tokenize(value).join(" ")
else
value
end
end
private
def tokenize(value)
tokens = []
current_word = +""
value.each_char do |char|
if cjk_character?(char)
if current_word.present?
tokens << stem_word(current_word)
current_word = +""
end
tokens << char
elsif char =~ /[\p{L}\p{N}_]/
current_word << char
else
if current_word.present?
tokens << stem_word(current_word)
current_word = +""
end
end
end
tokens << stem_word(current_word) if current_word.present?
tokens
end
def cjk_character?(char)
char.match?(Search::CJK_PATTERN)
end
def stem_word(word)
STEMMER.stem(word.downcase)
end
end
+6
View File
@@ -11,6 +11,12 @@ module User::Accessor
after_create_commit :grant_access_to_boards, unless: :system?
end
def draft_new_card_in(board)
board.cards.find_or_initialize_by(creator: self, status: "drafted").tap do |card|
card.update!(created_at: Time.current, updated_at: Time.current, last_active_at: Time.current)
end
end
private
def grant_access_to_boards
Access.insert_all account.boards.all_access.ids.collect { |board_id| { id: ActiveRecord::Type::Uuid.generate, board_id: board_id, user_id: id, account_id: account.id } }
+15 -2
View File
@@ -1,4 +1,6 @@
class Webhook::Delivery < ApplicationRecord
include Rails.application.routes.url_helpers
class ResponseTooLarge < StandardError; end
STALE_TRESHOLD = 7.days
@@ -130,8 +132,7 @@ class Webhook::Delivery < ApplicationRecord
elsif webhook.for_campfire?
render_payload(formats: :html)
elsif webhook.for_slack?
html = render_payload(formats: :html)
{ text: convert_html_to_mrkdwn(html) }.to_json
slack_payload
else
render_payload(formats: :json)
end
@@ -158,4 +159,16 @@ class Webhook::Delivery < ApplicationRecord
document.text
end
def slack_payload
text = event.description_for(nil).to_plain_text
url = polymorphic_url(event.eventable, base_url_options.merge(script_name: account.slug))
{ text: "#{text} <#{url}|Open in Fizzy>" }.to_json
end
def base_url_options
Rails.application.routes.default_url_options.presence ||
Rails.application.config.action_mailer.default_url_options
end
end
@@ -1,6 +1,4 @@
<div class="cards__list" data-drag-drop-item-container>
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
-3
View File
@@ -1,3 +0,0 @@
<div class="cards__list" data-drag-drop-item-container >
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
</div>
@@ -12,12 +12,14 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :closed_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :closed_column, @page do %>
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if @page.used? %>
<%= with_automatic_pagination :closed_column, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
</div>
<% end %>
</section>
@@ -12,12 +12,14 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :not_now_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :not_now_column, @page do %>
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if @page.used? %>
<%= with_automatic_pagination :not_now_column, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
</div>
<% end %>
</section>
+8 -6
View File
@@ -12,12 +12,14 @@
<section class="cards cards--grid">
<%= turbo_frame_tag @column, :cards do %>
<% if @page.used? %>
<%= with_automatic_pagination dom_id(@column, :cards), @page do %>
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if @page.used? %>
<%= with_automatic_pagination dom_id(@column, :cards), @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
</div>
<% end %>
</section>
@@ -12,12 +12,14 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :stream_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :stream_column, @page do %>
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if @page.used? %>
<%= with_automatic_pagination :stream_column, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% else %>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
</div>
<% end %>
</section>
+2 -1
View File
@@ -1,4 +1,5 @@
<% @page_title = @board.name %>
<% @body_class = "contained-scrolling" %>
<% turbo_exempts_page_from_cache %>
<%= turbo_stream_from @board %>
@@ -27,7 +28,7 @@
<% if @filter.used?(ignore_boards: true) %>
<%= render "boards/show/filtered_cards", page: @page %>
<% else %>
<%= render "columns/show/add_card_button", board: @board %>
<%= render "boards/show/columns", page: @page, board: @board %>
<%= render "boards/show/mobile_columns", page: @page, board: @board %>
<% end %>
<% end %>
+1
View File
@@ -4,6 +4,7 @@
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
collapsible_columns_board_value: board.id,
collapsible_columns_collapsed_class: "is-collapsed",
collapsible_columns_expanded_class: "is-expanded",
collapsible_columns_no_transitions_class: "no-transitions",
collapsible_columns_title_not_visible_class: "is-off-screen",
navigable_list_supports_vertical_navigation_value: false,
@@ -1,6 +1,8 @@
<section class="cards cards--grid">
<%= with_automatic_pagination :filtered_cards_paginated_container, page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="cards__list hide-scrollbar">
<%= with_automatic_pagination :filtered_cards_paginated_container, page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
</div>
</section>
@@ -1,48 +0,0 @@
<% postponed_count = board.cards.postponed.size %>
<% awaiting_triage_count = board.cards.awaiting_triage.size %>
<% closed_count = board.cards.closed.size %>
<section class="mobile-card-columns">
<div class="cards margin-block-end-half">
<%= render "columns/show/add_card_button", board: board %>
</div>
<%= link_to board_columns_not_now_path(board), class: "cards cards--on-deck is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= postponed_count %>">
<span class="cards__expander-count"><%= postponed_count %></span>
<h2 class="cards__expander-title">
Not Now
</h2>
</div>
<% end %>
<%= link_to board_columns_stream_path(board), class: "cards cards--considering is-collapsed", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= awaiting_triage_count %>">
<span class="cards__expander-count"><%= awaiting_triage_count %></span>
<h2 class="cards__expander-title">
Maybe?
</h2>
</div>
<% end %>
<% board.columns.sorted.each do |column| %>
<% active_count = column.cards.active.size %>
<%= link_to board_column_path(column.board, column), class: "cards cards--doing is-collapsed", style: "--card-color: #{column.color}", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= active_count %>">
<span class="cards__expander-count"><%= active_count %></span>
<h2 class="cards__expander-title">
<%= column.name %>
</h2>
</div>
<% end %>
<% end %>
<%= link_to board_columns_closed_path(board), class: "cards cards--closed is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= closed_count %>">
<span class="cards__expander-count"><%= closed_count %></span>
<h2 class="cards__expander-title">
Done
</h2>
</div>
<% end %>
</section>
+13 -15
View File
@@ -1,20 +1,18 @@
<%= column_tag id: "the-stream", name: "Maybe?", drop_url: columns_card_drops_stream_path("__id__"), collapsed: false, selected: "true", class: "cards--considering" do %>
<%= column_tag id: "maybe", name: "Maybe?", drop_url: columns_card_drops_stream_path("__id__"), collapsed: false, selected: "true", class: "cards--maybe", data: {
drag_and_strum_target: "container",
collapsible_columns_target: "column maybeColumn",
action: "focus->collapsible-columns#focusOnColumn"
} do %>
<header class="cards__header">
<div class="cards__expander">
<h2 class="cards__expander-title" data-collapsible-columns-target="title">Maybe?</h2>
</div>
<%= link_to board_columns_stream_path(board), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Expand column</span>
<% end %>
<%= render "boards/show/expander", title: "Maybe?", count: board.cards.awaiting_triage.count, column_id: "maybe" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_stream_path(board) %>
</header>
<%= render "columns/show/add_card_button", board: board %>
<% if page.used? %>
<%= with_automatic_pagination "the-stream", @page do %>
<%= render "boards/columns/list", cards: page.records, draggable: true %>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if page.used? %>
<%= with_automatic_pagination "maybe", @page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<% end %>
<% else %>
<div class="cards__list" data-drag-drop-item-container>
</div>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="cards__new-column position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" data-turbo-permanent>
<div class="cards__new-column" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" data-turbo-permanent>
<button class="btn btn--circle txt-x-small borderless" data-controller="tooltip" data-action="click->dialog#open:stop">
<%= icon_tag "add", class: "translucent" %>
<span class="for-screen-reader">Add a column</span>
+2
View File
@@ -16,6 +16,8 @@ json.cache! card do
json.board card.board, partial: "boards/board", as: :board
json.column card.column, partial: "columns/column", as: :column if card.column
json.creator card.creator, partial: "users/user", as: :user
json.assignees card.assignees.limit(5), partial: "users/user", as: :user
json.has_more_assignees card.assignees.size > 5
json.comments_url card_comments_url(card)
end
@@ -1,4 +1,4 @@
<div class="flex gap-half">
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<%= button_tag type: :submit, form: dom_id(card, :edit_form), class: "btn borderless",
title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })",
data: { controller: "hotkey", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
+6 -4
View File
@@ -19,9 +19,11 @@
<%= turbo_frame_tag :cards_container do %>
<section class="cards cards--grid">
<%= with_automatic_pagination :cards_paginated_container, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="cards__list hide-scrollbar">
<%= with_automatic_pagination :cards_paginated_container, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
</div>
</section>
<% end %>
@@ -1 +1 @@
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals:{ board: @card.board, page: @page }) %>
<%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals:{ board: @card.board, page: @page }) %>
@@ -5,8 +5,6 @@
<kbd class="hide-on-touch">C</kbd>
<% end %>
<hr class="separator--horizontal full-width" aria-hidden="true">
<footer>
<%= access_involvement_advance_button(board, Current.user, show_watchers: true) %>
</footer>
+1 -1
View File
@@ -1,7 +1,7 @@
<% filter = user_filtering.filter %>
<div class="filters__manage gap-half">
<%= link_to root_path, class: "btn btn--remove txt-x-small", data: { controller: "hotkey tooltip", action: "keydown.esc@document->hotkey#click"} do %>
<%= link_to no_filtering_url, class: "btn btn--remove txt-x-small", data: { controller: "hotkey tooltip", action: "keydown.esc@document->hotkey#click"} do %>
<%= icon_tag "close" %>
<span class="for-screen-reader">Clear all</span>
<% end %>
+13 -11
View File
@@ -2,19 +2,21 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body data-controller="bridge--page bridge--insets local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation" data-platform="<%= platform.type %>" <%= "data-bridge--page-title-value=#{@page_title}" if @page_title %>>
<header class="header header--mobile-actions-stack <%= @header_class %>" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= render "my/menu" if Current.user %>
<%= yield :header %>
</header>
<body class="<%= @body_class %>" data-controller="bridge--page bridge--insets local-time timezone-cookie turbo-navigation theme" data-action="turbo:morph@window->local-time#refreshAll turbo:before-visit@document->turbo-navigation#rememberLocation" data-platform="<%= platform.type %>" <%= "data-bridge--page-title-value=#{@page_title}" if @page_title %>>
<div id="global-container">
<header class="header header--mobile-actions-stack <%= @header_class %>" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= render "my/menu" if Current.user %>
<%= yield :header %>
</header>
<%= render "layouts/shared/flash" %>
<%= render "layouts/shared/time_zone" if Current.user %>
<%= render "layouts/shared/flash" %>
<%= render "layouts/shared/time_zone" if Current.user %>
<main id="main">
<%= yield %>
</main>
<main id="main">
<%= yield %>
</main>
</div>
<footer id="footer" class="hide-on-native">
<%= yield :footer %>
+17 -15
View File
@@ -2,24 +2,26 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body class="public" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header class="header" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<nav>
<%= link_to "https://fizzy.do", class: "header__logo center flex-inline align-center" do %>
<span><%= image_tag "logo.png", alt: "" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg" title="Fizzy"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<% end %>
</nav>
<body class="public <%= @body_class %>" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<div id="global-container">
<header class="header" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<nav>
<%= link_to "https://fizzy.do", class: "header__logo center flex-inline align-center" do %>
<span><%= image_tag "logo.png", alt: "" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg" title="Fizzy"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
<% end %>
</nav>
<%= yield :header %>
</header>
<%= yield :header %>
</header>
<%= render "layouts/shared/flash" %>
<%= render "layouts/shared/flash" %>
<main id="main">
<%= yield %>
</main>
<main id="main">
<%= yield %>
</main>
</div>
<footer id="footer">
<%= yield :footer %>
+1 -1
View File
@@ -16,7 +16,7 @@
<div data-navigable-list-target="item" class="full-width">
<%= link_to notifications_settings_path,
class: "btn borderless tray__notification-settings",
title: "Notification settings",
title: "Notification Settings",
data: { action: "dialog#close" } do %>
<%= icon_tag "settings" %>
<span>Settings</span>
@@ -70,7 +70,7 @@
<li>Click <em aria-label="the Apple menu"></em> in the top left.</li>
<li>Click <em>System Settings…</em>.</li>
<li>Click <em>Notifications</em>.</li>
<li>Click <em><%= platform.browser.capitalize %></em>.</li>
<li>Click <em><%= platform.browser == "Chrome" ? "Google Chrome" : platform.browser.capitalize %></em>.</li>
<li>Click <em><%= icon_tag "switch", alt: "the switch" %></em> to <em>Allow notifications</em>.</li>
<% end %>
</ol>
@@ -1,10 +1,10 @@
<% @page_title = "Notifications settings" %>
<% @page_title = "Notification Settings" %>
<% content_for :header do %>
<h1 class="header__title" data-bridge--page-target="header"><%= @page_title %></h1>
<% end %>
<section class="settings">
<section class="settings margin-block-start-half">
<div class="settings__panel settings__panel--users panel shadow center">
<h2 class="txt-medium txt-uppercase divider">Boards</h2>
<div class="settings__user-list flex flex-column gap-half">
@@ -1,6 +1,6 @@
<div class="panel margin-block-double center shadow">
<h1 class="font-black txt-x-large margin-none-block-end">Youre unsubscribed</h1>
<p class="margin-block-start-half">Thanks, <%= @user.first_name %>! Fizzy wont send you any more email notifications. If you change your mind you can turn them back on in <%= link_to "Notifications Settings", notifications_settings_path %>.</p>
<p class="margin-block-start-half">Thanks, <%= @user.first_name %>! Fizzy wont send you any more email notifications. If you change your mind, you can turn them back on in <%= link_to "Notification Settings", notifications_settings_path %>.</p>
<%= link_to root_path, class: "btn btn--link margin-block-start" do %>
<%= icon_tag "arrow-left" %>
@@ -1,3 +0,0 @@
<div class="cards__list">
<%= render "cards/display/public_previews", cards: cards %>
</div>
@@ -12,13 +12,15 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :closed_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :closed_column, @page do %>
<%= render "public/boards/columns/list", cards: @page.records %>
<div class="cards__list hide-scrollbar">
<% if @page.used? %>
<%= with_automatic_pagination :closed_column, @page do %>
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
</div>
<% end %>
</section>
@@ -12,13 +12,15 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :not_now_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :not_now_column, @page do %>
<%= render "public/boards/columns/list", cards: @page.records %>
<div class="cards__list hide-scrollbar">
<% if @page.used? %>
<%= with_automatic_pagination :not_now_column, @page do %>
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
</div>
<% end %>
</section>
@@ -12,13 +12,15 @@
<section class="cards cards--grid">
<%= turbo_frame_tag @column, :cards do %>
<% if @page.used? %>
<%= with_automatic_pagination dom_id(@column, :cards), @page do %>
<%= render "public/boards/columns/list", cards: @page.records %>
<div class="cards__list hide-scrollbar">
<% if @page.used? %>
<%= with_automatic_pagination dom_id(@column, :cards), @page do %>
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
</div>
<% end %>
</section>
@@ -12,13 +12,15 @@
<section class="cards cards--grid">
<%= turbo_frame_tag :stream_column do %>
<% if @page.used? %>
<%= with_automatic_pagination :stream_column, @page do %>
<%= render "public/boards/columns/list", cards: @page.records %>
<div class="cards__list hide-scrollbar">
<% if @page.used? %>
<%= with_automatic_pagination :stream_column, @page do %>
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<% end %>
</div>
<% end %>
</section>
+1
View File
@@ -1,4 +1,5 @@
<% @page_title = @board.name %>
<% @body_class = "contained-scrolling" %>
<% content_for :head do %>
<%= tag.meta property: "og:title", content: "#{@board.name} | #{Current.account.name}" %>
@@ -3,6 +3,7 @@
data-controller="collapsible-columns"
data-collapsible-columns-board-value="<%= board.id %>"
data-collapsible-columns-collapsed-class="is-collapsed"
data-collapsible-columns-expanded-class="is-expanded"
data-collapsible-columns-no-transitions-class="no-transitions"
data-collapsible-columns-title-not-visible-class="is-off-screen">
@@ -1,11 +1,17 @@
<section id="the-stream" class="cards cards--considering" data-column-name="Maybe?"
<section id="maybe"
class="cards cards--maybe is-expanded"
data-column-name="Maybe?"
data-collapsible-columns-target="column maybeColumn"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle">
<div class="cards__transition-container">
<header class="cards__header">
<div class="cards__expander">
<div hidden class="cards__expander">
<h2 class="cards__expander-title" data-collapsible-columns-target="title">Maybe?</h2>
</div>
<%# render "boards/show/expander", title: "Maybe?", count: column.cards.active.count, column_id: dom_id(column) %>
<%= render "boards/show/expander", title: "Maybe?", count: 2, column_id: "maybe" %>
<%= link_to public_board_columns_stream_url(board.publication.key), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Maximize column</span>
+5
View File
@@ -90,6 +90,11 @@ Rails.application.configure do
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]
# "info" includes generic and useful information about system operation, but avoids logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). If you
# want to log everything, set the level to "debug".
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Use a different cache store in production.
config.cache_store = :solid_cache_store
@@ -0,0 +1,16 @@
class DropCardEngagements < ActiveRecord::Migration[8.2]
def up
drop_table :card_engagements
end
def down
create_table :card_engagements, id: :uuid do |t|
t.references :account, type: :uuid, null: false
t.references :card, type: :uuid, null: false, index: true
t.string :status, null: false
t.timestamps
end
add_index :card_engagements, [ :account_id, :status ]
end
end
Generated
+1 -11
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -169,16 +169,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true
end
create_table "card_engagements", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id"
t.datetime "created_at", null: false
t.string "status", default: "doing", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "index_card_engagements_on_account_id_and_status"
t.index ["card_id"], name: "index_card_engagements_on_card_id"
end
create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
+1 -11
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do
create_table "accesses", id: :uuid, force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -169,16 +169,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_10_054934) do
t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true
end
create_table "card_engagements", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id"
t.datetime "created_at", null: false
t.string "status", limit: 255, default: "doing", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "status"], name: "index_card_engagements_on_account_id_and_status"
t.index ["card_id"], name: "index_card_engagements_on_card_id"
end
create_table "card_goldnesses", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "card_id", null: false
+4 -4
View File
@@ -45,7 +45,7 @@ bin/rails secret
Once you have one, set it in the `SECRET_KEY_BASE` environment variable:
```sh
docker run --environment SECRET_KEY_BASE=abcdefabcdef ...
docker run --env SECRET_KEY_BASE=abcdefabcdef ...
```
#### SSL
@@ -56,7 +56,7 @@ Note that if you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `fizzy.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --environment TLS_DOMAIN=fizzy.example.com ...
docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=fizzy.example.com ...
```
If you are terminating SSL in some other proxy in front of Fizzy, then you don't need to set `TLS_DOMAIN`, and can just publish port 80:
@@ -67,7 +67,7 @@ docker run --publish 80:80 ...
If you aren't using SSL at all (for example, if you want to run it locally on your laptop) then you should specify `DISABLE_SSL=true` instead:
```sh
docker run --publish 80:80 --environment DISABLE_SSL=true ...
docker run --publish 80:80 --env DISABLE_SSL=true ...
```
#### SMTP Email
@@ -96,7 +96,7 @@ Fizzy needs to know the public URL of your instance so it can generate correct l
Set `BASE_URL` to the full URL where your Fizzy instance is accessible:
```sh
docker run --environment BASE_URL=https://fizzy.example.com ...
docker run --env BASE_URL=https://fizzy.example.com ...
```
#### VAPID keys
+1
View File
@@ -76,6 +76,7 @@ env:
clear:
APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com
RAILS_ENV: beta
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
MYSQL_DATABASE_HOST: fizzy-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
+1
View File
@@ -27,6 +27,7 @@ ssh:
env:
clear:
RAILS_ENV: production
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
MYSQL_DATABASE_HOST: fizzy-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-mysql-primary
+1
View File
@@ -27,6 +27,7 @@ ssh:
env:
clear:
RAILS_ENV: staging
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
MYSQL_DATABASE_HOST: fizzy-staging-mysql-primary
MYSQL_DATABASE_REPLICA_HOST: fizzy-staging-mysql-replica
MYSQL_SOLID_CABLE_HOST: fizzy-staging-mysql-primary
+1 -2
View File
@@ -1,9 +1,8 @@
Rails.application.configure do
config.active_storage.service = :purestorage
# Enable structured logging, suppress unstructured log lines
# Enable structured logging
config.structured_logging.logger = ActiveSupport::Logger.new(STDOUT)
config.log_level = :fatal
config.action_controller.default_url_options = { host: "app.fizzy.do", protocol: "https" }
config.action_mailer.default_url_options = { host: "app.fizzy.do", protocol: "https" }
@@ -10,7 +10,7 @@ class Users::RolesControllerTest < ActionDispatch::IntegrationTest
put user_role_path(users(:david)), params: { user: { role: "admin" } }
assert_redirected_to users_path
assert_redirected_to account_settings_path
assert users(:david).reload.admin?
end
+1 -1
View File
@@ -37,7 +37,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
delete user_path(users(:david))
end
assert_redirected_to users_path
assert_redirected_to account_settings_path
assert_nil User.active.find_by(id: users(:david).id)
end
-4
View File
@@ -1,4 +0,0 @@
logo:
id: <%= ActiveRecord::FixtureSet.identify("logo_engagement", :uuid) %>
account: 37s_uuid
card: logo_uuid
+28
View File
@@ -31,4 +31,32 @@ class Card::CloseableTest < ActiveSupport::TestCase
assert cards(:shipping).reload.open?
assert cards(:shipping).events.last.action.card_reopened?
end
test "close card from triage column" do
card = cards(:logo)
assert_equal columns(:writebook_triage), card.column
card.close
assert card.closed?
end
test "close card from active column" do
card = cards(:text)
assert_equal columns(:writebook_in_progress), card.column
card.close
assert card.closed?
end
test "close card from NOT NOW" do
card = cards(:logo)
card.postpone
assert card.postponed?
assert card.not_now.present?
card.close
assert card.closed?
assert_nil card.reload.not_now
end
end
+4 -7
View File
@@ -4,15 +4,12 @@ class Filter::SearchTest < ActiveSupport::TestCase
include SearchTestHelper
test "deduplicate multiple results" do
user = users(:david)
board = boards(:writebook)
card = board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: user)
card = @board.cards.create!(title: "Duplicate results test", description: "Have you had any haggis today?", creator: @user)
card.published!
card.comments.create(body: "I hate haggis.", creator: user)
card.comments.create(body: "I love haggis.", creator: user)
card.comments.create(body: "I hate haggis.", creator: @user)
card.comments.create(body: "I love haggis.", creator: @user)
filter = user.filters.new(terms: [ "haggis" ], indexed_by: "all", sorted_by: "latest")
filter = @user.filters.new(terms: [ "haggis" ], indexed_by: "all", sorted_by: "latest")
assert_equal [ card ], filter.cards.to_a
end
+28
View File
@@ -82,6 +82,34 @@ class Search::HighlighterTest < ActiveSupport::TestCase
assert_equal "&lt;script&gt;#{mark('test')}&lt;/script&gt;", result
end
test "highlight Chinese characters" do
highlighter = Search::Highlighter.new("测试")
result = highlighter.highlight("这是一个测试文本")
assert_equal "这是一个#{mark('测试')}文本", result
end
test "highlight Japanese characters" do
highlighter = Search::Highlighter.new("テスト")
result = highlighter.highlight("これはテストです")
assert_equal "これは#{mark('テスト')}です", result
end
test "highlight Korean characters" do
highlighter = Search::Highlighter.new("테스트")
result = highlighter.highlight("이것은 테스트입니다")
assert_equal "이것은 #{mark('테스트')}입니다", result
end
test "highlight mixed CJK and English" do
highlighter = Search::Highlighter.new("world 世界")
result = highlighter.highlight("hello world 你好世界")
assert_equal "hello #{mark('world')} 你好#{mark('世界')}", result
end
private
def mark(text)
"#{Search::Highlighter::OPENING_MARK}#{text}#{Search::Highlighter::CLOSING_MARK}"
+57
View File
@@ -0,0 +1,57 @@
require "test_helper"
class Search::QueryTest < ActiveSupport::TestCase
setup do
@account = accounts(:"37s")
Current.account = @account
end
test "sanitize preserves ASCII words" do
query = build_query("hello world")
assert_equal "hello world", query.terms
end
test "sanitize preserves Chinese characters" do
query = build_query("测试文本")
assert_equal "测试文本", query.terms
end
test "sanitize preserves Japanese characters" do
query = build_query("テスト")
assert_equal "テスト", query.terms
end
test "sanitize preserves Korean characters" do
query = build_query("테스트")
assert_equal "테스트", query.terms
end
test "sanitize preserves mixed CJK and English" do
query = build_query("hello 世界 test")
assert_equal "hello 世界 test", query.terms
end
test "sanitize removes special characters but preserves CJK" do
query = build_query("测试@文本")
assert_equal "测试 文本", query.terms
end
test "sanitize preserves quoted phrases with CJK" do
query = build_query('"你好世界"')
assert_equal '"你好世界"', query.terms
end
private
def build_query(terms)
query = Search::Query.wrap(terms)
query.validate
query
end
end
+36
View File
@@ -12,4 +12,40 @@ class Search::StemmerTest < ActiveSupport::TestCase
assert_equal "test run jump walk", result
end
test "split Chinese characters for FTS indexing" do
result = Search::Stemmer.stem("测试")
assert_equal "测 试", result
end
test "split Japanese characters for FTS indexing" do
result = Search::Stemmer.stem("テスト")
assert_equal "テ ス ト", result
end
test "split Korean characters for FTS indexing" do
result = Search::Stemmer.stem("테스트")
assert_equal "테 스 트", result
end
test "mixed CJK and English" do
result = Search::Stemmer.stem("running 测试 test")
assert_equal "run 测 试 test", result
end
test "mixed CJK and English without spaces" do
result = Search::Stemmer.stem("hello世界test")
assert_equal "hello 世 界 test", result
end
test "CJK punctuation is treated as separator" do
result = Search::Stemmer.stem("你好。世界")
assert_equal "你 好 世 界", result
end
end
+32
View File
@@ -12,4 +12,36 @@ class User::AccessorTest < ActiveSupport::TestCase
system_user = User.create!(account: accounts("37s"), role: "system", name: "Test System User")
assert_empty system_user.boards
end
test "creating a new card draft sets current timestamps" do
user = users(:david)
board = boards(:writebook)
freeze_time do
card = user.draft_new_card_in(board)
assert card.persisted?
assert card.drafted?
assert_equal user, card.creator
assert_equal board, card.board
assert_equal Time.current, card.created_at
assert_equal Time.current, card.updated_at
assert_equal Time.current, card.last_active_at
end
end
test "reusing an existing card draft refreshes timestamps" do
existing_draft = cards(:unfinished_thoughts)
user = existing_draft.creator
board = existing_draft.board
freeze_time do
card = user.draft_new_card_in(board)
assert_equal existing_draft, card
assert_equal Time.current, card.created_at
assert_equal Time.current, card.updated_at
assert_equal Time.current, card.last_active_at
end
end
end