Skip to content

Instantly share code, notes, and snippets.

@scytacki
Last active October 16, 2023 16:00
Show Gist options
  • Save scytacki/9b10acae9eed92dcc6609ef706b028a9 to your computer and use it in GitHub Desktop.
Save scytacki/9b10acae9eed92dcc6609ef706b028a9 to your computer and use it in GitHub Desktop.
Some scripts for updating cells in timesheet.
function updateReactInput(inp, value) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set.call(inp, value);
inp.dispatchEvent(new Event('change', { bubbles: true}));
}
function updateReactSelect(inp, value) {
Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value').set.call(inp, value);
inp.dispatchEvent(new Event('change', { bubbles: true}));
}
function updateReactTextArea(inp, value) {
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set.call(inp, value);
inp.dispatchEvent(new Event('change', { bubbles: true}));
}
function fillRow(row, value) {
const rowInputs = row.querySelectorAll(".only-desktop input:not(:disabled)");
// update all elements
rowInputs.forEach((el,index) => {
if (!isHolidayColumn(index)) {
updateReactInput(el, value);
}
});
// FIXME: we probably need to wait until the row is actually updated
// I guess the best way to do that is to check the value of the rowInput after
// setting them all.
}
function holidayRow() {
const tds = document.querySelectorAll(".c-timesheet-table-wide tr.c-table__desktop-view td:nth-child(1)");
let holidayRowIdx = -1;
Array.from(tds).forEach((td, index) => {
const input = td.querySelector("input");
if (!input) {
return;
}
if (input.value === "Holiday") {
holidayRowIdx = index;
}
});
return holidayRowIdx;
}
let holidayColumnIndexes;
/**
* To speed this up, the result is cached since it shouldn't change.
*
* @returns the indexes of holiday columns, the indexes do not count disabled weekend cells
* this should be inline with how the rest of the code only selects cells that are not
* disabled.
*/
function holidayColumns() {
if (Array.isArray(holidayColumnIndexes)) {
return holidayColumnIndexes;
}
holidayColumnIndexes = [];
const holidayRowIdx = holidayRow();
if (holidayRowIdx < 0) {
return holidayColumnIndexes;
}
const rows = document.querySelectorAll(".c-timesheet-table-short tr.c-table__desktop-view");
const row = rows[holidayRowIdx];
const rowInputs = row.querySelectorAll(".only-desktop input");
// Keep our own index so we can skip weekend cells
let columnIdx = 0;
// update all elements
rowInputs.forEach(input => {
if (input.disabled) {
if (input.value === "100") {
holidayColumnIndexes.push(columnIdx);
} else {
// skip disabled columns without 100 values these are weekend rows
return;
}
}
columnIdx++;
});
return holidayColumnIndexes;
}
function isHolidayColumn(columnIdx) {
const holidayColumnArray = holidayColumns();
return holidayColumnArray.indexOf(columnIdx) >= 0;
}
function applyToRow() {
const value = document.activeElement.value;
const row = document.activeElement.closest("tr");
fillRow(row, value);
}
function processProjectsCell(td) {
const select = td.querySelector("select");
if (!select) {
return null;
}
const cell = Array.from(select.selectedOptions).map(option => option.text)[0];
if (!cell) {
return null;
}
const numString = cell.match(/\d+/)?.[0];
if (!numString) {
return null;
}
return parseInt(numString, 10);
}
function getSelectedGrantProjects() {
const tds = document.querySelectorAll(".c-timesheet-table-wide tr.c-table__desktop-view td:nth-child(2)");
return Array.from(tds).map(processProjectsCell);
}
// This doesn't actually add the rows, it just updates the input field
function updateRowsToAdd(num) {
const numInput = document.querySelector("input[aria-label='Number of Rows To Add']");
updateReactInput(numInput, num);
}
// click the add rows button
function addRows() {
document.querySelector(".c-timesheet-add-block button").click();
}
function deleteAllRows() {
const deleteButtons = document.querySelectorAll(".only-desktop button:not(:disabled)[aria-label='Delete']");
deleteButtons.forEach(el => el.click());
}
// This brings up the project browse dialog for the last row
function browseLastRowProject() {
const selects = document.querySelectorAll(".c-timesheet-table-wide tr.c-table__desktop-view td:nth-child(2) select");
const lastProjectField = selects[selects.length-1];
updateReactSelect(lastProjectField, "browse");
}
// Wait for a function to return a truthy value, checking it every 300ms
async function waitFor(func, errorMsg = null, intervalMS=300) {
let intervalId;
return new Promise((resolve, reject) => {
let count = 0;
intervalId = setInterval(() => {
count += 1;
const result = func();
if(result){
clearInterval(intervalId);
resolve(result);
} else if(count > 40) {
clearInterval(intervalId);
reject(errorMsg || "waitFor didn't succeed");
}
}, intervalMS);
});
}
// Wait
function wait(millis) {
return new Promise((resolve) => {
setTimeout(() => resolve(), millis);
});
}
// Type in a project number and apply the filter,
// wait for the result to match the request
// then select this result
async function selectProjectInDialog(projNum) {
const input = await waitFor(
() => document.querySelector("input[name='CostCenterFullPath']"),
"Could not find input for project number"
);
updateReactInput(input, projNum);
const applyFilterButton = await waitFor(
() => document.querySelector("button[title='Apply Filter']"),
"Could not find the apply filter button"
);
// Try a few times to filter
let foundIt = false;
for (let tryNumber = 0; tryNumber < 3; tryNumber++) {
applyFilterButton.click();
try {
foundIt = await waitFor(
() => document.querySelector(".item.left.col_2.row_4 span")?.textContent.startsWith(projNum),
`Could not find the project ${projNum}`,
10
);
} catch (e) {
// the project wasn't found
console.log(`Failed to find project: ${projNum} on try: ${tryNumber}`);
}
if (foundIt) {
break;
}
}
if (!foundIt) {
throw new Error(`Could not find the project ${projNum}`);
}
// then this query will click the first radio button
document.querySelector("input[type='radio']").click();
}
// change the project of the last row to be projNum
async function updateLastProject(projNum) {
browseLastRowProject();
await selectProjectInDialog(projNum);
await waitFor(() => {
const updatedProjects = getSelectedGrantProjects();
return updatedProjects.lastItem === projNum;
},
`Project did not update after selection in dialog: ${projNum}`,
50);
}
// Add new rows for all of the passed in projects
async function addProjects(projArray) {
console.log("adding projects", projArray);
for(const projNum of projArray) {
addRows();
await waitFor(
() => getSelectedGrantProjects().lastItem === null,
"Blank row wasn't added",
50);
await updateLastProject(projNum);
}
}
// Delete any rows for projects not in the array
function deleteRowsNotIn(projArray) {
// This call is fast, it will return null for a holiday row
const initialProjectNumbers = getSelectedGrantProjects();
console.log("Existing projects", initialProjectNumbers);
const deleteButtons = document.querySelectorAll(".only-desktop button[aria-label='Delete']");
// By working backward the delete buttons and rows don't have to be recomputed
for(let rowIdx = initialProjectNumbers.length - 1; rowIdx >= 0; rowIdx--) {
const rowProject = initialProjectNumbers[rowIdx];
if (projArray.includes(rowProject)) {
continue;
}
const deleteButton = deleteButtons.item(rowIdx);
if (deleteButton.disabled) {
// holiday rows should have an null rowProject so no error will be shown
if (rowProject) {
console.error(`Unable to delete project ${rowProject} button disabled`);
}
continue;
}
deleteButton.click();
}
}
// Add rows for projects in projArray that don't have rows yet
async function addNewRowsFrom(projArray) {
// This call is fast, it will return null for a holiday row
const initialProjectNumbers = getSelectedGrantProjects();
const newProjects = [];
projArray.forEach(proj => {
if (!initialProjectNumbers.includes(proj)) {
newProjects.push(proj);
}
});
await addProjects(newProjects);
}
async function syncRowsWithProjects(projArray) {
deleteRowsNotIn(projArray);
await addNewRowsFrom(projArray);
}
function firstNotesButton(rowIndex) {
const rows = document.querySelectorAll(".c-timesheet-table-short tr.c-table__desktop-view");
const row = rows[rowIndex];
// The :has(input:not(:disabled)) makes sure we put the notes
// in a column that isn't a weekend
// TODO: this won't handle holidays correctly
const buttons = row.querySelectorAll(".only-desktop:has(input:not(:disabled)) button[title='Time Entry Notes']");
// TODO: make this more efficient, we don't have to do this every time
return Array.from(buttons).find((button, index) => {
if(!isHolidayColumn(index)) {
return button;
}
});
}
async function fillFirstNotes(rowIndex, notes) {
const firstButton = firstNotesButton(rowIndex);
firstButton.click();
const textarea = await waitFor(
() => document.querySelector("textarea[name='comment']"),
"Could not find textarea for notes"
);
updateReactTextArea(textarea, notes);
const saveButton = await waitFor(
() => document.querySelector(".c-popup button.btn-primary:not(:disabled)"),
"Save button isn't enabled"
);
saveButton.click();
await waitFor(
() => !document.querySelector(".c-popup"),
"Notes dialog didn't close"
);
}
async function fillPercentagesAndNotes(timesheetData) {
const rows = document.querySelectorAll(".c-timesheet-table-short tr.c-table__desktop-view");
const projectNumbers = getSelectedGrantProjects();
for (let index=0; index<projectNumbers.length; index++) {
const project = projectNumbers[index];
if (!project) {
// This isn't a project row
continue;
}
console.log(`Filling percentages for project row: ${project}`);
const projectData = timesheetData[project];
if (!projectData) {
console.log("The project of this row can't be found in the timesheetData");
continue;
}
if (projectData.notes) {
await fillFirstNotes(index, projectData.notes);
}
const row = rows[index];
fillRow(row, projectData.percentage);
}
}
async function applyTimesheetData(timesheetData) {
const projs = Object.keys(timesheetData).map(proj => parseInt(proj, 10));
await syncRowsWithProjects(projs);
console.log("Updated rows to match input projects");
fillPercentagesAndNotes(timesheetData);
}
// iterate all the values of an object and transform them
// something like a {}.map
function mapObject(obj, fn) {
return Object.fromEntries(
Object.entries(obj).map(
([k, v], i) => [k, fn(v, k, i)]
)
);
}
async function syncRowsAndFillPercentages(percentageMap) {
applyTimesheetData(mapObject(percentageMap, value => ({percentage: value})));
}

