Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion backend/src/baserow/contrib/database/search/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ def _get_singleton_autoreschedule_flag(table_id: int) -> SingletonAutoReschedule
return SingletonAutoRescheduleFlag(f"database_search_data_lock_{table_id}")


@app.task(queue="export")
@app.task(
queue="export",
soft_time_limit=max(1, settings.CELERY_SEARCH_UPDATE_HARD_TIME_LIMIT - 30),
time_limit=settings.CELERY_SEARCH_UPDATE_HARD_TIME_LIMIT,
)
def schedule_update_search_data(
table_id: int,
field_ids: Optional[List[int]] = None,
Expand Down
1 change: 1 addition & 0 deletions backend/src/baserow/core/jobs/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
bind=True,
queue="export",
soft_time_limit=settings.BASEROW_JOB_SOFT_TIME_LIMIT,
time_limit=settings.BASEROW_JOB_SOFT_TIME_LIMIT + 30,
)
def run_async_job(self, job_id: int):
"""Run the job task asynchronously"""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Fixes background tasks being stopped early by the default time limit",
"issue_origin": "github",
"issue_number": null,
"domain": "core",
"bullet_points": [],
"created_at": "2026-06-29"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Resolved a bug which caused invalid expressions to not be styled as invalid.",
"issue_origin": "github",
"issue_number": null,
"domain": "builder",
"bullet_points": [],
"created_at": "2026-06-24"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "bug",
"message": "Resolved a sporadic 'querySelector' error which appeared after a dependency was upgraded.",
"issue_origin": "github",
"issue_number": null,
"domain": "core",
"bullet_points": [],
"created_at": "2026-06-24"
}
5 changes: 4 additions & 1 deletion premium/backend/src/baserow_premium/fields/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ def generate_scheduled_ai_field_generation(field_id: int):
_schedule_generate_ai_value_generation(field_id)


