snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

107
frontend/tests/fixtures/leadMeta.js vendored Normal file
View File

@@ -0,0 +1,107 @@
export const leadFields = [
{
fieldname: 'lead_name',
fieldtype: 'Data',
label: 'Lead Name',
reqd: 1,
parent: 'CRM Lead',
},
{
fieldname: 'status',
fieldtype: 'Select',
label: 'Status',
options: 'New\nContacted\nQualified\nLost',
reqd: 1,
parent: 'CRM Lead',
},
{
fieldname: 'email',
fieldtype: 'Data',
label: 'Email',
options: 'Email',
parent: 'CRM Lead',
},
{
fieldname: 'lead_owner',
fieldtype: 'Link',
label: 'Lead Owner',
options: 'User',
parent: 'CRM Lead',
},
{
fieldname: 'annual_revenue',
fieldtype: 'Currency',
label: 'Annual Revenue',
precision: '2',
parent: 'CRM Lead',
},
{
fieldname: 'source',
fieldtype: 'Link',
label: 'Source',
options: 'Lead Source',
parent: 'CRM Lead',
},
{
fieldname: 'lost_reason',
fieldtype: 'Link',
label: 'Lost Reason',
options: 'CRM Lost Reason',
hidden: 1,
parent: 'CRM Lead',
},
{
fieldname: 'mobile_no',
fieldtype: 'Data',
label: 'Mobile No',
parent: 'CRM Lead',
},
{
fieldname: 'is_deal_created',
fieldtype: 'Check',
label: 'Is Deal Created',
parent: 'CRM Lead',
},
{
fieldname: 'lead_summary',
fieldtype: 'HTML',
label: 'Lead Summary',
parent: 'CRM Lead',
},
{
fieldname: 'send_brochure',
fieldtype: 'Button',
label: 'Send Brochure',
parent: 'CRM Lead',
},
{
fieldname: 'salary',
fieldtype: 'Currency',
label: 'Salary',
permlevel: 1,
parent: 'CRM Lead',
},
{
fieldname: 'priority',
fieldtype: 'Select',
label: 'Priority',
options: 'Low\nMedium\nHigh\nUrgent',
reqd: 1,
parent: 'CRM Lead',
},
{
fieldname: 'website',
fieldtype: 'Data',
label: 'Website',
parent: 'CRM Lead',
mandatory_depends_on: 'eval:doc.source === "Website"',
},
{
fieldname: 'no_of_employees',
fieldtype: 'Select',
label: 'No. of Employees',
options:
'1-10\n11-50\n51-200\n201-500\n501-1000\n1001-5000\n5001-10000\n10000+',
parent: 'CRM Lead',
},
]

14
frontend/tests/setup.js Normal file
View File

@@ -0,0 +1,14 @@
// Minimal globals that CRM code expects
globalThis.__ = (msg, args) => {
if (!args) return msg
let str = msg
if (Array.isArray(args)) {
args.forEach((arg, i) => {
str = str.replace(`{${i}}`, arg)
})
}
return str
}
globalThis.window = globalThis.window || {}
globalThis.window.sysdefaults = { currency: 'USD' }

View File

@@ -0,0 +1,128 @@
import { applyStateFieldOptions } from '@/utils/fieldTransforms'
const stateField = { fieldname: 'state', fieldtype: 'Data', label: 'State' }
const stateOptions = { India: ['Goa', 'Kerala', 'Punjab'] }
describe('applyStateFieldOptions', () => {
it('turns the Address state field into an Autocomplete for a known country', () => {
const result = applyStateFieldOptions(
stateField,
{ country: 'India' },
'Address',
stateOptions,
)
expect(result.fieldtype).toBe('Autocomplete')
expect(result.options).toEqual(['Goa', 'Kerala', 'Punjab'])
})
it('prepends an out-of-list current state value so it stays visible', () => {
const result = applyStateFieldOptions(
stateField,
{ country: 'India', state: 'California' },
'Address',
stateOptions,
)
expect(result.options).toEqual(['California', 'Goa', 'Kerala', 'Punjab'])
})
it('does not duplicate a current value already in the list', () => {
const result = applyStateFieldOptions(
stateField,
{ country: 'India', state: 'Goa' },
'Address',
stateOptions,
)
expect(result.options).toEqual(['Goa', 'Kerala', 'Punjab'])
})
it('does not mutate the input field', () => {
applyStateFieldOptions(
stateField,
{ country: 'India' },
'Address',
stateOptions,
)
expect(stateField.fieldtype).toBe('Data')
expect(stateField.options).toBeUndefined()
})
it('leaves the field unchanged when the country has no state list', () => {
const result = applyStateFieldOptions(
stateField,
{ country: 'France' },
'Address',
stateOptions,
)
expect(result).toBe(stateField)
})
it('hints to pick a country first when regional state data exists but no country is set', () => {
const withEmptyDoc = applyStateFieldOptions(
stateField,
{},
'Address',
stateOptions,
)
expect(withEmptyDoc.fieldtype).toBe('Data')
expect(withEmptyDoc.placeholder).toBe('Select Country to see state options')
const withNullDoc = applyStateFieldOptions(
stateField,
null,
'Address',
stateOptions,
)
expect(withNullDoc.placeholder).toBe('Select Country to see state options')
})
it('does not mutate the input field when adding the country hint', () => {
applyStateFieldOptions(stateField, {}, 'Address', stateOptions)
expect(stateField.placeholder).toBeUndefined()
})
it('leaves the field unchanged when no options map is provided (app absent)', () => {
expect(
applyStateFieldOptions(
stateField,
{ country: 'India' },
'Address',
undefined,
),
).toBe(stateField)
expect(
applyStateFieldOptions(stateField, { country: 'India' }, 'Address', {}),
).toBe(stateField)
})
it('only applies to the Address doctype', () => {
const result = applyStateFieldOptions(
stateField,
{ country: 'India' },
'CRM Lead',
stateOptions,
)
expect(result).toBe(stateField)
})
it('only applies to the state field', () => {
const cityField = { fieldname: 'city', fieldtype: 'Data' }
const result = applyStateFieldOptions(
cityField,
{ country: 'India' },
'Address',
stateOptions,
)
expect(result).toBe(cityField)
})
it('handles a nullish field safely', () => {
expect(
applyStateFieldOptions(
null,
{ country: 'India' },
'Address',
stateOptions,
),
).toBeNull()
})
})

View File

@@ -0,0 +1,192 @@
import { findMissingMandatory } from '@/utils/fieldTransforms'
import { leadFields } from '../fixtures/leadMeta'
describe('findMissingMandatory', () => {
// ─── Basic mandatory checks ───────────────────────────────────
it('catches missing required field', () => {
const doc = { lead_name: '', status: 'New' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Lead Name')
})
it('does not flag filled required field', () => {
const doc = { lead_name: 'John', status: 'New', priority: 'High' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).not.toContain('Lead Name')
expect(missing).not.toContain('Status')
})
it('catches null value as missing', () => {
const doc = { lead_name: null, status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Lead Name')
})
it('catches undefined value as missing', () => {
const doc = { status: 'New', priority: 'Low' } // lead_name not present
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Lead Name')
})
it('catches whitespace-only string as missing', () => {
const doc = { lead_name: ' ', status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Lead Name')
})
it('catches empty array as missing', () => {
const doc = { lead_name: [], status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Lead Name')
})
it('returns empty array when all mandatory fields filled', () => {
const doc = { lead_name: 'John', status: 'New', priority: 'High' }
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toEqual([])
})
it('returns empty array for empty fields list', () => {
expect(findMissingMandatory([], {})).toEqual([])
})
it('returns empty array for null fields', () => {
expect(findMissingMandatory(null, {})).toEqual([])
})
it('returns empty array for null doc', () => {
expect(findMissingMandatory(leadFields, null)).toEqual([])
})
// ─── Hidden fields ───────────────────────────────────────────
it('skips hidden mandatory fields', () => {
// lost_reason is hidden: 1 in meta — should be skipped even if reqd was set
const fields = [
{ fieldname: 'x', label: 'X', reqd: 1, hidden: 1, fieldtype: 'Data' },
]
const doc = { x: '' }
const missing = findMissingMandatory(fields, doc)
expect(missing).not.toContain('X')
})
// ─── Script overrides: reqd ───────────────────────────────────
it('script override reqd=true makes non-required field mandatory', () => {
const doc = { lead_name: 'John', status: 'New', priority: 'Low', email: '' }
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { email: { reqd: true } },
})
expect(missing).toContain('Email')
})
it('script override reqd=false removes mandatory', () => {
const doc = { lead_name: '', status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { lead_name: { reqd: false } },
})
expect(missing).not.toContain('Lead Name')
})
// ─── Script overrides: hidden ─────────────────────────────────
it('script override hidden=true skips mandatory check', () => {
const doc = { lead_name: '', status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { lead_name: { hidden: true } },
})
expect(missing).not.toContain('Lead Name')
})
it('script override hidden=false on originally-hidden field re-enables check', () => {
// lost_reason is hidden:1 in meta and not reqd
// Script un-hides it AND makes it reqd
const doc = {
lead_name: 'John',
status: 'Lost',
priority: 'Low',
lost_reason: '',
}
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { lost_reason: { hidden: false, reqd: true } },
})
expect(missing).toContain('Lost Reason')
})
// ─── Combined overrides ───────────────────────────────────────
it('hidden override wins — hidden+reqd via override skips check', () => {
const doc = { lead_name: '', status: 'New', priority: 'Low' }
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { lead_name: { hidden: true, reqd: true } },
})
// hidden takes priority — field is not validated
expect(missing).not.toContain('Lead Name')
})
// ─── mandatory_depends_on ─────────────────────────────────────
it('evaluates mandatory_depends_on expression', () => {
// website has mandatory_depends_on: eval:doc.source === "Website"
const doc = {
lead_name: 'John',
status: 'New',
priority: 'High',
source: 'Website',
website: '',
}
const missing = findMissingMandatory(leadFields, doc)
expect(missing).toContain('Website')
})
it('mandatory_depends_on not triggered when condition false', () => {
const doc = {
lead_name: 'John',
status: 'New',
priority: 'High',
source: 'Referral',
website: '',
}
const missing = findMissingMandatory(leadFields, doc)
expect(missing).not.toContain('Website')
})
it('script override reqd wins over mandatory_depends_on', () => {
// Even though source is not "Website", script forces reqd
const doc = {
lead_name: 'John',
status: 'New',
priority: 'High',
source: 'Referral',
website: '',
}
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { website: { reqd: true } },
})
expect(missing).toContain('Website')
})
it('script override reqd=false overrides mandatory_depends_on', () => {
const doc = {
lead_name: 'John',
status: 'New',
priority: 'High',
source: 'Website',
website: '',
}
const missing = findMissingMandatory(leadFields, doc, {
propertyOverrides: { website: { reqd: false } },
})
expect(missing).not.toContain('Website')
})
// ─── Uses label fallback ──────────────────────────────────────
it('uses fieldname if label is missing', () => {
const fields = [{ fieldname: 'custom_field', reqd: 1, fieldtype: 'Data' }]
const doc = { custom_field: '' }
const missing = findMissingMandatory(fields, doc)
expect(missing).toContain('custom_field')
})
})

View File

@@ -0,0 +1,106 @@
// useDocument pulls in stores, composables and a live createDocumentResource
// that aren't wired up for vitest — stub them so this test exercises only the
// save-error toast selection in setValue.onError (regression for #1703).
vi.mock('@/data/script', () => ({
getScript: () => ({ setupScript: vi.fn(), scripts: {} }),
}))
vi.mock('@/stores/global', () => ({ globalStore: () => ({}) }))
vi.mock('@/stores/meta', () => ({ getMeta: () => ({}) }))
vi.mock('@/composables/useAttachments', () => ({
useAttachments: () => ({
trackOldFile: vi.fn(),
processPendingDeletions: vi.fn(),
}),
}))
vi.mock('@/composables/settings', () => ({
showSettings: { value: false },
activeSettingsPage: { value: '' },
}))
vi.mock('@/utils', () => ({
runSequentially: vi.fn(),
parseAssignees: (d) => d,
sanitizeText: (t) => t,
}))
vi.mock('@/utils/fieldTransforms', () => ({ findMissingMandatory: () => [] }))
vi.mock('@/utils/fetchFrom', () => ({
getFetchSource: vi.fn(),
getFieldsToFetch: vi.fn(),
getPendingFetchFields: vi.fn(),
getSourceFieldnames: vi.fn(),
}))
const { toast, captured } = vi.hoisted(() => ({
toast: { error: vi.fn(), success: vi.fn() },
captured: { options: null },
}))
vi.mock('frappe-ui', () => ({
call: vi.fn(),
toast,
createResource: () => ({}),
createDocumentResource: (options) => {
captured.options = options
return { doc: {}, save: { submit: vi.fn() } }
},
}))
import { useDocument } from '@/data/document'
describe('useDocument setValue.onError toasts', () => {
let onError
// useDocument caches the resource per docname, so set it up once and
// reuse the captured handler across tests.
beforeAll(() => {
useDocument('CRM Lead', 'CRM-LEAD-TEST')
onError = captured.options.setValue.onError
})
beforeEach(() => {
toast.error.mockClear()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows one toast per server message', () => {
onError({ messages: ['Editing this lead is not allowed.'] })
expect(toast.error).toHaveBeenCalledTimes(1)
expect(toast.error).toHaveBeenCalledWith(
'Editing this lead is not allowed.',
)
})
it('shows the generic toast when messages is an empty array', () => {
onError({ messages: [] })
expect(toast.error).toHaveBeenCalledTimes(1)
expect(toast.error).toHaveBeenCalledWith(
'An error occurred while updating the document',
)
})
it('shows the generic toast when messages is missing', () => {
onError({})
expect(toast.error).toHaveBeenCalledTimes(1)
expect(toast.error).toHaveBeenCalledWith(
'An error occurred while updating the document',
)
})
it('collapses mandatory errors into a single toast', () => {
onError({
exc_type: 'MandatoryError',
messages: ['Missing: first_name', 'Missing: email'],
})
expect(toast.error).toHaveBeenCalledTimes(1)
expect(toast.error).toHaveBeenCalledWith(
'Mandatory field error: first_name, email',
)
})
})

View File

