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
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:
128
frontend/tests/unit/applyStateFieldOptions.test.js
Normal file
128
frontend/tests/unit/applyStateFieldOptions.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
192
frontend/tests/unit/checkMandatory.test.js
Normal file
192
frontend/tests/unit/checkMandatory.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
106
frontend/tests/unit/documentSaveErrorToast.test.js
Normal file
106
frontend/tests/unit/documentSaveErrorToast.test.js
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
125
frontend/tests/unit/fetchFrom.test.js
Normal file
125
frontend/tests/unit/fetchFrom.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
405
frontend/tests/unit/fieldLayoutTabState.test.js
Normal file
405
frontend/tests/unit/fieldLayoutTabState.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
230
frontend/tests/unit/fieldPropertyOverrides.test.js
Normal file
230
frontend/tests/unit/fieldPropertyOverrides.test.js
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
35
frontend/tests/unit/getCallStatusLabel.test.js
Normal file
35
frontend/tests/unit/getCallStatusLabel.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
27
frontend/tests/unit/parseLinkFilters.test.js
Normal file
27
frontend/tests/unit/parseLinkFilters.test.js
Normal 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])
|
||||
})
|
||||
})
|
||||
274
frontend/tests/unit/processField.test.js
Normal file
274
frontend/tests/unit/processField.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
255
frontend/tests/unit/renderFieldLayoutDialog.test.js
Normal file
255
frontend/tests/unit/renderFieldLayoutDialog.test.js
Normal 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 })
|
||||
})
|
||||
})
|
||||
153
frontend/tests/unit/scriptHelpers.test.js
Normal file
153
frontend/tests/unit/scriptHelpers.test.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
69
frontend/tests/unit/viewsStoreDefaultView.test.js
Normal file
69
frontend/tests/unit/viewsStoreDefaultView.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
78
frontend/tests/unit/webFormConditionalEval.test.js
Normal file
78
frontend/tests/unit/webFormConditionalEval.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user