@app.task()
@app.task(
soft_time_limit=max(1, settings.CELERY_SEARCH_UPDATE_HARD_TIME_LIMIT - 30),
time_limit=settings.CELERY_SEARCH_UPDATE_HARD_TIME_LIMIT,
)
def schedule_ai_field_generation(field_id: int, row_ids: list[int] | None = None):
"""
Populates scheduled rows table for AI field generation.
Expand Down
52 changes: 35 additions & 17 deletions web-frontend/modules/core/components/formula/FormulaInputField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,8 @@ export default {
if (!this.isFormulaInvalid) {
this.content = content
}
// A newly displayed formula must reflect its own validity.
this.validateFormula(value)
}
},
content: {
Expand All @@ -433,6 +435,9 @@ export default {
},
mounted() {
this.createEditor()
// Reflect the validity of the initially-displayed formula so an
// already-invalid stored value shows its error state without an edit.
this.validateFormula(this.value)
this.setupIntersectionObserver()
},
beforeUnmount() {
Expand Down Expand Up @@ -501,16 +506,17 @@ export default {
this.editor?.destroy()
this.createEditor(currentFormula)
},
emitChange() {
this.formulaErrorContext = { scope: null, title: '', message: '' }
this.errorExpanded = false

/**
* Validate a formula string, updating the error state (and the error
* context shown to the user). Returns whether the formula is valid.
*
* Kept separate from emitChange() so it can also run when a formula is
* merely *displayed* (on mount and when the value prop changes), not only
* when the user edits it. Otherwise an already-invalid stored formula
* renders without any error styling until the field is touched.
*/
validateFormula(formula) {
const functions = new RuntimeFunctionCollection(this.$registry)
// this.wrapperContent can be stale content, so get the data
// directly from the editor.
const editorContent = this.editor.getJSON()
const formula = this.toFormula(editorContent)

// Validate the syntax, and assuming it's valid, then validate the arguments.
const validationResult = isFormulaValid(
formula,
Expand All @@ -520,17 +526,29 @@ export default {
this.$t('formulaInputField.invalidSyntax')
)
this.isFormulaInvalid = !validationResult.valid
if (this.isFormulaInvalid) {
this.formulaErrorContext = {
scope: validationResult.scope,
title: this.$t('formulaInputField.invalidFormulaTitle'),
message: validationResult.errors[0],
}
this.formulaErrorContext = this.isFormulaInvalid
? {
scope: validationResult.scope,
title: this.$t('formulaInputField.invalidFormulaTitle'),
message: validationResult.errors[0],
}
: { scope: null, title: '', message: '' }
return validationResult.valid
},
emitChange() {
this.errorExpanded = false

// this.wrapperContent can be stale content, so get the data
// directly from the editor.
const editorContent = this.editor.getJSON()
const formula = this.toFormula(editorContent)

if (this.validateFormula(formula)) {
this.$emit('input', formula)
} else {
this.$nextTick(() => {
this.editor.commands.repositionContext()
})
} else {
this.$emit('input', formula)
}
},
onUpdate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default {
// first in the preview. Try to select that one, and if that's not possible,
// then try to select the first application of the template.
const openApplication = this.applications.find(
(a) => a.id === this.template.open_application
(a) => a.id === template.open_application
)
if (openApplication) {
this.selectApplication(openApplication)
Expand Down
10 changes: 9 additions & 1 deletion web-frontend/modules/core/mixins/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,15 @@ export default {
* it is outside of the current viewport.
*/
focusOnError() {
const error = this.$el.querySelector('[data-error]')
// In Vue 3 a component can have multiple root nodes or a root-level
// `v-if`, in which case `this.$el` is a comment/text placeholder node
// that doesn't expose `querySelector`. Fall back to the parent element
// so we can still locate and scroll to the error message.
const root =
typeof this.$el?.querySelector === 'function'
? this.$el
: this.$el?.parentElement
const error = root?.querySelector('[data-error]')
if (error) {
error.scrollIntoView({ behavior: 'smooth' })
}
Expand Down
50 changes: 49 additions & 1 deletion web-frontend/test/unit/core/formula/FormulaInputField.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { RuntimeFunctionCollection } from '@baserow/modules/core/functionCollect
import { ToTipTapVisitor } from '@baserow/modules/core/formula/tiptap/toTipTapVisitor'
import { FromTipTapVisitor } from '@baserow/modules/core/formula/tiptap/fromTipTapVisitor'
import parseBaserowFormula from '@baserow/modules/core/formula/parser/parser'
import { disambiguateMinusOperator } from '@baserow/modules/core/components/formula/FormulaInputField.vue'
import FormulaInputField, {
disambiguateMinusOperator,
} from '@baserow/modules/core/components/formula/FormulaInputField.vue'

// ── disambiguateMinusOperator ──────────────────────────────────────

Expand Down Expand Up @@ -133,3 +135,49 @@ describe('Advanced mode formula roundtrip', () => {
expect(roundtrip('3.14')).toBe('3.14')
})
})

// ── Validation on display ───────────────────────────────────────────
// An invalid stored formula (e.g. one that leaked a `$formula:` prefix)
// must surface its error state as soon as it is displayed, not only after
// the user edits the field.

describe('FormulaInputField validates on display', () => {
let testApp = null

beforeEach(() => {
testApp = new TestApp()
})

afterEach(() => {
testApp.afterEach()
})

async function mountField(value) {
const wrapper = await testApp.mount(FormulaInputField, {
props: { value, mode: 'advanced' },
})
await wrapper.vm.$nextTick()
return wrapper
}

it('flags an invalid initial value without requiring an edit', async () => {
const wrapper = await mountField('$formula: now()')
expect(wrapper.vm.isFormulaInvalid).toBe(true)
expect(wrapper.find('.formula-input-field--error').exists()).toBe(true)
})

it('does not flag a valid initial value', async () => {
const wrapper = await mountField('now()')
expect(wrapper.vm.isFormulaInvalid).toBe(false)
expect(wrapper.find('.formula-input-field--error').exists()).toBe(false)
})

it('re-validates when the displayed value changes', async () => {
const wrapper = await mountField('now()')
expect(wrapper.vm.isFormulaInvalid).toBe(false)

await wrapper.setProps({ value: '$formula: now()' })
await wrapper.vm.$nextTick()
expect(wrapper.vm.isFormulaInvalid).toBe(true)
})
})
45 changes: 45 additions & 0 deletions web-frontend/test/unit/core/mixins/error.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import error from '@baserow/modules/core/mixins/error'

describe('error mixin focusOnError', () => {
const { focusOnError } = error.methods

test('scrolls to the error element when $el is a real element', () => {
const scrollIntoView = vi.fn()
const errorElement = { scrollIntoView }
const $el = {
querySelector: vi.fn(() => errorElement),
}

focusOnError.call({ $el })

expect($el.querySelector).toHaveBeenCalledWith('[data-error]')
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
})

test('does not throw when $el is a fragment placeholder without querySelector', () => {
// In Vue 3 a component with a root-level v-if (like the Error component)
// is a fragment, so $el is a comment placeholder node that has no
// querySelector. The mixin should fall back to the parent element.
const scrollIntoView = vi.fn()
const errorElement = { scrollIntoView }
const parentElement = {
querySelector: vi.fn(() => errorElement),
}
// A comment/text placeholder node: no querySelector method.
const $el = { parentElement }

expect(() => focusOnError.call({ $el })).not.toThrow()
expect(parentElement.querySelector).toHaveBeenCalledWith('[data-error]')
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
})

test('does nothing when no error element is found', () => {
const $el = { querySelector: vi.fn(() => null) }
expect(() => focusOnError.call({ $el })).not.toThrow()
})

test('does not throw when $el is a placeholder with no parent element', () => {
const $el = { parentElement: null }
expect(() => focusOnError.call({ $el })).not.toThrow()
})
})
Loading