fix(core,extensions): 설치 완료 스크롤 + 미인증 화면 테마 버튼 무반응 수정
Modern PHP User Group 2026-09 정기모임 설치 리뷰에서 접수된 제보 2건. 설치 완료·실패·중단·필수파일 안내는 페이지 최상단에 뜨는데, 진행 로그를 보느라 화면이 아래에 머물러 있으면 안내가 눈에 들어오지 않았다. 네 경로 모두 DOM 변경이 끝난 뒤 결과 섹션으로 부드럽게 이동시킨다(모션 최소화 설정 시 즉시 이동). 미인증 3화면의 테마 버튼은 setTheme 을 params.theme 으로 불렀는데 핸들러는 action.target 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고 아무 일도 하지 않았다. 로그인 이후 화면은 핸들러를 거치지 않는 ThemeToggle 을 쓰기 때문에 정상이었고, 그래서 이 세 화면에서만 나타났다. 같은 계약 불일치가 편집기 액션 레시피(admin 9·basic 3)와 두 템플릿 문서에도 있어 함께 고쳤다. 편집기로 만든 액션은 생성 즉시 no-op 이 되는데 오류가 남지 않는다. 정적 검사 레지스트리가 오히려 틀린 계약(params.theme 필수)을 강제하고 있어 올바른 형태를 막고 있었으므로 두 미러를 함께 정정했다. 테마를 고치는 과정에서 별개 결함이 드러났다. GDPR 스토리지 인터셉터가 기능 쿠키 미동의 상태에서 필수 목록 밖 저장을 부팅마다 파기하는데, 화면 테마가 그 목록에서 빠져 있었다. 저장소의 setItem 호출을 기계 도출해 대조한 결과 같은 이유로 사라지던 항목이 13건 더 있었다 — 비회원 주문 조회, 결제창 복귀 기록, 본인인증 복귀 기록, 관리자 화면 상태, 자산 주소 형식 캐시. 전량 필수로 분류하고, 동의 안내 문구가 "다크모드는 기능 쿠키" 라고 말하던 부분을 사실에 맞게 정정했다(기설치본의 저장된 문구는 업그레이드 스텝이 정정하며, 운영자가 고친 문구는 건드리지 않는다). 재발 방지는 허용목록을 직접 import 하고 모집단을 디렉토리 순회로 도출하는 커버리지 테스트가 맡는다 — 손으로 열거하지 않으므로 새 저장 키가 등재를 빠뜨리면 붉어진다.
This commit is contained in:
@@ -121,8 +121,8 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 203개 | `vitest.config.ts` |
|
||||
| Playwright | 8개 | `tests/Playwright` |
|
||||
| Vitest | 205개 | `vitest.config.ts` |
|
||||
| Playwright | 9개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
- 대시보드 알림 중 일부 경고가 일반 안내와 같은 회색으로 표시되어 경고로 보이지 않던 문제를 수정했습니다. 이제 알림의 심각도에 따라 색이 정해집니다.
|
||||
- 경고 등급 알림을 대시보드 맨 아래 「시스템 알림」 칸이 아니라 **화면 상단**에 표시합니다. 종전에는 스크롤을 끝까지 내려야 보여, 화면이 정상으로 보이는 상황에서는 조치가 필요하다는 사실을 지나치기 쉬웠습니다. 경고가 아닌 안내는 종전대로 하단에 남으며, 같은 알림이 두 곳에 중복으로 뜨지 않습니다.
|
||||
- 알림이 여러 건일 때 서로 맞붙어 표시되던 문제를 수정했습니다. 이제 간격을 두고 차례로 쌓입니다.
|
||||
- 로그인·비밀번호 찾기·비밀번호 재설정 화면의 테마(밝게/어둡게/시스템 설정) 버튼이 눌러도 아무 반응이 없던 문제를 수정했습니다. 로그인 이후 화면은 정상이었기 때문에 이 세 화면에서만 나타났습니다. (Modern PHP User Group 박민권 님께서 제보해주셨습니다.)
|
||||
- 레이아웃 편집기의 「액션 추가」로 만든 일부 동작이 만들자마자 아무 일도 하지 않던 문제를 수정했습니다. 화면 테마 바꾸기·화면 언어 바꾸기·화면 테마 초기화·특정 영역으로 스크롤·기간 빠르게 선택·필터 보이기/숨기기·필터 표시 초기화·다국어 태그 저장·화면 상태 바꾸기 아홉 가지가 대상이며, 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
|
||||
- 필터 보이기/숨기기와 필터 표시 초기화 동작에 저장 키를 입력할 자리가 없어, 편집기로 만들면 필터 상태가 저장도 복원도 되지 않던 문제를 수정했습니다.
|
||||
- 사이드바 하위 메뉴의 펼치기/접기 버튼에 번역되지 않은 원문(`common.expand`)이 그대로 붙어 있던 문제를 수정했습니다. 화면에는 보이지 않지만 화면 낭독기 사용자에게 그대로 읽혔습니다.
|
||||
|
||||
## [1.0.7] - 2026-08-24
|
||||
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file action-recipes-contract.test.ts
|
||||
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
|
||||
*
|
||||
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
|
||||
* 그래서 레시피가 핸들러 계약과 어긋나 있으면, 운영자가 편집기로 만든 액션이 **생성 즉시 no-op** 이
|
||||
* 된다. 예외도 오류도 남지 않고 버튼만 반응하지 않으므로 만든 사람이 알아챌 방법이 없다.
|
||||
*
|
||||
* 아래 8종은 실제로 어긋나 있던 항목이다 (dev-g7#640 부수의무 전수 조사).
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const recipes = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
/** 레시피의 입력 필드 선언에서 key 목록을 뽑는다. */
|
||||
function paramKeys(id: string): string[] {
|
||||
return (recipes[id]?.params ?? []).map((p: any) => p.key);
|
||||
}
|
||||
|
||||
describe('actionRecipes.json — 핸들러 계약 일치', () => {
|
||||
describe('target 형 액션 (params 가 아니라 top-level target)', () => {
|
||||
// setLocale 은 엔진 빌트인(ActionDispatcher), setTheme/initTheme 은 템플릿 핸들러.
|
||||
// 셋 다 action.target 만 읽고 params 는 보지 않는다.
|
||||
it.each(['setLocale', 'setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
|
||||
const build = recipes[id]?.build;
|
||||
expect(build).toBeDefined();
|
||||
expect(build.target).toBe('{{target}}');
|
||||
expect(build.params).toBeUndefined();
|
||||
expect(paramKeys(id)).toContain('target');
|
||||
});
|
||||
});
|
||||
|
||||
it('scrollToSection 은 params.targetId 로 굽는다 (sectionId 아님)', () => {
|
||||
const build = recipes.scrollToSection?.build;
|
||||
expect(build.params).toEqual({ targetId: '{{targetId}}' });
|
||||
expect(build.params.sectionId).toBeUndefined();
|
||||
expect(paramKeys('scrollToSection')).toEqual(['targetId']);
|
||||
});
|
||||
|
||||
it('setDateRange 의 preset 선택지는 핸들러 유효값 안에 있다', () => {
|
||||
// setDateRangeHandler 의 DatePreset 타입
|
||||
const validPresets = ['today', 'week', 'month', '3months', '6months', '1year'];
|
||||
const options = recipes.setDateRange?.params?.[0]?.options ?? [];
|
||||
const values = options.map((o: any) => o.value);
|
||||
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
for (const v of values) expect(validPresets).toContain(v);
|
||||
// 'year' 는 핸들러가 모르는 값 — switch 어느 분기에도 걸리지 않는다
|
||||
expect(values).not.toContain('year');
|
||||
expect(values).toContain('1year');
|
||||
});
|
||||
|
||||
it('toggleFilterVisibility 는 storageKey + filterId 를 모두 넘긴다', () => {
|
||||
const build = recipes.toggleFilterVisibility?.build;
|
||||
expect(build.params).toEqual({
|
||||
storageKey: '{{storageKey}}',
|
||||
filterId: '{{filterId}}',
|
||||
});
|
||||
expect(build.params.filterKey).toBeUndefined();
|
||||
expect(paramKeys('toggleFilterVisibility').sort()).toEqual(['filterId', 'storageKey']);
|
||||
});
|
||||
|
||||
it('initFilterVisibility 는 storageKey 를 넘긴다 (없으면 핸들러가 조기 반환)', () => {
|
||||
const build = recipes.initFilterVisibility?.build;
|
||||
expect(build.params).toEqual({ storageKey: '{{storageKey}}' });
|
||||
expect(paramKeys('initFilterVisibility')).toEqual(['storageKey']);
|
||||
});
|
||||
|
||||
it('saveMultilingualTag 은 인자를 받지 않는다 (핸들러가 전역 편집 상태만 읽음)', () => {
|
||||
const recipe = recipes.saveMultilingualTag;
|
||||
expect(recipe.build.params).toBeUndefined();
|
||||
expect(recipe.params).toEqual([]);
|
||||
});
|
||||
|
||||
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
|
||||
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
|
||||
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
|
||||
// 전역 대신 _local 에 조용히 기록된다.
|
||||
const build = recipes.changeState?.build;
|
||||
expect(build.handler).toBe('setState');
|
||||
expect(build.target).toBeUndefined();
|
||||
expect(build.params?.target).toBe('local');
|
||||
});
|
||||
});
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file admin-auth-settheme-target.test.tsx
|
||||
* @description 비인증 화면(로그인·비밀번호찾기·비밀번호재설정) 테마 버튼의 setTheme 액션 형태 회귀 테스트
|
||||
*
|
||||
* 배경: 세 화면의 테마 버튼(밝게/어둡게/자동)이 `"params": { "theme": "dark" }` 형태로
|
||||
* `setTheme` 을 호출했으나, 템플릿 핸들러(`src/handlers/setThemeHandler.ts`)는 **`action.target`
|
||||
* 만** 읽는다. 엔진(ActionDispatcher)에도 `target` ↔ `params` 상호 폴백이 없으므로 클릭 시
|
||||
* `[Handler:SetTheme] Invalid theme: undefined` 경고만 남기고 아무 일도 일어나지 않았다.
|
||||
* 로그인 이후 화면은 핸들러를 경유하지 않는 `ThemeToggle` 컴포지트를 쓰므로 정상이었고,
|
||||
* 그래서 이 결함은 미인증 3화면에서만 나타났다.
|
||||
*
|
||||
* 조치: 같은 디렉토리의 `admin-auth-setlocale-target.test.tsx` 가 잠근 setLocale 선례와 동형으로,
|
||||
* 세 화면의 액션을 `params.theme` → top-level `target` 으로 옮겼다.
|
||||
*
|
||||
* 이 테스트를 `params.theme` 로 되돌리면 세 화면의 테마 전환이 조용히 죽는다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const baseDir = path.resolve(__dirname, '../..');
|
||||
|
||||
function loadJson(relPath: string): any {
|
||||
return JSON.parse(fs.readFileSync(path.resolve(baseDir, relPath), 'utf8'));
|
||||
}
|
||||
|
||||
/** 레이아웃 전체에서 handler === 'setTheme' 인 액션을 모두 수집한다. */
|
||||
function collectSetThemeActions(node: any, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
if (Array.isArray(node)) {
|
||||
for (const n of node) collectSetThemeActions(n, acc);
|
||||
return acc;
|
||||
}
|
||||
for (const action of node.actions ?? []) {
|
||||
if (action?.handler === 'setTheme') acc.push(action);
|
||||
}
|
||||
for (const k of ['children', 'components']) {
|
||||
if (node[k]) collectSetThemeActions(node[k], acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const VALID_THEMES = ['light', 'dark', 'auto'];
|
||||
|
||||
const layouts: Array<[string, string]> = [
|
||||
['admin_login', 'layouts/admin_login.json'],
|
||||
['admin_forgot_password', 'layouts/admin_forgot_password.json'],
|
||||
['admin_reset_password', 'layouts/admin_reset_password.json'],
|
||||
];
|
||||
|
||||
describe('비인증 화면 테마 버튼 — setThemeHandler 규약', () => {
|
||||
it.each(layouts)('%s 의 setTheme 3건은 target 으로 테마를 넘긴다', (_name, relPath) => {
|
||||
const layout = loadJson(relPath);
|
||||
const actions = collectSetThemeActions(layout.components ?? layout);
|
||||
|
||||
// 밝게 / 어둡게 / 자동 세 버튼
|
||||
expect(actions.length).toBe(3);
|
||||
|
||||
for (const action of actions) {
|
||||
expect(action.type).toBe('click');
|
||||
expect(VALID_THEMES).toContain(action.target);
|
||||
// 핸들러는 params 를 읽지 않는다. 남아 있으면 무시되어 테마 전환이 죽는다.
|
||||
expect(action.params).toBeUndefined();
|
||||
}
|
||||
|
||||
// 세 버튼이 서로 다른 테마를 지정한다
|
||||
expect([...actions.map((a) => a.target)].sort()).toEqual(['auto', 'dark', 'light']);
|
||||
});
|
||||
|
||||
it('세 화면 합계 9건이 모두 target 형식이다', () => {
|
||||
const all = layouts.flatMap(([, relPath]) => {
|
||||
const layout = loadJson(relPath);
|
||||
return collectSetThemeActions(layout.components ?? layout);
|
||||
});
|
||||
|
||||
expect(all.length).toBe(9);
|
||||
expect(all.every((a) => VALID_THEMES.includes(a.target))).toBe(true);
|
||||
expect(all.every((a) => a.params === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -43,46 +43,63 @@
|
||||
|
||||
다크/라이트/자동(`auto`, 시스템 설정 따름) 테마를 전환·복원합니다. `setTheme` 은 localStorage
|
||||
저장 + `document.documentElement` 클래스 적용(Tailwind `dark:` variant 활성화)을,
|
||||
`initTheme` 은 params 없이 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
|
||||
`initTheme` 은 `init_actions` 에서 호출해 저장된 테마를 앱 시작 시 복원합니다.
|
||||
|
||||
두 핸들러 모두 테마 값을 액션 **top-level `target`** 으로 받습니다. `params.theme` 으로 넘기면
|
||||
엔진에 상호 폴백이 없어 조용히 no-op 이 됩니다 — 콘솔 경고 한 줄 외에는 아무 흔적이 없습니다.
|
||||
|
||||
```json
|
||||
{ "type": "click", "handler": "setTheme", "params": { "theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" } }
|
||||
{ "type": "click", "handler": "setTheme", "target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}" }
|
||||
```
|
||||
|
||||
`initTheme` 의 `target` 은 선택입니다 — 유효한 테마 값이면 그것을 적용하고, 없거나 유효하지
|
||||
않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
|
||||
|
||||
### scrollToSection
|
||||
|
||||
`params.selector`(CSS 선택자, 필수) 로 지정한 요소로 부드럽게 스크롤합니다. `params.offset`
|
||||
(기본 `0`, 음수면 위로)은 고정 헤더 높이를 보상할 때 씁니다.
|
||||
`params.targetId`(엘리먼트 **ID**, 필수) 로 지정한 요소로 부드럽게 스크롤합니다 — CSS 선택자가
|
||||
아니라 `getElementById` 대상이므로 `#` 이나 클래스 선택자를 넣지 않습니다. `params.offset`
|
||||
(기본 `120`)은 고정 헤더 높이를 보상하는 여백이고, `params.delay`(기본 `100`)는 조건부 렌더링
|
||||
요소를 기다리는 재시도 간격, `params.scrollContainerId` 는 스크롤 컨테이너를 명시할 때 씁니다.
|
||||
|
||||
```json
|
||||
{ "type": "click", "handler": "scrollToSection", "params": { "selector": "#features", "offset": -80 } }
|
||||
{ "type": "click", "handler": "scrollToSection", "params": { "targetId": "features", "offset": 80 } }
|
||||
```
|
||||
|
||||
### initMenuFromUrl
|
||||
|
||||
현재 URL 경로를 사이드바 메뉴 항목과 매칭해 활성 메뉴(및 부모 메뉴의 펼침 상태)를 자동
|
||||
설정합니다. params 없이 `_admin_base.json` 의 `init_actions` 에서 호출합니다.
|
||||
URL **쿼리스트링**(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를
|
||||
초기화합니다. `window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다 —
|
||||
메뉴 관리 화면에 URL 로 직접 들어왔을 때 해당 메뉴를 선택 상태로 여는 용도입니다.
|
||||
params 없이 그 화면의 `init_actions` 에서 호출합니다.
|
||||
|
||||
### 필터 가시성 핸들러 4종
|
||||
|
||||
목록 화면 필터 패널의 표시/숨김을 localStorage 에 저장해 새로고침 후에도 유지합니다.
|
||||
|
||||
`storageKey` 는 네 핸들러 모두 **필수**입니다 — 빠지면 경고 한 줄을 남기고 조기 반환하므로
|
||||
필터 상태가 복원도 저장도 되지 않습니다. localStorage 키는 `g7_filter_visibility_{storageKey}`
|
||||
이고, 복원 대상 로컬 상태 경로는 `params.stateKey`(기본 `visibleFilters`)입니다.
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|---|---|---|
|
||||
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 (`init_actions`에서 호출) |
|
||||
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
|
||||
| `toggleFilterVisibility` | `{ key }` | 특정 필터 키 가시성 토글 |
|
||||
| `resetFilterVisibility` | 없음 | 전체 초기화 |
|
||||
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 (`init_actions`에서 호출) |
|
||||
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
|
||||
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 가시성 토글 + 즉시 저장 |
|
||||
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
|
||||
|
||||
### 다국어 태그 핸들러 3종
|
||||
|
||||
`MultilingualInput` 컴포넌트가 쓰는 태그 편집 핸들러입니다.
|
||||
|
||||
편집 중인 값은 전역 상태 `_global.multilingualTagEdit` 에 있습니다 — 저장·취소 핸들러는 그
|
||||
상태만 읽으므로 액션 인자를 받지 않습니다.
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|---|---|---|
|
||||
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
|
||||
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
|
||||
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
||||
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
|
||||
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
|
||||
|
||||
### setDateRange
|
||||
|
||||
@@ -105,7 +122,7 @@ JSON 이 `sequence` + `setState` 로 반환값(`$prev.startDate` 등)을 원하
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|---|---|---|
|
||||
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (`init_actions`에서 호출) |
|
||||
| `initSidebar` | 없음 | 저장된 접힘 상태 복원 (레이아웃 `init_actions` 가 아니라 `src/index.ts` 부트스트랩이 1회 호출) |
|
||||
| `toggleSidebar` | 없음 | 접힘 상태 반전 + 저장 |
|
||||
|
||||
### downloadAttachment
|
||||
@@ -168,28 +185,29 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
||||
### setLocale
|
||||
|
||||
> **정정(#601)**: `setLocale` 은 더 이상 이 템플릿이 등록하는 핸들러가 아닙니다 — 엔진(ActionDispatcher)
|
||||
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, 동작·파라미터는
|
||||
> 같지만 **소유 주체가 템플릿이 아니라 엔진**입니다.
|
||||
> 빌트인으로 승격되어 모든 템플릿에서 동작합니다. 아래 서술은 이관 시점 기록이며, **소유 주체가
|
||||
> 템플릿이 아니라 엔진**입니다.
|
||||
>
|
||||
> **정정(#640)**: 엔진 빌트인은 로케일을 액션 **top-level `target`** 으로만 읽습니다.
|
||||
> `params.locale` 로 넘기면 무시되어 언어 전환이 조용히 죽습니다.
|
||||
|
||||
앱 언어를 변경합니다. 번역 파일을 다시 로드하고 UI를 갱신합니다.
|
||||
|
||||
**소스**: `src/handlers/setLocaleHandler.ts`
|
||||
**소스**: 엔진 빌트인 (`resources/js/core/template-engine/ActionDispatcher.ts`) — 이 템플릿에는 소스 파일이 없습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "en"
|
||||
}
|
||||
"target": "en"
|
||||
}
|
||||
```
|
||||
|
||||
#### params
|
||||
#### 파라미터
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
| 위치 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `locale` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
|
||||
| `target` | string | ✅ | 변경할 로케일 코드 (예: `"ko"`, `"en"`, `"ja"`) |
|
||||
|
||||
#### 동작
|
||||
|
||||
@@ -213,9 +231,7 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"locale": "en"
|
||||
}
|
||||
"target": "en"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -235,17 +251,18 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
"target": "dark"
|
||||
}
|
||||
```
|
||||
|
||||
#### setTheme params
|
||||
#### setTheme 파라미터
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
| 위치 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
|
||||
핸들러는 `action.target` 만 읽습니다. `params.theme` 으로 넘기면 콘솔에 `Invalid theme:
|
||||
undefined` 경고만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
|
||||
|
||||
#### 동작
|
||||
|
||||
@@ -269,7 +286,16 @@ ApiClient 경유로 토큰을 자동 첨부해야 다운로드 행위가 관리
|
||||
}
|
||||
```
|
||||
|
||||
params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원합니다.
|
||||
`target` 은 선택입니다. 유효한 테마 값(`light`/`dark`/`auto`)이면 그 값을 적용하고, 없거나
|
||||
유효하지 않으면 localStorage 저장값(없으면 `auto`)으로 복원합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initTheme", "target": "{{query.theme}}" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 사용 예시
|
||||
|
||||
@@ -282,9 +308,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
|
||||
}
|
||||
"target": "{{_global.theme === 'dark' ? 'light' : 'dark'}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -294,7 +318,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
### scrollToSection
|
||||
|
||||
특정 섹션으로 스크롤합니다. 오프셋 지원에 특화되어 있습니다.
|
||||
특정 섹션으로 스크롤합니다. 고정 헤더 보상 오프셋과 조건부 렌더링 대기에 특화되어 있습니다.
|
||||
|
||||
**소스**: `src/handlers/scrollToSectionHandler.ts`
|
||||
|
||||
@@ -303,8 +327,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
"type": "click",
|
||||
"handler": "scrollToSection",
|
||||
"params": {
|
||||
"selector": "#features",
|
||||
"offset": -80
|
||||
"targetId": "features",
|
||||
"offset": 80
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -313,15 +337,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
||||
|------|------|------|--------|------|
|
||||
| `selector` | string | ✅ | - | CSS 선택자 (예: `"#section-id"`, `".class-name"`) |
|
||||
| `offset` | number | ❌ | `0` | 스크롤 오프셋 (음수: 위로, 양수: 아래로). 고정 헤더 높이 보상에 사용 |
|
||||
| `targetId` | string | ✅ | - | 대상 엘리먼트의 **ID** (`getElementById` 대상 — `#` 없이, CSS 선택자 아님) |
|
||||
| `offset` | number | ❌ | `120` | 고정 헤더 높이 보상 여백 |
|
||||
| `delay` | number | ❌ | `100` | 요소가 아직 렌더되지 않았을 때의 재시도 간격(ms) |
|
||||
| `scrollContainerId` | string | ❌ | - | 스크롤 컨테이너를 명시할 때 (미지정 시 자동 탐색 → window) |
|
||||
|
||||
#### 동작
|
||||
|
||||
```text
|
||||
1. document.querySelector(selector)로 대상 요소 검색
|
||||
2. 요소의 위치 계산 + offset 적용
|
||||
3. window.scrollTo({ top, behavior: 'smooth' })로 부드러운 스크롤
|
||||
1. document.getElementById(targetId)로 대상 요소 검색 (미발견 시 delay 간격으로 재시도)
|
||||
2. 스크롤 컨테이너 결정 (scrollContainerId → 자동 탐색 → window)
|
||||
3. 요소의 위치 계산 + offset 보상
|
||||
4. 부드러운 스크롤 실행
|
||||
```
|
||||
|
||||
#### 사용 예시
|
||||
@@ -340,8 +367,8 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
"type": "click",
|
||||
"handler": "scrollToSection",
|
||||
"params": {
|
||||
"selector": "#features",
|
||||
"offset": -80
|
||||
"targetId": "features",
|
||||
"offset": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -352,7 +379,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
### initMenuFromUrl
|
||||
|
||||
현재 URL을 기반으로 사이드바/네비게이션 메뉴의 활성 상태를 초기화합니다. 주로 관리자 템플릿의 `init_actions`에서 사용합니다.
|
||||
URL 쿼리스트링(`?menu=<slug>&mode=<모드>`)을 읽어 메뉴 관리 화면의 선택 메뉴와 편집 모드를 초기화합니다. 그 화면의 `init_actions`에서 사용합니다.
|
||||
|
||||
**소스**: `src/handlers/initMenuFromUrlHandler.ts`
|
||||
|
||||
@@ -368,23 +395,23 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
#### params
|
||||
|
||||
없음. 현재 URL 경로를 메뉴 항목과 매칭하여 활성 메뉴를 자동 설정합니다.
|
||||
없음. 읽는 값은 액션 인자가 아니라 URL 쿼리 파라미터입니다.
|
||||
|
||||
#### 동작
|
||||
|
||||
```text
|
||||
1. 현재 URL 경로 (window.location.pathname) 추출
|
||||
2. 사이드바 메뉴 데이터에서 URL 매칭
|
||||
3. 매칭된 메뉴 항목의 is_active 상태 설정
|
||||
4. 부모 메뉴도 자동으로 펼침 상태 설정
|
||||
1. URLSearchParams 로 ?menu= (메뉴 slug) 와 ?mode= 추출
|
||||
2. 메뉴 데이터 소스에서 slug 로 해당 메뉴 검색 (자식 메뉴까지 재귀)
|
||||
3. 찾은 메뉴를 선택 상태로, mode 를 편집 모드로 설정
|
||||
```
|
||||
|
||||
#### 사용 예시 (_admin_base.json)
|
||||
`window.location.pathname` 을 사이드바 메뉴와 매칭하는 핸들러가 아닙니다.
|
||||
|
||||
#### 사용 예시 (메뉴 관리 화면)
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initTheme" },
|
||||
{ "handler": "initMenuFromUrl" }
|
||||
]
|
||||
}
|
||||
@@ -400,13 +427,18 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
#### initFilterVisibility
|
||||
|
||||
저장된 필터 가시성 상태를 `_local`에 복원합니다.
|
||||
저장된 필터 가시성 상태를 `_local`에 복원합니다. `storageKey` 가 없으면 경고 후 조기 반환합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{
|
||||
"handler": "initFilterVisibility"
|
||||
"handler": "initFilterVisibility",
|
||||
"params": {
|
||||
"storageKey": "product_index_filters",
|
||||
"defaultFilters": ["category", "date"],
|
||||
"stateKey": "visibleFilters"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -420,51 +452,65 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
{
|
||||
"handler": "saveFilterVisibility",
|
||||
"params": {
|
||||
"filters": "{{_local.filterVisibility}}"
|
||||
"storageKey": "product_index_filters",
|
||||
"filters": "{{_local.visibleFilters}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### toggleFilterVisibility
|
||||
|
||||
특정 필터 키의 가시성을 토글합니다.
|
||||
특정 필터의 가시성을 토글하고 즉시 localStorage 에 저장합니다. `storageKey` 와 `filterId` 가
|
||||
모두 있어야 하며, 하나라도 없으면 경고 후 조기 반환합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "toggleFilterVisibility",
|
||||
"params": {
|
||||
"key": "advancedFilters"
|
||||
"storageKey": "product_index_filters",
|
||||
"filterId": "category"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### resetFilterVisibility
|
||||
|
||||
모든 필터 가시성을 초기 상태로 리셋합니다.
|
||||
모든 필터 가시성을 `defaultFilters` 로 되돌립니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "resetFilterVisibility"
|
||||
"handler": "resetFilterVisibility",
|
||||
"params": {
|
||||
"storageKey": "product_index_filters",
|
||||
"defaultFilters": ["category", "date"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 핸들러 params 요약
|
||||
|
||||
`storageKey` 는 네 핸들러 모두 필수입니다. 실제 localStorage 키는
|
||||
`g7_filter_visibility_{storageKey}` 이고, 복원 대상 로컬 상태 경로는 `stateKey`(기본
|
||||
`visibleFilters`)입니다.
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `initFilterVisibility` | 없음 | localStorage → `_local` 복원 |
|
||||
| `saveFilterVisibility` | `{ filters }` | `_local` → localStorage 저장 |
|
||||
| `toggleFilterVisibility` | `{ key }` | 특정 키 토글 |
|
||||
| `resetFilterVisibility` | 없음 | 전체 초기화 |
|
||||
| `initFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | localStorage → `_local` 복원 |
|
||||
| `saveFilterVisibility` | `{ storageKey, filters }` | `_local` → localStorage 저장 |
|
||||
| `toggleFilterVisibility` | `{ storageKey, filterId, stateKey? }` | 특정 필터 토글 + 즉시 저장 |
|
||||
| `resetFilterVisibility` | `{ storageKey, defaultFilters?, stateKey? }` | 기본값으로 초기화 |
|
||||
|
||||
#### 사용 예시 (목록 페이지)
|
||||
|
||||
```json
|
||||
{
|
||||
"init_actions": [
|
||||
{ "handler": "initFilterVisibility" }
|
||||
{
|
||||
"handler": "initFilterVisibility",
|
||||
"params": { "storageKey": "product_index_filters", "defaultFilters": ["advancedFilters"] }
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
@@ -478,7 +524,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "toggleFilterVisibility",
|
||||
"params": { "key": "advancedFilters" }
|
||||
"params": { "storageKey": "product_index_filters", "filterId": "advancedFilters" }
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -486,7 +532,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
"id": "filter_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.filterVisibility?.advancedFilters}}",
|
||||
"if": "{{_local.visibleFilters?.includes('advancedFilters')}}",
|
||||
"children": [
|
||||
{ "comment": "필터 컴포넌트들" }
|
||||
]
|
||||
@@ -505,15 +551,13 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
#### saveMultilingualTag
|
||||
|
||||
다국어 태그를 저장합니다.
|
||||
편집 중인 다국어 태그를 부모 태그 배열에 반영하고 모달을 닫습니다. 액션 인자를 받지 않으며,
|
||||
읽는 값은 전역 상태 `_global.multilingualTagEdit`(필드명·편집 인덱스·로케일별 값·상태 경로)
|
||||
뿐입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"handler": "saveMultilingualTag",
|
||||
"params": {
|
||||
"field": "tags",
|
||||
"locale": "{{_global.locale}}"
|
||||
}
|
||||
"handler": "saveMultilingualTag"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -529,15 +573,15 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
#### updateMultilingualTagValue
|
||||
|
||||
다국어 태그 값을 업데이트합니다.
|
||||
편집 중인 다국어 태그의 특정 로케일 값을 갱신합니다. 값은 액션 인자가 아니라
|
||||
`context.event`(입력 이벤트)에서 읽으므로 `params` 에는 `locale` 만 넘깁니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "updateMultilingualTagValue",
|
||||
"params": {
|
||||
"field": "tags",
|
||||
"locale": "ko",
|
||||
"value": "{{$event.target.value}}"
|
||||
"locale": "ko"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -546,9 +590,9 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
| 핸들러 | params | 설명 |
|
||||
|--------|--------|------|
|
||||
| `saveMultilingualTag` | `{ field, locale }` | 태그 저장 |
|
||||
| `saveMultilingualTag` | 없음 | `_global.multilingualTagEdit` 을 부모 태그 배열에 반영 |
|
||||
| `cancelMultilingualTag` | 없음 | 편집 취소 |
|
||||
| `updateMultilingualTagValue` | `{ field, locale, value }` | 값 업데이트 |
|
||||
| `updateMultilingualTagValue` | `{ locale }` | 그 로케일 값 갱신 (값은 `context.event` 에서 읽음) |
|
||||
|
||||
---
|
||||
|
||||
@@ -556,7 +600,7 @@ params 없이 호출합니다. localStorage에 저장된 테마 설정을 복원
|
||||
|
||||
| 핸들러명 | 소스 파일 | 등록 함수 |
|
||||
|---------|----------|----------|
|
||||
| `setLocale` | `src/handlers/setLocaleHandler.ts` | `setLocaleHandler` |
|
||||
| `setLocale` | 엔진 빌트인 (이 템플릿에 소스 없음) | — |
|
||||
| `setTheme`, `initTheme` | `src/handlers/setThemeHandler.ts` | `initTheme` |
|
||||
| `scrollToSection` | `src/handlers/scrollToSectionHandler.ts` | `scrollToSectionHandler` |
|
||||
| `initMenuFromUrl` | `src/handlers/initMenuFromUrlHandler.ts` | `initMenuFromUrlHandler` |
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "setState",
|
||||
"target": "local",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"{{key}}": "{{value}}"
|
||||
}
|
||||
}
|
||||
@@ -132,9 +132,7 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "setLocale",
|
||||
"params": {
|
||||
"target": "{{target}}"
|
||||
}
|
||||
"target": "{{target}}"
|
||||
}
|
||||
},
|
||||
"setTheme": {
|
||||
@@ -153,24 +151,22 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"target": "{{target}}"
|
||||
}
|
||||
"target": "{{target}}"
|
||||
}
|
||||
},
|
||||
"scrollToSection": {
|
||||
"label": "$t:editor.action.scroll_to_section.label",
|
||||
"params": [
|
||||
{
|
||||
"key": "sectionId",
|
||||
"label": "$t:editor.action.scroll_to_section.param_section_id",
|
||||
"key": "targetId",
|
||||
"label": "$t:editor.action.scroll_to_section.param_target_id",
|
||||
"widget": "text"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"handler": "scrollToSection",
|
||||
"params": {
|
||||
"sectionId": "{{sectionId}}"
|
||||
"targetId": "{{targetId}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -185,7 +181,7 @@
|
||||
{ "value": "today", "label": "$t:editor.action.set_date_range.preset_today" },
|
||||
{ "value": "week", "label": "$t:editor.action.set_date_range.preset_week" },
|
||||
{ "value": "month", "label": "$t:editor.action.set_date_range.preset_month" },
|
||||
{ "value": "year", "label": "$t:editor.action.set_date_range.preset_year" }
|
||||
{ "value": "1year", "label": "$t:editor.action.set_date_range.preset_1year" }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -200,32 +196,29 @@
|
||||
"label": "$t:editor.action.toggle_filter_visibility.label",
|
||||
"params": [
|
||||
{
|
||||
"key": "filterKey",
|
||||
"label": "$t:editor.action.toggle_filter_visibility.param_filter_key",
|
||||
"key": "storageKey",
|
||||
"label": "$t:editor.action.toggle_filter_visibility.param_storage_key",
|
||||
"widget": "text"
|
||||
},
|
||||
{
|
||||
"key": "filterId",
|
||||
"label": "$t:editor.action.toggle_filter_visibility.param_filter_id",
|
||||
"widget": "text"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"handler": "toggleFilterVisibility",
|
||||
"params": {
|
||||
"filterKey": "{{filterKey}}"
|
||||
"storageKey": "{{storageKey}}",
|
||||
"filterId": "{{filterId}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"saveMultilingualTag": {
|
||||
"label": "$t:editor.action.save_multilingual_tag.label",
|
||||
"params": [
|
||||
{
|
||||
"key": "tag",
|
||||
"label": "$t:editor.action.save_multilingual_tag.param_tag",
|
||||
"widget": "i18n-text"
|
||||
}
|
||||
],
|
||||
"params": [],
|
||||
"build": {
|
||||
"handler": "saveMultilingualTag",
|
||||
"params": {
|
||||
"tag": "{{tag}}"
|
||||
}
|
||||
"handler": "saveMultilingualTag"
|
||||
}
|
||||
},
|
||||
"initTheme": {
|
||||
@@ -244,9 +237,7 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "initTheme",
|
||||
"params": {
|
||||
"target": "{{target}}"
|
||||
}
|
||||
"target": "{{target}}"
|
||||
}
|
||||
},
|
||||
"initMenuFromUrl": {
|
||||
@@ -258,9 +249,18 @@
|
||||
},
|
||||
"initFilterVisibility": {
|
||||
"label": "$t:editor.action.init_filter_visibility.label",
|
||||
"params": [],
|
||||
"params": [
|
||||
{
|
||||
"key": "storageKey",
|
||||
"label": "$t:editor.action.init_filter_visibility.param_storage_key",
|
||||
"widget": "text"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"handler": "initFilterVisibility"
|
||||
"handler": "initFilterVisibility",
|
||||
"params": {
|
||||
"storageKey": "{{storageKey}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"logout": "Logout",
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"collapse_sidebar": "Collapse sidebar",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"module": "Module",
|
||||
"plugin": "Plugin",
|
||||
"status_active": "Active",
|
||||
|
||||
@@ -1261,7 +1261,7 @@
|
||||
},
|
||||
"scroll_to_section": {
|
||||
"label": "Scroll to a section",
|
||||
"param_section_id": "Section ID"
|
||||
"param_target_id": "Section ID"
|
||||
},
|
||||
"set_date_range": {
|
||||
"label": "Quick date range",
|
||||
@@ -1269,15 +1269,15 @@
|
||||
"preset_today": "Today",
|
||||
"preset_week": "This week",
|
||||
"preset_month": "This month",
|
||||
"preset_year": "This year"
|
||||
"preset_1year": "Last 1 year"
|
||||
},
|
||||
"toggle_filter_visibility": {
|
||||
"label": "Show/hide filter",
|
||||
"param_filter_key": "Filter key"
|
||||
"param_storage_key": "Storage key",
|
||||
"param_filter_id": "Filter ID"
|
||||
},
|
||||
"save_multilingual_tag": {
|
||||
"label": "Save multilingual tag",
|
||||
"param_tag": "Tag"
|
||||
"label": "Save multilingual tag"
|
||||
},
|
||||
"init_theme": {
|
||||
"label": "Initialize screen theme",
|
||||
@@ -1290,7 +1290,8 @@
|
||||
"label": "Initialize menu from URL"
|
||||
},
|
||||
"init_filter_visibility": {
|
||||
"label": "Initialize filter visibility"
|
||||
"label": "Initialize filter visibility",
|
||||
"param_storage_key": "Storage key"
|
||||
}
|
||||
},
|
||||
"computed": {
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"logout": "로그아웃",
|
||||
"expand_sidebar": "사이드바 펼치기",
|
||||
"collapse_sidebar": "사이드바 접기",
|
||||
"expand": "펼치기",
|
||||
"collapse": "접기",
|
||||
"module": "모듈",
|
||||
"plugin": "플러그인",
|
||||
"status_active": "활성화",
|
||||
|
||||
@@ -1261,7 +1261,7 @@
|
||||
},
|
||||
"scroll_to_section": {
|
||||
"label": "특정 영역으로 스크롤",
|
||||
"param_section_id": "영역 ID"
|
||||
"param_target_id": "영역 ID"
|
||||
},
|
||||
"set_date_range": {
|
||||
"label": "기간 빠르게 선택",
|
||||
@@ -1269,15 +1269,15 @@
|
||||
"preset_today": "오늘",
|
||||
"preset_week": "이번 주",
|
||||
"preset_month": "이번 달",
|
||||
"preset_year": "올해"
|
||||
"preset_1year": "최근 1년"
|
||||
},
|
||||
"toggle_filter_visibility": {
|
||||
"label": "필터 보이기/숨기기",
|
||||
"param_filter_key": "필터 키"
|
||||
"param_storage_key": "저장 키",
|
||||
"param_filter_id": "필터 ID"
|
||||
},
|
||||
"save_multilingual_tag": {
|
||||
"label": "다국어 태그 저장",
|
||||
"param_tag": "태그"
|
||||
"label": "다국어 태그 저장"
|
||||
},
|
||||
"init_theme": {
|
||||
"label": "화면 테마 초기화",
|
||||
@@ -1290,7 +1290,8 @@
|
||||
"label": "주소로 메뉴 초기화"
|
||||
},
|
||||
"init_filter_visibility": {
|
||||
"label": "필터 표시 초기화"
|
||||
"label": "필터 표시 초기화",
|
||||
"param_storage_key": "저장 키"
|
||||
}
|
||||
},
|
||||
"computed": {
|
||||
|
||||
@@ -330,7 +330,7 @@
|
||||
"props": {
|
||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||
"value": "{{$locale}}",
|
||||
"options": "{{$locales}}"
|
||||
"options": "{{$locales ?? []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -372,9 +372,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "light"
|
||||
}
|
||||
"target": "light"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -402,9 +400,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
"target": "dark"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -432,9 +428,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "auto"
|
||||
}
|
||||
"target": "auto"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
|
||||
@@ -374,7 +374,7 @@
|
||||
"props": {
|
||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||
"value": "{{$locale}}",
|
||||
"options": "{{$locales}}"
|
||||
"options": "{{$locales ?? []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -415,9 +415,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "light"
|
||||
}
|
||||
"target": "light"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -445,9 +443,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
"target": "dark"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -475,9 +471,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "auto"
|
||||
}
|
||||
"target": "auto"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
|
||||
@@ -472,7 +472,7 @@
|
||||
"props": {
|
||||
"className": "block w-full px-4 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm",
|
||||
"value": "{{$locale}}",
|
||||
"options": "{{$locales}}"
|
||||
"options": "{{$locales ?? []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -514,9 +514,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "light"
|
||||
}
|
||||
"target": "light"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -544,9 +542,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
"target": "dark"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -574,9 +570,7 @@
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "auto"
|
||||
}
|
||||
"target": "auto"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
|
||||
@@ -198,6 +198,25 @@ describe('setThemeHandler', () => {
|
||||
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// 계약 명문화: 이 핸들러가 읽는 것은 action.target 뿐이다.
|
||||
// 레이아웃이 params.theme 로 넘기면(과거 admin_login/forgot/reset 3화면이 그랬다)
|
||||
// 엔진에 상호 폴백이 없어 조용히 no-op 이 된다. 되살아나면 여기가 red 가 된다.
|
||||
it('params.theme 로만 넘기면 경고 후 아무 것도 하지 않아야 함 (target 단일 계약)', async () => {
|
||||
const action = {
|
||||
type: 'click',
|
||||
handler: 'setTheme',
|
||||
params: { theme: 'dark' },
|
||||
};
|
||||
|
||||
await setThemeHandler(action);
|
||||
|
||||
expect(console.warn).toHaveBeenCalledWith('[Handler:SetTheme]', 'Invalid theme:', undefined);
|
||||
expect(setItemSpy).not.toHaveBeenCalled();
|
||||
expect(setAttributeSpy).not.toHaveBeenCalled();
|
||||
expect(classListAddSpy).not.toHaveBeenCalled();
|
||||
expect(classListRemoveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('localStorage 저장 실패 시 오류를 출력하고 테마를 적용하지 않아야 함', async () => {
|
||||
// localStorage.setItem이 실패하도록 모의
|
||||
const setItemError = new Error('Storage quota exceeded');
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* E2E: 미인증 관리자 화면(로그인·비밀번호 찾기·비밀번호 재설정)의 테마 버튼 (dev-g7#640)
|
||||
*
|
||||
* @scenario screen=admin_login|admin_forgot_password|admin_reset_password, theme=light|dark|auto
|
||||
*
|
||||
* 배경: 세 화면의 테마 버튼이 `setTheme` 을 `params.theme` 로 호출했으나 템플릿 핸들러는
|
||||
* `action.target` 만 읽는다. 엔진에 상호 폴백이 없어 클릭이 콘솔 경고 한 줄만 남기고
|
||||
* 아무 것도 하지 않았다 — 예외도 실패한 요청도 없어 화면상 원인이 보이지 않는다.
|
||||
* 로그인 이후 화면은 핸들러를 경유하지 않는 ThemeToggle 컴포지트를 쓰므로 정상이었다.
|
||||
*
|
||||
* 이 spec 은 클릭 한 번으로 `data-theme` 속성 · `dark` 클래스 · `localStorage` 세 값이
|
||||
* 함께 바뀌는지를 세 화면 × 세 버튼(9변종) 전부에 대해 잰다.
|
||||
*
|
||||
* ## 저장 축이 여기 함께 있는 이유 (실측 2026-09-03)
|
||||
*
|
||||
* 이 결함을 고치는 과정에서 **두 번째 독립 결함**이 드러났다. sirsoft-gdpr 플러그인의
|
||||
* 스토리지 인터셉터가 `Storage.prototype.setItem` 을 감싸고 functional 동의 전에는
|
||||
* strictly-necessary 허용목록 밖 키의 쓰기를 조용히 버리는데, `g7_color_scheme` 이 그
|
||||
* 목록에서 빠져 있었다. 그래서 테마를 바꿔도 새로고침하면 되돌아갔고, 증상이 미인증
|
||||
* 화면뿐 아니라 관리자 화면 전체에 나타났다.
|
||||
*
|
||||
* 그 키를 언어 설정(`g7_locale`)과 같은 필수 항목으로 재분류해 함께 고쳤으므로, 저장과
|
||||
* 새로고침 영속까지 이 spec 이 잰다. 두 결함 중 하나만 되돌아가도 여기가 붉어진다.
|
||||
*
|
||||
* 규율: `check()` 를 쓰지 않고 `click()` + 단언을 분리한다. `_global.theme` 은 `page.goto`
|
||||
* 로 초기화되므로 E2E 단언에 넣지 않는다.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** 테마 버튼의 `title` 은 `$t:admin.theme.*` — config 가 로케일을 ko 로 고정한다. */
|
||||
const THEME_BUTTON_TITLE = {
|
||||
light: '라이트 모드',
|
||||
dark: '다크 모드',
|
||||
auto: '시스템 설정',
|
||||
} as const;
|
||||
|
||||
type ThemeMode = keyof typeof THEME_BUTTON_TITLE;
|
||||
|
||||
const SCREENS: Array<[string, string]> = [
|
||||
['로그인', '/admin/login'],
|
||||
['비밀번호 찾기', '/admin/forgot-password'],
|
||||
// 토큰 없이도 레이아웃은 렌더된다 (init_actions 에 토큰 가드 없음).
|
||||
['비밀번호 재설정', '/admin/reset-password'],
|
||||
];
|
||||
|
||||
/** 화면 진입 — 테마 세그먼트 컨트롤이 그려질 때까지 기다린다. */
|
||||
async function gotoScreen(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path);
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await expect(page.locator(`button[title="${THEME_BUTTON_TITLE.dark}"]`)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** DOM 표현 2종 + 저장값을 한 번에 채집한다. */
|
||||
async function readThemeState(page: Page) {
|
||||
return await page.evaluate(() => ({
|
||||
dataTheme: document.documentElement.getAttribute('data-theme'),
|
||||
darkClass: document.documentElement.classList.contains('dark'),
|
||||
stored: window.localStorage.getItem('g7_color_scheme'),
|
||||
}));
|
||||
}
|
||||
|
||||
async function clickTheme(page: Page, mode: ThemeMode): Promise<void> {
|
||||
await page.locator(`button[title="${THEME_BUTTON_TITLE[mode]}"]`).click();
|
||||
}
|
||||
|
||||
test.describe('미인증 관리자 화면 테마 버튼', () => {
|
||||
for (const [label, path] of SCREENS) {
|
||||
test(`${label} 화면 — 다크/라이트 전환이 화면과 저장값에 즉시 반영된다`, async ({ page }) => {
|
||||
await gotoScreen(page, path);
|
||||
|
||||
await clickTheme(page, 'dark');
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||
.toBe('dark');
|
||||
const afterDark = await readThemeState(page);
|
||||
expect(afterDark.darkClass).toBe(true);
|
||||
expect(afterDark.stored).toBe('dark');
|
||||
|
||||
await clickTheme(page, 'light');
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||
.toBe('light');
|
||||
const afterLight = await readThemeState(page);
|
||||
expect(afterLight.darkClass).toBe(false);
|
||||
expect(afterLight.stored).toBe('light');
|
||||
});
|
||||
|
||||
test(`${label} 화면 — 자동(시스템 설정) 버튼이 시스템 모드로 해석된다`, async ({ page }) => {
|
||||
await gotoScreen(page, path);
|
||||
|
||||
// 먼저 dark 를 걸어 두고 auto 로 되돌아오는지 본다 (초기값과 구분).
|
||||
await clickTheme(page, 'dark');
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 10_000 })
|
||||
.toBe('dark');
|
||||
|
||||
await clickTheme(page, 'auto');
|
||||
// auto 는 prefers-color-scheme 으로 해석된다 — 이 실행 환경은 light 다.
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
|
||||
.toBe('auto');
|
||||
const state = await readThemeState(page);
|
||||
expect(state.dataTheme).toBe('light');
|
||||
expect(state.darkClass).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
// 두 결함(핸들러 계약 · GDPR 허용목록)이 모두 고쳐져야만 통과한다.
|
||||
// 계약이 되돌아가면 클릭이 no-op 이 되고, 허용목록이 되돌아가면 저장이 버려진다.
|
||||
test('로그인 화면 — 다크 설정이 새로고침 뒤에도 유지된다 (동의 없는 첫 방문)', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await gotoScreen(page, '/admin/login');
|
||||
|
||||
await clickTheme(page, 'dark');
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).stored, { timeout: 10_000 })
|
||||
.toBe('dark');
|
||||
|
||||
await page.reload();
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readThemeState(page)).dataTheme, { timeout: 20_000 })
|
||||
.toBe('dark');
|
||||
const restored = await readThemeState(page);
|
||||
expect(restored.darkClass).toBe(true);
|
||||
expect(restored.stored).toBe('dark');
|
||||
});
|
||||
|
||||
// 대조군 — 허용목록이 통째로 열린 것이 아님을 확인한다.
|
||||
// 이 단언이 없으면 "테마가 저장된다" 가 게이트 무력화로도 성립해 버린다.
|
||||
test('로그인 화면 — 허용목록 밖 키는 여전히 동의 전 차단된다', async ({ page }) => {
|
||||
await gotoScreen(page, '/admin/login');
|
||||
|
||||
const accepted = await page.evaluate(() => {
|
||||
window.localStorage.setItem('e2e_non_allowlisted_probe', 'x');
|
||||
return window.localStorage.getItem('e2e_non_allowlisted_probe') !== null;
|
||||
});
|
||||
|
||||
expect(accepted).toBe(false);
|
||||
});
|
||||
|
||||
test('로그인 화면 — 테마 클릭이 Invalid theme 경고를 남기지 않는다', async ({ page }) => {
|
||||
const noisy: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'warning' || msg.type() === 'error') noisy.push(msg.text());
|
||||
});
|
||||
|
||||
await gotoScreen(page, '/admin/login');
|
||||
|
||||
// 세 버튼 전부 — 어느 하나라도 계약이 어긋나면 그 클릭에서 경고가 난다.
|
||||
for (const mode of ['dark', 'light', 'auto'] as ThemeMode[]) {
|
||||
await clickTheme(page, mode);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
expect(noisy.filter((w) => /Invalid theme|Unsupported theme/i.test(w))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -158,7 +158,7 @@ API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 141개 | `vitest.config.ts` |
|
||||
| Vitest | 142개 | `vitest.config.ts` |
|
||||
| Playwright | 8개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 3개 | `tests/scenarios` |
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 게시글·페이지 본문을 표시할 때 쓰는 HTML 정화 라이브러리가 구버전에 머물러 있던 문제를 고쳤습니다. 관리자 템플릿과 동일한 최신 버전으로 맞췄습니다. (#126 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 통화 표시·선호 통화 저장 관련 화면 동작 함수 4종이 실제 호출 규약과 다른 형태로 작성돼 있어, 호출되면 값이 전달되지 않고 상태가 잘못 기록되던 문제를 고쳤습니다.
|
||||
- 주소 검색을 불러오지 못한 상태에서 주문서의 우편번호·주소를 직접 입력해도 값이 주문에 반영되지 않아 결제 버튼이 계속 눌리지 않던 문제를 고쳤습니다. 이제 직접 입력한 주소로 주문을 끝까지 진행할 수 있습니다.
|
||||
- 레이아웃 편집기의 「액션 추가」로 만든 「테마 바꾸기」·「테마 초기화」·「화면 상태 바꾸기」 동작이 만들자마자 아무 일도 하지 않던 문제를 고쳤습니다. 편집기가 만들어 주는 값의 형태가 실제 동작이 읽는 형태와 달라 오류 표시도 없이 무시되고 있었습니다.
|
||||
|
||||
## [1.1.2] - 2026-08-24
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file action-recipes-contract.test.ts
|
||||
* @description 레이아웃 편집기 액션 레시피(actionRecipes.json) ↔ 실제 핸들러 계약 일치 회귀 테스트
|
||||
*
|
||||
* 배경: 편집기의 「액션 추가」 팔레트는 이 레시피의 `build` 를 그대로 레이아웃 JSON 으로 굽는다.
|
||||
* `setTheme` 레시피가 `params.target` 으로 굽고 있었으나 테마 핸들러는 `action.target` 만 읽으므로,
|
||||
* 편집기로 만든 테마 버튼은 생성 즉시 no-op 이었다 (오류·경고 없음).
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const recipes = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../editor-spec/actionRecipes.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
describe('actionRecipes.json — 핸들러 계약 일치', () => {
|
||||
// 두 핸들러 모두 action.target 만 읽는다 (src/handlers/setThemeHandler.ts).
|
||||
it.each(['setTheme', 'initTheme'])('%s 는 top-level target 으로 굽는다', (id) => {
|
||||
const build = recipes[id]?.build;
|
||||
expect(build).toBeDefined();
|
||||
expect(build.target).toBe('{{target}}');
|
||||
expect(build.params).toBeUndefined();
|
||||
expect((recipes[id].params ?? []).map((p: any) => p.key)).toContain('target');
|
||||
});
|
||||
|
||||
it('changeState 는 setState 의 상태 범위를 params.target 으로 넘긴다', () => {
|
||||
// handleSetState 는 resolvedParams 에서 target 을 읽는다 (루트 action.target 은 무시).
|
||||
// 루트에 두면 기본값 'component' 로 떨어져, 나중에 global 을 고를 수 있게 되는 순간
|
||||
// 전역 대신 _local 에 조용히 기록된다.
|
||||
const build = recipes.changeState?.build;
|
||||
expect(build.handler).toBe('setState');
|
||||
expect(build.target).toBeUndefined();
|
||||
expect(build.params?.target).toBe('local');
|
||||
});
|
||||
});
|
||||
@@ -169,23 +169,25 @@ sirsoft-admin_basic과 동일한 localStorage 키(`g7_color_scheme`)를 사용
|
||||
|
||||
#### setTheme
|
||||
|
||||
테마 값은 액션 **top-level `target`** 으로 넘깁니다. 핸들러는 `params` 를 읽지 않으므로
|
||||
`params.theme` 으로 넘기면 콘솔 경고 한 줄만 남기고 아무 것도 하지 않습니다 (dev-g7#640).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "setTheme",
|
||||
"params": {
|
||||
"theme": "dark"
|
||||
}
|
||||
"target": "dark"
|
||||
}
|
||||
```
|
||||
|
||||
| 필드 | 타입 | 필수 | 설명 |
|
||||
| 위치 | 타입 | 필수 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `theme` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
| `target` | string | ✅ | `"light"`, `"dark"`, `"auto"` (시스템 설정 따름) |
|
||||
|
||||
#### initTheme
|
||||
|
||||
앱 시작 시 `init_actions`에서 호출. params 없음.
|
||||
앱 시작 시 `init_actions`에서 호출합니다. `target` 은 선택이며, 유효한 테마 값이면 그 값을,
|
||||
없거나 유효하지 않으면 localStorage 저장값(없으면 `auto`)을 적용합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "setState",
|
||||
"target": "local",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"{{key}}": "{{value}}"
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "setTheme",
|
||||
"params": { "target": "{{target}}" }
|
||||
"target": "{{target}}"
|
||||
}
|
||||
},
|
||||
"savePreferredCurrency": {
|
||||
@@ -240,7 +240,7 @@
|
||||
],
|
||||
"build": {
|
||||
"handler": "initTheme",
|
||||
"params": { "target": "{{target}}" }
|
||||
"target": "{{target}}"
|
||||
}
|
||||
},
|
||||
"initCartKey": {
|
||||
|
||||
Reference in New Issue
Block a user