Some scripts to help automate the input of timesheets.

Installation

  1. open your timesheet,
  2. open the developer console.
  3. paste the code above into the console

applyTimesheetData({[projectNumber]: {percentage: [percentage], notes: "[notes]"}, ...})

This will delete any rows that are not in the passed in map. And then add rows and set their project for projects in the passed map that don't have a row yet. It will then fill in the first note of the row with the string from notes. And then fill in all of the cells of the row with the perctange from the map.

This should not delete and re-add the same project row. So if you have other comments on that row they should be safe, but that hasn't been tested.

If you run it multiple times it will currently add new notes each time. So you'll ened up with multiple notes in each cell of the first column. So either clear the old notes first or clear them after. Or clear the whole timesheet first with deleteAllRows()

Example: applyTimesheetData({123: {percentage: 30, notes: "project 123 notes"}, 124: {percentage: 30, notes: "project 124 notes\nmore notes"}, 125: {percentage: 40, notes: "simple notes"}})

syncRowsAndFillPercentages({[projectNumber]: [percentage], ...})

This is a simplified version of applyTimesheetData it will takes an parameter like: {123: 30, 124: 30, 125: 40} It will then update all of the project rows so only the projects listed in that parameter are in the timesheet. Then it will add the percentages to each row.

applyToRow()

