Fully implemented the Manage Time functionality
This commit is contained in:
112
dist/chromium/background.js
vendored
112
dist/chromium/background.js
vendored
@@ -34,7 +34,7 @@ function createTimeDates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/time-entries.js
|
// Source: src/services/fusion/time-entries.js
|
||||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||||
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
||||||
async function requestWeekDetails(message) {
|
async function requestWeekDetails(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
@@ -51,6 +51,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function requestTimeEntries(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
|
||||||
|
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
|
||||||
|
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesDelete(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
|
||||||
|
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesSave(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
|
||||||
|
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
|
||||||
|
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
|
||||||
|
for (const item of message.items) {
|
||||||
|
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|
||||||
|
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|
||||||
|
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|
||||||
|
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|
||||||
|
|| !hours.some(key => item[key] > 0)
|
||||||
|
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
|
||||||
|
}
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const items = message.items.map(item => ({
|
||||||
|
...(message.method === "PUT" ? { id: item.id } : {}),
|
||||||
|
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
|
||||||
|
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
|
||||||
|
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
|
||||||
|
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
|
||||||
|
}));
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "");
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: message.method, credentials: "include",
|
||||||
|
headers, body: JSON.stringify(items)
|
||||||
|
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
async function requestActiveServiceRequests(message) {
|
async function requestActiveServiceRequests(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +150,7 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
return { ok: true, items: result.items, debugRequests };
|
return { ok: true, items: result.items, debugRequests };
|
||||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
}
|
}
|
||||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/shared/messages.js
|
// Source: src/shared/messages.js
|
||||||
@@ -103,6 +158,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
|
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
|
||||||
|
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
|
||||||
|
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
|
||||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||||
@@ -518,7 +576,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function observeHeaders(rawUrl, headers) {
|
function observeHeaders(rawUrl, headers) {
|
||||||
let url;
|
let url;
|
||||||
try { url = new URL(rawUrl); } catch { return; }
|
try { url = new URL(rawUrl); } catch { return; }
|
||||||
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
|
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
|
||||||
// Read only the client ID, not cookies, bodies or authorization.
|
// Read only the client ID, not cookies, bodies or authorization.
|
||||||
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
||||||
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
||||||
@@ -531,7 +589,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (started || !runtimeApi?.webRequest) return;
|
if (started || !runtimeApi?.webRequest) return;
|
||||||
started = true;
|
started = true;
|
||||||
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
|
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
|
||||||
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
||||||
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
||||||
}, filter, ["requestHeaders"]);
|
}, filter, ["requestHeaders"]);
|
||||||
@@ -608,7 +666,29 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
});
|
});
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
|
async function timeEntriesHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||||
|
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
|
||||||
|
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
|
||||||
|
throw new Error("Unexpected time entries URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the time entry header permissions.");
|
||||||
|
}
|
||||||
|
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: [isDelete ? 7205 : 7204],
|
||||||
|
addRules: [{
|
||||||
|
id: isDelete ? 7205 : 7204, priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
|
||||||
|
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
|
||||||
|
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
return sessionHeaders(target.origin, "application/json");
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/accounts.js
|
// Source: src/services/spa/accounts.js
|
||||||
@@ -2425,6 +2505,9 @@ function startBackgroundApplication(dependencies) {
|
|||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
requestWeekDetails,
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
requestLookupTaskType,
|
requestLookupTaskType,
|
||||||
requestServiceRequestExpand,
|
requestServiceRequestExpand,
|
||||||
requestResourceUser,
|
requestResourceUser,
|
||||||
@@ -2468,8 +2551,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
if (message.type === messageTypes.requestTimeEntriesSave) {
|
||||||
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestTimeEntriesDelete) {
|
||||||
|
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
||||||
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
@@ -2675,7 +2768,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||||
const contracts = createBackgroundContracts();
|
const contracts = createBackgroundContracts();
|
||||||
@@ -2816,6 +2909,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
startBackgroundApplication({
|
startBackgroundApplication({
|
||||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||||
|
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||||
|
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
|
||||||
|
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
|
||||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||||
|
|||||||
303
dist/chromium/content.js
vendored
303
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
112
dist/firefox/background.js
vendored
112
dist/firefox/background.js
vendored
@@ -34,7 +34,7 @@ function createTimeDates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/fusion/time-entries.js
|
// Source: src/services/fusion/time-entries.js
|
||||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||||
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
||||||
async function requestWeekDetails(message) {
|
async function requestWeekDetails(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
@@ -51,6 +51,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function requestTimeEntries(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
|
||||||
|
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
|
||||||
|
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesDelete(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
|
||||||
|
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesSave(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
|
||||||
|
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
|
||||||
|
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
|
||||||
|
for (const item of message.items) {
|
||||||
|
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|
||||||
|
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|
||||||
|
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|
||||||
|
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|
||||||
|
|| !hours.some(key => item[key] > 0)
|
||||||
|
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
|
||||||
|
}
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const items = message.items.map(item => ({
|
||||||
|
...(message.method === "PUT" ? { id: item.id } : {}),
|
||||||
|
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
|
||||||
|
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
|
||||||
|
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
|
||||||
|
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
|
||||||
|
}));
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "");
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: message.method, credentials: "include",
|
||||||
|
headers, body: JSON.stringify(items)
|
||||||
|
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
async function requestActiveServiceRequests(message) {
|
async function requestActiveServiceRequests(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +150,7 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
return { ok: true, items: result.items, debugRequests };
|
return { ok: true, items: result.items, debugRequests };
|
||||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
}
|
}
|
||||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/shared/messages.js
|
// Source: src/shared/messages.js
|
||||||
@@ -103,6 +158,9 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
|
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
|
||||||
|
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
|
||||||
|
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
|
||||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||||
@@ -518,7 +576,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function observeHeaders(rawUrl, headers) {
|
function observeHeaders(rawUrl, headers) {
|
||||||
let url;
|
let url;
|
||||||
try { url = new URL(rawUrl); } catch { return; }
|
try { url = new URL(rawUrl); } catch { return; }
|
||||||
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
|
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
|
||||||
// Read only the client ID, not cookies, bodies or authorization.
|
// Read only the client ID, not cookies, bodies or authorization.
|
||||||
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
||||||
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
||||||
@@ -531,7 +589,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (started || !runtimeApi?.webRequest) return;
|
if (started || !runtimeApi?.webRequest) return;
|
||||||
started = true;
|
started = true;
|
||||||
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
|
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
|
||||||
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
||||||
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
||||||
}, filter, ["requestHeaders"]);
|
}, filter, ["requestHeaders"]);
|
||||||
@@ -608,7 +666,29 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
});
|
});
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
|
async function timeEntriesHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||||
|
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
|
||||||
|
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
|
||||||
|
throw new Error("Unexpected time entries URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the time entry header permissions.");
|
||||||
|
}
|
||||||
|
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: [isDelete ? 7205 : 7204],
|
||||||
|
addRules: [{
|
||||||
|
id: isDelete ? 7205 : 7204, priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
|
||||||
|
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
|
||||||
|
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
return sessionHeaders(target.origin, "application/json");
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source: src/services/spa/accounts.js
|
// Source: src/services/spa/accounts.js
|
||||||
@@ -2425,6 +2505,9 @@ function startBackgroundApplication(dependencies) {
|
|||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
requestWeekDetails,
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
requestLookupTaskType,
|
requestLookupTaskType,
|
||||||
requestServiceRequestExpand,
|
requestServiceRequestExpand,
|
||||||
requestResourceUser,
|
requestResourceUser,
|
||||||
@@ -2468,8 +2551,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
if (message.type === messageTypes.requestTimeEntriesSave) {
|
||||||
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestTimeEntriesDelete) {
|
||||||
|
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
||||||
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
@@ -2675,7 +2768,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||||
const contracts = createBackgroundContracts();
|
const contracts = createBackgroundContracts();
|
||||||
@@ -2816,6 +2909,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
startBackgroundApplication({
|
startBackgroundApplication({
|
||||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||||
|
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||||
|
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
|
||||||
|
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
|
||||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||||
|
|||||||
303
dist/firefox/content.js
vendored
303
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function observeHeaders(rawUrl, headers) {
|
function observeHeaders(rawUrl, headers) {
|
||||||
let url;
|
let url;
|
||||||
try { url = new URL(rawUrl); } catch { return; }
|
try { url = new URL(rawUrl); } catch { return; }
|
||||||
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
|
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management|oalset_timeentrymobile)\/live(?:\/|;)/.test(url.pathname)) return;
|
||||||
// Read only the client ID, not cookies, bodies or authorization.
|
// Read only the client ID, not cookies, bodies or authorization.
|
||||||
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
|
||||||
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
|
||||||
@@ -28,7 +28,7 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
function start() {
|
function start() {
|
||||||
if (started || !runtimeApi?.webRequest) return;
|
if (started || !runtimeApi?.webRequest) return;
|
||||||
started = true;
|
started = true;
|
||||||
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
|
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`, `${origin}/ic/builder/rt/oalset_timeentrymobile/*`], types: ["xmlhttprequest"] };
|
||||||
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
|
||||||
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
|
||||||
}, filter, ["requestHeaders"]);
|
}, filter, ["requestHeaders"]);
|
||||||
@@ -105,5 +105,27 @@ function createSrRequestHeaders({ runtimeApi }) {
|
|||||||
});
|
});
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
|
async function timeEntriesHeaders(url) {
|
||||||
|
const target = new URL(url);
|
||||||
|
const path = "/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||||
|
const isDelete = target.pathname.startsWith(`${path}/`) && /^[1-9]\d*$/.test(target.pathname.slice(path.length + 1));
|
||||||
|
if (target.origin !== origin || (target.pathname !== path && !isDelete) || target.search || target.hash) {
|
||||||
|
throw new Error("Unexpected time entries URL.");
|
||||||
|
}
|
||||||
|
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
|
||||||
|
throw new Error("Reload the extension to enable the time entry header permissions.");
|
||||||
|
}
|
||||||
|
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
await runtimeApi.declarativeNetRequest.updateSessionRules({
|
||||||
|
removeRuleIds: [isDelete ? 7205 : 7204],
|
||||||
|
addRules: [{
|
||||||
|
id: isDelete ? 7205 : 7204, priority: 1,
|
||||||
|
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: target.origin }] },
|
||||||
|
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [new URL(runtimeApi.runtime.getURL("/")).hostname],
|
||||||
|
requestMethods: isDelete ? ["delete"] : ["post", "put"], resourceTypes: ["xmlhttprequest"] }
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
return sessionHeaders(target.origin, "application/json");
|
||||||
|
}
|
||||||
|
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, timeEntriesHeaders, observeResponseHeaders: observeHeaders };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
const messageTypes = createMessageTypes();
|
const messageTypes = createMessageTypes();
|
||||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message), saveHeaders: url => srRequestHeaders.timeEntriesHeaders(url) });
|
||||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||||
const contracts = createBackgroundContracts();
|
const contracts = createBackgroundContracts();
|
||||||
@@ -148,6 +148,9 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
startBackgroundApplication({
|
startBackgroundApplication({
|
||||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||||
|
requestTimeEntries: message => timeEntries.requestTimeEntries(message),
|
||||||
|
requestTimeEntriesSave: message => timeEntries.requestTimeEntriesSave(message),
|
||||||
|
requestTimeEntriesDelete: message => timeEntries.requestTimeEntriesDelete(message),
|
||||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
function registerBackgroundMessages({
|
function registerBackgroundMessages({
|
||||||
messageTypes,
|
messageTypes,
|
||||||
requestWeekDetails,
|
requestWeekDetails,
|
||||||
|
requestTimeEntries,
|
||||||
|
requestTimeEntriesSave,
|
||||||
|
requestTimeEntriesDelete,
|
||||||
requestLookupTaskType,
|
requestLookupTaskType,
|
||||||
requestServiceRequestExpand,
|
requestServiceRequestExpand,
|
||||||
requestResourceUser,
|
requestResourceUser,
|
||||||
@@ -45,8 +48,18 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
if (message.type === messageTypes.requestTimeEntriesSave) {
|
||||||
const request = message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
requestTimeEntriesSave(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.requestTimeEntriesDelete) {
|
||||||
|
requestTimeEntriesDelete(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ([messageTypes.requestTimeEntries, messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||||
|
const request = message.type === messageTypes.requestTimeEntries ? requestTimeEntries : message.type === messageTypes.requestServiceRequestExpand ? requestServiceRequestExpand : message.type === messageTypes.requestWeekDetails ? requestWeekDetails : message.type === messageTypes.requestResourceUser ? requestResourceUser : requestActiveServiceRequests;
|
||||||
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
request(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -137,6 +137,30 @@ function createContentApplication(overrides = {}) {
|
|||||||
});
|
});
|
||||||
const svgPaths = createSvgPaths({ environment });
|
const svgPaths = createSvgPaths({ environment });
|
||||||
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
|
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
|
||||||
|
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
|
||||||
|
async requestDelete(id) {
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesDelete, id });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to delete time entry.");
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
async requestSave(method, items) {
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
await currentUser.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntriesSave, method, items, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to save time entries.");
|
||||||
|
return result.result;
|
||||||
|
},
|
||||||
|
async requestEntries(weekId) {
|
||||||
|
await srSessionPreparation.ensure();
|
||||||
|
await currentUser.ensure();
|
||||||
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestTimeEntries, weekId, emailAddress: currentUser.getUserId() });
|
||||||
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
if (!result?.ok) throw new Error(result?.error || "Unable to load time entries.");
|
||||||
|
return result.items;
|
||||||
|
},
|
||||||
async requestTaskTypes() {
|
async requestTaskTypes() {
|
||||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
|
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
|
||||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
|
.oe-time-grid th:nth-child(2), .oe-time-grid th:nth-child(3) { width: 12%; }
|
||||||
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
|
.oe-time-grid th:nth-last-child(-n+3) { width: 46px; }
|
||||||
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
|
.oe-time-grid td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
|
||||||
|
.oe-time-duplicate-row { outline: 2px solid #b3261e; outline-offset: -2px; animation: oe-time-duplicate-fade 5.5s linear forwards; }
|
||||||
|
.opportunities-extension-modal[data-theme="dark"] .oe-time-duplicate-row { --time-duplicate-color: #ffaaa4; }
|
||||||
|
@keyframes oe-time-duplicate-fade { 0%, 90.909% { outline-color: var(--time-duplicate-color, #b3261e); } 100% { outline-color: transparent; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .oe-time-duplicate-row { animation-timing-function: step-end; } }
|
||||||
.oe-time-grid select { width: 100%; }
|
.oe-time-grid select { width: 100%; }
|
||||||
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
|
.oe-time-grid input[type="number"] { width: min(100%, 60px); padding: 6px 3px; text-align: center; }
|
||||||
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
|
.oe-time-grid .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
|
||||||
@@ -45,6 +49,14 @@
|
|||||||
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
|
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
|
||||||
.oe-time-footer button { padding: 7px 14px; }
|
.oe-time-footer button { padding: 7px 14px; }
|
||||||
.oe-time-status { padding: 12px 0; }
|
.oe-time-status { padding: 12px 0; }
|
||||||
|
.oe-time-loading-status { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
.oe-time-skeleton { display: inline-block; vertical-align: middle; border-radius: 4px; background: var(--time-border); color: transparent; animation: oe-time-pulse 1.3s ease-in-out infinite alternate; }
|
||||||
|
.oe-time-skeleton-select { display: block; width: 100%; height: 38px; }
|
||||||
|
.oe-time-skeleton-control { width: min(100%, 60px); height: 38px; }
|
||||||
|
.oe-time-skeleton-heading { width: 65%; height: 20px; margin: 17px 0; }
|
||||||
|
.oe-time-skeleton-summary { min-width: 44px; height: 20px; }
|
||||||
|
@keyframes oe-time-pulse { from { opacity: .4; } to { opacity: 1; } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .oe-time-skeleton { animation: none; } }
|
||||||
.oe-time-status[role="alert"] { color: #a12d27; }
|
.oe-time-status[role="alert"] { color: #a12d27; }
|
||||||
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
.oe-time-dialog [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
|
||||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
|
.opportunities-extension-modal[data-theme="dark"] .oe-time-status[role="alert"] { color: #ffd9d5; }
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestSave, requestDelete, notify = () => {}, requestEntries = async () => [], requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
let active = null;
|
let active = null;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let launcher = null;
|
let launcher = null;
|
||||||
|
const referenceDateKey = "opportunities-extension-manage-time-reference-date";
|
||||||
|
let lastReferenceDate = "";
|
||||||
|
function savedReferenceDate() {
|
||||||
|
try {
|
||||||
|
const saved = environment.localStorage?.getItem(referenceDateKey);
|
||||||
|
if (dates.parse(saved)) lastReferenceDate = saved;
|
||||||
|
} catch { /* Keep the in-memory selection when storage is unavailable. */ }
|
||||||
|
return dates.parse(lastReferenceDate) ? lastReferenceDate : dates.today();
|
||||||
|
}
|
||||||
|
function saveReferenceDate(value) {
|
||||||
|
if (!dates.parse(value)) return;
|
||||||
|
lastReferenceDate = value;
|
||||||
|
try { environment.localStorage?.setItem(referenceDateKey, value); }
|
||||||
|
catch { /* Keep the selection for this page even if persistence is blocked. */ }
|
||||||
|
}
|
||||||
function setReady(value) {
|
function setReady(value) {
|
||||||
ready = !!value;
|
ready = !!value;
|
||||||
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
||||||
@@ -54,12 +69,14 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
}
|
}
|
||||||
function close(restoreFocus = true) {
|
function close(restoreFocus = true) {
|
||||||
if (!active) return false;
|
if (!active) return false;
|
||||||
|
if (active.saving) return true;
|
||||||
const previous = active; active = null;
|
const previous = active; active = null;
|
||||||
previous.modal.remove();
|
previous.modal.remove();
|
||||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function open(trigger) {
|
function open(trigger) {
|
||||||
|
if (active?.saving) return;
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const host = document.getElementById(MODAL_ID);
|
const host = document.getElementById(MODAL_ID);
|
||||||
if (!host || host.hidden) return;
|
if (!host || host.hidden) return;
|
||||||
@@ -76,17 +93,18 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
const body = node("div", "oe-time-body");
|
const body = node("div", "oe-time-body");
|
||||||
const toolbar = node("div", "oe-time-toolbar");
|
const toolbar = node("div", "oe-time-toolbar");
|
||||||
const navigation = node("div", "oe-time-navigation");
|
const navigation = node("div", "oe-time-navigation");
|
||||||
const date = node("input"); date.type = "date"; date.value = dates.today(); date.required = true;
|
const date = node("input"); date.type = "date"; date.value = savedReferenceDate(); date.required = true;
|
||||||
date.setAttribute("aria-label", "Reference date");
|
date.setAttribute("aria-label", "Reference date");
|
||||||
const ending = node("div", "oe-time-ending");
|
const ending = node("div", "oe-time-ending");
|
||||||
const endingValue = node("strong", "", "--");
|
const endingValue = node("strong", "", "--");
|
||||||
ending.append(node("span", "", "Week Ending"), endingValue);
|
ending.append(node("span", "", "Week Ending"), endingValue);
|
||||||
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); load(); } };
|
const changeDate = () => { saveReferenceDate(date.value); load(); };
|
||||||
|
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); changeDate(); } };
|
||||||
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
|
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
|
||||||
date.addEventListener("change", load); toolbar.append(navigation, ending);
|
date.addEventListener("change", changeDate); toolbar.append(navigation, ending);
|
||||||
const summary = node("div", "oe-time-summary"), sums = {};
|
const summary = node("div", "oe-time-summary"), sums = {};
|
||||||
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
|
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
|
||||||
const item = node("div"); sums[label] = node("strong", "", "0 hrs");
|
const item = node("div"); sums[label] = node("strong", "", "0h");
|
||||||
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
|
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
|
||||||
}
|
}
|
||||||
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
|
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
|
||||||
@@ -100,13 +118,14 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
|
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
|
||||||
const add = button("Add", "add", () => addRow("Service"), true);
|
const add = button("Add", "add", () => addRow("Service"), true);
|
||||||
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
|
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
|
||||||
const save = button("Save", "save", null, true); save.disabled = true;
|
const save = button("Save", "save", saveEntries, true); save.disabled = true;
|
||||||
save.title = "Saving time entries is not available yet.";
|
const saveStatus = node("div", "oe-time-status"); saveStatus.hidden = true; saveStatus.setAttribute("role", "status");
|
||||||
footer.append(add, addNonService, save);
|
footer.append(add, addNonService, save);
|
||||||
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, footer);
|
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, saveStatus, footer);
|
||||||
dialog.append(header, body); modal.append(dialog); host.append(modal);
|
dialog.append(header, body); modal.append(dialog); host.append(modal);
|
||||||
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
|
const entry = { modal, trigger, version: 0, days: [], rows: [], drafts: new Map(), tbody: null, dayTotals: [], services: [], servicesStarted: false, servicesReady: false, expanded: new Map() };
|
||||||
active = entry;
|
active = entry;
|
||||||
|
entry.deletions = new Map();
|
||||||
entry.taskTypes = [];
|
entry.taskTypes = [];
|
||||||
entry.taskTypesReady = false;
|
entry.taskTypesReady = false;
|
||||||
let combos = [];
|
let combos = [];
|
||||||
@@ -121,7 +140,88 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||||
});
|
});
|
||||||
function blankRow(type) { return { type, hours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
|
function blankRow(type) { return { type, hours: {}, invalidHours: {}, notes: "", srNumber: "", activity: "", taskType: "", notesOpen: false }; }
|
||||||
|
const hourFields = ["sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours", "saturdayTaskHours"];
|
||||||
|
function rowPayload(row) {
|
||||||
|
const payload = { srNumber: row.type === "Non Service" ? "non-service-sr" : row.srNumber,
|
||||||
|
activityCode: row.activity, taskType: row.taskType, deliveryType: row.deliveryType || "",
|
||||||
|
notes: row.notes, weekId: entry.days[0]?.WeekId, tag: row.type === "Non Service" ? "NON_SERVICE" : "SERVICE" };
|
||||||
|
for (const day of entry.days) payload[hourFields[dates.fromApi(day.Date).getUTCDay()]] = Number(row.hours[day.Date]) || 0;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
function signature(row) { return JSON.stringify(rowPayload(row)); }
|
||||||
|
function touched(row) { return row.id || row.persisted || row.srNumber || row.activity || row.taskType || row.notes.trim() || Object.values(row.hours).some(value => value !== "") || Object.values(row.invalidHours).some(Boolean); }
|
||||||
|
function validRow(row) {
|
||||||
|
return !!row.taskType && (row.type === "Non Service" || (!!row.srNumber && !!row.activity))
|
||||||
|
&& !Object.values(row.invalidHours).some(Boolean)
|
||||||
|
&& Object.values(row.hours).every(value => Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 24)
|
||||||
|
&& Object.values(row.hours).some(value => Number(value) > 0);
|
||||||
|
}
|
||||||
|
function changedRows() { return entry.rows.filter(row => touched(row) && signature(row) !== row.baseline); }
|
||||||
|
function pendingDeletes() {
|
||||||
|
const key = entry.days.map(day => day.Date).join("|");
|
||||||
|
if (!entry.deletions.has(key)) entry.deletions.set(key, new Set());
|
||||||
|
return entry.deletions.get(key);
|
||||||
|
}
|
||||||
|
function updateSave() {
|
||||||
|
const rows = changedRows();
|
||||||
|
const deletions = pendingDeletes();
|
||||||
|
save.disabled = (rows.length > 0 && !requestSave) || (deletions.size > 0 && !requestDelete) || !entry.tbody || entry.saving || (!rows.length && !deletions.size)
|
||||||
|
|| entry.rows.some(row => touched(row) && !validRow(row)) || rows.some(row => row.persisted && !row.id);
|
||||||
|
save.title = save.disabled ? "Complete the required fields and enter hours to save changes." : "Save time entries";
|
||||||
|
}
|
||||||
|
async function saveEntries() {
|
||||||
|
updateSave();
|
||||||
|
if (save.disabled) return;
|
||||||
|
const rows = changedRows(), knownIds = new Set(entry.rows.filter(row => row.id).map(row => String(row.id)));
|
||||||
|
const controls = [];
|
||||||
|
function lock(node) {
|
||||||
|
if (["BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(String(node.tagName || node.tag).toUpperCase())) { controls.push([node, node.disabled]); node.disabled = true; }
|
||||||
|
Array.from(node.children).forEach(lock);
|
||||||
|
}
|
||||||
|
entry.saving = true; lock(modal);
|
||||||
|
saveStatus.hidden = false; saveStatus.setAttribute("role", "status"); saveStatus.textContent = "Saving time entries...";
|
||||||
|
let savedCount = 0, deletedCount = 0;
|
||||||
|
try {
|
||||||
|
const deletions = pendingDeletes();
|
||||||
|
for (const id of [...deletions]) {
|
||||||
|
await requestDelete(id);
|
||||||
|
deletions.delete(id);
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
for (const method of ["PUT", "POST"]) {
|
||||||
|
const group = rows.filter(row => method === "PUT" ? !!row.id : !row.id);
|
||||||
|
if (!group.length) continue;
|
||||||
|
const result = await requestSave(method, group.map(row => ({ ...rowPayload(row), ...(row.id ? { id: row.id } : {}) })));
|
||||||
|
group.forEach(row => { row.persisted = true; row.baseline = signature(row); });
|
||||||
|
savedCount += group.length;
|
||||||
|
if (method === "POST") {
|
||||||
|
let records = Array.isArray(result) ? result : Array.isArray(result?.items) ? result.items : [];
|
||||||
|
function assignIds() {
|
||||||
|
const candidates = rowsFromEntries(records, entry.days);
|
||||||
|
for (const row of group.filter(row => !row.id)) {
|
||||||
|
const match = candidates.find(candidate => candidate.id && !knownIds.has(String(candidate.id)) && signature(candidate) === row.baseline);
|
||||||
|
if (match) { row.id = match.id; knownIds.add(String(match.id)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignIds();
|
||||||
|
if (group.some(row => !row.id)) { records = await requestEntries(entry.days[0].WeekId); assignIds(); }
|
||||||
|
if (group.some(row => !row.id)) throw new Error("Entries saved, but their IDs could not be confirmed. Reopen Manage Time before editing these entries.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveStatus.textContent = [savedCount ? `${savedCount} time ${savedCount === 1 ? "entry" : "entries"} saved.` : "", deletedCount ? `${deletedCount} time ${deletedCount === 1 ? "entry" : "entries"} deleted.` : ""].filter(Boolean).join(" ");
|
||||||
|
notify(saveStatus.textContent);
|
||||||
|
saveStatus.hidden = true;
|
||||||
|
} catch (error) {
|
||||||
|
saveStatus.setAttribute("role", "alert");
|
||||||
|
saveStatus.textContent = `${savedCount ? `${savedCount} saved. ` : ""}${deletedCount ? `${deletedCount} deleted. ` : ""}${error.message || "Unable to save time entries."}`;
|
||||||
|
notify(saveStatus.textContent, true);
|
||||||
|
} finally {
|
||||||
|
entry.saving = false;
|
||||||
|
controls.forEach(([control, disabled]) => { control.disabled = disabled; });
|
||||||
|
if (active === entry) { renderRows(); updateSave(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
|
function total(value) { return Number(Number(value).toFixed(2)).toString(); }
|
||||||
function updateTotals() {
|
function updateTotals() {
|
||||||
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
|
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
|
||||||
@@ -133,18 +233,27 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
});
|
});
|
||||||
entry.dayTotals[i].textContent = total(sum);
|
entry.dayTotals[i].textContent = total(sum);
|
||||||
});
|
});
|
||||||
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)} hrs`; });
|
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)}h`; });
|
||||||
|
updateSave();
|
||||||
}
|
}
|
||||||
function addRow(type, source) {
|
function addRow(type, source) {
|
||||||
if (!entry.tbody || add.disabled) return;
|
if (!entry.tbody || add.disabled) return;
|
||||||
entry.rows.push(source ? { ...source, hours: { ...source.hours } } : blankRow(type)); renderRows();
|
entry.rows.push(source ? { ...source, id: undefined, persisted: false, baseline: undefined, duplicateHighlightAt: undefined,
|
||||||
|
...(source.type === "Service" ? { srNumber: "", activity: "" } : {}),
|
||||||
|
invalidHours: { ...source.invalidHours }, hours: { ...source.hours } } : blankRow(type)); renderRows();
|
||||||
}
|
}
|
||||||
function renderRows() {
|
function renderRows() {
|
||||||
|
if (entry.saving) return;
|
||||||
const focusedId = document.activeElement?.id;
|
const focusedId = document.activeElement?.id;
|
||||||
combos = [];
|
combos = [];
|
||||||
entry.tbody.replaceChildren();
|
entry.tbody.replaceChildren();
|
||||||
entry.rows.forEach((row, index) => {
|
entry.rows.forEach((row, index) => {
|
||||||
const tr = node("tr");
|
const tr = node("tr");
|
||||||
|
if (row.duplicateHighlightAt && Date.now() - row.duplicateHighlightAt < 5500) {
|
||||||
|
tr.className = "oe-time-duplicate-row";
|
||||||
|
tr.setAttribute("style", `animation-delay: -${Math.max(0, Date.now() - row.duplicateHighlightAt)}ms`);
|
||||||
|
tr.addEventListener("animationend", () => { tr.className = ""; row.duplicateHighlightAt = undefined; });
|
||||||
|
}
|
||||||
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
|
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
|
||||||
const td = node("td"), select = node("select");
|
const td = node("td"), select = node("select");
|
||||||
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
|
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
|
||||||
@@ -156,34 +265,51 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
|
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
|
||||||
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
|
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
|
||||||
}
|
}
|
||||||
|
if (row.srNumber && !entry.services.some(sr => sr.srNumber === row.srNumber)) {
|
||||||
|
const option = node("option", "", row.srNumber); option.value = row.srNumber; select.append(option);
|
||||||
|
}
|
||||||
select.value = row.srNumber;
|
select.value = row.srNumber;
|
||||||
select.disabled = !entry.servicesReady;
|
select.disabled = !entry.servicesReady;
|
||||||
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
|
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
|
||||||
change = () => { row.srNumber = select.value; row.activity = ""; renderRows(); };
|
change = () => {
|
||||||
|
const duplicate = select.value && entry.rows.find(other => other !== row && other.type === "Service" && other.srNumber === select.value);
|
||||||
|
if (duplicate) {
|
||||||
|
duplicate.duplicateHighlightAt = Date.now();
|
||||||
|
select.value = row.srNumber;
|
||||||
|
notify("This Service Request is already selected in another row.", true);
|
||||||
|
renderRows(); return;
|
||||||
|
}
|
||||||
|
row.srNumber = select.value; row.activity = ""; renderRows();
|
||||||
|
};
|
||||||
} else if (label === "Activity") {
|
} else if (label === "Activity") {
|
||||||
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
|
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
|
||||||
const raw = String(source?.SRActivities_c || "");
|
const raw = String(source?.SRActivities_c || "");
|
||||||
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
|
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
|
||||||
|
if (row.activity && !activities.includes(row.activity)) activities.push(row.activity);
|
||||||
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
|
for (const activity of activities) { const option = node("option", "", activity); option.value = activity; select.append(option); }
|
||||||
select.value = row.activity; select.disabled = !activities.length;
|
select.value = row.activity; select.disabled = !activities.length;
|
||||||
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
|
select.title = activities.length ? "Activity" : "No activities available for this Service Request";
|
||||||
change = () => { row.activity = select.value; };
|
change = () => { row.activity = select.value; updateSave(); };
|
||||||
} else if (label === "Task Type") {
|
} else if (label === "Task Type") {
|
||||||
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
|
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
|
||||||
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
|
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
|
||||||
for (const item of taskTypes) {
|
for (const item of taskTypes) {
|
||||||
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
|
const option = node("option", "", item.lookUpMeaning_c); option.value = item.lookUpCode_c; select.append(option);
|
||||||
}
|
}
|
||||||
if (entry.taskTypesReady && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) row.taskType = "";
|
if (row.taskType && !taskTypes.some(item => item.lookUpCode_c === row.taskType)) {
|
||||||
|
const option = node("option", "", row.taskType); option.value = row.taskType; select.append(option);
|
||||||
|
}
|
||||||
select.value = row.taskType;
|
select.value = row.taskType;
|
||||||
select.disabled = !entry.taskTypesReady || !taskTypes.length;
|
select.disabled = !entry.taskTypesReady || (!taskTypes.length && !row.taskType);
|
||||||
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
|
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
|
||||||
change = () => { row.taskType = select.value; };
|
change = () => { row.taskType = select.value; updateSave(); };
|
||||||
} else { select.disabled = true; select.title = "Options are not available yet."; }
|
} else { select.disabled = true; select.title = "Options are not available yet."; }
|
||||||
td.append(select); tr.append(td);
|
td.append(select); tr.append(td);
|
||||||
select.addEventListener("change", change);
|
select.addEventListener("change", change);
|
||||||
const combo = searchableSelect(select, td, change, {
|
const combo = searchableSelect(select, td, change, {
|
||||||
floating: true,
|
floating: true,
|
||||||
|
listWidth: label === "Service Request" ? 640 : 0,
|
||||||
|
listMaxHeight: label === "Service Request" ? 360 : 180,
|
||||||
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
|
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
|
||||||
});
|
});
|
||||||
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
||||||
@@ -193,10 +319,17 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
const td = node("td"), input = node("input");
|
const td = node("td"), input = node("input");
|
||||||
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
|
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
|
||||||
input.value = row.hours[day.Date] ?? "";
|
input.value = row.hours[day.Date] ?? "";
|
||||||
|
input.setAttribute("aria-invalid", String(!!row.invalidHours[day.Date]));
|
||||||
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
|
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
const valid = input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24);
|
const valid = !input.validity?.badInput && (input.value === "" || (Number.isFinite(input.valueAsNumber) && input.valueAsNumber >= 0 && input.valueAsNumber <= 24));
|
||||||
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = valid ? input.value : ""; updateTotals();
|
if (!valid) {
|
||||||
|
input.value = row.hours[day.Date] ?? "";
|
||||||
|
notify("Enter hours between 0 and 24.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.invalidHours[day.Date] = !valid;
|
||||||
|
input.setAttribute("aria-invalid", String(!valid)); row.hours[day.Date] = input.value; updateTotals();
|
||||||
}); td.append(input); tr.append(td);
|
}); td.append(input); tr.append(td);
|
||||||
}
|
}
|
||||||
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
|
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
|
||||||
@@ -205,7 +338,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
|
const remaining = node("span", "oe-time-notes-count", `${2000 - row.notes.length} characters left`);
|
||||||
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
|
const notes = node("textarea"); notes.rows = 6; notes.maxLength = 2000; notes.value = row.notes;
|
||||||
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
|
notes.setAttribute("aria-label", `Notes, row ${index + 1}`);
|
||||||
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; });
|
notes.addEventListener("input", () => { row.notes = notes.value.slice(0, 2000); remaining.textContent = `${2000 - row.notes.length} characters left`; updateSave(); });
|
||||||
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
|
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
|
||||||
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
|
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
|
||||||
});
|
});
|
||||||
@@ -216,7 +349,17 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
if (!notesRow.hidden) notes.focus();
|
if (!notesRow.hidden) notes.focus();
|
||||||
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
|
}); notesButton.setAttribute("aria-expanded", String(row.notesOpen)); notesCell.append(notesButton); tr.append(notesCell);
|
||||||
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
|
const copy = node("td"); copy.append(button(`Copy row ${index + 1}`, "copy", () => addRow(row.type, row))); tr.append(copy);
|
||||||
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => { entry.rows.splice(index, 1); renderRows(); })); tr.append(remove);
|
const remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => {
|
||||||
|
if (entry.saving) return;
|
||||||
|
if (row.persisted && !row.id) {
|
||||||
|
saveStatus.hidden = false; saveStatus.setAttribute("role", "alert");
|
||||||
|
saveStatus.textContent = "Reopen Manage Time to obtain the saved entry ID before deleting it."; return;
|
||||||
|
}
|
||||||
|
if (row.id) pendingDeletes().add(String(row.id));
|
||||||
|
entry.rows.splice(index, 1); renderRows();
|
||||||
|
saveStatus.hidden = false; saveStatus.setAttribute("role", "status");
|
||||||
|
saveStatus.textContent = pendingDeletes().size ? `${pendingDeletes().size} pending ${pendingDeletes().size === 1 ? "deletion" : "deletions"}. Click Save to apply.` : "Unsaved row removed.";
|
||||||
|
})); tr.append(remove);
|
||||||
entry.tbody.append(tr, notesRow);
|
entry.tbody.append(tr, notesRow);
|
||||||
}); updateTotals();
|
}); updateTotals();
|
||||||
combos.find(combo => combo.input.id === focusedId)?.input.focus();
|
combos.find(combo => combo.input.id === focusedId)?.input.focus();
|
||||||
@@ -240,6 +383,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
async function loadTaskTypes() {
|
async function loadTaskTypes() {
|
||||||
|
taskStatus.className = "oe-time-status oe-time-loading-status";
|
||||||
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
|
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
|
||||||
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
|
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
|
||||||
try {
|
try {
|
||||||
@@ -248,10 +392,12 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
|
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
|
||||||
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
|
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
|
||||||
entry.taskTypesReady = true;
|
entry.taskTypesReady = true;
|
||||||
|
taskStatus.className = "oe-time-status";
|
||||||
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
|
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
|
||||||
if (entry.tbody) renderRows();
|
if (entry.tbody) renderRows();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (active !== entry) return;
|
if (active !== entry) return;
|
||||||
|
taskStatus.className = "oe-time-status";
|
||||||
taskStatus.textContent = error.message || "Unable to load Task Types.";
|
taskStatus.textContent = error.message || "Unable to load Task Types.";
|
||||||
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
|
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
|
||||||
}
|
}
|
||||||
@@ -260,6 +406,7 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
if (entry.servicesStarted && !retrying) return;
|
if (entry.servicesStarted && !retrying) return;
|
||||||
entry.servicesStarted = true; entry.initialWeekId = weekId;
|
entry.servicesStarted = true; entry.initialWeekId = weekId;
|
||||||
serviceRetry.hidden = true; serviceStatus.hidden = false;
|
serviceRetry.hidden = true; serviceStatus.hidden = false;
|
||||||
|
serviceStatus.className = "oe-time-status oe-time-loading-status";
|
||||||
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
|
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
|
||||||
try {
|
try {
|
||||||
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
|
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
|
||||||
@@ -276,14 +423,32 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
if (entry.tbody) renderRows();
|
if (entry.tbody) renderRows();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (active !== entry) return;
|
if (active !== entry) return;
|
||||||
|
serviceStatus.className = "oe-time-status";
|
||||||
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
|
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
|
||||||
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
|
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function renderWeek(items) {
|
function rowsFromEntries(records, days) {
|
||||||
|
return records.map(record => {
|
||||||
|
const row = blankRow(record.tag === "NON_SERVICE" || record.srNumber === "non-service-sr" ? "Non Service" : "Service");
|
||||||
|
row.id = record.id;
|
||||||
|
row.deliveryType = record.deliveryType || "";
|
||||||
|
row.srNumber = record.srNumber || "";
|
||||||
|
row.activity = record.activityCode || "";
|
||||||
|
row.taskType = record.taskType || "";
|
||||||
|
row.notes = typeof record.notes === "string" ? record.notes : "";
|
||||||
|
for (const day of days) {
|
||||||
|
const value = record[hourFields[dates.fromApi(day.Date).getUTCDay()]];
|
||||||
|
row.hours[day.Date] = value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
row.baseline = signature(row);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderWeek(items, records) {
|
||||||
entry.days = items;
|
entry.days = items;
|
||||||
const key = items.map(day => day.Date).join("|");
|
const key = items.map(day => day.Date).join("|");
|
||||||
if (!entry.drafts.has(key)) entry.drafts.set(key, Array.from({ length: 3 }, () => blankRow("Service")));
|
if (!entry.drafts.has(key)) entry.drafts.set(key, records.length ? rowsFromEntries(records, items) : Array.from({ length: 3 }, () => blankRow("Service")));
|
||||||
entry.rows = entry.drafts.get(key);
|
entry.rows = entry.drafts.get(key);
|
||||||
const head = node("thead"), row = node("tr");
|
const head = node("thead"), row = node("tr");
|
||||||
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
|
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
|
||||||
@@ -299,20 +464,55 @@ function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requ
|
|||||||
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
|
head.append(row); entry.tbody = node("tbody"); table.replaceChildren(head, entry.tbody);
|
||||||
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
|
endingValue.textContent = dates.fromApi(items[0].EndDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); renderRows();
|
||||||
}
|
}
|
||||||
|
function renderSkeleton() {
|
||||||
|
const head = node("thead"), heading = node("tr"), skeletonBody = node("tbody");
|
||||||
|
for (let column = 0; column < 13; column++) {
|
||||||
|
const cell = node("th");
|
||||||
|
cell.append(node("span", "oe-time-skeleton oe-time-skeleton-heading")); heading.append(cell);
|
||||||
|
}
|
||||||
|
head.append(heading);
|
||||||
|
for (let row = 0; row < 3; row++) {
|
||||||
|
const tr = node("tr", "oe-time-skeleton-row");
|
||||||
|
for (let column = 0; column < 13; column++) {
|
||||||
|
const cell = node("td");
|
||||||
|
cell.append(node("span", `oe-time-skeleton ${column < 3 ? "oe-time-skeleton-select" : "oe-time-skeleton-control"}`)); tr.append(cell);
|
||||||
|
}
|
||||||
|
skeletonBody.append(tr);
|
||||||
|
}
|
||||||
|
table.setAttribute("aria-hidden", "true"); table.replaceChildren(head, skeletonBody);
|
||||||
|
}
|
||||||
|
function finishSkeleton() {
|
||||||
|
table.setAttribute("aria-hidden", "false");
|
||||||
|
endingValue.className = "";
|
||||||
|
Object.values(sums).forEach(value => { value.className = ""; });
|
||||||
|
status.className = "oe-time-status";
|
||||||
|
}
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (entry.saving) return;
|
||||||
|
finishSkeleton();
|
||||||
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
|
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
|
||||||
|
save.disabled = true; saveStatus.hidden = true;
|
||||||
Object.values(sums).forEach(value => { value.textContent = "--"; });
|
Object.values(sums).forEach(value => { value.textContent = "--"; });
|
||||||
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
|
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
|
||||||
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
|
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
|
||||||
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
|
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
|
||||||
|
status.className = "oe-time-status oe-time-loading-status";
|
||||||
|
endingValue.className = "oe-time-skeleton oe-time-skeleton-summary";
|
||||||
|
Object.values(sums).forEach(value => { value.className = "oe-time-skeleton oe-time-skeleton-summary"; });
|
||||||
|
renderSkeleton();
|
||||||
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
|
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
|
||||||
try {
|
try {
|
||||||
const items = await requestWeek(date.value);
|
const items = await requestWeek(date.value);
|
||||||
if (active !== entry || entry.version !== version) return;
|
if (active !== entry || entry.version !== version) return;
|
||||||
renderWeek(items); add.disabled = addNonService.disabled = false; status.hidden = true;
|
status.textContent = "Loading time entries...";
|
||||||
|
const key = items.map(day => day.Date).join("|");
|
||||||
|
const records = entry.drafts.has(key) ? [] : await requestEntries(items[0].WeekId);
|
||||||
|
if (active !== entry || entry.version !== version) return;
|
||||||
|
finishSkeleton(); renderWeek(items, records); add.disabled = addNonService.disabled = false; status.hidden = true;
|
||||||
loadServices(items[0].WeekId);
|
loadServices(items[0].WeekId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (active !== entry || entry.version !== version) return;
|
if (active !== entry || entry.version !== version) return;
|
||||||
|
finishSkeleton(); table.replaceChildren();
|
||||||
status.textContent = error.message || "Unable to load week details.";
|
status.textContent = error.message || "Unable to load week details.";
|
||||||
status.setAttribute("role", "alert"); retry.hidden = false;
|
status.setAttribute("role", "alert"); retry.hidden = false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function createSearchableSelect({ environment }) {
|
|||||||
if (text !== undefined) node.textContent = text;
|
if (text !== undefined) node.textContent = text;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
function searchableSelect(select, field, onChange, { floating = false, tooltipContent } = {}) {
|
function searchableSelect(select, field, onChange, { floating = false, tooltipContent, listWidth = 0, listMaxHeight = 180 } = {}) {
|
||||||
const box = element("div", "opportunities-extension-action-combobox");
|
const box = element("div", "opportunities-extension-action-combobox");
|
||||||
const input = element("input");
|
const input = element("input");
|
||||||
input.type = "text";
|
input.type = "text";
|
||||||
@@ -79,11 +79,11 @@ function createSearchableSelect({ environment }) {
|
|||||||
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
||||||
input.title = tooltipContent ? "" : input.value;
|
input.title = tooltipContent ? "" : input.value;
|
||||||
}
|
}
|
||||||
function highlight() {
|
function highlight(withTooltip = true) {
|
||||||
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
||||||
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
||||||
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
||||||
if (matches[index]) showTooltip(matches[index].value, list.children[index]);
|
if (withTooltip && matches[index]) showTooltip(matches[index].value, list.children[index]);
|
||||||
}
|
}
|
||||||
function choose(i) {
|
function choose(i) {
|
||||||
if (!matches[i]) return;
|
if (!matches[i]) return;
|
||||||
@@ -93,18 +93,22 @@ function createSearchableSelect({ environment }) {
|
|||||||
if (changed) onChange?.();
|
if (changed) onChange?.();
|
||||||
}
|
}
|
||||||
function positionList() {
|
function positionList() {
|
||||||
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
let bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||||
const anchor = input.getBoundingClientRect?.();
|
const anchor = input.getBoundingClientRect?.();
|
||||||
if (!bounds || !anchor) return;
|
if (!bounds || !anchor) return;
|
||||||
|
if (floating && listWidth && document.documentElement?.clientWidth) {
|
||||||
|
bounds = { top: 0, left: 0, right: document.documentElement.clientWidth, bottom: document.documentElement.clientHeight };
|
||||||
|
}
|
||||||
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
|
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
|
||||||
const above = Math.max(0, anchor.top - bounds.top - 8);
|
const above = Math.max(0, anchor.top - bounds.top - 8);
|
||||||
const opensAbove = below < 180 && above > below;
|
const opensAbove = below < listMaxHeight && above > below;
|
||||||
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
||||||
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
|
list.style.maxHeight = `${Math.min(listMaxHeight, opensAbove ? above : below)}px`;
|
||||||
if (floating) {
|
if (floating) {
|
||||||
|
const width = listWidth ? Math.min(Math.max(anchor.width, listWidth), bounds.right - bounds.left - 16) : anchor.width;
|
||||||
list.style.position = "fixed";
|
list.style.position = "fixed";
|
||||||
list.style.left = `${anchor.left}px`;
|
list.style.left = `${listWidth ? Math.max(bounds.left + 8, Math.min(anchor.left, bounds.right - width - 8)) : anchor.left}px`;
|
||||||
list.style.width = `${anchor.width}px`;
|
list.style.width = `${width}px`;
|
||||||
list.style.right = "auto";
|
list.style.right = "auto";
|
||||||
list.style.bottom = "auto";
|
list.style.bottom = "auto";
|
||||||
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
|
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
|
||||||
@@ -115,7 +119,7 @@ function createSearchableSelect({ environment }) {
|
|||||||
if (input.disabled) return;
|
if (input.disabled) return;
|
||||||
hideTooltip();
|
hideTooltip();
|
||||||
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||||
index = -1;
|
index = matches.findIndex(item => item.value === select.value);
|
||||||
list.textContent = "";
|
list.textContent = "";
|
||||||
matches.forEach((item, i) => {
|
matches.forEach((item, i) => {
|
||||||
const row = element("div", "opportunities-extension-action-option", item.textContent);
|
const row = element("div", "opportunities-extension-action-option", item.textContent);
|
||||||
@@ -140,7 +144,7 @@ function createSearchableSelect({ environment }) {
|
|||||||
positionList();
|
positionList();
|
||||||
list.hidden = false;
|
list.hidden = false;
|
||||||
input.setAttribute("aria-expanded", "true");
|
input.setAttribute("aria-expanded", "true");
|
||||||
input.setAttribute("aria-activedescendant", "");
|
highlight(false);
|
||||||
}
|
}
|
||||||
input.addEventListener("click", () => { render(); input.select?.(); });
|
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||||
input.addEventListener("input", () => render(input.value));
|
input.addEventListener("input", () => render(input.value));
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ function createFormRequestCache({ getHours, getUserId }) {
|
|||||||
let generation = 0;
|
let generation = 0;
|
||||||
let owner = "";
|
let owner = "";
|
||||||
const queries = new Set([
|
const queries = new Set([
|
||||||
"requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
|
"requestWeekDetails", "requestTimeEntries", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
|
||||||
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
|
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
|
||||||
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
|
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
|
||||||
]);
|
]);
|
||||||
const writes = new Set(["requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
|
const writes = new Set(["requestTimeEntriesDelete", "requestTimeEntriesSave", "requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
|
||||||
function invalidate() { generation++; entries.clear(); pending.clear(); }
|
function invalidate() { generation++; entries.clear(); pending.clear(); }
|
||||||
function maxAge() {
|
function maxAge() {
|
||||||
const hours = Number(getHours());
|
const hours = Number(getHours());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
function createTimeEntries({ fetchJson, dates, getResourceUser, saveHeaders }) {
|
||||||
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
const endpoint = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries/weekDetails";
|
||||||
async function requestWeekDetails(message) {
|
async function requestWeekDetails(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
@@ -15,6 +15,61 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function requestTimeEntries(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^\d+$/.test(String(message.weekId)) || Number(message.weekId) <= 0) throw new Error("Missing or invalid WeekId.");
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&sortBy=createdDate&sortOrder=ASC&weekId=${encodeURIComponent(message.weekId)}`;
|
||||||
|
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestTimeEntries", debugRequests);
|
||||||
|
if (!Array.isArray(items)) throw new Error("Invalid time entries response.");
|
||||||
|
return { ok: true, items, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesDelete(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!/^[1-9]\d*$/.test(String(message.id))) throw new Error("Missing or invalid time entry ID.");
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "") + `/${message.id}`;
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, { method: "DELETE", credentials: "include", headers },
|
||||||
|
"requestTimeEntriesDelete", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
|
}
|
||||||
|
async function requestTimeEntriesSave(message) {
|
||||||
|
const debugRequests = [];
|
||||||
|
try {
|
||||||
|
if (!["POST", "PUT"].includes(message.method)) throw new Error("Invalid time entry save method.");
|
||||||
|
if (!Array.isArray(message.items) || !message.items.length) throw new Error("No time entries to save.");
|
||||||
|
const hours = ["saturdayTaskHours", "sundayTaskHours", "mondayTaskHours", "tuesdayTaskHours", "wednesdayTaskHours", "thursdayTaskHours", "fridayTaskHours"];
|
||||||
|
for (const item of message.items) {
|
||||||
|
if (!item || !["SERVICE", "NON_SERVICE"].includes(item.tag) || !item.taskType
|
||||||
|
|| (item.tag === "SERVICE" && (!/^SR\d+$/.test(item.srNumber) || !item.activityCode))
|
||||||
|
|| !/^\d+$/.test(String(item.weekId)) || Number(item.weekId) <= 0
|
||||||
|
|| hours.some(key => typeof item[key] !== "number" || !Number.isFinite(item[key]) || item[key] < 0 || item[key] > 24)
|
||||||
|
|| !hours.some(key => item[key] > 0)
|
||||||
|
|| (message.method === "PUT" && (!/^\d+$/.test(String(item.id)) || Number(item.id) <= 0))) throw new Error("Invalid or incomplete time entry.");
|
||||||
|
}
|
||||||
|
const resource = await getResourceUser(message);
|
||||||
|
debugRequests.push(...resource.debugRequests);
|
||||||
|
const items = message.items.map(item => ({
|
||||||
|
...(message.method === "PUT" ? { id: item.id } : {}),
|
||||||
|
srNumber: item.tag === "NON_SERVICE" ? "non-service-sr" : item.srNumber,
|
||||||
|
activityCode: item.activityCode || "", taskType: item.taskType, deliveryType: item.deliveryType || "",
|
||||||
|
...Object.fromEntries(hours.map(key => [key, item[key]])), notes: String(item.notes || ""),
|
||||||
|
weekId: item.weekId, resourceId: String(resource.resource.resourcePartyId), tag: item.tag
|
||||||
|
}));
|
||||||
|
const url = endpoint.replace(/\/weekDetails$/, "");
|
||||||
|
const headers = await saveHeaders(url);
|
||||||
|
const result = await fetchJson(url, {
|
||||||
|
method: message.method, credentials: "include",
|
||||||
|
headers, body: JSON.stringify(items)
|
||||||
|
}, "requestTimeEntriesSave", debugRequests, { allowEmpty: true });
|
||||||
|
return { ok: true, result, debugRequests };
|
||||||
|
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||||
|
}
|
||||||
async function requestActiveServiceRequests(message) {
|
async function requestActiveServiceRequests(message) {
|
||||||
const debugRequests = [];
|
const debugRequests = [];
|
||||||
try {
|
try {
|
||||||
@@ -59,5 +114,5 @@ function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
|||||||
return { ok: true, items: result.items, debugRequests };
|
return { ok: true, items: result.items, debugRequests };
|
||||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||||
}
|
}
|
||||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
return { requestWeekDetails, requestTimeEntries, requestTimeEntriesSave, requestTimeEntriesDelete, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
function createMessageTypes() {
|
function createMessageTypes() {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||||
|
requestTimeEntries: "opportunitiesExtension.requestTimeEntries",
|
||||||
|
requestTimeEntriesSave: "opportunitiesExtension.requestTimeEntriesSave",
|
||||||
|
requestTimeEntriesDelete: "opportunitiesExtension.requestTimeEntriesDelete",
|
||||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||||
|
|||||||
1
tests/fixtures/extension.js
vendored
1
tests/fixtures/extension.js
vendored
@@ -30,6 +30,7 @@
|
|||||||
onMessage: { addListener: listener => listeners.push(listener) },
|
onMessage: { addListener: listener => listeners.push(listener) },
|
||||||
async sendMessage(message) {
|
async sendMessage(message) {
|
||||||
const operation = message.type.split(".").at(-1);
|
const operation = message.type.split(".").at(-1);
|
||||||
|
if (operation === "requestTimeEntries") return { ok: true, items: [], debugRequests: [] };
|
||||||
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
|
if (operation === "requestLookupTaskType") return { ok: true, items: [{ lookUpCode_c: "SCTA_SERVICE_CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_SERVICE_STANDARD", lookUpMeaning_c: "Standard", tags_c: "SERVICE" }, { lookUpCode_c: "SCTA_NON_SERVICE_TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }], debugRequests: [] };
|
||||||
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
|
if (operation === "requestServiceRequestExpand") return { ok: true, items: [...message.srNumbers, "non-service-sr"].map(SrNumber => ({ SrNumber, SRActivities_c: "Account Plan;Customer Research, Assessment and Recommendation;Success Plan" })), debugRequests: [] };
|
||||||
if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };
|
if (operation === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ test("failed results are never cached and identity changes discard previous resp
|
|||||||
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
|
h.user(""); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 5);
|
||||||
});
|
});
|
||||||
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
|
test("writes and token requests always execute; successful saves and Refresh all invalidate queries", async () => {
|
||||||
for (const name of ["requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
|
for (const name of ["requestTimeEntriesSave", "requestTimeEntriesDelete", "requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
|
||||||
const h = setup(); let queries = 0, writes = 0;
|
const h = setup(); let queries = 0, writes = 0;
|
||||||
const query = message("requestActionFindLookup");
|
const query = message("requestActionFindLookup");
|
||||||
const queryTransport = async () => { queries++; return { ok: true }; };
|
const queryTransport = async () => { queries++; return { ok: true }; };
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test("week errors return status and debug logs without masking failure", async (
|
|||||||
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
|
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
|
||||||
assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1);
|
assert.equal(result.ok, false); assert.equal(result.httpStatus, 401); assert.equal(result.debugRequests.length, 1);
|
||||||
});
|
});
|
||||||
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => []) {
|
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [], requestEntries = async () => [], localStorage, requestSave, requestDelete) {
|
||||||
let document;
|
let document;
|
||||||
class Element {
|
class Element {
|
||||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
||||||
@@ -53,16 +53,314 @@ function setup(requestServices = async () => [], requestExpand = async () => [],
|
|||||||
}
|
}
|
||||||
const host = new Element("host");
|
const host = new Element("host");
|
||||||
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
|
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
|
||||||
const calls = [];
|
const calls = [], notifications = [];
|
||||||
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
|
const searchableSelect = factory("src/features/opportunities/searchable-select.js", "createSearchableSelect")({ environment: { document } });
|
||||||
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes,
|
const app = factory("src/features/manage-time/view.js", "createManageTime")({ MODAL_ID: "host", environment: { document, localStorage }, searchableSelect, dates, requestServices, requestExpand, requestTaskTypes, requestEntries, requestSave, requestDelete,
|
||||||
|
notify: (message, isError = false) => notifications.push({ message, isError }),
|
||||||
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
|
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
|
||||||
app.setReady(true);
|
app.setReady(true);
|
||||||
const walk = el => [el, ...el.children.flatMap(walk)];
|
const walk = el => [el, ...el.children.flatMap(walk)];
|
||||||
const nodes = () => walk(host);
|
const nodes = () => walk(host);
|
||||||
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
|
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
|
||||||
return { app, calls, nodes, find, host };
|
return { app, calls, nodes, find, host, notifications };
|
||||||
}
|
}
|
||||||
|
test("persisted deletions wait for Save, stay scoped to their week and allow a deletion-only save", async () => {
|
||||||
|
const deleted = [];
|
||||||
|
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" }], undefined, undefined, async id => deleted.push(id));
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
h.find("Delete row 1").click(); assert.deepEqual(deleted, []); assert.equal(h.find("Save").disabled, false);
|
||||||
|
h.find("Next week").click();
|
||||||
|
const otherWeek = week().map(day => ({ ...day, Date: dates.toApi(dates.shift(dates.fromApi(day.Date).toISOString().slice(0, 10), 7)), EndDate: "23-OCT-2026", WeekId: 420 }));
|
||||||
|
h.calls[1].resolve(otherWeek); await flush(); assert.equal(h.find("Save").disabled, true);
|
||||||
|
h.find("Previous week").click(); h.calls[2].resolve(week()); await flush();
|
||||||
|
assert.equal(h.find("Service Request, row 1"), undefined);
|
||||||
|
h.find("Save").click(); await flush();
|
||||||
|
assert.deepEqual(deleted, ["7"]); assert.equal(h.find("Save").disabled, true);
|
||||||
|
assert.deepEqual(h.notifications, [{ message: "1 time entry deleted.", isError: false }]);
|
||||||
|
});
|
||||||
|
test("deleting new rows is local; failed deletions retain only unfinished IDs", async () => {
|
||||||
|
const deleted = []; let fail = true;
|
||||||
|
const h = setup(undefined, undefined, undefined, async () => [7, 8].map(id => ({ id, srNumber: "SR001", activityCode: "Design", taskType: "PREP", mondayTaskHours: 2, tag: "SERVICE" })), undefined, undefined, async id => {
|
||||||
|
deleted.push(id); if (id === "8" && fail) throw new Error("HTTP 403");
|
||||||
|
});
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
h.find("Add").click(); h.find("Delete row 3").click();
|
||||||
|
assert.equal(h.find("Save").disabled, true); assert.deepEqual(deleted, []);
|
||||||
|
h.find("Delete row 1").click(); h.find("Delete row 1").click();
|
||||||
|
h.find("Save").click(); h.find("Save").click(); await flush();
|
||||||
|
assert.deepEqual(deleted, ["7", "8"]); assert.equal(h.find("Save").disabled, false);
|
||||||
|
assert.deepEqual(h.notifications, [{ message: "1 deleted. HTTP 403", isError: true }]);
|
||||||
|
fail = false; h.find("Save").click(); await flush();
|
||||||
|
assert.deepEqual(deleted, ["7", "8", "8"]); assert.equal(h.find("Save").disabled, true);
|
||||||
|
});
|
||||||
|
for (const family of ["chrome", "firefox"]) test(`${family}: DELETE uses the saved ID, identical write headers and a narrow Origin rule`, async () => {
|
||||||
|
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
|
||||||
|
const calls = [];
|
||||||
|
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options }); return new Response(null, { status: 204 });
|
||||||
|
} });
|
||||||
|
app.responseHeaderListeners[0].listener({ url: `${origin}/ic/builder/rt/oalset_timeentrymobile/live/test`, incognito: false,
|
||||||
|
responseHeaders: [{ name: "x-appbuilder-client-id", value: "delete-client" }] });
|
||||||
|
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: 9339403 });
|
||||||
|
assert.equal(result.ok, true); assert.equal(calls.length, 1);
|
||||||
|
assert.match(calls[0].url, /\/mgmt\/timeEntries\/9339403$/);
|
||||||
|
assert.equal(calls[0].options.method, "DELETE"); assert.equal(calls[0].options.body, undefined);
|
||||||
|
assert.deepEqual({ ...calls[0].options.headers }, { Accept: "application/json", "Content-Type": "application/json", Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "delete-client" });
|
||||||
|
const rule = app.headerRules[0].addRules[0];
|
||||||
|
assert.equal(rule.id, 7205); assert.deepEqual(rule.condition.requestMethods, ["delete"]);
|
||||||
|
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url), true);
|
||||||
|
assert.equal(new RegExp(rule.condition.regexFilter).test(calls[0].url + "0"), false);
|
||||||
|
const invalid = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesDelete", id: "../other" });
|
||||||
|
assert.equal(invalid.ok, false); assert.equal(calls.length, 1);
|
||||||
|
});
|
||||||
|
test("Save validates service rows, posts once, then puts changes with the returned id", async () => {
|
||||||
|
const calls = []; let finish;
|
||||||
|
const h = setup(undefined, undefined, undefined, undefined, undefined, (method, items) => {
|
||||||
|
calls.push({ method, items }); return new Promise(resolve => { finish = () => resolve(items.map(item => ({ ...item, id: 123 }))); });
|
||||||
|
});
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
assert.equal(h.find("Save").disabled, true);
|
||||||
|
for (const [label, value] of [["Service Request", "SR001"], ["Activity", "Solution Design"], ["Task Type", "PREP"]]) {
|
||||||
|
const input = h.find(`${label}, row 1`); input.value = value; input.listeners.change();
|
||||||
|
assert.equal(h.find("Save").disabled, true);
|
||||||
|
}
|
||||||
|
const hours = h.find("Hours 12-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
|
||||||
|
assert.equal(h.find("Save").disabled, false);
|
||||||
|
h.find("Save").click(); h.find("Save").click();
|
||||||
|
assert.equal(calls.length, 1); assert.equal(calls[0].method, "POST");
|
||||||
|
assert.equal(calls[0].items[0].mondayTaskHours, 2); assert.equal(calls[0].items[0].saturdayTaskHours, 0);
|
||||||
|
assert.equal(calls[0].items[0].id, undefined); assert.equal(calls[0].items[0].weekId, 419);
|
||||||
|
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
|
||||||
|
assert.deepEqual(h.notifications, [{ message: "1 time entry saved.", isError: false }]);
|
||||||
|
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
|
||||||
|
notes.value = "Updated"; notes.listeners.input(); h.find("Save").click();
|
||||||
|
assert.equal(calls[1].method, "PUT"); assert.equal(calls[1].items[0].id, 123);
|
||||||
|
finish(); await flush(); assert.equal(h.find("Save").disabled, true);
|
||||||
|
assert.equal(h.notifications.length, 2); assert.equal(h.notifications[1].isError, false);
|
||||||
|
});
|
||||||
|
test("mixed saves retain successful PUTs when POST fails and never repost a saved row without an id", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = setup(undefined, undefined, undefined, async () => [{ id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", mondayTaskHours: 2 }], undefined,
|
||||||
|
async (method, items) => { calls.push({ method, items }); if (method === "POST" && calls.length === 2) throw new Error("Post failed"); return null; });
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
|
||||||
|
notes.value = "Changed"; notes.listeners.input();
|
||||||
|
h.find("Copy row 1").click();
|
||||||
|
const copiedSr = h.find("Service Request, row 2"); copiedSr.value = "SR002"; copiedSr.listeners.change();
|
||||||
|
const copiedActivity = h.find("Activity, row 2"); copiedActivity.value = "Design"; copiedActivity.listeners.change();
|
||||||
|
h.find("Save").click(); await flush();
|
||||||
|
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST"]);
|
||||||
|
assert.equal(h.find("Save").disabled, false);
|
||||||
|
h.find("Save").click(); await flush();
|
||||||
|
assert.deepEqual(calls.map(call => call.method), ["PUT", "POST", "POST"]);
|
||||||
|
const copyNotes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 2");
|
||||||
|
copyNotes.value = "Further edit"; copyNotes.listeners.input();
|
||||||
|
assert.equal(h.find("Save").disabled, true);
|
||||||
|
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("IDs could not be confirmed")));
|
||||||
|
});
|
||||||
|
test("Non Service saves without activity and failed saves keep edited values", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = setup(undefined, undefined, undefined, undefined, undefined, async (method, items) => { calls.push({ method, items }); throw new Error("HTTP 403"); });
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
|
||||||
|
const type = h.find("Task Type, row 4"); type.value = "TRAIN"; type.listeners.change();
|
||||||
|
const hours = h.find("Hours 10-OCT-2026, row 4"); hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input();
|
||||||
|
assert.equal(h.find("Save").disabled, false);
|
||||||
|
hours.value = "25"; hours.valueAsNumber = 25; hours.listeners.input();
|
||||||
|
assert.equal(hours.value, "3"); assert.equal(h.find("Save").disabled, false);
|
||||||
|
assert.deepEqual(h.notifications.at(-1), { message: "Enter hours between 0 and 24.", isError: true });
|
||||||
|
hours.value = "3"; hours.valueAsNumber = 3; hours.listeners.input(); h.find("Save").click(); await flush();
|
||||||
|
assert.equal(calls[0].items[0].tag, "NON_SERVICE"); assert.equal(calls[0].items[0].srNumber, "non-service-sr");
|
||||||
|
assert.equal(calls[0].items[0].activityCode, "");
|
||||||
|
assert.equal(h.find("Hours 10-OCT-2026, row 4").value, "3");
|
||||||
|
assert.equal(h.find("Save").disabled, false);
|
||||||
|
assert.ok(h.nodes().some(node => node.attributes.role === "alert" && node.textContent.includes("403")));
|
||||||
|
});
|
||||||
|
for (const method of ["POST", "PUT"]) test(`time entry ${method} uses Session and the current resource with a JSON array`, async () => {
|
||||||
|
let posted;
|
||||||
|
const app = loadBackground({ fetch: async (url, options) => {
|
||||||
|
if (url.includes("resourceUsers?")) return new Response(JSON.stringify({ resourcePartyId: 123 }), { headers: { "x-appbuilder-client-id": "time-save-client" } });
|
||||||
|
assert.match(url, /\/mgmt\/timeEntries$/); assert.equal(options.method, method);
|
||||||
|
assert.equal(options.headers.Authorization, "Session"); assert.equal(options.headers["Content-Type"], "application/json");
|
||||||
|
assert.equal(options.headers.Origin, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
|
||||||
|
assert.equal(options.headers["x-appbuilder-client-id"], "time-save-client");
|
||||||
|
posted = JSON.parse(options.body); return new Response(null, { status: 204 });
|
||||||
|
} });
|
||||||
|
const item = { id: 7, srNumber: "SR001", activityCode: "Design", taskType: "PREP", weekId: 419, tag: "SERVICE", mondayTaskHours: 2,
|
||||||
|
tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0, saturdayTaskHours: 0, sundayTaskHours: 0 };
|
||||||
|
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method, items: [item], emailAddress: "test@oracle.com" });
|
||||||
|
assert.equal(result.ok, true); assert.equal(posted[0].resourceId, "123"); assert.equal(posted[0].id, method === "PUT" ? 7 : undefined);
|
||||||
|
const rule = app.headerRules.find(update => update.addRules.some(rule => rule.id === 7204)).addRules[0];
|
||||||
|
assert.deepEqual(rule.condition.requestMethods, ["post", "put"]);
|
||||||
|
assert.equal(rule.condition.initiatorDomains.length, 1);
|
||||||
|
assert.equal(rule.action.requestHeaders[0].value, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com");
|
||||||
|
assert.equal(new RegExp(rule.condition.regexFilter).test("https://example.com/mgmt/timeEntries"), false);
|
||||||
|
});
|
||||||
|
test("time entry headers reuse IDs captured from Time Entry responses and session storage", async () => {
|
||||||
|
const stored = {};
|
||||||
|
const runtimeApi = { runtime: { getURL: () => "chrome-extension://test/" },
|
||||||
|
storage: { session: { set: async values => Object.assign(stored, values), get: async () => stored } },
|
||||||
|
declarativeNetRequest: { updateSessionRules: async () => {} } };
|
||||||
|
const createHeaders = new vm.Script(fs.readFileSync("src/auth/sr-request-headers.js", "utf8") + "\ncreateSrRequestHeaders;").runInNewContext({ URL });
|
||||||
|
const headers = createHeaders({ runtimeApi });
|
||||||
|
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/timeEntries";
|
||||||
|
headers.observeResponseHeaders(`${url}/weekDetails?dateValue=16-OCT-2026`, [{ name: "x-appbuilder-client-id", value: "time-entry-client" }]);
|
||||||
|
assert.equal((await headers.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
|
||||||
|
const restarted = createHeaders({ runtimeApi });
|
||||||
|
assert.equal((await restarted.timeEntriesHeaders(url))["x-appbuilder-client-id"], "time-entry-client");
|
||||||
|
});
|
||||||
|
test("hour inputs accept inclusive 0..24 and reject out-of-range or nonnumeric values", async () => {
|
||||||
|
const h = setup(); h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
const input = h.find("Hours 10-OCT-2026, row 1");
|
||||||
|
for (const value of [0, 24]) {
|
||||||
|
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
|
||||||
|
assert.equal(input.value, String(value));
|
||||||
|
}
|
||||||
|
for (const value of [-1, 25, 100, Infinity, NaN]) {
|
||||||
|
input.value = String(value); input.valueAsNumber = value; input.listeners.input();
|
||||||
|
assert.equal(input.value, "24"); assert.equal(h.notifications.at(-1).isError, true);
|
||||||
|
}
|
||||||
|
input.value = ""; input.valueAsNumber = NaN; input.listeners.input();
|
||||||
|
assert.equal(input.value, "");
|
||||||
|
});
|
||||||
|
test("time entry writes reject out-of-range hours before making a network request", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const app = loadBackground({ fetch: async () => { calls++; throw new Error("Unexpected fetch"); } });
|
||||||
|
for (const value of [-1, 25]) {
|
||||||
|
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntriesSave", method: "POST", items: [{
|
||||||
|
srNumber: "SR001", activityCode: "Design", taskType: "PREP", tag: "SERVICE", weekId: 419,
|
||||||
|
saturdayTaskHours: value, sundayTaskHours: 0, mondayTaskHours: 1, tuesdayTaskHours: 0, wednesdayTaskHours: 0, thursdayTaskHours: 0, fridayTaskHours: 0
|
||||||
|
}] });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
}
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
test("duplicate SR selection restores the previous selection and highlights the existing row", async () => {
|
||||||
|
const h = setup(async () => [{ srNumber: "SR001", srTitle: "One" }, { srNumber: "SR002", srTitle: "Two" }]);
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
function select(row, value) { const input = h.find(`Service Request, row ${row}`); input.value = value; input.listeners.change(); }
|
||||||
|
select(1, "SR001"); select(2, "SR002"); select(2, "SR001");
|
||||||
|
assert.equal(h.find("Service Request, row 2").value, "SR002");
|
||||||
|
const highlighted = h.nodes().filter(el => el.className === "oe-time-duplicate-row");
|
||||||
|
assert.equal(highlighted.length, 1);
|
||||||
|
assert.equal(h.find("Service Request, row 1").parent.parent, highlighted[0]);
|
||||||
|
assert.equal(h.notifications.at(-1).isError, true);
|
||||||
|
highlighted[0].listeners.animationend();
|
||||||
|
assert.equal(highlighted[0].className, "");
|
||||||
|
h.find("Delete row 1").click(); select(1, "SR001");
|
||||||
|
assert.equal(h.find("Service Request, row 1").value, "SR001");
|
||||||
|
const css = fs.readFileSync("src/features/manage-time/styles.css", "utf8");
|
||||||
|
assert.match(css, /oe-time-duplicate-fade 5\.5s/);
|
||||||
|
assert.match(css, /90\.909%/);
|
||||||
|
});
|
||||||
|
test("reference date persists manual and weekly changes across modal and page reopening", () => {
|
||||||
|
const saved = new Map();
|
||||||
|
const storage = { getItem: key => saved.get(key), setItem: (key, value) => saved.set(key, value) };
|
||||||
|
const h = setup(undefined, undefined, undefined, undefined, storage);
|
||||||
|
h.app.open();
|
||||||
|
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
|
||||||
|
h.app.close(); h.app.open();
|
||||||
|
assert.equal(h.find("Reference date").value, "2026-10-09");
|
||||||
|
h.find("Next week").click();
|
||||||
|
const next = setup(undefined, undefined, undefined, undefined, storage); next.app.open();
|
||||||
|
assert.equal(next.calls[0].date, "2026-10-16");
|
||||||
|
const invalid = next.find("Reference date"); invalid.value = ""; invalid.listeners.change();
|
||||||
|
next.app.close(); next.app.open();
|
||||||
|
assert.equal(next.find("Reference date").value, "2026-10-16");
|
||||||
|
});
|
||||||
|
test("invalid stored date falls back to today and unavailable storage retains the in-memory date", () => {
|
||||||
|
const h = setup(undefined, undefined, undefined, undefined, { getItem: () => "invalid", setItem() { throw new Error("Blocked"); } });
|
||||||
|
h.app.open(); assert.equal(h.calls[0].date, dates.today());
|
||||||
|
const date = h.find("Reference date"); date.value = "2026-10-09"; date.listeners.change();
|
||||||
|
h.app.close(); h.app.open();
|
||||||
|
assert.equal(h.find("Reference date").value, "2026-10-09");
|
||||||
|
});
|
||||||
|
test("loading renders three skeleton rows until entries resolve, then restores empty input rows", async () => {
|
||||||
|
let resolveEntries;
|
||||||
|
const h = setup(undefined, undefined, undefined, () => new Promise(resolve => { resolveEntries = resolve; }));
|
||||||
|
h.app.open();
|
||||||
|
const skeletons = () => h.nodes().filter(el => el.className === "oe-time-skeleton-row");
|
||||||
|
assert.equal(skeletons().length, 3);
|
||||||
|
assert.ok(skeletons().every(row => row.children.length === 13));
|
||||||
|
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "true");
|
||||||
|
assert.equal(h.find("Add").disabled, true);
|
||||||
|
h.calls[0].resolve(week()); await flush();
|
||||||
|
assert.equal(skeletons().length, 3);
|
||||||
|
resolveEntries([]); await flush();
|
||||||
|
assert.equal(skeletons().length, 0);
|
||||||
|
assert.equal(h.find("Weekly time entries").attributes["aria-hidden"], "false");
|
||||||
|
assert.ok(h.find("Service Request, row 3"));
|
||||||
|
assert.equal(h.find("Add").disabled, false);
|
||||||
|
});
|
||||||
|
test("time entry errors allow retry and stale weeks cannot replace current entries", async () => {
|
||||||
|
const pending = [];
|
||||||
|
const h = setup(undefined, undefined, undefined, weekId => new Promise((resolve, reject) => pending.push({ weekId, resolve, reject })));
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
pending[0].reject(new Error("Time entries unavailable")); await flush();
|
||||||
|
assert.equal(h.find("Retry").hidden, false);
|
||||||
|
assert.equal(h.find("Add").disabled, true);
|
||||||
|
h.find("Retry").click(); h.calls[1].resolve(week()); await flush();
|
||||||
|
h.find("Next week").click(); h.calls[2].resolve(week().map(day => ({ ...day, WeekId: 420 }))); await flush();
|
||||||
|
pending[2].resolve([{ srNumber: "SR002", activityCode: "Current", tag: "SERVICE" }]); await flush();
|
||||||
|
pending[1].resolve([{ srNumber: "SR001", activityCode: "Stale", tag: "SERVICE" }]); await flush();
|
||||||
|
assert.equal(h.find("Service Request, row 1").value, "SR002");
|
||||||
|
assert.equal(h.find("Activity, row 1").value, "Current");
|
||||||
|
});
|
||||||
|
test("saved entries hydrate selections, weekday hours and notes, preserving drafts", async () => {
|
||||||
|
const calls = [];
|
||||||
|
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }], async weekId => {
|
||||||
|
calls.push(weekId);
|
||||||
|
return [{ id: 9339403, srNumber: "SR0001502709", taskType: "PREP", activityCode: "Solution Design", tag: "SERVICE", mondayTaskHours: 2, saturdayTaskHours: 3, sundayTaskHours: 0, notes: "Saved notes" },
|
||||||
|
{ id: 2, srNumber: "non-service-sr", taskType: "TRAIN", activityCode: "Training", tag: "NON_SERVICE", fridayTaskHours: 4 }];
|
||||||
|
});
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
assert.deepEqual(calls, [419]);
|
||||||
|
assert.equal(h.find("Service Request, row 1").value, "SR0001502709");
|
||||||
|
assert.equal(h.find("Activity, row 1").value, "Solution Design");
|
||||||
|
assert.equal(h.find("Task Type, row 1").value, "PREP");
|
||||||
|
assert.equal(h.find("Hours 12-OCT-2026, row 1").value, "2");
|
||||||
|
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "3");
|
||||||
|
assert.equal(h.find("Hours 11-OCT-2026, row 1").value, "0");
|
||||||
|
assert.equal(h.find("Hours 16-OCT-2026, row 2").value, "4");
|
||||||
|
assert.ok(h.find("Non Service, row 2"));
|
||||||
|
const notes = h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1");
|
||||||
|
assert.equal(notes.value, "Saved notes");
|
||||||
|
notes.value = "Edited notes"; notes.listeners.input();
|
||||||
|
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(h.nodes().find(el => el.tag === "textarea" && el.attributes["aria-label"] === "Notes, row 1").value, "Edited notes");
|
||||||
|
});
|
||||||
|
for (const family of ["chrome", "firefox"]) test(`${family}: time entries use the resource party, selected week, Session and ascending created date`, async () => {
|
||||||
|
const calls = [];
|
||||||
|
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||||
|
calls.push({ url, options });
|
||||||
|
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123 } : [{ id: 7, srNumber: "SR001" }]));
|
||||||
|
} });
|
||||||
|
const result = await app.dispatch({ type: "opportunitiesExtension.requestTimeEntries", weekId: 418, emailAddress: "test@oracle.com" });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const call = calls.find(call => call.url.includes("timeEntries?"));
|
||||||
|
assert.match(call.url, /timeEntries\?resourceId=123&sortBy=createdDate&sortOrder=ASC&weekId=418$/);
|
||||||
|
assert.equal(call.options.headers.Authorization, "Session");
|
||||||
|
assert.equal(call.options.credentials, "include");
|
||||||
|
assert.equal(result.items[0].id, 7);
|
||||||
|
});
|
||||||
|
test("autocomplete reopening highlights its saved option and filtering does not select another value", async () => {
|
||||||
|
const h = setup(undefined, undefined, async () => [
|
||||||
|
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
|
||||||
|
{ lookUpCode_c: "PREP", lookUpMeaning_c: "Preparation", tags_c: "SERVICE" }
|
||||||
|
], async () => [{ srNumber: "SR001", taskType: "PREP", tag: "SERVICE" }]);
|
||||||
|
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||||
|
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Task Type, row 1");
|
||||||
|
input.click();
|
||||||
|
const list = input.parent.children[1];
|
||||||
|
assert.equal(list.children[0].attributes["aria-selected"], "false");
|
||||||
|
assert.equal(list.children[1].attributes["aria-selected"], "true");
|
||||||
|
assert.equal(input.attributes["aria-activedescendant"], list.children[1].id);
|
||||||
|
input.value = "Config"; input.listeners.input();
|
||||||
|
assert.equal(list.children[0].attributes["aria-selected"], "false");
|
||||||
|
assert.equal(h.find("Task Type, row 1").value, "PREP");
|
||||||
|
input.listeners.blur(); input.click();
|
||||||
|
assert.equal(list.children[1].attributes["aria-selected"], "true");
|
||||||
|
});
|
||||||
test("Service Request tooltips use expanded fields in order and keep description separate", async () => {
|
test("Service Request tooltips use expanded fields in order and keep description separate", async () => {
|
||||||
const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"];
|
const keys = ["SrNumber", "AccountPartyUniqueName", "OptyName_c", "OptyNumber_c", "OptyOwner_c", "OptyStatus_c", "ServiceNew_c", "ReportedByPartyUniqueName", "CountryText_c", "StatusCdMeaning", "AssigneePersonName", "ProblemDescription"];
|
||||||
const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
|
const record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
|
||||||
@@ -108,7 +406,8 @@ test("Manage Time autocompletes search and select SR, dependent Activity and fil
|
|||||||
choose("Task Type, row 1", "config");
|
choose("Task Type, row 1", "config");
|
||||||
assert.equal(h.find("Task Type, row 1").value, "CONFIG");
|
assert.equal(h.find("Task Type, row 1").value, "CONFIG");
|
||||||
h.find("Copy row 1").click();
|
h.find("Copy row 1").click();
|
||||||
assert.equal(combo("Activity, row 4").value, "Technical Support");
|
assert.equal(combo("Activity, row 4").value, "");
|
||||||
|
assert.equal(combo("Service Request, row 4").value, "");
|
||||||
h.find("Add (Non service)").click();
|
h.find("Add (Non service)").click();
|
||||||
choose("Task Type, row 5", "train");
|
choose("Task Type, row 5", "train");
|
||||||
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
|
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
|
||||||
@@ -181,7 +480,7 @@ test("modal loads today, navigates seven days, ignores stale results and preserv
|
|||||||
assert.equal(h.find("Save").disabled, true);
|
assert.equal(h.find("Save").disabled, true);
|
||||||
const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
|
const hours = h.find("Hours 10-OCT-2026, row 1"); hours.value = "2"; hours.valueAsNumber = 2; hours.listeners.input();
|
||||||
assert.equal(hours.step, "1");
|
assert.equal(hours.step, "1");
|
||||||
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2 hrs"));
|
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2h"));
|
||||||
h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28);
|
h.find("Copy row 1").click(); assert.equal(h.nodes().filter(el => el.type === "number").length, 28);
|
||||||
h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23");
|
h.find("Next week").click(); assert.equal(h.calls[2].date, "2026-10-23");
|
||||||
h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
|
h.find("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
|
||||||
@@ -246,7 +545,7 @@ test("active SRs load once per modal and notes preserve text, character count an
|
|||||||
assert.ok(h.host.textContent.includes("1995 characters left"));
|
assert.ok(h.host.textContent.includes("1995 characters left"));
|
||||||
h.find("Collapse notes, row 1").click();
|
h.find("Collapse notes, row 1").click();
|
||||||
assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false");
|
assert.equal(h.find("Notes, row 1").attributes["aria-expanded"], "false");
|
||||||
h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "SR1");
|
h.find("Copy row 1").click(); assert.equal(h.find("Service Request, row 4").value, "");
|
||||||
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
|
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
|
||||||
assert.deepEqual(calls, [419]);
|
assert.deepEqual(calls, [419]);
|
||||||
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();
|
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();
|
||||||
|
|||||||
Reference in New Issue
Block a user