235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
/**
|
|
* Layout Editor — array 노드 에디터(빌트인 ARRAY-PROP).
|
|
*
|
|
* TabNavigation 등 배열 prop 컴포넌트는 capability `nodeEditor:{kind:"array",params:{arrayProp,
|
|
* fields,...}}` 로 props 배열(tabs/items/columns 등)을 속성 모달 [속성] 탭에서 항목 단위로
|
|
* 편집한다. 코어 빌트인 ArrayItemsEditor 가 registerCoreEditors 의 registerNodeEditor('array', ...)
|
|
* 로 일반 레지스트리에 등록되어, PropertyEditorModal 이 kind(컴포넌트명 아님)로 디스패치한다.
|
|
*
|
|
* 본 E2E 는 라이브 편집기에서:
|
|
* 1. TabNavigation 을 추가하고 선택 → 속성 모달의 array 에디터(g7le-array-editor)가 마운트되는지,
|
|
* 2. 항목 추가가 행(g7le-array-row-N)에 반영되는지,
|
|
* 3. 필드 편집(id text 필드)이 즉시 반영되는지,
|
|
* 4. 저장(PUT 200),
|
|
* 을 검증한다. 추가/삭제/정렬/필드편집/디그레이드 회귀는 단위(ArrayItemsEditor.test.tsx)가
|
|
* 잠그고, 본 E2E 는 라이브 반영·저장을 브라우저로 확인한다.
|
|
*
|
|
* @scenario array_node_editor + add_item + edit_text_field + live_persist
|
|
* @effects property_modal_dispatches_array_node_editor_in_props_tab_by_kind, add_item_appends_newitem_skeleton_patches_whole_node, text_field_updates_item_immediately, live_add_tab_edit_label_reorder_save_persists_to_user_page
|
|
*/
|
|
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
async function gotoEditor(page: Page, route = '%2F'): Promise<void> {
|
|
const token = issueToken('core.templates.layouts.edit');
|
|
await authenticatePage(page, token);
|
|
await page.goto(`/admin/layout-editor/sirsoft-basic?route=${route}`);
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
|
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
|
|
await page.waitForFunction(() => document.querySelectorAll('[data-editor-path]').length > 0, {
|
|
timeout: 20_000,
|
|
});
|
|
}
|
|
|
|
async function selectByPath(page: Page, path: string, timeout = 5_000): Promise<boolean> {
|
|
await page.evaluate((p) => {
|
|
const el = document.querySelector(`[data-editor-path="${p}"]`);
|
|
if (!el) return;
|
|
el.scrollIntoView({ block: 'center' });
|
|
const r = el.getBoundingClientRect();
|
|
const cx = r.left + Math.min(r.width / 2, 10);
|
|
const cy = r.top + Math.min(r.height / 2, 10);
|
|
for (const type of ['pointerover', 'pointermove', 'pointerdown', 'pointerup', 'click']) {
|
|
el.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: cx, clientY: cy }));
|
|
}
|
|
}, path);
|
|
return page
|
|
.waitForSelector('[data-testid="g7le-overlay-info-button"]', { timeout })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
}
|
|
|
|
async function openPropsTab(page: Page): Promise<void> {
|
|
await page.getByTestId('g7le-overlay-info-button').click();
|
|
await page.waitForSelector('[data-testid="g7le-context-menu-edit-props"]', { timeout: 5_000 });
|
|
await page.getByTestId('g7le-context-menu-edit-props').click();
|
|
await page.waitForSelector('[data-testid="g7le-property-tab-props"]', { timeout: 10_000 });
|
|
await page.getByTestId('g7le-property-tab-props').click();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
/** content root(Div) 안에 TabNavigation 을 추가하고 그 path 반환. */
|
|
async function addTabNavigation(page: Page): Promise<string> {
|
|
// 편집 가능한(ⓘ 가 뜨는) 컨테이너 Div 후보 순회 — 첫 Div 고정 선택은 루트/베이스 잠금
|
|
// 노드가 첫 후보가 되는 레이아웃 구조 변화에 깨진다(children-list-editor.spec 와 동일 패턴).
|
|
const candidates = await page.evaluate(() =>
|
|
Array.from(document.querySelectorAll('[data-editor-name="Div"]'))
|
|
.filter((e) => {
|
|
const r = e.getBoundingClientRect();
|
|
return r.width > 40 && r.height > 20;
|
|
})
|
|
.map((e) => e.getAttribute('data-editor-path') ?? '')
|
|
.filter(Boolean)
|
|
.slice(0, 10),
|
|
);
|
|
expect(candidates.length).toBeGreaterThan(0);
|
|
let containerPath: string | null = null;
|
|
for (const cand of candidates) {
|
|
if (await selectByPath(page, cand, 2_000)) {
|
|
containerPath = cand;
|
|
break;
|
|
}
|
|
}
|
|
expect(containerPath).toBeTruthy();
|
|
await page.getByTestId('g7le-toolbar-add-element').click();
|
|
await page.waitForSelector('[data-testid="g7le-palette-item-TabNavigation"]', { timeout: 10_000 });
|
|
await page.getByTestId('g7le-palette-item-TabNavigation').click();
|
|
await page.waitForTimeout(400);
|
|
return page.evaluate((C) => {
|
|
const re = new RegExp('^' + C.replace(/\./g, '\\.') + '\\.children\\.\\d+$');
|
|
const idxs = Array.from(document.querySelectorAll('[data-editor-path]'))
|
|
.map((e) => e.getAttribute('data-editor-path') ?? '')
|
|
.filter((p) => re.test(p))
|
|
.map((p) => parseInt(p.split('.').pop() as string, 10));
|
|
return C + '.children.' + Math.max(...idxs);
|
|
}, containerPath!);
|
|
}
|
|
|
|
test.describe('@layout-editor array 노드 에디터(빌트인 ARRAY-PROP)', () => {
|
|
test('TabNavigation 선택 시 array 에디터가 속성 탭에 마운트되고 항목 추가가 행 반영', async ({ page }) => {
|
|
await gotoEditor(page);
|
|
const navPath = await addTabNavigation(page);
|
|
|
|
expect(await selectByPath(page, navPath)).toBe(true);
|
|
await openPropsTab(page);
|
|
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
|
|
|
|
const rowsBefore = await page.locator('[data-testid^="g7le-array-row-"]').count();
|
|
await page.getByTestId('g7le-array-add').click();
|
|
await page.waitForTimeout(300);
|
|
const rowsAfter = await page.locator('[data-testid^="g7le-array-row-"]').count();
|
|
expect(rowsAfter).toBe(rowsBefore + 1);
|
|
});
|
|
|
|
test('항목 id text 필드 편집이 즉시 반영(입력값 유지)', async ({ page }) => {
|
|
await gotoEditor(page);
|
|
const navPath = await addTabNavigation(page);
|
|
expect(await selectByPath(page, navPath)).toBe(true);
|
|
await openPropsTab(page);
|
|
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
|
|
|
|
// 항목이 없으면 추가.
|
|
if ((await page.locator('[data-testid^="g7le-array-row-"]').count()) === 0) {
|
|
await page.getByTestId('g7le-array-add').click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
const idField = page.getByTestId('g7le-array-field-0-id');
|
|
await idField.fill('home');
|
|
await expect(idField).toHaveValue('home');
|
|
});
|
|
|
|
test('array 편집 후 저장 → PUT 200', async ({ page }) => {
|
|
test.setTimeout(60_000); // 후보 순회 + 저장 + 정리 합산 — 기본 30s 부족
|
|
await gotoEditor(page);
|
|
const navPath = await addTabNavigation(page);
|
|
expect(await selectByPath(page, navPath)).toBe(true);
|
|
await openPropsTab(page);
|
|
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
|
|
await page.getByTestId('g7le-array-add').click();
|
|
await page.waitForTimeout(300);
|
|
|
|
// 모달을 확실히 닫은 뒤(미닫힘 상태 role 매칭은 모달 안 버튼이 잡히는 flake)
|
|
// 툴바 저장 testid 클릭 (children-list-editor.spec 와 동일 패턴).
|
|
await page
|
|
.getByTestId('g7le-property-modal-done')
|
|
.click({ timeout: 2_000 })
|
|
.catch(() => undefined);
|
|
await page
|
|
.getByRole('button', { name: /^(닫기|Close)$/ })
|
|
.first()
|
|
.click({ timeout: 3_000 })
|
|
.catch(() => undefined);
|
|
await page.waitForTimeout(300);
|
|
const savePromise = page.waitForResponse(
|
|
(r) =>
|
|
/\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) &&
|
|
r.request().method() === 'PUT',
|
|
{ timeout: 15_000 },
|
|
);
|
|
await page.getByTestId('g7le-toolbar-save').click();
|
|
const saveRes = await savePromise;
|
|
expect(saveRes.status()).toBe(200);
|
|
|
|
// 정리 — 테스트가 추가한 TabNavigation 을 삭제하고 다시 저장(잔여물 누적 방지).
|
|
expect(await selectByPath(page, navPath)).toBe(true);
|
|
await page.getByTestId('g7le-overlay-info-button').click();
|
|
await page.waitForSelector('[data-testid="g7le-context-menu-delete"]', { timeout: 5_000 });
|
|
await page.getByTestId('g7le-context-menu-delete').click();
|
|
await page.waitForTimeout(400);
|
|
const cleanupPromise = page.waitForResponse(
|
|
(r) =>
|
|
/\/api\/admin\/templates\/sirsoft-basic\/layouts\//.test(r.url()) &&
|
|
r.request().method() === 'PUT',
|
|
{ timeout: 15_000 },
|
|
);
|
|
await page.getByTestId('g7le-toolbar-save').click();
|
|
expect((await cleanupPromise).status()).toBe(200);
|
|
});
|
|
|
|
// array kind `defaultItems` 시드.
|
|
// IconSelect 는 options 미지정 시 내장 기본 아이콘 20종으로 렌더한다. 종전에는 옵션
|
|
// 편집이 빈 목록에서 시작해 1개 추가가 내장 목록 전체를 교체하는 함정이었다. 스펙
|
|
// params.defaultItems 선언으로 에디터가 기본 20종을 시작 목록으로 보여주고, 추가 시
|
|
// 전체+추가분이 함께 커밋된다.
|
|
test('IconSelect 옵션 에디터 — defaultItems 20종 시드 + 추가 시 기본 목록 보존', async ({ page }) => {
|
|
// 관리자 템플릿 편집기(IconSelect 는 admin 전용 컴포넌트).
|
|
const token = issueToken('core.templates.layouts.edit');
|
|
await authenticatePage(page, token);
|
|
await page.goto('/admin/layout-editor/sirsoft-admin_basic?route=*%2Fadmin%2Fdashboard');
|
|
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
|
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
|
|
await page.waitForFunction(
|
|
() => document.querySelectorAll('[data-editor-path]').length > 0,
|
|
{ timeout: 20_000 },
|
|
);
|
|
|
|
// 가시 노드(BarChart) 선택 → 아래 삽입 팔레트 → IconSelect 추가.
|
|
const anchorPath = await page.evaluate(
|
|
() => document.querySelector('[data-editor-name="BarChart"]')?.getAttribute('data-editor-path') ?? null,
|
|
);
|
|
expect(anchorPath).toBeTruthy();
|
|
expect(await selectByPath(page, anchorPath!)).toBe(true);
|
|
await page.getByTestId('g7le-insertion-below').click();
|
|
await page.waitForSelector('[data-testid="g7le-palette-item-IconSelect"]', { timeout: 10_000 });
|
|
await page.getByTestId('g7le-palette-item-IconSelect').click();
|
|
await page.waitForTimeout(600);
|
|
const nodePath = await page.evaluate(() => {
|
|
const els = Array.from(document.querySelectorAll('[data-editor-name="IconSelect"]'));
|
|
return els[els.length - 1]?.getAttribute('data-editor-path') ?? null;
|
|
});
|
|
expect(nodePath).toBeTruthy();
|
|
expect(await selectByPath(page, nodePath!)).toBe(true);
|
|
await openPropsTab(page);
|
|
|
|
// 시드: 기본 20종이 시작 목록으로 표시(빈 목록 아님).
|
|
await expect(page.getByTestId('g7le-array-editor')).toBeVisible();
|
|
const rowsOf = () =>
|
|
page.evaluate(() => document.querySelectorAll('[data-testid^="g7le-array-row-"]').length);
|
|
expect(await rowsOf()).toBe(20);
|
|
|
|
// 추가 → 21행, 기본 목록 보존(첫 항목 유지).
|
|
await page.getByTestId('g7le-array-add').click();
|
|
await page.waitForTimeout(300);
|
|
const rows1 = await rowsOf();
|
|
expect(rows1).toBe(21);
|
|
await expect(page.locator('[data-testid="g7le-array-row-0"] input').first()).toHaveValue(
|
|
'LayoutDashboard',
|
|
);
|
|
|
|
// 정리(저장 안 함) — 추가 행 삭제로 잔여 0.
|
|
await page.getByTestId(`g7le-array-remove-${rows1 - 1}`).click();
|
|
await page.waitForTimeout(300);
|
|
expect(await rowsOf()).toBe(20);
|
|
});
|
|
});
|