This will take which ever cell is focused and copy its value to all other cells in that row. It skips disabled cells those are the weekend cells.

fillPercentagesAndNotes({[projectNumber]: {percentage: [percentage], notes: "[notes]"}, ...})

This will fill in the row that has a matching projectNumber with the percentage and also fill in the notes in the first column of this row. It will skip disabled cells which are the weekend cells.

addProjects(projArray)

This take an array of project numbers like [105, 336]. For each project, it adds a row, then opens the browse dialog, searches for the project with this number, and selects the project.

Warning: this code is waiting for various UI elements to appear and be ready. If some of those elements take longer than normal it can get stuck. It should print an error if this happens, but you should use at your own risk. It currently seems to randomly fail to click the apply filter button (refresh looking icon on the right of the dialog).

deleteAllRows()

This will delete all of the rows that have an enabled delete button. The Holiday row delete button is not enabled so it should be safe. This function seems to take effect very fast, so it might not be necessary to wait after calling it.

getSelectedGrantProjects

This isn't that useful yet. But it gets the numbers of the projects currently selected in the timesheet.

TODO

  • identify and skip holiday columns
  • support sick and vacation days
  • when run multiple times, clean up existing notes
  • when run multiple times only clean notes that have changed
  • handle "tree view" tab in the project selection dialog. If that tab is selected the current code can't find the project to select. The tree view might also be a faster way to select projects since it seems like you don't need to search for them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment