feat(12.1-01): list row states from one controller call per page
- pact.ListRowStates with the fixed RowState set deleted, negative, disabled - list response meta.row_states keyed by row id; unknown values dropped - list messages rowStateDeleted, rowStateNegative, rowStateDisabled - update writes through the scope the load used, so a soft-deleted record a controller includes stays soft-deleted - DataTable row state badges and text styles - roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
||||
// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10).
|
||||
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
|
||||
// row state badges (UI-SPEC S4, D-12).
|
||||
// Fixtures are neutral acme.roster.* data; no application
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import RecordActions from '../../src/components/form/RecordActions.vue'
|
||||
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
|
||||
import ToastHost from '../../src/components/ui/Toast.vue'
|
||||
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
@@ -461,3 +463,85 @@ describe('record actions (UI-SPEC S2, D-10)', () => {
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
const rows = (wrapper: VueWrapper) => wrapper.findAll('tbody tr')
|
||||
const firstCell = (wrapper: VueWrapper, index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]!
|
||||
const badges = (wrapper: VueWrapper, index: number) =>
|
||||
firstCell(wrapper, index)
|
||||
.findAll('[data-row-state]')
|
||||
.map((badge) => [badge.attributes('data-row-state'), badge.text()])
|
||||
|
||||
it('leaves a row without states unchanged', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[0]!
|
||||
expect(row.attributes('data-row-states')).toBeUndefined()
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||
})
|
||||
|
||||
it('renders two states as text badges in the fixed order and combines their text styles', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[1]!
|
||||
expect(row.attributes('data-row-states')).toBe('deleted negative')
|
||||
expect(badges(wrapper, 1)).toEqual([
|
||||
['deleted', 'Deleted'],
|
||||
['negative', 'Blocked'],
|
||||
])
|
||||
// deleted + negative: the first-cell text is struck through and danger;
|
||||
// every cell is muted; the badges sit outside the struck text.
|
||||
const cell = firstCell(wrapper, 1)
|
||||
const link = cell.find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||
const deleted = cell.find('[data-row-state="deleted"]')
|
||||
expect(deleted.classes()).toEqual(expect.arrayContaining(['border-border-strong', 'text-muted', 'whitespace-nowrap', 'shrink-0']))
|
||||
expect(deleted.classes()).not.toContain('line-through')
|
||||
expect(cell.find('[data-row-state="negative"]').classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger']))
|
||||
// The row background is not a state: selection still tints it.
|
||||
expect(row.classes()).not.toContain('bg-danger-soft')
|
||||
})
|
||||
|
||||
it('ignores a state outside the fixed set: no badge, no class, no attribute value', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
const row = rows(wrapper)[2]!
|
||||
expect(row.attributes('data-row-states')).toBe('disabled')
|
||||
expect(badges(wrapper, 2)).toEqual([['disabled', 'Not active']])
|
||||
expect(row.html()).not.toContain('starred')
|
||||
const cell = firstCell(wrapper, 2)
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(cell.find('a').classes()).not.toContain('line-through')
|
||||
expect(cell.find('[data-row-state="disabled"]').classes()).toEqual(expect.arrayContaining(['bg-subtle', 'text-muted']))
|
||||
|
||||
const only = clone(rosterListFixture)
|
||||
only.meta.row_states = { '1': ['starred', '<b>x</b>'] }
|
||||
const unknown = await mountApp('/acme/roster/people', routes({ [`GET ${LIST}`]: { body: only } }))
|
||||
const first = rows(unknown.wrapper)[0]!
|
||||
expect(first.attributes('data-row-states')).toBeUndefined()
|
||||
expect(first.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(first.html()).not.toContain('starred')
|
||||
expect(firstCell(unknown.wrapper, 0).find('a').classes()).toContain('text-text')
|
||||
})
|
||||
|
||||
it('keeps selection and the row link working for a row with states', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
||||
await rows(wrapper)[1]!.find('td[data-select] [role="checkbox"]').trigger('click')
|
||||
expect(rows(wrapper)[1]!.classes()).toContain('bg-sel')
|
||||
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('deleted negative')
|
||||
})
|
||||
|
||||
it('renders each known state once, in the fixed order, whatever the input order', () => {
|
||||
const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Inactive' }
|
||||
const wrapper = mount(RowStateBadges, { props: { states: ['disabled', 'bogus', 'deleted', 'negative'], labels } })
|
||||
expect(wrapper.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Blocked', 'Inactive'])
|
||||
const none = mount(RowStateBadges, { props: { states: ['bogus'], labels } })
|
||||
expect(none.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(none.text()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user