diff --git a/.github/workflows/deploy-timeweb.yml b/.github/workflows/deploy-timeweb.yml index 3767346..98b6d23 100644 --- a/.github/workflows/deploy-timeweb.yml +++ b/.github/workflows/deploy-timeweb.yml @@ -50,6 +50,7 @@ jobs: core/catalog-pricing.js core/client-menu.css core/ops-ux-v1762.js + core/mobile-order.js core/banquet-menu.js core/data-layer-v1773.js core/trial-demo.js @@ -112,6 +113,9 @@ jobs: - name: Check the published loading screen, Help, clients and promotions timeout-minutes: 4 run: node tests/production-ui-smoke.mjs + - name: Check the published mobile menu form and return arrow + timeout-minutes: 4 + run: node tests/production-mobile-menu.mjs - name: Save production UI verification if: always() uses: actions/upload-artifact@v4 diff --git a/public/core/mobile-order.js b/public/core/mobile-order.js index 76a879d..a131b15 100644 --- a/public/core/mobile-order.js +++ b/public/core/mobile-order.js @@ -35,5 +35,79 @@ const observer=new MutationObserver(update);observer.observe(view,{attributes:true,attributeFilter:['class']});observer.observe(document.body,{attributes:true,attributeFilter:['class']}); layout(); } - if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot(); + // The menu page is created later by ops-ux. Attach once when it exists, then + // observe only its visibility and direct detail children, never the whole app. + function bootMenu(){ + const view=document.getElementById('sun-menu-editor-v1762'),layout=view?.querySelector('.sun-menu-layout'); + const list=layout?.querySelector(':scope > .sun-menu-list-pane'),detail=document.getElementById('sunMenuDetailV1762'); + if(!view||!layout||!list||!detail)return false; + if(view.dataset.ctMobileMenu==='1')return true; + view.dataset.ctMobileMenu='1'; + const mobile=matchMedia('(max-width:760px)'); + const button=document.createElement('button');button.id='ctMenuTop';button.type='button';button.hidden=true; + button.setAttribute('aria-label','Наверх к редактору меню');button.title='Наверх к редактору меню'; + button.setAttribute('aria-controls','sunMenuDetailV1762'); + button.innerHTML='Наверх'; + document.body.appendChild(button); + const style=document.createElement('style');style.id='ctMobileMenuStyle';style.textContent=` + #ctMenuTop{display:none} + @media(max-width:760px){ + #sun-menu-editor-v1762 .sun-menu-layout{grid-template-columns:minmax(0,1fr)} + #sun-menu-editor-v1762 #sunMenuDetailV1762{grid-column:1;grid-row:1;min-width:0;min-height:0} + #sun-menu-editor-v1762 .sun-menu-list-pane{grid-column:1;grid-row:2;min-width:0;min-height:0} + #sun-menu-editor-v1762 .sun-menu-list{max-height:none;overflow:visible} + #sun-menu-editor-v1762 .sun-menu-detail-placeholder{min-height:76px} + #ctMenuTop:not([hidden]){display:flex;align-items:center;justify-content:center;gap:6px;position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:900;min-width:48px;min-height:48px;padding:10px 14px;border:1px solid #d7bb65;border-radius:24px;background:#f6e7af;color:#302919;box-shadow:0 4px 18px #0002;font:700 13px/1.2 Arial,sans-serif;cursor:pointer} + #ctMenuTop:focus-visible{outline:3px solid #546747;outline-offset:3px} + } + @media print{#ctMenuTop{display:none!important}} + `;document.head.appendChild(style); + function active(){return mobile.matches&&view.classList.contains('on')&&!document.body.classList.contains('sun-cloud-auth-required');} + function visibility(){button.hidden=!active()||document.body.classList.contains('sun-modal-open')||window.scrollY<300;} + function neutralHint(){ + const label=detail.querySelector('.sun-menu-detail-placeholder b'); + if(label?.textContent==='Выберите позицию слева, чтобы открыть карточку.')label.textContent='Выберите позицию из списка, чтобы открыть карточку.'; + } + function arrange(){ + // Move the LIST, not the form: an unsaved or focused editor keeps its node, + // input values, file selection and listeners across viewport changes. + if(mobile.matches){if(detail.nextElementSibling!==list)detail.after(list);} + else if(list.nextElementSibling!==detail)layout.insertBefore(list,detail); + neutralHint();visibility(); + } + function returnToEditor(){ + if(!active())return; + window.scrollTo({top:0,left:0,behavior:'instant'}); + detail.setAttribute('tabindex','-1');detail.focus({preventScroll:true});visibility(); + } + let frame=0; + function update(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;neutralHint();visibility();});} + button.addEventListener('click',returnToEditor); + view.addEventListener('click',e=>{ + const target=e.target.closest?.('[data-menu-item-v1762],#sunMenuAddV1762'); + if(!target||target.disabled||!active())return; + // The existing click handler docks the editor in a zero-delay task. Wait + // for it, and do not scroll if the user has left Menu in the meantime. + setTimeout(()=>requestAnimationFrame(()=>{ + // Never take focus or scroll away after the user has started editing. + const focused=document.activeElement; + if(detail.contains(focused)&&focused?.matches('input,textarea,select,[contenteditable="true"]'))return; + if(detail.querySelector(':scope > .dialog,:scope > .sun-menu-readonly'))returnToEditor(); + }),0); + }); + window.addEventListener('scroll',update,{passive:true});mobile.addEventListener('change',arrange); + const observer=new MutationObserver(update); + observer.observe(view,{attributes:true,attributeFilter:['class']}); + observer.observe(document.body,{attributes:true,attributeFilter:['class']}); + observer.observe(detail,{childList:true}); + arrange();return true; + } + function start(){ + boot(); + if(!bootMenu()){ + const waiting=new MutationObserver(()=>{if(bootMenu())waiting.disconnect();}); + waiting.observe(document.body,{childList:true}); + } + } + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start(); })(); diff --git a/tests/mobile-menu-scenario.mjs b/tests/mobile-menu-scenario.mjs new file mode 100644 index 0000000..22de839 --- /dev/null +++ b/tests/mobile-menu-scenario.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import {expect} from '@playwright/test'; + +// The real application assets run with a synthetic local workspace. No backend +// traffic or real customer credentials are allowed, including on production. +export async function bootMenuFixture(page,baseUrl='http://127.0.0.1:4173/'){ + const base=new URL(baseUrl); + await page.route('**/*',route=>{ + const req=route.request(),url=new URL(req.url()); + if(req.method()==='GET'&&url.origin===base.origin&&!url.pathname.startsWith('/api/'))return route.continue(); + return route.abort(); + }); + await page.addInitScript(()=>{ + const id='mobile-menu-workspace',user={id:'mobile-menu-user',email:'menu-test@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'}; + localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:id,localWorkspaceId:id,tenantStorageReady:true,autoSync:false})); + localStorage.setItem('sunBoxes',JSON.stringify(Array.from({length:24},(_,i)=>({id:`mobile-box-${i+1}`,name:`Бокс ${String(i+1).padStart(2,'0')}`,category:0,price:1000+i*10,weight:'600 г',pieces:12,ingredients:[['Томаты',1,'кг']]})))); + localStorage.setItem('sunOrders',JSON.stringify([{id:1,event:'Тестовый заказ',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:'mobile-box-1',qty:2,price:1000}],total:2000}])); + const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true])); + window.supabase={createClient:()=>({ + auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})}, + rpc:async name=>({data:name==='sun_my_workspaces'?[{id,name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}]:name==='sun_subscription_snapshot'?{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}:name==='sun_is_platform_admin'?false:name==='caterium_trial_demo_status'?{canInstall:false,canUpgrade:false}:null,error:null}), + channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){} + })}; + }); + await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000}); + await expect(page.locator('body > header')).toBeVisible({timeout:20000}); + await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.__cateriumOrderEnhancementsV1775); + await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click(); + await expect(page.locator('#sun-menu-editor-v1762')).toHaveAttribute('data-ct-mobile-menu','1'); + await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(24); +} + +export async function menuGeometry(page){ + let geometry; + // Read rectangles and media queries atomically. WebKit/mobile Chromium can + // update the layout viewport a frame after a resize or keyboard transition. + await expect.poll(async()=>{ + geometry=await page.evaluate(()=>{ + const d=document.getElementById('sunMenuDetailV1762'),l=document.querySelector('.sun-menu-list-pane'); + return {detail:d.getBoundingClientRect().toJSON(),list:l.getBoundingClientRect().toJSON(),mobile:matchMedia('(max-width:760px)').matches,singleColumn:matchMedia('(max-width:950px)').matches,formFirst:d.nextElementSibling===l,listFirst:l.nextElementSibling===d,viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth}; + }); + const {detail:d,list:l,mobile,singleColumn,formFirst,listFirst}=geometry; + return mobile?(formFirst&&d.bottom<=l.y+1):(listFirst&&(singleColumn?l.bottom<=d.y+1:l.right<=d.x+1)); + },{message:'Menu layout and reading order must match the current breakpoint'}).toBe(true); + if(geometry.mobile)assert(geometry.scrollWidth<=geometry.viewport+1,'No horizontal overflow on phones'); + return geometry; +} + +export async function exerciseMenuNavigation(page,{screenshot=async()=>{}}={}){ + const geometry=await menuGeometry(page),top=page.locator('#ctMenuTop'); + await expect(top).toBeHidden(); + const before=await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})); + await page.locator('[data-menu-item-v1762="mobile-box-24"]').click(); + await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Бокс 24'); + if(geometry.mobile)await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); + await menuGeometry(page); + await screenshot('menu-form-top'); + await page.locator('#boxName').fill('Несохранённый бокс'); + await page.locator('#boxPrice').fill('1350'); + await page.evaluate(()=>{window.mobileEditorNode=document.getElementById('boxName');window.mobileEditorInputEvents=0;mobileEditorNode.addEventListener('input',()=>window.mobileEditorInputEvents++);}); + await page.locator('[data-menu-item-v1762="mobile-box-24"]').scrollIntoViewIfNeeded(); + if(geometry.mobile){ + await expect(top).toBeVisible(); + await expect(page.locator('#ctOrderTop')).toBeHidden(); + const bounds=await top.boundingBox();assert(bounds.width>=48&&bounds.height>=48); + await screenshot('menu-arrow'); + await top.click(); + await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); + await expect(page.locator('#sunMenuDetailV1762')).toBeFocused(); + await expect(top).toBeHidden(); + }else await expect(top).toBeHidden(); + await expect(page.locator('#boxName')).toHaveValue('Несохранённый бокс'); + await expect(page.locator('#boxPrice')).toHaveValue('1350'); + assert(await page.evaluate(()=>window.mobileEditorNode===document.getElementById('boxName')),'The form must not be recreated'); + assert.deepEqual(await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})),before,'Navigation must not save or change data'); + await page.locator('#saveBoxButton').click(); + await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24')?.name)).toBe('Несохранённый бокс'); + assert.equal(await page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24').price),1350); + assert.equal(await page.evaluate(()=>localStorage.sunOrders),before.orders); + await page.locator('header nav').getByRole('button',{name:'Заказы',exact:true}).click(); + await expect(top).toBeHidden(); + await expect(page.locator('#editor > .dialog')).toHaveCount(1); + return {...geometry,navigationPreservesDraft:true,saveWorks:true,ordersUnchanged:true}; +} diff --git a/tests/mobile-menu.spec.mjs b/tests/mobile-menu.spec.mjs new file mode 100644 index 0000000..a75d907 --- /dev/null +++ b/tests/mobile-menu.spec.mjs @@ -0,0 +1,81 @@ +import {test,expect} from '@playwright/test'; +import {bootMenuFixture,menuGeometry,exerciseMenuNavigation} from './mobile-menu-scenario.mjs'; + +test.use({serviceWorkers:'block'}); + +test('mobile menu puts editing above boxes and returns without losing unsaved values',async({page})=>{ + await bootMenuFixture(page);await exerciseMenuNavigation(page); +}); + +test('mobile menu viewport changes move the list, not the focused editor',async({page})=>{ + await bootMenuFixture(page); + await page.locator('[data-menu-item-v1762="mobile-box-1"]').click(); + await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible(); + // The automatic return is phone-only; desktop keeps its existing scroll. + if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); + await page.waitForTimeout(80); + await page.locator('#boxName').fill('Сохрани ввод при повороте'); + await page.locator('#boxName').focus(); + await page.evaluate(()=>{window.formInput=document.getElementById('boxName');window.inputsSeen=0;formInput.addEventListener('input',()=>window.inputsSeen++);}); + for(const width of [1440,390,760,1440,390]){ + await page.setViewportSize({width,height:900}); + await expect.poll(()=>page.evaluate(()=>{const d=document.getElementById('sunMenuDetailV1762'),list=document.querySelector('.sun-menu-list-pane');return matchMedia('(max-width:760px)').matches?d.nextElementSibling===list:list.nextElementSibling===d;})).toBe(true); + await menuGeometry(page); + await expect(page.locator('#boxName')).toHaveValue('Сохрани ввод при повороте'); + await expect(page.locator('#boxName')).toBeFocused(); + expect(await page.evaluate(()=>formInput===document.getElementById('boxName'))).toBe(true); + } + await page.locator('#boxName').fill('Обработчик остался'); + expect(await page.evaluate(()=>inputsSeen)).toBeGreaterThan(0); + await expect(page.locator('#ctMenuTop')).toHaveCount(1); +}); + +test('mobile menu search, category and new item keep working; leaving prevents a delayed scroll',async({page})=>{ + await bootMenuFixture(page); + await page.locator('#sunMenuSearchV1762').fill('Бокс 07'); + await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1); + await page.locator('[data-menu-cat-v1762="5"]').click(); + await page.locator('#sunMenuAddV1762').click(); + await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible(); + await expect(page.locator('#itemCategory')).toHaveValue('5'); + if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); + await menuGeometry(page); + await page.locator('#boxName').fill('Премиум с телефона');await page.locator('#boxPrice').fill('2200');await page.locator('#saveBoxButton').click(); + await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.name==='Премиум с телефона')?.category)).toBe(5); + await page.evaluate(()=>{document.getElementById('sunMenuAddV1762').click();[...document.querySelectorAll('header nav button')].find(b=>b.textContent.trim()==='Заказы').click();}); + await page.waitForTimeout(100); + await expect(page.locator('#editor > .dialog')).toHaveCount(1); + await expect(page.locator('#ctMenuTop')).toBeHidden(); + await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click(); + await expect(page.locator('#ctMenuTop')).toHaveCount(1); +}); + +test('read-only mobile menu opens the card above the list without enabling editing',async({page})=>{ + await bootMenuFixture(page); + const before=await page.evaluate(()=>localStorage.sunBoxes); + await page.evaluate(()=>{SunCloudV2.hasPermission=p=>p==='catalog.view'||p==='app.read';window.dispatchEvent(new Event('sun:cloud-permissions-changed'));}); + await expect(page.locator('#sunMenuAddV1762')).toBeDisabled(); + await page.locator('[data-menu-item-v1762="mobile-box-24"]').click(); + await expect(page.locator('#sunMenuDetailV1762 .sun-menu-readonly')).toContainText('Бокс 24'); + if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0); + await menuGeometry(page); + await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toHaveCount(0); + expect(await page.evaluate(()=>localStorage.sunBoxes)).toBe(before); +}); + + +test('a queued mobile return does not steal focus once the user starts typing',async({page})=>{ + await bootMenuFixture(page); + await page.evaluate(()=>{ + document.querySelector('[data-menu-item-v1762="mobile-box-1"]').click(); + // Docking is queued by the existing menu handler. Start typing in the next + // task, before the mobile requestAnimationFrame navigation callback runs. + setTimeout(()=>{ + const input=document.getElementById('boxName');input.value='Уже редактирую';input.focus({preventScroll:true}); + },0); + }); + await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Уже редактирую'); + await page.waitForTimeout(150); + await expect(page.locator('#boxName')).toBeFocused(); + await expect(page.locator('#boxName')).toHaveValue('Уже редактирую'); +}); diff --git a/tests/playwright.config.mjs b/tests/playwright.config.mjs index 216620c..5f93f3c 100644 --- a/tests/playwright.config.mjs +++ b/tests/playwright.config.mjs @@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test'; import {fileURLToPath} from 'node:url'; export default defineConfig({ testDir:'.', - testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], + testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'], timeout:30000, use:{baseURL:'http://127.0.0.1:4173'}, webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true}, projects:[ {name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}}, - {name:'iphone-webkit',testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, + {name:'iphone-webkit',testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}}, {name:'desktop',use:{...devices['Desktop Chrome']}}, {name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}} ] diff --git a/tests/production-mobile-menu.mjs b/tests/production-mobile-menu.mjs new file mode 100644 index 0000000..76ecf24 --- /dev/null +++ b/tests/production-mobile-menu.mjs @@ -0,0 +1,19 @@ +import fs from 'node:fs/promises'; +import {chromium} from '@playwright/test'; +import {bootMenuFixture,exerciseMenuNavigation} from './mobile-menu-scenario.mjs'; +const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru'); +if(base.protocol!=='https:')throw new Error('Production UI verification requires HTTPS'); +const output='production-ui-results';await fs.mkdir(output,{recursive:true}); +const browser=await chromium.launch(),results=[]; +try{ + for(const width of [390,1440]){ + const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'}); + try{ + const page=await context.newPage(); + await bootMenuFixture(page,base.href); + const result=await exerciseMenuNavigation(page,{screenshot:async name=>{await page.screenshot({path:`${output}/${name}-${width}.png`,animations:'disabled'});}}); + results.push({width,...result});console.log(`PASS published menu ${width}px: form placement, arrow, unsaved values, save, unchanged orders`); + }finally{await context.close();} + } + await fs.writeFile(`${output}/mobile-menu-result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'synthetic, network-blocked',results},null,2)); +}finally{await browser.close();}