fix(admin_basic): 레이아웃 코드 편집 화면의 패널 폭·목록 갱신·버전 이력 도달 범위

리베이스 결과를 브라우저로 검수하다 같은 화면에서 셋을 확인했다. 셋 다 응답에도
콘솔에도 신호가 없고 화면에서만 드러나는 종류다.

- 두 카드 어디에도 폭 규약이 없어 flex 가 max-content 비율로 폭을 나눴다. 모듈
 파티션 레이아웃의 공백 없는 긴 파일명이 목록의 max-content 를 밀어 올려 편집기가
 절반 이하로 눌렸다(실측 목록 814 / 편집기 473 → 384 / 903). 이름은 자르지 않고
 접는다 — 잘라내면 어느 파티션인지 구분할 수 없다.
- 저장 뒤 상세만 다시 불러서, 방금 저장한 파일의 목록 행이 저장 전 설명·크기·수정일을
 그대로 달고 있었다. 목록 응답이 본문을 싣지 않게 된 뒤로 재조회가 수십 KB 라
 매 저장마다 부른다.
- 서버가 버전 목록에 기본 상한 100 을 도입했는데 이 화면에는 limit 도 '더 보기' 도
 없어 상한 밖 버전이 도달 불가였다(실측 114건 중 14건). 페이징이 위지윅 편집기에만
 들어가 상한 도입이 이 화면에서는 그대로 기능 축소가 됐다.

