* feat(components): match Figma asset browsing - Add thumbnail grid and list views grouped by source page - Support component drag insertion and main-component navigation - Keep asset previews working across document pages - Cover views, context actions, and drag-to-canvas behavior * fix(components): remove redundant Assets heading * fix(components): render crisp asset thumbnails - Render preview images at twice their display dimensions - Preserve the existing grid and list thumbnail sizes * fix(render): exclude ancestors from selection exports - Reparent selected export roots directly under the extracted page - Preserve absolute positions without drawing ancestor backgrounds - Cover transparent selection export around nested components * fix(components): address Assets panel review - Handle thumbnail export failures without stale previews - Share page lookup and thumbnail sizing across asset views - Make list actions keyboard accessible and honor requested export pages
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
|
|
async function selectedNodeSnapshot(page: Page) {
|
|
return page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
const selectedId = [...store.state.selectedIds][0]
|
|
const selected = selectedId ? store.graph.getNode(selectedId) : null
|
|
return selected
|
|
? {
|
|
id: selected.id,
|
|
type: selected.type,
|
|
parentId: selected.parentId,
|
|
componentId: selected.componentId,
|
|
pageId: store.state.currentPageId,
|
|
width: selected.width,
|
|
childTexts: store.graph
|
|
.getChildren(selected.id)
|
|
.filter((child) => child.type === 'TEXT')
|
|
.map((child) => child.text)
|
|
}
|
|
: null
|
|
})
|
|
}
|
|
|
|
test('assets panel groups component sets and inserts the default variant', async ({ page }) => {
|
|
const canvas = new CanvasHelper(page)
|
|
await page.goto('/?test')
|
|
await canvas.waitForInit()
|
|
|
|
const ids = await page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
|
|
const pageNode = store.graph.getNode(store.state.currentPageId)
|
|
if (!pageNode) throw new Error('Current page not found')
|
|
|
|
const set = store.graph.createNode('COMPONENT_SET', pageNode.id, {
|
|
name: 'Button',
|
|
x: 80,
|
|
y: 80,
|
|
width: 260,
|
|
height: 100,
|
|
sourceLibraryKey: 'lk-test-library',
|
|
symbolDescription: 'Reusable button component',
|
|
symbolLinks: [{ uri: 'https://example.com/button', displayName: 'Button docs' }],
|
|
componentPropertyDefinitions: [
|
|
{
|
|
id: 'prop:type',
|
|
name: 'Type',
|
|
type: 'VARIANT',
|
|
defaultValue: 'Secondary',
|
|
variantOptions: ['Primary', 'Secondary']
|
|
}
|
|
]
|
|
})
|
|
const primary = store.graph.createNode('COMPONENT', set.id, {
|
|
name: 'Type=Primary',
|
|
x: 0,
|
|
y: 0,
|
|
width: 96,
|
|
height: 40,
|
|
componentPropertyValues: { Type: 'Primary' }
|
|
})
|
|
store.graph.createNode('TEXT', primary.id, {
|
|
name: 'Label',
|
|
text: 'Primary',
|
|
width: 72,
|
|
height: 20
|
|
})
|
|
const secondary = store.graph.createNode('COMPONENT', set.id, {
|
|
name: 'Type=Secondary',
|
|
x: 120,
|
|
y: 0,
|
|
width: 132,
|
|
height: 40,
|
|
componentPropertyValues: { Type: 'Secondary' }
|
|
})
|
|
store.graph.createNode('TEXT', secondary.id, {
|
|
name: 'Label',
|
|
text: 'Secondary',
|
|
width: 96,
|
|
height: 20
|
|
})
|
|
const duplicateSecondary = store.graph.createNode('COMPONENT', set.id, {
|
|
name: 'Type=Secondary duplicate',
|
|
x: 280,
|
|
y: 0,
|
|
width: 132,
|
|
height: 40,
|
|
componentPropertyValues: { Type: 'Secondary' }
|
|
})
|
|
const card = store.graph.createNode('COMPONENT', pageNode.id, {
|
|
name: 'Card',
|
|
x: 80,
|
|
y: 240,
|
|
width: 160,
|
|
height: 100
|
|
})
|
|
store.requestRender()
|
|
return {
|
|
setId: set.id,
|
|
primaryId: primary.id,
|
|
secondaryId: secondary.id,
|
|
duplicateSecondaryId: duplicateSecondary.id,
|
|
cardId: card.id
|
|
}
|
|
})
|
|
await canvas.waitForRender()
|
|
|
|
await page.getByTestId('left-panel-assets-tab').click()
|
|
await page.getByRole('button', { name: 'List view' }).click()
|
|
|
|
const assetsPanel = page.getByTestId('assets-panel')
|
|
const assetItems = page.getByTestId('asset-item')
|
|
await expect(assetItems).toHaveCount(2)
|
|
await expect(assetsPanel).toContainText('Button')
|
|
await expect(assetsPanel).toContainText('Card')
|
|
await expect(assetsPanel).not.toContainText('Type=Primary')
|
|
await expect(assetsPanel).not.toContainText('Type=Secondary')
|
|
const buttonAsset = page.locator(`[data-asset-id="${ids.setId}"]`)
|
|
await expect(buttonAsset.getByTestId('asset-variant-summary')).toContainText('Type')
|
|
await expect(buttonAsset.getByTestId('asset-library-badge')).toContainText('Library')
|
|
await expect(buttonAsset.getByTestId('asset-description')).toContainText(
|
|
'Reusable button component'
|
|
)
|
|
await expect(buttonAsset.getByTestId('asset-docs')).toBeVisible()
|
|
await expect(buttonAsset.getByTestId('asset-variant-conflict')).toContainText(
|
|
'Duplicate variant values'
|
|
)
|
|
|
|
await buttonAsset.click()
|
|
const details = page.getByTestId('asset-details-dialog')
|
|
await expect(details).toBeVisible()
|
|
await expect(details).toContainText('Button')
|
|
await expect(details.getByTestId('asset-details-preview')).toBeVisible()
|
|
await expect(details.getByTestId('asset-details-preview-image')).toBeVisible()
|
|
await expect(details.getByTestId('asset-details-description')).toContainText(
|
|
'Reusable button component'
|
|
)
|
|
await expect(details.getByTestId('asset-details-library')).toContainText('lk-test-library')
|
|
await expect(details.getByTestId('asset-details-docs')).toBeVisible()
|
|
await expect(details.getByTestId('asset-details-property')).toContainText('Type')
|
|
await page.getByTestId('asset-details-close').click()
|
|
await expect(details).toBeHidden()
|
|
|
|
await page.getByTestId('assets-search').fill('card')
|
|
await expect(assetItems).toHaveCount(1)
|
|
await expect(assetsPanel).toContainText('Card')
|
|
await page.locator(`[data-asset-id="${ids.cardId}"]`).getByTestId('asset-insert').click()
|
|
await canvas.waitForRender()
|
|
|
|
const cardInstance = await selectedNodeSnapshot(page)
|
|
expect(cardInstance?.type).toBe('INSTANCE')
|
|
expect(cardInstance?.componentId).toBe(ids.cardId)
|
|
|
|
await page.getByTestId('assets-search').fill('missing asset')
|
|
await expect(page.getByTestId('assets-empty')).toBeVisible()
|
|
await expect(assetItems).toHaveCount(0)
|
|
|
|
await page.getByTestId('assets-search').fill('button')
|
|
await expect(assetItems).toHaveCount(1)
|
|
await page.locator(`[data-asset-id="${ids.setId}"]`).getByTestId('asset-insert').click()
|
|
await canvas.waitForRender()
|
|
|
|
const inserted = await selectedNodeSnapshot(page)
|
|
|
|
expect(inserted?.type).toBe('INSTANCE')
|
|
expect(inserted?.componentId).toBe(ids.secondaryId)
|
|
expect(inserted?.parentId).toBe(inserted?.pageId)
|
|
expect(inserted?.width).toBe(132)
|
|
expect(inserted?.childTexts).toEqual(['Secondary'])
|
|
|
|
const variantSection = page.getByRole('region', { name: 'Variants' })
|
|
await expect(variantSection).toBeVisible()
|
|
|
|
await variantSection.getByRole('combobox', { name: 'Type' }).click()
|
|
await page.getByRole('option', { name: 'Primary' }).click()
|
|
|
|
expectDefined(inserted?.id, 'inserted instance id')
|
|
const switched = await selectedNodeSnapshot(page)
|
|
expect(switched?.componentId).toBe(ids.primaryId)
|
|
expect(switched?.width).toBe(96)
|
|
expect(switched?.childTexts).toEqual(['Primary'])
|
|
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('assets panel supports Figma-style views, component actions, and canvas drag', async ({
|
|
page
|
|
}) => {
|
|
const canvas = new CanvasHelper(page)
|
|
await page.goto('/?test')
|
|
await canvas.waitForInit()
|
|
|
|
const setup = await page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.state.panX = 0
|
|
store.state.panY = 0
|
|
store.state.zoom = 1
|
|
const currentPage = store.graph.getNode(store.state.currentPageId)
|
|
if (!currentPage) throw new Error('Current page not found')
|
|
const secondPage = store.graph.addPage('Components')
|
|
const component = store.graph.createNode('COMPONENT', secondPage.id, {
|
|
name: 'Toolbar button',
|
|
x: 100,
|
|
y: 120,
|
|
width: 96,
|
|
height: 40
|
|
})
|
|
store.requestRender()
|
|
return { currentPageId: currentPage.id, secondPageId: secondPage.id, componentId: component.id }
|
|
})
|
|
await canvas.waitForRender()
|
|
|
|
await page.getByTestId('left-panel-assets-tab').click()
|
|
const asset = page.locator(`[data-asset-id="${setup.componentId}"]`)
|
|
await expect(page.getByRole('heading', { name: 'Components' })).toBeVisible()
|
|
await expect(asset.locator('[data-slot="asset-thumbnail"]')).toHaveClass(/size-24/)
|
|
|
|
await page.getByRole('button', { name: 'List view' }).click()
|
|
await expect(asset.locator('[data-slot="asset-thumbnail"]')).toHaveClass(/size-10/)
|
|
|
|
await asset.click({ button: 'right' })
|
|
await page.getByTestId('asset-context-go-to-main').click()
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
return {
|
|
pageId: store.state.currentPageId,
|
|
selectedId: [...store.state.selectedIds][0]
|
|
}
|
|
})
|
|
)
|
|
.toEqual({ pageId: setup.secondPageId, selectedId: setup.componentId })
|
|
|
|
await page.getByTestId('left-panel-assets-tab').click()
|
|
const canvasBox = expectDefined(await canvas.canvas.boundingBox(), 'canvas bounds')
|
|
await page
|
|
.locator(`[data-asset-id="${setup.componentId}"]`)
|
|
.dragTo(canvas.canvas, { targetPosition: { x: canvasBox.width / 2, y: canvasBox.height / 2 } })
|
|
await canvas.waitForRender()
|
|
|
|
const inserted = await selectedNodeSnapshot(page)
|
|
expect(inserted?.type).toBe('INSTANCE')
|
|
expect(inserted?.componentId).toBe(setup.componentId)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('assets insertion accounts for entered container coordinates', async ({ page }) => {
|
|
const canvas = new CanvasHelper(page)
|
|
await page.goto('/?test')
|
|
await canvas.waitForInit()
|
|
|
|
const setup = await page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.state.panX = 0
|
|
store.state.panY = 0
|
|
store.state.zoom = 1
|
|
const pageNode = store.graph.getNode(store.state.currentPageId)
|
|
if (!pageNode) throw new Error('Current page not found')
|
|
const frame = store.graph.createNode('FRAME', pageNode.id, {
|
|
name: 'Target Frame',
|
|
x: 200,
|
|
y: 150,
|
|
width: 300,
|
|
height: 240
|
|
})
|
|
const component = store.graph.createNode('COMPONENT', pageNode.id, {
|
|
name: 'Panel Card',
|
|
x: 40,
|
|
y: 40,
|
|
width: 120,
|
|
height: 60
|
|
})
|
|
store.state.enteredContainerId = frame.id
|
|
store.requestRender()
|
|
return { frameId: frame.id, componentId: component.id }
|
|
})
|
|
await canvas.waitForRender()
|
|
|
|
await page.getByTestId('left-panel-assets-tab').click()
|
|
await page.getByRole('button', { name: 'List view' }).click()
|
|
await page.locator(`[data-asset-id="${setup.componentId}"]`).getByTestId('asset-insert').click()
|
|
await canvas.waitForRender()
|
|
|
|
const inserted = await page.evaluate(() => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
const selectedId = [...store.state.selectedIds][0]
|
|
const selected = selectedId ? store.graph.getNode(selectedId) : null
|
|
if (!selected) return null
|
|
const abs = store.graph.getAbsolutePosition(selected.id)
|
|
const center = store.screenToCanvas(
|
|
...(Object.values(store.viewportCanvasCenter()) as [number, number])
|
|
)
|
|
return {
|
|
parentId: selected.parentId,
|
|
centerX: abs.x + selected.width / 2,
|
|
centerY: abs.y + selected.height / 2,
|
|
expectedCenterX: center.x,
|
|
expectedCenterY: center.y
|
|
}
|
|
})
|
|
|
|
expect(inserted?.parentId).toBe(setup.frameId)
|
|
expect(inserted?.centerX).toBeCloseTo(inserted?.expectedCenterX ?? 0, 1)
|
|
expect(inserted?.centerY).toBeCloseTo(inserted?.expectedCenterY ?? 0, 1)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('demo exposes component set assets', async ({ page }) => {
|
|
const canvas = new CanvasHelper(page)
|
|
await page.goto('/demo')
|
|
await canvas.waitForInit()
|
|
|
|
await page.getByTestId('left-panel-assets-tab').click()
|
|
await page.getByRole('button', { name: 'List view' }).click()
|
|
const assetsPanel = page.getByTestId('assets-panel')
|
|
|
|
await expect(assetsPanel).toContainText('Button')
|
|
await expect(assetsPanel).toContainText('2 variants · Variant')
|
|
await expect(assetsPanel).toContainText('Avatar')
|
|
await expect(assetsPanel).not.toContainText('Button/Primary')
|
|
|
|
canvas.assertNoErrors()
|
|
})
|