@@ -0,0 +1,125 @@
import {
getFetchSource,
getSourceFieldnames,
getFieldsToFetch,
getPendingFetchFields,
isFetchedFromLink,
} from '@/utils/fetchFrom'
const fields = [
{ fieldname: 'organization', fieldtype: 'Link', options: 'CRM Organization' },
{ fieldname: 'website', fetch_from: 'organization.website' },
{ fieldname: 'territory', fetch_from: 'organization.territory' },
{
fieldname: 'industry',
fetch_from: 'organization.industry',
fetch_if_empty: 1,
},
{ fieldname: 'annual_revenue', fetch_from: '.annual_revenue' },
{ fieldname: 'first_name' },
{ fieldname: 'email', fetch_from: 'contact.email_id' },
]
describe('getFetchSource', () => {
it('splits a fetch_from into its link and source field', () => {
expect(getFetchSource('organization.website')).toEqual({
link: 'organization',
source: 'website',
})
})
it('takes the last segment as the source, like the server does', () => {
expect(getFetchSource('a.b.c')).toEqual({ link: 'a', source: 'c' })
})
it('returns null when there is no link field to fetch from', () => {
expect(getFetchSource('.website')).toBeNull()
expect(getFetchSource('website')).toBeNull()
expect(getFetchSource('organization.')).toBeNull()
expect(getFetchSource('')).toBeNull()
expect(getFetchSource(undefined)).toBeNull()
expect(getFetchSource(null)).toBeNull()
expect(getFetchSource(123)).toBeNull()
})
})
describe('getFieldsToFetch', () => {
it('picks the fields that hang off a link field', () => {
expect(getFieldsToFetch(fields, 'organization').map((f) => f.fieldname)) //
.toEqual(['website', 'territory', 'industry'])
})
it('ignores fields pointing at a different link', () => {
expect(getFieldsToFetch(fields, 'contact').map((f) => f.fieldname)).toEqual(
['email'],
)
})
it('skips a fetch_from with no link field, matching the server', () => {
expect(getFieldsToFetch(fields, '')).toEqual([])
expect(getFieldsToFetch(fields, 'annual_revenue')).toEqual([])
})
})
describe('getPendingFetchFields', () => {
it('fetches everything when the doc is empty', () => {
expect(
getPendingFetchFields(fields, 'organization', {}).map((f) => f.fieldname),
).toEqual(['website', 'territory', 'industry'])
})
it('leaves a fetch_if_empty field alone once it has a value', () => {
const doc = { industry: 'Manufacturing' }
expect(
getPendingFetchFields(fields, 'organization', doc).map(
(f) => f.fieldname,
),
).toEqual(['website', 'territory'])
})
it('still overwrites a plain fetched field that has a value', () => {
const doc = { website: 'https://old.example.com' }
expect(
getPendingFetchFields(fields, 'organization', doc).map(
(f) => f.fieldname,
),
).toEqual(['website', 'territory', 'industry'])
})
})
describe('getSourceFieldnames', () => {
it('returns the source fieldnames to ask the server for', () => {
expect(
getSourceFieldnames(getFieldsToFetch(fields, 'organization')),
).toEqual(['website', 'territory', 'industry'])
})
it('asks for a shared source only once', () => {
const dupes = [
{ fieldname: 'a', fetch_from: 'org.website' },
{ fieldname: 'b', fetch_from: 'org.website' },
]
expect(getSourceFieldnames(dupes)).toEqual(['website'])
})
})
describe('isFetchedFromLink', () => {
it('locks the field once its link has a value', () => {
const field = { fetch_from: 'organization.website' }
expect(isFetchedFromLink(field, { organization: 'Acme' })).toBe(true)
})
it('leaves the field editable while the link is empty', () => {
const field = { fetch_from: 'organization.website' }
expect(isFetchedFromLink(field, {})).toBe(false)
expect(isFetchedFromLink(field, { organization: '' })).toBe(false)
expect(isFetchedFromLink(field, { organization: null })).toBe(false)
})
it('leaves ordinary and malformed fields editable', () => {
expect(isFetchedFromLink({ fieldname: 'first_name' }, {})).toBe(false)
expect(
isFetchedFromLink({ fetch_from: '.website' }, { website: 'x' }),
).toBe(false)
})
})

View File

@@ -0,0 +1,405 @@
import { describe, it, expect, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, ref } from 'vue'
vi.mock('frappe-ui', async () => {
const { defineComponent, h } = await import('vue')
return {
Tabs: defineComponent({
props: {
modelValue: { type: Number, default: 0 },
tabs: { type: Array, default: () => [] },
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
return () =>
h('div', [
h(
'div',
{ role: 'tablist' },
props.tabs.map((tab, index) =>
h(
'button',
{
role: 'tab',
'aria-selected': props.modelValue === index,
onClick: () => emit('update:modelValue', index),
},
tab.label,
),
),
),
h(
'div',
{ role: 'tabpanel' },
slots['tab-panel']?.({ tab: props.tabs[props.modelValue] }),
),
])
},
}),
}
})
vi.mock('@/data/document', () => ({
useDocument: () => ({
document: {
fieldPropertyOverrides: {},
},
}),
}))
vi.mock('@/components/FieldLayout/Section.vue', () => ({
default: defineComponent({
props: {
section: { type: Object, required: true },
},
setup(props) {
return () => h('section', props.section.label)
},
}),
}))
describe('FieldLayout tab state', () => {
it('keeps the selected data field tab when the layout remounts', async () => {
const { default: FieldLayout } = await import(
'@/components/FieldLayout/FieldLayout.vue'
)
const root = document.createElement('div')
document.body.appendChild(root)
const Parent = defineComponent({
setup() {
const showLayout = ref(true)
const fieldLayoutTabIndex = ref(0)
const tabs = [
{
name: 'details_tab',
label: 'Details',
sections: [{ name: 'details_section', label: 'Details' }],
},
{
name: 'data_tab',
label: 'Data',
sections: [{ name: 'data_section', label: 'Data' }],
},
]
return () =>
h('div', [
h(
'button',
{
'data-testid': 'remount',
onClick: async () => {
showLayout.value = false
await nextTick()
showLayout.value = true
},
},
'Remount',
),
showLayout.value &&
h(FieldLayout, {
tabs,
data: {},
doctype: 'CRM Deal',
isGridRow: true,
tabIndex: fieldLayoutTabIndex.value,
'onUpdate:tabIndex': (value) => {
fieldLayoutTabIndex.value = value
},
}),
])
},
})
const app = createApp(Parent)
app.mount(root)
root.querySelectorAll('[role="tab"]')[1].click()
await nextTick()
expect(root.querySelector('[aria-selected="true"]').textContent).toBe(
'Data',
)
root.querySelector('[data-testid="remount"]').click()
await nextTick()
await nextTick()
expect(root.querySelector('[aria-selected="true"]').textContent).toBe(
'Data',
)
app.unmount()
root.remove()
})
it('keeps the selected data field tab when DataFields remounts', async () => {
const { default: FieldLayout } = await import(
'@/components/FieldLayout/FieldLayout.vue'
)
const root = document.createElement('div')
document.body.appendChild(root)
const DataFieldsStub = defineComponent({
props: {
fieldLayoutTabIndex: { type: Number, default: 0 },
},
emits: ['update:fieldLayoutTabIndex'],
setup(props, { emit }) {
const tabs = [
{
name: 'details_tab',
label: 'Details',
sections: [{ name: 'details_section', label: 'Details' }],
},
{
name: 'data_tab',
label: 'Data',
sections: [{ name: 'data_section', label: 'Data' }],
},
]
return () =>
h(FieldLayout, {
tabs,
data: {},
doctype: 'CRM Deal',
isGridRow: true,
tabIndex: props.fieldLayoutTabIndex,
'onUpdate:tabIndex': (value) => {
emit('update:fieldLayoutTabIndex', value)
},
})
},
})
const Parent = defineComponent({
setup() {
const showDataFields = ref(true)
const fieldLayoutTabIndex = ref(0)
return () =>
h('div', [
h(
'button',
{
'data-testid': 'remount-data-fields',
onClick: async () => {
showDataFields.value = false
await nextTick()
showDataFields.value = true
},
},
'Remount DataFields',
),
showDataFields.value &&
h(DataFieldsStub, {
fieldLayoutTabIndex: fieldLayoutTabIndex.value,
'onUpdate:fieldLayoutTabIndex': (value) => {
fieldLayoutTabIndex.value = value
},
}),
])
},
})
const app = createApp(Parent)
app.mount(root)
root.querySelectorAll('[role="tab"]')[1].click()
await nextTick()
expect(root.querySelector('[aria-selected="true"]').textContent).toBe(
'Data',
)
root.querySelector('[data-testid="remount-data-fields"]').click()
await nextTick()
await nextTick()
expect(root.querySelector('[aria-selected="true"]').textContent).toBe(
'Data',
)
app.unmount()
root.remove()
})
it('keeps the selected tab by name when labels are duplicated and tabs reload', async () => {
const { default: FieldLayout } = await import(
'@/components/FieldLayout/FieldLayout.vue'
)
const root = document.createElement('div')
document.body.appendChild(root)
const Parent = defineComponent({
setup() {
const fieldLayoutTabIndex = ref(0)
const fieldLayoutTabName = ref('')
const tabs = ref([
{
name: 'tab_a',
label: 'New Tab',
sections: [{ name: 'section_a', label: 'First' }],
},
{
name: 'tab_b',
label: 'New Tab',
sections: [{ name: 'section_b', label: 'Second' }],
},
])
return () =>
h('div', [
h(
'button',
{
'data-testid': 'reload-tabs',
onClick: () => {
tabs.value = [
{
name: 'tab_a',
label: 'New Tab',
sections: [{ name: 'section_a_new', label: 'First' }],
},
{
name: 'tab_b',
label: 'New Tab',
sections: [{ name: 'section_b_new', label: 'Second' }],
},
]
},
},
'Reload Tabs',
),
h(FieldLayout, {
tabs: tabs.value,
data: {},
doctype: 'CRM Deal',
isGridRow: true,
tabIndex: fieldLayoutTabIndex.value,
tabName: fieldLayoutTabName.value,
'onUpdate:tabIndex': (value) => {
fieldLayoutTabIndex.value = value
},
'onUpdate:tabName': (value) => {
fieldLayoutTabName.value = value
},
}),
])
},
})
const app = createApp(Parent)
app.mount(root)
root.querySelectorAll('[role="tab"]')[1].click()
await nextTick()
expect(root.querySelector('[role="tabpanel"]').textContent).toBe('Second')
root.querySelector('[data-testid="reload-tabs"]').click()
await nextTick()
expect(root.querySelector('[role="tabpanel"]').textContent).toBe('Second')
app.unmount()
root.remove()
})
it('restores the selected tab name from session storage after page remount', async () => {
const { default: FieldLayout } = await import(
'@/components/FieldLayout/FieldLayout.vue'
)
const root = document.createElement('div')
document.body.appendChild(root)
sessionStorage.clear()
const DataFieldsStub = defineComponent({
setup() {
const doctype = 'CRM Deal'
const docname = 'DEAL-001'
const fieldLayoutTabIndex = ref(0)
const fieldLayoutTabName = ref('')
const storageKey = `fieldLayoutTab:${doctype}:${docname}`
const tabs = [
{
name: 'tab_a',
label: 'New Tab',
sections: [{ name: 'section_a', label: 'First' }],
},
{
name: 'tab_b',
label: 'New Tab',
sections: [{ name: 'section_b', label: 'Second' }],
},
]
const storedFieldLayoutTabName = sessionStorage.getItem(storageKey)
if (storedFieldLayoutTabName) {
fieldLayoutTabName.value = storedFieldLayoutTabName
}
return () =>
h(FieldLayout, {
tabs,
data: {},
doctype,
isGridRow: true,
tabIndex: fieldLayoutTabIndex.value,
tabName: fieldLayoutTabName.value,
'onUpdate:tabIndex': (value) => {
fieldLayoutTabIndex.value = value
},
'onUpdate:tabName': (value) => {
fieldLayoutTabName.value = value
sessionStorage.setItem(storageKey, value)
},
})
},
})
const Parent = defineComponent({
setup() {
const showPage = ref(true)
return () =>
h('div', [
h(
'button',
{
'data-testid': 'remount-page',
onClick: async () => {
showPage.value = false
await nextTick()
showPage.value = true
},
},
'Remount Page',
),
showPage.value && h(DataFieldsStub),
])
},
})
const app = createApp(Parent)
app.mount(root)
root.querySelectorAll('[role="tab"]')[1].click()
await nextTick()
expect(root.querySelector('[role="tabpanel"]').textContent).toBe('Second')
root.querySelector('[data-testid="remount-page"]').click()
await nextTick()
await nextTick()
expect(root.querySelector('[role="tabpanel"]').textContent).toBe('Second')
app.unmount()
root.remove()
sessionStorage.clear()
})
})