회귀는 레이아웃 구조(5건)와 브라우저(6건) 양쪽에서 잠근다. 브라우저 쪽은 수정을
되돌린 상태로 fail 을 먼저 확인했다.
This commit is contained in:
HeuJung
2026-08-06 09:02:00 +09:00
parent b5c0ec1178
commit 4f6fced2db
11 changed files with 599 additions and 28 deletions
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.6] - 2026-08-05
### Added
- 레이아웃 버전 이력의 「이전 버전 더 보기」 버튼 문구 일본어 번역 추가 — 기본 표시 개수를 넘는 오래된 버전을 여는 버튼이 일본어 로케일에서도 정상 표기됩니다.
## [1.0.5] - 2026-08-01
### Added
@@ -2159,7 +2159,9 @@
"saved_by": "保存者:{{name}}",
"lines": "行",
"modified": "編集",
"chars": "個"
"chars": "個",
"load_more": "以前のバージョンをもっと見る",
"limit_reached": "表示できる最大件数に達しました。"
},
"wysiwyg_select_layout_title": "ウィジウィグ編集 - レイアウト選択",
"wysiwyg_select_layout_description": "編集するレイアウトを選択してください。選択したレイアウトが新しいタブでウィジウィグ編集モードで開きます。",
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
@@ -32,6 +32,9 @@
### Fixed
- 레이아웃 편집 화면에서 파일 목록이 편집기보다 넓게 자리를 차지해 코드가 좁게 보이던 문제를 수정했습니다. 파일 이름이 긴 레이아웃(모듈이 제공하는 부분 화면 등)이 많을수록 편집 영역이 좁아졌습니다. 이제 파일 목록은 일정한 너비를 쓰고 남는 공간은 모두 편집기가 사용하며, 긴 이름은 잘리지 않고 줄바꿈되어 표시됩니다.
- 레이아웃을 저장해도 왼쪽 파일 목록의 설명·크기·수정일이 저장 전 값 그대로 남아 있던 문제를 수정했습니다. 이제 저장 즉시 목록에도 반영됩니다.
- 레이아웃 버전 이력에서 최근 100개까지만 볼 수 있고 그보다 오래된 버전은 열 수 없던 문제를 수정했습니다. 이력 창 아래에 「이전 버전 더 보기」를 추가해 오래된 버전까지 확인하고 복구할 수 있습니다.
- 표와 카드 목록에서 항목별 표시 조건이 서로 다르게 판정되던 문제를 수정했습니다. 같은 조건을 써도 표에서는 항상 보이고 카드에서는 아무것도 보이지 않는 식으로 결과가 갈렸으며, 특히 화면 상태값을 참조하는 조건에서 그랬습니다. 이제 두 목록 모두 다른 화면 요소와 같은 기준으로 조건을 판정합니다.
- 표 하단 합계 영역에 넣은 내용이 표 본문과 다른 방식으로 처리되던 문제를 수정했습니다. 합계 영역도 각 항목과 같은 규칙으로 값을 해석합니다.
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 수정했습니다. 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다.
@@ -0,0 +1,195 @@
/**
* @file admin-template-layout-edit-panels-and-versions.test.tsx
* @description 레이아웃 코드 편집 화면 — 패널 폭 규약 / 저장 후 목록 갱신 / 버전 이력 상한 회귀 테스트
*
* 브라우저 실측(2026-08-05)에서 이 화면의 결함 3건이 드러났다. 셋 다 응답이나 콘솔에는
* 아무 신호가 없고 화면에서만 드러나는 종류라, 계약을 레이아웃 JSON 수준에서 고정한다.
*
* 1. **패널 폭 규약 부재** — 파일 목록과 편집기 카드 어느 쪽에도 폭 지정이 없어 flex 가
* 두 카드의 max-content 비율로 폭을 나눴다. 모듈 파티션 레이아웃 이름은 공백이 없는
* 80자 이상이라 목록의 max-content 를 900px 이상으로 밀어 올렸고, 편집기가 473px 로
* 눌렸다(실측 목록 814 / 편집기 473). 이름이 길어질수록 편집기가 좁아지는 구조다.
*
* 2. **저장 후 목록 미갱신** — 저장 성공 시 상세(`current_layout`)만 다시 불러서, 방금
* 저장한 파일의 목록 행이 저장 전 설명·크기·수정일을 그대로 달고 있었다. 목록 응답이
* 본문을 싣지 않게 된 뒤로 재조회가 수십 KB 라 매 저장마다 불러도 된다.
*
* 3. **버전 이력 상한 도달 불가** — 서버가 버전 목록에 기본 상한 100 을 도입(#518)했는데
* 이 화면의 데이터소스는 `limit` 도 '더 보기' 도 없었다. 실측: DB 114건 중 100건만
* 내려오고 나머지 14건은 어떤 조작으로도 열 수 없었다.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const LAYOUT = path.resolve(__dirname, '../../layouts/admin_template_layout_edit.json');
const VERSION_MODAL = path.resolve(
__dirname,
'../../layouts/partials/admin_template_layout_edit/_modal_version_history.json',
);
/** 서버 `LayoutVersionListRequest` 의 DEFAULT_LIMIT / MAX_LIMIT 과 같아야 하는 값 */
const VERSION_PAGE_SIZE = 100;
const VERSION_MAX_LIMIT = 500;
/**
* JSON 레이아웃을 읽어 파싱합니다.
*
* @param file 대상 파일 절대경로
* @returns 파싱된 레이아웃 객체
*/
function readLayout(file: string): any {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}
/**
* 트리에서 조건을 만족하는 첫 노드를 찾습니다.
*
* @param node 탐색 시작 노드
* @param predicate 판정 함수
* @returns 찾은 노드 또는 null
*/
function findNode(node: any, predicate: (n: any) => boolean): any | null {
if (!node || typeof node !== 'object') return null;
if (!Array.isArray(node) && predicate(node)) return node;
for (const value of Array.isArray(node) ? node : Object.values(node)) {
const hit = findNode(value, predicate);
if (hit) return hit;
}
return null;
}
/**
* 트리에서 조건을 만족하는 모든 노드를 모읍니다.
*
* @param node 탐색 시작 노드
* @param predicate 판정 함수
* @returns 찾은 노드 배열
*/
function collectNodes(node: any, predicate: (n: any) => boolean): any[] {
const found: any[] = [];
const walk = (n: any): void => {
if (!n || typeof n !== 'object') return;
if (!Array.isArray(n) && predicate(n)) found.push(n);
for (const value of Array.isArray(n) ? n : Object.values(n)) walk(value);
};
walk(node);
return found;
}
describe('레이아웃 코드 편집 화면 — 패널 폭 규약', () => {
it('파일 목록과 편집기가 각자 폭 규약을 갖는다 (내용 길이로 폭이 갈리지 않는다)', () => {
const layout = readLayout(LAYOUT);
const listCard = findNode(layout, (n) => n.id === 'file_list_card');
const editorCard = findNode(layout, (n) => n.id === 'editor_card');
expect(listCard, 'file_list_card 노드가 사라졌다').toBeTruthy();
expect(editorCard, 'editor_card 노드가 사라졌다').toBeTruthy();
const listClass = String(listCard.props?.className ?? '');
const editorClass = String(editorCard.props?.className ?? '');
// 목록: 고정 폭 + 줄어들지 않음
expect(listClass, '파일 목록에 고정 폭이 없으면 긴 파일명이 편집기 폭을 빼앗는다').toMatch(/\bw-\d+\b/);
expect(listClass, '파일 목록이 shrink 되면 고정 폭 선언이 무력해진다').toContain('shrink-0');
// 편집기: 남는 폭 전부 + 최소폭 해제
expect(editorClass, '편집기가 flex-1 이 아니면 남는 폭을 가져가지 못한다').toContain('flex-1');
expect(editorClass, 'min-w-0 이 없으면 코드의 긴 줄이 편집기 최소폭을 밀어 올린다').toContain('min-w-0');
});
it('파일명과 설명이 줄바꿈되어 목록 폭을 넘지 않는다', () => {
const layout = readLayout(LAYOUT);
// 파일명 노드 — `{{file.name}}.json`
const nameNode = findNode(layout, (n) => typeof n.text === 'string' && n.text.includes('{{file.name}}.json'));
expect(nameNode, '파일명 노드를 찾지 못했다').toBeTruthy();
expect(
String(nameNode.props?.className ?? ''),
'모듈 파티션 이름은 공백이 없어 break-all 없이는 끊기지 않는다',
).toContain('break-all');
// 설명 노드 — 설명이 없으면 이름으로 폴백하므로 같은 규칙이 필요하다
const descNode = findNode(
layout,
(n) => typeof n.text === 'string' && n.text.includes('file.description') && n.text.includes('file.name'),
);
expect(descNode, '설명 노드를 찾지 못했다').toBeTruthy();
expect(
String(descNode.props?.className ?? ''),
'설명은 이름으로 폴백하므로 파일명과 같은 줄바꿈 규칙이 필요하다',
).toContain('break-all');
});
});
describe('레이아웃 코드 편집 화면 — 저장 후 목록 갱신', () => {
it('저장 성공 시 상세와 목록을 모두 다시 부른다', () => {
const layout = readLayout(LAYOUT);
const saveCall = findNode(
layout,
(n) => n.handler === 'apiCall' && n.params?.method === 'PUT' && String(n.target ?? '').includes('/layouts/'),
);
expect(saveCall, '저장 apiCall 액션을 찾지 못했다').toBeTruthy();
const refetched = collectNodes(saveCall.onSuccess ?? [], (n) => n.handler === 'refetchDataSource').map(
(n) => n.params?.dataSourceId,
);
expect(refetched, '저장 후 상세를 다시 부르지 않으면 lock_version 이 낡는다').toContain('current_layout');
expect(
refetched,
'저장 후 목록을 다시 부르지 않으면 방금 저장한 행이 저장 전 설명·크기·수정일을 그대로 단다',
).toContain('layout_files');
});
});
describe('레이아웃 코드 편집 화면 — 버전 이력 상한', () => {
it('버전 목록 조회에 limit 이 붙고 열 때마다 첫 묶음으로 초기화된다', () => {
const layout = readLayout(LAYOUT);
const source = (layout.data_sources ?? []).find((d: any) => d.id === 'layout_versions');
expect(source, 'layout_versions 데이터소스가 사라졌다').toBeTruthy();
expect(
String(source.endpoint),
'limit 이 없으면 서버 기본 상한을 넘는 버전은 어떤 조작으로도 열 수 없다',
).toContain('limit=');
// 모달 진입 시 상한 초기화 — 남겨두면 다른 레이아웃 이력 조회로 이월된다
const openSequence = collectNodes(
layout,
(n) => n.handler === 'setState' && n.params?.layoutVersionLimit !== undefined,
);
expect(openSequence.length, '모달을 열 때 조회 상한을 초기화하지 않는다').toBeGreaterThan(0);
expect(openSequence.some((n) => n.params.layoutVersionLimit === VERSION_PAGE_SIZE)).toBe(true);
});
it('더 보기 버튼이 상한을 한 묶음씩 넓히고 서버 상한에서 멈춘다', () => {
const modal = readLayout(VERSION_MODAL);
const loadMore = findNode(modal, (n) => n.props?.['data-testid'] === 'layout-version-history-load-more');
expect(loadMore, '더 보기 버튼이 없으면 상한 밖 버전은 도달 불가다').toBeTruthy();
const wrapper = findNode(
modal,
(n) => typeof n.if === 'string' && n.if.includes('layoutVersionLimit') && n.if.includes('layout_versions'),
);
expect(wrapper, '더 보기 노출 조건이 없다').toBeTruthy();
expect(
String(wrapper.if),
`서버 상한(${VERSION_MAX_LIMIT})에서 멈추지 않으면 그 이상 요청이 422 가 되어 목록 전체가 사라진다`,
).toContain(String(VERSION_MAX_LIMIT));
const raw = JSON.stringify(loadMore);
expect(raw, '더 보기가 상한을 넓히지 않는다').toContain('layoutVersionLimit');
expect(raw, '넓힌 뒤 다시 조회하지 않으면 화면이 그대로다').toContain('layout_versions');
expect(raw, `한 묶음(${VERSION_PAGE_SIZE})씩 넓혀야 한다`).toContain(String(VERSION_PAGE_SIZE));
});
});
@@ -2156,7 +2156,9 @@
"saved_by": "Saved by: {{name}}",
"lines": "lines",
"modified": "modified",
"chars": "chars"
"chars": "chars",
"load_more": "Load older versions",
"limit_reached": "Reached the maximum number of versions that can be listed."
},
"extension_version_history": {
"title": "Extension Version History",
@@ -2160,7 +2160,9 @@
"saved_by": "저장자: {{name}}",
"lines": "라인",
"modified": "수정",
"chars": "자"
"chars": "자",
"load_more": "이전 버전 더 보기",
"limit_reached": "표시할 수 있는 최대 개수에 도달했습니다."
},
"extension_version_history": {
"title": "확장 버전 히스토리",
@@ -76,7 +76,8 @@
"id": "layout_versions",
"label_key": "$t:editor.data_source.layout_versions",
"type": "api",
"endpoint": "/api/admin/templates/{{route.identifier}}/layouts/{{_global.selectedLayoutName}}/versions",
"comment": "limit 는 서버 기본 상한(100)과 같은 값에서 출발해 '더 보기' 마다 한 묶음씩 넓힌다. 서버가 상한 조회만 지원하므로(#518) 이 파라미터가 없으면 100건을 넘는 버전은 이 화면에서 도달할 수 없다. 상한 500 은 서버 LayoutVersionListRequest::MAX_LIMIT 과 같아야 하며, 넘기면 목록 전체가 422 가 된다.",
"endpoint": "/api/admin/templates/{{route.identifier}}/layouts/{{_global.selectedLayoutName}}/versions?limit={{_global.layoutVersionLimit ?? 100}}",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
@@ -229,7 +230,7 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-eye",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -312,7 +313,7 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-eye",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -330,8 +331,10 @@
"name": "Button",
"if": "{{_global.selectedNodeKind !== 'extension' && current_layout?.data?.abilities?.can_update !== false}}",
"props": {
"type": "button",
"className": "flex-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50",
"disabled": "{{_global.isSaving}}"
"disabled": "{{_global.isSaving}}",
"data-testid": "layout-save"
},
"actions": [
{
@@ -376,6 +379,13 @@
"params": {
"dataSourceId": "current_layout"
}
},
{
"comment": "목록도 함께 다시 부른다 — 파일 목록은 저장으로 바뀌는 값(설명·크기·수정일)을 표시하므로, 상세만 갱신하면 방금 저장한 파일의 행이 저장 전 값으로 남는다. 목록 응답이 본문을 싣지 않게 된 뒤로 재조회 비용이 수십 KB 수준이라 매 저장마다 불러도 된다.",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "layout_files"
}
}
],
"onError": [
@@ -405,7 +415,7 @@
"if": "{{!_global.isSaving}}",
"props": {
"name": "fa-regular fa-floppy-disk",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -510,7 +520,7 @@
"if": "{{!_global.isSaving}}",
"props": {
"name": "fa-regular fa-floppy-disk",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -565,7 +575,7 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-folder-open",
"className": "text-secondary w-5 h-5"
"className": "text-secondary text-xl"
}
},
{
@@ -763,8 +773,9 @@
"id": "file_list_card",
"type": "basic",
"name": "Div",
"comment": "파일 목록은 고정 폭(w-96)이다. 폭 규약이 없으면 flex 가 두 카드의 max-content 비율로 나누는데, 모듈 파티션 레이아웃의 긴 파일명(줄바꿈 지점 없는 80자 이상)이 목록의 max-content 를 900px 이상으로 밀어 올려 편집기가 절반 이하로 눌린다.",
"props": {
"className": "admin-card"
"className": "admin-card w-96 shrink-0"
},
"responsive": {
"portable": {
@@ -793,7 +804,7 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-folder-open",
"className": "text-secondary w-5 h-5"
"className": "text-secondary text-xl"
}
},
{
@@ -895,7 +906,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
"className": "flex-center gap-2 min-w-0"
},
"children": [
{
@@ -903,14 +914,15 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-file-code",
"className": "w-4 h-4 text-gray-400 dark:text-gray-500"
"className": "text-base text-gray-400 dark:text-gray-500"
}
},
{
"type": "basic",
"name": "Span",
"comment": "break-all — 모듈 파티션 레이아웃 이름은 공백이 없어 기본 줄바꿈 규칙으로는 끊기지 않는다. 자르지 않고 접어서 전체 이름을 보이게 한다(잘라내면 어느 파티션인지 구분 불가).",
"props": {
"className": "text-primary font-medium text-sm"
"className": "text-primary font-medium text-sm break-all"
},
"text": "{{file.name}}.json"
},
@@ -928,8 +940,9 @@
{
"type": "basic",
"name": "P",
"comment": "설명이 없는 레이아웃은 이름으로 폴백하므로 파일명과 같은 줄바꿈 규칙이 필요하다. break-all 이 없으면 모듈 파티션 이름이 목록에 가로 스크롤을 만든다.",
"props": {
"className": "form-hint"
"className": "form-hint break-all"
},
"text": "{{raw:file.description || file.name}}"
},
@@ -1008,7 +1021,7 @@
"name": "Icon",
"props": {
"name": "{{extGroup.source_type === 'plugin' ? 'fa-solid fa-plug' : 'fa-solid fa-puzzle-piece'}}",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
}
},
{
@@ -1090,7 +1103,7 @@
"name": "Icon",
"props": {
"name": "fa-solid fa-circle-plus",
"className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500"
"className": "text-sm text-gray-400 dark:text-gray-500"
}
},
{
@@ -1176,8 +1189,9 @@
"id": "editor_card",
"type": "basic",
"name": "Div",
"comment": "편집기는 남는 폭을 전부 가져간다. min-w-0 이 없으면 코드 영역의 긴 줄이 flex 아이템의 최소 폭을 밀어 올려 목록 쪽으로 폭을 되돌려준다.",
"props": {
"className": "admin-card"
"className": "admin-card flex-1 min-w-0"
},
"children": [
{
@@ -1217,7 +1231,7 @@
"name": "Icon",
"props": {
"name": "fa-regular fa-file-code",
"className": "text-secondary w-5 h-5"
"className": "text-secondary text-xl"
}
},
{
@@ -1297,7 +1311,9 @@
"type": "basic",
"name": "Button",
"props": {
"className": "flex-center gap-2 px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-body rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
"type": "button",
"className": "flex-center gap-2 px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-body rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",
"data-testid": "layout-version-history-open"
},
"actions": [
{
@@ -1321,6 +1337,14 @@
},
{
"then": [
{
"comment": "모달을 열 때마다 조회 상한을 첫 묶음으로 되돌린다. 남겨두면 이전에 넓혀 둔 상한이 다른 레이아웃의 이력 조회에 그대로 이월된다.",
"handler": "setState",
"params": {
"target": "global",
"layoutVersionLimit": 100
}
},
{
"handler": "refetchDataSource",
"params": {
@@ -1342,7 +1366,7 @@
"name": "Icon",
"props": {
"name": "fa-solid fa-clock-rotate-left",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -1408,7 +1432,7 @@
"name": "Icon",
"props": {
"name": "fa-solid fa-circle-info",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -23,7 +23,8 @@
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-3 max-h-96 overflow-y-auto"
"className": "space-y-3 max-h-96 overflow-y-auto",
"data-testid": "layout-version-history-list"
},
"children": [
{
@@ -36,7 +37,8 @@
},
"blur_until_loaded": "{{_global.isRestoring}}",
"props": {
"className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4"
"className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4",
"data-testid": "layout-version-row-{{version.version ?? idx}}"
},
"children": [
{
@@ -166,7 +168,7 @@
"if": "{{!_global.isRestoring}}",
"props": {
"name": "refresh",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -232,6 +234,67 @@
}
]
},
{
"comment": "받은 묶음이 상한만큼 꽉 찼으면 더 오래된 버전이 남아 있다는 뜻이라 '더 보기' 를 노출한다. 서버 상한(500)에 도달하면 감춘다 — 더 넓히면 목록 전체가 422 가 되어 보고 있던 이력까지 사라진다.",
"if": "{{(layout_versions?.data?.length ?? 0) >= (_global.layoutVersionLimit ?? 100) && (_global.layoutVersionLimit ?? 100) < 500}}",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center justify-center mt-3"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn-secondary w-full",
"data-testid": "layout-version-history-load-more"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"layoutVersionLimit": "{{Math.min((_global.layoutVersionLimit ?? 100) + 100, 500)}}"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "layout_versions"
}
}
]
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:admin.templates.modals.version_history.load_more"
}
]
}
]
},
{
"comment": "서버 상한까지 넓혔는데도 묶음이 꽉 차 있으면 더 오래된 버전이 남아 있지만 이 화면에서는 도달할 수 없다. '더 보기' 만 조용히 사라지면 목록이 끝난 것으로 읽히므로 도달 한계를 명시한다.",
"if": "{{(layout_versions?.data?.length ?? 0) >= (_global.layoutVersionLimit ?? 100) && (_global.layoutVersionLimit ?? 100) >= 500}}",
"type": "basic",
"name": "Div",
"props": {
"className": "text-xs text-secondary text-center mt-3",
"data-testid": "layout-version-history-limit-reached"
},
"text": "$t:admin.templates.modals.version_history.limit_reached"
},
{
"type": "basic",
"name": "Div",
@@ -251,7 +314,7 @@
"name": "Icon",
"props": {
"name": "info-circle",
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
"className": "text-base text-blue-600 dark:text-blue-400"
}
},
{
@@ -0,0 +1,109 @@
/**
* sirsoft-admin_basic — 코드 편집기 패널 폭 규약과 저장 후 목록 갱신
*
* 브라우저 실측(2026-08-05)에서 드러난 두 결함을 화면 쪽에서 잠근다. 둘 다 응답에도
* 콘솔에도 신호가 없고 화면에서만 드러나는 종류라, 레이아웃 JSON 구조 단언만으로는
* "실제로 그렇게 그려지는가 / 실제로 그 요청이 나가는가" 가 남지 않는다.
*
* 1. **패널 폭** — 두 카드 어디에도 폭 규약이 없어 flex 가 max-content 비율로 폭을
* 나눴다. 모듈 파티션 레이아웃 이름(줄바꿈 지점 없는 80자 이상)이 목록을 밀어 올려
* 편집기가 절반 이하로 눌렸다(실측 목록 814 / 편집기 473).
* 2. **저장 후 목록** — 저장 성공 시 상세만 다시 불러서, 방금 저장한 파일의 목록 행이
* 저장 전 설명·크기·수정일을 그대로 달고 있었다.
*
* 저장은 응답을 고정해 실제 파일을 건드리지 않는다. 검사 대상은 **브라우저가 실제로
* 만든 요청 순서** 다 — 목록 재조회가 저장 뒤에 오지 않으면 화면은 낡은 행을 그린다.
*
* @scenario surface=code_editor,entry=direct
* @effects code_editor_editor_panel_wider_than_file_list, code_editor_list_refetched_after_save
*/
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
import type { Page, Route } from '@playwright/test';
/** 목록 조회 — 뒤에 세그먼트가 붙는 단건/버전 조회와 구분한다 */
const LIST_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts(\?[^/]*)?$/;
/** 단건 저장 — 목록 URL 뒤에 레이아웃 이름 한 세그먼트 */
const SAVE_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts\/[^/?]+(\?.*)?$/;
/**
* 코드 편집기를 열고 파일 목록이 그려질 때까지 기다립니다.
*
* @param page Playwright page
* @param token 인증 토큰
*/
async function openCodeEditor(page: Page, token: string): Promise<void> {
await authenticatePage(page, token);
await page.goto('/admin/templates/sirsoft-admin_basic/edit');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('#file_list_card', { timeout: 30_000 });
await expect(page.locator('#editor_card').getByText(/\.json$/).first()).toBeVisible({ timeout: 30_000 });
}
test.describe('@sirsoft-admin_basic 코드 편집기 패널 폭', () => {
test('편집기가 파일 목록보다 넓고, 긴 파일명이 목록 폭을 넘지 않는다', async ({ page, layoutEditToken }) => {
await openCodeEditor(page, layoutEditToken);
const measured = await page.evaluate(() => {
const list = document.querySelector('#file_list_card')!;
const editor = document.querySelector('#editor_card')!;
// 목록 안에서 가로 스크롤을 만드는 요소 — 긴 파일명이 끊기지 않으면 여기서 잡힌다
const overflowing = [...list.querySelectorAll('*')].filter((e) => e.scrollWidth > e.clientWidth + 1).length;
return {
listWidth: Math.round(list.getBoundingClientRect().width),
editorWidth: Math.round(editor.getBoundingClientRect().width),
overflowing,
};
});
expect(measured.editorWidth, '편집기가 파일 목록보다 좁으면 코드를 볼 수 없다').toBeGreaterThan(
measured.listWidth,
);
expect(measured.overflowing, '목록 안에 가로 스크롤이 생기면 파일명이 끊기지 않은 것이다').toBe(0);
});
});
test.describe('@sirsoft-admin_basic 코드 편집기 저장 후 목록 갱신', () => {
test('저장이 성공하면 파일 목록을 다시 부른다', async ({ page, layoutEditToken }) => {
const calls: string[] = [];
// 저장은 고정 응답 — 실제 파일과 lock_version 을 건드리지 않는다
await page.route(SAVE_URL_RE, async (route: Route) => {
if (route.request().method() !== 'PUT') {
await route.fallback();
return;
}
calls.push('save');
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, message: '저장되었습니다.', data: { lock_version: 1 } }),
});
});
page.on('request', (req) => {
if (req.method() === 'GET' && LIST_URL_RE.test(req.url())) calls.push('list');
});
await openCodeEditor(page, layoutEditToken);
const before = calls.length;
await page.getByTestId('layout-save').click();
await expect
.poll(() => calls.includes('save'), { timeout: 20_000 })
.toBe(true);
// 저장 뒤에 목록 조회가 한 번 더 나가야 한다. 상세만 다시 부르면 방금 저장한 행이
// 저장 전 설명·크기·수정일을 그대로 단 채 남는다.
await expect
.poll(() => calls.slice(calls.indexOf('save')).includes('list'), { timeout: 20_000 })
.toBe(true);
expect(calls.length, '진입 시점 요청만으로 통과하면 안 된다').toBeGreaterThan(before);
});
});
@@ -0,0 +1,165 @@
/**
* sirsoft-admin_basic — 코드 편집기 버전 이력의 상한 조회와 '더 보기'
*
* 버전 행은 저장할 때마다 쌓이고 정리되지 않는다. 목록이 전량을 내려주면 오래 쓴 레이아웃일수록
* 모달 하나를 여는 데 수천 행이 실린다(#518). 서버가 조회 건수를 제한한 뒤로 화면이 상한을
* 넘겨주지 않으면 100건 밖의 버전은 이 화면에서 도달할 수 없다 — 오류 없이 목록이 끝난 것처럼
* 보일 뿐이라 화면만 보고는 알 수 없다.
*
* 위지윅 편집기의 같은 기능은 React 훅(useLayoutVersions)이 담당하고 코어
* layout-editor/version-history-paging.spec.ts 가 잠근다. 이 spec 은 **레이아웃 JSON 이
* 구동하는 코드 편집기** 라는 별개 표면을 잠근다 — 두 화면은 코드를 공유하지 않으므로
* 한쪽이 초록이어도 다른 쪽은 전량 요청일 수 있다.
*
* 응답은 고정한다(개발 사이트의 실제 버전 수에 좌우되지 않도록). 요청 URL 은 브라우저가
* 실제로 만든 것을 검사한다.
*
* @scenario resource=layout_version,endpoint=list,surface=code_editor
* @effects version_list_request_carries_limit, version_list_load_more_widens_limit, version_list_load_more_hidden_when_batch_not_full, version_list_limit_ceiling_disclosed
*/
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
import type { Page, Route } from '@playwright/test';
/** 한 묶음 크기 — admin_template_layout_edit.json 의 layoutVersionLimit 기본값과 같은 값 */
const VERSION_PAGE_SIZE = 100;
/** 서버 상한 — LayoutVersionListRequest::MAX_LIMIT 과 같은 값 */
const VERSION_MAX_LIMIT = 500;
const VERSIONS_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts\/[^/]+\/versions(\?|$)/;
/**
* 버전 목록 응답 한 건을 만듭니다.
*
* @param version 버전 번호
* @returns 목록 항목
*/
function versionRow(version: number): Record<string, unknown> {
return {
id: version,
version,
is_latest: version === 1,
created_at: '2026-01-01T00:00:00+09:00',
created_by_name: 'tester',
description: `v${version}`,
lines_count: 10,
chars_count: 100,
};
}
/**
* 버전 목록 응답을 대체하고, 나간 요청 URL 을 수집합니다.
*
* `count` 를 생략하면 요청한 상한만큼(서버가 가진 버전이 상한보다 많은 상태) 돌려준다.
* 응답 크기가 상한을 따라 커져야 화면에 "이 회차가 반영됐다" 는 관측 지점이 생긴다 —
* 매번 같은 개수를 돌려주면 재렌더를 기다릴 방법이 없어 다음 클릭이 이전 화면에 꽂힌다.
*
* @param page Playwright page
* @param count 응답에 담을 행 수 (생략 시 요청 상한과 동일)
* @param seen 요청 URL 수집 배열
*/
async function stubVersions(page: Page, count: number | null, seen: string[]): Promise<void> {
await page.route(VERSIONS_URL_RE, async (route: Route) => {
const url = route.request().url();
seen.push(url);
const requested = Number(new URL(url).searchParams.get('limit') ?? VERSION_PAGE_SIZE);
const rows = count ?? Math.min(requested, VERSION_MAX_LIMIT);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: Array.from({ length: rows }, (_, i) => versionRow(i + 1)),
}),
});
});
}
/**
* 코드 편집기를 열고 버전 이력 모달을 띄웁니다.
*
* @param page Playwright page
* @param token 인증 토큰
*/
async function openVersionHistory(page: Page, token: string): Promise<void> {
await authenticatePage(page, token);
await page.goto('/admin/templates/sirsoft-basic/edit');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForSelector('[data-testid="layout-version-history-open"]', { timeout: 30_000 });
await page.getByTestId('layout-version-history-open').click();
await page.waitForSelector('[data-testid="layout-version-history-list"]', { timeout: 20_000 });
}
test.describe('@sirsoft-admin_basic 코드 편집기 버전 이력 — 상한 조회', () => {
test('첫 조회가 상한(limit)을 달고 나간다', async ({ page, layoutEditToken }) => {
const seen: string[] = [];
await stubVersions(page, 3, seen);
await openVersionHistory(page, layoutEditToken);
expect(seen.length).toBeGreaterThan(0);
// 상한 없이 나가면 오래 쓴 레이아웃에서 전량이 실린다
expect(seen[seen.length - 1]).toContain(`limit=${VERSION_PAGE_SIZE}`);
});
test('한 묶음이 가득 차지 않으면 더 보기가 없다', async ({ page, layoutEditToken }) => {
const seen: string[] = [];
await stubVersions(page, 3, seen);
await openVersionHistory(page, layoutEditToken);
// 존재를 먼저 확정한 뒤 부재를 단언한다 — 렌더 전에는 어떤 부재 단언도 무조건 통과한다
await expect(page.getByTestId('layout-version-row-1')).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId('layout-version-history-load-more')).toHaveCount(0);
await expect(page.getByTestId('layout-version-history-limit-reached')).toHaveCount(0);
});
test('묶음이 가득 차면 더 보기로 상한을 넓혀 재조회한다', async ({ page, layoutEditToken }) => {
const seen: string[] = [];
await stubVersions(page, VERSION_PAGE_SIZE, seen);
await openVersionHistory(page, layoutEditToken);
const loadMore = page.getByTestId('layout-version-history-load-more');
await expect(loadMore).toBeVisible({ timeout: 20_000 });
const before = seen.length;
await loadMore.click();
await expect.poll(() => seen.length, { timeout: 20_000 }).toBeGreaterThan(before);
// 두 번째 요청은 반드시 더 넓은 상한이어야 한다 — 같은 상한으로 다시 부르면
// 같은 묶음이 돌아와 '더 보기' 가 영원히 아무 일도 하지 않는다
expect(seen[seen.length - 1]).toContain(`limit=${VERSION_PAGE_SIZE * 2}`);
});
test('서버 상한까지 넓히면 더 보기 대신 도달 한계를 알린다', async ({ page, layoutEditToken }) => {
const seen: string[] = [];
// 서버에 상한보다 많은 버전이 있는 상태 — 응답은 늘 요청한 상한만큼 꽉 차서 돌아온다
await stubVersions(page, null, seen);
await openVersionHistory(page, layoutEditToken);
const loadMore = page.getByTestId('layout-version-history-load-more');
await expect(loadMore).toBeVisible({ timeout: 20_000 });
// 100 → 200 → 300 → 400 → 500. 다음 회차를 누르기 전에 화면이 그 묶음을 다 그렸는지
// 확인한다 — 요청이 나갔다는 것만으로 누르면 아직 이전 상한을 들고 있는 화면에 꽂힌다.
for (const expected of [200, 300, 400, VERSION_MAX_LIMIT]) {
await loadMore.click();
await expect
.poll(() => seen.some((url) => url.includes(`limit=${expected}`)), { timeout: 20_000 })
.toBe(true);
await expect
.poll(async () => page.locator('[data-testid^="layout-version-row-"]').count(), { timeout: 20_000 })
.toBe(expected);
}
// 상한을 넘겨 요청하면 목록 전체가 422 가 되어 보고 있던 이력까지 사라진다.
// 그래서 더 보기는 감추되, 그냥 사라지면 목록이 끝난 것으로 읽히므로 한계를 명시한다.
await expect(page.getByTestId('layout-version-history-limit-reached')).toBeVisible();
await expect(loadMore).toHaveCount(0);
});
});