fix(extensions,core): 저장 대상이 아닌 편집기가 폼을 변경됨으로 만들지 않게
미리보기 편집기는 설정을 시험해 보는 자리인데, 본문 동기화가 hasChanges 를 렌더를 일으키는 setLocal 로 올리면서 시험 입력만으로 [저장] 이 켜졌다. 어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명을 알아보게 하지 않고 trackChanges 선언으로 받는다 — 하드코딩하면 다른 확장이 같은 미리보기 패턴을 쓸 때 그대로 재발한다. 같은 결함이 형제 경로에도 있었다. 평문 폴백은 hasChanges 를 본문 배치 (render:false + selfManaged:true)에 실어, 편집기 자산을 못 불러온 환경에서 본문만 고치면 저장 버튼이 잠긴 채로 남았다 — 폐쇄망에서는 그 폴백이 정상 경로다. 폴백은 렌더 시점에도 같은 함수를 부르므로 화면을 여는 것만으로 변경됨이 되지 않도록, 사용자 입력에서 온 호출만 플래그를 올린다. components.json 생성기는 종결 개행을 PHP_EOL 로 써서 Windows 빌드마다 그 파일을 유령 변경으로 남겼다. 줄 내용이 같아 diff 가 비어 보이므로 무엇이 바뀐 것인지 알 수 없는 변경만 쌓인다.
This commit is contained in:
@@ -53,7 +53,11 @@ trait GeneratesComponentManifest
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
);
|
||||
|
||||
$written = $json !== false && file_put_contents($outputPath, $json.PHP_EOL) !== false;
|
||||
// 종결 개행은 "\n" 고정 — PHP_EOL 은 Windows 에서 "\r\n" 이라, 같은 소스를 빌드해도
|
||||
// 빌드한 OS 에 따라 산출물의 마지막 바이트가 달라진다. components.json 은 Git 추적
|
||||
// 대상이므로 그 차이가 매 빌드마다 변경으로 잡히는데, 줄 내용이 같아 diff 는 비어
|
||||
// 보인다 — 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
|
||||
$written = $json !== false && file_put_contents($outputPath, $json."\n") !== false;
|
||||
|
||||
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
|
||||
|
||||
|
||||
@@ -163,7 +163,6 @@ G7이 자동으로 주입하는 `_global` 속성입니다. 레이아웃에서
|
||||
- **예외**: `selfManaged: true` 명시한 호출은 자동 승격 제외 (CKEditor5 등 자체 DOM 관리 플러그인 전용)
|
||||
- **pending 스냅샷**: 자동바인딩은 `__g7PendingLocalState` 에 "지금 화면과 같은 전체 스냅샷" 을 싣는다. 이 값이 뒤이은 `setLocal` 의 base 가 되므로, 저장소 A 스냅샷을 그대로 실으면 B 에만 있던 값(selfManaged 플러그인이 쓴 편집기 본문 등)이 사라진다. 그래서 렌더러가 화면을 만드는 순서(`dataContext._local → dynamicState → __g7ForcedLocalFields`)를 그대로 따라 합성한다 (engine-v1.63.4)
|
||||
|
||||
|
||||
### 엔진 수정 시 금지 사항 (CRITICAL)
|
||||
|
||||
```text
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 15개 | `plugins/_bundled/sirsoft-ckeditor5/tests` |
|
||||
| Vitest | 10개 | `vitest.config.ts` |
|
||||
| Vitest | 11개 | `vitest.config.ts` |
|
||||
| Playwright | 4개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 1개 | `tests/scenarios` |
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -81,6 +81,20 @@ _훅 리스너가 없습니다._
|
||||
| `html-editor.json` | `html_editor` | 동봉 CKEditor 5 UMD 를 로드하고 컨테이너 `onMount` 에서 `initEditor` 실행 |
|
||||
| `html-content.json` | `html_content` | 저장된 본문을 읽기 화면에 렌더 |
|
||||
|
||||
`html_editor` 를 쓰는 쪽은 `props` 로 `name` · `content` · `multilingual` · `placeholder` ·
|
||||
`readOnly` 를 선언합니다. 여기에 **`trackChanges`(기본 `true`)** 가 있습니다 — 이 편집기의 입력을
|
||||
"폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부입니다.
|
||||
|
||||
저장 대상이 아닌 편집기(설정 화면의 미리보기처럼 시험용으로 두는 자리)는 `false` 로 선언합니다.
|
||||
그러지 않으면 운영자가 시험 삼아 글자만 쳐도 [저장] 버튼이 켜져, 바뀐 것이 없는데 바뀐 것처럼
|
||||
보입니다. 어느 편집기가 저장 대상인지는 **레이아웃만 압니다** — 그래서 핸들러가 필드명으로
|
||||
알아보지 않고 선언으로 받습니다. 필드명을 알아보게 만들면 다른 확장이 같은 미리보기 패턴을
|
||||
쓸 때 그대로 재발합니다.
|
||||
|
||||
저장 대상이 아닌 편집기는 **저장 요청 body 에서도 그 필드를 빼야** 합니다. 자동바인딩은
|
||||
`trackChanges` 와 무관하게 값을 `_local.form` 에 쌓으므로, body 를 `_local.form` 통째로 보내면
|
||||
미리보기 입력이 설정으로 저장됩니다. 서버는 200 을 돌려주므로 화면에는 아무 이상이 없습니다.
|
||||
|
||||
둘 다 `mode: replace` 입니다 — 확장점 자리를 비우고 대신 들어갑니다. 같은 확장점을 노리는
|
||||
다른 편집기 플러그인이 함께 활성화되면 어느 쪽이 이기는지가 설치 순서에 좌우되므로, 편집기
|
||||
플러그인은 하나만 켭니다.
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"readOnly": "{{_computed.isReadOnly ?? extensionPointProps.disabled ?? extensionPointProps.readOnly ?? false}}",
|
||||
"imageUpload": "{{_global.plugins['sirsoft-ckeditor5']?.imageUpload ?? false}}",
|
||||
"height": "{{_global.plugins['sirsoft-ckeditor5']?.editorHeight ?? 400}}",
|
||||
"toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}"
|
||||
"toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}",
|
||||
"trackChanges": "{{extensionPointProps.trackChanges ?? true}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+9
-1
@@ -88,7 +88,15 @@ describe('CKEditor5 textarea 폴백', () => {
|
||||
|
||||
expect(updates['form.content']).toBe('사용자가 쓴 글');
|
||||
expect(updates['form.content_mode']).toBe('text');
|
||||
expect(updates.hasChanges).toBe(true);
|
||||
|
||||
// `hasChanges` 는 이 배치에 없다 — 배치가 `render:false + selfManaged:true` 라
|
||||
// React 렌더를 일으키지 않아, 여기에 실으면 저장 버튼의 활성 조건이 재평가되지
|
||||
// 않는다(수정 화면에서 저장 자체가 불가능해진다). 별도 setLocal 로 나간다.
|
||||
expect(updates.hasChanges, '본문 배치에 섞으면 저장 버튼이 안 켜진다').toBeUndefined();
|
||||
expect(
|
||||
setLocal.mock.calls.some(([u]: [Record<string, any>]) => u.hasChanges === true),
|
||||
'플래그 자체는 올라가야 한다',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+199
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { syncToForm } from '../../handlers/initEditor';
|
||||
import { renderTextareaFallback } from '../../handlers/textareaFallback';
|
||||
|
||||
type Call = { updates: Record<string, any>; options?: Record<string, any> };
|
||||
|
||||
@@ -119,3 +120,201 @@ describe('편집기 본문 동기화 — hasChanges 렌더 분리 (저장 버튼
|
||||
expect(hasChangesCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 저장 대상이 아닌 편집기는 폼을 "변경됨" 으로 만들지 않는다.
|
||||
*
|
||||
* 위 수정이 `hasChanges` 를 **렌더를 일으키는** setLocal 로 승격시키면서, 설정 화면의
|
||||
* 미리보기 편집기처럼 저장 대상이 아닌 편집기의 입력까지 [저장] 버튼을 즉시 켜게 됐다.
|
||||
* 미리보기 내용은 저장 액션의 body 에서 제외되므로 잘못 저장될 위험은 없지만, 운영자에게는
|
||||
* "바뀐 것이 없는데 바뀐 것처럼" 보인다.
|
||||
*
|
||||
* 어느 편집기가 저장 대상인지는 **레이아웃이 안다**. 그래서 핸들러가 필드명(`preview_content`)
|
||||
* 을 알아보는 대신 `trackChanges` 선언으로 받는다 — 필드명 하드코딩은 다른 확장이 같은
|
||||
* 미리보기 패턴을 쓸 때 그대로 재발한다.
|
||||
*/
|
||||
describe('편집기 본문 동기화 — 저장 대상이 아닌 편집기 (trackChanges:false)', () => {
|
||||
beforeEach(() => {
|
||||
stubG7Core();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).G7Core;
|
||||
});
|
||||
|
||||
it('trackChanges:false 면 hasChanges 를 아예 보내지 않는다', () => {
|
||||
syncToForm('preview_content', 'ko', '<p>시험 입력</p>', true, false);
|
||||
|
||||
expect(
|
||||
hasChangesCalls(),
|
||||
'미리보기 입력이 [저장] 버튼을 켜면 안 된다',
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('trackChanges:false 여도 본문 동기화는 그대로 한다 (미리보기 렌더가 이 값을 읽는다)', () => {
|
||||
syncToForm('preview_content', 'ko', '<p>시험 입력</p>', true, false);
|
||||
|
||||
const body = contentCalls();
|
||||
expect(body, '본문은 종전대로 보낸다').toHaveLength(1);
|
||||
expect(body[0].updates['form.preview_content.ko']).toBe('<p>시험 입력</p>');
|
||||
expect(body[0].updates['form.preview_content_mode']).toBe('html');
|
||||
expect(body[0].options?.render).toBe(false);
|
||||
expect(body[0].options?.selfManaged).toBe(true);
|
||||
});
|
||||
|
||||
it('생략하면 종전대로 hasChanges 를 보낸다 (기본값 true — 게시글 편집기 보호)', () => {
|
||||
syncToForm('content', 'ko', '<p>본문</p>', false);
|
||||
|
||||
expect(hasChangesCalls(), '기본값이 false 로 뒤집히면 저장 버튼 회귀가 되돌아온다').toHaveLength(1);
|
||||
});
|
||||
|
||||
it('trackChanges:false 인 편집기는 이미 켜진 hasChanges 를 끄지도 않는다', () => {
|
||||
// 다른 입력이 이미 폼을 변경 상태로 만든 뒤 미리보기를 건드리는 순서
|
||||
stubG7Core({ hasChanges: true });
|
||||
|
||||
syncToForm('preview_content', 'ko', '<p>시험</p>', true, false);
|
||||
|
||||
expect(hasChangesCalls(), '건드리지 않는다 — 끄면 진짜 변경이 묻힌다').toHaveLength(0);
|
||||
expect(localState.hasChanges, '기존 변경 상태는 유지돼야 한다').toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 회귀: 평문 폴백에서도 본문만 고치면 저장 버튼이 활성화된다 (형제 경로 패리티)
|
||||
*
|
||||
* `syncToForm` 의 결함을 고치면서 **형제 경로인 `syncFallbackToForm` 은 그대로 남았다.**
|
||||
* 그쪽도 `hasChanges: true` 를 `render:false + selfManaged:true` 배치에 넣으므로,
|
||||
* 편집기 자산을 못 불러와 평문 입력창으로 내려간 상태에서 본문만 고치면
|
||||
* `admin_board_post_form.json` 의 저장 버튼
|
||||
* (`disabled: "{{... || (!!route?.id && !_local.hasChanges)}}"`)이 계속 비활성이다 —
|
||||
* **글 수정 자체가 불가능하다.** 폐쇄망·방화벽·광고차단기 환경에서는 그 폴백이 정상 경로다.
|
||||
*
|
||||
* 폴백에는 편집기에 없는 결이 하나 더 있다: `syncFallbackToForm` 은 사용자 입력뿐 아니라
|
||||
* **렌더 시점에도** 불린다(`_mode='text'` 를 미리 심어 두려고). 그 자리까지 플래그를 켜면
|
||||
* 사용자가 아무것도 입력하지 않았는데 화면을 여는 것만으로 "변경됨" 이 된다. 그래서
|
||||
* 사용자 입력에서 온 호출만 플래그를 올린다.
|
||||
*/
|
||||
describe('평문 폴백 본문 동기화 — hasChanges 렌더 분리 (형제 경로)', () => {
|
||||
let container: HTMLElement;
|
||||
|
||||
/**
|
||||
* 폴백을 세울 컨테이너를 만듭니다.
|
||||
*
|
||||
* @return 문서에 붙은 빈 컨테이너
|
||||
*/
|
||||
function mountContainer(): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubG7Core();
|
||||
container = mountContainer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
delete (window as any).G7Core;
|
||||
});
|
||||
|
||||
it('폴백이 서는 것만으로는 폼이 변경됨이 되지 않는다', () => {
|
||||
renderTextareaFallback({
|
||||
container,
|
||||
name: 'content',
|
||||
height: 400,
|
||||
readOnly: false,
|
||||
multilingual: false,
|
||||
initialContent: '<p>서버 원본</p>',
|
||||
});
|
||||
|
||||
expect(
|
||||
hasChangesCalls(),
|
||||
'입력이 없는데 [저장] 이 켜지면 운영자가 바뀐 줄 알고 누른다',
|
||||
).toHaveLength(0);
|
||||
// 평문 계약은 그대로 심어야 한다 (서버가 HTML 로 신뢰하면 안 된다)
|
||||
expect(contentCalls().at(-1)?.updates['form.content_mode']).toBe('text');
|
||||
});
|
||||
|
||||
it('폴백 입력창에 글자를 치면 hasChanges 가 렌더를 일으키며 올라간다', () => {
|
||||
renderTextareaFallback({
|
||||
container,
|
||||
name: 'content',
|
||||
height: 400,
|
||||
readOnly: false,
|
||||
multilingual: false,
|
||||
initialContent: '',
|
||||
});
|
||||
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.value = '폴백으로 쓴 본문';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
const flag = hasChangesCalls();
|
||||
expect(flag, '입력했는데 플래그가 안 오르면 저장 버튼이 비활성으로 남는다').toHaveLength(1);
|
||||
expect(flag[0].updates.hasChanges).toBe(true);
|
||||
expect(flag[0].options?.render, 'render:false 면 React 가 버튼을 다시 그리지 않는다').not.toBe(false);
|
||||
expect(flag[0].options?.selfManaged, 'selfManaged 면 안 된다').not.toBe(true);
|
||||
});
|
||||
|
||||
it('본문 배치에는 hasChanges 가 섞이지 않는다', () => {
|
||||
renderTextareaFallback({
|
||||
container,
|
||||
name: 'content',
|
||||
height: 400,
|
||||
readOnly: false,
|
||||
multilingual: false,
|
||||
initialContent: '',
|
||||
});
|
||||
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.value = 'x';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
for (const c of contentCalls()) {
|
||||
expect('hasChanges' in c.updates, 'render:false 배치에 섞이면 저장소 A 가 못 받는다').toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('연속 입력에서도 hasChanges 렌더는 첫 1회뿐이다', () => {
|
||||
renderTextareaFallback({
|
||||
container,
|
||||
name: 'content',
|
||||
height: 400,
|
||||
readOnly: false,
|
||||
multilingual: false,
|
||||
initialContent: '',
|
||||
});
|
||||
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
for (const v of ['a', 'ab', 'abc']) {
|
||||
textarea.value = v;
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
expect(hasChangesCalls(), '편집 세션당 추가 렌더는 최대 1회').toHaveLength(1);
|
||||
});
|
||||
|
||||
it('저장 대상이 아닌 편집기의 폴백은 폼을 변경됨으로 만들지 않는다', () => {
|
||||
renderTextareaFallback({
|
||||
container,
|
||||
name: 'preview_content',
|
||||
height: 400,
|
||||
readOnly: false,
|
||||
multilingual: false,
|
||||
initialContent: '',
|
||||
trackChanges: false,
|
||||
});
|
||||
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.value = '미리보기 시험';
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
expect(
|
||||
hasChangesCalls(),
|
||||
'미리보기가 폴백으로 내려앉아도 저장 대상이 아닌 것은 그대로다',
|
||||
).toHaveLength(0);
|
||||
expect(contentCalls().at(-1)?.updates['form.preview_content'], '본문 동기화는 유지').toBe('미리보기 시험');
|
||||
});
|
||||
});
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* CKEditor5 설정 화면 — 미리보기 편집기는 저장 대상이 아니다
|
||||
*
|
||||
* @description
|
||||
* 설정 화면 하단의 미리보기 편집기는 운영자가 설정을 시험해 보는 자리다. 그런데 그
|
||||
* 편집기도 `props.name` 을 갖기 때문에 폼 자동바인딩으로 `_local.form` 에 쌓인다.
|
||||
* 여기서 두 가지가 조용히 어긋난다:
|
||||
*
|
||||
* ① 저장 body 가 `_local.form` 을 통째로 보내면 미리보기 입력이 설정으로 저장된다.
|
||||
* 서버는 200 을 돌려주므로 화면에는 아무 이상이 없다.
|
||||
* ② 미리보기 입력이 `_local.hasChanges` 를 켜면 [저장] 버튼이 활성화된다.
|
||||
* 바뀐 설정이 없는데 바뀐 것처럼 보인다.
|
||||
*
|
||||
* 둘 다 **선언**으로 막는다 — 핸들러가 필드명(`preview_content`)을 알아보게 하면,
|
||||
* 다른 확장이 같은 미리보기 패턴을 쓸 때 그대로 재발한다.
|
||||
*
|
||||
* 이 계약은 화면에 오류로 드러나지 않으므로(저장은 성공하고 버튼은 눌린다) 선언 자체를
|
||||
* 잠근다. 특히 body 표현식은 평가기가 거부하는 구문(rest 구조분해 / 콜백 안 멤버 대입)을
|
||||
* 쓰면 **원문 문자열이 그대로 전송**되고 200 이 떨어져 드러나지 않는다.
|
||||
*
|
||||
* @effects preview_editor_excluded_from_save_body, preview_editor_does_not_dirty_form
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import pluginSettings from '../../../layouts/admin/plugin_settings.json';
|
||||
import htmlEditorExtension from '../../../extensions/html-editor.json';
|
||||
|
||||
/**
|
||||
* 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다 (slots·default 포함).
|
||||
*
|
||||
* @param root 탐색 시작 노드
|
||||
* @param match 판정 함수
|
||||
* @return 찾은 노드 또는 null
|
||||
*/
|
||||
function findNode(root: unknown, match: (node: any) => boolean): any | null {
|
||||
let found: any = null;
|
||||
|
||||
const walk = (node: any): void => {
|
||||
if (found || !node || typeof node !== 'object') return;
|
||||
if (!Array.isArray(node) && match(node)) {
|
||||
found = node;
|
||||
return;
|
||||
}
|
||||
for (const value of Object.values(node)) {
|
||||
if (Array.isArray(value)) value.forEach(walk);
|
||||
else if (value && typeof value === 'object') walk(value);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('설정 화면 미리보기 편집기 — 저장 대상 제외', () => {
|
||||
it('미리보기 편집기가 trackChanges:false 를 선언한다', () => {
|
||||
const preview = findNode(pluginSettings, (n) => n.id === 'preview_editor');
|
||||
|
||||
expect(preview, 'preview_editor 노드를 찾아야 한다').not.toBeNull();
|
||||
expect(
|
||||
preview.props?.trackChanges,
|
||||
'선언이 빠지면 미리보기 입력만으로 [저장] 이 켜진다',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('확장점이 trackChanges 를 핸들러 params 까지 나른다', () => {
|
||||
const container = findNode(htmlEditorExtension, (n) => n.id === 'ckeditor5_container');
|
||||
const onMount = container?.lifecycle?.onMount?.[0];
|
||||
|
||||
expect(onMount?.handler).toBe('sirsoft-ckeditor5.initEditor');
|
||||
expect(
|
||||
onMount?.params?.trackChanges,
|
||||
'params 로 나르지 않으면 레이아웃 선언이 핸들러에 도달하지 않는다 (선언은 있는데 무효)',
|
||||
).toBe("{{extensionPointProps.trackChanges ?? true}}");
|
||||
});
|
||||
|
||||
it('선언하지 않은 편집기는 기본값(변경 추적함)으로 남는다', () => {
|
||||
// 확장점 표현식의 기본값이 true 여야 게시글 편집기의 저장 버튼 활성화가 유지된다.
|
||||
const container = findNode(htmlEditorExtension, (n) => n.id === 'ckeditor5_container');
|
||||
const expr = container.lifecycle.onMount[0].params.trackChanges as string;
|
||||
|
||||
expect(expr, '기본값이 false 로 뒤집히면 저장 버튼 회귀가 되돌아온다').toContain('?? true');
|
||||
});
|
||||
|
||||
it('저장 body 가 미리보기 키를 제외한다', () => {
|
||||
const save = findNode(
|
||||
pluginSettings,
|
||||
(n) =>
|
||||
n.handler === 'apiCall'
|
||||
&& typeof n.params?.body === 'string'
|
||||
&& n.params.body.includes('_local.form'),
|
||||
);
|
||||
|
||||
expect(save, '설정 저장 apiCall 을 찾아야 한다').not.toBeNull();
|
||||
|
||||
const body = save.params.body as string;
|
||||
expect(body, '미리보기 본문을 제외해야 한다').toContain('preview_content');
|
||||
expect(body, '미리보기 모드 플래그도 제외해야 한다').toContain('preview_content_mode');
|
||||
|
||||
// 평가기가 거부하는 구문이 들어가면 식이 죽고 원문 문자열이 그대로 전송된다
|
||||
// (오류 없이 200 이 떨어져 화면으로는 드러나지 않는다).
|
||||
expect(body, 'rest 구조분해 금지').not.toMatch(/\.\.\.\w+\s*\}\s*=/);
|
||||
expect(body, '콜백 안 멤버 대입 금지').not.toMatch(/\w+\[[^\]]+\]\s*=[^=]/);
|
||||
});
|
||||
|
||||
it('저장 body 표현식이 실제로 미리보기 키를 걸러낸다 (죽은 식이면 여기서 잡힌다)', () => {
|
||||
const save = findNode(
|
||||
pluginSettings,
|
||||
(n) =>
|
||||
n.handler === 'apiCall'
|
||||
&& typeof n.params?.body === 'string'
|
||||
&& n.params.body.includes('_local.form'),
|
||||
);
|
||||
|
||||
// `{{...}}` 를 벗겨 식만 실행한다.
|
||||
const expr = (save.params.body as string).replace(/^\{\{/, '').replace(/\}\}$/, '');
|
||||
// eslint-disable-next-line no-new-func
|
||||
const run = new Function('_local', `return ${expr};`);
|
||||
|
||||
const out = run({
|
||||
form: {
|
||||
editorHeight: 500,
|
||||
toolbar: 'full',
|
||||
preview_content: { ko: '<p>시험</p>' },
|
||||
preview_content_mode: 'html',
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(out).sort(), '설정 키만 남아야 한다').toEqual(['editorHeight', 'toolbar']);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ interface InitEditorParams {
|
||||
imageUpload?: boolean | string;
|
||||
height?: number | string;
|
||||
toolbar?: string;
|
||||
/**
|
||||
* 이 편집기의 입력을 "폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부. 기본 `true`.
|
||||
*
|
||||
* 저장 대상이 아닌 편집기(설정 화면의 미리보기 등)는 `false` 로 선언한다. 그러지 않으면
|
||||
* 운영자가 시험 삼아 미리보기에만 글자를 쳐도 [저장] 버튼이 켜져, 바뀐 것이 없는데
|
||||
* 바뀐 것처럼 보인다. 어느 편집기가 저장 대상인지는 **레이아웃이 안다** — 그래서
|
||||
* 핸들러가 필드명을 알아보는 대신 선언으로 받는다.
|
||||
*/
|
||||
trackChanges?: boolean | string;
|
||||
}
|
||||
|
||||
/** 툴바 프리셋 */
|
||||
@@ -1100,12 +1109,20 @@ function createMultilingualTabs(
|
||||
* 테스트를 위해 export 한다 — `hasChanges` 를 본문과 분리해 보내는 계약은 화면에서
|
||||
* "저장 버튼이 계속 비활성" 으로만 드러나므로 단위 테스트로 잠근다
|
||||
* (`resolveSingleContent` 와 같은 선례).
|
||||
*
|
||||
* @param name 폼 필드명
|
||||
* @param locale 이 값이 속한 로케일 (다국어 모드에서만 경로에 반영)
|
||||
* @param value 편집기가 내놓은 HTML
|
||||
* @param isMultilingual 다국어 편집기 여부 (`form.{name}.{locale}` vs `form.{name}`)
|
||||
* @param tracksChanges 이 입력을 폼 변경(`_local.hasChanges`)으로 칠지 여부. 기본 `true`
|
||||
* @return void
|
||||
*/
|
||||
export function syncToForm(
|
||||
name: string,
|
||||
locale: string,
|
||||
value: string,
|
||||
isMultilingual: boolean
|
||||
isMultilingual: boolean,
|
||||
tracksChanges: boolean = true
|
||||
): void {
|
||||
// setData() 호출 중 change:data 재진입 방지
|
||||
if (isSyncSuppressed()) return;
|
||||
@@ -1127,7 +1144,11 @@ export function syncToForm(
|
||||
// 이 플래그는 스칼라 한 개라 본문과 달리 성능 사유가 없다. 그래서 렌더를 일으키는 일반
|
||||
// setLocal 로 분리하되, **false → true 로 처음 넘어갈 때만** 보낸다. 이미 true 면 건너뛰므로
|
||||
// 편집 세션당 추가 렌더는 최대 1회다.
|
||||
if (G7Core.state.getLocal?.()?.hasChanges !== true) {
|
||||
//
|
||||
// 저장 대상이 아닌 편집기(설정 화면의 미리보기 등)는 `tracksChanges:false` 로 이 축에서
|
||||
// 빠진다 — 그러지 않으면 시험 입력만으로 [저장] 이 켜져 바뀐 것이 없는데 바뀐 것처럼 보인다.
|
||||
// 본문 동기화는 그런 편집기도 그대로 수행한다(미리보기 렌더가 그 값을 읽는다).
|
||||
if (tracksChanges && G7Core.state.getLocal?.()?.hasChanges !== true) {
|
||||
G7Core.state.setLocal({ hasChanges: true });
|
||||
}
|
||||
|
||||
@@ -1307,6 +1328,9 @@ export async function initEditorHandler(
|
||||
const placeholder = params.placeholder ?? '';
|
||||
const height = params.height !== undefined ? (Number(params.height) || 400) : (Number(pluginSettings.editorHeight) || 400);
|
||||
const toolbarType = (params.toolbar !== undefined ? (params.toolbar as string) : (pluginSettings.toolbar as string)) ?? 'standard';
|
||||
// 기본은 true — 저장 대상이 아닌 편집기만 레이아웃이 명시적으로 끈다.
|
||||
// 미평가 표현식(`{{...}}`)이 그대로 들어와도 truthy 문자열이라 기본값 쪽으로 떨어진다.
|
||||
const tracksChanges = !(params.trackChanges === false || params.trackChanges === 'false');
|
||||
|
||||
// content 파싱: 다국어 시 객체, 단일 시 문자열
|
||||
// extensionPointProps를 통해 전달되는 경우 표현식이 평가되지 않고 raw 문자열로 전달될 수 있음
|
||||
@@ -1326,6 +1350,7 @@ export async function initEditorHandler(
|
||||
height,
|
||||
readOnly: isReadOnly,
|
||||
placeholder,
|
||||
trackChanges: tracksChanges,
|
||||
multilingual: isMultilingual,
|
||||
locales: isMultilingual ? getSupportedLocales() : undefined,
|
||||
activeLocale: getCurrentLocale(),
|
||||
@@ -1397,7 +1422,7 @@ export async function initEditorHandler(
|
||||
if (!externalSync.shouldEmit()) return;
|
||||
const html = (editor as any).getData();
|
||||
externalSync.noteEmitted(html);
|
||||
syncToForm(name, locale, html, true);
|
||||
syncToForm(name, locale, html, true, tracksChanges);
|
||||
updateCheckIconsRef.current();
|
||||
});
|
||||
|
||||
@@ -1447,6 +1472,7 @@ export async function initEditorHandler(
|
||||
locales,
|
||||
activeLocale,
|
||||
contentMap,
|
||||
trackChanges: tracksChanges,
|
||||
});
|
||||
notifyEditorAssetFailure(
|
||||
`ckeditor5-editor:${containerId}`,
|
||||
@@ -1503,7 +1529,7 @@ export async function initEditorHandler(
|
||||
if (!externalSync.shouldEmit()) return;
|
||||
const html = (editor as any).getData();
|
||||
externalSync.noteEmitted(html);
|
||||
syncToForm(name, singleLocale, html, false);
|
||||
syncToForm(name, singleLocale, html, false, tracksChanges);
|
||||
});
|
||||
|
||||
isInitializing = false;
|
||||
@@ -1542,6 +1568,7 @@ export async function initEditorHandler(
|
||||
multilingual: false,
|
||||
activeLocale: getCurrentLocale(),
|
||||
initialContent,
|
||||
trackChanges: tracksChanges,
|
||||
});
|
||||
notifyEditorAssetFailure(
|
||||
`ckeditor5-editor:${containerId}`,
|
||||
|
||||
@@ -39,6 +39,13 @@ export interface TextareaFallbackOptions {
|
||||
contentMap?: Record<string, string>;
|
||||
/** 단일 모드 초기값 */
|
||||
initialContent?: string;
|
||||
/**
|
||||
* 이 입력을 "폼이 변경됨"(`_local.hasChanges`)으로 칠지 여부. 기본 `true`.
|
||||
*
|
||||
* 편집기 쪽 `trackChanges` 와 같은 축이다 — 저장 대상이 아닌 편집기가 폴백으로
|
||||
* 내려앉아도 저장 대상이 아닌 것은 그대로여야 한다.
|
||||
*/
|
||||
trackChanges?: boolean;
|
||||
}
|
||||
|
||||
/** 컨테이너별 폴백 상태 (재시도 시 값 승계에 쓴다) */
|
||||
@@ -54,20 +61,42 @@ const fallbackState = new WeakMap<HTMLElement, {
|
||||
* `_mode` 를 `'text'` 로 둔다 — 편집기가 없으므로 입력된 것은 HTML 이 아니다.
|
||||
* 이 값을 그대로 `'html'` 로 두면 서버가 평문을 HTML 로 신뢰하게 된다.
|
||||
*
|
||||
* `hasChanges` 는 본문 배치에 섞지 않는다 — `syncToForm` 과 같은 이유다. 그 배치는
|
||||
* `render:false + selfManaged:true` 라 React 렌더를 일으키지 않으므로, 플래그가 저장소 B 에만
|
||||
* 들어가고 저장 버튼의 활성 조건이 재평가되지 않는다. `admin_board_post_form.json` 은
|
||||
* 수정 화면에서 `(!!route?.id && !_local.hasChanges)` 로 저장을 잠그므로, 폴백에서 본문만
|
||||
* 고친 운영자는 **저장 자체를 할 수 없다.** 폐쇄망·방화벽 환경에서는 이 폴백이 정상 경로다.
|
||||
*
|
||||
* 이 함수는 사용자 입력뿐 아니라 **폴백을 세우는 시점에도** 불린다(`_mode='text'` 를 미리
|
||||
* 심어 두려고). 그 자리까지 플래그를 켜면 아무것도 입력하지 않았는데 화면을 여는 것만으로
|
||||
* "변경됨" 이 되므로, 실제 입력에서 온 호출만 올린다.
|
||||
*
|
||||
* @param name 폼 필드명
|
||||
* @param value 반영할 값 (다국어면 로케일 맵)
|
||||
* @param userInitiated 사용자 입력에서 온 호출인지 (렌더 시점 시드는 `false`)
|
||||
* @param tracksChanges 이 입력을 폼 변경으로 칠지 여부 (저장 대상이 아닌 편집기는 `false`)
|
||||
* @return void
|
||||
*/
|
||||
function syncFallbackToForm(name: string, value: string | Record<string, string>): void {
|
||||
function syncFallbackToForm(
|
||||
name: string,
|
||||
value: string | Record<string, string>,
|
||||
userInitiated: boolean,
|
||||
tracksChanges: boolean
|
||||
): void {
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
if (!G7Core?.state?.setLocal || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 렌더를 일으키는 별도 setLocal. false → true 로 처음 넘어갈 때만 보내므로
|
||||
// 편집 세션당 추가 렌더는 최대 1회다 (`syncToForm` 과 같은 규칙).
|
||||
if (userInitiated && tracksChanges && G7Core.state.getLocal?.()?.hasChanges !== true) {
|
||||
G7Core.state.setLocal({ hasChanges: true });
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {
|
||||
[`form.${name}_mode`]: 'text',
|
||||
hasChanges: true,
|
||||
};
|
||||
|
||||
if (typeof value === 'string') {
|
||||
@@ -99,6 +128,9 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// 기본은 true — 저장 대상이 아닌 편집기(설정 화면 미리보기 등)만 명시적으로 끈다.
|
||||
const tracksChanges = options.trackChanges !== false;
|
||||
|
||||
const locales = multilingual ? (options.locales ?? []) : [];
|
||||
const activeLocale = options.activeLocale ?? locales[0] ?? '';
|
||||
|
||||
@@ -169,7 +201,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
|
||||
currentLocale = locale;
|
||||
textarea.value = values[locale] ?? '';
|
||||
textarea.dataset.locale = locale;
|
||||
syncFallbackToForm(name, values);
|
||||
syncFallbackToForm(name, values, false, tracksChanges);
|
||||
refreshTabs();
|
||||
});
|
||||
|
||||
@@ -183,7 +215,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
values[currentLocale] = textarea.value;
|
||||
syncFallbackToForm(name, values);
|
||||
syncFallbackToForm(name, values, true, tracksChanges);
|
||||
refreshTabs();
|
||||
});
|
||||
} else {
|
||||
@@ -191,7 +223,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
values[''] = textarea.value;
|
||||
syncFallbackToForm(name, textarea.value);
|
||||
syncFallbackToForm(name, textarea.value, true, tracksChanges);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,7 +233,7 @@ export function renderTextareaFallback(options: TextareaFallbackOptions): void {
|
||||
|
||||
// 폴백으로 내려앉았다는 사실 자체를 폼 상태에 반영한다 — 사용자가 아무것도 입력하지
|
||||
// 않고 저장해도 서버가 평문으로 처리하도록.
|
||||
syncFallbackToForm(name, multilingual ? values : (values[''] ?? ''));
|
||||
syncFallbackToForm(name, multilingual ? values : (values[''] ?? ''), false, tracksChanges);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -700,11 +700,13 @@
|
||||
"id": "preview_editor",
|
||||
"type": "extension_point",
|
||||
"name": "html_editor",
|
||||
"comment": "trackChanges:false — 미리보기는 설정을 시험해 보는 자리라 저장 대상이 아니다(위 저장 액션의 body 에서도 제외된다). 이것을 빼면 미리보기에 글자만 쳐도 _local.hasChanges 가 켜져 [저장] 버튼이 활성화되고, 운영자는 바뀐 것이 없는데 바뀐 줄 알고 누르게 된다. 어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명으로 알아보게 하지 않고 여기서 선언한다.",
|
||||
"props": {
|
||||
"name": "preview_content",
|
||||
"content": "",
|
||||
"placeholder": "$t:sirsoft-ckeditor5.settings.preview_placeholder",
|
||||
"multilingual": true
|
||||
"multilingual": true,
|
||||
"trackChanges": false
|
||||
},
|
||||
"default": [
|
||||
{
|
||||
|
||||
@@ -165,7 +165,11 @@ test.describe('편집기 자산 실패 폴백', () => {
|
||||
|
||||
test('폭을 바꾼 뒤 저장해도 폴백 입력창의 본문이 그대로 전송된다', async ({ page, editorToken }) => {
|
||||
// @scenario asset_class=vendored, outcome=failed
|
||||
// @effects failed_asset_falls_back_to_plain_input
|
||||
// @effects failed_asset_falls_back_to_plain_input, fallback_body_survives_resize_on_save
|
||||
//
|
||||
// 이 케이스는 두 매니페스트에 걸친다 — 폴백이 서는 것 자체는
|
||||
// `self-hosted-runtime-assets.yaml`, 폭 변경 후 본문이 살아남는 것은
|
||||
// `editor-resize-save-body-integrity.yaml` 이 소유한다.
|
||||
//
|
||||
// 공개 #130(engine-v1.63.3) 인접 축. 평문 폴백도 본문을 `setLocal({ render:false,
|
||||
// selfManaged:true })` 로 저장소 B 에만 쓰므로, 편집기 경로와 **같은 조건**이 성립한다.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension;
|
||||
|
||||
use App\Extension\Traits\GeneratesComponentManifest;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* components.json 종결 개행이 빌드 OS 에 좌우되지 않는지 확인합니다.
|
||||
*
|
||||
* 이 매니페스트는 `module:build` / `plugin:build` 산출물이면서 Git 추적 대상이다.
|
||||
* 종결 개행을 PHP_EOL 로 쓰면 Windows 빌드에서만 "\r\n" 이 되어, 소스를 하나도
|
||||
* 고치지 않고 다시 빌드하기만 해도 변경으로 잡힌다. 줄 내용은 같으므로 diff 는
|
||||
* 비어 보이고, 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
|
||||
*/
|
||||
class GeneratesComponentManifestNewlineTest extends TestCase
|
||||
{
|
||||
private string $buildPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->buildPath = sys_get_temp_dir().'/g7-cm-'.uniqid();
|
||||
mkdir($this->buildPath.'/resources/js/components/basic', 0o777, true);
|
||||
file_put_contents($this->buildPath.'/plugin.json', json_encode(['version' => '1.2.3']));
|
||||
file_put_contents($this->buildPath.'/resources/js/components/basic/Foo.tsx', 'export const Foo = () => null;');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->removeDirectory($this->buildPath);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 트레이트를 쓰는 익명 클래스로 생성기를 호출합니다.
|
||||
*
|
||||
* @return array{written: bool, count: int, path: string} 작성 결과
|
||||
*/
|
||||
private function generate(): array
|
||||
{
|
||||
$generator = new class
|
||||
{
|
||||
use GeneratesComponentManifest;
|
||||
|
||||
/**
|
||||
* protected 생성기를 테스트에서 호출하기 위한 통로입니다.
|
||||
*
|
||||
* @param string $buildPath 빌드 경로
|
||||
* @return array{written: bool, count: int, path: string} 작성 결과
|
||||
*/
|
||||
public function run(string $buildPath): array
|
||||
{
|
||||
return $this->generateComponentManifest($buildPath, 'sirsoft-sample');
|
||||
}
|
||||
};
|
||||
|
||||
return $generator->run($this->buildPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리를 재귀 삭제합니다.
|
||||
*
|
||||
* @param string $dir 삭제할 디렉토리
|
||||
* @return void
|
||||
*/
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.'/'.$entry;
|
||||
is_dir($path) ? $this->removeDirectory($path) : @unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
public function test_manifest_ends_with_lf_not_platform_eol(): void
|
||||
{
|
||||
$result = $this->generate();
|
||||
|
||||
$this->assertTrue($result['written']);
|
||||
|
||||
$raw = file_get_contents($result['path']);
|
||||
|
||||
$this->assertStringEndsWith("}\n", $raw, 'LF 로 끝나야 한다');
|
||||
$this->assertStringNotContainsString("\r", $raw, '어느 줄에도 CR 이 없어야 한다 — 있으면 Windows 빌드에서만 변경으로 잡힌다');
|
||||
}
|
||||
|
||||
public function test_rebuilding_the_same_source_produces_identical_bytes(): void
|
||||
{
|
||||
$first = file_get_contents($this->generate()['path']);
|
||||
$second = file_get_contents($this->generate()['path']);
|
||||
|
||||
$this->assertSame($first, $second, '같은 소스를 다시 빌드하면 바이트까지 같아야 한다');
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ test_files:
|
||||
- resources/js/core/__tests__/G7CoreGlobals.selfManagedPromotion.test.ts
|
||||
- resources/js/core/template-engine/__tests__/DynamicRenderer.autoBindingPendingBase.test.tsx
|
||||
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/admin/editor-resize-save-body-integrity.spec.ts
|
||||
# 편집기 확보 실패 시의 평문 폴백도 같은 조건(저장소 B 전용 쓰기)을 만든다 — 그 축을
|
||||
# 잠그는 케이스가 이 spec 안에 있으므로 여기에 함께 건다. 자산 폴백 자체의 계약은
|
||||
# self-hosted-runtime-assets.yaml 이 소유한다.
|
||||
- plugins/_bundled/sirsoft-ckeditor5/tests/Playwright/specs/editor-fallback.spec.ts
|
||||
|
||||
axes:
|
||||
save_flow: [create, edit]
|
||||
@@ -43,6 +47,7 @@ effects:
|
||||
- autobinding_keystroke_preserves_editor_content
|
||||
- pending_snapshot_carries_forced_overlay
|
||||
- typed_path_wins_over_forced_previous_value
|
||||
- fallback_body_survives_resize_on_save
|
||||
|
||||
sub_flows:
|
||||
- id: control_no_resize
|
||||
@@ -92,3 +97,14 @@ sub_flows:
|
||||
- autobinding_keystroke_preserves_editor_content
|
||||
- pending_snapshot_carries_forced_overlay
|
||||
- typed_path_wins_over_forced_previous_value
|
||||
|
||||
- id: plain_fallback_after_resize
|
||||
description: |
|
||||
편집기 확보에 실패해 평문 입력창으로 내려간 경우도 본문을 `setLocal({ render:false,
|
||||
selfManaged:true })` 로 저장소 B 에만 쓴다 — 이 결함의 성립 조건이 그대로 성립한다.
|
||||
폴백은 디바운스를 쓰지 않아 입력 즉시 B 에 실리므로, 편집기 축의 결정화 4단계 중
|
||||
①(디바운스 발화 대기)만 빠지고 ②폭 변경 → ③pending 클리어 확정 → ④저장은 동일하다.
|
||||
|
||||
이 축이 별도로 필요한 이유: 폐쇄망·차단 환경에서는 폴백이 **정상 경로**다. 편집기 축만
|
||||
잠그면 그 환경의 운영자에게는 결함이 남은 채로 남는다.
|
||||
effects: [fallback_body_survives_resize_on_save]
|
||||
|
||||
Reference in New Issue
Block a user