Partially implemented the Manage Time functionality
This commit is contained in:
191
dist/chromium/background.js
vendored
191
dist/chromium/background.js
vendored
@@ -1,9 +1,112 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// Source: src/shared/time-dates.js
|
||||
function createTimeDates() {
|
||||
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
|
||||
function parse(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
|
||||
}
|
||||
function fromApi(value) {
|
||||
const match = /^(\d{2})-([A-Z]{3})-(\d{4})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const month = months.indexOf(match[2]) + 1;
|
||||
return parse(`${match[3]}-${String(month).padStart(2, "0")}-${match[1]}`);
|
||||
}
|
||||
function toApi(value) {
|
||||
const date = parse(value);
|
||||
if (!date) throw new Error("Select a valid reference date.");
|
||||
return `${String(date.getUTCDate()).padStart(2, "0")}-${months[date.getUTCMonth()]}-${date.getUTCFullYear()}`;
|
||||
}
|
||||
function shift(value, days) {
|
||||
const date = parse(value);
|
||||
if (!date) return value;
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
function today() {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
return { parse, fromApi, toApi, shift, today };
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/time-entries.js
|
||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
||||
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) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const date = dates.toApi(message.dateReference);
|
||||
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
|
||||
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
|
||||
}, "requestWeekDetails", debugRequests);
|
||||
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|
||||
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestActiveServiceRequests(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$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
|
||||
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
|
||||
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||
}
|
||||
async function requestServiceRequestExpand(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
|
||||
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
|
||||
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
|
||||
const batches = [];
|
||||
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
|
||||
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
|
||||
batches[batches.length - 1].push("non-service-sr");
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
|
||||
const items = [];
|
||||
for (const batch of batches) {
|
||||
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
|
||||
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
|
||||
items.push(...result.items);
|
||||
}
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestLookupTaskType() {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
|
||||
return { ok: true, items: result.items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||
}
|
||||
|
||||
// Source: src/shared/messages.js
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||
requestActiveServiceRequests: "opportunitiesExtension.requestActiveServiceRequests",
|
||||
getXsrfToken: "opportunitiesExtension.getXsrfToken",
|
||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||
@@ -1056,7 +1159,10 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/sr-create.js
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
|
||||
const resourceRequests = new Map();
|
||||
let resourceGeneration = 0;
|
||||
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
|
||||
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
|
||||
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
|
||||
@@ -1082,12 +1188,45 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
|
||||
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
|
||||
}
|
||||
async function requestResourceUser(email, debugRequests) {
|
||||
const emailAddress = text(email, "current user email");
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
async function requestResourceUser(message, debugRequests) {
|
||||
try {
|
||||
const result = await getResourceUser(message);
|
||||
debugRequests.push(...result.debugRequests);
|
||||
return numericId(result.resource.resourcePartyId, "resourcePartyId");
|
||||
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
|
||||
}
|
||||
async function getResourceUser(message) {
|
||||
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
|
||||
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
|
||||
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
|
||||
const current = resourceRequests.get(emailAddress);
|
||||
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
|
||||
const generation = resourceGeneration;
|
||||
const record = { promise: null, fetchedAt: null };
|
||||
const debugRequests = [];
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
|
||||
const cached = stored?.[resourceCacheKey];
|
||||
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
|
||||
record.fetchedAt = cached.fetchedAt;
|
||||
return cached.resource;
|
||||
}
|
||||
} catch { /* Session storage is optional; retain the in-memory cache. */ }
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
record.fetchedAt = Date.now();
|
||||
if (generation === resourceGeneration) {
|
||||
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
|
||||
}
|
||||
return resource;
|
||||
})();
|
||||
record.promise = pending;
|
||||
resourceRequests.set(emailAddress, record);
|
||||
try { return { ok: true, resource: await pending, debugRequests }; }
|
||||
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestPostInternalServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
@@ -1108,7 +1247,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
|
||||
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
|
||||
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const result = await fetchJson(url, { method: "POST", credentials: "include",
|
||||
headers: await internalServiceRequestHeaders(url),
|
||||
@@ -1138,7 +1277,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
|
||||
const payload = payloadFor(message.payload);
|
||||
if (message.phase === "prepare") {
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const sharedHeaders = await previewTokenHeaders(catalog);
|
||||
return { ok: true, payload, debugRequests, headers: {
|
||||
requestSrTokenCatalog: sharedHeaders,
|
||||
@@ -1194,7 +1333,12 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
}
|
||||
}
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||
async function invalidateResourceUser() {
|
||||
resourceGeneration++;
|
||||
resourceRequests.clear();
|
||||
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/current-user.js
|
||||
@@ -2280,7 +2424,13 @@ function startBackgroundApplication(dependencies) {
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
requestWeekDetails,
|
||||
requestLookupTaskType,
|
||||
requestServiceRequestExpand,
|
||||
requestResourceUser,
|
||||
requestActiveServiceRequests,
|
||||
invalidateProductsCache,
|
||||
invalidateResourceUser = async () => {},
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -2313,6 +2463,17 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
if (message.type === messageTypes.requestLookupTaskType) {
|
||||
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||
const request = 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,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
@@ -2343,7 +2504,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
@@ -2514,6 +2675,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||
const contracts = createBackgroundContracts();
|
||||
@@ -2634,6 +2796,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||
const fusionSrCreate = createFusionSrCreate({
|
||||
runtimeApi,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
|
||||
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
|
||||
@@ -2651,6 +2814,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
|
||||
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
|
||||
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
|
||||
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
|
||||
|
||||
545
dist/chromium/content.js
vendored
545
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
191
dist/firefox/background.js
vendored
191
dist/firefox/background.js
vendored
@@ -1,9 +1,112 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
// Source: src/shared/time-dates.js
|
||||
function createTimeDates() {
|
||||
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
|
||||
function parse(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
|
||||
}
|
||||
function fromApi(value) {
|
||||
const match = /^(\d{2})-([A-Z]{3})-(\d{4})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const month = months.indexOf(match[2]) + 1;
|
||||
return parse(`${match[3]}-${String(month).padStart(2, "0")}-${match[1]}`);
|
||||
}
|
||||
function toApi(value) {
|
||||
const date = parse(value);
|
||||
if (!date) throw new Error("Select a valid reference date.");
|
||||
return `${String(date.getUTCDate()).padStart(2, "0")}-${months[date.getUTCMonth()]}-${date.getUTCFullYear()}`;
|
||||
}
|
||||
function shift(value, days) {
|
||||
const date = parse(value);
|
||||
if (!date) return value;
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
function today() {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
return { parse, fromApi, toApi, shift, today };
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/time-entries.js
|
||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
||||
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) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const date = dates.toApi(message.dateReference);
|
||||
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
|
||||
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
|
||||
}, "requestWeekDetails", debugRequests);
|
||||
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|
||||
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestActiveServiceRequests(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$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
|
||||
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
|
||||
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||
}
|
||||
async function requestServiceRequestExpand(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
|
||||
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
|
||||
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
|
||||
const batches = [];
|
||||
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
|
||||
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
|
||||
batches[batches.length - 1].push("non-service-sr");
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
|
||||
const items = [];
|
||||
for (const batch of batches) {
|
||||
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
|
||||
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
|
||||
items.push(...result.items);
|
||||
}
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestLookupTaskType() {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
|
||||
return { ok: true, items: result.items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||
}
|
||||
|
||||
// Source: src/shared/messages.js
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||
requestActiveServiceRequests: "opportunitiesExtension.requestActiveServiceRequests",
|
||||
getXsrfToken: "opportunitiesExtension.getXsrfToken",
|
||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||
@@ -1056,7 +1159,10 @@ function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFire
|
||||
}
|
||||
|
||||
// Source: src/services/fusion/sr-create.js
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
|
||||
const resourceRequests = new Map();
|
||||
let resourceGeneration = 0;
|
||||
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
|
||||
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
|
||||
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
|
||||
@@ -1082,12 +1188,45 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
|
||||
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
|
||||
}
|
||||
async function requestResourceUser(email, debugRequests) {
|
||||
const emailAddress = text(email, "current user email");
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
async function requestResourceUser(message, debugRequests) {
|
||||
try {
|
||||
const result = await getResourceUser(message);
|
||||
debugRequests.push(...result.debugRequests);
|
||||
return numericId(result.resource.resourcePartyId, "resourcePartyId");
|
||||
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
|
||||
}
|
||||
async function getResourceUser(message) {
|
||||
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
|
||||
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
|
||||
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
|
||||
const current = resourceRequests.get(emailAddress);
|
||||
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
|
||||
const generation = resourceGeneration;
|
||||
const record = { promise: null, fetchedAt: null };
|
||||
const debugRequests = [];
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
|
||||
const cached = stored?.[resourceCacheKey];
|
||||
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
|
||||
record.fetchedAt = cached.fetchedAt;
|
||||
return cached.resource;
|
||||
}
|
||||
} catch { /* Session storage is optional; retain the in-memory cache. */ }
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
record.fetchedAt = Date.now();
|
||||
if (generation === resourceGeneration) {
|
||||
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
|
||||
}
|
||||
return resource;
|
||||
})();
|
||||
record.promise = pending;
|
||||
resourceRequests.set(emailAddress, record);
|
||||
try { return { ok: true, resource: await pending, debugRequests }; }
|
||||
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestPostInternalServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
@@ -1108,7 +1247,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
|
||||
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
|
||||
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const result = await fetchJson(url, { method: "POST", credentials: "include",
|
||||
headers: await internalServiceRequestHeaders(url),
|
||||
@@ -1138,7 +1277,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
|
||||
const payload = payloadFor(message.payload);
|
||||
if (message.phase === "prepare") {
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const sharedHeaders = await previewTokenHeaders(catalog);
|
||||
return { ok: true, payload, debugRequests, headers: {
|
||||
requestSrTokenCatalog: sharedHeaders,
|
||||
@@ -1194,7 +1333,12 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
}
|
||||
}
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||
async function invalidateResourceUser() {
|
||||
resourceGeneration++;
|
||||
resourceRequests.clear();
|
||||
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
|
||||
}
|
||||
|
||||
// Source: src/services/spa/current-user.js
|
||||
@@ -2280,7 +2424,13 @@ function startBackgroundApplication(dependencies) {
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
requestWeekDetails,
|
||||
requestLookupTaskType,
|
||||
requestServiceRequestExpand,
|
||||
requestResourceUser,
|
||||
requestActiveServiceRequests,
|
||||
invalidateProductsCache,
|
||||
invalidateResourceUser = async () => {},
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -2313,6 +2463,17 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
if (message.type === messageTypes.requestLookupTaskType) {
|
||||
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||
const request = 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,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
@@ -2343,7 +2504,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
@@ -2514,6 +2675,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||
const contracts = createBackgroundContracts();
|
||||
@@ -2634,6 +2796,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||
const fusionSrCreate = createFusionSrCreate({
|
||||
runtimeApi,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
|
||||
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
|
||||
@@ -2651,6 +2814,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
|
||||
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
|
||||
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
|
||||
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
|
||||
|
||||
545
dist/firefox/content.js
vendored
545
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ function composeScript(target, { bootstrap = true } = {}) {
|
||||
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
|
||||
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.concat("src/features/manage-time/styles.css")
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
module.exports = {
|
||||
"content": [
|
||||
"src/shared/time-dates.js",
|
||||
"src/features/manage-time/view.js",
|
||||
"src/shared/messages.js",
|
||||
"src/shared/debug-body.js",
|
||||
"src/content/contracts.js",
|
||||
@@ -9,6 +11,7 @@ module.exports = {
|
||||
"src/features/opportunities/table-state.js",
|
||||
"src/features/opportunities/filter-state.js",
|
||||
"src/features/preferences/state.js",
|
||||
"src/features/preferences/request-cache.js",
|
||||
"src/features/products/content-state.js",
|
||||
"src/features/ai-summary/state.js",
|
||||
"src/features/consumption/plans-state.js",
|
||||
@@ -81,6 +84,8 @@ module.exports = {
|
||||
"src/content/application.js"
|
||||
],
|
||||
"background": [
|
||||
"src/shared/time-dates.js",
|
||||
"src/services/fusion/time-entries.js",
|
||||
"src/shared/messages.js",
|
||||
"src/shared/debug-body.js",
|
||||
"src/background/contracts.js",
|
||||
|
||||
@@ -7,6 +7,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
...overrides
|
||||
};
|
||||
const messageTypes = createMessageTypes();
|
||||
const timeEntries = createTimeEntries({ dates: createTimeDates(), fetchJson: (...args) => httpBackgroundJson.fetchJson(...args), getResourceUser: message => fusionSrCreate.getResourceUser(message) });
|
||||
const spaCurrentUser = createSpaCurrentUser({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
|
||||
const requestCurrentUser = () => spaCurrentUser.requestCurrentUser();
|
||||
const contracts = createBackgroundContracts();
|
||||
@@ -127,6 +128,7 @@ function createBackgroundApplication(overrides = {}) {
|
||||
const runtimeApi = environment.browser || environment.chrome;
|
||||
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
|
||||
const fusionSrCreate = createFusionSrCreate({
|
||||
runtimeApi,
|
||||
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
|
||||
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
|
||||
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
|
||||
@@ -144,6 +146,12 @@ function createBackgroundApplication(overrides = {}) {
|
||||
function start() {
|
||||
srRequestHeaders.start();
|
||||
startBackgroundApplication({
|
||||
invalidateResourceUser: () => fusionSrCreate.invalidateResourceUser(),
|
||||
requestWeekDetails: message => timeEntries.requestWeekDetails(message),
|
||||
requestLookupTaskType: () => timeEntries.requestLookupTaskType(),
|
||||
requestServiceRequestExpand: message => timeEntries.requestServiceRequestExpand(message),
|
||||
requestResourceUser: message => fusionSrCreate.getResourceUser(message),
|
||||
requestActiveServiceRequests: message => timeEntries.requestActiveServiceRequests(message),
|
||||
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
|
||||
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
|
||||
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
|
||||
function registerBackgroundMessages({
|
||||
messageTypes,
|
||||
requestWeekDetails,
|
||||
requestLookupTaskType,
|
||||
requestServiceRequestExpand,
|
||||
requestResourceUser,
|
||||
requestActiveServiceRequests,
|
||||
invalidateProductsCache,
|
||||
invalidateResourceUser = async () => {},
|
||||
getXsrfTokenCookie,
|
||||
refreshXsrfCookie,
|
||||
requestAccountPlans,
|
||||
@@ -34,6 +40,17 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (!message || !message.type) {
|
||||
return false;
|
||||
}
|
||||
if (message.type === messageTypes.requestLookupTaskType) {
|
||||
requestLookupTaskType().then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if ([messageTypes.requestServiceRequestExpand, messageTypes.requestWeekDetails, messageTypes.requestResourceUser, messageTypes.requestActiveServiceRequests].includes(message.type)) {
|
||||
const request = 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,
|
||||
error: error.message, httpStatus: error.httpStatus, debugRequests: error.debugRequests || [] }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.requestPostInternalServiceRequest) {
|
||||
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
|
||||
error: error.message, debugRequests: error.debugRequests || [] }));
|
||||
@@ -64,7 +81,7 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === messageTypes.invalidateProductsCache) {
|
||||
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
invalidateProductsCache().then(() => invalidateResourceUser()).then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
|
||||
return true;
|
||||
}
|
||||
if (message.type === messageTypes.getXsrfToken) {
|
||||
|
||||
@@ -56,8 +56,9 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const contentModal = createContentModal({
|
||||
createManageTimeButton: () => manageTime.createButton(),
|
||||
prepareSrSession: () => srSessionPreparation.ensure(),
|
||||
closeActionForm: (...args) => internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
closeActionForm: (...args) => manageTime.close(...args) || internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
|
||||
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
|
||||
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
|
||||
closeCrmSrPopover: () => crmSrPopover.close(),
|
||||
@@ -135,6 +136,34 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const svgPaths = createSvgPaths({ environment });
|
||||
const manageTime = createManageTime({ MODAL_ID: contracts.MODAL_ID, environment, searchableSelect, dates: createTimeDates(),
|
||||
async requestTaskTypes() {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestLookupTaskType });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Task Types.");
|
||||
return result.items;
|
||||
},
|
||||
async requestExpand(srNumbers) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestServiceRequestExpand, srNumbers });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load activities.");
|
||||
return result.items;
|
||||
},
|
||||
async requestServices(weekId) {
|
||||
await srSessionPreparation.ensure();
|
||||
await currentUser.ensure();
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestActiveServiceRequests, weekId, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Requests.");
|
||||
return result.items;
|
||||
},
|
||||
async requestWeek(dateReference) {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestWeekDetails, dateReference });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load week details.");
|
||||
return result.items;
|
||||
}
|
||||
});
|
||||
const contentStyles = createContentStyles({
|
||||
STYLE_ID: contracts.STYLE_ID,
|
||||
redwoodStyles,
|
||||
@@ -173,6 +202,7 @@ function createContentApplication(overrides = {}) {
|
||||
environment
|
||||
});
|
||||
const platformContentMessages = createPlatformContentMessages({
|
||||
requestCache: createFormRequestCache({ getHours: () => preferencesState.opptyProductsCacheHours, getUserId: () => currentUser.getUserId() }),
|
||||
messageTypes,
|
||||
environment
|
||||
});
|
||||
@@ -434,10 +464,15 @@ function createContentApplication(overrides = {}) {
|
||||
});
|
||||
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
|
||||
const srSessionPreparation = createSrSessionPreparation({
|
||||
async onReady() {
|
||||
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestResourceUser, emailAddress: currentUser.getUserId() });
|
||||
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load resource user.");
|
||||
},
|
||||
ensureCurrentUser: () => currentUser.ensure(),
|
||||
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
|
||||
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
|
||||
reportProgress: state => activityProgress.update("session", state),
|
||||
reportProgress: state => { activityProgress.update("session", state); manageTime.setReady(state === "complete"); },
|
||||
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
|
||||
});
|
||||
const activityFilters = createActivityFilters({
|
||||
|
||||
@@ -34,6 +34,7 @@ function createContentModal({
|
||||
closePreferencesModal,
|
||||
createSettingsIcon,
|
||||
createRefreshAllButton,
|
||||
createManageTimeButton,
|
||||
createActivityFilterControls,
|
||||
mountOpportunityViews,
|
||||
debugState,
|
||||
@@ -176,7 +177,7 @@ function createContentModal({
|
||||
|
||||
const headerActions = document.createElement("div");
|
||||
headerActions.className = "opportunities-extension-header-actions";
|
||||
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
|
||||
headerActions.append(themeButton, createRefreshAllButton(() => select.value), createManageTimeButton(), preferencesButton, closeButton);
|
||||
|
||||
header.append(titleBlock, headerActions);
|
||||
|
||||
|
||||
52
src/features/manage-time/styles.css
Normal file
52
src/features/manage-time/styles.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.oe-time-dialog { --time-bg: #fff; --time-input-bg: #fff; --time-muted: #faf9f8; --time-border: #dedbd7; --time-input-border: #8f8983; --time-accent: #00758f; width: min(1600px, 100%); max-height: calc(100dvh - 24px); display: flex; flex-direction: column; overflow: hidden; font-size: 13px; line-height: 20px; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .oe-time-dialog { --time-bg: #292725; --time-input-bg: #211f1d; --time-muted: #35322f; --time-border: #514c47; --time-input-border: #918a82; --time-accent: #80d6e8; }
|
||||
.oe-time-modal { grid-template-columns: minmax(0, 1fr); }
|
||||
.oe-time-dialog, .oe-time-body { min-width: 0; }
|
||||
.oe-time-dialog * { box-sizing: border-box; }
|
||||
.oe-time-dialog [hidden] { display: none !important; }
|
||||
.oe-time-header { flex-shrink: 0; }
|
||||
.oe-time-body { padding: 18px; overflow: auto; min-height: 0; }
|
||||
.oe-time-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.oe-time-navigation { display: flex; align-items: center; gap: 8px; }
|
||||
.oe-time-ending { display: grid; gap: 4px; text-align: right; }
|
||||
.oe-time-summary { display: grid; grid-template-columns: repeat(4, 1fr); padding: 12px 0; gap: 12px; text-align: center; border-block: 1px solid var(--time-border); margin-bottom: 18px; }
|
||||
.oe-time-body button { min-width: 38px; min-height: 38px; display: inline-flex; gap: 8px; align-items: center; justify-content: center; color: inherit; background: transparent; border: 1px solid var(--time-input-border); border-radius: 4px; padding: 7px; cursor: pointer; font: inherit; }
|
||||
.oe-time-dialog button:hover:not(:disabled) { background: var(--time-muted); border-color: var(--time-accent); }
|
||||
.oe-time-dialog svg { width: 17px; height: 17px; flex-shrink: 0; }
|
||||
.oe-time-dialog input, .oe-time-dialog select, .oe-time-dialog textarea { font: inherit; line-height: 20px; color: inherit; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 8px; min-height: 38px; }
|
||||
.oe-time-dialog select { height: 38px; }
|
||||
.oe-time-dialog :focus-visible { outline: 2px solid var(--time-accent); outline-offset: 2px; }
|
||||
.oe-time-dialog :disabled { opacity: .55; cursor: not-allowed; }
|
||||
.oe-time-grid { overflow-x: auto; }
|
||||
.oe-time-grid table { width: 100%; min-width: 1060px; border-collapse: collapse; table-layout: fixed; }
|
||||
.oe-time-grid th { background: var(--time-muted); padding: 10px 5px; font-weight: 600; text-align: center; border-bottom: 1px solid var(--time-border); }
|
||||
.oe-time-grid th:first-child { width: 23%; }
|
||||
.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 td { padding: 10px 5px; border-bottom: 1px solid var(--time-border); vertical-align: middle; text-align: center; }
|
||||
.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 .opportunities-extension-action-combobox input { width: 100%; text-align: left; text-overflow: ellipsis; }
|
||||
.oe-time-grid .opportunities-extension-action-options { text-align: left; }
|
||||
.oe-time-dialog .opportunities-extension-select-tooltip { position: fixed; z-index: 100; width: min(560px, calc(100vw - 40px)); max-height: min(440px, calc(100dvh - 48px)); overflow: auto; padding: 14px; border: 1px solid var(--time-input-border); border-radius: 4px; background: var(--time-input-bg); color: inherit; box-shadow: 0 4px 16px #0003; text-align: left; }
|
||||
.oe-time-sr-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; margin: 0; }
|
||||
.oe-time-sr-details > div { min-width: 0; overflow-wrap: anywhere; }
|
||||
.oe-time-sr-details dt { font-size: 11px; opacity: .75; margin-bottom: 2px; }
|
||||
.oe-time-sr-details dd { margin: 0; white-space: pre-wrap; }
|
||||
.oe-time-sr-details .oe-time-sr-description { grid-column: 1 / -1; }
|
||||
.oe-time-day > span { display: block; }
|
||||
.oe-time-day .oe-time-day-label { display: inline-flex; align-items: center; gap: 4px; margin-bottom: 9px; }
|
||||
.oe-time-day-total { font-size: 10px; background: var(--time-accent); color: var(--time-bg); min-width: 16px; border-radius: 8px; padding: 2px 4px; }
|
||||
.oe-time-notes textarea { width: 100%; resize: vertical; }
|
||||
.oe-time-notes-box { position: relative; text-align: left; background: var(--time-input-bg); border: 1px solid var(--time-input-border); border-radius: 4px; padding: 14px 12px 42px; }
|
||||
.oe-time-notes-count { display: block; font-weight: 600; opacity: .8; margin-bottom: 8px; }
|
||||
.oe-time-notes-box textarea { border: 0; min-height: 128px; padding: 0; }
|
||||
.oe-time-notes-box .oe-time-notes-collapse { position: absolute; right: 0; bottom: 0; background: var(--time-muted); }
|
||||
.oe-time-footer { flex-wrap: wrap; justify-content: flex-start; margin-top: 18px; }
|
||||
.oe-time-footer button { padding: 7px 14px; }
|
||||
.oe-time-status { padding: 12px 0; }
|
||||
.oe-time-status[role="alert"] { color: #a12d27; }
|
||||
.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-dialog [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
|
||||
@media (max-width: 700px) { .oe-time-modal { padding: 8px; } .oe-time-dialog { max-height: calc(100dvh - 16px); } .oe-time-body { padding: 12px; } .oe-time-summary { grid-template-columns: repeat(2, 1fr); text-align: left; } .oe-time-toolbar { flex-wrap: wrap; } .oe-time-ending { text-align: left; } }
|
||||
325
src/features/manage-time/view.js
Normal file
325
src/features/manage-time/view.js
Normal file
@@ -0,0 +1,325 @@
|
||||
function createManageTime({ MODAL_ID, environment, searchableSelect, dates, requestWeek, requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => [] }) {
|
||||
const { document } = environment;
|
||||
let active = null;
|
||||
let ready = false;
|
||||
let launcher = null;
|
||||
function setReady(value) {
|
||||
ready = !!value;
|
||||
if (launcher) { launcher.disabled = !ready; launcher.title = ready ? "Manage Time" : "Waiting for SR session ready"; }
|
||||
}
|
||||
function node(tag, className = "", text) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function icon(name) {
|
||||
const paths = {
|
||||
clock: "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM12 6v6l4 2",
|
||||
left: "m15 18-6-6 6-6", right: "m9 18 6-6-6-6", close: "m6 6 12 12M18 6 6 18",
|
||||
up: "m6 15 6-6 6 6",
|
||||
add: "M12 5v14M5 12h14", copy: "M9 9h12v12H9ZM5 15H3V3h12v2",
|
||||
notes: "M4 3h16v18H4ZM8 7h8M8 11h8M8 15h5",
|
||||
trash: "M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7",
|
||||
save: "M5 3h12l4 4v14H3V3ZM7 3v6h10V3M7 21v-8h10v8"
|
||||
};
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.setAttribute("d", paths[name]);
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-width", "1.8");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
function button(label, symbol, onClick, text = false) {
|
||||
const el = node("button");
|
||||
el.type = "button"; el.title = label;
|
||||
el.setAttribute("aria-label", label);
|
||||
el.append(icon(symbol));
|
||||
if (text) el.append(node("span", "", label));
|
||||
if (onClick) el.addEventListener("click", onClick);
|
||||
return el;
|
||||
}
|
||||
function createButton() {
|
||||
const trigger = button("Manage Time", "clock", () => open(trigger));
|
||||
trigger.className = "opportunities-extension-icon-button";
|
||||
launcher = trigger; setReady(ready);
|
||||
return trigger;
|
||||
}
|
||||
function close(restoreFocus = true) {
|
||||
if (!active) return false;
|
||||
const previous = active; active = null;
|
||||
previous.modal.remove();
|
||||
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
|
||||
return true;
|
||||
}
|
||||
function open(trigger) {
|
||||
if (!ready) return;
|
||||
const host = document.getElementById(MODAL_ID);
|
||||
if (!host || host.hidden) return;
|
||||
close(false);
|
||||
const modal = node("section", "opportunities-extension-action-form-modal oe-time-modal");
|
||||
modal.setAttribute("role", "dialog"); modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "oe-time-title");
|
||||
const dialog = node("div", "opportunities-extension-action-form-dialog oe-time-dialog");
|
||||
const header = node("header", "opportunities-extension-account-plans-header oe-time-header");
|
||||
const title = node("h2", "", "Manage Time"); title.id = "oe-time-title";
|
||||
const closeButton = button("Close Manage Time", "close", () => close());
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
header.append(title, closeButton);
|
||||
const body = node("div", "oe-time-body");
|
||||
const toolbar = node("div", "oe-time-toolbar");
|
||||
const navigation = node("div", "oe-time-navigation");
|
||||
const date = node("input"); date.type = "date"; date.value = dates.today(); date.required = true;
|
||||
date.setAttribute("aria-label", "Reference date");
|
||||
const ending = node("div", "oe-time-ending");
|
||||
const endingValue = node("strong", "", "--");
|
||||
ending.append(node("span", "", "Week Ending"), endingValue);
|
||||
const changeWeek = days => { if (dates.parse(date.value)) { date.value = dates.shift(date.value, days); load(); } };
|
||||
navigation.append(button("Previous week", "left", () => changeWeek(-7)), date, button("Next week", "right", () => changeWeek(7)));
|
||||
date.addEventListener("change", load); toolbar.append(navigation, ending);
|
||||
const summary = node("div", "oe-time-summary"), sums = {};
|
||||
for (const label of ["Service", "Non Service", "Time Off", "Total Hours"]) {
|
||||
const item = node("div"); sums[label] = node("strong", "", "0 hrs");
|
||||
item.append(node("span", "", `${label}: `), sums[label]); summary.append(item);
|
||||
}
|
||||
const status = node("div", "oe-time-status"); status.setAttribute("role", "status");
|
||||
const retry = button("Retry", "clock", load, true); retry.hidden = true;
|
||||
const serviceStatus = node("div", "oe-time-status"); serviceStatus.setAttribute("role", "status"); serviceStatus.hidden = true;
|
||||
const serviceRetry = button("Retry Service Requests", "clock", () => loadServices(entry.initialWeekId, true), true); serviceRetry.hidden = true;
|
||||
const taskStatus = node("div", "oe-time-status"); taskStatus.setAttribute("role", "status");
|
||||
const taskRetry = button("Retry Task Types", "clock", loadTaskTypes, true); taskRetry.hidden = true;
|
||||
const scroll = node("div", "oe-time-grid"), table = node("table");
|
||||
table.setAttribute("aria-label", "Weekly time entries"); scroll.append(table);
|
||||
const footer = node("footer", "opportunities-extension-action-form-footer oe-time-footer");
|
||||
const add = button("Add", "add", () => addRow("Service"), true);
|
||||
const addNonService = button("Add (Non service)", "add", () => addRow("Non Service"), true);
|
||||
const save = button("Save", "save", null, true); save.disabled = true;
|
||||
save.title = "Saving time entries is not available yet.";
|
||||
footer.append(add, addNonService, save);
|
||||
body.append(toolbar, summary, status, retry, serviceStatus, serviceRetry, taskStatus, taskRetry, scroll, footer);
|
||||
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() };
|
||||
active = entry;
|
||||
entry.taskTypes = [];
|
||||
entry.taskTypesReady = false;
|
||||
let combos = [];
|
||||
modal.addEventListener("scroll", event => {
|
||||
if (event.target === body || event.target === scroll) combos.forEach(combo => combo.sync());
|
||||
}, true);
|
||||
modal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
|
||||
if (event.key !== "Tab") return;
|
||||
const elements = [...dialog.querySelectorAll("button:not(:disabled), input:not(:disabled), textarea:not(:disabled)")].filter(el => !el.hidden && el.getClientRects().length);
|
||||
const first = elements[0], last = elements[elements.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.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 total(value) { return Number(Number(value).toFixed(2)).toString(); }
|
||||
function updateTotals() {
|
||||
const groups = { Service: 0, "Non Service": 0, "Time Off": 0, "Total Hours": 0 };
|
||||
entry.days.forEach((day, i) => {
|
||||
let sum = 0;
|
||||
entry.rows.forEach(row => {
|
||||
const hours = Number(row.hours[day.Date]) || 0;
|
||||
sum += hours; groups[row.type] += hours; groups["Total Hours"] += hours;
|
||||
});
|
||||
entry.dayTotals[i].textContent = total(sum);
|
||||
});
|
||||
Object.entries(groups).forEach(([label, value]) => { sums[label].textContent = `${total(value)} hrs`; });
|
||||
}
|
||||
function addRow(type, source) {
|
||||
if (!entry.tbody || add.disabled) return;
|
||||
entry.rows.push(source ? { ...source, hours: { ...source.hours } } : blankRow(type)); renderRows();
|
||||
}
|
||||
function renderRows() {
|
||||
const focusedId = document.activeElement?.id;
|
||||
combos = [];
|
||||
entry.tbody.replaceChildren();
|
||||
entry.rows.forEach((row, index) => {
|
||||
const tr = node("tr");
|
||||
for (const label of [row.type === "Service" ? "Service Request" : "Non Service", "Activity", "Task Type"]) {
|
||||
const td = node("td"), select = node("select");
|
||||
select.id = `oe-time-${index}-${label.toLowerCase().replaceAll(" ", "-")}`;
|
||||
let change = () => {};
|
||||
select.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
||||
const placeholder = node("option", "", label); placeholder.value = ""; select.append(placeholder);
|
||||
if (label === "Service Request") {
|
||||
for (const sr of entry.services) {
|
||||
const option = node("option", "", sr.srTitle ? `${sr.srTitle} (${sr.srNumber})` : sr.srNumber);
|
||||
option.value = sr.srNumber; option.title = option.textContent; select.append(option);
|
||||
}
|
||||
select.value = row.srNumber;
|
||||
select.disabled = !entry.servicesReady;
|
||||
select.title = entry.servicesReady ? "Service Request" : "Loading Service Requests";
|
||||
change = () => { row.srNumber = select.value; row.activity = ""; renderRows(); };
|
||||
} else if (label === "Activity") {
|
||||
const source = entry.expanded.get(row.type === "Service" ? row.srNumber : "non-service-sr");
|
||||
const raw = String(source?.SRActivities_c || "");
|
||||
const activities = [...new Set(raw.split(raw.includes(";") ? ";" : ",").map(value => value.trim()).filter(Boolean))];
|
||||
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.title = activities.length ? "Activity" : "No activities available for this Service Request";
|
||||
change = () => { row.activity = select.value; };
|
||||
} else if (label === "Task Type") {
|
||||
const tag = row.type === "Service" ? "SERVICE" : "NON_SERVICE";
|
||||
const taskTypes = entry.taskTypes.filter(item => item.tags_c === tag);
|
||||
for (const item of taskTypes) {
|
||||
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 = "";
|
||||
select.value = row.taskType;
|
||||
select.disabled = !entry.taskTypesReady || !taskTypes.length;
|
||||
select.title = !entry.taskTypesReady ? "Loading Task Types" : taskTypes.length ? "Task Type" : "No Task Types available for this row type";
|
||||
change = () => { row.taskType = select.value; };
|
||||
} else { select.disabled = true; select.title = "Options are not available yet."; }
|
||||
td.append(select); tr.append(td);
|
||||
select.addEventListener("change", change);
|
||||
const combo = searchableSelect(select, td, change, {
|
||||
floating: true,
|
||||
tooltipContent: label === "Service Request" ? serviceRequestTooltip : undefined
|
||||
});
|
||||
combo.input.setAttribute("aria-label", `${label}, row ${index + 1}`);
|
||||
combos.push(combo);
|
||||
}
|
||||
for (const day of entry.days) {
|
||||
const td = node("td"), input = node("input");
|
||||
input.type = "number"; input.min = "0"; input.max = "24"; input.step = "1";
|
||||
input.value = row.hours[day.Date] ?? "";
|
||||
input.setAttribute("aria-label", `Hours ${day.Date}, row ${index + 1}`);
|
||||
input.addEventListener("input", () => {
|
||||
const valid = 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();
|
||||
}); td.append(input); tr.append(td);
|
||||
}
|
||||
const notesCell = node("td"), notesRow = node("tr", "oe-time-notes"); notesRow.hidden = !row.notesOpen;
|
||||
const noteCell = node("td"); noteCell.colSpan = entry.days.length + 6;
|
||||
const notesBox = node("div", "oe-time-notes-box");
|
||||
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;
|
||||
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`; });
|
||||
const collapse = button(`Collapse notes, row ${index + 1}`, "up", () => {
|
||||
row.notesOpen = false; notesRow.hidden = true; notesButton.setAttribute("aria-expanded", "false"); notesButton.focus();
|
||||
});
|
||||
collapse.className = "oe-time-notes-collapse";
|
||||
notesBox.append(remaining, notes, collapse); noteCell.append(notesBox); notesRow.append(noteCell);
|
||||
const notesButton = button(`Notes, row ${index + 1}`, "notes", () => {
|
||||
row.notesOpen = !row.notesOpen; notesRow.hidden = !row.notesOpen; notesButton.setAttribute("aria-expanded", String(row.notesOpen));
|
||||
if (!notesRow.hidden) notes.focus();
|
||||
}); 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 remove = node("td"); remove.append(button(`Delete row ${index + 1}`, "trash", () => { entry.rows.splice(index, 1); renderRows(); })); tr.append(remove);
|
||||
entry.tbody.append(tr, notesRow);
|
||||
}); updateTotals();
|
||||
combos.find(combo => combo.input.id === focusedId)?.input.focus();
|
||||
}
|
||||
function serviceRequestTooltip(srNumber) {
|
||||
const sr = entry.expanded.get(srNumber);
|
||||
if (!sr) return null;
|
||||
const details = node("dl", "oe-time-sr-details");
|
||||
for (const [key, label] of [
|
||||
["SrNumber", "SR number"], ["AccountPartyUniqueName", "Account"],
|
||||
["OptyName_c", "Opportunity"], ["OptyNumber_c", "Opty number"],
|
||||
["OptyOwner_c", "Opty owner"], ["OptyStatus_c", "Opty status"],
|
||||
["ServiceNew_c", "Service"], ["ReportedByPartyUniqueName", "Requested by"],
|
||||
["CountryText_c", "Country"], ["StatusCdMeaning", "Status"],
|
||||
["AssigneePersonName", "Service lead"], ["ProblemDescription", "Description"]
|
||||
]) {
|
||||
const field = node("div", key === "ProblemDescription" ? "oe-time-sr-description" : "");
|
||||
field.append(node("dt", "", label), node("dd", "", String(sr[key] ?? "-")));
|
||||
details.append(field);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
async function loadTaskTypes() {
|
||||
taskStatus.hidden = false; taskStatus.textContent = "Loading Task Types...";
|
||||
taskStatus.setAttribute("role", "status"); taskRetry.hidden = true;
|
||||
try {
|
||||
const items = await requestTaskTypes();
|
||||
if (active !== entry) return;
|
||||
entry.taskTypes = items.filter(item => typeof item?.lookUpCode_c === "string" && item.lookUpCode_c.trim()
|
||||
&& typeof item.lookUpMeaning_c === "string" && item.lookUpMeaning_c.trim());
|
||||
entry.taskTypesReady = true;
|
||||
taskStatus.textContent = "No Task Types available."; taskStatus.hidden = entry.taskTypes.length > 0;
|
||||
if (entry.tbody) renderRows();
|
||||
} catch (error) {
|
||||
if (active !== entry) return;
|
||||
taskStatus.textContent = error.message || "Unable to load Task Types.";
|
||||
taskStatus.setAttribute("role", "alert"); taskRetry.hidden = false;
|
||||
}
|
||||
}
|
||||
async function loadServices(weekId, retrying = false) {
|
||||
if (entry.servicesStarted && !retrying) return;
|
||||
entry.servicesStarted = true; entry.initialWeekId = weekId;
|
||||
serviceRetry.hidden = true; serviceStatus.hidden = false;
|
||||
serviceStatus.setAttribute("role", "status"); serviceStatus.textContent = "Loading Service Requests...";
|
||||
try {
|
||||
const services = entry.servicesReady ? entry.services : await requestServices(weekId);
|
||||
if (active !== entry) return;
|
||||
entry.services = services; entry.servicesReady = true;
|
||||
serviceStatus.textContent = services.length ? "" : "No active Service Requests.";
|
||||
serviceStatus.hidden = services.length > 0;
|
||||
if (entry.tbody) renderRows();
|
||||
serviceStatus.hidden = false; serviceStatus.textContent = "Loading activities...";
|
||||
const expanded = await requestExpand(services.map(sr => sr.srNumber));
|
||||
if (active !== entry) return;
|
||||
entry.expanded = new Map(expanded.map(sr => [sr.SrNumber, sr]));
|
||||
serviceStatus.hidden = true;
|
||||
if (entry.tbody) renderRows();
|
||||
} catch (error) {
|
||||
if (active !== entry) return;
|
||||
serviceStatus.textContent = error.message || "Unable to load Service Requests.";
|
||||
serviceStatus.setAttribute("role", "alert"); serviceRetry.hidden = false;
|
||||
}
|
||||
}
|
||||
function renderWeek(items) {
|
||||
entry.days = items;
|
||||
const key = items.map(day => day.Date).join("|");
|
||||
if (!entry.drafts.has(key)) entry.drafts.set(key, Array.from({ length: 3 }, () => blankRow("Service")));
|
||||
entry.rows = entry.drafts.get(key);
|
||||
const head = node("thead"), row = node("tr");
|
||||
for (const label of ["Service Request", "Activity", "Task Type"]) row.append(node("th", "", label));
|
||||
entry.dayTotals = [];
|
||||
for (const day of items) {
|
||||
const parsed = dates.fromApi(day.Date), th = node("th", "oe-time-day");
|
||||
const dayLabel = node("span", "oe-time-day-label", ["Su", "M", "T", "W", "Th", "F", "Sa"][parsed.getUTCDay()]);
|
||||
const count = node("span", "oe-time-day-total", "0"); entry.dayTotals.push(count); dayLabel.append(count);
|
||||
th.append(dayLabel, node("span", "", parsed.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }))); row.append(th);
|
||||
}
|
||||
for (const label of ["Notes", "Copy", ""]) row.append(node("th", "", label));
|
||||
[...row.children].forEach(th => th.setAttribute("scope", "col"));
|
||||
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();
|
||||
}
|
||||
async function load() {
|
||||
const version = ++entry.version; entry.tbody = null; table.replaceChildren(); endingValue.textContent = "--";
|
||||
Object.values(sums).forEach(value => { value.textContent = "--"; });
|
||||
add.disabled = addNonService.disabled = true; retry.hidden = true; status.hidden = false;
|
||||
status.setAttribute("role", "status"); scroll.setAttribute("aria-busy", "false");
|
||||
if (!dates.parse(date.value)) { status.textContent = "Select a valid reference date."; return; }
|
||||
status.textContent = "Loading week details..."; scroll.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const items = await requestWeek(date.value);
|
||||
if (active !== entry || entry.version !== version) return;
|
||||
renderWeek(items); add.disabled = addNonService.disabled = false; status.hidden = true;
|
||||
loadServices(items[0].WeekId);
|
||||
} catch (error) {
|
||||
if (active !== entry || entry.version !== version) return;
|
||||
status.textContent = error.message || "Unable to load week details.";
|
||||
status.setAttribute("role", "alert"); retry.hidden = false;
|
||||
} finally {
|
||||
if (active === entry && entry.version === version) scroll.setAttribute("aria-busy", "false");
|
||||
}
|
||||
}
|
||||
date.focus(); load(); loadTaskTypes();
|
||||
}
|
||||
return { createButton, open, close, setReady };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ function createSearchableSelect({ environment }) {
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function searchableSelect(select, field, onChange) {
|
||||
function searchableSelect(select, field, onChange, { floating = false, tooltipContent } = {}) {
|
||||
const box = element("div", "opportunities-extension-action-combobox");
|
||||
const input = element("input");
|
||||
input.type = "text";
|
||||
@@ -27,10 +27,47 @@ function createSearchableSelect({ environment }) {
|
||||
select.tabIndex = -1;
|
||||
box.append(input, list);
|
||||
field.append(box);
|
||||
const tooltip = tooltipContent ? element("div", "opportunities-extension-select-tooltip") : null;
|
||||
if (tooltip) {
|
||||
tooltip.id = `${select.id}-tooltip`;
|
||||
tooltip.setAttribute("role", "tooltip");
|
||||
tooltip.hidden = true;
|
||||
box.append(tooltip);
|
||||
}
|
||||
let tooltipAnchor = null;
|
||||
function hideTooltip() {
|
||||
if (!tooltip) return;
|
||||
tooltip.hidden = true;
|
||||
tooltipAnchor?.removeAttribute?.("aria-describedby");
|
||||
tooltipAnchor = null;
|
||||
}
|
||||
function showTooltip(value, anchor) {
|
||||
hideTooltip();
|
||||
if (!tooltip || !value) return;
|
||||
const content = tooltipContent(value);
|
||||
if (!content) return;
|
||||
tooltip.replaceChildren(content);
|
||||
tooltip.hidden = false;
|
||||
tooltipAnchor = anchor;
|
||||
anchor.setAttribute("aria-describedby", tooltip.id);
|
||||
const rect = anchor.getBoundingClientRect?.();
|
||||
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
|
||||
if (!rect || !bounds) return;
|
||||
const size = tooltip.getBoundingClientRect();
|
||||
const left = rect.right + 8 + size.width <= bounds.right ? rect.right + 8 : Math.max(bounds.left + 8, rect.left - size.width - 8);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${Math.max(bounds.top + 8, Math.min(rect.top, bounds.bottom - size.height - 8))}px`;
|
||||
}
|
||||
function leaveTooltip(event) {
|
||||
if (!tooltip?.contains?.(event.relatedTarget)) hideTooltip();
|
||||
}
|
||||
tooltip?.addEventListener("mouseleave", hideTooltip);
|
||||
list.addEventListener("scroll", hideTooltip);
|
||||
let matches = [], index = -1;
|
||||
const options = () => Array.from(select.children).filter(item => item.value);
|
||||
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
|
||||
function closeList() {
|
||||
hideTooltip();
|
||||
list.hidden = true;
|
||||
input.setAttribute("aria-expanded", "false");
|
||||
input.setAttribute("aria-activedescendant", "");
|
||||
@@ -40,12 +77,13 @@ function createSearchableSelect({ environment }) {
|
||||
input.disabled = !!select.disabled;
|
||||
input.placeholder = select.children[0]?.textContent || "Select";
|
||||
input.value = options().find(item => item.value === select.value)?.textContent || "";
|
||||
input.title = input.value;
|
||||
input.title = tooltipContent ? "" : input.value;
|
||||
}
|
||||
function highlight() {
|
||||
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
|
||||
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
|
||||
list.children[index]?.scrollIntoView?.({ block: "nearest" });
|
||||
if (matches[index]) showTooltip(matches[index].value, list.children[index]);
|
||||
}
|
||||
function choose(i) {
|
||||
if (!matches[i]) return;
|
||||
@@ -63,9 +101,19 @@ function createSearchableSelect({ environment }) {
|
||||
const opensAbove = below < 180 && above > below;
|
||||
list.setAttribute("data-placement", opensAbove ? "above" : "below");
|
||||
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
|
||||
if (floating) {
|
||||
list.style.position = "fixed";
|
||||
list.style.left = `${anchor.left}px`;
|
||||
list.style.width = `${anchor.width}px`;
|
||||
list.style.right = "auto";
|
||||
list.style.bottom = "auto";
|
||||
list.style.top = `${opensAbove ? anchor.top - 4 : anchor.bottom + 4}px`;
|
||||
list.style.transform = opensAbove ? "translateY(-100%)" : "none";
|
||||
}
|
||||
}
|
||||
function render(query = "") {
|
||||
if (input.disabled) return;
|
||||
hideTooltip();
|
||||
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
|
||||
index = -1;
|
||||
list.textContent = "";
|
||||
@@ -78,6 +126,10 @@ function createSearchableSelect({ environment }) {
|
||||
row.setAttribute("aria-selected", "false");
|
||||
row.addEventListener("mousedown", event => event.preventDefault());
|
||||
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
|
||||
if (tooltip) {
|
||||
row.addEventListener("mouseenter", () => showTooltip(item.value, row));
|
||||
row.addEventListener("mouseleave", leaveTooltip);
|
||||
}
|
||||
list.append(row);
|
||||
});
|
||||
if (!matches.length) {
|
||||
@@ -93,6 +145,10 @@ function createSearchableSelect({ environment }) {
|
||||
input.addEventListener("click", () => { render(); input.select?.(); });
|
||||
input.addEventListener("input", () => render(input.value));
|
||||
input.addEventListener("blur", sync);
|
||||
if (tooltip) {
|
||||
input.addEventListener("mouseenter", () => { if (list.hidden) showTooltip(select.value, input); });
|
||||
input.addEventListener("mouseleave", leaveTooltip);
|
||||
}
|
||||
input.addEventListener("keydown", event => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
@@ -102,7 +158,7 @@ function createSearchableSelect({ environment }) {
|
||||
} else if (event.key === "Enter" && !list.hidden) {
|
||||
event.preventDefault();
|
||||
choose(index < 0 && matches.length === 1 ? 0 : index);
|
||||
} else if (event.key === "Escape" && !list.hidden) {
|
||||
} else if (event.key === "Escape" && (!list.hidden || (tooltip && !tooltip.hidden))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
sync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) {
|
||||
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog, onReady = async () => {} }) {
|
||||
let pending = null;
|
||||
function ensure() {
|
||||
if (pending) return pending;
|
||||
@@ -11,6 +11,7 @@ function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, se
|
||||
appendLog(result?.debugRequests || []);
|
||||
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
|
||||
reportProgress("complete");
|
||||
await onReady();
|
||||
} catch {
|
||||
reportProgress("error");
|
||||
}
|
||||
|
||||
61
src/features/preferences/request-cache.js
Normal file
61
src/features/preferences/request-cache.js
Normal file
@@ -0,0 +1,61 @@
|
||||
function createFormRequestCache({ getHours, getUserId }) {
|
||||
const entries = new Map();
|
||||
const pending = new Map();
|
||||
let generation = 0;
|
||||
let owner = "";
|
||||
const queries = new Set([
|
||||
"requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType",
|
||||
"requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage",
|
||||
"requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"
|
||||
]);
|
||||
const writes = new Set(["requestMgmtAction", "requestPostInternalServiceRequest", "requestPostServiceRequest"]);
|
||||
function invalidate() { generation++; entries.clear(); pending.clear(); }
|
||||
function maxAge() {
|
||||
const hours = Number(getHours());
|
||||
return (Number.isFinite(hours) && hours > 0 ? Math.min(hours, 168) : 24) * 3600000;
|
||||
}
|
||||
function copy(value) { return JSON.parse(JSON.stringify(value)); }
|
||||
function stable(value) {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
||||
return value;
|
||||
}
|
||||
async function send(message, transport) {
|
||||
const userId = String(getUserId() || "").trim().toLowerCase();
|
||||
if (userId !== owner) { invalidate(); owner = userId; }
|
||||
const operation = String(message.type || "").split(".").pop();
|
||||
const duration = maxAge();
|
||||
const request = queries.has(operation) || writes.has(operation) || operation === "requestResourceUser"
|
||||
? { ...message, cacheMaxAgeMs: duration } : message;
|
||||
if (operation === "invalidateProductsCache") invalidate();
|
||||
// Only explicitly known read operations are reusable. Writes and token generation always execute.
|
||||
if (!queries.has(operation) || !userId) {
|
||||
const result = await transport(request);
|
||||
if (result?.ok && writes.has(operation) && !(operation === "requestPostServiceRequest" && message.phase === "prepare")) invalidate();
|
||||
return result;
|
||||
}
|
||||
const key = JSON.stringify(stable(message));
|
||||
const hit = entries.get(key);
|
||||
if (hit && Date.now() >= hit.fetchedAt && Date.now() - hit.fetchedAt < duration) {
|
||||
return { ...copy(hit.result), debugRequests: [], fromCache: true, cachedAt: hit.fetchedAt };
|
||||
}
|
||||
entries.delete(key);
|
||||
if (pending.has(key)) {
|
||||
const result = await pending.get(key);
|
||||
return { ...copy(result), debugRequests: [] };
|
||||
}
|
||||
const version = generation;
|
||||
const promise = transport(request).then(result => {
|
||||
if (result?.ok && version === generation) {
|
||||
// Keep this per-page cache bounded and avoid persisting credentials or customer data to disk.
|
||||
if (entries.size >= 500) entries.delete(entries.keys().next().value);
|
||||
entries.set(key, { result: copy({ ...result, debugRequests: [] }), fetchedAt: Date.now() });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
pending.set(key, promise);
|
||||
try { return await promise; }
|
||||
finally { if (pending.get(key) === promise) pending.delete(key); }
|
||||
}
|
||||
return { send, invalidate };
|
||||
}
|
||||
@@ -102,11 +102,11 @@ function createPreferencesView({
|
||||
cacheField.className = "opportunities-extension-preferences-cache-field";
|
||||
|
||||
const cacheLabel = document.createElement("span");
|
||||
cacheLabel.textContent = "Product cache duration";
|
||||
cacheLabel.textContent = "Cache duration";
|
||||
|
||||
const cacheSelect = document.createElement("select");
|
||||
cacheSelect.name = "opptyProductsCacheHours";
|
||||
cacheSelect.setAttribute("aria-label", "Product cache duration");
|
||||
cacheSelect.setAttribute("aria-label", "Cache duration");
|
||||
OPPTY_PRODUCTS_CACHE_OPTIONS.forEach((option) => {
|
||||
const optionElement = document.createElement("option");
|
||||
optionElement.value = String(option.value);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
function createPlatformContentMessages({
|
||||
messageTypes,
|
||||
requestCache,
|
||||
environment
|
||||
}) {
|
||||
const { browser, chrome } = environment;
|
||||
function sendRuntimeMessage(message) {
|
||||
return requestCache ? requestCache.send(message, sendUncached) : sendUncached(message);
|
||||
}
|
||||
function sendUncached(message) {
|
||||
const runtimeApi = typeof browser !== "undefined" ? browser : chrome;
|
||||
|
||||
if (!runtimeApi || !runtimeApi.runtime || !runtimeApi.runtime.sendMessage) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
|
||||
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, runtimeApi }) {
|
||||
const resourceRequests = new Map();
|
||||
let resourceGeneration = 0;
|
||||
const resourceCacheKey = "opportunitiesExtension.resourceUsers.v1";
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
|
||||
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
|
||||
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
|
||||
@@ -24,12 +27,45 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
|
||||
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
|
||||
}
|
||||
async function requestResourceUser(email, debugRequests) {
|
||||
const emailAddress = text(email, "current user email");
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
return numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
async function requestResourceUser(message, debugRequests) {
|
||||
try {
|
||||
const result = await getResourceUser(message);
|
||||
debugRequests.push(...result.debugRequests);
|
||||
return numericId(result.resource.resourcePartyId, "resourcePartyId");
|
||||
} catch (error) { debugRequests.push(...(error.debugRequests || [])); throw error; }
|
||||
}
|
||||
async function getResourceUser(message) {
|
||||
const emailAddress = text(message.emailAddress, "current user email").toLowerCase();
|
||||
const duration = Number(message.cacheMaxAgeMs) > 0 ? Math.min(Number(message.cacheMaxAgeMs), 168 * 3600000) : 24 * 3600000;
|
||||
const fresh = timestamp => Number.isFinite(timestamp) && Date.now() >= timestamp && Date.now() - timestamp < duration;
|
||||
const current = resourceRequests.get(emailAddress);
|
||||
if (current && (current.fetchedAt === null || fresh(current.fetchedAt))) return { ok: true, resource: await current.promise, debugRequests: [] };
|
||||
const generation = resourceGeneration;
|
||||
const record = { promise: null, fetchedAt: null };
|
||||
const debugRequests = [];
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const stored = await runtimeApi?.storage?.session?.get(resourceCacheKey);
|
||||
const cached = stored?.[resourceCacheKey];
|
||||
if (cached?.emailAddress === emailAddress && cached.resource?.resourcePartyId && fresh(cached.fetchedAt)) {
|
||||
record.fetchedAt = cached.fetchedAt;
|
||||
return cached.resource;
|
||||
}
|
||||
} catch { /* Session storage is optional; retain the in-memory cache. */ }
|
||||
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
|
||||
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
|
||||
}, "requestResourceUser", debugRequests);
|
||||
numericId(resource?.resourcePartyId, "resourcePartyId");
|
||||
record.fetchedAt = Date.now();
|
||||
if (generation === resourceGeneration) {
|
||||
try { await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: { emailAddress, resource, fetchedAt: record.fetchedAt } }); } catch { /* Keep the successful response in memory. */ }
|
||||
}
|
||||
return resource;
|
||||
})();
|
||||
record.promise = pending;
|
||||
resourceRequests.set(emailAddress, record);
|
||||
try { return { ok: true, resource: await pending, debugRequests }; }
|
||||
catch (error) { if (resourceRequests.get(emailAddress) === record) resourceRequests.delete(emailAddress); error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestPostInternalServiceRequest(message) {
|
||||
const debugRequests = [];
|
||||
@@ -50,7 +86,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
|
||||
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
|
||||
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const result = await fetchJson(url, { method: "POST", credentials: "include",
|
||||
headers: await internalServiceRequestHeaders(url),
|
||||
@@ -80,7 +116,7 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
|
||||
const payload = payloadFor(message.payload);
|
||||
if (message.phase === "prepare") {
|
||||
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
|
||||
payload.ReportedByPartyId = await requestResourceUser(message, debugRequests);
|
||||
const sharedHeaders = await previewTokenHeaders(catalog);
|
||||
return { ok: true, payload, debugRequests, headers: {
|
||||
requestSrTokenCatalog: sharedHeaders,
|
||||
@@ -136,5 +172,10 @@ function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, in
|
||||
}
|
||||
}
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest };
|
||||
async function invalidateResourceUser() {
|
||||
resourceGeneration++;
|
||||
resourceRequests.clear();
|
||||
await runtimeApi?.storage?.session?.set({ [resourceCacheKey]: null });
|
||||
}
|
||||
return { requestPostServiceRequest, requestPostInternalServiceRequest, getResourceUser, invalidateResourceUser };
|
||||
}
|
||||
|
||||
63
src/services/fusion/time-entries.js
Normal file
63
src/services/fusion/time-entries.js
Normal file
@@ -0,0 +1,63 @@
|
||||
function createTimeEntries({ fetchJson, dates, getResourceUser }) {
|
||||
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) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const date = dates.toApi(message.dateReference);
|
||||
const items = await fetchJson(`${endpoint}?dateValue=${encodeURIComponent(date)}`, {
|
||||
method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" }
|
||||
}, "requestWeekDetails", debugRequests);
|
||||
if (!Array.isArray(items) || items.length !== 7 || items.some(item => !dates.fromApi(item?.Date) || !dates.fromApi(item?.EndDate))
|
||||
|| new Set(items.map(item => item.Date)).size !== 7) throw new Error("Invalid week details response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) {
|
||||
error.debugRequests = debugRequests;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function requestActiveServiceRequests(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$/, "activeServiceRequests") + `?resourceId=${encodeURIComponent(resource.resource.resourcePartyId)}&weekId=${encodeURIComponent(message.weekId)}`;
|
||||
const items = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestActiveServiceRequests", debugRequests);
|
||||
if (!Array.isArray(items)) throw new Error("Invalid active Service Requests response.");
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = [...debugRequests, ...(error.debugRequests || [])]; throw error; }
|
||||
}
|
||||
async function requestServiceRequestExpand(message) {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
if (!Array.isArray(message.srNumbers)) throw new Error("Missing Service Request numbers.");
|
||||
const numbers = [...new Set(message.srNumbers.map(value => String(value || "").trim()).filter(Boolean))].filter(value => value !== "non-service-sr");
|
||||
if (numbers.some(value => !/^SR\d+$/.test(value))) throw new Error("Invalid Service Request number.");
|
||||
const batches = [];
|
||||
for (let index = 0; index < numbers.length; index += 50) batches.push(numbers.slice(index, index + 50));
|
||||
if (!batches.length || batches[batches.length - 1].length === 50) batches.push([]);
|
||||
batches[batches.length - 1].push("non-service-sr");
|
||||
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_timeentrymobile/live;profile=PROD/services/auth/1.1/proxy/crmRestApiSearchResourcesLatestCustomActions/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
|
||||
const fields = "SrNumber,SRActivities_c,ProblemDescription,AccountPartyUniqueName,ServiceNew_c,OptyOwner_c,OptyName_c,OptyNumber_c,ReportedByPartyUniqueName,StatusCdMeaning,CountryText_c,AssigneePersonName,OptyStatus_c";
|
||||
const items = [];
|
||||
for (const batch of batches) {
|
||||
const query = `SrNumber in (${batch.map(value => `'${value}'`).join(",")})`;
|
||||
const url = `${base}?expand=ServiceRequest_SRToInternalSR_Tgt&fields=${encodeURIComponent(fields)}&limit=200&onlyData=true&q=${encodeURIComponent(query).replace(/'/g, "%27")}`;
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json", "vb-proxy-header-rest-framework-version": "3" } }, "requestServiceRequestExpand", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Service Request expansion response.");
|
||||
items.push(...result.items);
|
||||
}
|
||||
return { ok: true, items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
async function requestLookupTaskType() {
|
||||
const debugRequests = [];
|
||||
try {
|
||||
const url = endpoint.replace(/mgmt\/timeEntries\/weekDetails$/, "crm/lookups?lookupType=SCTA_TASK_TYPE&type=CUSTOM");
|
||||
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Authorization: "Session", Accept: "application/json" } }, "requestLookupTaskType", debugRequests);
|
||||
if (!Array.isArray(result?.items)) throw new Error("Invalid Task Type lookup response.");
|
||||
return { ok: true, items: result.items, debugRequests };
|
||||
} catch (error) { error.debugRequests = debugRequests; throw error; }
|
||||
}
|
||||
return { requestWeekDetails, requestActiveServiceRequests, requestServiceRequestExpand, requestLookupTaskType };
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/** Stable wire names. All payloads remain plain, serializable objects. */
|
||||
function createMessageTypes() {
|
||||
return Object.freeze({
|
||||
requestWeekDetails: "opportunitiesExtension.requestWeekDetails",
|
||||
requestLookupTaskType: "opportunitiesExtension.requestLookupTaskType",
|
||||
requestServiceRequestExpand: "opportunitiesExtension.requestServiceRequestExpand",
|
||||
requestResourceUser: "opportunitiesExtension.requestResourceUser",
|
||||
requestActiveServiceRequests: "opportunitiesExtension.requestActiveServiceRequests",
|
||||
getXsrfToken: "opportunitiesExtension.getXsrfToken",
|
||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||
|
||||
31
src/shared/time-dates.js
Normal file
31
src/shared/time-dates.js
Normal file
@@ -0,0 +1,31 @@
|
||||
function createTimeDates() {
|
||||
const months = "JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC".split(" ");
|
||||
function parse(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value ? date : null;
|
||||
}
|
||||
function fromApi(value) {
|
||||
const match = /^(\d{2})-([A-Z]{3})-(\d{4})$/.exec(String(value));
|
||||
if (!match) return null;
|
||||
const month = months.indexOf(match[2]) + 1;
|
||||
return parse(`${match[3]}-${String(month).padStart(2, "0")}-${match[1]}`);
|
||||
}
|
||||
function toApi(value) {
|
||||
const date = parse(value);
|
||||
if (!date) throw new Error("Select a valid reference date.");
|
||||
return `${String(date.getUTCDate()).padStart(2, "0")}-${months[date.getUTCMonth()]}-${date.getUTCFullYear()}`;
|
||||
}
|
||||
function shift(value, days) {
|
||||
const date = parse(value);
|
||||
if (!date) return value;
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
function today() {
|
||||
const date = new Date();
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
return { parse, fromApi, toApi, shift, today };
|
||||
}
|
||||
@@ -37,7 +37,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js", "src/shared/debug-body.js"]);
|
||||
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
|
||||
});
|
||||
|
||||
test("Redwood cascade is byte-identical to the characterized original", () => {
|
||||
|
||||
@@ -292,6 +292,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
|
||||
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
|
||||
const root = path.join(__dirname, "..");
|
||||
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
|
||||
.concat("src/features/manage-time/styles.css")
|
||||
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
|
||||
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));
|
||||
|
||||
17
tests/fixtures/extension.js
vendored
17
tests/fixtures/extension.js
vendored
@@ -30,6 +30,23 @@
|
||||
onMessage: { addListener: listener => listeners.push(listener) },
|
||||
async sendMessage(message) {
|
||||
const operation = message.type.split(".").at(-1);
|
||||
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 === "requestResourceUser") return { ok: true, resource: { resourcePartyId: 123 }, debugRequests: [] };
|
||||
if (operation === "requestActiveServiceRequests") return { ok: true, items: [
|
||||
{ srNumber: "SR0001", srTitle: "Opportunity Pursuit - Example Customer" },
|
||||
{ srNumber: "SR0002", srTitle: "Consumption Services - Example Customer" }
|
||||
], debugRequests: [] };
|
||||
if (operation === "requestWeekDetails") {
|
||||
const selected = new Date(`${message.dateReference}T00:00:00Z`);
|
||||
const start = new Date(selected); start.setUTCDate(start.getUTCDate() - (start.getUTCDay() + 1) % 7);
|
||||
const end = new Date(start); end.setUTCDate(end.getUTCDate() + 6);
|
||||
const apiDate = value => `${String(value.getUTCDate()).padStart(2, "0")}-${value.toLocaleDateString("en-US", { month: "short", timeZone: "UTC" }).toUpperCase()}-${value.getUTCFullYear()}`;
|
||||
return { ok: true, items: Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(start); day.setUTCDate(day.getUTCDate() + index);
|
||||
return { Id: index + 1, WeekId: 419, Date: apiDate(day), StartDate: apiDate(start), EndDate: apiDate(end) };
|
||||
}), debugRequests: [] };
|
||||
}
|
||||
if (operation === "requestPostInternalServiceRequest") return { ok: true, result: { SrNumber: "ISR123" }, debugRequests: [] };
|
||||
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: internalSrPreview ? [] : ["SALES_REP"] }, debugRequests: [] };
|
||||
if (["prepareSrSession", "requestInternalServiceRequest", "requestWlActions"].includes(operation)) return { ok: true, items: [], actions: [], debugRequests: [] };
|
||||
|
||||
71
tests/formRequestCache.test.cjs
Normal file
71
tests/formRequestCache.test.cjs
Normal file
@@ -0,0 +1,71 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const { test } = require("node:test");
|
||||
const { loadBackground } = require("./support/characterization.cjs");
|
||||
function setup() {
|
||||
let now = 100000, hours = 24, user = "a@example.invalid";
|
||||
const factory = new vm.Script(fs.readFileSync("src/features/preferences/request-cache.js", "utf8") + "\ncreateFormRequestCache;").runInNewContext({ Date: { now: () => now } });
|
||||
const cache = factory({ getHours: () => hours, getUserId: () => user });
|
||||
return { cache, advance: value => { now += value; }, hours: value => { hours = value; }, user: value => { user = value; } };
|
||||
}
|
||||
const message = (name, fields = {}) => ({ type: `opportunitiesExtension.${name}`, ...fields });
|
||||
test("all four form query families reuse successes with parameter-specific keys and live duration", async () => {
|
||||
const h = setup(); let calls = 0;
|
||||
const fetch = async request => { calls++; assert.equal(request.cacheMaxAgeMs, 24 * 3600000); return { ok: true, items: [{ name: "Original" }], debugRequests: [{ name: "network" }] }; };
|
||||
for (const name of ["requestWeekDetails", "requestActiveServiceRequests", "requestServiceRequestExpand", "requestLookupTaskType", "requestActionTeamMembers", "requestActionFindLookup", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestLookupTypePlatform", "requestInternalSrServices"]) {
|
||||
const msg = message(name, { id: 1 });
|
||||
const result = await h.cache.send(msg, fetch); result.items[0].name = "Edited";
|
||||
const cached = await h.cache.send(msg, fetch);
|
||||
assert.equal(cached.fromCache, true); assert.equal(cached.items[0].name, "Original"); assert.equal(cached.debugRequests.length, 0);
|
||||
await h.cache.send(message(name, { id: 2 }), fetch);
|
||||
}
|
||||
assert.equal(calls, 24);
|
||||
h.advance(2 * 3600000); h.hours(1);
|
||||
await h.cache.send(message("requestWeekDetails", { id: 1 }), async request => { calls++; assert.equal(request.cacheMaxAgeMs, 3600000); return { ok: true }; });
|
||||
assert.equal(calls, 25);
|
||||
});
|
||||
test("failed results are never cached and identity changes discard previous responses", async () => {
|
||||
const h = setup(); let calls = 0;
|
||||
const send = async () => { calls++; return { ok: calls > 1 }; };
|
||||
const msg = message("requestLookupTaskType");
|
||||
await h.cache.send(msg, send); await h.cache.send(msg, send); await h.cache.send(msg, send); assert.equal(calls, 2);
|
||||
h.user("b@example.invalid"); await h.cache.send(msg, send); assert.equal(calls, 3);
|
||||
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 () => {
|
||||
for (const name of ["requestMgmtAction", "requestPostServiceRequest", "requestPostInternalServiceRequest", "invalidateProductsCache"]) {
|
||||
const h = setup(); let queries = 0, writes = 0;
|
||||
const query = message("requestActionFindLookup");
|
||||
const queryTransport = async () => { queries++; return { ok: true }; };
|
||||
await h.cache.send(query, queryTransport);
|
||||
for (let i = 0; i < 2; i++) await h.cache.send(message(name, { phase: "confirm" }), async () => { writes++; return { ok: true }; });
|
||||
await h.cache.send(query, queryTransport);
|
||||
assert.equal(writes, 2); assert.equal(queries, 2);
|
||||
}
|
||||
const h = setup(); let calls = 0;
|
||||
for (let i = 0; i < 2; i++) await h.cache.send(message("requestSrTokenCatalog"), async () => { calls++; return { ok: true }; });
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
test("concurrent queries share work but old responses cannot repopulate an invalidated cache", async () => {
|
||||
const h = setup(); let resolve, calls = 0;
|
||||
const msg = message("requestLookupTypePlatform");
|
||||
const transport = () => { calls++; return new Promise(done => { resolve = done; }); };
|
||||
const first = h.cache.send(msg, transport), second = h.cache.send(msg, transport);
|
||||
assert.equal(calls, 1); h.cache.invalidate(); resolve({ ok: true, items: [] }); await Promise.all([first, second]);
|
||||
await h.cache.send(msg, async () => { calls++; return { ok: true }; }); assert.equal(calls, 2);
|
||||
});
|
||||
test("resource user respects duration and Refresh all invalidation", async () => {
|
||||
let calls = 0;
|
||||
const app = loadBackground({ fetch: async () => { calls++; return new Response(JSON.stringify({ resourcePartyId: 123 })); } });
|
||||
const msg = message("requestResourceUser", { emailAddress: "a@example.invalid", cacheMaxAgeMs: 3600000 });
|
||||
await app.dispatch(msg); await app.dispatch(msg); assert.equal(calls, 1);
|
||||
app.advance(3600000); await app.dispatch(msg); assert.equal(calls, 2);
|
||||
await app.dispatch(message("invalidateProductsCache")); await app.dispatch(msg); assert.equal(calls, 3);
|
||||
});
|
||||
test("Preferences displays generic cache duration while preserving the saved preference key", () => {
|
||||
const source = fs.readFileSync("src/features/preferences/view.js", "utf8");
|
||||
assert.match(source, /cacheLabel.textContent = "Cache duration"/);
|
||||
assert.doesNotMatch(source, /Product cache duration/);
|
||||
assert.match(source, /cacheSelect.name = "opptyProductsCacheHours"/);
|
||||
});
|
||||
272
tests/manageTime.test.cjs
Normal file
272
tests/manageTime.test.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const vm = require("node:vm");
|
||||
const { loadBackground } = require("./support/characterization.cjs");
|
||||
const factory = (file, name) => new vm.Script(fs.readFileSync(file, "utf8") + `\n${name};`).runInNewContext();
|
||||
const dates = factory("src/shared/time-dates.js", "createTimeDates")();
|
||||
const week = () => Array.from({ length: 7 }, (_, index) => ({ Id: 2927 + index, WeekId: 419, Date: `${10 + index}-OCT-2026`, EndDate: "16-OCT-2026" }));
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
test("time dates use English API dates and calendar-day arithmetic across month/year boundaries", () => {
|
||||
assert.equal(dates.toApi("2026-10-16"), "16-OCT-2026");
|
||||
assert.equal(dates.shift("2026-12-28", 7), "2027-01-04");
|
||||
assert.equal(dates.shift("2026-03-03", -7), "2026-02-24");
|
||||
assert.equal(dates.fromApi("10-OCT-2026").getUTCDay(), 6);
|
||||
assert.equal(dates.fromApi("31-FEB-2026"), null);
|
||||
assert.throws(() => dates.toApi("2026-02-30"));
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) test(`${family}: week details use Session, include cookies and preserve debug response`, async () => {
|
||||
const calls = [];
|
||||
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(JSON.stringify(week()), { headers: { "content-type": "application/json" } });
|
||||
} });
|
||||
const result = await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "2026-10-16" });
|
||||
assert.equal(result.ok, true); assert.equal(result.items.length, 7);
|
||||
assert.match(calls[0].url, /oalset_timeentrymobile\/live;profile=PROD\/.*weekDetails\?dateValue=16-OCT-2026$/);
|
||||
assert.equal(calls[0].options.headers.Authorization, "Session");
|
||||
assert.equal(calls[0].options.credentials, "include"); assert.equal(calls[0].options.method, "GET");
|
||||
assert.equal(result.debugRequests[0].name, "requestWeekDetails");
|
||||
assert.match(result.debugRequests[0].responseBody, /10-OCT-2026/);
|
||||
assert.equal((await app.dispatch({ type: "opportunitiesExtension.requestWeekDetails", dateReference: "invalid" })).ok, false);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
test("week errors return status and debug logs without masking failure", async () => {
|
||||
const app = loadBackground({ fetch: async () => new Response("Unauthorized", { status: 401 }) });
|
||||
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);
|
||||
});
|
||||
function setup(requestServices = async () => [], requestExpand = async () => [], requestTaskTypes = async () => []) {
|
||||
let document;
|
||||
class Element {
|
||||
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
|
||||
set textContent(value) { this.text = value; this.children = []; }
|
||||
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
|
||||
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
|
||||
replaceChildren(...children) { this.children = []; this.append(...children); }
|
||||
setAttribute(key, value) { this.attributes[key] = value; }
|
||||
addEventListener(key, fn) { this.listeners[key] = fn; }
|
||||
focus() { document.activeElement = this; }
|
||||
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
|
||||
click() { if (!this.disabled) this.listeners.click?.(); }
|
||||
}
|
||||
const host = new Element("host");
|
||||
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag), getElementById: () => host };
|
||||
const calls = [];
|
||||
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,
|
||||
requestWeek: date => new Promise((resolve, reject) => calls.push({ date, resolve, reject })) });
|
||||
app.setReady(true);
|
||||
const walk = el => [el, ...el.children.flatMap(walk)];
|
||||
const nodes = () => walk(host);
|
||||
const find = label => nodes().find(el => el.attributes["aria-label"] === label);
|
||||
return { app, calls, nodes, find, host };
|
||||
}
|
||||
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 record = Object.fromEntries(keys.map(key => [key, key === "SrNumber" ? "SR001" : `Value ${key}`]));
|
||||
record.ProblemDescription = "<b>Plain text</b>\nSecond line";
|
||||
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Example" }], async () => [record]);
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
const input = h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === "Service Request, row 1");
|
||||
input.click();
|
||||
const list = input.parent.children[1], tooltip = input.parent.children[2];
|
||||
list.children[0].listeners.mouseenter();
|
||||
assert.equal(tooltip.hidden, false);
|
||||
const fields = tooltip.children[0].children;
|
||||
assert.deepEqual(fields.map(field => field.children[0].textContent), ["SR number", "Account", "Opportunity", "Opty number", "Opty owner", "Opty status", "Service", "Requested by", "Country", "Status", "Service lead", "Description"]);
|
||||
assert.deepEqual(fields.map(field => field.children[1].textContent), keys.map(key => record[key]));
|
||||
assert.equal(fields[11].className, "oe-time-sr-description");
|
||||
assert.equal(fields[11].children[1].textContent, record.ProblemDescription);
|
||||
assert.equal(fields[11].children[1].children.length, 0);
|
||||
input.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
|
||||
assert.equal(tooltip.hidden, true);
|
||||
assert.equal(h.nodes().filter(el => el.attributes.role === "tooltip").length, 3);
|
||||
});
|
||||
test("Manage Time autocompletes search and select SR, dependent Activity and filtered Task Type", async () => {
|
||||
const h = setup(async () => [{ srNumber: "SR001", srTitle: "Customer migration" }],
|
||||
async () => [{ SrNumber: "SR001", SRActivities_c: "Account Plan;Technical Support" }],
|
||||
async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Configuration", tags_c: "SERVICE" },
|
||||
{ lookUpCode_c: "TRAIN", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" }]);
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
const combo = label => h.nodes().find(el => el.attributes.role === "combobox" && el.attributes["aria-label"] === label);
|
||||
function choose(label, query) {
|
||||
const input = combo(label); input.focus(); input.value = query; input.listeners.input();
|
||||
const list = input.parent.children[1];
|
||||
assert.equal(list.hidden, false);
|
||||
assert.equal(list.children.length, 1);
|
||||
input.listeners.keydown({ key: "Enter", preventDefault() {} });
|
||||
}
|
||||
assert.equal(h.find("Service Request, row 1").hidden, true);
|
||||
assert.equal(combo("Activity, row 1").disabled, true);
|
||||
choose("Service Request, row 1", "sr001");
|
||||
assert.equal(combo("Service Request, row 1").value, "Customer migration (SR001)");
|
||||
assert.equal(h.find("Service Request, row 1").value, "SR001");
|
||||
assert.equal(combo("Activity, row 1").disabled, false);
|
||||
choose("Activity, row 1", "technical");
|
||||
choose("Task Type, row 1", "config");
|
||||
assert.equal(h.find("Task Type, row 1").value, "CONFIG");
|
||||
h.find("Copy row 1").click();
|
||||
assert.equal(combo("Activity, row 4").value, "Technical Support");
|
||||
h.find("Add (Non service)").click();
|
||||
choose("Task Type, row 5", "train");
|
||||
const ids = h.nodes().filter(el => el.attributes.role === "combobox").map(el => el.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) test(`${family}: Task Type lookup uses the exact endpoint, Session and logs its response`, async () => {
|
||||
const app = loadBackground({ family, fetch: async (url, options) => {
|
||||
assert.match(url, /oalset_timeentrymobile\/live;profile=PROD\/.*\/crm\/lookups\?lookupType=SCTA_TASK_TYPE&type=CUSTOM$/);
|
||||
assert.equal(options.method, "GET"); assert.equal(options.credentials, "include");
|
||||
assert.equal(options.headers.Authorization, "Session");
|
||||
return new Response(JSON.stringify({ items: [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config" }] }));
|
||||
} });
|
||||
const result = await app.dispatch({ type: "opportunitiesExtension.requestLookupTaskType" });
|
||||
assert.equal(result.ok, true); assert.equal(result.items[0].lookUpCode_c, "CONFIG");
|
||||
assert.equal(result.debugRequests[0].name, "requestLookupTaskType");
|
||||
});
|
||||
test("Task Types load once per opening and retain selected codes when copying or navigating weeks", async () => {
|
||||
let count = 0;
|
||||
const h = setup(undefined, undefined, async () => { count++; return [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]; });
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
const select = h.find("Task Type, row 1");
|
||||
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "CONFIG");
|
||||
assert.equal(select.children[1].textContent, "Config"); select.value = "CONFIG"; select.listeners.change();
|
||||
h.find("Copy row 1").click(); assert.equal(h.find("Task Type, row 4").value, "CONFIG");
|
||||
h.find("Next week").click(); h.calls[1].resolve(week()); await flush();
|
||||
assert.equal(h.find("Task Type, row 1").value, "CONFIG"); assert.equal(count, 1);
|
||||
});
|
||||
test("Task Types match the exact row tag, including new and copied non-service rows", async () => {
|
||||
const h = setup(undefined, undefined, async () => [
|
||||
{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" },
|
||||
{ lookUpCode_c: "TRAINING", lookUpMeaning_c: "Training", tags_c: "NON_SERVICE" },
|
||||
{ lookUpCode_c: "OTHER", lookUpMeaning_c: "Other", tags_c: "OTHER" },
|
||||
{ lookUpCode_c: "UNTAGGED", lookUpMeaning_c: "Untagged" }
|
||||
]);
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
assert.deepEqual(h.find("Task Type, row 1").children.map(item => item.value), ["", "CONFIG"]);
|
||||
h.find("Add (Non service)").click();
|
||||
const select = h.find("Task Type, row 4");
|
||||
assert.deepEqual(select.children.map(item => item.value), ["", "TRAINING"]);
|
||||
select.value = "TRAINING"; select.listeners.change(); h.find("Copy row 4").click();
|
||||
assert.equal(h.find("Task Type, row 5").value, "TRAINING");
|
||||
assert.deepEqual(h.find("Task Type, row 5").children.map(item => item.value), ["", "TRAINING"]);
|
||||
});
|
||||
test("Task Type stays disabled when no options match the row tag", async () => {
|
||||
const h = setup(undefined, undefined, async () => [{ lookUpCode_c: "CONFIG", lookUpMeaning_c: "Config", tags_c: "SERVICE" }]);
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush(); h.find("Add (Non service)").click();
|
||||
assert.equal(h.find("Task Type, row 4").disabled, true);
|
||||
assert.equal(h.find("Task Type, row 4").children.length, 1);
|
||||
});
|
||||
test("Task Type failure can be retried and late responses cannot alter a closed modal", async () => {
|
||||
let resolve;
|
||||
let count = 0;
|
||||
const h = setup(undefined, undefined, () => {
|
||||
if (++count === 1) return Promise.reject(new Error("Lookup unavailable"));
|
||||
return new Promise(done => { resolve = done; });
|
||||
});
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
assert.equal(h.find("Task Type, row 1").disabled, true);
|
||||
assert.equal(h.find("Retry Task Types").hidden, false);
|
||||
h.find("Retry Task Types").click(); h.app.close(); resolve([]); await flush();
|
||||
assert.equal(h.host.children.length, 0);
|
||||
});
|
||||
test("modal loads today, navigates seven days, ignores stale results and preserves drafts within the modal", async () => {
|
||||
const h = setup(); h.app.open();
|
||||
assert.equal(h.calls[0].date, dates.today());
|
||||
const date = h.find("Reference date"); date.value = "2026-10-16"; date.listeners.change();
|
||||
h.calls[1].resolve(week()); await flush();
|
||||
assert.equal(h.nodes().filter(el => el.tag === "th").length, 13);
|
||||
assert.equal(h.nodes().filter(el => el.type === "number").length, 21);
|
||||
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();
|
||||
assert.equal(hours.step, "1");
|
||||
assert.ok(h.nodes().some(el => el.tag === "strong" && el.textContent === "2 hrs"));
|
||||
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("Previous week").click(); assert.equal(h.calls[3].date, "2026-10-16");
|
||||
h.calls[3].resolve(week()); await flush();
|
||||
assert.equal(h.find("Hours 10-OCT-2026, row 1").value, "2");
|
||||
h.calls[2].reject(new Error("Stale error")); h.calls[0].resolve([]); await flush();
|
||||
assert.ok(!h.host.textContent.includes("Stale error"));
|
||||
h.app.close(); assert.equal(h.host.children.length, 0);
|
||||
});
|
||||
test("Manage Time is disabled until session ready, and activities correlate with selected SR", async () => {
|
||||
const h = setup(async () => [{ srNumber: "SR1" }, { srNumber: "SR2" }], async () => [
|
||||
{ SrNumber: "SR1", SRActivities_c: "Design;Sizing, Quoting or Configuration;Design" },
|
||||
{ SrNumber: "SR2", SRActivities_c: "Review, Workshop" },
|
||||
{ SrNumber: "non-service-sr", SRActivities_c: "Training;Meeting" }
|
||||
]);
|
||||
h.app.setReady(false); const trigger = h.app.createButton();
|
||||
assert.equal(trigger.disabled, true); h.app.open(); assert.equal(h.calls.length, 0);
|
||||
h.app.setReady(true); trigger.click(); h.calls[0].resolve(week()); await flush();
|
||||
let select = h.find("Service Request, row 1"); select.value = "SR1"; select.listeners.change();
|
||||
let activity = h.find("Activity, row 1");
|
||||
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Design", "Sizing, Quoting or Configuration"]);
|
||||
activity.value = "Design"; activity.listeners.change();
|
||||
select = h.find("Service Request, row 1"); select.value = "SR2"; select.listeners.change();
|
||||
activity = h.find("Activity, row 1"); assert.equal(activity.value, "");
|
||||
assert.deepEqual(activity.children.map(el => el.textContent), ["Activity", "Review", "Workshop"]);
|
||||
h.find("Add (Non service)").click(); assert.equal(h.find("Activity, row 4").children[1].value, "Training");
|
||||
});
|
||||
for (const count of [0, 49, 50, 51, 100, 101]) test(`expansion batches ${count} unique SRs and appends non-service exactly once`, async () => {
|
||||
const queries = [];
|
||||
const app = loadBackground({ fetch: async (url, options) => {
|
||||
queries.push(new URL(url).searchParams.get("q"));
|
||||
assert.equal(options.headers["vb-proxy-header-rest-framework-version"], "3");
|
||||
assert.equal(options.headers.Authorization, "Session");
|
||||
assert.equal(options.credentials, "include");
|
||||
return new Response(JSON.stringify({ items: [] }));
|
||||
} });
|
||||
const numbers = Array.from({ length: count }, (_, i) => `SR${i + 1}`);
|
||||
const result = await app.dispatch({ type: "opportunitiesExtension.requestServiceRequestExpand", srNumbers: [...numbers, ...numbers] });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(queries.length, Math.floor(count / 50) + 1);
|
||||
const batches = queries.map(query => [...query.matchAll(/'([^']+)'/g)].map(match => match[1]));
|
||||
assert.deepEqual(batches.flat(), [...numbers, "non-service-sr"]);
|
||||
assert.ok(batches.every(batch => batch.length <= 50));
|
||||
});
|
||||
test("modal supports retry and ignores completion after close", async () => {
|
||||
const h = setup(); h.app.open(); h.calls[0].reject(new Error("Session expired")); await flush();
|
||||
assert.equal(h.find("Retry").hidden, false); assert.ok(h.host.textContent.includes("Session expired"));
|
||||
h.find("Retry").click(); h.app.close(); h.calls[1].resolve(week()); await flush();
|
||||
assert.equal(h.host.children.length, 0);
|
||||
});
|
||||
test("active SRs load once per modal and notes preserve text, character count and collapse state", async () => {
|
||||
const calls = [];
|
||||
const h = setup(async weekId => { calls.push(weekId); return [{ srNumber: "SR1", srTitle: "Customer service" }]; });
|
||||
h.app.open(); h.calls[0].resolve(week()); await flush();
|
||||
const select = h.find("Service Request, row 1");
|
||||
assert.equal(select.disabled, false); assert.equal(select.children[1].value, "SR1");
|
||||
assert.equal(select.children[1].textContent, "Customer service (SR1)");
|
||||
select.value = "SR1"; select.listeners.change();
|
||||
h.find("Notes, row 1").click();
|
||||
const notes = h.nodes().find(el => el.tag === "textarea");
|
||||
assert.equal(notes.maxLength, 2000); notes.value = "Hello"; notes.listeners.input();
|
||||
assert.ok(h.host.textContent.includes("1995 characters left"));
|
||||
h.find("Collapse notes, row 1").click();
|
||||
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("Next week").click(); h.calls[1].resolve(week()); await flush();
|
||||
assert.deepEqual(calls, [419]);
|
||||
h.app.close(); h.app.open(); h.calls[2].resolve(week()); await flush();
|
||||
assert.deepEqual(calls, [419, 419]);
|
||||
});
|
||||
for (const family of ["chrome", "firefox"]) test(`${family}: resource response is cached across concurrent calls and worker restarts`, async () => {
|
||||
const calls = [], sessionStorage = {};
|
||||
const fetch = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return new Response(JSON.stringify(url.includes("resourceUsers?") ? { resourcePartyId: 123, partyName: "Test User" } : [{ srNumber: "SR1", srTitle: "Test" }]), { headers: { "content-type": "application/json" } });
|
||||
};
|
||||
const message = { type: "opportunitiesExtension.requestResourceUser", emailAddress: "test@example.invalid" };
|
||||
const app = loadBackground({ family, fetch, sessionStorage });
|
||||
const results = await Promise.all([app.dispatch(message), app.dispatch(message)]);
|
||||
assert.equal(results[0].resource.resourcePartyId, 123); assert.equal(calls.length, 1);
|
||||
const restarted = loadBackground({ family, fetch, sessionStorage });
|
||||
assert.equal((await restarted.dispatch(message)).resource.partyName, "Test User"); assert.equal(calls.length, 1);
|
||||
const active = await restarted.dispatch({ ...message, type: "opportunitiesExtension.requestActiveServiceRequests", weekId: 419 });
|
||||
assert.equal(active.ok, true); assert.equal(active.items[0].srNumber, "SR1");
|
||||
assert.equal(calls.length, 2); assert.match(calls[1].url, /activeServiceRequests\?resourceId=123&weekId=419$/);
|
||||
assert.equal(calls[1].options.headers.Authorization, "Session");
|
||||
await restarted.dispatch({ ...message, emailAddress: "other@example.invalid" }); assert.equal(calls.length, 3);
|
||||
});
|
||||
@@ -254,6 +254,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
|
||||
assert.ok(remaining > 0);
|
||||
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
|
||||
}
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"].map(read).join("\n");
|
||||
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css"].map(read).join("\n");
|
||||
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
|
||||
});
|
||||
|
||||
@@ -6,12 +6,20 @@ test("preparation runs once, shares pending work and reports loading and complet
|
||||
const app = loadApplication("content");
|
||||
const progress = [];
|
||||
let requests = 0;
|
||||
let resourceRequests = 0;
|
||||
let resolveUser, resolveSession;
|
||||
app.currentUser.ensure = () => new Promise(resolve => { resolveUser = resolve; });
|
||||
app.currentUser.allowsCrmActivities = () => true;
|
||||
app.currentUser.getUserId = () => "test@example.invalid";
|
||||
app.activityProgress.update = (...args) => progress.push(args);
|
||||
app.debugContentLog.appendBackgroundRequestLog = () => {};
|
||||
app.platformContentMessages.sendRuntimeMessage = message => {
|
||||
if (message.type === "opportunitiesExtension.requestResourceUser") {
|
||||
resourceRequests += 1;
|
||||
assert.equal(message.emailAddress, "test@example.invalid");
|
||||
assert.deepEqual(progress.at(-1), ["session", "complete"]);
|
||||
return Promise.resolve({ ok: true, resource: { resourcePartyId: 123 } });
|
||||
}
|
||||
assert.equal(message.type, "opportunitiesExtension.prepareSrSession");
|
||||
requests += 1;
|
||||
return new Promise(resolve => { resolveSession = resolve; });
|
||||
@@ -26,6 +34,7 @@ test("preparation runs once, shares pending work and reports loading and complet
|
||||
await first;
|
||||
await app.srSessionPreparation.ensure();
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(resourceRequests, 1);
|
||||
assert.deepEqual(progress.at(-1), ["session", "complete"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user