diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 0c313879..f3da91f7 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -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 diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 55328ee1..5f919366 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -2159,7 +2159,9 @@ "saved_by": "保存者:{{name}}", "lines": "行", "modified": "編集", - "chars": "個" + "chars": "個", + "load_more": "以前のバージョンをもっと見る", + "limit_reached": "表示できる最大件数に達しました。" }, "wysiwyg_select_layout_title": "ウィジウィグ編集 - レイアウト選択", "wysiwyg_select_layout_description": "編集するレイアウトを選択してください。選択したレイアウトが新しいタブでウィジウィグ編集モードで開きます。", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index 063f3b9c..a69f93ef 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -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", diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index e4cb77a2..6765a254 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -32,6 +32,9 @@ ### Fixed +- 레이아웃 편집 화면에서 파일 목록이 편집기보다 넓게 자리를 차지해 코드가 좁게 보이던 문제를 수정했습니다. 파일 이름이 긴 레이아웃(모듈이 제공하는 부분 화면 등)이 많을수록 편집 영역이 좁아졌습니다. 이제 파일 목록은 일정한 너비를 쓰고 남는 공간은 모두 편집기가 사용하며, 긴 이름은 잘리지 않고 줄바꿈되어 표시됩니다. +- 레이아웃을 저장해도 왼쪽 파일 목록의 설명·크기·수정일이 저장 전 값 그대로 남아 있던 문제를 수정했습니다. 이제 저장 즉시 목록에도 반영됩니다. +- 레이아웃 버전 이력에서 최근 100개까지만 볼 수 있고 그보다 오래된 버전은 열 수 없던 문제를 수정했습니다. 이력 창 아래에 「이전 버전 더 보기」를 추가해 오래된 버전까지 확인하고 복구할 수 있습니다. - 표와 카드 목록에서 항목별 표시 조건이 서로 다르게 판정되던 문제를 수정했습니다. 같은 조건을 써도 표에서는 항상 보이고 카드에서는 아무것도 보이지 않는 식으로 결과가 갈렸으며, 특히 화면 상태값을 참조하는 조건에서 그랬습니다. 이제 두 목록 모두 다른 화면 요소와 같은 기준으로 조건을 판정합니다. - 표 하단 합계 영역에 넣은 내용이 표 본문과 다른 방식으로 처리되던 문제를 수정했습니다. 합계 영역도 각 항목과 같은 규칙으로 값을 해석합니다. - 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 수정했습니다. 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다. diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-template-layout-edit-panels-and-versions.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-template-layout-edit-panels-and-versions.test.tsx new file mode 100644 index 00000000..a5d88450 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-template-layout-edit-panels-and-versions.test.tsx @@ -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)); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 46ee8cf4..ac1dfd61 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -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", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index cb36a768..a9b2fd4b 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -2160,7 +2160,9 @@ "saved_by": "저장자: {{name}}", "lines": "라인", "modified": "수정", - "chars": "자" + "chars": "자", + "load_more": "이전 버전 더 보기", + "limit_reached": "표시할 수 있는 최대 개수에 도달했습니다." }, "extension_version_history": { "title": "확장 버전 히스토리", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json index 18714b12..12459d06 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_template_layout_edit.json @@ -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" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_version_history.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_version_history.json index 55afdd23..95f6a559 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_version_history.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_layout_edit/_modal_version_history.json @@ -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" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-panels-and-list-refresh.spec.ts b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-panels-and-list-refresh.spec.ts new file mode 100644 index 00000000..8dc58b85 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-panels-and-list-refresh.spec.ts @@ -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 { + 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); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-version-history-paging.spec.ts b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-version-history-paging.spec.ts new file mode 100644 index 00000000..03ad55bc --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/admin/code-editor-version-history-paging.spec.ts @@ -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 { + 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 { + 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 { + 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); + }); +});