View File

@@ -0,0 +1,230 @@
import { reactive } from 'vue'
/**
* Mirrors the actual setFieldProperty / removeFieldProperty / setFieldProperties
* logic that will be added to script.js prototype methods.
* Tests the override map manipulation in isolation.
*/
function setFieldProperty(ctx, target, property, value, rowName) {
if (!ctx.fieldPropertyOverrides) ctx.fieldPropertyOverrides = {}
const key = rowName ? `${target}:${rowName}` : target
if (!ctx.fieldPropertyOverrides[key]) ctx.fieldPropertyOverrides[key] = {}
ctx.fieldPropertyOverrides[key][property] = value
}
function setFieldProperties(ctx, target, properties, rowName) {
for (const [key, value] of Object.entries(properties)) {
setFieldProperty(ctx, target, key, value, rowName)
}
}
function removeFieldProperty(ctx, target, property, rowName) {
const key = rowName ? `${target}:${rowName}` : target
if (!ctx.fieldPropertyOverrides?.[key]) return
delete ctx.fieldPropertyOverrides[key][property]
if (Object.keys(ctx.fieldPropertyOverrides[key]).length === 0) {
delete ctx.fieldPropertyOverrides[key]
}
}
function createCtx() {
return reactive({ fieldPropertyOverrides: {} })
}
describe('fieldPropertyOverrides map operations', () => {
// ─── setFieldProperty ─────────────────────────────────────────
it('sets a single property on a field', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
expect(ctx.fieldPropertyOverrides.email.hidden).toBe(true)
})
it('sets multiple properties on the same field', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
setFieldProperty(ctx, 'email', 'label', 'Work Email')
expect(ctx.fieldPropertyOverrides.email).toEqual({
hidden: true,
label: 'Work Email',
})
})
it('overwrites a previously set property', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
setFieldProperty(ctx, 'email', 'hidden', false)
expect(ctx.fieldPropertyOverrides.email.hidden).toBe(false)
})
it('sets properties on different fields independently', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
setFieldProperty(ctx, 'phone', 'read_only', true)
expect(ctx.fieldPropertyOverrides.email).toEqual({ hidden: true })
expect(ctx.fieldPropertyOverrides.phone).toEqual({ read_only: true })
})
it('handles string values', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'status', 'options', 'New\nOpen\nClosed')
expect(ctx.fieldPropertyOverrides.status.options).toBe('New\nOpen\nClosed')
})
it('handles object values (link_filters)', () => {
const ctx = createCtx()
const filters = { company: 'ACME', enabled: 1 }
setFieldProperty(ctx, 'contact', 'link_filters', filters)
expect(ctx.fieldPropertyOverrides.contact.link_filters).toEqual(filters)
})
it('initializes fieldPropertyOverrides if missing', () => {
const ctx = reactive({})
setFieldProperty(ctx, 'x', 'hidden', true)
expect(ctx.fieldPropertyOverrides.x.hidden).toBe(true)
})
// ─── setFieldProperties (batch) ────────────────────────────────
it('sets multiple properties at once', () => {
const ctx = createCtx()
setFieldProperties(ctx, 'revenue', {
hidden: false,
read_only: true,
label: 'Revenue (USD)',
})
expect(ctx.fieldPropertyOverrides.revenue).toEqual({
hidden: false,
read_only: true,
label: 'Revenue (USD)',
})
})
it('batch set merges with existing properties', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'x', 'hidden', true)
setFieldProperties(ctx, 'x', { label: 'New', description: 'Help' })
expect(ctx.fieldPropertyOverrides.x).toEqual({
hidden: true,
label: 'New',
description: 'Help',
})
})
// ─── removeFieldProperty ──────────────────────────────────────
it('removes a single property', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
setFieldProperty(ctx, 'email', 'label', 'X')
removeFieldProperty(ctx, 'email', 'hidden')
expect(ctx.fieldPropertyOverrides.email).toEqual({ label: 'X' })
})
it('cleans up empty field key after last property removed', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
removeFieldProperty(ctx, 'email', 'hidden')
expect(ctx.fieldPropertyOverrides.email).toBeUndefined()
})
it('is safe to call on non-existent field', () => {
const ctx = createCtx()
expect(() => removeFieldProperty(ctx, 'nope', 'hidden')).not.toThrow()
})
it('is safe to call on non-existent property', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'email', 'hidden', true)
expect(() => removeFieldProperty(ctx, 'email', 'nope')).not.toThrow()
// original property still there
expect(ctx.fieldPropertyOverrides.email.hidden).toBe(true)
})
// ─── Dot notation (child table fields) ────────────────────────
it('handles dot notation for child table fields', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'products.qty', 'read_only', true)
expect(ctx.fieldPropertyOverrides['products.qty'].read_only).toBe(true)
})
it('removes dot notation overrides', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'products.qty', 'read_only', true)
removeFieldProperty(ctx, 'products.qty', 'read_only')
expect(ctx.fieldPropertyOverrides['products.qty']).toBeUndefined()
})
// ─── Section and tab names ────────────────────────────────────
it('handles section names', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'details_section', 'hidden', true)
expect(ctx.fieldPropertyOverrides.details_section.hidden).toBe(true)
})
it('handles tab names', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'advanced_tab', 'hidden', true)
expect(ctx.fieldPropertyOverrides.advanced_tab.hidden).toBe(true)
})
it('section override with multiple properties', () => {
const ctx = createCtx()
setFieldProperties(ctx, 'notes_section', {
collapsible: true,
opened: false,
label: 'Internal Notes',
})
expect(ctx.fieldPropertyOverrides.notes_section).toEqual({
collapsible: true,
opened: false,
label: 'Internal Notes',
})
})
// ─── Per-row overrides (4th param: rowName) ─────────────────
it('sets per-row override with rowName', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'products.qty', 'read_only', true, 'row_abc')
expect(ctx.fieldPropertyOverrides['products.qty:row_abc'].read_only).toBe(
true,
)
})
it('per-row and column-level overrides coexist', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'products.qty', 'read_only', true)
setFieldProperty(ctx, 'products.qty', 'read_only', false, 'row_abc')
expect(ctx.fieldPropertyOverrides['products.qty'].read_only).toBe(true)
expect(ctx.fieldPropertyOverrides['products.qty:row_abc'].read_only).toBe(
false,
)
})
it('removes per-row override independently', () => {
const ctx = createCtx()
setFieldProperty(ctx, 'products.qty', 'read_only', true)
setFieldProperty(ctx, 'products.qty', 'read_only', false, 'row_abc')
removeFieldProperty(ctx, 'products.qty', 'read_only', 'row_abc')
expect(ctx.fieldPropertyOverrides['products.qty:row_abc']).toBeUndefined()
expect(ctx.fieldPropertyOverrides['products.qty'].read_only).toBe(true)
})
it('batch set with per-row', () => {
const ctx = createCtx()
setFieldProperties(
ctx,
'products.rate',
{ read_only: true, label: 'Fixed Rate' },
'row_xyz',
)
expect(ctx.fieldPropertyOverrides['products.rate:row_xyz']).toEqual({
read_only: true,
label: 'Fixed Rate',
})
})
})

