Make report coverage calendar expandable
This commit is contained in:
1 parent
4356160cc9
commit
6d1a98743f
11 files changed
+283
-57
No files matched your search
@@ -9,10 +9,10 @@ const boot = ' boot();\n})();';
|
||||
assert(source.endsWith(boot+'\n') || source.endsWith(boot));
|
||||
|
||||
function harness(respond) {
|
||||
const elements=new Map(), storage=new Map(), storageReads=[], calls=[], timers=[];
|
||||
const elements=new Map(), storage=new Map(), storageReads=[], calls=[], timers=[], documentListeners=new Map();
|
||||
const element=selector => {
|
||||
if (!elements.has(selector)) {
|
||||
const attributes=new Map(),classes=new Set();
|
||||
const attributes=new Map(),classes=new Set(),listeners=new Map();
|
||||
elements.set(selector,{value:'', disabled:false, hidden:false, textContent:'', innerHTML:'',style:{},
|
||||
classList:{add:value=>classes.add(value),remove:value=>classes.delete(value),toggle(value,force){
|
||||
const enabled=force===undefined?!classes.has(value):force;
|
||||
@@ -20,19 +20,21 @@ function harness(respond) {
|
||||
return enabled;
|
||||
},contains:value=>classes.has(value)},setAttribute:(key,value)=>attributes.set(key,String(value)),
|
||||
removeAttribute:key=>attributes.delete(key),getAttribute:key=>attributes.get(key)??null,
|
||||
querySelector:selector=>element(selector),querySelectorAll(){return [];},scrollIntoView(){},focus(){context.document.activeElement=this;}});
|
||||
querySelector:selector=>element(selector),querySelectorAll(){return [];},scrollIntoView(){},focus(){context.document.activeElement=this;},
|
||||
addEventListener(type,callback){if(!listeners.has(type)) listeners.set(type,[]);listeners.get(type).push(callback);},
|
||||
dispatchEvent(event){for(const callback of listeners.get(event.type)||[]) callback(event);},contains(node){return node===this;}});
|
||||
}
|
||||
return elements.get(selector);
|
||||
};
|
||||
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array, TextEncoder, btoa,
|
||||
document:{hidden:false,querySelector:element},
|
||||
document:{hidden:false,querySelector:element,addEventListener(type,callback){if(!documentListeners.has(type)) documentListeners.set(type,[]);documentListeners.get(type).push(callback);}},
|
||||
window:{ARRI18n:{t:(key,values)=>key==='manual_review.context'?`${values.date} · ${values.type}`:key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value),formatMonth:value=>value,formatDate:value=>value,formatDurationSeconds:value=>`${value}s`},crypto:webcrypto,
|
||||
setTimeout(callback,delay){const timer={id:timers.length+1,callback,delay,cancelled:false};timers.push(timer);return timer.id;},
|
||||
clearTimeout(id){const timer=timers.find(entry=>entry.id===id);if(timer) timer.cancelled=true;},location:{replace(){}}},
|
||||
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
|
||||
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
|
||||
});
|
||||
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, openDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel, handleUpload, renderJobs, renderDailyOverview, syncDailyOverviewDate, loadDailyOverview, selectDailyReportDate, loadJobs, loadDailyHistory, openDailyHistory, closeDailyHistory, openDailyDelete, closeDailyDelete, confirmDailyDelete, localMonth, localDate, shiftMonth, renderReportCalendar, loadReportCalendar, changeReportCalendarMonth, selectReportCalendarDate, moveReportCalendarFocus, reportCalendarReady, currentReportGenerated';
|
||||
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview, strictReviewPrice, reviewPriceInput, renderDailyPriceReview, saveDailyPriceReviewItem, loadDailyPriceReview, openDailyPriceReview, dailyPriceReviewCanFinalize, dailyPriceReviewHasUnsavedChanges, finalizeDailyPriceReview, confirmDailyPriceReviewCancel, handleUpload, renderJobs, renderDailyOverview, syncDailyOverviewDate, loadDailyOverview, selectDailyReportDate, loadJobs, loadDailyHistory, openDailyHistory, closeDailyHistory, openDailyDelete, closeDailyDelete, confirmDailyDelete, localMonth, localDate, shiftMonth, renderReportCalendar, loadReportCalendar, changeReportCalendarMonth, selectReportCalendarDate, moveReportCalendarFocus, reportCalendarReady, currentReportGenerated, openReportCalendar, closeReportCalendar, bindReportCalendar, openARRCalendar, closeARRCalendar, arrCalendar';
|
||||
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
|
||||
const subject=context.subject;
|
||||
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});
|
||||
@@ -42,7 +44,9 @@ function harness(respond) {
|
||||
const node={dataset:{arrDataReviewItemId:itemId},querySelector:()=>input};
|
||||
return {input,button:{closest:()=>node}};
|
||||
};
|
||||
return {...subject,calls,storage,storageReads,timers,element,row,i18n:context.window.ARRI18n,submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
return {...subject,calls,storage,storageReads,timers,element,row,i18n:context.window.ARRI18n,document:context.document,
|
||||
emitDocumentEvent(event){for(const callback of documentListeners.get(event.type)||[]) callback(event);},
|
||||
submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
}
|
||||
const response=(status,data,code) => ({status,ok:status<400,json:async()=>code?{ok:false,error:{code}}:{ok:true,data}});
|
||||
const scopedKey=(context='production',username='operator')=>`arr:last-download:v2:${encodeURIComponent(context)}:${encodeURIComponent(username)}`;
|
||||
|
||||
@@ -76,7 +76,7 @@ test('loading and failed reads never label days ungenerated and a retry can rest
|
||||
resolve(response(503,null,'DATABASE_QUERY_FAILED'));
|
||||
await reading;
|
||||
assert.equal(h.state.reportCalendarError,true);
|
||||
assert.match(block(h,`${month}-01`),/report_calendar.day_unknown/);
|
||||
assert.match(block(h,`${month}-01`),/report_calendar.unknown/);
|
||||
assert.doesNotMatch(h.element('#report-calendar-days').innerHTML,/is-ungenerated/);
|
||||
assert.equal(h.element('#report-calendar-retry').hidden,false);
|
||||
fail=false;
|
||||
@@ -272,7 +272,7 @@ test('calendar labels, status legends and read failures are translated in all th
|
||||
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
|
||||
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
|
||||
const catalog=context.window.ARRI18n.CATALOG;
|
||||
for(const key of ['title','widget_aria','generated','not_generated','unknown','loading_day',
|
||||
for(const key of ['title','widget_aria','open','loading_summary','failed_summary','generated','not_generated','unknown','loading_day',
|
||||
'day_generated','day_not_generated','day_unknown','day_loading_day','loading','failed','coverage','help','locked','day_group','legend','retry']) {
|
||||
const entry=catalog[`report_calendar.${key}`];
|
||||
assert.equal(entry?.length,3,key);
|
||||
@@ -324,3 +324,130 @@ test('same-date selections across an environment A to B to A transition reject t
|
||||
assert.equal(h.state.dailyOverviewDate,date);
|
||||
assert.equal(h.state.jobsMonth,month);
|
||||
});
|
||||
|
||||
test('the compact calendar starts closed and its popup and ARR date picker are mutually exclusive',async()=>{
|
||||
const h=setup(()=>page([row(16)]));
|
||||
await h.loadReportCalendar();
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,true);
|
||||
assert.equal(h.element('#report-calendar-toggle').getAttribute('aria-expanded'),'false');
|
||||
assert.equal(h.element('#report-calendar-summary').textContent,'report_calendar.coverage');
|
||||
const reads=h.calls.length;
|
||||
h.openARRCalendar();
|
||||
assert.equal(h.arrCalendar.open,true);
|
||||
h.openReportCalendar();
|
||||
assert.equal(h.arrCalendar.open,false);
|
||||
assert.equal(h.state.reportCalendarOpen,true);
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,false);
|
||||
assert.equal(h.element('#report-calendar-toggle').getAttribute('aria-expanded'),'true');
|
||||
assert.match(block(h,`${month}-16`),/report-calendar-day-check/,'a generated day has a non-color check mark');
|
||||
assert.doesNotMatch(block(h,`${month}-17`),/report-calendar-day-check|report-calendar-day-state/);
|
||||
h.openARRCalendar();
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,true);
|
||||
assert.equal(h.arrCalendar.open,true);
|
||||
assert.equal(h.calls.length,reads,'opening either calendar is a view action');
|
||||
});
|
||||
|
||||
test('Escape and close return focus to the trigger while outside click or keyboard focus preserve the destination',()=>{
|
||||
const h=setup(()=>{throw new Error('Closing must not request data');});
|
||||
h.bindReportCalendar();
|
||||
h.openReportCalendar();
|
||||
let prevented=false;
|
||||
h.emitDocumentEvent({type:'keydown',key:'Escape',preventDefault(){prevented=true;}});
|
||||
assert.equal(prevented,true);
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
|
||||
h.openReportCalendar();
|
||||
h.element('#report-calendar-close').dispatchEvent({type:'click'});
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
|
||||
for(const type of ['click','focusin']) {
|
||||
h.openReportCalendar();
|
||||
const destination=h.element('#xml-file');
|
||||
destination.focus();
|
||||
h.emitDocumentEvent({type,target:destination});
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.document.activeElement,destination,'dismissal cannot steal focus from the next operation');
|
||||
}
|
||||
});
|
||||
|
||||
test('successful read-only selection closes the popup but busy or unsaved prices keep it open without discarding input',async()=>{
|
||||
const date=`${month}-17`;
|
||||
const h=setup(url=>url===`/api/daily-reports/${date}`?response(200,{business_date:date,current_job:null,latest_job:null}):page());
|
||||
await h.loadReportCalendar();
|
||||
h.openReportCalendar();
|
||||
h.state.uploadInFlight=true;
|
||||
assert.equal(await h.selectReportCalendarDate(date),false);
|
||||
assert.equal(h.state.reportCalendarOpen,true);
|
||||
h.state.uploadInFlight=false;
|
||||
h.state.dailyPriceReview={case_status:'open',items:[{item_id:1,real_price:null}]};
|
||||
const input={value:'0',closest:()=>({dataset:{dailyPriceReviewItemId:'1'}})};
|
||||
h.element('#daily-price-review-body').querySelectorAll=()=>[input];
|
||||
assert.equal(await h.selectReportCalendarDate(date),false);
|
||||
assert.equal(h.state.reportCalendarOpen,true);
|
||||
assert.equal(input.value,'0');
|
||||
assert.equal(h.element('#arr-download-date').value,`${month}-16`);
|
||||
h.state.dailyPriceReview=null;
|
||||
assert.equal(await h.selectReportCalendarDate(date),true);
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,true);
|
||||
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
assert.equal(h.calls.some(call=>call.url.includes('/arr-downloads')),false);
|
||||
});
|
||||
|
||||
test('a hidden popup never captures focus during a late month refresh or keyboard-render request',async()=>{
|
||||
let resolveRead;
|
||||
const h=setup(()=>new Promise(resolve=>{resolveRead=resolve;}));
|
||||
h.openReportCalendar();
|
||||
const pending=h.loadReportCalendar();
|
||||
const hiddenDay=h.element(`[data-report-calendar-day="${h.state.reportCalendarFocusDate}"]`);
|
||||
hiddenDay.closest=()=>hiddenDay;
|
||||
h.closeReportCalendar();
|
||||
let focusAttempts=0;
|
||||
hiddenDay.focus=()=>{focusAttempts+=1;};
|
||||
h.document.activeElement=hiddenDay;
|
||||
resolveRead(page());
|
||||
await pending;
|
||||
h.renderReportCalendar(true);
|
||||
assert.equal(focusAttempts,0);
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,true);
|
||||
});
|
||||
|
||||
test('a late day selection cannot close a popup opened again after the original popup was dismissed',async()=>{
|
||||
let resolveSelection;
|
||||
const date=`${month}-17`;
|
||||
const h=setup(url=>url===`/api/daily-reports/${date}`?new Promise(resolve=>{resolveSelection=resolve;}):page());
|
||||
await h.loadReportCalendar();
|
||||
h.openReportCalendar();
|
||||
const selecting=h.selectReportCalendarDate(date);
|
||||
h.closeReportCalendar();
|
||||
h.openReportCalendar();
|
||||
resolveSelection(response(200,{business_date:date,current_job:null,latest_job:null}));
|
||||
assert.equal(await selecting,true);
|
||||
assert.equal(h.state.reportCalendarOpen,true);
|
||||
assert.equal(h.element('#report-calendar-popup').hidden,false);
|
||||
});
|
||||
|
||||
test('a delegated day click stays internal after synchronous rendering removes its target and restores trigger focus on success',async()=>{
|
||||
let resolveSelection;
|
||||
const date=`${month}-17`;
|
||||
const h=setup(url=>url===`/api/daily-reports/${date}`?new Promise(resolve=>{resolveSelection=resolve;}):page());
|
||||
await h.loadReportCalendar();
|
||||
h.bindReportCalendar();
|
||||
h.openReportCalendar();
|
||||
const originalButton={dataset:{reportCalendarDay:date},closest(){return this;}};
|
||||
const control=h.element('#report-calendar');
|
||||
const event={type:'click',target:originalButton,composedPath:()=>[
|
||||
originalButton,h.element('#report-calendar-days'),h.element('#report-calendar-popup'),control]};
|
||||
h.element('#report-calendar-days').dispatchEvent(event);
|
||||
assert.equal(h.state.dailyOverviewDate,date,'the delegated handler has already synchronously rerendered the days');
|
||||
assert.equal(control.contains(originalButton),false,'the original click target is no longer a current DOM descendant');
|
||||
h.emitDocumentEvent(event);
|
||||
assert.equal(h.state.reportCalendarOpen,true,'document must honor the event origin instead of the removed target');
|
||||
resolveSelection(response(200,{business_date:date,current_job:null,latest_job:null}));
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(h.state.reportCalendarOpen,false);
|
||||
assert.equal(h.document.activeElement,h.element('#report-calendar-toggle'));
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
});
|
||||
Reference in new issue
Block a user