diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index ae57b225..e082f21b 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -22,6 +22,12 @@ - 페이지 상세의 버전 이력에서 "변경내역" 항목이 `title`·`content` 같은 영문 필드명으로 표시되던 문제를 수정했습니다 — 이제 "제목", "내용", "SEO 정보" 처럼 알아보기 쉬운 이름으로 표시됩니다. - 관리자가 발행 전 페이지를 사용자 화면에서 미리 확인할 수 있게 되었습니다. 미리보기 화면에는 미발행 상태임을 알리는 안내가 표시되며, 비로그인 사용자와 일반 회원에게는 이전과 동일하게 노출되지 않습니다. - 페이지 첨부파일을 올릴 때 허용 용량을 초과하거나 허용되지 않은 형식의 파일을 선택하면 안내 메시지가 표시됩니다 — 이전에는 업로드가 조용히 막히기만 하고 이유를 알 수 없었습니다. +- 모바일 화면의 페이지 목록에서 검색어를 입력하고 검색해도 검색이 적용되지 않고 전체 목록이 그대로 나오던 문제를 수정했습니다. 이제 모바일에서도 입력한 검색어로 정상 검색됩니다. +- 페이지 등록 / 수정 화면에서 취소를 눌렀을 때 이동 위치가 일정하지 않던 문제를 수정했습니다 — 수정 화면에서는 해당 페이지 상세로, 새 페이지 등록 화면에서는 목록으로 돌아갑니다. + +### Changed + +- 페이지 관리 화면(목록·상세·등록/수정)의 버튼·선택 상자·카드 여백 등 전반적인 구성을 다른 관리 화면과 통일해 정돈했습니다. 상세 화면의 첨부파일 목록은 처음부터 펼쳐져 보이고, 어두운 화면(다크 모드)에서 첨부 항목과 카드가 더 또렷하게 구분됩니다. ## [1.0.0] - 2026-07-01 diff --git a/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts index 68aa9d4f..021addee 100644 --- a/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts +++ b/modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-page-layouts.test.ts @@ -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'); + }); + }); diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json index 56c8c186..990e9324 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_detail.json @@ -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": [ { diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json index 19876bbd..3731b629 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json @@ -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", diff --git a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json index caf5aa8f..33dc05d0 100644 --- a/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json +++ b/modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json @@ -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": [ diff --git a/modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-cancel-navigation.spec.ts b/modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-cancel-navigation.spec.ts new file mode 100644 index 00000000..b1bca0be --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-form-cancel-navigation.spec.ts @@ -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$')); + }); +});