View File

@@ -0,0 +1,35 @@
// callLog.js pulls in unrelated heavy dependencies (icon-resolving imports,
// a live createResource network call) that aren't wired up for vitest —
// stub them so this test exercises only getCallStatusLabel's pure logic.
vi.mock('@/utils', () => ({ formatDate: vi.fn() }))
vi.mock('@/stores/meta', () => ({
getMeta: () => ({
getFormattedPercent: vi.fn(),
getFormattedFloat: vi.fn(),
getFormattedCurrency: vi.fn(),
}),
}))
vi.mock('@/composables/useTimelinePreferences', () => ({
timestampCell: vi.fn(),
}))
import { getCallStatusLabel } from '@/utils/callLog'
describe('getCallStatusLabel', () => {
it('labels an unanswered incoming call as Missed Call', () => {
expect(getCallStatusLabel('No Answer', 'Incoming')).toBe('Missed Call')
})
it('labels an unanswered outgoing call as No Answer', () => {
expect(getCallStatusLabel('No Answer', 'Outgoing')).toBe('No Answer')
})
it('falls back to No Answer when call type is missing', () => {
expect(getCallStatusLabel('No Answer', undefined)).toBe('No Answer')
})
it('leaves other statuses unaffected by call type', () => {
expect(getCallStatusLabel('Completed', 'Incoming')).toBe('Completed')
expect(getCallStatusLabel('Completed', 'Outgoing')).toBe('Completed')
})
})

View File

@@ -0,0 +1,27 @@
import { parseLinkFilters } from '@/utils/fieldTransforms'
describe('parseLinkFilters', () => {
it('returns null for falsy input', () => {
expect(parseLinkFilters(null)).toBeNull()
expect(parseLinkFilters(undefined)).toBeNull()
expect(parseLinkFilters('')).toBeNull()
expect(parseLinkFilters(0)).toBeNull()
})
it('parses a valid JSON string', () => {
expect(parseLinkFilters('{"company":"ACME"}')).toEqual({ company: 'ACME' })
})
it('returns object as-is if already an object', () => {
const obj = { company: 'ACME', enabled: 1 }
expect(parseLinkFilters(obj)).toBe(obj)
})
it('returns null for invalid JSON string', () => {
expect(parseLinkFilters('not json')).toBeNull()
})
it('handles array JSON', () => {
expect(parseLinkFilters('[1,2]')).toEqual([1, 2])
})
})

View File

