fix(page): 페이지 관리 화면 UI 정돈 및 모바일 검색·취소 이동 결함 수정
목록·상세·등록/수정 화면의 버튼·선택 상자·카드 여백을 시맨틱 클래스로 통일하고, 검수 중 발견한 동작 결함을 함께 수정한다. - 모바일 검색이 저장소 불일치(_global 저장 ↔ _local 읽기)로 미작동하던 문제 수정 - 폼 취소 이동을 수정=상세 / 등록=목록으로 명시 (기존 navigateBack 안티패턴 제거) - 검증 에러 박스 제목 색/굵기·하단 여백, 저장 버튼 시맨틱화(spinner 포함) - 상세: 첨부 기본 열림 + 항목별 구분선, 다크모드 카드 대비 개선, 메타 정보 2열 배치, 카드 헤더 통일(이중 여백·구분선 제거), 실패 토스트 실제 메시지 노출 - 언어 탭이 모바일에서 줄바꿈되도록 반응형 처리 레이아웃 렌더 회귀 테스트 113건 통과 + 취소 이동 E2E spec 추가.
This commit is contained in:
1 parent
b98a536162
commit
155b95989a
6 files changed
+498
-102
No files matched your search
@@ -22,6 +22,12 @@
|
||||
- 페이지 상세의 버전 이력에서 "변경내역" 항목이 `title`·`content` 같은 영문 필드명으로 표시되던 문제를 수정했습니다 — 이제 "제목", "내용", "SEO 정보" 처럼 알아보기 쉬운 이름으로 표시됩니다.
|
||||
- 관리자가 발행 전 페이지를 사용자 화면에서 미리 확인할 수 있게 되었습니다. 미리보기 화면에는 미발행 상태임을 알리는 안내가 표시되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 노출되지 않습니다.
|
||||
- 페이지 첨부파일을 올릴 때 허용 용량을 초과하거나 허용되지 않은 형식의 파일을 선택하면 안내 메시지가 표시됩니다 — 이전에는 업로드가 조용히 막히기만 하고 이유를 알 수 없었습니다.
|
||||
- 모바일 화면의 페이지 목록에서 검색어를 입력하고 검색해도 검색이 적용되지 않고 전체 목록이 그대로 나오던 문제를 수정했습니다. 이제 모바일에서도 입력한 검색어로 정상 검색됩니다.
|
||||
- 페이지 등록 / 수정 화면에서 취소를 눌렀을 때 이동 위치가 일정하지 않던 문제를 수정했습니다 — 수정 화면에서는 해당 페이지 상세로, 새 페이지 등록 화면에서는 목록으로 돌아갑니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 페이지 관리 화면(목록·상세·등록/수정)의 버튼·선택 상자·카드 여백 등 전반적인 구성을 다른 관리 화면과 통일해 정돈했습니다. 상세 화면의 첨부파일 목록은 처음부터 펼쳐져 보이고, 어두운 화면(다크 모드)에서 첨부 항목과 카드가 더 또렷하게 구분됩니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
|
||||
+344
@@ -291,6 +291,120 @@ describe('admin_page_list.json', () => {
|
||||
expect(findById(adminPageList, 'published_true')).toBeNull();
|
||||
expect(findById(adminPageList, 'published_false')).toBeNull();
|
||||
});
|
||||
|
||||
// ─── F2: 모바일 검색 저장/읽기 저장소 정합 (이슈 #424) ───
|
||||
|
||||
/**
|
||||
* responsive.portable 등 반응형 오버라이드 내부까지 순회하며 id 로 노드를 찾는다.
|
||||
* (모바일 검색 요소는 search_row 의 responsive.portable.children 안에 중첩됨)
|
||||
*/
|
||||
function findByIdDeep(node: any, id: string): any | null {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const f = findByIdDeep(child, id);
|
||||
if (f) return f;
|
||||
}
|
||||
if (node.slots) {
|
||||
for (const sc of Object.values(node.slots)) {
|
||||
if (Array.isArray(sc)) for (const c of sc) { const f = findByIdDeep(c, id); if (f) return f; }
|
||||
}
|
||||
}
|
||||
for (const modal of node.modals ?? []) { const f = findByIdDeep(modal, id); if (f) return f; }
|
||||
// 반응형 오버라이드 순회
|
||||
if (node.responsive) {
|
||||
for (const bp of Object.values(node.responsive) as any[]) {
|
||||
for (const c of bp?.children ?? []) { const f = findByIdDeep(c, id); if (f) return f; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
it('[F2] 모바일 검색필드 select 의 change setState 가 target:global 을 쓰지 않음 (읽기는 _local 이므로 저장도 _local 이어야 검색 동작)', () => {
|
||||
// 회귀 배경(실측): 모바일 select/input 이 target:"global" 로 _global.searchQuery 에 저장하는데,
|
||||
// 검색 버튼·value 바인딩은 {{_local.searchQuery}} 를 읽음 → 검색 버튼이 항상 빈 값 전송 →
|
||||
// 모바일에서 검색이 전혀 걸리지 않음(전체 목록 그대로). 데스크톱은 target 없이 _local 에 저장해 정상.
|
||||
const select = findByIdDeep(adminPageList, 'mobile_search_field_select');
|
||||
expect(select).not.toBeNull();
|
||||
const changeAction = (select.actions ?? []).find((a: any) => a.type === 'change');
|
||||
expect(changeAction?.handler).toBe('setState');
|
||||
expect(changeAction?.params?.target).not.toBe('global');
|
||||
});
|
||||
|
||||
it('[F2] 모바일 검색 input 의 change setState 가 target:global 을 쓰지 않음 (데스크톱과 동일하게 _local 저장)', () => {
|
||||
const input = findByIdDeep(adminPageList, 'mobile_search_input');
|
||||
expect(input).not.toBeNull();
|
||||
const changeAction = (input.actions ?? []).find((a: any) => a.type === 'change');
|
||||
expect(changeAction?.handler).toBe('setState');
|
||||
expect(changeAction?.params?.target).not.toBe('global');
|
||||
// 저장 키는 searchQuery 유지
|
||||
expect(changeAction?.params).toHaveProperty('searchQuery');
|
||||
});
|
||||
|
||||
it('[F2] 데스크톱 검색 select/input 은 이미 target:global 을 쓰지 않음 (정상 기준선 — 회귀 대조군)', () => {
|
||||
const dField = findById(adminPageList, 'search_field_select');
|
||||
const dInput = findById(adminPageList, 'search_input');
|
||||
const fieldChange = (dField.actions ?? []).find((a: any) => a.type === 'change');
|
||||
const inputChange = (dInput.actions ?? []).find((a: any) => a.type === 'change');
|
||||
expect(fieldChange?.params?.target).not.toBe('global');
|
||||
expect(inputChange?.params?.target).not.toBe('global');
|
||||
});
|
||||
|
||||
// ─── C1·C2·C5: 시맨틱 클래스 정리 (이슈 #424) ───
|
||||
|
||||
it('[C1] 검색바 안 필드 셀렉트는 pill(select-composite), 툴바 발행/정렬/개수는 테두리(border+bg-white) — 이커머스 목록 관행', () => {
|
||||
// 관행(이커머스 shipping_policy/order/product 목록): 검색바 내부 필드 셀렉트 = pill,
|
||||
// 목록 툴바의 필터/정렬/개수 셀렉트 = 테두리(bg-white border). 위치에 따라 외형이 다름.
|
||||
const searchField = findById(adminPageList, 'search_field_select');
|
||||
expect(searchField.props.className).toContain('select-composite');
|
||||
|
||||
for (const id of ['published_filter_select', 'sort_select', 'per_page_select']) {
|
||||
const sel = findById(adminPageList, id);
|
||||
expect(sel, id).not.toBeNull();
|
||||
// 툴바 셀렉트는 테두리 형태 — select-composite(pill) 아님
|
||||
expect(sel.props.className, id).not.toContain('select-composite');
|
||||
expect(sel.props.className, id).toContain('border');
|
||||
expect(sel.props.className, id).toContain('bg-white');
|
||||
}
|
||||
});
|
||||
|
||||
it('[C2] 검색 버튼(데스크톱/모바일)이 btn btn-primary 를 사용하고 bg-gray-800 하드코딩을 쓰지 않음', () => {
|
||||
// 표준(이커머스 주문/상품 목록): 검색·필터 적용 버튼 = btn btn-primary(파랑).
|
||||
const dBtn = findById(adminPageList, 'search_button');
|
||||
const mBtn = findByIdDeep(adminPageList, 'mobile_search_button');
|
||||
expect(dBtn.props.className).toContain('btn btn-primary');
|
||||
expect(dBtn.props.className).not.toContain('bg-gray-800');
|
||||
expect(mBtn.props.className).toContain('btn btn-primary');
|
||||
expect(mBtn.props.className).toContain('w-full');
|
||||
expect(mBtn.props.className).not.toContain('bg-gray-800');
|
||||
});
|
||||
|
||||
it('[C2] 등록 버튼이 btn btn-primary 를 사용하고 bg-blue-600 하드코딩을 쓰지 않음', () => {
|
||||
const addBtn = findById(adminPageList, 'add_page_button');
|
||||
expect(addBtn.props.className).toContain('btn btn-primary');
|
||||
expect(addBtn.props.className).not.toContain('bg-blue-600');
|
||||
});
|
||||
|
||||
it('[C5] 목록 모달 버튼에 하드코딩 색상(bg-green-600/gray-600/red-600/bg-white...border) 이 남아있지 않음', () => {
|
||||
const modalsStr = JSON.stringify((adminPageList as any).modals ?? []);
|
||||
expect(modalsStr).not.toContain('bg-green-600');
|
||||
expect(modalsStr).not.toContain('bg-gray-600');
|
||||
expect(modalsStr).not.toContain('bg-red-600');
|
||||
// 취소 버튼 하드코딩 패턴(bg-white...border) 제거 확인
|
||||
expect(modalsStr).not.toContain('bg-white dark:bg-gray-800 text-gray-700');
|
||||
// 시맨틱 btn 계열 사용 확인 (취소=secondary, 삭제=danger, 그 외 확인=primary)
|
||||
expect(modalsStr).toContain('btn btn-secondary');
|
||||
expect(modalsStr).toContain('btn btn-primary');
|
||||
expect(modalsStr).toContain('btn btn-danger');
|
||||
});
|
||||
|
||||
it('[C5] 모달 버튼에 flex-center gap-* 접두사가 없음 (btn 자체가 inline-flex 정렬 — 취소/확인 크기 정합)', () => {
|
||||
// 회귀 배경: btn 은 이미 inline-flex items-center justify-center gap-2 를 포함.
|
||||
// 위에 flex-center(=flex items-center)를 덧붙이면 inline-flex→flex 로 덮어써 버튼 폭이
|
||||
// 늘어나 취소/확인 버튼 크기가 어긋남(PO 지적: 닫기≠복원). btn 만 남겨 정합.
|
||||
const modalsStr = JSON.stringify((adminPageList as any).modals ?? []);
|
||||
expect(modalsStr).not.toMatch(/flex-center gap-[\d.]+ btn/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -657,6 +771,68 @@ describe('admin_page_form.json', () => {
|
||||
const uploaders = findComponentsByName(adminPageForm, 'FileUploader');
|
||||
expect(uploaders[0].props.apiEndpoints.reorder).toContain('route?.id');
|
||||
});
|
||||
|
||||
// ─── UI 검토 M1·M6 회귀 차단 (이슈 #424) ───
|
||||
|
||||
it('[M1-a] 검증에러 제목 클래스가 공백으로 분리됨 (font-medium + text-danger-soft, 병합 클래스 금지)', () => {
|
||||
// 회귀 배경: "font-mediumtext-danger-soft" 처럼 공백 없이 붙으면 두 클래스 모두 무효
|
||||
// → 빌드 CSS 에 해당 클래스 부재 → 빨간색/굵기 미적용.
|
||||
const errorBox = findById(adminPageForm, 'error_message_box');
|
||||
expect(errorBox).not.toBeNull();
|
||||
const boxStr = JSON.stringify(errorBox);
|
||||
// 병합된 무효 클래스가 없어야 함
|
||||
expect(boxStr).not.toContain('font-mediumtext-danger-soft');
|
||||
// 제목 H3 은 두 클래스를 공백으로 분리해서 보유
|
||||
const heading = findComponentsByName(errorBox, 'H3')[0];
|
||||
expect(heading).toBeDefined();
|
||||
const cls = heading.props.className ?? '';
|
||||
expect(cls).toContain('font-medium');
|
||||
expect(cls).toContain('text-danger-soft');
|
||||
});
|
||||
|
||||
it('[M1-b] 에러 박스에 하단 여백(mb-*)이 있어 아래 카드와 붙지 않음', () => {
|
||||
// 회귀 배경: alert-danger 자산에 margin-bottom 없음 → 바로 아래 admin-card 와 딱 붙음.
|
||||
// 같은 폼의 read_only_banner 는 mb-6 으로 간격을 주므로 에러 박스도 통일.
|
||||
const errorBox = findById(adminPageForm, 'error_message_box');
|
||||
expect(errorBox).not.toBeNull();
|
||||
expect(errorBox.props.className).toContain('alert-danger');
|
||||
expect(errorBox.props.className).toMatch(/\bmb-\d/);
|
||||
});
|
||||
|
||||
it('[C4] 저장 버튼이 btn btn-primary 시맨틱 클래스를 쓰고 saving 시 색을 바꾸지 않음 (board/ecommerce norm)', () => {
|
||||
// 회귀 배경: saving 시 bg-blue-400 로 색을 바꾸던 하드코딩(다크모드 미지원) → btn-primary 고정.
|
||||
// 저장중 표현은 색이 아니라 disabled + spinner 아이콘 + 라벨 토글로.
|
||||
const saveBtn = findById(adminPageForm, 'footer_save_button');
|
||||
expect(saveBtn).not.toBeNull();
|
||||
const cls = saveBtn.props.className ?? '';
|
||||
expect(cls).toContain('btn btn-primary');
|
||||
// saving 조건부 색 변경(bg-blue-400/bg-blue-600) 제거
|
||||
expect(cls).not.toContain('bg-blue-400');
|
||||
expect(cls).not.toContain('bg-blue-600');
|
||||
// disabled 는 saving 으로 유지
|
||||
expect(saveBtn.props.disabled).toContain('saving');
|
||||
// spinner 아이콘이 saving 시 표시됨
|
||||
const spinner = findComponentsByName(saveBtn, 'Icon').find(
|
||||
(i: any) => i.props?.name === 'spinner',
|
||||
);
|
||||
expect(spinner).toBeDefined();
|
||||
expect(spinner.if).toContain('saving');
|
||||
});
|
||||
|
||||
it('[M6] 취소 버튼이 navigateBack 이 아니라 navigate 로 목록/상세를 명시함', () => {
|
||||
// 회귀 배경: navigateBack(history.back)은 직전 위치(다른 메뉴)로 튀거나 직접 진입 시 멈춤.
|
||||
// 수정 모드는 상세(/admin/pages/{id}), 등록 모드는 목록(/admin/pages) 으로 명시 이동.
|
||||
const cancelBtn = findById(adminPageForm, 'footer_cancel_button');
|
||||
expect(cancelBtn).not.toBeNull();
|
||||
const action = (cancelBtn.actions ?? []).find((a: any) => a.type === 'click');
|
||||
expect(action).toBeDefined();
|
||||
expect(action.handler).toBe('navigate');
|
||||
expect(action.handler).not.toBe('navigateBack');
|
||||
// route.id 분기로 수정=상세 / 등록=목록
|
||||
const path = action.params?.path ?? '';
|
||||
expect(path).toContain('/admin/pages');
|
||||
expect(path).toContain('route');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -847,4 +1023,172 @@ describe('admin_page_detail.json', () => {
|
||||
// 동적 키 prefix 가 versions.field_labels 를 가리킴
|
||||
expect(cellStr).toContain('sirsoft-page.admin.page.detail.versions.field_labels');
|
||||
});
|
||||
|
||||
// ─── UI 검토 M2·M3·M4·M5 회귀 차단 (이슈 #424) ───
|
||||
|
||||
it('[M5] onError 메시지 바인딩이 $error 가 아닌 error 컨텍스트 변수를 사용함 (엔진 onError context 키는 error)', () => {
|
||||
// 회귀 배경: 엔진은 onError 콜백 컨텍스트를 `error` 키로 제공(ActionDispatcher).
|
||||
// `$error` 는 undefined → message 표현식이 항상 fallback 문구로 떨어져
|
||||
// 서버가 준 실제 실패 사유가 사용자에게 노출되지 않음. 목록 모달은 이미 error.* 사용.
|
||||
const layoutStr = JSON.stringify(adminPageDetail);
|
||||
expect(layoutStr).not.toContain('$error');
|
||||
// 삭제/복원 실패 onError 는 error.message fallback 패턴을 사용해야 함
|
||||
expect(layoutStr).toContain('error.message');
|
||||
});
|
||||
|
||||
it('[M3] 첨부 카드 hover 배경이 다크모드 기본 상태에 색을 박지 않음 (admin-card 배경과 중복 방지)', () => {
|
||||
// 회귀 배경: "hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50" 는
|
||||
// 다크모드에서 hover 가 아닌 기본 상태에 bg-gray-800 을 상시 적용 → admin-card 배경과 중복.
|
||||
const attachmentsSection = findById(adminPageDetail, 'attachments_section');
|
||||
const cards = findComponentsByName(attachmentsSection, 'Div').filter(
|
||||
(n: any) => typeof n.props?.className === 'string' && n.props.className.includes('hover:bg-gray-50'),
|
||||
);
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
for (const card of cards) {
|
||||
// 기본 상태 다크 배경 토큰(dark:bg-gray-800) 을 hover 카드에서 제거
|
||||
expect(card.props.className).not.toContain('dark:bg-gray-800');
|
||||
}
|
||||
});
|
||||
|
||||
it('[토글] 첨부 목록이 기본 열림 상태 (showAttachments ?? true) — 토글/아이콘/본문 3곳 일관', () => {
|
||||
// PO 요청: 첨부 목록이 처음부터 펼쳐져 보이도록.
|
||||
const section = findById(adminPageDetail, 'attachments_section');
|
||||
const sectionStr = JSON.stringify(section);
|
||||
// 기본 열림: (_local.showAttachments ?? true) 패턴 사용 (fallback true)
|
||||
expect(sectionStr).toContain('_local.showAttachments ?? true');
|
||||
// 이전 fallback 없는 raw 참조가 본문 if / 아이콘에 남지 않아야 함
|
||||
// (토글 액션의 !(...) 는 유지되므로 정확 매칭으로 raw if 만 차단)
|
||||
const listNode = findById(section, 'attachments_list_{{att_idx}}')
|
||||
?? (section.children ?? []).find((c: any) => typeof c.if === 'string' && c.if.includes('showAttachments'));
|
||||
expect(listNode).toBeDefined();
|
||||
expect(listNode.if).toContain('showAttachments ?? true');
|
||||
});
|
||||
|
||||
it('[메타카드] info_grid 가 grid-2col-responsive (모바일 1열 → PC 2열) 를 사용함', () => {
|
||||
// PO 결정: 메타 4항목(작성자/발행일시/생성일/수정일)을 PC 2열·모바일 1열로.
|
||||
const infoGrid = findById(adminPageDetail, 'info_grid');
|
||||
expect(infoGrid).not.toBeNull();
|
||||
expect(infoGrid.props.className).toContain('grid-2col-responsive');
|
||||
// 세로 나열 원인이던 panel-header-row 단독 사용 금지
|
||||
expect(infoGrid.props.className).not.toBe('panel-header-row');
|
||||
});
|
||||
|
||||
it('[헤더통일 A] 카드 섹션 헤더가 card-title + px-6/panel-header 미사용 (admin-card 패딩만 의존, 이중여백·구분선 제거)', () => {
|
||||
// 조사 결과 board/ecommerce norm = admin-card > card-title (헤더에 px-6/panel-header 없음).
|
||||
// 회귀 배경: admin-card 자체 p-4 위에 헤더가 px-6(24px)+panel-header(border-b)를 더해
|
||||
// 헤더 앞 여백이 16+24=40px 로 이중 적용되고 구분선이 어색하게 뜸(PO 지적).
|
||||
const headerIds = ['content_header', 'seo_header', 'versions_header'];
|
||||
for (const id of headerIds) {
|
||||
const header = findById(adminPageDetail, id);
|
||||
expect(header, id).not.toBeNull();
|
||||
const cls = header.props?.className ?? '';
|
||||
expect(cls, id).not.toContain('px-6');
|
||||
expect(cls, id).not.toContain('panel-header');
|
||||
const titleSpan = findComponentsByName(header, 'Span').find(
|
||||
(s: any) => typeof s.text === 'string' && s.text.startsWith('$t:'),
|
||||
);
|
||||
expect(titleSpan, id).toBeDefined();
|
||||
expect(titleSpan.props?.className, id).toContain('card-title');
|
||||
expect(titleSpan.props?.className, id).not.toContain('text-body font-medium');
|
||||
}
|
||||
});
|
||||
|
||||
it('[헤더통일 A] 첨부 헤더가 paperclip 제거 + card-title + px-6/panel-header 미사용', () => {
|
||||
const header = findById(adminPageDetail, 'attachments_header');
|
||||
expect(header).not.toBeNull();
|
||||
const cls = header.props?.className ?? '';
|
||||
expect(cls).not.toContain('px-6');
|
||||
expect(cls).not.toContain('panel-header');
|
||||
// paperclip 아이콘 미존재
|
||||
const icons = findComponentsByName(header, 'Icon');
|
||||
const paperclip = icons.find((i: any) => i.props?.name === 'paperclip');
|
||||
expect(paperclip).toBeUndefined();
|
||||
// 제목 텍스트가 card-title
|
||||
const titleSpan = findComponentsByName(header, 'Span').find(
|
||||
(s: any) => typeof s.text === 'string' && s.text.includes('attachments'),
|
||||
);
|
||||
expect(titleSpan).toBeDefined();
|
||||
expect(titleSpan.props?.className).toContain('card-title');
|
||||
});
|
||||
|
||||
it('[헤더통일 A] info_header / info_grid 도 px-6 미사용 (admin-card 패딩만 의존)', () => {
|
||||
const infoHeader = findById(adminPageDetail, 'info_header');
|
||||
const infoGrid = findById(adminPageDetail, 'info_grid');
|
||||
expect(infoHeader).not.toBeNull();
|
||||
expect(infoGrid).not.toBeNull();
|
||||
expect(infoHeader.props.className).not.toContain('px-6');
|
||||
expect(infoGrid.props.className).not.toContain('px-6');
|
||||
});
|
||||
|
||||
it('[C3] 상세 헤더 수정/삭제 버튼이 btn 시맨틱 클래스 사용 (하드코딩 bg-blue/red 제거, 다크모드 자산 내장)', () => {
|
||||
// 회귀 배경: 헤더 수정/삭제 버튼이 bg-blue-600 / bg-red-600 하드코딩(다크모드 미지원).
|
||||
// board/ecommerce norm = btn btn-primary / btn btn-danger (뒤로 버튼은 이미 btn btn-secondary).
|
||||
const editBtn = findById(adminPageDetail, 'header_edit_button');
|
||||
const deleteBtn = findById(adminPageDetail, 'header_delete_button');
|
||||
expect(editBtn).not.toBeNull();
|
||||
expect(deleteBtn).not.toBeNull();
|
||||
// 데스크톱 className 이 btn 시맨틱, 하드코딩 색 제거
|
||||
expect(editBtn.props.className).toContain('btn btn-primary');
|
||||
expect(editBtn.props.className).not.toContain('bg-blue-600');
|
||||
expect(deleteBtn.props.className).toContain('btn btn-danger');
|
||||
expect(deleteBtn.props.className).not.toContain('bg-red-600');
|
||||
// 모바일 responsive 오버라이드에도 하드코딩 색이 남지 않아야 함
|
||||
expect(JSON.stringify(editBtn.responsive ?? {})).not.toContain('bg-blue-600');
|
||||
expect(JSON.stringify(deleteBtn.responsive ?? {})).not.toContain('bg-red-600');
|
||||
});
|
||||
|
||||
it('[C3] 삭제/복원 모달 확정 버튼이 btn 시맨틱 클래스 사용 (하드코딩 색 제거)', () => {
|
||||
// deletePageModal 삭제 확정 = btn btn-danger, restore/preview 복원 = btn btn-primary
|
||||
const deleteModal = (adminPageDetail as any).modals?.find((m: any) => m.id === 'deletePageModal');
|
||||
const restoreModal = (adminPageDetail as any).modals?.find((m: any) => m.id === 'restoreVersionModal');
|
||||
expect(deleteModal).toBeDefined();
|
||||
expect(restoreModal).toBeDefined();
|
||||
// 확정 버튼들에 하드코딩 solid 색 미사용
|
||||
expect(JSON.stringify(deleteModal)).not.toContain('bg-red-600');
|
||||
expect(JSON.stringify(restoreModal)).not.toContain('bg-blue-600');
|
||||
});
|
||||
|
||||
it('[버전여백] 버전 이력 안내문(restore_guide)이 하단 여백을 갖고 좌우 마진(mx-6) 미사용 (카드 패딩에 위임)', () => {
|
||||
// 회귀 배경: 헤더 → 안내문 → 표가 여백 없이 딱 붙음 → mb-4 부여.
|
||||
// 추가: mx-6(좌우 24px)은 admin-card p-4 위에 이중 적용되어 안내문 양옆이 과하게 들어감 → 제거.
|
||||
const section = findById(adminPageDetail, 'versions_section');
|
||||
expect(section).not.toBeNull();
|
||||
const guideBox = (section.children ?? []).find(
|
||||
(c: any) => JSON.stringify(c).includes('restore_guide'),
|
||||
);
|
||||
expect(guideBox).toBeDefined();
|
||||
const cls = guideBox.props?.className ?? '';
|
||||
// 하단 여백 유지
|
||||
expect(cls).toMatch(/\bmb-\d/);
|
||||
// 좌우 마진(mx-*) 미사용 — 카드 폭에 맞게
|
||||
expect(cls).not.toMatch(/\bmx-\d/);
|
||||
});
|
||||
|
||||
it('[첨부구분] 각 첨부 항목이 자체 border-b 로 구분됨 (divide-y 는 iteration 자식에 미적용 → 항목별 하단 구분선)', () => {
|
||||
// 회귀 배경: 리스트 컨테이너의 divide-y 는 iteration 항목이 wrapper 로 감싸져
|
||||
// 직접 형제가 되지 못해 border-top 이 렌더되지 않음(childCount=1, MCP 실측).
|
||||
// → 각 항목(attachment_card)에 직접 border-b 를 부여해 항목 구분.
|
||||
const card = findById(adminPageDetail, 'attachment_card_{{att_idx}}');
|
||||
expect(card).not.toBeNull();
|
||||
const cls = card.props?.className ?? '';
|
||||
expect(cls).toContain('border-b');
|
||||
expect(cls).toContain('border-gray-200');
|
||||
expect(cls).toContain('dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('[언어탭 반응형] 표시언어 영역이 flex-wrap + 힌트가 모바일에서 줄바꿈(w-full) (좁은 화면 탭 글자 깨짐 방지)', () => {
|
||||
// 회귀 배경: 표시언어 라벨/탭/힌트가 flex 한 줄에 배치되어 모바일(390px)에서
|
||||
// 공간 부족으로 탭 버튼 글자가 세로로 깨짐. 컨테이너 flex-wrap + 힌트 모바일 w-full 로 줄바꿈.
|
||||
const container = findById(adminPageDetail, 'unified_lang_tabs');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.props.className).toContain('flex-wrap');
|
||||
// 힌트 Span 이 모바일 responsive 로 전체폭(줄바꿈) 처리
|
||||
const hint = findComponentsByName(container, 'Span').find(
|
||||
(s: any) => typeof s.text === 'string' && s.text.includes('display_language_hint'),
|
||||
);
|
||||
expect(hint).toBeDefined();
|
||||
const portableCls = hint.responsive?.portable?.props?.className ?? '';
|
||||
expect(portableCls).toContain('w-full');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -93,7 +93,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isDeletingPage}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -114,7 +114,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-danger",
|
||||
"disabled": "{{_global.isDeletingPage || page?.data?.abilities?.can_delete !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -174,7 +174,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.delete_failed'}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.delete_failed'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -249,7 +249,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isRestoringVersion}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -270,7 +270,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-primary",
|
||||
"disabled": "{{_global.isRestoringVersion || page?.data?.abilities?.can_update !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -335,7 +335,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.restore_failed'}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.restore_failed'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -554,7 +554,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
"className": "btn btn-secondary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -577,7 +577,7 @@
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{page?.data?.abilities?.can_update !== true}}",
|
||||
"className": "inline-flex items-center gap-1.5 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
"className": "btn btn-primary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -648,14 +648,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 btn btn-secondary"
|
||||
},
|
||||
"responsive": {
|
||||
"mobile": {
|
||||
"props": {
|
||||
"className": "flex-center justify-center p-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
}
|
||||
}
|
||||
"className": "btn btn-secondary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -691,14 +684,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"disabled": "{{page?.data?.abilities?.can_update !== true}}",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
},
|
||||
"responsive": {
|
||||
"mobile": {
|
||||
"props": {
|
||||
"className": "flex-center justify-center p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
"className": "btn btn-primary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -734,14 +720,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"disabled": "{{page?.data?.abilities?.can_delete !== true}}",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
},
|
||||
"responsive": {
|
||||
"mobile": {
|
||||
"props": {
|
||||
"className": "flex-center justify-center p-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
}
|
||||
}
|
||||
"className": "btn btn-danger"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -788,7 +767,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{page?.data}}",
|
||||
"props": {
|
||||
"className": "flex-center gap-3 mb-4 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
"className": "flex-center flex-wrap gap-3 mb-4 px-3 py-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -829,7 +808,7 @@
|
||||
},
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "{{(_local.lang ?? $locale) === loc ? 'px-3 py-1 text-xs font-medium text-white bg-blue-600 rounded' : 'px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 rounded'}}"
|
||||
"className": "{{(_local.lang ?? $locale) === loc ? 'px-3 py-1 text-xs font-medium text-white bg-blue-600 dark:bg-blue-500 rounded' : 'px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 rounded'}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -857,6 +836,13 @@
|
||||
"props": {
|
||||
"className": "text-hint ml-auto italic"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "text-hint w-full italic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.display_language_hint"
|
||||
}
|
||||
]
|
||||
@@ -875,14 +861,14 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-2col px-6 py-4 sm:grid-cols-4"
|
||||
"className": "flex flex-col gap-3 pb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center flex-wrap gap-3 mb-3"
|
||||
"className": "flex-center flex-wrap gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -934,7 +920,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -989,7 +975,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{$error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
|
||||
"message": "{{error.message ?? '$t:sirsoft-page.admin.page.errors.publish_change_failed'}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1073,18 +1059,21 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
"className": "grid-2col-responsive pt-4 border-t border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-baseline gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-table-header"
|
||||
"className": "text-table-header shrink-0 w-20"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.info.creator"
|
||||
},
|
||||
@@ -1092,7 +1081,7 @@
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-primary mt-1 text-sm"
|
||||
"className": "text-primary text-sm"
|
||||
},
|
||||
"text": "{{page?.data?.creator?.name ?? '-'}}"
|
||||
}
|
||||
@@ -1101,12 +1090,15 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-baseline gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-table-header"
|
||||
"className": "text-table-header shrink-0 w-20"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.info.published_at"
|
||||
},
|
||||
@@ -1114,7 +1106,7 @@
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-primary mt-1 text-sm"
|
||||
"className": "text-primary text-sm"
|
||||
},
|
||||
"text": "{{page?.data?.published_at ?? '-'}}"
|
||||
}
|
||||
@@ -1123,12 +1115,15 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-baseline gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-table-header"
|
||||
"className": "text-table-header shrink-0 w-20"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.info.created_at"
|
||||
},
|
||||
@@ -1136,7 +1131,7 @@
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-primary mt-1 text-sm"
|
||||
"className": "text-primary text-sm"
|
||||
},
|
||||
"text": "{{page?.data?.created_at ?? '-'}}"
|
||||
}
|
||||
@@ -1145,12 +1140,15 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-baseline gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-table-header"
|
||||
"className": "text-table-header shrink-0 w-20"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.info.updated_at"
|
||||
},
|
||||
@@ -1158,7 +1156,7 @@
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-primary mt-1 text-sm"
|
||||
"className": "text-primary text-sm"
|
||||
},
|
||||
"text": "{{page?.data?.updated_at ?? '-'}}"
|
||||
}
|
||||
@@ -1182,7 +1180,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-6 panel-header flex-between cursor-pointer select-none"
|
||||
"className": "flex-between cursor-pointer select-none"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1199,7 +1197,7 @@
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-body font-medium"
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.tabs.preview"
|
||||
},
|
||||
@@ -1261,7 +1259,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-6 panel-header flex-between cursor-pointer"
|
||||
"className": "flex-between cursor-pointer"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1269,41 +1267,24 @@
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"showAttachments": "{{!_local.showAttachments}}"
|
||||
"showAttachments": "{{!(_local.showAttachments ?? true)}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "flex-center gap-2"
|
||||
"className": "card-title"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "paperclip",
|
||||
"className": "text-gray-500 dark:text-gray-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-primary text-sm font-semibold"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.attachments|count={{(page?.data?.attachments ?? []).length}}"
|
||||
}
|
||||
]
|
||||
"text": "$t:sirsoft-page.admin.page.detail.attachments|count={{(page?.data?.attachments ?? []).length}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{_local.showAttachments ? 'chevron-up' : 'chevron-down'}}",
|
||||
"name": "{{(_local.showAttachments ?? true) ? 'chevron-up' : 'chevron-down'}}",
|
||||
"className": "text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
}
|
||||
@@ -1313,10 +1294,7 @@
|
||||
"id": "attachments_list_{{att_idx}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.showAttachments}}",
|
||||
"props": {
|
||||
"className": "divide-y divide-gray-100 dark:divide-gray-700"
|
||||
},
|
||||
"if": "{{_local.showAttachments ?? true}}",
|
||||
"iteration": {
|
||||
"source": "page?.data?.attachments ?? []",
|
||||
"item_var": "attachment_item",
|
||||
@@ -1328,7 +1306,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center group gap-3 px-4 py-2.5 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700/50 transition-colors cursor-pointer"
|
||||
"className": "flex-center group gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1433,14 +1411,14 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-6 panel-header"
|
||||
"className": "pb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-body font-medium"
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.form.seo.title"
|
||||
}
|
||||
@@ -1541,14 +1519,14 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-6 panel-header"
|
||||
"className": "pb-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-body font-medium"
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.detail.tabs.versions"
|
||||
}
|
||||
@@ -1558,7 +1536,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "px-6 py-2 flex-center gap-2 text-info-soft bg-blue-50 dark:bg-blue-900/20"
|
||||
"className": "mb-4 px-4 py-2.5 flex-center gap-2 rounded-md text-info-soft bg-blue-50 dark:bg-blue-900/20"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{_local.errors && Object.keys(_local.errors).length > 0}}",
|
||||
"props": {
|
||||
"className": "alert-danger"
|
||||
"className": "alert-danger mb-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -147,7 +147,7 @@
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "text-sm font-mediumtext-danger-soft"
|
||||
"className": "text-sm font-medium text-danger-soft"
|
||||
},
|
||||
"text": "$t:sirsoft-page.admin.page.form.validation_error"
|
||||
},
|
||||
@@ -961,7 +961,10 @@
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigateBack"
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "{{route?.id ? '/admin/pages/' + route.id : '/admin/pages'}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -980,7 +983,7 @@
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_local.saving}}",
|
||||
"className": "{{_local.saving ? 'px-4 py-2 bg-blue-400 text-white rounded-lg font-medium cursor-not-allowed' : 'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors'}}"
|
||||
"className": "btn btn-primary flex-center gap-1.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1083,6 +1086,15 @@
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_local.saving}}",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "spinner-sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "refresh",
|
||||
"className": "w-5 h-5"
|
||||
"size": "sm"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -131,7 +131,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-primary",
|
||||
"disabled": "{{pages?.data?.abilities?.can_create !== true}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -149,7 +149,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "plus",
|
||||
"className": "w-5 h-5"
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -250,7 +250,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-4 py-2 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium"
|
||||
"className": "btn btn-primary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -323,7 +323,6 @@
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchField": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
@@ -344,7 +343,6 @@
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"searchQuery": "{{$event.target.value}}"
|
||||
}
|
||||
},
|
||||
@@ -373,7 +371,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "w-full px-4 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium"
|
||||
"className": "btn btn-primary w-full"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -442,7 +440,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{query.published ?? ''}}",
|
||||
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
|
||||
"className": "w-32 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
|
||||
"options": [
|
||||
{
|
||||
"label": "$t:sirsoft-page.admin.page.published_filter.all",
|
||||
@@ -480,7 +478,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{query.sort_by ? (query.sort_by + '_' + (query.sort_order || 'desc')) : 'created_at_desc'}}",
|
||||
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
|
||||
"className": "w-32 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
|
||||
"options": [
|
||||
{
|
||||
"label": "$t:sirsoft-page.admin.page.sort.created_at_desc",
|
||||
@@ -523,7 +521,7 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{query.per_page || '20'}}",
|
||||
"className": "px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 text-sm focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400",
|
||||
"className": "w-20 px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
|
||||
"options": [
|
||||
{
|
||||
"label": "$t:sirsoft-page.admin.page.per_page|count=10",
|
||||
@@ -902,7 +900,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isBulkPublishing}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -924,7 +922,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-primary",
|
||||
"disabled": "{{_global.isBulkPublishing}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -1063,7 +1061,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isBulkPublishing}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -1085,7 +1083,7 @@
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-primary",
|
||||
"disabled": "{{_global.isBulkPublishing}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -1223,7 +1221,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isDeletingPage}}"
|
||||
},
|
||||
"actions": [
|
||||
@@ -1244,7 +1242,7 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"className": "btn btn-danger",
|
||||
"disabled": "{{_global.isDeletingPage}}"
|
||||
},
|
||||
"actions": [
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 페이지 폼 취소 버튼 이동 경로 — 수정 모드는 상세로, 등록 모드는 목록으로
|
||||
* (placeholder — data-testid 보강 후 활성화).
|
||||
*
|
||||
* @scenario form_mode=create, form_mode=edit
|
||||
* @effects cancel_in_edit_mode_navigates_to_detail,
|
||||
* cancel_in_create_mode_navigates_to_list
|
||||
*
|
||||
* e2e:allow 취소 버튼이 navigateBack(history.back)이던 회귀 — 직전 위치(다른 관리 메뉴)로
|
||||
* 튀거나 URL 직접 진입 시 멈추던 문제를, navigate + route.id 분기(수정=상세 /admin/pages/{id},
|
||||
* 등록=목록 /admin/pages)로 교체했다.
|
||||
* 레이아웃 렌더링 테스트(admin-page-layouts.test.ts > "[M6] 취소 버튼이 navigateBack 이 아니라
|
||||
* navigate 로 목록/상세를 명시함")가 핸들러가 navigateBack 이 아니라 navigate 이고 path 표현식이
|
||||
* route.id 분기를 가짐을 구조적으로 회귀 차단한다. 본 placeholder spec(test.describe.skip)은
|
||||
* 취소 버튼에 data-testid 보강 후 활성화되어 실제 라우트 이동(직접 진입 → 취소 → 목록/상세)을 확인한다.
|
||||
*
|
||||
* 본 spec 은 다음 사전 작업 완료 후 활성화한다:
|
||||
* 1. 취소 버튼(footer_cancel_button)에 data-testid="page-form-cancel"
|
||||
* 2. seededPage fixture(playwright:seed-page) 실 시드 로직 구현
|
||||
* 3. test.describe.skip → test.describe 변경
|
||||
*
|
||||
* 매트릭스(시나리오 매니페스트 form_mode axis 와 1:1):
|
||||
* - edit 모드 직접 진입 → 취소 → /admin/pages/{id} (상세)
|
||||
* - create 모드 직접 진입 → 취소 → /admin/pages (목록)
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
|
||||
|
||||
const EDIT_URL = '/admin/pages/1/edit';
|
||||
const CREATE_URL = '/admin/pages/create';
|
||||
|
||||
test.describe.skip('페이지 폼 취소 이동 경로 (placeholder — data-testid 보강 후 활성화)', () => {
|
||||
test('수정 폼에 직접 진입 후 취소하면 해당 페이지 상세로 이동한다', async ({
|
||||
page,
|
||||
pageManageToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageManageToken);
|
||||
|
||||
// URL 직접 진입 (직전 history 없음 — navigateBack 이면 목록/상세로 못 감)
|
||||
await page.goto(EDIT_URL);
|
||||
await page.getByTestId('page-form-cancel').click();
|
||||
|
||||
// 수정 모드 → 상세로 이동
|
||||
await expect(page).toHaveURL(new RegExp('/admin/pages/1$'));
|
||||
});
|
||||
|
||||
test('등록 폼에 직접 진입 후 취소하면 목록으로 이동한다', async ({
|
||||
page,
|
||||
pageCreateToken,
|
||||
}) => {
|
||||
await authenticatePage(page, pageCreateToken);
|
||||
|
||||
await page.goto(CREATE_URL);
|
||||
await page.getByTestId('page-form-cancel').click();
|
||||
|
||||
// 등록 모드 → 목록으로 이동
|
||||
await expect(page).toHaveURL(new RegExp('/admin/pages$'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user