Added the Refresh All button to invalidate caches and force requests to be sent again
This commit is contained in:
52
src/features/opportunities/activity-cache.js
Normal file
52
src/features/opportunities/activity-cache.js
Normal file
@@ -0,0 +1,52 @@
|
||||
function createActivityCache({ authSessionState, preferencesState, environment }) {
|
||||
const { browser, chrome } = environment;
|
||||
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
|
||||
async function invalidate() {
|
||||
await storage("set", { [invalidatedKey]: Date.now() });
|
||||
const entries = await storage("get", null);
|
||||
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
|
||||
if (keys.length) await storage("remove", keys);
|
||||
}
|
||||
function storage(method, argument) {
|
||||
if (browser?.storage?.local) return browser.storage.local[method](argument);
|
||||
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.storage.local[method](argument, result => {
|
||||
const error = chrome.runtime?.lastError;
|
||||
if (error) reject(new Error(error.message));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
function context(kind, id, timestamp) {
|
||||
const principal = authSessionState.principal;
|
||||
const fetchedAt = Number(timestamp);
|
||||
const hours = Number(preferencesState.opptyProductsCacheHours);
|
||||
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
|
||||
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
|
||||
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify([principal, kind, String(id)])}`, fetchedAt };
|
||||
}
|
||||
async function read(kind, id, timestamp) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope) return null;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
|
||||
const result = await storage("get", scope.key);
|
||||
const entry = result?.[scope.key];
|
||||
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
|
||||
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
|
||||
&& typeof entry.hasNext === "boolean" ? entry : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function write(kind, id, timestamp, items, hasNext = false) {
|
||||
const scope = context(kind, id, timestamp);
|
||||
if (!scope || !Array.isArray(items)) return;
|
||||
try {
|
||||
const invalidated = await storage("get", invalidatedKey);
|
||||
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
|
||||
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
|
||||
} catch { /* Storage failure must not discard successfully fetched data. */ }
|
||||
}
|
||||
return { invalidate, read, write };
|
||||
}
|
||||
38
src/features/opportunities/activity-progress.js
Normal file
38
src/features/opportunities/activity-progress.js
Normal file
@@ -0,0 +1,38 @@
|
||||
function createActivityProgress({ MODAL_ID, environment }) {
|
||||
const { document, setTimeout, clearTimeout, matchMedia } = environment;
|
||||
const entries = new Map();
|
||||
function update(key, state, completed = 0, total = null) {
|
||||
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
|
||||
if (!host) return;
|
||||
let entry = entries.get(key);
|
||||
if (!entry) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "opportunities-extension-products-progress";
|
||||
badge.setAttribute("role", "status");
|
||||
badge.setAttribute("aria-live", "polite");
|
||||
host.append(badge);
|
||||
entry = { badge, timer: null, hideTimer: null, state: "idle" };
|
||||
entries.set(key, entry);
|
||||
}
|
||||
if (state === "complete" && entry.state === "complete") return;
|
||||
clearTimeout(entry.timer);
|
||||
clearTimeout(entry.hideTimer);
|
||||
entry.state = state;
|
||||
const label = key === "sr" ? "Service Requests" : "Actions";
|
||||
entry.badge.hidden = state === "idle";
|
||||
entry.badge.setAttribute("data-state", state);
|
||||
entry.badge.setAttribute("data-fading", "false");
|
||||
entry.badge.setAttribute("aria-busy", String(state === "running"));
|
||||
const count = total === null ? String(completed) : `${completed}/${total}`;
|
||||
entry.badge.textContent = state === "error" ? `${label} unavailable`
|
||||
: state === "complete-with-errors" ? `${label} ${count} (errors)`
|
||||
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
|
||||
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
|
||||
if (state === "complete") entry.timer = setTimeout(() => {
|
||||
entry.badge.setAttribute("data-fading", "true");
|
||||
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
|
||||
}, 5000);
|
||||
}
|
||||
return { update };
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
|
||||
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, environment }) {
|
||||
let status = "idle";
|
||||
let byOpportunity = new Map();
|
||||
let pending = null;
|
||||
let generation = 0;
|
||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
|
||||
function load(token, version) {
|
||||
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); }
|
||||
function load(token, version, cacheOptions = null) {
|
||||
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
|
||||
if (!pending) pending = run(token, version, generation);
|
||||
if (!pending) pending = run(token, version, generation, cacheOptions);
|
||||
return pending;
|
||||
}
|
||||
async function run(token, version, currentGeneration) {
|
||||
async function run(token, version, currentGeneration, cacheOptions) {
|
||||
const isActive = () => currentGeneration === generation;
|
||||
status = "loading";
|
||||
try {
|
||||
const items = await requestCrmServiceRequest(token, version, isActive);
|
||||
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
|
||||
if (!isActive()) return;
|
||||
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; }
|
||||
let items = cached?.items;
|
||||
if (!items) {
|
||||
reportProgress("running", 0);
|
||||
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
|
||||
if (isActive()) reportProgress("running", count, total);
|
||||
});
|
||||
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
|
||||
}
|
||||
if (!items || !isActive()) return;
|
||||
const grouped = new Map();
|
||||
for (const item of items) {
|
||||
@@ -25,8 +35,9 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
|
||||
}
|
||||
byOpportunity = grouped;
|
||||
status = "ready";
|
||||
if (!cached) reportProgress("complete", items.length, items.length);
|
||||
} catch {
|
||||
if (isActive()) status = "error";
|
||||
if (isActive()) { status = "error"; reportProgress("error"); }
|
||||
}
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
|
||||
@@ -9,7 +9,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
if (!current || current.completed || current.requestId !== requestId
|
||||
|| productsContentState.currentProductsRequestId !== requestId) return;
|
||||
current.completed = true;
|
||||
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error && !result.fromCache);
|
||||
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
|
||||
if (!products.length) return;
|
||||
resetCrm();
|
||||
const seen = new Set();
|
||||
@@ -20,9 +20,10 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
loadActions({ ...result, items }, true);
|
||||
loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true });
|
||||
}
|
||||
Promise.resolve(loadCrm(current.token, current.version)).finally(() => {
|
||||
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
|
||||
Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).finally(() => {
|
||||
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render();
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
50
src/features/opportunities/refresh-all.js
Normal file
50
src/features/opportunities/refresh-all.js
Normal file
@@ -0,0 +1,50 @@
|
||||
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
|
||||
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
|
||||
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
|
||||
let pending = null;
|
||||
function refresh(period) {
|
||||
if (pending) return pending;
|
||||
opportunitiesQueryState.periodRequestVersion += 1;
|
||||
productsContentState.currentProductsRequestId = "";
|
||||
productsContentState.opptyProductsByNumber = new Map();
|
||||
productsContentState.expandedProductOptyNumbers = new Set();
|
||||
productsContentState.productsLastCachedAt = 0;
|
||||
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
|
||||
consumptionPlansState.accountPlansRequestState = new Map();
|
||||
consumptionTrendState.consumptionTrendCache = new Map();
|
||||
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
|
||||
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
|
||||
aiSummaryState.aiSummariesByOptyNumber = new Map();
|
||||
resetRelatedData();
|
||||
pending = (async () => {
|
||||
await activityCache.invalidate();
|
||||
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
|
||||
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
|
||||
await reload(period);
|
||||
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
|
||||
return pending;
|
||||
}
|
||||
function createButton(getPeriod) {
|
||||
const button = environment.document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
|
||||
button.append(createRefreshIcon());
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-label", "Refresh all");
|
||||
button.addEventListener("click", async () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
button.title = "Refreshing...";
|
||||
button.setAttribute("aria-busy", "true");
|
||||
try { await refresh(getPeriod()); }
|
||||
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
|
||||
finally {
|
||||
button.disabled = false;
|
||||
button.title = "Refresh all";
|
||||
button.setAttribute("aria-busy", "false");
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
return { refresh, createButton };
|
||||
}
|
||||
@@ -311,3 +311,41 @@
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
|
||||
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
|
||||
.opportunities-extension-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.opportunities-extension-title-row h1 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
min-height: 22px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
|
||||
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
|
||||
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
|
||||
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
|
||||
}
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
|
||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
|
||||
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, environment }) {
|
||||
let session = newSession();
|
||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
|
||||
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
|
||||
function updateProgress(current) {
|
||||
if (current !== session || !current.enabled || !current.progress.size) return;
|
||||
const records = [...current.progress];
|
||||
const completed = records.filter(record => record.status !== "loading").length;
|
||||
const failed = records.filter(record => record.status === "error").length;
|
||||
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
|
||||
}
|
||||
function reset(enabled = true) {
|
||||
session.enabled = false;
|
||||
session.queue.length = 0;
|
||||
session = newSession();
|
||||
session.enabled = enabled;
|
||||
reportProgress("idle");
|
||||
}
|
||||
function idsFor(products) {
|
||||
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
|
||||
}
|
||||
function loadProducts(products, refresh = false) {
|
||||
function loadProducts(products, refresh = false, cacheOptions = null) {
|
||||
if (!session.enabled || !allowsCrmActivities()) return;
|
||||
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
|
||||
for (const revnId of idsFor(products)) {
|
||||
if (!refresh && session.records.has(revnId)) continue;
|
||||
session.progress.delete(session.records.get(revnId));
|
||||
const record = { status: "loading", actions: [], hasNext: false };
|
||||
session.records.set(revnId, record);
|
||||
session.queue.push({ revnId, record });
|
||||
if (!cacheOptions) session.progress.add(record);
|
||||
session.queue.push({ revnId, record, cacheOptions });
|
||||
}
|
||||
updateProgress(session);
|
||||
pump(session);
|
||||
}
|
||||
function reopen() {
|
||||
@@ -33,23 +45,35 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
|
||||
run(current, job).finally(() => { current.running -= 1; pump(current); });
|
||||
}
|
||||
}
|
||||
async function run(current, { revnId, record }) {
|
||||
async function run(current, { revnId, record, cacheOptions }) {
|
||||
try {
|
||||
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
if (cached) {
|
||||
record.actions = cached.items;
|
||||
record.hasNext = cached.hasNext;
|
||||
record.status = "ready";
|
||||
return;
|
||||
}
|
||||
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
|
||||
current.progress.add(record);
|
||||
updateProgress(current);
|
||||
const authorizations = await requestConsumerTokenServiceOnce();
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
|
||||
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
|
||||
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
|
||||
appendBackgroundRequestLog(result?.debugRequests);
|
||||
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
|
||||
record.actions = result.actions;
|
||||
record.hasNext = result.hasNext;
|
||||
record.status = "ready";
|
||||
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
|
||||
} catch (error) {
|
||||
if (!current.enabled || current !== session) return;
|
||||
record.status = "error";
|
||||
record.error = error.message || "Unable to load workload actions.";
|
||||
} finally {
|
||||
if (current.enabled && current === session) queueProductsTableRender();
|
||||
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
|
||||
}
|
||||
}
|
||||
function createBadge(optyNumber) {
|
||||
|
||||
Reference in New Issue
Block a user