@@ -0,0 +1,274 @@
import { processField } from '@/utils/fieldTransforms'
describe('processField', () => {
// ─── Cloning ──────────────────────────────────────────────────
it('returns a new object, not the original', () => {
const raw = { fieldname: 'status', fieldtype: 'Data', label: 'Status' }
const result = processField(raw)
expect(result).not.toBe(raw)
})
it('does not mutate the original field', () => {
const raw = {
fieldname: 'status',
fieldtype: 'Select',
options: 'New\nOpen',
}
processField(raw)
expect(raw.options).toBe('New\nOpen')
expect(raw.fieldtype).toBe('Select')
})
it('does not mutate the original when overrides are applied', () => {
const raw = { fieldname: 'x', fieldtype: 'Data', label: 'Original' }
processField(raw, { propertyOverrides: { x: { label: 'Changed' } } })
expect(raw.label).toBe('Original')
})
it('returns null for null input', () => {
expect(processField(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(processField(undefined)).toBeNull()
})
// ─── Select option transform ──────────────────────────────────
it('converts Select options string to {label,value} array', () => {
const raw = {
fieldname: 'status',
fieldtype: 'Select',
options: 'New\nOpen\nClosed',
}
const result = processField(raw)
expect(result.options).toEqual([
{ label: '', value: '' },
{ label: 'New', value: 'New' },
{ label: 'Open', value: 'Open' },
{ label: 'Closed', value: 'Closed' },
])
})
it('translates Select option labels but keeps raw values', () => {
const original = globalThis.__
const dictionary = { Billing: 'Rozliczeniowy', Shipping: 'Wysyłkowy' }
globalThis.__ = (msg) => dictionary[msg] || msg
try {
const raw = {
fieldname: 'address_type',
fieldtype: 'Select',
options: 'Billing\nShipping\nOffice',
reqd: 1,
}
const result = processField(raw)
expect(result.options).toEqual([
{ label: 'Rozliczeniowy', value: 'Billing' },
{ label: 'Wysyłkowy', value: 'Shipping' },
{ label: 'Office', value: 'Office' },
])
} finally {
globalThis.__ = original
}
})
it('prepends blank option for non-required Select', () => {
const raw = {
fieldname: 'priority',
fieldtype: 'Select',
options: 'Low\nHigh',
}
const result = processField(raw)
expect(result.options[0]).toEqual({ label: '', value: '' })
expect(result.options).toHaveLength(3)
})
it('skips blank option for required Select', () => {
const raw = {
fieldname: 'status',
fieldtype: 'Select',
options: 'New\nOpen',
reqd: 1,
}
const result = processField(raw)
expect(result.options[0]).toEqual({ label: 'New', value: 'New' })
expect(result.options).toHaveLength(2)
})
it('does not prepend blank when first option is already blank', () => {
const raw = { fieldname: 'x', fieldtype: 'Select', options: '\nA\nB' }
const result = processField(raw)
expect(result.options[0]).toEqual({ label: '', value: '' })
// should NOT have two blanks
expect(result.options.filter((o) => o.value === '')).toHaveLength(1)
})
it('handles single option Select', () => {
const raw = { fieldname: 'x', fieldtype: 'Select', options: 'Only' }
const result = processField(raw)
expect(result.options).toEqual([
{ label: '', value: '' },
{ label: 'Only', value: 'Only' },
])
})
it('handles empty options string', () => {
const raw = { fieldname: 'x', fieldtype: 'Select', options: '' }
const result = processField(raw)
// '' split by \n gives [''] — first value is '' so no blank prepended
expect(result.options).toEqual([{ label: '', value: '' }])
})
it('does not transform if options is already an array', () => {
const opts = [{ label: 'A', value: 'A' }]
const raw = { fieldname: 'x', fieldtype: 'Select', options: opts }
const result = processField(raw)
expect(result.options).toEqual(opts)
})
it('does not transform options for non-Select field types', () => {
const raw = { fieldname: 'x', fieldtype: 'Data', options: 'Email' }
const result = processField(raw)
expect(result.options).toBe('Email')
})
// ─── Link→User transform ─────────────────────────────────────
it('transforms Link with options=User to fieldtype User', () => {
const raw = { fieldname: 'owner', fieldtype: 'Link', options: 'User' }
const result = processField(raw)
expect(result.fieldtype).toBe('User')
// original untouched
expect(raw.fieldtype).toBe('Link')
})
it('does not transform Link with other options', () => {
const raw = { fieldname: 'org', fieldtype: 'Link', options: 'Organization' }
const result = processField(raw)
expect(result.fieldtype).toBe('Link')
expect(result.options).toBe('Organization')
})
it('does not transform non-Link field types', () => {
const raw = { fieldname: 'x', fieldtype: 'Data', options: 'User' }
const result = processField(raw)
expect(result.fieldtype).toBe('Data')
})
// ─── Perm overrides ──────────────────────────────────────────
it('applies perm overrides', () => {
const raw = { fieldname: 'salary', fieldtype: 'Currency', read_only: 0 }
const result = processField(raw, {
permOverrides: { salary: { read_only: 1 } },
})
expect(result.read_only).toBe(1)
})
it('perm overrides do not mutate original', () => {
const raw = { fieldname: 'salary', fieldtype: 'Currency', read_only: 0 }
processField(raw, { permOverrides: { salary: { read_only: 1 } } })
expect(raw.read_only).toBe(0)
})
// ─── Script property overrides ────────────────────────────────
it('applies script property overrides', () => {
const raw = {
fieldname: 'email',
fieldtype: 'Data',
hidden: 0,
label: 'Email',
}
const result = processField(raw, {
propertyOverrides: { email: { hidden: 1, label: 'Work Email' } },
})
expect(result.hidden).toBe(1)
expect(result.label).toBe('Work Email')
})
it('script overrides win over perm overrides', () => {
const raw = { fieldname: 'salary', fieldtype: 'Currency', read_only: 0 }
const result = processField(raw, {
permOverrides: { salary: { read_only: 1 } },
propertyOverrides: { salary: { read_only: 0 } },
})
expect(result.read_only).toBe(0)
})
it('script overrides win over raw field values', () => {
const raw = { fieldname: 'x', fieldtype: 'Data', label: 'Old', hidden: 0 }
const result = processField(raw, {
propertyOverrides: { x: { label: 'New', hidden: true } },
})
expect(result.label).toBe('New')
expect(result.hidden).toBe(true)
})
it('unrelated overrides do not affect the field', () => {
const raw = { fieldname: 'email', fieldtype: 'Data', label: 'Email' }
const result = processField(raw, {
propertyOverrides: { other_field: { hidden: true } },
})
expect(result.label).toBe('Email')
expect(result.hidden).toBeUndefined()
})
// ─── Override changes Select options ──────────────────────────
it('override can replace Select options string', () => {
const raw = { fieldname: 'status', fieldtype: 'Select', options: 'A\nB' }
const result = processField(raw, {
propertyOverrides: { status: { options: 'X\nY\nZ' } },
})
expect(result.options).toEqual([
{ label: '', value: '' },
{ label: 'X', value: 'X' },
{ label: 'Y', value: 'Y' },
{ label: 'Z', value: 'Z' },
])
})
it('override reqd affects blank option prepending', () => {
const raw = {
fieldname: 'status',
fieldtype: 'Select',
options: 'A\nB',
reqd: 0,
}
const result = processField(raw, {
propertyOverrides: { status: { reqd: 1 } },
})
// reqd=1 from override → no blank option
expect(result.options[0]).toEqual({ label: 'A', value: 'A' })
})
// ─── Combined transforms ─────────────────────────────────────
it('applies overrides before Select transform', () => {
// Override changes options string, then transform converts to array
const raw = { fieldname: 's', fieldtype: 'Select', options: 'Old1\nOld2' }
const result = processField(raw, {
propertyOverrides: { s: { options: 'New1\nNew2\nNew3' } },
})
expect(result.options).toHaveLength(4) // blank + 3
expect(result.options[1].value).toBe('New1')
})
it('preserves non-overridden properties', () => {
const raw = {
fieldname: 'x',
fieldtype: 'Data',
label: 'X',
description: 'Help text',
placeholder: 'Enter X',
}
const result = processField(raw, {
propertyOverrides: { x: { label: 'Y' } },
})
expect(result.label).toBe('Y')
expect(result.description).toBe('Help text')
expect(result.placeholder).toBe('Enter X')
})
})

View File

@@ -0,0 +1,255 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
fieldLayoutDialogs,
renderFieldLayoutDialog,
} from '@/utils/renderFieldLayoutDialog'
describe('renderFieldLayoutDialog', () => {
beforeEach(() => {
fieldLayoutDialogs.value = []
})
// ── Core promise behavior ──
it('returns a Promise', () => {
const result = renderFieldLayoutDialog({ title: 'Test' })
expect(result).toBeInstanceOf(Promise)
})
it('pushes a dialog entry to fieldLayoutDialogs', () => {
renderFieldLayoutDialog({ title: 'Test Dialog' })
expect(fieldLayoutDialogs.value).toHaveLength(1)
expect(fieldLayoutDialogs.value[0].props.title).toBe('Test Dialog')
expect(fieldLayoutDialogs.value[0].key).toBeTruthy()
})
it('resolves with data when onResolve is called', async () => {
const promise = renderFieldLayoutDialog({ title: 'Test' })
fieldLayoutDialogs.value[0].props.onResolve({ name: 'Alice' })
expect(await promise).toEqual({ name: 'Alice' })
})
it('resolves with null on cancel', async () => {
const promise = renderFieldLayoutDialog({ title: 'Test' })
fieldLayoutDialogs.value[0].props.onResolve(null)
expect(await promise).toBeNull()
})
it('only resolves once even if onResolve is called multiple times', async () => {
const promise = renderFieldLayoutDialog({ title: 'Test' })
const entry = fieldLayoutDialogs.value[0]
entry.props.onResolve({ first: true })
entry.props.onResolve({ second: true })
expect(await promise).toEqual({ first: true })
})
it('removes dialog entry after resolve (with delay)', async () => {
vi.useFakeTimers()
const promise = renderFieldLayoutDialog({ title: 'Test' })
expect(fieldLayoutDialogs.value).toHaveLength(1)
fieldLayoutDialogs.value[0].props.onResolve({ done: true })
expect(fieldLayoutDialogs.value).toHaveLength(1)
vi.advanceTimersByTime(300)
expect(fieldLayoutDialogs.value).toHaveLength(0)
vi.useRealTimers()
await promise
})
// ── Options passthrough ──
it('passes all core options through to dialog props', () => {
renderFieldLayoutDialog({
title: 'My Dialog',
doctype: 'CRM Lost Reason',
size: 'lg',
defaults: { lead: 'LEAD-001' },
required: ['lost_reason'],
actions: [{ label: 'Save', variant: 'solid' }],
})
const props = fieldLayoutDialogs.value[0].props
expect(props.title).toBe('My Dialog')
expect(props.doctype).toBe('CRM Lost Reason')
expect(props.size).toBe('lg')
expect(props.defaults).toEqual({ lead: 'LEAD-001' })
expect(props.required).toEqual(['lost_reason'])
expect(props.actions).toEqual([{ label: 'Save', variant: 'solid' }])
})
it('passes fieldnames option', () => {
renderFieldLayoutDialog({
title: 'Pick Fields',
doctype: 'CRM Lead',
fieldnames: ['first_name', 'email', 'phone'],
})
const props = fieldLayoutDialogs.value[0].props
expect(props.fieldnames).toEqual(['first_name', 'email', 'phone'])
})
it('passes inline fields', () => {
renderFieldLayoutDialog({
title: 'Inline',
fields: [
{ fieldname: 'territory', fieldtype: 'Data', label: 'Territory' },
],
})
expect(fieldLayoutDialogs.value[0].props.fields).toHaveLength(1)
})
it('passes full tabs layout', () => {
const tabs = [
{
name: 'tab1',
label: 'Details',
sections: [
{
name: 'sec1',
label: 'Basic',
columns: [
{
name: 'col1',
fields: [
{ fieldname: 'name', fieldtype: 'Data', label: 'Name' },
],
},
{
name: 'col2',
fields: [
{ fieldname: 'email', fieldtype: 'Data', label: 'Email' },
],
},
],
},
],
},
]
renderFieldLayoutDialog({ title: 'Full Layout', tabs })
const props = fieldLayoutDialogs.value[0].props
expect(props.tabs).toHaveLength(1)
expect(props.tabs[0].sections[0].columns).toHaveLength(2)
})
// ── onSubmit / onCancel passthrough ──
it('passes onSubmit function through to dialog props', () => {
const handler = vi.fn()
renderFieldLayoutDialog({
title: 'Test',
fields: [{ fieldname: 'x', fieldtype: 'Data', label: 'X' }],
onSubmit: handler,
})
expect(fieldLayoutDialogs.value[0].props.onSubmit).toBe(handler)
})
it('passes onCancel function through to dialog props', () => {
const handler = vi.fn()
renderFieldLayoutDialog({
title: 'Test',
fields: [{ fieldname: 'x', fieldtype: 'Data', label: 'X' }],
onCancel: handler,
})
expect(fieldLayoutDialogs.value[0].props.onCancel).toBe(handler)
})
it('promise resolves with null when onCancel is provided and dialog is cancelled', async () => {
const cancelHandler = vi.fn()
const promise = renderFieldLayoutDialog({
title: 'Test',
onCancel: cancelHandler,
})
// Simulate cancel — onResolve called with null
fieldLayoutDialogs.value[0].props.onResolve(null)
expect(await promise).toBeNull()
})
it('promise still resolves with data even when onSubmit is provided', async () => {
const submitHandler = vi.fn()
const promise = renderFieldLayoutDialog({
title: 'Test',
onSubmit: submitHandler,
})
// Simulate submit — onResolve called with data
fieldLayoutDialogs.value[0].props.onResolve({ foo: 'bar' })
expect(await promise).toEqual({ foo: 'bar' })
})
// ── Concurrent dialogs ──
it('supports multiple concurrent dialogs', () => {
renderFieldLayoutDialog({ title: 'Dialog 1' })
renderFieldLayoutDialog({ title: 'Dialog 2' })
expect(fieldLayoutDialogs.value).toHaveLength(2)
expect(fieldLayoutDialogs.value[0].key).not.toBe(
fieldLayoutDialogs.value[1].key,
)
})
it('resolves concurrent dialogs independently', async () => {
const p1 = renderFieldLayoutDialog({ title: 'D1' })
const p2 = renderFieldLayoutDialog({ title: 'D2' })
fieldLayoutDialogs.value[1].props.onResolve({ from: 'D2' })
fieldLayoutDialogs.value[0].props.onResolve({ from: 'D1' })
expect(await p1).toEqual({ from: 'D1' })
expect(await p2).toEqual({ from: 'D2' })
})
// ── Actions passthrough ──
it('passes custom actions array', () => {
const actions = [
{ label: 'Approve', variant: 'solid' },
{ label: 'Reject', variant: 'subtle', theme: 'red' },
{ label: 'Cancel' },
]
renderFieldLayoutDialog({ title: 'Review', actions })
expect(fieldLayoutDialogs.value[0].props.actions).toEqual(actions)
})
it('works without actions or onSubmit (default Submit via promise)', () => {
renderFieldLayoutDialog({ title: 'Simple' })
const props = fieldLayoutDialogs.value[0].props
expect(props.actions).toBeUndefined()
expect(props.onSubmit).toBeUndefined()
})
// ── submitLabel / cancelLabel passthrough ──
it('passes submitLabel through to dialog props', () => {
renderFieldLayoutDialog({
title: 'Test',
fields: [{ fieldname: 'x', fieldtype: 'Data', label: 'X' }],
submitLabel: 'Save',
})
expect(fieldLayoutDialogs.value[0].props.submitLabel).toBe('Save')
})
it('passes cancelLabel through to dialog props', () => {
renderFieldLayoutDialog({
title: 'Test',
fields: [{ fieldname: 'x', fieldtype: 'Data', label: 'X' }],
cancelLabel: 'Discard',
})
expect(fieldLayoutDialogs.value[0].props.cancelLabel).toBe('Discard')
})
it('does not pass cancelLabel when not provided', () => {
renderFieldLayoutDialog({ title: 'Test' })
expect(fieldLayoutDialogs.value[0].props.cancelLabel).toBeUndefined()
})
// ── onResolve is separate from user options ──
it('onResolve does not collide with user onSubmit', async () => {
const submitFn = vi.fn()
const promise = renderFieldLayoutDialog({
title: 'Test',
onSubmit: submitFn,
})
const entry = fieldLayoutDialogs.value[0]
// onResolve is our internal hook, onSubmit is the user's
expect(entry.props.onResolve).toBeTypeOf('function')
expect(entry.props.onSubmit).toBe(submitFn)
entry.props.onResolve({ data: 1 })
expect(await promise).toEqual({ data: 1 })
})
})

View File

@@ -0,0 +1,153 @@
import { getClassNames, createDocProxy } from '@/utils/scriptHelpers'
describe('getClassNames', () => {
it('extracts single class name', () => {
expect(getClassNames('class CRMLead { }')).toEqual(['CRMLead'])
})
it('extracts multiple class names', () => {
const script = 'class CRMDeal { }\nclass CRMProducts { }'
expect(getClassNames(script)).toEqual(['CRMDeal', 'CRMProducts'])
})
it('handles class with extends', () => {
expect(getClassNames('class CRMLead extends Base { }')).toEqual(['CRMLead'])
})
it('ignores class in single-line comments', () => {
const script = '// class Ignored { }\nclass CRMLead { }'
expect(getClassNames(script)).toEqual(['CRMLead'])
})
it('ignores class in multi-line comments', () => {
const script = '/* class Ignored { } */\nclass CRMLead { }'
expect(getClassNames(script)).toEqual(['CRMLead'])
})
it('ignores class in multi-line comment spanning lines', () => {
const script = `/*
class Ignored {
onLoad() {}
}
*/
class CRMLead { }`
expect(getClassNames(script)).toEqual(['CRMLead'])
})
it('returns empty array for no classes', () => {
expect(getClassNames('const x = 1')).toEqual([])
})
it('returns empty array for empty string', () => {
expect(getClassNames('')).toEqual([])
})
it('handles class names with underscores and numbers', () => {
expect(getClassNames('class My_Class_2 { }')).toEqual(['My_Class_2'])
})
it('handles mixed comments and classes', () => {
const script = `
// class Skipped1 { }
class CRMDeal { }
/* class Skipped2 { } */
class CRMProducts { }
`
expect(getClassNames(script)).toEqual(['CRMDeal', 'CRMProducts'])
})
})
describe('createDocProxy', () => {
// ─── Read ─────────────────────────────────────────────────────
it('reads properties from source object', () => {
const data = { lead_name: 'John', status: 'New' }
const instance = {}
const proxy = createDocProxy(data, instance)
expect(proxy.lead_name).toBe('John')
expect(proxy.status).toBe('New')
})
it('reads properties from source getter function', () => {
const data = { lead_name: 'John' }
const instance = {}
const proxy = createDocProxy(() => data, instance)
expect(proxy.lead_name).toBe('John')
})
it('returns undefined for missing property', () => {
const proxy = createDocProxy({ a: 1 }, {})
expect(proxy.b).toBeUndefined()
})
it('returns undefined when source is null', () => {
const proxy = createDocProxy(() => null, {})
expect(proxy.anything).toBeUndefined()
})
// ─── Write ────────────────────────────────────────────────────
it('writes properties to source object', () => {
const data = { status: 'New' }
const proxy = createDocProxy(data, {})
proxy.status = 'Qualified'
expect(data.status).toBe('Qualified')
})
it('writes properties to source via getter function', () => {
const data = { status: 'New' }
const proxy = createDocProxy(() => data, {})
proxy.status = 'Qualified'
expect(data.status).toBe('Qualified')
})
// ─── trigger() ────────────────────────────────────────────────
it('trigger calls method on instance', () => {
const instance = {
_myMethod: vi.fn().mockReturnValue('result'),
}
const proxy = createDocProxy({ name: 'test' }, instance)
const result = proxy.trigger('_myMethod', 'arg1')
expect(instance._myMethod).toHaveBeenCalledWith('arg1')
expect(result).toBe('result')
})
it('trigger with non-existent method does not throw', () => {
const instance = {}
const proxy = createDocProxy({ name: 'test' }, instance)
expect(() => proxy.trigger('nonExistent')).not.toThrow()
})
it('trigger binds correct this context', () => {
let capturedThis = null
const instance = {
myMethod() {
capturedThis = this
},
}
const proxy = createDocProxy({ name: 'test' }, instance)
proxy.trigger('myMethod')
expect(capturedThis).toBe(instance)
})
// ─── has / in operator ────────────────────────────────────────
it('supports "in" operator', () => {
const proxy = createDocProxy({ lead_name: 'John' }, {})
expect('lead_name' in proxy).toBe(true)
expect('missing' in proxy).toBe(false)
})
it('"in" returns false when source is null', () => {
const proxy = createDocProxy(() => null, {})
expect('anything' in proxy).toBe(false)
})
// ─── ownKeys ──────────────────────────────────────────────────
it('returns own keys from source', () => {
const proxy = createDocProxy({ a: 1, b: 2 }, {})
expect(Object.keys(proxy)).toEqual(['a', 'b'])
})
})

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
let serverViews = []
vi.mock('frappe-ui', () => ({
createResource(options) {
const resource = { data: [], promise: Promise.resolve() }
resource.reload = async () => {
resource.data = options.transform(serverViews)
}
resource.reload()
return resource
},
}))
function defaultView(route_name, name) {
return { name, route_name, type: 'list', is_default: 1 }
}
async function storeWithViews(views) {
serverViews = views
const { viewsStore } = await import('@/stores/views')
return viewsStore()
}
describe('viewsStore.getDefaultView', () => {
beforeEach(() => {
vi.resetModules()
setActivePinia(createPinia())
})
it('returns the default for the requested route', async () => {
const store = await storeWithViews([
defaultView('Deals', 'My Deals'),
defaultView('Leads', 'My Leads'),
])
expect(store.getDefaultView('Deals').name).toBe('My Deals')
expect(store.getDefaultView('Contacts')).toBeNull()
})
it('picks the home default by fixed route priority, not response order', async () => {
const store = await storeWithViews([
defaultView('Tasks', 'My Tasks'),
defaultView('Deals', 'My Deals'),
defaultView('Leads', 'My Leads'),
])
expect(store.getDefaultView().name).toBe('My Leads')
})
it('falls back to sorted custom routes when no known route has a default', async () => {
const store = await storeWithViews([
defaultView('Zeta', 'Zeta View'),
defaultView('Alpha', 'Alpha View'),
])
expect(store.getDefaultView().name).toBe('Alpha View')
})
it('returns null when no default exists', async () => {
const store = await storeWithViews([
{ name: 'Plain', route_name: 'Leads', type: 'list', is_default: 0 },
])
expect(store.getDefaultView()).toBeNull()
})
})

View File

@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
// Load the inline conditional-logic parser straight out of crm_form.html (the guest
// page's security boundary) so we test the exact code that ships, not a copy.
const html = readFileSync(
join(
dirname(fileURLToPath(import.meta.url)),
'../../../crm/www/crm_form.html',
),
'utf8',
)
const src = html
.split('// [conditional-eval:start]')[1]
.split('// [conditional-eval:end]')[0]
// eslint-disable-next-line no-new-func
const { truthy } = new Function(`${src}; return { truthy };`)()
describe('crm_form.html conditional-logic evaluator', () => {
const doc = { organization: 'Acme', first_name: 'Al', n: '10', items: [1] }
describe('correctness', () => {
it('plain fieldname → truthiness', () => {
expect(truthy('organization', doc, false)).toBe(true)
expect(truthy('missing', doc, false)).toBe(false)
})
it('eval: comparisons and logic', () => {
expect(truthy('eval:doc.organization == "Acme"', doc, false)).toBe(true)
expect(truthy('eval:doc.organization == "Other"', doc, false)).toBe(false)
expect(truthy('eval:doc.n > 5', doc, false)).toBe(true)
expect(
truthy('eval:doc.first_name && doc.organization', doc, false),
).toBe(true)
expect(truthy('eval:!doc.missing', doc, false)).toBe(true)
expect(
truthy(
'eval:(doc.a || doc.organization) && doc.first_name',
doc,
false,
),
).toBe(true)
})
it('empty/blank expression → fallback', () => {
expect(truthy('', doc, true)).toBe(true)
expect(truthy(' ', doc, false)).toBe(false)
})
it('array field → non-empty truthiness', () => {
expect(truthy('items', doc, false)).toBe(true)
})
})
describe('security — never executes arbitrary JS, always falls back', () => {
const attacks = [
'eval:window',
'eval:document',
'eval:globalThis',
'eval:fetch("/x")',
'eval:alert(1)',
'eval:doc.constructor.constructor("return 1")()',
'eval:(0).constructor.constructor("return 1")()',
'eval:doc.x = 1',
]
for (const expr of attacks) {
it(`rejects ${expr}`, () => {
// any parse error → fallback is returned, so the expression never runs
expect(truthy(expr, doc, false)).toBe(false)
expect(truthy(expr, doc, true)).toBe(true)
})
}
it('cannot cause side effects on globals', () => {
globalThis.__pwned = false
truthy('eval:(globalThis.__pwned = true)', doc, false)
expect(globalThis.__pwned).toBe(false)
delete globalThis.__pwned
})
})
})